rework of the event admin UI
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -29,6 +30,7 @@ import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
EventToolkit,
|
||||
EventToolkitTask,
|
||||
TenantEmotion,
|
||||
TenantEvent,
|
||||
TenantPhoto,
|
||||
EventStats,
|
||||
@@ -39,6 +41,9 @@ import {
|
||||
submitTenantFeedback,
|
||||
updatePhotoVisibility,
|
||||
createEventAddonCheckout,
|
||||
featurePhoto,
|
||||
unfeaturePhoto,
|
||||
getEmotions,
|
||||
} from '../api';
|
||||
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
@@ -51,6 +56,7 @@ import {
|
||||
ADMIN_EVENT_PHOTOBOOTH_PATH,
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
ADMIN_EVENT_TASKS_PATH,
|
||||
buildEngagementTabPath,
|
||||
} from '../constants';
|
||||
import {
|
||||
SectionCard,
|
||||
@@ -62,6 +68,9 @@ import { AddonsPicker } from '../components/Addons/AddonsPicker';
|
||||
import { AddonSummaryList } from '../components/Addons/AddonSummaryList';
|
||||
import { EventAddonCatalogItem, getAddonCatalog } from '../api';
|
||||
import { GuestBroadcastCard } from '../components/GuestBroadcastCard';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { filterEmotionsByEventType } from '../lib/emotions';
|
||||
import { buildEventTabs } from '../lib/eventTabs';
|
||||
|
||||
type EventDetailPageProps = {
|
||||
mode?: 'detail' | 'toolkit';
|
||||
@@ -102,6 +111,7 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
const [addonBusyId, setAddonBusyId] = React.useState<string | null>(null);
|
||||
const [addonRefreshCount, setAddonRefreshCount] = React.useState(0);
|
||||
const [addonsCatalog, setAddonsCatalog] = React.useState<EventAddonCatalogItem[]>([]);
|
||||
const [emotions, setEmotions] = React.useState<TenantEmotion[]>([]);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!slug) {
|
||||
@@ -145,6 +155,26 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const list = await getEmotions();
|
||||
if (!cancelled) {
|
||||
setEmotions(list);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
console.warn('Failed to load emotions for event detail', error);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleToggle(): Promise<void> {
|
||||
if (!slug) {
|
||||
return;
|
||||
@@ -187,12 +217,32 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
? t('events.workspace.toolkitSubtitle', 'Moderation, Aufgaben und Einladungen für deinen Eventtag bündeln.')
|
||||
: t('events.workspace.detailSubtitle', 'Behalte Status, Aufgaben und Einladungen deines Events im Blick.');
|
||||
|
||||
|
||||
const tabLabels = React.useMemo(
|
||||
() => ({
|
||||
overview: t('events.workspace.tabs.overview', 'Überblick'),
|
||||
live: t('events.workspace.tabs.live', 'Live'),
|
||||
setup: t('events.workspace.tabs.setup', 'Vorbereitung'),
|
||||
recap: t('events.workspace.tabs.recap', 'Nachbereitung'),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
const limitWarnings = React.useMemo(
|
||||
() => (event?.limits ? buildLimitWarnings(event.limits, (key, options) => tCommon(`limits.${key}`, options)) : []),
|
||||
[event?.limits, tCommon],
|
||||
);
|
||||
|
||||
const eventTabs = React.useMemo(() => {
|
||||
if (!event) {
|
||||
return [];
|
||||
}
|
||||
const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
return buildEventTabs(event, translateMenu, {
|
||||
photos: toolkitData?.photos?.pending?.length ?? event.photo_count ?? 0,
|
||||
tasks: toolkitData?.tasks?.summary.total ?? event.tasks_count ?? 0,
|
||||
invites: toolkitData?.invites?.summary.active ?? event.active_invites_count ?? event.total_invites_count ?? 0,
|
||||
});
|
||||
}, [event, toolkitData?.photos?.pending?.length, toolkitData?.tasks?.summary.total, toolkitData?.invites?.summary.active, t]);
|
||||
|
||||
const shownWarningToasts = React.useRef<Set<string>>(new Set());
|
||||
//const [addonBusyId, setAddonBusyId] = React.useState<string | null>(null);
|
||||
|
||||
@@ -286,7 +336,7 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout title={eventName} subtitle={subtitle}>
|
||||
<AdminLayout title={eventName} subtitle={subtitle} tabs={eventTabs} currentTabKey="overview">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('events.alerts.failedTitle', 'Aktion fehlgeschlagen')}</AlertTitle>
|
||||
@@ -358,60 +408,82 @@ const shownWarningToasts = React.useRef<Set<string>>(new Set());
|
||||
navigate={navigate}
|
||||
/>
|
||||
|
||||
{(toolkitData?.alerts?.length ?? 0) > 0 && <AlertList alerts={toolkitData?.alerts ?? []} />}
|
||||
<Tabs defaultValue="overview" className="space-y-6">
|
||||
<TabsList className="grid gap-2 rounded-2xl bg-slate-100/80 p-1 dark:bg-white/5 sm:grid-cols-4">
|
||||
<TabsTrigger value="overview">{tabLabels.overview}</TabsTrigger>
|
||||
<TabsTrigger value="live">{tabLabels.live}</TabsTrigger>
|
||||
<TabsTrigger value="setup">{tabLabels.setup}</TabsTrigger>
|
||||
<TabsTrigger value="recap">{tabLabels.recap}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{state.event?.addons?.length ? (
|
||||
<SectionCard>
|
||||
<SectionHeader
|
||||
title={t('events.sections.addons.title', 'Add-ons & Upgrades')}
|
||||
description={t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}
|
||||
/>
|
||||
<AddonSummaryList addons={state.event.addons} t={(key, fallback) => t(key, fallback)} />
|
||||
</SectionCard>
|
||||
) : null}
|
||||
<TabsContent value="overview" className="space-y-6">
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
|
||||
<StatusCard event={event} stats={stats} busy={busy} onToggle={handleToggle} />
|
||||
<QuickActionsCard slug={event.slug} busy={busy} onToggle={handleToggle} navigate={navigate} />
|
||||
</div>
|
||||
<MetricsGrid metrics={toolkitData?.metrics} stats={stats} />
|
||||
</TabsContent>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
|
||||
<StatusCard event={event} stats={stats} busy={busy} onToggle={handleToggle} />
|
||||
<QuickActionsCard
|
||||
slug={event.slug}
|
||||
busy={busy}
|
||||
onToggle={handleToggle}
|
||||
navigate={navigate}
|
||||
/>
|
||||
</div>
|
||||
<TabsContent value="live" className="space-y-6">
|
||||
{(toolkitData?.alerts?.length ?? 0) > 0 && <AlertList alerts={toolkitData?.alerts ?? []} />}
|
||||
|
||||
<MetricsGrid metrics={toolkitData?.metrics} stats={stats} />
|
||||
<SectionCard className="space-y-6">
|
||||
<SectionHeader
|
||||
eyebrow={t('events.notifications.badge', 'Gästefeeds')}
|
||||
title={t('events.notifications.panelTitle', 'Nachrichten an Gäste')}
|
||||
description={t('events.notifications.panelDescription', 'Verschicke kurze Hinweise oder Hilfe an die Gästepwa. Links werden direkt im Notification-Center angezeigt.')}
|
||||
/>
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
|
||||
<GuestNotificationStatsCard notifications={toolkitData?.notifications} />
|
||||
<GuestBroadcastCard eventSlug={event.slug} eventName={eventName} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard className="space-y-6">
|
||||
<SectionHeader
|
||||
eyebrow={t('events.notifications.badge', 'Gästefeeds')}
|
||||
title={t('events.notifications.panelTitle', 'Nachrichten an Gäste')}
|
||||
description={t('events.notifications.panelDescription', 'Verschicke kurze Hinweise oder Hilfe an die Gästepwa. Links werden direkt im Notification-Center angezeigt.')}
|
||||
/>
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
|
||||
<GuestNotificationStatsCard notifications={toolkitData?.notifications} />
|
||||
<GuestBroadcastCard eventSlug={event.slug} eventName={eventName} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
|
||||
<PendingPhotosCard
|
||||
slug={event.slug}
|
||||
photos={toolkitData?.photos.pending ?? []}
|
||||
navigateToModeration={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}
|
||||
/>
|
||||
<RecentUploadsCard slug={event.slug} photos={toolkitData?.photos.recent ?? []} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
|
||||
<TaskOverviewCard tasks={toolkitData?.tasks} navigateToTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))} />
|
||||
<InviteSummary
|
||||
invites={toolkitData?.invites}
|
||||
navigateToInvites={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)}
|
||||
/>
|
||||
</div>
|
||||
<TabsContent value="setup" className="space-y-6">
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
|
||||
<TaskOverviewCard tasks={toolkitData?.tasks} navigateToTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))} />
|
||||
<InviteSummary
|
||||
invites={toolkitData?.invites}
|
||||
navigateToInvites={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,0.8fr)]">
|
||||
<PendingPhotosCard
|
||||
slug={event.slug}
|
||||
photos={toolkitData?.photos.pending ?? []}
|
||||
navigateToModeration={() => navigate(ADMIN_EVENT_PHOTOS_PATH(event.slug))}
|
||||
/>
|
||||
<RecentUploadsCard slug={event.slug} photos={toolkitData?.photos.recent ?? []} />
|
||||
</div>
|
||||
<BrandingMissionCard
|
||||
event={event}
|
||||
invites={toolkitData?.invites}
|
||||
emotions={emotions}
|
||||
onOpenBranding={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)}
|
||||
onOpenCollections={() => navigate(buildEngagementTabPath('collections'))}
|
||||
onOpenTasks={() => navigate(ADMIN_EVENT_TASKS_PATH(event.slug))}
|
||||
onOpenEmotions={() => navigate(buildEngagementTabPath('emotions'))}
|
||||
/>
|
||||
|
||||
<FeedbackCard slug={event.slug} />
|
||||
{event.addons?.length ? (
|
||||
<SectionCard>
|
||||
<SectionHeader
|
||||
title={t('events.sections.addons.title', 'Add-ons & Upgrades')}
|
||||
description={t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}
|
||||
/>
|
||||
<AddonSummaryList addons={event.addons} t={(key, fallback) => t(key, fallback)} />
|
||||
</SectionCard>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="recap" className="space-y-6">
|
||||
<GalleryShareCard invites={toolkitData?.invites} onManageInvites={() => navigate(`${ADMIN_EVENT_INVITES_PATH(event.slug)}?tab=layout`)} />
|
||||
<FeedbackCard slug={event.slug} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
) : (
|
||||
<SectionCard>
|
||||
@@ -764,6 +836,238 @@ function TaskRow({ task }: { task: EventToolkitTask }) {
|
||||
);
|
||||
}
|
||||
|
||||
function BrandingMissionCard({
|
||||
event,
|
||||
invites,
|
||||
emotions,
|
||||
onOpenBranding,
|
||||
onOpenCollections,
|
||||
onOpenTasks,
|
||||
onOpenEmotions,
|
||||
}: {
|
||||
event: TenantEvent;
|
||||
invites?: EventToolkit['invites'];
|
||||
emotions?: TenantEmotion[];
|
||||
onOpenBranding: () => void;
|
||||
onOpenCollections: () => void;
|
||||
onOpenTasks: () => void;
|
||||
onOpenEmotions: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
|
||||
const palette = extractBrandingPalette(event.settings);
|
||||
const activeInvites = invites?.summary.active ?? 0;
|
||||
const eventTypeId = event.event_type_id ?? event.event_type?.id ?? null;
|
||||
const spotlightEmotions = React.useMemo(
|
||||
() => filterEmotionsByEventType(emotions ?? [], eventTypeId).slice(0, 4),
|
||||
[emotions, eventTypeId],
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionCard className="space-y-4">
|
||||
<SectionHeader
|
||||
eyebrow={t('events.branding.badge', 'Branding & Story')}
|
||||
title={t('events.branding.title', 'Branding & Mission Packs')}
|
||||
description={t('events.branding.subtitle', 'Stimme Farben, Schriftarten und Aufgabenpakete aufeinander ab.')}
|
||||
/>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-indigo-100 bg-indigo-50/80 p-4 text-sm text-indigo-900 shadow-inner shadow-indigo-100 dark:border-indigo-300/40 dark:bg-indigo-500/10 dark:text-indigo-100">
|
||||
<p className="text-xs uppercase tracking-[0.3em]">{t('events.branding.brandingTitle', 'Branding')}</p>
|
||||
<p className="mt-1 text-base font-semibold">{palette.font ?? t('events.branding.brandingFallback', 'Aktuelle Auswahl')}</p>
|
||||
<p className="text-xs text-indigo-900/70 dark:text-indigo-100/80">
|
||||
{t('events.branding.brandingCopy', 'Passe Farben & Schriftarten im Layout-Editor an.')}
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
{(palette.colors.length ? palette.colors : ['#f472b6', '#fef3c7', '#312e81']).map((color) => (
|
||||
<span
|
||||
key={color}
|
||||
className="h-10 w-10 rounded-xl border border-white/70 shadow"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button size="sm" variant="secondary" className="mt-4 rounded-full bg-white/80 text-indigo-900 hover:bg-white" onClick={onOpenBranding}>
|
||||
{t('events.branding.brandingCta', 'Branding anpassen')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-rose-100 bg-rose-50/80 p-4 text-sm text-rose-900 shadow-inner shadow-rose-100 dark:border-rose-300/40 dark:bg-rose-500/10 dark:text-rose-100">
|
||||
<p className="text-xs uppercase tracking-[0.3em]">{t('events.branding.collectionsTitle', 'Mission Packs')}</p>
|
||||
<p className="mt-1 text-base font-semibold">
|
||||
{event.event_type?.name ?? t('events.branding.collectionsFallback', 'Empfohlene Story')}
|
||||
</p>
|
||||
<p className="text-xs text-rose-900/70 dark:text-rose-100/80">
|
||||
{t('events.branding.collectionsCopy', 'Importiere passende Kollektionen oder aktiviere Emotionen im Aufgabenbereich.')}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs">
|
||||
<Badge variant="outline" className="border-rose-200 text-rose-700 dark:border-rose-300/40 dark:text-rose-100">
|
||||
{t('events.branding.collectionsActive', { defaultValue: '{{count}} aktive Links', count: activeInvites })}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-rose-200 text-rose-700 dark:border-rose-300/40 dark:text-rose-100">
|
||||
{t('events.branding.tasksCount', {
|
||||
defaultValue: '{{count}} Aufgaben',
|
||||
count: Number(event.tasks_count ?? 0),
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-4 rounded-xl border border-rose-100/80 bg-white/70 p-3 text-xs text-rose-900/80">
|
||||
<p className="text-[10px] uppercase tracking-[0.3em] text-rose-400">
|
||||
{t('events.branding.emotionsTitle', 'Emotionen')}
|
||||
</p>
|
||||
{spotlightEmotions.length ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{spotlightEmotions.map((emotion) => (
|
||||
<span
|
||||
key={emotion.id}
|
||||
className="flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold shadow-sm"
|
||||
style={{
|
||||
backgroundColor: `${emotion.color ?? '#fecdd3'}33`,
|
||||
color: emotion.color ?? '#be123c',
|
||||
}}
|
||||
>
|
||||
{emotion.icon ? <span>{emotion.icon}</span> : null}
|
||||
{emotion.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-xs text-rose-900/70">
|
||||
{t('events.branding.emotionsEmpty', 'Aktiviere Emotionen, um Aufgaben zu kategorisieren.')}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="mt-3 h-8 px-0 text-rose-700 hover:bg-rose-100/70"
|
||||
onClick={onOpenEmotions}
|
||||
>
|
||||
{t('events.branding.emotionsCta', 'Emotionen verwalten')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" className="border-rose-200 text-rose-700 hover:bg-rose-100" onClick={onOpenTasks}>
|
||||
{t('events.branding.collectionsManage', 'Aufgaben bearbeiten')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="text-rose-700 hover:bg-rose-100/80" onClick={onOpenCollections}>
|
||||
{t('events.branding.collectionsImport', 'Mission Pack importieren')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function GalleryShareCard({
|
||||
invites,
|
||||
onManageInvites,
|
||||
}: {
|
||||
invites?: EventToolkit['invites'];
|
||||
onManageInvites: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const primaryInvite = React.useMemo(
|
||||
() => invites?.items?.find((invite) => invite.is_active) ?? invites?.items?.[0] ?? null,
|
||||
[invites?.items],
|
||||
);
|
||||
|
||||
const handleCopy = React.useCallback(async () => {
|
||||
if (!primaryInvite?.url) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(primaryInvite.url);
|
||||
toast.success(t('events.galleryShare.copied', 'Link kopiert'));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error(t('events.galleryShare.copyFailed', 'Konnte Link nicht kopieren'));
|
||||
}
|
||||
}, [primaryInvite, t]);
|
||||
|
||||
if (!primaryInvite) {
|
||||
return (
|
||||
<SectionCard className="space-y-3">
|
||||
<SectionHeader
|
||||
eyebrow={t('events.galleryShare.badge', 'Galerie')}
|
||||
title={t('events.galleryShare.title', 'Galerie teilen')}
|
||||
description={t('events.galleryShare.emptyDescription', 'Erstelle einen Einladungslink, um Fotos zu teilen.')}
|
||||
/>
|
||||
<Button onClick={onManageInvites} className="w-full rounded-full bg-brand-rose text-white shadow-md shadow-rose-300/40">
|
||||
{t('events.galleryShare.createInvite', 'Einladung erstellen')}
|
||||
</Button>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard className="space-y-3">
|
||||
<SectionHeader
|
||||
eyebrow={t('events.galleryShare.badge', 'Galerie')}
|
||||
title={t('events.galleryShare.title', 'Galerie-Link & QR')}
|
||||
description={t('events.galleryShare.description', 'Teile den Link nach dem Event oder lade QR-Karten herunter.')}
|
||||
/>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white/90 p-4 text-sm text-slate-700 dark:border-white/10 dark:bg-white/5 dark:text-slate-200">
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-slate-500 dark:text-slate-400">
|
||||
{primaryInvite.label ?? t('events.galleryShare.linkLabel', 'Standard-Link')}
|
||||
</p>
|
||||
<p className="mt-2 truncate text-base font-semibold text-slate-900 dark:text-white">{primaryInvite.url}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs">
|
||||
<Badge variant="outline" className="border-slate-200 text-slate-600 dark:border-white/20 dark:text-white">
|
||||
{t('events.galleryShare.scans', { defaultValue: '{{count}} Aufrufe', count: primaryInvite.usage_count })}
|
||||
</Badge>
|
||||
{typeof primaryInvite.usage_limit === 'number' && (
|
||||
<Badge variant="outline" className="border-slate-200 text-slate-600 dark:border-white/20 dark:text-white">
|
||||
{t('events.galleryShare.limit', { defaultValue: 'Limit {{count}}', count: primaryInvite.usage_limit })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button size="sm" className="rounded-full bg-brand-rose px-4 text-white shadow-rose-400/40" onClick={handleCopy}>
|
||||
{t('events.galleryShare.copy', 'Link kopieren')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onManageInvites}>
|
||||
{t('events.galleryShare.manage', 'Layouts & QR öffnen')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function extractBrandingPalette(
|
||||
settings: TenantEvent['settings'],
|
||||
): { colors: string[]; font?: string } {
|
||||
const colors: string[] = [];
|
||||
let font: string | undefined;
|
||||
|
||||
if (settings && typeof settings === 'object') {
|
||||
const brandingSource =
|
||||
(settings as Record<string, unknown>).branding && typeof (settings as Record<string, unknown>).branding === 'object'
|
||||
? (settings as Record<string, unknown>).branding
|
||||
: settings;
|
||||
|
||||
const candidateKeys = ['primary_color', 'secondary_color', 'accent_color', 'background_color', 'color'];
|
||||
candidateKeys.forEach((key) => {
|
||||
const value = (brandingSource as Record<string, unknown>)[key];
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
colors.push(value);
|
||||
}
|
||||
});
|
||||
|
||||
const fontKeys = ['font_family', 'font', 'heading_font'];
|
||||
fontKeys.some((key) => {
|
||||
const value = (brandingSource as Record<string, unknown>)[key];
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
font = value;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
return { colors, font };
|
||||
}
|
||||
|
||||
function PendingPhotosCard({
|
||||
slug,
|
||||
photos,
|
||||
@@ -802,6 +1106,23 @@ function PendingPhotosCard({
|
||||
}
|
||||
};
|
||||
|
||||
const handleFeature = async (photo: TenantPhoto, feature: boolean) => {
|
||||
setUpdatingId(photo.id);
|
||||
try {
|
||||
const updated = feature ? await featurePhoto(slug, photo.id) : await unfeaturePhoto(slug, photo.id);
|
||||
setEntries((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
|
||||
toast.success(
|
||||
feature
|
||||
? t('events.photos.toastFeatured', 'Foto als Highlight markiert.')
|
||||
: t('events.photos.toastUnfeatured', 'Highlight entfernt.'),
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('events.photos.errorFeature', 'Aktion fehlgeschlagen.')));
|
||||
} finally {
|
||||
setUpdatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionCard className="space-y-3">
|
||||
<SectionHeader
|
||||
@@ -816,26 +1137,49 @@ function PendingPhotosCard({
|
||||
/>
|
||||
<div className="space-y-3 text-sm text-slate-700 dark:text-slate-300">
|
||||
{entries.length ? (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{entries.slice(0, 6).map((photo) => {
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{entries.slice(0, 4).map((photo) => {
|
||||
const hidden = photo.status === 'hidden';
|
||||
return (
|
||||
<div key={photo.id} className="relative">
|
||||
<img
|
||||
src={photo.thumbnail_url ?? photo.url ?? undefined}
|
||||
alt={photo.caption ?? 'Foto'}
|
||||
className={`h-24 w-full rounded-lg object-cover ${hidden ? 'opacity-60' : ''}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleVisibility(photo, hidden)}
|
||||
disabled={updatingId === photo.id}
|
||||
className="absolute inset-x-2 bottom-2 rounded-full bg-white/90 px-2 py-1 text-[11px] font-semibold text-slate-700 shadow disabled:opacity-60"
|
||||
>
|
||||
{hidden
|
||||
? t('events.photos.show', 'Einblenden')
|
||||
: t('events.photos.hide', 'Ausblenden')}
|
||||
</button>
|
||||
<div key={photo.id} className="rounded-xl border border-slate-200 bg-white/90 p-2">
|
||||
<div className="relative overflow-hidden rounded-lg">
|
||||
<img
|
||||
src={photo.thumbnail_url ?? photo.url ?? undefined}
|
||||
alt={photo.caption ?? 'Foto'}
|
||||
className={`h-32 w-full object-cover ${hidden ? 'opacity-60' : ''}`}
|
||||
/>
|
||||
{photo.is_featured ? (
|
||||
<span className="absolute left-2 top-2 rounded-full bg-pink-500/90 px-2 py-0.5 text-[10px] font-semibold text-white">
|
||||
Highlight
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-[11px] text-slate-500">
|
||||
<Badge variant="outline">{photo.uploader_name ?? 'Gast'}</Badge>
|
||||
<Badge variant="outline">♥ {photo.likes_count}</Badge>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={updatingId === photo.id}
|
||||
onClick={() => handleVisibility(photo, hidden)}
|
||||
>
|
||||
{hidden
|
||||
? t('events.photos.show', 'Einblenden')
|
||||
: t('events.photos.hide', 'Verstecken')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={photo.is_featured ? 'secondary' : 'outline'}
|
||||
disabled={updatingId === photo.id}
|
||||
onClick={() => handleFeature(photo, !photo.is_featured)}
|
||||
>
|
||||
{photo.is_featured
|
||||
? t('events.photos.unfeature', 'Highlight entfernen')
|
||||
: t('events.photos.feature', 'Als Highlight markieren')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -866,11 +1210,6 @@ function RecentUploadsCard({ slug, photos }: { slug: string; photos: TenantPhoto
|
||||
try {
|
||||
const updated = await updatePhotoVisibility(slug, photo.id, visible);
|
||||
setEntries((prev) => prev.map((item) => (item.id === photo.id ? updated : item)));
|
||||
toast.success(
|
||||
visible
|
||||
? t('events.photos.toastVisible', 'Foto wieder sichtbar gemacht.')
|
||||
: t('events.photos.toastHidden', 'Foto ausgeblendet.'),
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
isAuthError(err)
|
||||
@@ -891,26 +1230,40 @@ function RecentUploadsCard({ slug, photos }: { slug: string; photos: TenantPhoto
|
||||
/>
|
||||
<div className="space-y-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
{entries.length ? (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{entries.slice(0, 6).map((photo) => {
|
||||
const hidden = photo.status === 'hidden';
|
||||
return (
|
||||
<div key={photo.id} className="relative">
|
||||
<img
|
||||
src={photo.thumbnail_url ?? photo.url ?? undefined}
|
||||
alt={photo.caption ?? 'Foto'}
|
||||
className={`h-24 w-full rounded-lg object-cover ${hidden ? 'opacity-60' : ''}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleVisibility(photo, hidden)}
|
||||
disabled={updatingId === photo.id}
|
||||
className="absolute inset-x-2 bottom-2 rounded-full bg-white/90 px-2 py-1 text-[11px] font-semibold text-slate-700 shadow disabled:opacity-60"
|
||||
>
|
||||
{hidden
|
||||
? t('events.photos.show', 'Einblenden')
|
||||
: t('events.photos.hide', 'Ausblenden')}
|
||||
</button>
|
||||
<div key={photo.id} className="rounded-xl border border-slate-200 bg-white/90 p-2">
|
||||
<div className="relative overflow-hidden rounded-lg">
|
||||
<img
|
||||
src={photo.thumbnail_url ?? photo.url ?? undefined}
|
||||
alt={photo.caption ?? 'Foto'}
|
||||
className={`h-28 w-full object-cover ${hidden ? 'opacity-60' : ''}`}
|
||||
/>
|
||||
{photo.is_featured ? (
|
||||
<span className="absolute left-2 top-2 rounded-full bg-pink-500/90 px-2 py-0.5 text-[10px] font-semibold text-white">
|
||||
Highlight
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-[11px] text-slate-500">
|
||||
<Badge variant="outline">♥ {photo.likes_count}</Badge>
|
||||
<Badge variant="outline">{photo.uploader_name ?? 'Gast'}</Badge>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||||
<Button size="sm" variant="outline" disabled={updatingId === photo.id} onClick={() => handleVisibility(photo, hidden)}>
|
||||
{hidden ? t('events.photos.show', 'Einblenden') : t('events.photos.hide', 'Verstecken')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={photo.is_featured ? 'secondary' : 'outline'}
|
||||
disabled={updatingId === photo.id}
|
||||
onClick={() => handleFeature(photo, !photo.is_featured)}
|
||||
>
|
||||
{photo.is_featured ? t('events.photos.unfeature', 'Highlight entfernen') : t('events.photos.feature', 'Highlight')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user