Initial commit

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

No files matched your search

+69
View File
@@ -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
View File
@@ -0,0 +1,12 @@
# Frontend build artifacts
Frontend/node_modules/
Frontend/dist/
# Python
**/__pycache__/
*.pyc
# Editor/OS cruft
.DS_Store
.vscode/
.idea/
+53
View File
@@ -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
+75
View File
@@ -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)
+85
View File
@@ -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}
+67
View File
@@ -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"
+15
View File
@@ -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"
+15
View File
@@ -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"
+8
View File
@@ -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")
+130
View File
@@ -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
View File
@@ -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")
+44
View File
@@ -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
+33
View File
@@ -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"]
+185
View File
@@ -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)
+170
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
tomli-w==1.0.0
python-multipart==0.0.12
+17
View File
@@ -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"]
+12
View File
@@ -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>
+26
View File
@@ -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.
+172
View File
@@ -0,0 +1,172 @@
const ApiBase = "/api";
async function ParseJsonOrThrow(Response) {
const Body = await Response.json().catch(() => null);
if (!Response.ok) {
const Detail = Body && Body.detail ? Body.detail : Response.statusText;
throw new Error(Detail);
}
return Body;
}
function FetchJson(Path, Options = {}) {
return fetch(`${ApiBase}${Path}`, { credentials: "same-origin", ...Options }).then(
ParseJsonOrThrow
);
}
function FetchText(Path, Options = {}) {
return fetch(`${ApiBase}${Path}`, { credentials: "same-origin", ...Options }).then(
(Response) => Response.text()
);
}
function JsonBody(Body) {
return { headers: { "Content-Type": "application/json" }, body: JSON.stringify(Body) };
}
// --- Auth -------------------------------------------------------------
export function FetchAuthStatus() {
return FetchJson("/auth/status");
}
export function Bootstrap(Username, Password) {
return FetchJson("/auth/bootstrap", { method: "POST", ...JsonBody({ Username, Password }) });
}
export function Login(Username, Password) {
return FetchJson("/auth/login", { method: "POST", ...JsonBody({ Username, Password }) });
}
export function Logout() {
return FetchJson("/auth/logout", { method: "POST" });
}
export function FetchMe() {
return FetchJson("/auth/me");
}
export function FetchUsers() {
return FetchJson("/users");
}
export function CreateUserAccount(Username, Password, IsAdmin, Colour) {
return FetchJson("/users", { method: "POST", ...JsonBody({ Username, Password, IsAdmin, Colour }) });
}
export function FetchProfileColourOptions() {
return FetchJson("/profile/colours");
}
export function UpdateProfileColour(Colour) {
return FetchJson("/profile/colour", { method: "PUT", ...JsonBody({ Colour }) });
}
export async function UploadAvatar(File) {
const Form = new FormData();
Form.append("Avatar", File);
const Response = await fetch(`${ApiBase}/profile/avatar`, {
method: "POST",
credentials: "same-origin",
body: Form,
});
return ParseJsonOrThrow(Response);
}
export function GetAvatarUrl(Username) {
const Params = new URLSearchParams({ Username });
return `${ApiBase}/profile/avatar?${Params}`;
}
// --- Files --------------------------------------------------------------
export function FetchRoots() {
return FetchJson("/roots");
}
export function FetchTree(RootName, RelativePath) {
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
return FetchJson(`/tree?${Params}`);
}
export function FetchFile(RootName, RelativePath) {
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
return FetchText(`/file?${Params}`);
}
export function GetFileUrl(RootName, RelativePath) {
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
return `${ApiBase}/file?${Params}`;
}
export function GetRawFileUrl(RootName, RelativePath) {
const EncodedSegments = RelativePath.split("/").map(encodeURIComponent);
return `${ApiBase}/raw/${encodeURIComponent(RootName)}/${EncodedSegments.join("/")}`;
}
export function SaveFile(RootName, RelativePath, Content) {
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
return FetchJson(`/file?${Params}`, { method: "PUT", body: Content });
}
export function FetchBook(RootName, RelativePath) {
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
return FetchJson(`/book?${Params}`);
}
export function FetchBlame(RootName, RelativePath) {
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
return FetchJson(`/blame?${Params}`);
}
export function CreateEntry(RootName, RelativePath, IsDirectory) {
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
return FetchJson(`/tree?${Params}`, { method: "POST", ...JsonBody({ IsDirectory }) });
}
export function RenameEntry(RootName, RelativePath, NewName) {
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
return FetchJson(`/tree?${Params}`, { method: "PATCH", ...JsonBody({ NewName }) });
}
export function MoveEntry(RootName, RelativePath, DestinationPath) {
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
return FetchJson(`/tree?${Params}`, { method: "PATCH", ...JsonBody({ DestinationPath }) });
}
export function DeleteEntry(RootName, RelativePath) {
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
return FetchJson(`/tree?${Params}`, { method: "DELETE" });
}
// --- Settings / themes -------------------------------------------------
export function FetchSettings() {
return FetchJson("/settings");
}
export function SaveSettings(NewSettings) {
return FetchJson("/settings", { method: "PUT", ...JsonBody(NewSettings) });
}
export function FetchThemeNames() {
return FetchJson("/themes");
}
export function FetchTheme(Name) {
const Params = new URLSearchParams({ Name });
return FetchJson(`/theme?${Params}`);
}
export function SaveTheme(Name, Palette) {
const Params = new URLSearchParams({ Name });
return FetchJson(`/theme?${Params}`, { method: "PUT", ...JsonBody({ Palette }) });
}
export function DeleteTheme(Name) {
const Params = new URLSearchParams({ Name });
return FetchJson(`/theme?${Params}`, { method: "DELETE" });
}
+258
View File
@@ -0,0 +1,258 @@
import React, { useEffect, useState } from "react";
import Sidebar from "./Components/Sidebar.jsx";
import Breadcrumb from "./Components/Breadcrumb.jsx";
import PreviewPane from "./Components/PreviewPane.jsx";
import DirectoryPane from "./Components/DirectoryPane.jsx";
import TagPanel from "./Components/TagPanel.jsx";
import SettingsPanel from "./Components/SettingsPanel.jsx";
import LoginScreen from "./Components/LoginScreen.jsx";
import { FetchAuthStatus, FetchMe, FetchRoots, FetchSettings, FetchTheme } from "./Api.js";
import { PaletteRoles } from "./ThemeRoles.js";
function ApplyTheme(Palette) {
const RootStyle = document.documentElement.style;
PaletteRoles.forEach((Role) => {
if (Palette[Role]) {
RootStyle.setProperty(`--${Role}`, Palette[Role]);
}
});
}
function ApplyFont(Settings) {
const RootStyle = document.documentElement.style;
RootStyle.setProperty(
"--FontFamily",
`"${Settings.FontFamily}", "Cascadia Mono", monospace`
);
RootStyle.setProperty("--FontSize", `${Settings.FontSize}px`);
}
function GetParentPath(FilePath) {
return FilePath.includes("/") ? FilePath.slice(0, FilePath.lastIndexOf("/")) : "";
}
export default function App() {
const [AuthState, SetAuthState] = useState("Loading"); // Loading | Bootstrap | LoggedOut | LoggedIn
const [CurrentUser, SetCurrentUser] = useState(null);
const [Roots, SetRoots] = useState([]);
const [ActiveRoot, SetActiveRoot] = useState(null);
const [ViewPath, SetViewPath] = useState("");
const [ViewIsDirectory, SetViewIsDirectory] = useState(true);
const [ViewFileType, SetViewFileType] = useState(null);
const [CurrentDirectoryPath, SetCurrentDirectoryPath] = useState("");
const [SidebarKey, SetSidebarKey] = useState(0);
const [Settings, SetSettings] = useState(null);
const [IsSettingsOpen, SetIsSettingsOpen] = useState(false);
useEffect(() => {
FetchAuthStatus().then((Status) => {
if (!Status.SetupComplete) {
SetAuthState("Bootstrap");
return;
}
FetchMe()
.then((User) => {
SetCurrentUser(User);
SetAuthState("LoggedIn");
})
.catch(() => SetAuthState("LoggedOut"));
});
}, []);
useEffect(() => {
if (AuthState !== "LoggedIn") return;
FetchRoots().then((Fetched) => {
SetRoots(Fetched);
if (Fetched.length > 0) {
SetActiveRoot(Fetched[0].Name);
}
});
FetchSettings().then((FetchedSettings) => {
SetSettings(FetchedSettings);
ApplyFont(FetchedSettings);
FetchTheme(FetchedSettings.ThemeName).then(ApplyTheme);
});
}, [AuthState]);
function HandleAuthenticated(User) {
SetCurrentUser(User);
SetAuthState("LoggedIn");
}
function HandleLoggedOut() {
SetCurrentUser(null);
SetAuthState("LoggedOut");
}
function HandleProfileUpdated(UpdatedUser) {
SetCurrentUser((Existing) => ({ ...Existing, ...UpdatedUser }));
}
function HandleSettingsChanged(NewSettings) {
SetSettings(NewSettings);
ApplyFont(NewSettings);
FetchTheme(NewSettings.ThemeName).then(ApplyTheme);
SetSidebarKey((Key) => Key + 1);
}
function HandleSelectFile(FilePath, FileType) {
SetViewPath(FilePath);
SetViewIsDirectory(false);
SetViewFileType(FileType);
SetCurrentDirectoryPath(GetParentPath(FilePath));
}
function HandleNavigateDirectory(DirectoryPath) {
SetViewPath(DirectoryPath);
SetViewIsDirectory(true);
SetViewFileType(null);
SetCurrentDirectoryPath(DirectoryPath);
}
function HandleBrowseDirectory(DirectoryPath) {
SetCurrentDirectoryPath(DirectoryPath);
}
function HandleEntrySelect(EntryPath, IsDirectory, FileType) {
if (IsDirectory) {
HandleNavigateDirectory(EntryPath);
} else {
HandleSelectFile(EntryPath, FileType);
}
}
function RefreshSidebar() {
SetSidebarKey((Key) => Key + 1);
}
function HandleEntryRenamed(OldPath, NewPath, IsDirectory) {
RefreshSidebar();
if (!ViewIsDirectory && ViewPath === OldPath) {
HandleSelectFile(NewPath, ViewFileType);
} else if (ViewIsDirectory && ViewPath === OldPath) {
HandleNavigateDirectory(NewPath);
} else if (CurrentDirectoryPath === OldPath) {
SetCurrentDirectoryPath(NewPath);
}
}
function HandleEntryDeleted(DeletedPath) {
RefreshSidebar();
if (ViewPath === DeletedPath || ViewPath.startsWith(`${DeletedPath}/`)) {
HandleNavigateDirectory(GetParentPath(DeletedPath));
} else if (CurrentDirectoryPath === DeletedPath || CurrentDirectoryPath.startsWith(`${DeletedPath}/`)) {
SetCurrentDirectoryPath(GetParentPath(DeletedPath));
}
}
if (AuthState === "Loading") {
return null;
}
if (AuthState === "Bootstrap") {
return <LoginScreen IsBootstrap OnAuthenticated={HandleAuthenticated} />;
}
if (AuthState === "LoggedOut") {
return <LoginScreen IsBootstrap={false} OnAuthenticated={HandleAuthenticated} />;
}
// Only take up a TopBar row at all if there's something to put in it —
// otherwise turning breadcrumbs off with a single root left a blank strip.
const ShowTopBar = (Settings && Settings.ShowBreadcrumbs) || Roots.length > 1;
const ConfirmBeforeDelete = Settings ? Settings.ConfirmBeforeDelete : true;
return (
<div className="AppShell">
<Sidebar
key={SidebarKey}
RootName={ActiveRoot}
SelectedFile={ViewIsDirectory ? null : ViewPath}
CurrentDirectoryPath={CurrentDirectoryPath}
CurrentUser={CurrentUser}
OnSelectFile={HandleSelectFile}
OnNavigateDirectory={HandleNavigateDirectory}
OnBrowseDirectory={HandleBrowseDirectory}
OnEntryRenamed={HandleEntryRenamed}
OnEntryDeleted={HandleEntryDeleted}
OnOpenSettings={() => SetIsSettingsOpen(true)}
OnEntryCreated={RefreshSidebar}
UseHyphenBullets={Settings ? Settings.UseHyphenBullets : true}
UseAsciiFolderIcons={Settings ? Settings.UseAsciiFolderIcons : true}
NavigateIntoDirectories={Settings ? Settings.NavigateIntoDirectories : false}
NewEntryButtonsAtTop={Settings ? Settings.NewEntryButtonsAtTop : false}
ConfirmBeforeDelete={ConfirmBeforeDelete}
/>
<div className="MainColumn">
{ShowTopBar && (
<div className="TopBar">
{Settings && Settings.ShowBreadcrumbs ? (
<Breadcrumb
RootName={ActiveRoot}
FilePath={ViewPath}
IsDirectory={ViewIsDirectory}
OnNavigate={HandleNavigateDirectory}
/>
) : (
<span />
)}
{Roots.length > 1 && (
<select
className="RootSwitcher"
value={ActiveRoot || ""}
onChange={(Event) => SetActiveRoot(Event.target.value)}
>
{Roots.map((Root) => (
<option key={Root.Name} value={Root.Name}>
{Root.Name}
</option>
))}
</select>
)}
</div>
)}
<div className="MainBody">
{ViewIsDirectory ? (
<DirectoryPane
RootName={ActiveRoot}
DirectoryPath={ViewPath}
OnEntrySelect={HandleEntrySelect}
OnEntryCreated={RefreshSidebar}
OnEntryRenamed={HandleEntryRenamed}
OnEntryDeleted={HandleEntryDeleted}
UseHyphenBullets={Settings ? Settings.UseHyphenBullets : true}
UseAsciiFolderIcons={Settings ? Settings.UseAsciiFolderIcons : true}
ConfirmBeforeDelete={ConfirmBeforeDelete}
/>
) : (
<PreviewPane
RootName={ActiveRoot}
FilePath={ViewPath}
FileType={ViewFileType}
OnNavigateLink={HandleSelectFile}
ShowGitBlame={Settings ? Settings.ShowGitBlame : false}
StickyToolbar={Settings ? Settings.StickyToolbar : true}
/>
)}
{!ViewIsDirectory && <TagPanel RootName={ActiveRoot} FilePath={ViewPath} />}
</div>
</div>
{IsSettingsOpen && (
<SettingsPanel
CurrentUser={CurrentUser}
OnClose={() => SetIsSettingsOpen(false)}
OnSettingsChanged={HandleSettingsChanged}
OnLoggedOut={HandleLoggedOut}
OnProfileUpdated={HandleProfileUpdated}
/>
)}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import React from "react";
export default function Breadcrumb({ RootName, FilePath, IsDirectory, OnNavigate }) {
const Segments = FilePath ? FilePath.split("/") : [];
function HandleSegmentClick(Index) {
OnNavigate(Segments.slice(0, Index + 1).join("/"));
}
return (
<div className="Breadcrumb">
<span className="BreadcrumbSegment BreadcrumbLink" onClick={() => OnNavigate("")}>
{RootName}
</span>
{Segments.map((Segment, Index) => {
const IsFileSegment = !IsDirectory && Index === Segments.length - 1;
return (
<React.Fragment key={Index}>
<span className="BreadcrumbDivider">/</span>
{IsFileSegment ? (
<span className="BreadcrumbSegment">{Segment}</span>
) : (
<span
className="BreadcrumbSegment BreadcrumbLink"
onClick={() => HandleSegmentClick(Index)}
>
{Segment}
</span>
)}
</React.Fragment>
);
})}
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
import React, { useEffect, useState } from "react";
import { CreateEntry, DeleteEntry, FetchTree, MoveEntry, RenameEntry } from "../Api.js";
import { GetFileIcon, GetFolderIcon } from "../IconStyle.js";
export default function DirectoryPane({
RootName,
DirectoryPath,
OnEntrySelect,
OnEntryCreated,
OnEntryRenamed,
OnEntryDeleted,
UseHyphenBullets,
UseAsciiFolderIcons,
ConfirmBeforeDelete,
}) {
const [Entries, SetEntries] = useState([]);
const [LoadError, SetLoadError] = useState(null);
async function Reload() {
try {
const Fetched = await FetchTree(RootName, DirectoryPath);
SetEntries(Fetched);
SetLoadError(null);
} catch (Error) {
SetLoadError(Error.message);
}
}
useEffect(() => {
if (RootName !== null && RootName !== undefined) {
Reload();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [RootName, DirectoryPath]);
function JoinPath(Name) {
return DirectoryPath ? `${DirectoryPath}/${Name}` : Name;
}
async function HandleCreateFile() {
const Name = window.prompt("New file name (e.g. Notes.md):");
if (!Name) return;
try {
await CreateEntry(RootName, JoinPath(Name), false);
await Reload();
OnEntryCreated();
} catch (Error) {
SetLoadError(Error.message);
}
}
async function HandleCreateDirectory() {
const Name = window.prompt("New directory name:");
if (!Name) return;
try {
await CreateEntry(RootName, JoinPath(Name), true);
await Reload();
OnEntryCreated();
} catch (Error) {
SetLoadError(Error.message);
}
}
async function HandleRename(Event, Entry) {
Event.stopPropagation();
const FullPath = JoinPath(Entry.Name);
const NewValue = window.prompt("Rename to (or type a path to move):", Entry.Name);
if (!NewValue || NewValue === Entry.Name) return;
try {
if (NewValue.includes("/")) {
await MoveEntry(RootName, FullPath, NewValue);
OnEntryRenamed(FullPath, NewValue, Entry.IsDirectory);
} else {
await RenameEntry(RootName, FullPath, NewValue);
OnEntryRenamed(FullPath, JoinPath(NewValue), Entry.IsDirectory);
}
await Reload();
} catch (Error) {
window.alert(Error.message);
}
}
async function HandleDelete(Event, Entry) {
Event.stopPropagation();
const FullPath = JoinPath(Entry.Name);
if (ConfirmBeforeDelete && !window.confirm(`Delete "${Entry.Name}"? This can't be undone.`)) {
return;
}
try {
await DeleteEntry(RootName, FullPath);
OnEntryDeleted(FullPath, Entry.IsDirectory);
await Reload();
} catch (Error) {
window.alert(Error.message);
}
}
return (
<div className="DirectoryPane">
<div className="PreviewToolbar">
<button onClick={HandleCreateFile}>New File</button>
<button onClick={HandleCreateDirectory}>New Directory</button>
</div>
{LoadError && <div className="SidebarError">{LoadError}</div>}
<div className="DirectoryList">
{Entries.map((Entry) => (
<div
className="DirectoryEntry"
key={Entry.Name}
onClick={() => OnEntrySelect(JoinPath(Entry.Name), Entry.IsDirectory, Entry.FileType)}
>
<span className="TreeIcon">
{Entry.IsDirectory
? GetFolderIcon(false, UseAsciiFolderIcons)
: GetFileIcon(UseHyphenBullets)}
</span>
<span className="TreeLabel">{Entry.Name}</span>
<span className="TreeRowActions">
<button
className="TreeRowActionButton"
onClick={(Event) => HandleRename(Event, Entry)}
title="Rename / move"
>
✎
</button>
<button
className="TreeRowActionButton"
onClick={(Event) => HandleDelete(Event, Entry)}
title="Delete"
>
✕
</button>
</span>
</div>
))}
{Entries.length === 0 && !LoadError && (
<div className="DirectoryEmpty">Empty directory.</div>
)}
</div>
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
import React, { useState } from "react";
import { Bootstrap, Login } from "../Api.js";
export default function LoginScreen({ IsBootstrap, OnAuthenticated }) {
const [Username, SetUsername] = useState("");
const [Password, SetPassword] = useState("");
const [ConfirmPassword, SetConfirmPassword] = useState("");
const [Error, SetError] = useState(null);
const [IsSubmitting, SetIsSubmitting] = useState(false);
async function HandleSubmit(Event) {
Event.preventDefault();
SetError(null);
if (IsBootstrap && Password !== ConfirmPassword) {
SetError("Passwords don't match.");
return;
}
SetIsSubmitting(true);
try {
if (IsBootstrap) {
await Bootstrap(Username, Password);
}
const User = await Login(Username, Password);
OnAuthenticated(User);
} catch (SubmitError) {
SetError(SubmitError.message);
} finally {
SetIsSubmitting(false);
}
}
return (
<div className="LoginScreen">
<form className="LoginBox" onSubmit={HandleSubmit}>
<div className="LoginTitle">
{IsBootstrap ? "Create the admin account" : "SaraView"}
</div>
{IsBootstrap && (
<div className="LoginSubtitle">
No accounts exist yet — this first one becomes an admin.
</div>
)}
<div className="SettingsRow">
<label className="SettingsLabel">Username</label>
<input
type="text"
value={Username}
onChange={(Event) => SetUsername(Event.target.value)}
autoFocus
/>
</div>
<div className="SettingsRow">
<label className="SettingsLabel">Password</label>
<input
type="password"
value={Password}
onChange={(Event) => SetPassword(Event.target.value)}
/>
</div>
{IsBootstrap && (
<div className="SettingsRow">
<label className="SettingsLabel">Confirm password</label>
<input
type="password"
value={ConfirmPassword}
onChange={(Event) => SetConfirmPassword(Event.target.value)}
/>
</div>
)}
{Error && <div className="SettingsSaveError">{Error}</div>}
<button type="submit" disabled={IsSubmitting}>
{IsSubmitting ? "..." : IsBootstrap ? "Create account & log in" : "Log in"}
</button>
</form>
</div>
);
}
+284
View File
@@ -0,0 +1,284 @@
import React, { useEffect, useState } from "react";
import CodeMirror from "@uiw/react-codemirror";
import { gutter, GutterMarker } from "@codemirror/view";
import { markdown } from "@codemirror/lang-markdown";
import { html } from "@codemirror/lang-html";
import { json } from "@codemirror/lang-json";
import MarkdownIt from "markdown-it";
import MarkdownItTaskLists from "markdown-it-task-lists";
import { FetchBlame, FetchFile, GetFileUrl, GetRawFileUrl, SaveFile } from "../Api.js";
const MarkdownRenderer = new MarkdownIt({ breaks: true }).use(MarkdownItTaskLists, {
enabled: true,
label: true,
});
const DefaultImageRenderer =
MarkdownRenderer.renderer.rules.image ||
((Tokens, Index, Options, Env, Self) => Self.renderToken(Tokens, Index, Options));
MarkdownRenderer.renderer.rules.image = (Tokens, Index, Options, Env, Self) => {
const Token = Tokens[Index];
const SrcAttrIndex = Token.attrIndex("src");
if (SrcAttrIndex >= 0 && Env && Env.ResolveImageSrc) {
Token.attrs[SrcAttrIndex][1] = Env.ResolveImageSrc(Token.attrs[SrcAttrIndex][1]);
}
return DefaultImageRenderer(Tokens, Index, Options, Env, Self);
};
const TextTypes = ["markdown", "text", "config", "html"];
const LinkedFileTypeMap = {
".md": "markdown",
".markdown": "markdown",
".txt": "text",
".toml": "config",
".yaml": "config",
".yml": "config",
".json": "config",
".html": "html",
".htm": "html",
".png": "image",
".jpg": "image",
".jpeg": "image",
".gif": "image",
".webp": "image",
".svg": "image",
".mp4": "video",
".webm": "video",
".mov": "video",
".mkv": "video",
};
function ClassifyLinkedFile(LinkedPath) {
const DotIndex = LinkedPath.lastIndexOf(".");
const Extension = DotIndex === -1 ? "" : LinkedPath.slice(DotIndex).toLowerCase();
return LinkedFileTypeMap[Extension] || "unknown";
}
function ResolveRelativeLink(CurrentFilePath, Href) {
if (/^([a-z][a-z0-9+.-]*:)?\/\//i.test(Href) || Href.startsWith("#") || Href.startsWith("mailto:")) {
return null;
}
const CurrentDirectory = CurrentFilePath.includes("/")
? CurrentFilePath.slice(0, CurrentFilePath.lastIndexOf("/"))
: "";
const Combined = CurrentDirectory ? `${CurrentDirectory}/${Href}` : Href;
const ResolvedParts = [];
for (const Part of Combined.split("/")) {
if (Part === "" || Part === ".") continue;
if (Part === "..") {
ResolvedParts.pop();
} else {
ResolvedParts.push(Part);
}
}
return ResolvedParts.join("/");
}
function GetLanguageExtension(FileType) {
if (FileType === "markdown") return [markdown()];
if (FileType === "html") return [html()];
if (FileType === "config") return [json()];
return [];
}
class BlameMarker extends GutterMarker {
constructor(ShortLabel, Title) {
super();
this.ShortLabel = ShortLabel;
this.Title = Title;
}
eq(Other) {
return this.ShortLabel === Other.ShortLabel && this.Title === Other.Title;
}
toDOM() {
const Element = document.createElement("span");
Element.className = "BlameGutterMarker";
Element.textContent = this.ShortLabel;
Element.title = this.Title;
return Element;
}
}
function BuildBlameGutter(BlameMap) {
return gutter({
class: "BlameGutter",
lineMarker(view, line) {
const LineNumber = view.state.doc.lineAt(line.from).number;
const Entry = BlameMap[String(LineNumber)];
if (!Entry) return null;
const ShortLabel = Entry.Username.slice(0, 6);
const Title = `${Entry.Username} — ${new Date(Entry.Timestamp).toLocaleString()}`;
return new BlameMarker(ShortLabel, Title);
},
});
}
export default function PreviewPane({
RootName,
FilePath,
FileType,
OnNavigateLink,
ShowGitBlame,
StickyToolbar,
}) {
const [Content, SetContent] = useState("");
const [IsEditing, SetIsEditing] = useState(false);
const [IsSaving, SetIsSaving] = useState(false);
const [SaveError, SetSaveError] = useState(null);
const [BlameMap, SetBlameMap] = useState({});
useEffect(() => {
SetIsEditing(false);
SetSaveError(null);
SetBlameMap({});
if (FilePath && TextTypes.includes(FileType)) {
FetchFile(RootName, FilePath).then(SetContent);
if (ShowGitBlame) {
FetchBlame(RootName, FilePath)
.then(SetBlameMap)
.catch(() => SetBlameMap({}));
}
}
}, [RootName, FilePath, FileType, ShowGitBlame]);
if (!FilePath) {
return <div className="PreviewEmpty">Select a file to preview.</div>;
}
const ToolbarClass = `PreviewToolbar${StickyToolbar ? " PreviewToolbarSticky" : ""}`;
async function HandleSave() {
SetIsSaving(true);
SetSaveError(null);
try {
await SaveFile(RootName, FilePath, Content);
if (ShowGitBlame) {
FetchBlame(RootName, FilePath)
.then(SetBlameMap)
.catch(() => {});
}
} catch (Error) {
SetSaveError(Error.message);
} finally {
SetIsSaving(false);
}
}
function HandleMarkdownClick(Event) {
const AnchorElement = Event.target.closest("a");
if (!AnchorElement) return;
const Href = AnchorElement.getAttribute("href");
if (!Href) return;
const ResolvedPath = ResolveRelativeLink(FilePath, Href);
if (ResolvedPath === null) return;
Event.preventDefault();
OnNavigateLink(ResolvedPath, ClassifyLinkedFile(ResolvedPath));
}
if (FileType === "image") {
return (
<div className="PreviewMedia">
<img src={GetFileUrl(RootName, FilePath)} alt={FilePath} />
</div>
);
}
if (FileType === "video") {
return (
<div className="PreviewMedia">
<video controls src={GetFileUrl(RootName, FilePath)} />
</div>
);
}
if (FileType === "html" && !IsEditing) {
return (
<div className="PreviewFrame">
<div className={ToolbarClass}>
<button onClick={() => SetIsEditing(true)}>Edit</button>
</div>
{/* Raw, path-shaped URL (not the query-param /api/file form) so
relative <link>/<script>/<img> references inside the HTML
resolve against their real sibling files. */}
<iframe title={FilePath} sandbox="" src={GetRawFileUrl(RootName, FilePath)} />
</div>
);
}
if (FileType === "markdown" && !IsEditing) {
return (
<div className="PreviewMarkdown">
<div className={ToolbarClass}>
<button onClick={() => SetIsEditing(true)}>Edit</button>
</div>
<div
className="MarkdownBody"
onClick={HandleMarkdownClick}
dangerouslySetInnerHTML={{
__html: MarkdownRenderer.render(Content, {
ResolveImageSrc: (Src) => {
const ResolvedPath = ResolveRelativeLink(FilePath, Src);
return ResolvedPath === null ? Src : GetFileUrl(RootName, ResolvedPath);
},
}),
}}
/>
</div>
);
}
if (FileType === "text" && !IsEditing) {
return (
<div className="PreviewText">
<div className={ToolbarClass}>
<button onClick={() => SetIsEditing(true)}>Edit</button>
</div>
<pre className="TextBody">{Content}</pre>
</div>
);
}
if (TextTypes.includes(FileType)) {
return (
<div className="PreviewEditor">
<div className={ToolbarClass}>
{(FileType === "markdown" || FileType === "text" || FileType === "html") && (
<button onClick={() => SetIsEditing(false)}>View</button>
)}
<button onClick={HandleSave} disabled={IsSaving}>
{IsSaving ? "Saving…" : "Save"}
</button>
{SaveError && <span className="SaveErrorText">{SaveError}</span>}
</div>
<CodeMirror
value={Content}
height="100%"
extensions={[
...GetLanguageExtension(FileType),
...(ShowGitBlame ? [BuildBlameGutter(BlameMap)] : []),
]}
onChange={SetContent}
theme="dark"
/>
</div>
);
}
return <div className="PreviewEmpty">No preview available for this file type.</div>;
}
+424
View File
@@ -0,0 +1,424 @@
import React, { useEffect, useState } from "react";
import {
CreateUserAccount,
DeleteTheme,
FetchProfileColourOptions,
FetchSettings,
FetchTheme,
FetchThemeNames,
FetchUsers,
GetAvatarUrl,
Logout,
SaveSettings,
SaveTheme,
UpdateProfileColour,
UploadAvatar,
} from "../Api.js";
import Switch from "./Switch.jsx";
import { PaletteRoles } from "../ThemeRoles.js";
export default function SettingsPanel({
CurrentUser,
OnClose,
OnSettingsChanged,
OnLoggedOut,
OnProfileUpdated,
}) {
const [Settings, SetSettings] = useState(null);
const [ThemeNames, SetThemeNames] = useState([]);
const [SaveError, SetSaveError] = useState(null);
const [IsSaving, SetIsSaving] = useState(false);
const [IsCreatingTheme, SetIsCreatingTheme] = useState(false);
const [NewThemeName, SetNewThemeName] = useState("");
const [NewThemePalette, SetNewThemePalette] = useState(null);
const [ThemeError, SetThemeError] = useState(null);
const [Users, SetUsers] = useState(null);
const [NewUsername, SetNewUsername] = useState("");
const [NewUserPassword, SetNewUserPassword] = useState("");
const [NewUserIsAdmin, SetNewUserIsAdmin] = useState(false);
const [NewUserColour, SetNewUserColour] = useState("Purple");
const [UserError, SetUserError] = useState(null);
const [ColourOptions, SetColourOptions] = useState([]);
const [ProfileError, SetProfileError] = useState(null);
const [AvatarNonce, SetAvatarNonce] = useState(0);
useEffect(() => {
FetchSettings().then(SetSettings);
FetchThemeNames().then(SetThemeNames);
FetchProfileColourOptions().then(SetColourOptions);
if (CurrentUser && CurrentUser.IsAdmin) {
FetchUsers().then(SetUsers);
}
}, [CurrentUser]);
if (!Settings) {
return null;
}
function UpdateField(Field, Value) {
SetSettings({ ...Settings, [Field]: Value });
}
async function HandleSave() {
SetIsSaving(true);
SetSaveError(null);
try {
const Saved = await SaveSettings(Settings);
OnSettingsChanged(Saved);
OnClose();
} catch (Error) {
SetSaveError(Error.message);
} finally {
SetIsSaving(false);
}
}
async function HandleStartCreateTheme() {
SetThemeError(null);
const BasePalette = await FetchTheme(Settings.ThemeName);
SetNewThemePalette(BasePalette);
SetNewThemeName("");
SetIsCreatingTheme(true);
}
async function HandleSaveNewTheme() {
if (!NewThemeName.trim()) return;
try {
await SaveTheme(NewThemeName.trim(), NewThemePalette);
const Updated = await FetchThemeNames();
SetThemeNames(Updated);
UpdateField("ThemeName", NewThemeName.trim());
SetIsCreatingTheme(false);
} catch (Error) {
SetThemeError(Error.message);
}
}
async function HandleRenameTheme() {
const NewName = window.prompt("Rename theme to:", Settings.ThemeName);
if (!NewName || !NewName.trim() || NewName.trim() === Settings.ThemeName) return;
SetThemeError(null);
try {
const Palette = await FetchTheme(Settings.ThemeName);
await SaveTheme(NewName.trim(), Palette);
await DeleteTheme(Settings.ThemeName);
const Updated = await FetchThemeNames();
SetThemeNames(Updated);
UpdateField("ThemeName", NewName.trim());
} catch (Error) {
SetThemeError(Error.message);
}
}
async function HandleDeleteTheme() {
if (ThemeNames.length <= 1) {
SetThemeError("Can't delete the only remaining theme.");
return;
}
if (!window.confirm(`Delete theme "${Settings.ThemeName}"? This can't be undone.`)) return;
SetThemeError(null);
try {
await DeleteTheme(Settings.ThemeName);
const Updated = await FetchThemeNames();
SetThemeNames(Updated);
UpdateField("ThemeName", Updated[0]);
} catch (Error) {
SetThemeError(Error.message);
}
}
async function HandleLogout() {
await Logout();
OnLoggedOut();
}
async function HandleCreateUser() {
SetUserError(null);
try {
await CreateUserAccount(NewUsername.trim(), NewUserPassword, NewUserIsAdmin, NewUserColour);
const Updated = await FetchUsers();
SetUsers(Updated);
SetNewUsername("");
SetNewUserPassword("");
SetNewUserIsAdmin(false);
SetNewUserColour("Purple");
} catch (Error) {
SetUserError(Error.message);
}
}
async function HandlePickColour(Colour) {
SetProfileError(null);
try {
const Updated = await UpdateProfileColour(Colour);
OnProfileUpdated(Updated);
} catch (Error) {
SetProfileError(Error.message);
}
}
async function HandleAvatarChange(Event) {
const File = Event.target.files && Event.target.files[0];
if (!File) return;
SetProfileError(null);
try {
await UploadAvatar(File);
const NextNonce = Date.now();
SetAvatarNonce(NextNonce);
OnProfileUpdated({ AvatarNonce: NextNonce });
} catch (Error) {
SetProfileError(Error.message);
}
}
return (
<div className="SettingsOverlay" onClick={OnClose}>
<div className="SettingsModal" onClick={(Event) => Event.stopPropagation()}>
<div className="SettingsHeader">
<span>Settings</span>
<button className="SettingsClose" onClick={OnClose}>
✕
</button>
</div>
<div className="SettingsBody">
<div className="SettingsRow">
<label className="SettingsLabel">Font family</label>
<input
type="text"
value={Settings.FontFamily}
onChange={(Event) => UpdateField("FontFamily", Event.target.value)}
/>
</div>
<div className="SettingsRow">
<label className="SettingsLabel">Font size (px)</label>
<input
type="number"
value={Settings.FontSize}
onChange={(Event) => UpdateField("FontSize", Number(Event.target.value))}
/>
</div>
<div className="SettingsRowInline">
<label className="SettingsLabel">Show hidden directories</label>
<Switch Checked={Settings.ShowHiddenFolders} OnChange={(Event) => UpdateField("ShowHiddenFolders", Event.target.checked)} />
</div>
<div className="SettingsRowInline">
<label className="SettingsLabel">Show breadcrumbs</label>
<Switch Checked={Settings.ShowBreadcrumbs} OnChange={(Event) => UpdateField("ShowBreadcrumbs", Event.target.checked)} />
</div>
<div className="SettingsRowInline">
<label className="SettingsLabel">Hyphen file icons (-)</label>
<Switch Checked={Settings.UseHyphenBullets} OnChange={(Event) => UpdateField("UseHyphenBullets", Event.target.checked)} />
</div>
<div className="SettingsRowInline">
<label className="SettingsLabel">ASCII directory icons (&gt;/v)</label>
<Switch Checked={Settings.UseAsciiFolderIcons} OnChange={(Event) => UpdateField("UseAsciiFolderIcons", Event.target.checked)} />
</div>
<div className="SettingsRowInline">
<label className="SettingsLabel">Open directory file on click</label>
<Switch Checked={Settings.NavigateIntoDirectories} OnChange={(Event) => UpdateField("NavigateIntoDirectories", Event.target.checked)} />
</div>
<div className="SettingsRowInline">
<label className="SettingsLabel">New File/Directory buttons at top of sidebar</label>
<Switch Checked={Settings.NewEntryButtonsAtTop} OnChange={(Event) => UpdateField("NewEntryButtonsAtTop", Event.target.checked)} />
</div>
<div className="SettingsRowInline">
<label className="SettingsLabel">Show blame gutter (who last changed each line)</label>
<Switch Checked={Settings.ShowGitBlame} OnChange={(Event) => UpdateField("ShowGitBlame", Event.target.checked)} />
</div>
<div className="SettingsRowInline">
<label className="SettingsLabel">Sticky edit/view/save toolbar</label>
<Switch Checked={Settings.StickyToolbar} OnChange={(Event) => UpdateField("StickyToolbar", Event.target.checked)} />
</div>
<div className="SettingsRowInline">
<label className="SettingsLabel">Confirm before delete</label>
<Switch Checked={Settings.ConfirmBeforeDelete} OnChange={(Event) => UpdateField("ConfirmBeforeDelete", Event.target.checked)} />
</div>
<div className="SettingsSection">
<div className="SettingsSectionTitle">Colour theme</div>
<div className="SettingsRow">
<select
value={Settings.ThemeName}
onChange={(Event) => UpdateField("ThemeName", Event.target.value)}
>
{ThemeNames.map((Name) => (
<option key={Name} value={Name}>
{Name}
</option>
))}
</select>
<div className="SettingsRowInline">
<button onClick={HandleRenameTheme}>Rename</button>
<button onClick={HandleDeleteTheme}>Delete</button>
</div>
</div>
{!IsCreatingTheme && (
<button onClick={HandleStartCreateTheme}>Create from current theme</button>
)}
{IsCreatingTheme && NewThemePalette && (
<>
<div className="SettingsRow">
<label className="SettingsLabel">New theme name</label>
<input
type="text"
value={NewThemeName}
onChange={(Event) => SetNewThemeName(Event.target.value)}
placeholder="MyTheme"
/>
</div>
<div className="PaletteGrid">
{PaletteRoles.map((Role) => (
<div className="PaletteSwatchRow" key={Role}>
<input
type="color"
value={NewThemePalette[Role] || "#000000"}
onChange={(Event) =>
SetNewThemePalette({ ...NewThemePalette, [Role]: Event.target.value })
}
/>
<span>{Role}</span>
</div>
))}
</div>
<button onClick={HandleSaveNewTheme}>Save theme</button>
</>
)}
{ThemeError && <div className="SettingsSaveError">{ThemeError}</div>}
</div>
<div className="SettingsSection">
<div className="SettingsSectionTitle">Profile</div>
<div className="SettingsRow">
<label className="SettingsLabel">Your colour</label>
<div className="ColourSwatchPicker">
{ColourOptions.map((Colour) => (
<button
key={Colour}
className={`ColourSwatch${CurrentUser && CurrentUser.Colour === Colour ? " ColourSwatchSelected" : ""}`}
style={{ background: `var(--${Colour})` }}
title={Colour}
onClick={() => HandlePickColour(Colour)}
/>
))}
</div>
</div>
<div className="SettingsRow">
<label className="SettingsLabel">Avatar</label>
<div className="AvatarUploadRow">
{CurrentUser && (
<img
className="AvatarPreview"
src={`${GetAvatarUrl(CurrentUser.Username)}&v=${AvatarNonce}`}
alt=""
onError={(Event) => {
Event.target.style.visibility = "hidden";
}}
/>
)}
<input type="file" accept="image/*" onChange={HandleAvatarChange} />
</div>
</div>
{ProfileError && <div className="SettingsSaveError">{ProfileError}</div>}
</div>
<div className="SettingsSection">
<div className="SettingsSectionTitle">Account</div>
<div className="SettingsRowInline">
<span className="SettingsLabel">
Logged in as {CurrentUser ? CurrentUser.Username : "..."}
{CurrentUser && CurrentUser.IsAdmin ? " (admin)" : ""}
</span>
<button onClick={HandleLogout}>Log out</button>
</div>
</div>
{CurrentUser && CurrentUser.IsAdmin && (
<div className="SettingsSection">
<div className="SettingsSectionTitle">Users (admin)</div>
{Users &&
Users.map((User) => (
<div className="SettingsRowInline" key={User.Username}>
<span className="SettingsLabel">
<span
className="ColourSwatchSmall"
style={{ background: `var(--${User.Colour || "Purple"})` }}
/>
{User.Username}
{User.IsAdmin ? " (admin)" : ""}
</span>
</div>
))}
<div className="SettingsRow">
<label className="SettingsLabel">New username</label>
<input
type="text"
value={NewUsername}
onChange={(Event) => SetNewUsername(Event.target.value)}
/>
</div>
<div className="SettingsRow">
<label className="SettingsLabel">New password</label>
<input
type="password"
value={NewUserPassword}
onChange={(Event) => SetNewUserPassword(Event.target.value)}
/>
</div>
<div className="SettingsRow">
<label className="SettingsLabel">Colour</label>
<div className="ColourSwatchPicker">
{ColourOptions.map((Colour) => (
<button
key={Colour}
className={`ColourSwatch${NewUserColour === Colour ? " ColourSwatchSelected" : ""}`}
style={{ background: `var(--${Colour})` }}
title={Colour}
onClick={() => SetNewUserColour(Colour)}
/>
))}
</div>
</div>
<div className="SettingsRowInline">
<label className="SettingsLabel">Admin</label>
<Switch Checked={NewUserIsAdmin} OnChange={(Event) => SetNewUserIsAdmin(Event.target.checked)} />
</div>
{UserError && <div className="SettingsSaveError">{UserError}</div>}
<button onClick={HandleCreateUser}>Create user</button>
</div>
)}
{SaveError && <div className="SettingsSaveError">{SaveError}</div>}
</div>
<div className="SettingsFooter">
<button onClick={OnClose}>Cancel</button>
<button onClick={HandleSave} disabled={IsSaving}>
{IsSaving ? "Saving…" : "Save"}
</button>
</div>
</div>
</div>
);
}
+296
View File
@@ -0,0 +1,296 @@
import React, { useEffect, useState } from "react";
import { CreateEntry, DeleteEntry, FetchTree, GetAvatarUrl, MoveEntry, RenameEntry } from "../Api.js";
import { GetFileIcon, GetFolderIcon } from "../IconStyle.js";
function TreeNode({
RootName,
Entry,
ParentPath,
SelectedFile,
CurrentDirectoryPath,
OnSelectFile,
OnNavigateDirectory,
OnBrowseDirectory,
OnEntryRenamed,
OnEntryDeleted,
UseHyphenBullets,
UseAsciiFolderIcons,
NavigateIntoDirectories,
ConfirmBeforeDelete,
}) {
const [IsExpanded, SetIsExpanded] = useState(false);
const [Children, SetChildren] = useState(null);
const FullPath = ParentPath ? `${ParentPath}/${Entry.Name}` : Entry.Name;
const IsSelected = SelectedFile === FullPath;
const IsCurrentDirectory = Entry.IsDirectory && CurrentDirectoryPath === FullPath;
const IsAncestorOfCurrentDirectory =
Entry.IsDirectory &&
CurrentDirectoryPath &&
(CurrentDirectoryPath === FullPath || CurrentDirectoryPath.startsWith(`${FullPath}/`));
async function LoadChildren() {
try {
const Fetched = await FetchTree(RootName, FullPath);
SetChildren(Fetched);
return Fetched;
} catch (Error) {
SetChildren([]);
return [];
}
}
async function ToggleExpand() {
if (!Entry.IsDirectory) {
OnSelectFile(FullPath, Entry.FileType);
return;
}
if (!IsExpanded && Children === null) {
await LoadChildren();
}
SetIsExpanded(!IsExpanded);
OnBrowseDirectory(FullPath);
if (NavigateIntoDirectories) {
OnNavigateDirectory(FullPath);
}
}
useEffect(() => {
if (IsAncestorOfCurrentDirectory && !IsExpanded) {
if (Children === null) {
LoadChildren().then(() => SetIsExpanded(true));
} else {
SetIsExpanded(true);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [CurrentDirectoryPath]);
async function HandleRename(Event) {
Event.stopPropagation();
const NewValue = window.prompt("Rename to (or type a path to move):", Entry.Name);
if (!NewValue || NewValue === Entry.Name) return;
try {
if (NewValue.includes("/")) {
await MoveEntry(RootName, FullPath, NewValue);
OnEntryRenamed(FullPath, NewValue, Entry.IsDirectory);
} else {
await RenameEntry(RootName, FullPath, NewValue);
const NewPath = ParentPath ? `${ParentPath}/${NewValue}` : NewValue;
OnEntryRenamed(FullPath, NewPath, Entry.IsDirectory);
}
} catch (Error) {
window.alert(Error.message);
}
}
async function HandleDelete(Event) {
Event.stopPropagation();
if (ConfirmBeforeDelete && !window.confirm(`Delete "${Entry.Name}"? This can't be undone.`)) {
return;
}
try {
await DeleteEntry(RootName, FullPath);
OnEntryDeleted(FullPath, Entry.IsDirectory);
} catch (Error) {
window.alert(Error.message);
}
}
return (
<div className="TreeNode">
<div
className={`TreeRow${IsSelected ? " TreeRowSelected" : ""}${
IsCurrentDirectory ? " TreeRowCurrentDirectory" : ""
}`}
onClick={ToggleExpand}
>
<span className="TreeIcon">
{Entry.IsDirectory
? GetFolderIcon(IsExpanded, UseAsciiFolderIcons)
: GetFileIcon(UseHyphenBullets)}
</span>
<span className="TreeLabel">{Entry.Name}</span>
<span className="TreeRowActions">
<button className="TreeRowActionButton" onClick={HandleRename} title="Rename / move">
✎
</button>
<button className="TreeRowActionButton" onClick={HandleDelete} title="Delete">
✕
</button>
</span>
</div>
{IsExpanded && Children && (
<div className="TreeChildren">
{Children.map((Child) => (
<TreeNode
key={Child.Name}
RootName={RootName}
Entry={Child}
ParentPath={FullPath}
SelectedFile={SelectedFile}
CurrentDirectoryPath={CurrentDirectoryPath}
OnSelectFile={OnSelectFile}
OnNavigateDirectory={OnNavigateDirectory}
OnBrowseDirectory={OnBrowseDirectory}
OnEntryRenamed={OnEntryRenamed}
OnEntryDeleted={OnEntryDeleted}
UseHyphenBullets={UseHyphenBullets}
UseAsciiFolderIcons={UseAsciiFolderIcons}
NavigateIntoDirectories={NavigateIntoDirectories}
ConfirmBeforeDelete={ConfirmBeforeDelete}
/>
))}
</div>
)}
</div>
);
}
function UserBadge({ CurrentUser, OnOpenSettings }) {
const [AvatarFailed, SetAvatarFailed] = useState(false);
useEffect(() => {
SetAvatarFailed(false);
}, [CurrentUser && CurrentUser.AvatarNonce]);
if (!CurrentUser) return null;
const AvatarUrl = CurrentUser.AvatarNonce
? `${GetAvatarUrl(CurrentUser.Username)}&v=${CurrentUser.AvatarNonce}`
: GetAvatarUrl(CurrentUser.Username);
return (
<span className="UserBadge" onClick={OnOpenSettings} title={CurrentUser.Username}>
{!AvatarFailed ? (
<img
className="UserBadgeAvatar"
src={AvatarUrl}
alt=""
onError={() => SetAvatarFailed(true)}
/>
) : (
<span
className="UserBadgeInitial"
style={{ background: `var(--${CurrentUser.Colour || "Purple"})` }}
>
{CurrentUser.Username.slice(0, 1).toUpperCase()}
</span>
)}
</span>
);
}
export default function Sidebar({
RootName,
SelectedFile,
CurrentDirectoryPath,
CurrentUser,
OnSelectFile,
OnNavigateDirectory,
OnBrowseDirectory,
OnEntryRenamed,
OnEntryDeleted,
OnOpenSettings,
OnEntryCreated,
UseHyphenBullets,
UseAsciiFolderIcons,
NavigateIntoDirectories,
NewEntryButtonsAtTop,
ConfirmBeforeDelete,
}) {
const [RootEntries, SetRootEntries] = useState([]);
const [LoadError, SetLoadError] = useState(null);
useEffect(() => {
if (RootName) {
SetLoadError(null);
FetchTree(RootName, "")
.then(SetRootEntries)
.catch((Error) => SetLoadError(Error.message));
}
}, [RootName]);
function JoinPath(Name) {
return CurrentDirectoryPath ? `${CurrentDirectoryPath}/${Name}` : Name;
}
async function HandleCreateFile() {
const Name = window.prompt(
`New file name in ${CurrentDirectoryPath || "root"} (e.g. Notes.md):`
);
if (!Name) return;
try {
await CreateEntry(RootName, JoinPath(Name), false);
OnEntryCreated();
} catch (Error) {
SetLoadError(Error.message);
}
}
async function HandleCreateDirectory() {
const Name = window.prompt(`New directory name in ${CurrentDirectoryPath || "root"}:`);
if (!Name) return;
try {
await CreateEntry(RootName, JoinPath(Name), true);
OnEntryCreated();
} catch (Error) {
SetLoadError(Error.message);
}
}
const NewEntryButtons = (
<div className={`SidebarNewEntry${NewEntryButtonsAtTop ? " SidebarNewEntryTop" : ""}`}>
<button onClick={HandleCreateFile}>New File</button>
<button onClick={HandleCreateDirectory}>New Directory</button>
</div>
);
return (
<div className="Sidebar">
<div className="SidebarHeader">
<span className="SidebarHeaderLink" onClick={() => OnNavigateDirectory("")}>
SaraView
</span>
<span className="SidebarHeaderRight">
<UserBadge CurrentUser={CurrentUser} OnOpenSettings={OnOpenSettings} />
<button className="SettingsButton" onClick={OnOpenSettings}>
⚙
</button>
</span>
</div>
{NewEntryButtonsAtTop && NewEntryButtons}
{LoadError && <div className="SidebarError">{LoadError}</div>}
<div className="SidebarTree">
{RootEntries.map((Entry) => (
<TreeNode
key={Entry.Name}
RootName={RootName}
Entry={Entry}
ParentPath=""
SelectedFile={SelectedFile}
CurrentDirectoryPath={CurrentDirectoryPath}
OnSelectFile={OnSelectFile}
OnNavigateDirectory={OnNavigateDirectory}
OnBrowseDirectory={OnBrowseDirectory}
OnEntryRenamed={OnEntryRenamed}
OnEntryDeleted={OnEntryDeleted}
UseHyphenBullets={UseHyphenBullets}
UseAsciiFolderIcons={UseAsciiFolderIcons}
NavigateIntoDirectories={NavigateIntoDirectories}
ConfirmBeforeDelete={ConfirmBeforeDelete}
/>
))}
</div>
{!NewEntryButtonsAtTop && NewEntryButtons}
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import React from "react";
export default function Switch({ Checked, OnChange }) {
return (
<label className="Switch">
<input type="checkbox" checked={Checked} onChange={OnChange} />
<span className="SwitchTrack">
<span className="SwitchThumb" />
</span>
</label>
);
}
+42
View File
@@ -0,0 +1,42 @@
import React, { useEffect, useState } from "react";
import { FetchBook } from "../Api.js";
export default function TagPanel({ RootName, FilePath }) {
const [Book, SetBook] = useState(null);
useEffect(() => {
SetBook(null);
if (RootName && FilePath) {
FetchBook(RootName, FilePath).then((Result) => {
if (Result && Object.keys(Result).length > 0) {
SetBook(Result);
}
});
}
}, [RootName, FilePath]);
if (!Book) {
return null;
}
const TagEntries = Object.entries(Book.Tags || {});
return (
<div className="TagPanel">
<div className="TagPanelHeader">Tags</div>
<div className="TagPanelBody">
{TagEntries.length === 0 && (
<div className="TagPanelEmpty">No tags recorded.</div>
)}
{TagEntries.map(([TagName, TagValue]) => (
<div className="TagRow" key={TagName}>
<span className="TagName">{TagName}</span>
<span className="TagValue">{String(TagValue)}</span>
</div>
))}
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
export function GetFolderIcon(IsExpanded, UseAsciiFolderIcons) {
if (UseAsciiFolderIcons) {
return IsExpanded ? "v" : ">";
}
return IsExpanded ? "▾" : "▸";
}
export function GetFileIcon(UseHyphenBullets) {
return UseHyphenBullets ? "-" : "•";
}
+11
View File
@@ -0,0 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "./Styles/Theme.css";
ReactDOM.createRoot(document.getElementById("Root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+794
View File
@@ -0,0 +1,794 @@
@font-face {
font-family: "Fixedsys Excelsior";
src: url("/Fonts/FSEX302.ttf") format("truetype");
font-display: swap;
}
/*
* Palette roles below are DEFAULTS ONLY. The active theme's actual values
* are applied at runtime (see App.jsx), which sets these same custom
* property names on :root via JS after fetching the chosen theme from the
* backend. Any theme file just needs to supply this same set of roles.
*/
:root {
--Background: #1e1e2e;
--Surface: #181825;
--SurfaceAlt: #313244;
--Border: #45475a;
--Text: #cdd6f4;
--TextMuted: #6c7086;
--Black: #11111b;
--White: #cdd6f4;
--Red: #f38ba8;
--Orange: #fab387;
--Yellow: #f9e2af;
--Green: #a6e3a1;
--Blue: #89b4fa;
--Purple: #cba6f7;
--FontFamily: "Fixedsys Excelsior", "Cascadia Mono", monospace;
--FontSize: 14px;
--HeaderHeight: 45px;
}
* {
box-sizing: border-box;
}
html,
body,
#Root {
height: 100%;
margin: 0;
}
body {
background: var(--Background);
color: var(--Text);
font-family: var(--FontFamily);
font-size: var(--FontSize);
font-smooth: never;
-webkit-font-smoothing: none;
}
img,
.PixelIcon {
image-rendering: pixelated;
}
.AppShell {
display: flex;
height: 100vh;
}
/* Sidebar / file tree */
.Sidebar {
width: 260px;
flex-shrink: 0;
background: var(--Surface);
border-right: 1px solid var(--Border);
display: flex;
flex-direction: column;
}
.SidebarTree {
flex: 1;
overflow-y: auto;
}
.SidebarNewEntry {
display: flex;
gap: 8px;
padding: 8px 16px;
border-top: 1px solid var(--Border);
}
.SidebarNewEntryTop {
border-top: none;
border-bottom: 1px solid var(--Border);
}
.SidebarNewEntry button {
flex: 1;
background: var(--SurfaceAlt);
color: var(--Text);
border: 1px solid var(--Purple);
border-radius: 4px;
padding: 4px 8px;
font-family: inherit;
font-size: 13px;
cursor: pointer;
}
.SidebarNewEntry button:hover {
background: var(--Border);
}
.SidebarHeader {
height: var(--HeaderHeight);
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
color: var(--Purple);
font-size: 18px;
border-bottom: 1px solid var(--Purple);
}
.SidebarHeaderLink {
cursor: pointer;
}
.SidebarHeaderLink:hover {
text-decoration: underline;
}
.SidebarHeaderRight {
display: flex;
align-items: center;
gap: 8px;
}
.UserBadge {
cursor: pointer;
display: flex;
align-items: center;
}
.UserBadgeAvatar {
width: 22px;
height: 22px;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--Border);
}
.UserBadgeInitial {
width: 22px;
height: 22px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: var(--Background);
}
.SettingsButton {
background: none;
border: 1px solid var(--Purple);
color: var(--Purple);
border-radius: 4px;
padding: 2px 8px;
font-family: inherit;
font-size: 14px;
cursor: pointer;
}
.SettingsButton:hover {
background: var(--SurfaceAlt);
}
.SidebarError {
padding: 10px 16px;
color: var(--Red);
font-size: 13px;
}
.TreeRow {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 16px;
cursor: pointer;
}
.TreeRow:hover {
background: var(--SurfaceAlt);
}
.TreeRowSelected {
background: var(--Border);
color: var(--Purple);
}
.TreeRowCurrentDirectory {
border-left: 2px solid var(--Purple);
padding-left: 14px;
}
.TreeIcon {
width: 12px;
text-align: center;
color: var(--TextMuted);
}
.TreeLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.TreeRowActions {
display: none;
gap: 4px;
flex-shrink: 0;
}
.TreeRow:hover .TreeRowActions,
.DirectoryEntry:hover .TreeRowActions {
display: flex;
}
.TreeRowActionButton {
background: none;
border: 1px solid var(--Border);
color: var(--TextMuted);
border-radius: 3px;
width: 20px;
height: 20px;
line-height: 1;
font-size: 11px;
cursor: pointer;
}
.TreeRowActionButton:hover {
border-color: var(--Purple);
color: var(--Purple);
}
.TreeChildren {
padding-left: 14px;
}
/* Main column */
.MainColumn {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.TopBar {
height: var(--HeaderHeight);
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--Border);
}
.Breadcrumb {
padding: 0 16px;
color: var(--TextMuted);
}
.BreadcrumbLink {
cursor: pointer;
}
.BreadcrumbLink:hover {
text-decoration: underline;
}
.BreadcrumbSegment {
color: var(--Purple);
}
.BreadcrumbDivider {
margin: 0 6px;
color: var(--TextMuted);
}
.RootSwitcher {
margin-right: 16px;
background: var(--SurfaceAlt);
color: var(--Text);
border: 1px solid var(--Purple);
border-radius: 4px;
padding: 4px 8px;
font-family: inherit;
}
.MainBody {
flex: 1;
min-height: 0;
display: flex;
}
.PreviewEmpty {
margin: auto;
color: var(--TextMuted);
}
.DirectoryPane {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: auto;
}
.DirectoryList {
padding: 8px 0;
}
.DirectoryEntry {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
cursor: pointer;
}
.DirectoryEntry:hover {
background: var(--SurfaceAlt);
}
.DirectoryEmpty {
padding: 12px 16px;
color: var(--TextMuted);
}
.PreviewMarkdown,
.PreviewEditor,
.PreviewFrame,
.PreviewMedia {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: auto;
}
.PreviewToolbar {
display: flex;
gap: 8px;
padding: 8px 16px;
border-bottom: 1px solid var(--Border);
}
.PreviewToolbarSticky {
position: sticky;
top: 0;
z-index: 2;
background: var(--Background);
}
.PreviewToolbar button {
background: var(--SurfaceAlt);
color: var(--Text);
border: 1px solid var(--Purple);
border-radius: 4px;
padding: 4px 10px;
font-family: inherit;
cursor: pointer;
}
.PreviewToolbar button:hover {
background: var(--Border);
}
.PreviewToolbar button:disabled {
opacity: 0.6;
cursor: default;
}
.SaveErrorText {
color: var(--Red);
align-self: center;
font-size: 13px;
}
.MarkdownBody {
padding: 24px;
max-width: 720px;
line-height: 1.6;
}
.MarkdownBody h1,
.MarkdownBody h2,
.MarkdownBody h3 {
color: var(--Purple);
}
.MarkdownBody a {
color: var(--Blue);
}
.MarkdownBody code {
background: var(--SurfaceAlt);
padding: 2px 4px;
border-radius: 3px;
}
.MarkdownBody img {
max-width: 100%;
}
.MarkdownBody ul.contains-task-list {
list-style: none;
padding-left: 4px;
}
.MarkdownBody .task-list-item {
display: flex;
align-items: center;
gap: 6px;
}
.PreviewText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: auto;
}
.TextBody {
margin: 0;
padding: 24px;
max-width: 720px;
line-height: 1.6;
white-space: pre-wrap;
word-wrap: break-word;
font-family: inherit;
}
.BlameGutter {
background: var(--Surface);
border-right: 1px solid var(--Border);
}
.BlameGutterMarker {
display: inline-block;
padding: 0 6px;
font-size: 11px;
color: var(--TextMuted);
white-space: nowrap;
}
.PreviewMedia img,
.PreviewMedia video {
margin: auto;
max-width: 100%;
max-height: 100%;
}
.PreviewFrame iframe {
flex: 1;
border: none;
background: white;
}
/* Tag panel — bordered box style, mirroring the Statistics panel look */
.TagPanel {
width: 280px;
flex-shrink: 0;
margin: 16px;
height: fit-content;
background: var(--Surface);
border: 1px solid var(--Purple);
border-radius: 8px;
overflow: hidden;
}
.TagPanelHeader {
padding: 10px 16px;
color: var(--Purple);
font-size: 16px;
border-bottom: 1px solid var(--Purple);
}
.TagPanelBody {
padding: 4px 0;
}
.TagPanelEmpty {
padding: 12px 16px;
color: var(--TextMuted);
}
.TagRow {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 6px 16px;
border-bottom: 1px solid var(--SurfaceAlt);
}
.TagRow:last-child {
border-bottom: none;
}
.TagName {
color: var(--Text);
}
.TagValue {
color: var(--Orange);
}
/* Settings modal */
.SettingsOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.SettingsModal {
width: 420px;
max-height: 80vh;
overflow-y: auto;
background: var(--Surface);
border: 1px solid var(--Purple);
border-radius: 8px;
}
.SettingsHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 16px;
color: var(--Purple);
font-size: 18px;
border-bottom: 1px solid var(--Purple);
}
.SettingsClose {
background: none;
border: none;
color: var(--TextMuted);
font-size: 18px;
cursor: pointer;
}
.SettingsClose:hover {
color: var(--Text);
}
.SettingsBody {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.SettingsRow {
display: flex;
flex-direction: column;
gap: 6px;
}
.SettingsRowInline {
display: flex;
align-items: center;
justify-content: space-between;
}
.SettingsLabel {
color: var(--TextMuted);
font-size: 13px;
}
.SettingsRow input[type="text"],
.SettingsRow input[type="number"],
.SettingsRow select {
background: var(--SurfaceAlt);
color: var(--Text);
border: 1px solid var(--Border);
border-radius: 4px;
padding: 6px 8px;
font-family: inherit;
}
.SettingsRow input[type="checkbox"] {
accent-color: var(--Purple);
}
.Switch {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
flex-shrink: 0;
}
.Switch input {
opacity: 0;
width: 0;
height: 0;
}
.SwitchTrack {
position: absolute;
inset: 0;
background: var(--SurfaceAlt);
border: 1px solid var(--Border);
border-radius: 999px;
transition: background 0.15s ease, border-color 0.15s ease;
}
.SwitchThumb {
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--TextMuted);
transition: transform 0.15s ease, background 0.15s ease;
}
.Switch input:checked + .SwitchTrack {
background: var(--Purple);
border-color: var(--Purple);
}
.Switch input:checked + .SwitchTrack .SwitchThumb {
transform: translateX(16px);
background: var(--Text);
}
.SettingsBody button,
.SettingsFooter button {
background: var(--SurfaceAlt);
color: var(--Text);
border: 1px solid var(--Purple);
border-radius: 4px;
padding: 4px 10px;
font-family: inherit;
cursor: pointer;
}
.SettingsBody button:hover,
.SettingsFooter button:hover {
background: var(--Border);
}
.SettingsSection {
border-top: 1px solid var(--Border);
padding-top: 12px;
}
.SettingsSectionTitle {
color: var(--Purple);
margin-bottom: 8px;
}
.PaletteGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.PaletteSwatchRow {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.PaletteSwatchRow input[type="color"] {
width: 28px;
height: 22px;
padding: 0;
border: 1px solid var(--Border);
border-radius: 3px;
background: none;
}
.ColourSwatchPicker {
display: flex;
gap: 8px;
}
.ColourSwatch {
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
padding: 0;
}
.ColourSwatch:hover {
border-color: var(--Border);
}
.ColourSwatchSelected {
border-color: var(--Text);
}
.ColourSwatchSmall {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.AvatarUploadRow {
display: flex;
align-items: center;
gap: 10px;
}
.AvatarPreview {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--Border);
}
.SettingsFooter {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--Border);
}
.SettingsSaveError {
color: var(--Red);
font-size: 13px;
}
/* Login screen */
.LoginScreen {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--Background);
}
.LoginBox {
width: 320px;
display: flex;
flex-direction: column;
gap: 14px;
padding: 20px;
background: var(--Surface);
border: 1px solid var(--Purple);
border-radius: 8px;
}
.LoginTitle {
color: var(--Purple);
font-size: 20px;
}
.LoginSubtitle {
color: var(--TextMuted);
font-size: 13px;
margin-top: -8px;
}
.LoginBox button {
background: var(--SurfaceAlt);
color: var(--Text);
border: 1px solid var(--Purple);
border-radius: 4px;
padding: 8px;
font-family: inherit;
cursor: pointer;
}
.LoginBox button:hover {
background: var(--Border);
}
+16
View File
@@ -0,0 +1,16 @@
export const PaletteRoles = [
"Background",
"Surface",
"SurfaceAlt",
"Border",
"Text",
"TextMuted",
"Black",
"White",
"Red",
"Orange",
"Yellow",
"Green",
"Blue",
"Purple",
];
+11
View File
@@ -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",
},
},
});
+141
View File
@@ -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."
+33
View File
@@ -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"]
+33
View File
@@ -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:
+25
View File
@@ -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: