Files
SnapOtter/docs/superpowers/plans/2026-03-25-files-page.md
T
Siddharth Kumar Sah c8d2a3f439 docs: add Files page implementation plan
12 sequential tasks covering DB schema, API routes, file storage,
frontend components, tool integration, mobile layout, and E2E testing.
2026-03-26 01:10:50 +08:00

1744 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<void> {
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<string> {
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<void> {
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<string, string> = {
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<void> {
// ── 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<number>`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<string, string> = {
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<UserFileDetail> {
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<string>;
activeTab: "recent" | "upload";
searchQuery: string;
loading: boolean;
error: string | null;
fetchFiles: () => Promise<void>;
uploadFiles: (files: File[]) => Promise<void>;
deleteChecked: () => Promise<void>;
selectFile: (id: string | null) => void;
toggleChecked: (id: string) => void;
toggleCheckAll: () => void;
setSearchQuery: (q: string) => void;
setActiveTab: (tab: "recent" | "upload") => void;
}
export const useFilesPageStore = create<FilesPageState>((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 (
<div className="w-48 border-r border-border p-4 shrink-0 hidden md:block">
<h3 className="text-sm font-semibold text-foreground mb-3">My Files</h3>
<div className="space-y-1">
{items.map((item) => (
<button
key={item.id}
onClick={() => setActiveTab(item.id)}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors",
activeTab === item.id
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</button>
))}
{/* Google Drive placeholder */}
<div className="flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground/40 cursor-not-allowed">
<Cloud className="h-4 w-4" />
Google Drive
<span className="text-[10px] bg-muted px-1.5 py-0.5 rounded ml-auto">Soon</span>
</div>
</div>
</div>
);
}
```
- [ ] **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 (
<div
className={cn(
"flex items-center gap-3 px-3 py-2.5 border-b border-border cursor-pointer transition-colors",
isSelected ? "bg-muted" : "hover:bg-muted/50",
)}
onClick={() => selectFile(file.id)}
>
<input
type="checkbox"
checked={isChecked}
onChange={(e) => { e.stopPropagation(); toggleChecked(file.id); }}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4 rounded border-border accent-primary shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground truncate">{file.originalName}</div>
<div className="text-xs text-muted-foreground mt-0.5">
{formatSize(file.size)} · {formatDate(file.createdAt)}
{file.toolChain.length > 0 && (
<span className="text-primary ml-1">
{file.toolChain.map(toolName).join(" → ")}
</span>
)}
</div>
</div>
<span
className={cn(
"text-[10px] font-medium px-1.5 py-0.5 rounded shrink-0",
file.version > 1
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground",
)}
>
V{file.version}
</span>
</div>
);
}
```
- [ ] **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 (
<div className="flex-1 flex flex-col min-w-0">
{/* Search */}
<div className="p-3 border-b border-border">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search files..."
value={searchQuery}
onChange={(e) => 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"
/>
</div>
</div>
{/* Toolbar */}
<div className="px-3 py-2 border-b border-border flex items-center gap-3">
<input
type="checkbox"
checked={checkedIds.size > 0 && checkedIds.size === files.length}
onChange={toggleCheckAll}
className="h-4 w-4 rounded border-border accent-primary"
/>
<span className="flex-1" />
{checkedIds.size > 0 && (
<>
<button
onClick={deleteChecked}
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title="Delete selected"
>
<Trash2 className="h-4 w-4" />
</button>
<button
onClick={handleBulkDownload}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title="Download selected"
>
<Download className="h-4 w-4" />
</button>
</>
)}
</div>
{/* File rows */}
<div className="flex-1 overflow-y-auto">
{loading && files.length === 0 && (
<div className="flex items-center justify-center h-32 text-sm text-muted-foreground">
Loading...
</div>
)}
{error && (
<div className="p-4 text-sm text-destructive">{error}</div>
)}
{!loading && files.length === 0 && !error && (
<div className="flex items-center justify-center h-32 text-sm text-muted-foreground">
No files yet. Upload some images to get started.
</div>
)}
{files.map((file) => (
<FileListItem key={file.id} file={file} />
))}
</div>
</div>
);
}
```
- [ ] **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<string, string> = {
"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<UserFileDetail | null>(null);
useEffect(() => {
if (!selectedFileId) { setDetail(null); return; }
apiGetFileDetails(selectedFileId).then(setDetail).catch(() => setDetail(null));
}, [selectedFileId]);
if (!detail) {
return (
<div className="w-60 border-l border-border p-4 shrink-0 hidden lg:flex flex-col items-center justify-center text-muted-foreground">
<FileImage className="h-10 w-10 mb-2 opacity-50" />
<p className="text-xs">Select a file to view details</p>
</div>
);
}
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 (
<div className="w-60 border-l border-border p-4 shrink-0 hidden lg:flex flex-col gap-4">
{/* Thumbnail */}
<div className="bg-muted rounded-lg h-36 flex items-center justify-center overflow-hidden">
<img
src={getFileThumbnailUrl(detail.id)}
alt={detail.originalName}
className="max-h-full max-w-full object-contain"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
</div>
{/* Details card */}
<div className="bg-muted rounded-lg overflow-hidden">
<div className="bg-primary text-primary-foreground text-center py-2 text-sm font-medium">
File Details
</div>
<div className="p-3 space-y-2 text-xs">
{[
["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]) => (
<div key={label} className="flex justify-between border-b border-border pb-2 last:border-0 last:pb-0">
<span className="text-muted-foreground">{label}</span>
<span className={`text-foreground text-right max-w-[120px] truncate ${label === "Tools Used" && detail.toolChain.length > 0 ? "text-primary" : ""}`}>
{value}
</span>
</div>
))}
</div>
</div>
<button
onClick={handleOpenFile}
className="w-full py-2.5 bg-primary text-primary-foreground rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
>
Open File
</button>
</div>
);
}
```
- [ ] **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 (
<div className="flex-1 flex items-center justify-center p-8">
<div
className={cn(
"w-full max-w-lg border-2 border-dashed rounded-xl p-12 text-center transition-colors cursor-pointer",
isDragging
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50",
)}
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
onClick={handleClick}
>
<Upload className="h-10 w-10 mx-auto mb-4 text-muted-foreground" />
<p className="text-sm font-medium text-foreground mb-1">
{loading ? "Uploading..." : "Upload images to your file library"}
</p>
<p className="text-xs text-muted-foreground">
Drop files here or click to browse
</p>
</div>
</div>
);
}
```
- [ ] **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 (
<AppLayout showToolPanel={false}>
<div className="flex h-full w-full overflow-hidden">
<FilesNav />
{activeTab === "recent" ? (
<>
<FileList />
<FileDetails />
</>
) : (
<FileUploadArea />
)}
</div>
</AppLayout>
);
}
```
- [ ] **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
<Route path="/files" element={<FilesPage />} />
```
- [ ] **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
<MobileNavItem icon={FolderOpen} label="Files" href="/files" />
```
- [ ] **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 (
<AppLayout showToolPanel={false}>
<div className="flex flex-col h-full w-full overflow-hidden">
{/* Mobile tabs */}
<div className="flex border-b border-border">
<button
onClick={() => setActiveTab("recent")}
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
activeTab === "recent"
? "text-primary border-b-2 border-primary"
: "text-muted-foreground"
}`}
>
Recent
</button>
<button
onClick={() => setActiveTab("upload")}
className={`flex-1 py-2.5 text-sm font-medium text-center transition-colors ${
activeTab === "upload"
? "text-primary border-b-2 border-primary"
: "text-muted-foreground"
}`}
>
Upload
</button>
</div>
{activeTab === "recent" ? (
<div className="flex-1 overflow-hidden" onClick={handleFileSelect}>
<FileList />
</div>
) : (
<FileUploadArea />
)}
{/* Mobile detail bottom sheet */}
{showDetails && selectedFileId && (
<div className="fixed inset-0 z-50 bg-black/50" onClick={() => setShowDetails(false)}>
<div
className="absolute bottom-0 left-0 right-0 bg-background rounded-t-xl p-4 max-h-[70vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center mb-3">
<span className="text-sm font-semibold">File Details</span>
<button onClick={() => setShowDetails(false)}>
<X className="h-5 w-5 text-muted-foreground" />
</button>
</div>
<FileDetails />
</div>
</div>
)}
</div>
</AppLayout>
);
}
return (
<AppLayout showToolPanel={false}>
<div className="flex h-full w-full overflow-hidden">
<FilesNav />
{activeTab === "recent" ? (
<>
<FileList />
<FileDetails />
</>
) : (
<FileUploadArea />
)}
</div>
</AppLayout>
);
}
```
- [ ] **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 `<FileDetails />` 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: `<FileDetails mobile />`
- [ ] **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.