- TypeScript 96.8%
- Shell 2.3%
- Dockerfile 0.7%
The Pixi ticker rendered every rAF forever (idle included) at full DPR with MSAA, pinning the GPU. Now the canvas uses an activity-gated loop (autoStart:false + wake-on-mutation, auto-stop after ~1.2s idle) with a maxFPS cap, and touch devices get an automatic render-budget trim (resolution capped to 1.5x, MSAA off, 30fps). Frontend render-loop only; no backend/schema/texture changes. Ships as 2.1.1-beta. |
||
|---|---|---|
| backend | ||
| docker | ||
| docs | ||
| frontend | ||
| .dockerignore | ||
| .env.example | ||
| .env.production.example | ||
| .gitignore | ||
| AGENTS.md | ||
| docker-compose.migrate.yml | ||
| docker-compose.prod.yml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| encrypt_work_checklist.md | ||
| init.sql | ||
| LICENSE | ||
| product_requirements.md | ||
| README.md | ||
| social_sharing_build_plan.md | ||
| start.sh | ||
Privacy-First Custom Jigsaw Puzzle Platform
A privacy-focused web application that lets you create custom jigsaw puzzles from your own photos. Built with zero-knowledge encryption to ensure your photos remain private.
Features
- Custom Puzzles: Create jigsaw puzzles from your own photos with adjustable difficulty (4-5000 pieces)
- Zero-Knowledge Encryption: Optional client-side encryption ensures only you can view your encrypted photos
- Progress Saving: Auto-saves puzzle progress every 15 seconds
- Upload Progress Tracking: Real-time progress feedback with speed and ETA during photo uploads
- Full-Board Image Display: Puzzle image fills the entire board area for an immersive experience
- Guide Overlay: Optional ghost image overlay to help with piece placement (adjustable opacity)
- Responsive Design: Works seamlessly on desktop and mobile devices with automatic tray repositioning on orientation change
- Full Screen Mode: Immersive fullscreen puzzle solving with touch-optimized controls
- Dark Mode: Full dark mode support
- Admin Dashboard: User management, site settings, and IP blocking
- Wall of Glory: Showcase completed puzzles in a customizable gallery with piece outlines, drag-and-drop positioning, and resize handles
- Social Sharing: Share completed puzzles and galleries with public links, with optional gallery access control
- Photo Encryption: Full encryption/decryption flow with passphrase management and re-encryption support
- Photo Usage Badges: Puzzle-piece and checkmark count badges on photos that have been used to create puzzles, shown on both the upload page and puzzle creation page
- Piece Groups: Adjoining pieces lock together wherever you place them, move as a single unit, and tray as one compact block — "Tray All Unlocked" and drag-over-tray keep groups intact. A group only locks in place after it snaps as a whole unit into its solved position; an off-target group always stays draggable
- Zoom & Pan: Zoom grows both the puzzle and the tray 1:1 up to 6x (tray capped at 25% of the canvas width, oversized trays fit-to-tray). Mouse wheel, trackpad pinch (ctrl+wheel), and multi-touch two-finger pinch are all anchored at the pointer; locked pieces and piece groups are never disturbed by zooming or panning. The tray also captures wheel/scroll input in a small margin to its left, so aiming a few pixels off the tray still scrolls the tray instead of zooming the board
- Puzzle Card Thumbnails: Each card on the My Puzzles page shows the thumbnail of the photo the puzzle is built from; encrypted photos show a lock placeholder unless a decrypted copy is cached in the browser session
- Puzzle Rotation: Rotate the puzzle board 90° clockwise during solving to better fit portrait or landscape photos; rotation persists with your progress and works for both in-progress and already-solved puzzles
- Cool-Running Rendering: The puzzle canvas no longer repaints nonstop. The render loop is activity-gated — it runs only while you drag/zoom/pan/scroll (and for a short settle window after), then fully stops so the GPU idles while you think. On touch devices it also auto-trims the render budget (caps backing resolution and drops MSAA anti-aliasing, ~30fps) so phones and iPads stay cool during long sessions
- Audit Logging: Track all user and admin actions
Tech Stack
Frontend
- Next.js 16 (App Router)
- TypeScript
- Tailwind CSS
- PixiJS (puzzle canvas rendering)
Backend
- Node.js + Express
- TypeScript
- Lucia Auth (authentication)
- PostgreSQL (database)
- Garage (open-source S3-compatible object storage)
- ClamAV (virus scanning)
Infrastructure
- Docker Compose
- Zero-knowledge encryption (Web Crypto API)
Quick Start
Prerequisites
- Docker and Docker Compose
- Node.js 20+ (for development)
- npm or yarn (for development)
Option 1: Docker Hub Image (Easiest)
Pull and run the pre-built image with only the infrastructure services:
- Create a project directory and add the production compose file:
mkdir puzzle && cd puzzle
curl -sO https://raw.githubusercontent.com/<repo>/main/docker-compose.prod.yml
curl -sO https://raw.githubusercontent.com/<repo>/main/.env.production.example
cp .env.production.example .env
# Edit .env with your secure passwords
- Start everything:
docker compose -f docker-compose.prod.yml up -d
- Open http://localhost:8080 and register your first user (becomes admin automatically).
Services:
- App (frontend + backend + nginx): http://localhost:8080
Option 2: Build from Source (Docker Compose)
Build and run the full stack locally with Docker:
- Clone the repository:
git clone <repository-url>
cd get_puzzles
- Start all services (builds backend and frontend images, starts Postgres, Garage, and ClamAV):
docker compose up -d --build
-
Open http://localhost:3002 in your browser
-
Register your first user - they will automatically become an admin
Services:
- Frontend: http://localhost:3002
- Backend API: http://localhost:3001
- Garage S3 API: http://localhost:3900 (path-style, region
garage; keys from your.env) - Garage admin CLI:
docker compose exec puzzle-garage /garage status
Stop all services:
docker compose down
Stop and remove data volumes:
docker compose down -v
Option 3: Development Setup
For local development with hot reload:
- Clone the repository:
git clone <repository-url>
cd get_puzzles
- Copy the backend environment file:
cp .env.example backend/.env
-
Update environment variables in
backend/.envas needed. -
Start infrastructure services (Postgres, Garage, ClamAV):
docker compose up -d postgres garage clamav
- Install and start the backend:
cd backend
npm install
npm run dev
- Install and start the frontend (in a new terminal):
cd frontend
npm install
npm run dev
-
Open http://localhost:3000 in your browser
-
Register your first user - they will automatically become an admin
View logs (production):
docker compose -f docker-compose.prod.yml logs -f
Stop all services:
docker compose -f docker-compose.prod.yml down
Stop and remove volumes:
docker compose -f docker-compose.prod.yml down -v
Starting from a Fresh Server
Deploy without cloning the whole repo — everything else is inside the images.
You need 4 files + a .env, in this exact layout (the compose file uses
relative paths):
/opt/get-puzzles/
├── docker-compose.prod.yml # the stack definition
├── init.sql # MUST be a file, not a directory — on a fresh
│ # Postgres volume it creates the base schema;
│ # if the path is missing Docker bind-mounts an
│ # empty directory named "init.sql" instead
├── .env # created in step 3 below
└── docker/garage/
├── garage.toml # Garage single-node config (mounted)
└── preflight.sh # validates secrets/config on every `up`
-
Create the directories and place the four files (copy from a workstation or download the raw files from the repository web UI).
-
Pin the app image tag in
docker-compose.prod.yml:bmcgonag/get-puzzles:latest→bmcgonag/get-puzzles:2.1.0-beta(or newer).latestpredates the Garage switch and will fail against this compose file. -
Create
.env. Garage credentials are strict lowercase hex — base64 is rejected and the preflight check will refuse to start the stack:DB_USER=puzzle_user DB_PASSWORD=<strong password> DB_NAME=puzzle_db GARAGE_RPC_SECRET=<64 hex> # openssl rand -hex 32 S3_ACCESS_KEY=GK<24 hex> # echo "GK$(openssl rand -hex 12)" S3_SECRET_KEY=<64 hex> # openssl rand -hex 32 S3_BUCKET=puzzle-uploads APP_PORT=8080 -
Start the stack:
docker compose -f docker-compose.prod.yml up -dOrder it enforces:
preflight(fails fast with the exact fix command if a secret orgarage.tomlis wrong) →garageself-bootstraps its layout, bucket, and access key on first boot →postgres/clamavbecome healthy → theappcontainer waits for all three and runs database migrations itself. ClamAV's first start takes several minutes downloading virus definitions; the app waits for it. -
Verify and go:
docker compose -f docker-compose.prod.yml ps # garage: healthy, app: up docker compose -f docker-compose.prod.yml logs preflight garage | tailOpen
http://<server>:8080— the first registered user becomes admin.
Full details: docs/DEPLOYMENT.md.
Migrating from a version before 2.1.0
Releases before 2.1.0 stored photo blobs in MinIO; 2.1.0+ uses Garage
(open-source, S3-compatible). The migration copies every object preserving
its key, so uploaded_photos.file_path rows stay valid — the database is not
touched. Full operator guide: docs/MINIO-TO-GARAGE.md.
Files needed (same four as the fresh install, plus the migration overlay):
/opt/get-puzzles/
├── docker-compose.prod.yml # updated version (garage + preflight services)
├── docker-compose.migrate.yml # ← new: one-shot migration overlay
├── init.sql
├── .env # Garage values as in the fresh-server section, plus:
│ # SOURCE_ACCESS_KEY / SOURCE_SECRET_KEY = the
│ # OLD MinIO root credentials (from the old .env)
└── docker/garage/
├── garage.toml
└── preflight.sh
Steps:
-
Update the files above (pin
image: bmcgonag/get-puzzles:2.1.0-beta) and add the Garage credentials to.env(hex format — see fresh-server section), plus the old MinIO root user/password asSOURCE_ACCESS_KEY/SOURCE_SECRET_KEY. -
Point the overlay at your old MinIO data. Bind-mount setup (a host folder whose subdirectory is the bucket) — the common case:
ls ./minio_data # must show a subdirectory per bucket, e.g. puzzle-uploads/Docker-volume setup:
docker volume ls | grep minio_dataand use the volume's_datahost path (docker volume inspect <vol> --format '{{.Mountpoint}}'). Also find the compose network name:docker network ls | grep puzzle-net # e.g. <project>_puzzle-netAdd to
.env:SOURCE_MINIO_DIR=./minio_data # host folder holding the old buckets MIGRATE_NETWORK=<project>_puzzle-net SOURCE_BUCKET=puzzle-uploads # old MinIO bucket name -
Bring up the new stack (Garage starts with an empty bucket; app + Postgres run normally):
docker compose -f docker-compose.prod.yml up -d -
Run the migration (boots the old MinIO from
SOURCE_MINIO_DIRasminio-sourceand rclone-copies it into Garage over the shared network — no ports exposed):docker compose -f docker-compose.prod.yml -f docker-compose.migrate.yml \ --profile migrate run --rm migrateConfirm the
==> Source bucket:line prints a non-zero object count; if it printsTotal objects: 0,SOURCE_MINIO_DIRpoints at the wrong/empty folder and nothing was copied. The run is idempotent — safe to re-run; identical objects are skipped. -
Verify:
docker compose -f docker-compose.prod.yml exec garage /garage bucket info puzzle-uploadsObjects:must match the old MinIO count, then open a photo in the app. -
Once confirmed, clean up:
# optional final backup of the old folder, then remove it (or the old volume) # rm -rf ./minio_data # bind-mount setup # docker volume rm <project>_minio_data # docker-volume setupand remove the
SOURCE_*,SOURCE_MINIO_DIR,MIGRATE_NETWORKlines from.env(the app never uses them).
Note: the migration does not run automatically on docker compose up —
the old MinIO must be reachable during the copy, so it is an explicit operator
action.
Documentation
- Setup Guide - Detailed setup and configuration
- API Documentation - REST API endpoints
- Security Model - Encryption and privacy details
- Architecture - System architecture overview
- Deployment - Production deployment guide
- MinIO → Garage Migration - Move existing storage to Garage
Testing
Unit tests (143 backend tests):
cd backend
npm test
Backend E2E tests (31 tests — requires the full stack running on http://localhost:3002):
cd backend
npx playwright test
Frontend E2E tests (63 tests x 5 browsers, 315 total — starts its own dev server):
cd frontend
npx playwright test
The project includes:
- 124 frontend unit tests covering auth, uploads, puzzle creation, hints, admin, user settings, gallery, social sharing, photo usage badges, piece groups, zoom/tray layout math (including the tray capture margin so wheel-scroll a few px left of the tray still scrolls the tray), puzzle rotation coordinate transforms, texture-frame clamping (piece bounding boxes from float-ulp overshoots of the photo edge never overflow the base texture), and the idle-render loop (touch/desktop render-budget matrix plus the activity-gated start/stop controller: it does not spin on idle, restarts on wake, and disposes cleanly)
- Backend tests cover auth, uploads, storage (real Garage S3 round-trip: put/get/delete/batch-delete), puzzle creation, hints, admin, user settings, gallery, sharing, photo usage badges, and jigsaw-cut frame correctness
- Frontend E2E tests covering puzzle layout, tray behavior, piece-group behavior (group formation, tray-all-unlocked unit behavior, drag-over-tray grouping, group solve-snap/lock correctness), zoom controls (tray growth, locked/group invariance, zoom-aware dragging, fit-on-reload, tray pieces staying in the tray across a reload while zoomed), puzzle rotation (button exists, rotation persists, four rotations return to original, locked pieces maintain relative positions, works on existing puzzles), piece locking regression (snap→merge→re-snap→lock ordering), piece texture-frame fit (creating puzzles from real-size photos at counts like 100/275/350 where float rounding overshot the photo edge), idle/cool rendering (board paints then the render loop freezes when idle and wakes on input, group drag + rotate still correct under the gated ticker), My Puzzles card photo thumbnails, and mobile responsiveness
Security Features
- Zero-Knowledge Encryption: Photos can be encrypted client-side before upload
- Virus Scanning: All uploads scanned with ClamAV
- Audit Logging: All actions logged for security review
- IP Blocking: Admin can block suspicious IP addresses
- Password Hashing: Argon2id password hashing
- Session Management: Secure session handling with Lucia Auth
Project Structure
get_puzzles/
├── backend/ # Express API server
│ ├── src/
│ │ ├── controllers/ # Request handlers
│ │ ├── services/ # Business logic
│ │ ├── middleware/ # Express middleware
│ │ └── config/ # Configuration
│ ├── migrations/ # Database migration scripts
│ └── e2e/ # Playwright E2E tests
├── frontend/ # Next.js application
│ ├── app/ # App router pages
│ ├── components/ # React components
│ └── lib/ # Utilities and API client
├── docker/ # Docker support files
│ ├── nginx.conf # Reverse proxy config
│ ├── entrypoint.sh # Container startup script
│ └── run-migrations.sh # Automatic migration runner
├── docs/ # Documentation
├── Dockerfile # Multi-stage production build
├── docker-compose.yml # Development compose
├── docker-compose.prod.yml # Production compose
├── init.sql # Database schema
└── .env.production.example
Development Phases
- ✅ Phase 1: Authentication (Login/Registration)
- ✅ Phase 2: Photo Upload (with virus scanning)
- ✅ Phase 3: Puzzle Creation (jigsaw cut algorithm)
- ✅ Phase 4: Puzzle Solving (drag-and-drop, hints)
- ✅ Phase 5: Admin Dashboard (user management, settings)
- ✅ Phase 6: User Settings (profile, password, encryption)
- ✅ Phase 7: Wall of Glory (gallery showcase with piece outlines)
- ✅ Phase 8: Social Sharing (share puzzles and galleries publicly)
- ✅ Phase 9: Photo Encryption (full encryption/decryption flow)
Database Migrations
The application includes an automatic migration system that runs on container startup:
- The base schema (
users,sessions,uploaded_photos,puzzles, ...) lives ininit.sqland is applied idempotently on every boot bydocker/run-migrations.sh— it does not depend on the Postgres volume being fresh (Docker's/docker-entrypoint-initdb.dhook runs only once on an empty data volume) - Migration changes are stored in
backend/migrations/ - The
docker/run-migrations.shscript tracks applied migrations in aschema_migrationstable - Migrations are applied in alphabetical order and are idempotent
- No manual intervention required - just pull the new image and restart
To create a new migration:
- Add a new
.sqlfile tobackend/migrations/with a numbered prefix (e.g.,010_add_feature.sql) - Use
IF NOT EXISTSorIF EXISTSclauses to make migrations idempotent - The migration will run automatically on next container startup
Contributing
Contributions are welcome! Please ensure:
- All tests pass (
npm test) - Code follows existing patterns
- New features include tests
- Documentation is updated
License
MIT License - see LICENSE file for details
Support
For issues and feature requests, please use the GitHub issue tracker.