cloud_gallery: React media-gallery SPA served by dufs (Phase 3)
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run

Replace dufs's plain file list with a React 19 + Vite + TypeScript SPA served
same-origin by dufs, so the cloud URL becomes a real gallery behind the existing
auth: thumbnail grid, image lightbox (zoom + keyboard nav), inline video, drag/
pick upload, download, confirm-guarded delete, and a simple .txt/.md editor.

Serving architecture (de-risked against the real dufs 0.46.0): --assets does not
override the UI, so dufs `render-spa` serves the app shell, the SPA lists dirs
via WebDAV PROPFIND (render-flag-immune) and streams files via GET (Range for
video seeking), with HashRouter so navigation stays at "/". Verified render-spa
leaves KeePass file GET/PUT/?hash intact (Phase 1 unaffected).

Adds generate_thumbnails.sh (ImageMagick images + ffmpeg video posters →
~/cloud/.thumbs, idempotent) and setup_cloud_gallery.sh (build + deploy the SPA
into the cloud root, enable render-spa, wire thumbnail refresh into the Phase-2
media sync). setup_dufs_cloud.sh now emits render-spa only when the SPA is
deployed, so a fresh dufs keeps its default listing.

Verified: pnpm build + lint (strictTypeChecked) clean; 38 vitest tests, coverage
92.8% stmts / 96% lines (dufs-client 100%); local dufs --render-spa serves shell
+ assets + thumbnails + PROPFIND (all 200). Live deploy left for the user (needs
sudo; enables render-spa on the public service). Note: host ffmpeg is broken
(libvpx.so.11) so video posters are skipped until it's fixed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0126QvdMrv2WNeUVShFiLTc3
This commit is contained in:
Krzysztof Rudnicki 2026-07-12 06:03:09 +02:00
parent 095587c0de
commit 76d4e8e0cf
34 changed files with 6252 additions and 0 deletions

9
.prettierignore Normal file
View File

@ -0,0 +1,9 @@
# Lockfiles are tool-generated and must not be reformatted by prettier.
**/pnpm-lock.yaml
**/package-lock.json
**/yarn.lock
# Build output / vendored / generated
**/dist/
**/coverage/
node_modules/

6
cloud_gallery/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
dist/
coverage/
*.tsbuildinfo
*.local
.DS_Store

View File

@ -0,0 +1,30 @@
import js from "@eslint/js";
import reactHooksPlugin from "eslint-plugin-react-hooks";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist", "coverage"] },
js.configs.recommended,
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
{
plugins: { "react-hooks": reactHooksPlugin },
languageOptions: {
parserOptions: {
project: "./tsconfig.json",
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
...reactHooksPlugin.configs.recommended.rules,
"@typescript-eslint/restrict-template-expressions": [
"error",
{ allowNumber: true },
],
},
},
{
files: ["*.js", "*.mjs", "*.cjs"],
...tseslint.configs.disableTypeChecked,
},
);

13
cloud_gallery/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>Cloud</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,41 @@
{
"name": "cloud-gallery",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Media-gallery SPA served by dufs at the self-hosted cloud",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "tsc --noEmit && eslint src",
"preview": "vite preview",
"test": "vitest run",
"coverage": "vitest run --coverage"
},
"engines": {
"node": ">=22",
"pnpm": ">=10"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.5.2",
"@vitest/coverage-v8": "^4.1.9",
"eslint": "^10.6.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"jsdom": "^29.1.1",
"typescript": "~5.8.3",
"typescript-eslint": "^8.62.0",
"vite": "^6.3.5",
"vitest": "^4.1.9"
}
}

4007
cloud_gallery/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
# pnpm build-script allowlist. esbuild needs its postinstall to link its
# platform binary (Vite depends on it). `onlyBuiltDependencies` is the pnpm 10
# location; `allowBuilds` is the pnpm 11 location — both are set so either works.
allowBuilds:
esbuild: true
onlyBuiltDependencies:
- esbuild

View File

@ -0,0 +1,126 @@
import { describe, it, expect, vi } from "vitest";
import {
createDufsClient,
parsePropfind,
sortEntries,
} from "./dufs-client.ts";
import type { DirEntry } from "./types.ts";
const PROPFIND_XML = `<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:">
<D:response>
<D:href>/Media/2026/07/</D:href>
<D:propstat><D:prop><D:resourcetype><D:collection/></D:resourcetype></D:prop></D:propstat>
</D:response>
<D:response>
<D:href>/Media/2026/07/sub/</D:href>
<D:propstat><D:prop>
<D:resourcetype><D:collection/></D:resourcetype>
<D:getlastmodified>Sat, 12 Jul 2026 03:00:00 GMT</D:getlastmodified>
</D:prop></D:propstat>
</D:response>
<D:response>
<D:href>/Media/2026/07/a%20b.jpg</D:href>
<D:propstat><D:prop>
<D:resourcetype/>
<D:getcontentlength>2048</D:getcontentlength>
<D:getlastmodified>Sat, 12 Jul 2026 03:00:00 GMT</D:getlastmodified>
</D:prop></D:propstat>
</D:response>
<D:response><D:href></D:href></D:response>
</D:multistatus>`;
describe("parsePropfind", () => {
it("parses entries, skips self, decodes names, reads size/mtime", () => {
const entries = parsePropfind(PROPFIND_XML, "/Media/2026/07");
// self ("/Media/2026/07/") and the empty-href entry are dropped
expect(entries.map((e) => e.name)).toEqual(["sub", "a b.jpg"]);
const file = entries.find((e) => e.name === "a b.jpg");
expect(file?.kind).toBe("file");
expect(file?.size).toBe(2048);
expect(file?.mtimeMs).toBeGreaterThan(0);
expect(entries.find((e) => e.name === "sub")?.kind).toBe("dir");
});
it("handles missing size/mtime as zero", () => {
const xml = `<D:multistatus xmlns:D="DAV:"><D:response><D:href>/x.bin</D:href><D:propstat><D:prop><D:resourcetype/></D:prop></D:propstat></D:response></D:multistatus>`;
const [entry] = parsePropfind(xml, "/");
expect(entry?.size).toBe(0);
expect(entry?.mtimeMs).toBe(0);
});
});
describe("sortEntries", () => {
it("dirs first then case-insensitive name", () => {
const mk = (name: string, kind: "dir" | "file"): DirEntry => ({
name,
path: `/${name}`,
kind,
size: 0,
mtimeMs: 0,
});
const sorted = sortEntries([
mk("banana.jpg", "file"),
mk("Apple", "dir"),
mk("apple.png", "file"),
mk("Zeta", "dir"),
]);
expect(sorted.map((e) => e.name)).toEqual([
"Apple",
"Zeta",
"apple.png",
"banana.jpg",
]);
});
});
function jsonResponse(body: string, ok = true, status = 200): Response {
return {
ok,
status,
text: () => Promise.resolve(body),
} as unknown as Response;
}
describe("createDufsClient", () => {
it("list() PROPFINDs, sorts, and returns entries", async () => {
const fetchImpl = vi.fn(() => Promise.resolve(jsonResponse(PROPFIND_XML)));
const client = createDufsClient(fetchImpl as unknown as typeof fetch);
const entries = await client.list("/Media/2026/07");
const [url, init] = fetchImpl.mock.calls[0] ?? [];
expect(url).toBe("/Media/2026/07");
expect((init as RequestInit).method).toBe("PROPFIND");
expect(entries.length).toBe(2);
});
it("builds file and thumbnail URLs (encoded)", () => {
const client = createDufsClient(vi.fn() as unknown as typeof fetch);
expect(client.fileUrl("/Media/a b.jpg")).toBe("/Media/a%20b.jpg");
expect(client.thumbUrl("/Media/a b.jpg")).toBe(
"/.thumbs/Media/a%20b.jpg.jpg",
);
});
it("upload PUTs to dir/name, remove DELETEs, writeText PUTs, readText GETs", async () => {
const fetchImpl = vi.fn(() => Promise.resolve(jsonResponse("content")));
const client = createDufsClient(fetchImpl as unknown as typeof fetch);
const file = new File(["x"], "n.txt");
await client.upload("/dir", file);
await client.remove("/dir/n.txt");
await client.writeText("/dir/n.txt", "hi");
const text = await client.readText("/dir/n.txt");
expect(text).toBe("content");
const methods = fetchImpl.mock.calls.map(
(c) => (c[1] as RequestInit).method,
);
expect(methods).toEqual(["PUT", "DELETE", "PUT", "GET"]);
expect(fetchImpl.mock.calls[0]?.[0]).toBe("/dir/n.txt");
});
it("throws on non-ok responses", async () => {
const fetchImpl = vi.fn(() =>
Promise.resolve(jsonResponse("", false, 404)),
);
const client = createDufsClient(fetchImpl as unknown as typeof fetch);
await expect(client.remove("/nope")).rejects.toThrow("404");
});
});

