Files
SnapOtter/docs/superpowers/plans/2026-03-22-phase1-foundation.md
T
Siddharth Kumar Sah b7998a79f7 Add Phase 1 Foundation implementation plan
15 tasks covering monorepo setup, Fastify API, SQLite/Drizzle,
Better-Auth, React SPA with Stirling-PDF layout, theme system,
login page, tool page template, Docker multi-stage build, and
file cleanup cron.
2026-03-22 02:38:12 +08:00

71 KiB

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

cd /Users/sidd/Desktop/Personal/Projects/Stirling-Image

Create package.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
packages:
  - "apps/*"
  - "packages/*"
  • Step 3: Create turbo.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
{
  "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
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
pnpm install

Expected: pnpm-lock.yaml created, node_modules/ populated.

  • Step 8: Commit
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

{
  "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
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"]
}
  • Step 3: Create packages/shared/src/types.ts
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
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
export * from "./types.js";
export * from "./constants.js";
  • Step 6: Install dependencies and verify typecheck
pnpm install
pnpm --filter @stirling-image/shared typecheck

Expected: No errors.

  • Step 7: Commit
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

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

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"]
}

src/index.ts:

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

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

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"]
}

packages/ai/src/index.ts:

// 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
pnpm install
pnpm typecheck

Expected: All packages pass.

  • Step 5: Commit
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

{
  "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
{
  "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
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
import "dotenv/config";
import { loadEnv } from "./lib/env.js";

export const env = loadEnv();
  • Step 5: Create apps/api/src/index.ts
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
pnpm install
cd apps/api && pnpm dev

In another terminal:

curl http://localhost:1349/api/v1/health

Expected: JSON response with "status": "healthy".

  • Step 7: Commit
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:

"drizzle-orm": "^0.38.0",
"better-sqlite3": "^11.7.0"

Add to devDependencies:

"drizzle-kit": "^0.30.0",
"@types/better-sqlite3": "^7.6.0"

Run pnpm install.

  • Step 2: Create apps/api/src/db/schema.ts
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
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
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:

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
cd apps/api
pnpm drizzle-kit generate

Update apps/api/src/index.ts to init DB before starting:

// 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
pnpm dev

Expected: "Database initialized" in logs, ./data/stirling.db file created.

  • Step 8: Commit
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

cd apps/api
pnpm add better-auth
  • Step 2: Create apps/api/src/plugins/auth.ts
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:

import { registerAuth, ensureDefaultAdmin } from "./plugins/auth.js";

// After migrations:
await ensureDefaultAdmin();

// After plugins:
await registerAuth(app);
  • Step 4: Test auth endpoints exist
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
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

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

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

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:

export default {};
  • Step 3: Create HTML entry and React root

apps/web/index.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:

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

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:

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:

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
pnpm install
cd apps/web && pnpm dev

Open http://localhost:5173 — should see "Stirling Image" text.

  • Step 5: Commit
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:

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:

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

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:

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:

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:

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:

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:

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:

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:

import { AppLayout } from "@/components/layout/app-layout";

export function HomePage() {
  return <AppLayout />;
}

Update apps/web/src/App.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
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
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:

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

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
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
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
# ============================================
# 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
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
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
docker run --rm -p 1349:1349 stirling-image:dev

Open http://localhost:1349 — should see the app.

  • Step 6: Commit
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:

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:

import { registerStatic } from "./plugins/static.js";

// After all API routes registered:
if (process.env.NODE_ENV === "production") {
  await registerStatic(app);
}
  • Step 3: Commit
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:

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:

import { startCleanupCron } from "./lib/cleanup.js";

// Before app.listen:
startCleanupCron();
  • Step 3: Commit
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:

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
cd apps/api && pnpm dev

Open http://localhost:1349/api/docs — should see Swagger UI with the health endpoint.

  • Step 3: Commit
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
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
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
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).