Skip to content

MCwindTIM/vrc-gallery

Repository files navigation

土豆 VRChat Gallery

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.

Features

  • 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 CreateDate or VRChat_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 (see ADMIN_PASSWORD)
  • Catalog sync — CLI scans photos/, generates WebP thumbnails, writes data/photos.json; non-image files (e.g. photos.json, thumbs/) are skipped automatically; catalog reloads when the file changes on disk; preserves admin-edited displayOrientation, hidden, date, and annotation on re-sync
  • Backward compatibleGET /photos.json serves the legacy flat catalog format

Gallery URL

  • ?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

Project structure

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

Stack

  • Client: React 19, Vite 6, Tailwind CSS 4, Framer Motion; fonts via Google Fonts CDN (fonts.loli.net / fonts.googleapis.com) — Klee One for handwritten body; LXGW WenKai Lite for mixed-script display names; Noto Sans TC + Noto Sans SC for UI / photo titles
  • Server: Express 5, TypeScript, Sharp (thumbnails + metadata), Multer (uploads)

Development

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

Place images in photos/ (JPEG, PNG, or WebP), then run npm run sync-photos again to refresh the catalog and thumbnails.

Production

Production must build both workspaces before start:

  1. Clientvite buildclient/dist/ (static SPA)
  2. Servertscserver/dist/ (Node API + serves client/dist)

A single Node process on PORT (default 8787) serves the SPA, /api, and /photos.

npm scripts

Script When What it does
npm run dev Local dev Vite :5173 + tsx API :8787no 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.

First deploy

cd vrc-gallery
./scripts/deploy.sh      # npm ci, .env, mkdir, build, sync-photos:prod
./scripts/prod.sh        # load .env, npm start

Or 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 + start

App listens on http://localhost:8787 (or your PORT). Put nginx/Caddy in front for HTTPS.

PM2

npm run build
pm2 start ecosystem.config.cjs

Customize env in ecosystem.config.cjs or override at runtime. PM2 runs npm start, so catalog sync runs on each (re)start.

Docker

Build and run with Docker Compose:

mkdir -p photos data
docker compose up -d --build

App 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 -d

Volume 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.

Update deploy

git pull
npm ci
npm run build
npm run sync-photos:prod   # if photos/ changed on disk
pm2 restart vrc-gallery     # or ./scripts/prod.sh

Note: The app does not load .env automatically. Use ./scripts/prod.sh (sources .env), PM2 env, or systemd EnvironmentFile. Export vars manually if you run npm start directly.

Environment variables

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

Photo catalog

  1. Drop full-size images into photos/ (supported: .jpg, .jpeg, .png, .webp)
  2. 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):

  1. XMP CreateDate or tiff:DateTime
  2. VRChat_YYYY-MM-DD_HH-MM-SS filename (uses PHOTO_TZ_OFFSET)
  3. 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 date values are ISO UTC strings from XMP / filename / filesystem at sync time
  • Gallery day headers, month filter (?month=YYYY-MM), and /api/photos/stats month counts use local calendar dates in Asia/Taipei (override server-side with PHOTO_TZ)
  • Lightbox and admin list timestamps use 24-hour format: YYYY/MM/DD HH:mm (no 上午/下午)
  • VRChat filename fallback still uses PHOTO_TZ_OFFSET when the filename has no timezone

Admin (internal network only)

  • UI: /admin (lazy-loaded; non-private clients are redirected to /)
  • API: /api/admin/* (same IP restriction; returns 302 redirect to / for public IPs)
  • Dual protection: private-network IP check plus ADMIN_PASSWORD session cookie. External visitors are redirected away from /admin; internal clients see the login form.

Access matrix

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 /

Session cookie (vrc_admin)

  • HttpOnly, SameSite=Strict, signed HMAC token
  • Secure flag is set only on HTTPS (or when ADMIN_COOKIE_SECURE=1). Plain HTTP LAN access (e.g. http://192.168.x.x:8787/admin) must not use Secure, 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 = never Secure; 1 = always Secure (HTTPS only).

TRUST_PROXY for admin IP checks

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;
}

Public API

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

Reverse proxy

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.

About

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors