# 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.