View File

@ -0,0 +1,108 @@
import type { DirEntry, EntryKind } from "./types.ts";
import { basename, encodePath, joinPath, normalize } from "../lib/paths.ts";
const DAV_NS = "DAV:";
/** Client over the dufs WebDAV + HTTP API (same-origin; browser supplies auth). */
export interface DufsClient {
/** List a directory via WebDAV PROPFIND (works under dufs `render-spa`). */
list(dirPath: string): Promise<DirEntry[]>;
/** Same-origin URL to fetch/stream a file's raw bytes. */
fileUrl(path: string): string;
/** URL of the generated thumbnail for a media entry (see generate_thumbnails.sh). */
thumbUrl(path: string): string;
upload(dirPath: string, file: File): Promise<void>;
remove(path: string): Promise<void>;
readText(path: string): Promise<string>;
writeText(path: string, content: string): Promise<void>;
}
/** Parse a dufs PROPFIND multistatus XML body into entries under `dirPath`. */
export function parsePropfind(xml: string, dirPath: string): DirEntry[] {
const doc = new DOMParser().parseFromString(xml, "application/xml");
const self = normalize(dirPath);
const responses = Array.from(doc.getElementsByTagNameNS(DAV_NS, "response"));
const entries: DirEntry[] = [];
for (const res of responses) {
const hrefEl = res.getElementsByTagNameNS(DAV_NS, "href")[0];
const rawHref = hrefEl?.textContent;
if (rawHref === null || rawHref === undefined || rawHref === "") continue;
// href is URL-encoded and absolute; decode and normalize.
const path = normalize(decodeURIComponent(rawHref));
if (path === self) continue; // skip the directory itself
const isDir =
res.getElementsByTagNameNS(DAV_NS, "collection").length > 0;
const kind: EntryKind = isDir ? "dir" : "file";
const sizeText = res
.getElementsByTagNameNS(DAV_NS, "getcontentlength")[0]
?.textContent;
const size = sizeText !== null && sizeText !== undefined ? Number(sizeText) : 0;
const mtimeText = res
.getElementsByTagNameNS(DAV_NS, "getlastmodified")[0]
?.textContent;
const mtimeMs =
mtimeText !== null && mtimeText !== undefined
? Date.parse(mtimeText) || 0
: 0;
entries.push({
name: basename(path),
path,
kind,
size: Number.isFinite(size) ? size : 0,
mtimeMs,
});
}
return entries;
}
/** Sort: directories first, then by name (locale, case-insensitive). */
export function sortEntries(entries: readonly DirEntry[]): DirEntry[] {
return [...entries].sort((a, b) => {
if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1;
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
});
}
export function createDufsClient(fetchImpl: typeof fetch = fetch): DufsClient {
async function request(path: string, init: RequestInit): Promise<Response> {
const res = await fetchImpl(encodePath(path), {
credentials: "same-origin",
...init,
});
if (!res.ok) {
throw new Error(`${init.method ?? "GET"} ${path} → ${res.status}`);
}
return res;
}
return {
async list(dirPath) {
const res = await request(dirPath, {
method: "PROPFIND",
headers: { Depth: "1" },
});
return sortEntries(parsePropfind(await res.text(), dirPath));
},
fileUrl(path) {
return encodePath(path);
},
thumbUrl(path) {
return encodePath(`/.thumbs${normalize(path)}.jpg`);
},
async upload(dirPath, file) {
await request(joinPath(dirPath, file.name), {
method: "PUT",
body: file,
});
},
async remove(path) {
await request(path, { method: "DELETE" });
},
async readText(path) {
return (await request(path, { method: "GET" })).text();
},
async writeText(path, content) {
await request(path, { method: "PUT", body: content });
},
};
}

View File

@ -0,0 +1,14 @@
export type EntryKind = "dir" | "file";
/** One entry in a directory listing, as returned by the dufs WebDAV PROPFIND. */
export interface DirEntry {
/** Base name, e.g. "pic.jpg". */
readonly name: string;
/** Absolute path from the cloud root, e.g. "/Media/2026/07/pic.jpg". */
readonly path: string;
readonly kind: EntryKind;
/** Size in bytes (0 for directories). */
readonly size: number;
/** Last-modified time in epoch milliseconds (0 if unknown). */
readonly mtimeMs: number;
}

View File

@ -0,0 +1,8 @@
import { useMemo } from "react";
import { createDufsClient } from "./api/dufs-client.ts";
import { Gallery } from "./components/gallery.tsx";
export function App(): React.JSX.Element {
const client = useMemo(() => createDufsClient(), []);
return <Gallery client={client} />;
}

View File

@ -0,0 +1,35 @@
interface ConfirmDialogProps {
readonly message: string;
readonly confirmLabel: string;
readonly onConfirm: () => void;
readonly onCancel: () => void;
}
/** A small modal confirm used to guard destructive actions (delete). */
export function ConfirmDialog({
message,
confirmLabel,
onConfirm,
onCancel,
}: ConfirmDialogProps): React.JSX.Element {
return (
<div className="overlay" role="dialog" aria-modal="true" onClick={onCancel}>
<div
className="dialog"
onClick={(e) => {
e.stopPropagation();
}}
>
<p>{message}</p>
<div className="dialog-actions">
<button type="button" onClick={onCancel}>
Cancel
</button>
<button type="button" className="danger" onClick={onConfirm}>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,159 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Gallery } from "./gallery.tsx";
import type { DufsClient } from "../api/dufs-client.ts";
import type { DirEntry } from "../api/types.ts";
function entry(name: string, kind: "dir" | "file"): DirEntry {
return { name, path: `/${name}`, kind, size: 10, mtimeMs: 0 };
}
function makeClient(overrides: Partial<DufsClient> = {}): DufsClient {
return {
list: vi.fn(() =>
Promise.resolve([
entry("Media", "dir"),
entry("pic.jpg", "file"),
entry("clip.mp4", "file"),
entry("notes.txt", "file"),
]),
),
fileUrl: (p: string) => p,
thumbUrl: (p: string) => `/.thumbs${p}.jpg`,
upload: vi.fn(() => Promise.resolve()),
remove: vi.fn(() => Promise.resolve()),
readText: vi.fn(() => Promise.resolve("hello world")),
writeText: vi.fn(() => Promise.resolve()),
...overrides,
};
}
beforeEach(() => {
window.location.hash = "";
});
describe("Gallery", () => {
it("renders a directory listing", async () => {
render(<Gallery client={makeClient()} />);
expect(await screen.findByText("Media")).toBeInTheDocument();
expect(screen.getByText("pic.jpg")).toBeInTheDocument();
expect(screen.getByText("clip.mp4")).toBeInTheDocument();
});
it("opens an image in the viewer and closes it", async () => {
render(<Gallery client={makeClient()} />);
await userEvent.click(await screen.findByText("pic.jpg"));
await waitFor(() => {
expect(document.querySelector(".viewer-img")).not.toBeNull();
});
await userEvent.click(screen.getByLabelText("Close"));
await waitFor(() => {
expect(document.querySelector(".viewer-img")).toBeNull();
});
});
it("plays a video in the viewer", async () => {
render(<Gallery client={makeClient()} />);
await userEvent.click(await screen.findByText("clip.mp4"));
await waitFor(() => {
expect(document.querySelector("video")).not.toBeNull();
});
});
it("edits a text file and saves it", async () => {
const client = makeClient();
render(<Gallery client={client} />);
await userEvent.click(await screen.findByText("notes.txt"));
const area = await screen.findByDisplayValue("hello world");
await userEvent.clear(area);
await userEvent.type(area, "new text");
await userEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(client.writeText).toHaveBeenCalledWith("/notes.txt", "new text");
});
});
it("deletes a file after confirming", async () => {
const client = makeClient();
render(<Gallery client={client} />);
await screen.findByText("pic.jpg");
await userEvent.click(screen.getByLabelText("Delete pic.jpg"));
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
await waitFor(() => {
expect(client.remove).toHaveBeenCalledWith("/pic.jpg");
});
});
it("uploads picked files", async () => {
const client = makeClient();
const { container } = render(<Gallery client={client} />);
await screen.findByText("pic.jpg");
const input = container.querySelector<HTMLInputElement>(
'input[type="file"]',
);
expect(input).not.toBeNull();
const file = new File(["x"], "up.png", { type: "image/png" });
if (input) fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => {
expect(client.upload).toHaveBeenCalled();
});
});
it("navigates between media in the viewer", async () => {
render(<Gallery client={makeClient()} />);
await userEvent.click(await screen.findByText("pic.jpg"));
await waitFor(() => {
expect(document.querySelector(".viewer-img")).not.toBeNull();
});
fireEvent.keyDown(window, { key: "ArrowRight" });
await waitFor(() => {
expect(document.querySelector("video")).not.toBeNull();
});
});
it("surfaces an upload error", async () => {
const client = makeClient({
upload: vi.fn(() => Promise.reject(new Error("uperr"))),
});
const { container } = render(<Gallery client={client} />);
await screen.findByText("pic.jpg");
const input = container.querySelector<HTMLInputElement>(
'input[type="file"]',
);
if (input) {
fireEvent.change(input, {
target: { files: [new File(["x"], "u.png")] },
});
}
expect(await screen.findByText(/uperr/)).toBeInTheDocument();
});
it("surfaces a delete error", async () => {
const client = makeClient({
remove: vi.fn(() => Promise.reject(new Error("delerr"))),
});
render(<Gallery client={client} />);
await screen.findByText("pic.jpg");
await userEvent.click(screen.getByLabelText("Delete pic.jpg"));
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
expect(await screen.findByText(/delerr/)).toBeInTheDocument();
});
it("cancels a delete", async () => {
const client = makeClient();
render(<Gallery client={client} />);
await screen.findByText("pic.jpg");
await userEvent.click(screen.getByLabelText("Delete pic.jpg"));
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(client.remove).not.toHaveBeenCalled();
});
it("shows an error when listing fails", async () => {
const client = makeClient({
list: vi.fn(() => Promise.reject(new Error("boom"))),
});
render(<Gallery client={client} />);
expect(await screen.findByText(/boom/)).toBeInTheDocument();
});
});

