Files
SnapOtter/docs/superpowers/specs/2026-03-25-files-page-design.md
T
Siddharth Kumar Sah 2375572860 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.
2026-03-26 01:10:50 +08:00

314 lines
10 KiB
Markdown

# 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: <binary>, 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<string>; // for bulk operations
total: number;
// UI state
activeTab: "recent" | "upload";
searchQuery: string;
loading: boolean;
// Actions
fetchFiles: () => Promise<void>;
uploadFiles: (files: File[]) => Promise<void>;
deleteFiles: (ids: string[]) => Promise<void>;
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
<Route path="/files" element={<FilesPage />} />
```
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