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
@@ -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