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 && (