behoben: ohne aufgabe kann die kamera nicht gestartet werden (offensichtlich kein fehler mit browserzugriff auf kamera!)

platz zu begrenzt im aufnahmemodus - vollbildmodus möglich? Menü und Kopfleiste ausblenden?
	Bild aus eigener galerie auswählen - Upload schlägt fehl (zu groß? evtl fehlende Rechte - aber browser hat rechte auf bilder und dateien!)
	hochgeladene bilder tauchen in der galerie nicht beim filter "Meine Bilder" auf - fotos werden auch nicht gezählt in den stats und achievements zeigen keinen fortschriftt.
	geteilte fotos: ruft man den Link auf, bekommt man die meldung "Link abgelaufen"
	der im startbildschirm gewählte name mit Umlauten (Sören) ist nach erneutem aufruf der pwa ohne umlaut (Sren).
Aufgabenseite verbessert (Zwischenstand)
This commit is contained in:
Codex Agent
2025-12-04 11:58:07 +01:00
parent 899e742c38
commit c73a3163c0
15 changed files with 776 additions and 610 deletions

View File

@@ -630,14 +630,22 @@ class EventPublicController extends BaseController
{ {
$guestNameParam = trim((string) $request->query('guest_name', '')); $guestNameParam = trim((string) $request->query('guest_name', ''));
$deviceIdHeader = (string) $request->headers->get('X-Device-Id', ''); $deviceIdHeader = (string) $request->headers->get('X-Device-Id', '');
$deviceId = substr(preg_replace('/[^A-Za-z0-9 _\-]/', '', $deviceIdHeader), 0, 120); $candidate = $guestNameParam !== '' ? $guestNameParam : $deviceIdHeader;
$candidate = $guestNameParam !== '' ? $guestNameParam : $deviceId; $normalized = $this->normalizeGuestIdentifier($candidate);
if ($candidate === '') { if ($normalized === '') {
return null; return null;
} }
return substr(preg_replace('/[^A-Za-z0-9 _\-]/', '', $candidate), 0, 120); return $normalized;
}
private function normalizeGuestIdentifier(string $value): string
{
$cleaned = preg_replace('/[^\p{L}\p{N}\s_\-]/u', '', $value) ?? '';
$trimmed = trim(mb_substr($cleaned, 0, 120));
return $trimmed;
} }
private function buildAchievementsPayload(int $eventId, ?string $guestIdentifier, array $fallbacks): array private function buildAchievementsPayload(int $eventId, ?string $guestIdentifier, array $fallbacks): array
@@ -1380,7 +1388,7 @@ class EventPublicController extends BaseController
$photo = $shareLink->photo; $photo = $shareLink->photo;
$event = $photo->event; $event = $photo->event;
if (! $event || $photo->status !== 'approved') { if (! $event || in_array($photo->status, ['hidden', 'rejected'], true)) {
return ApiError::response( return ApiError::response(
'photo_not_shareable', 'photo_not_shareable',
'Photo Not Shareable', 'Photo Not Shareable',
@@ -1399,14 +1407,23 @@ class EventPublicController extends BaseController
} }
} }
$emotionName = null;
if ($photo->emotion) {
$emotionName = $this->translateLocalized($photo->emotion->name, app()->getLocale(), '');
if ($emotionName === '') {
$emotionName = is_string($photo->emotion->name) ? $photo->emotion->name : null;
}
}
$photoResource = [ $photoResource = [
'id' => $photo->id, 'id' => $photo->id,
'title' => $taskTitle, 'title' => $taskTitle,
'emotion' => $photo->emotion ? [ 'emotion' => $photo->emotion ? [
'name' => $photo->emotion->name, 'name' => $emotionName,
'emoji' => $photo->emotion->emoji, 'emoji' => $photo->emotion->emoji,
] : null, ] : null,
'likes_count' => $photo->likes()->count(), 'likes_count' => $photo->likes()->count(),
'created_at' => $photo->created_at?->toIso8601String(),
'image_urls' => [ 'image_urls' => [
'thumbnail' => $this->makeShareAssetUrl($shareLink, 'thumbnail'), 'thumbnail' => $this->makeShareAssetUrl($shareLink, 'thumbnail'),
'full' => $this->makeShareAssetUrl($shareLink, 'full'), 'full' => $this->makeShareAssetUrl($shareLink, 'full'),
@@ -1453,7 +1470,7 @@ class EventPublicController extends BaseController
$photo = $shareLink->photo; $photo = $shareLink->photo;
$event = $photo->event; $event = $photo->event;
if (! $event || $photo->status !== 'approved') { if (! $event || in_array($photo->status, ['hidden', 'rejected'], true)) {
return ApiError::response( return ApiError::response(
'photo_not_shareable', 'photo_not_shareable',
'Photo Not Shareable', 'Photo Not Shareable',
@@ -2165,8 +2182,7 @@ class EventPublicController extends BaseController
private function resolveDeviceIdentifier(Request $request): string private function resolveDeviceIdentifier(Request $request): string
{ {
$deviceId = (string) $request->headers->get('X-Device-Id', ''); $deviceId = (string) $request->headers->get('X-Device-Id', '');
$normalized = preg_replace('/[^A-Za-z0-9 _\-]/', '', $deviceId) ?? ''; $normalized = $this->normalizeGuestIdentifier($deviceId);
$normalized = trim(substr($normalized, 0, 120));
return $normalized !== '' ? $normalized : 'anonymous'; return $normalized !== '' ? $normalized : 'anonymous';
} }
@@ -2634,6 +2650,7 @@ class EventPublicController extends BaseController
'thumbnail_path' => $thumbUrl, 'thumbnail_path' => $thumbUrl,
'likes_count' => 0, 'likes_count' => 0,
'ingest_source' => Photo::SOURCE_GUEST_PWA, 'ingest_source' => Photo::SOURCE_GUEST_PWA,
'status' => 'approved',
// Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default // Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default
'emotion_id' => $this->resolveEmotionId($validated, $eventId), 'emotion_id' => $this->resolveEmotionId($validated, $eventId),

View File

@@ -244,7 +244,7 @@ class PhotoController extends Controller
'thumbnail_path' => $watermarkedThumb, 'thumbnail_path' => $watermarkedThumb,
'width' => null, // Filled below 'width' => null, // Filled below
'height' => null, 'height' => null,
'status' => 'pending', // Requires moderation 'status' => 'approved',
'uploader_id' => null, 'uploader_id' => null,
'ip_address' => $request->ip(), 'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(), 'user_agent' => $request->userAgent(),

View File

@@ -537,3 +537,13 @@ h4,
background-size: 400% 400%, 400% 400%; background-size: 400% 400%, 400% 400%;
animation: aurora 20s ease infinite; animation: aurora 20s ease infinite;
} }
.guest-immersive .guest-header,
.guest-immersive .guest-bottom-nav {
display: none !important;
}
.guest-immersive {
overscroll-behavior: contain;
background-color: #000;
}

View File

@@ -58,7 +58,7 @@ export function DashboardEventFocusCard({
{t('empty.title', 'Leg mit deinem ersten Event los')} {t('empty.title', 'Leg mit deinem ersten Event los')}
</CardTitle> </CardTitle>
<CardDescription className="text-sm text-slate-600 dark:text-slate-300"> <CardDescription className="text-sm text-slate-600 dark:text-slate-300">
{t('empty.description', 'Importiere ein Mission Pack, lege Branding fest und teile sofort den Gästelink.')} {t('empty.description', 'Importiere ein Aufgaben-Set, lege Branding fest und teile sofort den Gästelink.')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -117,7 +117,7 @@ export function DashboardEventFocusCard({
}, },
{ {
key: 'tasks', key: 'tasks',
label: t('actions.tasks', 'Mission Packs & Emotionen'), label: t('actions.tasks', 'Aufgaben-Sets & Emotionen'),
description: t('actions.tasksHint', 'Kollektionen importieren und Emotionen aktivieren.'), description: t('actions.tasksHint', 'Kollektionen importieren und Emotionen aktivieren.'),
icon: ClipboardList, icon: ClipboardList,
handler: onOpenTasks, handler: onOpenTasks,

View File

@@ -1,4 +1,4 @@
{ {
"billing": { "billing": {
"title": "Pakete & Abrechnung", "title": "Pakete & Abrechnung",
"subtitle": "Verwalte deine gebuchten Pakete und behalte Laufzeiten im Blick.", "subtitle": "Verwalte deine gebuchten Pakete und behalte Laufzeiten im Blick.",
@@ -146,8 +146,8 @@
} }
}, },
"billingWarning": { "billingWarning": {
"title": "Handlungsbedarf", "title": "Achtung",
"description": "Paketwarnungen und Limits, die du im Blick behalten solltest." "description": "Paket-Hinweise und Limits, die du im Blick behalten solltest."
}, },
"photos": { "photos": {
"moderation": { "moderation": {
@@ -191,21 +191,252 @@
} }
}, },
"events": { "events": {
"list": { "errors": {
"title": "Deine Events", "missingSlug": "Kein Event ausgewählt.",
"subtitle": "Plane Momente, die in Erinnerung bleiben. Hier verwaltest du alles rund um deine Veranstaltungen.", "loadFailed": "Event konnte nicht geladen werden.",
"actions": { "notFoundTitle": "Event nicht gefunden",
"create": "Neues Event", "notFoundBody": "Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.",
"settings": "Einstellungen" "toggleFailed": "Status konnte nicht angepasst werden.",
"checkoutMissing": "Checkout konnte nicht gestartet werden.",
"checkoutFailed": "Add-on Checkout fehlgeschlagen."
},
"alerts": {
"failedTitle": "Aktion fehlgeschlagen"
},
"success": {
"addonApplied": "Add-on angewendet. Limits aktualisieren sich in Kürze."
},
"placeholders": {
"untitled": "Unbenanntes Event"
},
"actions": {
"backToList": "Zurück zur Liste",
"edit": "Bearbeiten",
"members": "Team & Rollen",
"tasks": "Aufgaben verwalten",
"invites": "QR-Codes & Layouts",
"photos": "Fotos moderieren",
"refresh": "Aktualisieren",
"buyMorePhotos": "Mehr Fotos freischalten",
"buyMoreGuests": "Mehr Gäste freischalten",
"extendGallery": "Galerie verlängern"
},
"workspace": {
"detailSubtitle": "Behalte Status, Aufgaben und QR-Codes deines Events im Blick.",
"toolkitSubtitle": "Moderation, Aufgaben und QR-Codes für deinen Eventtag bündeln.",
"hero": {
"badge": "Event",
"description": "Konzentriere dich auf Aufgaben, Moderation und QR-Codes für dieses Event.",
"liveBadge": "Live?"
}, },
"overview": { "sections": {
"title": "Übersicht", "statusTitle": "Eventstatus & Sichtbarkeit",
"empty": "Noch keine Events - starte jetzt und lege dein erstes Event an.", "statusSubtitle": "Aktiviere dein Event für Gäste oder verstecke es vorübergehend."
"count": "{{count}} {{count, plural, one {Event} other {Events}}} aktiv verwaltet.", },
"badge": { "fields": {
"dashboard": "Kunden-Dashboard" "status": "Status",
"active": "Aktiv für Gäste",
"date": "Eventdatum",
"noDate": "Kein Datum",
"eventType": "Event-Typ",
"insights": "Letzte Aktivität",
"uploadsTotal": "{{count}} Uploads gesamt",
"uploadsToday": "{{count}} Uploads (24h)",
"likesTotal": "{{count}} Likes vergeben"
},
"actions": {
"pause": "Event pausieren",
"activate": "Event aktivieren"
},
"activeYes": "Ja",
"activeNo": "Nein"
},
"sections": {
"addons": {
"title": "Add-ons & Upgrades",
"description": "Zuletzt gebuchte Add-ons für dieses Event.",
"status": {
"completed": "Aktiv",
"pending": "In Bearbeitung",
"failed": "Fehlgeschlagen"
},
"purchasedAt": "Gekauft {{date}}",
"summary": {
"photos": "+{{count}} Fotos",
"guests": "+{{count}} Gäste",
"gallery": "+{{count}} Tage Galerie"
} }
} }
},
"status": {
"published": "Veröffentlicht",
"draft": "Entwurf",
"archived": "Archiviert"
},
"quickActions": {
"title": "Schnellaktionen",
"subtitle": "Nutze die wichtigsten Schritte vor und während deines Events.",
"moderate": "Fotos moderieren",
"tasks": "Aufgaben bearbeiten",
"invites": "Layouts & QR verwalten",
"roles": "Team & Rollen anpassen",
"print": "Layouts als PDF drucken",
"toggle": "Status ändern"
},
"metrics": {
"uploadsTotal": "Uploads gesamt",
"uploads24h": "Uploads (24h)",
"pending": "Fotos in Moderation",
"activeInvites": "Aktive QR-Codes"
},
"invites": {
"badge": "QR-Codes",
"title": "QR-Codes",
"subtitle": "Behält aktive QR-Codes und Layouts im Blick.",
"activeCount": "{{count}} aktiv",
"totalCount": "{{count}} gesamt",
"empty": "Noch keine QR-Codes erstellt.",
"manage": "Layouts & QR-Codes verwalten"
},
"tasks": {
"badge": "Aufgaben",
"title": "Aktive Aufgaben",
"subtitle": "Motiviere Gäste mit klaren Aufgaben & Highlights.",
"summary": "{{completed}} von {{total}} erledigt",
"empty": "Noch keine Aufgaben zugewiesen.",
"manage": "Aufgabenbereich öffnen",
"status": {
"completed": "Erledigt",
"open": "Offen"
}
},
"recap": {
"badge": "Nachbereitung",
"subtitle": "Abschluss, Export und Galerie-Laufzeit verwalten.",
"galleryTitle": "Galerie-Status",
"galleryCounts": "{{photos}} Fotos, {{pending}} offen, {{likes}} Likes",
"open": "Offen",
"closed": "Geschlossen",
"openGallery": "Galerie öffnen",
"closeGallery": "Galerie schließen",
"moderate": "Uploads ansehen",
"shareGuests": "Gäste-Galerie teilen",
"shareLink": "Gäste-Link",
"noPublicUrl": "Kein Gäste-Link gesetzt. Lege den öffentlichen Link im Event-Setup fest.",
"copyLink": "Link kopieren",
"copySuccess": "Link kopiert",
"copyError": "Link konnte nicht geteilt werden.",
"qrTitle": "QR-Code teilen",
"qrDownload": "QR-Code herunterladen",
"qrShare": "Link/QR teilen",
"qrAlt": "QR-Code zur Gäste-Galerie",
"allowDownloads": "Downloads erlauben",
"allowDownloadsHint": "Gäste dürfen Fotos speichern",
"allowSharing": "Teilen erlauben",
"allowSharingHint": "Gäste dürfen Links teilen",
"galleryOpen": "Galerie geöffnet",
"galleryClosed": "Galerie geschlossen",
"exportTitle": "Export & Backup",
"exportCopy": "Alle Assets sichern",
"exportHint": "Zip/CSV Export und Backup anstoßen.",
"backup": "Backup",
"downloadAll": "Alles herunterladen",
"downloadHighlights": "Highlights herunterladen",
"highlightsHint": "„Highlights“ = als Highlight markierte Fotos in der Galerie.",
"retentionTitle": "Verlängerung / Archivierung",
"expiresAt": "Läuft ab am {{date}}",
"noExpiry": "Ablaufdatum nicht gesetzt",
"retentionHint": "Verlängere die Galerie-Laufzeit mit einem Add-on. Verlängerungen addieren sich.",
"expiry": "Ablauf",
"archive": "Archivieren/Löschen",
"extendOptions": "Alle Add-ons für dieses Event",
"extendHint": "Verlängerungen addieren sich. Checkout öffnet in einem neuen Tab.",
"priceMissing": "Preis nicht verknüpft",
"noAddons": "Aktuell keine Add-ons verfügbar.",
"archivedSuccess": "Event archiviert. Galerie ist geschlossen.",
"archiveTitle": "Galerie archivieren?",
"archiveDesc": "Das Archivieren schließt die Galerie, deaktiviert Gäste-Links und stoppt neue Uploads. Exporte vorher abschließen.",
"archiveImpact": "Auswirkungen des Archivierens",
"archiveImpactClose": "Gäste-Zugriff endet; Uploads/Downloads werden deaktiviert.",
"archiveImpactLinks": "Öffentliche Links und QR-Codes werden ungültig; Sessions laufen aus.",
"archiveImpactData": "Daten bleiben intern für Compliance/Support und können auf Anfrage gelöscht werden (DSGVO).",
"archiveConfirm": "Ich habe Exporte abgeschlossen und möchte jetzt archivieren.",
"archiveConfirmCta": "Jetzt archivieren"
},
"branding": {
"badge": "Branding & Story",
"title": "Branding & Vorlagen / Aufgaben-Bundles",
"subtitle": "Stimme Farben, Schriftarten und Aufgabenpakete aufeinander ab.",
"brandingTitle": "Branding",
"brandingFallback": "Aktuelle Auswahl",
"brandingCopy": "Passe Farben & Schriftarten im Layout-Editor an.",
"brandingCta": "Branding anpassen",
"collectionsTitle": "Vorlagen / Aufgaben-Bundles",
"collectionsFallback": "Empfohlene Story",
"collectionsCopy": "Importiere passende Kollektionen oder aktiviere Emotionen im Aufgabenbereich.",
"collectionsActive": "{{count}} aktive Links",
"tasksCount": "{{count}} Aufgaben",
"collectionsManage": "Aufgaben bearbeiten",
"collectionsImport": "Aufgaben-Set importieren",
"emotionsTitle": "Emotionen",
"emotionsEmpty": "Aktiviere Emotionen, um Aufgaben zu kategorisieren.",
"emotionsCta": "Emotionen verwalten"
},
"photos": {
"pendingBadge": "Moderation",
"pendingTitle": "Fotos in Moderation",
"pendingSubtitle": "Schnell prüfen, bevor Gäste live gehen.",
"pendingCount": "{{count}} Fotos offen",
"pendingEmpty": "Aktuell warten keine Fotos auf Freigabe.",
"openModeration": "Moderation öffnen",
"recentBadge": "Uploads",
"recentTitle": "Neueste Uploads",
"recentSubtitle": "Halte Ausschau nach Highlight-Momenten der Gäste.",
"recentEmpty": "Noch keine neuen Uploads.",
"toastVisible": "Foto wieder sichtbar gemacht.",
"toastHidden": "Foto ausgeblendet.",
"toastFeatured": "Foto als Highlight markiert.",
"toastUnfeatured": "Highlight entfernt.",
"errorAuth": "Session abgelaufen. Bitte erneut anmelden.",
"errorVisibility": "Sichtbarkeit konnte nicht geändert werden.",
"errorFeature": "Aktion fehlgeschlagen.",
"show": "Einblenden",
"hide": "Verstecken",
"feature": "Als Highlight markieren",
"unfeature": "Highlight entfernen"
},
"feedback": {
"title": "Wie läuft dein Event?",
"subtitle": "Feedback hilft uns, neue Features zu priorisieren.",
"afterEventTitle": "Event beendet kurzes Feedback?",
"afterEventCopy": "Hat alles geklappt? Deine Antwort hilft uns für kommende Events.",
"privacyHint": "Nur Admin-Feedback, keine Gastdaten",
"positive": "War super",
"neutral": "In Ordnung",
"negative": "Brauch(t)e Unterstützung",
"best": {
"uploads": "Uploads & Geschwindigkeit",
"invites": "QR-Codes & Layouts",
"moderation": "Moderation & Export",
"experience": "Allgemeine App-Erfahrung"
},
"placeholder": "Optional: Lass uns wissen, was gut funktioniert oder wo du Unterstützung brauchst.",
"errorTitle": "Feedback konnte nicht gesendet werden.",
"authError": "Deine Session ist abgelaufen. Bitte melde dich erneut an.",
"genericError": "Feedback konnte nicht gesendet werden.",
"submit": "Feedback senden",
"submitted": "Danke!",
"afterEventThanks": "Dein Feedback ist angekommen. Wir melden uns, falls Rückfragen bestehen.",
"sendAnother": "Weiteres Feedback senden",
"supportFollowup": "Support anfragen",
"cta": "Feedback geben",
"quickSentiment": "Stimmung auswählbar (positiv/neutral/Support).",
"dialogTitle": "Kurzes After-Event Feedback",
"dialogCopy": "Wähle eine Stimmung, was am besten lief und optional, was wir verbessern sollen.",
"sentiment": "Stimmung",
"bestQuestion": "Was lief am besten?",
"improve": "Was sollen wir verbessern?",
"supportHelp": "Ich hätte gern ein kurzes Follow-up (Support)."
} }
}, },
"galleryStatus": { "galleryStatus": {
@@ -311,7 +542,7 @@
}, },
"tabs": { "tabs": {
"tasks": "Aufgaben", "tasks": "Aufgaben",
"packs": "Mission Packs" "packs": "Vorlagen / Aufgaben-Bundles"
}, },
"eventStatus": "Status: {{status}}", "eventStatus": "Status: {{status}}",
"summary": { "summary": {
@@ -323,7 +554,7 @@
}, },
"library": { "library": {
"hintTitle": "Weitere Vorlagen in der Aufgaben-Bibliothek", "hintTitle": "Weitere Vorlagen in der Aufgaben-Bibliothek",
"hintCopy": "Lege Aufgaben, Emotionen oder Mission Packs zentral an und nutze sie in mehreren Events.", "hintCopy": "Lege Aufgaben, Emotionen oder Vorlagen / Aufgaben-Bundles zentral an und nutze sie in mehreren Events.",
"open": "Aufgaben-Bibliothek öffnen" "open": "Aufgaben-Bibliothek öffnen"
}, },
"sections": { "sections": {
@@ -354,7 +585,7 @@
"updating": "Einstellung wird gespeichert ..." "updating": "Einstellung wird gespeichert ..."
}, },
"collections": { "collections": {
"title": "Mission Packs", "title": "Vorlagen / Aufgaben-Bundles",
"subtitle": "Importiere Aufgaben-Kollektionen, die zu deinem Event passen.", "subtitle": "Importiere Aufgaben-Kollektionen, die zu deinem Event passen.",
"viewAll": "Alle Kollektionen ansehen", "viewAll": "Alle Kollektionen ansehen",
"errorTitle": "Kollektionen nicht verfügbar", "errorTitle": "Kollektionen nicht verfügbar",
@@ -365,9 +596,9 @@
"custom": "Custom", "custom": "Custom",
"recommended": "Empfohlen", "recommended": "Empfohlen",
"optional": "Optional", "optional": "Optional",
"importCta": "Mission Pack importieren", "importCta": "Aufgaben-Set importieren",
"imported": "Kollektion erfolgreich importiert", "imported": "Kollektion erfolgreich importiert",
"importFailed": "Mission Pack konnte nicht importiert werden", "importFailed": "Aufgaben-Set konnte nicht importiert werden",
"error": "Kollektionen konnten nicht geladen werden." "error": "Kollektionen konnten nicht geladen werden."
}, },
"toolkit": { "toolkit": {
@@ -449,15 +680,15 @@
}, },
"story": { "story": {
"title": "Branding & Story", "title": "Branding & Story",
"description": "Verbinde Farben, Emotionen und Mission Packs für ein stimmiges Gäste-Erlebnis.", "description": "Verbinde Farben, Emotionen und Vorlagen / Aufgaben-Bundles für ein stimmiges Gäste-Erlebnis.",
"emotionsTitle": "Emotionen", "emotionsTitle": "Emotionen",
"emotionsCount": "{{count}} aktiviert", "emotionsCount": "{{count}} aktiviert",
"emotionsEmpty": "Aktiviere Emotionen, um Aufgaben zu kategorisieren.", "emotionsEmpty": "Aktiviere Emotionen, um Aufgaben zu kategorisieren.",
"emotionsCta": "Emotionen verwalten", "emotionsCta": "Emotionen verwalten",
"collectionsTitle": "Mission Packs", "collectionsTitle": "Vorlagen / Aufgaben-Bundles",
"collectionsCount": "{{count}} Aufgaben", "collectionsCount": "{{count}} Aufgaben",
"collectionsEmpty": "Noch keine empfohlenen Mission Packs.", "collectionsEmpty": "Noch keine empfohlenen Vorlagen / Aufgaben-Bundles.",
"collectionsCta": "Mission Packs anzeigen" "collectionsCta": "Aufgaben-Sets anzeigen"
}, },
"customizer": { "customizer": {
"title": "QR-Code anpassen", "title": "QR-Code anpassen",
@@ -783,255 +1014,6 @@
"sourceEvent": "Quelle: Event" "sourceEvent": "Quelle: Event"
} }
}, },
"events": {
"errors": {
"missingSlug": "Kein Event ausgewählt.",
"loadFailed": "Event konnte nicht geladen werden.",
"notFoundTitle": "Event nicht gefunden",
"notFoundBody": "Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.",
"toggleFailed": "Status konnte nicht angepasst werden.",
"checkoutMissing": "Checkout konnte nicht gestartet werden.",
"checkoutFailed": "Add-on Checkout fehlgeschlagen."
},
"alerts": {
"failedTitle": "Aktion fehlgeschlagen"
},
"success": {
"addonApplied": "Add-on angewendet. Limits aktualisieren sich in Kürze."
},
"placeholders": {
"untitled": "Unbenanntes Event"
},
"actions": {
"backToList": "Zurück zur Liste",
"edit": "Bearbeiten",
"members": "Team & Rollen",
"tasks": "Aufgaben verwalten",
"invites": "QR-Codes & Layouts",
"photos": "Fotos moderieren",
"refresh": "Aktualisieren",
"buyMorePhotos": "Mehr Fotos freischalten",
"buyMoreGuests": "Mehr Gäste freischalten",
"extendGallery": "Galerie verlängern"
},
"workspace": {
"detailSubtitle": "Behalte Status, Aufgaben und QR-Codes deines Events im Blick.",
"toolkitSubtitle": "Moderation, Aufgaben und QR-Codes für deinen Eventtag bündeln.",
"hero": {
"badge": "Event",
"description": "Konzentriere dich auf Aufgaben, Moderation und QR-Codes für dieses Event.",
"liveBadge": "Live?"
},
"sections": {
"statusTitle": "Eventstatus & Sichtbarkeit",
"statusSubtitle": "Aktiviere dein Event für Gäste oder verstecke es vorübergehend."
},
"fields": {
"status": "Status",
"active": "Aktiv für Gäste",
"date": "Eventdatum",
"noDate": "Kein Datum",
"eventType": "Event-Typ",
"insights": "Letzte Aktivität",
"uploadsTotal": "{{count}} Uploads gesamt",
"uploadsToday": "{{count}} Uploads (24h)",
"likesTotal": "{{count}} Likes vergeben"
},
"actions": {
"pause": "Event pausieren",
"activate": "Event aktivieren"
},
"activeYes": "Ja",
"activeNo": "Nein"
},
"sections": {
"addons": {
"title": "Add-ons & Upgrades",
"description": "Zuletzt gebuchte Add-ons für dieses Event.",
"status": {
"completed": "Aktiv",
"pending": "In Bearbeitung",
"failed": "Fehlgeschlagen"
},
"purchasedAt": "Gekauft {{date}}",
"summary": {
"photos": "+{{count}} Fotos",
"guests": "+{{count}} Gäste",
"gallery": "+{{count}} Tage Galerie"
}
}
},
"status": {
"published": "Veröffentlicht",
"draft": "Entwurf",
"archived": "Archiviert"
},
"quickActions": {
"title": "Schnellaktionen",
"subtitle": "Nutze die wichtigsten Schritte vor und während deines Events.",
"moderate": "Fotos moderieren",
"tasks": "Aufgaben bearbeiten",
"invites": "Layouts & QR verwalten",
"roles": "Team & Rollen anpassen",
"print": "Layouts als PDF drucken",
"toggle": "Status ändern"
},
"metrics": {
"uploadsTotal": "Uploads gesamt",
"uploads24h": "Uploads (24h)",
"pending": "Fotos in Moderation",
"activeInvites": "Aktive QR-Codes"
},
"invites": {
"badge": "QR-Codes",
"title": "QR-Codes",
"subtitle": "Behält aktive QR-Codes und Layouts im Blick.",
"activeCount": "{{count}} aktiv",
"totalCount": "{{count}} gesamt",
"empty": "Noch keine QR-Codes erstellt.",
"manage": "Layouts & QR-Codes verwalten"
},
"tasks": {
"badge": "Aufgaben",
"title": "Aktive Aufgaben",
"subtitle": "Motiviere Gäste mit klaren Aufgaben & Highlights.",
"summary": "{{completed}} von {{total}} erledigt",
"empty": "Noch keine Aufgaben zugewiesen.",
"manage": "Aufgabenbereich öffnen",
"status": {
"completed": "Erledigt",
"open": "Offen"
}
},
"recap": {
"badge": "Nachbereitung",
"subtitle": "Abschluss, Export und Galerie-Laufzeit verwalten.",
"galleryTitle": "Galerie-Status",
"galleryCounts": "{{photos}} Fotos, {{pending}} offen, {{likes}} Likes",
"open": "Offen",
"closed": "Geschlossen",
"openGallery": "Galerie öffnen",
"closeGallery": "Galerie schließen",
"moderate": "Uploads ansehen",
"shareGuests": "Gäste-Galerie teilen",
"shareLink": "Gäste-Link",
"noPublicUrl": "Kein Gäste-Link gesetzt. Lege den öffentlichen Link im Event-Setup fest.",
"copyLink": "Link kopieren",
"copySuccess": "Link kopiert",
"copyError": "Link konnte nicht geteilt werden.",
"qrTitle": "QR-Code teilen",
"qrDownload": "QR-Code herunterladen",
"qrShare": "Link/QR teilen",
"qrAlt": "QR-Code zur Gäste-Galerie",
"allowDownloads": "Downloads erlauben",
"allowDownloadsHint": "Gäste dürfen Fotos speichern",
"allowSharing": "Teilen erlauben",
"allowSharingHint": "Gäste dürfen Links teilen",
"galleryOpen": "Galerie geöffnet",
"galleryClosed": "Galerie geschlossen",
"exportTitle": "Export & Backup",
"exportCopy": "Alle Assets sichern",
"exportHint": "Zip/CSV Export und Backup anstoßen.",
"backup": "Backup",
"downloadAll": "Alles herunterladen",
"downloadHighlights": "Highlights herunterladen",
"highlightsHint": "„Highlights“ = als Highlight markierte Fotos in der Galerie.",
"retentionTitle": "Verlängerung / Archivierung",
"expiresAt": "Läuft ab am {{date}}",
"noExpiry": "Ablaufdatum nicht gesetzt",
"retentionHint": "Verlängere die Galerie-Laufzeit mit einem Add-on. Verlängerungen addieren sich.",
"expiry": "Ablauf",
"archive": "Archivieren/Löschen",
"extendOptions": "Alle Add-ons für dieses Event",
"extendHint": "Verlängerungen addieren sich. Checkout öffnet in einem neuen Tab.",
"priceMissing": "Preis nicht verknüpft",
"noAddons": "Aktuell keine Add-ons verfügbar.",
"archivedSuccess": "Event archiviert. Galerie ist geschlossen.",
"archiveTitle": "Galerie archivieren?",
"archiveDesc": "Das Archivieren schließt die Galerie, deaktiviert Gäste-Links und stoppt neue Uploads. Exporte vorher abschließen.",
"archiveImpact": "Auswirkungen des Archivierens",
"archiveImpactClose": "Gäste-Zugriff endet; Uploads/Downloads werden deaktiviert.",
"archiveImpactLinks": "Öffentliche Links und QR-Codes werden ungültig; Sessions laufen aus.",
"archiveImpactData": "Daten bleiben intern für Compliance/Support und können auf Anfrage gelöscht werden (DSGVO).",
"archiveConfirm": "Ich habe Exporte abgeschlossen und möchte jetzt archivieren.",
"archiveConfirmCta": "Jetzt archivieren"
},
"branding": {
"badge": "Branding & Story",
"title": "Branding & Mission Packs",
"subtitle": "Stimme Farben, Schriftarten und Aufgabenpakete aufeinander ab.",
"brandingTitle": "Branding",
"brandingFallback": "Aktuelle Auswahl",
"brandingCopy": "Passe Farben & Schriftarten im Layout-Editor an.",
"brandingCta": "Branding anpassen",
"collectionsTitle": "Mission Packs",
"collectionsFallback": "Empfohlene Story",
"collectionsCopy": "Importiere passende Kollektionen oder aktiviere Emotionen im Aufgabenbereich.",
"collectionsActive": "{{count}} aktive Links",
"tasksCount": "{{count}} Aufgaben",
"collectionsManage": "Aufgaben bearbeiten",
"collectionsImport": "Mission Pack importieren",
"emotionsTitle": "Emotionen",
"emotionsEmpty": "Aktiviere Emotionen, um Aufgaben zu kategorisieren.",
"emotionsCta": "Emotionen verwalten"
},
"photos": {
"pendingBadge": "Moderation",
"pendingTitle": "Fotos in Moderation",
"pendingSubtitle": "Schnell prüfen, bevor Gäste live gehen.",
"pendingCount": "{{count}} Fotos offen",
"pendingEmpty": "Aktuell warten keine Fotos auf Freigabe.",
"openModeration": "Moderation öffnen",
"recentBadge": "Uploads",
"recentTitle": "Neueste Uploads",
"recentSubtitle": "Halte Ausschau nach Highlight-Momenten der Gäste.",
"recentEmpty": "Noch keine neuen Uploads.",
"toastVisible": "Foto wieder sichtbar gemacht.",
"toastHidden": "Foto ausgeblendet.",
"toastFeatured": "Foto als Highlight markiert.",
"toastUnfeatured": "Highlight entfernt.",
"errorAuth": "Session abgelaufen. Bitte erneut anmelden.",
"errorVisibility": "Sichtbarkeit konnte nicht geändert werden.",
"errorFeature": "Aktion fehlgeschlagen.",
"show": "Einblenden",
"hide": "Verstecken",
"feature": "Als Highlight markieren",
"unfeature": "Highlight entfernen"
},
"feedback": {
"title": "Wie läuft dein Event?",
"subtitle": "Feedback hilft uns, neue Features zu priorisieren.",
"afterEventTitle": "Event beendet kurzes Feedback?",
"afterEventCopy": "Hat alles geklappt? Deine Antwort hilft uns für kommende Events.",
"privacyHint": "Nur Admin-Feedback, keine Gastdaten",
"positive": "War super",
"neutral": "In Ordnung",
"negative": "Brauch(t)e Unterstützung",
"best": {
"uploads": "Uploads & Geschwindigkeit",
"invites": "QR-Codes & Layouts",
"moderation": "Moderation & Export",
"experience": "Allgemeine App-Erfahrung"
},
"placeholder": "Optional: Lass uns wissen, was gut funktioniert oder wo du Unterstützung brauchst.",
"errorTitle": "Feedback konnte nicht gesendet werden.",
"authError": "Deine Session ist abgelaufen. Bitte melde dich erneut an.",
"genericError": "Feedback konnte nicht gesendet werden.",
"submit": "Feedback senden",
"submitted": "Danke!",
"afterEventThanks": "Dein Feedback ist angekommen. Wir melden uns, falls Rückfragen bestehen.",
"sendAnother": "Weiteres Feedback senden",
"supportFollowup": "Support anfragen",
"cta": "Feedback geben",
"quickSentiment": "Stimmung auswählbar (positiv/neutral/Support).",
"dialogTitle": "Kurzes After-Event Feedback",
"dialogCopy": "Wähle eine Stimmung, was am besten lief und optional, was wir verbessern sollen.",
"sentiment": "Stimmung",
"bestQuestion": "Was lief am besten?",
"improve": "Was sollen wir verbessern?",
"supportHelp": "Ich hätte gern ein kurzes Follow-up (Support)."
}
},
"tasks": { "tasks": {
"actions": { "actions": {
"back": "Zurück zur Übersicht", "back": "Zurück zur Übersicht",
@@ -1045,7 +1027,7 @@
}, },
"tabs": { "tabs": {
"tasks": "Aufgaben", "tasks": "Aufgaben",
"packs": "Mission Packs" "packs": "Vorlagen / Aufgaben-Bundles"
}, },
"eventStatus": "Status: {{status}}", "eventStatus": "Status: {{status}}",
"modes": { "modes": {
@@ -1066,7 +1048,7 @@
}, },
"library": { "library": {
"hintTitle": "Weitere Vorlagen in der Aufgaben-Bibliothek", "hintTitle": "Weitere Vorlagen in der Aufgaben-Bibliothek",
"hintCopy": "Lege eigene Aufgaben, Emotionen oder Mission Packs zentral an und nutze sie in mehreren Events.", "hintCopy": "Lege eigene Aufgaben, Emotionen oder Vorlagen / Aufgaben-Bundles zentral an und nutze sie in mehreren Events.",
"open": "Aufgaben-Bibliothek öffnen" "open": "Aufgaben-Bibliothek öffnen"
}, },
"sections": { "sections": {
@@ -1096,7 +1078,7 @@
"errorTitle": "Kollektionen nicht verfügbar", "errorTitle": "Kollektionen nicht verfügbar",
"import": "Kollektion importieren", "import": "Kollektion importieren",
"error": "Kollektionen konnten nicht geladen werden.", "error": "Kollektionen konnten nicht geladen werden.",
"title": "Mission Packs", "title": "Vorlagen / Aufgaben-Bundles",
"subtitle": "Importiere Aufgaben-Kollektionen, die zu deinem Event passen.", "subtitle": "Importiere Aufgaben-Kollektionen, die zu deinem Event passen.",
"viewAll": "Alle Kollektionen ansehen", "viewAll": "Alle Kollektionen ansehen",
"empty": "Keine empfohlenen Kollektionen gefunden.", "empty": "Keine empfohlenen Kollektionen gefunden.",
@@ -1106,9 +1088,9 @@
"custom": "Custom", "custom": "Custom",
"recommended": "Empfohlen", "recommended": "Empfohlen",
"optional": "Optional", "optional": "Optional",
"importCta": "Mission Pack importieren", "importCta": "Aufgaben-Set importieren",
"imported": "Kollektion erfolgreich importiert", "imported": "Kollektion erfolgreich importiert",
"importFailed": "Mission Pack konnte nicht importiert werden" "importFailed": "Aufgaben-Set konnte nicht importiert werden"
} }
}, },
"collections": { "collections": {
@@ -1277,8 +1259,7 @@
} }
} }
} }
} },
,
"settings": { "settings": {
"hero": { "hero": {
"badge": "Administration", "badge": "Administration",
@@ -1538,14 +1519,9 @@
"cta": "Erste Task erstellen" "cta": "Erste Task erstellen"
} }
}, },
"billingWarning": {
"title": "Achtung",
"description": "Paket-Hinweise und Limits, die du im Blick behalten solltest."
},
"eventForm": { "eventForm": {
"errors": { "errors": {
"nameRequired": "Bitte gib einen Eventnamen ein.", "notice": "Hinweis"
"typeRequired": "Bitte wähle einen Event-Typ aus."
}, },
"titles": { "titles": {
"create": "Neues Event erstellen", "create": "Neues Event erstellen",
@@ -1583,9 +1559,6 @@
"saving": "Speichert", "saving": "Speichert",
"save": "Speichern", "save": "Speichern",
"cancel": "Abbrechen" "cancel": "Abbrechen"
},
"errors": {
"notice": "Hinweis"
} }
}, },
"notifications": { "notifications": {
@@ -1625,4 +1598,4 @@
"ctaFallback": "Events ansehen" "ctaFallback": "Events ansehen"
} }
} }
} }

