Merge pull request #542 from the-luap/fix/recover-orphaned-527

fix: recover three orphaned commits from #527 (BRAND_TITLE runtime, Web Share, pan zoom)
This commit is contained in:
Paul Nothaft
2026-05-21 10:23:59 +02:00
committed by GitHub
12 changed files with 255 additions and 83 deletions
+10
View File
@@ -68,6 +68,16 @@ [email protected]
FRONTEND_URL=https://yourdomain.com
ADMIN_URL=https://yourdomain.com
# Static HTML title + description used for social link previews when the
# fetcher doesn't trigger the per-event OG endpoint — most notably the
# WhatsApp Business API and various 3rd-party preview-service caches
# (#521). Set these to your brand so link previews aren't generic.
# Substituted into index.html at frontend-container start, so changes
# take effect on the next `docker compose up -d frontend` — no rebuild
# required.
BRAND_TITLE=PicPeak
BRAND_DESCRIPTION=Photo gallery shared with PicPeak.
# API URL for email assets (logos, images in notification emails)
# This must be the publicly accessible URL where email recipients can load images.
# If not set, defaults to http://localhost:3001 which will show broken images in emails.
+10
View File
@@ -88,6 +88,16 @@ services:
container_name: picpeak-frontend
# Note: Pre-built frontend uses Nginx to proxy /api to backend:3001.
# Prefer keeping API base as '/api' in builds to avoid CORS.
environment:
# Substituted into index.html at container start (see frontend/
# docker-entrypoint.sh) so social link previews reaching the
# static SPA shell (WhatsApp Business API, Twilio, LinkPreview,
# etc. — see #521) show the configured brand instead of the
# generic "PicPeak" default. Defaults applied when unset; restart
# the frontend container after changing for the new title to
# take effect.
- BRAND_TITLE=${BRAND_TITLE:-PicPeak}
- BRAND_DESCRIPTION=${BRAND_DESCRIPTION:-Photo gallery shared with PicPeak.}
ports:
- "${FRONTEND_PORT:-3000}:80"
networks:
+4
View File
@@ -121,6 +121,10 @@ services:
restart: unless-stopped
environment:
- NODE_ENV=${NODE_ENV:-production}
# Static social-preview brand (#521) — substituted into
# index.html at container start; see frontend/docker-entrypoint.sh.
- BRAND_TITLE=${BRAND_TITLE:-PicPeak}
- BRAND_DESCRIPTION=${BRAND_DESCRIPTION:-Photo gallery shared with PicPeak.}
ports:
- "${FRONTEND_PORT:-3000}:80"
depends_on:
-16
View File
@@ -1,19 +1,3 @@
# Static HTML fallback for social-link previews (#521).
#
# Most link previews (WhatsApp, Facebook, Slack, etc.) hit the backend's
# per-event OG endpoint and get the actual event name + branding. Some
# third-party preview services and the WhatsApp Business API cache
# metadata with a non-crawler User-Agent and end up reading these static
# values instead. Set these to your brand so that fallback isn't generic
# "PicPeak - Photo Sharing Platform".
#
# These are baked into index.html at build time, so they take effect on
# the next `npm run build` / docker build. Live admin Branding settings
# do NOT propagate here — for that, use the per-event OG endpoint, which
# always serves the live branded preview.
VITE_DEFAULT_TITLE=PicPeak
VITE_DEFAULT_DESCRIPTION=Photo gallery shared with PicPeak.
# Backend API URL
# For local development with Docker:
VITE_API_URL=http://localhost:3001/api
-6
View File
@@ -1,12 +1,6 @@
# Production Environment Configuration
# When running behind a reverse proxy like Traefik, use relative URLs
# Static HTML fallback for social-link previews (#521).
# Override these with your brand so previews that hit the static
# index.html (vs the per-event OG endpoint) aren't generic.
VITE_DEFAULT_TITLE=PicPeak
VITE_DEFAULT_DESCRIPTION=Photo gallery shared with PicPeak.
# Backend API URL
# For production behind reverse proxy, use relative URL:
VITE_API_URL=/api
+19 -3
View File
@@ -33,8 +33,11 @@ FROM nginx:1.28-alpine
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
RUN apk upgrade --no-cache
# Install runtime dependencies
RUN apk add --no-cache curl
# Install runtime dependencies. `gettext` provides envsubst, used by
# docker-entrypoint.sh for the BRAND_TITLE / BRAND_DESCRIPTION runtime
# substitution into index.html (#521 — runtime fix for self-hosters
# on the pre-built GHCR image who can't override at build time).
RUN apk add --no-cache curl gettext
# Remove default nginx config
RUN rm -rf /etc/nginx/conf.d/*
@@ -45,6 +48,16 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built application from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Snapshot index.html as a template so the entrypoint always renders
# from a known-good source — not from its own previous substitution.
# Container restarts can change BRAND_TITLE freely; the rendered file
# is recomputed from the .tpl each time.
RUN mv /usr/share/nginx/html/index.html /usr/share/nginx/html/index.html.tpl
# Runtime entrypoint that envsubsts the template and execs nginx
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Set permissions (nginx user already exists in nginx:alpine)
RUN chown -R nginx:nginx /usr/share/nginx/html && \
chown -R nginx:nginx /var/cache/nginx && \
@@ -62,5 +75,8 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
# Switch to non-root user
USER nginx
# Start nginx
# Start nginx via the entrypoint so each container start re-renders
# index.html from the template against the current BRAND_TITLE /
# BRAND_DESCRIPTION env vars (defaults applied when unset).
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
+40
View File
@@ -0,0 +1,40 @@
#!/bin/sh
# Frontend container entrypoint (#521).
#
# Renders /usr/share/nginx/html/index.html from a build-time .tpl
# snapshot, substituting BRAND_TITLE / BRAND_DESCRIPTION env vars into
# the static HTML head. This is what self-hosters running the pre-built
# GHCR image use to brand their link-preview fallback — see the matching
# comment in frontend/index.html for the three-path architecture
# (per-event OG endpoint, crawler-detected SPA shell, and this static
# fallback that catches WhatsApp Business / Twilio / LinkPreview).
#
# Re-runs on every container start. The .tpl is the immutable source so
# changing BRAND_TITLE in compose env and `docker compose up -d frontend`
# is enough — no rebuild required.
#
# Locked to BRAND_TITLE + BRAND_DESCRIPTION explicitly (rather than
# letting envsubst expand every ${...} it finds) so the JS bundle's
# template literals in /assets/*.js stay untouched if anyone ever
# accidentally points the substitution at them.
set -eu
: "${BRAND_TITLE:=PicPeak}"
: "${BRAND_DESCRIPTION:=Photo gallery shared with PicPeak.}"
export BRAND_TITLE BRAND_DESCRIPTION
TEMPLATE=/usr/share/nginx/html/index.html.tpl
RENDERED=/usr/share/nginx/html/index.html
if [ -f "$TEMPLATE" ]; then
envsubst '${BRAND_TITLE} ${BRAND_DESCRIPTION}' < "$TEMPLATE" > "$RENDERED"
else
# Template missing — image build skipped the .tpl rename for some
# reason. Don't crash: nginx can still serve whatever is at
# $RENDERED (probably the unsubstituted output of `npm run build`).
# Log loudly so it's visible during boot.
echo "[frontend-entrypoint] WARN: $TEMPLATE missing; serving $RENDERED as-is." >&2
fi
exec "$@"
+17 -11
View File
@@ -17,20 +17,26 @@
title to "PicPeak - Photo Sharing Platform" left every such
preview looking unbranded for self-hosted installs.
Self-hosters set VITE_DEFAULT_TITLE / VITE_DEFAULT_DESCRIPTION
at build time (see .env.example) to bake their brand into this
fallback. Default values keep the upstream-image behaviour for
anyone who doesn't override them.
${BRAND_TITLE} / ${BRAND_DESCRIPTION} are replaced at *container
start* by the frontend image's docker-entrypoint.sh (envsubst on
an index.html.tpl snapshot taken at image build). That keeps the
tokens working even for self-hosters running the pre-built GHCR
image — set BRAND_TITLE in compose env and the next container
restart picks it up. No frontend rebuild needed.
Vite dev server doesn't run the entrypoint, so dev mode shows the
literal tokens in the tab title — acceptable since dev sessions
don't care about social previews.
-->
<title>%VITE_DEFAULT_TITLE%</title>
<meta name="description" content="%VITE_DEFAULT_DESCRIPTION%" />
<title>${BRAND_TITLE}</title>
<meta name="description" content="${BRAND_DESCRIPTION}" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="%VITE_DEFAULT_TITLE%" />
<meta property="og:title" content="%VITE_DEFAULT_TITLE%" />
<meta property="og:description" content="%VITE_DEFAULT_DESCRIPTION%" />
<meta property="og:site_name" content="${BRAND_TITLE}" />
<meta property="og:title" content="${BRAND_TITLE}" />
<meta property="og:description" content="${BRAND_DESCRIPTION}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="%VITE_DEFAULT_TITLE%" />
<meta name="twitter:description" content="%VITE_DEFAULT_DESCRIPTION%" />
<meta name="twitter:title" content="${BRAND_TITLE}" />
<meta name="twitter:description" content="${BRAND_DESCRIPTION}" />
<!-- Pre-React theme bootstrap (#358).
The browser may paint the very first frame before our inline
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react';
import type { Photo } from '../../types';
import { useDownloadPhoto } from '../../hooks/useGallery';
import { useSavePhotoToDevice } from '../../hooks/useGallery';
import { AuthenticatedImage } from '../common';
import { PhotoFeedback } from './PhotoFeedback';
import { feedbackService } from '../../services/feedback.service';
@@ -99,7 +99,12 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
}, []);
const downloadPhotoMutation = useDownloadPhoto();
// Save-aware download. On mobile (where Web Share + files is supported)
// this opens the OS share sheet so "Save to Photos" actually lands in
// the Photos/Gallery app — matters for non-technical clients who
// otherwise have to chain Files → unzip → save (#531). Desktop and
// unsupported browsers fall through to a regular <a download>.
const downloadPhotoMutation = useSavePhotoToDevice();
const currentPhoto = photos[currentIndex];
// DevTools protection - enabled by individual setting OR legacy protection level
@@ -406,6 +411,16 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
setDragX(0);
}
}
} else if (e.touches.length === 1 && zoom > 1) {
// Single-finger pan when zoomed in (#532). Mirrors the desktop
// handleMouseDown path so mobile users can drag a zoomed image
// around instead of being stuck looking at the centre crop.
// Carousel swipe is disabled in this branch — when zoom > 1 the
// gesture has to mean "pan", not "next photo", or zoomed nav
// becomes unusable.
const t = e.touches[0];
setIsDragging(true);
setDragStart({ x: t.clientX - dragOffset.x, y: t.clientY - dragOffset.y });
} else if (e.touches.length === 1 && zoom <= 1 && (phase === 'idle' || phase === 'dragging')) {
const t = e.touches[0];
swipeStartRef.current = { x: t.clientX, y: t.clientY, t: Date.now() };
@@ -427,6 +442,24 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
const newZoom = Math.max(1, Math.min(3, zoom * scale));
setZoom(newZoom);
setTouchDistance(newDistance);
// Pinch-out back down to 1.0 has to re-centre the image — without
// this the previous pan offset persists and the photo sits off-
// centre at the natural zoom level (#532 follow-on).
if (newZoom <= 1 && (dragOffset.x !== 0 || dragOffset.y !== 0)) {
setDragOffset({ x: 0, y: 0 });
}
return;
}
if (isDragging && zoom > 1 && e.touches.length === 1) {
// Single-finger pan when zoomed (#532). Touch counterpart to
// handleMouseMove. Same dragOffset state so the transform on the
// <img> stays consistent across input modalities.
const t = e.touches[0];
setDragOffset({
x: t.clientX - dragStart.x,
y: t.clientY - dragStart.y,
});
return;
}
@@ -454,6 +487,10 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
const handleTouchEnd = (e: React.TouchEvent) => {
setTouchDistance(null);
// Release single-finger pan state (#532). The pan offset itself
// persists so the image stays where the user left it — only the
// "actively dragging" flag clears.
if (isDragging) setIsDragging(false);
const start = swipeStartRef.current;
if (phase === 'dragging' && start && e.changedTouches.length > 0) {
const t = e.changedTouches[0];
+29
View File
@@ -65,6 +65,35 @@ export const useDownloadPhoto = () => {
});
};
// Save-aware download — opens the OS share sheet on mobile (so "Save to
// Photos" lands the file in the Photos/Gallery app instead of Files),
// falls back to a regular download on browsers without Web Share file
// support. See galleryService.savePhotoToDevice for the negotiation
// (#531).
//
// Toast omitted on success because the share-sheet path doesn't really
// finish from this code's perspective — the OS UI takes over and the
// user picks where it goes. Showing "Photo downloaded" before they've
// even picked is misleading. The fallback download path is also silent
// to keep the two paths symmetrical; the file appearing in Downloads
// is its own affordance.
export const useSavePhotoToDevice = () => {
return useMutation({
mutationFn: ({
slug,
photoId,
filename,
}: {
slug: string;
photoId: number;
filename: string;
}) => galleryService.savePhotoToDevice(slug, photoId, filename),
onError: () => {
toast.error('Failed to save photo');
},
});
};
export const useDownloadAllPhotos = () => {
return useMutation({
mutationFn: ({ slug, zipReady }: { slug: string; zipReady?: boolean }) =>
+85 -22
View File
@@ -48,43 +48,106 @@ export const galleryService = {
};
},
// Download single photo
async downloadPhoto(slug: string, photoId: number, filename: string): Promise<void> {
// Honour the server's Content-Disposition filename so the #493
// "use original camera filename" toggle reaches disk for single
// downloads (it already worked for zips because those skip the
// `<a download>` attribute). Falls back to the caller-provided
// sanitized filename if the header is unreadable.
const downloadFromResponse = (response: { data: Blob; headers: Record<string, string> }) => {
// Save single photo via the Web Share API on mobile, falling back to a
// regular browser download elsewhere (#531).
//
// On iOS Safari 15+ and Chrome Android the OS share sheet opened by
// navigator.share() includes "Save Image" / "Save to Photos", which
// is what non-technical clients actually want — straight into the
// Photos / Gallery app instead of the Files folder. Desktop browsers
// and Firefox don't implement Web Share File support, so they get the
// existing <a download> path (file lands in Downloads, same as before).
async savePhotoToDevice(slug: string, photoId: number, filename: string): Promise<void> {
const fetched = await this.fetchPhotoBlob(slug, photoId);
const resolvedFilename = fetched.serverFilename || filename;
// canShare() returns false on browsers without Web Share file support
// (desktop, older Safari, all Firefox as of writing). Probe with a
// representative File so the negotiation is accurate — `canShare({
// files: [] })` returns true on some browsers that don't actually
// accept files at share() time.
const file = new File([fetched.blob], resolvedFilename, {
type: fetched.blob.type || 'image/jpeg',
});
const canShareFile =
typeof navigator !== 'undefined' &&
typeof navigator.canShare === 'function' &&
navigator.canShare({ files: [file] });
if (canShareFile) {
try {
await navigator.share({ files: [file], title: resolvedFilename });
return;
} catch (err) {
// AbortError = user dismissed the share sheet. Don't fall back —
// they made a choice. Any other failure (NotAllowedError,
// DataError, etc.) is unexpected; surface a download instead so
// the user still gets the file.
if ((err as DOMException)?.name === 'AbortError') return;
}
}
this.triggerBrowserDownload(fetched.blob, resolvedFilename);
},
// Fetch the photo as a Blob + the server-suggested filename, falling
// back to the view endpoint when the original isn't available. Shared
// between the regular download flow and the Web Share path (#531).
// The server's Content-Disposition is the source of truth for the
// filename (#493 — "use original camera filename" toggle reaches disk
// through this header).
async fetchPhotoBlob(
slug: string,
photoId: number,
): Promise<{ blob: Blob; serverFilename: string | null }> {
const readResponse = (response: { data: Blob; headers: Record<string, string> }) => {
const headerName =
response.headers['content-disposition'] || response.headers['Content-Disposition'];
const serverFilename = parseContentDispositionFilename(headerName);
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', serverFilename || filename);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
return {
blob: response.data,
serverFilename: parseContentDispositionFilename(headerName),
};
};
try {
const response = await api.get(`/gallery/${slug}/download/${photoId}`, {
responseType: 'blob',
});
downloadFromResponse(response);
return readResponse(response);
} catch {
// Fallback: use the view endpoint if direct download fails (e.g., missing original).
// The view endpoint doesn't emit a download-oriented Content-Disposition,
// so we expect the caller-supplied filename to win here.
// Fallback: view endpoint when /download isn't available (e.g.
// the original is missing and only a derivative remains). The
// view endpoint doesn't emit a download-oriented Content-Disposition,
// so serverFilename will be null and the caller's name wins.
const response = await api.get(`/gallery/${slug}/photo/${photoId}`, {
responseType: 'blob',
});
downloadFromResponse(response);
return readResponse(response);
}
},
// Trigger a regular browser download via a transient <a download>
// anchor. Extracted from downloadPhoto so the share-fallback path
// can reuse it without re-fetching the blob.
triggerBrowserDownload(blob: Blob, filename: string): void {
const url = window.URL.createObjectURL(new Blob([blob]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
// Download single photo — kept as the canonical name for the existing
// grid + lightbox-action callers that haven't been migrated to the
// share-aware savePhotoToDevice path yet.
async downloadPhoto(slug: string, photoId: number, filename: string): Promise<void> {
const fetched = await this.fetchPhotoBlob(slug, photoId);
this.triggerBrowserDownload(fetched.blob, fetched.serverFilename || filename);
},
// Download all photos as ZIP
// When a pre-generated zip is available, use native browser download (Content-Length → progress bar).
// Otherwise fall back to blob download.
+2 -23
View File
@@ -1,34 +1,13 @@
/// <reference types="vitest" />
// @ts-nocheck
import { defineConfig, loadEnv } from 'vite'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import type { UserConfig as VitestUserConfig } from 'vitest/config'
// Inject defaults for the %VITE_DEFAULT_TITLE% / %VITE_DEFAULT_DESCRIPTION%
// placeholders in index.html when the env vars aren't set (#521). Without
// this, Vite would leave the literal "%VITE_DEFAULT_TITLE%" string in the
// built HTML, breaking the link-preview fallback we're trying to create.
//
// Self-hosters override by exporting the env vars at build time
// (typical Docker build pattern: --build-arg VITE_DEFAULT_TITLE="My Brand").
function htmlTitleDefaults(mode: string) {
const env = loadEnv(mode, process.cwd(), 'VITE_')
const title = env.VITE_DEFAULT_TITLE || 'PicPeak'
const description = env.VITE_DEFAULT_DESCRIPTION || 'Photo gallery shared with PicPeak.'
return {
name: 'html-title-defaults',
transformIndexHtml(html: string) {
return html
.replaceAll('%VITE_DEFAULT_TITLE%', title)
.replaceAll('%VITE_DEFAULT_DESCRIPTION%', description)
},
}
}
// https://vite.dev/config/
const config: VitestUserConfig = {
plugins: [react(), htmlTitleDefaults(process.env.NODE_ENV || 'production')],
plugins: [react()],
build: {
rollupOptions: {
output: {