feat(frontend): dedupe /public/settings via shared usePublicSettings hook (#325)

Pre-dedup: 7 calls to /api/public/settings on a single /admin/login page
load — 4 from raw-fetch consumers + 3 from React Query consumers using
inconsistent queryKeys. Captured live in Chrome DevTools.

Post-dedup: 1 call. Verified by tests/e2e/public-settings-dedup.spec.ts.

Adds:
- frontend/src/hooks/usePublicSettings.ts — single React Query hook,
  60s staleTime, queryKey ['public-settings']. Vitest with mocked api
  proves multi-mount dedup.
- Extended PublicSettings interface with seo_meta_* fields used by
  RobotsMetaTags and branding_logo_* fields used by GalleryView/AdminHeader.

Migrates 19 call sites across 4 risk-ordered rounds:
- Round A (high fan-in): GlobalThemeProvider, MaintenanceContext (with
  refetchInterval to preserve maintenance polling), MaintenanceWrapper
  (drops the now-redundant per-route ping; axios interceptor already
  handles 503), AdminHeader.
- Round B (gallery/login): GalleryView, GalleryPage, ClientAccessPage,
  AdminLoginPage, MaintenanceMode.
- Round C (decorative): RobotsMetaTags, DynamicFavicon, CMSContentBlock,
  ReCaptcha, useWatermarkSettings (rips out raw fetch + local state),
  LegalPage.
- Round D (queryKey alignment): useLocalizedDate, UserPhotoUpload,
  CreateEventPage. EventDetailsPage Round D ships in the follow-up
  commit that adds presigned-download UI on the same page.

App.tsx (AnalyticsBootstrap) and EventDetailsPage are deferred to
later commits — both files mix #325 changes with backend feature work.
This commit is contained in:
Paul Nothaft
2026-04-28 10:01:53 +02:00
parent 46bc894d91
commit 3d4ae4d7e9
23 changed files with 258 additions and 271 deletions
@@ -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,22 +9,13 @@ 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;
setTheme(settingsData.theme_config);
+3 -28
View File
@@ -1,38 +1,13 @@
import React, { useEffect } from 'react';
import { AlertTriangle } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { api } from '../config/api';
import { usePublicSettings } from '../hooks/usePublicSettings';
import { buildResourceUrl } from '../utils/url';
interface BrandingSettings {
branding_company_name?: string;
branding_company_tagline?: string;
branding_support_email?: string;
branding_footer_text?: string;
branding_favicon_url?: string;
branding_logo_url?: string;
default_language?: string;
}
export const MaintenanceMode: React.FC = () => {
const { t, i18n } = useTranslation();
// Fetch branding settings
const { data: settings } = useQuery<BrandingSettings>({
queryKey: ['public-settings-maintenance'],
queryFn: async () => {
try {
const response = await api.get('/public/settings');
return response.data;
} catch {
// Return empty object if settings can't be fetched
return {};
}
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
retry: false, // Don't retry on failure
});
const { data: settings } = usePublicSettings({ retry: false });
// Set language based on system settings
useEffect(() => {
+6 -31
View File
@@ -1,6 +1,5 @@
import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { MaintenanceMode } from './MaintenanceMode';
import { useMaintenanceMode } from '../contexts/MaintenanceContext';
import { setMaintenanceModeCallback, api } from '../config/api';
@@ -9,12 +8,16 @@ interface MaintenanceWrapperProps {
children: React.ReactNode;
}
// Maintenance detection now lives in two places:
// 1. The axios interceptor in config/api.ts flips the flag on any 503 response.
// 2. MaintenanceContext polls /public/settings every 30s and reads the explicit
// maintenance_mode field (via the shared usePublicSettings hook).
// This wrapper only needs to gate the rendered tree on the resulting state.
export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children }) => {
const location = useLocation();
const { isMaintenanceMode, setMaintenanceMode } = useMaintenanceMode();
const [hasAdminSession, setHasAdminSession] = useState(false);
// Check if current route is admin route
const isAdminRoute = location.pathname.startsWith('/admin');
useEffect(() => {
@@ -45,40 +48,12 @@ export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children
};
}, [isAdminRoute]);
// Register the maintenance mode callback
useEffect(() => {
setMaintenanceModeCallback((enabled: boolean) => {
setMaintenanceMode(enabled);
});
}, [setMaintenanceMode]);
// Check maintenance mode on mount and when location changes
useQuery({
queryKey: ['maintenance-check', location.pathname],
queryFn: async () => {
try {
// Make a lightweight request to check maintenance status
await api.get('/public/settings');
// If successful, maintenance mode is off
setMaintenanceMode(false);
return { maintenance: false };
} catch (error: any) {
if (error.response?.status === 503) {
// Only set maintenance mode for non-admin routes or unauthenticated admin routes
if (!isAdminRoute || !hasAdminSession) {
setMaintenanceMode(true);
return { maintenance: true };
}
}
return { maintenance: false };
}
},
staleTime: 30000, // Check every 30 seconds
retry: false, // Don't retry on failure
enabled: (!isAdminRoute || !hasAdminSession) && !isMaintenanceMode, // Don't check if already in maintenance
});
// Show maintenance page if in maintenance mode and not on admin route with auth
if (isMaintenanceMode && (!isAdminRoute || !hasAdminSession)) {
return <MaintenanceMode />;
}
+3 -11
View File
@@ -9,11 +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, getApiBaseUrl } from '../../utils/url';
import { buildResourceUrl } from '../../utils/url';
interface AdminHeaderProps {
onMenuClick: () => void;
@@ -31,16 +32,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
const [showPasswordModal, setShowPasswordModal] = useState(false);
const queryClient = useQueryClient();
// Fetch branding settings
const { data: brandingSettings } = useQuery({
queryKey: ['admin-settings', 'branding'],
queryFn: async () => {
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
if (response.ok) return response.json();
return null;
},
staleTime: 5 * 60 * 1000,
});
const { data: brandingSettings } = usePublicSettings();
const companyName = brandingSettings?.branding_company_name?.trim() || 'PicPeak';
const logoUrl = brandingSettings?.branding_logo_url?.trim();
@@ -6,7 +6,7 @@ import DOMPurify from 'dompurify';
import { Card } from './Card';
import { Loading } from './Loading';
import { cmsService } from '../../services/cms.service';
import { api } from '../../config/api';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { buildResourceUrl } from '../../utils/url';
import '../../styles/prose-overrides.css';
@@ -33,14 +33,7 @@ const ALLOWED_ATTR = ['href', 'target', 'rel', 'class', 'style', 'src', 'alt', '
export const CMSContentBlock: React.FC<CMSContentBlockProps> = ({ slug, fallback }) => {
const { i18n } = useTranslation();
const { data: settings } = useQuery({
queryKey: ['public-settings'],
queryFn: async () => {
const response = await api.get('/public/settings');
return response.data;
},
staleTime: 5 * 60 * 1000,
});
const { data: settings } = usePublicSettings();
const lang = settings?.default_language || i18n.language || 'en';
@@ -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(() => {
@@ -82,4 +68,4 @@ export const DynamicFavicon: React.FC = () => {
}, [settings?.branding_company_name, settings?.branding_company_tagline]);
return null;
};
};
+8 -24
View File
@@ -1,7 +1,6 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import ReCAPTCHA from 'react-google-recaptcha';
import { useQuery } from '@tanstack/react-query';
import { getApiBaseUrl } from '../../utils/url';
import { usePublicSettings } from '../../hooks/usePublicSettings';
interface ReCaptchaProps {
onChange: (token: string | null) => void;
@@ -9,31 +8,16 @@ interface ReCaptchaProps {
size?: 'normal' | 'compact';
}
export const ReCaptcha: React.FC<ReCaptchaProps> = ({
onChange,
export const ReCaptcha: React.FC<ReCaptchaProps> = ({
onChange,
onExpired,
size = 'normal'
size = 'normal'
}) => {
const recaptchaRef = React.useRef<ReCAPTCHA>(null);
const [siteKey, setSiteKey] = useState<string>('');
const { data: settings } = usePublicSettings();
// Fetch public settings to get reCAPTCHA site key
const { data: settings } = useQuery({
queryKey: ['public-settings'],
queryFn: async () => {
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
return response.json();
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
const siteKey = settings?.recaptcha_site_key ?? '';
useEffect(() => {
if (settings?.recaptcha_site_key) {
setSiteKey(settings.recaptcha_site_key);
}
}, [settings]);
// If reCAPTCHA is not enabled or site key is not available, return null
if (!settings?.enable_recaptcha || !siteKey) {
return null;
}
@@ -52,4 +36,4 @@ export const ReCaptcha: React.FC<ReCaptchaProps> = ({
);
};
export default ReCaptcha;
export default ReCaptcha;
@@ -1,23 +1,8 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getApiBaseUrl } from '../../utils/url';
import { usePublicSettings } from '../../hooks/usePublicSettings';
export const RobotsMetaTags: React.FC = () => {
const { data: settings } = useQuery({
queryKey: ['public-settings'],
queryFn: async () => {
try {
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
if (response.ok) {
return response.json();
}
return null;
} catch {
return null;
}
},
staleTime: 5 * 60 * 1000,
});
const { data: settings } = usePublicSettings({ retry: false });
useEffect(() => {
// Remove any existing robots meta tags we previously injected
@@ -25,6 +25,7 @@ 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';
@@ -199,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({
@@ -1,11 +1,10 @@
import React, { useState, useMemo } from 'react';
import { Upload, X, CheckCircle } 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 {
@@ -26,11 +25,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<{ [key: string]: number }>({});
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),