chore(release): promote beta → main as v3.42.1

Stable release promoting the entire `beta` channel to `main`. Brings
~300 commits of features, fixes, and infrastructure improvements that
have been baked on the beta channel since v2.6.5.

## Major themes since v2.6.5

* Multi-administrator support with RBAC (super admin / admin / editor)
* Async upload pipeline (background worker pool for sharp/ffmpeg/EXIF/
  watermark/webhooks; bytes-on-wire returns 202)
* Self-hosted webfonts (filesystem-driven scanner; replaces Google Fonts
  CDN; GDPR-compliant)
* 8-token CI palette + force color mode (full theming across admin and
  public site, with WCAG-safe contrast helpers)
* Native multi-arch Docker images (Apple Silicon + ARM64 Linux native)
* Native S3 storage backend (S3 + S3-compatible providers)
* Comprehensive video support (MP4/WebM/MOV upload, stream, play)
* Outbound webhooks for event/photo lifecycle (HMAC-signed)
* Gallery layout overhaul (decoupled header style, banner option,
  theme-aware skeletons, lazy-loaded folder tree picker)
* Multilingual email templates (EN/DE/NL/PT/RU translations table)
* Bulk operations (delete with password gate, archive)
* Photo dimensions backfill (true masonry layout)
* Customer client access (review area before guest share)
* Image security (devtools detection, watermarking, right-click,
  secure thumbnails)

## Notable bug fixes from beta