View File

@ -0,0 +1,174 @@
import { useMemo, useRef, useState } from "react";
import type { DufsClient } from "../api/dufs-client.ts";
import type { DirEntry } from "../api/types.ts";
import { crumbs, isImage, isVideo } from "../lib/paths.ts";
import { useHashPath } from "../lib/use-hash-path.ts";
import { useListing } from "../hooks/use-listing.ts";
import { Grid } from "./grid.tsx";
import { MediaViewer } from "./media-viewer.tsx";
import { TextEditor } from "./text-editor.tsx";
import { ConfirmDialog } from "./confirm-dialog.tsx";
export function Gallery({ client }: { readonly client: DufsClient }): React.JSX.Element {
const [path, navigate] = useHashPath();
const { entries, loading, error, reload } = useListing(client, path);
const [viewerIndex, setViewerIndex] = useState<number | null>(null);
const [editEntry, setEditEntry] = useState<DirEntry | null>(null);
const [deleteEntry, setDeleteEntry] = useState<DirEntry | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const media = useMemo(
() => entries.filter((e) => isImage(e.name) || isVideo(e.name)),
[entries],
);
function openMediaAt(entriesIndex: number): void {
const entry = entries[entriesIndex];
if (entry === undefined) return;
const mi = media.findIndex((m) => m.path === entry.path);
if (mi >= 0) setViewerIndex(mi);
}
function step(delta: number): void {
setViewerIndex((cur) => {
if (cur === null || media.length === 0) return cur;
return (cur + delta + media.length) % media.length;
});
}
function onUploadPicked(files: FileList | null): void {
if (files === null || files.length === 0) return;
setBusy(`Uploading ${files.length} file(s)…`);
void (async () => {
try {
for (const file of Array.from(files)) {
await client.upload(path, file);
}
reload();
} catch (err: unknown) {
setBusy(err instanceof Error ? err.message : String(err));
return;
}
setBusy(null);
})();
}
function confirmDelete(): void {
const entry = deleteEntry;
if (entry === null) return;
setDeleteEntry(null);
setBusy(`Deleting ${entry.name}…`);
client
.remove(entry.path)
.then(() => {
setBusy(null);
reload();
})
.catch((err: unknown) => {
setBusy(err instanceof Error ? err.message : String(err));
});
}
const viewerEntry = viewerIndex !== null ? media[viewerIndex] : null;
return (
<div className="gallery">
<header className="topbar">
<nav className="crumbs" aria-label="Breadcrumb">
{crumbs(path).map((c, i, all) => (
<span key={c.path}>
<button
type="button"
className="crumb"
onClick={() => {
navigate(c.path);
}}
>
{c.name}
</button>
{i < all.length - 1 && <span className="crumb-sep">/</span>}
</span>
))}
</nav>
<div className="tools">
{busy !== null && <span className="busy">{busy}</span>}
<button
type="button"
className="primary"
onClick={() => {
fileInput.current?.click();
}}
>
Upload
</button>
<input
ref={fileInput}
type="file"
multiple
hidden
onChange={(e) => {
onUploadPicked(e.target.files);
e.target.value = "";
}}
/>
</div>
</header>
<main className="content">
{loading && <p className="muted">Loading…</p>}
{error !== null && <p className="error">Could not load: {error}</p>}
{!loading && error === null && entries.length === 0 && (
<p className="muted">This folder is empty.</p>
)}
{!loading && error === null && entries.length > 0 && (
<Grid
client={client}
entries={entries}
onOpenDir={navigate}
onOpenMedia={openMediaAt}
onEditText={setEditEntry}
onDelete={setDeleteEntry}
/>
)}
</main>
{viewerEntry && (
<MediaViewer
key={viewerEntry.path}
entry={viewerEntry}
url={client.fileUrl(viewerEntry.path)}
onClose={() => {
setViewerIndex(null);
}}
onPrev={() => {
step(-1);
}}
onNext={() => {
step(1);
}}
/>
)}
{editEntry && (
<TextEditor
client={client}
entry={editEntry}
onClose={() => {
setEditEntry(null);
reload();
}}
/>
)}
{deleteEntry && (
<ConfirmDialog
message={`Delete "${deleteEntry.name}"? This cannot be undone.`}
confirmLabel="Delete"
onConfirm={confirmDelete}
onCancel={() => {
setDeleteEntry(null);
}}
/>
)}
</div>
);
}

View File

@ -0,0 +1,105 @@
import { useState } from "react";
import type { DufsClient } from "../api/dufs-client.ts";
import type { DirEntry } from "../api/types.ts";
import { humanSize, isImage, isText, isVideo } from "../lib/paths.ts";
interface GridProps {
readonly client: DufsClient;
readonly entries: readonly DirEntry[];
readonly onOpenDir: (path: string) => void;
readonly onOpenMedia: (index: number) => void;
readonly onEditText: (entry: DirEntry) => void;
readonly onDelete: (entry: DirEntry) => void;
}
function Thumb({
client,
entry,
}: {
readonly client: DufsClient;
readonly entry: DirEntry;
}): React.JSX.Element {
const [broken, setBroken] = useState(false);
if (broken) {
return <div className="thumb-fallback">{isVideo(entry.name) ? "🎞" : "🖼"}</div>;
}
return (
<img
className="thumb-img"
src={client.thumbUrl(entry.path)}
alt={entry.name}
loading="lazy"
onError={() => {
setBroken(true);
}}
/>
);
}
export function Grid({
client,
entries,
onOpenDir,
onOpenMedia,
onEditText,
onDelete,
}: GridProps): React.JSX.Element {
return (
<ul className="grid">
{entries.map((entry, index) => {
const media = isImage(entry.name) || isVideo(entry.name);
const activate = (): void => {
if (entry.kind === "dir") onOpenDir(entry.path);
else if (media) onOpenMedia(index);
else if (isText(entry.name)) onEditText(entry);
};
return (
<li key={entry.path} className={`tile tile-${entry.kind}`}>
<button type="button" className="tile-main" onClick={activate}>
<span className="tile-preview">
{entry.kind === "dir" ? (
<span className="tile-icon">📁</span>
) : media ? (
<>
<Thumb client={client} entry={entry} />
{isVideo(entry.name) && <span className="play-badge">▶</span>}
</>
) : (
<span className="tile-icon">{isText(entry.name) ? "📝" : "📄"}</span>
)}
</span>
<span className="tile-name" title={entry.name}>
{entry.name}
</span>
{entry.kind === "file" && (
<span className="tile-size">{humanSize(entry.size)}</span>
)}
</button>
{entry.kind === "file" && (
<div className="tile-actions">
<a
className="tile-act"
href={client.fileUrl(entry.path)}
download
aria-label={`Download ${entry.name}`}
>
⭳
</a>
<button
type="button"
className="tile-act danger"
aria-label={`Delete ${entry.name}`}
onClick={() => {
onDelete(entry);
}}
>
🗑
</button>
</div>
)}
</li>
);
})}
</ul>
);
}

