mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-14 10:38:11 +02:00
Initial commit
This commit is contained in:
commit
8aa223bc7b
40 files changed
+4320
No files matched your search
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
|
||||
export default function Breadcrumb({ RootName, FilePath, IsDirectory, OnNavigate }) {
|
||||
const Segments = FilePath ? FilePath.split("/") : [];
|
||||
|
||||
function HandleSegmentClick(Index) {
|
||||
OnNavigate(Segments.slice(0, Index + 1).join("/"));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="Breadcrumb">
|
||||
<span className="BreadcrumbSegment BreadcrumbLink" onClick={() => OnNavigate("")}>
|
||||
{RootName}
|
||||
</span>
|
||||
{Segments.map((Segment, Index) => {
|
||||
const IsFileSegment = !IsDirectory && Index === Segments.length - 1;
|
||||
|
||||
return (
|
||||
<React.Fragment key={Index}>
|
||||
<span className="BreadcrumbDivider">/</span>
|
||||
{IsFileSegment ? (
|
||||
<span className="BreadcrumbSegment">{Segment}</span>
|
||||
) : (
|
||||
<span
|
||||
className="BreadcrumbSegment BreadcrumbLink"
|
||||
onClick={() => HandleSegmentClick(Index)}
|
||||
>
|
||||
{Segment}
|
||||
</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { CreateEntry, DeleteEntry, FetchTree, MoveEntry, RenameEntry } from "../Api.js";
|
||||
import { GetFileIcon, GetFolderIcon } from "../IconStyle.js";
|
||||
|
||||
export default function DirectoryPane({
|
||||
RootName,
|
||||
DirectoryPath,
|
||||
OnEntrySelect,
|
||||
OnEntryCreated,
|
||||
OnEntryRenamed,
|
||||
OnEntryDeleted,
|
||||
UseHyphenBullets,
|
||||
UseAsciiFolderIcons,
|
||||
ConfirmBeforeDelete,
|
||||
}) {
|
||||
const [Entries, SetEntries] = useState([]);
|
||||
const [LoadError, SetLoadError] = useState(null);
|
||||
|
||||
async function Reload() {
|
||||
try {
|
||||
const Fetched = await FetchTree(RootName, DirectoryPath);
|
||||
SetEntries(Fetched);
|
||||
SetLoadError(null);
|
||||
} catch (Error) {
|
||||
SetLoadError(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (RootName !== null && RootName !== undefined) {
|
||||
Reload();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [RootName, DirectoryPath]);
|
||||
|
||||
function JoinPath(Name) {
|
||||
return DirectoryPath ? `${DirectoryPath}/${Name}` : Name;
|
||||
}
|
||||
|
||||
async function HandleCreateFile() {
|
||||
const Name = window.prompt("New file name (e.g. Notes.md):");
|
||||
if (!Name) return;
|
||||
|
||||
try {
|
||||
await CreateEntry(RootName, JoinPath(Name), false);
|
||||
await Reload();
|
||||
OnEntryCreated();
|
||||
} catch (Error) {
|
||||
SetLoadError(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function HandleCreateDirectory() {
|
||||
const Name = window.prompt("New directory name:");
|
||||
if (!Name) return;
|
||||
|
||||
try {
|
||||
await CreateEntry(RootName, JoinPath(Name), true);
|
||||
await Reload();
|
||||
OnEntryCreated();
|
||||
} catch (Error) {
|
||||
SetLoadError(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function HandleRename(Event, Entry) {
|
||||
Event.stopPropagation();
|
||||
const FullPath = JoinPath(Entry.Name);
|
||||
const NewValue = window.prompt("Rename to (or type a path to move):", Entry.Name);
|
||||
if (!NewValue || NewValue === Entry.Name) return;
|
||||
|
||||
try {
|
||||
if (NewValue.includes("/")) {
|
||||
await MoveEntry(RootName, FullPath, NewValue);
|
||||
OnEntryRenamed(FullPath, NewValue, Entry.IsDirectory);
|
||||
} else {
|
||||
await RenameEntry(RootName, FullPath, NewValue);
|
||||
OnEntryRenamed(FullPath, JoinPath(NewValue), Entry.IsDirectory);
|
||||
}
|
||||
await Reload();
|
||||
} catch (Error) {
|
||||
window.alert(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function HandleDelete(Event, Entry) {
|
||||
Event.stopPropagation();
|
||||
const FullPath = JoinPath(Entry.Name);
|
||||
|
||||
if (ConfirmBeforeDelete && !window.confirm(`Delete "${Entry.Name}"? This can't be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await DeleteEntry(RootName, FullPath);
|
||||
OnEntryDeleted(FullPath, Entry.IsDirectory);
|
||||
await Reload();
|
||||
} catch (Error) {
|
||||
window.alert(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="DirectoryPane">
|
||||
<div className="PreviewToolbar">
|
||||
<button onClick={HandleCreateFile}>New File</button>
|
||||
<button onClick={HandleCreateDirectory}>New Directory</button>
|
||||
</div>
|
||||
{LoadError && <div className="SidebarError">{LoadError}</div>}
|
||||
<div className="DirectoryList">
|
||||
{Entries.map((Entry) => (
|
||||
<div
|
||||
className="DirectoryEntry"
|
||||
key={Entry.Name}
|
||||
onClick={() => OnEntrySelect(JoinPath(Entry.Name), Entry.IsDirectory, Entry.FileType)}
|
||||
>
|
||||
<span className="TreeIcon">
|
||||
{Entry.IsDirectory
|
||||
? GetFolderIcon(false, UseAsciiFolderIcons)
|
||||
: GetFileIcon(UseHyphenBullets)}
|
||||
</span>
|
||||
<span className="TreeLabel">{Entry.Name}</span>
|
||||
<span className="TreeRowActions">
|
||||
<button
|
||||
className="TreeRowActionButton"
|
||||
onClick={(Event) => HandleRename(Event, Entry)}
|
||||
title="Rename / move"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
className="TreeRowActionButton"
|
||||
onClick={(Event) => HandleDelete(Event, Entry)}
|
||||
title="Delete"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{Entries.length === 0 && !LoadError && (
|
||||
<div className="DirectoryEmpty">Empty directory.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { Bootstrap, Login } from "../Api.js";
|
||||
|
||||
export default function LoginScreen({ IsBootstrap, OnAuthenticated }) {
|
||||
const [Username, SetUsername] = useState("");
|
||||
const [Password, SetPassword] = useState("");
|
||||
const [ConfirmPassword, SetConfirmPassword] = useState("");
|
||||
const [Error, SetError] = useState(null);
|
||||
const [IsSubmitting, SetIsSubmitting] = useState(false);
|
||||
|
||||
async function HandleSubmit(Event) {
|
||||
Event.preventDefault();
|
||||
SetError(null);
|
||||
|
||||
if (IsBootstrap && Password !== ConfirmPassword) {
|
||||
SetError("Passwords don't match.");
|
||||
return;
|
||||
}
|
||||
|
||||
SetIsSubmitting(true);
|
||||
try {
|
||||
if (IsBootstrap) {
|
||||
await Bootstrap(Username, Password);
|
||||
}
|
||||
const User = await Login(Username, Password);
|
||||
OnAuthenticated(User);
|
||||
} catch (SubmitError) {
|
||||
SetError(SubmitError.message);
|
||||
} finally {
|
||||
SetIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="LoginScreen">
|
||||
<form className="LoginBox" onSubmit={HandleSubmit}>
|
||||
<div className="LoginTitle">
|
||||
{IsBootstrap ? "Create the admin account" : "SaraView"}
|
||||
</div>
|
||||
{IsBootstrap && (
|
||||
<div className="LoginSubtitle">
|
||||
No accounts exist yet — this first one becomes an admin.
|
||||
</div>
|
||||
)}
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={Username}
|
||||
onChange={(Event) => SetUsername(Event.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={Password}
|
||||
onChange={(Event) => SetPassword(Event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{IsBootstrap && (
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">Confirm password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={ConfirmPassword}
|
||||
onChange={(Event) => SetConfirmPassword(Event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{Error && <div className="SettingsSaveError">{Error}</div>}
|
||||
<button type="submit" disabled={IsSubmitting}>
|
||||
{IsSubmitting ? "..." : IsBootstrap ? "Create account & log in" : "Log in"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { gutter, GutterMarker } from "@codemirror/view";
|
||||
import { markdown } from "@codemirror/lang-markdown";
|
||||
import { html } from "@codemirror/lang-html";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import MarkdownItTaskLists from "markdown-it-task-lists";
|
||||
|
||||
import { FetchBlame, FetchFile, GetFileUrl, GetRawFileUrl, SaveFile } from "../Api.js";
|
||||
|
||||
const MarkdownRenderer = new MarkdownIt({ breaks: true }).use(MarkdownItTaskLists, {
|
||||
enabled: true,
|
||||
label: true,
|
||||
});
|
||||
|
||||
const DefaultImageRenderer =
|
||||
MarkdownRenderer.renderer.rules.image ||
|
||||
((Tokens, Index, Options, Env, Self) => Self.renderToken(Tokens, Index, Options));
|
||||
|
||||
MarkdownRenderer.renderer.rules.image = (Tokens, Index, Options, Env, Self) => {
|
||||
const Token = Tokens[Index];
|
||||
const SrcAttrIndex = Token.attrIndex("src");
|
||||
|
||||
if (SrcAttrIndex >= 0 && Env && Env.ResolveImageSrc) {
|
||||
Token.attrs[SrcAttrIndex][1] = Env.ResolveImageSrc(Token.attrs[SrcAttrIndex][1]);
|
||||
}
|
||||
|
||||
return DefaultImageRenderer(Tokens, Index, Options, Env, Self);
|
||||
};
|
||||
|
||||
const TextTypes = ["markdown", "text", "config", "html"];
|
||||
|
||||
const LinkedFileTypeMap = {
|
||||
".md": "markdown",
|
||||
".markdown": "markdown",
|
||||
".txt": "text",
|
||||
".toml": "config",
|
||||
".yaml": "config",
|
||||
".yml": "config",
|
||||
".json": "config",
|
||||
".html": "html",
|
||||
".htm": "html",
|
||||
".png": "image",
|
||||
".jpg": "image",
|
||||
".jpeg": "image",
|
||||
".gif": "image",
|
||||
".webp": "image",
|
||||
".svg": "image",
|
||||
".mp4": "video",
|
||||
".webm": "video",
|
||||
".mov": "video",
|
||||
".mkv": "video",
|
||||
};
|
||||
|
||||
function ClassifyLinkedFile(LinkedPath) {
|
||||
const DotIndex = LinkedPath.lastIndexOf(".");
|
||||
const Extension = DotIndex === -1 ? "" : LinkedPath.slice(DotIndex).toLowerCase();
|
||||
return LinkedFileTypeMap[Extension] || "unknown";
|
||||
}
|
||||
|
||||
function ResolveRelativeLink(CurrentFilePath, Href) {
|
||||
if (/^([a-z][a-z0-9+.-]*:)?\/\//i.test(Href) || Href.startsWith("#") || Href.startsWith("mailto:")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const CurrentDirectory = CurrentFilePath.includes("/")
|
||||
? CurrentFilePath.slice(0, CurrentFilePath.lastIndexOf("/"))
|
||||
: "";
|
||||
const Combined = CurrentDirectory ? `${CurrentDirectory}/${Href}` : Href;
|
||||
const ResolvedParts = [];
|
||||
|
||||
for (const Part of Combined.split("/")) {
|
||||
if (Part === "" || Part === ".") continue;
|
||||
if (Part === "..") {
|
||||
ResolvedParts.pop();
|
||||
} else {
|
||||
ResolvedParts.push(Part);
|
||||
}
|
||||
}
|
||||
|
||||
return ResolvedParts.join("/");
|
||||
}
|
||||
|
||||
function GetLanguageExtension(FileType) {
|
||||
if (FileType === "markdown") return [markdown()];
|
||||
if (FileType === "html") return [html()];
|
||||
if (FileType === "config") return [json()];
|
||||
return [];
|
||||
}
|
||||
|
||||
class BlameMarker extends GutterMarker {
|
||||
constructor(ShortLabel, Title) {
|
||||
super();
|
||||
this.ShortLabel = ShortLabel;
|
||||
this.Title = Title;
|
||||
}
|
||||
|
||||
eq(Other) {
|
||||
return this.ShortLabel === Other.ShortLabel && this.Title === Other.Title;
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const Element = document.createElement("span");
|
||||
Element.className = "BlameGutterMarker";
|
||||
Element.textContent = this.ShortLabel;
|
||||
Element.title = this.Title;
|
||||
return Element;
|
||||
}
|
||||
}
|
||||
|
||||
function BuildBlameGutter(BlameMap) {
|
||||
return gutter({
|
||||
class: "BlameGutter",
|
||||
lineMarker(view, line) {
|
||||
const LineNumber = view.state.doc.lineAt(line.from).number;
|
||||
const Entry = BlameMap[String(LineNumber)];
|
||||
if (!Entry) return null;
|
||||
|
||||
const ShortLabel = Entry.Username.slice(0, 6);
|
||||
const Title = `${Entry.Username} — ${new Date(Entry.Timestamp).toLocaleString()}`;
|
||||
return new BlameMarker(ShortLabel, Title);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default function PreviewPane({
|
||||
RootName,
|
||||
FilePath,
|
||||
FileType,
|
||||
OnNavigateLink,
|
||||
ShowGitBlame,
|
||||
StickyToolbar,
|
||||
}) {
|
||||
const [Content, SetContent] = useState("");
|
||||
const [IsEditing, SetIsEditing] = useState(false);
|
||||
const [IsSaving, SetIsSaving] = useState(false);
|
||||
const [SaveError, SetSaveError] = useState(null);
|
||||
const [BlameMap, SetBlameMap] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
SetIsEditing(false);
|
||||
SetSaveError(null);
|
||||
SetBlameMap({});
|
||||
|
||||
if (FilePath && TextTypes.includes(FileType)) {
|
||||
FetchFile(RootName, FilePath).then(SetContent);
|
||||
|
||||
if (ShowGitBlame) {
|
||||
FetchBlame(RootName, FilePath)
|
||||
.then(SetBlameMap)
|
||||
.catch(() => SetBlameMap({}));
|
||||
}
|
||||
}
|
||||
}, [RootName, FilePath, FileType, ShowGitBlame]);
|
||||
|
||||
if (!FilePath) {
|
||||
return <div className="PreviewEmpty">Select a file to preview.</div>;
|
||||
}
|
||||
|
||||
const ToolbarClass = `PreviewToolbar${StickyToolbar ? " PreviewToolbarSticky" : ""}`;
|
||||
|
||||
async function HandleSave() {
|
||||
SetIsSaving(true);
|
||||
SetSaveError(null);
|
||||
try {
|
||||
await SaveFile(RootName, FilePath, Content);
|
||||
if (ShowGitBlame) {
|
||||
FetchBlame(RootName, FilePath)
|
||||
.then(SetBlameMap)
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (Error) {
|
||||
SetSaveError(Error.message);
|
||||
} finally {
|
||||
SetIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function HandleMarkdownClick(Event) {
|
||||
const AnchorElement = Event.target.closest("a");
|
||||
if (!AnchorElement) return;
|
||||
|
||||
const Href = AnchorElement.getAttribute("href");
|
||||
if (!Href) return;
|
||||
|
||||
const ResolvedPath = ResolveRelativeLink(FilePath, Href);
|
||||
if (ResolvedPath === null) return;
|
||||
|
||||
Event.preventDefault();
|
||||
OnNavigateLink(ResolvedPath, ClassifyLinkedFile(ResolvedPath));
|
||||
}
|
||||
|
||||
if (FileType === "image") {
|
||||
return (
|
||||
<div className="PreviewMedia">
|
||||
<img src={GetFileUrl(RootName, FilePath)} alt={FilePath} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (FileType === "video") {
|
||||
return (
|
||||
<div className="PreviewMedia">
|
||||
<video controls src={GetFileUrl(RootName, FilePath)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (FileType === "html" && !IsEditing) {
|
||||
return (
|
||||
<div className="PreviewFrame">
|
||||
<div className={ToolbarClass}>
|
||||
<button onClick={() => SetIsEditing(true)}>Edit</button>
|
||||
</div>
|
||||
{/* Raw, path-shaped URL (not the query-param /api/file form) so
|
||||
relative <link>/<script>/<img> references inside the HTML
|
||||
resolve against their real sibling files. */}
|
||||
<iframe title={FilePath} sandbox="" src={GetRawFileUrl(RootName, FilePath)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (FileType === "markdown" && !IsEditing) {
|
||||
return (
|
||||
<div className="PreviewMarkdown">
|
||||
<div className={ToolbarClass}>
|
||||
<button onClick={() => SetIsEditing(true)}>Edit</button>
|
||||
</div>
|
||||
<div
|
||||
className="MarkdownBody"
|
||||
onClick={HandleMarkdownClick}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: MarkdownRenderer.render(Content, {
|
||||
ResolveImageSrc: (Src) => {
|
||||
const ResolvedPath = ResolveRelativeLink(FilePath, Src);
|
||||
return ResolvedPath === null ? Src : GetFileUrl(RootName, ResolvedPath);
|
||||
},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (FileType === "text" && !IsEditing) {
|
||||
return (
|
||||
<div className="PreviewText">
|
||||
<div className={ToolbarClass}>
|
||||
<button onClick={() => SetIsEditing(true)}>Edit</button>
|
||||
</div>
|
||||
<pre className="TextBody">{Content}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (TextTypes.includes(FileType)) {
|
||||
return (
|
||||
<div className="PreviewEditor">
|
||||
<div className={ToolbarClass}>
|
||||
{(FileType === "markdown" || FileType === "text" || FileType === "html") && (
|
||||
<button onClick={() => SetIsEditing(false)}>View</button>
|
||||
)}
|
||||
<button onClick={HandleSave} disabled={IsSaving}>
|
||||
{IsSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
{SaveError && <span className="SaveErrorText">{SaveError}</span>}
|
||||
</div>
|
||||
<CodeMirror
|
||||
value={Content}
|
||||
height="100%"
|
||||
extensions={[
|
||||
...GetLanguageExtension(FileType),
|
||||
...(ShowGitBlame ? [BuildBlameGutter(BlameMap)] : []),
|
||||
]}
|
||||
onChange={SetContent}
|
||||
theme="dark"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="PreviewEmpty">No preview available for this file type.</div>;
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
CreateUserAccount,
|
||||
DeleteTheme,
|
||||
FetchProfileColourOptions,
|
||||
FetchSettings,
|
||||
FetchTheme,
|
||||
FetchThemeNames,
|
||||
FetchUsers,
|
||||
GetAvatarUrl,
|
||||
Logout,
|
||||
SaveSettings,
|
||||
SaveTheme,
|
||||
UpdateProfileColour,
|
||||
UploadAvatar,
|
||||
} from "../Api.js";
|
||||
import Switch from "./Switch.jsx";
|
||||
import { PaletteRoles } from "../ThemeRoles.js";
|
||||
|
||||
export default function SettingsPanel({
|
||||
CurrentUser,
|
||||
OnClose,
|
||||
OnSettingsChanged,
|
||||
OnLoggedOut,
|
||||
OnProfileUpdated,
|
||||
}) {
|
||||
const [Settings, SetSettings] = useState(null);
|
||||
const [ThemeNames, SetThemeNames] = useState([]);
|
||||
const [SaveError, SetSaveError] = useState(null);
|
||||
const [IsSaving, SetIsSaving] = useState(false);
|
||||
|
||||
const [IsCreatingTheme, SetIsCreatingTheme] = useState(false);
|
||||
const [NewThemeName, SetNewThemeName] = useState("");
|
||||
const [NewThemePalette, SetNewThemePalette] = useState(null);
|
||||
const [ThemeError, SetThemeError] = useState(null);
|
||||
|
||||
const [Users, SetUsers] = useState(null);
|
||||
const [NewUsername, SetNewUsername] = useState("");
|
||||
const [NewUserPassword, SetNewUserPassword] = useState("");
|
||||
const [NewUserIsAdmin, SetNewUserIsAdmin] = useState(false);
|
||||
const [NewUserColour, SetNewUserColour] = useState("Purple");
|
||||
const [UserError, SetUserError] = useState(null);
|
||||
|
||||
const [ColourOptions, SetColourOptions] = useState([]);
|
||||
const [ProfileError, SetProfileError] = useState(null);
|
||||
const [AvatarNonce, SetAvatarNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
FetchSettings().then(SetSettings);
|
||||
FetchThemeNames().then(SetThemeNames);
|
||||
FetchProfileColourOptions().then(SetColourOptions);
|
||||
if (CurrentUser && CurrentUser.IsAdmin) {
|
||||
FetchUsers().then(SetUsers);
|
||||
}
|
||||
}, [CurrentUser]);
|
||||
|
||||
if (!Settings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function UpdateField(Field, Value) {
|
||||
SetSettings({ ...Settings, [Field]: Value });
|
||||
}
|
||||
|
||||
async function HandleSave() {
|
||||
SetIsSaving(true);
|
||||
SetSaveError(null);
|
||||
try {
|
||||
const Saved = await SaveSettings(Settings);
|
||||
OnSettingsChanged(Saved);
|
||||
OnClose();
|
||||
} catch (Error) {
|
||||
SetSaveError(Error.message);
|
||||
} finally {
|
||||
SetIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function HandleStartCreateTheme() {
|
||||
SetThemeError(null);
|
||||
const BasePalette = await FetchTheme(Settings.ThemeName);
|
||||
SetNewThemePalette(BasePalette);
|
||||
SetNewThemeName("");
|
||||
SetIsCreatingTheme(true);
|
||||
}
|
||||
|
||||
async function HandleSaveNewTheme() {
|
||||
if (!NewThemeName.trim()) return;
|
||||
|
||||
try {
|
||||
await SaveTheme(NewThemeName.trim(), NewThemePalette);
|
||||
const Updated = await FetchThemeNames();
|
||||
SetThemeNames(Updated);
|
||||
UpdateField("ThemeName", NewThemeName.trim());
|
||||
SetIsCreatingTheme(false);
|
||||
} catch (Error) {
|
||||
SetThemeError(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function HandleRenameTheme() {
|
||||
const NewName = window.prompt("Rename theme to:", Settings.ThemeName);
|
||||
if (!NewName || !NewName.trim() || NewName.trim() === Settings.ThemeName) return;
|
||||
|
||||
SetThemeError(null);
|
||||
try {
|
||||
const Palette = await FetchTheme(Settings.ThemeName);
|
||||
await SaveTheme(NewName.trim(), Palette);
|
||||
await DeleteTheme(Settings.ThemeName);
|
||||
const Updated = await FetchThemeNames();
|
||||
SetThemeNames(Updated);
|
||||
UpdateField("ThemeName", NewName.trim());
|
||||
} catch (Error) {
|
||||
SetThemeError(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function HandleDeleteTheme() {
|
||||
if (ThemeNames.length <= 1) {
|
||||
SetThemeError("Can't delete the only remaining theme.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm(`Delete theme "${Settings.ThemeName}"? This can't be undone.`)) return;
|
||||
|
||||
SetThemeError(null);
|
||||
try {
|
||||
await DeleteTheme(Settings.ThemeName);
|
||||
const Updated = await FetchThemeNames();
|
||||
SetThemeNames(Updated);
|
||||
UpdateField("ThemeName", Updated[0]);
|
||||
} catch (Error) {
|
||||
SetThemeError(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function HandleLogout() {
|
||||
await Logout();
|
||||
OnLoggedOut();
|
||||
}
|
||||
|
||||
async function HandleCreateUser() {
|
||||
SetUserError(null);
|
||||
|
||||
try {
|
||||
await CreateUserAccount(NewUsername.trim(), NewUserPassword, NewUserIsAdmin, NewUserColour);
|
||||
const Updated = await FetchUsers();
|
||||
SetUsers(Updated);
|
||||
SetNewUsername("");
|
||||
SetNewUserPassword("");
|
||||
SetNewUserIsAdmin(false);
|
||||
SetNewUserColour("Purple");
|
||||
} catch (Error) {
|
||||
SetUserError(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function HandlePickColour(Colour) {
|
||||
SetProfileError(null);
|
||||
try {
|
||||
const Updated = await UpdateProfileColour(Colour);
|
||||
OnProfileUpdated(Updated);
|
||||
} catch (Error) {
|
||||
SetProfileError(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function HandleAvatarChange(Event) {
|
||||
const File = Event.target.files && Event.target.files[0];
|
||||
if (!File) return;
|
||||
|
||||
SetProfileError(null);
|
||||
try {
|
||||
await UploadAvatar(File);
|
||||
const NextNonce = Date.now();
|
||||
SetAvatarNonce(NextNonce);
|
||||
OnProfileUpdated({ AvatarNonce: NextNonce });
|
||||
} catch (Error) {
|
||||
SetProfileError(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="SettingsOverlay" onClick={OnClose}>
|
||||
<div className="SettingsModal" onClick={(Event) => Event.stopPropagation()}>
|
||||
<div className="SettingsHeader">
|
||||
<span>Settings</span>
|
||||
<button className="SettingsClose" onClick={OnClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="SettingsBody">
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">Font family</label>
|
||||
<input
|
||||
type="text"
|
||||
value={Settings.FontFamily}
|
||||
onChange={(Event) => UpdateField("FontFamily", Event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">Font size (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={Settings.FontSize}
|
||||
onChange={(Event) => UpdateField("FontSize", Number(Event.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">Show hidden directories</label>
|
||||
<Switch Checked={Settings.ShowHiddenFolders} OnChange={(Event) => UpdateField("ShowHiddenFolders", Event.target.checked)} />
|
||||
</div>
|
||||
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">Show breadcrumbs</label>
|
||||
<Switch Checked={Settings.ShowBreadcrumbs} OnChange={(Event) => UpdateField("ShowBreadcrumbs", Event.target.checked)} />
|
||||
</div>
|
||||
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">Hyphen file icons (-)</label>
|
||||
<Switch Checked={Settings.UseHyphenBullets} OnChange={(Event) => UpdateField("UseHyphenBullets", Event.target.checked)} />
|
||||
</div>
|
||||
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">ASCII directory icons (>/v)</label>
|
||||
<Switch Checked={Settings.UseAsciiFolderIcons} OnChange={(Event) => UpdateField("UseAsciiFolderIcons", Event.target.checked)} />
|
||||
</div>
|
||||
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">Open directory file on click</label>
|
||||
<Switch Checked={Settings.NavigateIntoDirectories} OnChange={(Event) => UpdateField("NavigateIntoDirectories", Event.target.checked)} />
|
||||
</div>
|
||||
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">New File/Directory buttons at top of sidebar</label>
|
||||
<Switch Checked={Settings.NewEntryButtonsAtTop} OnChange={(Event) => UpdateField("NewEntryButtonsAtTop", Event.target.checked)} />
|
||||
</div>
|
||||
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">Show blame gutter (who last changed each line)</label>
|
||||
<Switch Checked={Settings.ShowGitBlame} OnChange={(Event) => UpdateField("ShowGitBlame", Event.target.checked)} />
|
||||
</div>
|
||||
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">Sticky edit/view/save toolbar</label>
|
||||
<Switch Checked={Settings.StickyToolbar} OnChange={(Event) => UpdateField("StickyToolbar", Event.target.checked)} />
|
||||
</div>
|
||||
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">Confirm before delete</label>
|
||||
<Switch Checked={Settings.ConfirmBeforeDelete} OnChange={(Event) => UpdateField("ConfirmBeforeDelete", Event.target.checked)} />
|
||||
</div>
|
||||
|
||||
<div className="SettingsSection">
|
||||
<div className="SettingsSectionTitle">Colour theme</div>
|
||||
<div className="SettingsRow">
|
||||
<select
|
||||
value={Settings.ThemeName}
|
||||
onChange={(Event) => UpdateField("ThemeName", Event.target.value)}
|
||||
>
|
||||
{ThemeNames.map((Name) => (
|
||||
<option key={Name} value={Name}>
|
||||
{Name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="SettingsRowInline">
|
||||
<button onClick={HandleRenameTheme}>Rename</button>
|
||||
<button onClick={HandleDeleteTheme}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!IsCreatingTheme && (
|
||||
<button onClick={HandleStartCreateTheme}>Create from current theme</button>
|
||||
)}
|
||||
{IsCreatingTheme && NewThemePalette && (
|
||||
<>
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">New theme name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={NewThemeName}
|
||||
onChange={(Event) => SetNewThemeName(Event.target.value)}
|
||||
placeholder="MyTheme"
|
||||
/>
|
||||
</div>
|
||||
<div className="PaletteGrid">
|
||||
{PaletteRoles.map((Role) => (
|
||||
<div className="PaletteSwatchRow" key={Role}>
|
||||
<input
|
||||
type="color"
|
||||
value={NewThemePalette[Role] || "#000000"}
|
||||
onChange={(Event) =>
|
||||
SetNewThemePalette({ ...NewThemePalette, [Role]: Event.target.value })
|
||||
}
|
||||
/>
|
||||
<span>{Role}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={HandleSaveNewTheme}>Save theme</button>
|
||||
</>
|
||||
)}
|
||||
{ThemeError && <div className="SettingsSaveError">{ThemeError}</div>}
|
||||
</div>
|
||||
|
||||
<div className="SettingsSection">
|
||||
<div className="SettingsSectionTitle">Profile</div>
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">Your colour</label>
|
||||
<div className="ColourSwatchPicker">
|
||||
{ColourOptions.map((Colour) => (
|
||||
<button
|
||||
key={Colour}
|
||||
className={`ColourSwatch${CurrentUser && CurrentUser.Colour === Colour ? " ColourSwatchSelected" : ""}`}
|
||||
style={{ background: `var(--${Colour})` }}
|
||||
title={Colour}
|
||||
onClick={() => HandlePickColour(Colour)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">Avatar</label>
|
||||
<div className="AvatarUploadRow">
|
||||
{CurrentUser && (
|
||||
<img
|
||||
className="AvatarPreview"
|
||||
src={`${GetAvatarUrl(CurrentUser.Username)}&v=${AvatarNonce}`}
|
||||
alt=""
|
||||
onError={(Event) => {
|
||||
Event.target.style.visibility = "hidden";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<input type="file" accept="image/*" onChange={HandleAvatarChange} />
|
||||
</div>
|
||||
</div>
|
||||
{ProfileError && <div className="SettingsSaveError">{ProfileError}</div>}
|
||||
</div>
|
||||
|
||||
<div className="SettingsSection">
|
||||
<div className="SettingsSectionTitle">Account</div>
|
||||
<div className="SettingsRowInline">
|
||||
<span className="SettingsLabel">
|
||||
Logged in as {CurrentUser ? CurrentUser.Username : "..."}
|
||||
{CurrentUser && CurrentUser.IsAdmin ? " (admin)" : ""}
|
||||
</span>
|
||||
<button onClick={HandleLogout}>Log out</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{CurrentUser && CurrentUser.IsAdmin && (
|
||||
<div className="SettingsSection">
|
||||
<div className="SettingsSectionTitle">Users (admin)</div>
|
||||
{Users &&
|
||||
Users.map((User) => (
|
||||
<div className="SettingsRowInline" key={User.Username}>
|
||||
<span className="SettingsLabel">
|
||||
<span
|
||||
className="ColourSwatchSmall"
|
||||
style={{ background: `var(--${User.Colour || "Purple"})` }}
|
||||
/>
|
||||
{User.Username}
|
||||
{User.IsAdmin ? " (admin)" : ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">New username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={NewUsername}
|
||||
onChange={(Event) => SetNewUsername(Event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">New password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={NewUserPassword}
|
||||
onChange={(Event) => SetNewUserPassword(Event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">Colour</label>
|
||||
<div className="ColourSwatchPicker">
|
||||
{ColourOptions.map((Colour) => (
|
||||
<button
|
||||
key={Colour}
|
||||
className={`ColourSwatch${NewUserColour === Colour ? " ColourSwatchSelected" : ""}`}
|
||||
style={{ background: `var(--${Colour})` }}
|
||||
title={Colour}
|
||||
onClick={() => SetNewUserColour(Colour)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">Admin</label>
|
||||
<Switch Checked={NewUserIsAdmin} OnChange={(Event) => SetNewUserIsAdmin(Event.target.checked)} />
|
||||
</div>
|
||||
{UserError && <div className="SettingsSaveError">{UserError}</div>}
|
||||
<button onClick={HandleCreateUser}>Create user</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{SaveError && <div className="SettingsSaveError">{SaveError}</div>}
|
||||
</div>
|
||||
|
||||
<div className="SettingsFooter">
|
||||
<button onClick={OnClose}>Cancel</button>
|
||||
<button onClick={HandleSave} disabled={IsSaving}>
|
||||
{IsSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { CreateEntry, DeleteEntry, FetchTree, GetAvatarUrl, MoveEntry, RenameEntry } from "../Api.js";
|
||||
import { GetFileIcon, GetFolderIcon } from "../IconStyle.js";
|
||||
|
||||
function TreeNode({
|
||||
RootName,
|
||||
Entry,
|
||||
ParentPath,
|
||||
SelectedFile,
|
||||
CurrentDirectoryPath,
|
||||
OnSelectFile,
|
||||
OnNavigateDirectory,
|
||||
OnBrowseDirectory,
|
||||
OnEntryRenamed,
|
||||
OnEntryDeleted,
|
||||
UseHyphenBullets,
|
||||
UseAsciiFolderIcons,
|
||||
NavigateIntoDirectories,
|
||||
ConfirmBeforeDelete,
|
||||
}) {
|
||||
const [IsExpanded, SetIsExpanded] = useState(false);
|
||||
const [Children, SetChildren] = useState(null);
|
||||
const FullPath = ParentPath ? `${ParentPath}/${Entry.Name}` : Entry.Name;
|
||||
const IsSelected = SelectedFile === FullPath;
|
||||
const IsCurrentDirectory = Entry.IsDirectory && CurrentDirectoryPath === FullPath;
|
||||
const IsAncestorOfCurrentDirectory =
|
||||
Entry.IsDirectory &&
|
||||
CurrentDirectoryPath &&
|
||||
(CurrentDirectoryPath === FullPath || CurrentDirectoryPath.startsWith(`${FullPath}/`));
|
||||
|
||||
async function LoadChildren() {
|
||||
try {
|
||||
const Fetched = await FetchTree(RootName, FullPath);
|
||||
SetChildren(Fetched);
|
||||
return Fetched;
|
||||
} catch (Error) {
|
||||
SetChildren([]);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function ToggleExpand() {
|
||||
if (!Entry.IsDirectory) {
|
||||
OnSelectFile(FullPath, Entry.FileType);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsExpanded && Children === null) {
|
||||
await LoadChildren();
|
||||
}
|
||||
|
||||
SetIsExpanded(!IsExpanded);
|
||||
OnBrowseDirectory(FullPath);
|
||||
|
||||
if (NavigateIntoDirectories) {
|
||||
OnNavigateDirectory(FullPath);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (IsAncestorOfCurrentDirectory && !IsExpanded) {
|
||||
if (Children === null) {
|
||||
LoadChildren().then(() => SetIsExpanded(true));
|
||||
} else {
|
||||
SetIsExpanded(true);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [CurrentDirectoryPath]);
|
||||
|
||||
async function HandleRename(Event) {
|
||||
Event.stopPropagation();
|
||||
const NewValue = window.prompt("Rename to (or type a path to move):", Entry.Name);
|
||||
if (!NewValue || NewValue === Entry.Name) return;
|
||||
|
||||
try {
|
||||
if (NewValue.includes("/")) {
|
||||
await MoveEntry(RootName, FullPath, NewValue);
|
||||
OnEntryRenamed(FullPath, NewValue, Entry.IsDirectory);
|
||||
} else {
|
||||
await RenameEntry(RootName, FullPath, NewValue);
|
||||
const NewPath = ParentPath ? `${ParentPath}/${NewValue}` : NewValue;
|
||||
OnEntryRenamed(FullPath, NewPath, Entry.IsDirectory);
|
||||
}
|
||||
} catch (Error) {
|
||||
window.alert(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function HandleDelete(Event) {
|
||||
Event.stopPropagation();
|
||||
|
||||
if (ConfirmBeforeDelete && !window.confirm(`Delete "${Entry.Name}"? This can't be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await DeleteEntry(RootName, FullPath);
|
||||
OnEntryDeleted(FullPath, Entry.IsDirectory);
|
||||
} catch (Error) {
|
||||
window.alert(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="TreeNode">
|
||||
<div
|
||||
className={`TreeRow${IsSelected ? " TreeRowSelected" : ""}${
|
||||
IsCurrentDirectory ? " TreeRowCurrentDirectory" : ""
|
||||
}`}
|
||||
onClick={ToggleExpand}
|
||||
>
|
||||
<span className="TreeIcon">
|
||||
{Entry.IsDirectory
|
||||
? GetFolderIcon(IsExpanded, UseAsciiFolderIcons)
|
||||
: GetFileIcon(UseHyphenBullets)}
|
||||
</span>
|
||||
<span className="TreeLabel">{Entry.Name}</span>
|
||||
<span className="TreeRowActions">
|
||||
<button className="TreeRowActionButton" onClick={HandleRename} title="Rename / move">
|
||||
✎
|
||||
</button>
|
||||
<button className="TreeRowActionButton" onClick={HandleDelete} title="Delete">
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{IsExpanded && Children && (
|
||||
<div className="TreeChildren">
|
||||
{Children.map((Child) => (
|
||||
<TreeNode
|
||||
key={Child.Name}
|
||||
RootName={RootName}
|
||||
Entry={Child}
|
||||
ParentPath={FullPath}
|
||||
SelectedFile={SelectedFile}
|
||||
CurrentDirectoryPath={CurrentDirectoryPath}
|
||||
OnSelectFile={OnSelectFile}
|
||||
OnNavigateDirectory={OnNavigateDirectory}
|
||||
OnBrowseDirectory={OnBrowseDirectory}
|
||||
OnEntryRenamed={OnEntryRenamed}
|
||||
OnEntryDeleted={OnEntryDeleted}
|
||||
UseHyphenBullets={UseHyphenBullets}
|
||||
UseAsciiFolderIcons={UseAsciiFolderIcons}
|
||||
NavigateIntoDirectories={NavigateIntoDirectories}
|
||||
ConfirmBeforeDelete={ConfirmBeforeDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserBadge({ CurrentUser, OnOpenSettings }) {
|
||||
const [AvatarFailed, SetAvatarFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
SetAvatarFailed(false);
|
||||
}, [CurrentUser && CurrentUser.AvatarNonce]);
|
||||
|
||||
if (!CurrentUser) return null;
|
||||
|
||||
const AvatarUrl = CurrentUser.AvatarNonce
|
||||
? `${GetAvatarUrl(CurrentUser.Username)}&v=${CurrentUser.AvatarNonce}`
|
||||
: GetAvatarUrl(CurrentUser.Username);
|
||||
|
||||
return (
|
||||
<span className="UserBadge" onClick={OnOpenSettings} title={CurrentUser.Username}>
|
||||
{!AvatarFailed ? (
|
||||
<img
|
||||
className="UserBadgeAvatar"
|
||||
src={AvatarUrl}
|
||||
alt=""
|
||||
onError={() => SetAvatarFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="UserBadgeInitial"
|
||||
style={{ background: `var(--${CurrentUser.Colour || "Purple"})` }}
|
||||
>
|
||||
{CurrentUser.Username.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
RootName,
|
||||
SelectedFile,
|
||||
CurrentDirectoryPath,
|
||||
CurrentUser,
|
||||
OnSelectFile,
|
||||
OnNavigateDirectory,
|
||||
OnBrowseDirectory,
|
||||
OnEntryRenamed,
|
||||
OnEntryDeleted,
|
||||
OnOpenSettings,
|
||||
OnEntryCreated,
|
||||
UseHyphenBullets,
|
||||
UseAsciiFolderIcons,
|
||||
NavigateIntoDirectories,
|
||||
NewEntryButtonsAtTop,
|
||||
ConfirmBeforeDelete,
|
||||
}) {
|
||||
const [RootEntries, SetRootEntries] = useState([]);
|
||||
const [LoadError, SetLoadError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (RootName) {
|
||||
SetLoadError(null);
|
||||
FetchTree(RootName, "")
|
||||
.then(SetRootEntries)
|
||||
.catch((Error) => SetLoadError(Error.message));
|
||||
}
|
||||
}, [RootName]);
|
||||
|
||||
function JoinPath(Name) {
|
||||
return CurrentDirectoryPath ? `${CurrentDirectoryPath}/${Name}` : Name;
|
||||
}
|
||||
|
||||
async function HandleCreateFile() {
|
||||
const Name = window.prompt(
|
||||
`New file name in ${CurrentDirectoryPath || "root"} (e.g. Notes.md):`
|
||||
);
|
||||
if (!Name) return;
|
||||
|
||||
try {
|
||||
await CreateEntry(RootName, JoinPath(Name), false);
|
||||
OnEntryCreated();
|
||||
} catch (Error) {
|
||||
SetLoadError(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function HandleCreateDirectory() {
|
||||
const Name = window.prompt(`New directory name in ${CurrentDirectoryPath || "root"}:`);
|
||||
if (!Name) return;
|
||||
|
||||
try {
|
||||
await CreateEntry(RootName, JoinPath(Name), true);
|
||||
OnEntryCreated();
|
||||
} catch (Error) {
|
||||
SetLoadError(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const NewEntryButtons = (
|
||||
<div className={`SidebarNewEntry${NewEntryButtonsAtTop ? " SidebarNewEntryTop" : ""}`}>
|
||||
<button onClick={HandleCreateFile}>New File</button>
|
||||
<button onClick={HandleCreateDirectory}>New Directory</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="Sidebar">
|
||||
<div className="SidebarHeader">
|
||||
<span className="SidebarHeaderLink" onClick={() => OnNavigateDirectory("")}>
|
||||
SaraView
|
||||
</span>
|
||||
<span className="SidebarHeaderRight">
|
||||
<UserBadge CurrentUser={CurrentUser} OnOpenSettings={OnOpenSettings} />
|
||||
<button className="SettingsButton" onClick={OnOpenSettings}>
|
||||
⚙
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{NewEntryButtonsAtTop && NewEntryButtons}
|
||||
{LoadError && <div className="SidebarError">{LoadError}</div>}
|
||||
<div className="SidebarTree">
|
||||
{RootEntries.map((Entry) => (
|
||||
<TreeNode
|
||||
key={Entry.Name}
|
||||
RootName={RootName}
|
||||
Entry={Entry}
|
||||
ParentPath=""
|
||||
SelectedFile={SelectedFile}
|
||||
CurrentDirectoryPath={CurrentDirectoryPath}
|
||||
OnSelectFile={OnSelectFile}
|
||||
OnNavigateDirectory={OnNavigateDirectory}
|
||||
OnBrowseDirectory={OnBrowseDirectory}
|
||||
OnEntryRenamed={OnEntryRenamed}
|
||||
OnEntryDeleted={OnEntryDeleted}
|
||||
UseHyphenBullets={UseHyphenBullets}
|
||||
UseAsciiFolderIcons={UseAsciiFolderIcons}
|
||||
NavigateIntoDirectories={NavigateIntoDirectories}
|
||||
ConfirmBeforeDelete={ConfirmBeforeDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!NewEntryButtonsAtTop && NewEntryButtons}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
|
||||
export default function Switch({ Checked, OnChange }) {
|
||||
return (
|
||||
<label className="Switch">
|
||||
<input type="checkbox" checked={Checked} onChange={OnChange} />
|
||||
<span className="SwitchTrack">
|
||||
<span className="SwitchThumb" />
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { FetchBook } from "../Api.js";
|
||||
|
||||
export default function TagPanel({ RootName, FilePath }) {
|
||||
const [Book, SetBook] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
SetBook(null);
|
||||
|
||||
if (RootName && FilePath) {
|
||||
FetchBook(RootName, FilePath).then((Result) => {
|
||||
if (Result && Object.keys(Result).length > 0) {
|
||||
SetBook(Result);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [RootName, FilePath]);
|
||||
|
||||
if (!Book) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const TagEntries = Object.entries(Book.Tags || {});
|
||||
|
||||
return (
|
||||
<div className="TagPanel">
|
||||
<div className="TagPanelHeader">Tags</div>
|
||||
<div className="TagPanelBody">
|
||||
{TagEntries.length === 0 && (
|
||||
<div className="TagPanelEmpty">No tags recorded.</div>
|
||||
)}
|
||||
{TagEntries.map(([TagName, TagValue]) => (
|
||||
<div className="TagRow" key={TagName}>
|
||||
<span className="TagName">{TagName}</span>
|
||||
<span className="TagValue">{String(TagValue)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user