mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(demo): populate the Files library with sample files (#465)
Seed eight sample files across modalities in the demo Files library and mock the list (search + pagination), details with version history, bulk delete, and thumbnails. Thumbnails are generated colour-coded SVG tiles served through the interceptable fetch path, so the grid always renders with no real file bytes. Uploads/processing stay disabled.
This commit is contained in:
+240
-2
@@ -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<string, string>;
|
||||
preferences: Record<string, unknown>;
|
||||
} = {
|
||||
@@ -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<FileKind, [string, string]> = {
|
||||
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 '<rect x="104" y="70" width="112" height="140" rx="10" fill="rgba(255,255,255,0.16)" stroke="rgba(255,255,255,0.65)" stroke-width="3"/><circle cx="140" cy="112" r="12" fill="rgba(255,255,255,0.85)"/><path d="M126 176 150 150 166 166 188 140 194 176Z" fill="rgba(255,255,255,0.85)"/>';
|
||||
case "video":
|
||||
return '<rect x="96" y="82" width="128" height="116" rx="14" fill="rgba(255,255,255,0.16)" stroke="rgba(255,255,255,0.65)" stroke-width="3"/><path d="M146 116 184 140 146 164Z" fill="rgba(255,255,255,0.9)"/>';
|
||||
case "audio":
|
||||
return '<g fill="rgba(255,255,255,0.85)"><rect x="120" y="120" width="12" height="50" rx="6"/><rect x="142" y="96" width="12" height="98" rx="6"/><rect x="164" y="112" width="12" height="66" rx="6"/><rect x="186" y="104" width="12" height="82" rx="6"/></g>';
|
||||
case "archive":
|
||||
return '<rect x="112" y="82" width="96" height="120" rx="10" fill="rgba(255,255,255,0.16)" stroke="rgba(255,255,255,0.65)" stroke-width="3"/><rect x="152" y="82" width="16" height="120" fill="rgba(255,255,255,0.22)"/><g fill="rgba(255,255,255,0.85)"><rect x="154" y="96" width="12" height="10"/><rect x="154" y="118" width="12" height="10"/><rect x="154" y="140" width="12" height="10"/></g>';
|
||||
default:
|
||||
return '<rect x="112" y="74" width="96" height="132" rx="8" fill="rgba(255,255,255,0.16)" stroke="rgba(255,255,255,0.65)" stroke-width="3"/><g stroke="rgba(255,255,255,0.8)" stroke-width="5" stroke-linecap="round"><path d="M128 108H192"/><path d="M128 128H192"/><path d="M128 148H176"/></g>';
|
||||
}
|
||||
}
|
||||
|
||||
const KIND_LABELS: Record<FileKind, string> = {
|
||||
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 `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="320" viewBox="0 0 320 320"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${c1}"/><stop offset="1" stop-color="${c2}"/></linearGradient></defs><rect width="320" height="320" fill="url(#g)"/>${fileGlyph(kind)}<text x="160" y="252" text-anchor="middle" font-family="Helvetica, Arial, sans-serif" font-size="38" font-weight="700" fill="#ffffff">${ext}</text><text x="160" y="282" text-anchor="middle" font-family="Helvetica, Arial, sans-serif" font-size="16" fill="rgba(255,255,255,0.9)">${label}</text></svg>`;
|
||||
}
|
||||
|
||||
function svgResponse(svg: string, extraHeaders: Record<string, string> = {}): 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") {
|
||||
|
||||
@@ -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 <img>, 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([]);
|
||||
});
|
||||
|
||||
@@ -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("<svg");
|
||||
});
|
||||
|
||||
it("returns file details with a version history", async () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user