Files
SnapOtter/docs/superpowers/plans/2026-03-22-phase1-foundation.md
T

2409 lines
71 KiB
Markdown
Raw Normal View History

2026-03-22 02:37:08 +08:00
# Phase 1: Foundation 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 working Docker container that serves Stirling-Image with login, a sidebar tool grid, dropzone, theme toggle, settings skeleton, and a Fastify API with auth — the foundation all tools build on.
**Architecture:** Turborepo monorepo with two apps (Vite React SPA + Fastify API) and three packages (shared types, image-engine stub, ai stub). Fastify serves both the API and the built SPA in production. SQLite via Drizzle ORM for persistence. Better-Auth for authentication. Single Docker container with multi-arch support.
**Tech Stack:** TypeScript, Vite 6, React 19, Tailwind CSS 4, shadcn/ui, Fastify 5, Drizzle ORM, better-sqlite3, Better-Auth, Turborepo, pnpm, Docker
**Spec:** `docs/superpowers/specs/2026-03-22-stirling-image-design.md`
---
## File Structure
```
stirling-image/
├── apps/
│ ├── web/
│ │ ├── src/
│ │ │ ├── main.tsx # React entry point
│ │ │ ├── App.tsx # Root with router + providers
│ │ │ ├── lib/
│ │ │ │ ├── api.ts # Fetch wrapper for API calls
│ │ │ │ ├── auth-client.ts # Better-Auth React client
│ │ │ │ └── utils.ts # cn() helper for tailwind
│ │ │ ├── hooks/
│ │ │ │ ├── use-theme.ts # Theme toggle hook
│ │ │ │ └── use-auth.ts # Auth state hook
│ │ │ ├── stores/
│ │ │ │ └── theme-store.ts # Zustand theme store
│ │ │ ├── components/
│ │ │ │ ├── ui/ # shadcn/ui components (Button, Input, etc.)
│ │ │ │ ├── layout/
│ │ │ │ │ ├── app-layout.tsx # Main layout wrapper (sidebar + content)
│ │ │ │ │ ├── sidebar.tsx # Left icon sidebar (Tools, Reader, Automate, etc.)
│ │ │ │ │ ├── tool-panel.tsx # Scrollable tool list with search + categories
│ │ │ │ │ ├── navbar.tsx # Top bar with search + view toggle
│ │ │ │ │ └── footer.tsx # Bottom bar (theme, language)
│ │ │ │ ├── common/
│ │ │ │ │ ├── dropzone.tsx # Global file dropzone
│ │ │ │ │ ├── tool-card.tsx # Individual tool card (icon + name + fav)
│ │ │ │ │ └── search-bar.tsx # Tool search input
│ │ │ │ └── settings/
│ │ │ │ └── settings-dialog.tsx # Settings modal skeleton
│ │ │ ├── pages/
│ │ │ │ ├── login-page.tsx # Login page (split layout)
│ │ │ │ ├── home-page.tsx # Tool grid home page
│ │ │ │ └── tool-page.tsx # Generic tool page template
│ │ │ └── styles/
│ │ │ └── globals.css # Tailwind imports + CSS variables
│ │ ├── index.html
│ │ ├── vite.config.ts
│ │ ├── tailwind.config.ts
│ │ ├── tsconfig.json
│ │ ├── postcss.config.js
│ │ └── package.json
│ │
│ └── api/
│ ├── src/
│ │ ├── index.ts # Fastify server entry point
│ │ ├── config.ts # Environment variable loading + validation
│ │ ├── db/
│ │ │ ├── schema.ts # Drizzle schema (users, sessions, settings, jobs)
│ │ │ ├── migrate.ts # Migration runner
│ │ │ └── index.ts # DB connection (singleton, WAL mode)
│ │ ├── plugins/
│ │ │ ├── auth.ts # Better-Auth Fastify plugin
│ │ │ ├── cors.ts # CORS plugin (dev only)
│ │ │ ├── static.ts # Serve SPA in production
│ │ │ └── rate-limit.ts # Per-IP rate limiting
│ │ ├── routes/
│ │ │ ├── health.ts # GET /api/v1/health
│ │ │ └── config.ts # GET /api/v1/config/formats, presets
│ │ └── lib/
│ │ ├── cleanup.ts # Temp file cleanup cron
│ │ └── env.ts # Typed env var schema
│ ├── tsconfig.json
│ └── package.json
├── packages/
│ ├── shared/
│ │ ├── src/
│ │ │ ├── types.ts # Shared types (Tool, Category, Format, etc.)
│ │ │ ├── constants.ts # Tool definitions, format lists, presets
│ │ │ └── index.ts # Re-exports
│ │ ├── tsconfig.json
│ │ └── package.json
│ ├── image-engine/ # Stub for Phase 2
│ │ ├── src/
│ │ │ └── index.ts # Export placeholder
│ │ ├── tsconfig.json
│ │ └── package.json
│ └── ai/ # Stub for Phase 4
│ ├── src/
│ │ └── index.ts # Export placeholder
│ ├── tsconfig.json
│ └── package.json
├── docker/
│ ├── Dockerfile # Multi-stage, multi-arch
│ └── .dockerignore
├── turbo.json
├── pnpm-workspace.yaml
├── package.json # Root package.json
├── tsconfig.base.json # Shared TS config
├── .gitignore
├── .env.example
└── README.md
```
---
## Task 1: Initialize Monorepo
**Files:**
- Create: `package.json`, `pnpm-workspace.yaml`, `turbo.json`, `tsconfig.base.json`, `.gitignore`, `.env.example`
- [ ] **Step 1: Initialize root package.json**
```bash
cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image
```
Create `package.json`:
```json
{
"name": "stirling-image",
"private": true,
"packageManager": "pnpm@9.15.4",
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"clean": "turbo clean",
"typecheck": "turbo typecheck"
},
"devDependencies": {
"turbo": "^2.4.0",
"typescript": "^5.7.0"
}
}
```
- [ ] **Step 2: Create pnpm-workspace.yaml**
```yaml
packages:
- "apps/*"
- "packages/*"
```
- [ ] **Step 3: Create turbo.json**
```json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"dev": {
"cache": false,
"persistent": true
},
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"lint": {
"dependsOn": ["^build"]
},
"typecheck": {
"dependsOn": ["^build"]
},
"clean": {
"cache": false
}
}
}
```
- [ ] **Step 4: Create tsconfig.base.json**
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}
```
- [ ] **Step 5: Create .gitignore**
```
node_modules/
dist/
.turbo/
*.db
*.db-journal
*.db-wal
.env
.env.local
.DS_Store
*.png
*.jpg
*.jpeg
!apps/web/public/**
.playwright-mcp/
```
- [ ] **Step 6: Create .env.example**
```bash
PORT=1349
AUTH_ENABLED=true
DEFAULT_USERNAME=admin
DEFAULT_PASSWORD=admin
STORAGE_MODE=local
FILE_MAX_AGE_HOURS=24
CLEANUP_INTERVAL_MINUTES=30
MAX_UPLOAD_SIZE_MB=100
MAX_BATCH_SIZE=200
CONCURRENT_JOBS=3
MAX_MEGAPIXELS=100
RATE_LIMIT_PER_MIN=100
DB_PATH=./data/stirling.db
WORKSPACE_PATH=./tmp/workspace
DEFAULT_THEME=light
DEFAULT_LOCALE=en
APP_NAME=Stirling Image
```
- [ ] **Step 7: Install root dependencies and verify**
```bash
pnpm install
```
Expected: `pnpm-lock.yaml` created, `node_modules/` populated.
- [ ] **Step 8: Commit**
```bash
git add -A
git commit -m "feat: initialize Turborepo monorepo with pnpm workspaces"
```
---
## Task 2: Create Shared Package
**Files:**
- Create: `packages/shared/package.json`, `packages/shared/tsconfig.json`, `packages/shared/src/types.ts`, `packages/shared/src/constants.ts`, `packages/shared/src/index.ts`
- [ ] **Step 1: Create packages/shared/package.json**
```json
{
"name": "@stirling-image/shared",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},
"devDependencies": {
"typescript": "^5.7.0"
}
}
```
- [ ] **Step 2: Create packages/shared/tsconfig.json**
```json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
```
- [ ] **Step 3: Create packages/shared/src/types.ts**
```typescript
export interface Tool {
id: string;
name: string;
description: string;
category: ToolCategory;
icon: string;
route: string;
shortcut?: string;
disabled?: boolean;
alpha?: boolean;
}
export type ToolCategory =
| "essentials"
| "optimization"
| "adjustments"
| "ai"
| "watermark"
| "utilities"
| "layout"
| "format"
| "automation";
export interface CategoryInfo {
id: ToolCategory;
name: string;
icon: string;
color: string;
}
export type ImageFormat =
| "jpg"
| "png"
| "webp"
| "avif"
| "tiff"
| "bmp"
| "gif"
| "svg"
| "heic"
| "jxl"
| "ico"
| "raw"
| "pdf";
export interface SocialMediaPreset {
platform: string;
name: string;
width: number;
height: number;
}
export interface AppConfig {
appName: string;
version: string;
defaultTheme: "light" | "dark";
defaultLocale: string;
maxUploadSizeMb: number;
maxBatchSize: number;
maxMegapixels: number;
authEnabled: boolean;
}
export interface ApiError {
error: string;
code: string;
details?: Record<string, unknown>;
}
export interface HealthResponse {
status: "healthy" | "degraded";
version: string;
uptime: string;
storage: { mode: string; available: string };
queue: { active: number; pending: number };
ai: Record<string, string>;
}
export interface JobProgress {
jobId: string;
status: "queued" | "processing" | "completed" | "failed";
progress: number;
currentFile?: string;
totalFiles?: number;
downloadUrl?: string;
error?: string;
}
```
- [ ] **Step 4: Create packages/shared/src/constants.ts**
```typescript
import type { CategoryInfo, SocialMediaPreset, Tool } from "./types.js";
export const CATEGORIES: CategoryInfo[] = [
{ id: "essentials", name: "Essentials", icon: "Layers", color: "#3B82F6" },
{ id: "optimization", name: "Optimization", icon: "Zap", color: "#10B981" },
{ id: "adjustments", name: "Adjustments", icon: "SlidersHorizontal", color: "#8B5CF6" },
{ id: "ai", name: "AI Tools", icon: "Sparkles", color: "#F59E0B" },
{ id: "watermark", name: "Watermark & Overlay", icon: "Stamp", color: "#EF4444" },
{ id: "utilities", name: "Utilities", icon: "Wrench", color: "#6366F1" },
{ id: "layout", name: "Layout & Composition", icon: "LayoutGrid", color: "#EC4899" },
{ id: "format", name: "Format & Conversion", icon: "FileType", color: "#14B8A6" },
{ id: "automation", name: "Automation", icon: "Workflow", color: "#F97316" },
];
export const TOOLS: Tool[] = [
// Essentials
{ id: "resize", name: "Resize", description: "Resize by pixels, percentage, or social media presets", category: "essentials", icon: "Maximize2", route: "/resize" },
{ id: "crop", name: "Crop", description: "Freeform crop, aspect ratio presets, shape crop", category: "essentials", icon: "Crop", route: "/crop" },
{ id: "rotate", name: "Rotate & Flip", description: "Rotate, flip, and straighten images", category: "essentials", icon: "RotateCw", route: "/rotate" },
{ id: "convert", name: "Convert", description: "Convert between image formats", category: "essentials", icon: "FileOutput", route: "/convert" },
{ id: "compress", name: "Compress", description: "Reduce file size by quality or target size", category: "essentials", icon: "Minimize2", route: "/compress" },
// Optimization
{ id: "strip-metadata", name: "Strip Metadata", description: "Remove EXIF, GPS, and camera info", category: "optimization", icon: "ShieldOff", route: "/strip-metadata" },
{ id: "bulk-rename", name: "Bulk Rename", description: "Rename multiple files with patterns", category: "optimization", icon: "FileEdit", route: "/bulk-rename" },
{ id: "image-to-pdf", name: "Image to PDF", description: "Combine images into a PDF document", category: "optimization", icon: "FileText", route: "/image-to-pdf" },
{ id: "favicon", name: "Favicon Generator", description: "Generate all favicon and app icon sizes", category: "optimization", icon: "Globe", route: "/favicon" },
// Adjustments
{ id: "brightness-contrast", name: "Brightness & Contrast", description: "Adjust brightness and contrast levels", category: "adjustments", icon: "Sun", route: "/brightness-contrast" },
{ id: "saturation", name: "Saturation & Exposure", description: "Adjust color saturation and exposure", category: "adjustments", icon: "Palette", route: "/saturation" },
{ id: "color-channels", name: "Color Channels", description: "Adjust individual R, G, B channels", category: "adjustments", icon: "CircleDot", route: "/color-channels" },
{ id: "color-effects", name: "Color Effects", description: "Grayscale, Sepia, Invert, Tint", category: "adjustments", icon: "Paintbrush", route: "/color-effects" },
{ id: "replace-color", name: "Replace & Invert Color", description: "Replace specific colors or invert", category: "adjustments", icon: "Pipette", route: "/replace-color" },
// AI Tools
{ id: "remove-background", name: "Remove Background", description: "AI-powered background removal", category: "ai", icon: "Eraser", route: "/remove-background" },
{ id: "upscale", name: "Image Upscaling", description: "AI super-resolution enhancement", category: "ai", icon: "ZoomIn", route: "/upscale" },
{ id: "erase-object", name: "Object Eraser", description: "Paint over unwanted elements", category: "ai", icon: "Wand2", route: "/erase-object" },
{ id: "ocr", name: "OCR / Text Extraction", description: "Extract text from images", category: "ai", icon: "ScanText", route: "/ocr" },
{ id: "blur-faces", name: "Face / PII Blur", description: "Auto-detect and blur faces and sensitive info", category: "ai", icon: "EyeOff", route: "/blur-faces" },
{ id: "smart-crop", name: "Smart Crop", description: "AI detects subject and crops optimally", category: "ai", icon: "Focus", route: "/smart-crop" },
// Watermark & Overlay
{ id: "watermark-text", name: "Text Watermark", description: "Add text watermark overlay", category: "watermark", icon: "Type", route: "/watermark-text" },
{ id: "watermark-image", name: "Image Watermark", description: "Overlay a logo as watermark", category: "watermark", icon: "Image", route: "/watermark-image" },
{ id: "text-overlay", name: "Text Overlay", description: "Add styled text to images", category: "watermark", icon: "TextCursorInput", route: "/text-overlay" },
{ id: "compose", name: "Image Composition", description: "Layer images with position and opacity", category: "watermark", icon: "Layers", route: "/compose" },
// Utilities
{ id: "info", name: "Image Info", description: "View all metadata and image properties", category: "utilities", icon: "Info", route: "/info" },
{ id: "compare", name: "Image Compare", description: "Side-by-side comparison of two images", category: "utilities", icon: "Columns2", route: "/compare" },
{ id: "find-duplicates", name: "Find Duplicates", description: "Detect duplicate and near-duplicate images", category: "utilities", icon: "Copy", route: "/find-duplicates" },
{ id: "color-palette", name: "Color Palette", description: "Extract dominant colors from image", category: "utilities", icon: "Palette", route: "/color-palette" },
{ id: "qr-generate", name: "QR Code Generator", description: "Generate QR codes from text or URLs", category: "utilities", icon: "QrCode", route: "/qr-generate" },
{ id: "barcode-read", name: "Barcode Reader", description: "Read QR codes and barcodes from images", category: "utilities", icon: "ScanLine", route: "/barcode-read" },
// Layout & Composition
{ id: "collage", name: "Collage / Grid", description: "Combine images into a grid layout", category: "layout", icon: "LayoutGrid", route: "/collage" },
{ id: "split", name: "Image Splitting", description: "Split image into grid parts", category: "layout", icon: "Grid3x3", route: "/split" },
{ id: "border", name: "Border & Frame", description: "Add borders, rounded corners, shadows", category: "layout", icon: "Frame", route: "/border" },
// Format & Conversion
{ id: "svg-to-raster", name: "SVG to Raster", description: "Convert SVG to PNG/JPG at custom resolution", category: "format", icon: "FileImage", route: "/svg-to-raster" },
{ id: "vectorize", name: "Image to SVG", description: "Vectorize images using tracing", category: "format", icon: "PenTool", route: "/vectorize" },
{ id: "gif-tools", name: "GIF Tools", description: "Resize/crop/convert animated GIFs", category: "format", icon: "Film", route: "/gif-tools" },
// Automation
{ id: "pipeline", name: "Pipeline Builder", description: "Chain multiple tools into a workflow", category: "automation", icon: "Workflow", route: "/pipeline" },
{ id: "batch", name: "Batch Processing", description: "Apply any tool to multiple images", category: "automation", icon: "FolderInput", route: "/batch" },
];
export const SOCIAL_MEDIA_PRESETS: SocialMediaPreset[] = [
{ platform: "Instagram", name: "Post (Square)", width: 1080, height: 1080 },
{ platform: "Instagram", name: "Story / Reel", width: 1080, height: 1920 },
{ platform: "Instagram", name: "Profile Picture", width: 320, height: 320 },
{ platform: "Instagram", name: "Landscape Post", width: 1080, height: 566 },
{ platform: "Instagram", name: "Portrait Post", width: 1080, height: 1350 },
{ platform: "Twitter/X", name: "Post Image", width: 1200, height: 675 },
{ platform: "Twitter/X", name: "Header", width: 1500, height: 500 },
{ platform: "Twitter/X", name: "Profile Picture", width: 400, height: 400 },
{ platform: "Facebook", name: "Post Image", width: 1200, height: 630 },
{ platform: "Facebook", name: "Cover Photo", width: 820, height: 312 },
{ platform: "Facebook", name: "Profile Picture", width: 170, height: 170 },
{ platform: "Facebook", name: "Event Cover", width: 1920, height: 1005 },
{ platform: "YouTube", name: "Thumbnail", width: 1280, height: 720 },
{ platform: "YouTube", name: "Channel Banner", width: 2560, height: 1440 },
{ platform: "YouTube", name: "Profile Picture", width: 800, height: 800 },
{ platform: "LinkedIn", name: "Post Image", width: 1200, height: 627 },
{ platform: "LinkedIn", name: "Banner", width: 1584, height: 396 },
{ platform: "LinkedIn", name: "Profile Picture", width: 400, height: 400 },
{ platform: "TikTok", name: "Video Cover", width: 1080, height: 1920 },
{ platform: "TikTok", name: "Profile Picture", width: 200, height: 200 },
{ platform: "WhatsApp", name: "Profile Picture", width: 500, height: 500 },
{ platform: "Pinterest", name: "Pin", width: 1000, height: 1500 },
{ platform: "Threads", name: "Post Image", width: 1080, height: 1080 },
];
export const SUPPORTED_INPUT_FORMATS = [
"jpg", "jpeg", "png", "webp", "avif", "tiff", "tif",
"bmp", "gif", "svg", "heic", "heif", "jxl", "ico",
"cr2", "nef", "arw", "dng", "orf", "rw2",
] as const;
export const SUPPORTED_OUTPUT_FORMATS = [
"jpg", "png", "webp", "avif", "tiff", "gif", "jxl", "svg", "ico", "pdf",
] as const;
export const DEFAULT_OUTPUT_FORMAT = "jpg" as const;
export const APP_VERSION = "0.1.0";
```
- [ ] **Step 5: Create packages/shared/src/index.ts**
```typescript
export * from "./types.js";
export * from "./constants.js";
```
- [ ] **Step 6: Install dependencies and verify typecheck**
```bash
pnpm install
pnpm --filter @stirling-image/shared typecheck
```
Expected: No errors.
- [ ] **Step 7: Commit**
```bash
git add packages/shared/
git commit -m "feat: add shared package with types, tool definitions, and constants"
```
---
## Task 3: Create Stub Packages (image-engine, ai)
**Files:**
- Create: `packages/image-engine/package.json`, `packages/image-engine/tsconfig.json`, `packages/image-engine/src/index.ts`
- Create: `packages/ai/package.json`, `packages/ai/tsconfig.json`, `packages/ai/src/index.ts`, `packages/ai/python/requirements.txt`
- [ ] **Step 1: Create packages/image-engine/package.json**
```json
{
"name": "@stirling-image/image-engine",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"@stirling-image/shared": "workspace:*"
},
"devDependencies": {
"typescript": "^5.7.0"
}
}
```
- [ ] **Step 2: Create packages/image-engine/tsconfig.json and src/index.ts**
tsconfig.json:
```json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
```
src/index.ts:
```typescript
// Image engine - implements in Phase 2
export const IMAGE_ENGINE_VERSION = "0.0.1";
```
- [ ] **Step 3: Create packages/ai with same structure**
packages/ai/package.json:
```json
{
"name": "@stirling-image/ai",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"@stirling-image/shared": "workspace:*"
},
"devDependencies": {
"typescript": "^5.7.0"
}
}
```
packages/ai/tsconfig.json:
```json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
```
packages/ai/src/index.ts:
```typescript
// AI package - implements in Phase 4
export const AI_VERSION = "0.0.1";
```
packages/ai/python/requirements.txt:
```
rembg[cpu]==2.0.62
realesrgan==0.3.0
lama-cleaner==1.2.5
paddleocr==2.9.1
paddlepaddle==3.0.0
mediapipe==0.10.21
onnxruntime==1.20.1
numpy==1.26.4
Pillow==11.1.0
opencv-python-headless==4.10.0.84
```
- [ ] **Step 4: Run pnpm install and typecheck all**
```bash
pnpm install
pnpm typecheck
```
Expected: All packages pass.
- [ ] **Step 5: Commit**
```bash
git add packages/image-engine/ packages/ai/
git commit -m "feat: add image-engine and ai stub packages"
```
---
## Task 4: Fastify API Server
**Files:**
- Create: `apps/api/package.json`, `apps/api/tsconfig.json`, `apps/api/src/index.ts`, `apps/api/src/config.ts`, `apps/api/src/lib/env.ts`
- [ ] **Step 1: Create apps/api/package.json**
```json
{
"name": "@stirling-image/api",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"@stirling-image/shared": "workspace:*",
"fastify": "^5.2.0",
"@fastify/static": "^8.1.0",
"@fastify/multipart": "^9.0.0",
"@fastify/cors": "^11.0.0",
"@fastify/rate-limit": "^10.2.0",
"@fastify/swagger": "^9.4.0",
"@fastify/swagger-ui": "^5.2.0",
"dotenv": "^16.4.0",
"zod": "^3.24.0"
},
"devDependencies": {
"typescript": "^5.7.0",
"tsx": "^4.19.0",
"@types/node": "^22.0.0"
}
}
```
- [ ] **Step 2: Create apps/api/tsconfig.json**
```json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"lib": ["ES2022"],
"module": "ESNext",
"target": "ES2022"
},
"include": ["src/**/*"]
}
```
- [ ] **Step 3: Create apps/api/src/lib/env.ts**
```typescript
import { z } from "zod";
const envSchema = z.object({
PORT: z.coerce.number().default(1349),
AUTH_ENABLED: z
.enum(["true", "false"])
.default("true")
.transform((v) => v === "true"),
DEFAULT_USERNAME: z.string().default("admin"),
DEFAULT_PASSWORD: z.string().default("admin"),
STORAGE_MODE: z.enum(["local", "s3"]).default("local"),
FILE_MAX_AGE_HOURS: z.coerce.number().default(24),
CLEANUP_INTERVAL_MINUTES: z.coerce.number().default(30),
MAX_UPLOAD_SIZE_MB: z.coerce.number().default(100),
MAX_BATCH_SIZE: z.coerce.number().default(200),
CONCURRENT_JOBS: z.coerce.number().default(3),
MAX_MEGAPIXELS: z.coerce.number().default(100),
RATE_LIMIT_PER_MIN: z.coerce.number().default(100),
DB_PATH: z.string().default("./data/stirling.db"),
WORKSPACE_PATH: z.string().default("./tmp/workspace"),
DEFAULT_THEME: z.enum(["light", "dark"]).default("light"),
DEFAULT_LOCALE: z.string().default("en"),
APP_NAME: z.string().default("Stirling Image"),
});
export type Env = z.infer<typeof envSchema>;
export function loadEnv(): Env {
return envSchema.parse(process.env);
}
```
- [ ] **Step 4: Create apps/api/src/config.ts**
```typescript
import "dotenv/config";
import { loadEnv } from "./lib/env.js";
export const env = loadEnv();
```
- [ ] **Step 5: Create apps/api/src/index.ts**
```typescript
import Fastify from "fastify";
import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit";
import { env } from "./config.js";
import { APP_VERSION } from "@stirling-image/shared";
const app = Fastify({
logger: true,
bodyLimit: env.MAX_UPLOAD_SIZE_MB * 1024 * 1024,
});
// Plugins
await app.register(cors, { origin: true });
await app.register(rateLimit, {
max: env.RATE_LIMIT_PER_MIN,
timeWindow: "1 minute",
});
// Health check
app.get("/api/v1/health", async () => ({
status: "healthy",
version: APP_VERSION,
uptime: process.uptime().toFixed(0) + "s",
storage: { mode: env.STORAGE_MODE, available: "N/A" },
queue: { active: 0, pending: 0 },
ai: {},
}));
// Start
try {
await app.listen({ port: env.PORT, host: "0.0.0.0" });
console.log(`Stirling Image API running on port ${env.PORT}`);
} catch (err) {
app.log.error(err);
process.exit(1);
}
```
- [ ] **Step 6: Install dependencies and test server starts**
```bash
pnpm install
cd apps/api && pnpm dev
```
In another terminal:
```bash
curl http://localhost:1349/api/v1/health
```
Expected: JSON response with `"status": "healthy"`.
- [ ] **Step 7: Commit**
```bash
git add apps/api/
git commit -m "feat: add Fastify API server with health check and env config"
```
---
## Task 5: SQLite Database with Drizzle ORM
**Files:**
- Create: `apps/api/src/db/schema.ts`, `apps/api/src/db/index.ts`, `apps/api/src/db/migrate.ts`
- Modify: `apps/api/package.json` (add drizzle deps)
- Modify: `apps/api/src/index.ts` (init DB on startup)
- [ ] **Step 1: Add Drizzle dependencies**
Add to `apps/api/package.json` dependencies:
```json
"drizzle-orm": "^0.38.0",
"better-sqlite3": "^11.7.0"
```
Add to devDependencies:
```json
"drizzle-kit": "^0.30.0",
"@types/better-sqlite3": "^7.6.0"
```
Run `pnpm install`.
- [ ] **Step 2: Create apps/api/src/db/schema.ts**
```typescript
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
username: text("username").notNull().unique(),
passwordHash: text("password_hash").notNull(),
role: text("role", { enum: ["admin", "user"] }).notNull().default("user"),
mustChangePassword: integer("must_change_password", { mode: "boolean" }).notNull().default(true),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
});
export const sessions = sqliteTable("sessions", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
});
export const settings = sqliteTable("settings", {
key: text("key").primaryKey(),
value: text("value").notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
});
export const jobs = sqliteTable("jobs", {
id: text("id").primaryKey(),
type: text("type").notNull(),
status: text("status", { enum: ["queued", "processing", "completed", "failed"] }).notNull().default("queued"),
progress: real("progress").notNull().default(0),
inputFiles: text("input_files").notNull(), // JSON array
outputPath: text("output_path"),
settings: text("settings"), // JSON
error: text("error"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
completedAt: integer("completed_at", { mode: "timestamp" }),
});
export const apiKeys = sqliteTable("api_keys", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
keyHash: text("key_hash").notNull(),
name: text("name").notNull().default("Default API Key"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().$defaultFn(() => new Date()),
lastUsedAt: integer("last_used_at", { mode: "timestamp" }),
});
```
- [ ] **Step 3: Create apps/api/src/db/index.ts**
```typescript
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { env } from "../config.js";
import * as schema from "./schema.js";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
// Ensure data directory exists
mkdirSync(dirname(env.DB_PATH), { recursive: true });
const sqlite = new Database(env.DB_PATH);
// Critical SQLite pragmas for reliability
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("busy_timeout = 5000");
sqlite.pragma("synchronous = NORMAL");
sqlite.pragma("foreign_keys = ON");
export const db = drizzle(sqlite, { schema });
export { schema };
```
- [ ] **Step 4: Create apps/api/src/db/migrate.ts**
```typescript
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { db } from "./index.js";
export function runMigrations() {
migrate(db, { migrationsFolder: "./drizzle" });
}
```
- [ ] **Step 5: Create drizzle.config.ts**
Create `apps/api/drizzle.config.ts`:
```typescript
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "sqlite",
dbCredentials: {
url: process.env.DB_PATH || "./data/stirling.db",
},
});
```
- [ ] **Step 6: Generate initial migration and update server startup**
```bash
cd apps/api
pnpm drizzle-kit generate
```
Update `apps/api/src/index.ts` to init DB before starting:
```typescript
// Add at the top after imports:
import { db } from "./db/index.js";
import { runMigrations } from "./db/migrate.js";
// Add before plugins:
runMigrations();
console.log("Database initialized");
```
- [ ] **Step 7: Test database initializes on server start**
```bash
pnpm dev
```
Expected: "Database initialized" in logs, `./data/stirling.db` file created.
- [ ] **Step 8: Commit**
```bash
git add apps/api/
git commit -m "feat: add SQLite database with Drizzle ORM schema and migrations"
```
---
## Task 6: Authentication with Better-Auth
**Files:**
- Modify: `apps/api/package.json` (add better-auth)
- Create: `apps/api/src/plugins/auth.ts`
- Modify: `apps/api/src/index.ts` (register auth plugin)
- Modify: `apps/api/src/db/schema.ts` (add better-auth tables if needed)
- [ ] **Step 1: Add better-auth dependency**
```bash
cd apps/api
pnpm add better-auth
```
- [ ] **Step 2: Create apps/api/src/plugins/auth.ts**
```typescript
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "../db/index.js";
import { env } from "../config.js";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import { hash } from "better-auth/crypto";
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "sqlite" }),
emailAndPassword: { enabled: true },
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // 1 day
},
});
export async function ensureDefaultAdmin() {
// Check if any user exists
const existingUsers = await db.query.users.findFirst();
if (!existingUsers) {
const passwordHash = await hash(env.DEFAULT_PASSWORD);
await db.insert(require("../db/schema.js").users).values({
id: randomUUID(),
username: env.DEFAULT_USERNAME,
passwordHash,
role: "admin",
mustChangePassword: true,
});
console.log(`Default admin user '${env.DEFAULT_USERNAME}' created`);
}
}
export async function registerAuth(app: FastifyInstance) {
// Mount better-auth handler
app.all("/api/auth/*", async (request, reply) => {
const response = await auth.handler(request.raw as any);
reply.raw.writeHead(response.status, Object.fromEntries(response.headers.entries()));
reply.raw.end(response.body ? await response.text() : undefined);
});
}
```
Note: Better-Auth's exact API may vary. This will be refined during implementation based on the actual library version. The core pattern (adapter → ensureDefaultAdmin → mount handler) is correct.
- [ ] **Step 3: Register auth in server startup**
Update `apps/api/src/index.ts`:
```typescript
import { registerAuth, ensureDefaultAdmin } from "./plugins/auth.js";
// After migrations:
await ensureDefaultAdmin();
// After plugins:
await registerAuth(app);
```
- [ ] **Step 4: Test auth endpoints exist**
```bash
pnpm dev
curl -X POST http://localhost:1349/api/auth/sign-in \
-H "Content-Type: application/json" \
-d '{"email":"admin","password":"admin"}'
```
Expected: Auth response (success or error depending on better-auth API shape).
- [ ] **Step 5: Commit**
```bash
git add apps/api/
git commit -m "feat: add Better-Auth authentication with default admin user"
```
---
## Task 7: Vite + React SPA Setup
**Files:**
- Create: `apps/web/package.json`, `apps/web/tsconfig.json`, `apps/web/vite.config.ts`, `apps/web/tailwind.config.ts`, `apps/web/postcss.config.js`, `apps/web/index.html`, `apps/web/src/main.tsx`, `apps/web/src/App.tsx`, `apps/web/src/styles/globals.css`, `apps/web/src/lib/utils.ts`
- [ ] **Step 1: Create apps/web/package.json**
```json
{
"name": "@stirling-image/web",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"@stirling-image/shared": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0",
"zustand": "^5.0.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.6.0",
"lucide-react": "^0.469.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"tailwindcss": "^4.0.0",
"@tailwindcss/vite": "^4.0.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}
```
- [ ] **Step 2: Create config files**
apps/web/tsconfig.json:
```json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"]
}
```
apps/web/vite.config.ts:
```typescript
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "node:path";
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
port: 5173,
proxy: {
"/api": "http://localhost:1349",
},
},
});
```
apps/web/postcss.config.js:
```javascript
export default {};
```
- [ ] **Step 3: Create HTML entry and React root**
apps/web/index.html:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Stirling Image</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
```
apps/web/src/styles/globals.css:
```css
@import "tailwindcss";
@theme {
--color-primary: #3b82f6;
--color-primary-foreground: #ffffff;
--color-background: #ffffff;
--color-foreground: #0f172a;
--color-muted: #f1f5f9;
--color-muted-foreground: #64748b;
--color-border: #e2e8f0;
--color-card: #ffffff;
--color-card-foreground: #0f172a;
--color-sidebar: #f8fafc;
--color-sidebar-foreground: #334155;
--color-accent: #3b82f6;
--color-destructive: #ef4444;
}
.dark {
--color-background: #0f172a;
--color-foreground: #f8fafc;
--color-muted: #1e293b;
--color-muted-foreground: #94a3b8;
--color-border: #334155;
--color-card: #1e293b;
--color-card-foreground: #f8fafc;
--color-sidebar: #1e293b;
--color-sidebar-foreground: #cbd5e1;
}
```
apps/web/src/lib/utils.ts:
```typescript
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
```
apps/web/src/main.tsx:
```tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles/globals.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);
```
apps/web/src/App.tsx:
```tsx
import { BrowserRouter, Routes, Route } from "react-router-dom";
export function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<div className="p-8 text-2xl font-bold">Stirling Image</div>} />
</Routes>
</BrowserRouter>
);
}
```
- [ ] **Step 4: Install deps and verify dev server starts**
```bash
pnpm install
cd apps/web && pnpm dev
```
Open http://localhost:5173 — should see "Stirling Image" text.
- [ ] **Step 5: Commit**
```bash
git add apps/web/
git commit -m "feat: add Vite + React SPA with Tailwind CSS and routing"
```
---
## Task 8: Theme System
**Files:**
- Create: `apps/web/src/stores/theme-store.ts`, `apps/web/src/hooks/use-theme.ts`
- Modify: `apps/web/src/App.tsx` (wrap with theme provider)
- [ ] **Step 1: Create theme store**
apps/web/src/stores/theme-store.ts:
```typescript
import { create } from "zustand";
import { persist } from "zustand/middleware";
type Theme = "light" | "dark" | "system";
interface ThemeStore {
theme: Theme;
setTheme: (theme: Theme) => void;
resolvedTheme: "light" | "dark";
}
function getSystemTheme(): "light" | "dark" {
if (typeof window === "undefined") return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export const useThemeStore = create<ThemeStore>()(
persist(
(set, get) => ({
theme: "system",
resolvedTheme: getSystemTheme(),
setTheme: (theme) => {
const resolved = theme === "system" ? getSystemTheme() : theme;
document.documentElement.classList.toggle("dark", resolved === "dark");
set({ theme, resolvedTheme: resolved });
},
}),
{ name: "stirling-image-theme" }
)
);
```
- [ ] **Step 2: Create theme hook**
apps/web/src/hooks/use-theme.ts:
```typescript
import { useEffect } from "react";
import { useThemeStore } from "../stores/theme-store";
export function useTheme() {
const { theme, resolvedTheme, setTheme } = useThemeStore();
useEffect(() => {
const resolved = theme === "system"
? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
: theme;
document.documentElement.classList.toggle("dark", resolved === "dark");
}, [theme]);
const toggleTheme = () => {
setTheme(resolvedTheme === "dark" ? "light" : "dark");
};
return { theme, resolvedTheme, setTheme, toggleTheme };
}
```
- [ ] **Step 3: Commit**
```bash
git add apps/web/src/stores/ apps/web/src/hooks/
git commit -m "feat: add theme system with dark/light/system support and persistence"
```
---
## Task 9: Layout Components (Sidebar + Tool Panel + Dropzone)
**Files:**
- Create: `apps/web/src/components/layout/app-layout.tsx`, `apps/web/src/components/layout/sidebar.tsx`, `apps/web/src/components/layout/tool-panel.tsx`, `apps/web/src/components/layout/footer.tsx`
- Create: `apps/web/src/components/common/dropzone.tsx`, `apps/web/src/components/common/tool-card.tsx`, `apps/web/src/components/common/search-bar.tsx`
- Create: `apps/web/src/pages/home-page.tsx`
- Modify: `apps/web/src/App.tsx`
- [ ] **Step 1: Create sidebar**
apps/web/src/components/layout/sidebar.tsx:
```tsx
import { Link, useLocation } from "react-router-dom";
import { cn } from "@/lib/utils";
import {
LayoutGrid, BookOpen, Workflow, FolderOpen,
HelpCircle, Settings, type LucideIcon
} from "lucide-react";
interface SidebarItem {
icon: LucideIcon;
label: string;
href?: string;
onClick?: () => void;
}
const topItems: SidebarItem[] = [
{ icon: LayoutGrid, label: "Tools", href: "/" },
{ icon: BookOpen, label: "Reader", href: "/reader" },
{ icon: Workflow, label: "Automate", href: "/automate" },
{ icon: FolderOpen, label: "Files", href: "/files" },
];
const bottomItems: SidebarItem[] = [
{ icon: HelpCircle, label: "Help", href: "/help" },
{ icon: Settings, label: "Settings" },
];
interface SidebarProps {
onSettingsClick: () => void;
}
export function Sidebar({ onSettingsClick }: SidebarProps) {
const location = useLocation();
const renderItem = (item: SidebarItem, isActive: boolean) => {
const content = (
<div
className={cn(
"flex flex-col items-center gap-1 p-2 rounded-lg cursor-pointer transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<item.icon className="h-6 w-6" />
<span className="text-[10px] font-medium">{item.label}</span>
</div>
);
if (item.label === "Settings") {
return (
<button key={item.label} onClick={onSettingsClick} className="w-full">
{content}
</button>
);
}
return (
<Link key={item.label} to={item.href || "/"}>
{content}
</Link>
);
};
return (
<aside className="flex flex-col items-center w-16 bg-sidebar border-r border-border py-3 gap-1">
<div className="flex flex-col gap-1 flex-1">
{topItems.map((item) => renderItem(item, location.pathname === item.href))}
</div>
<div className="border-t border-border w-10 my-2" />
<div className="flex flex-col gap-1">
{bottomItems.map((item) => renderItem(item, false))}
</div>
</aside>
);
}
```
- [ ] **Step 2: Create tool panel with search and categories**
apps/web/src/components/common/search-bar.tsx:
```tsx
import { Search } from "lucide-react";
interface SearchBarProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export function SearchBar({ value, onChange, placeholder = "Search tools..." }: SearchBarProps) {
return (
<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"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full pl-10 pr-4 py-2 rounded-lg border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
);
}
```
apps/web/src/components/common/tool-card.tsx:
```tsx
import { Link } from "react-router-dom";
import { Star } from "lucide-react";
import * as icons from "lucide-react";
import type { Tool } from "@stirling-image/shared";
import { cn } from "@/lib/utils";
interface ToolCardProps {
tool: Tool;
}
export function ToolCard({ tool }: ToolCardProps) {
const IconComponent = (icons as Record<string, React.ComponentType<{ className?: string }>>)[tool.icon] || icons.FileImage;
return (
<div className="group flex items-center gap-3 relative">
<button
className="opacity-0 group-hover:opacity-100 transition-opacity absolute -left-5"
title="Add to favourites"
>
<Star className="h-3 w-3 text-muted-foreground hover:text-yellow-500" />
</button>
<Link
to={tool.route}
className={cn(
"flex items-center gap-3 py-2 px-3 rounded-lg w-full transition-colors",
"hover:bg-muted",
tool.disabled && "opacity-50 pointer-events-none"
)}
>
<IconComponent className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">{tool.name}</span>
{tool.alpha && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-100 text-orange-600 font-medium">
Alpha
</span>
)}
</Link>
</div>
);
}
```
apps/web/src/components/layout/tool-panel.tsx:
```tsx
import { useState, useMemo } from "react";
import { TOOLS, CATEGORIES } from "@stirling-image/shared";
import { SearchBar } from "../common/search-bar";
import { ToolCard } from "../common/tool-card";
export function ToolPanel() {
const [search, setSearch] = useState("");
const filteredTools = useMemo(() => {
if (!search) return TOOLS;
const q = search.toLowerCase();
return TOOLS.filter(
(t) => t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q)
);
}, [search]);
const groupedTools = useMemo(() => {
const groups = new Map<string, typeof TOOLS>();
for (const tool of filteredTools) {
const list = groups.get(tool.category) || [];
list.push(tool);
groups.set(tool.category, list);
}
return groups;
}, [filteredTools]);
return (
<div className="w-72 border-r border-border bg-background overflow-y-auto flex flex-col">
<div className="p-3 sticky top-0 bg-background z-10">
<SearchBar value={search} onChange={setSearch} />
</div>
<div className="px-3 pb-4 flex-1">
{CATEGORIES.filter((cat) => groupedTools.has(cat.id)).map((category) => (
<div key={category.id} className="mb-4">
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-2">
{category.name}
</h3>
<div className="space-y-0.5">
{groupedTools.get(category.id)!.map((tool) => (
<ToolCard key={tool.id} tool={tool} />
))}
</div>
</div>
))}
{filteredTools.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">No tools found</p>
)}
</div>
</div>
);
}
```
- [ ] **Step 3: Create dropzone**
apps/web/src/components/common/dropzone.tsx:
```tsx
import { useCallback, useState, type DragEvent } from "react";
import { Upload } from "lucide-react";
import { cn } from "@/lib/utils";
interface DropzoneProps {
onFiles?: (files: File[]) => void;
accept?: string;
multiple?: boolean;
}
export function Dropzone({ onFiles, accept, multiple = true }: DropzoneProps) {
const [isDragging, setIsDragging] = useState(false);
const handleDrag = useCallback((e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setIsDragging(true);
} else if (e.type === "dragleave") {
setIsDragging(false);
}
}, []);
const handleDrop = useCallback(
(e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) onFiles?.(files);
},
[onFiles]
);
const handleClick = () => {
const input = document.createElement("input");
input.type = "file";
input.multiple = multiple;
if (accept) input.accept = accept;
input.onchange = (e) => {
const files = Array.from((e.target as HTMLInputElement).files || []);
if (files.length > 0) onFiles?.(files);
};
input.click();
};
return (
<div
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
onClick={handleClick}
className={cn(
"flex flex-col items-center justify-center rounded-2xl border-2 border-dashed transition-colors cursor-pointer min-h-[400px] mx-auto max-w-2xl",
isDragging
? "border-primary bg-primary/5"
: "border-border bg-muted/30 hover:border-primary/50 hover:bg-muted/50"
)}
>
<div className="flex flex-col items-center gap-4 p-8">
<div className="text-3xl font-bold text-muted-foreground/30">
Stirling <span className="text-primary/30">Image</span>
</div>
<button className="flex items-center gap-2 px-6 py-2.5 rounded-lg border border-primary text-primary hover:bg-primary/5 transition-colors text-sm font-medium">
<Upload className="h-4 w-4" />
Upload from computer
</button>
<p className="text-sm text-muted-foreground">
Drop files here or click the upload button
</p>
</div>
</div>
);
}
```
- [ ] **Step 4: Create footer**
apps/web/src/components/layout/footer.tsx:
```tsx
import { Moon, Sun, Globe } from "lucide-react";
import { useTheme } from "@/hooks/use-theme";
export function Footer() {
const { resolvedTheme, toggleTheme } = useTheme();
return (
<div className="fixed bottom-4 right-4 flex items-center gap-2 z-50">
<button
onClick={toggleTheme}
className="p-2 rounded-lg bg-card border border-border hover:bg-muted transition-colors"
title="Toggle Theme"
>
{resolvedTheme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</button>
<button
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-card border border-border hover:bg-muted transition-colors text-sm"
title="Language"
>
<Globe className="h-4 w-4" />
English
</button>
</div>
);
}
```
- [ ] **Step 5: Create app layout**
apps/web/src/components/layout/app-layout.tsx:
```tsx
import { useState } from "react";
import { Sidebar } from "./sidebar";
import { ToolPanel } from "./tool-panel";
import { Footer } from "./footer";
import { Dropzone } from "../common/dropzone";
interface AppLayoutProps {
children?: React.ReactNode;
showToolPanel?: boolean;
}
export function AppLayout({ children, showToolPanel = true }: AppLayoutProps) {
const [settingsOpen, setSettingsOpen] = useState(false);
return (
<div className="flex h-screen bg-background text-foreground">
<Sidebar onSettingsClick={() => setSettingsOpen(true)} />
{showToolPanel && <ToolPanel />}
<main className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto p-6">
{children || <Dropzone />}
</div>
<div className="text-center text-xs text-muted-foreground py-2 border-t border-border">
Privacy Policy
</div>
</main>
<Footer />
</div>
);
}
```
- [ ] **Step 6: Create home page and wire up App.tsx**
apps/web/src/pages/home-page.tsx:
```tsx
import { AppLayout } from "@/components/layout/app-layout";
export function HomePage() {
return <AppLayout />;
}
```
Update apps/web/src/App.tsx:
```tsx
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { HomePage } from "./pages/home-page";
export function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
</Routes>
</BrowserRouter>
);
}
```
- [ ] **Step 7: Test the full layout renders**
```bash
cd apps/web && pnpm dev
```
Open http://localhost:5173 — should see the Stirling-PDF-style layout: sidebar (left icons), tool panel (categorized list with search), and dropzone (main area).
- [ ] **Step 8: Commit**
```bash
git add apps/web/src/
git commit -m "feat: add Stirling-PDF-style layout with sidebar, tool panel, dropzone, and theme toggle"
```
---
## Task 10: Login Page
**Files:**
- Create: `apps/web/src/pages/login-page.tsx`
- Modify: `apps/web/src/App.tsx` (add login route and auth guard)
- [ ] **Step 1: Create login page (split layout matching Stirling-PDF)**
apps/web/src/pages/login-page.tsx:
```tsx
import { useState, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
export function LoginPage() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
try {
const res = await fetch("/api/auth/sign-in", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: username, password }),
});
if (!res.ok) {
setError("Invalid username or password");
return;
}
navigate("/");
} catch {
setError("Connection error");
} finally {
setLoading(false);
}
};
return (
<div className="flex h-screen">
{/* Left: Login Form */}
<div className="flex-1 flex items-center justify-center p-8">
<div className="w-full max-w-md space-y-8">
<div>
<h1 className="text-3xl font-bold">
Stirling <span className="text-primary">Image</span>
</h1>
<h2 className="text-2xl font-bold mt-4">Login</h2>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Username</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter username"
className="w-full px-4 py-3 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary/20"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
className="w-full px-4 py-3 rounded-lg border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary/20"
required
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<button
type="submit"
disabled={loading || !username || !password}
className="w-full py-3 rounded-lg bg-primary/80 text-primary-foreground font-medium hover:bg-primary transition-colors disabled:opacity-50"
>
{loading ? "Logging in..." : "Login"}
</button>
</form>
</div>
</div>
{/* Right: Branding Panel */}
<div className="hidden lg:flex flex-1 bg-primary/90 items-center justify-center p-12 text-white">
<div className="max-w-lg space-y-6 text-center">
<h2 className="text-3xl font-bold">
Your one-stop-shop for all your image needs.
</h2>
<p className="text-lg text-white/80">
A privacy-first image suite that lets you resize, compress, convert,
and process images with 30+ powerful tools.
</p>
</div>
</div>
</div>
);
}
```
- [ ] **Step 2: Add login route to App.tsx**
```tsx
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { HomePage } from "./pages/home-page";
import { LoginPage } from "./pages/login-page";
export function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/*" element={<HomePage />} />
</Routes>
</BrowserRouter>
);
}
```
- [ ] **Step 3: Test login page renders**
Open http://localhost:5173/login — should see split layout with login form and branding panel.
- [ ] **Step 4: Commit**
```bash
git add apps/web/src/
git commit -m "feat: add login page with split layout matching Stirling-PDF style"
```
---
## Task 11: Generic Tool Page Template
**Files:**
- Create: `apps/web/src/pages/tool-page.tsx`
- Modify: `apps/web/src/App.tsx` (add dynamic tool route)
- [ ] **Step 1: Create generic tool page**
apps/web/src/pages/tool-page.tsx:
```tsx
import { useParams } from "react-router-dom";
import { useMemo } from "react";
import { TOOLS } from "@stirling-image/shared";
import { AppLayout } from "@/components/layout/app-layout";
import { Dropzone } from "@/components/common/dropzone";
import * as icons from "lucide-react";
export function ToolPage() {
const { toolId } = useParams<{ toolId: string }>();
const tool = useMemo(() => TOOLS.find((t) => t.id === toolId), [toolId]);
if (!tool) {
return (
<AppLayout>
<div className="flex items-center justify-center h-full text-muted-foreground">
Tool not found
</div>
</AppLayout>
);
}
const IconComponent = (icons as Record<string, React.ComponentType<{ className?: string }>>)[tool.icon] || icons.FileImage;
return (
<AppLayout>
<div className="flex h-full gap-0">
{/* Tool Settings Panel */}
<div className="w-72 border-r border-border p-4 space-y-4 overflow-y-auto">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary text-primary-foreground">
<IconComponent className="h-5 w-5" />
</div>
<h2 className="font-semibold text-lg">{tool.name}</h2>
</div>
{/* Files section */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">Files</h3>
<button className="flex items-center gap-2 text-sm text-primary hover:underline">
<icons.Upload className="h-4 w-4" />
Upload
</button>
</div>
<div className="border-t border-border" />
{/* Settings section placeholder */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">Settings</h3>
<p className="text-xs text-muted-foreground">
Tool settings will appear here once implemented.
</p>
</div>
<div className="border-t border-border" />
{/* Process button */}
<button
disabled
className="w-full py-2.5 rounded-lg bg-muted text-muted-foreground font-medium disabled:opacity-50"
>
{tool.name}
</button>
</div>
{/* Dropzone */}
<div className="flex-1 flex items-center justify-center p-6">
<Dropzone />
</div>
</div>
</AppLayout>
);
}
```
- [ ] **Step 2: Update App.tsx with dynamic tool routes**
```tsx
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { HomePage } from "./pages/home-page";
import { LoginPage } from "./pages/login-page";
import { ToolPage } from "./pages/tool-page";
export function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/:toolId" element={<ToolPage />} />
<Route path="/" element={<HomePage />} />
</Routes>
</BrowserRouter>
);
}
```
- [ ] **Step 3: Test clicking a tool in the sidebar navigates to its page**
Click "Compress" in tool panel — should navigate to `/compress` and show tool page template with settings panel + dropzone.
- [ ] **Step 4: Commit**
```bash
git add apps/web/src/
git commit -m "feat: add generic tool page template with settings panel and dropzone"
```
---
## Task 12: Docker Setup (Multi-Stage, Multi-Arch)
**Files:**
- Create: `docker/Dockerfile`, `docker/.dockerignore`, `docker/docker-compose.yml`
- [ ] **Step 1: Create docker/.dockerignore**
```
node_modules
.git
.turbo
dist
*.db
*.db-journal
*.db-wal
.env
.env.local
.DS_Store
.playwright-mcp
*.png
*.jpg
*.jpeg
```
- [ ] **Step 2: Create docker/Dockerfile**
```dockerfile
# ============================================
# Stage 1: Build
# ============================================
FROM node:22-bookworm AS builder
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
WORKDIR /app
# Copy workspace files
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json turbo.json tsconfig.base.json ./
COPY apps/web/package.json apps/web/tsconfig.json apps/web/vite.config.ts apps/web/postcss.config.js apps/web/index.html ./apps/web/
COPY apps/api/package.json apps/api/tsconfig.json ./apps/api/
COPY packages/shared/package.json packages/shared/tsconfig.json ./packages/shared/
COPY packages/image-engine/package.json packages/image-engine/tsconfig.json ./packages/image-engine/
COPY packages/ai/package.json packages/ai/tsconfig.json ./packages/ai/
# Install dependencies
RUN pnpm install --frozen-lockfile
# Copy source code
COPY . .
# Build everything
RUN pnpm build
# ============================================
# Stage 2: Production
# ============================================
FROM node:22-bookworm-slim AS production
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip python3-venv \
imagemagick \
tesseract-ocr tesseract-ocr-eng \
libraw-dev \
potrace \
curl \
&& rm -rf /var/lib/apt/lists/*
# Create Python venv and install ML packages
RUN python3 -m venv /opt/venv
COPY packages/ai/python/requirements.txt /tmp/requirements.txt
RUN /opt/venv/bin/pip install --no-cache-dir -r /tmp/requirements.txt && rm /tmp/requirements.txt
WORKDIR /app
# Copy built artifacts from builder
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/apps/api/dist ./apps/api/dist
COPY --from=builder /app/apps/api/node_modules ./apps/api/node_modules
COPY --from=builder /app/apps/api/package.json ./apps/api/package.json
COPY --from=builder /app/apps/api/drizzle ./apps/api/drizzle
COPY --from=builder /app/apps/web/dist ./apps/web/dist
COPY --from=builder /app/packages ./packages
# Create data and workspace directories
RUN mkdir -p /data /tmp/workspace
# Environment defaults
ENV PORT=1349 \
NODE_ENV=production \
AUTH_ENABLED=true \
DEFAULT_USERNAME=admin \
DEFAULT_PASSWORD=admin \
STORAGE_MODE=local \
DB_PATH=/data/stirling.db \
WORKSPACE_PATH=/tmp/workspace \
PYTHON_VENV_PATH=/opt/venv \
DEFAULT_THEME=light \
APP_NAME="Stirling Image"
EXPOSE 1349
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:1349/api/v1/health || exit 1
CMD ["node", "apps/api/dist/index.js"]
```
- [ ] **Step 3: Create docker/docker-compose.yml**
```yaml
services:
stirling-image:
build:
context: ..
dockerfile: docker/Dockerfile
container_name: stirling-image
ports:
- "1349:1349"
environment:
- AUTH_ENABLED=true
- DEFAULT_USERNAME=admin
- DEFAULT_PASSWORD=admin
- STORAGE_MODE=local
- DEFAULT_THEME=light
- APP_NAME=Stirling Image
volumes:
- ./data:/data
- ./workspace:/tmp/workspace
restart: unless-stopped
```
- [ ] **Step 4: Test Docker build**
```bash
cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image
docker build -f docker/Dockerfile -t stirling-image:dev .
```
Expected: Build completes (may take a few minutes for Python deps on first build).
- [ ] **Step 5: Test Docker run**
```bash
docker run --rm -p 1349:1349 stirling-image:dev
```
Open http://localhost:1349 — should see the app.
- [ ] **Step 6: Commit**
```bash
git add docker/
git commit -m "feat: add multi-stage Docker build with Python ML dependencies"
```
---
## Task 13: Static File Serving in Production
**Files:**
- Create: `apps/api/src/plugins/static.ts`
- Modify: `apps/api/src/index.ts`
- [ ] **Step 1: Create static file serving plugin**
apps/api/src/plugins/static.ts:
```typescript
import type { FastifyInstance } from "fastify";
import fastifyStatic from "@fastify/static";
import { resolve } from "node:path";
import { existsSync } from "node:fs";
export async function registerStatic(app: FastifyInstance) {
const webDistPath = resolve(process.cwd(), "../web/dist");
if (!existsSync(webDistPath)) {
app.log.warn(`SPA dist not found at ${webDistPath} — skipping static file serving`);
return;
}
await app.register(fastifyStatic, {
root: webDistPath,
prefix: "/",
wildcard: false,
});
// SPA fallback — serve index.html for all non-API routes
app.setNotFoundHandler((request, reply) => {
if (request.url.startsWith("/api/")) {
reply.code(404).send({ error: "Not found", code: "NOT_FOUND" });
} else {
reply.sendFile("index.html");
}
});
}
```
- [ ] **Step 2: Register in server startup**
Add to `apps/api/src/index.ts` after auth registration:
```typescript
import { registerStatic } from "./plugins/static.js";
// After all API routes registered:
if (process.env.NODE_ENV === "production") {
await registerStatic(app);
}
```
- [ ] **Step 3: Commit**
```bash
git add apps/api/src/plugins/static.ts apps/api/src/index.ts
git commit -m "feat: serve React SPA from Fastify in production mode"
```
---
## Task 14: File Cleanup Cron
**Files:**
- Create: `apps/api/src/lib/cleanup.ts`
- Modify: `apps/api/src/index.ts`
- [ ] **Step 1: Create cleanup service**
apps/api/src/lib/cleanup.ts:
```typescript
import { readdir, stat, rm } from "node:fs/promises";
import { join } from "node:path";
import { env } from "../config.js";
export function startCleanupCron() {
const intervalMs = env.CLEANUP_INTERVAL_MINUTES * 60 * 1000;
const maxAgeMs = env.FILE_MAX_AGE_HOURS * 60 * 60 * 1000;
const cleanup = async () => {
try {
const entries = await readdir(env.WORKSPACE_PATH, { withFileTypes: true }).catch(() => []);
const now = Date.now();
let cleaned = 0;
for (const entry of entries) {
const fullPath = join(env.WORKSPACE_PATH, entry.name);
try {
const stats = await stat(fullPath);
if (now - stats.mtimeMs > maxAgeMs) {
await rm(fullPath, { recursive: true });
cleaned++;
}
} catch {
// Skip files that can't be stat'd
}
}
if (cleaned > 0) {
console.log(`Cleanup: removed ${cleaned} expired workspace entries`);
}
} catch (err) {
console.error("Cleanup error:", err);
}
};
// Run on startup if configured
cleanup();
// Schedule recurring cleanup
const timer = setInterval(cleanup, intervalMs);
console.log(`Cleanup scheduled: every ${env.CLEANUP_INTERVAL_MINUTES}m, max age ${env.FILE_MAX_AGE_HOURS}h`);
return () => clearInterval(timer);
}
```
- [ ] **Step 2: Start cleanup in server**
Add to `apps/api/src/index.ts`:
```typescript
import { startCleanupCron } from "./lib/cleanup.js";
// Before app.listen:
startCleanupCron();
```
- [ ] **Step 3: Commit**
```bash
git add apps/api/src/lib/cleanup.ts apps/api/src/index.ts
git commit -m "feat: add automatic workspace file cleanup cron"
```
---
## Task 15: Swagger API Documentation
**Files:**
- Modify: `apps/api/src/index.ts`
- [ ] **Step 1: Register Swagger plugins**
Add to `apps/api/src/index.ts` after CORS:
```typescript
import swagger from "@fastify/swagger";
import swaggerUi from "@fastify/swagger-ui";
await app.register(swagger, {
openapi: {
info: {
title: "Stirling Image API",
description: "API for Stirling Image — self-hosted image processing suite",
version: APP_VERSION,
},
servers: [{ url: `http://localhost:${env.PORT}` }],
},
});
await app.register(swaggerUi, {
routePrefix: "/api/docs",
});
```
- [ ] **Step 2: Test Swagger UI loads**
```bash
cd apps/api && pnpm dev
```
Open http://localhost:1349/api/docs — should see Swagger UI with the health endpoint.
- [ ] **Step 3: Commit**
```bash
git add apps/api/src/index.ts
git commit -m "feat: add Swagger/OpenAPI documentation at /api/docs"
```
---
## Verification: Full Stack Integration Test
- [ ] **Step 1: Start both servers**
```bash
pnpm dev
```
This should start both `apps/api` (port 1349) and `apps/web` (port 5173).
- [ ] **Step 2: Verify**
| Check | URL | Expected |
|-------|-----|----------|
| API health | http://localhost:1349/api/v1/health | JSON with status "healthy" |
| Swagger UI | http://localhost:1349/api/docs | Swagger documentation |
| Frontend | http://localhost:5173 | Stirling-Image layout with sidebar, tools, dropzone |
| Login page | http://localhost:5173/login | Split login page |
| Tool page | http://localhost:5173/compress | Tool settings panel + dropzone |
| Theme toggle | Click moon/sun icon | Theme switches between light/dark |
| Search | Type in search bar | Tool list filters |
- [ ] **Step 3: Docker build and test**
```bash
docker build -f docker/Dockerfile -t stirling-image:dev .
docker run --rm -p 1349:1349 stirling-image:dev
```
Open http://localhost:1349 — full app should work from the single container.
- [ ] **Step 4: Final commit**
```bash
git add -A
git commit -m "feat: Phase 1 Foundation complete — monorepo, auth, layout, Docker"
```
---
## What Phase 1 Delivers
After completing this plan, you have:
- **Working monorepo** with Turborepo + pnpm, 2 apps + 3 packages
- **Fastify API** with health check, rate limiting, Swagger docs
- **SQLite database** with Drizzle ORM, proper WAL mode, auto-migrations
- **Authentication** via Better-Auth with default admin user
- **React SPA** with Stirling-PDF-style layout (sidebar, tool panel, dropzone)
- **Theme system** (light/dark/system with persistence)
- **Login page** (split layout matching Stirling-PDF)
- **Generic tool page template** (settings panel + dropzone — ready for Phase 2)
- **37 tools defined** in shared constants (routes ready, UI placeholders)
- **Docker container** (multi-stage build, Python ML deps included)
- **File cleanup cron** (24h max age, 30min intervals)
- **API documentation** at /api/docs
**Next:** Phase 2 plan will implement the image-engine package and first 10 core tools (resize, crop, rotate, convert, compress, strip metadata, brightness/contrast, saturation, color channels, color effects).