mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 20:07:04 +01:00
cloud_gallery: React media-gallery SPA served by dufs (Phase 3)
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
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:
parent
095587c0de
commit
76d4e8e0cf
9
.prettierignore
Normal file
9
.prettierignore
Normal 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
6
cloud_gallery/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
*.local
|
||||
.DS_Store
|
||||
30
cloud_gallery/eslint.config.js
Normal file
30
cloud_gallery/eslint.config.js
Normal 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
13
cloud_gallery/index.html
Normal 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>
|
||||
41
cloud_gallery/package.json
Normal file
41
cloud_gallery/package.json
Normal 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
4007
cloud_gallery/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
7
cloud_gallery/pnpm-workspace.yaml
Normal file
7
cloud_gallery/pnpm-workspace.yaml
Normal 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
|
||||
126
cloud_gallery/src/api/dufs-client.test.ts
Normal file
126
cloud_gallery/src/api/dufs-client.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
108
cloud_gallery/src/api/dufs-client.ts
Normal file
108
cloud_gallery/src/api/dufs-client.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
14
cloud_gallery/src/api/types.ts
Normal file
14
cloud_gallery/src/api/types.ts
Normal 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;
|
||||
}
|
||||
8
cloud_gallery/src/app.tsx
Normal file
8
cloud_gallery/src/app.tsx
Normal 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} />;
|
||||
}
|
||||
35
cloud_gallery/src/components/confirm-dialog.tsx
Normal file
35
cloud_gallery/src/components/confirm-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
159
cloud_gallery/src/components/gallery.test.tsx
Normal file
159
cloud_gallery/src/components/gallery.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
174
cloud_gallery/src/components/gallery.tsx
Normal file
174
cloud_gallery/src/components/gallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
cloud_gallery/src/components/grid.tsx
Normal file
105
cloud_gallery/src/components/grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
cloud_gallery/src/components/media-viewer.test.tsx
Normal file
94
cloud_gallery/src/components/media-viewer.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
98
cloud_gallery/src/components/media-viewer.tsx
Normal file
98
cloud_gallery/src/components/media-viewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
cloud_gallery/src/components/text-editor.test.tsx
Normal file
66
cloud_gallery/src/components/text-editor.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
94
cloud_gallery/src/components/text-editor.tsx
Normal file
94
cloud_gallery/src/components/text-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
cloud_gallery/src/hooks/use-listing.ts
Normal file
73
cloud_gallery/src/hooks/use-listing.ts
Normal 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
343
cloud_gallery/src/index.css
Normal 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;
|
||||
}
|
||||
83
cloud_gallery/src/lib/paths.test.ts
Normal file
83
cloud_gallery/src/lib/paths.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
96
cloud_gallery/src/lib/paths.ts
Normal file
96
cloud_gallery/src/lib/paths.ts
Normal 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}`;
|
||||
}
|
||||
27
cloud_gallery/src/lib/use-hash-path.ts
Normal file
27
cloud_gallery/src/lib/use-hash-path.ts
Normal 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];
|
||||
}
|
||||
13
cloud_gallery/src/main.tsx
Normal file
13
cloud_gallery/src/main.tsx
Normal 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>,
|
||||
);
|
||||
}
|
||||
83
cloud_gallery/src/misc.test.tsx
Normal file
83
cloud_gallery/src/misc.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
10
cloud_gallery/src/test/setup.ts
Normal file
10
cloud_gallery/src/test/setup.ts
Normal 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();
|
||||
23
cloud_gallery/tsconfig.json
Normal file
23
cloud_gallery/tsconfig.json
Normal 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"]
|
||||
}
|
||||
22
cloud_gallery/vite.config.ts
Normal file
22
cloud_gallery/vite.config.ts
Normal 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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
17
docs/superpowers/contracts/cloud-gallery-2026-07-12.json
Normal file
17
docs/superpowers/contracts/cloud-gallery-2026-07-12.json
Normal 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"
|
||||
}
|
||||
57
docs/superpowers/evidence/cloud-gallery-2026-07-12.json
Normal file
57
docs/superpowers/evidence/cloud-gallery-2026-07-12.json
Normal 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."
|
||||
]
|
||||
}
|
||||
121
linux_configuration/scripts/single_use/features/setup_cloud_gallery.sh
Executable file
121
linux_configuration/scripts/single_use/features/setup_cloud_gallery.sh
Executable 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 "$@"
|
||||
@ -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
|
||||
|
||||
85
linux_configuration/scripts/single_use/utils/generate_thumbnails.sh
Executable file
85
linux_configuration/scripts/single_use/utils/generate_thumbnails.sh
Executable 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"
|
||||
Loading…
Reference in New Issue
Block a user