- 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.
214 lines
8.5 KiB
TypeScript
214 lines
8.5 KiB
TypeScript
import React from 'react';
|
|
import { useNavigate, NavLink, useLocation } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { CalendarDays, ChevronDown, PlusCircle } from 'lucide-react';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { type TenantEvent } from '../api';
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
SheetTrigger,
|
|
} from '@/components/ui/sheet';
|
|
|
|
import { useEventContext } from '../context/EventContext';
|
|
import {
|
|
ADMIN_EVENT_CREATE_PATH,
|
|
ADMIN_EVENT_INVITES_PATH,
|
|
ADMIN_EVENT_MEMBERS_PATH,
|
|
ADMIN_EVENT_PHOTOS_PATH,
|
|
ADMIN_EVENT_TASKS_PATH,
|
|
ADMIN_EVENT_VIEW_PATH,
|
|
ADMIN_EVENT_PHOTOBOOTH_PATH,
|
|
ADMIN_EVENT_BRANDING_PATH,
|
|
} from '../constants';
|
|
import { cn } from '@/lib/utils';
|
|
import { resolveEventDisplayName, formatEventDate } from '../lib/events';
|
|
|
|
function buildEventLinks(slug: string, t: ReturnType<typeof useTranslation>['t']) {
|
|
return [
|
|
{ key: 'summary', label: t('eventMenu.summary', 'Übersicht'), href: ADMIN_EVENT_VIEW_PATH(slug) },
|
|
{ key: 'photos', label: t('eventMenu.photos', 'Uploads'), href: ADMIN_EVENT_PHOTOS_PATH(slug) },
|
|
{ key: 'photobooth', label: t('eventMenu.photobooth', 'Photobooth'), href: ADMIN_EVENT_PHOTOBOOTH_PATH(slug) },
|
|
{ key: 'guests', label: t('eventMenu.guests', 'Team & Gäste'), href: ADMIN_EVENT_MEMBERS_PATH(slug) },
|
|
{ key: 'tasks', label: t('eventMenu.tasks', 'Aufgaben'), href: ADMIN_EVENT_TASKS_PATH(slug) },
|
|
{ key: 'invites', label: t('eventMenu.invites', 'Einladungen'), href: ADMIN_EVENT_INVITES_PATH(slug) },
|
|
{ key: 'branding', label: t('eventMenu.branding', 'Branding & Fonts'), href: ADMIN_EVENT_BRANDING_PATH(slug) },
|
|
];
|
|
}
|
|
|
|
type EventSwitcherProps = {
|
|
buttonClassName?: string;
|
|
compact?: boolean;
|
|
};
|
|
|
|
export function EventSwitcher({ buttonClassName, compact = false }: EventSwitcherProps = {}) {
|
|
const { events, activeEvent, selectEvent } = useEventContext();
|
|
const { t, i18n } = useTranslation('common');
|
|
const navigate = useNavigate();
|
|
const [open, setOpen] = React.useState(false);
|
|
|
|
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
|
|
const buttonLabel = activeEvent ? resolveEventDisplayName(activeEvent) : t('eventSwitcher.placeholder', 'Event auswählen');
|
|
const buttonHint = activeEvent?.event_date
|
|
? formatEventDate(activeEvent.event_date, locale)
|
|
: events.length > 1
|
|
? t('eventSwitcher.multiple', 'Mehrere Events')
|
|
: t('eventSwitcher.empty', 'Noch kein Event');
|
|
|
|
const handleSelect = (event: TenantEvent) => {
|
|
selectEvent(event.slug ?? null);
|
|
setOpen(false);
|
|
if (event.slug) {
|
|
navigate(ADMIN_EVENT_VIEW_PATH(event.slug));
|
|
}
|
|
};
|
|
|
|
const buttonClasses = cn(
|
|
'rounded-full border-rose-100 bg-white/80 px-4 text-sm font-semibold text-slate-700 shadow-sm hover:bg-rose-50 dark:border-white/20 dark:bg-white/10 dark:text-white',
|
|
compact && 'px-3 text-xs sm:text-sm',
|
|
buttonClassName,
|
|
);
|
|
|
|
const buttonLabelClasses = compact ? 'text-sm' : 'hidden sm:inline';
|
|
const hintClasses = compact
|
|
? 'text-xs text-slate-500 dark:text-slate-300'
|
|
: 'text-xs text-slate-500 dark:text-slate-300 sm:ml-2';
|
|
|
|
return (
|
|
<Sheet open={open} onOpenChange={setOpen}>
|
|
<SheetTrigger asChild>
|
|
<Button variant="outline" size="sm" className={buttonClasses}>
|
|
<CalendarDays className="mr-2 h-4 w-4" />
|
|
<span className={buttonLabelClasses}>{buttonLabel}</span>
|
|
<span className={hintClasses}>
|
|
{buttonHint}
|
|
</span>
|
|
<ChevronDown className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
</SheetTrigger>
|
|
<SheetContent side="bottom" className="rounded-t-3xl p-0">
|
|
<SheetHeader className="border-b border-slate-200 p-4 dark:border-white/10">
|
|
<SheetTitle>{t('eventSwitcher.title', 'Event auswählen')}</SheetTitle>
|
|
<SheetDescription>
|
|
{events.length === 0
|
|
? t('eventSwitcher.emptyDescription', 'Erstelle dein erstes Event, um loszulegen.')
|
|
: t('eventSwitcher.description', 'Wähle ein Event für die Bearbeitung oder lege ein neues an.')}
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
<div className="max-h-[60vh] overflow-y-auto p-4">
|
|
{events.length === 0 ? (
|
|
<div className="rounded-2xl border border-dashed border-slate-200 p-6 text-center text-sm text-slate-600 dark:border-white/15 dark:text-slate-300">
|
|
{t('eventSwitcher.noEvents', 'Noch keine Events vorhanden.')}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{events.map((event) => {
|
|
const isActive = activeEvent?.id === event.id;
|
|
const date = formatEventDate(event.event_date, locale);
|
|
return (
|
|
<button
|
|
key={event.id}
|
|
type="button"
|
|
onClick={() => handleSelect(event)}
|
|
className={cn(
|
|
'w-full rounded-2xl border px-4 py-3 text-left transition hover:border-rose-200 dark:border-white/10 dark:bg-white/5',
|
|
isActive
|
|
? 'border-rose-500 bg-rose-50/70 text-rose-900 dark:border-rose-300 dark:bg-rose-200/10 dark:text-rose-100'
|
|
: 'bg-white text-slate-900'
|
|
)}
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-sm font-semibold">{resolveEventDisplayName(event)}</p>
|
|
<p className="text-xs text-slate-500 dark:text-slate-300">{date ?? t('eventSwitcher.noDate', 'Kein Datum')}</p>
|
|
</div>
|
|
{isActive ? (
|
|
<Badge className="bg-rose-600 text-white">{t('eventSwitcher.active', 'Aktiv')}</Badge>
|
|
) : null}
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
className="mt-4 w-full rounded-full"
|
|
onClick={() => {
|
|
setOpen(false);
|
|
navigate(ADMIN_EVENT_CREATE_PATH);
|
|
}}
|
|
>
|
|
<PlusCircle className="mr-2 h-4 w-4" />
|
|
{t('eventSwitcher.create', 'Neues Event anlegen')}
|
|
</Button>
|
|
{activeEvent?.slug ? (
|
|
<div className="mt-6 space-y-3">
|
|
<p className="text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-300">
|
|
{t('eventSwitcher.actions', 'Event-Funktionen')}
|
|
</p>
|
|
<div className="grid gap-2">
|
|
{buildEventLinks(activeEvent.slug, t).map((action) => (
|
|
<Button
|
|
key={action.key}
|
|
variant="ghost"
|
|
className="justify-between rounded-2xl border border-slate-200 bg-white text-left text-sm font-semibold text-slate-700 hover:bg-rose-50 dark:border-white/10 dark:bg-white/5 dark:text-white"
|
|
onClick={() => {
|
|
setOpen(false);
|
|
navigate(action.href);
|
|
}}
|
|
>
|
|
{action.label}
|
|
<ChevronDown className="rotate-[-90deg]" />
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|
|
|
|
export function EventMenuBar() {
|
|
const { activeEvent } = useEventContext();
|
|
const { t } = useTranslation('common');
|
|
const location = useLocation();
|
|
|
|
if (!activeEvent?.slug) {
|
|
return null;
|
|
}
|
|
|
|
const links = buildEventLinks(activeEvent.slug, t);
|
|
|
|
return (
|
|
<div className="border-t border-slate-200 bg-white/80 px-4 py-2 dark:border-white/10 dark:bg-slate-950/80">
|
|
<div className="flex items-center gap-2 overflow-x-auto text-sm">
|
|
{links.map((link) => (
|
|
<NavLink
|
|
key={link.key}
|
|
to={link.href}
|
|
className={({ isActive }) =>
|
|
cn(
|
|
'whitespace-nowrap rounded-full px-3 py-1 text-xs font-semibold transition',
|
|
isActive || location.pathname.startsWith(link.href)
|
|
? 'bg-rose-600 text-white shadow shadow-rose-400/40'
|
|
: 'bg-white text-slate-600 ring-1 ring-slate-200 hover:text-rose-600 dark:bg-white/10 dark:text-white dark:ring-white/10'
|
|
)
|
|
}
|
|
>
|
|
{link.label}
|
|
</NavLink>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|