Neue Branding-Page und Gäste-PWA reagiert nun auf Branding-Einstellungen vom event-admin. Implemented local Google Fonts pipeline and admin UI selects for branding and invites.

- Added fonts:sync-google command (uses GOOGLE_FONTS_API_KEY, generates /public/fonts/google files, manifest, CSS, cache flush) and
    exposed manifest via new GET /api/v1/tenant/fonts endpoint with fallbacks for existing local fonts.
  - Imported generated fonts CSS, added API client + font loader hook, and wired branding page font fields to searchable selects (with
    custom override) that auto-load selected fonts.
  - Invites layout editor now offers font selection per element with runtime font loading for previews/export alignment.
  - New tests cover font sync command and font manifest API.

  Tests run: php artisan test --filter=Fonts --testsuite=Feature.
  Note: repository already has other modified files (e.g., EventPublicController, SettingsStoreRequest, guest components, etc.); left
  untouched. Run php artisan fonts:sync-google after setting the API key to populate /public/fonts/google.
This commit is contained in:
Codex Agent
2025-11-25 19:31:52 +01:00
parent 4d31eb4d42
commit 9bde8f3f32
38 changed files with 2420 additions and 104 deletions

View File

@@ -14,6 +14,7 @@ import { useToast } from '../components/ToastHost';
import { localizeTaskLabel } from '../lib/localizeTaskLabel';
import { createPhotoShareLink } from '../services/photosApi';
import { cn } from '@/lib/utils';
import { useEventBranding } from '../context/EventBrandingContext';
const allGalleryFilters: GalleryFilter[] = ['latest', 'popular', 'mine', 'photobooth'];
type GalleryPhoto = {
@@ -285,36 +286,41 @@ export default function GalleryPage() {
return (
<Page title="">
<div className="space-y-2">
<div className="space-y-2" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-500/10 text-pink-500">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-500/10 text-pink-500" style={{ borderRadius: radius }}>
<ImageIcon className="h-5 w-5" aria-hidden />
</div>
<div>
<h1 className="text-2xl font-semibold text-foreground">{t('galleryPage.title')}</h1>
<h1 className="text-2xl font-semibold text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>{t('galleryPage.title')}</h1>
<p className="text-sm text-muted-foreground">{t('galleryPage.subtitle')}</p>
</div>
{newCount > 0 ? (
<button
type="button"
onClick={acknowledgeNew}
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold transition ${badgeEmphasisClass}`}
style={{ borderRadius: radius }}
>
{newPhotosBadgeText}
</button>
) : (
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold ${badgeEmphasisClass}`}>
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold ${badgeEmphasisClass}`} style={{ borderRadius: radius }}>
{newPhotosBadgeText}
</span>
)}
</div>
</div>
<FiltersBar value={filter} onChange={setFilter} className="mt-2" showPhotobooth={showPhotoboothFilter} />
{loading && <p className="px-4">{t('galleryPage.loading', 'Lade…')}</p>}
<FiltersBar
value={filter}
onChange={setFilter}
className="mt-2"
showPhotobooth={showPhotoboothFilter}
styleOverride={{ borderRadius: radius, fontFamily: headingFont }}
/>
{loading && <p className="px-4" style={bodyFont ? { fontFamily: bodyFont } : undefined}>{t('galleryPage.loading', 'Lade…')}</p>}
<div className="grid gap-3 px-4 pb-16 sm:grid-cols-2 lg:grid-cols-3">
{list.map((p: GalleryPhoto) => {
const imageUrl = normalizeImageUrl(p.thumbnail_path || p.file_path);
@@ -344,7 +350,8 @@ export default function GalleryPage() {
openPhoto();
}
}}
className="group relative overflow-hidden rounded-[28px] border border-white/20 bg-gray-950 text-white shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-400"
className="group relative overflow-hidden border border-white/20 bg-gray-950 text-white shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-400"
style={{ borderRadius: radius }}
>
<img
src={imageUrl}
@@ -356,16 +363,16 @@ export default function GalleryPage() {
loading="lazy"
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" aria-hidden />
<div className="absolute inset-x-0 bottom-0 space-y-2 px-4 pb-4">
{localizedTaskTitle && <p className="text-sm font-medium leading-tight line-clamp-2 text-white">{localizedTaskTitle}</p>}
<div className="flex items-center justify-between text-xs text-white/90">
<div className="absolute inset-x-0 bottom-0 space-y-2 px-4 pb-4" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
{localizedTaskTitle && <p className="text-sm font-medium leading-tight line-clamp-2 text-white" style={headingFont ? { fontFamily: headingFont } : undefined}>{localizedTaskTitle}</p>}
<div className="flex items-center justify-between text-xs text-white/90" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
<span className="truncate">{createdLabel}</span>
<span className="ml-3 truncate text-right">{p.uploader_name || t('galleryPage.photo.anonymous', 'Gast')}</span>
</div>
</div>
<div className="absolute left-3 top-3 z-10 flex flex-col items-start gap-2">
{localizedTaskTitle && (
<span className="rounded-full bg-white/20 px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-white shadow">
<span className="rounded-full bg-white/20 px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-white shadow" style={{ borderRadius: radius }}>
{localizedTaskTitle}
</span>
)}
@@ -378,11 +385,17 @@ export default function GalleryPage() {
onShare(p);
}}
className={cn(
'flex h-9 w-9 items-center justify-center rounded-full border border-white/40 bg-black/40 text-white transition backdrop-blur',
'flex h-9 w-9 items-center justify-center border text-white transition backdrop-blur',
shareTargetId === p.id ? 'opacity-60' : 'hover:bg-white/10'
)}
aria-label={t('galleryPage.photo.shareAria', 'Foto teilen')}
disabled={shareTargetId === p.id}
style={{
borderRadius: radius,
background: buttonStyle === 'outline' ? 'transparent' : '#00000066',
border: buttonStyle === 'outline' ? `1px solid ${linkColor}` : '1px solid rgba(255,255,255,0.4)',
color: buttonStyle === 'outline' ? linkColor : undefined,
}}
>
<Share2 className="h-4 w-4" aria-hidden />
</button>
@@ -393,10 +406,16 @@ export default function GalleryPage() {
onLike(p.id);
}}
className={cn(
'flex items-center gap-1 rounded-full border border-white/40 bg-black/40 px-3 py-1 text-sm font-medium text-white transition backdrop-blur',
'flex items-center gap-1 px-3 py-1 text-sm font-medium transition backdrop-blur',
liked.has(p.id) ? 'text-pink-300' : 'text-white'
)}
aria-label={t('galleryPage.photo.likeAria', 'Foto liken')}
style={{
borderRadius: radius,
background: buttonStyle === 'outline' ? 'transparent' : '#00000066',
border: buttonStyle === 'outline' ? `1px solid ${linkColor}` : '1px solid rgba(255,255,255,0.4)',
color: buttonStyle === 'outline' ? linkColor : undefined,
}}
>
<Heart className={`h-4 w-4 ${liked.has(p.id) ? 'fill-current' : ''}`} aria-hidden />
{likeCount}
@@ -491,3 +510,9 @@ export default function GalleryPage() {
</Page>
);
}
const { branding } = useEventBranding();
const radius = branding.buttons?.radius ?? 12;
const buttonStyle = branding.buttons?.style ?? 'filled';
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;

View File

@@ -20,6 +20,9 @@ export default function HomePage() {
const { completedCount } = useGuestTaskProgress(token ?? '');
const { t, locale } = useTranslation();
const { branding } = useEventBranding();
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const radius = branding.buttons?.radius ?? 12;
const heroStorageKey = token ? `guestHeroDismissed_${token}` : 'guestHeroDismissed';
const [heroVisible, setHeroVisible] = React.useState(() => {
@@ -133,7 +136,7 @@ export default function HomePage() {
}
return (
<div className="space-y-6 pb-32">
<div className="space-y-6 pb-32" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
{heroVisible && (
<HeroCard
name={displayName}
@@ -147,7 +150,7 @@ export default function HomePage() {
/>
)}
<section className="space-y-4" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
<section className="space-y-4" style={headingFont ? { fontFamily: headingFont } : undefined}>
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-foreground">Starte dein Fotospiel</h2>
@@ -163,7 +166,7 @@ export default function HomePage() {
onShuffle={shuffleMissionPreview}
/>
<EmotionActionCard />
<UploadActionCard token={token} accentColor={accentColor} secondaryAccent={secondaryAccent} />
<UploadActionCard token={token} accentColor={accentColor} secondaryAccent={secondaryAccent} radius={radius} bodyFont={bodyFont} />
</div>
</section>
@@ -328,15 +331,23 @@ function UploadActionCard({
token,
accentColor,
secondaryAccent,
radius,
bodyFont,
}: {
token: string;
accentColor: string;
secondaryAccent: string;
radius: number;
bodyFont?: string;
}) {
return (
<Card
className="overflow-hidden border-0 text-white shadow-sm"
style={{ background: `linear-gradient(120deg, ${accentColor}, ${secondaryAccent})` }}
style={{
background: `linear-gradient(120deg, ${accentColor}, ${secondaryAccent})`,
borderRadius: `${radius}px`,
fontFamily: bodyFont,
}}
>
<CardContent className="flex flex-col gap-3 py-5">
<div className="flex items-center gap-3">
@@ -348,7 +359,11 @@ function UploadActionCard({
<p className="text-sm text-white/80">Kamera öffnen oder ein Foto aus deiner Galerie wählen.</p>
</div>
</div>
<Button asChild className="bg-white/90 text-slate-900 hover:bg-white">
<Button
asChild
className="bg-white/90 text-slate-900 hover:bg-white"
style={{ borderRadius: `${radius}px` }}
>
<Link to={`/e/${encodeURIComponent(token)}/upload`}>Foto hochladen</Link>
</Button>
</CardContent>

View File

@@ -8,6 +8,7 @@ import { fetchGalleryMeta, fetchGalleryPhotos, type GalleryMetaResponse, type Ga
import { useTranslation } from '../i18n/useTranslation';
import { DEFAULT_LOCALE, isLocaleCode } from '../i18n/messages';
import { AlertTriangle, Download, Loader2, X } from 'lucide-react';
import { getContrastingTextColor } from '../lib/color';
interface GalleryState {
meta: GalleryMetaResponse | null;
@@ -90,6 +91,29 @@ export default function PublicGalleryPage(): React.ReactElement | null {
loadInitial();
}, [loadInitial]);
useEffect(() => {
const mode = state.meta?.branding.mode;
if (!mode || typeof document === 'undefined') {
return;
}
const wasDark = document.documentElement.classList.contains('dark');
if (mode === 'dark') {
document.documentElement.classList.add('dark');
} else if (mode === 'light') {
document.documentElement.classList.remove('dark');
}
return () => {
if (wasDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
};
}, [state.meta?.branding.mode]);
const loadMore = useCallback(async () => {
if (!token || !state.cursor || state.loadingMore) {
return;
@@ -140,10 +164,17 @@ export default function PublicGalleryPage(): React.ReactElement | null {
return {} as React.CSSProperties;
}
const palette = state.meta.branding.palette ?? {};
const primary = palette.primary ?? state.meta.branding.primary_color;
const secondary = palette.secondary ?? state.meta.branding.secondary_color;
const background = palette.background ?? state.meta.branding.background_color;
const surface = palette.surface ?? state.meta.branding.surface_color ?? background;
return {
'--gallery-primary': state.meta.branding.primary_color,
'--gallery-secondary': state.meta.branding.secondary_color,
'--gallery-background': state.meta.branding.background_color,
'--gallery-primary': primary,
'--gallery-secondary': secondary,
'--gallery-background': background,
'--gallery-surface': surface,
} as React.CSSProperties & Record<string, string>;
}, [state.meta]);
@@ -151,9 +182,13 @@ export default function PublicGalleryPage(): React.ReactElement | null {
if (!state.meta) {
return {};
}
const palette = state.meta.branding.palette ?? {};
const primary = palette.primary ?? state.meta.branding.primary_color;
const secondary = palette.secondary ?? state.meta.branding.secondary_color ?? primary;
const textColor = getContrastingTextColor(primary ?? '#f43f5e', '#0f172a', '#ffffff');
return {
background: state.meta.branding.primary_color,
color: '#ffffff',
background: `linear-gradient(135deg, ${primary}, ${secondary})`,
color: textColor,
} satisfies React.CSSProperties;
}, [state.meta]);
@@ -162,7 +197,7 @@ export default function PublicGalleryPage(): React.ReactElement | null {
return {};
}
return {
color: state.meta.branding.primary_color,
color: (state.meta.branding.palette?.primary ?? state.meta.branding.primary_color),
} satisfies React.CSSProperties;
}, [state.meta]);
@@ -171,7 +206,7 @@ export default function PublicGalleryPage(): React.ReactElement | null {
return {};
}
return {
backgroundColor: state.meta.branding.background_color,
backgroundColor: state.meta.branding.palette?.background ?? state.meta.branding.background_color,
} satisfies React.CSSProperties;
}, [state.meta]);

View File

@@ -169,6 +169,11 @@ export default function TaskPickerPage() {
const [searchParams, setSearchParams] = useSearchParams();
const { branding } = useEventBranding();
const { t, locale } = useTranslation();
const radius = branding.buttons?.radius ?? 12;
const buttonStyle = branding.buttons?.style ?? 'filled';
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
const { isCompleted } = useGuestTaskProgress(eventKey);

View File

@@ -33,6 +33,7 @@ import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import { buildLimitSummaries, type LimitSummaryCard } from '../lib/limitSummaries';
import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadErrorDialog';
import { useEventStats } from '../context/EventStatsContext';
import { useEventBranding } from '../context/EventBrandingContext';
import { compressPhoto, formatBytes } from '../lib/image';
interface Task {
@@ -113,6 +114,11 @@ export default function UploadPage() {
const { markCompleted } = useGuestTaskProgress(token);
const { t, locale } = useTranslation();
const stats = useEventStats();
const { branding } = useEventBranding();
const radius = branding.buttons?.radius ?? 12;
const buttonStyle = branding.buttons?.style ?? 'filled';
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const taskIdParam = searchParams.get('task');
const emotionSlug = searchParams.get('emotion') || '';
@@ -936,7 +942,10 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
const canRetryCamera = permissionState !== 'unsupported';
return (
<div className="rounded-[32px] border border-white/15 bg-white/85 p-5 text-slate-900 shadow-lg dark:border-white/10 dark:bg-white/5 dark:text-white">
<div
className="rounded-[32px] border border-white/15 bg-white/85 p-5 text-slate-900 shadow-lg dark:border-white/10 dark:bg-white/5 dark:text-white"
style={{ borderRadius: radius, fontFamily: bodyFont }}
>
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-900/10 dark:bg-white/10">
<Camera className="h-6 w-6" />
@@ -948,11 +957,20 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
</div>
<div className="mt-4 flex flex-wrap gap-3">
{canRetryCamera && (
<Button onClick={startCamera} size="sm">
<Button
onClick={startCamera}
size="sm"
style={buttonStyle === 'outline' ? { borderRadius: radius, background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : { borderRadius: radius }}
>
{t('upload.buttons.startCamera')}
</Button>
)}
<Button variant="secondary" size="sm" onClick={() => fileInputRef.current?.click()}>
<Button
variant="secondary"
size="sm"
onClick={() => fileInputRef.current?.click()}
style={buttonStyle === 'outline' ? { borderRadius: radius, background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : { borderRadius: radius }}
>
{t('upload.galleryButton')}
</Button>
</div>
@@ -962,9 +980,12 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
return renderWithDialog(
<>
<div className="relative pt-8">
<div className="relative pt-8" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
{taskFloatingCard}
<section className="relative overflow-hidden rounded-[32px] border border-white/10 bg-black text-white shadow-2xl">
<section
className="relative overflow-hidden border border-white/10 bg-black text-white shadow-2xl"
style={{ borderRadius: radius }}
>
<div className="relative aspect-[3/4] sm:aspect-video">
<video
ref={videoRef}
@@ -1028,7 +1049,10 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
)}
</div>
<div className="relative z-30 flex flex-col gap-4 bg-gradient-to-t from-black via-black/80 to-transparent p-4">
<div
className="relative z-30 flex flex-col gap-4 bg-gradient-to-t from-black via-black/80 to-transparent p-4"
style={{ fontFamily: bodyFont }}
>
{uploadWarning && (
<Alert className="border-yellow-400/20 bg-yellow-500/10 text-white">
<AlertDescription className="text-xs">