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:
@@ -630,14 +630,22 @@ class EventPublicController extends BaseController
|
||||
{
|
||||
$guestNameParam = trim((string) $request->query('guest_name', ''));
|
||||
$deviceIdHeader = (string) $request->headers->get('X-Device-Id', '');
|
||||
$deviceId = substr(preg_replace('/[^A-Za-z0-9 _\-]/', '', $deviceIdHeader), 0, 120);
|
||||
$candidate = $guestNameParam !== '' ? $guestNameParam : $deviceId;
|
||||
$candidate = $guestNameParam !== '' ? $guestNameParam : $deviceIdHeader;
|
||||
$normalized = $this->normalizeGuestIdentifier($candidate);
|
||||
|
||||
if ($candidate === '') {
|
||||
if ($normalized === '') {
|
||||
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
|
||||
@@ -1380,7 +1388,7 @@ class EventPublicController extends BaseController
|
||||
$photo = $shareLink->photo;
|
||||
$event = $photo->event;
|
||||
|
||||
if (! $event || $photo->status !== 'approved') {
|
||||
if (! $event || in_array($photo->status, ['hidden', 'rejected'], true)) {
|
||||
return ApiError::response(
|
||||
'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 = [
|
||||
'id' => $photo->id,
|
||||
'title' => $taskTitle,
|
||||
'emotion' => $photo->emotion ? [
|
||||
'name' => $photo->emotion->name,
|
||||
'name' => $emotionName,
|
||||
'emoji' => $photo->emotion->emoji,
|
||||
] : null,
|
||||
'likes_count' => $photo->likes()->count(),
|
||||
'created_at' => $photo->created_at?->toIso8601String(),
|
||||
'image_urls' => [
|
||||
'thumbnail' => $this->makeShareAssetUrl($shareLink, 'thumbnail'),
|
||||
'full' => $this->makeShareAssetUrl($shareLink, 'full'),
|
||||
@@ -1453,7 +1470,7 @@ class EventPublicController extends BaseController
|
||||
$photo = $shareLink->photo;
|
||||
$event = $photo->event;
|
||||
|
||||
if (! $event || $photo->status !== 'approved') {
|
||||
if (! $event || in_array($photo->status, ['hidden', 'rejected'], true)) {
|
||||
return ApiError::response(
|
||||
'photo_not_shareable',
|
||||
'Photo Not Shareable',
|
||||
@@ -2165,8 +2182,7 @@ class EventPublicController extends BaseController
|
||||
private function resolveDeviceIdentifier(Request $request): string
|
||||
{
|
||||
$deviceId = (string) $request->headers->get('X-Device-Id', '');
|
||||
$normalized = preg_replace('/[^A-Za-z0-9 _\-]/', '', $deviceId) ?? '';
|
||||
$normalized = trim(substr($normalized, 0, 120));
|
||||
$normalized = $this->normalizeGuestIdentifier($deviceId);
|
||||
|
||||
return $normalized !== '' ? $normalized : 'anonymous';
|
||||
}
|
||||
@@ -2634,6 +2650,7 @@ class EventPublicController extends BaseController
|
||||
'thumbnail_path' => $thumbUrl,
|
||||
'likes_count' => 0,
|
||||
'ingest_source' => Photo::SOURCE_GUEST_PWA,
|
||||
'status' => 'approved',
|
||||
|
||||
// Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default
|
||||
'emotion_id' => $this->resolveEmotionId($validated, $eventId),
|
||||
|
||||
@@ -244,7 +244,7 @@ class PhotoController extends Controller
|
||||
'thumbnail_path' => $watermarkedThumb,
|
||||
'width' => null, // Filled below
|
||||
'height' => null,
|
||||
'status' => 'pending', // Requires moderation
|
||||
'status' => 'approved',
|
||||
'uploader_id' => null,
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
|
||||
@@ -537,3 +537,13 @@ h4,
|
||||
background-size: 400% 400%, 400% 400%;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export function DashboardEventFocusCard({
|
||||
{t('empty.title', 'Leg mit deinem ersten Event los')}
|
||||
</CardTitle>
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -117,7 +117,7 @@ export function DashboardEventFocusCard({
|
||||
},
|
||||
{
|
||||
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.'),
|
||||
icon: ClipboardList,
|
||||
handler: onOpenTasks,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"billing": {
|
||||
"title": "Pakete & Abrechnung",
|
||||
"subtitle": "Verwalte deine gebuchten Pakete und behalte Laufzeiten im Blick.",
|
||||
@@ -146,8 +146,8 @@
|
||||
}
|
||||
},
|
||||
"billingWarning": {
|
||||
"title": "Handlungsbedarf",
|
||||
"description": "Paketwarnungen und Limits, die du im Blick behalten solltest."
|
||||
"title": "Achtung",
|
||||
"description": "Paket-Hinweise und Limits, die du im Blick behalten solltest."
|
||||
},
|
||||
"photos": {
|
||||
"moderation": {
|
||||
@@ -191,21 +191,252 @@
|
||||
}
|
||||
},
|
||||
"events": {
|
||||
"list": {
|
||||
"title": "Deine Events",
|
||||
"subtitle": "Plane Momente, die in Erinnerung bleiben. Hier verwaltest du alles rund um deine Veranstaltungen.",
|
||||
"actions": {
|
||||
"create": "Neues Event",
|
||||
"settings": "Einstellungen"
|
||||
"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?"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Übersicht",
|
||||
"empty": "Noch keine Events - starte jetzt und lege dein erstes Event an.",
|
||||
"count": "{{count}} {{count, plural, one {Event} other {Events}}} aktiv verwaltet.",
|
||||
"badge": {
|
||||
"dashboard": "Kunden-Dashboard"
|
||||
"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 & 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": {
|
||||
@@ -311,7 +542,7 @@
|
||||
},
|
||||
"tabs": {
|
||||
"tasks": "Aufgaben",
|
||||
"packs": "Mission Packs"
|
||||
"packs": "Vorlagen / Aufgaben-Bundles"
|
||||
},
|
||||
"eventStatus": "Status: {{status}}",
|
||||
"summary": {
|
||||
@@ -323,7 +554,7 @@
|
||||
},
|
||||
"library": {
|
||||
"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"
|
||||
},
|
||||
"sections": {
|
||||
@@ -354,7 +585,7 @@
|
||||
"updating": "Einstellung wird gespeichert ..."
|
||||
},
|
||||
"collections": {
|
||||
"title": "Mission Packs",
|
||||
"title": "Vorlagen / Aufgaben-Bundles",
|
||||
"subtitle": "Importiere Aufgaben-Kollektionen, die zu deinem Event passen.",
|
||||
"viewAll": "Alle Kollektionen ansehen",
|
||||
"errorTitle": "Kollektionen nicht verfügbar",
|
||||
@@ -365,9 +596,9 @@
|
||||
"custom": "Custom",
|
||||
"recommended": "Empfohlen",
|
||||
"optional": "Optional",
|
||||
"importCta": "Mission Pack importieren",
|
||||
"importCta": "Aufgaben-Set importieren",
|
||||
"imported": "Kollektion erfolgreich importiert",
|
||||
"importFailed": "Mission Pack konnte nicht importiert werden",
|
||||
"importFailed": "Aufgaben-Set konnte nicht importiert werden",
|
||||
"error": "Kollektionen konnten nicht geladen werden."
|
||||
},
|
||||
"toolkit": {
|
||||
@@ -449,15 +680,15 @@
|
||||
},
|
||||
"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",
|
||||
"emotionsCount": "{{count}} aktiviert",
|
||||
"emotionsEmpty": "Aktiviere Emotionen, um Aufgaben zu kategorisieren.",
|
||||
"emotionsCta": "Emotionen verwalten",
|
||||
"collectionsTitle": "Mission Packs",
|
||||
"collectionsTitle": "Vorlagen / Aufgaben-Bundles",
|
||||
"collectionsCount": "{{count}} Aufgaben",
|
||||
"collectionsEmpty": "Noch keine empfohlenen Mission Packs.",
|
||||
"collectionsCta": "Mission Packs anzeigen"
|
||||
"collectionsEmpty": "Noch keine empfohlenen Vorlagen / Aufgaben-Bundles.",
|
||||
"collectionsCta": "Aufgaben-Sets anzeigen"
|
||||
},
|
||||
"customizer": {
|
||||
"title": "QR-Code anpassen",
|
||||
@@ -783,255 +1014,6 @@
|
||||
"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": {
|
||||
"actions": {
|
||||
"back": "Zurück zur Übersicht",
|
||||
@@ -1045,7 +1027,7 @@
|
||||
},
|
||||
"tabs": {
|
||||
"tasks": "Aufgaben",
|
||||
"packs": "Mission Packs"
|
||||
"packs": "Vorlagen / Aufgaben-Bundles"
|
||||
},
|
||||
"eventStatus": "Status: {{status}}",
|
||||
"modes": {
|
||||
@@ -1066,7 +1048,7 @@
|
||||
},
|
||||
"library": {
|
||||
"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"
|
||||
},
|
||||
"sections": {
|
||||
@@ -1096,7 +1078,7 @@
|
||||
"errorTitle": "Kollektionen nicht verfügbar",
|
||||
"import": "Kollektion importieren",
|
||||
"error": "Kollektionen konnten nicht geladen werden.",
|
||||
"title": "Mission Packs",
|
||||
"title": "Vorlagen / Aufgaben-Bundles",
|
||||
"subtitle": "Importiere Aufgaben-Kollektionen, die zu deinem Event passen.",
|
||||
"viewAll": "Alle Kollektionen ansehen",
|
||||
"empty": "Keine empfohlenen Kollektionen gefunden.",
|
||||
@@ -1106,9 +1088,9 @@
|
||||
"custom": "Custom",
|
||||
"recommended": "Empfohlen",
|
||||
"optional": "Optional",
|
||||
"importCta": "Mission Pack importieren",
|
||||
"importCta": "Aufgaben-Set importieren",
|
||||
"imported": "Kollektion erfolgreich importiert",
|
||||
"importFailed": "Mission Pack konnte nicht importiert werden"
|
||||
"importFailed": "Aufgaben-Set konnte nicht importiert werden"
|
||||
}
|
||||
},
|
||||
"collections": {
|
||||
@@ -1277,8 +1259,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
,
|
||||
},
|
||||
"settings": {
|
||||
"hero": {
|
||||
"badge": "Administration",
|
||||
@@ -1538,14 +1519,9 @@
|
||||
"cta": "Erste Task erstellen"
|
||||
}
|
||||
},
|
||||
"billingWarning": {
|
||||
"title": "Achtung",
|
||||
"description": "Paket-Hinweise und Limits, die du im Blick behalten solltest."
|
||||
},
|
||||
"eventForm": {
|
||||
"errors": {
|
||||
"nameRequired": "Bitte gib einen Eventnamen ein.",
|
||||
"typeRequired": "Bitte wähle einen Event-Typ aus."
|
||||
"notice": "Hinweis"
|
||||
},
|
||||
"titles": {
|
||||
"create": "Neues Event erstellen",
|
||||
@@ -1583,9 +1559,6 @@
|
||||
"saving": "Speichert",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"errors": {
|
||||
"notice": "Hinweis"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
@@ -1625,4 +1598,4 @@
|
||||
"ctaFallback": "Events ansehen"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -884,7 +884,7 @@ function BrandingMissionCard({
|
||||
<SectionCard className="space-y-4">
|
||||
<SectionHeader
|
||||
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.')}
|
||||
/>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
@@ -908,7 +908,7 @@ function BrandingMissionCard({
|
||||
</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="text-xs uppercase tracking-[0.3em]">{t('events.branding.collectionsTitle', 'Aufgaben-Sets')}</p>
|
||||
<p className="mt-1 text-base font-semibold">
|
||||
{event.event_type?.name ?? t('events.branding.collectionsFallback', 'Empfohlene Story')}
|
||||
</p>
|
||||
@@ -965,7 +965,7 @@ function BrandingMissionCard({
|
||||
{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')}
|
||||
{t('events.branding.collectionsImport', 'Aufgaben-Set importieren')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function EventTasksPage() {
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [modeSaving, setModeSaving] = React.useState(false);
|
||||
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 [debouncedTaskSearch, setDebouncedTaskSearch] = React.useState('');
|
||||
const [difficultyFilter, setDifficultyFilter] = React.useState<TenantTask['difficulty'] | ''>('');
|
||||
@@ -88,12 +88,14 @@ export default function EventTasksPage() {
|
||||
const [newTaskEmotionId, setNewTaskEmotionId] = React.useState<number | null>(null);
|
||||
const [newTaskDifficulty, setNewTaskDifficulty] = React.useState<TenantTask['difficulty'] | ''>('');
|
||||
const [creatingTask, setCreatingTask] = React.useState(false);
|
||||
const [quickAddOpen, setQuickAddOpen] = React.useState(false);
|
||||
const [draggingId, setDraggingId] = React.useState<number | null>(null);
|
||||
const [selectedAssignedIds, setSelectedAssignedIds] = React.useState<number[]>([]);
|
||||
const [selectedAvailableIds, setSelectedAvailableIds] = React.useState<number[]>([]);
|
||||
const [batchSaving, setBatchSaving] = React.useState(false);
|
||||
const [inlineSavingId, setInlineSavingId] = React.useState<number | null>(null);
|
||||
const [emotionFilterOpen, setEmotionFilterOpen] = React.useState(false);
|
||||
const libraryRef = React.useRef<HTMLDivElement | null>(null);
|
||||
React.useEffect(() => {
|
||||
const handle = window.setTimeout(() => setDebouncedTaskSearch(taskSearch.trim().toLowerCase()), 180);
|
||||
return () => window.clearTimeout(handle);
|
||||
@@ -333,6 +335,7 @@ export default function EventTasksPage() {
|
||||
setNewTaskDescription('');
|
||||
setNewTaskEmotionId(null);
|
||||
setNewTaskDifficulty('');
|
||||
setQuickAddOpen(false);
|
||||
await hydrateTasks(event);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
@@ -424,14 +427,15 @@ export default function EventTasksPage() {
|
||||
await importTaskCollection(collection.id, slug);
|
||||
toast.success(
|
||||
t('collections.imported', {
|
||||
defaultValue: 'Mission Pack "{{name}}" importiert.',
|
||||
defaultValue: 'Aufgaben-Set "{{name}}" importiert.',
|
||||
name: collection.name,
|
||||
}),
|
||||
);
|
||||
setTab('tasks');
|
||||
await hydrateTasks(event);
|
||||
} catch (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 {
|
||||
setImportingCollectionId(null);
|
||||
@@ -447,30 +451,17 @@ export default function EventTasksPage() {
|
||||
return mode !== 'photo_only';
|
||||
}, [event?.engagement_mode, event?.settings]);
|
||||
|
||||
const summaryBadges = !loading && event ? (
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Badge className="flex items-center gap-2 rounded-full bg-slate-900 text-white">
|
||||
<span className="text-xs uppercase tracking-wide text-white/80">
|
||||
{t('summary.assigned', 'Zugeordnete Tasks')}
|
||||
</span>
|
||||
<span className="text-sm font-semibold">{assignedTasks.length}</span>
|
||||
</Badge>
|
||||
<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">
|
||||
{t('summary.library', 'Bibliothek')}
|
||||
</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;
|
||||
const hasSelection = selectedAssignedIds.length > 0 || selectedAvailableIds.length > 0;
|
||||
const tasksFirst = assignedTasks.length > 0;
|
||||
const tabOrder: Array<'tasks' | 'packs' | 'emotions'> = tasksFirst ? ['tasks', 'packs', 'emotions'] : ['packs', 'tasks', 'emotions'];
|
||||
const prevAssignedRef = React.useRef(assignedTasks.length);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (prevAssignedRef.current === 0 && assignedTasks.length > 0) {
|
||||
setTab('tasks');
|
||||
}
|
||||
prevAssignedRef.current = assignedTasks.length;
|
||||
}, [assignedTasks.length, setTab]);
|
||||
|
||||
async function handleModeChange(checked: boolean) {
|
||||
if (!event || !slug) return;
|
||||
@@ -658,8 +649,6 @@ export default function EventTasksPage() {
|
||||
tabs={eventTabs}
|
||||
currentTabKey="tasks"
|
||||
>
|
||||
{summaryBadges}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{tDashboard('alerts.errorTitle', 'Fehler')}</AlertTitle>
|
||||
@@ -676,17 +665,33 @@ export default function EventTasksPage() {
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Tabs value={tab} onValueChange={(value) => setTab(value as 'tasks' | 'packs')} className="space-y-6">
|
||||
<TabsList className="grid gap-2 rounded-2xl bg-slate-100/80 p-1 sm:grid-cols-2">
|
||||
<TabsTrigger value="tasks">{t('tabs.tasks', 'Aufgaben')}</TabsTrigger>
|
||||
<TabsTrigger value="packs">{t('tabs.packs', 'Mission Packs')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<Tabs value={tab} onValueChange={(value) => setTab(value as 'tasks' | 'packs' | 'emotions')} className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<TabsList className="grid flex-1 gap-2 rounded-2xl bg-slate-100/80 p-1 sm:grid-cols-3">
|
||||
{tabOrder.map((key) => (
|
||||
<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">
|
||||
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
||||
<CardHeader>
|
||||
<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-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<CardHeader className="space-y-4">
|
||||
<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-start lg:justify-between">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{t('modes.title', 'Aufgaben & Foto-Modus')}
|
||||
</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.photoOnlyHint', 'Der Foto-Modus ist aktiv. Gäste können Fotos hochladen, sehen aber keine Aufgaben.')}
|
||||
</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 className="flex items-center gap-3">
|
||||
<span className="text-xs uppercase tracking-wide text-slate-500">
|
||||
{tasksEnabled ? t('modes.tasks', 'Aufgaben aktiv') : t('modes.photoOnly', 'Foto-Modus')}
|
||||
</span>
|
||||
<Switch
|
||||
checked={tasksEnabled}
|
||||
onCheckedChange={handleModeChange}
|
||||
disabled={modeSaving}
|
||||
aria-label={t('modes.switchLabel', 'Aufgaben aktivieren/deaktivieren')}
|
||||
/>
|
||||
<div className="flex flex-col items-start gap-2 lg:items-end">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs uppercase tracking-wide text-slate-500">
|
||||
{tasksEnabled ? t('modes.tasks', 'Aufgaben aktiv') : t('modes.photoOnly', 'Foto-Modus')}
|
||||
</span>
|
||||
<Switch
|
||||
checked={tasksEnabled}
|
||||
onCheckedChange={handleModeChange}
|
||||
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>
|
||||
{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}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
onClick={() => setQuickAddOpen(true)}
|
||||
>
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
{t('actions.addCustom', 'Eigene Aufgabe hinzufügen')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</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
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
@@ -939,73 +951,17 @@ export default function EventTasksPage() {
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
|
||||
<PlusCircle className="h-4 w-4 text-emerald-500" />
|
||||
{t('sections.library.title', 'Tasks aus Bibliothek hinzufügen')}
|
||||
</h3>
|
||||
<div className="rounded-2xl border border-emerald-100 bg-emerald-50/60 p-3 shadow-inner">
|
||||
<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 className="flex flex-wrap items-center justify-between gap-2" ref={libraryRef} id="library-section">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-slate-900">
|
||||
<PlusCircle className="h-4 w-4 text-emerald-500" />
|
||||
{t('sections.library.title', 'Tasks aus Bibliothek hinzufügen')}
|
||||
</h3>
|
||||
</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">
|
||||
<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 ? (
|
||||
<EmptyState message={t('sections.library.empty', 'Keine Tasks in der Bibliothek gefunden.')} />
|
||||
) : (
|
||||
@@ -1087,58 +1043,54 @@ export default function EventTasksPage() {
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</Card>
|
||||
|
||||
<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="flex flex-wrap items-center gap-3 text-sm text-slate-700">
|
||||
<span className="font-semibold text-slate-900">
|
||||
{t('sections.bulk.title', 'Batch-Aktionen')}
|
||||
</span>
|
||||
<Badge variant="outline" className="border-slate-200 text-slate-700">
|
||||
{t('sections.bulk.assignedSelected', {
|
||||
defaultValue: '{{count}} ausgewählt (Zugeordnet)',
|
||||
count: selectedAssignedIds.length,
|
||||
})}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-slate-200 text-slate-700">
|
||||
{t('sections.bulk.librarySelected', {
|
||||
defaultValue: '{{count}} ausgewählt (Bibliothek)',
|
||||
count: selectedAvailableIds.length,
|
||||
})}
|
||||
</Badge>
|
||||
{hasSelection ? (
|
||||
<div className="fixed inset-x-0 bottom-4 z-30 flex justify-center px-4">
|
||||
<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">
|
||||
<div className="flex flex-1 flex-wrap items-center gap-2 text-sm text-slate-700">
|
||||
<Badge variant="outline" className="border-slate-200 text-slate-700">
|
||||
{t('sections.bulk.assignedSelected', {
|
||||
defaultValue: '{{count}} ausgewählt (Zugeordnet)',
|
||||
count: selectedAssignedIds.length,
|
||||
})}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-slate-200 text-slate-700">
|
||||
{t('sections.bulk.librarySelected', {
|
||||
defaultValue: '{{count}} ausgewählt (Bibliothek)',
|
||||
count: selectedAvailableIds.length,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
<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 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={() => navigate(buildEngagementTabPath('tasks'))}
|
||||
>
|
||||
{t('library.open', 'Aufgaben-Bibliothek öffnen')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EmotionsCard
|
||||
emotions={relevantEmotions}
|
||||
emotionsLoading={emotionsLoading}
|
||||
emotionsError={emotionsError}
|
||||
onOpenEmotions={() => setEmotionsModalOpen(true)}
|
||||
/>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
<TabsContent value="packs">
|
||||
<MissionPackGrid
|
||||
@@ -1150,10 +1102,96 @@ export default function EventTasksPage() {
|
||||
onViewAll={() => navigate(buildEngagementTabPath('collections'))}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="emotions">
|
||||
<EmotionsCard
|
||||
emotions={relevantEmotions}
|
||||
emotionsLoading={emotionsLoading}
|
||||
emotionsError={emotionsError}
|
||||
onOpenEmotions={() => setEmotionsModalOpen(true)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</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}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
@@ -1409,14 +1447,14 @@ function MissionPackGrid({
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-base text-slate-900">
|
||||
<Layers className="h-5 w-5 text-pink-500" />
|
||||
{t('title', 'Mission Packs')}
|
||||
{t('title', 'Vorlagen / Aufgaben-Bundles')}
|
||||
</CardTitle>
|
||||
<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>
|
||||
</div>
|
||||
<Button variant="outline" onClick={onViewAll}>
|
||||
{t('viewAll', 'Alle Kollektionen ansehen')}
|
||||
{t('viewAll', 'Alle Sets ansehen')}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -1463,7 +1501,7 @@ function MissionPackGrid({
|
||||
{importingId === collection.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
t('importCta', 'Mission Pack importieren')
|
||||
t('importCta', 'Aufgaben-Set importieren')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function BottomNav() {
|
||||
|
||||
return (
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -175,7 +175,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
||||
const guestName = identity?.name && identity?.hydrated ? identity.name : null;
|
||||
return (
|
||||
<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}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
@@ -207,7 +207,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
||||
|
||||
if (status === 'loading') {
|
||||
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="flex items-center gap-2">
|
||||
<AppearanceToggleDropdown />
|
||||
@@ -225,7 +225,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
|
||||
statsContext && statsContext.eventKey === eventToken ? statsContext : undefined;
|
||||
return (
|
||||
<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}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -342,7 +342,6 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
button: 'Teilen',
|
||||
copyLink: 'Link kopieren',
|
||||
copySuccess: 'Link kopiert!',
|
||||
copyError: 'Link konnte nicht kopiert werden.',
|
||||
manualPrompt: 'Link kopieren',
|
||||
openEvent: 'Event öffnen',
|
||||
loading: 'Moment wird geladen...',
|
||||
@@ -986,7 +985,6 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
defaultEvent: 'A special moment',
|
||||
button: 'Share',
|
||||
copyLink: 'Copy link',
|
||||
copySuccess: 'Link copied!',
|
||||
copyError: 'Link could not be copied.',
|
||||
manualPrompt: 'Copy link',
|
||||
openEvent: 'Open event',
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { router } from './router';
|
||||
import '../../css/app.css';
|
||||
import { initializeTheme } from '@/hooks/use-appearance';
|
||||
import { ToastProvider } from './components/ToastHost';
|
||||
import { LocaleProvider } from './i18n/LocaleContext';
|
||||
import { enableGuestDemoMode, shouldEnableGuestDemoMode } from './demo/demoMode';
|
||||
|
||||
initializeTheme();
|
||||
@@ -13,35 +9,71 @@ if (shouldEnableGuestDemoMode()) {
|
||||
enableGuestDemoMode();
|
||||
}
|
||||
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)
|
||||
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(() => {}));
|
||||
window.addEventListener('online', () => {
|
||||
const shareRoot = async () => {
|
||||
const { SharedPhotoStandalone } = await import('./pages/SharedPhotoPage');
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<SharedPhotoStandalone />
|
||||
</React.StrictMode>
|
||||
);
|
||||
};
|
||||
|
||||
const appRoot = async () => {
|
||||
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(() => {}));
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { fetchPhotoShare } from '../services/photosApi';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import { useToast } from '../components/ToastHost';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Loader2, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface ShareResponse {
|
||||
slug: string;
|
||||
@@ -14,16 +11,29 @@ interface ShareResponse {
|
||||
id: number;
|
||||
title?: string;
|
||||
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 };
|
||||
};
|
||||
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() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
return <SharedPhotoView slug={slug} />;
|
||||
}
|
||||
|
||||
function SharedPhotoView({ slug }: ShareProps) {
|
||||
const [state, setState] = React.useState<{
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
@@ -36,34 +46,22 @@ export default function SharedPhotoPage() {
|
||||
|
||||
setState({ loading: true, error: null, data: null });
|
||||
fetchPhotoShare(slug)
|
||||
.then((data) => {
|
||||
if (!active) return;
|
||||
setState({ loading: false, error: null, data });
|
||||
})
|
||||
.then((data) => { if (active) setState({ loading: false, error: null, data }); })
|
||||
.catch((error: unknown) => {
|
||||
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 () => {
|
||||
active = false;
|
||||
};
|
||||
}, [slug, t]);
|
||||
|
||||
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]);
|
||||
}, [slug]);
|
||||
|
||||
if (state.loading) {
|
||||
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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -71,29 +69,30 @@ export default function SharedPhotoPage() {
|
||||
if (state.error || !state.data) {
|
||||
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">
|
||||
<p className="text-lg font-semibold text-foreground">{t('share.expiredTitle', 'Link abgelaufen')}</p>
|
||||
<p className="text-sm text-muted-foreground max-w-md">{state.error ?? t('share.expiredDescription', 'Dieses Foto ist nicht mehr verfügbar.')}</p>
|
||||
<Button asChild>
|
||||
<Link to="/event">{t('share.openEvent', 'Event öffnen')}</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 text-rose-600">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<p className="text-lg font-semibold text-foreground">Link abgelaufen</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
{state.error ?? 'Dieses Foto ist nicht mehr verfügbar.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { data } = state;
|
||||
const chips = buildChips(data);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-white via-pink-50 to-white px-4 py-8">
|
||||
<div className="mx-auto flex max-w-2xl flex-col gap-5">
|
||||
<div className="rounded-3xl border border-white/60 bg-white/80 p-6 text-center shadow">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">{t('share.title', 'Geteiltes Foto')}</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold text-foreground">{data.event?.name ?? t('share.defaultEvent', 'Ein besonderer Moment')}</h1>
|
||||
{data.photo.title && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{data.photo.title}</p>
|
||||
)}
|
||||
<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 w-full max-w-xl flex-col gap-6">
|
||||
<div className="rounded-3xl border border-white/60 bg-white/90 p-5 text-center shadow-sm">
|
||||
<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 ?? 'Ein besonderer Moment'}</h1>
|
||||
{data.photo.title && <p className="mt-1 text-sm text-muted-foreground">{data.photo.title}</p>}
|
||||
</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
|
||||
src={data.photo.image_urls.full}
|
||||
alt={data.photo.title ?? 'Foto'}
|
||||
@@ -102,21 +101,42 @@ export default function SharedPhotoPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{data.photo.emotion && (
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{data.photo.emotion.emoji} {data.photo.emotion.name}
|
||||
</p>
|
||||
{chips.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{chips.map((chip) => (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
Sparkles,
|
||||
Zap,
|
||||
ZapOff,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
} from 'lucide-react';
|
||||
import { getEventPackage, type EventPackage } from '../services/eventApi';
|
||||
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
||||
@@ -35,6 +37,7 @@ import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadE
|
||||
import { useEventStats } from '../context/EventStatsContext';
|
||||
import { useEventBranding } from '../context/EventBrandingContext';
|
||||
import { compressPhoto, formatBytes } from '../lib/image';
|
||||
import { useGuestIdentity } from '../context/GuestIdentityContext';
|
||||
|
||||
interface Task {
|
||||
id: number;
|
||||
@@ -112,6 +115,7 @@ export default function UploadPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { markCompleted } = useGuestTaskProgress(token);
|
||||
const identity = useGuestIdentity();
|
||||
const { t, locale } = useTranslation();
|
||||
const stats = useEventStats();
|
||||
const { branding } = useEventBranding();
|
||||
@@ -143,6 +147,7 @@ export default function UploadPage() {
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
|
||||
const [immersiveMode, setImmersiveMode] = useState(false);
|
||||
|
||||
const [errorDialog, setErrorDialog] = useState<UploadErrorDialog | null>(null);
|
||||
const [taskDetailsExpanded, setTaskDetailsExpanded] = useState(false);
|
||||
@@ -155,6 +160,19 @@ const [canUpload, setCanUpload] = useState(true);
|
||||
[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>(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.localStorage.getItem(primerStorageKey) !== '1';
|
||||
@@ -374,7 +392,7 @@ const [canUpload, setCanUpload] = useState(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!task || mode === 'uploading') return;
|
||||
if (mode === 'uploading') return;
|
||||
|
||||
try {
|
||||
setPermissionState('prompt');
|
||||
@@ -401,15 +419,15 @@ const [canUpload, setCanUpload] = useState(true);
|
||||
setPermissionMessage(t('upload.cameraError.explanation'));
|
||||
}
|
||||
}
|
||||
}, [attachStreamToVideo, createConstraint, mode, preferences.facingMode, stopStream, supportsCamera, task, t]);
|
||||
}, [attachStreamToVideo, createConstraint, mode, preferences.facingMode, stopStream, supportsCamera, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!task || loadingTask) return;
|
||||
if (loadingTask) return;
|
||||
startCamera();
|
||||
return () => {
|
||||
stopStream();
|
||||
};
|
||||
}, [task, loadingTask, startCamera, stopStream, preferences.facingMode]);
|
||||
}, [loadingTask, startCamera, stopStream, preferences.facingMode]);
|
||||
|
||||
// Countdown live region updates
|
||||
useEffect(() => {
|
||||
@@ -601,8 +619,9 @@ const [canUpload, setCanUpload] = useState(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const photoId = await uploadPhoto(eventKey, fileForUpload, task.id, emotionSlug || undefined, {
|
||||
const photoId = await uploadPhoto(eventKey, fileForUpload, task?.id, emotionSlug || undefined, {
|
||||
maxRetries: 2,
|
||||
guestName: identity.name || undefined,
|
||||
onProgress: (percent) => {
|
||||
setUploadProgress(Math.max(15, Math.min(98, percent)));
|
||||
setStatusMessage(t('upload.status.uploading'));
|
||||
@@ -616,7 +635,18 @@ const [canUpload, setCanUpload] = useState(true);
|
||||
});
|
||||
setUploadProgress(100);
|
||||
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();
|
||||
navigateAfterUpload(photoId);
|
||||
} catch (error: unknown) {
|
||||
@@ -648,22 +678,49 @@ const [canUpload, setCanUpload] = useState(true);
|
||||
} finally {
|
||||
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;
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploadError(null);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setReviewPhoto({ dataUrl: reader.result as string, file });
|
||||
setMode('review');
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setUploadError(t('upload.galleryPickError'));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
setUploadWarning(null);
|
||||
setStatusMessage(t('upload.status.optimizing', 'Foto wird optimiert…'));
|
||||
|
||||
let prepared = file;
|
||||
try {
|
||||
prepared = await compressPhoto(file, {
|
||||
maxEdge: 2400,
|
||||
targetBytes: 4_000_000,
|
||||
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]);
|
||||
|
||||
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" />}
|
||||
{t('upload.controls.toggleFlash')}
|
||||
</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 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 {
|
||||
if (!value) {
|
||||
return t('upload.hud.relative.now');
|
||||
|
||||
@@ -95,6 +95,7 @@ export async function likePhoto(id: number): Promise<number> {
|
||||
}
|
||||
|
||||
type UploadOptions = {
|
||||
guestName?: string;
|
||||
onProgress?: (percent: number) => void;
|
||||
signal?: AbortSignal;
|
||||
maxRetries?: number;
|
||||
@@ -112,6 +113,7 @@ export async function uploadPhoto(
|
||||
formData.append('photo', file, file.name || `photo-${Date.now()}.jpg`);
|
||||
if (taskId) formData.append('task_id', taskId.toString());
|
||||
if (emotionSlug) formData.append('emotion_slug', emotionSlug);
|
||||
if (options.guestName) formData.append('guest_name', options.guestName);
|
||||
formData.append('device_id', getDeviceId());
|
||||
|
||||
const maxRetries = options.maxRetries ?? 2;
|
||||
|
||||
@@ -30,10 +30,6 @@ i18n
|
||||
backend: {
|
||||
loadPath: '/lang/{{lng}}/{{ns}}.json',
|
||||
},
|
||||
detection: {
|
||||
order: [],
|
||||
caches: ['localStorage'],
|
||||
},
|
||||
react: {
|
||||
useSuspense: false,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user