commit 8aa223bc7b8fc376694e51175c5e6ab4e788609a Author: SRHTNIN Date: Sat Sep 19 14:53:01 2026 +0200 Initial commit diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..a9ae934 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,69 @@ +name: Build and publish Docker image + +# Builds SaraView's image (Dockerfile at the repo root) and pushes it to +# GitHub Container Registry (ghcr.io) so it can be pulled directly on +# ZimaOS instead of building locally every time. +# +# Runs on every push to main (tagged "latest" + the short commit SHA) and +# on version tags like v1.0.0 (tagged with that version). Also runnable +# by hand from the Actions tab ("workflow_dispatch"). +# +# One manual, one-time step after this first runs: GHCR packages built via +# a workflow's built-in token default to PRIVATE, even on a public repo. +# Go to your GitHub profile -> Packages -> saraview -> Package settings -> +# Change visibility -> Public, or ZimaOS won't be able to pull it without +# a login. + +on: + push: + branches: ["main"] + tags: ["v*.*.*"] + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Compute lowercase image name + # GHCR requires an all-lowercase image path even though GitHub + # usernames/repos themselves are case-insensitive (e.g. SRHTNIN + # becomes srhtnin here). + run: echo "IMAGE=ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV" + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Compute tags + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=semver,pattern={{version}} + type=sha,prefix=,format=short + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b179d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Frontend build artifacts +Frontend/node_modules/ +Frontend/dist/ + +# Python +**/__pycache__/ +*.pyc + +# Editor/OS cruft +.DS_Store +.vscode/ +.idea/ diff --git a/Backend/Auth.py b/Backend/Auth.py new file mode 100644 index 0000000..c4fca17 --- /dev/null +++ b/Backend/Auth.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import secrets + +from typing import Any + +from fastapi import Depends, HTTPException, Request + +from Users import GetUser + + +# In-memory only for now: sessions don't survive a container restart, so +# everyone just logs in again. Fine for a handful of trusted tailnet users; +# worth revisiting if that stops being true. +ActiveSessions: dict[str, str] = {} + +SessionCookieName = "SaraViewSession" + + +def CreateSession(Username: str) -> str: + Token = secrets.token_urlsafe(32) + ActiveSessions[Token] = Username + return Token + + +def DeleteSession(Token: str) -> None: + ActiveSessions.pop(Token, None) + + +def GetCurrentUser(request: Request) -> dict[str, Any]: + Token = request.cookies.get(SessionCookieName) + Username = ActiveSessions.get(Token) if Token else None + + if Username is None: + raise HTTPException(status_code=401, detail="Not logged in.") + + User = GetUser(Username) + + if User is None: + raise HTTPException(status_code=401, detail="Session user no longer exists.") + + return { + "Username": Username, + "IsAdmin": User.get("IsAdmin", False), + "Colour": User.get("Colour", "Purple"), + } + + +def RequireAdmin(CurrentUser: dict = Depends(GetCurrentUser)) -> dict: + if not CurrentUser.get("IsAdmin"): + raise HTTPException(status_code=403, detail="Admin access required.") + + return CurrentUser diff --git a/Backend/Blame.py b/Backend/Blame.py new file mode 100644 index 0000000..475938b --- /dev/null +++ b/Backend/Blame.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import difflib +import tomllib + +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import tomli_w + +from Settings import GetDataDirectory + + +def GetBlameDirectory() -> Path: + return GetDataDirectory() / "Blame" + + +def GetBlamePath(RootName: str, RelativePath: str) -> Path: + # Mirrors the file's own relative path under the Blame/ folder, keyed by + # root so files with the same relative path in different roots don't + # collide. Validity of RelativePath itself is checked by the caller via + # Core.ResolvePath before this is ever used. + return GetBlameDirectory() / RootName / f"{RelativePath}.blame.toml" + + +def LoadBlame(RootName: str, RelativePath: str) -> dict[str, dict[str, Any]]: + BlamePath = GetBlamePath(RootName, RelativePath) + + if not BlamePath.is_file(): + return {} + + with BlamePath.open("rb") as BlameFile: + Loaded = tomllib.load(BlameFile) + + return Loaded.get("Lines", {}) + + +def SaveBlame(RootName: str, RelativePath: str, Blame: dict[str, dict[str, Any]]) -> None: + BlamePath = GetBlamePath(RootName, RelativePath) + BlamePath.parent.mkdir(parents=True, exist_ok=True) + + with BlamePath.open("wb") as BlameFile: + tomli_w.dump({"Lines": Blame}, BlameFile) + + +def UpdateBlameOnSave( + RootName: str, RelativePath: str, OldContent: str, NewContent: str, Username: str +) -> None: + """Recompute line attribution after a save. Unchanged lines (matched via + the same diff algorithm git's own blame uses) keep their prior author and + timestamp; changed or new lines are stamped with the current user & now. + """ + OldLines = OldContent.splitlines() + NewLines = NewContent.splitlines() + PreviousBlame = LoadBlame(RootName, RelativePath) + Timestamp = datetime.now(timezone.utc).isoformat() + + Matcher = difflib.SequenceMatcher(None, OldLines, NewLines, autojunk=False) + NewBlame: dict[str, dict[str, Any]] = {} + + for Tag, OldStart, OldEnd, NewStart, NewEnd in Matcher.get_opcodes(): + if Tag == "equal": + for Offset in range(OldEnd - OldStart): + OldLineNumber = str(OldStart + Offset + 1) + NewLineNumber = str(NewStart + Offset + 1) + NewBlame[NewLineNumber] = PreviousBlame.get( + OldLineNumber, {"Username": Username, "Timestamp": Timestamp} + ) + else: + for Offset in range(NewEnd - NewStart): + NewLineNumber = str(NewStart + Offset + 1) + NewBlame[NewLineNumber] = {"Username": Username, "Timestamp": Timestamp} + + SaveBlame(RootName, RelativePath, NewBlame) diff --git a/Backend/BookReader.py b/Backend/BookReader.py new file mode 100644 index 0000000..19a722a --- /dev/null +++ b/Backend/BookReader.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import importlib.util +import tomllib + +from pathlib import Path +from types import ModuleType +from typing import Any + +from Core import Config + + +CachedSaraTagCore: ModuleType | None = None +CachedSaraTagCoreLoaded = False + + +def GetSaraTagCore() -> ModuleType | None: + """Load SaraTag's Core.py directly by file path, under a private module + name, so it never collides with SaraView's own Core module in sys.modules. + Cached after the first successful load. + """ + global CachedSaraTagCore, CachedSaraTagCoreLoaded + + if CachedSaraTagCoreLoaded: + return CachedSaraTagCore + + CachedSaraTagCoreLoaded = True + + SaraTagConfig = Config.get("SaraTag", {}) + + if not SaraTagConfig.get("Enabled", False): + return None + + CoreModulePath = SaraTagConfig.get("CoreModulePath") + + if not CoreModulePath: + return None + + CoreFilePath = Path(CoreModulePath).expanduser().resolve() / "Core.py" + + if not CoreFilePath.is_file(): + return None + + ModuleSpec = importlib.util.spec_from_file_location("SaraTagCore", CoreFilePath) + + if ModuleSpec is None or ModuleSpec.loader is None: + return None + + SaraTagCoreModule = importlib.util.module_from_spec(ModuleSpec) + ModuleSpec.loader.exec_module(SaraTagCoreModule) + + CachedSaraTagCore = SaraTagCoreModule + return CachedSaraTagCore + + +def ReadBookForFile(FilePath: Path) -> dict[str, Any] | None: + """Return a file's resolved Book (tag values + Lexicon definitions), or + None if SaraTag integration is disabled or the file has no Book. + """ + SaraTagCore = GetSaraTagCore() + + if SaraTagCore is None: + return None + + BookPath = SaraTagCore.GetBookPath(FilePath) + + if not BookPath.is_file(): + return None + + with BookPath.open("rb") as BookFile: + Metadata = tomllib.load(BookFile) + + ParentValue = Metadata.get("Parent") + Definitions: dict[str, Any] = {} + + if isinstance(ParentValue, str): + try: + LexiconPath = SaraTagCore.ResolveBookLexiconPath(FilePath.parent, ParentValue) + Definitions = SaraTagCore.ResolveLexiconTags(LexiconPath) + except Exception: + Definitions = {} + + Tags = {Key: Value for Key, Value in Metadata.items() if Key != "Parent"} + + return {"Parent": ParentValue, "Tags": Tags, "Definitions": Definitions} diff --git a/Backend/Core.py b/Backend/Core.py new file mode 100644 index 0000000..22b4952 --- /dev/null +++ b/Backend/Core.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import fnmatch +import tomllib + +from pathlib import Path +from typing import Any + + +ApplicationDirectory = Path(__file__).resolve().parent +ConfigPath = ApplicationDirectory / "SaraViewConf.toml" + + +def LoadConfig() -> dict[str, Any]: + """Load SaraView's TOML configuration from disk.""" + if not ConfigPath.is_file(): + raise FileNotFoundError(f"Missing configuration file: {ConfigPath}") + + with ConfigPath.open("rb") as ConfigFile: + return tomllib.load(ConfigFile) + + +Config = LoadConfig() + + +def GetRoots() -> dict[str, Path]: + """Return the configured named roots as resolved, absolute paths.""" + RawRoots = Config.get("Roots", {}) + Roots: dict[str, Path] = {} + + for Name, RawPath in RawRoots.items(): + Roots[Name] = Path(RawPath).expanduser().resolve() + + return Roots + + +def ResolvePath(RootName: str, RelativePath: str) -> Path: + """Resolve a client-supplied relative path against a named root. + + Raises ValueError if the root is unknown or the resolved path escapes it. + """ + Roots = GetRoots() + Root = Roots.get(RootName) + + if Root is None: + raise ValueError(f"Unknown root: {RootName}") + + Candidate = (Root / RelativePath.lstrip("/")).resolve() + + if Candidate != Root and Root not in Candidate.parents: + raise ValueError("Path escapes the configured root.") + + return Candidate + + +def IsInternalPath(EntryPath: Path) -> bool: + """Return True if an entry should be hidden as a SaraTag internal file.""" + SaraTagConfig = Config.get("SaraTag", {}) + + if not SaraTagConfig.get("HideInternalFiles", True): + return False + + Patterns = SaraTagConfig.get( + "InternalPatterns", [".saratag", ".SaraTag*", "*.book.toml"] + ) + + return any(fnmatch.fnmatch(EntryPath.name, Pattern) for Pattern in Patterns) diff --git a/Backend/DefaultThemes/CatppuccinMocha.toml b/Backend/DefaultThemes/CatppuccinMocha.toml new file mode 100644 index 0000000..d64baae --- /dev/null +++ b/Backend/DefaultThemes/CatppuccinMocha.toml @@ -0,0 +1,15 @@ +[Palette] +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" diff --git a/Backend/DefaultThemes/Forest.toml b/Backend/DefaultThemes/Forest.toml new file mode 100644 index 0000000..a8a91ad --- /dev/null +++ b/Backend/DefaultThemes/Forest.toml @@ -0,0 +1,15 @@ +[Palette] +Background = "#1b2420" +Surface = "#141b17" +SurfaceAlt = "#243029" +Border = "#334539" +Text = "#d6e5d0" +TextMuted = "#7c9184" +Black = "#0d120f" +White = "#eef5ea" +Red = "#e0685a" +Orange = "#e0a458" +Yellow = "#e0c458" +Green = "#7fb069" +Blue = "#5c9e8f" +Purple = "#9b7fb0" diff --git a/Backend/DefaultThemes/Gruvbox.toml b/Backend/DefaultThemes/Gruvbox.toml new file mode 100644 index 0000000..616da36 --- /dev/null +++ b/Backend/DefaultThemes/Gruvbox.toml @@ -0,0 +1,15 @@ +[Palette] +Background = "#282828" +Surface = "#1d2021" +SurfaceAlt = "#3c3836" +Border = "#504945" +Text = "#ebdbb2" +TextMuted = "#a89984" +Black = "#1d2021" +White = "#fbf1c7" +Red = "#fb4934" +Orange = "#fe8019" +Yellow = "#fabd2f" +Green = "#b8bb26" +Blue = "#83a598" +Purple = "#d3869b" diff --git a/Backend/Editor.py b/Backend/Editor.py new file mode 100644 index 0000000..64376df --- /dev/null +++ b/Backend/Editor.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from pathlib import Path + + +def WriteTextFile(FilePath: Path, Content: str) -> None: + """Overwrite a text file's contents on disk.""" + FilePath.write_text(Content, encoding="utf-8") diff --git a/Backend/Explorer.py b/Backend/Explorer.py new file mode 100644 index 0000000..8c45b52 --- /dev/null +++ b/Backend/Explorer.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import shutil + +from pathlib import Path +from typing import Any + +from Core import GetRoots, IsInternalPath, ResolvePath + + +FileTypeMap = { + ".md": "markdown", + ".markdown": "markdown", + ".txt": "text", + ".toml": "config", + ".yaml": "config", + ".yml": "config", + ".json": "config", + ".cfg": "config", + ".ini": "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", +} + + +def ClassifyFile(EntryPath: Path) -> str: + """Return a coarse file-type label used to pick a preview renderer.""" + return FileTypeMap.get(EntryPath.suffix.lower(), "unknown") + + +def ListRoots() -> list[dict[str, Any]]: + """Return the configured roots as a flat list for the frontend.""" + return [{"Name": Name} for Name in GetRoots()] + + +def CreateEntry(RootName: str, RelativePath: str, IsDirectory: bool) -> None: + """Create a new empty file or directory at the given path.""" + EntryPath = ResolvePath(RootName, RelativePath) + + if EntryPath.exists(): + raise FileExistsError(f"{EntryPath.name} already exists.") + + if IsDirectory: + EntryPath.mkdir(parents=False) + else: + EntryPath.parent.mkdir(parents=True, exist_ok=True) + EntryPath.touch() + + +def MoveEntry(RootName: str, SourceRelativePath: str, DestinationRelativePath: str) -> None: + """Rename or move a file/directory within the same root. Used for both + plain renames (same parent, new name) and full moves. + """ + SourcePath = ResolvePath(RootName, SourceRelativePath) + DestinationPath = ResolvePath(RootName, DestinationRelativePath) + + if not SourcePath.exists(): + raise FileNotFoundError(f"{SourcePath.name} does not exist.") + + if DestinationPath.exists(): + raise FileExistsError(f"{DestinationPath.name} already exists.") + + DestinationPath.parent.mkdir(parents=True, exist_ok=True) + + try: + SourcePath.rename(DestinationPath) + except OSError: + # Cross-device or other rename failures — fall back to a copy+delete. + shutil.move(str(SourcePath), str(DestinationPath)) + + +def DeleteEntry(RootName: str, RelativePath: str) -> None: + """Delete a file, or an entire directory tree.""" + EntryPath = ResolvePath(RootName, RelativePath) + + if not EntryPath.exists(): + raise FileNotFoundError(f"{EntryPath.name} does not exist.") + + if EntryPath.is_dir(): + shutil.rmtree(EntryPath) + else: + EntryPath.unlink() + + +def ListDirectory(RootName: str, RelativePath: str, ShowHidden: bool = False) -> list[dict[str, Any]]: + """List the immediate contents of a directory. + + SaraTag's internal files are always hidden, regardless of ShowHidden. + Dotfiles/dotfolders are hidden unless ShowHidden is True. + """ + DirectoryPath = ResolvePath(RootName, RelativePath) + + if not DirectoryPath.is_dir(): + raise NotADirectoryError(f"{DirectoryPath} is not a directory.") + + Entries: list[dict[str, Any]] = [] + + SortedChildren = sorted( + DirectoryPath.iterdir(), + key=lambda EntryPath: (EntryPath.is_file(), EntryPath.name.casefold()), + ) + + for EntryPath in SortedChildren: + if IsInternalPath(EntryPath): + continue + + if not ShowHidden and EntryPath.name.startswith("."): + continue + + IsDirectory = EntryPath.is_dir() + Entries.append( + { + "Name": EntryPath.name, + "IsDirectory": IsDirectory, + "FileType": None if IsDirectory else ClassifyFile(EntryPath), + "Size": None if IsDirectory else EntryPath.stat().st_size, + } + ) + + return Entries diff --git a/Backend/Main.py b/Backend/Main.py new file mode 100644 index 0000000..f788fa4 --- /dev/null +++ b/Backend/Main.py @@ -0,0 +1,459 @@ +from __future__ import annotations + +import mimetypes +import sys +from pathlib import Path + +ApplicationDirectory = Path(__file__).resolve().parent +if str(ApplicationDirectory) not in sys.path: + sys.path.insert(0, str(ApplicationDirectory)) + +from fastapi import Depends, FastAPI, File, HTTPException, Request, Response, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import PlainTextResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles + +from Auth import CreateSession, DeleteSession, GetCurrentUser, RequireAdmin, SessionCookieName +from BookReader import ReadBookForFile +from Blame import LoadBlame, UpdateBlameOnSave +from Core import ResolvePath +from Editor import WriteTextFile +from Explorer import ClassifyFile, CreateEntry, DeleteEntry, ListDirectory, ListRoots, MoveEntry +from Preview import GetFileSize, GetMimeType, ParseRangeHeader, ReadTextFile, TextFileTypes +from Settings import ( + DeleteTheme, + ListThemeNames, + LoadSettings, + LoadTheme, + PaletteRoles, + SaveSettings, + SaveTheme, +) +from Users import ( + AnyUsersExist, + CreateUser, + GetAvatarPath, + ListUsers, + UpdateUserColour, + UserColourOptions, + VerifyLogin, + SaveAvatar, +) +from pydantic import BaseModel + + +Application = FastAPI(title="SaraView") + +Application.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + allow_credentials=True, +) + + +# --- Auth --------------------------------------------------------------- + + +class LoginRequest(BaseModel): + Username: str + Password: str + + +@Application.get("/api/auth/status") +def AuthStatusEndpoint() -> dict: + return {"SetupComplete": AnyUsersExist()} + + +@Application.post("/api/auth/bootstrap") +def BootstrapEndpoint(Body: LoginRequest) -> dict: + """Create the first (admin) account. Only usable while no users exist yet, + so there's no default/baked-in password sitting in the image. + """ + if AnyUsersExist(): + raise HTTPException(status_code=403, detail="Setup already completed.") + + try: + CreateUser(Body.Username, Body.Password, True) + except ValueError as Error: + raise HTTPException(status_code=400, detail=str(Error)) + + return {"Created": True} + + +@Application.post("/api/auth/login") +def LoginEndpoint(Body: LoginRequest, response: Response) -> dict: + User = VerifyLogin(Body.Username, Body.Password) + + if User is None: + raise HTTPException(status_code=401, detail="Invalid username or password.") + + Token = CreateSession(Body.Username) + response.set_cookie( + key=SessionCookieName, + value=Token, + httponly=True, + samesite="lax", + max_age=60 * 60 * 24 * 30, + ) + return User + + +@Application.post("/api/auth/logout") +def LogoutEndpoint(request: Request, response: Response) -> dict: + Token = request.cookies.get(SessionCookieName) + if Token: + DeleteSession(Token) + response.delete_cookie(SessionCookieName) + return {"LoggedOut": True} + + +@Application.get("/api/auth/me") +def MeEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> dict: + return CurrentUser + + +class CreateUserRequest(BaseModel): + Username: str + Password: str + IsAdmin: bool = False + Colour: str = "Purple" + + +@Application.get("/api/users") +def GetUsersEndpoint(CurrentUser: dict = Depends(RequireAdmin)) -> list[dict]: + return ListUsers() + + +@Application.post("/api/users") +def PostUsersEndpoint(Body: CreateUserRequest, CurrentUser: dict = Depends(RequireAdmin)) -> dict: + try: + CreateUser(Body.Username, Body.Password, Body.IsAdmin, Body.Colour) + except ValueError as Error: + raise HTTPException(status_code=400, detail=str(Error)) + + return {"Created": True} + + +# --- Profile (colour + avatar) -------------------------------------------- + + +@Application.get("/api/profile/colours") +def GetProfileColourOptionsEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[str]: + return UserColourOptions + + +class UpdateColourRequest(BaseModel): + Colour: str + + +@Application.put("/api/profile/colour") +def PutProfileColourEndpoint( + Body: UpdateColourRequest, CurrentUser: dict = Depends(GetCurrentUser) +) -> dict: + try: + UpdateUserColour(CurrentUser["Username"], Body.Colour) + except ValueError as Error: + raise HTTPException(status_code=400, detail=str(Error)) + + return {"Username": CurrentUser["Username"], "IsAdmin": CurrentUser["IsAdmin"], "Colour": Body.Colour} + + +@Application.post("/api/profile/avatar") +async def PostProfileAvatarEndpoint( + Avatar: UploadFile = File(...), CurrentUser: dict = Depends(GetCurrentUser) +) -> dict: + Extension = Path(Avatar.filename or "").suffix.lower() or ".png" + FileBytes = await Avatar.read() + + try: + SaveAvatar(CurrentUser["Username"], FileBytes, Extension) + except ValueError as Error: + raise HTTPException(status_code=400, detail=str(Error)) + + return {"Saved": True} + + +@Application.get("/api/profile/avatar") +def GetProfileAvatarEndpoint(Username: str, CurrentUser: dict = Depends(GetCurrentUser)): + AvatarPath = GetAvatarPath(Username) + + if AvatarPath is None: + raise HTTPException(status_code=404, detail="No avatar set.") + + MimeType = mimetypes.guess_type(str(AvatarPath))[0] or "application/octet-stream" + return StreamingResponse(iter([AvatarPath.read_bytes()]), media_type=MimeType) + + +# --- Files ---------------------------------------------------------------- + + +@Application.get("/api/roots") +def GetRootsEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[dict]: + return ListRoots() + + +@Application.get("/api/tree") +def GetTreeEndpoint( + Root: str, Path: str = "", CurrentUser: dict = Depends(GetCurrentUser) +) -> list[dict]: + try: + ShowHidden = LoadSettings(CurrentUser["Username"]).get("ShowHiddenFolders", False) + return ListDirectory(Root, Path, ShowHidden) + except (ValueError, NotADirectoryError, FileNotFoundError) as Error: + raise HTTPException(status_code=400, detail=str(Error)) + + +class CreateEntryRequest(BaseModel): + IsDirectory: bool + + +@Application.post("/api/tree") +def PostTreeEndpoint( + Root: str, Path: str, Body: CreateEntryRequest, CurrentUser: dict = Depends(GetCurrentUser) +) -> dict: + try: + CreateEntry(Root, Path, Body.IsDirectory) + except ValueError as Error: + raise HTTPException(status_code=400, detail=str(Error)) + except FileExistsError as Error: + raise HTTPException(status_code=409, detail=str(Error)) + except OSError as Error: + raise HTTPException(status_code=500, detail=f"Could not create entry: {Error}") + + return {"Created": True} + + +class MoveEntryRequest(BaseModel): + NewName: str | None = None + DestinationPath: str | None = None + + +@Application.patch("/api/tree") +def PatchTreeEndpoint( + Root: str, Path: str, Body: MoveEntryRequest, CurrentUser: dict = Depends(GetCurrentUser) +) -> dict: + if Body.DestinationPath: + Destination = Body.DestinationPath + elif Body.NewName: + ParentPath = Path.rsplit("/", 1)[0] if "/" in Path else "" + Destination = f"{ParentPath}/{Body.NewName}" if ParentPath else Body.NewName + else: + raise HTTPException(status_code=400, detail="Provide NewName or DestinationPath.") + + try: + MoveEntry(Root, Path, Destination) + except ValueError as Error: + raise HTTPException(status_code=400, detail=str(Error)) + except FileNotFoundError as Error: + raise HTTPException(status_code=404, detail=str(Error)) + except FileExistsError as Error: + raise HTTPException(status_code=409, detail=str(Error)) + except OSError as Error: + raise HTTPException(status_code=500, detail=f"Could not move entry: {Error}") + + return {"Moved": True, "NewPath": Destination} + + +@Application.delete("/api/tree") +def DeleteTreeEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict: + try: + DeleteEntry(Root, Path) + except ValueError as Error: + raise HTTPException(status_code=400, detail=str(Error)) + except FileNotFoundError as Error: + raise HTTPException(status_code=404, detail=str(Error)) + except OSError as Error: + raise HTTPException(status_code=500, detail=f"Could not delete entry: {Error}") + + return {"Deleted": True} + + +@Application.get("/api/file") +def GetFileEndpoint( + Root: str, Path: str, request: Request, CurrentUser: dict = Depends(GetCurrentUser) +): + try: + FilePath = ResolvePath(Root, Path) + except ValueError as Error: + raise HTTPException(status_code=400, detail=str(Error)) + + if not FilePath.is_file(): + raise HTTPException(status_code=404, detail="File not found.") + + FileType = ClassifyFile(FilePath) + + if FileType in TextFileTypes: + return PlainTextResponse(ReadTextFile(FilePath)) + + FileSize = GetFileSize(FilePath) + MimeType = GetMimeType(FilePath) + RangeHeader = request.headers.get("range") + + if RangeHeader: + Start, End = ParseRangeHeader(RangeHeader, FileSize) + ChunkLength = End - Start + 1 + + def IterateChunk(): + with FilePath.open("rb") as SourceFile: + SourceFile.seek(Start) + yield SourceFile.read(ChunkLength) + + Headers = { + "Content-Range": f"bytes {Start}-{End}/{FileSize}", + "Accept-Ranges": "bytes", + "Content-Length": str(ChunkLength), + } + return StreamingResponse( + IterateChunk(), status_code=206, media_type=MimeType, headers=Headers + ) + + def IterateFile(): + with FilePath.open("rb") as SourceFile: + while Chunk := SourceFile.read(1024 * 1024): + yield Chunk + + Headers = {"Accept-Ranges": "bytes", "Content-Length": str(FileSize)} + return StreamingResponse(IterateFile(), media_type=MimeType, headers=Headers) + + +@Application.put("/api/file") +async def PutFileEndpoint( + Root: str, Path: str, request: Request, CurrentUser: dict = Depends(GetCurrentUser) +): + try: + FilePath = ResolvePath(Root, Path) + except ValueError as Error: + raise HTTPException(status_code=400, detail=str(Error)) + + OldContent = "" + if FilePath.is_file(): + try: + OldContent = FilePath.read_text(encoding="utf-8") + except (UnicodeDecodeError, OSError): + OldContent = "" + + Content = (await request.body()).decode("utf-8") + + try: + WriteTextFile(FilePath, Content) + except OSError as Error: + raise HTTPException(status_code=500, detail=f"Could not write file: {Error}") + + UpdateBlameOnSave(Root, Path, OldContent, Content, CurrentUser["Username"]) + + return {"Saved": True} + + +@Application.get("/api/blame") +def GetBlameEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict: + try: + ResolvePath(Root, Path) + except ValueError as Error: + raise HTTPException(status_code=400, detail=str(Error)) + + return LoadBlame(Root, Path) + + +@Application.get("/api/raw/{RootName}/{FilePath:path}") +def GetRawFileEndpoint( + RootName: str, FilePath: str, CurrentUser: dict = Depends(GetCurrentUser) +): + """Serves a file at a URL that mirrors its real directory structure + (unlike /api/file's query-param form), so relative links, stylesheets, + and scripts INSIDE a previewed HTML document resolve correctly against + sibling files in the same directory. + """ + try: + ResolvedPath = ResolvePath(RootName, FilePath) + except ValueError as Error: + raise HTTPException(status_code=400, detail=str(Error)) + + if not ResolvedPath.is_file(): + raise HTTPException(status_code=404, detail="File not found.") + + MimeType = mimetypes.guess_type(str(ResolvedPath))[0] or "application/octet-stream" + + def IterateFile(): + with ResolvedPath.open("rb") as SourceFile: + while Chunk := SourceFile.read(1024 * 1024): + yield Chunk + + return StreamingResponse(IterateFile(), media_type=MimeType) + + +@Application.get("/api/book") +def GetBookEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict: + try: + FilePath = ResolvePath(Root, Path) + except ValueError as Error: + raise HTTPException(status_code=400, detail=str(Error)) + + Book = ReadBookForFile(FilePath) + return Book or {} + + +# --- Settings / themes ------------------------------------------------------ + + +@Application.get("/api/settings") +def GetSettingsEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> dict: + return LoadSettings(CurrentUser["Username"]) + + +@Application.put("/api/settings") +def PutSettingsEndpoint(NewSettings: dict, CurrentUser: dict = Depends(GetCurrentUser)) -> dict: + return SaveSettings(CurrentUser["Username"], NewSettings) + + +@Application.get("/api/themes") +def GetThemesEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[str]: + return ListThemeNames() + + +@Application.get("/api/theme") +def GetThemeEndpoint(Name: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict: + try: + return LoadTheme(Name) + except FileNotFoundError as Error: + raise HTTPException(status_code=404, detail=str(Error)) + except ValueError as Error: + raise HTTPException(status_code=400, detail=str(Error)) + + +class SaveThemeRequest(BaseModel): + Palette: dict[str, str] + + +@Application.put("/api/theme") +def PutThemeEndpoint( + Name: str, Body: SaveThemeRequest, CurrentUser: dict = Depends(GetCurrentUser) +) -> dict: + try: + SaveTheme(Name, Body.Palette) + except ValueError as Error: + raise HTTPException(status_code=400, detail=str(Error)) + + return {"Saved": True, "Roles": PaletteRoles} + + +@Application.delete("/api/theme") +def DeleteThemeEndpoint(Name: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict: + try: + DeleteTheme(Name) + except ValueError as Error: + raise HTTPException(status_code=400, detail=str(Error)) + except FileNotFoundError as Error: + raise HTTPException(status_code=404, detail=str(Error)) + + return {"Deleted": True} + + +# In production, the frontend's built assets are copied alongside this file +# under Frontend/dist (see Dockerfile) and served directly by the backend. +# This stays unauthenticated — the SPA itself needs to load before it can +# even show a login screen. +FrontendDist = ApplicationDirectory.parent / "Frontend" / "dist" +if FrontendDist.is_dir(): + Application.mount("/", StaticFiles(directory=str(FrontendDist), html=True), name="Frontend") diff --git a/Backend/Preview.py b/Backend/Preview.py new file mode 100644 index 0000000..35093b5 --- /dev/null +++ b/Backend/Preview.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from pathlib import Path + + +TextFileTypes = {"markdown", "text", "config", "html"} + +MimeTypeMap = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".mp4": "video/mp4", + ".webm": "video/webm", + ".mov": "video/quicktime", + ".mkv": "video/x-matroska", +} + + +def ReadTextFile(FilePath: Path) -> str: + """Read a text-classified file as UTF-8.""" + return FilePath.read_text(encoding="utf-8") + + +def GetFileSize(FilePath: Path) -> int: + return FilePath.stat().st_size + + +def GetMimeType(FilePath: Path) -> str: + return MimeTypeMap.get(FilePath.suffix.lower(), "application/octet-stream") + + +def ParseRangeHeader(RangeHeader: str, FileSize: int) -> tuple[int, int]: + """Parse a single-range HTTP Range header into an inclusive (Start, End).""" + RangeValue = RangeHeader.replace("bytes=", "").strip() + StartText, _, EndText = RangeValue.partition("-") + + Start = int(StartText) if StartText else 0 + End = int(EndText) if EndText else FileSize - 1 + End = min(End, FileSize - 1) + + return Start, End diff --git a/Backend/SaraViewConf.toml b/Backend/SaraViewConf.toml new file mode 100644 index 0000000..0d5cbdd --- /dev/null +++ b/Backend/SaraViewConf.toml @@ -0,0 +1,33 @@ +# SaraView configuration. +# Paths here are resolved as given — use absolute container paths when running +# via Docker (see docker-compose.yml), matching whatever you mount the volumes to. + +[SaraView] +ConfigVersion = 1 +ApplicationVersion = "0.1.0-beta" + +[Server] +Host = "0.0.0.0" +Port = 8420 + +# Where SaraView keeps its own data — Settings.toml and the Themes/ folder. +# This is NOT one of the [Roots] below, so it is structurally unreachable +# from the file browser regardless of any hidden-file setting. Give it its +# own persistent volume (see docker-compose.yml) so it survives rebuilds. +[Storage] +DataDirectory = "/data/SaraViewData" + +# Named roots the browser can navigate. Add as many as you like; each becomes +# a top-level entry point in the frontend's root switcher. +[Roots] +Notes = "/data/notes" + +# Integration with SaraTag: if Enabled, SaraView imports SaraTag's own Core.py +# directly (by file path, not by installing it as a package) to resolve Book +# tag files exactly the way SaraTag itself does. This is read-only — SaraView +# never writes Book or Lexicon files. +[SaraTag] +Enabled = true +CoreModulePath = "/data/SaraTag/.saratag" +HideInternalFiles = true +InternalPatterns = [".saratag", ".SaraTag*", "*.book.toml"] diff --git a/Backend/Settings.py b/Backend/Settings.py new file mode 100644 index 0000000..b67794a --- /dev/null +++ b/Backend/Settings.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +import re +import shutil +import tomllib + +from pathlib import Path +from typing import Any + +import tomli_w + +from Core import ApplicationDirectory, Config + + +PaletteRoles = [ + "Background", + "Surface", + "SurfaceAlt", + "Border", + "Text", + "TextMuted", + "Black", + "White", + "Red", + "Orange", + "Yellow", + "Green", + "Blue", + "Purple", +] + +DefaultSettings: dict[str, Any] = { + "FontFamily": "Fixedsys Excelsior", + "FontSize": 14, + "ThemeName": "CatppuccinMocha", + "ShowHiddenFolders": False, + "ShowBreadcrumbs": True, + "UseHyphenBullets": True, + "UseAsciiFolderIcons": True, + "NavigateIntoDirectories": False, + "ShowGitBlame": False, + "NewEntryButtonsAtTop": False, + "StickyToolbar": True, + "ConfirmBeforeDelete": True, +} + +DefaultThemesDirectory = ApplicationDirectory / "DefaultThemes" + + +def SanitizeThemeName(ThemeName: str) -> str: + """Reject theme names that could escape the Themes directory.""" + if not ThemeName or "/" in ThemeName or "\\" in ThemeName or ThemeName in (".", ".."): + raise ValueError(f"Invalid theme name: {ThemeName}") + + return ThemeName + + +def SanitizeUsernameForPath(Username: str) -> str: + """Usernames are validated at account-creation time, but this is a second + line of defense against a settings path ever escaping its directory. + """ + if not Username or not re.fullmatch(r"[A-Za-z0-9_.-]+", Username): + raise ValueError(f"Invalid username: {Username}") + + return Username + + +def GetDataDirectory() -> Path: + StorageConfig = Config.get("Storage", {}) + RawPath = StorageConfig.get("DataDirectory", str(ApplicationDirectory / "Data")) + return Path(RawPath).expanduser().resolve() + + +def GetSettingsDirectory() -> Path: + return GetDataDirectory() / "Settings" + + +def GetSettingsPath(Username: str) -> Path: + Username = SanitizeUsernameForPath(Username) + return GetSettingsDirectory() / f"{Username}.toml" + + +def GetThemesDirectory() -> Path: + return GetDataDirectory() / "Themes" + + +def EnsureDataDirectory() -> None: + """Create the data directory and seed default themes, if missing.""" + DataDirectory = GetDataDirectory() + ThemesDirectory = GetThemesDirectory() + DataDirectory.mkdir(parents=True, exist_ok=True) + GetSettingsDirectory().mkdir(parents=True, exist_ok=True) + ThemesDirectory.mkdir(parents=True, exist_ok=True) + + if DefaultThemesDirectory.is_dir(): + for ThemeFile in DefaultThemesDirectory.glob("*.toml"): + TargetPath = ThemesDirectory / ThemeFile.name + if not TargetPath.is_file(): + shutil.copy(ThemeFile, TargetPath) + + +def LoadSettings(Username: str) -> dict[str, Any]: + """Load one user's settings, seeding their file with the defaults on + first use. Settings are per-user; there is no shared/global settings + file anymore. + """ + EnsureDataDirectory() + SettingsPath = GetSettingsPath(Username) + + if not SettingsPath.is_file(): + with SettingsPath.open("wb") as SettingsFile: + tomli_w.dump(DefaultSettings, SettingsFile) + return dict(DefaultSettings) + + with SettingsPath.open("rb") as SettingsFile: + Loaded = tomllib.load(SettingsFile) + + return {**DefaultSettings, **Loaded} + + +def SaveSettings(Username: str, NewSettings: dict[str, Any]) -> dict[str, Any]: + EnsureDataDirectory() + SettingsPath = GetSettingsPath(Username) + Current: dict[str, Any] = {} + + if SettingsPath.is_file(): + with SettingsPath.open("rb") as SettingsFile: + Current = tomllib.load(SettingsFile) + + Merged = {**DefaultSettings, **Current, **NewSettings} + + with SettingsPath.open("wb") as SettingsFile: + tomli_w.dump(Merged, SettingsFile) + + return Merged + + +def ListThemeNames() -> list[str]: + EnsureDataDirectory() + return sorted(ThemeFile.stem for ThemeFile in GetThemesDirectory().glob("*.toml")) + + +def DeleteTheme(ThemeName: str) -> None: + EnsureDataDirectory() + ThemeName = SanitizeThemeName(ThemeName) + ThemePath = GetThemesDirectory() / f"{ThemeName}.toml" + + if not ThemePath.is_file(): + raise FileNotFoundError(f"Unknown theme: {ThemeName}") + + ThemePath.unlink() + + +def LoadTheme(ThemeName: str) -> dict[str, str]: + EnsureDataDirectory() + ThemeName = SanitizeThemeName(ThemeName) + ThemePath = GetThemesDirectory() / f"{ThemeName}.toml" + + if not ThemePath.is_file(): + raise FileNotFoundError(f"Unknown theme: {ThemeName}") + + with ThemePath.open("rb") as ThemeFile: + Loaded = tomllib.load(ThemeFile) + + return Loaded.get("Palette", {}) + + +def SaveTheme(ThemeName: str, Palette: dict[str, str]) -> None: + """Create or overwrite a custom theme. Missing roles fall back to the + active Catppuccin Mocha default so a partially-specified custom theme + still renders sensibly. + """ + EnsureDataDirectory() + ThemeName = SanitizeThemeName(ThemeName) + + MissingRoles = [Role for Role in PaletteRoles if Role not in Palette] + if MissingRoles: + FallbackPalette = LoadTheme("CatppuccinMocha") + for Role in MissingRoles: + Palette[Role] = FallbackPalette.get(Role, "#ff00ff") + + ThemePath = GetThemesDirectory() / f"{ThemeName}.toml" + + with ThemePath.open("wb") as ThemeFile: + tomli_w.dump({"Palette": Palette}, ThemeFile) diff --git a/Backend/Users.py b/Backend/Users.py new file mode 100644 index 0000000..231d61b --- /dev/null +++ b/Backend/Users.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import hashlib +import hmac +import secrets +import tomllib + +from pathlib import Path +from typing import Any + +import tomli_w + +from Settings import GetDataDirectory + + +Pbkdf2Iterations = 260_000 + +UserColourOptions = ["Red", "Orange", "Yellow", "Green", "Blue", "Purple"] +DefaultUserColour = "Purple" + +AllowedAvatarExtensions = {".png", ".jpg", ".jpeg", ".gif", ".webp"} + + +def GetUsersPath(): + return GetDataDirectory() / "Users.toml" + + +def GetAvatarDirectory() -> Path: + return GetDataDirectory() / "Avatars" + + +def HashPassword(Password: str) -> str: + Salt = secrets.token_hex(16) + DerivedKey = hashlib.pbkdf2_hmac( + "sha256", Password.encode("utf-8"), bytes.fromhex(Salt), Pbkdf2Iterations + ) + return f"pbkdf2_sha256${Pbkdf2Iterations}${Salt}${DerivedKey.hex()}" + + +def VerifyPassword(Password: str, StoredHash: str) -> bool: + try: + Algorithm, IterationsText, Salt, HashHex = StoredHash.split("$") + except ValueError: + return False + + if Algorithm != "pbkdf2_sha256": + return False + + DerivedKey = hashlib.pbkdf2_hmac( + "sha256", Password.encode("utf-8"), bytes.fromhex(Salt), int(IterationsText) + ) + return hmac.compare_digest(DerivedKey.hex(), HashHex) + + +def LoadUsers() -> dict[str, dict[str, Any]]: + UsersPath = GetUsersPath() + + if not UsersPath.is_file(): + return {} + + with UsersPath.open("rb") as UsersFile: + Loaded = tomllib.load(UsersFile) + + return Loaded.get("Users", {}) + + +def SaveUsers(Users: dict[str, dict[str, Any]]) -> None: + GetDataDirectory().mkdir(parents=True, exist_ok=True) + + with GetUsersPath().open("wb") as UsersFile: + tomli_w.dump({"Users": Users}, UsersFile) + + +def AnyUsersExist() -> bool: + return len(LoadUsers()) > 0 + + +def GetUser(Username: str) -> dict[str, Any] | None: + return LoadUsers().get(Username) + + +def CreateUser(Username: str, Password: str, IsAdmin: bool, Colour: str = DefaultUserColour) -> None: + if not Username or not Username.strip(): + raise ValueError("Username cannot be empty.") + + if not Password: + raise ValueError("Password cannot be empty.") + + if Colour not in UserColourOptions: + Colour = DefaultUserColour + + Users = LoadUsers() + + if Username in Users: + raise ValueError(f"User '{Username}' already exists.") + + Users[Username] = { + "PasswordHash": HashPassword(Password), + "IsAdmin": IsAdmin, + "Colour": Colour, + } + SaveUsers(Users) + + +def UpdateUserColour(Username: str, Colour: str) -> None: + if Colour not in UserColourOptions: + raise ValueError(f"Invalid colour: {Colour}") + + Users = LoadUsers() + + if Username not in Users: + raise ValueError("User not found.") + + Users[Username]["Colour"] = Colour + SaveUsers(Users) + + +def VerifyLogin(Username: str, Password: str) -> dict[str, Any] | None: + User = GetUser(Username) + + if User is None: + return None + + if not VerifyPassword(Password, User["PasswordHash"]): + return None + + return { + "Username": Username, + "IsAdmin": User.get("IsAdmin", False), + "Colour": User.get("Colour", DefaultUserColour), + } + + +def ListUsers() -> list[dict[str, Any]]: + Users = LoadUsers() + return [ + { + "Username": Name, + "IsAdmin": Data.get("IsAdmin", False), + "Colour": Data.get("Colour", DefaultUserColour), + } + for Name, Data in Users.items() + ] + + +def SaveAvatar(Username: str, FileBytes: bytes, Extension: str) -> None: + Extension = Extension.lower() + + if Extension not in AllowedAvatarExtensions: + raise ValueError(f"Unsupported image type: {Extension}") + + Directory = GetAvatarDirectory() + Directory.mkdir(parents=True, exist_ok=True) + + # Remove any previous avatar for this user under a different extension. + for ExistingPath in Directory.glob(f"{Username}.*"): + ExistingPath.unlink() + + AvatarPath = Directory / f"{Username}{Extension}" + AvatarPath.write_bytes(FileBytes) + + +def GetAvatarPath(Username: str) -> Path | None: + Directory = GetAvatarDirectory() + + if not Directory.is_dir(): + return None + + Matches = sorted(Directory.glob(f"{Username}.*")) + return Matches[0] if Matches else None diff --git a/Backend/requirements.txt b/Backend/requirements.txt new file mode 100644 index 0000000..bb4e3f0 --- /dev/null +++ b/Backend/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +tomli-w==1.0.0 +python-multipart==0.0.12 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d88f459 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM node:20-alpine AS FrontendBuild +WORKDIR /Frontend +COPY Frontend/package.json ./ +RUN npm install +COPY Frontend/ ./ +RUN npm run build + +FROM python:3.12-slim +WORKDIR /App +COPY Backend/requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt +COPY Backend/ ./Backend/ +COPY --from=FrontendBuild /Frontend/dist ./Frontend/dist + +WORKDIR /App/Backend +EXPOSE 8420 +CMD ["uvicorn", "Main:Application", "--host", "0.0.0.0", "--port", "8420"] diff --git a/Frontend/index.html b/Frontend/index.html new file mode 100644 index 0000000..bf8b8d0 --- /dev/null +++ b/Frontend/index.html @@ -0,0 +1,12 @@ + + + + + + SaraView + + +
+ + + diff --git a/Frontend/package.json b/Frontend/package.json new file mode 100644 index 0000000..234aac1 --- /dev/null +++ b/Frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "saraview-frontend", + "private": true, + "version": "0.1.0-beta", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "@uiw/react-codemirror": "^4.23.0", + "@codemirror/view": "^6.34.1", + "@codemirror/lang-markdown": "^6.2.5", + "@codemirror/lang-html": "^6.4.9", + "@codemirror/lang-json": "^6.0.1", + "markdown-it": "^14.1.0", + "markdown-it-task-lists": "^2.1.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.8" + } +} diff --git a/Frontend/public/Fonts/FSEX302.ttf b/Frontend/public/Fonts/FSEX302.ttf new file mode 100644 index 0000000..a3dbddd Binary files /dev/null and b/Frontend/public/Fonts/FSEX302.ttf differ diff --git a/Frontend/src/Api.js b/Frontend/src/Api.js new file mode 100644 index 0000000..3ceb3c8 --- /dev/null +++ b/Frontend/src/Api.js @@ -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" }); +} diff --git a/Frontend/src/App.jsx b/Frontend/src/App.jsx new file mode 100644 index 0000000..e8c5a1e --- /dev/null +++ b/Frontend/src/App.jsx @@ -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 ; + } + + if (AuthState === "LoggedOut") { + return ; + } + + // 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 ( +
+ 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} + /> +
+ {ShowTopBar && ( +
+ {Settings && Settings.ShowBreadcrumbs ? ( + + ) : ( + + )} + {Roots.length > 1 && ( + + )} +
+ )} +
+ {ViewIsDirectory ? ( + + ) : ( + + )} + {!ViewIsDirectory && } +
+
+ {IsSettingsOpen && ( + SetIsSettingsOpen(false)} + OnSettingsChanged={HandleSettingsChanged} + OnLoggedOut={HandleLoggedOut} + OnProfileUpdated={HandleProfileUpdated} + /> + )} +
+ ); +} diff --git a/Frontend/src/Components/Breadcrumb.jsx b/Frontend/src/Components/Breadcrumb.jsx new file mode 100644 index 0000000..6471bd1 --- /dev/null +++ b/Frontend/src/Components/Breadcrumb.jsx @@ -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 ( +
+ OnNavigate("")}> + {RootName} + + {Segments.map((Segment, Index) => { + const IsFileSegment = !IsDirectory && Index === Segments.length - 1; + + return ( + + / + {IsFileSegment ? ( + {Segment} + ) : ( + HandleSegmentClick(Index)} + > + {Segment} + + )} + + ); + })} +
+ ); +} diff --git a/Frontend/src/Components/DirectoryPane.jsx b/Frontend/src/Components/DirectoryPane.jsx new file mode 100644 index 0000000..3b2779c --- /dev/null +++ b/Frontend/src/Components/DirectoryPane.jsx @@ -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 ( +
+
+ + +
+ {LoadError &&
{LoadError}
} +
+ {Entries.map((Entry) => ( +
OnEntrySelect(JoinPath(Entry.Name), Entry.IsDirectory, Entry.FileType)} + > + + {Entry.IsDirectory + ? GetFolderIcon(false, UseAsciiFolderIcons) + : GetFileIcon(UseHyphenBullets)} + + {Entry.Name} + + + + +
+ ))} + {Entries.length === 0 && !LoadError && ( +
Empty directory.
+ )} +
+
+ ); +} diff --git a/Frontend/src/Components/LoginScreen.jsx b/Frontend/src/Components/LoginScreen.jsx new file mode 100644 index 0000000..57197fe --- /dev/null +++ b/Frontend/src/Components/LoginScreen.jsx @@ -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 ( +
+
+
+ {IsBootstrap ? "Create the admin account" : "SaraView"} +
+ {IsBootstrap && ( +
+ No accounts exist yet — this first one becomes an admin. +
+ )} +
+ + SetUsername(Event.target.value)} + autoFocus + /> +
+
+ + SetPassword(Event.target.value)} + /> +
+ {IsBootstrap && ( +
+ + SetConfirmPassword(Event.target.value)} + /> +
+ )} + {Error &&
{Error}
} + +
+
+ ); +} diff --git a/Frontend/src/Components/PreviewPane.jsx b/Frontend/src/Components/PreviewPane.jsx new file mode 100644 index 0000000..9f8140e --- /dev/null +++ b/Frontend/src/Components/PreviewPane.jsx @@ -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
Select a file to preview.
; + } + + 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 ( +
+ {FilePath} +
+ ); + } + + if (FileType === "video") { + return ( +
+
+ ); + } + + if (FileType === "html" && !IsEditing) { + return ( +
+
+ +
+ {/* Raw, path-shaped URL (not the query-param /api/file form) so + relative /