diff --git a/docs/superpowers/plans/2026-03-25-files-page.md b/docs/superpowers/plans/2026-03-25-files-page.md new file mode 100644 index 00000000..c8baaf86 --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-files-page.md @@ -0,0 +1,1743 @@ +# Files Page Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a persistent file manager page with Recent files, Upload, file details panel, and auto-versioning when tools process files. + +**Architecture:** New `user_files` SQLite table tracks uploaded/processed files with version chains. New `/api/v1/files/*` routes handle CRUD. Frontend is a three-panel page (nav, file list, details). The existing tool-factory gains an optional `fileId` parameter to auto-save processed results as new versions. + +**Tech Stack:** Drizzle ORM, Fastify, Sharp (thumbnails), Zustand, React, Tailwind CSS + +--- + +## File Structure + +### New files + +``` +apps/api/src/routes/user-files.ts # All /api/v1/files/* routes (list, upload, details, download, thumbnail, delete) +apps/api/src/lib/file-storage.ts # Disk I/O helpers: saveFile, deleteFile, getFilePath, ensureStorageDir +apps/web/src/pages/files-page.tsx # Three-panel Files page +apps/web/src/stores/files-page-store.ts # Zustand store for Files page state +apps/web/src/components/files/files-nav.tsx # Left nav panel +apps/web/src/components/files/file-list.tsx # Center: search + toolbar + file rows +apps/web/src/components/files/file-list-item.tsx # Single file row +apps/web/src/components/files/file-details.tsx # Right panel: thumbnail + metadata + Open File +apps/web/src/components/files/file-upload-area.tsx # Dropzone for Upload tab +``` + +### Modified files + +``` +apps/api/src/db/schema.ts # Add userFiles table +apps/api/src/lib/env.ts # Add FILES_STORAGE_PATH env var +apps/api/src/index.ts # Register userFiles routes +apps/api/src/routes/tool-factory.ts # Accept fileId, auto-save result as new version +apps/web/src/App.tsx # Add /files route +apps/web/src/components/layout/sidebar.tsx # Re-add Files nav item +apps/web/src/components/layout/app-layout.tsx # Re-add Files to mobile nav +apps/web/src/stores/file-store.ts # Add optional serverFileId field to FileEntry +apps/web/src/lib/api.ts # Add files API helpers +``` + +--- + +### Task 1: Database Schema — Add userFiles Table + +**Files:** +- Modify: `apps/api/src/db/schema.ts` + +- [ ] **Step 1: Add userFiles table to schema** + +Add to `apps/api/src/db/schema.ts` after the `pipelines` table: + +```typescript +export const userFiles = sqliteTable("user_files", { + id: text("id").primaryKey(), + userId: text("user_id").references(() => users.id, { onDelete: "cascade" }), + originalName: text("original_name").notNull(), + storedName: text("stored_name").notNull(), + mimeType: text("mime_type").notNull(), + size: integer("size").notNull(), + width: integer("width"), + height: integer("height"), + version: integer("version").notNull().default(1), + parentId: text("parent_id"), + toolChain: text("tool_chain"), + createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()), +}); +``` + +- [ ] **Step 2: Generate Drizzle migration** + +Run: `cd apps/api && npx drizzle-kit generate` + +Expected: A new migration file appears in `apps/api/drizzle/` with `CREATE TABLE user_files`. + +- [ ] **Step 3: Verify migration applies** + +Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter api dev` (start and stop quickly) + +Expected: Server starts without DB errors, table is created. + +- [ ] **Step 4: Commit** + +```bash +git add apps/api/src/db/schema.ts apps/api/drizzle/ +git commit -m "feat(db): add user_files table for persistent file storage" +``` + +--- + +### Task 2: Environment Config — Add FILES_STORAGE_PATH + +**Files:** +- Modify: `apps/api/src/lib/env.ts` + +- [ ] **Step 1: Add FILES_STORAGE_PATH to env schema** + +Add to the `envSchema` object in `apps/api/src/lib/env.ts`: + +```typescript +FILES_STORAGE_PATH: z.string().default("./data/files"), +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/api/src/lib/env.ts +git commit -m "feat(config): add FILES_STORAGE_PATH env variable" +``` + +--- + +### Task 3: File Storage Helpers + +**Files:** +- Create: `apps/api/src/lib/file-storage.ts` + +- [ ] **Step 1: Create file-storage.ts** + +```typescript +import { mkdir, writeFile, unlink, stat } from "node:fs/promises"; +import { join, extname } from "node:path"; +import { randomUUID } from "node:crypto"; +import { env } from "../config.js"; + +let storageReady = false; + +/** Ensure the storage directory exists. Called once on first use. */ +export async function ensureStorageDir(): Promise { + if (storageReady) return; + await mkdir(env.FILES_STORAGE_PATH, { recursive: true }); + storageReady = true; +} + +/** Save a buffer to disk with a UUID-based name. Returns the stored filename. */ +export async function saveFile(buffer: Buffer, originalName: string): Promise { + await ensureStorageDir(); + const ext = extname(originalName).toLowerCase() || ".bin"; + const storedName = `${randomUUID()}${ext}`; + await writeFile(join(env.FILES_STORAGE_PATH, storedName), buffer); + return storedName; +} + +/** Delete a file from storage by its stored name. */ +export async function deleteStoredFile(storedName: string): Promise { + try { + await unlink(join(env.FILES_STORAGE_PATH, storedName)); + } catch { + // File already gone — that's fine + } +} + +/** Get the full disk path for a stored file. */ +export function getStoredFilePath(storedName: string): string { + return join(env.FILES_STORAGE_PATH, storedName); +} + +/** Check if a stored file exists and return its size, or null. */ +export async function getStoredFileStat(storedName: string): Promise<{ size: number } | null> { + try { + const s = await stat(join(env.FILES_STORAGE_PATH, storedName)); + return { size: s.size }; + } catch { + return null; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/api/src/lib/file-storage.ts +git commit -m "feat: add file-storage helpers for persistent file management" +``` + +--- + +### Task 4: API Routes — User Files CRUD + +**Files:** +- Create: `apps/api/src/routes/user-files.ts` +- Modify: `apps/api/src/index.ts` + +- [ ] **Step 1: Create user-files.ts with all routes** + +```typescript +import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { extname } from "node:path"; +import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import sharp from "sharp"; +import { eq, desc, like, and, isNull, sql } from "drizzle-orm"; +import { db, schema } from "../db/index.js"; +import { validateImageBuffer } from "../lib/file-validation.js"; +import { sanitizeFilename } from "../lib/filename.js"; +import { saveFile, deleteStoredFile, getStoredFilePath } from "../lib/file-storage.js"; +import { getAuthUser } from "../plugins/auth.js"; + +function getMimeType(ext: string): string { + const map: Record = { + jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", + webp: "image/webp", gif: "image/gif", bmp: "image/bmp", + tiff: "image/tiff", tif: "image/tiff", avif: "image/avif", + }; + return map[ext] ?? "application/octet-stream"; +} + +/** + * Find the root ancestor of a file (the V1 original). + * Walks parentId chain up to the root. + */ +function getRootId(fileId: string): string { + let currentId = fileId; + for (let i = 0; i < 100; i++) { // safety limit + const row = db.select({ parentId: schema.userFiles.parentId }) + .from(schema.userFiles) + .where(eq(schema.userFiles.id, currentId)) + .get(); + if (!row || !row.parentId) return currentId; + currentId = row.parentId; + } + return currentId; +} + +export async function userFileRoutes(app: FastifyInstance): Promise { + // ── GET /api/v1/files — List recent files (latest version per group) ── + app.get( + "/api/v1/files", + async ( + request: FastifyRequest<{ Querystring: { search?: string; limit?: string; offset?: string } }>, + reply: FastifyReply, + ) => { + const user = getAuthUser(request); + const userId = user?.id ?? null; + const search = (request.query as { search?: string }).search || ""; + const limit = Math.min(parseInt((request.query as { limit?: string }).limit || "50", 10), 200); + const offset = parseInt((request.query as { offset?: string }).offset || "0", 10); + + // Get the latest version of each file chain. + // A file is "latest" if no other file has it as parentId. + let query = db.select() + .from(schema.userFiles) + .where( + and( + userId ? eq(schema.userFiles.userId, userId) : isNull(schema.userFiles.userId), + // Only get files that are NOT a parent of another file (i.e., they are the latest version) + sql`${schema.userFiles.id} NOT IN ( + SELECT ${schema.userFiles.parentId} FROM ${schema.userFiles} + WHERE ${schema.userFiles.parentId} IS NOT NULL + )`, + search ? like(schema.userFiles.originalName, `%${search}%`) : undefined, + ), + ) + .orderBy(desc(schema.userFiles.createdAt)) + .limit(limit) + .offset(offset); + + const files = query.all(); + + // Get total count for pagination + const countResult = db.select({ count: sql`count(*)` }) + .from(schema.userFiles) + .where( + and( + userId ? eq(schema.userFiles.userId, userId) : isNull(schema.userFiles.userId), + sql`${schema.userFiles.id} NOT IN ( + SELECT ${schema.userFiles.parentId} FROM ${schema.userFiles} + WHERE ${schema.userFiles.parentId} IS NOT NULL + )`, + search ? like(schema.userFiles.originalName, `%${search}%`) : undefined, + ), + ) + .get(); + + return reply.send({ + files: files.map((f) => ({ + id: f.id, + originalName: f.originalName, + mimeType: f.mimeType, + size: f.size, + width: f.width, + height: f.height, + version: f.version, + toolChain: f.toolChain ? JSON.parse(f.toolChain) : [], + createdAt: f.createdAt?.toISOString(), + })), + total: countResult?.count ?? 0, + }); + }, + ); + + // ── POST /api/v1/files/upload — Upload new files ────────────────── + app.post( + "/api/v1/files/upload", + async (request: FastifyRequest, reply: FastifyReply) => { + const user = getAuthUser(request); + const userId = user?.id ?? null; + const uploaded: Array<{ id: string; originalName: string; size: number; version: number }> = []; + + const parts = request.parts(); + for await (const part of parts) { + if (part.type !== "file") continue; + + const chunks: Buffer[] = []; + for await (const chunk of part.file) chunks.push(chunk); + const buffer = Buffer.concat(chunks); + if (buffer.length === 0) continue; + + const validation = await validateImageBuffer(buffer); + if (!validation.valid) { + return reply.status(400).send({ error: `Invalid file "${part.filename}": ${validation.reason}` }); + } + + const safeName = sanitizeFilename(part.filename ?? "upload"); + const storedName = await saveFile(buffer, safeName); + const ext = extname(safeName).toLowerCase().replace(/^\./, ""); + const id = randomUUID(); + + db.insert(schema.userFiles).values({ + id, + userId, + originalName: safeName, + storedName, + mimeType: getMimeType(ext), + size: buffer.length, + width: validation.width, + height: validation.height, + version: 1, + parentId: null, + toolChain: null, + }).run(); + + uploaded.push({ id, originalName: safeName, size: buffer.length, version: 1 }); + } + + if (uploaded.length === 0) { + return reply.status(400).send({ error: "No valid files uploaded" }); + } + + return reply.send({ files: uploaded }); + }, + ); + + // ── GET /api/v1/files/:id — File details with version history ───── + app.get( + "/api/v1/files/:id", + async ( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply, + ) => { + const { id } = request.params; + const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get(); + if (!file) return reply.status(404).send({ error: "File not found" }); + + // Build version chain by walking from root to latest + const rootId = getRootId(id); + const allVersions = db.select() + .from(schema.userFiles) + .where( + sql`${schema.userFiles.id} = ${rootId} + OR ${schema.userFiles.parentId} = ${rootId} + OR ${schema.userFiles.id} IN ( + WITH RECURSIVE chain(id) AS ( + SELECT ${rootId} + UNION ALL + SELECT uf.id FROM ${schema.userFiles} uf + JOIN chain c ON uf.parent_id = c.id + ) + SELECT id FROM chain + )`, + ) + .orderBy(schema.userFiles.version) + .all(); + + return reply.send({ + id: file.id, + originalName: file.originalName, + mimeType: file.mimeType, + size: file.size, + width: file.width, + height: file.height, + version: file.version, + toolChain: file.toolChain ? JSON.parse(file.toolChain) : [], + createdAt: file.createdAt?.toISOString(), + versions: allVersions.map((v) => ({ + id: v.id, + version: v.version, + size: v.size, + toolChain: v.toolChain ? JSON.parse(v.toolChain) : [], + createdAt: v.createdAt?.toISOString(), + })), + }); + }, + ); + + // ── GET /api/v1/files/:id/download — Download file ──────────────── + app.get( + "/api/v1/files/:id/download", + async ( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply, + ) => { + const { id } = request.params; + const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get(); + if (!file) return reply.status(404).send({ error: "File not found" }); + + const buffer = await readFile(getStoredFilePath(file.storedName)); + return reply + .header("Content-Type", file.mimeType) + .header("Content-Disposition", `attachment; filename="${encodeURIComponent(file.originalName)}"`) + .send(buffer); + }, + ); + + // ── GET /api/v1/files/:id/thumbnail — 300px JPEG thumbnail ──────── + app.get( + "/api/v1/files/:id/thumbnail", + async ( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply, + ) => { + const { id } = request.params; + const file = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, id)).get(); + if (!file) return reply.status(404).send({ error: "File not found" }); + + const buffer = await readFile(getStoredFilePath(file.storedName)); + const thumbnail = await sharp(buffer) + .resize(300, 300, { fit: "inside", withoutEnlargement: true }) + .jpeg({ quality: 80 }) + .toBuffer(); + + return reply + .header("Content-Type", "image/jpeg") + .header("Cache-Control", "public, max-age=3600") + .send(thumbnail); + }, + ); + + // ── DELETE /api/v1/files — Bulk delete ──────────────────────────── + app.delete( + "/api/v1/files", + async ( + request: FastifyRequest<{ Body: { ids: string[] } }>, + reply: FastifyReply, + ) => { + const { ids } = request.body as { ids: string[] }; + if (!ids || !Array.isArray(ids) || ids.length === 0) { + return reply.status(400).send({ error: "ids array required" }); + } + + for (const id of ids) { + // Find all versions in this chain + const rootId = getRootId(id); + const chain = db.select() + .from(schema.userFiles) + .where( + sql`${schema.userFiles.id} = ${rootId} + OR ${schema.userFiles.id} IN ( + WITH RECURSIVE ver(id) AS ( + SELECT ${rootId} + UNION ALL + SELECT uf.id FROM ${schema.userFiles} uf + JOIN ver v ON uf.parent_id = v.id + ) + SELECT id FROM ver + )`, + ) + .all(); + + for (const file of chain) { + await deleteStoredFile(file.storedName); + db.delete(schema.userFiles).where(eq(schema.userFiles.id, file.id)).run(); + } + } + + return reply.send({ deleted: ids.length }); + }, + ); + + // ── POST /api/v1/files/save-result — Save tool processing result ── + // Called internally by tool-factory after processing. Also available as API. + app.post( + "/api/v1/files/save-result", + async (request: FastifyRequest, reply: FastifyReply) => { + const user = getAuthUser(request); + const userId = user?.id ?? null; + + let buffer: Buffer | null = null; + let parentId: string | null = null; + let toolId: string | null = null; + let filename = "processed"; + + const parts = request.parts(); + for await (const part of parts) { + if (part.type === "file") { + const chunks: Buffer[] = []; + for await (const chunk of part.file) chunks.push(chunk); + buffer = Buffer.concat(chunks); + filename = part.filename ?? "processed"; + } else { + if (part.fieldname === "parentId") parentId = part.value as string; + if (part.fieldname === "toolId") toolId = part.value as string; + } + } + + if (!buffer || buffer.length === 0) { + return reply.status(400).send({ error: "No file provided" }); + } + + // Get dimensions + let width: number | undefined; + let height: number | undefined; + try { + const meta = await sharp(buffer).metadata(); + width = meta.width; + height = meta.height; + } catch { /* non-image or unsupported */ } + + const safeName = sanitizeFilename(filename); + const storedName = await saveFile(buffer, safeName); + const ext = extname(safeName).toLowerCase().replace(/^\./, ""); + + // Build version info from parent + let version = 1; + let toolChain: string[] = []; + if (parentId) { + const parent = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, parentId)).get(); + if (parent) { + version = parent.version + 1; + toolChain = parent.toolChain ? JSON.parse(parent.toolChain) : []; + // Use parent's original name for consistency + filename = parent.originalName; + } + } + if (toolId) toolChain.push(toolId); + + const id = randomUUID(); + db.insert(schema.userFiles).values({ + id, + userId, + originalName: sanitizeFilename(filename), + storedName, + mimeType: getMimeType(ext), + size: buffer.length, + width: width ?? null, + height: height ?? null, + version, + parentId, + toolChain: JSON.stringify(toolChain), + }).run(); + + return reply.send({ + id, + originalName: filename, + version, + size: buffer.length, + toolChain, + }); + }, + ); +} +``` + +- [ ] **Step 2: Register routes in index.ts** + +Add import at top of `apps/api/src/index.ts`: + +```typescript +import { userFileRoutes } from "./routes/user-files.js"; +``` + +Add after `await fileRoutes(app);`: + +```typescript +// Persistent file management routes +await userFileRoutes(app); +``` + +- [ ] **Step 3: Verify server starts** + +Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter api dev` + +Expected: Server starts without errors, routes are registered. + +- [ ] **Step 4: Commit** + +```bash +git add apps/api/src/routes/user-files.ts apps/api/src/index.ts +git commit -m "feat(api): add /api/v1/files/* routes for persistent file management" +``` + +--- + +### Task 5: Tool Factory Integration — Auto-save Results + +**Files:** +- Modify: `apps/api/src/routes/tool-factory.ts` + +- [ ] **Step 1: Add fileId support to tool-factory** + +Modify `apps/api/src/routes/tool-factory.ts`. In the multipart parsing loop, add a new field: + +```typescript +// After the existing settingsRaw field handling, add: +let fileId: string | null = null; +``` + +In the field parsing section (the `else` branch for non-file parts), add: + +```typescript +if (part.fieldname === "fileId") { + fileId = part.value as string; +} +``` + +After the successful processing block (after `await writeFile(outputPath, result.buffer);` and before the `return reply.send`), add the save-result logic: + +```typescript +// Auto-save to persistent file store if this file came from the Files page +let savedFileId: string | undefined; +if (fileId) { + try { + const { saveFile: saveToStorage } = await import("../lib/file-storage.js"); + const { sanitizeFilename: sanitize } = await import("../lib/filename.js"); + const { getAuthUser } = await import("../plugins/auth.js"); + const sharpMod = await import("sharp"); + + const user = getAuthUser(request); + const userId = user?.id ?? null; + + const storedName = await saveToStorage(result.buffer, result.filename); + const ext = result.filename.split(".").pop()?.toLowerCase() || ""; + const mimeMap: Record = { + jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", + webp: "image/webp", gif: "image/gif", bmp: "image/bmp", + tiff: "image/tiff", avif: "image/avif", + }; + + let width: number | undefined; + let height: number | undefined; + try { + const meta = await sharpMod.default(result.buffer).metadata(); + width = meta.width; + height = meta.height; + } catch { /* skip */ } + + // Look up parent to build version chain + const parent = db.select().from(schema.userFiles).where(eq(schema.userFiles.id, fileId)).get(); + const version = parent ? parent.version + 1 : 1; + const parentToolChain: string[] = parent?.toolChain ? JSON.parse(parent.toolChain) : []; + parentToolChain.push(config.toolId); + const originalName = parent ? parent.originalName : sanitize(result.filename); + + const newId = randomUUID(); + db.insert(schema.userFiles).values({ + id: newId, + userId, + originalName, + storedName, + mimeType: mimeMap[ext] ?? "application/octet-stream", + size: result.buffer.length, + width: width ?? null, + height: height ?? null, + version, + parentId: fileId, + toolChain: JSON.stringify(parentToolChain), + }).run(); + + savedFileId = newId; + } catch (err) { + request.log.warn({ err }, "Failed to auto-save to file store"); + // Non-fatal: tool still succeeded + } +} +``` + +Add the required imports at the top of tool-factory.ts: + +```typescript +import { eq } from "drizzle-orm"; +import { db, schema } from "../db/index.js"; +``` + +Update the reply to include `savedFileId`: + +```typescript +return reply.send({ + jobId, + downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(result.filename)}`, + originalSize: fileBuffer.length, + processedSize: result.buffer.length, + savedFileId, +}); +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/api/src/routes/tool-factory.ts +git commit -m "feat(tools): auto-save processed results to file store when fileId provided" +``` + +--- + +### Task 6: Frontend API Helpers + +**Files:** +- Modify: `apps/web/src/lib/api.ts` + +- [ ] **Step 1: Add files API functions** + +Append to `apps/web/src/lib/api.ts`: + +```typescript +// ── Persistent File Management ────────────────────────────────── + +export interface UserFile { + id: string; + originalName: string; + mimeType: string; + size: number; + width: number | null; + height: number | null; + version: number; + toolChain: string[]; + createdAt: string; +} + +export interface UserFileDetail extends UserFile { + versions: Array<{ + id: string; + version: number; + size: number; + toolChain: string[]; + createdAt: string; + }>; +} + +export async function apiListFiles(params?: { + search?: string; + limit?: number; + offset?: number; +}): Promise<{ files: UserFile[]; total: number }> { + const searchParams = new URLSearchParams(); + if (params?.search) searchParams.set("search", params.search); + if (params?.limit) searchParams.set("limit", String(params.limit)); + if (params?.offset) searchParams.set("offset", String(params.offset)); + const qs = searchParams.toString(); + return apiGet(`/v1/files${qs ? `?${qs}` : ""}`); +} + +export async function apiGetFileDetails(id: string): Promise { + return apiGet(`/v1/files/${id}`); +} + +export async function apiUploadFiles( + files: File[], +): Promise<{ files: Array<{ id: string; originalName: string; size: number; version: number }> }> { + const formData = new FormData(); + files.forEach((f) => formData.append("files", f)); + const res = await fetch("/api/v1/files/upload", { + method: "POST", + headers: { Authorization: `Bearer ${getToken()}` }, + body: formData, + }); + if (!res.ok) throw new Error(`Upload failed: ${res.status}`); + return res.json(); +} + +export async function apiDeleteFiles(ids: string[]): Promise<{ deleted: number }> { + return apiDelete("/v1/files") as Promise<{ deleted: number }>; + // NOTE: apiDelete doesn't support body — use fetch directly: +} + +// Override apiDeleteFiles to send body: +export async function apiDeleteUserFiles(ids: string[]): Promise<{ deleted: number }> { + const res = await fetch("/api/v1/files", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${getToken()}`, + }, + body: JSON.stringify({ ids }), + }); + if (!res.ok) throw new Error(`Delete failed: ${res.status}`); + return res.json(); +} + +export function getFileThumbnailUrl(id: string): string { + return `/api/v1/files/${id}/thumbnail`; +} + +export function getFileDownloadUrl(id: string): string { + return `/api/v1/files/${id}/download`; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/web/src/lib/api.ts +git commit -m "feat(web): add persistent files API helpers" +``` + +--- + +### Task 7: Files Page Store + +**Files:** +- Create: `apps/web/src/stores/files-page-store.ts` + +- [ ] **Step 1: Create the Zustand store** + +```typescript +import { create } from "zustand"; +import { + apiListFiles, + apiUploadFiles, + apiDeleteUserFiles, + type UserFile, +} from "@/lib/api"; + +interface FilesPageState { + files: UserFile[]; + total: number; + selectedFileId: string | null; + checkedIds: Set; + activeTab: "recent" | "upload"; + searchQuery: string; + loading: boolean; + error: string | null; + + fetchFiles: () => Promise; + uploadFiles: (files: File[]) => Promise; + deleteChecked: () => Promise; + selectFile: (id: string | null) => void; + toggleChecked: (id: string) => void; + toggleCheckAll: () => void; + setSearchQuery: (q: string) => void; + setActiveTab: (tab: "recent" | "upload") => void; +} + +export const useFilesPageStore = create((set, get) => ({ + files: [], + total: 0, + selectedFileId: null, + checkedIds: new Set(), + activeTab: "recent", + searchQuery: "", + loading: false, + error: null, + + fetchFiles: async () => { + set({ loading: true, error: null }); + try { + const { searchQuery } = get(); + const result = await apiListFiles({ + search: searchQuery || undefined, + limit: 100, + }); + set({ files: result.files, total: result.total, loading: false }); + } catch (err) { + set({ + error: err instanceof Error ? err.message : "Failed to load files", + loading: false, + }); + } + }, + + uploadFiles: async (files) => { + set({ loading: true, error: null }); + try { + await apiUploadFiles(files); + await get().fetchFiles(); + set({ activeTab: "recent" }); + } catch (err) { + set({ + error: err instanceof Error ? err.message : "Upload failed", + loading: false, + }); + } + }, + + deleteChecked: async () => { + const { checkedIds } = get(); + if (checkedIds.size === 0) return; + set({ loading: true, error: null }); + try { + await apiDeleteUserFiles(Array.from(checkedIds)); + set({ checkedIds: new Set(), selectedFileId: null }); + await get().fetchFiles(); + } catch (err) { + set({ + error: err instanceof Error ? err.message : "Delete failed", + loading: false, + }); + } + }, + + selectFile: (id) => set({ selectedFileId: id }), + + toggleChecked: (id) => { + const { checkedIds } = get(); + const next = new Set(checkedIds); + if (next.has(id)) next.delete(id); + else next.add(id); + set({ checkedIds: next }); + }, + + toggleCheckAll: () => { + const { files, checkedIds } = get(); + if (checkedIds.size === files.length) { + set({ checkedIds: new Set() }); + } else { + set({ checkedIds: new Set(files.map((f) => f.id)) }); + } + }, + + setSearchQuery: (q) => set({ searchQuery: q }), + + setActiveTab: (tab) => set({ activeTab: tab }), +})); +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/web/src/stores/files-page-store.ts +git commit -m "feat(web): add Zustand store for Files page" +``` + +--- + +### Task 8: Frontend Components — Files Nav, List, Details + +**Files:** +- Create: `apps/web/src/components/files/files-nav.tsx` +- Create: `apps/web/src/components/files/file-list-item.tsx` +- Create: `apps/web/src/components/files/file-list.tsx` +- Create: `apps/web/src/components/files/file-details.tsx` +- Create: `apps/web/src/components/files/file-upload-area.tsx` + +- [ ] **Step 1: Create files-nav.tsx** + +```typescript +import { Clock, Upload, Cloud } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useFilesPageStore } from "@/stores/files-page-store"; + +export function FilesNav() { + const { activeTab, setActiveTab } = useFilesPageStore(); + + const items = [ + { id: "recent" as const, label: "Recent", icon: Clock }, + { id: "upload" as const, label: "Upload Files", icon: Upload }, + ]; + + return ( +
+