* `/auth/session` symmetry — three rounds of fixes (#350, #355, #363,
  #398) for the admin-login redirect-loop family
* Email template renderer: handle {{#if}} conditionals, fix CSS leak in
  plain-text fallback, gate publish-from-draft password placeholder,
  gate external_url in public response
* Caller/template variable drift across gallery_created,
  expiration_warning, archive_complete, gallery_expired
* Full-URL gallery_link in all email types (was path-only in 3 sites)
* ffmpeg/ffprobe via apk for Alpine compatibility (was glibc-bundled)
* Admin events search and counters not bounded to first 100 (#346)

## Conflict resolution notes

* `README.md` — kept main's leaner v2.6.5 rewrite (#281); added a
  Contributors section adapted from PR #393.
* `DEPLOYMENT_GUIDE.md` — beta version (more recent, includes External
  Media docs already backported to main).
* `CHANGELOG.md` — new 3.42.1 entry leads, beta's 3.x history follows,
  main's 2.x entries appended below a divider so the historical chain
  is preserved.
* `package.json` (backend + frontend) — beta's structure with version
  bumped from `3.42.1-beta.0` → `3.42.1`.
* `package-lock.json` (backend + frontend) — regenerated via
  `npm install --package-lock-only`.
* `.release-please-manifest.json` — bumped from `2.6.5` → `3.42.1` so
  the next release-please run on main starts from the correct base.

## Pre-flight checks

* Frontend `tsc --noEmit` — clean
* Frontend `vite build` — clean (~3.5s, 2.6 MB main chunk; existing
  warning about chunking, not new)
* Backend `npm test` — pre-existing failures in 6 integration suites
  (DB-fixture-dependent, not regressions)
* Frontend `vitest` — pre-existing failures in
  ThemeCustomizerEnhanced.test.tsx (missing QueryClientProvider after
  PR #390 added useQuery; not a regression of this merge)

The pre-existing test failures are tracked as separate follow-ups and
do not block this release promotion.
This commit is contained in:
Paul Nothaft
2026-05-07 12:47:45 +02:00
328 changed files with 30942 additions and 5787 deletions
+45 -1
View File
@@ -3,8 +3,52 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>PicPeak - Photo Sharing Platform</title>
<!-- Pre-React theme bootstrap (#358).
The browser may paint the very first frame before our inline
<script> below runs, so we set OS-preference defaults via CSS
here in <head> — that gets applied before any paint. The
script then layers a per-gallery cache hit on top when one is
available. Without this CSS, the very first frame on first-
visit dark-OS devices flashed white briefly (see Rekoo-PS's
frame f1 in the issue). -->
<style>
html, body { background-color: #fafafa; }
@media (prefers-color-scheme: dark) {
html, body { background-color: #171717; }
}
/* Smooth out the cache → API theme transition for the rare case
where the cached colour drifts from the freshly fetched theme. */
html { transition: background-color 200ms ease; }
</style>
<script>
/*
* Pre-React theme bootstrap (#358).
*
* The CSS @media block above handles the OS-preference default
* before paint. This script then applies a per-gallery cached
* background (written by ThemeContext on the previous visit) so
* revisits land on the exact theme background from frame one.
*/
(function () {
try {
var m = location.pathname.match(/\/gallery\/([^\/?#]+)/);
var bg = null;
if (m && m[1]) {
bg = localStorage.getItem('gallery-theme-bg-' + decodeURIComponent(m[1]));
}
if (bg) {
var root = document.documentElement;
root.style.backgroundColor = bg;
document.body && (document.body.style.backgroundColor = bg);
root.style.setProperty('--color-background', bg);
}
} catch (e) { /* never block render on a cache miss */ }
})();
</script>
</head>
<body>
<div id="root"></div>
+51
View File
@@ -37,6 +37,11 @@ server {
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
# Re-apply security headers (add_header in location block overrides server-level)
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://www.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https: blob:; connect-src 'self' https://www.google.com https://www.gstatic.com; font-src 'self' https: data:; object-src 'none'; media-src 'self'; frame-src 'self' https://www.google.com" always;
}
# Cache index.html with revalidation
@@ -44,6 +49,11 @@ server {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
# Re-apply security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://www.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https: blob:; connect-src 'self' https://www.google.com https://www.gstatic.com; font-src 'self' https: data:; object-src 'none'; media-src 'self'; frame-src 'self' https://www.google.com" always;
}
# API proxy
@@ -112,6 +122,24 @@ server {
proxy_cache_valid 404 1m;
}
# Self-hosted webfonts proxy (bundled families + admin user additions).
# ^~ modifier stops regex matching, ensuring fonts are proxied to the
# backend (which scans backend/assets/fonts and STORAGE_PATH/fonts) and
# NOT served locally — the .woff2 files do not exist in the frontend image.
location ^~ /fonts {
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;
# Fonts rarely change; cache aggressively (matches backend Cache-Control).
proxy_cache_valid 200 302 7d;
proxy_cache_valid 404 1m;
}
# Dynamic robots.txt served by backend
location = /robots.txt {
set $backend_upstream backend;
@@ -138,6 +166,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/(?<gallery_slug>[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;
+17
View File
@@ -27,6 +27,23 @@ server {
add_header Content-Type text/plain;
}
# Gallery share URLs: route social-crawler UAs to backend OG handler.
location ~ ^/gallery/(?<gallery_slug>[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;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-frontend",
"version": "2.6.2",
"version": "3.42.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "2.6.2",
"version": "3.42.1",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"@tiptap/extension-character-count": "^2.26.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "2.6.5",
"version": "3.42.1",
"type": "module",
"scripts": {
"dev": "vite",
+51 -54
View File
@@ -8,6 +8,7 @@ import { analyticsService } from './services/analytics.service';
import { GalleryAuthProvider, MaintenanceProvider } from './contexts';
import { ThemeProvider } from './contexts/ThemeContext';
import { GalleryPage } from './pages/GalleryPage';
import { ClientAccessPage } from './pages/ClientAccessPage';
import { PreviewPage } from './pages/gallery/PreviewPage';
import { LegalPage } from './pages/public/LegalPage';
import {
@@ -25,14 +26,15 @@ import {
BackupManagement,
CMSPage,
UserManagementPage,
EventTypesPage
EventTypesPage,
WebhookDeliveriesPage
} from './pages/admin';
import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
import { AdminLayout, AdminAuthWrapper } from './components/admin';
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags } from './components/common';
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock } from './components/common';
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
import { GlobalThemeProvider } from './components/GlobalThemeProvider';
import { getApiBaseUrl } from './utils/url';
import { usePublicSettings } from './hooks/usePublicSettings';
// Create a client
const queryClient = new QueryClient({
@@ -44,6 +46,40 @@ const queryClient = new QueryClient({
},
});
// Bootstraps Umami analytics from /public/settings. Lives inside QueryClientProvider
// so it shares the public-settings cache with every other consumer of usePublicSettings.
function AnalyticsBootstrap() {
const { data: settings, isError } = usePublicSettings();
useEffect(() => {
if (!settings && !isError) return;
const envUmamiUrl = import.meta.env.VITE_UMAMI_URL;
const envUmamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
if (settings?.umami_enabled && settings.umami_url && settings.umami_website_id) {
analyticsService.initialize({
websiteId: settings.umami_website_id,
hostUrl: settings.umami_url,
autoTrack: true,
doNotTrack: true,
});
return;
}
if (envUmamiUrl && envUmamiWebsiteId && (isError || settings?.enable_analytics !== false)) {
analyticsService.initialize({
websiteId: envUmamiWebsiteId,
hostUrl: envUmamiUrl,
autoTrack: true,
doNotTrack: true,
});
}
}, [settings, isError]);
return null;
}
function App() {
// Track dark mode for toast theming
const [toastTheme, setToastTheme] = useState<'light' | 'dark'>('light');
@@ -56,60 +92,10 @@ function App() {
return () => observer.disconnect();
}, []);
// Initialize Umami Analytics based on settings
useEffect(() => {
const initializeAnalytics = async () => {
try {
// Fetch public settings to get Umami configuration
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
const settings = await response.json();
// Check if Umami is enabled and configured in backend settings
if (settings.umami_enabled && settings.umami_url && settings.umami_website_id) {
// Use backend configuration
analyticsService.initialize({
websiteId: settings.umami_website_id,
hostUrl: settings.umami_url,
autoTrack: true,
doNotTrack: true
});
} else {
// Fall back to environment variables if backend not configured
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
if (umamiUrl && umamiWebsiteId && settings.enable_analytics !== false) {
analyticsService.initialize({
websiteId: umamiWebsiteId,
hostUrl: umamiUrl,
autoTrack: true,
doNotTrack: true
});
}
}
} catch (error) {
console.error('Failed to fetch settings for analytics:', error);
// Fall back to environment variables on error
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
if (umamiUrl && umamiWebsiteId) {
analyticsService.initialize({
websiteId: umamiWebsiteId,
hostUrl: umamiUrl,
autoTrack: true,
doNotTrack: true
});
}
}
};
initializeAnalytics();
}, []);
return (
<PageErrorBoundary>
<QueryClientProvider client={queryClient}>
<AnalyticsBootstrap />
<MaintenanceProvider>
<ThemeProvider>
<GlobalThemeProvider>
@@ -121,6 +107,11 @@ function App() {
<Routes>
{/* Public gallery routes */}
<Route path="/gallery/preview" element={<PreviewPage />} />
<Route path="/gallery/:slug/client-access" element={
<GalleryAuthProvider>
<ClientAccessPage />
</GalleryAuthProvider>
} />
<Route path="/gallery/:slug/:token?" element={
<GalleryAuthProvider>
<GalleryPage />
@@ -142,6 +133,7 @@ function App() {
<Route path="branding" element={<BrandingPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="event-types" element={<EventTypesPage />} />
<Route path="webhooks/:id/deliveries" element={<WebhookDeliveriesPage />} />
<Route path="backup" element={<BackupManagement />} />
<Route path="cms" element={<CMSPage />} />
<Route path="users" element={<UserManagementPage />} />
@@ -159,6 +151,11 @@ function App() {
{/* Default redirect */}
<Route path="/" element={<Navigate to="/admin/login" replace />} />
{/* Customisable 404 (#324) — caught here for any path that
didn't match. Top-level `/:slug` is consumed above by
LegalPage; this picks up deeper unknown paths. */}
<Route path="*" element={<CMSContentBlock slug="not-found" />} />
</Routes>
</MaintenanceWrapper>
</Router>
@@ -1,7 +1,6 @@
import React, { useEffect, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTheme } from '../contexts/ThemeContext';
import { api } from '../config/api';
import { usePublicSettings } from '../hooks/usePublicSettings';
interface GlobalThemeProviderProps {
children: React.ReactNode;
@@ -10,24 +9,16 @@ interface GlobalThemeProviderProps {
export const GlobalThemeProvider: React.FC<GlobalThemeProviderProps> = ({ children }) => {
const { setTheme } = useTheme();
const themeAppliedRef = useRef(false);
// Fetch public settings including theme config
const { data: settingsData } = useQuery({
queryKey: ['global-theme-settings'],
queryFn: async () => {
const response = await api.get('/public/settings');
return response.data;
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
const { data: settingsData } = usePublicSettings();
// Apply global theme when settings are loaded (but not on gallery pages)
useEffect(() => {
// Skip if we're on a gallery page - gallery pages handle their own themes
const isGalleryPage = window.location.pathname.includes('/gallery/');
if (!themeAppliedRef.current && settingsData?.theme_config && !isGalleryPage) {
themeAppliedRef.current = true;
// Instance-wide force color mode is enforced inside ThemeContext.applyTheme.
setTheme(settingsData.theme_config);
}
}, [settingsData, setTheme]);
+3 -28
View File
@@ -1,38 +1,13 @@
import React, { useEffect } from 'react';
import { AlertTriangle } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { api } from '../config/api';
import { usePublicSettings } from '../hooks/usePublicSettings';
import { buildResourceUrl } from '../utils/url';
interface BrandingSettings {
branding_company_name?: string;
branding_company_tagline?: string;
branding_support_email?: string;
branding_footer_text?: string;
branding_favicon_url?: string;
branding_logo_url?: string;
default_language?: string;
}
export const MaintenanceMode: React.FC = () => {
const { t, i18n } = useTranslation();
// Fetch branding settings
const { data: settings } = useQuery<BrandingSettings>({
queryKey: ['public-settings-maintenance'],
queryFn: async () => {
try {
const response = await api.get('/public/settings');
return response.data;
} catch {
// Return empty object if settings can't be fetched
return {};
}
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
retry: false, // Don't retry on failure
});
const { data: settings } = usePublicSettings({ retry: false });
// Set language based on system settings
useEffect(() => {
+6 -31
View File
@@ -1,6 +1,5 @@
import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { MaintenanceMode } from './MaintenanceMode';
import { useMaintenanceMode } from '../contexts/MaintenanceContext';
import { setMaintenanceModeCallback, api } from '../config/api';
@@ -9,12 +8,16 @@ interface MaintenanceWrapperProps {
children: React.ReactNode;
}
// Maintenance detection now lives in two places:
// 1. The axios interceptor in config/api.ts flips the flag on any 503 response.
// 2. MaintenanceContext polls /public/settings every 30s and reads the explicit
// maintenance_mode field (via the shared usePublicSettings hook).
// This wrapper only needs to gate the rendered tree on the resulting state.
export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children }) => {
const location = useLocation();
const { isMaintenanceMode, setMaintenanceMode } = useMaintenanceMode();
const [hasAdminSession, setHasAdminSession] = useState(false);
// Check if current route is admin route
const isAdminRoute = location.pathname.startsWith('/admin');
useEffect(() => {
@@ -45,40 +48,12 @@ export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children
};
}, [isAdminRoute]);
// Register the maintenance mode callback
useEffect(() => {
setMaintenanceModeCallback((enabled: boolean) => {
setMaintenanceMode(enabled);
});
}, [setMaintenanceMode]);
// Check maintenance mode on mount and when location changes
useQuery({
queryKey: ['maintenance-check', location.pathname],
queryFn: async () => {
try {
// Make a lightweight request to check maintenance status
await api.get('/public/settings');
// If successful, maintenance mode is off
setMaintenanceMode(false);
return { maintenance: false };
} catch (error: any) {
if (error.response?.status === 503) {
// Only set maintenance mode for non-admin routes or unauthenticated admin routes
if (!isAdminRoute || !hasAdminSession) {
setMaintenanceMode(true);
return { maintenance: true };
}
}
return { maintenance: false };
}
},
staleTime: 30000, // Check every 30 seconds
retry: false, // Don't retry on failure
enabled: (!isAdminRoute || !hasAdminSession) && !isMaintenanceMode, // Don't check if already in maintenance
});
// Show maintenance page if in maintenance mode and not on admin route with auth
if (isMaintenanceMode && (!isAdminRoute || !hasAdminSession)) {
return <MaintenanceMode />;
}
@@ -0,0 +1,205 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Heart, Bookmark, Star, MessageCircle } from 'lucide-react';
import { Loading } from '../common';
import { guestsService, AdminGuest } from '../../services/guests.service';
import { AuthenticatedImage } from '../common/AuthenticatedImage';
import { buildResourceUrl } from '../../utils/url';
interface AdminGuestDetailProps {
eventId: number;
guest: AdminGuest;
onClose: () => void;
}
type Tab = 'all' | 'liked' | 'favorited' | 'rated' | 'commented';
export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, guest, onClose }) => {
const { t } = useTranslation();
const [tab, setTab] = useState<Tab>('all');
const { data, isLoading } = useQuery({
queryKey: ['admin-guest-detail', eventId, guest.id],
queryFn: () => guestsService.getGuestDetail(eventId, guest.id),
});
const selections = data?.selections;
const liked = selections?.liked || [];
const favorited = selections?.favorited || [];
const rated = selections?.rated || [];
const commented = selections?.commented || [];
// "all" view combines the three visual selection types.
type GridItem = { photo: { id: number; filename: string; thumbnail_url: string }; badges: string[] };
const allItems: GridItem[] = [];
const seen = new Map<number, GridItem>();
const add = (photo: { id: number; filename: string; thumbnail_url: string }, badge: string) => {
if (!seen.has(photo.id)) {
const item: GridItem = { photo, badges: [badge] };
seen.set(photo.id, item);
allItems.push(item);
} else {
seen.get(photo.id)!.badges.push(badge);
}
};
liked.forEach((p) => add(p, 'like'));
favorited.forEach((p) => add(p, 'favorite'));
rated.forEach((r) => add(r.photo, 'rating'));
const visibleItems: GridItem[] =
tab === 'all'
? allItems
: tab === 'liked'
? liked.map((p) => ({ photo: p, badges: ['like'] }))
: tab === 'favorited'
? favorited.map((p) => ({ photo: p, badges: ['favorite'] }))
: tab === 'rated'
? rated.map((r) => ({ photo: r.photo, badges: [`${r.rating}`] }))
: [];
return (
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto p-4 pt-16">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white dark:bg-neutral-900 rounded-lg shadow-xl w-full max-w-5xl max-h-[90vh] overflow-hidden flex flex-col">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{guest.name}</h2>
{guest.email && (
<p className="text-sm text-neutral-500 dark:text-neutral-400">{guest.email}</p>
)}
</div>
<button
type="button"
onClick={onClose}
className="p-1 text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100"
>
<X className="w-5 h-5" />
</button>
</div>
{isLoading ? (
<div className="p-8">
<Loading size="lg" text={t('admin.guests.loadingDetail', 'Loading selections...')} />
</div>
) : (
<div className="overflow-y-auto p-4">
{/* Stats */}
<div className="grid grid-cols-4 gap-2 mb-4">
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
{liked.length}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
<Heart className="w-3 h-3" />
{t('admin.guests.columns.likes', 'Likes')}
</div>
</div>
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
{favorited.length}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
<Bookmark className="w-3 h-3" />
{t('admin.guests.columns.favorites', 'Favorites')}
</div>
</div>
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
{rated.length}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
<Star className="w-3 h-3" />
{t('admin.guests.columns.ratings', 'Ratings')}
</div>
</div>
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
{commented.length}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
<MessageCircle className="w-3 h-3" />
{t('admin.guests.columns.comments', 'Comments')}
</div>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-neutral-200 dark:border-neutral-700 mb-4">
{(['all', 'liked', 'favorited', 'rated', 'commented'] as const).map((k) => (
<button
key={k}
type="button"
onClick={() => setTab(k)}
className={`px-3 py-2 text-sm font-medium border-b-2 transition ${
tab === k
? 'border-accent text-accent'
: 'border-transparent text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100'
}`}
>
{t(`admin.guests.detail.${k}`, k)}
</button>
))}
</div>
{/* Content */}
{tab === 'commented' ? (
commented.length === 0 ? (
<div className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-8">
{t('admin.guests.detail.noComments', 'No comments')}
</div>
) : (
<div className="space-y-3">
{commented.map((c, idx) => (
<div key={idx} className="flex gap-3 p-3 bg-neutral-50 dark:bg-neutral-800 rounded">
<AuthenticatedImage
src={buildResourceUrl(c.photo.thumbnail_url)}
alt={c.photo.filename}
className="w-16 h-16 object-cover rounded flex-shrink-0"
/>
<div className="flex-1">
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{c.photo.filename} · {new Date(c.created_at).toLocaleString()}
</div>
<p className="text-sm text-neutral-900 dark:text-neutral-100 mt-1">{c.comment}</p>
</div>
</div>
))}
</div>
)
) : visibleItems.length === 0 ? (
<div className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-8">
{t('admin.guests.detail.empty', 'No selections in this category')}
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
{visibleItems.map((item) => (
<div key={item.photo.id} className="relative group">
<AuthenticatedImage
src={buildResourceUrl(item.photo.thumbnail_url)}
alt={item.photo.filename}
className="w-full aspect-square object-cover rounded"
/>
<div className="absolute top-1 right-1 flex gap-1">
{item.badges.map((b, i) => (
<span
key={i}
className="bg-black/60 text-white text-xs px-1.5 py-0.5 rounded"
>
{b === 'like' ? '♥' : b === 'favorite' ? '★' : b}
</span>
))}
</div>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity text-white text-xs p-2 rounded-b">
{item.photo.filename}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,344 @@
import React, { useState } from 'react';
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Trash2, Eye, Download, UserPlus, Grid3x3, List } from 'lucide-react';
import { Card, Button, Loading } from '../common';
import { guestsService, AdminGuest } from '../../services/guests.service';
import { AdminGuestDetail } from './AdminGuestDetail';
import { GuestSelectionsAggregate } from './GuestSelectionsAggregate';
import { GuestInviteDialog } from './GuestInviteDialog';
import { toast } from 'react-toastify';
interface AdminGuestsListProps {
eventId: number;
eventName?: string;
}
type View = 'list' | 'aggregate';
export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, eventName }) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [view, setView] = useState<View>('list');
const [selectedGuest, setSelectedGuest] = useState<AdminGuest | null>(null);
const [mergeMode, setMergeMode] = useState(false);
const [mergeSelection, setMergeSelection] = useState<number[]>([]);
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
const { data, isLoading, refetch } = useQuery({
queryKey: ['admin-guests', eventId],
queryFn: () => guestsService.getEventGuests(eventId),
});
const deleteMutation = useMutation({
mutationFn: (guestId: number) => guestsService.deleteGuest(eventId, guestId),
onSuccess: () => {
toast.success(t('admin.guests.deletedToast', 'Guest removed'));
queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] });
},
onError: () => toast.error(t('admin.guests.deletedError', 'Failed to remove guest')),
});
const mergeMutation = useMutation({
mutationFn: ({ keepId, mergeIds }: { keepId: number; mergeIds: number[] }) =>
guestsService.mergeGuests(eventId, keepId, mergeIds),
onSuccess: () => {
toast.success(t('admin.guests.mergedToast', 'Guests merged'));
setMergeMode(false);
setMergeSelection([]);
queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] });
},
onError: () => toast.error(t('admin.guests.mergedError', 'Failed to merge guests')),
});
const handleDelete = (guest: AdminGuest) => {
if (window.confirm(t('admin.guests.forgetGuestConfirm', 'Remove this guest? Their picks will be anonymized but kept in aggregate totals.'))) {
deleteMutation.mutate(guest.id);
}
};
const handleExport = async (guest: AdminGuest, format: 'txt' | 'csv' | 'json') => {
try {
const blob = await guestsService.exportGuest(eventId, guest.id, format);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${guest.name.replace(/[^a-zA-Z0-9_-]/g, '_')}.${format}`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch {
toast.error(t('admin.guests.exportError', 'Export failed'));
}
};
const handleExportAll = async (format: 'txt' | 'csv' | 'json') => {
try {
const blob = await guestsService.exportAllGuests(eventId, format);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `event-${eventId}-guests.zip`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch {
toast.error(t('admin.guests.exportError', 'Export failed'));
}
};
const toggleMergeSelection = (id: number) => {
setMergeSelection((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
};
const performMerge = () => {
if (mergeSelection.length < 2) {
toast.warning(t('admin.guests.mergeSelectAtLeastTwo', 'Select at least 2 guests to merge'));
return;
}
const [keepId, ...mergeIds] = mergeSelection;
const keepName = data?.guests.find((g) => g.id === keepId)?.name;
const confirmMsg = t(
'admin.guests.mergeConfirm',
'Merge {{count}} guests into {{name}}? This cannot be undone.',
{ count: mergeSelection.length, name: keepName || '#' + keepId }
);
if (window.confirm(confirmMsg)) {
mergeMutation.mutate({ keepId, mergeIds });
}
};
if (isLoading) {
return <Loading size="lg" text={t('admin.guests.loading', 'Loading guests...')} />;
}
const guests = data?.guests || [];
if (view === 'aggregate') {
return (
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setView('list')} leftIcon={<List className="w-4 h-4" />}>
{t('admin.guests.backToList', 'Back to list')}
</Button>
</div>
</div>
<GuestSelectionsAggregate eventId={eventId} />
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('admin.guests.title', 'Guests')} ({guests.length})
</h3>
<div className="flex items-center gap-2">
{mergeMode ? (
<>
<span className="text-sm text-neutral-600 dark:text-neutral-400">
{t('admin.guests.mergeSelected', '{{count}} selected', { count: mergeSelection.length })}
</span>
<Button variant="primary" size="sm" onClick={performMerge} disabled={mergeSelection.length < 2}>
{t('admin.guests.mergeNow', 'Merge selected')}
</Button>
<Button variant="ghost" size="sm" onClick={() => { setMergeMode(false); setMergeSelection([]); }}>
{t('common.cancel', 'Cancel')}
</Button>
</>
) : (
<>
<Button
variant="outline"
size="sm"
leftIcon={<UserPlus className="w-4 h-4" />}
onClick={() => setInviteDialogOpen(true)}
>
{t('admin.guests.createInvite', 'Create invite')}
</Button>
<Button
variant="outline"
size="sm"
leftIcon={<Grid3x3 className="w-4 h-4" />}
onClick={() => setView('aggregate')}
>
{t('admin.guests.aggregateView', 'By popularity')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setMergeMode(true)}
disabled={guests.length < 2}
>
{t('admin.guests.mergeMode', 'Merge')}
</Button>
<div className="relative group">
<Button variant="outline" size="sm" leftIcon={<Download className="w-4 h-4" />}>
{t('admin.guests.exportAll', 'Export all')}
</Button>
<div className="absolute right-0 top-full mt-1 hidden group-hover:block bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded shadow-lg z-10 min-w-[120px]">
{(['csv', 'txt', 'json'] as const).map((fmt) => (
<button
key={fmt}
onClick={() => handleExportAll(fmt)}
className="block w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700"
>
{fmt.toUpperCase()}
</button>
))}
</div>
</div>
</>
)}
</div>
</div>
{guests.length === 0 ? (
<Card>
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">
{t('admin.guests.empty', 'No guests have registered yet.')}
</div>
</Card>
) : (
<Card>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-neutral-50 dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
<tr>
{mergeMode && <th className="px-4 py-3 w-8" />}
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.name', 'Name')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.email', 'Email')}
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.likes', 'Likes')}
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.favorites', 'Favorites')}
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.comments', 'Comments')}
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.ratings', 'Ratings')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.lastSeen', 'Last seen')}
</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-700">
{guests.map((guest) => (
<tr key={guest.id} className="hover:bg-neutral-50 dark:hover:bg-neutral-800">
{mergeMode && (
<td className="px-4 py-3">
<input
type="checkbox"
checked={mergeSelection.includes(guest.id)}
onChange={() => toggleMergeSelection(guest.id)}
className="w-4 h-4 text-accent rounded focus:ring-primary-500"
/>
</td>
)}
<td className="px-4 py-3 font-medium text-neutral-900 dark:text-neutral-100">
{guest.name}
{guest.email_verified_at && (
<span className="ml-2 text-xs text-green-600"></span>
)}
</td>
<td className="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400">
{guest.email || '—'}
</td>
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
{guest.stats.likes}
</td>
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
{guest.stats.favorites}
</td>
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
{guest.stats.comments}
</td>
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
{guest.stats.ratings}
</td>
<td className="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400">
{new Date(guest.last_seen_at).toLocaleDateString()}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button
type="button"
onClick={() => setSelectedGuest(guest)}
className="p-1 text-neutral-500 hover:text-accent"
title={t('admin.guests.view', 'View details')}
>
<Eye className="w-4 h-4" />
</button>
<div className="relative group">
<button
type="button"
className="p-1 text-neutral-500 hover:text-accent"
title={t('admin.guests.export', 'Export')}
>
<Download className="w-4 h-4" />
</button>
<div className="absolute right-0 top-full mt-1 hidden group-hover:block bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded shadow-lg z-10 min-w-[100px]">
{(['csv', 'txt', 'json'] as const).map((fmt) => (
<button
key={fmt}
onClick={() => handleExport(guest, fmt)}
className="block w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700"
>
{fmt.toUpperCase()}
</button>
))}
</div>
</div>
<button
type="button"
onClick={() => handleDelete(guest)}
className="p-1 text-neutral-500 hover:text-red-600"
title={t('admin.guests.forgetGuest', 'Remove guest')}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
{selectedGuest && (
<AdminGuestDetail
eventId={eventId}
guest={selectedGuest}
onClose={() => setSelectedGuest(null)}
/>
)}
{inviteDialogOpen && (
<GuestInviteDialog
eventId={eventId}
eventName={eventName}
onClose={() => {
setInviteDialogOpen(false);
refetch();
}}
/>
)}
</div>
);
};
+34 -16
View File
@@ -9,10 +9,12 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAdminAuth } from '../../contexts';
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
import { useOnClickOutside } from '../../hooks/useOnClickOutside';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { PasswordChangeModal } from './PasswordChangeModal';
import { LanguageSelector } from '../common';
import { notificationsService } from '../../services/notifications.service';
import { toast } from 'react-toastify';
import { buildResourceUrl } from '../../utils/url';
interface AdminHeaderProps {
onMenuClick: () => void;
@@ -21,7 +23,7 @@ interface AdminHeaderProps {
export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
const navigate = useNavigate();
const { user, logout } = useAdminAuth();
const { isDark, toggle: toggleDarkMode } = useAdminDarkMode();
const { isDark, toggle: toggleDarkMode, forcedMode } = useAdminDarkMode();
const { t } = useTranslation();
const { format } = useLocalizedDate();
const { formatTimeAgo } = useLocalizedTimeAgo();
@@ -30,6 +32,15 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
const [showPasswordModal, setShowPasswordModal] = useState(false);
const queryClient = useQueryClient();
const { data: brandingSettings } = usePublicSettings();
const companyName = brandingSettings?.branding_company_name?.trim() || 'PicPeak';
const logoUrl = brandingSettings?.branding_logo_url?.trim();
const logoDisplayMode = brandingSettings?.branding_logo_display_mode || 'logo_and_text';
const resolvedLogoUrl = logoUrl
? (logoUrl.startsWith('http') ? logoUrl : buildResourceUrl(logoUrl))
: '/picpeak-kamera-transparent.png';
const userMenuRef = useRef<HTMLDivElement>(null);
const notificationRef = useRef<HTMLDivElement>(null);
@@ -82,10 +93,14 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
<Menu className="w-6 h-6" />
</button>
{/* PicPeak logo - sticky to the left on all sizes */}
{/* Logo - sticky to the left on all sizes */}
<div className="flex items-center gap-2">
<img src="/picpeak-kamera-transparent.png" alt="PicPeak" className="h-8 w-auto object-contain" />
<span className="text-xl sm:text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>PicPeak</span>
{(logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text') && (
<img src={resolvedLogoUrl} alt={companyName} className="h-8 w-auto object-contain" />
)}
{(logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text') && (
<span className="text-xl sm:text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>{companyName}</span>
)}
</div>
{/* Date display - hidden on smaller screens */}
@@ -101,14 +116,17 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
{/* Language Selector */}
<LanguageSelector />
{/* Dark Mode Toggle */}
<button
onClick={toggleDarkMode}
className="p-2 text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
title={isDark ? t('admin.lightMode', 'Switch to light mode') : t('admin.darkMode', 'Switch to dark mode')}
>
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
{/* Dark Mode Toggle — hidden entirely when an admin has locked
the instance to a specific mode via Branding > Force color mode. */}
{!forcedMode && (
<button
onClick={toggleDarkMode}
className="p-2 text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
title={isDark ? t('admin.lightMode', 'Switch to light mode') : t('admin.darkMode', 'Switch to dark mode')}
>
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
)}
{/* Notifications */}
<div className="relative" ref={notificationRef}>
@@ -131,7 +149,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
{unreadCount > 0 && (
<button
onClick={() => markAllAsReadMutation.mutate()}
className="text-xs text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 flex items-center gap-1"
className="text-xs text-accent hover:opacity-80 flex items-center gap-1"
title={t('admin.markAllRead')}
>
<CheckCircle className="w-3 h-3" />
@@ -160,7 +178,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
<div
key={notification.id}
className={`px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-700 cursor-pointer border-l-4 ${
notification.isRead ? 'border-transparent opacity-75' : 'border-primary-500'
notification.isRead ? 'border-transparent opacity-75' : 'border-accent-dark'
}`}
>
<div className="flex items-start gap-3">
@@ -185,7 +203,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
<div className="px-4 py-2 border-t border-neutral-100 dark:border-neutral-700 text-center">
<button
onClick={() => setShowNotifications(false)}
className="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300"
className="text-sm text-accent hover:opacity-80"
>
{t('admin.close')}
</button>
@@ -205,7 +223,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{user?.username}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{user?.email}</p>
</div>
<div className="w-8 h-8 bg-primary-600 rounded-full flex items-center justify-center">
<div className="w-8 h-8 bg-accent-dark rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-white" />
</div>
</button>
@@ -19,7 +19,7 @@ export const AdminLayout: React.FC = () => {
return (
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-950 flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 border-4 border-primary-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<div className="w-16 h-16 border-4 border-accent-dark border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-neutral-600">Loading...</p>
</div>
</div>
@@ -1,10 +1,12 @@
import React, { useState } from 'react';
import { Check, Download, Trash2, Eye, Package, MessageSquare, Star, Video, FolderOpen } from 'lucide-react';
import { Check, Download, Trash2, Eye, EyeOff, Package, MessageSquare, Star, Video, FolderOpen, Cog, AlertTriangle, RefreshCw } from 'lucide-react';
import { toast } from 'react-toastify';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { AdminPhoto } from '../../services/photos.service';
import { photosService } from '../../services/photos.service';
import { uploadsService } from '../../services/uploads.service';
import { Button } from '../common';
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
import { BulkCategoryModal } from './BulkCategoryModal';
@@ -32,6 +34,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
categories = []
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
@@ -201,6 +204,34 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
>
{t('photos.moveToCategory', 'Move to Category')}
</Button>
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
await photosService.bulkUpdatePhotos(eventId, Array.from(selectedPhotos), { visibility: 'hidden' });
toast.success(t('admin.photos.hiddenSuccess', 'Photos hidden'));
onPhotosDeleted();
} catch { toast.error(t('common.error')); }
}}
leftIcon={<EyeOff className="w-4 h-4" />}
>
{t('admin.photos.hideSelected', 'Hide')}
</Button>
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
await photosService.bulkUpdatePhotos(eventId, Array.from(selectedPhotos), { visibility: 'visible' });
toast.success(t('admin.photos.visibleSuccess', 'Photos visible'));
onPhotosDeleted();
} catch { toast.error(t('common.error')); }
}}
leftIcon={<Eye className="w-4 h-4" />}
>
{t('admin.photos.showSelected', 'Show')}
</Button>
<button
onClick={handleDeleteSelected}
disabled={isDeleting}
@@ -253,16 +284,59 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
>
<div className={`w-6 h-6 rounded border-2 flex items-center justify-center ${
selectedPhotos.has(photo.id)
? 'bg-primary-600 border-primary-600'
? 'bg-accent-dark border-accent-dark'
: 'bg-white/90 border-white'
}`}>
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
</div>
</button>
{/* Thumbnail */}
{/* Visibility badge (#172) */}
{(photo as any).visibility === 'hidden' && (
<div className="absolute top-2 left-2 z-20">
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-red-500/90 text-white text-[10px] font-medium">
<EyeOff className="w-3 h-3" />
{t('admin.photos.hidden', 'Hidden')}
</span>
</div>
)}
{/* Thumbnail (or processing placeholder for in-flight photos) */}
<div className="aspect-square">
{photo.thumbnail_url ? (
{(photo as any).processing_status === 'pending' ||
(photo as any).processing_status === 'processing' ? (
<div className="w-full h-full flex flex-col items-center justify-center bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 gap-1 px-2 text-center">
<Cog className="w-7 h-7 animate-spin" />
<p className="text-[10px] font-medium leading-tight">
{t('admin.photos.processingStatus', 'Processing…')}
</p>
</div>
) : (photo as any).processing_status === 'failed' ? (
<div className="w-full h-full flex flex-col items-center justify-center bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 gap-1 px-2 text-center">
<AlertTriangle className="w-7 h-7" />
<p className="text-[10px] font-medium leading-tight">
{t('admin.photos.processingFailed', 'Failed')}
</p>
<button
onClick={async (e) => {
e.stopPropagation();
try {
await uploadsService.retryPhoto(photo.id);
toast.success(t('admin.photos.retryQueued', 'Retry queued'));
// Refetch grid via React Query so the placeholder
// updates without a full reload.
queryClient.invalidateQueries({ queryKey: ['admin-event-photos'] });
} catch (err: any) {
toast.error(err?.response?.data?.error || 'Retry failed');
}
}}
className="mt-1 px-2 py-0.5 rounded bg-red-200 dark:bg-red-800 text-[10px] inline-flex items-center gap-1"
>
<RefreshCw className="w-2.5 h-2.5" />
{t('upload.retryFailed', 'Retry')}
</button>
</div>
) : photo.thumbnail_url ? (
<AdminAuthenticatedImage
src={photo.thumbnail_url}
alt={photo.filename}
@@ -345,7 +419,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
)}
{commentCount > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${commentCount} comments`}>
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
<MessageSquare className="w-3.5 h-3.5 text-accent" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{commentCount}</span>
</div>
)}
@@ -266,7 +266,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
</span>
<button
onClick={() => setShowCategoryMenu(!showCategoryMenu)}
className="text-xs text-primary-400 hover:text-primary-300"
className="text-xs text-accent hover:text-accent-dark"
>
Change
</button>
@@ -393,7 +393,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
<div className="space-y-2">
<button
onClick={() => setExpandedComments(!expandedComments)}
className="text-xs text-primary-400 hover:text-primary-300 mb-2"
className="text-xs text-accent hover:text-accent-dark mb-2"
>
{expandedComments ? 'Hide' : 'Show'} Comments ({comments.length})
</button>
@@ -90,12 +90,17 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
onClick={() => onClose()}
className={`flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
isActive
? 'bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
? 'bg-accent-dark text-white'
: 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-neutral-100'
}`}
>
{/* Selected item: solid accent-dark fill with white text/icon
for unambiguous high-contrast selection — matches the
.tile-selected pattern used in the customizer. The accent
-dark token defaults to the legacy primary green so users
who haven't set CI colours yet see no migration regression. */}
<item.icon className={`w-5 h-5 mr-3 ${
isActive ? 'text-primary-600' : 'text-neutral-400'
isActive ? 'text-white' : 'text-neutral-400'
}`} />
{t(item.nameKey)}
</NavLink>
@@ -136,7 +141,7 @@ const StorageInfo: React.FC = () => {
? Math.round((storageInfo.total_used / limitInUse) * 100)
: 0;
const isOverSoftLimit = limitInUse && storageInfo.total_used >= limitInUse;
const progressBarClass = isOverSoftLimit ? 'bg-red-600' : 'bg-primary-600';
const progressBarClass = isOverSoftLimit ? 'bg-red-600' : 'bg-accent-dark';
const containerClass = isOverSoftLimit
? 'bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800'
: 'bg-neutral-100 dark:bg-neutral-800';
@@ -184,7 +184,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
onClick={() => handleChange('backup_destination_type', type.id)}
className={`p-4 rounded-lg border-2 transition-all ${
formData.backup_destination_type === type.id
? 'border-primary bg-primary-50 dark:bg-primary-900/30'
? 'border-primary bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
}`}
>
@@ -385,7 +385,7 @@ export const BackupHistory = () => {
onClick={() => setCurrentPage(pageNum)}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
currentPage === pageNum
? 'z-10 bg-primary-50 dark:bg-primary-900/30 border-primary text-primary'
? 'z-10 bg-accent-dark/15 border-primary text-primary'
: 'bg-white dark:bg-neutral-800 border-neutral-300 dark:border-neutral-600 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700'
}`}
>
@@ -25,13 +25,13 @@ export const BulkArchiveModal: React.FC<BulkArchiveModalProps> = ({
<Card className="w-full max-w-md">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">Confirm Bulk Archive</h2>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">Confirm Bulk Archive</h2>
<button
onClick={onClose}
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
className="p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
disabled={isLoading}
>
<X className="w-5 h-5 text-neutral-500" />
<X className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
</button>
</div>
@@ -64,7 +64,7 @@ export const BulkCategoryModal: React.FC<BulkCategoryModalProps> = ({
id="category-select"
value={selectedCategoryId ?? ''}
onChange={(e) => setSelectedCategoryId(e.target.value === '' ? null : Number(e.target.value))}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
disabled={isLoading}
>
<option value="">{t('photos.uncategorized', 'Uncategorized')}</option>
@@ -0,0 +1,144 @@
import React, { useState } from 'react';
import { Trash2, AlertTriangle, X, Lock, Eye, EyeOff, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Card, Input } from '../common';
import type { Event } from '../../types';
interface BulkDeleteModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (password: string) => Promise<void>;
selectedEvents: Event[];
isLoading?: boolean;
/** Set when the server responded 401 INVALID_PASSWORD; surfaces inline. */
passwordError?: string | null;
/** Clear the inline password error when the user starts typing again. */
onPasswordErrorClear?: () => void;
}
export const BulkDeleteModal: React.FC<BulkDeleteModalProps> = ({
isOpen,
onClose,
onConfirm,
selectedEvents,
isLoading = false,
passwordError = null,
onPasswordErrorClear,
}) => {
const { t } = useTranslation();
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
if (!isOpen) return null;
const count = selectedEvents.length;
const handleSubmit = async () => {
if (!password || isLoading) return;
await onConfirm(password);
};
const handlePasswordChange = (val: string) => {
setPassword(val);
if (passwordError && onPasswordErrorClear) onPasswordErrorClear();
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-red-700 dark:text-red-400">
{t('events.bulkDelete.title', 'Permanently delete {{count}} events?', { count })}
</h2>
<button
onClick={onClose}
className="p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
disabled={isLoading}
aria-label={t('common.close', 'Close')}
>
<X className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
</button>
</div>
{/* Processing-state banner replaces the warning + form when in flight. */}
{isLoading ? (
<div className="py-8 text-center">
<Loader2 className="w-8 h-8 mx-auto mb-3 animate-spin text-red-600 dark:text-red-400" />
<p className="text-sm text-neutral-700 dark:text-neutral-300">
{t('events.bulkDelete.processing', 'Deleting {{count}} events. This may take a few minutes — please don\'t close this window.', { count })}
</p>
</div>
) : (
<>
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800 dark:text-red-200">
{t('events.bulkDelete.warning', 'This will permanently delete the selected events, all their photos, archives, and audit logs. This action cannot be undone.')}
</p>
</div>
<div className="border border-neutral-200 dark:border-neutral-700 rounded-lg max-h-40 overflow-y-auto mb-4">
<ul className="p-3 space-y-1">
{selectedEvents.map((event) => (
<li key={event.id} className="text-sm text-neutral-700 dark:text-neutral-300">
{event.event_name} ({event.event_type})
</li>
))}
</ul>
</div>
<div className="mb-6">
<Input
type={showPassword ? 'text' : 'password'}
label={t('events.bulkDelete.passwordLabel', 'Re-enter your password to confirm')}
value={password}
onChange={(e) => handlePasswordChange(e.target.value)}
placeholder={t('events.bulkDelete.passwordPlaceholder', 'Your admin password')}
helperText={t('events.bulkDelete.passwordHelp', 'We require your password as a safeguard against accidental bulk deletions.')}
error={passwordError || undefined}
leftIcon={<Lock className="w-5 h-5" />}
rightIcon={
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="p-1"
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && password) handleSubmit();
}}
/>
</div>
<div className="flex justify-end gap-3">
<Button
variant="outline"
onClick={onClose}
disabled={isLoading}
>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="primary"
onClick={handleSubmit}
disabled={!password || isLoading}
leftIcon={<Trash2 className="w-4 h-4" />}
className="bg-red-600 hover:bg-red-700 focus:ring-red-500 text-white"
>
{t('events.bulkDelete.submit', 'Delete {{count}} events', { count })}
</Button>
</div>
</>
)}
</div>
</Card>
</div>
);
};
BulkDeleteModal.displayName = 'BulkDeleteModal';
+39 -48
View File
@@ -143,8 +143,10 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<button
onClick={onClick}
disabled={disabled}
className={`p-2 rounded hover:bg-neutral-100 transition-colors ${
active ? 'bg-primary-100 text-primary-700' : 'text-neutral-700'
className={`p-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors ${
active
? 'bg-accent-dark/15 text-accent-dark'
: 'text-neutral-700 dark:text-neutral-200'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
title={title}
type="button"
@@ -172,44 +174,32 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
});
};
// Reusable view-mode chip — three states (edit/preview/split). Shared
// styling block extracted as a const so the dark variants stay in sync.
const viewModeChipClass = (mode: typeof viewMode) =>
`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
viewMode === mode
? 'bg-accent-dark/15 text-accent-dark'
: 'text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700'
}`;
return (
<div className={`relative ${isFullscreen ? 'fixed inset-0 z-50 bg-white' : ''}`}>
<div className="border border-neutral-300 rounded-lg overflow-hidden h-full flex flex-col">
<div className={`relative ${isFullscreen ? 'fixed inset-0 z-50 bg-white dark:bg-neutral-900' : ''}`}>
<div className="border border-neutral-300 dark:border-neutral-700 rounded-lg overflow-hidden h-full flex flex-col bg-white dark:bg-neutral-900">
{/* Top Toolbar */}
<div className="border-b border-neutral-200 bg-neutral-50">
<div className="border-b border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800">
{/* View Mode Controls */}
<div className="flex items-center justify-between p-2 border-b border-neutral-200">
<div className="flex items-center justify-between p-2 border-b border-neutral-200 dark:border-neutral-700">
<div className="flex items-center gap-2">
<button
onClick={() => setViewMode('edit')}
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
viewMode === 'edit'
? 'bg-primary-100 text-primary-700'
: 'text-neutral-600 hover:bg-neutral-100'
}`}
>
<button onClick={() => setViewMode('edit')} className={viewModeChipClass('edit')}>
<Edit3 className="w-4 h-4 inline-block mr-1" />
Edit
</button>
<button
onClick={() => setViewMode('preview')}
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
viewMode === 'preview'
? 'bg-primary-100 text-primary-700'
: 'text-neutral-600 hover:bg-neutral-100'
}`}
>
<button onClick={() => setViewMode('preview')} className={viewModeChipClass('preview')}>
<Eye className="w-4 h-4 inline-block mr-1" />
Preview
</button>
<button
onClick={() => setViewMode('split')}
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
viewMode === 'split'
? 'bg-primary-100 text-primary-700'
: 'text-neutral-600 hover:bg-neutral-100'
}`}
>
<button onClick={() => setViewMode('split')} className={viewModeChipClass('split')}>
<Columns className="w-4 h-4 inline-block mr-1" />
Split
</button>
@@ -295,7 +285,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<Heading6 className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<div className="w-px h-6 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().toggleBold().run()}
@@ -329,7 +319,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<Code2 className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<div className="w-px h-6 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
@@ -355,7 +345,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<Quote className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<div className="w-px h-6 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => setShowLinkDialog(true)}
@@ -372,7 +362,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<Minus className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<div className="w-px h-6 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().setTextAlign('left').run()}
@@ -406,7 +396,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<AlignJustify className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<div className="w-px h-6 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
@@ -415,7 +405,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<RemoveFormatting className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<div className="w-px h-6 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().undo().run()}
@@ -438,14 +428,14 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
{/* Link Dialog */}
{showLinkDialog && (
<div className="p-3 bg-primary-50 border-b border-primary-200 flex items-center gap-2">
<div className="p-3 bg-accent-dark/15 border-b border-accent-dark/30 flex items-center gap-2">
<input
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addLink()}
placeholder="Enter URL..."
className="flex-1 px-3 py-1 border border-primary-300 rounded-md focus:ring-2 focus:ring-primary-500"
className="flex-1 px-3 py-1 border border-accent-dark/30 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 rounded-md focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<Button size="sm" onClick={addLink}>Add Link</Button>
@@ -460,21 +450,22 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
{/* Editor Content Area */}
<div className="flex-1 flex overflow-hidden">
{/* Editor */}
{/* Editor — prose-invert in dark mode flips the prose typography
palette without us having to override every prose-* class. */}
{viewMode !== 'preview' && (
<div className={`${viewMode === 'split' ? 'w-1/2 border-r border-neutral-200' : 'w-full'} overflow-auto`}>
<div className={`${viewMode === 'split' ? 'w-1/2 border-r border-neutral-200 dark:border-neutral-700' : 'w-full'} overflow-auto bg-white dark:bg-neutral-900`}>
<EditorContent
editor={editor}
className="min-h-[400px] p-4 prose prose-neutral max-w-none focus:outline-none [&_.ProseMirror]:min-h-[400px] [&_.ProseMirror]:outline-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-neutral-400 [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0 [&_.ProseMirror_br.hard-break]:display-block [&_.ProseMirror_br.hard-break]:content-[''] [&_.ProseMirror_br.hard-break]:margin-[0.5em_0] [&_.ProseMirror_pre]:bg-neutral-100 [&_.ProseMirror_pre]:rounded-md [&_.ProseMirror_pre]:p-4 [&_.ProseMirror_pre]:overflow-x-auto [&_.ProseMirror_code]:bg-neutral-100 [&_.ProseMirror_code]:rounded [&_.ProseMirror_code]:px-1 [&_.ProseMirror_code]:py-0.5 [&_.ProseMirror_code]:text-sm [&_.ProseMirror_pre_code]:bg-transparent [&_.ProseMirror_pre_code]:p-0"
className="min-h-[400px] p-4 prose prose-neutral dark:prose-invert max-w-none focus:outline-none [&_.ProseMirror]:min-h-[400px] [&_.ProseMirror]:outline-none [&_.ProseMirror]:text-neutral-900 dark:[&_.ProseMirror]:text-neutral-100 [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-neutral-400 dark:[&_.ProseMirror_p.is-editor-empty:first-child::before]:text-neutral-500 [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0 [&_.ProseMirror_br.hard-break]:display-block [&_.ProseMirror_br.hard-break]:content-[''] [&_.ProseMirror_br.hard-break]:margin-[0.5em_0] [&_.ProseMirror_pre]:bg-neutral-100 dark:[&_.ProseMirror_pre]:bg-neutral-800 [&_.ProseMirror_pre]:rounded-md [&_.ProseMirror_pre]:p-4 [&_.ProseMirror_pre]:overflow-x-auto [&_.ProseMirror_code]:bg-neutral-100 dark:[&_.ProseMirror_code]:bg-neutral-800 [&_.ProseMirror_code]:rounded [&_.ProseMirror_code]:px-1 [&_.ProseMirror_code]:py-0.5 [&_.ProseMirror_code]:text-sm [&_.ProseMirror_pre_code]:bg-transparent [&_.ProseMirror_pre_code]:p-0"
/>
</div>
)}
{/* Preview */}
{viewMode !== 'edit' && (
<div className={`${viewMode === 'split' ? 'w-1/2' : 'w-full'} overflow-auto bg-neutral-50 p-4`}>
<div
className="prose prose-neutral max-w-none"
<div className={`${viewMode === 'split' ? 'w-1/2' : 'w-full'} overflow-auto bg-neutral-50 dark:bg-neutral-800 p-4`}>
<div
className="prose prose-neutral dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: getPreviewContent() }}
/>
</div>
@@ -482,12 +473,12 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
</div>
{/* Status Bar */}
<div className="flex items-center justify-between px-4 py-2 bg-neutral-50 border-t border-neutral-200 text-sm text-neutral-600">
<div className="flex items-center justify-between px-4 py-2 bg-neutral-50 dark:bg-neutral-800 border-t border-neutral-200 dark:border-neutral-700 text-sm text-neutral-600 dark:text-neutral-300">
<div className="flex items-center gap-4">
<span>{wordCount} words</span>
<span>{charCount} characters</span>
</div>
<div className="text-xs text-neutral-500">
<div className="text-xs text-neutral-500 dark:text-neutral-400">
Press Shift+Enter for line break, Enter for new paragraph
</div>
</div>
@@ -496,7 +487,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
{/* Help Modal */}
{showHelp && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[80vh] overflow-auto">
<div className="bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 rounded-lg max-w-2xl w-full max-h-[80vh] overflow-auto">
<div className="p-6">
<h2 className="text-xl font-semibold mb-4">Editor Help & Keyboard Shortcuts</h2>
@@ -93,7 +93,7 @@ export const CategoryManager: React.FC = () => {
if (isLoading) {
return (
<div className="flex justify-center items-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-primary-600" />
<Loader2 className="w-6 h-6 animate-spin text-accent" />
</div>
);
}
@@ -205,7 +205,7 @@ export const CategoryManager: React.FC = () => {
<div className="flex gap-1">
<button
onClick={() => startEdit(category)}
className="p-1.5 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/30 rounded transition-colors"
className="p-1.5 text-neutral-600 dark:text-neutral-400 hover:text-accent dark:hover:text-accent hover:bg-accent-dark/15 rounded transition-colors"
title={t('common.edit')}
>
<Edit2 className="w-4 h-4" />
@@ -108,7 +108,7 @@ export const CssTemplateEditor: React.FC = () => {
onClick={() => setActiveSlot(slot)}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeSlot === slot
? 'border-primary-600 text-primary-600'
? 'border-accent text-accent'
: 'border-transparent text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 hover:border-neutral-300 dark:hover:border-neutral-600'
}`}
>
@@ -138,7 +138,7 @@ export const CssTemplateEditor: React.FC = () => {
value={activeTemplate.name}
onChange={(e) => updateLocalTemplate({ name: e.target.value })}
maxLength={50}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
/>
</div>
@@ -149,7 +149,7 @@ export const CssTemplateEditor: React.FC = () => {
type="checkbox"
checked={activeTemplate.is_enabled}
onChange={(e) => updateLocalTemplate({ is_enabled: e.target.checked })}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
className="rounded border-neutral-300 text-accent focus:ring-primary-500"
/>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('cssTemplates.enableTemplate', 'Enable this template')}
@@ -169,7 +169,7 @@ export const CssTemplateEditor: React.FC = () => {
<textarea
value={activeTemplate.css_content}
onChange={(e) => updateLocalTemplate({ css_content: e.target.value })}
className="w-full h-96 px-4 py-3 font-mono text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-neutral-900 text-green-400"
className="w-full h-96 px-4 py-3 font-mono text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark bg-neutral-900 text-green-400"
spellCheck={false}
placeholder="/* Enter your custom CSS here */"
/>
@@ -27,7 +27,7 @@ export const EmailPreviewModal: React.FC<EmailPreviewModalProps> = ({
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-neutral-200 dark:border-neutral-700">
<div className="flex items-center gap-3">
<Mail className="w-6 h-6 text-primary-600" />
<Mail className="w-6 h-6 text-accent" />
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">Email Preview</h2>
</div>
<button
@@ -76,6 +76,7 @@ export const EmailPreviewModal: React.FC<EmailPreviewModalProps> = ({
srcDoc={htmlContent}
className="w-full h-[600px] border-0"
title="Email Preview"
sandbox="allow-same-origin"
/>
</div>
) : (
@@ -0,0 +1,398 @@
import React, { useState, useCallback } from 'react';
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import HardBreak from '@tiptap/extension-hard-break';
import TextAlign from '@tiptap/extension-text-align';
import {
Bold,
Italic,
List,
ListOrdered,
Link as LinkIcon,
Heading2,
Heading3,
Quote,
Minus,
Undo,
Redo,
RemoveFormatting,
AlignLeft,
AlignCenter,
AlignRight,
Code2,
Variable,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface EmailTemplateEditorProps {
content: string;
onChange: (content: string) => void;
variables?: string[];
}
export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
content,
onChange,
variables = [],
}) => {
const { t } = useTranslation();
const [isSourceMode, setIsSourceMode] = useState(false);
const [sourceContent, setSourceContent] = useState(content);
const [linkUrl, setLinkUrl] = useState('');
const [showLinkDialog, setShowLinkDialog] = useState(false);
const [showVariables, setShowVariables] = useState(false);
const editor = useEditor({
extensions: [
StarterKit.configure({
hardBreak: false,
}),
HardBreak.configure({
keepMarks: true,
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
target: '_blank',
rel: 'noopener noreferrer',
},
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
alignments: ['left', 'center', 'right'],
defaultAlignment: 'left',
}),
],
content,
onUpdate: ({ editor }) => {
const html = editor.getHTML();
onChange(html);
setSourceContent(html);
},
});
// Sync editor when content prop changes externally
React.useEffect(() => {
if (editor && !isSourceMode && content !== editor.getHTML()) {
editor.commands.setContent(content);
setSourceContent(content);
}
}, [content, editor, isSourceMode]);
const handleSourceChange = useCallback((value: string) => {
setSourceContent(value);
onChange(value);
}, [onChange]);
const switchToVisual = useCallback(() => {
if (editor) {
editor.commands.setContent(sourceContent);
}
setIsSourceMode(false);
}, [editor, sourceContent]);
const switchToSource = useCallback(() => {
if (editor) {
setSourceContent(editor.getHTML());
}
setIsSourceMode(true);
}, [editor]);
const insertVariable = useCallback((variable: string) => {
const tag = `{{${variable}}}`;
if (isSourceMode) {
// Insert at cursor in textarea
const textarea = document.querySelector('[data-email-source]') as HTMLTextAreaElement;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newContent = sourceContent.substring(0, start) + tag + sourceContent.substring(end);
setSourceContent(newContent);
onChange(newContent);
// Restore cursor position after React re-render
requestAnimationFrame(() => {
textarea.selectionStart = textarea.selectionEnd = start + tag.length;
textarea.focus();
});
}
} else if (editor) {
editor.chain().focus().insertContent(tag).run();
}
setShowVariables(false);
}, [editor, isSourceMode, sourceContent, onChange]);
const addLink = useCallback(() => {
if (linkUrl && editor) {
editor.chain().focus().setLink({ href: linkUrl }).run();
setLinkUrl('');
setShowLinkDialog(false);
}
}, [editor, linkUrl]);
if (!editor) {
return null;
}
const MenuButton: React.FC<{
onClick: () => void;
active?: boolean;
children: React.ReactNode;
title: string;
disabled?: boolean;
}> = ({ onClick, active, children, title, disabled }) => (
<button
onClick={onClick}
disabled={disabled}
className={`p-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-600 transition-colors ${
active
? 'bg-accent-dark/15 text-accent-dark'
: 'text-neutral-700 dark:text-neutral-300'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
title={title}
type="button"
>
{children}
</button>
);
return (
<div className="border border-neutral-300 dark:border-neutral-600 rounded-lg overflow-hidden">
{/* Toolbar */}
<div className="border-b border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800">
<div className="flex items-center justify-between p-2">
{/* Formatting buttons */}
<div className="flex items-center gap-0.5 flex-wrap">
{!isSourceMode && (
<>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
active={editor.isActive('heading', { level: 2 })}
title="Heading 2"
>
<Heading2 className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
active={editor.isActive('heading', { level: 3 })}
title="Heading 3"
>
<Heading3 className="w-4 h-4" />
</MenuButton>
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive('bold')}
title={`${t('email.editor.bold')} (Ctrl+B)`}
>
<Bold className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive('italic')}
title={`${t('email.editor.italic')} (Ctrl+I)`}
>
<Italic className="w-4 h-4" />
</MenuButton>
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive('bulletList')}
title={t('email.editor.bulletList')}
>
<List className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive('orderedList')}
title={t('email.editor.numberedList')}
>
<ListOrdered className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
active={editor.isActive('blockquote')}
title={t('email.editor.blockquote')}
>
<Quote className="w-4 h-4" />
</MenuButton>
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => setShowLinkDialog(true)}
active={editor.isActive('link')}
title={t('email.editor.link')}
>
<LinkIcon className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title={t('email.editor.horizontalRule')}
>
<Minus className="w-4 h-4" />
</MenuButton>
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().setTextAlign('left').run()}
active={editor.isActive({ textAlign: 'left' })}
title={t('email.editor.alignLeft')}
>
<AlignLeft className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().setTextAlign('center').run()}
active={editor.isActive({ textAlign: 'center' })}
title={t('email.editor.alignCenter')}
>
<AlignCenter className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().setTextAlign('right').run()}
active={editor.isActive({ textAlign: 'right' })}
title={t('email.editor.alignRight')}
>
<AlignRight className="w-4 h-4" />
</MenuButton>
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
title={t('email.editor.clearFormatting')}
>
<RemoveFormatting className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title={`${t('email.editor.undo')} (Ctrl+Z)`}
>
<Undo className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title={`${t('email.editor.redo')} (Ctrl+Y)`}
>
<Redo className="w-4 h-4" />
</MenuButton>
</>
)}
</div>
{/* Right side: Variables + Source toggle */}
<div className="flex items-center gap-2">
{variables.length > 0 && (
<div className="relative">
<button
onClick={() => setShowVariables(!showVariables)}
className={`flex items-center gap-1 px-2 py-1 text-xs font-medium rounded transition-colors ${
showVariables
? 'bg-accent-dark/15 text-accent-dark'
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600'
}`}
type="button"
>
<Variable className="w-3.5 h-3.5" />
{t('email.editor.insertVariable')}
</button>
{showVariables && (
<div className="absolute right-0 top-full mt-1 z-10 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-600 rounded-lg shadow-lg py-1 min-w-[200px] max-h-[240px] overflow-auto">
{variables.map(variable => (
<button
key={variable}
onClick={() => insertVariable(variable)}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors"
type="button"
>
<code className="text-accent">{`{{${variable}}}`}</code>
</button>
))}
</div>
)}
</div>
)}
<button
onClick={isSourceMode ? switchToVisual : switchToSource}
className={`flex items-center gap-1 px-2 py-1 text-xs font-medium rounded transition-colors ${
isSourceMode
? 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300'
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600'
}`}
type="button"
>
<Code2 className="w-3.5 h-3.5" />
{isSourceMode ? t('email.editor.visualMode') : t('email.editor.sourceMode')}
</button>
</div>
</div>
</div>
{/* Link Dialog */}
{showLinkDialog && (
<div className="p-3 bg-accent-dark/15 border-b border-accent-dark/30 flex items-center gap-2">
<input
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addLink()}
placeholder={t('email.editor.enterUrl')}
className="flex-1 px-3 py-1 text-sm border border-accent-dark/30 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<button
onClick={addLink}
className="px-3 py-1 text-sm bg-accent-dark text-white rounded-md hover:opacity-90"
type="button"
>
{t('email.editor.addLink')}
</button>
<button
onClick={() => { setShowLinkDialog(false); setLinkUrl(''); }}
className="px-3 py-1 text-sm bg-neutral-200 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-md hover:bg-neutral-300 dark:hover:bg-neutral-600"
type="button"
>
{t('email.editor.cancel')}
</button>
</div>
)}
{/* Editor / Source Content Area */}
{isSourceMode ? (
<textarea
data-email-source=""
value={sourceContent}
onChange={(e) => handleSourceChange(e.target.value)}
rows={15}
className="w-full px-3 py-2 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 font-mono text-sm focus:outline-none resize-y"
spellCheck={false}
/>
) : (
<EditorContent
editor={editor}
className="min-h-[300px] p-4 prose prose-neutral dark:prose-invert max-w-none bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 focus:outline-none [&_.ProseMirror]:min-h-[300px] [&_.ProseMirror]:outline-none [&_.ProseMirror]:text-neutral-900 [&_.ProseMirror]:dark:text-neutral-100 [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-neutral-400 [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0"
/>
)}
</div>
);
};
EmailTemplateEditor.displayName = 'EmailTemplateEditor';
@@ -102,7 +102,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
if (isLoading) {
return (
<div className="flex justify-center items-center py-4">
<Loader2 className="w-5 h-5 animate-spin text-primary-600" />
<Loader2 className="w-5 h-5 animate-spin text-accent" />
</div>
);
}
@@ -185,7 +185,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
{/* Hero photo thumbnail */}
<button
onClick={() => setHeroPickerCategoryId(category.id)}
className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 dark:border-neutral-700 overflow-hidden bg-neutral-100 dark:bg-neutral-700 hover:border-primary-400 transition-colors flex items-center justify-center"
className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 dark:border-neutral-700 overflow-hidden bg-neutral-100 dark:bg-neutral-700 hover:border-accent-dark transition-colors flex items-center justify-center"
title={t('categories.setCoverPhoto')}
>
{heroPhoto ? (
@@ -195,7 +195,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
className="w-full h-full object-cover"
/>
) : category.hero_photo_id ? (
<ImageIcon className="w-4 h-4 text-primary-400" />
<ImageIcon className="w-4 h-4 text-accent" />
) : (
<ImageIcon className="w-4 h-4 text-neutral-300" />
)}
@@ -234,7 +234,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
<div key={cat.id} className="flex items-center gap-3 px-3 py-2 bg-neutral-50 dark:bg-neutral-800 rounded-md">
<button
onClick={() => setHeroPickerCategoryId(cat.id)}
className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 dark:border-neutral-700 overflow-hidden bg-neutral-100 dark:bg-neutral-700 hover:border-primary-400 transition-colors flex items-center justify-center"
className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 dark:border-neutral-700 overflow-hidden bg-neutral-100 dark:bg-neutral-700 hover:border-accent-dark transition-colors flex items-center justify-center"
title={t('categories.setCoverPhoto')}
>
{heroPhoto ? (
@@ -244,7 +244,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
className="w-full h-full object-cover"
/>
) : cat.hero_photo_id ? (
<ImageIcon className="w-4 h-4 text-primary-400" />
<ImageIcon className="w-4 h-4 text-accent" />
) : (
<ImageIcon className="w-4 h-4 text-neutral-300" />
)}
@@ -288,7 +288,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
onClick={() => handleSelectHeroPhoto(heroPickerCategoryId, photo.id)}
className={`relative cursor-pointer rounded-lg overflow-hidden border-2 transition-all ${
isSelected
? 'border-primary-500 ring-2 ring-primary-500 ring-offset-2'
? 'border-accent-dark ring-2 ring-primary-500 ring-offset-2'
: 'border-transparent hover:border-neutral-300'
}`}
>
@@ -300,7 +300,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
/>
</div>
{isSelected && (
<div className="absolute top-2 right-2 bg-primary-500 text-white rounded-full p-1">
<div className="absolute top-2 right-2 bg-accent-dark/150 text-white rounded-full p-1">
<Check className="w-4 h-4" />
</div>
)}
@@ -158,11 +158,11 @@ export const EventRenameDialog: React.FC<EventRenameDialogProps> = ({
</div>
{renameResult.newShareLink && (
<div className="p-3 bg-neutral-50 rounded-lg">
<p className="text-sm font-medium text-neutral-700 mb-1">
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg">
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-200 mb-1">
{t('events.rename.newLink', 'New Gallery Link')}
</p>
<p className="text-sm text-neutral-900 break-all">{renameResult.newShareLink}</p>
<p className="text-sm text-neutral-900 dark:text-neutral-100 break-all">{renameResult.newShareLink}</p>
</div>
)}
@@ -198,7 +198,7 @@ export const EventRenameDialog: React.FC<EventRenameDialogProps> = ({
// Renaming in progress
<div className="space-y-4 py-8">
<div className="flex flex-col items-center gap-4">
<Loader2 className="w-10 h-10 text-primary-600 animate-spin" />
<Loader2 className="w-10 h-10 text-accent animate-spin" />
<p className="text-neutral-700 font-medium">{renameStatus}</p>
</div>
</div>
@@ -258,7 +258,7 @@ export const EventRenameDialog: React.FC<EventRenameDialogProps> = ({
type="checkbox"
checked={resendEmail}
onChange={(e) => setResendEmail(e.target.checked)}
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
className="mt-1 w-4 h-4 text-accent border-neutral-300 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700 flex items-center gap-1">
@@ -94,28 +94,28 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
{!hasPending ? (
<div className="text-center py-8">
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-3" />
<p className="text-neutral-600">{t('feedback.noPendingComments', 'No comments pending moderation')}</p>
<p className="text-neutral-600 dark:text-neutral-300">{t('feedback.noPendingComments', 'No comments pending moderation')}</p>
</div>
) : (
<div className="space-y-3">
{pendingComments.slice(0, showAll ? undefined : maxItems).map((item) => (
<div key={item.id} className="border border-neutral-200 rounded-lg p-4 hover:bg-neutral-50">
<div key={item.id} className="border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 hover:bg-neutral-50 dark:hover:bg-neutral-800">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<div className="w-10 h-10 bg-neutral-100 rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-neutral-600" />
<div className="w-10 h-10 bg-neutral-100 dark:bg-neutral-800 rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-neutral-600 dark:text-neutral-300" />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-neutral-900">
<span className="font-medium text-neutral-900 dark:text-neutral-100">
{item.guest_name || t('feedback.anonymous', 'Anonymous')}
</span>
<span className="text-neutral-500"></span>
<span className="text-neutral-500">
<span className="text-neutral-500 dark:text-neutral-400"></span>
<span className="text-neutral-500 dark:text-neutral-400">
{format(
typeof item.created_at === 'string'
? parseISO(item.created_at)
@@ -191,7 +191,7 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
{pendingComments.length > maxItems && !showAll && (
<button
onClick={() => setShowAll(true)}
className="w-full text-center py-2 text-sm text-primary-600 hover:text-primary-700 font-medium"
className="w-full text-center py-2 text-sm text-accent hover:opacity-80 font-medium"
>
{t('feedback.showAll', 'Show all {{count}} pending comments', { count: pendingComments.length })}
</button>
@@ -203,7 +203,7 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
<div className="mt-4 pt-4 border-t border-neutral-200">
<a
href={`/admin/events/${eventId}/feedback`}
className="text-sm text-primary-600 hover:text-primary-700 font-medium flex items-center gap-1"
className="text-sm text-accent hover:opacity-80 font-medium flex items-center gap-1"
>
<MessageSquare className="w-4 h-4" />
{t('feedback.viewAllFeedback', 'View all feedback & settings')}
@@ -1,5 +1,5 @@
import React from 'react';
import { MessageSquare, Star, Heart, Bookmark, Shield, Eye } from 'lucide-react';
import { MessageSquare, Star, Heart, Bookmark, Shield, Eye, User, Users } from 'lucide-react';
import { Card } from '../common';
import { useTranslation } from 'react-i18next';
@@ -21,6 +21,7 @@ interface FeedbackSettings {
enable_rate_limiting: boolean;
rate_limit_window_minutes?: number;
rate_limit_max_requests?: number;
identity_mode?: 'simple' | 'guest';
}
export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
@@ -60,7 +61,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
type="checkbox"
checked={settings.feedback_enabled}
onChange={() => handleToggle('feedback_enabled')}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('feedback.settings.enableFeedback', 'Enable feedback')}
@@ -70,6 +71,74 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
{settings.feedback_enabled && (
<>
{/* Identity Mode */}
<div className="space-y-3">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('feedback.settings.identityMode', 'Identity Mode')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<label
className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer border transition ${
(settings.identity_mode || 'simple') === 'simple'
? 'border-accent-dark bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800'
}`}
>
<input
type="radio"
name="identity_mode"
value="simple"
checked={(settings.identity_mode || 'simple') === 'simple'}
onChange={() => onChange({ ...settings, identity_mode: 'simple' })}
className="mt-0.5 w-4 h-4 text-accent focus:ring-primary-500"
/>
<User className="w-5 h-5 mt-0.5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('feedback.settings.identityModeSimple', 'Simple feedback')}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t(
'feedback.settings.identityModeSimpleDesc',
'Anonymous, device-based. All visitors on the same device share state.'
)}
</div>
</div>
</label>
<label
className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer border transition ${
settings.identity_mode === 'guest'
? 'border-accent-dark bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800'
}`}
>
<input
type="radio"
name="identity_mode"
value="guest"
checked={settings.identity_mode === 'guest'}
onChange={() => onChange({ ...settings, identity_mode: 'guest' })}
className="mt-0.5 w-4 h-4 text-accent focus:ring-primary-500"
/>
<Users className="w-5 h-5 mt-0.5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('feedback.settings.identityModeGuest', 'Per-guest selections')}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t(
'feedback.settings.identityModeGuestDesc',
'Each visitor enters their name. Enables per-guest tracking and admin insights.'
)}
</div>
</div>
</label>
</div>
</div>
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4" />
{/* Feedback Types */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
@@ -81,7 +150,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
type="checkbox"
checked={settings.allow_ratings}
onChange={() => handleToggle('allow_ratings')}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/>
<Star className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1">
@@ -99,7 +168,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
type="checkbox"
checked={settings.allow_likes}
onChange={() => handleToggle('allow_likes')}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/>
<Heart className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1">
@@ -117,7 +186,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
type="checkbox"
checked={settings.allow_comments}
onChange={() => handleToggle('allow_comments')}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/>
<MessageSquare className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1">
@@ -135,7 +204,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
type="checkbox"
checked={settings.allow_favorites}
onChange={() => handleToggle('allow_favorites')}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/>
<Bookmark className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1">
@@ -163,7 +232,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
type="checkbox"
checked={settings.require_name_email}
onChange={() => handleToggle('require_name_email')}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/>
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
@@ -181,7 +250,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
checked={settings.moderate_comments}
onChange={() => handleToggle('moderate_comments')}
disabled={!settings.allow_comments}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500 disabled:opacity-50"
className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500 disabled:opacity-50"
/>
<Shield className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1">
@@ -199,7 +268,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
type="checkbox"
checked={settings.show_feedback_to_guests}
onChange={() => handleToggle('show_feedback_to_guests')}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/>
<Eye className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1">
@@ -223,7 +292,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
type="checkbox"
checked={settings.enable_rate_limiting}
onChange={() => handleToggle('enable_rate_limiting')}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/>
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
@@ -247,7 +316,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
max="60"
value={settings.rate_limit_window_minutes || 15}
onChange={(e) => handleNumberChange('rate_limit_window_minutes', e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-primary-500 focus:border-primary-500"
className="w-full px-3 py-1.5 text-sm border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-primary-500 focus:border-accent-dark"
/>
</div>
<div>
@@ -260,7 +329,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
max="100"
value={settings.rate_limit_max_requests || 10}
onChange={(e) => handleNumberChange('rate_limit_max_requests', e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-primary-500 focus:border-primary-500"
className="w-full px-3 py-1.5 text-sm border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-primary-500 focus:border-accent-dark"
/>
</div>
</div>
@@ -102,7 +102,7 @@ export const FocalPointPicker: React.FC<FocalPointPickerProps> = ({
onClick={() => onChange(p.value)}
className={
keywordToPercent(currentValue) === p.value
? 'bg-primary-50 border-primary-300 text-primary-700'
? 'bg-accent-dark/15 border-accent-dark/30 text-accent-dark'
: ''
}
>
@@ -166,7 +166,7 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
</div>
<div className="flex justify-center gap-1 mt-3">
{[0, 1, 2, 3].map((idx) => (
<div key={idx} className={`w-2 h-2 rounded-full ${idx === 0 ? 'bg-primary-600' : 'bg-neutral-300'}`} />
<div key={idx} className={`w-2 h-2 rounded-full ${idx === 0 ? 'bg-accent-dark' : 'bg-neutral-300'}`} />
))}
</div>
</div>
@@ -0,0 +1,194 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { X, Copy, Check, Trash2 } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button, Input, Loading } from '../common';
import { guestsService, GuestInvite } from '../../services/guests.service';
import { toast } from 'react-toastify';
interface GuestInviteDialogProps {
eventId: number;
eventName?: string;
onClose: () => void;
}
/**
* Admin dialog to create pre-minted invite tokens and list existing ones.
* Each invite generates a unique URL that the admin can send to a specific
* guest. Opening the URL auto-registers that guest (single use).
*/
export const GuestInviteDialog: React.FC<GuestInviteDialogProps> = ({ eventId, onClose }) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [copiedId, setCopiedId] = useState<number | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['admin-guest-invites', eventId],
queryFn: () => guestsService.listInvites(eventId),
});
const createMutation = useMutation({
mutationFn: () => guestsService.createInvite(eventId, { name, email: email || undefined }),
onSuccess: () => {
setName('');
setEmail('');
toast.success(t('admin.guests.inviteCreated', 'Invite created'));
queryClient.invalidateQueries({ queryKey: ['admin-guest-invites', eventId] });
queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] });
},
onError: () => toast.error(t('admin.guests.inviteCreateError', 'Failed to create invite')),
});
const revokeMutation = useMutation({
mutationFn: (inviteId: number) => guestsService.revokeInvite(eventId, inviteId),
onSuccess: () => {
toast.success(t('admin.guests.inviteRevoked', 'Invite revoked'));
queryClient.invalidateQueries({ queryKey: ['admin-guest-invites', eventId] });
},
onError: () => toast.error(t('admin.guests.inviteRevokeError', 'Failed to revoke invite')),
});
const copy = (invite: GuestInvite) => {
navigator.clipboard.writeText(invite.url).then(() => {
setCopiedId(invite.id);
setTimeout(() => setCopiedId(null), 1500);
});
};
const invites = data?.invites || [];
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 pt-16">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white dark:bg-neutral-900 rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('admin.guests.invitesTitle', 'Guest invites')}
</h2>
<button
type="button"
onClick={onClose}
className="p-1 text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="overflow-y-auto p-4 space-y-4">
{/* Create form */}
<div className="p-4 bg-neutral-50 dark:bg-neutral-800 rounded">
<h3 className="text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-3">
{t('admin.guests.createInvite', 'Create invite')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
<Input
label={t('admin.guests.inviteName', 'Guest name')}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Alice"
required
/>
<Input
type="email"
label={t('admin.guests.inviteEmail', 'Email (optional)')}
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
/>
</div>
<Button
variant="primary"
size="sm"
onClick={() => createMutation.mutate()}
disabled={!name.trim() || createMutation.isPending}
>
{createMutation.isPending
? t('common.submitting', 'Submitting...')
: t('admin.guests.generateInvite', 'Generate invite link')}
</Button>
</div>
{/* Existing invites */}
<div>
<h3 className="text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-2">
{t('admin.guests.existingInvites', 'Existing invites')}
</h3>
{isLoading ? (
<Loading size="sm" />
) : invites.length === 0 ? (
<div className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-4">
{t('admin.guests.noInvites', 'No invites yet')}
</div>
) : (
<div className="space-y-2">
{invites.map((invite) => (
<div
key={invite.id}
className="p-3 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-neutral-900 dark:text-neutral-100">
{invite.guest.name}
{invite.guest.email && (
<span className="text-neutral-500 dark:text-neutral-400 font-normal ml-2">
· {invite.guest.email}
</span>
)}
</div>
<div className="text-xs mt-1">
<span
className={`inline-block px-2 py-0.5 rounded-full font-medium ${
invite.status === 'redeemed'
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: invite.status === 'revoked'
? 'bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300'
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'
}`}
>
{t(`admin.guests.inviteStatus.${invite.status}`, invite.status)}
</span>
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 truncate mt-1 font-mono">
{invite.url}
</div>
</div>
<div className="flex gap-1">
{invite.status === 'pending' && (
<>
<button
type="button"
onClick={() => copy(invite)}
className="p-1.5 text-neutral-500 hover:text-accent"
title={t('admin.guests.copyLink', 'Copy link')}
>
{copiedId === invite.id ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
<button
type="button"
onClick={() => revokeMutation.mutate(invite.id)}
className="p-1.5 text-neutral-500 hover:text-red-600"
title={t('admin.guests.revokeInvite', 'Revoke')}
>
<Trash2 className="w-4 h-4" />
</button>
</>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,69 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Users } from 'lucide-react';
import { Card, Loading } from '../common';
import { guestsService } from '../../services/guests.service';
import { AuthenticatedImage } from '../common/AuthenticatedImage';
import { buildResourceUrl } from '../../utils/url';
interface GuestSelectionsAggregateProps {
eventId: number;
}
/**
* Shows photos sorted by the number of distinct guests who liked or
* favorited them. Photos with zero picks are filtered server-side.
*/
export const GuestSelectionsAggregate: React.FC<GuestSelectionsAggregateProps> = ({ eventId }) => {
const { t } = useTranslation();
const { data, isLoading } = useQuery({
queryKey: ['admin-guests-aggregate', eventId],
queryFn: () => guestsService.getAggregatePicks(eventId),
});
if (isLoading) {
return <Loading size="lg" text={t('admin.guests.loading', 'Loading...')} />;
}
const photos = data?.photos || [];
if (photos.length === 0) {
return (
<Card>
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">
{t('admin.guests.aggregate.empty', 'No guest picks yet.')}
</div>
</Card>
);
}
return (
<div className="space-y-3">
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{t(
'admin.guests.aggregate.description',
'Photos sorted by how many distinct guests liked or favorited them.'
)}
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{photos.map((p) => (
<div key={p.id} className="relative group">
<AuthenticatedImage
src={buildResourceUrl(p.thumbnail_url)}
alt={p.filename}
className="w-full aspect-square object-cover rounded"
/>
<div className="absolute top-2 right-2 bg-accent-dark text-white text-xs font-semibold px-2 py-1 rounded-full flex items-center gap-1 shadow">
<Users className="w-3 h-3" />
{p.picker_count}
</div>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity text-white text-xs p-2 rounded-b">
{p.original_filename || p.filename}
</div>
</div>
))}
</div>
</div>
);
};
@@ -132,7 +132,7 @@ export const HeroPhotoSelector: React.FC<HeroPhotoSelectorProps> = ({
onClick={() => handleSelect(photo.id)}
className={`relative cursor-pointer rounded-lg overflow-hidden border-2 transition-all ${
photo.id === selectedPhotoId
? 'border-primary-500 ring-2 ring-primary-500 ring-offset-2'
? 'border-accent-dark ring-2 ring-primary-500 ring-offset-2'
: 'border-transparent hover:border-neutral-300'
}`}
>
@@ -144,7 +144,7 @@ export const HeroPhotoSelector: React.FC<HeroPhotoSelectorProps> = ({
/>
</div>
{photo.id === selectedPhotoId && (
<div className="absolute top-2 right-2 bg-primary-500 text-white rounded-full p-1">
<div className="absolute top-2 right-2 bg-accent-dark/150 text-white rounded-full p-1">
<Check className="w-4 h-4" />
</div>
)}
@@ -27,14 +27,12 @@ export const MandatoryPasswordChangeModal: React.FC = () => {
mutationFn: adminService.changePassword,
onSuccess: () => {
toast.success(t('mandatoryPasswordChange.success'));
updatePasswordChanged();
// Reset form
setFormData({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
setErrors({});
// Force a full page reload so the browser picks up the new JWT cookie
// set by the backend. A React state update alone causes a race condition
// where the auth context checks the session before the cookie is stored.
setTimeout(() => {
window.location.href = '/admin/dashboard';
}, 2000);
},
onError: (error: any) => {
if (error.response?.data?.error) {
@@ -137,7 +135,7 @@ export const MandatoryPasswordChangeModal: React.FC = () => {
<button
type="button"
onClick={() => setShowPasswords(prev => ({ ...prev, current: !prev.current }))}
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded"
>
{showPasswords.current ?
<EyeOff className="w-4 h-4 text-neutral-500" /> :
@@ -165,7 +163,7 @@ export const MandatoryPasswordChangeModal: React.FC = () => {
<button
type="button"
onClick={() => setShowPasswords(prev => ({ ...prev, new: !prev.new }))}
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded"
>
{showPasswords.new ?
<EyeOff className="w-4 h-4 text-neutral-500" /> :
@@ -193,7 +191,7 @@ export const MandatoryPasswordChangeModal: React.FC = () => {
<button
type="button"
onClick={() => setShowPasswords(prev => ({ ...prev, confirm: !prev.confirm }))}
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded"
>
{showPasswords.confirm ?
<EyeOff className="w-4 h-4 text-neutral-500" /> :
@@ -30,14 +30,12 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
mutationFn: adminService.changePassword,
onSuccess: () => {
toast.success(t('passwordChange.success'));
onClose();
// Reset form
setFormData({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
setErrors({});
// Full page reload so the browser picks up the new JWT cookie.
// Same fix as MandatoryPasswordChangeModal — without this, the old
// token gets rejected and causes a redirect loop.
setTimeout(() => {
window.location.href = '/admin/dashboard';
}, 2000);
},
onError: (error: any) => {
if (error.response?.data?.error) {
@@ -1,53 +1,94 @@
import React, { useState } from 'react';
import { X, Key, Copy, CheckCircle, Mail } from 'lucide-react';
import { X, Key, Copy, CheckCircle, Mail, Lock, Eye, EyeOff } from 'lucide-react';
import { toast } from 'react-toastify';
import { Button, Card } from '../common';
import { useTranslation } from 'react-i18next';
import { Button, Card, Input, PasswordGenerator } from '../common';
interface PasswordResetModalProps {
eventName: string;
onConfirm: (sendEmail: boolean) => Promise<{ newPassword: string; emailSent: boolean }>;
eventDate?: string;
eventType?: string;
onConfirm: (sendEmail: boolean, password?: string) => Promise<{ newPassword: string; emailSent: boolean }>;
onClose: () => void;
}
export const PasswordResetModal: React.FC<PasswordResetModalProps> = ({
eventName,
eventDate,
eventType,
onConfirm,
onClose
}) => {
const [isResetting, setIsResetting] = useState(false);
const { t } = useTranslation();
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [sendEmail, setSendEmail] = useState(true);
const [newPassword, setNewPassword] = useState<string | null>(null);
const [isResetting, setIsResetting] = useState(false);
const [errors, setErrors] = useState<{ password?: string; confirmPassword?: string }>({});
const [resultPassword, setResultPassword] = useState<string | null>(null);
const [resultWasGenerated, setResultWasGenerated] = useState(false);
const [copied, setCopied] = useState(false);
const validate = (): boolean => {
const next: typeof errors = {};
// Empty is allowed → server auto-generates. Only validate when typed.
if (password) {
if (password.length < 6) {
next.password = t('events.passwordReset.errorMinLength');
}
if (password !== confirmPassword) {
next.confirmPassword = t('events.passwordReset.errorMismatch');
}
}
setErrors(next);
return Object.keys(next).length === 0;
};
const handleReset = async () => {
if (!validate()) return;
setIsResetting(true);
try {
const result = await onConfirm(sendEmail);
setNewPassword(result.newPassword);
toast.success('Password reset successfully');
} catch (error) {
toast.error('Failed to reset password');
onClose();
const supplied = password.length > 0 ? password : undefined;
const result = await onConfirm(sendEmail, supplied);
setResultPassword(result.newPassword);
setResultWasGenerated(!supplied);
if (supplied) {
toast.success(t('events.passwordReset.toastSuccess'));
}
} catch (error: any) {
const serverError = error?.response?.data;
if (serverError?.error === 'Password does not meet security requirements') {
setErrors({ password: serverError.feedback?.join?.(' ') || t('events.passwordReset.errorMinLength') });
} else {
toast.error(serverError?.error || t('events.passwordReset.toastError'));
}
} finally {
setIsResetting(false);
}
};
const handleCopy = async () => {
if (newPassword) {
await navigator.clipboard.writeText(newPassword);
if (resultPassword) {
await navigator.clipboard.writeText(resultPassword);
setCopied(true);
toast.success('Password copied to clipboard');
toast.success(t('events.passwordReset.toastCopied'));
setTimeout(() => setCopied(false), 2000);
}
};
const handlePasswordGenerated = (generated: string) => {
setPassword(generated);
setConfirmPassword(generated);
setErrors({});
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="max-w-md w-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">
{newPassword ? 'New Password' : 'Reset Gallery Password'}
{resultPassword ? t('events.passwordReset.newTitle') : t('events.passwordReset.title')}
</h2>
<button
onClick={onClose}
@@ -57,30 +98,82 @@ export const PasswordResetModal: React.FC<PasswordResetModalProps> = ({
</button>
</div>
{!newPassword ? (
{!resultPassword ? (
<>
<p className="text-neutral-600 mb-6">
Are you sure you want to reset the password for <strong>{eventName}</strong>?
This will generate a new password for gallery access.
<p className="text-neutral-600 mb-4">
{t('events.passwordReset.description', { eventName })}
</p>
<div className="mb-6">
<div className="space-y-4 mb-4">
<div>
<Input
type={showPassword ? 'text' : 'password'}
label={t('events.passwordReset.newPasswordLabel')}
placeholder={t('events.passwordReset.placeholder')}
value={password}
onChange={(e) => {
setPassword(e.target.value);
if (errors.password) setErrors((prev) => ({ ...prev, password: undefined }));
}}
error={errors.password}
helperText={t('events.passwordReset.helperText')}
leftIcon={<Lock className="w-5 h-5" />}
rightIcon={
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="p-1"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
}
/>
<div className="mt-2">
<PasswordGenerator
eventName={eventName}
eventDate={eventDate}
eventType={eventType}
onPasswordGenerated={handlePasswordGenerated}
passwordComplexity="moderate"
className="w-full"
/>
</div>
</div>
{password.length > 0 && (
<Input
type={showPassword ? 'text' : 'password'}
label={t('events.passwordReset.confirmLabel')}
placeholder={t('events.passwordReset.confirmLabel')}
value={confirmPassword}
onChange={(e) => {
setConfirmPassword(e.target.value);
if (errors.confirmPassword) setErrors((prev) => ({ ...prev, confirmPassword: undefined }));
}}
error={errors.confirmPassword}
leftIcon={<Lock className="w-5 h-5" />}
/>
)}
</div>
<div className="mb-4">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={sendEmail}
onChange={(e) => setSendEmail(e.target.checked)}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500 focus:ring-2"
className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500 focus:ring-2"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-neutral-500" />
<span className="text-sm font-medium text-neutral-700">
Send email notification
{t('events.passwordReset.sendEmail')}
</span>
</div>
<p className="text-xs text-neutral-500 mt-1">
Notify the host about the password change
{t('events.passwordReset.sendEmailHelp')}
</p>
</div>
</label>
@@ -88,8 +181,7 @@ export const PasswordResetModal: React.FC<PasswordResetModalProps> = ({
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-6">
<p className="text-sm text-amber-800">
<strong>Note:</strong> The old password will no longer work.
Make sure to share the new password with the host.
{t('events.passwordReset.warning')}
</p>
</div>
@@ -100,7 +192,7 @@ export const PasswordResetModal: React.FC<PasswordResetModalProps> = ({
disabled={isResetting}
className="flex-1"
>
Cancel
{t('common.cancel')}
</Button>
<Button
variant="primary"
@@ -110,7 +202,7 @@ export const PasswordResetModal: React.FC<PasswordResetModalProps> = ({
leftIcon={<Key className="w-4 h-4" />}
className="flex-1"
>
Reset Password
{t('events.passwordReset.submit')}
</Button>
</div>
</>
@@ -119,52 +211,56 @@ export const PasswordResetModal: React.FC<PasswordResetModalProps> = ({
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<div className="flex items-center gap-3 mb-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<p className="font-medium text-green-900">Password reset successfully!</p>
<p className="font-medium text-green-900">{t('events.passwordReset.successHeading')}</p>
</div>
{sendEmail && (
<p className="text-sm text-green-700">
An email notification has been sent to the host.
{t('events.passwordReset.emailSentNote')}
</p>
)}
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-neutral-700 mb-2">
New Gallery Password
</label>
<div className="flex gap-2">
<input
type="text"
value={newPassword}
readOnly
className="flex-1 px-3 py-2 bg-neutral-50 border border-neutral-300 rounded-lg font-mono text-sm"
/>
<Button
variant="outline"
onClick={handleCopy}
leftIcon={copied ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
>
{copied ? 'Copied!' : 'Copy'}
</Button>
</div>
</div>
{resultWasGenerated && (
<>
<div className="mb-4">
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('events.passwordReset.generatedLabel')}
</label>
<div className="flex gap-2">
<input
type="text"
value={resultPassword}
readOnly
className="flex-1 px-3 py-2 bg-neutral-50 dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-700 text-neutral-900 dark:text-neutral-100 rounded-lg font-mono text-sm"
/>
<Button
variant="outline"
onClick={handleCopy}
leftIcon={copied ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
>
{copied ? t('events.copied') : t('events.copy')}
</Button>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-6">
<p className="text-sm text-blue-800">
<strong>Important:</strong> Save this password securely. It cannot be recovered once you close this window.
</p>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-6">
<p className="text-sm text-blue-800">
{t('events.passwordReset.saveSecurelyNote')}
</p>
</div>
</>
)}
<Button
variant="primary"
onClick={onClose}
className="w-full"
>
Done
{t('events.passwordReset.done')}
</Button>
</>
)}
</Card>
</div>
);
};
};
@@ -126,7 +126,7 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
)}
{t('export.button', 'Export')}
{hasSelection && (
<span className="bg-primary-100 text-primary-700 text-xs px-2 py-0.5 rounded-full">
<span className="bg-accent-dark/15 text-accent-dark text-xs px-2 py-0.5 rounded-full">
{selectedPhotoIds.length}
</span>
)}
@@ -85,7 +85,7 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
<select
value={filters.minRating ?? ''}
onChange={(e) => handleRatingChange(e.target.value === '' ? null : parseFloat(e.target.value))}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
disabled={isLoading}
>
{RATING_OPTIONS.map(option => (
@@ -103,7 +103,7 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
type="checkbox"
checked={filters.hasLikes || false}
onChange={() => handleCheckboxChange('hasLikes')}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
className="rounded border-neutral-300 text-accent focus:ring-primary-500"
disabled={isLoading}
/>
<Heart className="w-4 h-4 text-red-500" />
@@ -120,7 +120,7 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
type="checkbox"
checked={filters.hasFavorites || false}
onChange={() => handleCheckboxChange('hasFavorites')}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
className="rounded border-neutral-300 text-accent focus:ring-primary-500"
disabled={isLoading}
/>
<Bookmark className="w-4 h-4 text-yellow-500" />
@@ -137,7 +137,7 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
type="checkbox"
checked={filters.hasComments || false}
onChange={() => handleCheckboxChange('hasComments')}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
className="rounded border-neutral-300 text-accent focus:ring-primary-500"
disabled={isLoading}
/>
<MessageCircle className="w-4 h-4 text-blue-500" />
@@ -160,7 +160,7 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
onClick={() => handleLogicChange('AND')}
className={`px-3 py-1 text-sm font-medium transition-colors ${
filters.logic === 'AND' || !filters.logic
? 'bg-primary-600 text-white'
? 'bg-accent-dark text-white'
: 'bg-white dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-700'
}`}
disabled={isLoading}
@@ -172,7 +172,7 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
onClick={() => handleLogicChange('OR')}
className={`px-3 py-1 text-sm font-medium transition-colors ${
filters.logic === 'OR'
? 'bg-primary-600 text-white'
? 'bg-accent-dark text-white'
: 'bg-white dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-700'
}`}
disabled={isLoading}
@@ -60,7 +60,7 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
const numeric = Number(raw);
onCategoryChange(Number.isNaN(numeric) ? raw : numeric);
}}
className="px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
className="px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
>
<option value="">{t('gallery.allCategories', 'All Categories')}</option>
<option value="0">{t('gallery.uncategorized', 'Uncategorized')}</option>
@@ -78,7 +78,7 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
<select
value={mediaType}
onChange={(e) => onMediaTypeChange(e.target.value as 'all' | 'photo' | 'video')}
className="px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
className="px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
>
<option value="all">{t('gallery.allMedia', 'All media')}</option>
<option value="photo">{t('gallery.photosOnly', 'Photos only')}</option>
@@ -92,7 +92,7 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
<select
value={sortBy}
onChange={(e) => onSortChange(e.target.value as 'date' | 'name' | 'size' | 'rating', sortOrder)}
className="px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
className="px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
>
<option value="date">{t('gallery.sortByDate', 'Sort by Date')}</option>
<option value="name">{t('gallery.sortByName', 'Sort by Name')}</option>
+203 -38
View File
@@ -1,5 +1,5 @@
import React, { useState, useRef, useMemo } from 'react';
import { Upload, X, Image, Loader2 } from 'lucide-react';
import React, { useState, useRef, useMemo, useEffect } from 'react';
import { Upload, X, Image, Loader2, Cog } from 'lucide-react';
import { Button } from '../common';
import { clsx } from 'clsx';
import { api } from '../../config/api';
@@ -9,6 +9,7 @@ import { categoriesService } from '../../services/categories.service';
import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes';
import { useUploadProgress } from '../../hooks/useUploadProgress';
interface PhotoUploadProps {
eventId: number;
@@ -18,6 +19,15 @@ interface PhotoUploadProps {
const DEFAULT_MAX_FILES_PER_UPLOAD = 500;
const MAX_FILES_PER_UPLOAD_LIMIT = 2000;
// Upload phase machine. The user perceives "frozen" during 'processing'
// because the bytes are already on the server and we're waiting for
// thumbnail/EXIF/etc. work — the explicit phase + hint message kills
// that perception (#352 / contributor analysis on issue 357 review).
type UploadPhase =
| { kind: 'idle' }
| { kind: 'transferring'; chunkIndex: number; totalChunks: number; bytePct: number }
| { kind: 'processing'; chunkIndex: number; totalChunks: number; filesInChunk: number };
export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadComplete }) => {
const { t } = useTranslation();
const [isUploading, setIsUploading] = useState(false);
@@ -25,8 +35,18 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
const [uploadProgress, setUploadProgress] = useState(0);
const [currentChunk, setCurrentChunk] = useState(0);
const [totalChunks, setTotalChunks] = useState(0);
const [phase, setPhase] = useState<UploadPhase>({ kind: 'idle' });
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
const [replaceByName, setReplaceByName] = useState(false);
// Upload IDs returned from each chunk POST. The processing tracker
// hook merges status across all of them so the user sees one unified
// progress count even when the upload spans multiple HTTP requests.
const [uploadIds, setUploadIds] = useState<string[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const { aggregate: processingAggregate } = useUploadProgress(uploadIds, {
enabled: phase.kind === 'processing' && uploadIds.length > 0,
});
// Fetch categories for this event
const { data: categories = [] } = useQuery({
@@ -106,6 +126,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
setIsUploading(true);
setUploadProgress(0);
setUploadIds([]);
// For large uploads, chunk the files by both count AND size to prevent memory/network issues
const MAX_FILES_PER_CHUNK = Math.max(1, Math.min(50, maxFilesPerUpload)); // Max 50 files per chunk
@@ -135,6 +156,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
setTotalChunks(chunks.length);
let totalUploaded = 0;
let totalReplaced = 0;
let failedFiles = [];
try {
@@ -142,68 +164,150 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
setCurrentChunk(chunkIndex + 1);
const chunk = chunks[chunkIndex];
const formData = new FormData();
chunk.forEach((file) => {
formData.append('photos', file);
});
if (selectedCategoryId) {
formData.append('category_id', selectedCategoryId.toString());
}
if (replaceByName) {
formData.append('replace_by_name', 'true');
}
setPhase({
kind: 'transferring',
chunkIndex,
totalChunks: chunks.length,
bytePct: 0,
});
try {
await api.post(`/admin/events/${eventId}/upload`, formData, {
const response = await api.post(`/admin/events/${eventId}/upload`, formData, {
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
// Calculate overall progress across all chunks
const chunkProgress = progressEvent.loaded / progressEvent.total;
const overallProgress = ((chunkIndex + chunkProgress) / chunks.length) * 100;
setUploadProgress(Math.round(overallProgress));
// Once bytes have all left the browser, the request is
// sitting in the backend processing pipeline. Flip to
// 'processing' so the UI explains the wait instead of
// looking frozen at the chunk's max progress.
if (chunkProgress >= 1) {
setPhase((prev) =>
prev.kind === 'transferring' && prev.chunkIndex === chunkIndex
? {
kind: 'processing',
chunkIndex,
totalChunks: chunks.length,
filesInChunk: chunk.length,
}
: prev
);
} else {
setPhase({
kind: 'transferring',
chunkIndex,
totalChunks: chunks.length,
bytePct: Math.round(chunkProgress * 100),
});
}
}
},
});
totalUploaded += chunk.length;
totalUploaded += (response.data?.successCount || chunk.length);
totalReplaced += (response.data?.replacedCount || 0);
// Backend returns a per-request upload_id. Track it so the
// processing-status hook can poll/stream live progress.
if (response.data?.upload_id) {
const newId = response.data.upload_id as string;
setUploadIds((prev) => (prev.includes(newId) ? prev : [...prev, newId]));
}
} catch (error: any) {
console.error(`Error uploading chunk ${chunkIndex + 1}:`, error);
failedFiles.push(...chunk.map(f => f.name));
// Continue with next chunk even if one fails
continue;
}
}
// Clear selected files
// Bytes are all on the server. Clear the file picker so the
// user can queue another batch — but DON'T dismiss the upload
// UI yet; we'll watch the processing aggregate (useEffect below)
// to know when the backend has finished generating thumbnails
// and metadata.
setSelectedFiles([]);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
// Show appropriate message
if (failedFiles.length === 0) {
toast.success(t('upload.uploadComplete') || `Successfully uploaded ${totalUploaded} files`);
} else {
if (totalReplaced > 0) {
toast.info(t('upload.replacedFiles', { count: totalReplaced }) || `${totalReplaced} photo(s) replaced`);
}
if (failedFiles.length > 0) {
toast.warning(
t('upload.someFilesFailed') ||
`Uploaded ${totalUploaded} files. ${failedFiles.length} files failed.`
t('upload.someFilesFailed') ||
`Transferred ${totalUploaded} files. ${failedFiles.length} files failed to transfer.`
);
}
// Call callback
// Refresh the grid early so the user sees their photos appearing
// as the worker processes them. The processing-aggregate effect
// below will refresh again on completion.
if (onUploadComplete) {
onUploadComplete();
}
// If the backend never returned an upload_id (e.g. only failures
// or pre-async-backend deployment), we have nothing to wait for —
// fall through to the finally cleanup which resets state.
} catch (error: any) {
console.error('Upload error:', error);
toast.error(error.response?.data?.error || t('toast.uploadError'));
} finally {
setIsUploading(false);
setUploadProgress(0);
setCurrentChunk(0);
setTotalChunks(0);
setPhase({ kind: 'idle' });
setUploadIds([]);
}
};
// When the background worker finishes processing every photo from
// this upload, dismiss the upload UI and surface the result.
useEffect(() => {
if (!isUploading) return;
if (uploadIds.length === 0) return;
if (!processingAggregate.isComplete) return;
if (processingAggregate.failed > 0) {
toast.warning(
t('upload.processingFailed', { count: processingAggregate.failed }) ||
`${processingAggregate.failed} photo(s) failed to process`
);
} else {
toast.success(
t('upload.uploadComplete') || `Successfully uploaded ${processingAggregate.complete} photo(s)`
);
}
if (onUploadComplete) onUploadComplete();
setIsUploading(false);
setUploadProgress(0);
setCurrentChunk(0);
setTotalChunks(0);
setPhase({ kind: 'idle' });
setUploadIds([]);
// We intentionally only react to processingAggregate.isComplete /
// .failed — the rest of the deps either don't move during this
// effect's lifetime or are stable callbacks.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [processingAggregate.isComplete, processingAggregate.failed, isUploading]);
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
@@ -231,12 +335,26 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
</select>
</div>
{/* Replace by name toggle */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="replace-by-name"
checked={replaceByName}
onChange={(e) => setReplaceByName(e.target.checked)}
className="rounded border-neutral-300 text-accent focus:ring-primary-500"
/>
<label htmlFor="replace-by-name" className="text-sm text-neutral-700 dark:text-neutral-300">
{t('upload.replaceByName', 'Replace existing photos with same name')}
</label>
</div>
{/* File Input Area */}
<div
className={clsx(
"border-2 border-dashed rounded-lg p-8 text-center transition-colors",
"hover:border-primary-400 hover:bg-primary-50/50",
selectedFiles.length > 0 ? "border-primary-400 bg-primary-50/30 dark:bg-primary-900/20" : "border-neutral-300 dark:border-neutral-600"
"hover:border-accent-dark hover:bg-accent-dark/15",
selectedFiles.length > 0 ? "border-accent-dark bg-accent-dark/15" : "border-neutral-300 dark:border-neutral-600"
)}
onClick={() => fileInputRef.current?.click()}
>
@@ -321,26 +439,73 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
</Button>
</div>
{/* Progress Bar */}
{/* Progress display two distinct phases. Bytes-on-wire ('transferring')
drives the determinate bar; the post-bytes wait ('processing') swaps
in an indeterminate spinner with an explanatory hint so users don't
assume the upload froze. */}
{isUploading && (
<div className="mt-4">
<div className="flex justify-between text-sm text-neutral-600 dark:text-neutral-400 mb-1">
<span>
{t('upload.uploading')}
{totalChunks > 1 && ` (${t('common.chunk')} ${currentChunk}/${totalChunks})`}
</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-neutral-200 dark:bg-neutral-700 rounded-full h-2">
<div
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
{totalChunks > 1 && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('upload.uploadingChunks', { count: selectedFiles.length, total: totalChunks })}
</p>
{phase.kind === 'processing' ? (
<div className="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-4">
<div className="flex items-start gap-3">
<Cog className="w-5 h-5 text-amber-600 dark:text-amber-400 animate-spin shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
{t('upload.processing')}
</p>
{processingAggregate.total > 0 && (
<>
<p className="text-xs text-amber-900 dark:text-amber-100 font-medium mt-2">
{t('upload.processingProgress', {
complete: processingAggregate.complete + processingAggregate.failed,
total: processingAggregate.total,
})}
</p>
<div className="w-full bg-amber-100 dark:bg-amber-900/40 rounded-full h-2 mt-1">
<div
className="bg-amber-600 dark:bg-amber-500 h-2 rounded-full transition-all duration-300"
style={{
width: `${
processingAggregate.total === 0
? 0
: Math.round(
((processingAggregate.complete + processingAggregate.failed) /
processingAggregate.total) *
100
)
}%`,
}}
/>
</div>
</>
)}
<p className="text-xs text-amber-800 dark:text-amber-200 mt-2">
{t('upload.processingHint')}
</p>
</div>
</div>
</div>
) : (
<>
<div className="flex justify-between text-sm text-neutral-600 dark:text-neutral-400 mb-1">
<span>
{t('upload.transferring')}
{totalChunks > 1 && ` (${t('common.chunk')} ${currentChunk}/${totalChunks})`}
</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-neutral-200 dark:bg-neutral-700 rounded-full h-2">
<div
className="bg-accent-dark h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
{totalChunks > 1 && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('upload.uploadingChunks', { count: selectedFiles.length, total: totalChunks })}
</p>
)}
</>
)}
</div>
)}
@@ -196,7 +196,7 @@ export const RestoreWizard = () => {
onClick={() => setRestoreData(prev => ({ ...prev, source: 'local' }))}
className={`p-6 rounded-lg border-2 transition-all ${
restoreData.source === 'local'
? 'border-primary bg-primary-50 dark:bg-primary-900/30'
? 'border-primary bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
}`}
>
@@ -211,7 +211,7 @@ export const RestoreWizard = () => {
onClick={() => setRestoreData(prev => ({ ...prev, source: 's3' }))}
className={`p-6 rounded-lg border-2 transition-all ${
restoreData.source === 's3'
? 'border-primary bg-primary-50 dark:bg-primary-900/30'
? 'border-primary bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
}`}
>
@@ -226,7 +226,7 @@ export const RestoreWizard = () => {
onClick={() => setRestoreData(prev => ({ ...prev, source: 'upload' }))}
className={`p-6 rounded-lg border-2 transition-all ${
restoreData.source === 'upload'
? 'border-primary bg-primary-50 dark:bg-primary-900/30'
? 'border-primary bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
}`}
>
@@ -312,7 +312,7 @@ export const RestoreWizard = () => {
key={backup.id}
className={`p-4 cursor-pointer transition-all ${
restoreData.selectedBackup?.id === backup.id
? 'ring-2 ring-primary bg-primary-50 dark:bg-primary-900/30'
? 'ring-2 ring-primary bg-accent-dark/15'
: 'hover:shadow-md'
}`}
onClick={() => setRestoreData(prev => ({ ...prev, selectedBackup: backup }))}
@@ -388,7 +388,7 @@ export const RestoreWizard = () => {
onClick={() => setRestoreData(prev => ({ ...prev, restoreType: type.id }))}
className={`p-4 rounded-lg border-2 text-left transition-all ${
restoreData.restoreType === type.id
? 'border-primary bg-primary-50 dark:bg-primary-900/30'
? 'border-primary bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
}`}
>
@@ -100,14 +100,14 @@ export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
onClick={() => handlePresetSelect(key)}
className={`relative p-4 rounded-lg border-2 transition-all ${
selectedPreset === key
? 'border-primary-600 bg-primary-50'
? 'tile-selected'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-sm">{theme.name}</span>
{selectedPreset === key && (
<Check className="w-4 h-4 text-primary-600" />
<Check className="w-4 h-4 text-accent" />
)}
</div>
<div className="flex gap-2">
@@ -247,7 +247,7 @@ export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
onClick={() => handleChange('borderRadius', radius)}
className={`px-4 py-2 rounded-lg border-2 transition-all ${
localTheme.borderRadius === radius
? 'border-primary-600 bg-primary-50'
? 'tile-selected'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
File diff suppressed because it is too large Load Diff
+17 -19
View File
@@ -93,32 +93,30 @@ export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
{showDetails && (
<>
{/* Color Palette */}
{/* Color Palette show all 8 tokens of the active theme.
Each swatch only renders if its token is set so legacy themes
(pre-8-token migration) still render their original 4 swatches. */}
<div className="flex items-center gap-2">
<Palette className="w-4 h-4 text-neutral-500 dark:text-neutral-300" />
<span className="text-sm text-neutral-600 dark:text-neutral-200">{t('branding.colors')}:</span>
<div className="flex gap-1">
{themeConfig.primaryColor && (
{[
{ value: themeConfig.backgroundColor, title: t('branding.backgroundColor', 'Background') },
{ value: themeConfig.surfaceColor, title: t('branding.surfaceColor', 'Surface') },
{ value: themeConfig.elevatedColor, title: t('branding.elevatedColor', 'Elevated') },
{ value: themeConfig.surfaceBorderColor, title: t('branding.borderColor', 'Border') },
{ value: themeConfig.textColor, title: t('branding.textColor', 'Text') },
{ value: themeConfig.mutedTextColor, title: t('branding.mutedTextColor', 'Muted text') },
{ value: themeConfig.accentColor, title: t('branding.accentColor', 'Accent') },
{ value: themeConfig.accentDarkColor || themeConfig.primaryColor, title: t('branding.accentDarkColor', 'Accent (filled)') },
].filter((s) => !!s.value).map((s, i) => (
<div
key={i}
className="w-6 h-6 rounded border border-neutral-300 dark:border-neutral-600"
style={{ backgroundColor: themeConfig.primaryColor }}
title={t('branding.primaryColor')}
style={{ backgroundColor: s.value }}
title={s.title}
/>
)}
{themeConfig.accentColor && (
<div
className="w-6 h-6 rounded border border-neutral-300 dark:border-neutral-600"
style={{ backgroundColor: themeConfig.accentColor }}
title={t('branding.accentColor')}
/>
)}
{themeConfig.backgroundColor && (
<div
className="w-6 h-6 rounded border border-neutral-300 dark:border-neutral-600"
style={{ backgroundColor: themeConfig.backgroundColor }}
title={t('branding.backgroundColor')}
/>
)}
))}
</div>
</div>
@@ -116,20 +116,20 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="px-6 py-4 border-b border-neutral-200 flex items-center justify-between">
<div className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-neutral-900">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{t('events.galleryTheme')}
</h2>
<p className="text-sm text-neutral-600 mt-1">
<p className="text-sm text-neutral-600 dark:text-neutral-300 mt-1">
{t('events.customizingThemeFor', { event: eventName })}
</p>
</div>
<button
onClick={onClose}
className="text-neutral-400 hover:text-neutral-600 transition-colors"
className="text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
>
<X className="w-6 h-6" />
</button>
@@ -139,7 +139,7 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 h-full">
{/* Left side - Theme Customizer */}
<div className="p-6 overflow-y-auto border-r border-neutral-200">
<div className="p-6 overflow-y-auto border-r border-neutral-200 dark:border-neutral-700">
<ThemeCustomizerEnhanced
value={theme}
onChange={handleThemeChange}
@@ -155,11 +155,11 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
</div>
{/* Right side - Gallery Preview */}
<div className="p-6 bg-neutral-50 overflow-y-auto">
<div className="p-6 bg-neutral-50 dark:bg-neutral-800 overflow-y-auto">
<div className="space-y-4">
{/* Grid Style Selector */}
<div>
<h3 className="text-sm font-medium text-neutral-700 mb-3">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-200 mb-3">
{t('branding.previewLayout')}
</h3>
<div className="grid grid-cols-3 gap-2">
@@ -169,12 +169,12 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
onClick={() => setPreviewLayout(layout)}
className={`relative p-3 rounded-lg border-2 transition-all ${
(previewLayout || theme.galleryLayout || 'grid') === layout
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300 bg-white'
? 'tile-selected'
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600 bg-white dark:bg-neutral-900'
}`}
>
<div className="flex flex-col items-center gap-1">
<div className="text-neutral-700">
<div className="text-neutral-700 dark:text-neutral-200">
{layoutIcons[layout]}
</div>
<span className="text-xs capitalize">
@@ -185,7 +185,7 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
</span>
</div>
{(previewLayout || theme.galleryLayout || 'grid') === layout && (
<Check className="absolute top-1 right-1 w-3 h-3 text-primary-600" />
<Check className="absolute top-1 right-1 w-3 h-3 text-accent" />
)}
</button>
))}
@@ -43,7 +43,7 @@ export const WelcomeMessageEditor: React.FC<WelcomeMessageEditorProps> = ({
onChange={handleChange}
placeholder={placeholder}
rows={rows}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 placeholder-neutral-400 dark:placeholder-neutral-500 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors resize-none font-mono text-sm"
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 placeholder-neutral-400 dark:placeholder-neutral-500 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark transition-colors resize-none font-mono text-sm"
/>
<div className="absolute top-2 right-2 text-neutral-400" title="Line breaks will be preserved in emails">
<HelpCircle className="w-4 h-4" aria-hidden="true" />
@@ -300,7 +300,7 @@ export const WordFilterManager: React.FC = () => {
type="checkbox"
checked={filter.is_active}
onChange={() => handleToggleActive(filter)}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
className="w-4 h-4 text-accent rounded focus:ring-primary-500"
/>
<span className="font-medium text-neutral-900 dark:text-neutral-100">{filter.word}</span>
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${getSeverityBadgeClass(filter.severity)}`}>
+5
View File
@@ -10,6 +10,7 @@ export { EventCategoryManager } from './EventCategoryManager';
export { CMSEditor } from './CMSEditor';
export { WelcomeMessageEditor } from './WelcomeMessageEditor';
export { BulkArchiveModal } from './BulkArchiveModal';
export { BulkDeleteModal } from './BulkDeleteModal';
export { MaintenanceBanner } from './MaintenanceBanner';
export { EmailPreviewModal } from './EmailPreviewModal';
export { AdminPhotoGrid } from './AdminPhotoGrid';
@@ -36,3 +37,7 @@ export { EventRenameDialog } from './EventRenameDialog';
export { PhotoFilterPanel } from './PhotoFilterPanel';
export { PhotoExportMenu } from './PhotoExportMenu';
export { CssTemplateEditor } from './CssTemplateEditor';
export { AdminGuestsList } from './AdminGuestsList';
export { AdminGuestDetail } from './AdminGuestDetail';
export { GuestSelectionsAggregate } from './GuestSelectionsAggregate';
export { GuestInviteDialog } from './GuestInviteDialog';
@@ -0,0 +1,143 @@
import React, { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import DOMPurify from 'dompurify';
import { Card } from './Card';
import { Loading } from './Loading';
import { cmsService } from '../../services/cms.service';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { buildResourceUrl } from '../../utils/url';
import '../../styles/prose-overrides.css';
interface CMSContentBlockProps {
/** CMS page slug, e.g. "not-found" or "gallery-not-found". */
slug: string;
/** Rendered when the slug doesn't exist or the fetch fails so the
* caller is never left with a blank screen during cold deployments. */
fallback?: React.ReactNode;
}
const ALLOWED_TAGS = [
'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li', 'blockquote', 'a', 'em', 'strong',
'code', 'pre', 'hr', 'div', 'span', 'img',
];
const ALLOWED_ATTR = ['href', 'target', 'rel', 'class', 'style', 'src', 'alt', 'title'];
/**
* Renders a CMS page inside the standard branded shell. Used for the
* customisable 404 and gallery-not-found pages (#324). Logo precedence:
* per-page logo global branding logo bundled placeholder.
*/
export const CMSContentBlock: React.FC<CMSContentBlockProps> = ({ slug, fallback }) => {
const { i18n } = useTranslation();
const { data: settings } = usePublicSettings();
const lang = settings?.default_language || i18n.language || 'en';
const { data: page, isLoading, error } = useQuery({
queryKey: ['cms-public-page', slug, lang],
queryFn: () => cmsService.getPublicPage(slug, lang),
enabled: !!slug,
retry: false,
});
useEffect(() => {
if (page?.title) document.title = `${page.title} - ${settings?.branding_company_name || 'PicPeak'}`;
}, [page?.title, settings?.branding_company_name]);
if (isLoading) {
return (
<div
className="min-h-screen flex items-center justify-center"
style={{ backgroundColor: 'var(--color-background, #fafafa)' }}
>
<Loading size="lg" />
</div>
);
}
if (error || !page) {
return <>{fallback ?? null}</>;
}
// Logo: per-page override beats global branding logo.
const rawLogo = page.logo_url || settings?.branding_logo_url || '/picpeak-logo-transparent.png';
const logoSrc = rawLogo.startsWith('http') || rawLogo.startsWith('/picpeak-')
? rawLogo
: buildResourceUrl(rawLogo);
const companyName = settings?.branding_company_name || 'PicPeak';
return (
<div className="min-h-screen flex flex-col" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="p-8 text-center">
<img
src={logoSrc}
alt={companyName}
className="h-16 w-auto object-contain mx-auto"
/>
</div>
<main className="flex-1 flex items-start justify-center px-4">
<div className="max-w-2xl w-full">
<Card padding="lg">
{/*
* Heading + body now read from theme tokens so dark themes
* (and force-dark mode) render correctly without dark: variants
* fighting the CSS variables.
*/}
<h1
className="text-2xl sm:text-3xl font-bold mb-6"
style={{ color: 'var(--color-text)' }}
>
{page.title}
</h1>
<div
className="prose prose-neutral dark:prose-invert max-w-none"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(page.content, {
ALLOWED_TAGS,
ALLOWED_ATTR,
ALLOW_DATA_ATTR: false,
KEEP_CONTENT: true,
}),
}}
/>
<div className="mt-8">
<Link
to="/"
className="text-sm font-medium hover:underline"
style={{ color: 'var(--color-accent)' }}
>
{lang === 'de' ? '← Zur Startseite' : '← Back to home'}
</Link>
</div>
</Card>
</div>
</main>
<footer
className="py-8 text-center text-xs"
style={{ color: 'var(--color-muted-text)' }}
>
<div className="flex justify-center gap-4">
<Link to="/impressum" className="hover:underline">
{lang === 'de' ? 'Impressum' : 'Legal Notice'}
</Link>
<span style={{ color: 'var(--color-surface-border)' }}></span>
<Link to="/datenschutz" className="hover:underline">
{lang === 'de' ? 'Datenschutz' : 'Privacy Policy'}
</Link>
</div>
{!settings?.branding_hide_powered_by && (
<p className="mt-2">
Powered by <span className="font-semibold">PicPeak</span>
</p>
)}
</footer>
</div>
);
};
@@ -1,25 +1,11 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getApiBaseUrl, buildResourceUrl } from '../../utils/url';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { buildResourceUrl } from '../../utils/url';
const DEFAULT_TITLE = 'PicPeak - Photo Sharing Platform';
export const DynamicFavicon: React.FC = () => {
const { data: settings } = useQuery({
queryKey: ['public-settings'],
queryFn: async () => {
try {
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
if (response.ok) {
return response.json();
}
return null;
} catch {
return null;
}
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
const { data: settings } = usePublicSettings({ retry: false });
// Update favicon when branding settings change
useEffect(() => {
@@ -40,7 +26,7 @@ export const DynamicFavicon: React.FC = () => {
}
}, [settings?.branding_favicon_url]);
// Update document title when company name or tagline changes
// Update document title and OG meta tags when company name or tagline changes
useEffect(() => {
const companyName = settings?.branding_company_name?.trim();
const tagline = settings?.branding_company_tagline?.trim();
@@ -52,7 +38,34 @@ export const DynamicFavicon: React.FC = () => {
} else {
document.title = DEFAULT_TITLE;
}
// Update OG meta tags
const title = companyName || 'PicPeak';
const description = tagline || 'Photo Sharing Platform';
const updateMeta = (property: string, content: string) => {
let meta = document.querySelector(`meta[property="${property}"]`) as HTMLMetaElement | null;
if (!meta) {
meta = document.createElement('meta');
meta.setAttribute('property', property);
document.head.appendChild(meta);
}
meta.content = content;
};
updateMeta('og:title', document.title);
updateMeta('og:site_name', title);
updateMeta('og:description', description);
// Also update standard meta description
let metaDesc = document.querySelector('meta[name="description"]') as HTMLMetaElement | null;
if (!metaDesc) {
metaDesc = document.createElement('meta');
metaDesc.name = 'description';
document.head.appendChild(metaDesc);
}
metaDesc.content = description;
}, [settings?.branding_company_name, settings?.branding_company_tagline]);
return null;
};
};
@@ -21,9 +21,37 @@ const DEFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) =>
</svg>
);
const RUFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => (
<svg className={className} viewBox="0 0 640 480" xmlns="http://www.w3.org/2000/svg">
<path fill="#FFF" d="M0 0h640v160H0z"/>
<path fill="#0039A6" d="M0 160h640v160H0z"/>
<path fill="#D52B1E" d="M0 320h640v160H0z"/>
</svg>
);
const PTBRFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => (
<svg className={className} viewBox="0 0 640 480" xmlns="http://www.w3.org/2000/svg">
<path fill="#009B3A" d="M0 0h640v480H0z"/>
<path fill="#FEDF00" d="M320 39.4 590.4 240 320 440.6 49.6 240z"/>
<circle fill="#002776" cx="320" cy="240" r="95"/>
<path fill="#FFF" d="M226.3 262.8c0-27 12.8-51 32.7-66.3a95.3 95.3 0 0 0-3.5 120.6c-17.8-14.8-29.2-37-29.2-54.3z" opacity=".5"/>
</svg>
);
const NLFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => (
<svg className={className} viewBox="0 0 640 480" xmlns="http://www.w3.org/2000/svg">
<path fill="#AE1C28" d="M0 0h640v160H0z"/>
<path fill="#FFF" d="M0 160h640v160H0z"/>
<path fill="#21468B" d="M0 320h640v160H0z"/>
</svg>
);
const languages = [
{ code: 'en', name: 'English', Flag: GBFlag },
{ code: 'de', name: 'Deutsch', Flag: DEFlag },
{ code: 'ru', name: 'Русский', Flag: RUFlag },
{ code: 'pt', name: 'Português', Flag: PTBRFlag },
{ code: 'nl', name: 'Nederlands', Flag: NLFlag },
];
export const LanguageSelector: React.FC = () => {
@@ -56,7 +84,7 @@ export const LanguageSelector: React.FC = () => {
onClick={() => handleLanguageChange(language.code)}
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3 ${
language.code === i18n.language
? 'text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/30'
? 'text-accent bg-accent-dark/15'
: 'text-neutral-700 dark:text-neutral-300'
}`}
>
+1 -1
View File
@@ -23,7 +23,7 @@ export const Loading: React.FC<LoadingProps> = ({
const content = (
<div className={clsx('flex flex-col items-center justify-center', className)}>
<Loader2 className={clsx('animate-spin text-primary-600', sizeStyles[size])} />
<Loader2 className={clsx('animate-spin text-accent', sizeStyles[size])} />
{text && (
<p className="mt-4 text-sm text-neutral-600">{text}</p>
)}
+8 -24
View File
@@ -1,7 +1,6 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import ReCAPTCHA from 'react-google-recaptcha';
import { useQuery } from '@tanstack/react-query';
import { getApiBaseUrl } from '../../utils/url';
import { usePublicSettings } from '../../hooks/usePublicSettings';
interface ReCaptchaProps {
onChange: (token: string | null) => void;
@@ -9,31 +8,16 @@ interface ReCaptchaProps {
size?: 'normal' | 'compact';
}
export const ReCaptcha: React.FC<ReCaptchaProps> = ({
onChange,
export const ReCaptcha: React.FC<ReCaptchaProps> = ({
onChange,
onExpired,
size = 'normal'
size = 'normal'
}) => {
const recaptchaRef = React.useRef<ReCAPTCHA>(null);
const [siteKey, setSiteKey] = useState<string>('');
const { data: settings } = usePublicSettings();
// Fetch public settings to get reCAPTCHA site key
const { data: settings } = useQuery({
queryKey: ['public-settings'],
queryFn: async () => {
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
return response.json();
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
const siteKey = settings?.recaptcha_site_key ?? '';
useEffect(() => {
if (settings?.recaptcha_site_key) {
setSiteKey(settings.recaptcha_site_key);
}
}, [settings]);
// If reCAPTCHA is not enabled or site key is not available, return null
if (!settings?.enable_recaptcha || !siteKey) {
return null;
}
@@ -52,4 +36,4 @@ export const ReCaptcha: React.FC<ReCaptchaProps> = ({
);
};
export default ReCaptcha;
export default ReCaptcha;
@@ -1,23 +1,8 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getApiBaseUrl } from '../../utils/url';
import { usePublicSettings } from '../../hooks/usePublicSettings';
export const RobotsMetaTags: React.FC = () => {
const { data: settings } = useQuery({
queryKey: ['public-settings'],
queryFn: async () => {
try {
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
if (response.ok) {
return response.json();
}
return null;
} catch {
return null;
}
},
staleTime: 5 * 60 * 1000,
});
const { data: settings } = usePublicSettings({ retry: false });
useEffect(() => {
// Remove any existing robots meta tags we previously injected
+23 -11
View File
@@ -16,8 +16,6 @@ export const Skeleton: React.FC<SkeletonProps> = ({
height,
animation = 'pulse'
}) => {
const baseClasses = 'bg-neutral-200';
const animationClasses = {
pulse: 'animate-pulse',
wave: 'animate-shimmer',
@@ -30,14 +28,21 @@ export const Skeleton: React.FC<SkeletonProps> = ({
rectangular: 'rounded-lg'
};
const style: React.CSSProperties = {};
// Theme-aware placeholder colour. Without this the skeleton tiles
// rendered as bright bg-neutral-200 light grey on dark gallery
// themes — the "most annoying" frame in #358's screenshots. Using
// var(--color-surface-border) tracks whatever shade ThemeContext
// resolves for the current colour mode (light: #e5e5e5, dark:
// #2e2e2e by default; per-event themes can override).
const style: React.CSSProperties = {
backgroundColor: 'var(--color-surface-border, #e5e5e5)',
};
if (width) style.width = typeof width === 'number' ? `${width}px` : width;
if (height) style.height = typeof height === 'number' ? `${height}px` : height;
return (
<div
className={cn(
baseClasses,
animationClasses[animation],
variantClasses[variant],
className
@@ -74,9 +79,16 @@ export const SkeletonGroup: React.FC<SkeletonGroupProps> = ({
);
};
// Theme-aware container surface — same reasoning as the Skeleton
// itself. Reads var(--color-surface) so the card sits on the right
// background regardless of the active theme's colour mode.
const SURFACE_STYLE: React.CSSProperties = {
backgroundColor: 'var(--color-surface, #ffffff)',
};
// Common skeleton patterns
export const SkeletonCard: React.FC<{ className?: string }> = ({ className }) => (
<div className={cn('bg-white rounded-lg shadow-sm p-6', className)}>
<div className={cn('rounded-lg shadow-sm p-6', className)} style={SURFACE_STYLE}>
<Skeleton height={24} width="60%" className="mb-4" />
<SkeletonGroup count={3} />
<div className="flex gap-3 mt-6">
@@ -86,12 +98,12 @@ export const SkeletonCard: React.FC<{ className?: string }> = ({ className }) =>
</div>
);
export const SkeletonTable: React.FC<{ rows?: number; className?: string }> = ({
rows = 5,
className
export const SkeletonTable: React.FC<{ rows?: number; className?: string }> = ({
rows = 5,
className
}) => (
<div className={cn('bg-white rounded-lg shadow-sm overflow-hidden', className)}>
<div className="border-b border-neutral-200 p-4">
<div className={cn('rounded-lg shadow-sm overflow-hidden', className)} style={SURFACE_STYLE}>
<div className="border-b border-neutral-200 dark:border-neutral-700 p-4">
<div className="flex gap-4">
<Skeleton width="30%" height={20} />
<Skeleton width="25%" height={20} />
@@ -99,7 +111,7 @@ export const SkeletonTable: React.FC<{ rows?: number; className?: string }> = ({
<Skeleton width="25%" height={20} />
</div>
</div>
<div className="divide-y divide-neutral-100">
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
{Array.from({ length: rows }).map((_, index) => (
<div key={index} className="p-4">
<div className="flex gap-4">
+1 -1
View File
@@ -4,7 +4,7 @@ export const SkipLink: React.FC = () => {
return (
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 bg-primary-600 text-white px-4 py-2 rounded-lg z-50 focus:outline-none focus:ring-2 focus:ring-primary-700"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 bg-accent-dark text-white px-4 py-2 rounded-lg z-50 focus:outline-none focus:ring-2 focus:ring-primary-700"
>
Skip to main content
</a>
@@ -0,0 +1,49 @@
import React from 'react';
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Skeleton, SkeletonGalleryGrid, SkeletonCard } from '../Skeleton';
/**
* Regression for #358. The Skeleton placeholders used to hard-code
* `bg-neutral-200`, which rendered as bright light grey on dark
* gallery themes (Rekoo-PS's "most annoying" frame). They must instead
* use the active theme's surface-border colour so the placeholders
* track whatever the theme defines for both light and dark modes.
*/
describe('Skeleton — theme-aware colour', () => {
it('uses var(--color-surface-border) for the placeholder background', () => {
const { container } = render(<Skeleton />);
const div = container.querySelector('div');
expect(div).not.toBeNull();
expect(div!.style.backgroundColor).toBe('var(--color-surface-border, #e5e5e5)');
});
it('does NOT add the legacy hard-coded bg-neutral-200 class', () => {
const { container } = render(<Skeleton />);
const div = container.querySelector('div');
expect(div!.className).not.toMatch(/bg-neutral-200/);
});
it('SkeletonGalleryGrid tiles inherit the theme colour', () => {
const { container } = render(<SkeletonGalleryGrid count={3} />);
// Tiles are the Skeleton components — direct children of the
// gallery-grid wrapper. They carry aria-busy="true" while the
// wrapper does not, which is the cleanest way to select them.
const tiles = container.querySelectorAll('[aria-busy="true"]');
expect(tiles.length).toBe(3);
tiles.forEach((tile) => {
expect((tile as HTMLElement).style.backgroundColor).toBe(
'var(--color-surface-border, #e5e5e5)'
);
});
});
it('SkeletonCard surface uses var(--color-surface)', () => {
const { container } = render(<SkeletonCard />);
const card = container.firstElementChild as HTMLElement;
expect(card).not.toBeNull();
expect(card.style.backgroundColor).toBe('var(--color-surface, #ffffff)');
// Sanity: should not retain the old bg-white class either
expect(card.className).not.toMatch(/bg-white/);
});
});
+1
View File
@@ -1,4 +1,5 @@
export { Button } from './Button';
export { CMSContentBlock } from './CMSContentBlock';
export { Input } from './Input';
export { Card, CardHeader, CardContent, CardFooter } from './Card';
export { Loading, LoadingSkeleton } from './Loading';
@@ -23,7 +23,7 @@ export const DownloadProgress: React.FC<DownloadProgressProps> = ({
<div className="fixed bottom-4 right-4 bg-surface rounded-lg shadow-lg border border-surface p-4 min-w-[300px] z-50">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<Download className="w-5 h-5 text-primary-600 animate-bounce" />
<Download className="w-5 h-5 text-accent animate-bounce" />
<div>
<p className="text-sm font-medium text-theme">{t('download.downloading')}</p>
{fileName && (
@@ -43,7 +43,7 @@ export const DownloadProgress: React.FC<DownloadProgressProps> = ({
<div className="w-full bg-black/10 rounded-full h-2">
<div
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
className="bg-accent-dark h-2 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
@@ -168,7 +168,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
<Heart className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">{t('gallery.liked', 'Liked')}</span>
{likeCount > 0 && (
<span className="bg-primary-100 text-primary-700 px-1.5 rounded">
<span className="bg-accent-dark/15 text-accent-dark px-1.5 rounded">
{likeCount}
</span>
)}
@@ -183,7 +183,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
<Bookmark className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">{t('gallery.favorited', 'Saved')}</span>
{favoriteCount > 0 && (
<span className="bg-primary-100 text-primary-700 px-1.5 rounded">
<span className="bg-accent-dark/15 text-accent-dark px-1.5 rounded">
{favoriteCount}
</span>
)}
@@ -198,7 +198,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
<Star className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">{t('gallery.rated', 'Rated')}</span>
{ratedCount > 0 && (
<span className="bg-primary-100 text-primary-700 px-1.5 rounded">
<span className="bg-accent-dark/15 text-accent-dark px-1.5 rounded">
{ratedCount}
</span>
)}
+177 -129
View File
@@ -1,5 +1,6 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Calendar, Clock, Download, LogOut } from 'lucide-react';
import { parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
@@ -7,7 +8,9 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button } from '../common';
import { DynamicFavicon } from '../common/DynamicFavicon';
import { useTheme } from '../../contexts/ThemeContext';
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
import { buildResourceUrl } from '../../utils/url';
import { cmsService, type PublicCMSPage } from '../../services/cms.service';
import type { HeaderStyleType } from '../../types/theme.types';
interface GalleryLayoutProps {
@@ -37,12 +40,54 @@ interface GalleryLayoutProps {
showDownloadAll?: boolean;
onDownloadAll?: () => void;
isDownloading?: boolean;
/**
* "Download" CTA shown immediately to the left of the Logout button in the
* standard / banner header. Same handler as Download All; the label and
* placement are intentionally simpler single primary action right before
* Logout, the natural step at the end of a gallery visit (#386). Always
* visible when allowed (independent of sidebar state) so guests aren't
* forced to discover the download in the menu.
*/
showHeaderDownload?: boolean;
onHeaderDownload?: () => void;
headerExtra?: React.ReactNode;
menuButton?: React.ReactNode;
headerStyle?: HeaderStyleType;
children: React.ReactNode;
}
/**
* Accent-coloured "Download" CTA shown immediately to the left of the
* Logout button. Identical markup is rendered in three header variants
* (standard/banner, minimal, hero) extracted into a small component
* here so changes (label, icon, contrast) only need to happen in one
* place. Background reads `--color-accent`; text reads `--color-accent-fg`
* which `ThemeContext.applyTheme` derives from the accent's luminance,
* so a pale accent automatically gets dark text and a saturated accent
* gets white. Falls back to white if the variable isn't set (legacy
* deployments before the contrast helper landed).
*/
const HeaderDownloadButton: React.FC<{
onClick: () => void;
isDownloading?: boolean;
label: string;
}> = ({ onClick, isDownloading = false, label }) => (
<button
type="button"
onClick={onClick}
disabled={isDownloading}
aria-label={label}
className="gallery-btn gallery-btn-download inline-flex items-center gap-2 px-3 sm:px-4 h-9 rounded-lg text-sm font-medium transition-opacity hover:opacity-90 disabled:opacity-60 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{
backgroundColor: 'var(--color-accent)',
color: 'var(--color-accent-fg, #ffffff)',
}}
>
<Download className="w-4 h-4" />
<span className="hidden sm:inline">{label}</span>
</button>
);
export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
event,
brandingSettings,
@@ -51,6 +96,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
showDownloadAll = false,
onDownloadAll,
isDownloading = false,
showHeaderDownload = false,
onHeaderDownload,
headerExtra,
menuButton,
headerStyle: headerStyleProp,
@@ -59,15 +106,30 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
const { t } = useTranslation();
const { format } = useLocalizedDate();
const { theme } = useTheme();
const guestIdentity = useGuestIdentityOptional();
// Footer legal-link config. Cached aggressively because the toggle state
// changes rarely and the gallery footer renders on every page view.
// Failures fall back to the internal /impressum and /datenschutz routes.
const { data: impressumPage } = useQuery<PublicCMSPage>({
queryKey: ['public-cms', 'impressum'],
queryFn: () => cmsService.getPublicPage('impressum'),
staleTime: 5 * 60 * 1000,
retry: false,
});
const { data: datenschutzPage } = useQuery<PublicCMSPage>({
queryKey: ['public-cms', 'datenschutz'],
queryFn: () => cmsService.getPublicPage('datenschutz'),
staleTime: 5 * 60 * 1000,
retry: false,
});
// Determine header style - use prop first (from event data), then theme, then fall back to 'standard'
const headerStyle: HeaderStyleType = headerStyleProp || theme.headerStyle || 'standard';
const isHeroHeader = headerStyle === 'hero';
const isBannerHeader = headerStyle === 'banner';
const isMinimalHeader = headerStyle === 'minimal';
const isNoHeader = headerStyle === 'none';
// Non-grid layouts that need the sidebar (excluding layouts using hero header)
const isNonGridLayout = theme.galleryLayout && theme.galleryLayout !== 'grid';
const fontFamily = theme.fontFamily || 'Inter, sans-serif';
const headingFontFamily = theme.headingFontFamily || fontFamily;
@@ -133,66 +195,25 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
<DynamicFavicon />
{/* Header structure */}
<header className={`gallery-header bg-surface border-b border-surface sticky top-0 z-40 ${isNonGridLayout || isHeroHeader ? 'shadow-sm' : ''}`}>
{/* For non-grid layouts - keep the current structure (standard and minimal/none) */}
{isNonGridLayout && !isHeroHeader && !isMinimalHeader && !isNoHeader && (
<div className="bg-surface border-b border-surface">
<div className="container py-2">
<div className="flex items-center justify-between">
{/* Left side - Menu button and other header extras */}
<div className="flex items-center gap-3">
{menuButton}
{headerExtra}
</div>
{/* Right side - Download and Logout */}
<div className="flex items-center gap-3">
{/* Download all button */}
{showDownloadAll && onDownloadAll && (
<Button
variant="primary"
size="sm"
leftIcon={<Download className="w-4 h-4" />}
onClick={onDownloadAll}
isLoading={isDownloading}
className="gallery-btn gallery-btn-download"
>
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
<span className="sm:hidden">{t('common.download')}</span>
</Button>
)}
{/* Logout button */}
{showLogout && onLogout && (
<Button
variant="outline"
size="sm"
leftIcon={<LogOut className="w-4 h-4" />}
onClick={onLogout}
className="gallery-btn gallery-btn-logout sm:min-w-0"
>
<span className="hidden sm:inline">{t('common.logout')}</span>
</Button>
)}
</div>
<header className={`gallery-header bg-surface border-b border-surface sticky top-0 z-40 ${isHeroHeader || isBannerHeader ? 'shadow-sm' : ''}`}>
{/* Standard / Banner header - full bar with logo, event info, and actions (all layouts) */}
{!isHeroHeader && !isMinimalHeader && !isNoHeader && (
<div className="container py-3 relative">
{/*
* Menu icon is absolute-positioned at the very left of the header
* row instead of sitting inside the flex flow, so the logo's left
* edge can align with the leftmost gallery image (both anchored at
* `.container` left padding) see #386. The icon stays vertically
* centred via top-1/2 + -translate-y-1/2.
*/}
{menuButton && (
<div className="absolute left-3 sm:left-6 lg:left-8 top-1/2 -translate-y-1/2 z-10">
{menuButton}
</div>
</div>
</div>
)}
{/* For grid layout - everything in one bar (standard header) */}
{!isNonGridLayout && !isHeroHeader && !isMinimalHeader && !isNoHeader && (
<div className="container py-3">
)}
<div className="flex items-center justify-between gap-2 sm:gap-4">
{/* Left side - Menu button, Logo */}
<div className="flex items-center gap-2 sm:gap-4 flex-shrink-0">
{/* Menu button */}
{menuButton && (
<div className="flex-shrink-0">
{menuButton}
</div>
)}
{/* Left side - Logo (menu lives in the absolute wrapper above) */}
<div className={`flex items-center gap-2 sm:gap-4 flex-shrink-0 ${menuButton ? 'pl-12 sm:pl-14' : ''}`}>
{/* Logo - Show custom logo or fallback to PicPeak logo */}
{shouldShowLogo('header') && (
<div className={`gallery-logo-wrapper flex-shrink-0 flex items-center gap-2 ${brandingSettings?.logo_position === 'center' ? 'flex-1' : ''} ${getLogoPositionClass()}`}>
@@ -267,6 +288,20 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
</Button>
)}
{/*
* "Download" CTA accent-coloured button immediately left of
* Logout, always visible when the gallery allows downloads.
* Markup lives in HeaderDownloadButton above; reused in the
* minimal and hero headers below.
*/}
{showHeaderDownload && onHeaderDownload && (
<HeaderDownloadButton
onClick={onHeaderDownload}
isDownloading={isDownloading}
label={t('gallery.download', 'Download')}
/>
)}
{/* Logout button */}
{showLogout && onLogout && (
<Button
@@ -302,56 +337,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
</div>
)}
{/* For minimal/none header + non-grid layouts - compact menu bar */}
{isNonGridLayout && (isMinimalHeader || isNoHeader) && (
<div className="bg-surface border-b border-surface">
<div className="container py-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{menuButton}
{headerExtra}
{isMinimalHeader && (
<h1
className="text-sm font-semibold text-theme truncate"
style={{ fontFamily: headingFontFamily }}
>
{event.event_name}
</h1>
)}
</div>
<div className="flex items-center gap-3">
{showDownloadAll && onDownloadAll && (
<Button
variant="primary"
size="sm"
leftIcon={<Download className="w-4 h-4" />}
onClick={onDownloadAll}
isLoading={isDownloading}
className="gallery-btn gallery-btn-download"
>
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
<span className="sm:hidden">{t('common.download')}</span>
</Button>
)}
{showLogout && onLogout && (
<Button
variant="outline"
size="sm"
leftIcon={<LogOut className="w-4 h-4" />}
onClick={onLogout}
className="gallery-btn gallery-btn-logout sm:min-w-0"
>
<span className="hidden sm:inline">{t('common.logout')}</span>
</Button>
)}
</div>
</div>
</div>
</div>
)}
{/* For minimal header + grid layout - compact bar with event name */}
{!isNonGridLayout && isMinimalHeader && (
{/* Minimal header - compact bar with event name (all layouts) */}
{isMinimalHeader && (
<div className="container py-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
@@ -377,6 +364,17 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
</Button>
)}
{/* Accent Download CTA also rendered in the minimal header
so the action stays one click away regardless of header
style. Intentionally NOT shown in the no-header variant
where the gallery is fully chromeless by design. */}
{showHeaderDownload && onHeaderDownload && (
<HeaderDownloadButton
onClick={onHeaderDownload}
isDownloading={isDownloading}
label={t('gallery.download', 'Download')}
/>
)}
{showLogout && onLogout && (
<Button
variant="outline"
@@ -393,8 +391,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
</div>
)}
{/* For none header + grid layout - just functional buttons, no event info */}
{!isNonGridLayout && isNoHeader && (
{/* No-header style - just functional buttons, no event info (all layouts) */}
{isNoHeader && (
<div className="container py-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
@@ -457,6 +455,18 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
</Button>
)}
{/* Accent Download CTA also rendered above the hero so the
primary download action is reachable without scrolling.
Intentionally NOT shown in the no-header variant where
the gallery is fully chromeless by design. */}
{showHeaderDownload && onHeaderDownload && (
<HeaderDownloadButton
onClick={onHeaderDownload}
isDownloading={isDownloading}
label={t('gallery.download', 'Download')}
/>
)}
{/* Logout button */}
{showLogout && onLogout && (
<Button
@@ -475,8 +485,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
)}
</header>
{/* Colored banner for non-grid layouts when using standard header style */}
{isNonGridLayout && !isHeroHeader && !isMinimalHeader && !isNoHeader && (
{/* Colored banner — only when headerStyle === 'banner', regardless of layout */}
{isBannerHeader && (
<div
className="gallery-hero relative text-white overflow-hidden"
style={{
@@ -571,7 +581,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
{t('gallery.needHelp')}{' '}
<a
href={`mailto:${brandingSettings.support_email}`}
className="text-primary-600 hover:text-primary-700 break-all"
className="text-accent hover:opacity-80 break-all"
>
{brandingSettings.support_email}
</a>
@@ -589,20 +599,58 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
</p>
)}
{/* Legal Links */}
<div className="mt-4 flex items-center justify-center gap-4">
<Link
to="/impressum"
className="text-xs text-muted-theme hover:text-theme transition-colors"
>
{t('legal.impressum')}
</Link>
<div className="mt-4 flex items-center justify-center gap-4 flex-wrap">
{impressumPage?.use_external_url && impressumPage.external_url ? (
<a
href={impressumPage.external_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-theme hover:text-theme transition-colors"
>
{t('legal.impressum')}
</a>
) : (
<Link
to="/impressum"
className="text-xs text-muted-theme hover:text-theme transition-colors"
>
{t('legal.impressum')}
</Link>
)}
<span className="text-xs text-muted-theme">|</span>
<Link
to="/datenschutz"
className="text-xs text-muted-theme hover:text-theme transition-colors"
>
{t('legal.datenschutz')}
</Link>
{datenschutzPage?.use_external_url && datenschutzPage.external_url ? (
<a
href={datenschutzPage.external_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-theme hover:text-theme transition-colors"
>
{t('legal.datenschutz')}
</a>
) : (
<Link
to="/datenschutz"
className="text-xs text-muted-theme hover:text-theme transition-colors"
>
{t('legal.datenschutz')}
</Link>
)}
{guestIdentity?.identity && (
<>
<span className="text-xs text-muted-theme">|</span>
<button
type="button"
className="text-xs text-muted-theme hover:text-theme transition-colors"
onClick={async () => {
if (window.confirm(t('gallery.footer.forgetMeConfirm', 'Your name and selections will be removed from this gallery.'))) {
await guestIdentity.forget();
}
}}
>
{t('gallery.footer.forgetMe', 'Forget me ({{name}})', { name: guestIdentity.identity.name })}
</button>
</>
)}
</div>
</div>
</footer>
@@ -255,7 +255,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
className={`
gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
${selectedCategoryId === null
? 'bg-primary-600/20 text-primary-500'
? 'bg-accent-dark text-white'
: 'hover:bg-black/10 text-muted-theme'
}
`}
@@ -278,7 +278,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
className={`
gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
${isSelected
? 'bg-primary-600/20 text-primary-500'
? 'bg-accent-dark text-white'
: 'hover:bg-black/10 text-muted-theme'
}
`}
@@ -356,13 +356,13 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
<button
key={option.value}
onClick={() => {
onSortChange(option.value as 'date' | 'name' | 'size' | 'rating');
onSortChange(option.value as 'date' | 'name' | 'size' | 'rating' | 'capture_date');
if (isMobile) onClose();
}}
className={`
gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center gap-3
${isSelected
? 'bg-primary-600/20 text-primary-500'
? 'bg-accent-dark text-white'
: 'hover:bg-black/10 text-muted-theme'
}
`}
@@ -0,0 +1,41 @@
import React, { useEffect, useState } from 'react';
import { Skeleton, SkeletonGalleryGrid } from '../common';
/**
* Loading placeholder shown while a gallery is resolving (slug info
* auto-login photos). The tile grid is delayed 300ms so fast loads
* never flash an empty grid before the real photos render (#321 follow-up).
*/
export const GallerySkeleton: React.FC = () => {
const [showGrid, setShowGrid] = useState(false);
useEffect(() => {
const t = setTimeout(() => setShowGrid(true), 300);
return () => clearTimeout(t);
}, []);
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<header className="bg-surface border-b border-surface sticky top-0 z-40">
<div className="container py-4">
<div className="flex items-center justify-between">
<div>
<Skeleton height={32} width={200} className="mb-2" />
<Skeleton height={20} width={300} />
</div>
<div className="flex items-center gap-2">
<Skeleton height={40} width={120} />
<Skeleton height={40} width={100} />
</div>
</div>
</div>
</header>
{showGrid && (
<div className="container mt-6">
<Skeleton height={80} className="mb-6" />
<SkeletonGalleryGrid count={12} />
</div>
)}
</div>
);
};
+180 -77
View File
@@ -3,7 +3,8 @@ import { differenceInDays, parseISO } from 'date-fns';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Button, SkeletonGalleryGrid, Skeleton } from '../common';
import { Button } from '../common';
import { GallerySkeleton } from './GallerySkeleton';
import { useGalleryAuth, useTheme } from '../../contexts';
import { useGalleryPhotos, useDownloadAllPhotos } from '../../hooks/useGallery';
import { PhotoGridWithLayouts } from './PhotoGridWithLayouts';
@@ -13,16 +14,21 @@ import { GalleryLayout } from './GalleryLayout';
import { GallerySidebar } from './GallerySidebar';
import { PhotoFilterBar } from './PhotoFilterBar';
import { UserPhotoUpload } from './UserPhotoUpload';
import { GuestNamePromptModal } from './GuestNamePromptModal';
import { GuestRecoveryModal } from './GuestRecoveryModal';
import { GuestIdentityProvider } from '../../contexts/GuestIdentityContext';
import type { FilterType } from './GalleryFilter';
import { analyticsService } from '../../services/analytics.service';
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
import { api } from '../../config/api';
import { Upload, Menu } from 'lucide-react';
import { Upload, Menu, Eye, EyeOff, Shield } from 'lucide-react';
import { galleryService } from '../../services/gallery.service';
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
import { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import type { Photo } from '../../types';
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
import { useQueryClient } from '@tanstack/react-query';
interface GalleryViewProps {
slug: string;
@@ -41,13 +47,35 @@ interface GalleryViewProps {
};
}
// Convert default_photo_sort DB value to internal sortBy state
const parseDefaultPhotoSort = (defaultSort?: string): { sortBy: 'date' | 'name' | 'size' | 'rating' | 'capture_date'; sortDesc: boolean } => {
switch (defaultSort) {
case 'upload_date_asc':
return { sortBy: 'date', sortDesc: false };
case 'capture_date_desc':
return { sortBy: 'capture_date', sortDesc: true };
case 'capture_date_asc':
return { sortBy: 'capture_date', sortDesc: false };
case 'filename_asc':
return { sortBy: 'name', sortDesc: false };
case 'filename_desc':
return { sortBy: 'name', sortDesc: true };
case 'upload_date_desc':
default:
return { sortBy: 'date', sortDesc: true };
}
};
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const { t } = useTranslation();
const { logout } = useGalleryAuth();
const { logout, isClient } = useGalleryAuth();
const { setTheme, theme } = useTheme();
const queryClient = useQueryClient();
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating' | 'capture_date'>('date');
const [sortDesc, setSortDesc] = useState(true);
const [defaultSortApplied, setDefaultSortApplied] = useState(false);
const [brandingSettings, setBrandingSettings] = useState<any>(null);
const [showUploadModal, setShowUploadModal] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
@@ -101,6 +129,16 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
}, [data?.event?.protection_level]);
// Apply default photo sort from event settings
useEffect(() => {
if (!defaultSortApplied && data?.event?.default_photo_sort) {
const { sortBy: defaultSortBy, sortDesc: defaultSortDesc } = parseDefaultPhotoSort(data.event.default_photo_sort);
setSortBy(defaultSortBy);
setSortDesc(defaultSortDesc);
setDefaultSortApplied(true);
}
}, [data?.event?.default_photo_sort, defaultSortApplied]);
// Get individual protection settings from event
const disableRightClick = data?.event?.disable_right_click === true;
const enableDevtoolsProtection = data?.event?.enable_devtools_protection === true;
@@ -162,15 +200,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
return () => window.removeEventListener('resize', handleResize);
}, []);
// Fetch branding settings
const { data: settingsData } = useQuery({
queryKey: ['gallery-settings'],
queryFn: async () => {
const response = await api.get('/public/settings');
return response.data;
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
const { data: settingsData } = usePublicSettings();
// Fetch feedback settings
const { data: feedbackSettings } = useQuery({
@@ -280,7 +310,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
if (settingsData && data?.event) {
let themeToApply = null;
const fullEvent = data.event; // Use the full event data from API
if (fullEvent.color_theme) {
try {
// Check if it's a valid JSON string
@@ -310,8 +340,10 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
// No event theme, use global theme
themeToApply = settingsData.theme_config;
}
// Apply theme with a small delay to ensure it overrides any global theme
// Apply theme with a small delay to ensure it overrides any global theme.
// Instance-wide force color mode is enforced inside ThemeContext.applyTheme,
// so callers don't have to wrap the theme themselves.
if (themeToApply) {
// Use setTimeout to ensure this runs after any global theme application
const timer = setTimeout(() => {
@@ -325,12 +357,43 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
setTheme(themeToApply);
}, 0);
return () => clearTimeout(timer);
}
}
}, [settingsData, data, setTheme]); // Use data instead of event prop
// Client visibility toggle handler (#172)
const handleToggleVisibility = async (photoId: number, currentVisibility: string) => {
const newVisibility = currentVisibility === 'hidden' ? 'visible' : 'hidden';
try {
await galleryService.togglePhotoVisibility(slug, photoId, newVisibility);
queryClient.invalidateQueries({ queryKey: ['gallery-photos', slug] });
} catch (error) {
console.error('Failed to toggle visibility:', error);
}
};
const handleBulkVisibility = async (visibility: 'visible' | 'hidden') => {
if (selectedPhotos.size === 0) return;
try {
await galleryService.bulkToggleVisibility(slug, Array.from(selectedPhotos), visibility);
setSelectedPhotos(new Set());
setIsSelectionMode(false);
queryClient.invalidateQueries({ queryKey: ['gallery-photos', slug] });
} catch (error) {
console.error('Failed to bulk toggle visibility:', error);
}
};
// Client visibility stats
const visibleCount = useMemo(() => {
if (!isClient || !data?.photos) return 0;
return data.photos.filter(p => p.visibility !== 'hidden').length;
}, [isClient, data?.photos]);
const totalCount = data?.photos?.length || 0;
// Calculate days until expiration (null means never expires)
const daysUntilExpiration = event.expires_at
? differenceInDays(parseISO(event.expires_at), new Date())
@@ -382,29 +445,32 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
// Apply sorting
// Each comparator defaults to its natural order (desc for dates/size/rating, asc for name).
// The flip multiplier reverses that when sortDesc differs from the natural order.
const flip = sortDesc ? 1 : -1;
photos.sort((a, b) => {
switch (sortBy) {
case 'name':
return a.filename.localeCompare(b.filename);
// Natural order is ascending (A-Z); flip when sortDesc=true
return (sortDesc ? -1 : 1) * a.filename.localeCompare(b.filename);
case 'size':
return b.size - a.size;
case 'rating':
// Sort by rating (highest first), then by comment count
return flip * (b.size - a.size);
case 'rating': {
const ratingA = a.average_rating || 0;
const ratingB = b.average_rating || 0;
if (ratingA !== ratingB) {
return ratingB - ratingA;
return flip * (ratingB - ratingA);
}
// If ratings are equal, sort by comment count
return (b.comment_count || 0) - (a.comment_count || 0);
case 'capture_date':
// Sort by capture date (from EXIF), fall back to upload date
return flip * ((b.comment_count || 0) - (a.comment_count || 0));
}
case 'capture_date': {
const captureDateA = a.captured_at || a.uploaded_at;
const captureDateB = b.captured_at || b.uploaded_at;
return new Date(captureDateB).getTime() - new Date(captureDateA).getTime();
return flip * (new Date(captureDateB).getTime() - new Date(captureDateA).getTime());
}
case 'date':
default:
return new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime();
return flip * (new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime());
}
});
@@ -418,7 +484,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
return photos;
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug, filterType, mediaFilter]);
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, filterType, mediaFilter]);
const likeCount = useMemo(
() => data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0,
@@ -444,7 +510,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
return;
}
downloadAllMutation.mutate(slug);
downloadAllMutation.mutate({ slug, zipReady: data?.event?.download_zip_ready });
// Track download all action
analyticsService.trackGalleryEvent('bulk_download', {
@@ -517,31 +583,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}, [showUrgentWarning, daysUntilExpiration, slug]);
if (isLoading) {
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background)' }}>
{/* Header Skeleton */}
<header className="bg-surface border-b border-surface sticky top-0 z-40">
<div className="container py-4">
<div className="flex items-center justify-between">
<div>
<Skeleton height={32} width={200} className="mb-2" />
<Skeleton height={20} width={300} />
</div>
<div className="flex items-center gap-2">
<Skeleton height={40} width={120} />
<Skeleton height={40} width={100} />
</div>
</div>
</div>
</header>
{/* Content Skeleton */}
<div className="container mt-6">
<Skeleton height={80} className="mb-6" />
<SkeletonGalleryGrid count={12} />
</div>
</div>
);
return <GallerySkeleton />;
}
if (error || !data) {
@@ -566,15 +608,14 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
);
}
// Determine controls style (sidebar vs classic inline filter bar)
// If controlsStyle is explicitly set in theme, use that
// Otherwise: use sidebar for non-grid layouts OR hero headers (prevents filter bar above hero)
// Determine controls style (sidebar vs classic inline filter bar).
// Decoupled from layout — only an explicit controlsStyle === 'sidebar' on
// the theme renders the sidebar. Default (unset or 'classic') is the inline
// filter bar for every layout, so the gallery header/filters look identical
// regardless of whether the photos render as grid, masonry, carousel, etc.
const headerStyle = data?.event?.header_style || theme.headerStyle || 'standard';
const isHeroHeader = headerStyle === 'hero';
const controlsStyle = theme.controlsStyle;
const showSidebar = controlsStyle
? controlsStyle === 'sidebar'
: (theme.galleryLayout !== 'grid' || isHeroHeader);
const showSidebar = theme.controlsStyle === 'sidebar';
// Full-page layouts (gallery-premium, gallery-story) have their own integrated UI
// Skip all wrapper elements (header, footer, sidebar, filters) for these layouts
@@ -619,6 +660,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
headerStyle={data?.event?.header_style || theme.headerStyle}
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
welcomeMessage={event.welcome_message}
onLogout={logout}
/>
@@ -638,8 +680,14 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
);
}
const identityMode: 'simple' | 'guest' =
feedbackSettings?.identity_mode === 'guest' ? 'guest' : 'simple';
return (
<GuestIdentityProvider slug={slug} identityMode={identityMode}>
<>
<GuestNamePromptModal requireEmail={!!feedbackSettings?.require_name_email} />
<GuestRecoveryModal />
{/* Sidebar for non-grid layouts */}
{showSidebar ? (
<GallerySidebar
@@ -683,21 +731,32 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
headerStyle={data?.event?.header_style || theme.headerStyle}
showLogout={true}
onLogout={logout}
showDownloadAll={!showSidebar && allowDownloads}
// Old Download All header button is replaced by the new
// showHeaderDownload below — accent-coloured, always visible when
// downloads are allowed, sits right before Logout (#386).
showDownloadAll={false}
onDownloadAll={handleDownloadAll}
isDownloading={downloadAllMutation.isPending}
menuButton={showSidebar ? (
<Button
variant="ghost"
size="sm"
className="gallery-btn"
leftIcon={<Menu className="w-4 h-4" />}
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label={t('gallery.toggleMenu')}
>
<span className="hidden sm:inline">{t('common.menu')}</span>
</Button>
) : undefined}
menuButton={
// Menu icon is shown when the event theme uses the sidebar
// controls style. The button is icon-only — the redundant
// "Menu" text label was dropped (#386). The wrapper aligns the
// icon to the very left of the header so the logo lines up
// with the leftmost gallery image.
showSidebar ? (
<Button
variant="ghost"
size="sm"
className="gallery-btn p-2"
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label={t('gallery.toggleMenu')}
>
<Menu className="w-5 h-5" />
</Button>
) : undefined
}
showHeaderDownload={allowDownloads}
onHeaderDownload={handleDownloadAll}
headerExtra={(() => {
const items = [];
@@ -733,8 +792,48 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
<ExpirationBanner daysRemaining={daysUntilExpiration} expiresAt={event.expires_at} />
)}
{/* Search and Filters - Only for grid layout */}
{!showSidebar ? (
{/* Client Access Banner (#172) */}
{isClient && (
<div className="mt-4 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-amber-600 dark:text-amber-400" />
<span className="text-sm font-medium text-amber-800 dark:text-amber-200">
{t('clientAccess.banner')}
</span>
<span className="text-xs text-amber-600 dark:text-amber-400 ml-2">
{t('clientAccess.visibleCount', { visible: visibleCount, total: totalCount })}
</span>
</div>
{isSelectionMode && selectedPhotos.size > 0 && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
leftIcon={<EyeOff className="w-4 h-4" />}
onClick={() => handleBulkVisibility('hidden')}
>
{t('clientAccess.hideSelected')}
</Button>
<Button
variant="outline"
size="sm"
leftIcon={<Eye className="w-4 h-4" />}
onClick={() => handleBulkVisibility('visible')}
>
{t('clientAccess.showSelected')}
</Button>
</div>
)}
</div>
</div>
)}
{/* Search and Filters - Only for grid layout, when admin enables the
filter bar globally, and when the gallery actually has photos
(avoids the empty "Search photos by filename" row in the screenshot
from discussion #317). */}
{!showSidebar && settingsData?.gallery_show_filter_bar !== false && (data?.photos?.length ?? 0) > 0 ? (
<div className="mt-6">
<PhotoFilterBar
categories={data.categories}
@@ -794,6 +893,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
headerStyle={data?.event?.header_style || theme.headerStyle}
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
welcomeMessage={event.welcome_message}
isClient={isClient}
onToggleVisibility={isClient ? handleToggleVisibility : undefined}
/>
</div>
@@ -812,5 +914,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
)}
</GalleryLayout>
</>
</GuestIdentityProvider>
);
};
@@ -0,0 +1,156 @@
import React, { useState } from 'react';
import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Input } from '../common';
import { useGuestIdentity } from '../../contexts/GuestIdentityContext';
interface GuestNamePromptModalProps {
requireEmail?: boolean;
allowCancel?: boolean;
onCancel?: () => void;
}
/**
* Session-wide prompt shown in guest identity mode when no identity exists
* yet. Triggered by `ensureIdentity()` on the first interactive feedback
* attempt, or manually via `openPrompt()`.
*
* Includes a link to the recovery flow for users who already registered on
* another device.
*/
export const GuestNamePromptModal: React.FC<GuestNamePromptModalProps> = ({
requireEmail = false,
allowCancel = true,
onCancel,
}) => {
const { t } = useTranslation();
const { promptOpen, closePrompt, register, openRecovery } = useGuestIdentity();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
if (!promptOpen) return null;
const handleClose = () => {
setName('');
setEmail('');
setErrors({});
setSubmitError(null);
closePrompt();
onCancel?.();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const newErrors: Record<string, string> = {};
if (!name.trim()) {
newErrors.name = t('gallery.guestPrompt.nameRequired', 'Name is required');
}
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
newErrors.email = t('gallery.guestPrompt.invalidEmail', 'Invalid email address');
}
if (requireEmail && !email.trim()) {
newErrors.email = t('gallery.guestPrompt.emailRequired', 'Email is required');
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setSubmitting(true);
setSubmitError(null);
try {
await register(name.trim(), email.trim() || undefined);
} catch (err) {
const error = err as { response?: { data?: { error?: string } } };
setSubmitError(error.response?.data?.error || t('gallery.guestPrompt.error', 'Registration failed'));
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={allowCancel ? handleClose : undefined} />
<div className="relative bg-surface rounded-lg shadow-xl max-w-md w-full p-6">
{allowCancel && (
<button
type="button"
onClick={handleClose}
className="absolute top-4 right-4 p-1 hover:bg-black/10 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-muted-theme" />
</button>
)}
<h2 className="text-lg font-semibold text-theme mb-2">
{t('gallery.guestPrompt.title', "Welcome — what's your name?")}
</h2>
<p className="text-sm text-muted-theme mb-4">
{t(
'gallery.guestPrompt.description',
'Your picks will be saved under this name so the photographer knows which photos you love.'
)}
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label={t('gallery.guestPrompt.nameLabel', 'Your name')}
value={name}
onChange={(e) => setName(e.target.value)}
error={errors.name}
placeholder={t('gallery.guestPrompt.namePlaceholder', 'Enter your name')}
autoFocus
required
maxLength={100}
/>
<Input
type="email"
label={
requireEmail
? t('gallery.guestPrompt.emailLabelRequired', 'Email')
: t('gallery.guestPrompt.emailLabel', 'Email (optional)')
}
value={email}
onChange={(e) => setEmail(e.target.value)}
error={errors.email}
placeholder={t('gallery.guestPrompt.emailPlaceholder', '[email protected]')}
maxLength={255}
/>
{submitError && (
<div className="text-sm text-red-600 bg-red-50 dark:bg-red-900/20 rounded px-3 py-2">
{submitError}
</div>
)}
<div className="flex gap-2 pt-2">
<Button type="submit" variant="primary" className="flex-1" disabled={submitting}>
{submitting
? t('common.submitting', 'Submitting...')
: t('gallery.guestPrompt.submit', 'Continue')}
</Button>
{allowCancel && (
<Button type="button" variant="ghost" onClick={handleClose} disabled={submitting}>
{t('common.cancel', 'Cancel')}
</Button>
)}
</div>
<button
type="button"
onClick={() => {
closePrompt();
openRecovery();
}}
className="text-sm text-accent hover:underline w-full text-center pt-2"
>
{t('gallery.guestPrompt.alreadyHere', "I've been here before")}
</button>
</form>
</div>
</div>
);
};
@@ -0,0 +1,173 @@
import React, { useState } from 'react';
import { X, ArrowLeft } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Input } from '../common';
import { useGuestIdentity } from '../../contexts/GuestIdentityContext';
/**
* Email-based identity recovery flow (Phase 3.2).
*
* Two steps:
* 1) Enter email server sends a 6-digit code.
* 2) Enter code server returns a guest token, identity restored.
*
* Opens when the user clicks "I've been here before" in the name prompt.
*/
export const GuestRecoveryModal: React.FC = () => {
const { t } = useTranslation();
const { recoveryOpen, closeRecovery, recoverRequest, recoverVerify, openPrompt } =
useGuestIdentity();
const [step, setStep] = useState<'email' | 'code'>('email');
const [email, setEmail] = useState('');
const [code, setCode] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
if (!recoveryOpen) return null;
const reset = () => {
setStep('email');
setEmail('');
setCode('');
setSubmitting(false);
setError(null);
setInfo(null);
};
const handleClose = () => {
reset();
closeRecovery();
};
const backToPrompt = () => {
reset();
closeRecovery();
openPrompt();
};
const handleRequestCode = async (e: React.FormEvent) => {
e.preventDefault();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setError(t('gallery.guestRecovery.invalidEmail', 'Enter a valid email address'));
return;
}
setSubmitting(true);
setError(null);
try {
await recoverRequest(email.trim().toLowerCase());
setInfo(t('gallery.guestRecovery.codeSent', 'Check your inbox for a verification code.'));
setStep('code');
} catch {
setError(t('gallery.guestRecovery.requestError', 'Could not send code. Try again.'));
} finally {
setSubmitting(false);
}
};
const handleVerify = async (e: React.FormEvent) => {
e.preventDefault();
if (!/^\d{6}$/.test(code.trim())) {
setError(t('gallery.guestRecovery.invalidCode', 'Enter the 6-digit code'));
return;
}
setSubmitting(true);
setError(null);
try {
await recoverVerify(email.trim().toLowerCase(), code.trim());
// Success: context clears recoveryOpen on success, component will
// unmount naturally.
} catch {
setError(t('gallery.guestRecovery.verifyError', 'Invalid or expired code.'));
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={handleClose} />
<div className="relative bg-surface rounded-lg shadow-xl max-w-md w-full p-6">
<button
type="button"
onClick={handleClose}
className="absolute top-4 right-4 p-1 hover:bg-black/10 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-muted-theme" />
</button>
<button
type="button"
onClick={backToPrompt}
className="flex items-center gap-1 text-sm text-muted-theme hover:text-theme mb-3"
>
<ArrowLeft className="w-4 h-4" />
{t('gallery.guestRecovery.back', 'Back')}
</button>
<h2 className="text-lg font-semibold text-theme mb-2">
{t('gallery.guestRecovery.title', 'Recover your picks')}
</h2>
<p className="text-sm text-muted-theme mb-4">
{step === 'email'
? t(
'gallery.guestRecovery.emailStepDescription',
'Enter the email you used before. We will send a 6-digit verification code.'
)
: t(
'gallery.guestRecovery.codeStepDescription',
'Enter the 6-digit code we sent to your email.'
)}
</p>
{info && step === 'code' && (
<div className="text-sm text-green-700 bg-green-50 dark:bg-green-900/20 rounded px-3 py-2 mb-3">
{info}
</div>
)}
{error && (
<div className="text-sm text-red-600 bg-red-50 dark:bg-red-900/20 rounded px-3 py-2 mb-3">
{error}
</div>
)}
{step === 'email' ? (
<form onSubmit={handleRequestCode} className="space-y-4">
<Input
type="email"
label={t('gallery.guestRecovery.emailLabel', 'Email')}
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
autoFocus
required
/>
<Button type="submit" variant="primary" className="w-full" disabled={submitting}>
{submitting
? t('common.submitting', 'Submitting...')
: t('gallery.guestRecovery.sendCode', 'Send code')}
</Button>
</form>
) : (
<form onSubmit={handleVerify} className="space-y-4">
<Input
label={t('gallery.guestRecovery.codeLabel', 'Verification code')}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="123456"
maxLength={6}
autoFocus
required
/>
<Button type="submit" variant="primary" className="w-full" disabled={submitting}>
{submitting
? t('common.submitting', 'Submitting...')
: t('gallery.guestRecovery.verifyCode', 'Verify and continue')}
</Button>
</form>
)}
</div>
</div>
);
};
@@ -7,6 +7,7 @@ import { toast } from 'react-toastify';
import { format } from 'date-fns';
import { Button, Input } from '../common';
import type { PhotoFeedback } from '../../services/feedback.service';
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
interface PhotoCommentsProps {
photoId: string;
@@ -29,6 +30,8 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const guestIdentity = useGuestIdentityOptional();
const isGuestMode = guestIdentity?.identityMode === 'guest';
const [showCommentForm, setShowCommentForm] = useState(false);
const [commentText, setCommentText] = useState('');
const [guestName, setGuestName] = useState('');
@@ -78,7 +81,7 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
}
});
const handleSubmitComment = (e: React.FormEvent) => {
const handleSubmitComment = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
@@ -87,7 +90,9 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
if (!commentText.trim()) {
newErrors.comment_text = t('feedback.commentRequired', 'Comment is required');
}
if (requireNameEmail) {
// In guest identity mode, name/email come from the guest token — don't
// ask for them here.
if (requireNameEmail && !isGuestMode) {
if (!guestName.trim()) {
newErrors.guest_name = t('feedback.nameRequired', 'Name is required');
}
@@ -101,6 +106,16 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
return;
}
if (isGuestMode && guestIdentity) {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
submitCommentMutation.mutate({ comment_text: commentText.trim() });
return;
}
submitCommentMutation.mutate({
comment_text: commentText.trim(),
guest_name: guestName.trim() || undefined,
@@ -140,7 +155,7 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
{/* Comment Form */}
{showCommentForm && (
<form onSubmit={handleSubmitComment} className="space-y-3 p-4 bg-surface rounded-lg border border-surface">
{requireNameEmail && (
{requireNameEmail && !isGuestMode && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Input
placeholder={t('feedback.yourName', 'Your name')}
@@ -164,7 +179,7 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder={t('feedback.writeComment', 'Write a comment...')}
className={`w-full px-3 py-2 text-sm border rounded-lg resize-vertical min-h-[100px] focus:ring-2 focus:ring-primary-500 focus:border-primary-500 ${
className={`w-full px-3 py-2 text-sm border rounded-lg resize-vertical min-h-[100px] focus:ring-2 focus:ring-primary-500 focus:border-accent-dark ${
errors.comment_text ? 'border-red-500' : 'border-surface'
}`}
rows={4}
@@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { feedbackService } from '../../services/feedback.service';
import { toast } from 'react-toastify';
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
interface PhotoFavoritesProps {
photoId: string;
@@ -27,6 +28,7 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const guestIdentity = useGuestIdentityOptional();
const [isSubmitting, setIsSubmitting] = useState(false);
const [animating, setAnimating] = useState(false);
const [showIdentityModal, setShowIdentityModal] = useState(false);
@@ -68,9 +70,19 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
}
});
const handleFavoriteClick = () => {
const handleFavoriteClick = async () => {
if (!isEnabled || isSubmitting) return;
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
submitFavoriteMutation.mutate({});
return;
}
if (requireNameEmail && !savedIdentity) {
setShowIdentityModal(true);
} else {
@@ -25,8 +25,8 @@ interface PhotoFilterBarProps {
onCategoryChange: (categoryId: number | string | null) => void;
searchTerm: string;
onSearchChange: (term: string) => void;
sortBy: 'date' | 'name' | 'size' | 'rating';
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating') => void;
sortBy: 'date' | 'name' | 'size' | 'rating' | 'capture_date';
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating' | 'capture_date') => void;
photoCount: number;
// Feedback filter props
feedbackEnabled?: boolean;
@@ -82,9 +82,10 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
className="w-full md:w-auto text-sm md:text-base"
>
<span className="hidden md:inline">{t('common.sortBy')} </span>
{sortBy === 'date' ? t('gallery.sortByDate').replace('Sort by ', '') :
sortBy === 'name' ? t('gallery.sortByName').replace('Sort by ', '') :
{sortBy === 'date' ? t('gallery.sortByDate').replace('Sort by ', '') :
sortBy === 'name' ? t('gallery.sortByName').replace('Sort by ', '') :
sortBy === 'size' ? t('gallery.sortBySize').replace('Sort by ', '') :
sortBy === 'capture_date' ? t('photoSort.dateTaken', 'Date Taken') :
t('gallery.sortByRating', 'Rating')}
</Button>
@@ -96,18 +97,29 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
setShowSortMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-black/10 ${
sortBy === 'date' ? 'text-primary-600 bg-primary-50' : 'text-muted-theme'
sortBy === 'date' ? 'bg-accent-dark text-white' : 'text-muted-theme'
}`}
>
{t('gallery.sortByDate')}
</button>
<button
onClick={() => {
onSortChange('capture_date');
setShowSortMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-black/10 ${
sortBy === 'capture_date' ? 'bg-accent-dark text-white' : 'text-muted-theme'
}`}
>
{t('photoSort.dateTaken', 'Date Taken')}
</button>
<button
onClick={() => {
onSortChange('name');
setShowSortMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-black/10 ${
sortBy === 'name' ? 'text-primary-600 bg-primary-50' : 'text-muted-theme'
sortBy === 'name' ? 'bg-accent-dark text-white' : 'text-muted-theme'
}`}
>
{t('gallery.sortByName')}
@@ -118,7 +130,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
setShowSortMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-black/10 ${
sortBy === 'size' ? 'text-primary-600 bg-primary-50' : 'text-muted-theme'
sortBy === 'size' ? 'bg-accent-dark text-white' : 'text-muted-theme'
}`}
>
{t('gallery.sortBySize')}
@@ -129,7 +141,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
setShowSortMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-black/10 ${
sortBy === 'rating' ? 'text-primary-600 bg-primary-50' : 'text-muted-theme'
sortBy === 'rating' ? 'bg-accent-dark text-white' : 'text-muted-theme'
}`}
>
{t('gallery.sortByRating', 'Sort by Rating')}
@@ -298,7 +298,7 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
<div className="absolute top-2 left-2 flex gap-1 z-10">
{(photo.comment_count ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count ?? 0} comments`}>
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
<MessageSquare className="w-3.5 h-3.5 text-accent" fill="currentColor" />
<span className="text-xs font-medium text-muted-theme">{photo.comment_count ?? 0}</span>
</div>
)}
@@ -341,7 +341,7 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
{/* Selection checkbox - Larger on mobile for easier tapping */}
{isSelectionMode && (
<div className={`absolute top-2 right-2 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 sm:opacity-0 sm:group-hover:opacity-100'} transition-opacity`}>
<div className={`w-7 h-7 sm:w-6 sm:h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}>
<div className={`w-7 h-7 sm:w-6 sm:h-6 rounded-full border-2 ${isSelected ? 'bg-accent-dark border-accent-dark' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}>
{isSelected && <Check className="w-4 h-4 text-white" />}
</div>
</div>
@@ -64,8 +64,13 @@ interface PhotoGridWithLayoutsProps {
heroDividerStyle?: HeroDividerStyle;
// Hero image anchor position (#162) keyword or "X% Y%" focal point
heroImageAnchor?: string;
// Welcome message (per-event) for layouts that display it
welcomeMessage?: string;
// Logout callback for full-page layouts
onLogout?: () => void;
// Client visibility controls (#172)
isClient?: boolean;
onToggleVisibility?: (photoId: number, currentVisibility: string) => void;
}
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
@@ -97,7 +102,10 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
headerStyle,
heroDividerStyle = 'wave',
heroImageAnchor = 'center',
onLogout
welcomeMessage,
onLogout,
isClient = false,
onToggleVisibility
}) => {
const { t } = useTranslation();
const { theme } = useTheme();
@@ -226,7 +234,10 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
heroLogoVisible,
heroLogoSize,
heroLogoPosition,
welcomeMessage,
onLogout,
isClient,
onToggleVisibility,
};
// Determine if we should show hero header (decoupled from layout)
@@ -284,6 +295,13 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
/>
)}
{/* Welcome Message - shown for non-fullpage layouts when set */}
{!isFullPageLayout && welcomeMessage && (
<div className="mb-6 px-4 py-3 rounded-lg bg-card-theme/50 border border-border-theme text-center">
<p className="text-sm text-muted-theme whitespace-pre-line">{welcomeMessage}</p>
</div>
)}
{/* Selection Mode Controls - Not shown for carousel, full-page layouts, or when controls are hidden */}
{showSelectionControls && photos.length > 1 && galleryLayout !== 'carousel' && !isFullPageLayout && (
<div className="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
+379 -104
View File
@@ -1,6 +1,6 @@
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 { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react';
import type { Photo } from '../../types';
import { useDownloadPhoto } from '../../hooks/useGallery';
import { AuthenticatedImage } from '../common';
@@ -8,6 +8,7 @@ import { PhotoFeedback } from './PhotoFeedback';
import { feedbackService } from '../../services/feedback.service';
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
import { VideoPlayer } from './VideoPlayer';
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
interface PhotoLightboxProps {
photos: Photo[];
@@ -46,6 +47,24 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [touchDistance, setTouchDistance] = useState<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);
// Carousel swipe state. A 3-slide track (prev/current/next) is shifted
// so the current slide is centered; the user's finger drags the track,
// and the track snaps to the neighbour or springs back when released.
// Percentage-based transforms avoid the need to measure the container
// before the first paint.
// - 'idle': showing the current slide, no transition
// - 'dragging': finger is down, track follows the finger (no transition)
// - 'committing': finger lifted past the threshold, animating to the
// neighbouring slot. On transitionend we advance currentIndex and reset.
// - 'springing': finger lifted below threshold, animating back to center.
const trackContainerRef = useRef<HTMLDivElement>(null);
const [dragX, setDragX] = useState(0);
const [phase, setPhase] = useState<'idle' | 'dragging' | 'committing' | 'springing'>('idle');
const [commitDirection, setCommitDirection] = useState<-1 | 1>(1);
const [showFeedback, setShowFeedback] = useState(initialShowFeedback);
const [isSmallScreen, setIsSmallScreen] = useState<boolean>(typeof window !== 'undefined' ? window.innerWidth < 640 : false);
const [feedbackSettings, setFeedbackSettings] = useState<{
@@ -62,7 +81,8 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'rating'; rating?: number }>(null);
const [imageLoaded, setImageLoaded] = useState(false);
const guestIdentity = useGuestIdentityOptional();
const isGuestMode = guestIdentity?.identityMode === 'guest';
useEffect(() => {
const onResize = () => setIsSmallScreen(window.innerWidth < 640);
@@ -70,11 +90,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
return () => window.removeEventListener('resize', onResize);
}, []);
// Reset image loaded state when changing photos
useEffect(() => {
setImageLoaded(false);
}, [currentIndex]);
const downloadPhotoMutation = useDownloadPhoto();
const currentPhoto = photos[currentIndex];
@@ -203,6 +219,33 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
}, [slug, currentPhoto.id, feedbackSettings?.feedback_enabled]);
const submitLike = async () => {
// Guest identity mode: ensure we have a per-person guest token. The
// server reads name/email from the token — body values are ignored.
if (isGuestMode && guestIdentity) {
try {
await guestIdentity.ensureIdentity();
} catch {
// User cancelled the prompt — abort silently.
return;
}
try {
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
feedback_type: 'like',
});
setMyLiked(prev => {
const next = !prev;
setLikeCount(c => Math.max(0, c + (next ? 1 : -1)));
return next;
});
if (onFeedbackChange) onFeedbackChange();
} catch (err) {
// eslint-disable-next-line no-console
console.warn('Like submit failed', err);
}
return;
}
// Simple mode: legacy inline identity modal flow.
const needIdentity = feedbackSettings?.require_name_email && !savedIdentity;
if (needIdentity) {
setPendingAction({ type: 'like' });
@@ -222,6 +265,33 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
};
const submitRating = async (value: number) => {
// Guest identity mode.
if (isGuestMode && guestIdentity) {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
try {
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
feedback_type: 'rating',
rating: value,
});
setMyRating(value);
try {
const fresh = await feedbackService.getPhotoFeedback(slug, String(currentPhoto.id));
setAvgRating(Number(fresh.summary?.average_rating) || 0);
setTotalRatings(Number(fresh.summary?.total_ratings) || 0);
} catch {}
if (onFeedbackChange) onFeedbackChange();
} catch (err) {
// eslint-disable-next-line no-console
console.warn('Rating submit failed', err);
}
return;
}
// Simple mode: legacy inline identity modal flow.
const needIdentity = feedbackSettings?.require_name_email && !savedIdentity;
if (needIdentity) {
setPendingAction({ type: 'rating', rating: value });
@@ -305,7 +375,9 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
}
};
// Touch event handlers for pinch-to-zoom
// Touch event handlers: pinch-to-zoom (2 fingers) + single-finger
// carousel-style swipe nav. Swipe is suppressed while zoomed in so the
// user can pan instead. The carousel is also disabled mid-animation.
const handleTouchStart = (e: React.TouchEvent) => {
if (e.touches.length === 2) {
const touch1 = e.touches[0];
@@ -315,6 +387,22 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
touch2.clientY - touch1.clientY
);
setTouchDistance(distance);
swipeStartRef.current = null;
// Cancel any in-progress carousel motion when a pinch starts —
// spring the track back so the image doesn't jerk under the user.
if (phase === 'dragging') {
if (dragX === 0) {
setPhase('idle');
} else {
setPhase('springing');
setDragX(0);
}
}
} 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() };
setPhase('dragging');
setDragX(0);
}
};
@@ -326,18 +414,129 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
touch2.clientX - touch1.clientX,
touch2.clientY - touch1.clientY
);
const scale = newDistance / touchDistance;
const newZoom = Math.max(1, Math.min(3, zoom * scale));
setZoom(newZoom);
setTouchDistance(newDistance);
return;
}
if (phase === 'dragging' && e.touches.length === 1 && swipeStartRef.current) {
const t = e.touches[0];
const dx = t.clientX - swipeStartRef.current.x;
const dy = t.clientY - swipeStartRef.current.y;
// Cancel the carousel drag if the gesture turns out to be vertical
// (e.g. an accidental scroll attempt while not zoomed). If we
// haven't moved horizontally yet, snap straight to idle — there's
// no transition to wait on — otherwise let the spring carry it back.
if (Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > 24) {
swipeStartRef.current = null;
if (dragX === 0) {
setPhase('idle');
} else {
setPhase('springing');
setDragX(0);
}
return;
}
setDragX(dx);
}
};
const handleTouchEnd = () => {
const handleTouchEnd = (e: React.TouchEvent) => {
setTouchDistance(null);
const start = swipeStartRef.current;
if (phase === 'dragging' && start && e.changedTouches.length > 0) {
const t = e.changedTouches[0];
const dx = t.clientX - start.x;
const dy = t.clientY - start.y;
const dt = Math.max(1, Date.now() - start.t);
const velocity = Math.abs(dx) / dt; // px / ms
const containerWidth = trackContainerRef.current?.offsetWidth ?? 0;
const threshold = Math.max(60, containerWidth * 0.2);
const isHorizontal = Math.abs(dx) > Math.abs(dy) * 1.2;
const shouldCommit = isHorizontal && (Math.abs(dx) > threshold || (velocity > 0.5 && Math.abs(dx) > 40));
if (shouldCommit) {
setCommitDirection(dx < 0 ? 1 : -1);
setDragX(dx);
setPhase('committing');
} else if (dragX === 0) {
// Tap with no movement — no transition would fire, so skip the
// springing phase to avoid getting stuck waiting for transitionend.
setPhase('idle');
} else {
setPhase('springing');
setDragX(0);
}
} else if (phase === 'dragging') {
// Touch ended without changedTouches data (rare) — reset cleanly.
setPhase('idle');
setDragX(0);
}
swipeStartRef.current = null;
};
const handleTouchCancel = () => {
// System took over the gesture (incoming call, edge swipe, etc.).
// Spring back if the carousel was being dragged.
if (phase === 'dragging') {
if (dragX === 0) {
setPhase('idle');
} else {
setPhase('springing');
setDragX(0);
}
}
swipeStartRef.current = null;
setTouchDistance(null);
};
// Track transform. Percentages on translateX are self-referential (a
// 300%-wide track translated -33.333% moves left by exactly one container
// width), so we never need to know the container width to position the
// slides. The drag delta is added in pixels.
// - idle / springing target: -33.333% (current centered)
// - dragging: -33.333% + dragX px (finger follows)
// - committing next: -66.666% (next centered)
// - committing prev: 0% (previous centered)
const trackTransform = (() => {
if (phase === 'dragging') return `translate3d(calc(-33.3333% + ${dragX}px), 0, 0)`;
if (phase === 'committing') {
return commitDirection === 1
? 'translate3d(-66.6666%, 0, 0)'
: 'translate3d(0%, 0, 0)';
}
return 'translate3d(-33.3333%, 0, 0)'; // idle | springing
})();
const trackTransition = phase === 'committing' || phase === 'springing'
? 'transform 280ms cubic-bezier(0.22, 0.61, 0.36, 1)'
: 'none';
const handleTrackTransitionEnd = (e: React.TransitionEvent) => {
if (e.propertyName !== 'transform') return;
if (phase === 'committing') {
if (commitDirection === 1) {
setCurrentIndex((prev) => (prev < photos.length - 1 ? prev + 1 : 0));
} else {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1));
}
setDragX(0);
setPhase('idle');
} else if (phase === 'springing') {
setPhase('idle');
}
};
const prevPhoto = photos.length > 1
? photos[(currentIndex - 1 + photos.length) % photos.length]
: null;
const nextPhoto = photos.length > 1
? photos[(currentIndex + 1) % photos.length]
: null;
// Apply protection class to the lightbox container
const lightboxClass = useEnhancedProtection ?
`fixed inset-0 bg-black z-50 flex items-center justify-center protected-image protection-${protectionLevel}` :
@@ -348,12 +547,16 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
return (
<div className={lightboxClass}>
{/* Close button */}
{/* Close button. top respects iOS safe-area (notch) so it doesn't
disappear under the camera/dynamic-island. */}
<button
onClick={onClose}
className="absolute top-4 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-30"
className="absolute p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-30"
aria-label="Close"
style={{ right: isDesktopFeedback ? `${desktopFeedbackWidth + 16}px` : '1rem' }}
style={{
top: 'max(1rem, env(safe-area-inset-top))',
right: isDesktopFeedback ? `${desktopFeedbackWidth + 16}px` : 'max(1rem, env(safe-area-inset-right))'
}}
>
<X className="w-6 h-6 text-white" />
</button>
@@ -378,19 +581,25 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
</button>
) : 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. */}
<div
className="absolute bottom-0 left-0 bg-gradient-to-t from-black/80 to-transparent p-4 z-20"
style={{ right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0 }}
className="absolute bottom-0 left-0 bg-gradient-to-t from-black/80 to-transparent px-3 pt-3 pb-3 sm:p-4 z-20"
style={{
right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0,
paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))'
}}
>
<div className="max-w-4xl mx-auto flex items-center justify-between">
<div className="max-w-4xl mx-auto flex items-center justify-between gap-2 flex-wrap">
<div className="text-white">
<p className="text-sm opacity-75">
{currentIndex + 1} / {photos.length}
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 sm:gap-2 flex-wrap justify-end">
<button
onClick={handleZoomOut}
disabled={zoom <= 1}
@@ -468,7 +677,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
>
<MessageSquare className="w-5 h-5 text-white" />
{((currentPhoto.comment_count ?? 0) > 0 || (currentPhoto.average_rating ?? 0) > 0) && (
<span className="absolute -top-1 -right-1 bg-primary-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
<span className="absolute -top-1 -right-1 bg-accent-dark/150 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{(currentPhoto.comment_count ?? 0) > 0 ? currentPhoto.comment_count ?? 0 : '★'}
</span>
)}
@@ -478,92 +687,158 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
</div>
</div>
{/* Image/Video container */}
<div
className="absolute top-0 left-0 bottom-0 flex items-center justify-center z-0"
onClick={currentPhoto.media_type === 'video' ? undefined : handleImageClick}
onMouseDown={currentPhoto.media_type === 'video' ? undefined : handleMouseDown}
onMouseMove={currentPhoto.media_type === 'video' ? undefined : handleMouseMove}
onMouseUp={currentPhoto.media_type === 'video' ? undefined : handleMouseUp}
onMouseLeave={currentPhoto.media_type === 'video' ? undefined : handleMouseUp}
onTouchStart={currentPhoto.media_type === 'video' ? undefined : handleTouchStart}
onTouchMove={currentPhoto.media_type === 'video' ? undefined : handleTouchMove}
onTouchEnd={currentPhoto.media_type === 'video' ? undefined : handleTouchEnd}
style={{
cursor: currentPhoto.media_type === 'video' ? 'default' : (zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'),
right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0,
}}
>
{/* Loading spinner */}
{!imageLoaded && currentPhoto.media_type !== 'video' && (
<div className="absolute inset-0 flex items-center justify-center z-10">
<Loader2 className="w-12 h-12 text-white animate-spin" />
</div>
)}
{/* Image/Video container.
For photos this hosts a 3-slide carousel (prev/current/next) so
swipe gestures animate the track and the neighbour images preload
while the user views the current one. Videos still render as a
single player sliding video elements during a drag is awkward
and the carousel adds nothing for that case. */}
{(() => {
const isVideoCurrent = currentPhoto.media_type === 'video';
{currentPhoto.media_type === 'video' ? (
<VideoPlayer
src={currentPhoto.url}
poster={currentPhoto.thumbnail_url}
className="max-w-full max-h-full"
controls={true}
autoPlay={false}
/>
) : (
<AuthenticatedImage
src={currentPhoto.url}
alt={currentPhoto.filename}
fallbackSrc={currentPhoto.thumbnail_url || undefined}
className="max-w-full max-h-full object-contain select-none"
const renderSlide = (photo: Photo | null, isCurrent: boolean) => {
// Reserve the slot even when there's no neighbour (single-photo
// gallery) so the flex layout keeps slides aligned.
if (!photo) {
return <div className="h-full" style={{ flex: '0 0 33.3333%' }} aria-hidden="true" />;
}
// Neighbouring slides are plain thumbnails — they're only on
// screen during the swipe animation, so we save the work of a
// protected canvas pipeline for them. The current slide keeps
// the full protection chain.
if (!isCurrent) {
return (
<div className="h-full flex items-center justify-center px-2" style={{ flex: '0 0 33.3333%' }}>
{photo.media_type === 'video' && photo.thumbnail_url ? (
<img
src={photo.thumbnail_url}
alt={photo.filename}
className="max-w-full max-h-full object-contain select-none pointer-events-none"
draggable={false}
/>
) : (
<AuthenticatedImage
src={photo.url}
alt={photo.filename}
fallbackSrc={photo.thumbnail_url || undefined}
className="max-w-full max-h-full object-contain select-none pointer-events-none"
draggable={false}
isGallery={true}
slug={slug}
photoId={photo.id}
requiresToken={photo.requires_token}
secureUrlTemplate={photo.secure_url_template}
/>
)}
</div>
);
}
return (
<div
className="h-full flex items-center justify-center"
style={{ flex: '0 0 33.3333%' }}
onClick={handleImageClick}
>
<AuthenticatedImage
src={photo.url}
alt={photo.filename}
fallbackSrc={photo.thumbnail_url || undefined}
className="max-w-full max-h-full object-contain select-none"
style={{
transform: `scale(${zoom}) translate(${dragOffset.x / zoom}px, ${dragOffset.y / zoom}px)`,
transition: isDragging ? 'none' : 'transform 0.2s',
}}
draggable={false}
useWatermark={useEnhancedProtection}
watermarkText={useEnhancedProtection ? `${photo.filename} - Protected` : undefined}
isGallery={true}
slug={slug}
photoId={photo.id}
requiresToken={photo.requires_token}
secureUrlTemplate={photo.secure_url_template}
protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
fragmentGrid={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
blockKeyboardShortcuts={useEnhancedProtection}
detectPrintScreen={useEnhancedProtection}
detectDevTools={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
onProtectionViolation={(violationType) => {
console.warn(`Protection violation in lightbox for photo ${photo.id}: ${violationType}`);
if (typeof window !== 'undefined' && (window as any).umami) {
(window as any).umami.track('lightbox_protection_violation', {
photoId: photo.id,
violationType,
protectionLevel,
zoom
});
}
if (protectionLevel === 'maximum' &&
['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) {
onClose();
}
}}
/>
</div>
);
};
return (
<div
ref={trackContainerRef}
className="absolute top-0 left-0 bottom-0 overflow-hidden z-0"
onClick={isVideoCurrent ? undefined : handleImageClick}
onMouseDown={isVideoCurrent ? undefined : handleMouseDown}
onMouseMove={isVideoCurrent ? undefined : handleMouseMove}
onMouseUp={isVideoCurrent ? undefined : handleMouseUp}
onMouseLeave={isVideoCurrent ? undefined : handleMouseUp}
onTouchStart={isVideoCurrent ? undefined : handleTouchStart}
onTouchMove={isVideoCurrent ? undefined : handleTouchMove}
onTouchEnd={isVideoCurrent ? undefined : handleTouchEnd}
onTouchCancel={isVideoCurrent ? undefined : handleTouchCancel}
style={{
transform: `scale(${zoom}) translate(${dragOffset.x / zoom}px, ${dragOffset.y / zoom}px)`,
transition: isDragging ? 'none' : 'transform 0.2s',
opacity: imageLoaded ? 1 : 0,
cursor: isVideoCurrent ? 'default' : (zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'),
right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0,
// Tell the browser we handle horizontal gestures ourselves so
// it doesn't fight us with edge-swipe back navigation, native
// pinch-zoom, etc. Videos keep default touch behaviour.
touchAction: isVideoCurrent ? 'auto' : 'none',
}}
draggable={false}
onLoad={() => setImageLoaded(true)}
useWatermark={useEnhancedProtection}
watermarkText={useEnhancedProtection ? `${currentPhoto.filename} - Protected` : undefined}
isGallery={true}
slug={slug}
photoId={currentPhoto.id}
requiresToken={currentPhoto.requires_token}
secureUrlTemplate={currentPhoto.secure_url_template}
protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
fragmentGrid={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
blockKeyboardShortcuts={useEnhancedProtection}
detectPrintScreen={useEnhancedProtection}
detectDevTools={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
onProtectionViolation={(violationType) => {
console.warn(`Protection violation in lightbox for photo ${currentPhoto.id}: ${violationType}`);
// Track analytics
if (typeof window !== 'undefined' && (window as any).umami) {
(window as any).umami.track('lightbox_protection_violation', {
photoId: currentPhoto.id,
violationType,
protectionLevel,
zoom
});
}
// For maximum protection, close lightbox on violation
if (protectionLevel === 'maximum' &&
['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) {
onClose();
}
}}
/>
)}
</div>
{/* Touch/swipe indicators for mobile */}
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 text-white text-sm opacity-50 pointer-events-none md:hidden z-20">
Swipe to navigate
</div>
>
{isVideoCurrent ? (
<div className="w-full h-full flex items-center justify-center">
<VideoPlayer
src={currentPhoto.url}
poster={currentPhoto.thumbnail_url}
className="max-w-full max-h-full"
controls={true}
autoPlay={false}
/>
</div>
) : (
<div
className="absolute inset-0 flex items-stretch"
style={{
width: '300%',
transform: trackTransform,
transition: trackTransition,
willChange: 'transform',
}}
onTransitionEnd={handleTrackTransitionEnd}
>
{renderSlide(prevPhoto, false)}
{renderSlide(currentPhoto, true)}
{renderSlide(nextPhoto, false)}
</div>
)}
</div>
);
})()}
{/* Feedback Panel */}
{showFeedback && (
+18 -2
View File
@@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { feedbackService } from '../../services/feedback.service';
import { toast } from 'react-toastify';
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
interface PhotoLikesProps {
photoId: string;
@@ -27,6 +28,7 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const guestIdentity = useGuestIdentityOptional();
const [isSubmitting, setIsSubmitting] = useState(false);
const [animating, setAnimating] = useState(false);
const [showIdentityModal, setShowIdentityModal] = useState(false);
@@ -68,9 +70,23 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
}
});
const handleLikeClick = () => {
const handleLikeClick = async () => {
if (!isEnabled || isSubmitting) return;
// Guest identity mode: ensure we have a per-person guest token. The
// server will read name/email from the token — body values are ignored.
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
// User cancelled the prompt — silently abort.
return;
}
submitLikeMutation.mutate({});
return;
}
// Simple mode (or no provider at all): legacy inline prompt flow.
if (requireNameEmail && !savedIdentity) {
setShowIdentityModal(true);
} else {
@@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { feedbackService } from '../../services/feedback.service';
import { toast } from 'react-toastify';
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
interface PhotoRatingProps {
photoId: string;
@@ -31,6 +32,7 @@ export const PhotoRating: React.FC<PhotoRatingProps> = ({
const safeAverageRating = typeof averageRating === 'number' && !isNaN(averageRating) ? averageRating : 0;
const { t } = useTranslation();
const queryClient = useQueryClient();
const guestIdentity = useGuestIdentityOptional();
const [hoveredRating, setHoveredRating] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showIdentityModal, setShowIdentityModal] = useState(false);
@@ -72,18 +74,28 @@ export const PhotoRating: React.FC<PhotoRatingProps> = ({
}
});
const handleRatingClick = (rating: number) => {
const handleRatingClick = async (rating: number) => {
if (!isEnabled || isSubmitting) return;
// If clicking the same rating, remove it
const newRating = rating === currentRating ? 0 : rating;
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
submitRatingMutation.mutate({ rating: newRating });
return;
}
if (requireNameEmail && !savedIdentity) {
setPendingRating(newRating);
setShowIdentityModal(true);
} else {
submitRatingMutation.mutate({
rating: newRating,
submitRatingMutation.mutate({
rating: newRating,
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email
});
@@ -1,11 +1,10 @@
import React, { useState, useMemo } from 'react';
import { Upload, X, CheckCircle } from 'lucide-react';
import { Upload, X, CheckCircle, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { Button } from '../common';
import { api } from '../../config/api';
import { publicSettingsService } from '../../services/publicSettings.service';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes';
interface UserPhotoUploadProps {
@@ -25,12 +24,12 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
const [files, setFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<{ [key: string]: number }>({});
// Per-file processing state — flips to true once axios reports
// bytes-on-wire for that file, so the UI can show "Processing…"
// instead of a static 100% bar while the backend works.
const [processingFiles, setProcessingFiles] = useState<{ [key: string]: boolean }>({});
const { data: publicSettings } = useQuery({
queryKey: ['public-settings'],
queryFn: () => publicSettingsService.getPublicSettings(),
staleTime: 5 * 60 * 1000,
});
const { data: publicSettings } = usePublicSettings();
const allowedMimeTypes = useMemo(
() => extensionsToMimeTypes(publicSettings?.allowed_file_types),
@@ -92,9 +91,18 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
...prev,
[file.name]: progress,
}));
if (progress >= 100) {
setProcessingFiles(prev => ({ ...prev, [file.name]: true }));
}
}
},
});
// Request resolved → file fully processed by backend.
setProcessingFiles(prev => {
const next = { ...prev };
delete next[file.name];
return next;
});
successCount++;
} catch (error: any) {
// Upload error handled - user notified via UI
@@ -149,7 +157,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
{/* Upload Area */}
<div className="mb-4 sm:mb-6">
<label className="block">
<div className="border-2 border-dashed border-surface rounded-lg p-6 sm:p-8 text-center hover:border-primary-500 transition-colors cursor-pointer">
<div className="border-2 border-dashed border-surface rounded-lg p-6 sm:p-8 text-center hover:border-accent-dark transition-colors cursor-pointer">
<Upload className="w-10 h-10 sm:w-12 sm:h-12 text-neutral-400 mx-auto mb-3" />
<p className="text-sm font-medium text-muted-theme mb-1">
{t('upload.clickToUpload')}
@@ -190,13 +198,19 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
</div>
{uploadProgress[file.name] !== undefined ? (
<div className="flex items-center gap-2">
{uploadProgress[file.name] === 100 ? (
{processingFiles[file.name] ? (
// Bytes are on the server; the request hasn't
// resolved yet because the backend is still
// generating thumbnails / reading EXIF. Show
// a spinner so it doesn't look stuck at 100%.
<Loader2 className="w-5 h-5 text-amber-600 animate-spin" />
) : uploadProgress[file.name] === 100 ? (
<CheckCircle className="w-5 h-5 text-green-600" />
) : (
<div className="w-20">
<div className="bg-neutral-200 rounded-full h-2">
<div
className="bg-primary-600 h-2 rounded-full transition-all"
className="bg-accent-dark h-2 rounded-full transition-all"
style={{ width: `${uploadProgress[file.name]}%` }}
/>
</div>
@@ -33,6 +33,9 @@ export interface BaseGalleryLayoutProps {
};
// Logout callback for full-page layouts
onLogout?: () => void;
// Client visibility controls (#172)
isClient?: boolean;
onToggleVisibility?: (photoId: number, currentVisibility: string) => void;
}
export abstract class BaseGalleryLayout<T extends BaseGalleryLayoutProps = BaseGalleryLayoutProps> extends React.Component<T> {
@@ -5,6 +5,7 @@ import { AuthenticatedImage, Button } from '../../common';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos,
@@ -66,6 +67,7 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const guestIdentity = useGuestIdentityOptional();
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
@@ -150,6 +152,20 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
variant="ghost"
size="sm"
onClick={async () => {
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
setLikedIds(prev => new Set(prev).add(currentPhoto.id));
try {
await feedbackService.submitFeedback(slug!, String(currentPhoto.id), {
feedback_type: 'like',
});
} catch (_) {}
return;
}
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
setPendingAction({ type: 'like', photoId: currentPhoto.id });
setShowIdentityModal(true);
@@ -17,6 +17,7 @@ import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
import { AuthenticatedImage } from '../../common';
import { feedbackService } from '../../../services/feedback.service';
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
import { FeedbackIdentityModal } from '../FeedbackIdentityModal';
import { galleryService } from '../../../services/gallery.service';
import { analyticsService } from '../../../services/analytics.service';
@@ -188,6 +189,7 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [likedPhotoIds, setLikedPhotoIds] = useState<Set<number>>(new Set());
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const guestIdentity = useGuestIdentityOptional();
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingLikePhotoId, setPendingLikePhotoId] = useState<number | null>(null);
@@ -237,6 +239,28 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
const handleLike = useCallback(async (photo: Photo, e: React.MouseEvent) => {
e.stopPropagation();
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
setLikedPhotoIds(prev => {
const next = new Set(prev);
next.add(photo.id);
return next;
});
try {
await feedbackService.submitFeedback(slug, String(photo.id), {
feedback_type: 'like',
});
onFeedbackChange?.();
} catch (err) {
console.warn('Like submit failed', err);
}
return;
}
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
setPendingLikePhotoId(photo.id);
setShowIdentityModal(true);
@@ -260,7 +284,7 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
} catch (err) {
console.warn('Like submit failed', err);
}
}, [slug, savedIdentity, feedbackOptions, onFeedbackChange]);
}, [slug, savedIdentity, feedbackOptions, onFeedbackChange, guestIdentity]);
const handleIdentitySubmit = useCallback(async (name: string, email: string) => {
setSavedIdentity({ name, email });
@@ -343,7 +367,7 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
<div
className="gallery-premium-hero-bg"
style={{
backgroundImage: heroPhoto ? `url(${heroPhoto.thumbnail_url || heroPhoto.url})` : undefined
backgroundImage: heroPhoto ? `url(${heroPhoto.hero_url || heroPhoto.url})` : undefined
}}
/>
<div className="gallery-premium-hero-overlay" />
@@ -17,6 +17,7 @@ import {
StoryFeedbackSheet,
StoryScrollToTop
} from './story';
import { PhotoLightbox } from '../PhotoLightbox';
import './GalleryStoryLayout.css';
@@ -34,6 +35,7 @@ interface CategoryScene {
interface GalleryStoryLayoutProps extends BaseGalleryLayoutProps {
heroPhotoOverride?: Photo | null;
welcomeMessage?: string;
}
export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
@@ -55,6 +57,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
feedbackEnabled = false,
feedbackOptions,
heroPhotoOverride,
welcomeMessage,
onLogout
}) => {
// These props are passed by parent but we use our own feedback system, so mark as intentionally unused
@@ -69,6 +72,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
const [searchQuery, setSearchQuery] = useState('');
const [favorites, setFavorites] = useState<Set<number>>(new Set());
const [selectedPhotoForFeedback, setSelectedPhotoForFeedback] = useState<Photo | null>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [comments, setComments] = useState<Record<number, Array<{ id: string; author: string; text: string; date: string }>>>({});
const [ratings, setRatings] = useState<Record<number, number>>({});
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
@@ -110,7 +114,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
// Group by category
filteredPhotos.forEach(photo => {
const categoryName = photo.category_name || t('gallery.uncategorized', 'Gallery');
const categoryName = photo.category_name || '';
if (!photosByCategory[categoryName]) {
photosByCategory[categoryName] = [];
}
@@ -161,6 +165,11 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
setSelectedPhotoForFeedback(photo);
}, []);
const handleOpenLightbox = useCallback((photo: Photo) => {
const index = photos.findIndex(p => p.id === photo.id);
setLightboxIndex(index >= 0 ? index : 0);
}, [photos]);
const handleCloseFeedback = useCallback(() => {
setSelectedPhotoForFeedback(null);
}, []);
@@ -310,7 +319,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
photos={scene.photos}
favorites={favorites}
onToggleFavorite={handleToggleFavorite}
onPhotoClick={handleOpenFeedback}
onPhotoClick={handleOpenLightbox}
slug={slug}
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
@@ -326,7 +335,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
index={index}
isFavorite={favorites.has(photo.id)}
onToggleFavorite={handleToggleFavorite}
onClick={() => handleOpenFeedback(photo)}
onClick={() => handleOpenLightbox(photo)}
slug={slug}
galleryId={`gallery-${scene.id}`}
allowDownloads={allowDownloads}
@@ -348,7 +357,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
<footer className="story-footer">
<h2 className="story-footer-title">{t('gallery.thankYou', 'Thank You')}</h2>
<p className="story-footer-text">
{t('gallery.thankYouMessage', 'For being part of our story and making our special day unforgettable.')}
{welcomeMessage || t('gallery.thankYouMessage', 'For being part of our story and making our special day unforgettable.')}
</p>
{allowDownloads && (
<button className="story-footer-btn" onClick={handleDownloadAll}>
@@ -357,6 +366,22 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
)}
</footer>
{/* Lightbox */}
{lightboxIndex !== null && (
<PhotoLightbox
photos={photos}
initialIndex={lightboxIndex}
onClose={() => setLightboxIndex(null)}
slug={slug}
feedbackEnabled={feedbackEnabled}
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
onFeedbackChange={onFeedbackChange}
/>
)}
{/* Feedback Sheet */}
{feedbackEnabled && (
<StoryFeedbackSheet
@@ -1,11 +1,12 @@
import React from 'react';
import { Download, Maximize2, Check, MessageSquare, Star, Heart, Video } from 'lucide-react';
import { Download, Maximize2, Check, MessageSquare, Star, Heart, Video, Eye, EyeOff } from 'lucide-react';
import { useInView } from 'react-intersection-observer';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
@@ -61,6 +62,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
onLikeSuccess
}) => {
const { t } = useTranslation();
const guestIdentity = useGuestIdentityOptional();
const [overlayVisible, setOverlayVisible] = React.useState(false);
const [isTouchDevice, setIsTouchDevice] = React.useState(false);
const overlayTimeoutRef = React.useRef<number | null>(null);
@@ -259,6 +261,25 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
className={`p-2 rounded-full transition-colors ${liked ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
onClick={async (e) => {
e.stopPropagation();
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
hideOverlay();
return;
}
if (onLikeSuccess) onLikeSuccess();
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
});
} catch (err) {
console.warn('Like submit failed, keeping optimistic UI', err);
}
if (onFeedbackChange) onFeedbackChange();
hideOverlay();
return;
}
if (feedbackOptions?.requireNameEmail && !savedIdentity && onRequireIdentity) {
onRequireIdentity('like', photo.id);
hideOverlay();
@@ -300,7 +321,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
className={`absolute top-2 right-2 z-20 transition-opacity ${checkboxVisibilityClass} md:group-hover:opacity-100`}
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
>
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-accent-dark border-accent-dark' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{isSelected && <Check className="w-4 h-4 text-white" />}
</div>
</button>
@@ -320,7 +341,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
)}
{commentCount > 0 && (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Commented">
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
<MessageSquare className="w-3.5 h-3.5 text-accent" fill="currentColor" />
</span>
)}
</div>
@@ -365,13 +386,19 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
useEnhancedProtection = false,
useCanvasRendering = false,
feedbackEnabled = false,
feedbackOptions
feedbackOptions,
isClient = false,
onToggleVisibility
}) => {
const { theme } = useTheme();
const gallerySettings = theme.gallerySettings || {};
const columns = gallerySettings.gridColumns || { mobile: 2, tablet: 3, desktop: 4 };
const spacing = gallerySettings.spacing || 'normal';
const animation = gallerySettings.photoAnimation || 'fade';
const scale = gallerySettings.thumbnailScale || 'md';
const scaleOffsets: Record<string, number> = { xs: 3, sm: 1, md: 0, lg: -1, xl: -2 };
const applyScale = (cols: number) => Math.max(1, cols + (scaleOffsets[scale] ?? 0));
const [showIdentityModal, setShowIdentityModal] = React.useState(false);
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like'; photoId: number }>(null);
@@ -379,49 +406,70 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null);
const spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4';
const gridClass = `photo-grid grid ${spacingClass}
grid-cols-${columns.mobile}
sm:grid-cols-${columns.tablet}
lg:grid-cols-${columns.desktop}
xl:grid-cols-${columns.desktop + 1}`;
grid-cols-${applyScale(columns.mobile)}
sm:grid-cols-${applyScale(columns.tablet)}
lg:grid-cols-${applyScale(columns.desktop)}
xl:grid-cols-${applyScale(columns.desktop + 1)}`;
return (
<div className={gridClass}>
{photos.map((photo, index) => (
<GridPhoto
key={photo.id}
photo={photo}
isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode}
onClick={() => onPhotoClick(index)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
onDownload={(e) => onDownload(photo, e)}
animationType={animation}
allowDownloads={allowDownloads}
slug={slug}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
savedIdentity={savedIdentity}
onRequireIdentity={(action, photoId) => {
setPendingAction({ type: action, photoId });
setShowIdentityModal(true);
}}
onQuickComment={() => onOpenPhotoWithFeedback && onOpenPhotoWithFeedback(index)}
onFeedbackChange={onFeedbackChange}
liked={likedPhotoIds.has(photo.id)}
onLikeSuccess={() => {
setLikedPhotoIds((prev) => {
const next = new Set(prev);
next.add(photo.id);
return next;
});
}}
/>
))}
{photos.map((photo, index) => {
const isHidden = photo.visibility === 'hidden';
return (
<div key={photo.id} className={`relative ${isClient && isHidden ? 'opacity-40' : ''}`}>
<GridPhoto
photo={photo}
isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode}
onClick={() => onPhotoClick(index)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
onDownload={(e) => onDownload(photo, e)}
animationType={animation}
allowDownloads={allowDownloads}
slug={slug}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
savedIdentity={savedIdentity}
onRequireIdentity={(action, photoId) => {
setPendingAction({ type: action, photoId });
setShowIdentityModal(true);
}}
onQuickComment={() => onOpenPhotoWithFeedback && onOpenPhotoWithFeedback(index)}
onFeedbackChange={onFeedbackChange}
liked={likedPhotoIds.has(photo.id)}
onLikeSuccess={() => {
setLikedPhotoIds((prev) => {
const next = new Set(prev);
next.add(photo.id);
return next;
});
}}
/>
{/* Client visibility toggle overlay (#172) */}
{isClient && onToggleVisibility && (
<button
onClick={(e) => {
e.stopPropagation();
onToggleVisibility(photo.id, photo.visibility || 'visible');
}}
className={`absolute top-2 left-2 z-10 p-1.5 rounded-full shadow-md transition-colors ${
isHidden
? 'bg-red-500/90 text-white hover:bg-red-600'
: 'bg-white/90 text-neutral-700 hover:bg-white dark:bg-neutral-800/90 dark:text-neutral-200 dark:hover:bg-neutral-700'
}`}
title={isHidden ? 'Hidden from guests' : 'Visible to guests'}
>
{isHidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
)}
</div>
);
})}
<FeedbackIdentityModal
isOpen={showIdentityModal}
onClose={() => { setShowIdentityModal(false); setPendingAction(null); }}
@@ -8,6 +8,7 @@ import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
import { buildResourceUrl } from '../../../utils/url';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
@@ -81,6 +82,7 @@ const JustifiedPhoto: React.FC<JustifiedPhotoProps> = ({
liked = false,
onLikeSuccess,
}) => {
const guestIdentity = useGuestIdentityOptional();
const [overlayVisible, setOverlayVisible] = useState(false);
const [isTouchDevice, setIsTouchDevice] = useState(false);
const overlayTimeoutRef = useRef<number | null>(null);
@@ -301,6 +303,25 @@ const JustifiedPhoto: React.FC<JustifiedPhotoProps> = ({
}`}
onClick={async (e) => {
e.stopPropagation();
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
hideOverlay();
return;
}
if (onLikeSuccess) onLikeSuccess();
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
});
} catch (err) {
console.warn('Like submit failed, keeping optimistic UI', err);
}
if (onFeedbackChange) onFeedbackChange();
hideOverlay();
return;
}
if (feedbackOptions?.requireNameEmail && !savedIdentity && onRequireIdentity) {
onRequireIdentity('like', photo.id);
hideOverlay();
@@ -349,7 +370,7 @@ const JustifiedPhoto: React.FC<JustifiedPhotoProps> = ({
>
<div
className={`w-6 h-6 rounded-full border-2 ${
isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'
isSelected ? 'bg-accent-dark border-accent-dark' : 'bg-white/90 border-white'
} flex items-center justify-center transition-colors`}
>
{isSelected && <Check className="w-4 h-4 text-white" />}
@@ -382,7 +403,7 @@ const JustifiedPhoto: React.FC<JustifiedPhotoProps> = ({
className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm"
title="Commented"
>
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
<MessageSquare className="w-3.5 h-3.5 text-accent" fill="currentColor" />
</span>
)}
</div>
@@ -4,6 +4,7 @@ import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
import {
calculateJustifiedLayout,
createJustifiedPhotos,
@@ -33,6 +34,9 @@ interface MasonryPhotoProps {
onQuickComment?: () => void;
// Column width for calculating proper aspect-ratio-based height
columnWidth?: number;
// Optimistic "I liked this" state + callback (lifted to parent)
liked?: boolean;
onLikeSuccess?: () => void;
}
const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
@@ -48,11 +52,14 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
slug,
feedbackOptions,
onQuickComment,
columnWidth = 300
columnWidth = 300,
liked = false,
onLikeSuccess,
}) => {
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const guestIdentity = useGuestIdentityOptional();
// Calculate height based on actual photo aspect ratio
// This preserves the photo's natural proportions in the masonry layout
@@ -95,7 +102,7 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
<div className="absolute top-2 left-2 flex gap-1 z-10">
{(photo.comment_count ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count ?? 0} comments`}>
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
<MessageSquare className="w-3.5 h-3.5 text-accent" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{photo.comment_count ?? 0}</span>
</div>
)}
@@ -148,24 +155,56 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
)}
{feedbackEnabled && feedbackOptions?.allowLikes && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
className={`p-2 rounded-full transition-colors ${
liked
? 'bg-red-500/90 hover:bg-red-500'
: 'bg-white/90 hover:bg-white'
}`}
onClick={async (e) => {
e.stopPropagation();
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
// Optimistic UI: mark as liked immediately
if (onLikeSuccess) onLikeSuccess();
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
});
} catch (err) {
// eslint-disable-next-line no-console
console.warn('Like submit failed, keeping optimistic UI', err);
}
return;
}
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
setPendingAction({ type: 'like', photoId: photo.id });
setShowIdentityModal(true);
return;
}
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
// Optimistic UI: mark as liked immediately
if (onLikeSuccess) onLikeSuccess();
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
} catch (err) {
// eslint-disable-next-line no-console
console.warn('Like submit failed, keeping optimistic UI', err);
}
}}
aria-label="Like photo"
title="Like"
aria-label={liked ? 'Unlike photo' : 'Like photo'}
aria-pressed={liked}
title={liked ? 'Unlike' : 'Like'}
>
<Heart className="w-5 h-5 text-neutral-800" />
<Heart
className={`w-5 h-5 ${liked ? 'text-white fill-white' : 'text-neutral-800'}`}
/>
</button>
)}
</>
@@ -180,6 +219,9 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
setSavedIdentity({ name, email });
setShowIdentityModal(false);
if (pendingAction) {
if (pendingAction.type === 'like' && onLikeSuccess) {
onLikeSuccess();
}
await feedbackService.submitFeedback(slug!, String(pendingAction.photoId), {
feedback_type: pendingAction.type,
guest_name: name,
@@ -203,7 +245,7 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
}`}
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
>
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-accent-dark border-accent-dark' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{isSelected && <Check className="w-4 h-4 text-white" />}
</div>
</button>
@@ -236,11 +278,21 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
const containerRef = useRef<HTMLDivElement>(null);
const [columns, setColumns] = useState(3);
const [containerWidth, setContainerWidth] = useState(0);
// Optimistic "I liked this" state — lifted here so it survives re-renders
// of individual MasonryPhoto components during layout reflow/resize.
const [likedPhotoIds, setLikedPhotoIds] = useState<Set<number>>(new Set());
const gallerySettings = theme.gallerySettings || {};
const gutter = gallerySettings.masonryGutter || 16;
const mode = gallerySettings.masonryMode || 'columns';
const targetRowHeight = gallerySettings.masonryRowHeight || 250;
const lastRowBehavior = gallerySettings.masonryLastRowBehavior || 'left';
const scale = gallerySettings.thumbnailScale || 'md';
const scaleOffsets: Record<string, number> = { xs: 3, sm: 1, md: 0, lg: -1, xl: -2 };
const applyScale = (cols: number) => Math.max(1, cols + (scaleOffsets[scale] ?? 0));
// Apply scale to columns only in columns mode
const scaledColumns = mode === 'columns' ? applyScale(columns) : columns;
// Calculate number of columns based on container width (for columns mode)
@@ -339,20 +391,20 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
// This creates a more balanced masonry layout instead of round-robin
const photoColumns: Photo[][] = useMemo(() => {
if (mode !== 'columns' || photos.length === 0) {
return Array.from({ length: columns }, () => []);
return Array.from({ length: scaledColumns }, () => []);
}
const cols: Photo[][] = Array.from({ length: columns }, () => []);
const colHeights: number[] = Array(columns).fill(0);
const cols: Photo[][] = Array.from({ length: scaledColumns }, () => []);
const colHeights: number[] = Array(scaledColumns).fill(0);
// Calculate approximate column width for height estimation
const approxColWidth = containerWidth > 0 ? (containerWidth - (columns - 1) * gutter) / columns : 300;
const approxColWidth = containerWidth > 0 ? (containerWidth - (scaledColumns - 1) * gutter) / scaledColumns : 300;
photos.forEach((photo) => {
// Find the shortest column
let shortestCol = 0;
let minHeight = colHeights[0];
for (let i = 1; i < columns; i++) {
for (let i = 1; i < scaledColumns; i++) {
if (colHeights[i] < minHeight) {
minHeight = colHeights[i];
shortestCol = i;
@@ -373,15 +425,15 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
});
return cols;
}, [mode, photos, columns, containerWidth, gutter]);
}, [mode, photos, scaledColumns, containerWidth, gutter]);
// Calculate approximate column width for aspect ratio calculations
const columnWidth = useMemo(() => {
if (containerWidth <= 0 || columns <= 0) return 300;
if (containerWidth <= 0 || scaledColumns <= 0) return 300;
// Account for gaps between columns
const totalGaps = (columns - 1) * gutter;
return (containerWidth - totalGaps) / columns;
}, [containerWidth, columns, gutter]);
const totalGaps = (scaledColumns - 1) * gutter;
return (containerWidth - totalGaps) / scaledColumns;
}, [containerWidth, scaledColumns, gutter]);
// ROWS MODE - Google Photos style justified layout
if (mode === 'rows') {
@@ -434,7 +486,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
<div className="absolute top-2 left-2 flex gap-1 z-10">
{(photo.comment_count ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
<MessageSquare className="w-3.5 h-3.5 text-accent" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{photo.comment_count ?? 0}</span>
</div>
)}
@@ -490,7 +542,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
}`}
onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photo.id); }}
>
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-accent-dark border-accent-dark' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
</div>
</button>
@@ -550,7 +602,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
<div className="absolute top-2 left-2 flex gap-1 z-10">
{(photo.comment_count ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
<MessageSquare className="w-3.5 h-3.5 text-accent" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{photo.comment_count ?? 0}</span>
</div>
)}
@@ -606,7 +658,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
}`}
onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photo.id); }}
>
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-accent-dark border-accent-dark' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
</div>
</button>
@@ -675,7 +727,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
<div className="absolute top-2 left-2 flex gap-1 z-10">
{(photo.comment_count ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
<MessageSquare className="w-3.5 h-3.5 text-accent" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{photo.comment_count ?? 0}</span>
</div>
)}
@@ -731,7 +783,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
}`}
onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photo.id); }}
>
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-accent-dark border-accent-dark' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
</div>
</button>
@@ -778,6 +830,14 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
feedbackOptions={feedbackOptions}
onQuickComment={() => onOpenPhotoWithFeedback && onOpenPhotoWithFeedback(originalIndex)}
columnWidth={columnWidth}
liked={likedPhotoIds.has(photo.id)}
onLikeSuccess={() => {
setLikedPhotoIds((prev) => {
const next = new Set(prev);
next.add(photo.id);
return next;
});
}}
/>
);
})}
@@ -1,8 +1,10 @@
import React from 'react';
import { Download, Maximize2, Check, Heart, MessageSquare } from 'lucide-react';
import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
@@ -52,6 +54,7 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
const [showIdentityModal, setShowIdentityModal] = React.useState(false);
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like'; photoId: number }>(null);
const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null);
const guestIdentity = useGuestIdentityOptional();
const [likedLocal, setLikedLocal] = React.useState(false);
const canComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onQuickComment);
@@ -107,6 +110,20 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
className={`p-2 rounded-full transition-colors ${likedLocal ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
onClick={async (e) => {
e.stopPropagation();
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
setLikedLocal(true);
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
});
} catch (_) {}
return;
}
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
setPendingAction({ type: 'like', photoId: photo.id });
setShowIdentityModal(true);
@@ -163,7 +180,7 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
}`}
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
>
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-accent-dark border-accent-dark' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{isSelected && <Check className="w-4 h-4 text-white" />}
</div>
</button>
@@ -210,23 +227,33 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
feedbackEnabled = false,
feedbackOptions
}) => {
const { theme } = useTheme();
const scale = theme.gallerySettings?.thumbnailScale || 'md';
const scaleOffsets: Record<string, number> = { xs: 3, sm: 1, md: 0, lg: -1, xl: -2 };
const applyScale = (cols: number, min = 1) => Math.max(min, cols + (scaleOffsets[scale] ?? 0));
const desktop = applyScale(4);
const xlDown = applyScale(3);
const lgDown = applyScale(2);
const mobile = Math.min(applyScale(1), 2); // Cap mobile at 2
return (
<div
className="photo-grid w-full"
style={{
columnCount: 4,
columnCount: desktop,
columnGap: '8px',
}}
>
<style>{`
@media (max-width: 1280px) {
.photo-grid { column-count: 3 !important; }
.photo-grid { column-count: ${xlDown} !important; }
}
@media (max-width: 1024px) {
.photo-grid { column-count: 2 !important; }
.photo-grid { column-count: ${lgDown} !important; }
}
@media (max-width: 640px) {
.photo-grid { column-count: 1 !important; }
.photo-grid { column-count: ${mobile} !important; }
}
`}</style>
{photos.map((photo, index) => (
@@ -7,6 +7,7 @@ import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos,
@@ -26,6 +27,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const guestIdentity = useGuestIdentityOptional();
const gallerySettings = theme.gallerySettings || {};
const grouping = gallerySettings.timelineGrouping || 'day';
const showDates = gallerySettings.timelineShowDates !== false;
@@ -84,8 +86,8 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
{/* Date marker */}
{showDates && (
<div className="flex items-center gap-4 mb-6">
<div className="hidden lg:flex items-center justify-center w-16 h-16 bg-white border-4 border-primary-600 rounded-full z-10">
<Calendar className="w-6 h-6 text-primary-600" />
<div className="hidden lg:flex items-center justify-center w-16 h-16 bg-white border-4 border-accent-dark rounded-full z-10">
<Calendar className="w-6 h-6 text-accent" />
</div>
<h3 className="text-xl font-semibold text-theme">
{group.label}
@@ -147,6 +149,20 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
className={`p-2 rounded-full transition-colors ${likedIds.has(photo.id) ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
onClick={async (e) => {
e.stopPropagation();
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
setLikedIds(prev => new Set(prev).add(photo.id));
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
});
} catch (_) {}
return;
}
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
setPendingAction({ type: 'like', photoId: photo.id });
setShowIdentityModal(true);
@@ -202,7 +218,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
}`}
onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photo.id); }}
>
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-accent-dark border-accent-dark' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
</div>
</button>
@@ -60,7 +60,7 @@ export const StoryPhotoCard: React.FC<StoryPhotoCardProps> = ({
className="block w-full h-full"
>
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
src={photo.url}
alt={photo.filename}
onLoad={() => setIsLoaded(true)}
className={`w-full h-full object-cover transition-all duration-700 ease-out will-change-transform ${
@@ -18,27 +18,29 @@ export const StoryScene: React.FC<StorySceneProps> = ({
}) => {
return (
<section className={`story-scene ${fullWidth ? 'full-width' : ''} ${className}`}>
<div className="story-scene-header">
<motion.h2
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
className="story-scene-title"
>
{title}
</motion.h2>
{subtitle && (
<motion.p
{title && (
<div className="story-scene-header">
<motion.h2
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.1 }}
className="story-scene-subtitle"
className="story-scene-title"
>
{subtitle}
</motion.p>
)}
</div>
{title}
</motion.h2>
{subtitle && (
<motion.p
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.1 }}
className="story-scene-subtitle"
>
{subtitle}
</motion.p>
)}
</div>
)}
{children}
</section>
);
+36 -3
View File
@@ -5,11 +5,18 @@ import {
inferGallerySlugFromLocation,
resolveSlugFromRequestUrl,
} from '../utils/galleryAuthStorage';
import { getGuestToken } from '../utils/guestIdentityStorage';
import { getApiBaseUrl } from '../utils/url';
// Maintenance mode callback
let maintenanceModeCallback: ((enabled: boolean) => void) | null = null;
// Set true the moment we kick off a hard redirect to /admin/login so
// subsequent 401s in the same tick don't queue more navigations on top
// (each `window.location.href = …` aborts the previous, producing a
// flicker storm — see the response interceptor below).
let adminLoginRedirectPending = false;
export const setMaintenanceModeCallback = (callback: (enabled: boolean) => void) => {
maintenanceModeCallback = callback;
};
@@ -80,6 +87,25 @@ api.interceptors.request.use(
}
}
}
// Also inject guest token (x-guest-token) for per-person identity.
// Separate header so gallery auth and guest identity are independent.
const guestToken = getGuestToken(slug);
if (guestToken) {
if (!config.headers) {
config.headers = new AxiosHeaders();
}
if (config.headers instanceof AxiosHeaders) {
if (!config.headers.get('x-guest-token')) {
config.headers.set('x-guest-token', guestToken);
}
} else {
const headersRecord = config.headers as Record<string, string | undefined>;
if (!headersRecord['x-guest-token']) {
headersRecord['x-guest-token'] = guestToken;
}
}
}
}
}
}
@@ -111,10 +137,17 @@ api.interceptors.response.use(
// Check if it's an admin route (but not public endpoints)
const isAdminRoute = error.config?.url?.includes('/admin') && !error.config?.url?.includes('/public/');
const currentPath = window.location.pathname;
if (isAdminRoute) {
// Only redirect if we're not already on the admin login page
if (!currentPath.includes('/admin/login')) {
// Only redirect if we're not already on the admin login page.
// `window.location.href = …` is async — `pathname` doesn't change
// synchronously — so a fan-out of 401s (the dashboard fires 7 admin
// queries in parallel) would each see the old pathname and each
// call `location.href`, producing a navigation storm where every
// request is aborted by the next. Guard with a module-level flag
// so only the first 401 triggers the redirect.
if (!currentPath.includes('/admin/login') && !adminLoginRedirectPending) {
adminLoginRedirectPending = true;
window.location.href = '/admin/login';
}
} else {
+43 -8
View File
@@ -1,11 +1,19 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { usePublicSettings } from '../hooks/usePublicSettings';
type DarkModePreference = 'light' | 'dark' | 'system';
interface AdminDarkModeContextType {
preference: DarkModePreference;
isDark: boolean;
/**
* When the admin has set `branding_force_color_mode`, the toggle is locked
* to that value. Consumers (AdminHeader) hide their toggle when this is
* truthy UI parity with the user's "disable lightmode option page wide"
* request from discussion #397.
*/
forcedMode: 'dark' | 'light' | null;
setPreference: (pref: DarkModePreference) => void;
toggle: () => void;
}
@@ -24,12 +32,25 @@ export const AdminDarkModeProvider: React.FC<{ children: React.ReactNode }> = ({
const location = useLocation();
const isLoginPage = location.pathname === '/admin/login';
// Instance-wide force mode (read from branding settings). When set, this
// wins over user preference and system preference. Refetches every 30s so
// toggling it in the Branding tab propagates to other open tabs without
// needing a full reload.
const { data: publicSettings } = usePublicSettings({ refetchInterval: 30_000 });
const forcedMode: 'dark' | 'light' | null = publicSettings?.branding_force_color_mode === 'dark'
? 'dark'
: publicSettings?.branding_force_color_mode === 'light'
? 'light'
: null;
const [preference, setPreferenceState] = useState<DarkModePreference>(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'dark' || stored === 'light' || stored === 'system') return stored;
return 'light';
});
// The effective dark state: if a force mode is set, that wins; otherwise
// we resolve from the user's preference (light / dark / system).
const [isDark, setIsDark] = useState(() => resolveIsDark(preference));
const applyDarkClass = useCallback((dark: boolean, forceLight = false) => {
@@ -42,26 +63,37 @@ export const AdminDarkModeProvider: React.FC<{ children: React.ReactNode }> = ({
}, []);
const setPreference = useCallback((pref: DarkModePreference) => {
// If an admin has locked the instance to a specific mode, the user
// toggle is a no-op — silently ignore so we don't desync the UI.
if (forcedMode) return;
setPreferenceState(pref);
localStorage.setItem(STORAGE_KEY, pref);
const dark = resolveIsDark(pref);
setIsDark(dark);
// Don't apply dark on login page
applyDarkClass(dark, isLoginPage);
}, [applyDarkClass, isLoginPage]);
}, [applyDarkClass, isLoginPage, forcedMode]);
const toggle = useCallback(() => {
if (forcedMode) return;
setPreference(isDark ? 'light' : 'dark');
}, [isDark, setPreference]);
}, [isDark, setPreference, forcedMode]);
// Apply on mount and when route changes - skip dark mode on login page
// Apply on mount and when route or force mode changes. The force-mode
// branch wins, then per-route login override, then user preference.
useEffect(() => {
if (forcedMode) {
const dark = forcedMode === 'dark';
setIsDark(dark);
applyDarkClass(dark, isLoginPage && forcedMode === 'light');
return;
}
applyDarkClass(isDark, isLoginPage);
}, [applyDarkClass, isDark, isLoginPage]);
}, [applyDarkClass, isDark, isLoginPage, forcedMode]);
// Listen for system changes when preference is 'system'
// Listen for system changes when preference is 'system' (and no force mode)
useEffect(() => {
if (preference !== 'system') return;
if (forcedMode || preference !== 'system') return;
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
@@ -70,7 +102,7 @@ export const AdminDarkModeProvider: React.FC<{ children: React.ReactNode }> = ({
};
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, [preference, applyDarkClass]);
}, [preference, applyDarkClass, forcedMode]);
// Strip dark class when unmounting (navigating away from admin)
useEffect(() => {
@@ -79,7 +111,10 @@ export const AdminDarkModeProvider: React.FC<{ children: React.ReactNode }> = ({
};
}, []);
const value = useMemo(() => ({ preference, isDark, setPreference, toggle }), [preference, isDark, setPreference, toggle]);
const value = useMemo(
() => ({ preference, isDark, forcedMode, setPreference, toggle }),
[preference, isDark, forcedMode, setPreference, toggle]
);
return (
<AdminDarkModeContext.Provider value={value}>
@@ -11,6 +11,7 @@ import {
setActiveGallerySlug,
storeGalleryToken,
} from '../utils/galleryAuthStorage';
import type { GalleryAccessLevel } from '../types';
interface GalleryEvent {
id: number;
@@ -37,7 +38,10 @@ const normalizeEvent = (incoming: GalleryEvent | null | undefined): GalleryEvent
interface GalleryAuthContextType {
isAuthenticated: boolean;
event: GalleryEvent | null;
accessLevel: GalleryAccessLevel;
isClient: boolean;
login: (slug: string, password?: string, recaptchaToken?: string | null) => Promise<void>;
clientLogin: (slug: string, password: string) => Promise<void>;
logout: () => void;
isLoading: boolean;
error: string | null;
@@ -60,6 +64,7 @@ interface GalleryAuthProviderProps {
export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [event, setEvent] = useState<GalleryEvent | null>(null);
const [accessLevel, setAccessLevel] = useState<GalleryAccessLevel>('guest');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [routeError, setRouteError] = useState<string | null>(null);
@@ -188,6 +193,14 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
}
}
// Restore access level from session storage
const storedAccessLevel = sessionStorage.getItem(`gallery_access_level_${currentSlug}`);
if (storedAccessLevel === 'client') {
setAccessLevel('client');
} else {
setAccessLevel('guest');
}
const initialise = async () => {
try {
setIsLoading(true);
@@ -285,15 +298,43 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
}
};
const clientLoginFn = async (slug: string, password: string) => {
try {
setRouteError(null);
setError(null);
setIsLoading(true);
const response = await authService.clientLogin(slug, password);
if (response.token) {
storeGalleryToken(slug, response.token);
}
setActiveGallerySlug(slug);
const normalizedEvent = normalizeEvent(response.event);
setEvent(normalizedEvent);
if (normalizedEvent) {
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent));
}
setAccessLevel(response.accessLevel || 'client');
sessionStorage.setItem(`gallery_access_level_${slug}`, 'client');
setIsAuthenticated(true);
} catch (err: any) {
setError(err.response?.data?.error || 'Invalid PIN');
throw err;
} finally {
setIsLoading(false);
}
};
const logout = () => {
const currentSlug = routeInfo.slug;
if (currentSlug) {
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
sessionStorage.removeItem(`gallery_access_level_${currentSlug}`);
clearGalleryToken(currentSlug);
}
authService.galleryLogout(currentSlug || undefined);
setIsAuthenticated(false);
setEvent(null);
setAccessLevel('guest');
clearActiveGallerySlug();
};
@@ -302,7 +343,10 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
value={{
isAuthenticated,
event,
accessLevel,
isClient: accessLevel === 'client',
login,
clientLogin: clientLoginFn,
logout,
isLoading,
error: routeError ?? error,
@@ -0,0 +1,224 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { guestsService, GuestIdentity } from '../services/guests.service';
import {
clearGuestIdentity,
getGuestIdentity,
storeGuestIdentity,
} from '../utils/guestIdentityStorage';
type IdentityMode = 'simple' | 'guest';
interface GuestIdentityContextValue {
slug: string;
identity: GuestIdentity | null;
identityMode: IdentityMode;
isRequired: boolean; // true when mode='guest' AND no identity yet
promptOpen: boolean;
recoveryOpen: boolean;
openPrompt: () => void;
closePrompt: () => void;
openRecovery: () => void;
closeRecovery: () => void;
register: (name: string, email?: string) => Promise<GuestIdentity>;
recoverRequest: (email: string) => Promise<void>;
recoverVerify: (email: string, code: string) => Promise<GuestIdentity>;
forget: () => Promise<void>;
/**
* Used by feedback components. Returns the current identity, or opens the
* prompt and waits until the user registers (or cancels, in which case it
* throws a "user_cancelled" error).
*/
ensureIdentity: () => Promise<GuestIdentity>;
}
const GuestIdentityContext = createContext<GuestIdentityContextValue | null>(null);
interface GuestIdentityProviderProps {
slug: string;
identityMode: IdentityMode;
children: React.ReactNode;
}
export const GuestIdentityProvider: React.FC<GuestIdentityProviderProps> = ({
slug,
identityMode,
children,
}) => {
const [identity, setIdentity] = useState<GuestIdentity | null>(() => getGuestIdentity(slug));
const [promptOpen, setPromptOpen] = useState(false);
const [recoveryOpen, setRecoveryOpen] = useState(false);
// Pending promise resolvers for ensureIdentity() calls waiting on prompt.
const pendingResolvers = useRef<Array<(identity: GuestIdentity) => void>>([]);
const pendingRejecters = useRef<Array<(reason: Error) => void>>([]);
// Rehydrate identity when slug changes.
useEffect(() => {
setIdentity(getGuestIdentity(slug));
}, [slug]);
// When an invite token is present on the URL (?invite=xxx), redeem it once
// on mount. The server returns a guest token we can persist.
useEffect(() => {
if (identityMode !== 'guest' || identity) return;
const params = new URLSearchParams(window.location.search);
const inviteToken = params.get('invite');
if (!inviteToken) return;
(async () => {
try {
const response = await guestsService.redeemInvite(slug, inviteToken);
storeGuestIdentity(slug, response.guest, response.token);
setIdentity(response.guest);
// Strip invite param from URL to prevent re-redemption on reload.
params.delete('invite');
const newSearch = params.toString();
const newUrl = window.location.pathname + (newSearch ? `?${newSearch}` : '') + window.location.hash;
window.history.replaceState({}, '', newUrl);
} catch (error) {
// Silently fail invalid invites; user will fall back to normal prompt.
// eslint-disable-next-line no-console
console.warn('Failed to redeem invite token', error);
}
})();
}, [slug, identityMode, identity]);
const openPrompt = useCallback(() => setPromptOpen(true), []);
const closePrompt = useCallback(() => {
setPromptOpen(false);
// Reject any pending ensureIdentity() promises.
pendingRejecters.current.forEach((r) => r(new Error('user_cancelled')));
pendingResolvers.current = [];
pendingRejecters.current = [];
}, []);
const openRecovery = useCallback(() => setRecoveryOpen(true), []);
const closeRecovery = useCallback(() => setRecoveryOpen(false), []);
const register = useCallback(
async (name: string, email?: string): Promise<GuestIdentity> => {
const response = await guestsService.registerGuest(slug, { name, email });
storeGuestIdentity(slug, response.guest, response.token);
setIdentity(response.guest);
setPromptOpen(false);
// Resolve pending ensureIdentity() promises.
pendingResolvers.current.forEach((r) => r(response.guest));
pendingResolvers.current = [];
pendingRejecters.current = [];
return response.guest;
},
[slug]
);
const recoverRequest = useCallback(
async (email: string): Promise<void> => {
await guestsService.requestRecoveryCode(slug, email);
},
[slug]
);
const recoverVerify = useCallback(
async (email: string, code: string): Promise<GuestIdentity> => {
const response = await guestsService.verifyRecoveryCode(slug, email, code);
storeGuestIdentity(slug, response.guest, response.token);
setIdentity(response.guest);
setPromptOpen(false);
setRecoveryOpen(false);
pendingResolvers.current.forEach((r) => r(response.guest));
pendingResolvers.current = [];
pendingRejecters.current = [];
return response.guest;
},
[slug]
);
const forget = useCallback(async (): Promise<void> => {
try {
if (identity) {
await guestsService.forgetMe(slug);
}
} catch {
// Best-effort. Clear local state regardless.
}
clearGuestIdentity(slug);
setIdentity(null);
}, [slug, identity]);
const ensureIdentity = useCallback((): Promise<GuestIdentity> => {
if (identityMode !== 'guest') {
// In simple mode, there is no per-person identity. Return a synthetic
// "null" identity that callers will ignore.
return Promise.resolve({
id: 0,
name: '',
email: null,
identifier: '',
} as GuestIdentity);
}
if (identity) return Promise.resolve(identity);
return new Promise((resolve, reject) => {
pendingResolvers.current.push(resolve);
pendingRejecters.current.push(reject);
setPromptOpen(true);
});
}, [identityMode, identity]);
const isRequired = identityMode === 'guest' && !identity;
const value = useMemo<GuestIdentityContextValue>(
() => ({
slug,
identity,
identityMode,
isRequired,
promptOpen,
recoveryOpen,
openPrompt,
closePrompt,
openRecovery,
closeRecovery,
register,
recoverRequest,
recoverVerify,
forget,
ensureIdentity,
}),
[
slug,
identity,
identityMode,
isRequired,
promptOpen,
recoveryOpen,
openPrompt,
closePrompt,
openRecovery,
closeRecovery,
register,
recoverRequest,
recoverVerify,
forget,
ensureIdentity,
]
);
return <GuestIdentityContext.Provider value={value}>{children}</GuestIdentityContext.Provider>;
};
export function useGuestIdentity(): GuestIdentityContextValue {
const ctx = useContext(GuestIdentityContext);
if (!ctx) {
throw new Error('useGuestIdentity must be used within a GuestIdentityProvider');
}
return ctx;
}
/**
* Safe hook that returns null if no provider is present. Useful when code
* needs to optionally tie into guest identity without crashing when used
* outside a gallery (e.g. in admin contexts).
*/
export function useGuestIdentityOptional(): GuestIdentityContextValue | null {
return useContext(GuestIdentityContext);
}
+6 -23
View File
@@ -1,7 +1,6 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { setMaintenanceModeCallback } from '../config/api';
import { getApiBaseUrl } from '../utils/url';
import { usePublicSettings } from '../hooks/usePublicSettings';
interface MaintenanceContextType {
isMaintenanceMode: boolean;
@@ -25,34 +24,18 @@ interface MaintenanceProviderProps {
export const MaintenanceProvider: React.FC<MaintenanceProviderProps> = ({ children }) => {
const [isMaintenanceMode, setIsMaintenanceMode] = useState(false);
// Check maintenance mode status on mount
const { data: settings } = useQuery({
queryKey: ['public-settings-maintenance'],
queryFn: async () => {
try {
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
if (response.status === 503) {
setIsMaintenanceMode(true);
return null;
}
return response.json();
} catch (error) {
// If we can't reach the server, don't assume maintenance mode
return null;
}
},
staleTime: 30 * 1000, // Check every 30 seconds
refetchInterval: 30 * 1000,
});
// Polls /public/settings every 30s so a maintenance flag flipped server-side propagates
// without a refresh. 503 responses are caught by the axios interceptor in config/api.ts
// (which calls setMaintenanceModeCallback below), so we only need to read the explicit
// maintenance_mode flag here.
const { data: settings } = usePublicSettings({ refetchInterval: 30_000 });
// Update maintenance mode based on settings
useEffect(() => {
if (settings?.maintenance_mode !== undefined) {
setIsMaintenanceMode(settings.maintenance_mode);
}
}, [settings]);
// Set up the callback for API interceptor
useEffect(() => {
setMaintenanceModeCallback((enabled: boolean) => {
setIsMaintenanceMode(enabled);
+138 -17
View File
@@ -1,6 +1,63 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
import type { ReactNode } from 'react';
import { ThemeConfig, EventTheme, GALLERY_THEME_PRESETS } from '../types/theme.types';
import { fontsService, extractFamilyName, type FontDefinition } from '../services/fonts.service';
import { applyForceColorMode } from '../utils/themeMigration';
import { getReadableForeground } from '../utils/contrast';
import { usePublicSettings } from '../hooks/usePublicSettings';
// Self-hosted font loader. Resolves the available-fonts list once (cached for
// 5 minutes) and lazily injects @font-face blocks into <head> only for the
// families a page actually uses. Avoids preloading every available font on
// every gallery view.
const FONTS_LIST_TTL_MS = 5 * 60 * 1000;
let fontsListPromise: Promise<FontDefinition[]> | null = null;
let fontsListExpiresAt = 0;
const injectedFamilies = new Set<string>();
const FONT_STYLE_ID = 'self-hosted-fonts';
function getFontsList(): Promise<FontDefinition[]> {
if (fontsListPromise && Date.now() < fontsListExpiresAt) {
return fontsListPromise;
}
fontsListPromise = fontsService.list().catch((err) => {
console.error('Failed to load fonts list:', err);
return [];
});
fontsListExpiresAt = Date.now() + FONTS_LIST_TTL_MS;
return fontsListPromise;
}
function ensureFontFaceLoaded(family: string, weights: number[]): void {
if (injectedFamilies.has(family)) return;
injectedFamilies.add(family);
let styleEl = document.getElementById(FONT_STYLE_ID) as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = FONT_STYLE_ID;
document.head.appendChild(styleEl);
}
// Folder name on disk = family name with hyphens. URL-encode in case of
// unusual characters (the scanner already restricts to subdirectory names,
// so this is belt-and-braces).
const folderName = family.replace(/ /g, '-');
const blocks = weights.map(
(w) => `@font-face{font-family:'${family}';font-style:normal;font-weight:${w};font-display:swap;src:url('/fonts/${encodeURIComponent(folderName)}/${w}.woff2') format('woff2');}`
);
styleEl.textContent += '\n' + blocks.join('\n');
}
async function loadFontForFamily(cssFontFamily: string | undefined | null): Promise<void> {
const family = extractFamilyName(cssFontFamily);
if (!family) return;
if (injectedFamilies.has(family)) return;
const fonts = await getFontsList();
const match = fonts.find((f) => f.family.toLowerCase() === family.toLowerCase());
if (!match) return; // unknown family — browser falls back to the CSS generic
ensureFontFaceLoaded(match.family, match.weights);
}
function resolveColorMode(mode: 'light' | 'dark' | 'auto' | undefined): 'light' | 'dark' {
if (mode === 'dark') return 'dark';
@@ -36,8 +93,8 @@ interface ThemeProviderProps {
initialThemeName?: string;
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({
children,
export const ThemeProvider: React.FC<ThemeProviderProps> = ({
children,
initialTheme = GALLERY_THEME_PRESETS.default.config,
initialThemeName = 'default'
}) => {
@@ -45,23 +102,76 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
const [themeName, setThemeName] = useState(initialThemeName);
const [resolvedColorMode, setResolvedColorMode] = useState<'light' | 'dark'>(() => resolveColorMode(initialTheme.colorMode));
const applyTheme = useCallback((themeConfig: ThemeConfig) => {
// Subscribe to the instance-wide force color mode setting. When an admin
// toggles "Force dark / light" in Branding, all open admin and gallery
// tabs re-apply the active theme through applyForceColorMode within the
// refetch interval so the lock takes effect without a full reload.
// Refetch is best-effort — a stale cached value just means a delayed flip,
// not a broken state.
const { data: publicSettings } = usePublicSettings({ refetchInterval: 30_000 });
const forcedMode = publicSettings?.branding_force_color_mode === 'dark'
? 'dark'
: publicSettings?.branding_force_color_mode === 'light'
? 'light'
: null;
const applyTheme = useCallback((rawThemeConfig: ThemeConfig) => {
const root = document.documentElement;
// Apply CSS variables
// Honour the instance-wide force color mode at the chokepoint so every
// call site (gallery, admin, preview iframe, branding live preview) is
// forced to follow without each one having to remember to do it.
// applyForceColorMode is a no-op when forcedMode is null, and only
// swaps surface/text tokens when the active theme doesn't natively
// support the locked mode — accent CI colours are preserved either way.
const themeConfig = applyForceColorMode(rawThemeConfig, forcedMode);
// Apply CSS variables — 8-token CI palette.
// Legacy --color-primary / --color-primary-light / --color-primary-dark
// are kept for any consumer still reading them; they mirror accent-dark.
if (themeConfig.primaryColor) {
root.style.setProperty('--color-primary', themeConfig.primaryColor);
// Generate primary color shades
root.style.setProperty('--color-primary-light', lightenColor(themeConfig.primaryColor, 20));
root.style.setProperty('--color-primary-dark', darkenColor(themeConfig.primaryColor, 20));
}
if (themeConfig.accentColor) {
root.style.setProperty('--color-accent', themeConfig.accentColor);
// Pick a readable foreground (white or black) for text/icons sitting
// on top of `--color-accent`. The gallery header Download CTA reads
// this via `var(--color-accent-fg, #ffffff)` so a pale accent doesn't
// leave the button text unreadable (PR #401 review follow-up).
root.style.setProperty('--color-accent-fg', getReadableForeground(themeConfig.accentColor));
}
// Accent-dark: filled CTA background. Falls back to primaryColor for
// legacy themes that pre-date the explicit token (matches the previous
// implicit behavior where .btn-primary used --color-primary).
const accentDark = themeConfig.accentDarkColor || themeConfig.primaryColor;
if (accentDark) {
root.style.setProperty('--color-accent-dark', accentDark);
// Same readable-foreground treatment for filled CTAs (.btn-primary
// and .tile-selected) that paint on top of accent-dark.
root.style.setProperty('--color-accent-dark-fg', getReadableForeground(accentDark));
}
if (themeConfig.backgroundColor) {
root.style.setProperty('--color-background', themeConfig.backgroundColor);
// Cache the resolved background by slug so the next visit can
// apply it from the inline bootstrap in index.html before React
// mounts (#358 — eliminates the white flash on dark-theme galleries).
try {
const m = window.location.pathname.match(/\/gallery\/([^/?#]+)/);
if (m && m[1]) {
localStorage.setItem(
`gallery-theme-bg-${decodeURIComponent(m[1])}`,
themeConfig.backgroundColor
);
}
} catch {
/* ignore — caching is best-effort */
}
}
if (themeConfig.textColor) {
@@ -70,10 +180,15 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
if (themeConfig.fontFamily) {
root.style.setProperty('--font-family', themeConfig.fontFamily);
// Lazily inject the @font-face for this family if we haven't already.
// Fire-and-forget: the CSS variable is set immediately, the font file
// streams in afterward and `font-display: swap` reflows on arrival.
void loadFontForFamily(themeConfig.fontFamily);
}
if (themeConfig.headingFontFamily) {
root.style.setProperty('--heading-font-family', themeConfig.headingFontFamily);
void loadFontForFamily(themeConfig.headingFontFamily);
}
if (themeConfig.borderRadius) {
@@ -120,6 +235,16 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
root.style.setProperty('--color-surface', '#ffffff');
}
// Elevated: raised panels, image placeholders. Falls back to a slight
// shift from surface so the layering still reads on legacy themes.
if (themeConfig.elevatedColor) {
root.style.setProperty('--color-elevated', themeConfig.elevatedColor);
} else if (effectiveMode === 'dark') {
root.style.setProperty('--color-elevated', '#242424');
} else {
root.style.setProperty('--color-elevated', '#f5f5f5');
}
if (themeConfig.surfaceBorderColor) {
root.style.setProperty('--color-surface-border', themeConfig.surfaceBorderColor);
} else if (effectiveMode === 'dark') {
@@ -178,7 +303,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
}
styleElement.textContent = themeConfig.customCss;
}
}, []);
}, [forcedMode]);
const setThemeConfig = useCallback((newTheme: ThemeConfig) => {
setTheme(newTheme);
@@ -198,15 +323,11 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
setThemeByName('default');
}, [setThemeByName]);
// Apply theme when it changes, but skip if it's the same
// Apply theme when it changes, OR when force-mode changes (so an admin
// toggling Force dark / light in Branding flips every open tab on the
// next public-settings refetch tick — no reload needed).
useEffect(() => {
const root = document.documentElement;
const currentPrimary = root.style.getPropertyValue('--color-primary');
// Only apply if the theme has actually changed
if (currentPrimary !== theme.primaryColor) {
applyTheme(theme);
}
applyTheme(theme);
}, [theme, applyTheme]);
// Load theme from localStorage on mount (skip if in gallery view)
@@ -50,6 +50,9 @@ export interface EventSettings {
event_require_admin_email: boolean;
event_require_event_date: boolean;
event_require_expiration: boolean;
event_default_require_password: boolean;
gallery_show_filter_bar: boolean;
event_phone_field_enabled: boolean;
}
export interface SeoSettings {
@@ -123,7 +126,10 @@ export function useSettingsState() {
event_require_customer_email: true,
event_require_admin_email: true,
event_require_event_date: true,
event_require_expiration: true
event_require_expiration: true,
event_default_require_password: true,
gallery_show_filter_bar: true,
event_phone_field_enabled: false
});
// SEO settings state
@@ -206,7 +212,10 @@ export function useSettingsState() {
event_require_customer_email: toBoolean(settings.event_require_customer_email, true),
event_require_admin_email: toBoolean(settings.event_require_admin_email, true),
event_require_event_date: toBoolean(settings.event_require_event_date, true),
event_require_expiration: toBoolean(settings.event_require_expiration, true)
event_require_expiration: toBoolean(settings.event_require_expiration, true),
event_default_require_password: toBoolean(settings.event_default_require_password, true),
gallery_show_filter_bar: toBoolean(settings.gallery_show_filter_bar, true),
event_phone_field_enabled: toBoolean(settings.event_phone_field_enabled, false)
});
setSeoSettings({
+2
View File
@@ -15,3 +15,5 @@ export { ModerationTab } from './tabs/ModerationTab';
export { StylingTab } from './tabs/StylingTab';
export { SEOTab } from './tabs/SEOTab';
export { ThumbnailsTab } from './tabs/ThumbnailsTab';
export { ApiTokensTab } from './tabs/ApiTokensTab';
export { WebhooksTab } from './tabs/WebhooksTab';
@@ -0,0 +1,252 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { KeyRound, Trash2, Copy, AlertTriangle } from 'lucide-react';
import { Button, Card, Input, Loading } from '../../../components/common';
import { api } from '../../../config/api';
interface ApiTokenRow {
id: number;
name: string;
scopes: string;
preview: string | null;
created_at: string;
expires_at: string | null;
last_used_at: string | null;
revoked_at: string | null;
owner_username: string | null;
}
const ALL_SCOPES: Array<'read' | 'write' | 'admin'> = ['read', 'write', 'admin'];
/**
* Admin tab for managing API tokens (#322). Lists active tokens, lets
* admins generate new ones (plaintext shown ONCE), and revokes them.
* The plaintext token is returned only on creation there is no way
* to retrieve it again, by design.
*/
export const ApiTokensTab: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [scopes, setScopes] = useState<Array<'read' | 'write' | 'admin'>>(['read']);
const [justCreatedToken, setJustCreatedToken] = useState<string | null>(null);
const { data: tokens, isLoading } = useQuery({
queryKey: ['admin-api-tokens'],
queryFn: async () => {
const res = await api.get<ApiTokenRow[]>('/admin/api-tokens');
return res.data;
},
});
const createMutation = useMutation({
mutationFn: async () => {
const res = await api.post<{ token: string }>('/admin/api-tokens', { name, scopes });
return res.data.token;
},
onSuccess: (token) => {
setJustCreatedToken(token);
setName('');
setScopes(['read']);
queryClient.invalidateQueries({ queryKey: ['admin-api-tokens'] });
},
onError: (err: any) => {
toast.error(err?.response?.data?.error || t('settings.apiTokens.createError', 'Failed to create token'));
},
});
const revokeMutation = useMutation({
mutationFn: async (id: number) => api.delete(`/admin/api-tokens/${id}`),
onSuccess: () => {
toast.success(t('settings.apiTokens.revoked', 'Token revoked'));
queryClient.invalidateQueries({ queryKey: ['admin-api-tokens'] });
},
onError: () => toast.error(t('toast.saveError')),
});
const toggleScope = (scope: 'read' | 'write' | 'admin') => {
setScopes((prev) => (prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope]));
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[200px]">
<Loading size="lg" />
</div>
);
}
return (
<div className="space-y-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2 flex items-center gap-2">
<KeyRound className="w-5 h-5" />
{t('settings.apiTokens.title', 'API Tokens')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('settings.apiTokens.subtitle', 'Long-lived bearer tokens for the public /api/v1 surface — n8n integrations, custom apps, scripts. Tokens act as the admin user that minted them, intersected with the chosen scopes.')}
</p>
{justCreatedToken && (
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-900/20 p-4 mb-4">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-amber-900 dark:text-amber-200 mb-1">
{t('settings.apiTokens.copyNow', 'Copy this token now — it will not be shown again.')}
</p>
<div className="flex items-center gap-2">
<code className="block flex-1 min-w-0 px-3 py-2 bg-white dark:bg-neutral-900 border border-amber-300 dark:border-amber-700 rounded text-xs font-mono break-all">
{justCreatedToken}
</code>
<Button
size="sm"
variant="outline"
leftIcon={<Copy className="w-4 h-4" />}
onClick={async () => {
try {
await navigator.clipboard.writeText(justCreatedToken);
toast.success(t('settings.apiTokens.copied', 'Copied'));
} catch {
toast.error(t('settings.apiTokens.copyFailed', 'Copy failed'));
}
}}
>
{t('events.copy', 'Copy')}
</Button>
<Button size="sm" variant="ghost" onClick={() => setJustCreatedToken(null)}>
{t('common.dismiss', 'Dismiss')}
</Button>
</div>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 items-end mb-2">
<div className="md:col-span-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.apiTokens.name', 'Name')}
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('settings.apiTokens.namePlaceholder', 'e.g. n8n production')}
/>
</div>
<div className="md:col-span-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.apiTokens.scopes', 'Scopes')}
</label>
<div className="flex gap-3 pt-2">
{ALL_SCOPES.map((s) => (
<label key={s} className="flex items-center gap-1.5 text-sm text-neutral-700 dark:text-neutral-300">
<input
type="checkbox"
checked={scopes.includes(s)}
onChange={() => toggleScope(s)}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
{s}
</label>
))}
</div>
</div>
<div className="md:col-span-1">
<Button
variant="primary"
onClick={() => createMutation.mutate()}
isLoading={createMutation.isPending}
disabled={!name.trim() || scopes.length === 0}
>
{t('settings.apiTokens.generate', 'Generate Token')}
</Button>
</div>
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.apiTokens.scopeHint', 'admin > write > read. A read-only token cannot mutate, even if its owner is super_admin.')}
</p>
</Card>
<Card padding="md">
<h3 className="text-base font-semibold text-neutral-900 dark:text-neutral-100 mb-3">
{t('settings.apiTokens.existing', 'Existing tokens')}
</h3>
{tokens && tokens.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-neutral-500 dark:text-neutral-400 border-b border-neutral-200 dark:border-neutral-700">
<th className="py-2 pr-3">{t('settings.apiTokens.name', 'Name')}</th>
<th className="py-2 pr-3">{t('settings.apiTokens.scopes', 'Scopes')}</th>
<th className="py-2 pr-3">Preview</th>
<th className="py-2 pr-3">{t('settings.apiTokens.lastUsed', 'Last used')}</th>
<th className="py-2 pr-3">{t('settings.apiTokens.created', 'Created')}</th>
<th className="py-2 pr-3">{t('settings.apiTokens.status', 'Status')}</th>
<th className="py-2"></th>
</tr>
</thead>
<tbody>
{tokens.map((token) => {
const revoked = !!token.revoked_at;
const expired = token.expires_at && new Date(token.expires_at) <= new Date();
const status = revoked
? t('settings.apiTokens.statusRevoked', 'Revoked')
: expired
? t('settings.apiTokens.statusExpired', 'Expired')
: t('settings.apiTokens.statusActive', 'Active');
return (
<tr key={token.id} className="border-b border-neutral-100 dark:border-neutral-800 last:border-0">
<td className="py-3 pr-3 font-medium">{token.name}</td>
<td className="py-3 pr-3 text-neutral-600 dark:text-neutral-400">{token.scopes}</td>
<td className="py-3 pr-3 font-mono text-xs text-neutral-500">
pp_live_{token.preview || '••••'}
</td>
<td className="py-3 pr-3 text-neutral-500">
{token.last_used_at ? new Date(token.last_used_at).toLocaleString() : '—'}
</td>
<td className="py-3 pr-3 text-neutral-500">
{new Date(token.created_at).toLocaleDateString()}
</td>
<td className="py-3 pr-3">
<span className={`text-xs px-2 py-0.5 rounded ${
revoked || expired
? 'bg-neutral-200 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-400'
: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
}`}>
{status}
</span>
</td>
<td className="py-3 text-right">
{!revoked && (
<Button
size="sm"
variant="ghost"
leftIcon={<Trash2 className="w-4 h-4" />}
onClick={() => {
if (confirm(t('settings.apiTokens.confirmRevoke', `Revoke "${token.name}"? Existing integrations using this token will start getting 401.`))) {
revokeMutation.mutate(token.id);
}
}}
>
{t('settings.apiTokens.revoke', 'Revoke')}
</Button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('settings.apiTokens.empty', 'No tokens yet. Generate one above to get started.')}
</p>
)}
</Card>
</div>
);
};
@@ -149,6 +149,63 @@ export const EventsTab: React.FC<EventsTabProps> = ({
</div>
</label>
</div>
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.event_default_require_password}
onChange={(e) => setEventSettings(prev => ({ ...prev, event_default_require_password: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('settings.events.defaultRequirePassword', 'Require password by default')}
</span>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.events.defaultRequirePasswordHelp', 'Pre-check "Require password" when creating new events. Disable for quicker creation of public galleries.')}
</p>
</div>
</label>
</div>
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.gallery_show_filter_bar}
onChange={(e) => setEventSettings(prev => ({ ...prev, gallery_show_filter_bar: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('settings.events.showGalleryFilterBar', 'Show filter bar in galleries')}
</span>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.events.showGalleryFilterBarHelp', 'Display the search-by-filename and sort controls above grid-layout galleries. Disable for a cleaner layout.')}
</p>
</div>
</label>
</div>
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.event_phone_field_enabled}
onChange={(e) => setEventSettings(prev => ({ ...prev, event_phone_field_enabled: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('settings.events.enablePhoneField', 'Enable phone number field')}
</span>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.events.enablePhoneFieldHelp', 'Adds an optional phone number input to the event form. Useful for downstream automations like WhatsApp delivery via n8n. Always optional even when enabled.')}
</p>
</div>
</label>
</div>
</div>
<div className="mt-6">
@@ -246,6 +246,9 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
>
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="nl">Nederlands</option>
<option value="pt">Português (Brasil)</option>
<option value="ru">Русский</option>
</select>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.general.defaultLanguageHelp')}
@@ -0,0 +1,368 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { Webhook as WebhookIcon, Trash2, Copy, AlertTriangle, Activity, CheckCircle2, XCircle } from 'lucide-react';
import { Button, Card, Input, Loading } from '../../../components/common';
import { api } from '../../../config/api';
const WEBHOOK_EVENT_TYPES = [
'event.created',
'event.published',
'event.archived',
'event.expired',
'photo.uploaded',
'photo.deleted',
] as const;
type WebhookEventType = typeof WEBHOOK_EVENT_TYPES[number];
interface WebhookRow {
id: number;
name: string;
url: string;
events: WebhookEventType[];
active: boolean;
secret_preview: string | null;
created_at: string;
updated_at: string;
last_success_at: string | null;
last_failure_at: string | null;
owner_username: string | null;
}
/**
* Settings Webhooks tab (#327). Mirrors the API Tokens tab pattern:
* the signing secret is returned exactly once on creation and never
* recoverable. Per-webhook delivery history lives on the dedicated
* /admin/webhooks/:id/deliveries page (link in the table).
*/
export const WebhooksTab: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [events, setEvents] = useState<WebhookEventType[]>(['event.published']);
const [filterText, setFilterText] = useState('{}');
const [template, setTemplate] = useState('');
const [showAdvanced, setShowAdvanced] = useState(false);
const [justCreatedSecret, setJustCreatedSecret] = useState<string | null>(null);
const [filterError, setFilterError] = useState<string | null>(null);
const { data: webhooks, isLoading } = useQuery({
queryKey: ['admin-webhooks'],
queryFn: async () => {
const res = await api.get<WebhookRow[]>('/admin/webhooks');
return res.data;
},
});
const createMutation = useMutation({
mutationFn: async () => {
let parsedFilter: Record<string, unknown> = {};
const trimmed = filterText.trim();
if (trimmed && trimmed !== '{}') {
try {
parsedFilter = JSON.parse(trimmed);
} catch {
setFilterError('Filter must be valid JSON');
throw new Error('Invalid filter JSON');
}
}
setFilterError(null);
const body: Record<string, unknown> = { name, url, events, active: true };
if (Object.keys(parsedFilter).length > 0) body.filter = parsedFilter;
if (template.trim()) body.template = template;
const res = await api.post<{ secret: string }>('/admin/webhooks', body);
return res.data.secret;
},
onSuccess: (secret) => {
setJustCreatedSecret(secret);
setName('');
setUrl('');
setEvents(['event.published']);
setFilterText('{}');
setTemplate('');
setShowAdvanced(false);
queryClient.invalidateQueries({ queryKey: ['admin-webhooks'] });
},
onError: (err: any) => {
toast.error(err?.response?.data?.errors?.[0]?.msg || err?.response?.data?.error || 'Failed to create webhook');
},
});
const toggleActiveMutation = useMutation({
mutationFn: async ({ id, active }: { id: number; active: boolean }) =>
api.put(`/admin/webhooks/${id}`, { active }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin-webhooks'] }),
onError: () => toast.error('Failed to update webhook'),
});
const deleteMutation = useMutation({
mutationFn: async (id: number) => api.delete(`/admin/webhooks/${id}`),
onSuccess: () => {
toast.success('Webhook deleted');
queryClient.invalidateQueries({ queryKey: ['admin-webhooks'] });
},
onError: () => toast.error('Failed to delete webhook'),
});
const toggleEvent = (e: WebhookEventType) => {
setEvents((prev) => (prev.includes(e) ? prev.filter((x) => x !== e) : [...prev, e]));
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[200px]">
<Loading size="lg" />
</div>
);
}
return (
<div className="space-y-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2 flex items-center gap-2">
<WebhookIcon className="w-5 h-5" />
{t('settings.webhooks.title', 'Webhooks')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('settings.webhooks.subtitle', 'POST event notifications to your URL the moment something happens — gallery published, photo uploaded, event archived, etc. Signed with HMAC-SHA256 in the X-PicPeak-Signature header.')}
</p>
{/* PII notice (#341). event.* payloads include customer contact
fields (name / email / phone) plus the share token. Make sure
admins know what flows to a webhook receiver before they wire
one up to a third-party automation tool. */}
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:border-amber-700/50 dark:bg-amber-900/20 p-3 mb-4 flex items-start gap-2.5">
<AlertTriangle className="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<p className="text-xs text-amber-900 dark:text-amber-200 leading-relaxed">
{t(
'settings.webhooks.piiNotice',
'event.* payloads include customer contact info (name, email, phone) and the gallery share token if you have stored them. Only point webhooks at receivers you trust — they have everything needed to message the customer or open the gallery.'
)}
</p>
</div>
{justCreatedSecret && (
<div className="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-900/20 p-4 mb-4">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-amber-900 dark:text-amber-200 mb-1">
{t('settings.webhooks.copyNow', 'Copy this signing secret now — it will not be shown again.')}
</p>
<div className="flex items-center gap-2">
<code className="block flex-1 min-w-0 px-3 py-2 bg-white dark:bg-neutral-900 border border-amber-300 dark:border-amber-700 rounded text-xs font-mono break-all">
{justCreatedSecret}
</code>
<Button
size="sm"
variant="outline"
leftIcon={<Copy className="w-4 h-4" />}
onClick={async () => {
try {
await navigator.clipboard.writeText(justCreatedSecret);
toast.success('Copied');
} catch {
toast.error('Copy failed');
}
}}
>
Copy
</Button>
<Button size="sm" variant="ghost" onClick={() => setJustCreatedSecret(null)}>
Dismiss
</Button>
</div>
</div>
</div>
</div>
)}
<div className="space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.webhooks.name', 'Name')}
</label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. n8n WhatsApp" />
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.webhooks.url', 'Receiver URL')}
</label>
<Input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://n8n.example.com/webhook/picpeak" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('settings.webhooks.events', 'Subscribe to events')}
</label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{WEBHOOK_EVENT_TYPES.map((e) => (
<label key={e} className="flex items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300">
<input
type="checkbox"
checked={events.includes(e)}
onChange={() => toggleEvent(e)}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<code className="text-xs">{e}</code>
</label>
))}
</div>
</div>
<button
type="button"
onClick={() => setShowAdvanced((prev) => !prev)}
className="text-sm text-primary-600 dark:text-primary-400 hover:underline self-start"
>
{showAdvanced ? ' Hide advanced (filter, template)' : '+ Advanced (filter, template)'}
</button>
{showAdvanced && (
<div className="space-y-3 border-l-2 border-neutral-200 dark:border-neutral-700 pl-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.webhooks.filter', 'Filter (JSON, optional)')}
</label>
<textarea
value={filterText}
onChange={(e) => { setFilterText(e.target.value); setFilterError(null); }}
placeholder='{"data.event.event_type": "wedding"}'
rows={3}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 rounded text-sm font-mono"
/>
<p className="text-xs text-neutral-500 mt-1">
Dot-path expected value. All keys must match (AND). Use an array for "any of": <code>{'{"type": ["event.published", "event.archived"]}'}</code>
</p>
{filterError && <p className="text-xs text-red-600 mt-1">{filterError}</p>}
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.webhooks.template', 'Template (optional)')}
</label>
<textarea
value={template}
onChange={(e) => setTemplate(e.target.value)}
placeholder={'New gallery: ${data.event.event_name} → ${data.event.share_url}'}
rows={3}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 rounded text-sm font-mono"
/>
<p className="text-xs text-neutral-500 mt-1">
Replaces the default JSON envelope as the request body. <code>${'{dot.path}'}</code> substitution from the payload only no logic, no expressions.
</p>
</div>
</div>
)}
<Button
variant="primary"
onClick={() => createMutation.mutate()}
isLoading={createMutation.isPending}
disabled={!name.trim() || !url.trim() || events.length === 0}
>
{t('settings.webhooks.create', 'Create Webhook')}
</Button>
</div>
</Card>
<Card padding="md">
<h3 className="text-base font-semibold text-neutral-900 dark:text-neutral-100 mb-3">
{t('settings.webhooks.existing', 'Existing webhooks')}
</h3>
{webhooks && webhooks.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-neutral-500 dark:text-neutral-400 border-b border-neutral-200 dark:border-neutral-700">
<th className="py-2 pr-3">Name</th>
<th className="py-2 pr-3">URL</th>
<th className="py-2 pr-3">Events</th>
<th className="py-2 pr-3">Last delivery</th>
<th className="py-2 pr-3">Status</th>
<th className="py-2 text-right">Actions</th>
</tr>
</thead>
<tbody>
{webhooks.map((wh) => {
const lastSuccess = wh.last_success_at ? new Date(wh.last_success_at) : null;
const lastFailure = wh.last_failure_at ? new Date(wh.last_failure_at) : null;
const lastEither = lastFailure && (!lastSuccess || lastFailure > lastSuccess) ? 'failure' : (lastSuccess ? 'success' : 'none');
return (
<tr key={wh.id} className="border-b border-neutral-100 dark:border-neutral-800 last:border-0 align-top">
<td className="py-3 pr-3 font-medium">{wh.name}</td>
<td className="py-3 pr-3 text-xs font-mono text-neutral-600 dark:text-neutral-400 max-w-xs truncate" title={wh.url}>{wh.url}</td>
<td className="py-3 pr-3 text-xs text-neutral-500">
{Array.isArray(wh.events) ? wh.events.length : 0} subscribed
</td>
<td className="py-3 pr-3 text-xs text-neutral-500">
{lastEither === 'success' && lastSuccess && (
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<CheckCircle2 className="w-3.5 h-3.5" />
{lastSuccess.toLocaleString()}
</span>
)}
{lastEither === 'failure' && lastFailure && (
<span className="flex items-center gap-1 text-red-600 dark:text-red-400">
<XCircle className="w-3.5 h-3.5" />
{lastFailure.toLocaleString()}
</span>
)}
{lastEither === 'none' && <span className="text-neutral-400"></span>}
</td>
<td className="py-3 pr-3">
<button
onClick={() => toggleActiveMutation.mutate({ id: wh.id, active: !wh.active })}
className={`text-xs px-2 py-0.5 rounded ${
wh.active
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
: 'bg-neutral-200 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-400'
}`}
title={wh.active ? 'Click to disable' : 'Click to enable'}
>
{wh.active ? 'Active' : 'Disabled'}
</button>
</td>
<td className="py-3 text-right">
<div className="flex items-center justify-end gap-1">
<Link
to={`/admin/webhooks/${wh.id}/deliveries`}
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100"
>
<Activity className="w-3.5 h-3.5" />
Deliveries
</Link>
<Button
size="sm"
variant="ghost"
leftIcon={<Trash2 className="w-4 h-4" />}
onClick={() => {
if (confirm(`Delete "${wh.name}"? Pending deliveries are also removed.`)) {
deleteMutation.mutate(wh.id);
}
}}
>
Delete
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('settings.webhooks.empty', 'No webhooks yet. Create one above to start receiving event notifications.')}
</p>
)}
</Card>
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More