From 2375572860328b5bdb69c70423696c313de8891a Mon Sep 17 00:00:00 2001 From: Siddharth Kumar Sah Date: Wed, 25 Mar 2026 01:04:59 +0800 Subject: [PATCH] docs: add Files page design spec Server-persisted file manager with version tracking, modeled after Stirling-PDF. Covers DB schema, API routes, file storage, frontend components, and tool processing integration. --- .../specs/2026-03-25-files-page-design.md | 313 ++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-25-files-page-design.md diff --git a/docs/superpowers/specs/2026-03-25-files-page-design.md b/docs/superpowers/specs/2026-03-25-files-page-design.md new file mode 100644 index 00000000..0ec71c32 --- /dev/null +++ b/docs/superpowers/specs/2026-03-25-files-page-design.md @@ -0,0 +1,313 @@ +# Files Page — Design Spec + +## Overview + +A persistent file manager for Stirling Image, modeled after Stirling-PDF's Files tab. Users can upload images, browse recent files, view file details with image metadata, and re-open files for further processing. Files processed through any tool automatically save the result as a new version, building a version chain (V1 → V2 → V3...) with tool attribution. + +### Scope + +- **In scope:** Recent files view, file upload, file details panel, version tracking, search, bulk select/delete/download, "Open File" navigation +- **Out of scope:** Google Drive integration (placeholder shown as "Coming Soon"), file sharing, folder organization + +### Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Persistence | Server-persisted with SQLite metadata + disk storage | Survives restarts, enables version history | +| Version tracking | Auto-save on tool processing | Matches Stirling-PDF; makes Files page useful | +| "Open File" behavior | Navigate to home page with file pre-loaded | Most flexible — user can pick any tool | +| Auth | Required when auth is enabled; works without auth in single-user mode | Files are per-user when auth is on, shared when off | + +--- + +## 1. Database Schema + +New table `user_files` in the existing SQLite database: + +```sql +CREATE TABLE user_files ( + id TEXT PRIMARY KEY, -- UUID + user_id TEXT, -- FK to users.id (nullable for no-auth mode) + original_name TEXT NOT NULL, -- Original filename as uploaded + stored_name TEXT NOT NULL, -- UUID-based name on disk + mime_type TEXT NOT NULL, -- e.g. "image/jpeg" + size INTEGER NOT NULL, -- File size in bytes + width INTEGER, -- Image width in px + height INTEGER, -- Image height in px + version INTEGER NOT NULL DEFAULT 1, -- Version number + parent_id TEXT, -- FK to user_files.id (previous version) + tool_chain TEXT, -- JSON array of tool IDs applied, e.g. ["resize", "compress"] + created_at INTEGER NOT NULL, -- Unix timestamp (ms) + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (parent_id) REFERENCES user_files(id) +); + +CREATE INDEX idx_user_files_user_id ON user_files(user_id); +CREATE INDEX idx_user_files_created_at ON user_files(created_at); +CREATE INDEX idx_user_files_parent_id ON user_files(parent_id); +``` + +### Drizzle ORM Definition + +```typescript +export const userFiles = sqliteTable("user_files", { + id: text("id").primaryKey(), + userId: text("user_id").references(() => users.id), + 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"), // JSON string: ["resize", "compress"] + createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()), +}); +``` + +--- + +## 2. File Storage + +- **Storage directory:** `{DATA_DIR}/files/` (configurable via `FILES_STORAGE_PATH` env var, default: `/data/files/`) +- **File naming:** `{uuid}.{ext}` — avoids collisions, the original name is in the DB +- **No subdirectories per user** — a flat directory with UUID names is simpler and avoids path traversal issues +- **Cleanup:** Files deleted from the DB also have their disk file removed. No cron needed — deletion is explicit. + +--- + +## 3. API Routes + +All routes prefixed with `/api/v1/files`. Auth required when auth is enabled. + +### 3.1 List Files (Recent) + +``` +GET /api/v1/files?search=&limit=50&offset=0 +``` + +Returns the latest version of each file group (grouped by root parent), sorted by `created_at` DESC. + +**Response:** +```json +{ + "files": [ + { + "id": "uuid", + "originalName": "beach_sunset.jpg", + "mimeType": "image/jpeg", + "size": 2400000, + "width": 1920, + "height": 1080, + "version": 3, + "toolChain": ["resize", "compress"], + "createdAt": "2026-03-24T21:15:00Z" + } + ], + "total": 42 +} +``` + +### 3.2 Upload Files + +``` +POST /api/v1/files/upload +Content-Type: multipart/form-data +Body: file (one or more image files) +``` + +Validates each file (magic bytes, supported format), extracts dimensions via Sharp, stores to disk, creates DB record with version=1. + +**Response:** +```json +{ + "files": [ + { "id": "uuid", "originalName": "photo.jpg", "size": 2400000, "version": 1 } + ] +} +``` + +### 3.3 Get File Details + +``` +GET /api/v1/files/:id +``` + +Returns full metadata for a single file, including all versions in the chain. + +**Response:** +```json +{ + "id": "uuid", + "originalName": "beach_sunset.jpg", + "mimeType": "image/jpeg", + "size": 2400000, + "width": 1920, + "height": 1080, + "version": 3, + "toolChain": ["resize", "compress"], + "createdAt": "2026-03-24T21:15:00Z", + "versions": [ + { "id": "uuid-v1", "version": 1, "size": 5000000, "toolChain": [], "createdAt": "..." }, + { "id": "uuid-v2", "version": 2, "size": 3000000, "toolChain": ["resize"], "createdAt": "..." }, + { "id": "uuid-v3", "version": 3, "size": 2400000, "toolChain": ["resize", "compress"], "createdAt": "..." } + ] +} +``` + +### 3.4 Download File + +``` +GET /api/v1/files/:id/download +``` + +Streams the file from disk with `Content-Disposition: attachment`. + +### 3.5 Get File Thumbnail + +``` +GET /api/v1/files/:id/thumbnail +``` + +Returns a 300px-wide JPEG thumbnail (generated on-the-fly via Sharp, can be cached later). + +### 3.6 Delete Files + +``` +DELETE /api/v1/files +Body: { "ids": ["uuid1", "uuid2"] } +``` + +Deletes specified files from DB and disk. When deleting a file that has child versions, deletes the entire chain. + +### 3.7 Save Tool Result (internal — called by tool-factory) + +``` +POST /api/v1/files/save-result +Body: { parentId?: string, toolId: string, buffer: , filename: string } +``` + +This is an internal route called by the tool processing pipeline. It: +1. Looks up the parent file (if parentId provided) +2. Computes the new version number (parent.version + 1) +3. Builds the tool chain (parent.toolChain + [toolId]) +4. Stores the file to disk +5. Creates the DB record +6. Returns the new file record + +--- + +## 4. Tool Processing Integration + +The tool-factory needs a small addition: after successfully processing a file, if the input file came from the Files store (identified by a `fileId` parameter in the request), save the result as a new version. + +**Flow:** +1. User clicks "Open File" on Files page → navigates to home with file loaded +2. The file-store entry carries a `fileId` (the user_files.id from the server) +3. User picks a tool, adjusts settings, clicks Process +4. Tool processes the file as normal +5. After success, the response includes the new `fileId` of the saved version +6. The file-store entry updates its `fileId` to the new version + +**Changes to tool-factory.ts:** +- Accept optional `fileId` field in multipart body +- After processing, call the save-result logic internally (not an HTTP call — direct function call) +- Return `fileId` in the response alongside existing `jobId` and `downloadUrl` + +--- + +## 5. Frontend + +### 5.1 New Files Page (`apps/web/src/pages/files-page.tsx`) + +Three-panel layout inside `AppLayout`: + +- **Left panel (180px):** "My Files" heading, nav items (Recent, Upload Files, Google Drive disabled) +- **Center panel (flex):** Search bar, toolbar (select all, delete, download), scrollable file list +- **Right panel (240px):** Thumbnail preview, File Details card, "Open File" button. Hidden when no file selected. + +### 5.2 Components + +``` +apps/web/src/components/files/ +├── files-nav.tsx # Left nav (Recent, Upload, Drive placeholder) +├── file-list.tsx # Center: search + toolbar + file rows +├── file-list-item.tsx # Single file row (checkbox, name, size, date, version, tools) +├── file-details.tsx # Right panel (thumbnail, metadata, Open File) +├── file-upload-area.tsx # Dropzone for the Upload Files tab +``` + +### 5.3 Files Store (`apps/web/src/stores/files-page-store.ts`) + +Separate Zustand store for the Files page (distinct from the existing `file-store.ts` which manages tool processing state): + +```typescript +interface FilesPageState { + // Data + files: UserFile[]; + selectedFileId: string | null; + selectedFileIds: Set; // for bulk operations + total: number; + + // UI state + activeTab: "recent" | "upload"; + searchQuery: string; + loading: boolean; + + // Actions + fetchFiles: () => Promise; + uploadFiles: (files: File[]) => Promise; + deleteFiles: (ids: string[]) => Promise; + selectFile: (id: string) => void; + toggleFileSelection: (id: string) => void; + selectAll: () => void; + deselectAll: () => void; + setSearchQuery: (query: string) => void; + setActiveTab: (tab: "recent" | "upload") => void; +} +``` + +### 5.4 "Open File" Flow + +When user clicks "Open File": +1. Fetch the file blob from `GET /api/v1/files/:id/download` +2. Create a `File` object from the blob +3. Add it to the existing `file-store` with the `fileId` attached +4. Navigate to `/` (home page) +5. Home page sees the file in the store and shows the tool selection + preview + +### 5.5 Routing + +Add to `App.tsx`: +```typescript +} /> +``` + +Re-add Files to sidebar and mobile nav (reverting the earlier removal). + +### 5.6 Mobile Layout + +On mobile, the three-panel layout collapses: +- Left nav becomes tabs at the top (Recent | Upload) +- File list takes full width +- File details shows as a bottom sheet when a file is tapped +- "Open File" button is prominent in the bottom sheet + +--- + +## 6. Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `FILES_STORAGE_PATH` | `/data/files` | Directory for persistent file storage | +| `MAX_STORED_FILES` | `500` | Maximum files per user (0 = unlimited) | + +--- + +## 7. Error Handling + +- **Upload validation:** Same as existing file validation (magic bytes, format, size limit) +- **Storage full:** Return 507 if disk write fails +- **File not found:** Return 404 if file ID doesn't exist or belongs to another user +- **Auth:** Return 401 if auth is enabled and user is not authenticated