View File

@@ -884,7 +884,7 @@ function BrandingMissionCard({
<SectionCard className="space-y-4"> <SectionCard className="space-y-4">
<SectionHeader <SectionHeader
eyebrow={t('events.branding.badge', 'Branding & Story')} eyebrow={t('events.branding.badge', 'Branding & Story')}
title={t('events.branding.title', 'Branding & Mission Packs')} title={t('events.branding.title', 'Branding & Aufgaben-Sets')}
description={t('events.branding.subtitle', 'Stimme Farben, Schriftarten und Aufgabenpakete aufeinander ab.')} description={t('events.branding.subtitle', 'Stimme Farben, Schriftarten und Aufgabenpakete aufeinander ab.')}
/> />
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
@@ -908,7 +908,7 @@ function BrandingMissionCard({
</Button> </Button>
</div> </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"> <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="text-xs uppercase tracking-[0.3em]">{t('events.branding.collectionsTitle', 'Aufgaben-Sets')}</p>
<p className="mt-1 text-base font-semibold"> <p className="mt-1 text-base font-semibold">
{event.event_type?.name ?? t('events.branding.collectionsFallback', 'Empfohlene Story')} {event.event_type?.name ?? t('events.branding.collectionsFallback', 'Empfohlene Story')}
</p> </p>
@@ -965,7 +965,7 @@ function BrandingMissionCard({
{t('events.branding.collectionsManage', 'Aufgaben bearbeiten')} {t('events.branding.collectionsManage', 'Aufgaben bearbeiten')}
</Button> </Button>
<Button size="sm" variant="ghost" className="text-rose-700 hover:bg-rose-100/80" onClick={onOpenCollections}> <Button size="sm" variant="ghost" className="text-rose-700 hover:bg-rose-100/80" onClick={onOpenCollections}>
{t('events.branding.collectionsImport', 'Mission Pack importieren')} {t('events.branding.collectionsImport', 'Aufgaben-Set importieren')}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -70,7 +70,7 @@ export default function EventTasksPage() {
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
const [modeSaving, setModeSaving] = React.useState(false); const [modeSaving, setModeSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [tab, setTab] = React.useState<'tasks' | 'packs'>('tasks'); const [tab, setTab] = React.useState<'tasks' | 'packs' | 'emotions'>('packs');
const [taskSearch, setTaskSearch] = React.useState(''); const [taskSearch, setTaskSearch] = React.useState('');
const [debouncedTaskSearch, setDebouncedTaskSearch] = React.useState(''); const [debouncedTaskSearch, setDebouncedTaskSearch] = React.useState('');
const [difficultyFilter, setDifficultyFilter] = React.useState<TenantTask['difficulty'] | ''>(''); const [difficultyFilter, setDifficultyFilter] = React.useState<TenantTask['difficulty'] | ''>('');
@@ -88,12 +88,14 @@ export default function EventTasksPage() {
const [newTaskEmotionId, setNewTaskEmotionId] = React.useState<number | null>(null); const [newTaskEmotionId, setNewTaskEmotionId] = React.useState<number | null>(null);
const [newTaskDifficulty, setNewTaskDifficulty] = React.useState<TenantTask['difficulty'] | ''>(''); const [newTaskDifficulty, setNewTaskDifficulty] = React.useState<TenantTask['difficulty'] | ''>('');
const [creatingTask, setCreatingTask] = React.useState(false); const [creatingTask, setCreatingTask] = React.useState(false);
const [quickAddOpen, setQuickAddOpen] = React.useState(false);
const [draggingId, setDraggingId] = React.useState<number | null>(null); const [draggingId, setDraggingId] = React.useState<number | null>(null);
const [selectedAssignedIds, setSelectedAssignedIds] = React.useState<number[]>([]); const [selectedAssignedIds, setSelectedAssignedIds] = React.useState<number[]>([]);
const [selectedAvailableIds, setSelectedAvailableIds] = React.useState<number[]>([]); const [selectedAvailableIds, setSelectedAvailableIds] = React.useState<number[]>([]);
const [batchSaving, setBatchSaving] = React.useState(false); const [batchSaving, setBatchSaving] = React.useState(false);
const [inlineSavingId, setInlineSavingId] = React.useState<number | null>(null); const [inlineSavingId, setInlineSavingId] = React.useState<number | null>(null);
const [emotionFilterOpen, setEmotionFilterOpen] = React.useState(false); const [emotionFilterOpen, setEmotionFilterOpen] = React.useState(false);
const libraryRef = React.useRef<HTMLDivElement | null>(null);
React.useEffect(() => { React.useEffect(() => {
const handle = window.setTimeout(() => setDebouncedTaskSearch(taskSearch.trim().toLowerCase()), 180); const handle = window.setTimeout(() => setDebouncedTaskSearch(taskSearch.trim().toLowerCase()), 180);
return () => window.clearTimeout(handle); return () => window.clearTimeout(handle);
@@ -333,6 +335,7 @@ export default function EventTasksPage() {
setNewTaskDescription(''); setNewTaskDescription('');
setNewTaskEmotionId(null); setNewTaskEmotionId(null);
setNewTaskDifficulty(''); setNewTaskDifficulty('');
setQuickAddOpen(false);
await hydrateTasks(event); await hydrateTasks(event);
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
@@ -424,14 +427,15 @@ export default function EventTasksPage() {
await importTaskCollection(collection.id, slug); await importTaskCollection(collection.id, slug);
toast.success( toast.success(
t('collections.imported', { t('collections.imported', {
defaultValue: 'Mission Pack "{{name}}" importiert.', defaultValue: 'Aufgaben-Set "{{name}}" importiert.',
name: collection.name, name: collection.name,
}), }),
); );
setTab('tasks');
await hydrateTasks(event); await hydrateTasks(event);
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
toast.error(t('collections.importFailed', 'Mission Pack konnte nicht importiert werden.')); toast.error(t('collections.importFailed', 'Aufgaben-Set konnte nicht importiert werden.'));
} }
} finally { } finally {
setImportingCollectionId(null); setImportingCollectionId(null);
@@ -447,30 +451,17 @@ export default function EventTasksPage() {
return mode !== 'photo_only'; return mode !== 'photo_only';
}, [event?.engagement_mode, event?.settings]); }, [event?.engagement_mode, event?.settings]);
const summaryBadges = !loading && event ? ( const hasSelection = selectedAssignedIds.length > 0 || selectedAvailableIds.length > 0;
<div className="mb-4 flex flex-wrap gap-2"> const tasksFirst = assignedTasks.length > 0;
<Badge className="flex items-center gap-2 rounded-full bg-slate-900 text-white"> const tabOrder: Array<'tasks' | 'packs' | 'emotions'> = tasksFirst ? ['tasks', 'packs', 'emotions'] : ['packs', 'tasks', 'emotions'];
<span className="text-xs uppercase tracking-wide text-white/80"> const prevAssignedRef = React.useRef(assignedTasks.length);
{t('summary.assigned', 'Zugeordnete Tasks')}
</span> React.useEffect(() => {
<span className="text-sm font-semibold">{assignedTasks.length}</span> if (prevAssignedRef.current === 0 && assignedTasks.length > 0) {
</Badge> setTab('tasks');
<Badge className="flex items-center gap-2 rounded-full bg-emerald-600/90 text-white"> }
<span className="text-xs uppercase tracking-wide text-white/80"> prevAssignedRef.current = assignedTasks.length;
{t('summary.library', 'Bibliothek')} }, [assignedTasks.length, setTab]);
</span>
<span className="text-sm font-semibold">{availableTasks.length}</span>
</Badge>
<Badge className="flex items-center gap-2 rounded-full bg-pink-500/90 text-white">
<span className="text-xs uppercase tracking-wide text-white/80">
{t('summary.mode', 'Aktiver Modus')}
</span>
<span className="text-sm font-semibold">
{tasksEnabled ? t('summary.tasksMode', 'Mission Cards') : t('summary.photoOnly', 'Nur Fotos')}
</span>
</Badge>
</div>
) : null;
async function handleModeChange(checked: boolean) { async function handleModeChange(checked: boolean) {
if (!event || !slug) return; if (!event || !slug) return;
@@ -658,8 +649,6 @@ export default function EventTasksPage() {
tabs={eventTabs} tabs={eventTabs}
currentTabKey="tasks" currentTabKey="tasks"
> >
{summaryBadges}
{error && ( {error && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTitle>{tDashboard('alerts.errorTitle', 'Fehler')}</AlertTitle> <AlertTitle>{tDashboard('alerts.errorTitle', 'Fehler')}</AlertTitle>
@@ -676,17 +665,33 @@ export default function EventTasksPage() {
</Alert> </Alert>
) : ( ) : (
<> <>
<Tabs value={tab} onValueChange={(value) => setTab(value as 'tasks' | 'packs')} className="space-y-6"> <Tabs value={tab} onValueChange={(value) => setTab(value as 'tasks' | 'packs' | 'emotions')} className="space-y-6">
<TabsList className="grid gap-2 rounded-2xl bg-slate-100/80 p-1 sm:grid-cols-2"> <div className="flex flex-wrap items-center justify-between gap-3">
<TabsTrigger value="tasks">{t('tabs.tasks', 'Aufgaben')}</TabsTrigger> <TabsList className="grid flex-1 gap-2 rounded-2xl bg-slate-100/80 p-1 sm:grid-cols-3">
<TabsTrigger value="packs">{t('tabs.packs', 'Mission Packs')}</TabsTrigger> {tabOrder.map((key) => (
</TabsList> <TabsTrigger key={key} value={key}>
{key === 'packs'
? t('tabs.packs', 'Vorlagen / Aufgaben-Bundles')
: key === 'tasks'
? t('tabs.tasks', 'Aufgaben')
: t('tabs.emotions', 'Emotionen')}
</TabsTrigger>
))}
</TabsList>
<Button
variant="outline"
className="border-emerald-200 text-emerald-700"
onClick={() => navigate(buildEngagementTabPath('tasks'))}
>
{t('library.open', 'Aufgaben-Bibliothek öffnen')}
</Button>
</div>
<TabsContent value="tasks" className="space-y-6"> <TabsContent value="tasks" className="space-y-6">
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60"> <Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardHeader> <CardHeader className="space-y-4">
<div className="mt-4 flex flex-col gap-4 rounded-2xl border border-slate-200 bg-white/70 p-4 text-sm text-slate-700"> <div className="flex flex-col gap-4 rounded-2xl border border-slate-200 bg-white/70 p-4 text-sm text-slate-700">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div className="space-y-2">
<p className="text-sm font-semibold text-slate-900"> <p className="text-sm font-semibold text-slate-900">
{t('modes.title', 'Aufgaben & Foto-Modus')} {t('modes.title', 'Aufgaben & Foto-Modus')}
</p> </p>
@@ -695,48 +700,55 @@ export default function EventTasksPage() {
? t('modes.tasksHint', 'Aufgaben sind aktiv. Gäste sehen Mission Cards in der App.') ? t('modes.tasksHint', 'Aufgaben sind aktiv. Gäste sehen Mission Cards in der App.')
: t('modes.photoOnlyHint', 'Der Foto-Modus ist aktiv. Gäste können Fotos hochladen, sehen aber keine Aufgaben.')} : t('modes.photoOnlyHint', 'Der Foto-Modus ist aktiv. Gäste können Fotos hochladen, sehen aber keine Aufgaben.')}
</p> </p>
<div className="flex flex-wrap gap-2">
<Badge className="rounded-full bg-slate-900 text-white">
{t('summary.assigned', 'Zugeordnete Tasks')} · {assignedTasks.length}
</Badge>
<Badge className="rounded-full bg-emerald-600/90 text-white">
{t('summary.library', 'Bibliothek')} · {availableTasks.length}
</Badge>
<Badge className="rounded-full bg-pink-500/90 text-white">
{t('summary.mode', 'Aktiver Modus')} ·{' '}
{tasksEnabled ? t('summary.tasksMode', 'Mission Cards') : t('summary.photoOnly', 'Nur Fotos')}
</Badge>
</div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex flex-col items-start gap-2 lg:items-end">
<span className="text-xs uppercase tracking-wide text-slate-500"> <div className="flex items-center gap-3">
{tasksEnabled ? t('modes.tasks', 'Aufgaben aktiv') : t('modes.photoOnly', 'Foto-Modus')} <span className="text-xs uppercase tracking-wide text-slate-500">
</span> {tasksEnabled ? t('modes.tasks', 'Aufgaben aktiv') : t('modes.photoOnly', 'Foto-Modus')}
<Switch </span>
checked={tasksEnabled} <Switch
onCheckedChange={handleModeChange} checked={tasksEnabled}
disabled={modeSaving} onCheckedChange={handleModeChange}
aria-label={t('modes.switchLabel', 'Aufgaben aktivieren/deaktivieren')} disabled={modeSaving || (!tasksEnabled && assignedTasks.length === 0)}
/> aria-label={t('modes.switchLabel', 'Aufgaben aktivieren/deaktivieren')}
/>
</div>
{modeSaving ? (
<div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t('modes.updating', 'Einstellung wird gespeichert ...')}
</div>
) : null}
{!tasksEnabled && assignedTasks.length === 0 ? (
<p className="text-[11px] text-slate-500">
{t('modes.needTasks', 'Aktiviere Aufgaben, sobald mindestens eine Aufgabe zugewiesen ist.')}
</p>
) : null}
</div> </div>
</div> </div>
{modeSaving ? ( <div className="flex flex-wrap gap-2">
<div className="flex items-center gap-2 text-xs text-slate-500"> <Button
<Loader2 className="h-3.5 w-3.5 animate-spin" /> className="bg-emerald-600 text-white hover:bg-emerald-700"
{t('modes.updating', 'Einstellung wird gespeichert ...')} onClick={() => setQuickAddOpen(true)}
</div> >
) : null} <PlusCircle className="mr-2 h-4 w-4" />
{t('actions.addCustom', 'Eigene Aufgabe hinzufügen')}
</Button>
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="pb-0">
<Alert variant="default" className="rounded-2xl border border-dashed border-emerald-200 bg-emerald-50/60 text-xs text-slate-700">
<AlertTitle className="text-sm font-semibold text-slate-900">
{t('library.hintTitle', 'Weitere Vorlagen in der Aufgaben-Bibliothek')}
</AlertTitle>
<AlertDescription className="mt-1 flex flex-wrap items-center gap-2">
<span>
{t('library.hintCopy', 'Lege eigene Aufgaben, Emotionen oder Mission Packs zentral an und nutze sie in mehreren Events.')}
</span>
<Button
type="button"
variant="outline"
size="sm"
className="mt-1 rounded-full border-emerald-300 text-emerald-700 hover:bg-emerald-100"
onClick={() => navigate(buildEngagementTabPath('tasks'))}
>
{t('library.open', 'Aufgaben-Bibliothek öffnen')}
</Button>
</AlertDescription>
</Alert>
</CardContent>
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
@@ -939,73 +951,17 @@ export default function EventTasksPage() {
</section> </section>
<section className="space-y-3"> <section className="space-y-3">
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900"> <div className="flex flex-wrap items-center justify-between gap-2" ref={libraryRef} id="library-section">
<PlusCircle className="h-4 w-4 text-emerald-500" /> <h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
{t('sections.library.title', 'Tasks aus Bibliothek hinzufügen')} <PlusCircle className="h-4 w-4 text-emerald-500" />
</h3> {t('sections.library.title', 'Tasks aus Bibliothek hinzufügen')}
<div className="rounded-2xl border border-emerald-100 bg-emerald-50/60 p-3 shadow-inner"> </h3>
<p className="text-xs font-semibold text-emerald-700">{t('sections.library.quickCreate', 'Schnell neue Aufgabe anlegen')}</p>
<div className="mt-2 grid gap-2">
<Input
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
placeholder={t('sections.library.quickTitle', 'Titel der Aufgabe')}
disabled={!tasksEnabled || creatingTask}
/>
<Textarea
value={newTaskDescription}
onChange={(e) => setNewTaskDescription(e.target.value)}
placeholder={t('sections.library.quickDescription', 'Beschreibung (optional)')}
disabled={!tasksEnabled || creatingTask}
className="min-h-[70px]"
/>
<div className="flex items-center gap-2">
<label className="text-xs text-slate-700">{t('sections.library.quickEmotion', 'Emotion')}</label>
<select
className="h-9 rounded-lg border border-slate-200 bg-white px-2 text-sm"
value={newTaskEmotionId ?? ''}
onChange={(e) => setNewTaskEmotionId(e.target.value ? Number(e.target.value) : null)}
disabled={!tasksEnabled || creatingTask}
>
<option value="">{t('sections.library.quickEmotionNone', 'Keine')}</option>
{relevantEmotions.map((emotion) => (
<option key={emotion.id} value={emotion.id}>
{emotion.name}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-slate-700">{t('sections.library.quickDifficulty', 'Schwierigkeit')}</label>
<select
className="h-9 rounded-lg border border-slate-200 bg-white px-2 text-sm"
disabled={!tasksEnabled || creatingTask}
value={newTaskDifficulty}
onChange={(e) => setNewTaskDifficulty(e.target.value as TenantTask['difficulty'] | '')}
>
<option value="">{t('sections.library.quickDifficultyNone', 'Keine')}</option>
<option value="easy">{t('sections.library.difficulty.easy', 'Leicht')}</option>
<option value="medium">{t('sections.library.difficulty.medium', 'Mittel')}</option>
<option value="hard">{t('sections.library.difficulty.hard', 'Schwer')}</option>
</select>
</div>
<div className="flex justify-end">
<Button
size="sm"
onClick={() => void handleCreateQuickTask()}
disabled={!newTaskTitle.trim() || creatingTask || !tasksEnabled}
>
{creatingTask ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
t('sections.library.quickCreateCta', 'Erstellen & zuweisen')
)}
</Button>
</div>
</div>
</div> </div>
<p className="text-xs text-slate-600">
{t('sections.library.helper', 'Suche, filtere und füge einzelne Aufgaben hinzu. Eigene Aufgaben legst du über den Dialog an.')}
</p>
<DropZone id="library-dropzone"> <DropZone id="library-dropzone">
<div className="space-y-2 max-h-72 overflow-y-auto"> <div className="space-y-2 max-h-80 overflow-y-auto">
{availableTasks.length === 0 ? ( {availableTasks.length === 0 ? (
<EmptyState message={t('sections.library.empty', 'Keine Tasks in der Bibliothek gefunden.')} /> <EmptyState message={t('sections.library.empty', 'Keine Tasks in der Bibliothek gefunden.')} />
) : ( ) : (
@@ -1087,58 +1043,54 @@ export default function EventTasksPage() {
</DragOverlay> </DragOverlay>
</DndContext> </DndContext>
</Card> </Card>
{hasSelection ? (
<div className="sticky bottom-3 z-10 flex flex-col gap-2 rounded-2xl border border-slate-200 bg-white/95 p-3 shadow-xl shadow-slate-200 lg:top-4 lg:bottom-auto"> <div className="fixed inset-x-0 bottom-4 z-30 flex justify-center px-4">
<div className="flex flex-wrap items-center gap-3 text-sm text-slate-700"> <div className="pointer-events-auto flex w-full max-w-4xl flex-wrap items-center gap-3 rounded-2xl border border-slate-200 bg-white/95 p-3 shadow-2xl">
<span className="font-semibold text-slate-900"> <div className="flex flex-1 flex-wrap items-center gap-2 text-sm text-slate-700">
{t('sections.bulk.title', 'Batch-Aktionen')} <Badge variant="outline" className="border-slate-200 text-slate-700">
</span> {t('sections.bulk.assignedSelected', {
<Badge variant="outline" className="border-slate-200 text-slate-700"> defaultValue: '{{count}} ausgewählt (Zugeordnet)',
{t('sections.bulk.assignedSelected', { count: selectedAssignedIds.length,
defaultValue: '{{count}} ausgewählt (Zugeordnet)', })}
count: selectedAssignedIds.length, </Badge>
})} <Badge variant="outline" className="border-slate-200 text-slate-700">
</Badge> {t('sections.bulk.librarySelected', {
<Badge variant="outline" className="border-slate-200 text-slate-700"> defaultValue: '{{count}} ausgewählt (Bibliothek)',
{t('sections.bulk.librarySelected', { count: selectedAvailableIds.length,
defaultValue: '{{count}} ausgewählt (Bibliothek)', })}
count: selectedAvailableIds.length, </Badge>
})} </div>
</Badge> <div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => void handleAssignSelected()}
disabled={selectedAvailableIds.length === 0 || !tasksEnabled || batchSaving || saving}
>
{t('actions.assignSelected', 'Auswahl zuweisen')}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => void handleDetachSelected()}
disabled={selectedAssignedIds.length === 0 || batchSaving || saving}
>
{t('actions.removeSelected', 'Auswahl entfernen')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setSelectedAssignedIds([]);
setSelectedAvailableIds([]);
}}
>
{t('sections.bulk.clear', 'Auswahl aufheben')}
</Button>
</div>
</div>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> ) : null}
<Button
size="sm"
variant="outline"
onClick={() => void handleAssignSelected()}
disabled={selectedAvailableIds.length === 0 || !tasksEnabled || batchSaving || saving}
>
{t('actions.assignSelected', 'Auswahl zuweisen')}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => void handleDetachSelected()}
disabled={selectedAssignedIds.length === 0 || batchSaving || saving}
>
{t('actions.removeSelected', 'Auswahl entfernen')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => navigate(buildEngagementTabPath('tasks'))}
>
{t('library.open', 'Aufgaben-Bibliothek öffnen')}
</Button>
</div>
</div>
<EmotionsCard
emotions={relevantEmotions}
emotionsLoading={emotionsLoading}
emotionsError={emotionsError}
onOpenEmotions={() => setEmotionsModalOpen(true)}
/>
</TabsContent> </TabsContent>
<TabsContent value="packs"> <TabsContent value="packs">
<MissionPackGrid <MissionPackGrid
@@ -1150,10 +1102,96 @@ export default function EventTasksPage() {
onViewAll={() => navigate(buildEngagementTabPath('collections'))} onViewAll={() => navigate(buildEngagementTabPath('collections'))}
/> />
</TabsContent> </TabsContent>
<TabsContent value="emotions">
<EmotionsCard
emotions={relevantEmotions}
emotionsLoading={emotionsLoading}
emotionsError={emotionsError}
onOpenEmotions={() => setEmotionsModalOpen(true)}
/>
</TabsContent>
</Tabs> </Tabs>
</> </>
)} )}
<Dialog open={quickAddOpen} onOpenChange={setQuickAddOpen}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>{t('sections.library.quickCreate', 'Eigene Aufgabe hinzufügen')}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<p className="text-xs text-slate-600">
{t('sections.library.quickHelper', 'Titel eingeben, optional beschreiben und sofort zum Event zuweisen.')}
</p>
<Input
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
placeholder={t('sections.library.quickTitle', 'Titel der Aufgabe')}
disabled={!tasksEnabled || creatingTask}
/>
<Textarea
value={newTaskDescription}
onChange={(e) => setNewTaskDescription(e.target.value)}
placeholder={t('sections.library.quickDescription', 'Beschreibung (optional)')}
disabled={!tasksEnabled || creatingTask}
className="min-h-[90px]"
/>
<div className="grid gap-2 sm:grid-cols-2">
<div className="flex items-center gap-2">
<label className="text-xs text-slate-700">{t('sections.library.quickEmotion', 'Emotion')}</label>
<select
className="h-9 w-full rounded-lg border border-slate-200 bg-white px-2 text-sm"
value={newTaskEmotionId ?? ''}
onChange={(e) => setNewTaskEmotionId(e.target.value ? Number(e.target.value) : null)}
disabled={!tasksEnabled || creatingTask}
>
<option value="">{t('sections.library.quickEmotionNone', 'Keine')}</option>
{relevantEmotions.map((emotion) => (
<option key={emotion.id} value={emotion.id}>
{emotion.name}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-slate-700">{t('sections.library.quickDifficulty', 'Schwierigkeit')}</label>
<select
className="h-9 w-full rounded-lg border border-slate-200 bg-white px-2 text-sm"
disabled={!tasksEnabled || creatingTask}
value={newTaskDifficulty}
onChange={(e) => setNewTaskDifficulty(e.target.value as TenantTask['difficulty'] | '')}
>
<option value="">{t('sections.library.quickDifficultyNone', 'Keine')}</option>
<option value="easy">{t('sections.library.difficulty.easy', 'Leicht')}</option>
<option value="medium">{t('sections.library.difficulty.medium', 'Mittel')}</option>
<option value="hard">{t('sections.library.difficulty.hard', 'Schwer')}</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
onClick={() => {
setQuickAddOpen(false);
setNewTaskTitle('');
setNewTaskDescription('');
setNewTaskEmotionId(null);
setNewTaskDifficulty('');
}}
>
{t('actions.cancel', 'Abbrechen')}
</Button>
<Button
onClick={() => void handleCreateQuickTask()}
disabled={!newTaskTitle.trim() || creatingTask || !tasksEnabled}
>
{creatingTask ? <Loader2 className="h-4 w-4 animate-spin" /> : t('sections.library.quickCreateCta', 'Erstellen & zuweisen')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={emotionsModalOpen} onOpenChange={setEmotionsModalOpen}> <Dialog open={emotionsModalOpen} onOpenChange={setEmotionsModalOpen}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
@@ -1409,14 +1447,14 @@ function MissionPackGrid({
<div> <div>
<CardTitle className="flex items-center gap-2 text-base text-slate-900"> <CardTitle className="flex items-center gap-2 text-base text-slate-900">
<Layers className="h-5 w-5 text-pink-500" /> <Layers className="h-5 w-5 text-pink-500" />
{t('title', 'Mission Packs')} {t('title', 'Vorlagen / Aufgaben-Bundles')}
</CardTitle> </CardTitle>
<CardDescription className="text-sm text-slate-600"> <CardDescription className="text-sm text-slate-600">
{t('subtitle', 'Importiere Aufgaben-Kollektionen, die zu deinem Event passen.')} {t('subtitle', 'Importiere Aufgaben-Sets, die zu deinem Event passen.')}
</CardDescription> </CardDescription>
</div> </div>
<Button variant="outline" onClick={onViewAll}> <Button variant="outline" onClick={onViewAll}>
{t('viewAll', 'Alle Kollektionen ansehen')} {t('viewAll', 'Alle Sets ansehen')}
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@@ -1463,7 +1501,7 @@ function MissionPackGrid({
{importingId === collection.id ? ( {importingId === collection.id ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
t('importCta', 'Mission Pack importieren') t('importCta', 'Aufgaben-Set importieren')
)} )}
</Button> </Button>
</div> </div>

View File

@@ -83,7 +83,7 @@ export default function BottomNav() {
return ( return (
<div <div
className={`fixed inset-x-0 bottom-0 z-30 border-t border-white/20 bg-gradient-to-t from-black/40 via-black/20 to-transparent px-4 shadow-xl backdrop-blur-2xl transition-all duration-200 dark:border-white/10 dark:from-gray-950/90 dark:via-gray-900/70 dark:to-gray-900/35 ${ className={`guest-bottom-nav fixed inset-x-0 bottom-0 z-30 border-t border-white/20 bg-gradient-to-t from-black/40 via-black/20 to-transparent px-4 shadow-xl backdrop-blur-2xl transition-all duration-200 dark:border-white/10 dark:from-gray-950/90 dark:via-gray-900/70 dark:to-gray-900/35 ${
compact ? 'pb-1 pt-1 translate-y-3' : 'pb-3 pt-2' compact ? 'pb-1 pt-1 translate-y-3' : 'pb-3 pt-2'
}`} }`}
> >

View File

@@ -175,7 +175,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
const guestName = identity?.name && identity?.hydrated ? identity.name : null; const guestName = identity?.name && identity?.hydrated ? identity.name : null;
return ( return (
<div <div
className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40" className="guest-header sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40"
style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined} style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}
> >
<div className="flex flex-col"> <div className="flex flex-col">
@@ -207,7 +207,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
if (status === 'loading') { if (status === 'loading') {
return ( return (
<div className="sticky top-0 z-20 flex items-center justify-between border-b border-white/10 px-4 py-2 text-white shadow-sm backdrop-blur" style={headerStyle}> <div className="guest-header sticky top-0 z-20 flex items-center justify-between border-b border-white/10 px-4 py-2 text-white shadow-sm backdrop-blur" style={headerStyle}>
<div className="font-semibold" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>{t('header.loading')}</div> <div className="font-semibold" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>{t('header.loading')}</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AppearanceToggleDropdown /> <AppearanceToggleDropdown />
@@ -225,7 +225,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
statsContext && statsContext.eventKey === eventToken ? statsContext : undefined; statsContext && statsContext.eventKey === eventToken ? statsContext : undefined;
return ( return (
<div <div
className="sticky top-0 z-20 flex items-center justify-between border-b border-white/10 px-4 py-3 text-white shadow-sm backdrop-blur" className="guest-header sticky top-0 z-20 flex items-center justify-between border-b border-white/10 px-4 py-3 text-white shadow-sm backdrop-blur"
style={headerStyle} style={headerStyle}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

View File

@@ -342,7 +342,6 @@ export const messages: Record<LocaleCode, NestedMessages> = {
button: 'Teilen', button: 'Teilen',
copyLink: 'Link kopieren', copyLink: 'Link kopieren',
copySuccess: 'Link kopiert!', copySuccess: 'Link kopiert!',
copyError: 'Link konnte nicht kopiert werden.',
manualPrompt: 'Link kopieren', manualPrompt: 'Link kopieren',
openEvent: 'Event öffnen', openEvent: 'Event öffnen',
loading: 'Moment wird geladen...', loading: 'Moment wird geladen...',
@@ -986,7 +985,6 @@ export const messages: Record<LocaleCode, NestedMessages> = {
defaultEvent: 'A special moment', defaultEvent: 'A special moment',
button: 'Share', button: 'Share',
copyLink: 'Copy link', copyLink: 'Copy link',
copySuccess: 'Link copied!',
copyError: 'Link could not be copied.', copyError: 'Link could not be copied.',
manualPrompt: 'Copy link', manualPrompt: 'Copy link',
openEvent: 'Open event', openEvent: 'Open event',

View File

@@ -1,11 +1,7 @@
import React, { Suspense } from 'react'; import React, { Suspense } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { router } from './router';
import '../../css/app.css'; import '../../css/app.css';
import { initializeTheme } from '@/hooks/use-appearance'; import { initializeTheme } from '@/hooks/use-appearance';
import { ToastProvider } from './components/ToastHost';
import { LocaleProvider } from './i18n/LocaleContext';
import { enableGuestDemoMode, shouldEnableGuestDemoMode } from './demo/demoMode'; import { enableGuestDemoMode, shouldEnableGuestDemoMode } from './demo/demoMode';
initializeTheme(); initializeTheme();
@@ -13,35 +9,71 @@ if (shouldEnableGuestDemoMode()) {
enableGuestDemoMode(); enableGuestDemoMode();
} }
const rootEl = document.getElementById('root')!; const rootEl = document.getElementById('root')!;
const isShareRoute = typeof window !== 'undefined' && window.location.pathname.startsWith('/share/');
// Register a minimal service worker for background sync (best-effort) const shareRoot = async () => {
if ('serviceWorker' in navigator) { const { SharedPhotoStandalone } = await import('./pages/SharedPhotoPage');
navigator.serviceWorker.register('/guest-sw.js').catch(() => {}); createRoot(rootEl).render(
navigator.serviceWorker.addEventListener('message', (evt) => { <React.StrictMode>
if (evt.data?.type === 'sync-queue') { <SharedPhotoStandalone />
import('./queue/queue').then((m) => m.processQueue().catch(() => {})); </React.StrictMode>
} );
}); };
// Also attempt to process queue on load and when going online
import('./queue/queue').then((m) => m.processQueue().catch(() => {})); const appRoot = async () => {
window.addEventListener('online', () => { const { RouterProvider } = await import('react-router-dom');
const { router } = await import('./router');
const { ToastProvider } = await import('./components/ToastHost');
const { LocaleProvider } = await import('./i18n/LocaleContext');
// Register a minimal service worker for background sync (best-effort)
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/guest-sw.js').catch(() => {});
navigator.serviceWorker.addEventListener('message', (evt) => {
if (evt.data?.type === 'sync-queue') {
import('./queue/queue').then((m) => m.processQueue().catch(() => {}));
}
});
// Also attempt to process queue on load and when going online
import('./queue/queue').then((m) => m.processQueue().catch(() => {})); import('./queue/queue').then((m) => m.processQueue().catch(() => {}));
window.addEventListener('online', () => {
import('./queue/queue').then((m) => m.processQueue().catch(() => {}));
});
}
createRoot(rootEl).render(
<React.StrictMode>
<LocaleProvider>
<ToastProvider>
<Suspense
fallback={(
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
Erlebnisse werden geladen
</div>
)}
>
<RouterProvider router={router} />
</Suspense>
</ToastProvider>
</LocaleProvider>
</React.StrictMode>
);
};
if (isShareRoute) {
shareRoot().catch(() => {
createRoot(rootEl).render(
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
Dieses Foto kann gerade nicht geladen werden.
</div>
);
});
} else {
appRoot().catch(() => {
createRoot(rootEl).render(
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
Erlebnisse können nicht geladen werden.
</div>
);
}); });
} }
createRoot(rootEl).render(
<React.StrictMode>
<LocaleProvider>
<ToastProvider>
<Suspense
fallback={(
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
Erlebnisse werden geladen
</div>
)}
>
<RouterProvider router={router} />
</Suspense>
</ToastProvider>
</LocaleProvider>
</React.StrictMode>
);

View File

@@ -1,11 +1,8 @@
// @ts-nocheck // @ts-nocheck
import React from 'react'; import React from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { fetchPhotoShare } from '../services/photosApi'; import { fetchPhotoShare } from '../services/photosApi';
import { useTranslation } from '../i18n/useTranslation'; import { Loader2, AlertCircle } from 'lucide-react';
import { useToast } from '../components/ToastHost';
import { Loader2 } from 'lucide-react';
interface ShareResponse { interface ShareResponse {
slug: string; slug: string;
@@ -14,16 +11,29 @@ interface ShareResponse {
id: number; id: number;
title?: string; title?: string;
likes_count?: number; likes_count?: number;
emotion?: { name?: string; emoji?: string } | null; emotion?: { name?: string; emoji?: string | null } | null;
created_at?: string | null;
image_urls: { full: string; thumbnail: string }; image_urls: { full: string; thumbnail: string };
}; };
event?: { id: number; name?: string | null } | null; event?: { id: number; name?: string | null } | null;
} }
type ShareProps = { slug: string | undefined };
export function SharedPhotoStandalone() {
const slug = React.useMemo(() => {
const parts = window.location.pathname.split('/').filter(Boolean);
return parts.length >= 2 ? parts[1] : undefined;
}, []);
return <SharedPhotoView slug={slug} />;
}
export default function SharedPhotoPage() { export default function SharedPhotoPage() {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const { t } = useTranslation(); return <SharedPhotoView slug={slug} />;
const toast = useToast(); }
function SharedPhotoView({ slug }: ShareProps) {
const [state, setState] = React.useState<{ const [state, setState] = React.useState<{
loading: boolean; loading: boolean;
error: string | null; error: string | null;
@@ -36,34 +46,22 @@ export default function SharedPhotoPage() {
setState({ loading: true, error: null, data: null }); setState({ loading: true, error: null, data: null });
fetchPhotoShare(slug) fetchPhotoShare(slug)
.then((data) => { .then((data) => { if (active) setState({ loading: false, error: null, data }); })
if (!active) return;
setState({ loading: false, error: null, data });
})
.catch((error: unknown) => { .catch((error: unknown) => {
if (!active) return; if (!active) return;
setState({ loading: false, error: error?.message ?? t('share.error', 'Moment konnte nicht geladen werden.'), data: null }); setState({ loading: false, error: 'Dieses Foto ist nicht mehr verfügbar.', data: null });
}); });
return () => { return () => {
active = false; active = false;
}; };
}, [slug, t]); }, [slug]);
const handleCopy = React.useCallback(async () => {
try {
await navigator.clipboard.writeText(window.location.href);
toast.push({ text: t('share.copySuccess', 'Link kopiert!') });
} catch {
toast.push({ text: t('share.copyError', 'Link konnte nicht kopiert werden.'), type: 'error' });
}
}, [toast, t]);
if (state.loading) { if (state.loading) {
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center gap-3 bg-gradient-to-br from-pink-50 to-white px-4 text-center"> <div className="flex min-h-screen flex-col items-center justify-center gap-3 bg-gradient-to-br from-pink-50 to-white px-4 text-center">
<Loader2 className="h-8 w-8 animate-spin text-pink-500" aria-hidden /> <Loader2 className="h-8 w-8 animate-spin text-pink-500" aria-hidden />
<p className="text-sm text-muted-foreground">{t('share.loading', 'Moment wird geladen...')}</p> <p className="text-sm text-muted-foreground">Moment wird geladen </p>
</div> </div>
); );
} }
@@ -71,29 +69,30 @@ export default function SharedPhotoPage() {
if (state.error || !state.data) { if (state.error || !state.data) {
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-gradient-to-br from-pink-50 to-white px-6 text-center"> <div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-gradient-to-br from-pink-50 to-white px-6 text-center">
<p className="text-lg font-semibold text-foreground">{t('share.expiredTitle', 'Link abgelaufen')}</p> <div className="flex items-center gap-2 text-rose-600">
<p className="text-sm text-muted-foreground max-w-md">{state.error ?? t('share.expiredDescription', 'Dieses Foto ist nicht mehr verfügbar.')}</p> <AlertCircle className="h-5 w-5" />
<Button asChild> <p className="text-lg font-semibold text-foreground">Link abgelaufen</p>
<Link to="/event">{t('share.openEvent', 'Event öffnen')}</Link> </div>
</Button> <p className="text-sm text-muted-foreground max-w-md">
{state.error ?? 'Dieses Foto ist nicht mehr verfügbar.'}
</p>
</div> </div>
); );
} }
const { data } = state; const { data } = state;
const chips = buildChips(data);
return ( return (
<div className="min-h-screen bg-gradient-to-b from-white via-pink-50 to-white px-4 py-8"> <div className="min-h-screen bg-gradient-to-b from-white via-pink-50 to-white px-4 py-10">
<div className="mx-auto flex max-w-2xl flex-col gap-5"> <div className="mx-auto flex w-full max-w-xl flex-col gap-6">
<div className="rounded-3xl border border-white/60 bg-white/80 p-6 text-center shadow"> <div className="rounded-3xl border border-white/60 bg-white/90 p-5 text-center shadow-sm">
<p className="text-xs uppercase tracking-wide text-muted-foreground">{t('share.title', 'Geteiltes Foto')}</p> <p className="text-[11px] uppercase tracking-[0.35em] text-muted-foreground">Geteiltes Foto</p>
<h1 className="mt-2 text-2xl font-semibold text-foreground">{data.event?.name ?? t('share.defaultEvent', 'Ein besonderer Moment')}</h1> <h1 className="mt-2 text-2xl font-semibold text-foreground">{data.event?.name ?? 'Ein besonderer Moment'}</h1>
{data.photo.title && ( {data.photo.title && <p className="mt-1 text-sm text-muted-foreground">{data.photo.title}</p>}
<p className="mt-1 text-sm text-muted-foreground">{data.photo.title}</p>
)}
</div> </div>
<div className="overflow-hidden rounded-[32px] border border-white/60 bg-black"> <div className="overflow-hidden rounded-[28px] border border-white/60 bg-black shadow-lg">
<img <img
src={data.photo.image_urls.full} src={data.photo.image_urls.full}
alt={data.photo.title ?? 'Foto'} alt={data.photo.title ?? 'Foto'}
@@ -102,21 +101,42 @@ export default function SharedPhotoPage() {
/> />
</div> </div>
{data.photo.emotion && ( {chips.length > 0 && (
<p className="text-center text-sm text-muted-foreground"> <div className="flex flex-wrap justify-center gap-2">
{data.photo.emotion.emoji} {data.photo.emotion.name} {chips.map((chip) => (
</p> <span
key={chip.id}
className="inline-flex items-center gap-1 rounded-full border border-slate-200/80 bg-white/90 px-3 py-1 text-xs font-semibold text-slate-700 shadow-sm"
>
{chip.icon ? <span aria-hidden className="text-sm">{chip.icon}</span> : null}
<span className="text-[11px] uppercase tracking-wide opacity-70">{chip.label}</span>
<span className="text-[12px]">{chip.value}</span>
</span>
))}
</div>
)} )}
<div className="flex flex-col gap-3 sm:flex-row sm:justify-center">
<Button variant="secondary" onClick={handleCopy}>
{t('share.copyLink', 'Link kopieren')}
</Button>
<Button asChild>
<Link to="/event">{t('share.openEvent', 'Event öffnen')}</Link>
</Button>
</div>
</div> </div>
</div> </div>
); );
} }
function buildChips(data: ShareResponse): { id: string; label: string; value: string; icon?: string }[] {
const list: { id: string; label: string; value: string; icon?: string }[] = [];
if (data.photo.emotion?.name) {
list.push({ id: 'emotion', label: 'Emotion', value: data.photo.emotion.name, icon: data.photo.emotion.emoji ?? '★' });
}
if (data.photo.title) {
list.push({ id: 'task', label: 'Aufgabe', value: data.photo.title });
}
if (data.photo.created_at) {
const date = formatDate(data.photo.created_at);
list.push({ id: 'date', label: 'Aufgenommen', value: date });
}
return list;
}
function formatDate(value: string): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '';
return parsed.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
}

View File

@@ -27,6 +27,8 @@ import {
Sparkles, Sparkles,
Zap, Zap,
ZapOff, ZapOff,
Maximize2,
Minimize2,
} from 'lucide-react'; } from 'lucide-react';
import { getEventPackage, type EventPackage } from '../services/eventApi'; import { getEventPackage, type EventPackage } from '../services/eventApi';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
@@ -35,6 +37,7 @@ import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadE
import { useEventStats } from '../context/EventStatsContext'; import { useEventStats } from '../context/EventStatsContext';
import { useEventBranding } from '../context/EventBrandingContext'; import { useEventBranding } from '../context/EventBrandingContext';
import { compressPhoto, formatBytes } from '../lib/image'; import { compressPhoto, formatBytes } from '../lib/image';
import { useGuestIdentity } from '../context/GuestIdentityContext';
interface Task { interface Task {
id: number; id: number;
@@ -112,6 +115,7 @@ export default function UploadPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { markCompleted } = useGuestTaskProgress(token); const { markCompleted } = useGuestTaskProgress(token);
const identity = useGuestIdentity();
const { t, locale } = useTranslation(); const { t, locale } = useTranslation();
const stats = useEventStats(); const stats = useEventStats();
const { branding } = useEventBranding(); const { branding } = useEventBranding();
@@ -143,6 +147,7 @@ export default function UploadPage() {
const [uploadProgress, setUploadProgress] = useState(0); const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState<string | null>(null); const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadWarning, setUploadWarning] = useState<string | null>(null); const [uploadWarning, setUploadWarning] = useState<string | null>(null);
const [immersiveMode, setImmersiveMode] = useState(false);
const [errorDialog, setErrorDialog] = useState<UploadErrorDialog | null>(null); const [errorDialog, setErrorDialog] = useState<UploadErrorDialog | null>(null);
const [taskDetailsExpanded, setTaskDetailsExpanded] = useState(false); const [taskDetailsExpanded, setTaskDetailsExpanded] = useState(false);
@@ -155,6 +160,19 @@ const [canUpload, setCanUpload] = useState(true);
[eventPackage?.limits, t] [eventPackage?.limits, t]
); );
useEffect(() => {
if (typeof document === 'undefined') return undefined;
const className = 'guest-immersive';
if (immersiveMode) {
document.body.classList.add(className);
} else {
document.body.classList.remove(className);
}
return () => {
document.body.classList.remove(className);
};
}, [immersiveMode]);
const [showPrimer, setShowPrimer] = useState<boolean>(() => { const [showPrimer, setShowPrimer] = useState<boolean>(() => {
if (typeof window === 'undefined') return false; if (typeof window === 'undefined') return false;
return window.localStorage.getItem(primerStorageKey) !== '1'; return window.localStorage.getItem(primerStorageKey) !== '1';
@@ -374,7 +392,7 @@ const [canUpload, setCanUpload] = useState(true);
return; return;
} }
if (!task || mode === 'uploading') return; if (mode === 'uploading') return;
try { try {
setPermissionState('prompt'); setPermissionState('prompt');
@@ -401,15 +419,15 @@ const [canUpload, setCanUpload] = useState(true);
setPermissionMessage(t('upload.cameraError.explanation')); setPermissionMessage(t('upload.cameraError.explanation'));
} }
} }
}, [attachStreamToVideo, createConstraint, mode, preferences.facingMode, stopStream, supportsCamera, task, t]); }, [attachStreamToVideo, createConstraint, mode, preferences.facingMode, stopStream, supportsCamera, t]);
useEffect(() => { useEffect(() => {
if (!task || loadingTask) return; if (loadingTask) return;
startCamera(); startCamera();
return () => { return () => {
stopStream(); stopStream();
}; };
}, [task, loadingTask, startCamera, stopStream, preferences.facingMode]); }, [loadingTask, startCamera, stopStream, preferences.facingMode]);
// Countdown live region updates // Countdown live region updates
useEffect(() => { useEffect(() => {
@@ -601,8 +619,9 @@ const [canUpload, setCanUpload] = useState(true);
} }
try { try {
const photoId = await uploadPhoto(eventKey, fileForUpload, task.id, emotionSlug || undefined, { const photoId = await uploadPhoto(eventKey, fileForUpload, task?.id, emotionSlug || undefined, {
maxRetries: 2, maxRetries: 2,
guestName: identity.name || undefined,
onProgress: (percent) => { onProgress: (percent) => {
setUploadProgress(Math.max(15, Math.min(98, percent))); setUploadProgress(Math.max(15, Math.min(98, percent)));
setStatusMessage(t('upload.status.uploading')); setStatusMessage(t('upload.status.uploading'));
@@ -616,7 +635,18 @@ const [canUpload, setCanUpload] = useState(true);
}); });
setUploadProgress(100); setUploadProgress(100);
setStatusMessage(t('upload.status.completed')); setStatusMessage(t('upload.status.completed'));
markCompleted(task.id); if (task?.id) {
markCompleted(task.id);
}
try {
const raw = localStorage.getItem('my-photo-ids');
const arr: number[] = raw ? JSON.parse(raw) : [];
if (photoId && !arr.includes(photoId)) {
localStorage.setItem('my-photo-ids', JSON.stringify([photoId, ...arr]));
}
} catch (error) {
console.warn('Failed to persist my-photo-ids', error);
}
stopStream(); stopStream();
navigateAfterUpload(photoId); navigateAfterUpload(photoId);
} catch (error: unknown) { } catch (error: unknown) {
@@ -648,22 +678,49 @@ const [canUpload, setCanUpload] = useState(true);
} finally { } finally {
setStatusMessage(''); setStatusMessage('');
} }
}, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload, t]); }, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload, t, identity.name]);
const handleGalleryPick = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { const handleGalleryPick = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!canUpload) return; if (!canUpload) return;
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file) return; if (!file) return;
setUploadError(null); setUploadError(null);
const reader = new FileReader(); setUploadWarning(null);
reader.onload = () => { setStatusMessage(t('upload.status.optimizing', 'Foto wird optimiert…'));
setReviewPhoto({ dataUrl: reader.result as string, file });
setMode('review'); let prepared = file;
}; try {
reader.onerror = () => { prepared = await compressPhoto(file, {
setUploadError(t('upload.galleryPickError')); maxEdge: 2400,
}; targetBytes: 4_000_000,
reader.readAsDataURL(file); qualityStart: 0.82,
});
if (prepared.size < file.size - 50_000) {
const saved = formatBytes(file.size - prepared.size);
setUploadWarning(
t('upload.optimizedNotice', 'Wir haben dein Foto verkleinert, damit der Upload schneller klappt. Eingespart: {saved}')
.replace('{saved}', saved)
);
}
} catch (error) {
console.warn('Gallery image optimization failed, falling back to original', error);
setUploadWarning(t('upload.optimizedFallback', 'Optimierung nicht möglich wir laden das Original hoch.'));
}
if (prepared.size > 12_000_000) {
setStatusMessage('');
setUploadError(
t('upload.errors.tooLargeHint', 'Das Foto war zu groß. Bitte erneut versuchen wir verkleinern es automatisch.')
);
event.target.value = '';
return;
}
const dataUrl = await readAsDataUrl(prepared);
setReviewPhoto({ dataUrl, file: prepared });
setMode('review');
setStatusMessage('');
event.target.value = '';
}, [canUpload, t]); }, [canUpload, t]);
const emotionLabel = useMemo(() => { const emotionLabel = useMemo(() => {
@@ -1119,6 +1176,20 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
{preferences.flashPreferred ? <Zap className="mr-1 h-3.5 w-3.5 text-yellow-300" /> : <ZapOff className="mr-1 h-3.5 w-3.5" />} {preferences.flashPreferred ? <Zap className="mr-1 h-3.5 w-3.5 text-yellow-300" /> : <ZapOff className="mr-1 h-3.5 w-3.5" />}
{t('upload.controls.toggleFlash')} {t('upload.controls.toggleFlash')}
</Button> </Button>
<Button
size="sm"
variant={immersiveMode ? 'default' : 'secondary'}
className={cn(
'rounded-full border-white/30 bg-white/10 px-4 py-1 text-xs',
immersiveMode && 'bg-white text-black'
)}
onClick={() => setImmersiveMode((prev) => !prev)}
>
{immersiveMode ? <Minimize2 className="mr-1 h-3.5 w-3.5" /> : <Maximize2 className="mr-1 h-3.5 w-3.5" />}
{immersiveMode
? t('upload.controls.exitFullscreen', 'Menü einblenden')
: t('upload.controls.enterFullscreen', 'Vollbild')}
</Button>
</div> </div>
<div className="flex items-center justify-center gap-6"> <div className="flex items-center justify-center gap-6">
@@ -1200,6 +1271,15 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
); );
} }
function readAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error ?? new Error('Failed to read file'));
reader.readAsDataURL(file);
});
}
function formatRelativeTimeLabel(value: string | null | undefined, t: TranslateFn): string { function formatRelativeTimeLabel(value: string | null | undefined, t: TranslateFn): string {
if (!value) { if (!value) {
return t('upload.hud.relative.now'); return t('upload.hud.relative.now');

View File

@@ -95,6 +95,7 @@ export async function likePhoto(id: number): Promise<number> {
} }
type UploadOptions = { type UploadOptions = {
guestName?: string;
onProgress?: (percent: number) => void; onProgress?: (percent: number) => void;
signal?: AbortSignal; signal?: AbortSignal;
maxRetries?: number; maxRetries?: number;
@@ -112,6 +113,7 @@ export async function uploadPhoto(
formData.append('photo', file, file.name || `photo-${Date.now()}.jpg`); formData.append('photo', file, file.name || `photo-${Date.now()}.jpg`);
if (taskId) formData.append('task_id', taskId.toString()); if (taskId) formData.append('task_id', taskId.toString());
if (emotionSlug) formData.append('emotion_slug', emotionSlug); if (emotionSlug) formData.append('emotion_slug', emotionSlug);
if (options.guestName) formData.append('guest_name', options.guestName);
formData.append('device_id', getDeviceId()); formData.append('device_id', getDeviceId());
const maxRetries = options.maxRetries ?? 2; const maxRetries = options.maxRetries ?? 2;

View File

@@ -30,10 +30,6 @@ i18n
backend: { backend: {
loadPath: '/lang/{{lng}}/{{ns}}.json', loadPath: '/lang/{{lng}}/{{ns}}.json',
}, },
detection: {
order: [],
caches: ['localStorage'],
},
react: { react: {
useSuspense: false, useSuspense: false,
}, },