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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user