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:
+45
-1
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Generated
+2
-2
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "2.6.5",
|
||||
"version": "3.42.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+51
-54
@@ -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]);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)}`}>
|
||||
|
||||
@@ -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'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user