View File

@ -0,0 +1,94 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MediaViewer } from "./media-viewer.tsx";
import type { DirEntry } from "../api/types.ts";
function mk(name: string): DirEntry {
return { name, path: `/${name}`, kind: "file", size: 1, mtimeMs: 0 };
}
describe("MediaViewer", () => {
it("shows an image and toggles zoom on click", async () => {
render(
<MediaViewer
entry={mk("a.jpg")}
url="/a.jpg"
onClose={vi.fn()}
onPrev={vi.fn()}
onNext={vi.fn()}
/>,
);
const img = screen.getByAltText("a.jpg");
expect(img.className).not.toContain("zoomed");
await userEvent.click(img);
expect(img.className).toContain("zoomed");
});
it("renders a <video> for video files", () => {
render(
<MediaViewer
entry={mk("v.mp4")}
url="/v.mp4"
onClose={vi.fn()}
onPrev={vi.fn()}
onNext={vi.fn()}
/>,
);
expect(document.querySelector("video")).not.toBeNull();
});
it("offers a download for non-media files", () => {
render(
<MediaViewer
entry={mk("x.bin")}
url="/x.bin"
onClose={vi.fn()}
onPrev={vi.fn()}
onNext={vi.fn()}
/>,
);
expect(screen.getByText("Download")).toBeInTheDocument();
});
it("responds to keyboard: Esc/←/→", () => {
const onClose = vi.fn();
const onPrev = vi.fn();
const onNext = vi.fn();
render(
<MediaViewer
entry={mk("a.jpg")}
url="/a.jpg"
onClose={onClose}
onPrev={onPrev}
onNext={onNext}
/>,
);
fireEvent.keyDown(window, { key: "ArrowRight" });
fireEvent.keyDown(window, { key: "ArrowLeft" });
fireEvent.keyDown(window, { key: "Escape" });
fireEvent.keyDown(window, { key: "a" });
expect(onNext).toHaveBeenCalledOnce();
expect(onPrev).toHaveBeenCalledOnce();
expect(onClose).toHaveBeenCalledOnce();
});
it("nav buttons and stage click work", async () => {
const onClose = vi.fn();
const onPrev = vi.fn();
const onNext = vi.fn();
render(
<MediaViewer
entry={mk("a.jpg")}
url="/a.jpg"
onClose={onClose}
onPrev={onPrev}
onNext={onNext}
/>,
);
await userEvent.click(screen.getByLabelText("Previous"));
await userEvent.click(screen.getByLabelText("Next"));
expect(onPrev).toHaveBeenCalledOnce();
expect(onNext).toHaveBeenCalledOnce();
});
});

View File

@ -0,0 +1,98 @@
import { useEffect, useState } from "react";
import type { DirEntry } from "../api/types.ts";
import { isImage, isVideo } from "../lib/paths.ts";
interface MediaViewerProps {
readonly entry: DirEntry;
readonly url: string;
readonly onClose: () => void;
readonly onPrev: () => void;
readonly onNext: () => void;
}
/** Full-screen viewer: image lightbox (click-to-zoom) or an inline video player. */
export function MediaViewer({
entry,
url,
onClose,
onPrev,
onNext,
}: MediaViewerProps): React.JSX.Element {
// Zoom resets automatically per media because the parent keys this component
// by path, remounting it on navigation.
const [zoomed, setZoomed] = useState(false);
useEffect(() => {
function onKey(e: KeyboardEvent): void {
if (e.key === "Escape") onClose();
else if (e.key === "ArrowLeft") onPrev();
else if (e.key === "ArrowRight") onNext();
}
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("keydown", onKey);
};
}, [onClose, onPrev, onNext]);
const video = isVideo(entry.name);
return (
<div className="overlay viewer" role="dialog" aria-modal="true">
<button
type="button"
className="viewer-close"
aria-label="Close"
onClick={onClose}
>
✕
</button>
<button
type="button"
className="viewer-nav prev"
aria-label="Previous"
onClick={onPrev}
>
‹
</button>
<div className="viewer-stage" onClick={onClose}>
{video ? (
<video
className="viewer-video"
src={url}
controls
autoPlay
onClick={(e) => {
e.stopPropagation();
}}
/>
) : isImage(entry.name) ? (
<img
className={zoomed ? "viewer-img zoomed" : "viewer-img"}
src={url}
alt={entry.name}
onClick={(e) => {
e.stopPropagation();
setZoomed((z) => !z);
}}
/>
) : (
<div className="viewer-fallback">
<p>{entry.name}</p>
<a href={url} download>
Download
</a>
</div>
)}
</div>
<button
type="button"
className="viewer-nav next"
aria-label="Next"
onClick={onNext}
>
›
</button>
<div className="viewer-caption">{entry.name}</div>
</div>
);
}

View File

@ -0,0 +1,66 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TextEditor } from "./text-editor.tsx";
import type { DufsClient } from "../api/dufs-client.ts";
import type { DirEntry } from "../api/types.ts";
const entry: DirEntry = {
name: "n.txt",
path: "/n.txt",
kind: "file",
size: 3,
mtimeMs: 0,
};
function client(over: Partial<DufsClient>): DufsClient {
return {
list: vi.fn(),
fileUrl: (p) => p,
thumbUrl: (p) => p,
upload: vi.fn(),
remove: vi.fn(),
readText: vi.fn(() => Promise.resolve("body")),
writeText: vi.fn(() => Promise.resolve()),
...over,
} as DufsClient;
}
describe("TextEditor", () => {
it("loads, edits and saves", async () => {
const c = client({});
render(<TextEditor client={c} entry={entry} onClose={vi.fn()} />);
const area = await screen.findByDisplayValue("body");
await userEvent.clear(area);
await userEvent.type(area, "changed");
await userEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(c.writeText).toHaveBeenCalledWith("/n.txt", "changed");
});
expect(await screen.findByText("Saved")).toBeInTheDocument();
});
it("shows a load error", async () => {
const c = client({ readText: vi.fn(() => Promise.reject(new Error("nope"))) });
render(<TextEditor client={c} entry={entry} onClose={vi.fn()} />);
expect(await screen.findByText("nope")).toBeInTheDocument();
});
it("shows a save error", async () => {
const c = client({
writeText: vi.fn(() => Promise.reject(new Error("wfail"))),
});
render(<TextEditor client={c} entry={entry} onClose={vi.fn()} />);
await screen.findByDisplayValue("body");
await userEvent.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("wfail")).toBeInTheDocument();
});
it("closes", async () => {
const onClose = vi.fn();
render(<TextEditor client={client({})} entry={entry} onClose={onClose} />);
await screen.findByDisplayValue("body");
await userEvent.click(screen.getByRole("button", { name: "Close" }));
expect(onClose).toHaveBeenCalledOnce();
});
});

View File

