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:
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user