Full-stack photo gallery for vrc.mcwind.cloud — browse VRChat screenshots with capture metadata, month filters, infinite scroll, and an internal admin panel for uploads and edits.
- Gallery — paginated masonry-style grid with infinite scroll, month filter, and lightbox; day/month grouping uses
Asia/Taipei(+08:00), timestamps shown in 24-hour format (e.g.2026/06/11 00:38) - Month filter — year-grouped dropdown; custom styled panel on desktop (
sm+), native<select>on mobile; filter syncs to?month=YYYY-MM(shareable URLs, browser back/forward); subtitle shows filtered count (e.g.12 張照片 · 2024年3月) - Lightbox — prev/next scoped to the active month filter (including cross-page neighbors via API); swipe/drag horizontally to change photos; keyboard ← → / Esc / R (rotate); load progress for large images with browser-cache awareness
- VRChat metadata — capture date from XMP
CreateDateorVRChat_YYYY-MM-DD_…filenames; optional annotations (world, author, description, in-game comment) from embedded XMP - Admin panel (
/admin, internal network only) — upload (drag-and-drop, up to 10 files), edit metadata, rename, delete, set per-photo display orientation (auto / portrait / landscape), hide from public gallery; masonry card layout; optional password login with signed HttpOnly session cookie (seeADMIN_PASSWORD) - Catalog sync — CLI scans
photos/, generates WebP thumbnails, writesdata/photos.json; non-image files (e.g.photos.json,thumbs/) are skipped automatically; catalog reloads when the file changes on disk; preserves admin-editeddisplayOrientation,hidden,date, andannotationon re-sync - Backward compatible —
GET /photos.jsonserves the legacy flat catalog format
?month=YYYY-MM— deep-link or share a month filter (e.g./?month=2024-03)- Omit the param to show all photos; invalid values are ignored
- Browser back/forward restores the previous filter
npm workspaces monorepo:
vrc-gallery/
├── client/ # React SPA (Vite) → client/dist/ after build
├── server/ # Express API → server/dist/ after build
├── scripts/ # deploy.sh, prod.sh, docker-import.sh
├── photos/ # Full-size images + thumbs/ (gitignored)
├── data/ # photos.json catalog (gitignored)
├── Dockerfile
├── docker-compose.yml
├── ecosystem.config.cjs
├── .env.example
└── package.json
- Client: React 19, Vite 6, Tailwind CSS 4, Framer Motion; fonts via Google Fonts CDN (
fonts.loli.net/fonts.googleapis.com) —Klee Onefor handwritten body;LXGW WenKai Litefor mixed-script display names;Noto Sans TC+Noto Sans SCfor UI / photo titles - Server: Express 5, TypeScript, Sharp (thumbnails + metadata), Multer (uploads)
Dev mode does not require npm run build. Vite serves the client on the fly; the server runs TypeScript via tsx.
cd vrc-gallery
npm install
cp .env.example .env # optional; defaults work for local dev
mkdir -p photos data
npm run sync-photos # create data/photos.json (empty [] if no images yet)
npm run dev- Frontend: http://localhost:5173 (proxies
/api,/photos, and/photos.jsonto the API) - API: http://localhost:8787
Place images in photos/ (JPEG, PNG, or WebP), then run npm run sync-photos again to refresh the catalog and thumbnails.
Production must build both workspaces before start:
- Client —
vite build→client/dist/(static SPA) - Server —
tsc→server/dist/(Node API + servesclient/dist)
A single Node process on PORT (default 8787) serves the SPA, /api, and /photos.
| Script | When | What it does |
|---|---|---|
npm run dev |
Local dev | Vite :5173 + tsx API :8787 — no build |
npm run build |
Deploy | vite build + tsc |
npm run prod |
Deploy | build then start (one-shot) |
npm start |
After build | sync-photos:prod then node server/dist/index.js |
npm run sync-photos |
Dev / manual | Scan photos/ via tsx (no build needed) |
npm run sync-photos:prod |
After build | Scan photos/ via compiled server/dist/scripts/sync-photos.js |
npm start runs sync-photos:prod first (via prestart), so thumbnails and data/photos.json stay in sync on every restart. Re-sync refreshes file metadata (dimensions, XMP) but keeps admin overrides already stored in photos.json (displayOrientation, hidden, edited date, edited annotation). After adding images while the server is already running, run npm run sync-photos:prod — the API reloads the catalog when photos.json changes on disk.
cd vrc-gallery
./scripts/deploy.sh # npm ci, .env, mkdir, build, sync-photos:prod
./scripts/prod.sh # load .env, npm startOr step by step:
npm ci
cp .env.example .env # set TRUST_PROXY, NODE_ENV, CORS_ORIGIN, etc.
mkdir -p photos data
# copy or rsync existing photos/ and data/photos.json if migrating
npm run prod # build + startApp listens on http://localhost:8787 (or your PORT). Put nginx/Caddy in front for HTTPS.
npm run build
pm2 start ecosystem.config.cjsCustomize env in ecosystem.config.cjs or override at runtime. PM2 runs npm start, so catalog sync runs on each (re)start.
Build and run with Docker Compose:
mkdir -p photos data
docker compose up -d --buildApp listens on http://localhost:8787. npm start inside the container runs sync-photos:prod on each start.
Import a pre-built image (e.g. on another machine or Portainer):
docker load -i vrc-gallery-image.tar
./scripts/docker-import.sh # load image + docker compose up -dVolume mapping — use two separate mounts; do not map the same volume to both paths:
| Host / volume | Container path | Contents |
|---|---|---|
./photos or vrc-gallery-photos |
/app/photos |
Original images + thumbs/ |
./data or vrc-gallery-data |
/app/data |
photos.json only |
Expected layout:
photos/
image.png
thumbs/
image_thumb.webp
data/
photos.json
In Portainer: Images → Import to upload vrc-gallery-image.tar, then deploy via Stacks with docker-compose.yml. Set TRUST_PROXY=1 when behind a reverse proxy.
git pull
npm ci
npm run build
npm run sync-photos:prod # if photos/ changed on disk
pm2 restart vrc-gallery # or ./scripts/prod.shNote: The app does not load
.envautomatically. Use./scripts/prod.sh(sources.env), PM2env, or systemdEnvironmentFile. Export vars manually if you runnpm startdirectly.
| Variable | Default | Description |
|---|---|---|
PORT |
8787 |
Server listen port |
NODE_ENV |
unset | Set to production to hide error details in API responses |
DATA_DIR |
./data |
Directory containing photos.json |
CATALOG_PATH |
{DATA_DIR}/photos.json |
Photo catalog file |
PHOTOS_DIR |
./photos |
Original images; thumbnails go in photos/thumbs/ |
PHOTO_TZ_OFFSET |
+08:00 |
Timezone offset for VRChat filename dates (no TZ in filename) |
PHOTO_TZ |
Asia/Taipei |
IANA timezone for gallery day/month grouping, month filter, and stats (server) |
CORS_ORIGIN |
reflect request origin | Allowed CORS origin (set explicitly in production) |
TRUST_PROXY |
unset | 1 behind a reverse proxy; use 0 for direct HTTP LAN access without a proxy |
ADMIN_PASSWORD |
unset | Admin password; when set, internal clients must log in after passing the IP check |
ADMIN_JWT_SECRET |
derived from password | HMAC secret for admin session cookie (set a dedicated random value in production) |
ADMIN_SESSION_HOURS |
24 |
Admin session lifetime |
ADMIN_COOKIE_SECURE |
auto | 1/0 to force; otherwise Secure only when the request is HTTPS |
When TRUST_PROXY is unset, forwarded headers are still trusted if the direct connection comes from a private IP (typical reverse-proxy setup). For a bare Node process on a home LAN (http://192.168.x.x:8787), set TRUST_PROXY=0 so the real client IP is used for admin access checks.
Dev-only (Vite):
| Variable | Default | Description |
|---|---|---|
VITE_API_PROXY |
http://127.0.0.1:8787 |
API proxy target during npm run dev |
VITE_PHOTO_PROXY |
same as VITE_API_PROXY |
/photos static proxy target |
- Drop full-size images into
photos/(supported:.jpg,.jpeg,.png,.webp) - Run
npm run sync-photos
This writes photos/thumbs/{id}_thumb.webp (max 640px, WebP quality 82) and updates data/photos.json. Re-run sync after upgrading to regenerate WebP thumbnails and refresh catalog paths.
Sync and catalog load skip non-image files and directories in photos/ (e.g. stray photos.json, thumbs/, other extensions) without failing. Unreadable image files are logged and skipped during sync. Admin upload silently ignores non-image files in a batch.
Capture date (in priority order):
- XMP
CreateDateortiff:DateTime VRChat_YYYY-MM-DD_HH-MM-SSfilename (usesPHOTO_TZ_OFFSET)- Filesystem birth time
Annotations (optional, from XMP at sync/upload time): WorldDisplayName, xmp:Author, dc:description / dc:title, exif:UserComment. Shown in the lightbox and editable in admin.
Display orientation (optional, admin-editable, stored in photos.json as displayOrientation):
| Value | Behavior |
|---|---|
| (omit / auto) | Layout follows pixel width × height |
portrait |
Gallery, lightbox, and admin thumbs treat the photo as vertical; rotates 90° when pixels are landscape |
landscape |
Layout treats the photo as horizontal; rotates 90° when pixels are portrait |
Set in admin 編輯 → 顯示方向. Lightbox still supports extra rotation with R. Orientation overrides survive sync-photos / server restarts.
Gallery visibility (optional, admin-editable, stored as hidden: true):
| Value | Behavior |
|---|---|
| (omit / show) | Photo appears in public gallery, stats, and month filter |
hidden: true |
Excluded from public gallery and stats; still visible in admin and via direct /photos/… URL |
Set in admin 編輯 → 相簿顯示. Hidden state survives sync-photos / server restarts.
Dates & time display
- Stored
datevalues are ISO UTC strings from XMP / filename / filesystem at sync time - Gallery day headers, month filter (
?month=YYYY-MM), and/api/photos/statsmonth counts use local calendar dates inAsia/Taipei(override server-side withPHOTO_TZ) - Lightbox and admin list timestamps use 24-hour format:
YYYY/MM/DD HH:mm(no 上午/下午) - VRChat filename fallback still uses
PHOTO_TZ_OFFSETwhen the filename has no timezone
- UI:
/admin(lazy-loaded; non-private clients are redirected to/) - API:
/api/admin/*(same IP restriction; returns302redirect to/for public IPs) - Dual protection: private-network IP check plus
ADMIN_PASSWORDsession cookie. External visitors are redirected away from/admin; internal clients see the login form.
| Client | /admin |
/api/admin/* |
|---|---|---|
| Private IP (RFC 1918, loopback, link-local) | Login form → password → admin UI | Requires valid session cookie when ADMIN_PASSWORD is set |
| Public IP | 302 redirect to / |
302 redirect to / |
- HttpOnly,
SameSite=Strict, signed HMAC token Secureflag is set only on HTTPS (or whenADMIN_COOKIE_SECURE=1). Plain HTTP LAN access (e.g.http://192.168.x.x:8787/admin) must not useSecure, or the browser will drop the cookie and login will appear to succeed but subsequent API calls return 401.- Force behavior with
ADMIN_COOKIE_SECURE:0= neverSecure;1= alwaysSecure(HTTPS only).
| Setup | TRUST_PROXY |
Notes |
|---|---|---|
Direct LAN access (http://192.168.x.x:8787) |
0 or unset |
Client IP is the browser's LAN address |
| Behind nginx/Caddy on HTTPS | 1 |
Proxy must send X-Forwarded-For / X-Real-IP |
| PM2 on same host, no proxy | 0 |
Match .env; do not trust forwarded headers |
If admin redirects to / from a LAN IP, check GET /api/admin/access — response includes privateNetwork and clientIp for debugging.
| Method | Path | Description |
|---|---|---|
GET |
/api/admin/access |
{ ok, admin, authRequired, authenticated, privateNetwork, clientIp } |
POST |
/api/admin/login |
{ password } → sets HttpOnly vrc_admin cookie |
POST |
/api/admin/logout |
Clears admin cookie |
All routes below also require a valid admin session cookie when ADMIN_PASSWORD is set:
From a private-network IP:
| Method | Path | Description |
|---|---|---|
GET |
/api/admin/access |
Check admin access |
GET |
/api/admin/photos |
Full catalog (with IDs) |
POST |
/api/admin/photos |
Upload images (multipart/form-data, field files, max 10 × 50 MB); non-image files in the batch are skipped |
PATCH |
/api/admin/photos/:id |
Update name, date, annotation, displayOrientation, hidden |
DELETE |
/api/admin/photos/:id |
Remove image, thumbnail, and catalog entry |
Behind a reverse proxy, set TRUST_PROXY=1 and forward the client IP:
# nginx example
location / {
proxy_pass http://127.0.0.1:8787;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Optional: block admin from the public internet at the proxy layer
location ~ ^/(admin|api/admin) {
allow 10.0.0.0/8;
allow 172.16.0.0/12;
allow 192.168.0.0/16;
allow 127.0.0.1;
deny all;
proxy_pass http://127.0.0.1:8787;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}| Method | Path | Description |
|---|---|---|
GET |
/api/health |
Health check |
GET |
/api/photos/stats |
{ total, months, latestDate, updatedAt } |
GET |
/api/photos |
Paginated list — query: page, limit (max 50), month (YYYY-MM or YYYY), year, q (name search) |
GET |
/api/photos/:id |
Single photo with prev / next neighbors; pass the same month / year / q filters as the list endpoint so neighbors stay within the active filter |
GET |
/photos.json |
Legacy catalog (no id / year fields) |
GET |
/photos/* |
Static image files |
Point your reverse proxy at the Node process on PORT (default 8787). The production server serves the built SPA from client/dist/, API routes under /api, and images under /photos.