@@ -126,7 +120,7 @@ export function Gallery({ client }: { readonly client: DufsClient }): React.JSX.
client={client}
entries={entries}
onOpenDir={navigate}
- onOpenMedia={openMediaAt}
+ onOpenMedia={openMedia}
onEditText={setEditEntry}
onDelete={setDeleteEntry}
/>
@@ -139,7 +133,7 @@ export function Gallery({ client }: { readonly client: DufsClient }): React.JSX.
entry={viewerEntry}
url={client.fileUrl(viewerEntry.path)}
onClose={() => {
- setViewerIndex(null);
+ setViewerIndex(-1);
}}
onPrev={() => {
step(-1);
@@ -163,7 +157,9 @@ export function Gallery({ client }: { readonly client: DufsClient }): React.JSX.
{
+ confirmDelete(deleteEntry);
+ }}
onCancel={() => {
setDeleteEntry(null);
}}
diff --git a/cloud_gallery/src/components/grid.test.tsx b/cloud_gallery/src/components/grid.test.tsx
new file mode 100644
index 00000000..a1ee5ef7
--- /dev/null
+++ b/cloud_gallery/src/components/grid.test.tsx
@@ -0,0 +1,80 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { Grid } from "./grid.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: 42, mtimeMs: 0 };
+}
+
+function makeClient(): DufsClient {
+ return {
+ list: vi.fn(),
+ fileUrl: (p: string) => p,
+ thumbUrl: (p: string) => `/.thumbs${p}.jpg`,
+ upload: vi.fn(),
+ remove: vi.fn(),
+ readText: vi.fn(),
+ writeText: vi.fn(),
+ };
+}
+
+const entries: DirEntry[] = [
+ entry("Media", "dir"),
+ entry("pic.jpg", "file"),
+ entry("clip.mp4", "file"),
+ entry("notes.txt", "file"),
+ entry("data.bin", "file"),
+];
+
+function renderGrid(over: Partial[0]> = {}) {
+ const props = {
+ client: makeClient(),
+ entries,
+ onOpenDir: vi.fn(),
+ onOpenMedia: vi.fn(),
+ onEditText: vi.fn(),
+ onDelete: vi.fn(),
+ ...over,
+ };
+ render();
+ return props;
+}
+
+describe("Grid", () => {
+ it("activates dir, media and text entries via their tiles", async () => {
+ const onOpenDir = vi.fn();
+ const onOpenMedia = vi.fn();
+ const onEditText = vi.fn();
+ renderGrid({ onOpenDir, onOpenMedia, onEditText });
+ await userEvent.click(screen.getByText("Media"));
+ await userEvent.click(screen.getByText("pic.jpg"));
+ await userEvent.click(screen.getByText("notes.txt"));
+ // Non-media, non-text file does nothing on activate.
+ await userEvent.click(screen.getByText("data.bin"));
+ expect(onOpenDir).toHaveBeenCalledWith("/Media");
+ expect(onOpenMedia).toHaveBeenCalledWith(entries[1]);
+ expect(onEditText).toHaveBeenCalledWith(entries[3]);
+ });
+
+ it("renders a download link and a delete button for files", async () => {
+ const onDelete = vi.fn();
+ renderGrid({ onDelete });
+ const download = screen.getByLabelText("Download pic.jpg");
+ expect(download).toHaveAttribute("href", "/pic.jpg");
+ await userEvent.click(screen.getByLabelText("Delete pic.jpg"));
+ expect(onDelete).toHaveBeenCalledWith(entries[1]);
+ });
+
+ it("falls back to an icon when a thumbnail fails to load", () => {
+ renderGrid();
+ // Image thumbnail breaks -> picture fallback glyph.
+ fireEvent.error(screen.getByAltText("pic.jpg"));
+ expect(screen.getByText("🖼")).toBeInTheDocument();
+ // Video thumbnail breaks -> film fallback glyph.
+ fireEvent.error(screen.getByAltText("clip.mp4"));
+ expect(screen.getByText("🎞")).toBeInTheDocument();
+ });
+});
diff --git a/cloud_gallery/src/components/grid.tsx b/cloud_gallery/src/components/grid.tsx
index 00bc507f..1f73caa5 100644
--- a/cloud_gallery/src/components/grid.tsx
+++ b/cloud_gallery/src/components/grid.tsx
@@ -7,7 +7,7 @@ interface GridProps {
readonly client: DufsClient;
readonly entries: readonly DirEntry[];
readonly onOpenDir: (path: string) => void;
- readonly onOpenMedia: (index: number) => void;
+ readonly onOpenMedia: (entry: DirEntry) => void;
readonly onEditText: (entry: DirEntry) => void;
readonly onDelete: (entry: DirEntry) => void;
}
@@ -46,11 +46,11 @@ export function Grid({
}: GridProps): React.JSX.Element {
return (
- {entries.map((entry, index) => {
+ {entries.map((entry) => {
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 (media) onOpenMedia(entry);
else if (isText(entry.name)) onEditText(entry);
};
return (
diff --git a/cloud_gallery/src/components/media-viewer.test.tsx b/cloud_gallery/src/components/media-viewer.test.tsx
index 8a9b2ebf..b47a1d21 100644
--- a/cloud_gallery/src/components/media-viewer.test.tsx
+++ b/cloud_gallery/src/components/media-viewer.test.tsx
@@ -25,17 +25,22 @@ describe("MediaViewer", () => {
expect(img.className).toContain("zoomed");
});
- it("renders a