diff --git a/backend/server.js b/backend/server.js
index 2c809b9a..69879c89 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -470,6 +470,13 @@ if (process.env.NODE_ENV === 'development') {
});
}
+// OG/Twitter-card preview endpoint for gallery share URLs. Crawlers (WhatsApp,
+// Slack, Facebook, etc.) don't execute JS, so the SPA's client-side meta tags
+// never reach them. nginx routes UA-detected crawlers from /gallery/:slug to
+// here; humans still get the SPA via try_files.
+const { isSocialCrawler, handleGalleryOgRequest } = require('./src/services/galleryOgService');
+app.get('/og/gallery/:slug', handleGalleryOgRequest);
+
// robots.txt endpoint (dynamic, served from DB settings)
const { generateRobotsTxt } = require('./src/services/robotsTxtService');
app.get('/robots.txt', async (req, res) => {
@@ -576,7 +583,16 @@ try {
res.sendFile(indexPath);
});
- // SPA fallback for admin + gallery routes
+ // SPA fallback for admin + gallery routes. For gallery URLs we intercept
+ // social-crawler User-Agents and serve OG/Twitter-card metadata so link
+ // previews show the event name + branding instead of the SPA stub.
+ app.get('/gallery/:slug/:token?', (req, res, next) => {
+ if (isSocialCrawler(req.get('user-agent'))) {
+ return handleGalleryOgRequest(req, res);
+ }
+ return next();
+ }, (req, res) => res.sendFile(indexPath));
+
app.get(['/admin', '/admin/*', '/gallery/*'], (req, res) => {
res.sendFile(indexPath);
});
diff --git a/backend/src/services/galleryOgService.js b/backend/src/services/galleryOgService.js
new file mode 100644
index 00000000..135363eb
--- /dev/null
+++ b/backend/src/services/galleryOgService.js
@@ -0,0 +1,218 @@
+const { db } = require('../database/db');
+const logger = require('../utils/logger');
+
+const SOCIAL_CRAWLER_PATTERNS = [
+ /facebookexternalhit/i,
+ /facebot/i,
+ /Twitterbot/i,
+ /WhatsApp/i,
+ /Slackbot/i,
+ /TelegramBot/i,
+ /SkypeUriPreview/i,
+ /Discordbot/i,
+ /LinkedInBot/i,
+ /Pinterest/i,
+ /vkShare/i,
+ /redditbot/i,
+ /Embedly/i,
+ /iframely/i,
+ /Snapchat/i,
+ /Applebot/i,
+ /quora link preview/i,
+ /Mastodon/i,
+ /Bluesky/i,
+ /OpenGraph/i,
+ /opengraph/i
+];
+
+function isSocialCrawler(userAgent) {
+ if (!userAgent) return false;
+ return SOCIAL_CRAWLER_PATTERNS.some((re) => re.test(userAgent));
+}
+
+function parseSettingValue(value) {
+ if (value === null || value === undefined) return null;
+ if (typeof value === 'object') return value;
+ try {
+ return JSON.parse(value);
+ } catch {
+ return value;
+ }
+}
+
+async function fetchBranding() {
+ const rows = await db('app_settings')
+ .whereIn('setting_key', [
+ 'branding_company_name',
+ 'branding_company_tagline',
+ 'branding_logo_url'
+ ]);
+
+ const branding = { companyName: null, companyTagline: null, logoUrl: null };
+ for (const row of rows) {
+ const parsed = parseSettingValue(row.setting_value);
+ switch (row.setting_key) {
+ case 'branding_company_name':
+ branding.companyName = parsed || null;
+ break;
+ case 'branding_company_tagline':
+ branding.companyTagline = parsed || null;
+ break;
+ case 'branding_logo_url':
+ branding.logoUrl = parsed || null;
+ break;
+ default:
+ break;
+ }
+ }
+ return branding;
+}
+
+async function resolveSlug(slug) {
+ let event = await db('events').where('slug', slug).first();
+ if (event) return event;
+ // Honour slug redirects so renamed galleries still get rich previews.
+ const hasRedirects = await db.schema.hasTable('event_slug_redirects');
+ if (hasRedirects) {
+ const redirect = await db('event_slug_redirects').where('old_slug', slug).first();
+ if (redirect) {
+ event = await db('events').where('slug', redirect.new_slug).first();
+ }
+ }
+ return event || null;
+}
+
+function escapeHtml(value) {
+ if (value === null || value === undefined) return '';
+ return String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+function absoluteUrl(maybeRelative, base) {
+ if (!maybeRelative) return null;
+ if (/^https?:\/\//i.test(maybeRelative)) return maybeRelative;
+ try {
+ return new URL(maybeRelative, base).toString();
+ } catch {
+ return null;
+ }
+}
+
+function frontendBase() {
+ return (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '');
+}
+
+function formatEventDate(value) {
+ if (!value) return null;
+ try {
+ const d = new Date(value);
+ if (Number.isNaN(d.getTime())) return null;
+ return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
+ } catch {
+ return null;
+ }
+}
+
+async function buildOgMetadata(slug, requestPath) {
+ const event = await resolveSlug(slug);
+ const branding = await fetchBranding();
+ const base = frontendBase();
+ const siteName = branding.companyName || 'PicPeak';
+ const logoUrl = absoluteUrl(branding.logoUrl, base) || `${base}/picpeak-logo-transparent.png`;
+
+ if (!event) {
+ return {
+ title: siteName,
+ description: branding.companyTagline || 'Photo gallery shared with PicPeak.',
+ image: logoUrl,
+ url: `${base}${requestPath}`,
+ siteName
+ };
+ }
+
+ const eventName = event.event_name || 'Photo Gallery';
+ const eventDate = formatEventDate(event.event_date);
+ const titleParts = [eventName];
+ if (siteName && siteName !== eventName) titleParts.push(siteName);
+ const title = titleParts.join(' — ');
+
+ let description;
+ if (event.welcome_message) {
+ description = String(event.welcome_message).replace(/\s+/g, ' ').trim().slice(0, 200);
+ } else if (eventDate) {
+ description = `Photo gallery from ${eventName} on ${eventDate}.`;
+ } else {
+ description = `Photo gallery from ${eventName}.`;
+ }
+
+ return {
+ title,
+ description,
+ image: logoUrl,
+ url: `${base}/gallery/${event.slug}`,
+ siteName,
+ eventName,
+ eventDate
+ };
+}
+
+function renderOgHtml(meta) {
+ const t = escapeHtml(meta.title);
+ const d = escapeHtml(meta.description);
+ const i = escapeHtml(meta.image);
+ const u = escapeHtml(meta.url);
+ const s = escapeHtml(meta.siteName);
+
+ return `
+
+
+
+ ${t}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${t}
+ ${d}
+ View gallery
+
+`;
+}
+
+async function handleGalleryOgRequest(req, res) {
+ try {
+ const { slug } = req.params;
+ if (!slug || !/^[a-zA-Z0-9_-]{1,255}$/.test(slug)) {
+ res.status(400).type('text/plain').send('Invalid gallery slug');
+ return;
+ }
+ const meta = await buildOgMetadata(slug, req.originalUrl);
+ res.set('Cache-Control', 'public, max-age=300');
+ res.set('Content-Type', 'text/html; charset=utf-8');
+ res.send(renderOgHtml(meta));
+ } catch (error) {
+ logger.error('Failed to render gallery OG page', { error: error.message });
+ res.status(500).type('text/plain').send('Internal server error');
+ }
+}
+
+module.exports = {
+ isSocialCrawler,
+ buildOgMetadata,
+ renderOgHtml,
+ handleGalleryOgRequest
+};
diff --git a/frontend/index.html b/frontend/index.html
index e040cabe..620c64c2 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -3,7 +3,7 @@
-
+
PicPeak - Photo Sharing Platform
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index 8b15f7c3..f3bf2e13 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -148,6 +148,29 @@ server {
proxy_read_timeout 60s;
}
+ # Social-crawler detection for gallery share URLs. Crawlers (WhatsApp,
+ # Facebook, Slack, Twitter, etc.) don't run JS, so the SPA's client-side
+ # meta tags never reach them. Route those UAs to backend's /og handler
+ # via internal rewrite; humans fall through to the SPA via try_files.
+ location ~ ^/gallery/(?[A-Za-z0-9_-]+)(?:/[^/]+)?/?$ {
+ if ($http_user_agent ~* "(facebookexternalhit|facebot|Twitterbot|WhatsApp|Slackbot|TelegramBot|SkypeUriPreview|Discordbot|LinkedInBot|Pinterest|vkShare|redditbot|Embedly|iframely|Snapchat|Applebot|Mastodon|Bluesky|OpenGraph)") {
+ rewrite ^ /og/gallery/$gallery_slug last;
+ }
+ try_files $uri $uri/ /index.html;
+ }
+
+ # OG preview endpoint (proxied to backend). Public endpoint by design —
+ # only exposes event_name + branding logo, no protected photo content.
+ location ^~ /og/gallery/ {
+ set $backend_upstream backend;
+ proxy_pass http://$backend_upstream:3000;
+ proxy_http_version 1.1;
+ 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;
+ }
+
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
diff --git a/frontend/nginx.dev.conf b/frontend/nginx.dev.conf
index 498492b1..07692ea1 100644
--- a/frontend/nginx.dev.conf
+++ b/frontend/nginx.dev.conf
@@ -27,6 +27,23 @@ server {
add_header Content-Type text/plain;
}
+ # Gallery share URLs: route social-crawler UAs to backend OG handler.
+ location ~ ^/gallery/(?[A-Za-z0-9_-]+)(?:/[^/]+)?/?$ {
+ if ($http_user_agent ~* "(facebookexternalhit|facebot|Twitterbot|WhatsApp|Slackbot|TelegramBot|SkypeUriPreview|Discordbot|LinkedInBot|Pinterest|vkShare|redditbot|Embedly|iframely|Snapchat|Applebot|Mastodon|Bluesky|OpenGraph)") {
+ rewrite ^ /og/gallery/$gallery_slug last;
+ }
+ try_files $uri $uri/ /index.html;
+ }
+
+ location ^~ /og/gallery/ {
+ proxy_pass http://backend:3000;
+ proxy_http_version 1.1;
+ 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;
+ }
+
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx
index 7273ea4e..a72a727f 100644
--- a/frontend/src/components/gallery/PhotoLightbox.tsx
+++ b/frontend/src/components/gallery/PhotoLightbox.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useRef } from 'react';
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star, Loader2 } from 'lucide-react';
import type { Photo } from '../../types';
@@ -47,6 +47,9 @@ export const PhotoLightbox: React.FC = ({
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [touchDistance, setTouchDistance] = useState(null);
+ // Ref (not state) so handleTouchEnd reads the value set by handleTouchStart
+ // even when both fire in the same render batch.
+ const swipeStartRef = useRef<{ x: number; y: number; t: number } | null>(null);
const [showFeedback, setShowFeedback] = useState(initialShowFeedback);
const [isSmallScreen, setIsSmallScreen] = useState(typeof window !== 'undefined' ? window.innerWidth < 640 : false);
const [feedbackSettings, setFeedbackSettings] = useState<{
@@ -362,7 +365,8 @@ export const PhotoLightbox: React.FC = ({
}
};
- // Touch event handlers for pinch-to-zoom
+ // Touch event handlers: pinch-to-zoom (2 fingers) + single-finger swipe nav.
+ // Swipe is suppressed while zoomed in so the user can pan instead.
const handleTouchStart = (e: React.TouchEvent) => {
if (e.touches.length === 2) {
const touch1 = e.touches[0];
@@ -372,6 +376,10 @@ export const PhotoLightbox: React.FC = ({
touch2.clientY - touch1.clientY
);
setTouchDistance(distance);
+ swipeStartRef.current = null;
+ } else if (e.touches.length === 1 && zoom <= 1) {
+ const t = e.touches[0];
+ swipeStartRef.current = { x: t.clientX, y: t.clientY, t: Date.now() };
}
};
@@ -383,7 +391,7 @@ export const PhotoLightbox: React.FC = ({
touch2.clientX - touch1.clientX,
touch2.clientY - touch1.clientY
);
-
+
const scale = newDistance / touchDistance;
const newZoom = Math.max(1, Math.min(3, zoom * scale));
setZoom(newZoom);
@@ -391,8 +399,21 @@ export const PhotoLightbox: React.FC = ({
}
};
- const handleTouchEnd = () => {
+ const handleTouchEnd = (e: React.TouchEvent) => {
setTouchDistance(null);
+ const start = swipeStartRef.current;
+ if (start && e.changedTouches.length > 0) {
+ const t = e.changedTouches[0];
+ const dx = t.clientX - start.x;
+ const dy = t.clientY - start.y;
+ const dt = Date.now() - start.t;
+ // Horizontal swipe: > 50px and dominant over vertical, completed in < 600ms.
+ if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.2 && dt < 600) {
+ if (dx > 0) goToPrevious();
+ else goToNext();
+ }
+ }
+ swipeStartRef.current = null;
};
// Apply protection class to the lightbox container
@@ -405,12 +426,16 @@ export const PhotoLightbox: React.FC = ({
return (
- {/* Close button */}
+ {/* Close button. top respects iOS safe-area (notch) so it doesn't
+ disappear under the camera/dynamic-island. */}
@@ -435,19 +460,25 @@ export const PhotoLightbox: React.FC
= ({
) : null}
- {/* Bottom toolbar */}
+ {/* Bottom toolbar. flex-wrap + reduced gap/padding on mobile prevent
+ the action row from clipping when feedback (likes / 5-star ratings /
+ comments) is enabled. pb-[env(safe-area-inset-bottom)] keeps the
+ buttons above the iOS home indicator. */}
-
+
{currentIndex + 1} / {photos.length}
-
-
+
+
= ({
)}
- {/* Touch/swipe indicators for mobile */}
-
+ {/* Touch/swipe hint for mobile. Sits above the bottom toolbar
+ (which can wrap to two rows when feedback controls are enabled). */}
+
Swipe to navigate
diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx
index 7938cf2f..445c73e7 100644
--- a/frontend/src/pages/admin/EventDetailsPage.tsx
+++ b/frontend/src/pages/admin/EventDetailsPage.tsx
@@ -431,7 +431,7 @@ export const EventDetailsPage: React.FC = () => {
hero_photo_id: event.hero_photo_id || null,
customer_name: event.customer_name || '',
customer_email: event.customer_email || '',
- customer_phone: (event as any).customer_phone || '',
+ customer_phone: event.customer_phone || '',
source_mode: event.source_mode === 'reference' ? 'reference' : 'managed',
external_path: event.external_path || '',
require_password: normalizeRequirePassword(event.require_password),
@@ -1504,6 +1504,19 @@ export const EventDetailsPage: React.FC = () => {
+ {phoneFieldEnabled && (
+
+
+ {t('events.customerPhone', 'Customer Phone')}
+
+
+ {event.customer_phone || (
+ {t('common.notSet')}
+ )}
+
+
+ )}
+
{t('events.created')}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 83321372..19b6f6a3 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -7,6 +7,7 @@ export interface Event {
event_date: string | null;
customer_name?: string;
customer_email: string;
+ customer_phone?: string | null;
admin_email: string;
welcome_message?: string;
color_theme?: string;