My Files

+
+ {items.map((item) => ( + + ))} + {/* Google Drive placeholder */} +
+ + Google Drive + Soon +
+
+
+ ); +} +``` + +- [ ] **Step 2: Create file-list-item.tsx** + +```typescript +import { cn } from "@/lib/utils"; +import { useFilesPageStore } from "@/stores/files-page-store"; +import { TOOLS } from "@stirling-image/shared"; + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function formatDate(iso: string): string { + const d = new Date(iso); + return d.toLocaleDateString(undefined, { day: "2-digit", month: "2-digit", year: "numeric" }) + + ", " + d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); +} + +function toolName(toolId: string): string { + const tool = TOOLS.find((t) => t.id === toolId); + return tool?.name ?? toolId; +} + +interface FileListItemProps { + file: { + id: string; + originalName: string; + size: number; + version: number; + toolChain: string[]; + createdAt: string; + }; +} + +export function FileListItem({ file }: FileListItemProps) { + const { selectedFileId, selectFile, checkedIds, toggleChecked } = useFilesPageStore(); + const isSelected = selectedFileId === file.id; + const isChecked = checkedIds.has(file.id); + + return ( +
selectFile(file.id)} + > + { e.stopPropagation(); toggleChecked(file.id); }} + onClick={(e) => e.stopPropagation()} + className="h-4 w-4 rounded border-border accent-primary shrink-0" + /> +
+
{file.originalName}
+
+ {formatSize(file.size)} · {formatDate(file.createdAt)} + {file.toolChain.length > 0 && ( + + {file.toolChain.map(toolName).join(" → ")} + + )} +
+
+ 1 + ? "bg-primary text-primary-foreground" + : "bg-muted text-muted-foreground", + )} + > + V{file.version} + +
+ ); +} +``` + +- [ ] **Step 3: Create file-list.tsx** + +```typescript +import { useEffect } from "react"; +import { Search, Trash2, Download } from "lucide-react"; +import { useFilesPageStore } from "@/stores/files-page-store"; +import { FileListItem } from "./file-list-item"; +import { getFileDownloadUrl } from "@/lib/api"; + +export function FileList() { + const { + files, loading, error, searchQuery, setSearchQuery, + fetchFiles, checkedIds, toggleCheckAll, deleteChecked, + } = useFilesPageStore(); + + useEffect(() => { + fetchFiles(); + }, [fetchFiles]); + + // Debounced search + useEffect(() => { + const timer = setTimeout(() => fetchFiles(), 300); + return () => clearTimeout(timer); + }, [searchQuery, fetchFiles]); + + const handleBulkDownload = () => { + for (const id of checkedIds) { + const a = document.createElement("a"); + a.href = getFileDownloadUrl(id); + a.download = ""; + a.click(); + } + }; + + return ( +
+ {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + className="w-full pl-9 pr-3 py-2 bg-muted rounded-lg text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-primary" + /> +
+
+ + {/* Toolbar */} +
+ 0 && checkedIds.size === files.length} + onChange={toggleCheckAll} + className="h-4 w-4 rounded border-border accent-primary" + /> + + {checkedIds.size > 0 && ( + <> + + + + )} +
+ + {/* File rows */} +
+ {loading && files.length === 0 && ( +
+ Loading... +
+ )} + {error && ( +
{error}
+ )} + {!loading && files.length === 0 && !error && ( +
+ No files yet. Upload some images to get started. +
+ )} + {files.map((file) => ( + + ))} +
+
+ ); +} +``` + +- [ ] **Step 4: Create file-details.tsx** + +```typescript +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { FileImage } from "lucide-react"; +import { useFilesPageStore } from "@/stores/files-page-store"; +import { useFileStore } from "@/stores/file-store"; +import { + apiGetFileDetails, + getFileThumbnailUrl, + getFileDownloadUrl, + type UserFileDetail, +} from "@/lib/api"; + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function formatExtension(mimeType: string): string { + const map: Record = { + "image/jpeg": "JPEG", "image/png": "PNG", "image/webp": "WebP", + "image/gif": "GIF", "image/bmp": "BMP", "image/tiff": "TIFF", + "image/avif": "AVIF", + }; + return map[mimeType] ?? mimeType.split("/")[1]?.toUpperCase() ?? "Unknown"; +} + +export function FileDetails() { + const { selectedFileId } = useFilesPageStore(); + const { setFiles } = useFileStore(); + const navigate = useNavigate(); + const [detail, setDetail] = useState(null); + + useEffect(() => { + if (!selectedFileId) { setDetail(null); return; } + apiGetFileDetails(selectedFileId).then(setDetail).catch(() => setDetail(null)); + }, [selectedFileId]); + + if (!detail) { + return ( +
+ +

Select a file to view details

+
+ ); + } + + const handleOpenFile = async () => { + const res = await fetch(getFileDownloadUrl(detail.id), { + headers: { Authorization: `Bearer ${localStorage.getItem("stirling-token") || ""}` }, + }); + const blob = await res.blob(); + const file = new File([blob], detail.originalName, { type: detail.mimeType }); + setFiles([file]); + navigate("/"); + }; + + const toolChainStr = detail.toolChain.length > 0 ? detail.toolChain.join(" → ") : "—"; + + return ( +
+ {/* Thumbnail */} +
+ {detail.originalName} { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> +
+ + {/* Details card */} +
+
+ File Details +
+
+ {[ + ["Name", detail.originalName], + ["Format", formatExtension(detail.mimeType)], + ["Size", formatSize(detail.size)], + ["Dimensions", detail.width && detail.height ? `${detail.width} × ${detail.height}` : "—"], + ["Version", `V${detail.version}`], + ["Tools Used", toolChainStr], + ].map(([label, value]) => ( +
+ {label} + 0 ? "text-primary" : ""}`}> + {value} + +
+ ))} +
+
+ + +
+ ); +} +``` + +- [ ] **Step 5: Create file-upload-area.tsx** + +```typescript +import { useCallback, useState, type DragEvent } from "react"; +import { Upload } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useFilesPageStore } from "@/stores/files-page-store"; + +export function FileUploadArea() { + const { uploadFiles, loading } = useFilesPageStore(); + const [isDragging, setIsDragging] = useState(false); + + const handleDrag = useCallback((e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(e.type === "dragenter" || e.type === "dragover"); + }, []); + + const handleDrop = useCallback( + (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + const files = Array.from(e.dataTransfer.files); + if (files.length > 0) uploadFiles(files); + }, + [uploadFiles], + ); + + const handleClick = () => { + const input = document.createElement("input"); + input.type = "file"; + input.multiple = true; + input.accept = "image/*"; + input.onchange = (e) => { + const files = Array.from((e.target as HTMLInputElement).files || []); + if (files.length > 0) uploadFiles(files); + }; + input.click(); + }; + + return ( +
+
+ +

+ {loading ? "Uploading..." : "Upload images to your file library"} +

+

+ Drop files here or click to browse +

+
+
+ ); +} +``` + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/components/files/ +git commit -m "feat(web): add Files page components (nav, list, details, upload)" +``` + +--- + +### Task 9: Files Page & Routing + +**Files:** +- Create: `apps/web/src/pages/files-page.tsx` +- Modify: `apps/web/src/App.tsx` +- Modify: `apps/web/src/components/layout/sidebar.tsx` +- Modify: `apps/web/src/components/layout/app-layout.tsx` + +- [ ] **Step 1: Create files-page.tsx** + +```typescript +import { AppLayout } from "@/components/layout/app-layout"; +import { FilesNav } from "@/components/files/files-nav"; +import { FileList } from "@/components/files/file-list"; +import { FileDetails } from "@/components/files/file-details"; +import { FileUploadArea } from "@/components/files/file-upload-area"; +import { useFilesPageStore } from "@/stores/files-page-store"; + +export function FilesPage() { + const { activeTab } = useFilesPageStore(); + + return ( + +
+ + {activeTab === "recent" ? ( + <> + + + + ) : ( + + )} +
+
+ ); +} +``` + +- [ ] **Step 2: Add route in App.tsx** + +Add import at top: + +```typescript +import { FilesPage } from "./pages/files-page"; +``` + +Add route before the `/:toolId` catch-all: + +```typescript +} /> +``` + +- [ ] **Step 3: Re-add Files to sidebar.tsx** + +Add `FolderOpen` back to the lucide-react import: + +```typescript +import { + LayoutGrid, + Workflow, + FolderOpen, + HelpCircle, + Settings, + Grid3x3, +} from "lucide-react"; +``` + +Add the Files item back to `topItems`: + +```typescript +const topItems: SidebarItem[] = [ + { icon: LayoutGrid, label: "Tools", href: "/" }, + { icon: Grid3x3, label: "Grid", href: "/fullscreen" }, + { icon: Workflow, label: "Automate", href: "/automate" }, + { icon: FolderOpen, label: "Files", href: "/files" }, +]; +``` + +- [ ] **Step 4: Re-add Files to app-layout.tsx mobile nav** + +Add `FolderOpen` back to the lucide-react import: + +```typescript +import { + LayoutGrid, + Workflow, + FolderOpen, + Settings as SettingsIcon, + Menu, + X, +} from "lucide-react"; +``` + +Add the Files mobile nav item back after the Automate item: + +```typescript + +``` + +- [ ] **Step 5: Verify the app builds** + +Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm --filter web build` + +Expected: Build succeeds with no TypeScript errors. + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/pages/files-page.tsx apps/web/src/App.tsx apps/web/src/components/layout/sidebar.tsx apps/web/src/components/layout/app-layout.tsx +git commit -m "feat: add Files page with routing, sidebar, and mobile nav" +``` + +--- + +### Task 10: Add serverFileId to FileEntry for Tool Integration + +**Files:** +- Modify: `apps/web/src/stores/file-store.ts` +- Modify: `apps/web/src/hooks/use-tool-processor.ts` + +- [ ] **Step 1: Add serverFileId to FileEntry** + +In `apps/web/src/stores/file-store.ts`, add to the `FileEntry` interface: + +```typescript +serverFileId?: string; // ID from user_files table (for version tracking) +``` + +In `createEntry`, add: `serverFileId: undefined` + +- [ ] **Step 2: Pass fileId in tool processor** + +In `apps/web/src/hooks/use-tool-processor.ts`, in the `processFiles` callback, after `formData.append("settings", JSON.stringify(settings));`: + +```typescript +// If this file came from the Files page, include its ID for version tracking +const currentEntry = useFileStore.getState().currentEntry; +if (currentEntry?.serverFileId) { + formData.append("fileId", currentEntry.serverFileId); +} +``` + +In the `xhr.onload` success handler, after `setSizes(result.originalSize, result.processedSize);`, add: + +```typescript +// Update serverFileId if a new version was saved +if (result.savedFileId) { + const { entries, selectedIndex } = useFileStore.getState(); + if (entries[selectedIndex]) { + useFileStore.getState().updateEntry(selectedIndex, { + serverFileId: result.savedFileId, + }); + } +} +``` + +Update the `ProcessResult` interface to include: + +```typescript +savedFileId?: string; +``` + +- [ ] **Step 3: Update FileDetails Open File to set serverFileId** + +In `apps/web/src/components/files/file-details.tsx`, in `handleOpenFile`, after `setFiles([file]);`: + +```typescript +// Set the serverFileId so tool processing creates a new version +const entries = useFileStore.getState().entries; +if (entries.length > 0) { + useFileStore.getState().updateEntry(0, { serverFileId: detail.id }); +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/stores/file-store.ts apps/web/src/hooks/use-tool-processor.ts apps/web/src/components/files/file-details.tsx +git commit -m "feat: wire serverFileId through file store and tool processor for version tracking" +``` + +--- + +### Task 11: Mobile Layout for Files Page + +**Files:** +- Modify: `apps/web/src/pages/files-page.tsx` + +- [ ] **Step 1: Add mobile tabs and bottom sheet** + +Update `files-page.tsx` to handle mobile layout: + +```typescript +import { useState } from "react"; +import { X } from "lucide-react"; +import { AppLayout } from "@/components/layout/app-layout"; +import { FilesNav } from "@/components/files/files-nav"; +import { FileList } from "@/components/files/file-list"; +import { FileDetails } from "@/components/files/file-details"; +import { FileUploadArea } from "@/components/files/file-upload-area"; +import { useFilesPageStore } from "@/stores/files-page-store"; +import { useMobile } from "@/hooks/use-mobile"; + +export function FilesPage() { + const { activeTab, setActiveTab, selectedFileId, selectFile } = useFilesPageStore(); + const isMobile = useMobile(); + const [showDetails, setShowDetails] = useState(false); + + // On mobile, show details as overlay when file selected + const handleFileSelect = () => { + if (isMobile && selectedFileId) setShowDetails(true); + }; + + if (isMobile) { + return ( + +
+ {/* Mobile tabs */} +
+ + +
+ + {activeTab === "recent" ? ( +
+ +
+ ) : ( + + )} + + {/* Mobile detail bottom sheet */} + {showDetails && selectedFileId && ( +
setShowDetails(false)}> +
e.stopPropagation()} + > +
+ File Details + +
+ +
+
+ )} +
+
+ ); + } + + return ( + +
+ + {activeTab === "recent" ? ( + <> + + + + ) : ( + + )} +
+
+ ); +} +``` + +- [ ] **Step 2: Make FileDetails render inline on mobile** + +In `apps/web/src/components/files/file-details.tsx`, change the outer wrapper classes from `hidden lg:flex` to just `flex` so it renders when explicitly placed in the mobile bottom sheet. Add a prop for this: + +The empty-state wrapper: +```typescript +// Change: "w-60 border-l border-border p-4 shrink-0 hidden lg:flex..." +// To: "w-60 border-l border-border p-4 shrink-0 hidden lg:flex..." (keep desktop hidden) +// But add md:flex as well for the main details div +``` + +Actually, simpler approach: keep the desktop version as-is (hidden lg:flex). For mobile, the bottom sheet renders `` separately — just remove the `hidden lg:` prefix when it's rendered in the mobile context. Add a `className` prop: + +In `file-details.tsx`, accept an optional `mobile` prop: + +```typescript +export function FileDetails({ mobile = false }: { mobile?: boolean }) { +``` + +Change the two outer div classes to use `mobile` to toggle visibility: +- Empty state: `className={mobile ? "flex flex-col items-center justify-center text-muted-foreground py-8" : "w-60 border-l border-border p-4 shrink-0 hidden lg:flex flex-col items-center justify-center text-muted-foreground"}` +- Detail state: `className={mobile ? "flex flex-col gap-4" : "w-60 border-l border-border p-4 shrink-0 hidden lg:flex flex-col gap-4"}` + +In the mobile bottom sheet in files-page.tsx, use: `` + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/pages/files-page.tsx apps/web/src/components/files/file-details.tsx +git commit -m "feat: add mobile layout for Files page with bottom sheet details" +``` + +--- + +### Task 12: End-to-End Testing + +- [ ] **Step 1: Start dev servers** + +Run: `cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image && pnpm dev` + +- [ ] **Step 2: Test file upload via Files page** + +Navigate to http://localhost:39411/files, click "Upload Files" tab, drop an image. Verify it appears in the "Recent" tab with V1 badge. + +- [ ] **Step 3: Test file details** + +Click a file in the list. Verify the right panel shows thumbnail, name, format, size, dimensions, version, tools used. + +- [ ] **Step 4: Test "Open File"** + +Click "Open File". Verify navigation to home page with the image pre-loaded. + +- [ ] **Step 5: Test version tracking** + +From home page (with file loaded from Files), pick a tool (e.g. Resize), process it. Go back to /files. Verify the file now shows V2 with the tool name in the chain. + +- [ ] **Step 6: Test search** + +Type part of a filename in the search box. Verify list filters. + +- [ ] **Step 7: Test bulk operations** + +Check multiple files, click delete. Verify they're removed. Check files and click download. Verify downloads trigger. + +- [ ] **Step 8: Final commit if any fixes needed** + +```bash +git add -A +git commit -m "fix: address issues found during Files page testing" +``` + +--- + +## Dependency Graph + +``` +Task 1 (DB schema) + └─> Task 2 (env config) + └─> Task 3 (file storage helpers) + └─> Task 4 (API routes) + ├─> Task 5 (tool-factory integration) + └─> Task 6 (frontend API helpers) + └─> Task 7 (Zustand store) + └─> Task 8 (UI components) + └─> Task 9 (page + routing) + └─> Task 10 (file store + tool processor wiring) + └─> Task 11 (mobile layout) + └─> Task 12 (E2E testing) +``` + +Tasks are sequential — each builds on the previous.