Initial commit

This commit is contained in:
sarah committed 2026-09-19 14:53:01 +02:00
commit 8aa223bc7b
40 files changed
+4320

No files matched your search

+172
View File
@@ -0,0 +1,172 @@
const ApiBase = "/api";
async function ParseJsonOrThrow(Response) {
const Body = await Response.json().catch(() => null);
if (!Response.ok) {
const Detail = Body && Body.detail ? Body.detail : Response.statusText;
throw new Error(Detail);
}
return Body;
}
function FetchJson(Path, Options = {}) {
return fetch(`${ApiBase}${Path}`, { credentials: "same-origin", ...Options }).then(
ParseJsonOrThrow
);
}
function FetchText(Path, Options = {}) {
return fetch(`${ApiBase}${Path}`, { credentials: "same-origin", ...Options }).then(
(Response) => Response.text()
);
}
function JsonBody(Body) {
return { headers: { "Content-Type": "application/json" }, body: JSON.stringify(Body) };
}
// --- Auth -------------------------------------------------------------
export function FetchAuthStatus() {
return FetchJson("/auth/status");
}
export function Bootstrap(Username, Password) {
return FetchJson("/auth/bootstrap", { method: "POST", ...JsonBody({ Username, Password }) });
}
export function Login(Username, Password) {
return FetchJson("/auth/login", { method: "POST", ...JsonBody({ Username, Password }) });
}
export function Logout() {
return FetchJson("/auth/logout", { method: "POST" });
}
export function FetchMe() {
return FetchJson("/auth/me");
}
export function FetchUsers() {
return FetchJson("/users");
}
export function CreateUserAccount(Username, Password, IsAdmin, Colour) {
return FetchJson("/users", { method: "POST", ...JsonBody({ Username, Password, IsAdmin, Colour }) });
}
export function FetchProfileColourOptions() {
return FetchJson("/profile/colours");
}
export function UpdateProfileColour(Colour) {
return FetchJson("/profile/colour", { method: "PUT", ...JsonBody({ Colour }) });
}
export async function UploadAvatar(File) {
const Form = new FormData();
Form.append("Avatar", File);
const Response = await fetch(`${ApiBase}/profile/avatar`, {
method: "POST",
credentials: "same-origin",
body: Form,
});
return ParseJsonOrThrow(Response);
}
export function GetAvatarUrl(Username) {
const Params = new URLSearchParams({ Username });
return `${ApiBase}/profile/avatar?${Params}`;
}
// --- Files --------------------------------------------------------------
export function FetchRoots() {
return FetchJson("/roots");
}
export function FetchTree(RootName, RelativePath) {
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
return FetchJson(`/tree?${Params}`);
}
export function FetchFile(RootName, RelativePath) {
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
return FetchText(`/file?${Params}`);
}
export function GetFileUrl(RootName, RelativePath) {
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
return `${ApiBase}/file?${Params}`;
}
export function GetRawFileUrl(RootName, RelativePath) {
const EncodedSegments = RelativePath.split("/").map(encodeURIComponent);
return `${ApiBase}/raw/${encodeURIComponent(RootName)}/${EncodedSegments.join("/")}`;
}
export function SaveFile(RootName, RelativePath, Content) {
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
return FetchJson(`/file?${Params}`, { method: "PUT", body: Content });
}
export function FetchBook(RootName, RelativePath) {
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
return FetchJson(`/book?${Params}`);
}
export function FetchBlame(RootName, RelativePath) {
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
return FetchJson(`/blame?${Params}`);
}
export function CreateEntry(RootName, RelativePath, IsDirectory) {
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
return FetchJson(`/tree?${Params}`, { method: "POST", ...JsonBody({ IsDirectory }) });
}
export function RenameEntry(RootName, RelativePath, NewName) {
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
return FetchJson(`/tree?${Params}`, { method: "PATCH", ...JsonBody({ NewName }) });
}
export function MoveEntry(RootName, RelativePath, DestinationPath) {
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
return FetchJson(`/tree?${Params}`, { method: "PATCH", ...JsonBody({ DestinationPath }) });
}
export function DeleteEntry(RootName, RelativePath) {
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
return FetchJson(`/tree?${Params}`, { method: "DELETE" });
}
// --- Settings / themes -------------------------------------------------
export function FetchSettings() {
return FetchJson("/settings");
}
export function SaveSettings(NewSettings) {
return FetchJson("/settings", { method: "PUT", ...JsonBody(NewSettings) });
}
export function FetchThemeNames() {
return FetchJson("/themes");
}
export function FetchTheme(Name) {
const Params = new URLSearchParams({ Name });
return FetchJson(`/theme?${Params}`);
}
export function SaveTheme(Name, Palette) {
const Params = new URLSearchParams({ Name });
return FetchJson(`/theme?${Params}`, { method: "PUT", ...JsonBody({ Palette }) });
}
export function DeleteTheme(Name) {
const Params = new URLSearchParams({ Name });
return FetchJson(`/theme?${Params}`, { method: "DELETE" });
}
+258
View File
@@ -0,0 +1,258 @@
import React, { useEffect, useState } from "react";
import Sidebar from "./Components/Sidebar.jsx";
import Breadcrumb from "./Components/Breadcrumb.jsx";
import PreviewPane from "./Components/PreviewPane.jsx";
import DirectoryPane from "./Components/DirectoryPane.jsx";
import TagPanel from "./Components/TagPanel.jsx";
import SettingsPanel from "./Components/SettingsPanel.jsx";
import LoginScreen from "./Components/LoginScreen.jsx";
import { FetchAuthStatus, FetchMe, FetchRoots, FetchSettings, FetchTheme } from "./Api.js";
import { PaletteRoles } from "./ThemeRoles.js";
function ApplyTheme(Palette) {
const RootStyle = document.documentElement.style;
PaletteRoles.forEach((Role) => {
if (Palette[Role]) {
RootStyle.setProperty(`--${Role}`, Palette[Role]);
}
});
}
function ApplyFont(Settings) {
const RootStyle = document.documentElement.style;
RootStyle.setProperty(
"--FontFamily",
`"${Settings.FontFamily}", "Cascadia Mono", monospace`
);
RootStyle.setProperty("--FontSize", `${Settings.FontSize}px`);
}
function GetParentPath(FilePath) {
return FilePath.includes("/") ? FilePath.slice(0, FilePath.lastIndexOf("/")) : "";
}
export default function App() {
const [AuthState, SetAuthState] = useState("Loading"); // Loading | Bootstrap | LoggedOut | LoggedIn
const [CurrentUser, SetCurrentUser] = useState(null);
const [Roots, SetRoots] = useState([]);
const [ActiveRoot, SetActiveRoot] = useState(null);
const [ViewPath, SetViewPath] = useState("");
const [ViewIsDirectory, SetViewIsDirectory] = useState(true);
const [ViewFileType, SetViewFileType] = useState(null);
const [CurrentDirectoryPath, SetCurrentDirectoryPath] = useState("");
const [SidebarKey, SetSidebarKey] = useState(0);
const [Settings, SetSettings] = useState(null);
const [IsSettingsOpen, SetIsSettingsOpen] = useState(false);
useEffect(() => {
FetchAuthStatus().then((Status) => {
if (!Status.SetupComplete) {
SetAuthState("Bootstrap");
return;
}
FetchMe()
.then((User) => {
SetCurrentUser(User);
SetAuthState("LoggedIn");
})
.catch(() => SetAuthState("LoggedOut"));
});
}, []);
useEffect(() => {
if (AuthState !== "LoggedIn") return;
FetchRoots().then((Fetched) => {
SetRoots(Fetched);
if (Fetched.length > 0) {
SetActiveRoot(Fetched[0].Name);
}
});
FetchSettings().then((FetchedSettings) => {
SetSettings(FetchedSettings);
ApplyFont(FetchedSettings);
FetchTheme(FetchedSettings.ThemeName).then(ApplyTheme);
});
}, [AuthState]);
function HandleAuthenticated(User) {
SetCurrentUser(User);
SetAuthState("LoggedIn");
}
function HandleLoggedOut() {
SetCurrentUser(null);
SetAuthState("LoggedOut");
}
function HandleProfileUpdated(UpdatedUser) {
SetCurrentUser((Existing) => ({ ...Existing, ...UpdatedUser }));
}
function HandleSettingsChanged(NewSettings) {
SetSettings(NewSettings);
ApplyFont(NewSettings);
FetchTheme(NewSettings.ThemeName).then(ApplyTheme);
SetSidebarKey((Key) => Key + 1);
}
function HandleSelectFile(FilePath, FileType) {
SetViewPath(FilePath);
SetViewIsDirectory(false);
SetViewFileType(FileType);
SetCurrentDirectoryPath(GetParentPath(FilePath));
}
function HandleNavigateDirectory(DirectoryPath) {
SetViewPath(DirectoryPath);
SetViewIsDirectory(true);
SetViewFileType(null);
SetCurrentDirectoryPath(DirectoryPath);
}
function HandleBrowseDirectory(DirectoryPath) {
SetCurrentDirectoryPath(DirectoryPath);
}
function HandleEntrySelect(EntryPath, IsDirectory, FileType) {
if (IsDirectory) {
HandleNavigateDirectory(EntryPath);
} else {
HandleSelectFile(EntryPath, FileType);
}
}
function RefreshSidebar() {
SetSidebarKey((Key) => Key + 1);
}
function HandleEntryRenamed(OldPath, NewPath, IsDirectory) {
RefreshSidebar();
if (!ViewIsDirectory && ViewPath === OldPath) {
HandleSelectFile(NewPath, ViewFileType);
} else if (ViewIsDirectory && ViewPath === OldPath) {
HandleNavigateDirectory(NewPath);
} else if (CurrentDirectoryPath === OldPath) {
SetCurrentDirectoryPath(NewPath);
}
}
function HandleEntryDeleted(DeletedPath) {
RefreshSidebar();
if (ViewPath === DeletedPath || ViewPath.startsWith(`${DeletedPath}/`)) {
HandleNavigateDirectory(GetParentPath(DeletedPath));
} else if (CurrentDirectoryPath === DeletedPath || CurrentDirectoryPath.startsWith(`${DeletedPath}/`)) {
SetCurrentDirectoryPath(GetParentPath(DeletedPath));
}
}
if (AuthState === "Loading") {
return null;
}
if (AuthState === "Bootstrap") {
return <LoginScreen IsBootstrap OnAuthenticated={HandleAuthenticated} />;
}
if (AuthState === "LoggedOut") {
return <LoginScreen IsBootstrap={false} OnAuthenticated={HandleAuthenticated} />;
}
// Only take up a TopBar row at all if there's something to put in it —
// otherwise turning breadcrumbs off with a single root left a blank strip.
const ShowTopBar = (Settings && Settings.ShowBreadcrumbs) || Roots.length > 1;
const ConfirmBeforeDelete = Settings ? Settings.ConfirmBeforeDelete : true;
return (
<div className="AppShell">
<Sidebar
key={SidebarKey}
RootName={ActiveRoot}
SelectedFile={ViewIsDirectory ? null : ViewPath}
CurrentDirectoryPath={CurrentDirectoryPath}
CurrentUser={CurrentUser}
OnSelectFile={HandleSelectFile}
OnNavigateDirectory={HandleNavigateDirectory}
OnBrowseDirectory={HandleBrowseDirectory}
OnEntryRenamed={HandleEntryRenamed}
OnEntryDeleted={HandleEntryDeleted}
OnOpenSettings={() => SetIsSettingsOpen(true)}
OnEntryCreated={RefreshSidebar}
UseHyphenBullets={Settings ? Settings.UseHyphenBullets : true}
UseAsciiFolderIcons={Settings ? Settings.UseAsciiFolderIcons : true}
NavigateIntoDirectories={Settings ? Settings.NavigateIntoDirectories : false}
NewEntryButtonsAtTop={Settings ? Settings.NewEntryButtonsAtTop : false}
ConfirmBeforeDelete={ConfirmBeforeDelete}
/>
<div className="MainColumn">
{ShowTopBar && (
<div className="TopBar">
{Settings && Settings.ShowBreadcrumbs ? (
<Breadcrumb
RootName={ActiveRoot}
FilePath={ViewPath}
IsDirectory={ViewIsDirectory}
OnNavigate={HandleNavigateDirectory}
/>
) : (
<span />
)}
{Roots.length > 1 && (
<select
className="RootSwitcher"
value={ActiveRoot || ""}
onChange={(Event) => SetActiveRoot(Event.target.value)}
>
{Roots.map((Root) => (
<option key={Root.Name} value={Root.Name}>
{Root.Name}
</option>
))}
</select>
)}
</div>
)}
<div className="MainBody">
{ViewIsDirectory ? (
<DirectoryPane
RootName={ActiveRoot}
DirectoryPath={ViewPath}
OnEntrySelect={HandleEntrySelect}
OnEntryCreated={RefreshSidebar}
OnEntryRenamed={HandleEntryRenamed}
OnEntryDeleted={HandleEntryDeleted}
UseHyphenBullets={Settings ? Settings.UseHyphenBullets : true}
UseAsciiFolderIcons={Settings ? Settings.UseAsciiFolderIcons : true}
ConfirmBeforeDelete={ConfirmBeforeDelete}
/>
) : (
<PreviewPane
RootName={ActiveRoot}
FilePath={ViewPath}
FileType={ViewFileType}
OnNavigateLink={HandleSelectFile}
ShowGitBlame={Settings ? Settings.ShowGitBlame : false}
StickyToolbar={Settings ? Settings.StickyToolbar : true}
/>
)}
{!ViewIsDirectory && <TagPanel RootName={ActiveRoot} FilePath={ViewPath} />}
</div>
</div>
{IsSettingsOpen && (
<SettingsPanel
CurrentUser={CurrentUser}
OnClose={() => SetIsSettingsOpen(false)}
OnSettingsChanged={HandleSettingsChanged}
OnLoggedOut={HandleLoggedOut}
OnProfileUpdated={HandleProfileUpdated}
/>
)}
</div>
);
}
+36
View File
@@ -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>
);
}
+148
View File
@@ -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>
);
}
+80
View File
@@ -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>
);
}
+284
View File
@@ -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>;
}
+424
View File
@@ -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 (&gt;/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>
);
}
+296
View File
@@ -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>
);
}
+12
View File
@@ -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>
);
}
+42
View File
@@ -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>
);
}
+10
View File
@@ -0,0 +1,10 @@
export function GetFolderIcon(IsExpanded, UseAsciiFolderIcons) {
if (UseAsciiFolderIcons) {
return IsExpanded ? "v" : ">";
}
return IsExpanded ? "▾" : "▸";
}
export function GetFileIcon(UseHyphenBullets) {
return UseHyphenBullets ? "-" : "•";
}
+11
View File
@@ -0,0 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "./Styles/Theme.css";
ReactDOM.createRoot(document.getElementById("Root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+794
View File
@@ -0,0 +1,794 @@
@font-face {
font-family: "Fixedsys Excelsior";
src: url("/Fonts/FSEX302.ttf") format("truetype");
font-display: swap;
}
/*
* Palette roles below are DEFAULTS ONLY. The active theme's actual values
* are applied at runtime (see App.jsx), which sets these same custom
* property names on :root via JS after fetching the chosen theme from the
* backend. Any theme file just needs to supply this same set of roles.
*/
:root {
--Background: #1e1e2e;
--Surface: #181825;
--SurfaceAlt: #313244;
--Border: #45475a;
--Text: #cdd6f4;
--TextMuted: #6c7086;
--Black: #11111b;
--White: #cdd6f4;
--Red: #f38ba8;
--Orange: #fab387;
--Yellow: #f9e2af;
--Green: #a6e3a1;
--Blue: #89b4fa;
--Purple: #cba6f7;
--FontFamily: "Fixedsys Excelsior", "Cascadia Mono", monospace;
--FontSize: 14px;
--HeaderHeight: 45px;
}
* {
box-sizing: border-box;
}
html,
body,
#Root {
height: 100%;
margin: 0;
}
body {
background: var(--Background);
color: var(--Text);
font-family: var(--FontFamily);
font-size: var(--FontSize);
font-smooth: never;
-webkit-font-smoothing: none;
}
img,
.PixelIcon {
image-rendering: pixelated;
}
.AppShell {
display: flex;
height: 100vh;
}
/* Sidebar / file tree */
.Sidebar {
width: 260px;
flex-shrink: 0;
background: var(--Surface);
border-right: 1px solid var(--Border);
display: flex;
flex-direction: column;
}
.SidebarTree {
flex: 1;
overflow-y: auto;
}
.SidebarNewEntry {
display: flex;
gap: 8px;
padding: 8px 16px;
border-top: 1px solid var(--Border);
}
.SidebarNewEntryTop {
border-top: none;
border-bottom: 1px solid var(--Border);
}
.SidebarNewEntry button {
flex: 1;
background: var(--SurfaceAlt);
color: var(--Text);
border: 1px solid var(--Purple);
border-radius: 4px;
padding: 4px 8px;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.SidebarNewEntry button:hover {
background: var(--Border);
}
.SidebarHeader {
height: var(--HeaderHeight);
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
color: var(--Purple);
font-size: 18px;
border-bottom: 1px solid var(--Purple);
}
.SidebarHeaderLink {
cursor: pointer;
}
.SidebarHeaderLink:hover {
text-decoration: underline;
}
.SidebarHeaderRight {
display: flex;
align-items: center;
gap: 8px;
}
.UserBadge {
cursor: pointer;
display: flex;
align-items: center;
}
.UserBadgeAvatar {
width: 22px;
height: 22px;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--Border);
}
.UserBadgeInitial {
width: 22px;
height: 22px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: var(--Background);
}
.SettingsButton {
background: none;
border: 1px solid var(--Purple);
color: var(--Purple);
border-radius: 4px;
padding: 2px 8px;
font-family: inherit;
font-size: 14px;
cursor: pointer;
}
.SettingsButton:hover {
background: var(--SurfaceAlt);
}
.SidebarError {
padding: 10px 16px;
color: var(--Red);
font-size: 13px;
}
.TreeRow {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 16px;
cursor: pointer;
}
.TreeRow:hover {
background: var(--SurfaceAlt);
}
.TreeRowSelected {
background: var(--Border);
color: var(--Purple);
}
.TreeRowCurrentDirectory {
border-left: 2px solid var(--Purple);
padding-left: 14px;
}
.TreeIcon {
width: 12px;
text-align: center;
color: var(--TextMuted);
}
.TreeLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.TreeRowActions {
display: none;
gap: 4px;
flex-shrink: 0;
}
.TreeRow:hover .TreeRowActions,
.DirectoryEntry:hover .TreeRowActions {
display: flex;
}
.TreeRowActionButton {
background: none;
border: 1px solid var(--Border);
color: var(--TextMuted);
border-radius: 3px;
width: 20px;
height: 20px;
line-height: 1;
font-size: 11px;
cursor: pointer;
}
.TreeRowActionButton:hover {
border-color: var(--Purple);
color: var(--Purple);
}
.TreeChildren {
padding-left: 14px;
}
/* Main column */
.MainColumn {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.TopBar {
height: var(--HeaderHeight);
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--Border);
}
.Breadcrumb {
padding: 0 16px;
color: var(--TextMuted);
}
.BreadcrumbLink {
cursor: pointer;
}
.BreadcrumbLink:hover {
text-decoration: underline;
}
.BreadcrumbSegment {
color: var(--Purple);
}
.BreadcrumbDivider {
margin: 0 6px;
color: var(--TextMuted);
}
.RootSwitcher {
margin-right: 16px;
background: var(--SurfaceAlt);
color: var(--Text);
border: 1px solid var(--Purple);
border-radius: 4px;
padding: 4px 8px;
font-family: inherit;
}
.MainBody {
flex: 1;
min-height: 0;
display: flex;
}
.PreviewEmpty {
margin: auto;
color: var(--TextMuted);
}
.DirectoryPane {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: auto;
}
.DirectoryList {
padding: 8px 0;
}
.DirectoryEntry {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
cursor: pointer;
}
.DirectoryEntry:hover {
background: var(--SurfaceAlt);
}
.DirectoryEmpty {
padding: 12px 16px;
color: var(--TextMuted);
}
.PreviewMarkdown,
.PreviewEditor,
.PreviewFrame,
.PreviewMedia {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: auto;
}
.PreviewToolbar {
display: flex;
gap: 8px;
padding: 8px 16px;
border-bottom: 1px solid var(--Border);
}
.PreviewToolbarSticky {
position: sticky;
top: 0;
z-index: 2;
background: var(--Background);
}
.PreviewToolbar button {
background: var(--SurfaceAlt);
color: var(--Text);
border: 1px solid var(--Purple);
border-radius: 4px;
padding: 4px 10px;
font-family: inherit;
cursor: pointer;
}
.PreviewToolbar button:hover {
background: var(--Border);
}
.PreviewToolbar button:disabled {
opacity: 0.6;
cursor: default;
}
.SaveErrorText {
color: var(--Red);
align-self: center;
font-size: 13px;
}
.MarkdownBody {
padding: 24px;
max-width: 720px;
line-height: 1.6;
}
.MarkdownBody h1,
.MarkdownBody h2,
.MarkdownBody h3 {
color: var(--Purple);
}
.MarkdownBody a {
color: var(--Blue);
}
.MarkdownBody code {
background: var(--SurfaceAlt);
padding: 2px 4px;
border-radius: 3px;
}
.MarkdownBody img {
max-width: 100%;
}
.MarkdownBody ul.contains-task-list {
list-style: none;
padding-left: 4px;
}
.MarkdownBody .task-list-item {
display: flex;
align-items: center;
gap: 6px;
}
.PreviewText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: auto;
}
.TextBody {
margin: 0;
padding: 24px;
max-width: 720px;
line-height: 1.6;
white-space: pre-wrap;
word-wrap: break-word;
font-family: inherit;
}
.BlameGutter {
background: var(--Surface);
border-right: 1px solid var(--Border);
}
.BlameGutterMarker {
display: inline-block;
padding: 0 6px;
font-size: 11px;
color: var(--TextMuted);
white-space: nowrap;
}
.PreviewMedia img,
.PreviewMedia video {
margin: auto;
max-width: 100%;
max-height: 100%;
}
.PreviewFrame iframe {
flex: 1;
border: none;
background: white;
}
/* Tag panel — bordered box style, mirroring the Statistics panel look */
.TagPanel {
width: 280px;
flex-shrink: 0;
margin: 16px;
height: fit-content;
background: var(--Surface);
border: 1px solid var(--Purple);
border-radius: 8px;
overflow: hidden;
}
.TagPanelHeader {
padding: 10px 16px;
color: var(--Purple);
font-size: 16px;
border-bottom: 1px solid var(--Purple);
}
.TagPanelBody {
padding: 4px 0;
}
.TagPanelEmpty {
padding: 12px 16px;
color: var(--TextMuted);
}
.TagRow {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 6px 16px;
border-bottom: 1px solid var(--SurfaceAlt);
}
.TagRow:last-child {
border-bottom: none;
}
.TagName {
color: var(--Text);
}
.TagValue {
color: var(--Orange);
}
/* Settings modal */
.SettingsOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.SettingsModal {
width: 420px;
max-height: 80vh;
overflow-y: auto;
background: var(--Surface);
border: 1px solid var(--Purple);
border-radius: 8px;
}
.SettingsHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px;
color: var(--Purple);
font-size: 18px;
border-bottom: 1px solid var(--Purple);
}
.SettingsClose {
background: none;
border: none;
color: var(--TextMuted);
font-size: 18px;
cursor: pointer;
}
.SettingsClose:hover {
color: var(--Text);
}
.SettingsBody {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.SettingsRow {
display: flex;
flex-direction: column;
gap: 6px;
}
.SettingsRowInline {
display: flex;
align-items: center;
justify-content: space-between;
}
.SettingsLabel {
color: var(--TextMuted);
font-size: 13px;
}
.SettingsRow input[type="text"],
.SettingsRow input[type="number"],
.SettingsRow select {
background: var(--SurfaceAlt);
color: var(--Text);
border: 1px solid var(--Border);
border-radius: 4px;
padding: 6px 8px;
font-family: inherit;
}
.SettingsRow input[type="checkbox"] {
accent-color: var(--Purple);
}
.Switch {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
flex-shrink: 0;
}
.Switch input {
opacity: 0;
width: 0;
height: 0;
}
.SwitchTrack {
position: absolute;
inset: 0;
background: var(--SurfaceAlt);
border: 1px solid var(--Border);
border-radius: 999px;
transition: background 0.15s ease, border-color 0.15s ease;
}
.SwitchThumb {
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--TextMuted);
transition: transform 0.15s ease, background 0.15s ease;
}
.Switch input:checked + .SwitchTrack {
background: var(--Purple);
border-color: var(--Purple);
}
.Switch input:checked + .SwitchTrack .SwitchThumb {
transform: translateX(16px);
background: var(--Text);
}
.SettingsBody button,
.SettingsFooter button {
background: var(--SurfaceAlt);
color: var(--Text);
border: 1px solid var(--Purple);
border-radius: 4px;
padding: 4px 10px;
font-family: inherit;
cursor: pointer;
}
.SettingsBody button:hover,
.SettingsFooter button:hover {
background: var(--Border);
}
.SettingsSection {
border-top: 1px solid var(--Border);
padding-top: 12px;
}
.SettingsSectionTitle {
color: var(--Purple);
margin-bottom: 8px;
}
.PaletteGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.PaletteSwatchRow {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.PaletteSwatchRow input[type="color"] {
width: 28px;
height: 22px;
padding: 0;
border: 1px solid var(--Border);
border-radius: 3px;
background: none;
}
.ColourSwatchPicker {
display: flex;
gap: 8px;
}
.ColourSwatch {
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
padding: 0;
}
.ColourSwatch:hover {
border-color: var(--Border);
}
.ColourSwatchSelected {
border-color: var(--Text);
}
.ColourSwatchSmall {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.AvatarUploadRow {
display: flex;
align-items: center;
gap: 10px;
}
.AvatarPreview {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--Border);
}
.SettingsFooter {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--Border);
}
.SettingsSaveError {
color: var(--Red);
font-size: 13px;
}
/* Login screen */
.LoginScreen {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--Background);
}
.LoginBox {
width: 320px;
display: flex;
flex-direction: column;
gap: 14px;
padding: 20px;
background: var(--Surface);
border: 1px solid var(--Purple);
border-radius: 8px;
}
.LoginTitle {
color: var(--Purple);
font-size: 20px;
}
.LoginSubtitle {
color: var(--TextMuted);
font-size: 13px;
margin-top: -8px;
}
.LoginBox button {
background: var(--SurfaceAlt);
color: var(--Text);
border: 1px solid var(--Purple);
border-radius: 4px;
padding: 8px;
font-family: inherit;
cursor: pointer;
}
.LoginBox button:hover {
background: var(--Border);
}
+16
View File
@@ -0,0 +1,16 @@
export const PaletteRoles = [
"Background",
"Surface",
"SurfaceAlt",
"Border",
"Text",
"TextMuted",
"Black",
"White",
"Red",
"Orange",
"Yellow",
"Green",
"Blue",
"Purple",
];