diff --git a/apps/demo/src/mock-api.ts b/apps/demo/src/mock-api.ts index 4b1b1d19..aca975f3 100644 --- a/apps/demo/src/mock-api.ts +++ b/apps/demo/src/mock-api.ts @@ -191,12 +191,25 @@ interface DemoAuditEntry { createdAt: string; } +interface DemoUserFile { + id: string; + originalName: string; + mimeType: string; + size: number; + width: number | null; + height: number | null; + version: number; + toolChain: string[]; + createdAt: string; +} + const db: { users: DemoUser[]; teams: DemoTeam[]; roles: DemoRole[]; apiKeys: DemoApiKey[]; audit: DemoAuditEntry[]; + files: DemoUserFile[]; settings: Record; preferences: Record; } = { @@ -457,6 +470,96 @@ const db: { defaultToolView: "sidebar", pinnedTools: ["compress-image", "remove-background", "pdf-merge", "resize-image"], }, + files: [ + { + id: "file_sunrise", + originalName: "mountain-sunrise.jpg", + mimeType: "image/jpeg", + size: 2_517_320, + width: 4032, + height: 3024, + version: 1, + toolChain: [], + createdAt: isoDaysAgo(2), + }, + { + id: "file_team", + originalName: "team-portrait.png", + mimeType: "image/png", + size: 5_204_880, + width: 3000, + height: 2000, + version: 1, + toolChain: [], + createdAt: isoDaysAgo(5), + }, + { + id: "file_mockup", + originalName: "product-mockup.webp", + mimeType: "image/webp", + size: 842_130, + width: 1600, + height: 1200, + version: 2, + toolChain: ["remove-background"], + createdAt: isoDaysAgo(6), + }, + { + id: "file_report", + originalName: "quarterly-report.pdf", + mimeType: "application/pdf", + size: 1_238_442, + width: null, + height: null, + version: 2, + toolChain: ["merge-pdf"], + createdAt: isoDaysAgo(9), + }, + { + id: "file_tour", + originalName: "product-tour.mp4", + mimeType: "video/mp4", + size: 18_734_210, + width: 1920, + height: 1080, + version: 1, + toolChain: [], + createdAt: isoDaysAgo(12), + }, + { + id: "file_podcast", + originalName: "episode-intro.mp3", + mimeType: "audio/mpeg", + size: 3_402_118, + width: null, + height: null, + version: 1, + toolChain: [], + createdAt: isoDaysAgo(14), + }, + { + id: "file_scan", + originalName: "invoice-scan.jpg", + mimeType: "image/jpeg", + size: 1_104_970, + width: 2480, + height: 3508, + version: 2, + toolChain: ["ocr"], + createdAt: isoDaysAgo(18), + }, + { + id: "file_assets", + originalName: "brand-assets.zip", + mimeType: "application/zip", + size: 6_845_002, + width: null, + height: null, + version: 1, + toolChain: [], + createdAt: isoDaysAgo(21), + }, + ], }; function seedAudit(): DemoAuditEntry[] { @@ -675,6 +778,98 @@ function buildUsage(days: number): unknown { }; } +/* ────────────────────── sample file thumbnails ────────────────────── + The file library renders thumbnails by fetching the thumbnail URL into a + blob (AuthImage), so the mock can answer with a generated SVG tile. No real + file bytes are needed; each tile is colour-coded by type. */ + +type FileKind = "image" | "video" | "audio" | "pdf" | "archive" | "doc"; + +function fileKind(mimeType: string): FileKind { + if (mimeType.startsWith("image/")) return "image"; + if (mimeType.startsWith("video/")) return "video"; + if (mimeType.startsWith("audio/")) return "audio"; + if (mimeType === "application/pdf") return "pdf"; + if (/zip|compressed|tar|rar|7z/.test(mimeType)) return "archive"; + return "doc"; +} + +const KIND_COLORS: Record = { + image: ["#F09550", "#C06520"], + video: ["#8B7CF6", "#5B44C7"], + audio: ["#34C77B", "#1F8F55"], + pdf: ["#EF6A6A", "#C23B3B"], + archive: ["#8A94A6", "#5B6472"], + doc: ["#5B9BF6", "#2F6FD0"], +}; + +function fileGlyph(kind: FileKind): string { + switch (kind) { + case "image": + return ''; + case "video": + return ''; + case "audio": + return ''; + case "archive": + return ''; + default: + return ''; + } +} + +const KIND_LABELS: Record = { + image: "Image", + video: "Video", + audio: "Audio", + pdf: "PDF Document", + archive: "Archive", + doc: "Document", +}; + +function fileThumbnailSvg(f: DemoUserFile): string { + const kind = fileKind(f.mimeType); + const [c1, c2] = KIND_COLORS[kind]; + const ext = (f.originalName.split(".").pop() || "file").toUpperCase(); + const label = f.width && f.height ? `${f.width} x ${f.height}` : KIND_LABELS[kind]; + return `${fileGlyph(kind)}${ext}${label}`; +} + +function svgResponse(svg: string, extraHeaders: Record = {}): Response { + return new Response(svg, { + status: 200, + headers: { "Content-Type": "image/svg+xml", ...extraHeaders }, + }); +} + +function fileVersions(f: DemoUserFile): Array<{ + id: string; + version: number; + size: number; + toolChain: string[]; + createdAt: string; +}> { + if (f.version >= 2) { + return [ + { + id: `${f.id}_v2`, + version: 2, + size: f.size, + toolChain: f.toolChain, + createdAt: f.createdAt, + }, + { + id: `${f.id}_v1`, + version: 1, + size: Math.round(f.size * 1.6), + toolChain: [], + createdAt: f.createdAt, + }, + ]; + } + return [{ id: `${f.id}_v1`, version: 1, size: f.size, toolChain: [], createdAt: f.createdAt }]; +} + /* ────────────────────── persisted flags ────────────────────── */ const STATE_KEY = "snapotter-demo-state"; @@ -1008,12 +1203,55 @@ export function matchDemoRoute(url: string, method: string, body?: unknown): Res return json({ error: DEMO_DISABLED_MESSAGE }, 403); } + /* ---- file library ---- */ + if (path === "/api/v1/files" && method === "GET") { - return json({ files: [], total: 0 }); + const search = (query.get("search") || "").toLowerCase(); + const limit = Number(query.get("limit") || "50"); + const offset = Number(query.get("offset") || "0"); + const matched = search + ? db.files.filter((f) => f.originalName.toLowerCase().includes(search)) + : db.files; + return json({ + files: matched.slice(offset, offset + limit), + total: matched.length, + limit, + offset, + }); } if (path === "/api/v1/files" && method === "DELETE") { - return json({ deleted: 0 }); + const b = parseBody(body); + const ids = Array.isArray(b.ids) ? (b.ids as string[]) : []; + const before = db.files.length; + db.files = db.files.filter((f) => !ids.includes(f.id)); + return json({ deleted: before - db.files.length }); + } + + // Thumbnails/previews are fetched into a blob, so answer with a generated SVG. + const fileImageMatch = path.match(/^\/api\/v1\/files\/([^/]+)\/(thumbnail|preview)$/); + if (fileImageMatch && method === "GET") { + const file = db.files.find((f) => f.id === fileImageMatch[1]); + if (!file) return json({ error: "Not found" }, 404); + return svgResponse(fileThumbnailSvg(file)); + } + + const fileDownloadMatch = path.match(/^\/api\/v1\/files\/([^/]+)\/download$/); + if (fileDownloadMatch && method === "GET") { + const file = db.files.find((f) => f.id === fileDownloadMatch[1]); + if (!file) return json({ error: "Not found" }, 404); + // The real file doesn't exist in the demo; hand back the representative + // tile so the download resolves instead of erroring. + return svgResponse(fileThumbnailSvg(file), { + "Content-Disposition": `attachment; filename="${file.originalName}"`, + }); + } + + const fileDetailMatch = path.match(/^\/api\/v1\/files\/([^/]+)$/); + if (fileDetailMatch && method === "GET") { + const file = db.files.find((f) => f.id === fileDetailMatch[1]); + if (!file) return json({ error: "Not found" }, 404); + return json({ file, versions: fileVersions(file) }); } if (path.startsWith("/api/v1/pipelines") && method === "GET") { diff --git a/tests/e2e-demo/demo-preview.spec.ts b/tests/e2e-demo/demo-preview.spec.ts index 0ddc1c10..a5eaa1d0 100644 --- a/tests/e2e-demo/demo-preview.spec.ts +++ b/tests/e2e-demo/demo-preview.spec.ts @@ -118,3 +118,22 @@ test("mobile bottom nav renders a visible image-editor icon", async ({ page }) = expect(box?.width ?? 0).toBeGreaterThan(0); expect(box?.height ?? 0).toBeGreaterThan(0); }); + +test("files library is populated with sample files and thumbnails render", async ({ page }) => { + const pageErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + + await page.goto("/files"); + + // Sample files across modalities show up instead of the empty state. + await expect(page.getByText("mountain-sunrise.jpg")).toBeVisible(); + await expect(page.getByText("quarterly-report.pdf")).toBeVisible(); + await expect(page.getByText("product-tour.mp4")).toBeVisible(); + + // Opening a file shows its details; the thumbnail is fetched into a blob and + // rendered as an , confirming the generated SVG thumbnail pipeline works. + await page.getByText("mountain-sunrise.jpg").click(); + await expect(page.locator("img[src^='blob:']").first()).toBeVisible(); + + expect(pageErrors).toEqual([]); +}); diff --git a/tests/unit/infra/demo-mock-api.test.ts b/tests/unit/infra/demo-mock-api.test.ts index 54085103..4b30dc4d 100644 --- a/tests/unit/infra/demo-mock-api.test.ts +++ b/tests/unit/infra/demo-mock-api.test.ts @@ -161,4 +161,63 @@ describe("demo mock API", () => { const data = (await readJson(response as Response)) as { error: string }; expect(data.error).toContain("demo"); }); + + it("lists sample files in the library with the shape the page reads", async () => { + const data = (await readJson(matchDemoRoute("/api/v1/files", "GET") as Response)) as { + files: Array<{ id: string; originalName: string; mimeType: string; size: number }>; + total: number; + }; + expect(Array.isArray(data.files)).toBe(true); + expect(data.files.length).toBeGreaterThan(3); + expect(data.total).toBe(data.files.length); + for (const file of data.files) { + expect(typeof file.originalName).toBe("string"); + expect(typeof file.mimeType).toBe("string"); + expect(typeof file.size).toBe("number"); + } + }); + + it("filters the file library by the search query", async () => { + const data = (await readJson( + matchDemoRoute("/api/v1/files?search=mp4", "GET") as Response, + )) as { files: Array<{ originalName: string }> }; + expect(data.files.length).toBeGreaterThan(0); + for (const file of data.files) { + expect(file.originalName.toLowerCase()).toContain("mp4"); + } + }); + + it("serves an SVG thumbnail for a file so the grid can render it", async () => { + const list = (await readJson(matchDemoRoute("/api/v1/files", "GET") as Response)) as { + files: Array<{ id: string }>; + }; + const id = list.files[0].id; + const thumb = matchDemoRoute(`/api/v1/files/${id}/thumbnail`, "GET"); + expect(thumb?.status).toBe(200); + expect(thumb?.headers.get("Content-Type")).toContain("image/svg+xml"); + expect(await (thumb as Response).text()).toContain(" { + const details = (await readJson( + matchDemoRoute("/api/v1/files/file_mockup", "GET") as Response, + )) as { + file: { id: string }; + versions: Array<{ version: number }>; + }; + expect(details.file.id).toBe("file_mockup"); + expect(Array.isArray(details.versions)).toBe(true); + expect(details.versions.length).toBeGreaterThan(0); + }); + + it("deletes files from the library so the page reflects the change", async () => { + const del = matchDemoRoute("/api/v1/files", "DELETE", JSON.stringify({ ids: ["file_assets"] })); + expect(del?.status).toBe(200); + const data = (await readJson(del as Response)) as { deleted: number }; + expect(data.deleted).toBe(1); + const list = (await readJson(matchDemoRoute("/api/v1/files", "GET") as Response)) as { + files: Array<{ id: string }>; + }; + expect(list.files.some((f) => f.id === "file_assets")).toBe(false); + }); });