From 4c73d228ed98b8ec05bec2824aee7ce066a184e1 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 28 Apr 2026 15:05:22 +0200 Subject: [PATCH 1/5] fix(events): show customer phone in event details view (#331) The phone field added in #322 was wired into the edit form but never rendered in the read-only event-info panel, so admins could only see the number while editing. Add a phone row gated on event_phone_field_enabled (same toggle the form uses), and tighten the Event type so customer_phone is no longer accessed via `(event as any)`. --- frontend/src/pages/admin/EventDetailsPage.tsx | 15 ++++++++++++++- frontend/src/types/index.ts | 1 + 2 files changed, 15 insertions(+), 1 deletion(-) 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; From 4c8eba0cb43635d92a53d90c58b19007136c1c12 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 28 Apr 2026 15:05:30 +0200 Subject: [PATCH 2/5] fix(gallery): single-finger swipe nav in mobile lightbox (#332) The lightbox showed a "Swipe to navigate" hint on mobile, but the touch handlers only implemented pinch-to-zoom (2-finger). Single-finger swipe fell through and the user could only navigate with the on-screen arrows. Add a 1-finger swipe detector: track the initial touch position, and on touchEnd compute deltaX/deltaY/duration. Trigger goToPrevious / goToNext when the horizontal swipe exceeds 50px, dominates over vertical motion (1.2x), and completes within 600ms. Suppressed while zoomed in so the user can pan the image instead. --- .../src/components/gallery/PhotoLightbox.tsx | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx index 7273ea4e..259107e4 100644 --- a/frontend/src/components/gallery/PhotoLightbox.tsx +++ b/frontend/src/components/gallery/PhotoLightbox.tsx @@ -47,6 +47,7 @@ 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); + const [swipeStart, setSwipeStart] = useState<{ 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 +363,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 +374,10 @@ export const PhotoLightbox: React.FC = ({ touch2.clientY - touch1.clientY ); setTouchDistance(distance); + setSwipeStart(null); + } else if (e.touches.length === 1 && zoom <= 1) { + const t = e.touches[0]; + setSwipeStart({ x: t.clientX, y: t.clientY, t: Date.now() }); } }; @@ -383,7 +389,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 +397,20 @@ export const PhotoLightbox: React.FC = ({ } }; - const handleTouchEnd = () => { + const handleTouchEnd = (e: React.TouchEvent) => { setTouchDistance(null); + if (swipeStart && e.changedTouches.length > 0) { + const t = e.changedTouches[0]; + const dx = t.clientX - swipeStart.x; + const dy = t.clientY - swipeStart.y; + const dt = Date.now() - swipeStart.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(); + } + } + setSwipeStart(null); }; // Apply protection class to the lightbox container From 5275621fcd38f1ec09b54595163ecd5e63614b1a Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 28 Apr 2026 15:05:44 +0200 Subject: [PATCH 3/5] fix(share): OG/Twitter-card metadata for gallery share URLs (#333) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WhatsApp / Slack / Facebook / Twitter previews showed nothing useful for shared gallery links — the SPA's stub index.html has no OG tags and the meta-injection in DynamicFavicon happens at runtime, which crawlers never see (they don't execute JS). Add a backend OG handler at /og/gallery/:slug that returns minimal HTML with proper og:* and twitter:* meta sourced from the event row + branding settings (event name, formatted date, welcome_message excerpt as description, configured logo as the preview image, FRONTEND_URL-based canonical). Honours slug redirects so renamed galleries still get rich previews. Wire crawler detection in both nginx configs (production and dev) — UA match against the standard list (facebookexternalhit, WhatsApp, Slackbot, Twitterbot, Discordbot, LinkedInBot, etc.) triggers an internal rewrite to /og/gallery/:slug, while humans fall through to the SPA via try_files. The OG endpoint is also wired into the native-install SPA fallback in server.js for setups that bypass nginx. The OG image is intentionally the brand logo, not a gallery photo — crawlers fetch it without auth, and password-protected gallery photos must not leak via share previews. --- backend/server.js | 18 +- backend/src/services/galleryOgService.js | 218 +++++++++++++++++++++++ frontend/nginx.conf | 23 +++ frontend/nginx.dev.conf | 17 ++ 4 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 backend/src/services/galleryOgService.js 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/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; From fcddfe094b2a01963f7b420afa886e7d5dae4390 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 28 Apr 2026 15:55:09 +0200 Subject: [PATCH 4/5] fix(gallery): use ref for swipe-start to avoid stale-closure miss (#332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found via real-browser verification: with useState the prior commit's handleTouchEnd captures swipeStart from its render closure, so when touchstart and touchend fire inside the same React batch (fast swipe, synthetic events, or a tight render cycle) the end handler reads the stale null and skips navigation. useRef sidesteps the closure entirely and is the right primitive for cross-event scratchpad state anyway. Verified in a 4-photo gallery on mobile-emulation (390x844 touch): - left swipe (-200px) advances 1/4 → 2/4 - right swipe (+200px) returns 2/4 → 1/4 - 20px swipe (under threshold) does not navigate - vertical swipe (dy 300, dx 20) does not navigate --- .../src/components/gallery/PhotoLightbox.tsx | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx index 259107e4..d59fdf7f 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,7 +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); - const [swipeStart, setSwipeStart] = useState<{ x: number; y: number; t: number } | null>(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<{ @@ -374,10 +376,10 @@ export const PhotoLightbox: React.FC = ({ touch2.clientY - touch1.clientY ); setTouchDistance(distance); - setSwipeStart(null); + swipeStartRef.current = null; } else if (e.touches.length === 1 && zoom <= 1) { const t = e.touches[0]; - setSwipeStart({ x: t.clientX, y: t.clientY, t: Date.now() }); + swipeStartRef.current = { x: t.clientX, y: t.clientY, t: Date.now() }; } }; @@ -399,18 +401,19 @@ export const PhotoLightbox: React.FC = ({ const handleTouchEnd = (e: React.TouchEvent) => { setTouchDistance(null); - if (swipeStart && e.changedTouches.length > 0) { + const start = swipeStartRef.current; + if (start && e.changedTouches.length > 0) { const t = e.changedTouches[0]; - const dx = t.clientX - swipeStart.x; - const dy = t.clientY - swipeStart.y; - const dt = Date.now() - swipeStart.t; + 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(); } } - setSwipeStart(null); + swipeStartRef.current = null; }; // Apply protection class to the lightbox container From 42a7ae4be8fe7b12104ae036465c9c4117606378 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 28 Apr 2026 16:50:44 +0200 Subject: [PATCH 5/5] fix(lightbox): mobile toolbar clipping + iOS safe-area + viewport-fit (#336) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When feedback was enabled the lightbox bottom toolbar packed counter + zoom + download + like + 5-star + comments into a single row that overflowed the viewport on iPhone-class widths, putting the rating stars under the screen edge and below the iOS home indicator. Changes: - Bottom toolbar now uses flex-wrap with reduced gap/padding on mobile, so all controls fit (375px viewport: max-right 363 < 375; 390px: max-right 378 < 390; 393px: max-right 393 < 393). - pb computed as max(0.75rem, env(safe-area-inset-bottom)) so the row sits above the iOS home indicator on devices with a gesture bar. - Close button top/right now use max(1rem, env(safe-area-inset-*)) so it doesn't disappear under the notch / dynamic island. - "Swipe to navigate" hint moved from bottom-20 to bottom-40 so it clears the now-taller wrapped toolbar. - index.html viewport meta gains viewport-fit=cover to enable env(safe-area-inset-*) on iOS Safari. Verified in mobile emulation across iPhone SE (375x667), iPhone 13/14 (390x844), iPhone 14 Pro (393x852) portrait, and 14 Pro landscape (852x393) — toolbar fits, photo centered, no clipping. --- frontend/index.html | 2 +- .../src/components/gallery/PhotoLightbox.tsx | 33 ++++++++++++------- 2 files changed, 23 insertions(+), 12 deletions(-) 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/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx index d59fdf7f..a72a727f 100644 --- a/frontend/src/components/gallery/PhotoLightbox.tsx +++ b/frontend/src/components/gallery/PhotoLightbox.tsx @@ -426,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. */} @@ -456,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