mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-13 18:28:14 +02:00
Initial commit
This commit is contained in:
commit
8aa223bc7b
40 files changed
+4320
No files matched your search
@@ -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
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
# Frontend build artifacts
|
||||
Frontend/node_modules/
|
||||
Frontend/dist/
|
||||
|
||||
# Python
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Editor/OS cruft
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
@@ -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
|
||||
+459
@@ -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")
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
tomli-w==1.0.0
|
||||
python-multipart==0.0.12
|
||||
+17
@@ -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"]
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SaraView</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="Root"></div>
|
||||
<script type="module" src="/src/Main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -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" });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
|
||||
export default function Breadcrumb({ RootName, FilePath, IsDirectory, OnNavigate }) {
|
||||
const Segments = FilePath ? FilePath.split("/") : [];
|
||||
|
||||
function HandleSegmentClick(Index) {
|
||||
OnNavigate(Segments.slice(0, Index + 1).join("/"));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="Breadcrumb">
|
||||
<span className="BreadcrumbSegment BreadcrumbLink" onClick={() => OnNavigate("")}>
|
||||
{RootName}
|
||||
</span>
|
||||
{Segments.map((Segment, Index) => {
|
||||
const IsFileSegment = !IsDirectory && Index === Segments.length - 1;
|
||||
|
||||
return (
|
||||
<React.Fragment key={Index}>
|
||||
<span className="BreadcrumbDivider">/</span>
|
||||
{IsFileSegment ? (
|
||||
<span className="BreadcrumbSegment">{Segment}</span>
|
||||
) : (
|
||||
<span
|
||||
className="BreadcrumbSegment BreadcrumbLink"
|
||||
onClick={() => HandleSegmentClick(Index)}
|
||||
>
|
||||
{Segment}
|
||||
</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { CreateEntry, DeleteEntry, FetchTree, MoveEntry, RenameEntry } from "../Api.js";
|
||||
import { GetFileIcon, GetFolderIcon } from "../IconStyle.js";
|
||||
|
||||
export default function DirectoryPane({
|
||||
RootName,
|
||||
DirectoryPath,
|
||||
OnEntrySelect,
|
||||
OnEntryCreated,
|
||||
OnEntryRenamed,
|
||||
OnEntryDeleted,
|
||||
UseHyphenBullets,
|
||||
UseAsciiFolderIcons,
|
||||
ConfirmBeforeDelete,
|
||||
}) {
|
||||
const [Entries, SetEntries] = useState([]);
|
||||
const [LoadError, SetLoadError] = useState(null);
|
||||
|
||||
async function Reload() {
|
||||
try {
|
||||
const Fetched = await FetchTree(RootName, DirectoryPath);
|
||||
SetEntries(Fetched);
|
||||
SetLoadError(null);
|
||||
} catch (Error) {
|
||||
SetLoadError(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (RootName !== null && RootName !== undefined) {
|
||||
Reload();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [RootName, DirectoryPath]);
|
||||
|
||||
function JoinPath(Name) {
|
||||
return DirectoryPath ? `${DirectoryPath}/${Name}` : Name;
|
||||
}
|
||||
|
||||
async function HandleCreateFile() {
|
||||
const Name = window.prompt("New file name (e.g. Notes.md):");
|
||||
if (!Name) return;
|
||||
|
||||
try {
|
||||
await CreateEntry(RootName, JoinPath(Name), false);
|
||||
await Reload();
|
||||
OnEntryCreated();
|
||||
} catch (Error) {
|
||||
SetLoadError(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function HandleCreateDirectory() {
|
||||
const Name = window.prompt("New directory name:");
|
||||
if (!Name) return;
|
||||
|
||||
try {
|
||||
await CreateEntry(RootName, JoinPath(Name), true);
|
||||
await Reload();
|
||||
OnEntryCreated();
|
||||
} catch (Error) {
|
||||
SetLoadError(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function HandleRename(Event, Entry) {
|
||||
Event.stopPropagation();
|
||||
const FullPath = JoinPath(Entry.Name);
|
||||
const NewValue = window.prompt("Rename to (or type a path to move):", Entry.Name);
|
||||
if (!NewValue || NewValue === Entry.Name) return;
|
||||
|
||||
try {
|
||||
if (NewValue.includes("/")) {
|
||||
await MoveEntry(RootName, FullPath, NewValue);
|
||||
OnEntryRenamed(FullPath, NewValue, Entry.IsDirectory);
|
||||
} else {
|
||||
await RenameEntry(RootName, FullPath, NewValue);
|
||||
OnEntryRenamed(FullPath, JoinPath(NewValue), Entry.IsDirectory);
|
||||
}
|
||||
await Reload();
|
||||
} catch (Error) {
|
||||
window.alert(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function HandleDelete(Event, Entry) {
|
||||
Event.stopPropagation();
|
||||
const FullPath = JoinPath(Entry.Name);
|
||||
|
||||
if (ConfirmBeforeDelete && !window.confirm(`Delete "${Entry.Name}"? This can't be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await DeleteEntry(RootName, FullPath);
|
||||
OnEntryDeleted(FullPath, Entry.IsDirectory);
|
||||
await Reload();
|
||||
} catch (Error) {
|
||||
window.alert(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="DirectoryPane">
|
||||
<div className="PreviewToolbar">
|
||||
<button onClick={HandleCreateFile}>New File</button>
|
||||
<button onClick={HandleCreateDirectory}>New Directory</button>
|
||||
</div>
|
||||
{LoadError && <div className="SidebarError">{LoadError}</div>}
|
||||
<div className="DirectoryList">
|
||||
{Entries.map((Entry) => (
|
||||
<div
|
||||
className="DirectoryEntry"
|
||||
key={Entry.Name}
|
||||
onClick={() => OnEntrySelect(JoinPath(Entry.Name), Entry.IsDirectory, Entry.FileType)}
|
||||
>
|
||||
<span className="TreeIcon">
|
||||
{Entry.IsDirectory
|
||||
? GetFolderIcon(false, UseAsciiFolderIcons)
|
||||
: GetFileIcon(UseHyphenBullets)}
|
||||
</span>
|
||||
<span className="TreeLabel">{Entry.Name}</span>
|
||||
<span className="TreeRowActions">
|
||||
<button
|
||||
className="TreeRowActionButton"
|
||||
onClick={(Event) => HandleRename(Event, Entry)}
|
||||
title="Rename / move"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
className="TreeRowActionButton"
|
||||
onClick={(Event) => HandleDelete(Event, Entry)}
|
||||
title="Delete"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{Entries.length === 0 && !LoadError && (
|
||||
<div className="DirectoryEmpty">Empty directory.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { Bootstrap, Login } from "../Api.js";
|
||||
|
||||
export default function LoginScreen({ IsBootstrap, OnAuthenticated }) {
|
||||
const [Username, SetUsername] = useState("");
|
||||
const [Password, SetPassword] = useState("");
|
||||
const [ConfirmPassword, SetConfirmPassword] = useState("");
|
||||
const [Error, SetError] = useState(null);
|
||||
const [IsSubmitting, SetIsSubmitting] = useState(false);
|
||||
|
||||
async function HandleSubmit(Event) {
|
||||
Event.preventDefault();
|
||||
SetError(null);
|
||||
|
||||
if (IsBootstrap && Password !== ConfirmPassword) {
|
||||
SetError("Passwords don't match.");
|
||||
return;
|
||||
}
|
||||
|
||||
SetIsSubmitting(true);
|
||||
try {
|
||||
if (IsBootstrap) {
|
||||
await Bootstrap(Username, Password);
|
||||
}
|
||||
const User = await Login(Username, Password);
|
||||
OnAuthenticated(User);
|
||||
} catch (SubmitError) {
|
||||
SetError(SubmitError.message);
|
||||
} finally {
|
||||
SetIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="LoginScreen">
|
||||
<form className="LoginBox" onSubmit={HandleSubmit}>
|
||||
<div className="LoginTitle">
|
||||
{IsBootstrap ? "Create the admin account" : "SaraView"}
|
||||
</div>
|
||||
{IsBootstrap && (
|
||||
<div className="LoginSubtitle">
|
||||
No accounts exist yet — this first one becomes an admin.
|
||||
</div>
|
||||
)}
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={Username}
|
||||
onChange={(Event) => SetUsername(Event.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={Password}
|
||||
onChange={(Event) => SetPassword(Event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{IsBootstrap && (
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">Confirm password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={ConfirmPassword}
|
||||
onChange={(Event) => SetConfirmPassword(Event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{Error && <div className="SettingsSaveError">{Error}</div>}
|
||||
<button type="submit" disabled={IsSubmitting}>
|
||||
{IsSubmitting ? "..." : IsBootstrap ? "Create account & log in" : "Log in"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { gutter, GutterMarker } from "@codemirror/view";
|
||||
import { markdown } from "@codemirror/lang-markdown";
|
||||
import { html } from "@codemirror/lang-html";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import MarkdownItTaskLists from "markdown-it-task-lists";
|
||||
|
||||
import { FetchBlame, FetchFile, GetFileUrl, GetRawFileUrl, SaveFile } from "../Api.js";
|
||||
|
||||
const MarkdownRenderer = new MarkdownIt({ breaks: true }).use(MarkdownItTaskLists, {
|
||||
enabled: true,
|
||||
label: true,
|
||||
});
|
||||
|
||||
const DefaultImageRenderer =
|
||||
MarkdownRenderer.renderer.rules.image ||
|
||||
((Tokens, Index, Options, Env, Self) => Self.renderToken(Tokens, Index, Options));
|
||||
|
||||
MarkdownRenderer.renderer.rules.image = (Tokens, Index, Options, Env, Self) => {
|
||||
const Token = Tokens[Index];
|
||||
const SrcAttrIndex = Token.attrIndex("src");
|
||||
|
||||
if (SrcAttrIndex >= 0 && Env && Env.ResolveImageSrc) {
|
||||
Token.attrs[SrcAttrIndex][1] = Env.ResolveImageSrc(Token.attrs[SrcAttrIndex][1]);
|
||||
}
|
||||
|
||||
return DefaultImageRenderer(Tokens, Index, Options, Env, Self);
|
||||
};
|
||||
|
||||
const TextTypes = ["markdown", "text", "config", "html"];
|
||||
|
||||
const LinkedFileTypeMap = {
|
||||
".md": "markdown",
|
||||
".markdown": "markdown",
|
||||
".txt": "text",
|
||||
".toml": "config",
|
||||
".yaml": "config",
|
||||
".yml": "config",
|
||||
".json": "config",
|
||||
".html": "html",
|
||||
".htm": "html",
|
||||
".png": "image",
|
||||
".jpg": "image",
|
||||
".jpeg": "image",
|
||||
".gif": "image",
|
||||
".webp": "image",
|
||||
".svg": "image",
|
||||
".mp4": "video",
|
||||
".webm": "video",
|
||||
".mov": "video",
|
||||
".mkv": "video",
|
||||
};
|
||||
|
||||
function ClassifyLinkedFile(LinkedPath) {
|
||||
const DotIndex = LinkedPath.lastIndexOf(".");
|
||||
const Extension = DotIndex === -1 ? "" : LinkedPath.slice(DotIndex).toLowerCase();
|
||||
return LinkedFileTypeMap[Extension] || "unknown";
|
||||
}
|
||||
|
||||
function ResolveRelativeLink(CurrentFilePath, Href) {
|
||||
if (/^([a-z][a-z0-9+.-]*:)?\/\//i.test(Href) || Href.startsWith("#") || Href.startsWith("mailto:")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const CurrentDirectory = CurrentFilePath.includes("/")
|
||||
? CurrentFilePath.slice(0, CurrentFilePath.lastIndexOf("/"))
|
||||
: "";
|
||||
const Combined = CurrentDirectory ? `${CurrentDirectory}/${Href}` : Href;
|
||||
const ResolvedParts = [];
|
||||
|
||||
for (const Part of Combined.split("/")) {
|
||||
if (Part === "" || Part === ".") continue;
|
||||
if (Part === "..") {
|
||||
ResolvedParts.pop();
|
||||
} else {
|
||||
ResolvedParts.push(Part);
|
||||
}
|
||||
}
|
||||
|
||||
return ResolvedParts.join("/");
|
||||
}
|
||||
|
||||
function GetLanguageExtension(FileType) {
|
||||
if (FileType === "markdown") return [markdown()];
|
||||
if (FileType === "html") return [html()];
|
||||
if (FileType === "config") return [json()];
|
||||
return [];
|
||||
}
|
||||
|
||||
class BlameMarker extends GutterMarker {
|
||||
constructor(ShortLabel, Title) {
|
||||
super();
|
||||
this.ShortLabel = ShortLabel;
|
||||
this.Title = Title;
|
||||
}
|
||||
|
||||
eq(Other) {
|
||||
return this.ShortLabel === Other.ShortLabel && this.Title === Other.Title;
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const Element = document.createElement("span");
|
||||
Element.className = "BlameGutterMarker";
|
||||
Element.textContent = this.ShortLabel;
|
||||
Element.title = this.Title;
|
||||
return Element;
|
||||
}
|
||||
}
|
||||
|
||||
function BuildBlameGutter(BlameMap) {
|
||||
return gutter({
|
||||
class: "BlameGutter",
|
||||
lineMarker(view, line) {
|
||||
const LineNumber = view.state.doc.lineAt(line.from).number;
|
||||
const Entry = BlameMap[String(LineNumber)];
|
||||
if (!Entry) return null;
|
||||
|
||||
const ShortLabel = Entry.Username.slice(0, 6);
|
||||
const Title = `${Entry.Username} — ${new Date(Entry.Timestamp).toLocaleString()}`;
|
||||
return new BlameMarker(ShortLabel, Title);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default function PreviewPane({
|
||||
RootName,
|
||||
FilePath,
|
||||
FileType,
|
||||
OnNavigateLink,
|
||||
ShowGitBlame,
|
||||
StickyToolbar,
|
||||
}) {
|
||||
const [Content, SetContent] = useState("");
|
||||
const [IsEditing, SetIsEditing] = useState(false);
|
||||
const [IsSaving, SetIsSaving] = useState(false);
|
||||
const [SaveError, SetSaveError] = useState(null);
|
||||
const [BlameMap, SetBlameMap] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
SetIsEditing(false);
|
||||
SetSaveError(null);
|
||||
SetBlameMap({});
|
||||
|
||||
if (FilePath && TextTypes.includes(FileType)) {
|
||||
FetchFile(RootName, FilePath).then(SetContent);
|
||||
|
||||
if (ShowGitBlame) {
|
||||
FetchBlame(RootName, FilePath)
|
||||
.then(SetBlameMap)
|
||||
.catch(() => SetBlameMap({}));
|
||||
}
|
||||
}
|
||||
}, [RootName, FilePath, FileType, ShowGitBlame]);
|
||||
|
||||
if (!FilePath) {
|
||||
return <div className="PreviewEmpty">Select a file to preview.</div>;
|
||||
}
|
||||
|
||||
const ToolbarClass = `PreviewToolbar${StickyToolbar ? " PreviewToolbarSticky" : ""}`;
|
||||
|
||||
async function HandleSave() {
|
||||
SetIsSaving(true);
|
||||
SetSaveError(null);
|
||||
try {
|
||||
await SaveFile(RootName, FilePath, Content);
|
||||
if (ShowGitBlame) {
|
||||
FetchBlame(RootName, FilePath)
|
||||
.then(SetBlameMap)
|
||||
.catch(() => {});
|
||||
}
|
||||
} catch (Error) {
|
||||
SetSaveError(Error.message);
|
||||
} finally {
|
||||
SetIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function HandleMarkdownClick(Event) {
|
||||
const AnchorElement = Event.target.closest("a");
|
||||
if (!AnchorElement) return;
|
||||
|
||||
const Href = AnchorElement.getAttribute("href");
|
||||
if (!Href) return;
|
||||
|
||||
const ResolvedPath = ResolveRelativeLink(FilePath, Href);
|
||||
if (ResolvedPath === null) return;
|
||||
|
||||
Event.preventDefault();
|
||||
OnNavigateLink(ResolvedPath, ClassifyLinkedFile(ResolvedPath));
|
||||
}
|
||||
|
||||
if (FileType === "image") {
|
||||
return (
|
||||
<div className="PreviewMedia">
|
||||
<img src={GetFileUrl(RootName, FilePath)} alt={FilePath} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (FileType === "video") {
|
||||
return (
|
||||
<div className="PreviewMedia">
|
||||
<video controls src={GetFileUrl(RootName, FilePath)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (FileType === "html" && !IsEditing) {
|
||||
return (
|
||||
<div className="PreviewFrame">
|
||||
<div className={ToolbarClass}>
|
||||
<button onClick={() => SetIsEditing(true)}>Edit</button>
|
||||
</div>
|
||||
{/* Raw, path-shaped URL (not the query-param /api/file form) so
|
||||
relative <link>/<script>/<img> references inside the HTML
|
||||
resolve against their real sibling files. */}
|
||||
<iframe title={FilePath} sandbox="" src={GetRawFileUrl(RootName, FilePath)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (FileType === "markdown" && !IsEditing) {
|
||||
return (
|
||||
<div className="PreviewMarkdown">
|
||||
<div className={ToolbarClass}>
|
||||
<button onClick={() => SetIsEditing(true)}>Edit</button>
|
||||
</div>
|
||||
<div
|
||||
className="MarkdownBody"
|
||||
onClick={HandleMarkdownClick}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: MarkdownRenderer.render(Content, {
|
||||
ResolveImageSrc: (Src) => {
|
||||
const ResolvedPath = ResolveRelativeLink(FilePath, Src);
|
||||
return ResolvedPath === null ? Src : GetFileUrl(RootName, ResolvedPath);
|
||||
},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (FileType === "text" && !IsEditing) {
|
||||
return (
|
||||
<div className="PreviewText">
|
||||
<div className={ToolbarClass}>
|
||||
<button onClick={() => SetIsEditing(true)}>Edit</button>
|
||||
</div>
|
||||
<pre className="TextBody">{Content}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (TextTypes.includes(FileType)) {
|
||||
return (
|
||||
<div className="PreviewEditor">
|
||||
<div className={ToolbarClass}>
|
||||
{(FileType === "markdown" || FileType === "text" || FileType === "html") && (
|
||||
<button onClick={() => SetIsEditing(false)}>View</button>
|
||||
)}
|
||||
<button onClick={HandleSave} disabled={IsSaving}>
|
||||
{IsSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
{SaveError && <span className="SaveErrorText">{SaveError}</span>}
|
||||
</div>
|
||||
<CodeMirror
|
||||
value={Content}
|
||||
height="100%"
|
||||
extensions={[
|
||||
...GetLanguageExtension(FileType),
|
||||
...(ShowGitBlame ? [BuildBlameGutter(BlameMap)] : []),
|
||||
]}
|
||||
onChange={SetContent}
|
||||
theme="dark"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="PreviewEmpty">No preview available for this file type.</div>;
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
CreateUserAccount,
|
||||
DeleteTheme,
|
||||
FetchProfileColourOptions,
|
||||
FetchSettings,
|
||||
FetchTheme,
|
||||
FetchThemeNames,
|
||||
FetchUsers,
|
||||
GetAvatarUrl,
|
||||
Logout,
|
||||
SaveSettings,
|
||||
SaveTheme,
|
||||
UpdateProfileColour,
|
||||
UploadAvatar,
|
||||
} from "../Api.js";
|
||||
import Switch from "./Switch.jsx";
|
||||
import { PaletteRoles } from "../ThemeRoles.js";
|
||||
|
||||
export default function SettingsPanel({
|
||||
CurrentUser,
|
||||
OnClose,
|
||||
OnSettingsChanged,
|
||||
OnLoggedOut,
|
||||
OnProfileUpdated,
|
||||
}) {
|
||||
const [Settings, SetSettings] = useState(null);
|
||||
const [ThemeNames, SetThemeNames] = useState([]);
|
||||
const [SaveError, SetSaveError] = useState(null);
|
||||
const [IsSaving, SetIsSaving] = useState(false);
|
||||
|
||||
const [IsCreatingTheme, SetIsCreatingTheme] = useState(false);
|
||||
const [NewThemeName, SetNewThemeName] = useState("");
|
||||
const [NewThemePalette, SetNewThemePalette] = useState(null);
|
||||
const [ThemeError, SetThemeError] = useState(null);
|
||||
|
||||
const [Users, SetUsers] = useState(null);
|
||||
const [NewUsername, SetNewUsername] = useState("");
|
||||
const [NewUserPassword, SetNewUserPassword] = useState("");
|
||||
const [NewUserIsAdmin, SetNewUserIsAdmin] = useState(false);
|
||||
const [NewUserColour, SetNewUserColour] = useState("Purple");
|
||||
const [UserError, SetUserError] = useState(null);
|
||||
|
||||
const [ColourOptions, SetColourOptions] = useState([]);
|
||||
const [ProfileError, SetProfileError] = useState(null);
|
||||
const [AvatarNonce, SetAvatarNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
FetchSettings().then(SetSettings);
|
||||
FetchThemeNames().then(SetThemeNames);
|
||||
FetchProfileColourOptions().then(SetColourOptions);
|
||||
if (CurrentUser && CurrentUser.IsAdmin) {
|
||||
FetchUsers().then(SetUsers);
|
||||
}
|
||||
}, [CurrentUser]);
|
||||
|
||||
if (!Settings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function UpdateField(Field, Value) {
|
||||
SetSettings({ ...Settings, [Field]: Value });
|
||||
}
|
||||
|
||||
async function HandleSave() {
|
||||
SetIsSaving(true);
|
||||
SetSaveError(null);
|
||||
try {
|
||||
const Saved = await SaveSettings(Settings);
|
||||
OnSettingsChanged(Saved);
|
||||
OnClose();
|
||||
} catch (Error) {
|
||||
SetSaveError(Error.message);
|
||||
} finally {
|
||||
SetIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function HandleStartCreateTheme() {
|
||||
SetThemeError(null);
|
||||
const BasePalette = await FetchTheme(Settings.ThemeName);
|
||||
SetNewThemePalette(BasePalette);
|
||||
SetNewThemeName("");
|
||||
SetIsCreatingTheme(true);
|
||||
}
|
||||
|
||||
async function HandleSaveNewTheme() {
|
||||
if (!NewThemeName.trim()) return;
|
||||
|
||||
try {
|
||||
await SaveTheme(NewThemeName.trim(), NewThemePalette);
|
||||
const Updated = await FetchThemeNames();
|
||||
SetThemeNames(Updated);
|
||||
UpdateField("ThemeName", NewThemeName.trim());
|
||||
SetIsCreatingTheme(false);
|
||||
} catch (Error) {
|
||||
SetThemeError(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function HandleRenameTheme() {
|
||||
const NewName = window.prompt("Rename theme to:", Settings.ThemeName);
|
||||
if (!NewName || !NewName.trim() || NewName.trim() === Settings.ThemeName) return;
|
||||
|
||||
SetThemeError(null);
|
||||
try {
|
||||
const Palette = await FetchTheme(Settings.ThemeName);
|
||||
await SaveTheme(NewName.trim(), Palette);
|
||||
await DeleteTheme(Settings.ThemeName);
|
||||
const Updated = await FetchThemeNames();
|
||||
SetThemeNames(Updated);
|
||||
UpdateField("ThemeName", NewName.trim());
|
||||
} catch (Error) {
|
||||
SetThemeError(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function HandleDeleteTheme() {
|
||||
if (ThemeNames.length <= 1) {
|
||||
SetThemeError("Can't delete the only remaining theme.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm(`Delete theme "${Settings.ThemeName}"? This can't be undone.`)) return;
|
||||
|
||||
SetThemeError(null);
|
||||
try {
|
||||
await DeleteTheme(Settings.ThemeName);
|
||||
const Updated = await FetchThemeNames();
|
||||
SetThemeNames(Updated);
|
||||
UpdateField("ThemeName", Updated[0]);
|
||||
} catch (Error) {
|
||||
SetThemeError(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function HandleLogout() {
|
||||
await Logout();
|
||||
OnLoggedOut();
|
||||
}
|
||||
|
||||
async function HandleCreateUser() {
|
||||
SetUserError(null);
|
||||
|
||||
try {
|
||||
await CreateUserAccount(NewUsername.trim(), NewUserPassword, NewUserIsAdmin, NewUserColour);
|
||||
const Updated = await FetchUsers();
|
||||
SetUsers(Updated);
|
||||
SetNewUsername("");
|
||||
SetNewUserPassword("");
|
||||
SetNewUserIsAdmin(false);
|
||||
SetNewUserColour("Purple");
|
||||
} catch (Error) {
|
||||
SetUserError(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function HandlePickColour(Colour) {
|
||||
SetProfileError(null);
|
||||
try {
|
||||
const Updated = await UpdateProfileColour(Colour);
|
||||
OnProfileUpdated(Updated);
|
||||
} catch (Error) {
|
||||
SetProfileError(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function HandleAvatarChange(Event) {
|
||||
const File = Event.target.files && Event.target.files[0];
|
||||
if (!File) return;
|
||||
|
||||
SetProfileError(null);
|
||||
try {
|
||||
await UploadAvatar(File);
|
||||
const NextNonce = Date.now();
|
||||
SetAvatarNonce(NextNonce);
|
||||
OnProfileUpdated({ AvatarNonce: NextNonce });
|
||||
} catch (Error) {
|
||||
SetProfileError(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="SettingsOverlay" onClick={OnClose}>
|
||||
<div className="SettingsModal" onClick={(Event) => Event.stopPropagation()}>
|
||||
<div className="SettingsHeader">
|
||||
<span>Settings</span>
|
||||
<button className="SettingsClose" onClick={OnClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="SettingsBody">
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">Font family</label>
|
||||
<input
|
||||
type="text"
|
||||
value={Settings.FontFamily}
|
||||
onChange={(Event) => UpdateField("FontFamily", Event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">Font size (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={Settings.FontSize}
|
||||
onChange={(Event) => UpdateField("FontSize", Number(Event.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">Show hidden directories</label>
|
||||
<Switch Checked={Settings.ShowHiddenFolders} OnChange={(Event) => UpdateField("ShowHiddenFolders", Event.target.checked)} />
|
||||
</div>
|
||||
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">Show breadcrumbs</label>
|
||||
<Switch Checked={Settings.ShowBreadcrumbs} OnChange={(Event) => UpdateField("ShowBreadcrumbs", Event.target.checked)} />
|
||||
</div>
|
||||
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">Hyphen file icons (-)</label>
|
||||
<Switch Checked={Settings.UseHyphenBullets} OnChange={(Event) => UpdateField("UseHyphenBullets", Event.target.checked)} />
|
||||
</div>
|
||||
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">ASCII directory icons (>/v)</label>
|
||||
<Switch Checked={Settings.UseAsciiFolderIcons} OnChange={(Event) => UpdateField("UseAsciiFolderIcons", Event.target.checked)} />
|
||||
</div>
|
||||
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">Open directory file on click</label>
|
||||
<Switch Checked={Settings.NavigateIntoDirectories} OnChange={(Event) => UpdateField("NavigateIntoDirectories", Event.target.checked)} />
|
||||
</div>
|
||||
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">New File/Directory buttons at top of sidebar</label>
|
||||
<Switch Checked={Settings.NewEntryButtonsAtTop} OnChange={(Event) => UpdateField("NewEntryButtonsAtTop", Event.target.checked)} />
|
||||
</div>
|
||||
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">Show blame gutter (who last changed each line)</label>
|
||||
<Switch Checked={Settings.ShowGitBlame} OnChange={(Event) => UpdateField("ShowGitBlame", Event.target.checked)} />
|
||||
</div>
|
||||
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">Sticky edit/view/save toolbar</label>
|
||||
<Switch Checked={Settings.StickyToolbar} OnChange={(Event) => UpdateField("StickyToolbar", Event.target.checked)} />
|
||||
</div>
|
||||
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">Confirm before delete</label>
|
||||
<Switch Checked={Settings.ConfirmBeforeDelete} OnChange={(Event) => UpdateField("ConfirmBeforeDelete", Event.target.checked)} />
|
||||
</div>
|
||||
|
||||
<div className="SettingsSection">
|
||||
<div className="SettingsSectionTitle">Colour theme</div>
|
||||
<div className="SettingsRow">
|
||||
<select
|
||||
value={Settings.ThemeName}
|
||||
onChange={(Event) => UpdateField("ThemeName", Event.target.value)}
|
||||
>
|
||||
{ThemeNames.map((Name) => (
|
||||
<option key={Name} value={Name}>
|
||||
{Name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="SettingsRowInline">
|
||||
<button onClick={HandleRenameTheme}>Rename</button>
|
||||
<button onClick={HandleDeleteTheme}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!IsCreatingTheme && (
|
||||
<button onClick={HandleStartCreateTheme}>Create from current theme</button>
|
||||
)}
|
||||
{IsCreatingTheme && NewThemePalette && (
|
||||
<>
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">New theme name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={NewThemeName}
|
||||
onChange={(Event) => SetNewThemeName(Event.target.value)}
|
||||
placeholder="MyTheme"
|
||||
/>
|
||||
</div>
|
||||
<div className="PaletteGrid">
|
||||
{PaletteRoles.map((Role) => (
|
||||
<div className="PaletteSwatchRow" key={Role}>
|
||||
<input
|
||||
type="color"
|
||||
value={NewThemePalette[Role] || "#000000"}
|
||||
onChange={(Event) =>
|
||||
SetNewThemePalette({ ...NewThemePalette, [Role]: Event.target.value })
|
||||
}
|
||||
/>
|
||||
<span>{Role}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={HandleSaveNewTheme}>Save theme</button>
|
||||
</>
|
||||
)}
|
||||
{ThemeError && <div className="SettingsSaveError">{ThemeError}</div>}
|
||||
</div>
|
||||
|
||||
<div className="SettingsSection">
|
||||
<div className="SettingsSectionTitle">Profile</div>
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">Your colour</label>
|
||||
<div className="ColourSwatchPicker">
|
||||
{ColourOptions.map((Colour) => (
|
||||
<button
|
||||
key={Colour}
|
||||
className={`ColourSwatch${CurrentUser && CurrentUser.Colour === Colour ? " ColourSwatchSelected" : ""}`}
|
||||
style={{ background: `var(--${Colour})` }}
|
||||
title={Colour}
|
||||
onClick={() => HandlePickColour(Colour)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">Avatar</label>
|
||||
<div className="AvatarUploadRow">
|
||||
{CurrentUser && (
|
||||
<img
|
||||
className="AvatarPreview"
|
||||
src={`${GetAvatarUrl(CurrentUser.Username)}&v=${AvatarNonce}`}
|
||||
alt=""
|
||||
onError={(Event) => {
|
||||
Event.target.style.visibility = "hidden";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<input type="file" accept="image/*" onChange={HandleAvatarChange} />
|
||||
</div>
|
||||
</div>
|
||||
{ProfileError && <div className="SettingsSaveError">{ProfileError}</div>}
|
||||
</div>
|
||||
|
||||
<div className="SettingsSection">
|
||||
<div className="SettingsSectionTitle">Account</div>
|
||||
<div className="SettingsRowInline">
|
||||
<span className="SettingsLabel">
|
||||
Logged in as {CurrentUser ? CurrentUser.Username : "..."}
|
||||
{CurrentUser && CurrentUser.IsAdmin ? " (admin)" : ""}
|
||||
</span>
|
||||
<button onClick={HandleLogout}>Log out</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{CurrentUser && CurrentUser.IsAdmin && (
|
||||
<div className="SettingsSection">
|
||||
<div className="SettingsSectionTitle">Users (admin)</div>
|
||||
{Users &&
|
||||
Users.map((User) => (
|
||||
<div className="SettingsRowInline" key={User.Username}>
|
||||
<span className="SettingsLabel">
|
||||
<span
|
||||
className="ColourSwatchSmall"
|
||||
style={{ background: `var(--${User.Colour || "Purple"})` }}
|
||||
/>
|
||||
{User.Username}
|
||||
{User.IsAdmin ? " (admin)" : ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">New username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={NewUsername}
|
||||
onChange={(Event) => SetNewUsername(Event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">New password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={NewUserPassword}
|
||||
onChange={(Event) => SetNewUserPassword(Event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="SettingsRow">
|
||||
<label className="SettingsLabel">Colour</label>
|
||||
<div className="ColourSwatchPicker">
|
||||
{ColourOptions.map((Colour) => (
|
||||
<button
|
||||
key={Colour}
|
||||
className={`ColourSwatch${NewUserColour === Colour ? " ColourSwatchSelected" : ""}`}
|
||||
style={{ background: `var(--${Colour})` }}
|
||||
title={Colour}
|
||||
onClick={() => SetNewUserColour(Colour)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="SettingsRowInline">
|
||||
<label className="SettingsLabel">Admin</label>
|
||||
<Switch Checked={NewUserIsAdmin} OnChange={(Event) => SetNewUserIsAdmin(Event.target.checked)} />
|
||||
</div>
|
||||
{UserError && <div className="SettingsSaveError">{UserError}</div>}
|
||||
<button onClick={HandleCreateUser}>Create user</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{SaveError && <div className="SettingsSaveError">{SaveError}</div>}
|
||||
</div>
|
||||
|
||||
<div className="SettingsFooter">
|
||||
<button onClick={OnClose}>Cancel</button>
|
||||
<button onClick={HandleSave} disabled={IsSaving}>
|
||||
{IsSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { CreateEntry, DeleteEntry, FetchTree, GetAvatarUrl, MoveEntry, RenameEntry } from "../Api.js";
|
||||
import { GetFileIcon, GetFolderIcon } from "../IconStyle.js";
|
||||
|
||||
function TreeNode({
|
||||
RootName,
|
||||
Entry,
|
||||
ParentPath,
|
||||
SelectedFile,
|
||||
CurrentDirectoryPath,
|
||||
OnSelectFile,
|
||||
OnNavigateDirectory,
|
||||
OnBrowseDirectory,
|
||||
OnEntryRenamed,
|
||||
OnEntryDeleted,
|
||||
UseHyphenBullets,
|
||||
UseAsciiFolderIcons,
|
||||
NavigateIntoDirectories,
|
||||
ConfirmBeforeDelete,
|
||||
}) {
|
||||
const [IsExpanded, SetIsExpanded] = useState(false);
|
||||
const [Children, SetChildren] = useState(null);
|
||||
const FullPath = ParentPath ? `${ParentPath}/${Entry.Name}` : Entry.Name;
|
||||
const IsSelected = SelectedFile === FullPath;
|
||||
const IsCurrentDirectory = Entry.IsDirectory && CurrentDirectoryPath === FullPath;
|
||||
const IsAncestorOfCurrentDirectory =
|
||||
Entry.IsDirectory &&
|
||||
CurrentDirectoryPath &&
|
||||
(CurrentDirectoryPath === FullPath || CurrentDirectoryPath.startsWith(`${FullPath}/`));
|
||||
|
||||
async function LoadChildren() {
|
||||
try {
|
||||
const Fetched = await FetchTree(RootName, FullPath);
|
||||
SetChildren(Fetched);
|
||||
return Fetched;
|
||||
} catch (Error) {
|
||||
SetChildren([]);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function ToggleExpand() {
|
||||
if (!Entry.IsDirectory) {
|
||||
OnSelectFile(FullPath, Entry.FileType);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsExpanded && Children === null) {
|
||||
await LoadChildren();
|
||||
}
|
||||
|
||||
SetIsExpanded(!IsExpanded);
|
||||
OnBrowseDirectory(FullPath);
|
||||
|
||||
if (NavigateIntoDirectories) {
|
||||
OnNavigateDirectory(FullPath);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (IsAncestorOfCurrentDirectory && !IsExpanded) {
|
||||
if (Children === null) {
|
||||
LoadChildren().then(() => SetIsExpanded(true));
|
||||
} else {
|
||||
SetIsExpanded(true);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [CurrentDirectoryPath]);
|
||||
|
||||
async function HandleRename(Event) {
|
||||
Event.stopPropagation();
|
||||
const NewValue = window.prompt("Rename to (or type a path to move):", Entry.Name);
|
||||
if (!NewValue || NewValue === Entry.Name) return;
|
||||
|
||||
try {
|
||||
if (NewValue.includes("/")) {
|
||||
await MoveEntry(RootName, FullPath, NewValue);
|
||||
OnEntryRenamed(FullPath, NewValue, Entry.IsDirectory);
|
||||
} else {
|
||||
await RenameEntry(RootName, FullPath, NewValue);
|
||||
const NewPath = ParentPath ? `${ParentPath}/${NewValue}` : NewValue;
|
||||
OnEntryRenamed(FullPath, NewPath, Entry.IsDirectory);
|
||||
}
|
||||
} catch (Error) {
|
||||
window.alert(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function HandleDelete(Event) {
|
||||
Event.stopPropagation();
|
||||
|
||||
if (ConfirmBeforeDelete && !window.confirm(`Delete "${Entry.Name}"? This can't be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await DeleteEntry(RootName, FullPath);
|
||||
OnEntryDeleted(FullPath, Entry.IsDirectory);
|
||||
} catch (Error) {
|
||||
window.alert(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="TreeNode">
|
||||
<div
|
||||
className={`TreeRow${IsSelected ? " TreeRowSelected" : ""}${
|
||||
IsCurrentDirectory ? " TreeRowCurrentDirectory" : ""
|
||||
}`}
|
||||
onClick={ToggleExpand}
|
||||
>
|
||||
<span className="TreeIcon">
|
||||
{Entry.IsDirectory
|
||||
? GetFolderIcon(IsExpanded, UseAsciiFolderIcons)
|
||||
: GetFileIcon(UseHyphenBullets)}
|
||||
</span>
|
||||
<span className="TreeLabel">{Entry.Name}</span>
|
||||
<span className="TreeRowActions">
|
||||
<button className="TreeRowActionButton" onClick={HandleRename} title="Rename / move">
|
||||
✎
|
||||
</button>
|
||||
<button className="TreeRowActionButton" onClick={HandleDelete} title="Delete">
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{IsExpanded && Children && (
|
||||
<div className="TreeChildren">
|
||||
{Children.map((Child) => (
|
||||
<TreeNode
|
||||
key={Child.Name}
|
||||
RootName={RootName}
|
||||
Entry={Child}
|
||||
ParentPath={FullPath}
|
||||
SelectedFile={SelectedFile}
|
||||
CurrentDirectoryPath={CurrentDirectoryPath}
|
||||
OnSelectFile={OnSelectFile}
|
||||
OnNavigateDirectory={OnNavigateDirectory}
|
||||
OnBrowseDirectory={OnBrowseDirectory}
|
||||
OnEntryRenamed={OnEntryRenamed}
|
||||
OnEntryDeleted={OnEntryDeleted}
|
||||
UseHyphenBullets={UseHyphenBullets}
|
||||
UseAsciiFolderIcons={UseAsciiFolderIcons}
|
||||
NavigateIntoDirectories={NavigateIntoDirectories}
|
||||
ConfirmBeforeDelete={ConfirmBeforeDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserBadge({ CurrentUser, OnOpenSettings }) {
|
||||
const [AvatarFailed, SetAvatarFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
SetAvatarFailed(false);
|
||||
}, [CurrentUser && CurrentUser.AvatarNonce]);
|
||||
|
||||
if (!CurrentUser) return null;
|
||||
|
||||
const AvatarUrl = CurrentUser.AvatarNonce
|
||||
? `${GetAvatarUrl(CurrentUser.Username)}&v=${CurrentUser.AvatarNonce}`
|
||||
: GetAvatarUrl(CurrentUser.Username);
|
||||
|
||||
return (
|
||||
<span className="UserBadge" onClick={OnOpenSettings} title={CurrentUser.Username}>
|
||||
{!AvatarFailed ? (
|
||||
<img
|
||||
className="UserBadgeAvatar"
|
||||
src={AvatarUrl}
|
||||
alt=""
|
||||
onError={() => SetAvatarFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="UserBadgeInitial"
|
||||
style={{ background: `var(--${CurrentUser.Colour || "Purple"})` }}
|
||||
>
|
||||
{CurrentUser.Username.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
RootName,
|
||||
SelectedFile,
|
||||
CurrentDirectoryPath,
|
||||
CurrentUser,
|
||||
OnSelectFile,
|
||||
OnNavigateDirectory,
|
||||
OnBrowseDirectory,
|
||||
OnEntryRenamed,
|
||||
OnEntryDeleted,
|
||||
OnOpenSettings,
|
||||
OnEntryCreated,
|
||||
UseHyphenBullets,
|
||||
UseAsciiFolderIcons,
|
||||
NavigateIntoDirectories,
|
||||
NewEntryButtonsAtTop,
|
||||
ConfirmBeforeDelete,
|
||||
}) {
|
||||
const [RootEntries, SetRootEntries] = useState([]);
|
||||
const [LoadError, SetLoadError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (RootName) {
|
||||
SetLoadError(null);
|
||||
FetchTree(RootName, "")
|
||||
.then(SetRootEntries)
|
||||
.catch((Error) => SetLoadError(Error.message));
|
||||
}
|
||||
}, [RootName]);
|
||||
|
||||
function JoinPath(Name) {
|
||||
return CurrentDirectoryPath ? `${CurrentDirectoryPath}/${Name}` : Name;
|
||||
}
|
||||
|
||||
async function HandleCreateFile() {
|
||||
const Name = window.prompt(
|
||||
`New file name in ${CurrentDirectoryPath || "root"} (e.g. Notes.md):`
|
||||
);
|
||||
if (!Name) return;
|
||||
|
||||
try {
|
||||
await CreateEntry(RootName, JoinPath(Name), false);
|
||||
OnEntryCreated();
|
||||
} catch (Error) {
|
||||
SetLoadError(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function HandleCreateDirectory() {
|
||||
const Name = window.prompt(`New directory name in ${CurrentDirectoryPath || "root"}:`);
|
||||
if (!Name) return;
|
||||
|
||||
try {
|
||||
await CreateEntry(RootName, JoinPath(Name), true);
|
||||
OnEntryCreated();
|
||||
} catch (Error) {
|
||||
SetLoadError(Error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const NewEntryButtons = (
|
||||
<div className={`SidebarNewEntry${NewEntryButtonsAtTop ? " SidebarNewEntryTop" : ""}`}>
|
||||
<button onClick={HandleCreateFile}>New File</button>
|
||||
<button onClick={HandleCreateDirectory}>New Directory</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="Sidebar">
|
||||
<div className="SidebarHeader">
|
||||
<span className="SidebarHeaderLink" onClick={() => OnNavigateDirectory("")}>
|
||||
SaraView
|
||||
</span>
|
||||
<span className="SidebarHeaderRight">
|
||||
<UserBadge CurrentUser={CurrentUser} OnOpenSettings={OnOpenSettings} />
|
||||
<button className="SettingsButton" onClick={OnOpenSettings}>
|
||||
⚙
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{NewEntryButtonsAtTop && NewEntryButtons}
|
||||
{LoadError && <div className="SidebarError">{LoadError}</div>}
|
||||
<div className="SidebarTree">
|
||||
{RootEntries.map((Entry) => (
|
||||
<TreeNode
|
||||
key={Entry.Name}
|
||||
RootName={RootName}
|
||||
Entry={Entry}
|
||||
ParentPath=""
|
||||
SelectedFile={SelectedFile}
|
||||
CurrentDirectoryPath={CurrentDirectoryPath}
|
||||
OnSelectFile={OnSelectFile}
|
||||
OnNavigateDirectory={OnNavigateDirectory}
|
||||
OnBrowseDirectory={OnBrowseDirectory}
|
||||
OnEntryRenamed={OnEntryRenamed}
|
||||
OnEntryDeleted={OnEntryDeleted}
|
||||
UseHyphenBullets={UseHyphenBullets}
|
||||
UseAsciiFolderIcons={UseAsciiFolderIcons}
|
||||
NavigateIntoDirectories={NavigateIntoDirectories}
|
||||
ConfirmBeforeDelete={ConfirmBeforeDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!NewEntryButtonsAtTop && NewEntryButtons}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
|
||||
export default function Switch({ Checked, OnChange }) {
|
||||
return (
|
||||
<label className="Switch">
|
||||
<input type="checkbox" checked={Checked} onChange={OnChange} />
|
||||
<span className="SwitchTrack">
|
||||
<span className="SwitchThumb" />
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { FetchBook } from "../Api.js";
|
||||
|
||||
export default function TagPanel({ RootName, FilePath }) {
|
||||
const [Book, SetBook] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
SetBook(null);
|
||||
|
||||
if (RootName && FilePath) {
|
||||
FetchBook(RootName, FilePath).then((Result) => {
|
||||
if (Result && Object.keys(Result).length > 0) {
|
||||
SetBook(Result);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [RootName, FilePath]);
|
||||
|
||||
if (!Book) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const TagEntries = Object.entries(Book.Tags || {});
|
||||
|
||||
return (
|
||||
<div className="TagPanel">
|
||||
<div className="TagPanelHeader">Tags</div>
|
||||
<div className="TagPanelBody">
|
||||
{TagEntries.length === 0 && (
|
||||
<div className="TagPanelEmpty">No tags recorded.</div>
|
||||
)}
|
||||
{TagEntries.map(([TagName, TagValue]) => (
|
||||
<div className="TagRow" key={TagName}>
|
||||
<span className="TagName">{TagName}</span>
|
||||
<span className="TagValue">{String(TagValue)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function GetFolderIcon(IsExpanded, UseAsciiFolderIcons) {
|
||||
if (UseAsciiFolderIcons) {
|
||||
return IsExpanded ? "v" : ">";
|
||||
}
|
||||
return IsExpanded ? "▾" : "▸";
|
||||
}
|
||||
|
||||
export function GetFileIcon(UseHyphenBullets) {
|
||||
return UseHyphenBullets ? "-" : "•";
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export const PaletteRoles = [
|
||||
"Background",
|
||||
"Surface",
|
||||
"SurfaceAlt",
|
||||
"Border",
|
||||
"Text",
|
||||
"TextMuted",
|
||||
"Black",
|
||||
"White",
|
||||
"Red",
|
||||
"Orange",
|
||||
"Yellow",
|
||||
"Green",
|
||||
"Blue",
|
||||
"Purple",
|
||||
];
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vite";
|
||||
import React from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [React()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:8420",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
# SaraView
|
||||
|
||||
A self-hosted web file previewer/editor — Obsidian's look, without the parts
|
||||
you don't use, plus image and video previewing. Read-only integration with
|
||||
SaraTag's per-file Book tags is included.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
SaraView/
|
||||
├── Backend/ FastAPI app (Core, Explorer, Preview, Editor, BookReader, Main)
|
||||
├── Frontend/ React app (Vite)
|
||||
├── SaraViewConf.toml Your editable config, mounted into the container
|
||||
├── Dockerfile
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
## Configure before deploying
|
||||
|
||||
Edit `SaraViewConf.toml` at the project root:
|
||||
|
||||
- `[Roots]` — add one entry per top-level folder you want browsable, e.g.
|
||||
`Notes = "/data/notes"`. The path here is the path *inside the container*;
|
||||
map it to a real folder via a volume mount in `docker-compose.yml`.
|
||||
- `[SaraTag]` — set `CoreModulePath` to wherever your SaraTag installation's
|
||||
`.saratag` folder ends up mounted inside the container. Set `Enabled = false`
|
||||
if you don't want the Book/tag panel at all.
|
||||
- `[Storage]` — `DataDirectory` is where Settings.toml and the Themes/ folder
|
||||
live. Leave this pointing at the `saraview_data` named volume already set
|
||||
up in `docker-compose.yml` — it's intentionally not one of your `[Roots]`,
|
||||
so it's unreachable from the file browser no matter what.
|
||||
|
||||
Then in `docker-compose.yml`:
|
||||
|
||||
- Add a `volumes:` line for every root you declared, mapping your real
|
||||
ZimaOS path to the container path used in `SaraViewConf.toml`.
|
||||
- Mount your SaraTag `.saratag` folder read-only the same way.
|
||||
- Bind the port to your Tailscale interface's IP rather than `0.0.0.0` if you
|
||||
want it reachable only over the tailnet (same pattern as your other
|
||||
tailnet-only services).
|
||||
|
||||
## Running it
|
||||
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
The backend serves the built frontend directly, so the whole app is on one
|
||||
port (`8420` by default).
|
||||
|
||||
## Local development (without Docker)
|
||||
|
||||
Backend:
|
||||
|
||||
```bash
|
||||
cd Backend
|
||||
pip install -r requirements.txt
|
||||
uvicorn Main:Application --reload --port 8420
|
||||
```
|
||||
|
||||
Frontend (in a second terminal — Vite proxies `/api` to the backend):
|
||||
|
||||
```bash
|
||||
cd Frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Notes on the SaraTag integration
|
||||
|
||||
SaraView never writes to Book or Lexicon files — it only reads them, by
|
||||
loading SaraTag's own `Core.py` directly (by file path, not as an installed
|
||||
package) so tag resolution always matches what SaraTag itself would show.
|
||||
Editing tags from SaraView isn't implemented yet; SaraTag's own TUI remains
|
||||
the place to do that for now.
|
||||
|
||||
## Settings and themes
|
||||
|
||||
Settings (font, font size, active theme, hidden-folder visibility, breadcrumb
|
||||
visibility) and themes live in `Settings.toml` and `Themes/*.toml` inside the
|
||||
`saraview_data` volume — not in any of your `[Roots]`, so they're never
|
||||
browsable from the app itself. Three themes ship built in: CatppuccinMocha,
|
||||
Gruvbox, and Forest. Every theme is just a flat set of named color roles
|
||||
(Background, Surface, Text, Red, Orange, ... Purple), so creating a custom
|
||||
theme from Settings — or hand-writing a new file in `Themes/` — only needs
|
||||
those same role names filled in.
|
||||
|
||||
This is all per-user now — each account gets its own `Settings/<Username>.toml`
|
||||
inside the data volume, seeded from the same defaults the first time that
|
||||
user logs in. Themes themselves are shared across everyone (there's only one
|
||||
copy of each theme file), but which theme is active is a per-user choice.
|
||||
|
||||
## Accounts and login
|
||||
|
||||
On first launch, with no accounts yet, SaraView shows a one-time "create the
|
||||
admin account" screen instead of a normal login — whoever sets that up first
|
||||
becomes an admin. After that, it's a normal username/password login screen
|
||||
for everyone.
|
||||
|
||||
Accounts live in `Users.toml` in the same `saraview_data` volume as Settings
|
||||
and Themes (passwords are hashed with PBKDF2-SHA256, never stored in plain
|
||||
text). Admins get a "Users" section inside Settings to create additional
|
||||
accounts, including other admins.
|
||||
|
||||
Sessions are currently in-memory only — restarting the container logs
|
||||
everyone out. Fine for now with a small, trusted set of tailnet users; worth
|
||||
revisiting if that stops being true.
|
||||
|
||||
## Running on ZimaOS without building locally
|
||||
|
||||
The repo publishes a built image to GitHub Container Registry via
|
||||
`.github/workflows/docker-publish.yml`, so ZimaOS (or any other machine) can
|
||||
just pull and run it instead of building from source every time.
|
||||
|
||||
**One-time setup after this workflow first runs:** GHCR packages built by 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. Until you do this, pulling the image from ZimaOS will
|
||||
fail with an authentication error.
|
||||
|
||||
**Installing on ZimaOS:**
|
||||
|
||||
1. Copy `docker-compose.ghcr.yml` and `SaraViewConf.toml` to the ZimaOS box
|
||||
(or wherever you're running `docker compose` from for this).
|
||||
2. Edit the volume paths in `docker-compose.ghcr.yml` the same way you would
|
||||
for local `docker-compose.yml` — real paths on the left of each `:`.
|
||||
3. In ZimaOS's dashboard, use **Install a customized app** (its Docker
|
||||
Compose import option) and point it at that compose file — or just run
|
||||
`docker compose -f docker-compose.ghcr.yml up -d` directly if you'd
|
||||
rather do it from a terminal on the box.
|
||||
|
||||
**Updating later:** push to `main` (or tag a release like `v1.1.0`) to build
|
||||
a fresh image, then on ZimaOS re-pull and recreate the container — either
|
||||
through its UI's update/recreate action, or `docker compose -f
|
||||
docker-compose.ghcr.yml pull && docker compose -f docker-compose.ghcr.yml up -d`.
|
||||
|
||||
This gets SaraView pullable as a plain image; it doesn't yet show up with its
|
||||
own icon/entry in ZimaOS's app store the way bundled apps do — that would
|
||||
need a CasaOS-format AppStore manifest (icon, category, metadata) submitted
|
||||
separately, which is a reasonable next step if you want it to feel fully
|
||||
native rather than "a compose file I pasted in."
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,33 @@
|
||||
# For ZimaOS (or any server that should just pull-and-run rather than
|
||||
# build): install this via ZimaOS's "Install a customized app" /
|
||||
# Docker Compose option, filling in your real paths below first.
|
||||
#
|
||||
# This is the "no local build" counterpart to docker-compose.yml, which
|
||||
# still builds from source for local dev/testing.
|
||||
|
||||
services:
|
||||
saraview:
|
||||
image: ghcr.io/srhtnin/saraview:latest
|
||||
container_name: SaraView
|
||||
ports:
|
||||
# Bind to your Tailscale IP only, e.g. "100.x.x.x:8420:8420",
|
||||
# so the app is reachable over the tailnet and nothing else.
|
||||
- "8420:8420"
|
||||
volumes:
|
||||
# Your own config, overriding the one baked into the image — edit
|
||||
# Roots and SaraTag.CoreModulePath to match your real paths below.
|
||||
- ./SaraViewConf.toml:/App/Backend/SaraViewConf.toml:ro
|
||||
# Persistent app data: Settings/, Users.toml, Avatars/, Themes/,
|
||||
# Blame/. A named volume (not a host path) is fine here — nothing
|
||||
# outside SaraView needs to touch it, and this is what keeps it
|
||||
# structurally unreachable from the file browser regardless of any
|
||||
# hidden-file setting.
|
||||
- saraview_data:/data/SaraViewData
|
||||
# One mount per root you declare in SaraViewConf.toml's [Roots] table.
|
||||
- /path/to/your/notes:/data/notes:ro
|
||||
# SaraTag's internal folder, for read-only Book resolution.
|
||||
- /path/to/SaraTag/.saratag:/data/SaraTag/.saratag:ro
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
saraview_data:
|
||||
@@ -0,0 +1,25 @@
|
||||
services:
|
||||
saraview:
|
||||
build: .
|
||||
container_name: SaraView
|
||||
ports:
|
||||
# Bind to your Tailscale IP only, e.g. "100.x.x.x:8420:8420",
|
||||
# so the app is reachable over the tailnet and nothing else.
|
||||
- "8420:8420"
|
||||
volumes:
|
||||
# Your own config, overriding the one baked into the image — edit
|
||||
# Roots and SaraTag.CoreModulePath to match your real paths below.
|
||||
- ./SaraViewConf.toml:/App/Backend/SaraViewConf.toml:ro
|
||||
# Persistent app data: Settings.toml + Themes/. A named volume (not a
|
||||
# host path) is fine here — nothing outside SaraView needs to touch it,
|
||||
# and this is what keeps it structurally unreachable from the file
|
||||
# browser regardless of any hidden-file setting.
|
||||
- saraview_data:/data/SaraViewData
|
||||
# One mount per root you declare in SaraViewConf.toml's [Roots] table.
|
||||
- /path/to/your/notes:/data/notes:ro
|
||||
# SaraTag's internal folder, for read-only Book resolution.
|
||||
- /path/to/SaraTag/.saratag:/data/SaraTag/.saratag:ro
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
saraview_data:
|
||||
Reference in new issue
Block a user