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

54 KiB
Raw Blame History

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:

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
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:

FILES_STORAGE_PATH: z.string().default("./data/files"),
  • Step 2: Commit
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

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

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:

import { userFileRoutes } from "./routes/user-files.js";

Add after await fileRoutes(app);:

// 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
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:

// 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:

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:

// 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:

import { eq } from "drizzle-orm";
import { db, schema } from "../db/index.js";

Update the reply to include savedFileId:

return reply.send({
  jobId,
  downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(result.filename)}`,
  originalSize: fileBuffer.length,
  processedSize: result.buffer.length,
  savedFileId,
});
  • Step 2: Commit
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:

// ── 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
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

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

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

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:

import { FilesPage } from "./pages/files-page";

Add route before the /:toolId catch-all:

<Route path="/files" element={<FilesPage />} />
  • Step 3: Re-add Files to sidebar.tsx

Add FolderOpen back to the lucide-react import:

import {
  LayoutGrid,
  Workflow,
  FolderOpen,
  HelpCircle,
  Settings,
  Grid3x3,
} from "lucide-react";

Add the Files item back to topItems:

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:

import {
  LayoutGrid,
  Workflow,
  FolderOpen,
  Settings as SettingsIcon,
  Menu,
  X,
} from "lucide-react";

Add the Files mobile nav item back after the Automate item:

<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
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:

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));:

// 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:

// 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:

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]);:

// 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
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:

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:

// 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:

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