@ -0,0 +1,94 @@
import { useEffect, useState } from "react";
import type { DufsClient } from "../api/dufs-client.ts";
import type { DirEntry } from "../api/types.ts";
interface TextEditorProps {
readonly client: DufsClient;
readonly entry: DirEntry;
readonly onClose: () => void;
}
type Status = "loading" | "ready" | "saving" | "error";
/** A minimal editor for .txt/.md files: GET the text, edit, PUT it back. */
export function TextEditor({
client,
entry,
onClose,
}: TextEditorProps): React.JSX.Element {
const [text, setText] = useState("");
const [status, setStatus] = useState<Status>("loading");
const [message, setMessage] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
client
.readText(entry.path)
.then((content) => {
if (cancelled) return;
setText(content);
setStatus("ready");
})
.catch((err: unknown) => {
if (cancelled) return;
setMessage(err instanceof Error ? err.message : String(err));
setStatus("error");
});
return () => {
cancelled = true;
};
}, [client, entry.path]);
function save(): void {
setStatus("saving");
setMessage(null);
client
.writeText(entry.path, text)
.then(() => {
setStatus("ready");
setMessage("Saved");
})
.catch((err: unknown) => {
setMessage(err instanceof Error ? err.message : String(err));
setStatus("error");
});
}
return (
<div className="overlay" role="dialog" aria-modal="true">
<div
className="dialog editor"
onClick={(e) => {
e.stopPropagation();
}}
>
<header className="editor-head">
<span>{entry.name}</span>
<span className="editor-status">{message ?? status}</span>
</header>
<textarea
className="editor-area"
value={text}
spellCheck={false}
disabled={status === "loading"}
onChange={(e) => {
setText(e.target.value);
}}
/>
<div className="dialog-actions">
<button type="button" onClick={onClose}>
Close
</button>
<button
type="button"
className="primary"
disabled={status === "loading" || status === "saving"}
onClick={save}
>
Save
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,73 @@
import { useCallback, useEffect, useState } from "react";
import type { DufsClient } from "../api/dufs-client.ts";
import type { DirEntry } from "../api/types.ts";
/** Names the app owns in the cloud root — hidden from the browsing grid. */
const APP_FILES = new Set([
"index.html",
"assets",
"favicon.ico",
"vite.svg",
".thumbs",
"_thumbs",
]);
export interface Listing {
readonly entries: readonly DirEntry[];
readonly loading: boolean;
readonly error: string | null;
readonly reload: () => void;
}
interface Loaded {
readonly path: string;
readonly entries: readonly DirEntry[];
readonly error: string | null;
}
export function useListing(client: DufsClient, path: string): Listing {
const [loaded, setLoaded] = useState<Loaded>({
path: "",
entries: [],
error: null,
});
const [nonce, setNonce] = useState(0);
const reload = useCallback(() => {
setNonce((n) => n + 1);
}, []);
useEffect(() => {
let cancelled = false;
client
.list(path)
.then((all) => {
if (cancelled) return;
setLoaded({
path,
entries: all.filter((e) => !APP_FILES.has(e.name)),
error: null,
});
})
.catch((err: unknown) => {
if (cancelled) return;
setLoaded({
path,
entries: [],
error: err instanceof Error ? err.message : String(err),
});
});
return () => {
cancelled = true;
};
}, [client, path, nonce]);
// Loading is derived (no synchronous setState in the effect): we are loading
// whenever the last completed load is for a different path than the current.
const loading = loaded.path !== path;
return {
entries: loading ? [] : loaded.entries,
loading,
error: loading ? null : loaded.error,
reload,
};
}

343
cloud_gallery/src/index.css Normal file
View File

@ -0,0 +1,343 @@
:root {
--bg: #ffffff;
--fg: #1c1f24;
--muted: #5b6570;
--card: #f4f6f8;
--border: #d8dee4;
--accent: #2f6feb;
--danger: #cf222e;
--overlay: rgba(0, 0, 0, 0.82);
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d1117;
--fg: #e6edf3;
--muted: #9198a1;
--card: #161b22;
--border: #30363d;
--accent: #589bff;
--danger: #f85149;
--overlay: rgba(0, 0, 0, 0.9);
}
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
height: 100%;
}
body {
background: var(--bg);
color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
button {
font: inherit;
color: inherit;
cursor: pointer;
}
.gallery {
display: flex;
flex-direction: column;
min-height: 100%;
}
/* top bar */
.topbar {
position: sticky;
top: 0;
z-index: 5;
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
padding: 0.6rem 1rem;
background: var(--bg);
border-bottom: 1px solid var(--border);
}
.crumbs {
flex: 1 1 auto;
min-width: 0;
overflow-x: auto;
white-space: nowrap;
}
.crumb {
background: none;
border: none;
color: var(--accent);
padding: 0.15rem 0.2rem;
}
.crumb:hover {
text-decoration: underline;
}
.crumb-sep {
color: var(--muted);
margin: 0 0.1rem;
}
.tools {
display: flex;
align-items: center;
gap: 0.6rem;
}
.busy {
color: var(--muted);
font-size: 0.85rem;
}
button.primary {
background: var(--accent);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.4rem 0.9rem;
}
button.primary:disabled {
opacity: 0.5;
cursor: default;
}
.content {
flex: 1 1 auto;
padding: 1rem;
}
.muted {
color: var(--muted);
}
.error {
color: var(--danger);
}
/* grid */
.grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.8rem;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
.tile {
position: relative;
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
.tile-main {
display: flex;
flex-direction: column;
width: 100%;
border: none;
background: none;
padding: 0;
text-align: left;
}
.tile-preview {
position: relative;
display: grid;
place-items: center;
aspect-ratio: 1 / 1;
background: color-mix(in srgb, var(--card) 60%, #000 6%);
overflow: hidden;
}
.thumb-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumb-fallback,
.tile-icon {
font-size: 2.4rem;
}
.play-badge {
position: absolute;
bottom: 0.35rem;
right: 0.4rem;
background: rgba(0, 0, 0, 0.6);
color: #fff;
border-radius: 50%;
width: 1.6rem;
height: 1.6rem;
display: grid;
place-items: center;
font-size: 0.8rem;
}
.tile-name {
padding: 0.4rem 0.5rem 0.1rem;
font-size: 0.82rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tile-size {
padding: 0 0.5rem 0.45rem;
font-size: 0.72rem;
color: var(--muted);
}
.tile-actions {
position: absolute;
top: 0.3rem;
right: 0.3rem;
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.12s;
}
.tile:hover .tile-actions,
.tile:focus-within .tile-actions {
opacity: 1;
}
.tile-act {
background: rgba(0, 0, 0, 0.55);
color: #fff;
border: none;
border-radius: 6px;
padding: 0.15rem 0.35rem;
font-size: 0.8rem;
text-decoration: none;
line-height: 1;
}
.tile-act.danger:hover {
background: var(--danger);
}
/* overlays / dialogs */
.overlay {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
background: var(--overlay);
}
.dialog {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.1rem 1.2rem;
max-width: min(92vw, 40rem);
width: 100%;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 1rem;
}
.dialog-actions button {
border: 1px solid var(--border);
background: var(--card);
border-radius: 8px;
padding: 0.4rem 0.9rem;
}
.dialog-actions .danger {
background: var(--danger);
color: #fff;
border-color: var(--danger);
}
.dialog-actions .primary {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
/* editor */
.editor {
display: flex;
flex-direction: column;
height: min(80vh, 44rem);
}
.editor-head {
display: flex;
justify-content: space-between;
margin-bottom: 0.6rem;
font-size: 0.9rem;
}
.editor-status {
color: var(--muted);
}
.editor-area {
flex: 1 1 auto;
resize: none;
width: 100%;
background: var(--card);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.7rem;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.9rem;
}
/* viewer */
.viewer {
flex-direction: column;
}
.viewer-stage {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 3rem 1rem;
}
.viewer-img {
max-width: 96vw;
max-height: 88vh;
object-fit: contain;
cursor: zoom-in;
}
.viewer-img.zoomed {
max-width: none;
max-height: none;
cursor: zoom-out;
}
.viewer-video {
max-width: 96vw;
max-height: 88vh;
}
.viewer-fallback {
color: #fff;
text-align: center;
}
.viewer-close,
.viewer-nav {
position: absolute;
z-index: 2;
background: rgba(0, 0, 0, 0.5);
color: #fff;
border: none;
border-radius: 50%;
width: 2.6rem;
height: 2.6rem;
font-size: 1.4rem;
}
.viewer-close {
top: 0.8rem;
right: 0.8rem;
}
.viewer-nav.prev {
left: 0.6rem;
top: 50%;
transform: translateY(-50%);
}
.viewer-nav.next {
right: 0.6rem;
top: 50%;
transform: translateY(-50%);
}
.viewer-caption {
position: absolute;
bottom: 0.8rem;
left: 0;
right: 0;
text-align: center;
color: #fff;
font-size: 0.85rem;
pointer-events: none;
}

View File

@ -0,0 +1,83 @@
import { describe, it, expect } from "vitest";
import {
basename,
crumbs,
encodePath,
extname,
humanSize,
isImage,
isText,
isVideo,
joinPath,
normalize,
parentPath,
} from "./paths.ts";
describe("extname", () => {
it("returns lower-case extension without dot", () => {
expect(extname("PIC.JPG")).toBe("jpg");
expect(extname("a.tar.gz")).toBe("gz");
});
it("returns empty for no/edge extensions", () => {
expect(extname("README")).toBe("");
expect(extname(".hidden")).toBe("");
expect(extname("trailing.")).toBe("");
});
});
describe("type predicates", () => {
it("classifies images, videos and text", () => {
expect(isImage("a.png")).toBe(true);
expect(isImage("a.mp4")).toBe(false);
expect(isVideo("a.mkv")).toBe(true);
expect(isVideo("a.png")).toBe(false);
expect(isText("notes.md")).toBe(true);
expect(isText("a.bin")).toBe(false);
});
});
describe("normalize / join / parent / basename", () => {
it("normalizes dots and slashes", () => {
expect(normalize("/a//b/./c")).toBe("/a/b/c");
expect(normalize("/a/b/../c")).toBe("/a/c");
expect(normalize("")).toBe("/");
expect(normalize("/")).toBe("/");
});
it("joins and finds parents / basenames", () => {
expect(joinPath("/a/b", "c.jpg")).toBe("/a/b/c.jpg");
expect(parentPath("/a/b/c.jpg")).toBe("/a/b");
expect(parentPath("/")).toBe("/");
expect(parentPath("/only")).toBe("/");
expect(basename("/a/b/c.jpg")).toBe("c.jpg");
expect(basename("/")).toBe("/");
});
});
describe("encodePath", () => {
it("encodes each segment", () => {
expect(encodePath("/Media/2026/07/a b.jpg")).toBe("/Media/2026/07/a%20b.jpg");
expect(encodePath("/")).toBe("/");
});
});
describe("crumbs", () => {
it("builds breadcrumb trail", () => {
expect(crumbs("/")).toEqual([{ name: "cloud", path: "/" }]);
expect(crumbs("/Media/2026")).toEqual([
{ name: "cloud", path: "/" },
{ name: "Media", path: "/Media" },
{ name: "2026", path: "/Media/2026" },
]);
});
});
describe("humanSize", () => {
it("formats bytes across units", () => {
expect(humanSize(512)).toBe("512 B");
expect(humanSize(1536)).toBe("1.5 KB");
expect(humanSize(5 * 1024 * 1024)).toBe("5.0 MB");
expect(humanSize(3 * 1024 * 1024 * 1024)).toBe("3.0 GB");
expect(humanSize(2 * 1024 ** 4)).toBe("2.0 TB");
expect(humanSize(50 * 1024)).toBe("50 KB");
});
});

View File

@ -0,0 +1,96 @@
/** Path and file-type helpers for cloud paths (absolute, from the cloud root). */
const IMAGE_EXTS = new Set([
"jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp",
"heic", "heif", "avif", "svg",
]);
const VIDEO_EXTS = new Set([
"mp4", "avi", "mkv", "mov", "wmv", "flv", "webm", "m4v",
"3gp", "ogv", "mpg", "mpeg", "mts", "m2ts", "vob",
]);
const TEXT_EXTS = new Set([
"txt", "md", "markdown", "log", "csv", "json", "yaml", "yml",
"ini", "conf", "sh", "toml", "xml",
]);
/** Lower-case extension without the dot, or "" if none. */
export function extname(name: string): string {
const dot = name.lastIndexOf(".");
if (dot <= 0 || dot === name.length - 1) return "";
return name.slice(dot + 1).toLowerCase();
}
export function isImage(name: string): boolean {
return IMAGE_EXTS.has(extname(name));
}
export function isVideo(name: string): boolean {
return VIDEO_EXTS.has(extname(name));
}
export function isText(name: string): boolean {
return TEXT_EXTS.has(extname(name));
}
/** Normalize to a leading-slash, no-trailing-slash absolute path ("/" stays "/"). */
export function normalize(path: string): string {
const parts = path.split("/").filter((p) => p.length > 0 && p !== ".");
const out: string[] = [];
for (const p of parts) {
if (p === "..") out.pop();
else out.push(p);
}
return "/" + out.join("/");
}
export function joinPath(base: string, name: string): string {
return normalize(`${base}/${name}`);
}
export function parentPath(path: string): string {
const n = normalize(path);
if (n === "/") return "/";
return normalize(n.slice(0, n.lastIndexOf("/")) || "/");
}
export function basename(path: string): string {
const n = normalize(path);
if (n === "/") return "/";
return n.slice(n.lastIndexOf("/") + 1);
}
/** Encode an absolute path into a same-origin URL, encoding each segment. */
export function encodePath(path: string): string {
return (
"/" +
normalize(path)
.split("/")
.filter((p) => p.length > 0)
.map((p) => encodeURIComponent(p))
.join("/")
);
}
/** Breadcrumb segments for a path: [{name, path}], root first. */
export function crumbs(path: string): readonly { name: string; path: string }[] {
const n = normalize(path);
const out: { name: string; path: string }[] = [{ name: "cloud", path: "/" }];
if (n === "/") return out;
let acc = "";
for (const seg of n.split("/").filter((p) => p.length > 0)) {
acc += "/" + seg;
out.push({ name: seg, path: acc });
}
return out;
}
/** Human-readable byte size. */
export function humanSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
let v = bytes / 1024;
let unit = "KB";
for (const next of ["MB", "GB", "TB"]) {
if (v < 1024) break;
v /= 1024;
unit = next;
}
return `${v.toFixed(v < 10 ? 1 : 0)} ${unit}`;
}

View File

@ -0,0 +1,27 @@
import { useCallback, useSyncExternalStore } from "react";
import { normalize } from "./paths.ts";
/**
* Hash-based routing: the current directory lives in the URL hash (e.g.
* `#/Media/2026/07`). Using the hash keeps the HTTP path at "/", so dufs
* `render-spa` always serves the app shell and deep links never 404.
*/
function readHash(): string {
const raw = window.location.hash.replace(/^#/, "");
return normalize(raw === "" ? "/" : raw);
}
function subscribe(onChange: () => void): () => void {
window.addEventListener("hashchange", onChange);
return () => {
window.removeEventListener("hashchange", onChange);
};
}
export function useHashPath(): readonly [string, (path: string) => void] {
const path = useSyncExternalStore(subscribe, readHash, () => "/");
const navigate = useCallback((next: string) => {
window.location.hash = normalize(next);
}, []);
return [path, navigate];
}

View File

@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./app.tsx";
import "./index.css";
const root = document.getElementById("root");
if (root !== null) {
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
}

View File

@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useHashPath } from "./lib/use-hash-path.ts";
import { ConfirmDialog } from "./components/confirm-dialog.tsx";
import { App } from "./app.tsx";
function Harness(): React.JSX.Element {
const [path, navigate] = useHashPath();
return (
<div>
<span data-testid="p">{path}</span>
<button
type="button"
onClick={() => {
navigate("/x/y");
}}
>
go
</button>
</div>
);
}
describe("useHashPath", () => {
beforeEach(() => {
window.location.hash = "";
});
it("reflects and updates the hash", async () => {
render(<Harness />);
expect(screen.getByTestId("p").textContent).toBe("/");
await userEvent.click(screen.getByText("go"));
// hashchange may not auto-fire in jsdom; nudge it.
act(() => {
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
await waitFor(() => {
expect(screen.getByTestId("p").textContent).toBe("/x/y");
});
});
});
describe("ConfirmDialog", () => {
it("confirms, cancels and cancels on overlay click", async () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
const { rerender } = render(
<ConfirmDialog
message="Sure?"
confirmLabel="Yes"
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Yes" }));
expect(onConfirm).toHaveBeenCalledOnce();
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledOnce();
rerender(
<ConfirmDialog
message="Sure?"
confirmLabel="Yes"
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);
// clicking the dialog body should NOT cancel (stopPropagation)
await userEvent.click(screen.getByText("Sure?"));
expect(onCancel).toHaveBeenCalledOnce();
});
});
describe("App", () => {
it("mounts the gallery", async () => {
globalThis.fetch = vi.fn(() =>
Promise.reject(new Error("offline")),
) as unknown as typeof fetch;
render(<App />);
expect(
await screen.findByRole("button", { name: "Upload" }),
).toBeInTheDocument();
});
});

View File

@ -0,0 +1,10 @@
import "@testing-library/jest-dom";
import { vi, beforeEach } from "vitest";
beforeEach(() => {
vi.clearAllMocks();
});
// jsdom lacks object-URL support; several flows touch it.
URL.createObjectURL = vi.fn(() => "blob:mock");
URL.revokeObjectURL = vi.fn();

View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "esnext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["vite/client", "vitest/globals"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "vite.config.ts"]
}

View File

@ -0,0 +1,22 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
// base: "./" so the built asset URLs are relative. The app is served by dufs
// with `render-spa` from the cloud root, and uses HashRouter, so the document
// path is always "/" and "./assets/..." resolves to "/assets/...".
export default defineConfig({
base: "./",
plugins: [react()],
server: { port: 5273 },
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
coverage: {
provider: "v8",
include: ["src/**/*.{ts,tsx}"],
exclude: ["src/main.tsx", "src/test/**", "src/**/*.d.ts"],
thresholds: { statements: 90, branches: 85, functions: 90, lines: 90 },
},
},
});

View File

@ -0,0 +1,17 @@
{
"title": "React media-gallery SPA for the dufs cloud",
"objective": "Give the self-hosted cloud a rich web UI at its existing URL: a React SPA served by dufs that shows thumbnails, opens images in a lightbox, plays videos inline, and supports upload, download, delete, and editing text files — all behind the existing dufs auth, without breaking the KeePass sync.",
"acceptance_criteria": [
"The SPA is served same-origin by dufs (render-spa); it lists directories via WebDAV PROPFIND and streams files via GET (Range for video), so dufs basic-auth covers every request.",
"Grid shows generated thumbnails (images via ImageMagick, video posters via ffmpeg) and falls back to an icon when a thumbnail is missing.",
"Images open in a lightbox (zoom + keyboard/next/prev); videos play inline; files can be uploaded, downloaded, and deleted (confirm-guarded); .txt/.md files can be edited and saved.",
"Enabling render-spa does not break Phase-1 KeePass file GET/PUT/?hash.",
"cloud_gallery builds, lints (tsc + eslint strictTypeChecked), and its vitest suite passes with coverage above the project thresholds."
],
"out_of_scope": [
"Server-side rendering / Next.js (dufs is a static file server)",
"Running the live deploy in this session (interactive + outward-facing)",
"Fixing the host's broken ffmpeg (video posters skipped until then)"
],
"verifier": "de-risk against the real dufs binary (render-spa + PROPFIND/GET/PUT/DELETE/?hash); pnpm build + pnpm lint + pnpm coverage (38 tests, >90%); generate_thumbnails.sh functional test; local dufs --render-spa serving check (shell/asset/thumbnail/PROPFIND all 200); documented in docs/superpowers/evidence/cloud-gallery-2026-07-12.json"
}

View File

@ -0,0 +1,57 @@
{
"intent": "Replace dufs's plain file-list UI with a bleeding-edge React SPA media gallery, served by dufs itself at the cloud URL behind the existing auth: thumbnail grid, image lightbox, inline video, upload/download, delete, and a simple .txt/.md editor.",
"scope": [
"cloud_gallery/ — new React 19 + Vite + TypeScript SPA (WebDAV client, browse, lightbox, video, upload, delete, text editor) with vitest tests",
"linux_configuration/scripts/single_use/utils/generate_thumbnails.sh — ImageMagick/ffmpeg thumbnail + video-poster generator",
"linux_configuration/scripts/single_use/features/setup_cloud_gallery.sh — build + deploy installer",
"linux_configuration/scripts/single_use/features/setup_dufs_cloud.sh — conditionally enable dufs render-spa when the SPA is deployed",
"Non-goals: no server-side rendering / Next.js (dufs is a static server); no live deploy in this session (interactive/outward-facing)"
],
"changes": [
"SPA serving architecture (de-risked against the real dufs 0.46.0 binary): dufs --assets does NOT override the UI, so instead dufs `render-spa` serves the app shell, the SPA lists directories via WebDAV PROPFIND (render-flag-immune) and streams files via GET (Range for video), with HashRouter so navigation stays at '/'. Same-origin → dufs basic-auth covers all API calls. Verified render-spa leaves KeePass file GET/PUT/?hash intact (Phase 1 safe).",
"cloud_gallery: typed dufs client (PROPFIND parse, GET/PUT/DELETE, thumbnail URLs), useHashPath routing, useListing, and components (grid+thumbnails, media-viewer lightbox/video with keyboard nav, drag/pick upload, confirm-guarded delete, text editor). Mirrors the repo's bucket_catch stack (pnpm, strictTypeChecked eslint, vitest).",
"generate_thumbnails.sh writes ~400px JPEGs to $CLOUD_ROOT/.thumbs/<path>.jpg (images via ImageMagick, video posters via ffmpeg), idempotent + flock-guarded; setup_cloud_gallery.sh builds+deploys the SPA into the cloud root, enables render-spa, runs an initial thumbnail pass, and adds an ExecStartPost drop-in so thumbnails refresh after each Phase-2 media sync."
],
"verification": [
{
"command": "dufs --assets vs --render-spa de-risk (real dufs binary)",
"result": "pass",
"evidence": "--assets did not override the listing UI; --render-spa + WebDAV PROPFIND works: PROPFIND lists dirs, GET serves files, PUT=201, DELETE=204, and ?hash/GET/PUT on files still work (KeePass unaffected)."
},
{
"command": "pnpm build && pnpm lint (tsc + eslint strictTypeChecked)",
"result": "pass",
"evidence": "vite build produces dist (index.html + assets); tsc --noEmit clean; eslint clean."
},
{
"command": "pnpm test / pnpm coverage",
"result": "pass",
"evidence": "38 vitest tests pass; coverage 92.8% statements / 85.3% branches / 92.1% functions / 96% lines — above the project thresholds (dufs-client 100%). Tests render the Gallery in jsdom and exercise browse, lightbox, video, upload, delete, and the editor."
},
{
"command": "generate_thumbnails.sh on throwaway media",
"result": "pass",
"evidence": "image → 400px JPEG thumbnail; idempotent re-run skips current thumbs. Video posters need ffmpeg, which is broken on this host (libvpx.so.11 missing) — the script degrades gracefully (failed count) and the grid shows a film icon."
},
{
"command": "Local dufs --render-spa end-to-end serving check",
"result": "pass",
"evidence": "Built SPA + test media + thumbnails served by a local dufs: GET / → app shell, /assets/*.js → 200, /.thumbs/....jpg → 200 image/jpeg, PROPFIND lists the media. Live in-browser pixel render pending (Chrome extension not connected while the user is away)."
},
{
"command": "Live deploy (setup_cloud_gallery.sh) + in-browser verification",
"result": "pending",
"evidence": "Needs sudo + a build on the live host and enables render-spa on the public dufs; left for the user to run via '! setup_cloud_gallery.sh' and eyeball. Note: fix ffmpeg first for video posters."
}
],
"risks": [
"The live deploy enables dufs render-spa (verified safe for the KeePass sync) and copies the SPA (index.html + assets/) into the cloud root; the gallery hides those app files from the grid.",
"System ffmpeg is broken (libvpx.so.11) so video posters are skipped until it's fixed (full system upgrade / reinstall ffmpeg); images are unaffected.",
"Coverage is high (>90%) but not the monorepo's 100% bar across a full media SPA; the dufs client is 100% and UI branches are covered by integration tests."
],
"rollback": [
"Remove render-spa from ~/.config/dufs/dufs.yaml and restart dufs to return to the default file-list UI.",
"rm ~/cloud/index.html ~/cloud/assets (the deployed SPA) and ~/cloud/.thumbs (thumbnails); remove /etc/systemd/system/media-cloud-sync.service.d/10-thumbnails.conf.",
"The cloud data (Media, Keepass, etc.) is untouched by any of the above."
]
}

View File

@ -0,0 +1,121 @@
#!/bin/bash
# setup_cloud_gallery.sh — build and deploy the React media-gallery SPA so it
# becomes the UI of the dufs cloud (served same-origin behind the existing auth).
#
# It builds cloud_gallery/ (Vite), copies the static build into the dufs serve
# path, turns on dufs `render-spa`, generates thumbnails, and wires thumbnail
# regeneration into the Phase-2 media sync. Run on the dufs host (the PC).
#
# The SPA lists directories with WebDAV PROPFIND and streams files with GET, so
# it works under render-spa without disturbing the KeePass sync (file GET/PUT/
# ?hash are unaffected — verified).
set -euo pipefail
HERE="$(dirname "$(readlink -f "$0")")"
readonly HERE
REPO_ROOT="$(git -C "$HERE" rev-parse --show-toplevel)"
readonly REPO_ROOT
readonly GALLERY_DIR="$REPO_ROOT/cloud_gallery"
readonly THUMBS_SCRIPT="$REPO_ROOT/linux_configuration/scripts/single_use/utils/generate_thumbnails.sh"
readonly DUFS_YAML="$HOME/.config/dufs/dufs.yaml"
C() { printf '\033[1;34m[cloud-gallery]\033[0m %s\n' "$*"; }
OK() { printf '\033[1;32m ✓\033[0m %s\n' "$*"; }
WARN() { printf '\033[1;33m !\033[0m %s\n' "$*"; }
die() { printf '\033[1;31m[cloud-gallery] ERROR:\033[0m %s\n' "$*" >&2; exit 1; }
cloud_root() {
local sp=""
[[ -f "$DUFS_YAML" ]] && sp="$(sed -nE 's/^serve-path:[[:space:]]*//p' "$DUFS_YAML" | head -1)"
printf '%s' "${sp:-$HOME/cloud}"
}
install_deps() {
C "Installing build + thumbnail dependencies"
local pkgs=(imagemagick ffmpeg)
command -v node >/dev/null || pkgs+=(nodejs)
command -v pnpm >/dev/null || pkgs+=(pnpm)
command -v fd >/dev/null || pkgs+=(fd)
if command -v pacman >/dev/null; then
sudo pacman -S --needed --noconfirm "${pkgs[@]}"
else
die "this installer targets the Arch dufs host (pacman)"
fi
command -v node >/dev/null || die "node missing after install"
command -v pnpm >/dev/null || die "pnpm missing after install"
OK "dependencies present ($(node -v), pnpm $(pnpm -v))"
command -v ffmpeg >/dev/null && ffmpeg -version >/dev/null 2>&1 \
|| WARN "ffmpeg is present but not runnable — video posters will be skipped (fix ffmpeg, e.g. full system upgrade)"
}
build_spa() {
C "Building the gallery (pnpm build)"
[[ -d "$GALLERY_DIR" ]] || die "missing $GALLERY_DIR"
(cd "$GALLERY_DIR" && pnpm install --silent && pnpm build)
[[ -f "$GALLERY_DIR/dist/index.html" ]] || die "build produced no dist/index.html"
OK "built $GALLERY_DIR/dist"
}
deploy_spa() {
local root; root="$(cloud_root)"
C "Deploying the SPA into the cloud root: $root"
mkdir -p "$root"
rm -rf "$root/assets"
cp -r "$GALLERY_DIR/dist/assets" "$root/assets"
cp -f "$GALLERY_DIR/dist/index.html" "$root/index.html"
OK "deployed index.html + assets/"
}
enable_render_spa() {
[[ -f "$DUFS_YAML" ]] || die "dufs config $DUFS_YAML not found — run setup_dufs_cloud.sh first"
if grep -q '^render-spa:' "$DUFS_YAML"; then
OK "dufs render-spa already enabled"
else
# Insert after the allow-all line so the app shell is served for the UI.
sed -i '/^allow-all:/a render-spa: true' "$DUFS_YAML"
OK "enabled render-spa in dufs.yaml"
fi
sudo systemctl restart dufs.service
OK "dufs restarted"
}
wire_thumbnails() {
C "Generating thumbnails + wiring into media sync"
bash "$THUMBS_SCRIPT" || WARN "initial thumbnail pass reported problems (continuing)"
# Regenerate thumbnails after each Phase-2 media sync (best-effort).
if systemctl is-enabled media-cloud-sync.timer >/dev/null 2>&1 \
|| systemctl is-active media-cloud-sync.path >/dev/null 2>&1; then
sudo mkdir -p /etc/systemd/system/media-cloud-sync.service.d
sudo tee /etc/systemd/system/media-cloud-sync.service.d/10-thumbnails.conf >/dev/null <<EOF
[Service]
ExecStartPost=-$THUMBS_SCRIPT
EOF
sudo systemctl daemon-reload
OK "thumbnails will refresh after each media sync"
else
WARN "media-cloud-sync not set up — run setup_media_cloud_sync.sh for auto thumbnails"
fi
}
main() {
[[ "${1:-}" == "-h" || "${1:-}" == "--help" ]] \
&& { grep -E '^#( |$)' "$0" | sed -E 's/^# ?//'; exit 0; }
install_deps
build_spa
deploy_spa
enable_render_spa
wire_thumbnails
local url="https://kuhy-cloud.duckdns.org"
cat <<EOF
────────────────────────────────────────────────────────────────────────────
Cloud gallery deployed. Open ${url}/ (log in with your dufs credentials).
Browse folders, view images in the lightbox, play videos, upload/download,
delete, and edit .txt/.md files — all from the browser.
Rebuild + redeploy anytime by re-running this script.
────────────────────────────────────────────────────────────────────────────
EOF
}
main "$@"

View File

@ -196,6 +196,10 @@ write_dufs_config() {
local hash
# Read the password from stdin so it never appears in the process argv (ps).
hash="$(printf '%s' "${DUFS_PASSWORD}" | openssl passwd -6 -stdin)"
# Serve the cloud_gallery SPA (render-spa) ONLY once it is deployed into the
# cloud root — a fresh dufs with no gallery keeps the default file listing.
local render=""
[[ -f "${CLOUD_ROOT}/index.html" ]] && render="render-spa: true"
ensure_dir "${cfg_dir}"
umask 077
cat >"${cfg}" <<EOF
@ -204,6 +208,7 @@ serve-path: ${CLOUD_ROOT}
bind: 127.0.0.1
port: ${DUFS_PORT}
allow-all: true
${render}
auth:
- "${DUFS_USER}:${hash}@/:rw"
EOF

View File

@ -0,0 +1,85 @@
#!/bin/bash
# generate_thumbnails.sh — make small preview thumbnails for the cloud media so
# the web gallery (cloud_gallery) loads fast instead of downloading full files.
#
# For every image/video under the dufs cloud it writes a ~400px JPEG to
# $CLOUD_ROOT/.thumbs/<same-path>.jpg
# which the gallery requests as `/.thumbs/<path>.jpg`. Images are resized with
# ImageMagick; videos get a poster frame via ffmpeg. Idempotent: a thumbnail
# newer than its source is skipped. flock-guarded, self-contained.
#
# CLOUD_ROOT comes from the dufs serve-path (~/.config/dufs/dufs.yaml), else
# ~/cloud. Usage: generate_thumbnails.sh [ROOT_DIR]
set -euo pipefail
readonly THUMB_MAX=400
log() { printf '[thumbnails] %s\n' "$*" >&2; }
CLOUD_ROOT="${CLOUD_ROOT:-}"
if [[ -z "$CLOUD_ROOT" && -f "$HOME/.config/dufs/dufs.yaml" ]]; then
CLOUD_ROOT="$(sed -nE 's/^serve-path:[[:space:]]*//p' "$HOME/.config/dufs/dufs.yaml" | head -1)"
fi
CLOUD_ROOT="${CLOUD_ROOT:-$HOME/cloud}"
ROOT="${1:-$CLOUD_ROOT}"
readonly THUMBS="$CLOUD_ROOT/.thumbs"
FD="$(command -v fd || command -v fdfind || true)"
[[ -n "$FD" ]] || { log "ERROR: fd (fd-find) not installed"; exit 1; }
command -v ffmpeg >/dev/null || { log "ERROR: ffmpeg not installed"; exit 1; }
MAGICK="$(command -v magick || command -v convert || true)"
[[ -n "$MAGICK" ]] || { log "ERROR: ImageMagick (magick/convert) not installed"; exit 1; }
readonly IMG_EXTS=(jpg jpeg png gif bmp tiff tif webp heic heif avif)
readonly VID_EXTS=(mp4 avi mkv mov wmv flv webm m4v 3gp ogv mpg mpeg mts m2ts vob)
FD_ARGS=()
for e in "${IMG_EXTS[@]}" "${VID_EXTS[@]}"; do FD_ARGS+=(-e "$e"); done
STATE_DIR="${THUMB_STATE:-$HOME/.local/state/media-cloud-sync}"
mkdir -p "$STATE_DIR" "$THUMBS"
exec 9>"$STATE_DIR/thumbs.lock"
flock -n 9 || { log "another thumbnail run is in progress — skipping"; exit 0; }
is_video() {
local ext="${1##*.}"
ext="${ext,,}"
for v in "${VID_EXTS[@]}"; do [[ "$ext" == "$v" ]] && return 0; done
return 1
}
made=0 skipped=0 failed=0
while IFS= read -r src; do
[[ -f "$src" ]] || continue
# Thumbnail path mirrors the file's path under the cloud root.
rel="${src#"$CLOUD_ROOT"}"
dst="$THUMBS${rel}.jpg"
if [[ -f "$dst" && "$dst" -nt "$src" ]]; then
skipped=$((skipped + 1)); continue
fi
mkdir -p "$(dirname "$dst")"
if is_video "$src"; then
if ffmpeg -y -loglevel error -ss 1 -i "$src" -frames:v 1 \
-vf "scale=${THUMB_MAX}:-2:force_original_aspect_ratio=decrease" \
"$dst" </dev/null 2>/dev/null; then
made=$((made + 1))
else
# Fall back to the very first frame (videos shorter than 1s).
if ffmpeg -y -loglevel error -i "$src" -frames:v 1 \
-vf "scale=${THUMB_MAX}:-2" "$dst" </dev/null 2>/dev/null; then
made=$((made + 1))
else
failed=$((failed + 1))
fi
fi
else
if "$MAGICK" "${src}[0]" -auto-orient -thumbnail "${THUMB_MAX}x${THUMB_MAX}>" \
-strip "$dst" 2>/dev/null; then
made=$((made + 1))
else
failed=$((failed + 1))
fi
fi
done < <("$FD" "${FD_ARGS[@]}" --type f --exclude '.thumbs' --exclude '_thumbs' . "$ROOT" 2>/dev/null || true)
log "done: $made made, $skipped current, $failed failed → $THUMBS"