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

@@ -10,12 +10,16 @@ function TabLink({
children,
isActive,
accentColor,
radius,
style,
compact = false,
}: {
to: string;
children: React.ReactNode;
isActive: boolean;
accentColor: string;
radius: number;
style?: React.CSSProperties;
compact?: boolean;
}) {
const activeStyle = isActive
@@ -23,8 +27,10 @@ function TabLink({
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}cc)`,
color: '#ffffff',
boxShadow: `0 12px 30px ${accentColor}33`,
borderRadius: radius,
...style,
}
: undefined;
: { borderRadius: radius, ...style };
return (
<NavLink
@@ -47,6 +53,10 @@ export default function BottomNav() {
const { event, status } = useEventData();
const { t } = useTranslation();
const { branding } = useEventBranding();
const radius = branding.buttons?.radius ?? 12;
const buttonStyle = branding.buttons?.style ?? 'filled';
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
const surface = branding.palette?.surface ?? branding.backgroundColor;
const isReady = status === 'ready' && !!event;
@@ -79,13 +89,27 @@ export default function BottomNav() {
>
<div className="mx-auto flex max-w-lg items-center gap-3">
<div className="flex flex-1 justify-evenly gap-2">
<TabLink to={`${base}`} isActive={isHomeActive} accentColor={branding.primaryColor} compact={compact}>
<TabLink
to={`${base}`}
isActive={isHomeActive}
accentColor={branding.primaryColor}
radius={radius}
compact={compact}
style={buttonStyle === 'outline' ? { background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : undefined}
>
<div className="flex flex-col items-center gap-1">
<Home className="h-5 w-5" aria-hidden />
<span>{labels.home}</span>
</div>
</TabLink>
<TabLink to={`${base}/tasks`} isActive={isTasksActive} accentColor={branding.primaryColor} compact={compact}>
<TabLink
to={`${base}/tasks`}
isActive={isTasksActive}
accentColor={branding.primaryColor}
radius={radius}
compact={compact}
style={buttonStyle === 'outline' ? { background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : undefined}
>
<div className="flex flex-col items-center gap-1">
<CheckSquare className="h-5 w-5" aria-hidden />
<span>{labels.tasks}</span>
@@ -102,6 +126,7 @@ export default function BottomNav() {
style={{
background: `radial-gradient(circle at 20% 20%, ${branding.secondaryColor}, ${branding.primaryColor})`,
boxShadow: `0 20px 35px ${branding.primaryColor}44`,
borderRadius: radius,
}}
>
<Camera className="h-6 w-6" aria-hidden />
@@ -112,6 +137,8 @@ export default function BottomNav() {
to={`${base}/achievements`}
isActive={isAchievementsActive}
accentColor={branding.primaryColor}
radius={radius}
style={buttonStyle === 'outline' ? { background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : undefined}
compact={compact}
>
<div className="flex flex-col items-center gap-1">
@@ -123,6 +150,8 @@ export default function BottomNav() {
to={`${base}/gallery`}
isActive={isGalleryActive}
accentColor={branding.primaryColor}
radius={radius}
style={buttonStyle === 'outline' ? { background: 'transparent', color: linkColor, border: `1px solid ${linkColor}` } : undefined}
compact={compact}
>
<div className="flex flex-col items-center gap-1">

View File

@@ -18,7 +18,14 @@ export default function FiltersBar({
onChange,
className,
showPhotobooth = true,
}: { value: GalleryFilter; onChange: (v: GalleryFilter) => void; className?: string; showPhotobooth?: boolean }) {
styleOverride,
}: {
value: GalleryFilter;
onChange: (v: GalleryFilter) => void;
className?: string;
showPhotobooth?: boolean;
styleOverride?: React.CSSProperties;
}) {
const { t } = useTranslation();
const filters: FilterConfig = React.useMemo(
() => (showPhotobooth
@@ -33,6 +40,7 @@ export default function FiltersBar({
'flex gap-2 overflow-x-auto px-4 pb-2 text-sm font-medium [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
className,
)}
style={styleOverride}
>
{filters.map((filter) => (
<button

View File

@@ -27,6 +27,7 @@ import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/Eve
import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext';
import { useGuestTaskProgress, TASK_BADGE_TARGET } from '../hooks/useGuestTaskProgress';
import { usePushSubscription } from '../hooks/usePushSubscription';
import { getContrastingTextColor, relativeLuminance } from '../lib/color';
const EVENT_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
heart: Heart,
@@ -66,7 +67,26 @@ function getInitials(name: string): string {
return name.substring(0, 2).toUpperCase();
}
function renderEventAvatar(name: string, icon: unknown, accentColor: string, textColor: string) {
function renderEventAvatar(name: string, icon: unknown, accentColor: string, textColor: string, logo?: { mode: 'emoticon' | 'upload'; value: string | null }) {
if (logo?.mode === 'upload' && logo.value) {
return (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white shadow-sm">
<img src={logo.value} alt={name} className="h-9 w-9 rounded-full object-contain" />
</div>
);
}
if (logo?.mode === 'emoticon' && logo.value && isLikelyEmoji(logo.value)) {
return (
<div
className="flex h-10 w-10 items-center justify-center rounded-full text-xl shadow-sm"
style={{ backgroundColor: accentColor, color: textColor }}
>
<span aria-hidden>{logo.value}</span>
<span className="sr-only">{name}</span>
</div>
);
}
if (typeof icon === 'string') {
const trimmed = icon.trim();
if (trimmed) {
@@ -113,7 +133,17 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
const { t } = useTranslation();
const brandingContext = useOptionalEventBranding();
const branding = brandingContext?.branding ?? DEFAULT_EVENT_BRANDING;
const primaryForeground = '#ffffff';
const headerTextColor = React.useMemo(() => {
const primaryLum = relativeLuminance(branding.primaryColor);
const secondaryLum = relativeLuminance(branding.secondaryColor);
const avgLum = (primaryLum + secondaryLum) / 2;
if (avgLum > 0.55) {
return getContrastingTextColor(branding.primaryColor, '#0f172a', '#ffffff');
}
return '#ffffff';
}, [branding.primaryColor, branding.secondaryColor]);
const { event, status } = useEventData();
const notificationCenter = useOptionalNotificationCenter();
const [notificationsOpen, setNotificationsOpen] = React.useState(false);
@@ -169,7 +199,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
const headerStyle: React.CSSProperties = {
background: `linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`,
color: primaryForeground,
color: headerTextColor,
fontFamily: branding.fontFamily ?? undefined,
};
@@ -199,7 +229,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
style={headerStyle}
>
<div className="flex items-center gap-3">
{renderEventAvatar(event.name, event.type?.icon, accentColor, primaryForeground)}
{renderEventAvatar(event.name, event.type?.icon, accentColor, headerTextColor, branding.logo)}
<div className="flex flex-col" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
<div className="font-semibold text-base">{event.name}</div>
{guestName && (

View File

@@ -12,6 +12,28 @@ export const DEFAULT_EVENT_BRANDING: EventBranding = {
backgroundColor: '#ffffff',
fontFamily: null,
logoUrl: null,
palette: {
primary: '#f43f5e',
secondary: '#fb7185',
background: '#ffffff',
surface: '#ffffff',
},
typography: {
heading: null,
body: null,
sizePreset: 'm',
},
logo: {
mode: 'emoticon',
value: null,
position: 'left',
size: 'm',
},
buttons: {
style: 'filled',
radius: 12,
},
mode: 'auto',
};
const DEFAULT_PRIMARY = DEFAULT_EVENT_BRANDING.primaryColor.toLowerCase();
const DEFAULT_SECONDARY = DEFAULT_EVENT_BRANDING.secondaryColor.toLowerCase();
@@ -33,12 +55,50 @@ function resolveBranding(input?: EventBranding | null): EventBranding {
return DEFAULT_EVENT_BRANDING;
}
const palettePrimary = input.palette?.primary ?? input.primaryColor;
const paletteSecondary = input.palette?.secondary ?? input.secondaryColor;
const paletteBackground = input.palette?.background ?? input.backgroundColor;
const paletteSurface = input.palette?.surface ?? input.backgroundColor;
const headingFont = input.typography?.heading ?? input.fontFamily ?? null;
const bodyFont = input.typography?.body ?? input.fontFamily ?? null;
const sizePreset = input.typography?.sizePreset ?? 'm';
const logoMode = input.logo?.mode ?? (input.logoUrl ? 'upload' : 'emoticon');
const logoValue = input.logo?.value ?? input.logoUrl ?? null;
return {
primaryColor: normaliseHexColor(input.primaryColor, DEFAULT_EVENT_BRANDING.primaryColor),
secondaryColor: normaliseHexColor(input.secondaryColor, DEFAULT_EVENT_BRANDING.secondaryColor),
backgroundColor: normaliseHexColor(input.backgroundColor, DEFAULT_EVENT_BRANDING.backgroundColor),
fontFamily: input.fontFamily?.trim() || null,
logoUrl: input.logoUrl?.trim() || null,
primaryColor: normaliseHexColor(palettePrimary, DEFAULT_EVENT_BRANDING.primaryColor),
secondaryColor: normaliseHexColor(paletteSecondary, DEFAULT_EVENT_BRANDING.secondaryColor),
backgroundColor: normaliseHexColor(paletteBackground, DEFAULT_EVENT_BRANDING.backgroundColor),
fontFamily: bodyFont?.trim() || null,
logoUrl: logoMode === 'upload' ? (logoValue?.trim() || null) : null,
palette: {
primary: normaliseHexColor(palettePrimary, DEFAULT_EVENT_BRANDING.primaryColor),
secondary: normaliseHexColor(paletteSecondary, DEFAULT_EVENT_BRANDING.secondaryColor),
background: normaliseHexColor(paletteBackground, DEFAULT_EVENT_BRANDING.backgroundColor),
surface: normaliseHexColor(paletteSurface, paletteBackground ?? DEFAULT_EVENT_BRANDING.backgroundColor),
},
typography: {
heading: headingFont?.trim() || null,
body: bodyFont?.trim() || null,
sizePreset,
},
logo: {
mode: logoMode,
value: logoMode === 'upload' ? (logoValue?.trim() || null) : (logoValue ?? null),
position: input.logo?.position ?? 'left',
size: input.logo?.size ?? 'm',
},
buttons: {
style: input.buttons?.style ?? 'filled',
radius: typeof input.buttons?.radius === 'number' ? input.buttons.radius : 12,
primary: input.buttons?.primary ?? normaliseHexColor(palettePrimary, DEFAULT_EVENT_BRANDING.primaryColor),
secondary: input.buttons?.secondary ?? normaliseHexColor(paletteSecondary, DEFAULT_EVENT_BRANDING.secondaryColor),
linkColor: input.buttons?.linkColor ?? normaliseHexColor(paletteSecondary, DEFAULT_EVENT_BRANDING.secondaryColor),
},
mode: input.mode ?? 'auto',
useDefaultBranding: input.useDefaultBranding ?? undefined,
};
}
@@ -51,12 +111,26 @@ function applyCssVariables(branding: EventBranding) {
root.style.setProperty('--guest-primary', branding.primaryColor);
root.style.setProperty('--guest-secondary', branding.secondaryColor);
root.style.setProperty('--guest-background', branding.backgroundColor);
root.style.setProperty('--guest-surface', branding.palette?.surface ?? branding.backgroundColor);
root.style.setProperty('--guest-button-radius', `${branding.buttons?.radius ?? 12}px`);
root.style.setProperty('--guest-radius', `${branding.buttons?.radius ?? 12}px`);
root.style.setProperty('--guest-link', branding.buttons?.linkColor ?? branding.secondaryColor);
root.style.setProperty('--guest-button-style', branding.buttons?.style ?? 'filled');
if (branding.fontFamily) {
root.style.setProperty('--guest-font-family', branding.fontFamily);
root.style.setProperty('--guest-heading-font', branding.fontFamily);
const headingFont = branding.typography?.heading ?? branding.fontFamily;
const bodyFont = branding.typography?.body ?? branding.fontFamily;
if (bodyFont) {
root.style.setProperty('--guest-font-family', bodyFont);
root.style.setProperty('--guest-body-font', bodyFont);
} else {
root.style.removeProperty('--guest-font-family');
root.style.removeProperty('--guest-body-font');
}
if (headingFont) {
root.style.setProperty('--guest-heading-font', headingFont);
} else {
root.style.removeProperty('--guest-heading-font');
}
}
@@ -70,10 +144,66 @@ function resetCssVariables() {
root.style.removeProperty('--guest-primary');
root.style.removeProperty('--guest-secondary');
root.style.removeProperty('--guest-background');
root.style.removeProperty('--guest-surface');
root.style.removeProperty('--guest-button-radius');
root.style.removeProperty('--guest-radius');
root.style.removeProperty('--guest-link');
root.style.removeProperty('--guest-button-style');
root.style.removeProperty('--guest-font-family');
root.style.removeProperty('--guest-body-font');
root.style.removeProperty('--guest-heading-font');
}
function applyThemeMode(mode: EventBranding['mode']) {
if (typeof document === 'undefined') {
return;
}
const root = document.documentElement;
const prefersDark = typeof window !== 'undefined'
? window.matchMedia('(prefers-color-scheme: dark)').matches
: false;
let storedTheme: 'light' | 'dark' | 'system' | null = null;
try {
const raw = localStorage.getItem('theme');
storedTheme = raw === 'light' || raw === 'dark' || raw === 'system' ? raw : null;
} catch {
storedTheme = null;
}
const applyDark = () => root.classList.add('dark');
const applyLight = () => root.classList.remove('dark');
if (mode === 'dark') {
applyDark();
return;
}
if (mode === 'light') {
applyLight();
return;
}
if (storedTheme === 'dark') {
applyDark();
return;
}
if (storedTheme === 'light') {
applyLight();
return;
}
if (prefersDark) {
applyDark();
return;
}
applyLight();
}
export function EventBrandingProvider({
branding,
children,
@@ -85,10 +215,20 @@ export function EventBrandingProvider({
useEffect(() => {
applyCssVariables(resolved);
const previousDark = typeof document !== 'undefined' ? document.documentElement.classList.contains('dark') : false;
applyThemeMode(resolved.mode ?? 'auto');
return () => {
if (typeof document !== 'undefined') {
if (previousDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
resetCssVariables();
applyCssVariables(DEFAULT_EVENT_BRANDING);
applyThemeMode(DEFAULT_EVENT_BRANDING.mode ?? 'auto');
};
}, [resolved]);
@@ -98,6 +238,7 @@ export function EventBrandingProvider({
resolved.primaryColor.toLowerCase() !== DEFAULT_PRIMARY
|| resolved.secondaryColor.toLowerCase() !== DEFAULT_SECONDARY
|| resolved.backgroundColor.toLowerCase() !== DEFAULT_BACKGROUND,
// legacy surface check omitted by intent
}), [resolved]);
return <EventBrandingContext.Provider value={value}>{children}</EventBrandingContext.Provider>;

View File

@@ -0,0 +1,50 @@
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const normalized = hex.trim();
if (!/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(normalized)) {
return null;
}
let r: number;
let g: number;
let b: number;
if (normalized.length === 4) {
r = parseInt(normalized[1] + normalized[1], 16);
g = parseInt(normalized[2] + normalized[2], 16);
b = parseInt(normalized[3] + normalized[3], 16);
} else {
r = parseInt(normalized.slice(1, 3), 16);
g = parseInt(normalized.slice(3, 5), 16);
b = parseInt(normalized.slice(5, 7), 16);
}
return { r, g, b };
}
export function relativeLuminance(hex: string): number {
const rgb = hexToRgb(hex);
if (!rgb) {
return 0;
}
const normalize = (channel: number) => {
const c = channel / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
const r = normalize(rgb.r);
const g = normalize(rgb.g);
const b = normalize(rgb.b);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
export function getContrastingTextColor(
backgroundHex: string,
lightColor = '#ffffff',
darkColor = '#0f172a',
): string {
const luminance = relativeLuminance(backgroundHex);
return luminance > 0.5 ? darkColor : lightColor;
}

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

View File

@@ -173,12 +173,59 @@ function mapEventBranding(raw?: EventBrandingPayload | null): EventBranding | nu
return null;
}
const palette = raw.palette ?? {};
const typography = raw.typography ?? {};
const buttons = raw.buttons ?? {};
const logo = raw.logo ?? {};
const primary = palette.primary ?? raw.primary_color ?? '';
const secondary = palette.secondary ?? raw.secondary_color ?? '';
const background = palette.background ?? raw.background_color ?? '';
const surface = palette.surface ?? raw.surface_color ?? background;
const headingFont = typography.heading ?? raw.heading_font ?? raw.font_family ?? null;
const bodyFont = typography.body ?? raw.body_font ?? raw.font_family ?? null;
const sizePreset = (typography.size as 's' | 'm' | 'l' | undefined) ?? (raw.font_size as 's' | 'm' | 'l' | undefined) ?? 'm';
const logoMode = logo.mode ?? raw.logo_mode ?? (logo.value || raw.logo_url ? 'upload' : 'emoticon');
const logoValue = logo.value ?? raw.logo_value ?? raw.logo_url ?? raw.icon ?? null;
const logoPosition = logo.position ?? raw.logo_position ?? 'left';
const logoSize = (logo.size as 's' | 'm' | 'l' | undefined) ?? (raw.logo_size as 's' | 'm' | 'l' | undefined) ?? 'm';
const buttonStyle = (buttons.style as 'filled' | 'outline' | undefined) ?? (raw.button_style as 'filled' | 'outline' | undefined) ?? 'filled';
const buttonRadius = typeof buttons.radius === 'number' ? buttons.radius : (typeof raw.button_radius === 'number' ? raw.button_radius : 12);
const buttonPrimary = buttons.primary ?? raw.button_primary_color ?? primary ?? '';
const buttonSecondary = buttons.secondary ?? raw.button_secondary_color ?? secondary ?? '';
const linkColor = buttons.link_color ?? raw.link_color ?? secondary ?? '';
return {
primaryColor: raw.primary_color ?? '',
secondaryColor: raw.secondary_color ?? '',
backgroundColor: raw.background_color ?? '',
fontFamily: raw.font_family ?? null,
logoUrl: raw.logo_url ?? null,
primaryColor: primary ?? '',
secondaryColor: secondary ?? '',
backgroundColor: background ?? '',
fontFamily: bodyFont,
logoUrl: logoMode === 'upload' ? (logoValue ?? null) : null,
palette: {
primary: primary ?? '',
secondary: secondary ?? '',
background: background ?? '',
surface: surface ?? background ?? '',
},
typography: {
heading: headingFont,
body: bodyFont,
sizePreset,
},
logo: {
mode: logoMode,
value: logoValue,
position: logoPosition,
size: logoSize,
},
buttons: {
style: buttonStyle,
radius: buttonRadius,
primary: buttonPrimary,
secondary: buttonSecondary,
linkColor,
},
mode: (raw.mode as 'light' | 'dark' | 'auto' | undefined) ?? 'auto',
useDefaultBranding: raw.use_default_branding ?? undefined,
};
}

View File

@@ -6,6 +6,46 @@ export interface EventBrandingPayload {
background_color?: string | null;
font_family?: string | null;
logo_url?: string | null;
surface_color?: string | null;
heading_font?: string | null;
body_font?: string | null;
font_size?: 's' | 'm' | 'l' | null;
icon?: string | null;
logo_mode?: 'emoticon' | 'upload' | null;
logo_value?: string | null;
logo_position?: 'left' | 'right' | 'center' | null;
logo_size?: 's' | 'm' | 'l' | null;
button_style?: 'filled' | 'outline' | null;
button_radius?: number | null;
button_primary_color?: string | null;
button_secondary_color?: string | null;
link_color?: string | null;
mode?: 'light' | 'dark' | 'auto' | null;
use_default_branding?: boolean | null;
palette?: {
primary?: string | null;
secondary?: string | null;
background?: string | null;
surface?: string | null;
} | null;
typography?: {
heading?: string | null;
body?: string | null;
size?: 's' | 'm' | 'l' | null;
} | null;
logo?: {
mode?: 'emoticon' | 'upload';
value?: string | null;
position?: 'left' | 'right' | 'center';
size?: 's' | 'm' | 'l';
} | null;
buttons?: {
style?: 'filled' | 'outline';
radius?: number | null;
primary?: string | null;
secondary?: string | null;
link_color?: string | null;
} | null;
}
export interface EventData {

View File

@@ -4,6 +4,14 @@ export interface GalleryBranding {
primary_color: string;
secondary_color: string;
background_color: string;
surface_color?: string;
mode?: 'light' | 'dark' | 'auto';
palette?: {
primary?: string | null;
secondary?: string | null;
background?: string | null;
surface?: string | null;
} | null;
}
export interface GalleryMetaResponse {

View File

@@ -1,8 +1,36 @@
export interface EventBranding {
// Legacy/compat fields used across components
primaryColor: string;
secondaryColor: string;
backgroundColor: string;
fontFamily: string | null;
logoUrl: string | null;
}
// Extended branding shape
useDefaultBranding?: boolean;
palette?: {
primary: string;
secondary: string;
background: string;
surface?: string;
};
typography?: {
heading: string | null;
body: string | null;
sizePreset?: 's' | 'm' | 'l';
};
logo?: {
mode: 'emoticon' | 'upload';
value: string | null;
position?: 'left' | 'right' | 'center';
size?: 's' | 'm' | 'l';
};
buttons?: {
style?: 'filled' | 'outline';
radius?: number; // px
primary?: string;
secondary?: string;
linkColor?: string;
};
mode?: 'light' | 'dark' | 'auto';
}