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.
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
Backend E2E tests (31 tests — requires the full stack running on http://localhost:3002):
cd backend
npx playwright test
Frontend E2E tests (49 tests x 5 browsers, 245 total — starts its own dev server):
cd frontend
npx playwright test
The project includes:
89 unit tests covering auth, uploads, puzzle creation, hints, admin, user settings, gallery, social sharing, photo usage badges, piece groups, and zoom/tray layout math (including the tray capture margin so wheel-scroll a few px left of the tray still scrolls the tray)
E2E tests covering registration, login, admin settings, photo upload, puzzle creation, thumbnails, user settings, and photo usage badges
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), and mobile responsiveness
The application includes an automatic migration system that runs on container startup:
The base schema (users, sessions, uploaded_photos, puzzles, ...) lives in init.sql and is applied idempotently on every boot by docker/run-migrations.sh — it does not depend on the Postgres volume being fresh (Docker's /docker-entrypoint-initdb.d hook runs only once on an empty data volume)
Migration changes are stored in backend/migrations/
The docker/run-migrations.sh script tracks applied migrations in a schema_migrations table
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 .sql file to backend/migrations/ with a numbered prefix (e.g., 010_add_feature.sql)
Use IF NOT EXISTS or IF EXISTS clauses to make migrations idempotent
The migration will run automatically on next container startup