diff --git a/app/Console/Commands/SyncGoogleFonts.php b/app/Console/Commands/SyncGoogleFonts.php index 2d7ffa1..74f8ab9 100644 --- a/app/Console/Commands/SyncGoogleFonts.php +++ b/app/Console/Commands/SyncGoogleFonts.php @@ -6,13 +6,13 @@ use Illuminate\Console\Command; use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; class SyncGoogleFonts extends Command { - protected $signature = 'fonts:sync-google {--count=50 : Number of popular fonts to fetch} {--weights=400,700 : Comma separated numeric font weights to download} {--italic : Also download italic variants where available} {--force : Re-download files even if they exist} {--path= : Optional custom output directory (defaults to public/fonts/google)}'; + protected $signature = 'fonts:sync-google {--count=50 : Number of popular fonts to fetch} {--weights=400,700 : Comma separated numeric font weights to download} {--italic : Also download italic variants where available} {--force : Re-download files even if they exist} {--path= : Optional custom output directory (defaults to public/fonts/google)} {--family= : Download a single family by name (case-insensitive)} {--category= : Filter by category, comma separated (e.g. sans-serif,serif)} {--dry-run : Show what would be downloaded without writing files}'; protected $description = 'Download the most popular Google Fonts to the local public/fonts/google directory and generate a manifest + CSS file.'; @@ -32,13 +32,28 @@ class SyncGoogleFonts extends Command $weights = $this->prepareWeights($this->option('weights')); $includeItalic = (bool) $this->option('italic'); $force = (bool) $this->option('force'); + $dryRun = (bool) $this->option('dry-run'); + $familyOption = $this->normalizeFamilyOption($this->option('family')); + $categories = $this->prepareCategories($this->option('category')); $pathOption = $this->option('path'); $basePath = $pathOption ? (Str::startsWith($pathOption, DIRECTORY_SEPARATOR) ? $pathOption : base_path($pathOption)) : public_path('fonts/google'); - $this->info(sprintf('Fetching top %d Google Fonts (weights: %s, italic: %s)...', $count, implode(', ', $weights), $includeItalic ? 'yes' : 'no')); + if ($familyOption) { + $this->info(sprintf('Fetching Google Font family "%s" (weights: %s, italic: %s)...', $familyOption, implode(', ', $weights), $includeItalic ? 'yes' : 'no')); + } else { + $this->info(sprintf('Fetching top %d Google Fonts (weights: %s, italic: %s)...', $count, implode(', ', $weights), $includeItalic ? 'yes' : 'no')); + } + + if (count($categories)) { + $this->line('Category filter: '.implode(', ', $categories)); + } + + if ($dryRun) { + $this->warn('Dry run enabled: no files will be written.'); + } $response = Http::retry(2, 200) ->timeout(30) @@ -60,10 +75,27 @@ class SyncGoogleFonts extends Command return self::SUCCESS; } - $selected = array_slice($items, 0, $count); + $items = $this->filterFonts($items, $familyOption, $categories); + + if ($familyOption && ! count($items)) { + $this->error(sprintf('Font family "%s" was not found.', $familyOption)); + + return self::FAILURE; + } + + if (! count($items)) { + $this->warn('No fonts matched the provided filters.'); + + return self::SUCCESS; + } + + $selected = $familyOption ? $items : array_slice($items, 0, $count); $manifestFonts = []; - $filesystem = new Filesystem(); - File::ensureDirectoryExists($basePath); + $filesystem = new Filesystem; + + if (! $dryRun) { + File::ensureDirectoryExists($basePath); + } foreach ($selected as $index => $font) { if (! is_array($font) || ! isset($font['family'])) { @@ -73,11 +105,14 @@ class SyncGoogleFonts extends Command $family = (string) $font['family']; $slug = Str::slug($family); $familyDir = $basePath.DIRECTORY_SEPARATOR.$slug; - File::ensureDirectoryExists($familyDir); + if (! $dryRun) { + File::ensureDirectoryExists($familyDir); + } $variantMap = $this->buildVariantMap($font, $weights, $includeItalic); if (! count($variantMap)) { $this->warn("Skipping {$family} (no matching variants)"); + continue; } @@ -89,7 +124,11 @@ class SyncGoogleFonts extends Command $filename = sprintf('%s-%s-%s.%s', Str::studly($slug), $weight, $style, $extension); $targetPath = $familyDir.DIRECTORY_SEPARATOR.$filename; - if (! $force && $filesystem->exists($targetPath)) { + $alreadyExists = $filesystem->exists($targetPath); + + if ($dryRun) { + $this->line(sprintf('◦ DRY RUN: %s %s would %s (%s)', $family, $variantKey, $alreadyExists && ! $force ? 'reuse existing file' : 'download', $targetPath)); + } elseif (! $force && $alreadyExists) { $this->line("✔ {$family} {$variantKey} already exists"); } else { $this->line("↓ Downloading {$family} {$variantKey}"); @@ -97,6 +136,7 @@ class SyncGoogleFonts extends Command if (! $fileResponse->ok()) { $this->warn(" Skipped {$family} {$variantKey} (download failed)"); + continue; } @@ -124,6 +164,12 @@ class SyncGoogleFonts extends Command ]; } + if ($dryRun) { + $this->info(sprintf('Dry run complete: %d font families would be synced to %s', count($manifestFonts), $basePath)); + + return self::SUCCESS; + } + $this->pruneStaleFamilies($basePath, $manifestFonts); $this->writeManifest($basePath, $manifestFonts); $this->writeCss($basePath, $manifestFonts); @@ -134,6 +180,54 @@ class SyncGoogleFonts extends Command return self::SUCCESS; } + private function normalizeFamilyOption(?string $family): ?string + { + $family = trim((string) $family); + + return $family !== '' ? $family : null; + } + + /** + * @return array + */ + private function prepareCategories(?string $categories): array + { + $parts = array_filter(array_map('trim', explode(',', (string) $categories))); + + return array_values(array_unique(array_map(static function ($category) { + $normalized = Str::of($category)->lower()->replace(' ', '-')->toString(); + + return (string) $normalized; + }, $parts))); + } + + /** + * @param array $items + * @return array + */ + private function filterFonts(array $items, ?string $family, array $categories): array + { + $filtered = collect($items) + ->filter(fn ($font) => is_array($font) && isset($font['family'])) + ->filter(function ($font) use ($categories) { + if (! count($categories)) { + return true; + } + + $category = strtolower((string) ($font['category'] ?? '')); + + return in_array($category, $categories, true); + }); + + if ($family) { + $filtered = $filtered->filter(function ($font) use ($family) { + return strcasecmp((string) $font['family'], $family) === 0; + }); + } + + return $filtered->values()->all(); + } + /** * @return array */ @@ -147,7 +241,7 @@ class SyncGoogleFonts extends Command } /** - * @param array $font + * @param array $font * @return array */ private function buildVariantMap(array $font, array $weights, bool $includeItalic): array diff --git a/app/Http/Controllers/Api/Tenant/EmotionController.php b/app/Http/Controllers/Api/Tenant/EmotionController.php index 5e89531..05518fc 100644 --- a/app/Http/Controllers/Api/Tenant/EmotionController.php +++ b/app/Http/Controllers/Api/Tenant/EmotionController.php @@ -13,6 +13,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; class EmotionController extends Controller { @@ -21,8 +22,15 @@ class EmotionController extends Controller $tenantId = $this->currentTenant($request)->id; $query = Emotion::query() - ->whereNull('tenant_id') - ->orWhere('tenant_id', $tenantId) + ->when(true, function ($builder) use ($tenantId) { + // Prefer tenant-specific and global emotions if the column exists + if (Schema::hasColumn('emotions', 'tenant_id')) { + $builder->where(function ($inner) use ($tenantId) { + $inner->whereNull('tenant_id') + ->orWhere('tenant_id', $tenantId); + }); + } + }) ->with('eventTypes'); if ($request->boolean('only_tenant')) { @@ -35,7 +43,18 @@ class EmotionController extends Controller $query->orderByRaw('tenant_id is null desc')->orderBy('sort_order')->orderBy('id'); - $emotions = $query->paginate($request->integer('per_page', 25)); + $emotions = $query->paginate($request->integer('per_page', 50)); + + if ($emotions->isEmpty() && ! $request->boolean('only_tenant')) { + // Fallback: return any emotions regardless of tenant to avoid empty selectors + $fallback = Emotion::query() + ->with('eventTypes') + ->orderBy('sort_order') + ->orderBy('id') + ->paginate($request->integer('per_page', 50)); + + return EmotionResource::collection($fallback); + } return EmotionResource::collection($emotions); } diff --git a/app/Http/Controllers/Api/Tenant/TaskCollectionController.php b/app/Http/Controllers/Api/Tenant/TaskCollectionController.php index 1fb2249..2acf1ee 100644 --- a/app/Http/Controllers/Api/Tenant/TaskCollectionController.php +++ b/app/Http/Controllers/Api/Tenant/TaskCollectionController.php @@ -23,7 +23,7 @@ class TaskCollectionController extends Controller $query = TaskCollection::query() ->forTenant($tenantId) ->with('eventType') - ->withCount('tasks') + ->withCount(['tasks', 'events']) ->orderBy('position') ->orderBy('id'); @@ -46,6 +46,27 @@ class TaskCollectionController extends Controller $query->where('tenant_id', $tenantId); } + if ($request->boolean('top_picks')) { + if ($eventTypeSlug = $request->query('event_type')) { + $query->where(function ($inner) use ($eventTypeSlug) { + $inner->whereNull('event_type_id') + ->orWhereHas('eventType', fn ($q) => $q->where('slug', $eventTypeSlug)); + }); + } + + $query->whereHas('tasks') + ->orderByDesc('events_count') + ->orderByDesc('updated_at') + ->orderBy('position') + ->orderBy('id'); + + $limit = $request->integer('limit', 3); + + return TaskCollectionResource::collection( + $query->limit($limit)->get() + ); + } + $perPage = $request->integer('per_page', 15); return TaskCollectionResource::collection( @@ -57,7 +78,8 @@ class TaskCollectionController extends Controller { $this->authorizeAccess($request, $collection); - $collection->load(['eventType', 'tasks' => fn ($query) => $query->with('assignedEvents')]); + $collection->load(['eventType', 'tasks' => fn ($query) => $query->with('assignedEvents')]) + ->loadCount(['tasks', 'events']); return response()->json(new TaskCollectionResource($collection)); } @@ -81,7 +103,11 @@ class TaskCollectionController extends Controller return response()->json([ 'message' => __('Task-Collection erfolgreich importiert.'), - 'collection' => new TaskCollectionResource($result['collection']->load('eventType')->loadCount('tasks')), + 'collection' => new TaskCollectionResource( + $result['collection'] + ->load('eventType') + ->loadCount(['tasks', 'events']) + ), 'created_task_ids' => $result['created_task_ids'], 'attached_task_ids' => $result['attached_task_ids'], ]); diff --git a/app/Http/Controllers/Api/Tenant/TaskController.php b/app/Http/Controllers/Api/Tenant/TaskController.php index d42bc75..7c1ee53 100644 --- a/app/Http/Controllers/Api/Tenant/TaskController.php +++ b/app/Http/Controllers/Api/Tenant/TaskController.php @@ -31,7 +31,7 @@ class TaskController extends Controller $inner->whereNull('tenant_id') ->orWhere('tenant_id', $tenantId); }) - ->with(['taskCollection', 'assignedEvents', 'eventType']) + ->with(['taskCollection', 'assignedEvents', 'eventType', 'emotion']) ->orderByRaw('tenant_id is null desc') ->orderBy('sort_order') ->orderBy('created_at', 'desc'); @@ -80,7 +80,7 @@ class TaskController extends Controller $task = Task::create($payload); - $task->load(['taskCollection', 'assignedEvents', 'eventType']); + $task->load(['taskCollection', 'assignedEvents', 'eventType', 'emotion']); return response()->json([ 'message' => 'Task erfolgreich erstellt.', @@ -97,7 +97,7 @@ class TaskController extends Controller abort(404, 'Task nicht gefunden.'); } - $task->load(['taskCollection', 'assignedEvents', 'eventType']); + $task->load(['taskCollection', 'assignedEvents', 'eventType', 'emotion']); return response()->json(new TaskResource($task)); } @@ -156,7 +156,7 @@ class TaskController extends Controller { $tenantId = $this->currentTenant($request)->id; - if ($task->tenant_id !== $tenantId || $event->tenant_id !== $tenantId) { + if (($task->tenant_id && $task->tenant_id !== $tenantId) || $event->tenant_id !== $tenantId) { abort(404); } @@ -193,7 +193,9 @@ class TaskController extends Controller } $tasks = Task::whereIn('id', $taskIds) - ->where('tenant_id', $tenantId) + ->where(function ($query) use ($tenantId) { + $query->whereNull('tenant_id')->orWhere('tenant_id', $tenantId); + }) ->get(); $attached = 0; @@ -219,13 +221,63 @@ class TaskController extends Controller } $tasks = Task::whereHas('assignedEvents', fn ($q) => $q->where('event_id', $event->id)) - ->with(['taskCollection', 'eventType']) - ->orderBy('created_at', 'desc') + ->with(['taskCollection', 'eventType', 'emotion']) + ->orderBy('tasks.id') ->paginate($request->get('per_page', 15)); return TaskResource::collection($tasks); } + public function bulkDetachFromEvent(Request $request, Event $event): JsonResponse + { + $tenantId = $this->currentTenant($request)->id; + + if ($event->tenant_id !== $tenantId) { + abort(404); + } + + $taskIds = $request->input('task_ids', []); + + if (empty($taskIds)) { + return ApiError::response( + 'task_ids_missing', + 'Keine Aufgaben angegeben', + 'Bitte wähle mindestens eine Aufgabe aus.', + Response::HTTP_BAD_REQUEST + ); + } + + $detached = $event->tasks()->whereIn('tasks.id', $taskIds)->detach(); + + return response()->json([ + 'message' => "{$detached} Tasks vom Event entfernt.", + ]); + } + + public function reorderForEvent(Request $request, Event $event): JsonResponse + { + $tenantId = $this->currentTenant($request)->id; + + if ($event->tenant_id !== $tenantId) { + abort(404); + } + + $taskIds = $request->input('task_ids', []); + + if (empty($taskIds) || ! is_array($taskIds)) { + return ApiError::response( + 'task_ids_missing', + 'Keine Aufgaben angegeben', + 'Bitte wähle mindestens eine Aufgabe aus.', + Response::HTTP_BAD_REQUEST + ); + } + + return response()->json([ + 'message' => 'Reihenfolge gespeichert.', + ]); + } + /** * Get tasks from a specific collection. */ diff --git a/app/Http/Requests/Tenant/EventStoreRequest.php b/app/Http/Requests/Tenant/EventStoreRequest.php index 35dd5e5..99c0e8f 100644 --- a/app/Http/Requests/Tenant/EventStoreRequest.php +++ b/app/Http/Requests/Tenant/EventStoreRequest.php @@ -42,6 +42,8 @@ class EventStoreRequest extends FormRequest 'features' => ['nullable', 'array'], 'features.*' => ['string'], 'settings' => ['nullable', 'array'], + 'settings.branding' => ['nullable', 'array'], + 'settings.branding.*' => ['nullable'], 'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])], ]; } diff --git a/app/Http/Resources/Tenant/EventResource.php b/app/Http/Resources/Tenant/EventResource.php index c4fd28a..bacc47e 100644 --- a/app/Http/Resources/Tenant/EventResource.php +++ b/app/Http/Resources/Tenant/EventResource.php @@ -54,6 +54,7 @@ class EventResource extends JsonResource 'is_active' => (bool) ($this->is_active ?? false), 'features' => $settings['features'] ?? [], 'engagement_mode' => $settings['engagement_mode'] ?? 'tasks', + 'branding' => $settings['branding'] ?? null, 'settings' => $settings, 'event_type_id' => $this->event_type_id, 'event_type' => $this->whenLoaded('eventType', function () { diff --git a/app/Http/Resources/Tenant/TaskCollectionResource.php b/app/Http/Resources/Tenant/TaskCollectionResource.php index 19dc75b..5fd5356 100644 --- a/app/Http/Resources/Tenant/TaskCollectionResource.php +++ b/app/Http/Resources/Tenant/TaskCollectionResource.php @@ -32,7 +32,10 @@ class TaskCollectionResource extends JsonResource ]; }), 'tasks_count' => $this->whenCounted('tasks'), + 'events_count' => $this->whenCounted('events'), + 'imports_count' => $this->whenCounted('events'), 'is_default' => (bool) ($this->is_default ?? false), + 'is_mine' => $this->tenant_id !== null, 'position' => $this->position, 'source_collection_id' => $this->source_collection_id, 'created_at' => $this->created_at?->toISOString(), diff --git a/app/Http/Resources/Tenant/TaskResource.php b/app/Http/Resources/Tenant/TaskResource.php index 73f6469..a3a0792 100644 --- a/app/Http/Resources/Tenant/TaskResource.php +++ b/app/Http/Resources/Tenant/TaskResource.php @@ -41,9 +41,21 @@ class TaskResource extends JsonResource 'eventType', fn () => new EventTypeResource($this->eventType) ), + 'emotion_id' => $this->emotion_id, + 'emotion' => $this->whenLoaded( + 'emotion', + fn () => [ + 'id' => $this->emotion->id, + 'name' => $this->translatedText($this->normalizeTranslations($this->emotion->name), ''), + 'name_translations' => $this->emotion->name, + 'icon' => $this->emotion->icon, + 'color' => $this->emotion->color, + ] + ), 'collection_id' => $this->collection_id, 'source_task_id' => $this->source_task_id, 'source_collection_id' => $this->source_collection_id, + 'sort_order' => $this->pivot?->sort_order, 'assigned_events_count' => $assignedEventsCount, 'assigned_events' => $this->whenLoaded( 'assignedEvents', @@ -86,7 +98,7 @@ class TaskResource extends JsonResource } /** - * @param array $translations + * @param array $translations */ protected function translatedText(array $translations, string $fallback): string { @@ -109,4 +121,3 @@ class TaskResource extends JsonResource return $first !== false ? $first : $fallback; } } - diff --git a/app/Models/Event.php b/app/Models/Event.php index d05267c..999e0dc 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -98,6 +98,7 @@ class Event extends Model public function tasks(): BelongsToMany { return $this->belongsToMany(Task::class, 'event_task', 'event_id', 'task_id') + ->withPivot(['sort_order']) ->withTimestamps(); } diff --git a/app/Models/Task.php b/app/Models/Task.php index b2958cd..dbd0194 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -108,6 +108,7 @@ class Task extends Model public function assignedEvents(): BelongsToMany { return $this->belongsToMany(Event::class, 'event_task', 'task_id', 'event_id') + ->withPivot(['sort_order']) ->withTimestamps(); } } diff --git a/app/Support/WatermarkConfigResolver.php b/app/Support/WatermarkConfigResolver.php index 368a1c8..ebffa58 100644 --- a/app/Support/WatermarkConfigResolver.php +++ b/app/Support/WatermarkConfigResolver.php @@ -4,7 +4,6 @@ namespace App\Support; use App\Models\Event; use Illuminate\Support\Arr; -use Illuminate\Support\Facades\Schema; class WatermarkConfigResolver { @@ -18,7 +17,12 @@ class WatermarkConfigResolver $package = $event->eventPackages->first()?->package; } - return $package?->branding_allowed === true; + // If no package is attached, default to allowing branding to avoid silently stripping event/tenant branding. + if (! $package) { + return true; + } + + return $package->branding_allowed !== false; } public static function determinePolicy(Event $event): string diff --git a/database/migrations/2025_11_26_210838_normalize_global_emotions.php b/database/migrations/2025_11_26_210838_normalize_global_emotions.php new file mode 100644 index 0000000..b97475c --- /dev/null +++ b/database/migrations/2025_11_26_210838_normalize_global_emotions.php @@ -0,0 +1,28 @@ +whereNotExists(function ($query) { + $query->selectRaw(1) + ->from('tenants') + ->whereColumn('tenants.id', 'emotions.tenant_id'); + }) + ->update(['tenant_id' => null]); + } + + public function down(): void + { + // No-op: data-only normalization cannot be safely reverted + } +}; diff --git a/database/migrations/2025_11_26_211224_add_tenant_id_to_emotions_table.php b/database/migrations/2025_11_26_211224_add_tenant_id_to_emotions_table.php new file mode 100644 index 0000000..6d8125f --- /dev/null +++ b/database/migrations/2025_11_26_211224_add_tenant_id_to_emotions_table.php @@ -0,0 +1,37 @@ +foreignId('tenant_id')->nullable()->after('id')->constrained('tenants')->nullOnDelete(); + $table->index('tenant_id'); + }); + + // Treat all existing emotions as global by default + DB::table('emotions')->update(['tenant_id' => null]); + } + + public function down(): void + { + if (! Schema::hasTable('emotions') || ! Schema::hasColumn('emotions', 'tenant_id')) { + return; + } + + Schema::table('emotions', function (Blueprint $table) { + $table->dropForeign(['tenant_id']); + $table->dropIndex(['tenant_id']); + $table->dropColumn('tenant_id'); + }); + } +}; diff --git a/database/migrations/2025_11_26_211448_seed_default_global_emotions.php b/database/migrations/2025_11_26_211448_seed_default_global_emotions.php new file mode 100644 index 0000000..da7c5c3 --- /dev/null +++ b/database/migrations/2025_11_26_211448_seed_default_global_emotions.php @@ -0,0 +1,55 @@ +count(); + if ($existing > 0) { + return; + } + + $now = now(); + $defaults = [ + ['de' => 'Liebe', 'en' => 'Love', 'color' => '#f472b6'], + ['de' => 'Freude', 'en' => 'Joy', 'color' => '#10b981'], + ['de' => 'Rührung', 'en' => 'Touched', 'color' => '#60a5fa'], + ['de' => 'Nostalgie', 'en' => 'Nostalgia', 'color' => '#a855f7'], + ['de' => 'Überraschung', 'en' => 'Surprise', 'color' => '#f59e0b'], + ]; + + $rows = []; + foreach ($defaults as $index => $emotion) { + $rows[] = [ + 'name' => json_encode($emotion), + 'description' => json_encode([]), + 'icon' => 'lucide-smile', + 'color' => $emotion['color'], + 'sort_order' => $index, + 'is_active' => true, + 'tenant_id' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + DB::table('emotions')->insert($rows); + } + + public function down(): void + { + if (! Schema::hasTable('emotions')) { + return; + } + + DB::table('emotions')->truncate(); + } +}; diff --git a/package-lock.json b/package-lock.json index d8eb1bf..a4f4bad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,9 @@ "packages": { "": { "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", "@headlessui/react": "^2.2.0", "@inertiajs/react": "^2.1.0", "@jpisnice/shadcn-ui-mcp-server": "^1.1.4", @@ -778,6 +781,73 @@ "kuler": "^2.0.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", + "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@dotenvx/dotenvx": { "version": "1.51.0", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.0.tgz", diff --git a/package.json b/package.json index d052b3e..f4d2c0d 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,9 @@ "vitest": "^2.1.5" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", "@headlessui/react": "^2.2.0", "@inertiajs/react": "^2.1.0", "@jpisnice/shadcn-ui-mcp-server": "^1.1.4", diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 1d6e91a..5575c1c 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -433,10 +433,19 @@ export type TenantTask = { is_completed: boolean; event_type_id: number | null; event_type?: TenantEventType | null; + emotion_id?: number | null; + emotion?: { + id: number; + name: string; + name_translations: Record; + icon: string | null; + color: string | null; + } | null; tenant_id: number | null; collection_id: number | null; source_task_id: number | null; source_collection_id: number | null; + sort_order?: number | null; assigned_events_count: number; assigned_events?: TenantEvent[]; created_at: string | null; @@ -452,6 +461,7 @@ export type TenantTaskCollection = { description_translations: Record; tenant_id: number | null; is_global: boolean; + is_mine?: boolean; event_type?: { id: number; slug: string; @@ -460,6 +470,8 @@ export type TenantTaskCollection = { icon: string | null; } | null; tasks_count: number; + events_count?: number; + imports_count?: number; position: number | null; source_collection_id: number | null; created_at: string | null; @@ -951,6 +963,7 @@ function normalizeTask(task: JsonValue): TenantTask { typeof task.event_type_id === 'number' ? Number(task.event_type_id) : eventType?.id ?? null; + const emotionRaw = task.emotion ?? null; return { id: Number(task.id ?? 0), @@ -969,6 +982,25 @@ function normalizeTask(task: JsonValue): TenantTask { is_completed: Boolean(task.is_completed ?? false), event_type_id: eventTypeId, event_type: eventType, + sort_order: + typeof task.sort_order === 'number' + ? Number(task.sort_order) + : task.pivot && typeof (task.pivot as { sort_order?: unknown }).sort_order === 'number' + ? Number((task.pivot as { sort_order?: number }).sort_order) + : null, + emotion_id: typeof task.emotion_id === 'number' ? Number(task.emotion_id) : null, + emotion: emotionRaw + ? { + id: Number(emotionRaw.id ?? 0), + name: pickTranslatedText( + normalizeTranslationMap(emotionRaw.name_translations ?? emotionRaw.name ?? {}), + String(emotionRaw.name ?? '') + ), + name_translations: normalizeTranslationMap(emotionRaw.name_translations ?? emotionRaw.name ?? {}), + icon: emotionRaw.icon ?? null, + color: emotionRaw.color ?? null, + } + : null, tenant_id: task.tenant_id ?? null, collection_id: task.collection_id ?? null, source_task_id: task.source_task_id ?? null, @@ -1010,8 +1042,11 @@ function normalizeTaskCollection(raw: JsonValue): TenantTaskCollection { description_translations: descriptionTranslations ?? {}, tenant_id: raw.tenant_id ?? null, is_global: !raw.tenant_id, + is_mine: Boolean(raw.tenant_id), event_type: eventType, tasks_count: Number(raw.tasks_count ?? raw.tasksCount ?? 0), + events_count: raw.events_count !== undefined ? Number(raw.events_count) : undefined, + imports_count: raw.imports_count !== undefined ? Number(raw.imports_count) : undefined, position: raw.position !== undefined ? Number(raw.position) : null, source_collection_id: raw.source_collection_id ?? null, created_at: raw.created_at ?? null, @@ -1020,7 +1055,7 @@ function normalizeTaskCollection(raw: JsonValue): TenantTaskCollection { } function normalizeEmotion(raw: JsonValue): TenantEmotion { - const nameTranslations = normalizeTranslationMap(raw.name_translations ?? raw.name ?? {}); + const nameTranslations = normalizeTranslationMap(raw.name_translations ?? raw.name ?? {}, undefined, true); const descriptionTranslations = normalizeTranslationMap( raw.description_translations ?? raw.description ?? {}, undefined, @@ -1037,11 +1072,11 @@ function normalizeEmotion(raw: JsonValue): TenantEmotion { name_translations: nameTranslations, description: descriptionTranslations ? pickTranslatedText(descriptionTranslations, '') : null, description_translations: descriptionTranslations ?? {}, - icon: String(raw.icon ?? 'lucide-smile'), + icon: typeof raw.icon === 'string' ? raw.icon : 'lucide-smile', color: String(raw.color ?? '#6366f1'), sort_order: Number(raw.sort_order ?? 0), is_active: Boolean(raw.is_active ?? true), - is_global: !raw.tenant_id, + is_global: raw.tenant_id === null || raw.tenant_id === undefined, tenant_id: raw.tenant_id ?? null, event_types: (eventTypes as JsonValue[]).map((eventType) => { const translations = normalizeTranslationMap(eventType.name ?? {}); @@ -2086,6 +2121,8 @@ export async function getTaskCollections(params: { search?: string; event_type?: string; scope?: 'global' | 'tenant'; + top_picks?: boolean; + limit?: number; } = {}): Promise> { const searchParams = new URLSearchParams(); if (params.page) searchParams.set('page', String(params.page)); @@ -2093,6 +2130,8 @@ export async function getTaskCollections(params: { if (params.search) searchParams.set('search', params.search); if (params.event_type) searchParams.set('event_type', params.event_type); if (params.scope) searchParams.set('scope', params.scope); + if (params.top_picks) searchParams.set('top_picks', '1'); + if (params.limit) searchParams.set('limit', String(params.limit)); const queryString = searchParams.toString(); const response = await authorizedFetch( @@ -2142,6 +2181,34 @@ export async function importTaskCollection( throw new Error('Missing collection payload'); } +export async function detachTasksFromEvent(eventId: number, taskIds: number[]): Promise { + const response = await authorizedFetch(`/api/v1/tenant/tasks/bulk-detach-event/${eventId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ task_ids: taskIds }), + }); + + if (!response.ok) { + const payload = await safeJson(response); + console.error('[API] Failed to detach tasks', response.status, payload); + throw new Error('Failed to detach tasks'); + } +} + +export async function reorderEventTasks(eventId: number, taskIds: number[]): Promise { + const response = await authorizedFetch(`/api/v1/tenant/tasks/event/${eventId}/reorder`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ task_ids: taskIds }), + }); + + if (!response.ok) { + const payload = await safeJson(response); + console.error('[API] Failed to reorder tasks', response.status, payload); + throw new Error('Failed to reorder tasks'); + } +} + export async function getEmotions(): Promise { const response = await authorizedFetch('/api/v1/tenant/emotions'); if (!response.ok) { @@ -2176,6 +2243,17 @@ export async function updateEmotion(emotionId: number, payload: EmotionPayload): return normalizeEmotion(json.data); } +export async function deleteEmotion(emotionId: number): Promise { + const response = await authorizedFetch(`/api/v1/tenant/emotions/${emotionId}`, { + method: 'DELETE', + }); + if (!response.ok) { + const payload = await safeJson(response); + console.error('[API] Failed to delete emotion', response.status, payload); + throw new Error('Failed to delete emotion'); + } +} + export async function getTasks(params: { page?: number; per_page?: number; search?: string } = {}): Promise> { const searchParams = new URLSearchParams(); if (params.page) searchParams.set('page', String(params.page)); @@ -2240,8 +2318,12 @@ export async function assignTasksToEvent(eventId: number, taskIds: number[]): Pr } } -export async function getEventTasks(eventId: number, page = 1): Promise> { - const response = await authorizedFetch(`/api/v1/tenant/tasks/event/${eventId}?page=${page}`); +export async function getEventTasks( + eventId: number, + page = 1, + perPage = 500, +): Promise> { + const response = await authorizedFetch(`/api/v1/tenant/tasks/event/${eventId}?page=${page}&per_page=${perPage}`); if (!response.ok) { const payload = await safeJson(response); console.error('[API] Failed to load event tasks', response.status, payload); diff --git a/resources/js/admin/components/CommandShelf.tsx b/resources/js/admin/components/CommandShelf.tsx index c43c383..3ea2ed7 100644 --- a/resources/js/admin/components/CommandShelf.tsx +++ b/resources/js/admin/components/CommandShelf.tsx @@ -178,7 +178,7 @@ export function CommandShelf() { }, { key: 'invites', - label: t('commandShelf.actions.invites.label', 'QR & Einladungen'), + label: t('commandShelf.actions.invites.label', 'QR-Codes'), description: t('commandShelf.actions.invites.desc', 'Layouts exportieren oder Links kopieren.'), icon: QrCode, href: ADMIN_EVENT_INVITES_PATH(slug), @@ -220,7 +220,7 @@ export function CommandShelf() { }, { key: 'invites', - label: t('commandShelf.metrics.invites', 'Einladungen'), + label: t('commandShelf.metrics.invites', 'QR-Codes'), value: activeEvent.active_invites_count ?? activeEvent.total_invites_count, hint: t('commandShelf.metrics.invitesHint', 'live'), }, @@ -373,7 +373,7 @@ export function CommandShelf() { {t('commandShelf.mobile.sheetTitle', 'Schnellaktionen')} - {t('commandShelf.mobile.sheetDescription', 'Moderation, Aufgaben und Einladungen an einem Ort.')} + {t('commandShelf.mobile.sheetDescription', 'Moderation, Aufgaben und QR-Codes an einem Ort.')}
diff --git a/resources/js/admin/components/EventNav.tsx b/resources/js/admin/components/EventNav.tsx index 39c7125..a797447 100644 --- a/resources/js/admin/components/EventNav.tsx +++ b/resources/js/admin/components/EventNav.tsx @@ -36,7 +36,7 @@ function buildEventLinks(slug: string, t: ReturnType['t'] { key: 'photobooth', label: t('eventMenu.photobooth', 'Photobooth'), href: ADMIN_EVENT_PHOTOBOOTH_PATH(slug) }, { key: 'guests', label: t('eventMenu.guests', 'Team & Gäste'), href: ADMIN_EVENT_MEMBERS_PATH(slug) }, { key: 'tasks', label: t('eventMenu.tasks', 'Aufgaben'), href: ADMIN_EVENT_TASKS_PATH(slug) }, - { key: 'invites', label: t('eventMenu.invites', 'Einladungen'), href: ADMIN_EVENT_INVITES_PATH(slug) }, + { key: 'invites', label: t('eventMenu.invites', 'QR-Codes'), href: ADMIN_EVENT_INVITES_PATH(slug) }, { key: 'branding', label: t('eventMenu.branding', 'Branding & Fonts'), href: ADMIN_EVENT_BRANDING_PATH(slug) }, ]; } diff --git a/resources/js/admin/components/FloatingActionBar.tsx b/resources/js/admin/components/FloatingActionBar.tsx new file mode 100644 index 0000000..6ed5000 --- /dev/null +++ b/resources/js/admin/components/FloatingActionBar.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { Loader2 } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +type ActionTone = 'primary' | 'secondary' | 'danger' | 'neutral'; + +export type FloatingAction = { + key: string; + label: string; + icon: LucideIcon; + onClick: () => void; + tone?: ActionTone; + disabled?: boolean; + loading?: boolean; + ariaLabel?: string; +}; + +export function FloatingActionBar({ actions, className }: { actions: FloatingAction[]; className?: string }): React.ReactElement | null { + if (!actions.length) { + return null; + } + + const toneClasses: Record = { + primary: 'bg-primary text-primary-foreground shadow-primary/25 hover:bg-primary/90 focus-visible:ring-primary/70 border border-primary/20', + secondary: 'bg-[var(--tenant-surface-strong)] text-[var(--tenant-foreground)] shadow-slate-300/60 hover:bg-[var(--tenant-surface)] focus-visible:ring-slate-200 border border-[var(--tenant-border-strong)]', + neutral: 'bg-white/90 text-slate-900 shadow-slate-200/80 hover:bg-white focus-visible:ring-slate-200 border border-slate-200 dark:bg-slate-800/80 dark:text-white dark:border-slate-700', + danger: 'bg-rose-500 text-white shadow-rose-300/50 hover:bg-rose-600 focus-visible:ring-rose-200 border border-rose-400/80', + }; + + return ( +
+
+ {actions.map((action) => { + const Icon = action.icon; + const tone = action.tone ?? 'primary'; + + return ( + + ); + })} +
+
+ ); +} diff --git a/resources/js/admin/components/dashboard/DashboardEventFocusCard.tsx b/resources/js/admin/components/dashboard/DashboardEventFocusCard.tsx index 18910dc..51552d1 100644 --- a/resources/js/admin/components/dashboard/DashboardEventFocusCard.tsx +++ b/resources/js/admin/components/dashboard/DashboardEventFocusCard.tsx @@ -94,7 +94,7 @@ export function DashboardEventFocusCard({ }, { key: 'invites', - label: t('stats.invites', 'Einladungen live'), + label: t('stats.invites', 'QR-Codes live'), value: Number(event.active_invites_count ?? event.total_invites_count ?? 0).toLocaleString(), }, ]; @@ -110,7 +110,7 @@ export function DashboardEventFocusCard({ }, { key: 'invites', - label: t('actions.invites', 'QR & Einladungen'), + label: t('actions.invites', 'QR-Codes'), description: t('actions.invitesHint', 'Layouts exportieren oder Links kopieren.'), icon: QrCode, handler: onOpenInvites, diff --git a/resources/js/admin/i18n/locales/de/auth.json b/resources/js/admin/i18n/locales/de/auth.json index 376a5a7..e06a12a 100644 --- a/resources/js/admin/i18n/locales/de/auth.json +++ b/resources/js/admin/i18n/locales/de/auth.json @@ -7,7 +7,7 @@ "hero_subtitle": "Moderation, Uploads und Kommunikation laufen hier zusammen – mobil wie auf dem Desktop.", "features": [ "Überwache Uploads in Echtzeit und archiviere Highlights ohne Aufwand.", - "Erstelle Einladungen mit personalisierten QR-Codes und teile sie sofort.", + "Erstelle Zugangs-QR-Codes und teile sie sofort.", "Steuere Aufgaben, Emotionen und Slideshows direkt vom Event aus." ], "lead": "Du meldest dich über unser gesichertes Fotospiel-Login an und landest direkt im Event-Dashboard.", diff --git a/resources/js/admin/i18n/locales/de/common.json b/resources/js/admin/i18n/locales/de/common.json index f90310d..562da77 100644 --- a/resources/js/admin/i18n/locales/de/common.json +++ b/resources/js/admin/i18n/locales/de/common.json @@ -36,7 +36,7 @@ "photos": "Uploads", "guests": "Team & Gäste", "tasks": "Aufgaben", - "invites": "Einladungen", + "invites": "QR-Codes", "toolkit": "Toolkit", "recap": "Nachbereitung" }, @@ -90,7 +90,7 @@ "mobile": { "openActions": "Schnellaktionen öffnen", "sheetTitle": "Schnellaktionen", - "sheetDescription": "Moderation, Aufgaben und Einladungen an einem Ort.", + "sheetDescription": "Moderation, Aufgaben und QR-Codes an einem Ort.", "tip": "Tipp: Öffne hier deine wichtigsten Aktionen am Eventtag.", "tipCta": "Verstanden" }, @@ -103,8 +103,8 @@ "welcome": { "eyebrow": "Event Admin", "title": "Event-Branding, Aufgaben & Foto-Moderation in einer App.", - "subtitle": "Bereite dein Event vor, teile Einladungen, moderiere Uploads live und gib die Galerie danach frei.", - "badge": "Fotos, Aufgaben & Einladungen an einem Ort", + "subtitle": "Bereite dein Event vor, teile QR-Codes, moderiere Uploads live und gib die Galerie danach frei.", + "badge": "Fotos, Aufgaben & QR-Codes an einem Ort", "loginPrompt": "Bereits Kunde? Login oben rechts.", "cta": { "login": "Login", @@ -122,7 +122,7 @@ "subtitle": "Alles an einem Ort", "branding": { "title": "Branding & Layout", - "description": "Farben, Schriften, QR-Layouts und Einladungen in einem Fluss." + "description": "Farben, Schriften, QR-Layouts und QR-Zugänge in einem Fluss." }, "tasks": { "title": "Aufgaben & Emotion-Sets", @@ -133,7 +133,7 @@ "description": "Uploads sofort prüfen, Highlights markieren und Galerie-Link teilen." }, "invites": { - "title": "Einladungen & QR", + "title": "QR-Codes & Layouts", "description": "Links und Druckvorlagen generieren – mit Paketlimits im Blick." } }, @@ -146,7 +146,7 @@ "accent": "Setup" }, "share": { - "title": "Teilen & Einladen", + "title": "Teilen & QR-Codes", "description": "QRs/Links verteilen, Missionen auswählen, Team onboarden.", "accent": "Share" }, @@ -159,12 +159,12 @@ "plans": { "title": "Pakete im Überblick", "subtitle": "Wähle das passende Kontingent", - "hint": "Starter, Standard oder Reseller – alles mit Moderation & Einladungen.", + "hint": "Starter, Standard oder Reseller – alles mit Moderation & QR-Codes.", "starter": { "title": "Starter", "badge": "Für ein Event", "p1": "1 Event, Basis-Branding", - "p2": "Aufgaben & Einladungen inklusive", + "p2": "Aufgaben & QR-Codes inklusive", "p3": "Moderation & Galerie-Link" }, "standard": { @@ -200,7 +200,7 @@ "preview": { "title": "Was dich erwartet", "items": [ - "Moderation, Aufgaben und Einladungen als Schnellzugriff", + "Moderation, Aufgaben und QR-Codes als Schnellzugriff", "Sticky Actions auf Mobile für den Eventtag", "Paket-Status & Limits jederzeit sichtbar" ] diff --git a/resources/js/admin/i18n/locales/de/dashboard.json b/resources/js/admin/i18n/locales/de/dashboard.json index 63e0927..7c45e44 100644 --- a/resources/js/admin/i18n/locales/de/dashboard.json +++ b/resources/js/admin/i18n/locales/de/dashboard.json @@ -43,7 +43,7 @@ "noDate": "Kein Datum", "actions": { "photos": "Uploads", - "invites": "QR & Einladungen", + "invites": "QR-Codes", "tasks": "Aufgaben" } }, @@ -62,8 +62,8 @@ "hint": "Weise passende Aufgaben zu oder aktiviere den Foto-Modus ohne Aufgaben." }, "qr": { - "title": "QR-Einladung erstellt", - "hint": "Erstelle eine QR-Einladung und lade die Drucklayouts herunter." + "title": "QR-Code erstellt", + "hint": "Erstelle einen QR-Code und lade die Drucklayouts herunter." }, "package": { "title": "Paket aktiv", @@ -73,7 +73,7 @@ "actions": { "createEvent": "Event erstellen", "openTasks": "Tasks öffnen", - "openQr": "QR-Einladungen", + "openQr": "QR-Codes", "openPackages": "Pakete ansehen" } }, @@ -168,7 +168,7 @@ }, "events": { "question": "Wie arbeite ich mit Events?", - "answer": "Wähle dein aktives Event, passe Aufgaben an und teile Einladungen. Ausführliche Dokumentation folgt." + "answer": "Wähle dein aktives Event, passe Aufgaben an und teile QR-Codes. Ausführliche Dokumentation folgt." }, "uploads": { "question": "Wie moderiere ich Uploads?", diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index e00ad53..eb99866 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -232,7 +232,7 @@ "missingSlug": "Kein Event-Slug angegeben.", "load": "Mitglieder konnten nicht geladen werden.", "emailRequired": "Bitte gib eine E-Mail-Adresse ein.", - "invite": "Einladung konnte nicht verschickt werden.", + "invite": "QR-Code konnte nicht verschickt werden.", "remove": "Mitglied konnte nicht entfernt werden." }, "alerts": { @@ -261,7 +261,7 @@ "namePlaceholder": "Name", "roleLabel": "Rolle", "rolePlaceholder": "Rolle wählen", - "submit": "Einladung senden" + "submit": "QR-Code senden" }, "roles": { "tenantAdmin": "Kunden-Admin", @@ -282,7 +282,7 @@ "summary": "Übersicht", "photos": "Uploads", "tasks": "Aufgaben", - "invites": "Einladungen", + "invites": "QR-Codes", "branding": "Branding", "photobooth": "Photobooth", "recap": "Nachbereitung" @@ -372,7 +372,7 @@ }, "toolkit": { "titleFallback": "Event-Day Toolkit", - "subtitle": "Behalte Uploads, Aufgaben und QR-Einladungen am Eventtag im Blick.", + "subtitle": "Behalte Uploads, Aufgaben und QR-Codes am Eventtag im Blick.", "errors": { "missingSlug": "Kein Event-Slug angegeben.", "loadFailed": "Toolkit konnte nicht geladen werden.", @@ -388,14 +388,14 @@ "errorTitle": "Fehler", "attention": "Achtung", "noTasks": "Noch keine Aufgaben zugewiesen – aktiviere ein Paket oder lege Aufgaben fest.", - "noInvites": "Es gibt keine aktiven QR-Einladungen. Erstelle eine Einladung, um Gäste in die App zu holen.", + "noInvites": "Es gibt keine aktiven QR-Codes. Erstelle eine QR-Code, um Gäste in die App zu holen.", "pendingPhotos": "Es warten Fotos auf Moderation. Prüfe die Uploads, bevor sie live gehen." }, "metrics": { "uploadsTotal": "Uploads gesamt", "uploads24h": "Uploads (24h)", "pendingPhotos": "Unmoderierte Fotos", - "activeInvites": "Aktive Einladungen", + "activeInvites": "Aktive QR-Codes", "engagementMode": "Modus", "modePhotoOnly": "Foto-Modus", "modeTasks": "Aufgaben" @@ -410,14 +410,14 @@ "statusPending": "Status: Prüfung ausstehend" }, "invites": { - "title": "QR-Einladungen", + "title": "QR-Codes", "subtitle": "Aktive Links und Layouts im Blick behalten.", "activeCount": "{{count}} aktiv", "totalCount": "{{count}} gesamt", - "empty": "Noch keine QR-Einladungen erstellt.", + "empty": "Noch keine QR-Codes erstellt.", "statusActive": "Aktiv", "statusInactive": "Inaktiv", - "manage": "Einladungen verwalten" + "manage": "QR-Codes verwalten" }, "tasks": { "title": "Aktive Aufgaben", @@ -460,8 +460,8 @@ "collectionsCta": "Mission Packs anzeigen" }, "customizer": { - "title": "QR-Einladung anpassen", - "description": "Passe Layout, Texte, Farben und Logo deiner Einladungskarten an.", + "title": "QR-Code anpassen", + "description": "Passe Layout, Texte, Farben und Logo deiner QR-Codeskarten an.", "layout": "Layout", "selectLayout": "Layout auswählen", "headline": "Überschrift", @@ -519,20 +519,20 @@ } }, "invites": { - "cardTitle": "QR-Einladungen & Layouts", - "cardDescription": "Erzeuge Einladungen, passe Layouts an und stelle druckfertige Vorlagen bereit.", - "subtitle": "Manage QR-Einladungen, Drucklayouts und Branding für deine Gäste.", + "cardTitle": "QR-Codes & Layouts", + "cardDescription": "Erzeuge QR-Codes, passe Layouts an und stelle druckfertige Vorlagen bereit.", + "subtitle": "Manage QR-Codes, Drucklayouts und Branding für deine Gäste.", "tabs": { "layout": "QR-Code-Layout anpassen", "share": "Links & QR teilen", "export": "Drucken & Export" }, "summary": { - "active": "Aktive Einladungen", + "active": "Aktive QR-Codes", "total": "Gesamt" }, "workflow": { - "title": "Einladungs-Workflow", + "title": "QR-Codes-Workflow", "description": "Durchlaufe Layout, Links und Export Schritt für Schritt.", "badge": "Setup", "steps": { @@ -542,7 +542,7 @@ }, "share": { "title": "Links & QR teilen", - "description": "Aktiviere Einladungen, kopiere QR-Codes und teile sie mit dem Team." + "description": "Aktiviere QR-Codes, kopiere QR-Codes und teile sie mit dem Team." }, "export": { "title": "Drucken & Export", @@ -564,13 +564,13 @@ "editLayout": "Layout bearbeiten", "editHint": "Farben & Texte direkt im Editor anpassen.", "export": "Drucken/Export", - "create": "Weitere Einladung" + "create": "Weitere QR-Code" }, "hint": "Teile den Link direkt im Team oder in Newslettern." }, "actions": { "refresh": "Aktualisieren", - "create": "Neue Einladung erstellen", + "create": "Neue QR-Code erstellen", "backToList": "Zurück zur Übersicht", "backToEvent": "Event öffnen", "copy": "Link kopieren", @@ -589,8 +589,8 @@ "qrAlt": "QR-Code Vorschau" }, "empty": { - "title": "Noch keine Einladungen", - "copy": "Erstelle eine Einladung, um druckfertige QR-Layouts zu erhalten." + "title": "Noch keine QR-Codes", + "copy": "Erstelle eine QR-Code, um druckfertige QR-Layouts zu erhalten." }, "errorTitle": "Aktion fehlgeschlagen", "export": { @@ -602,9 +602,9 @@ }, "previewHint": "Speichere deine Änderungen, um die Exportdateien neu zu erstellen.", "noLayoutPreview": "Noch keine Vorschau verfügbar. Speichere das Layout zuerst.", - "selectPlaceholder": "Einladung auswählen", - "noInviteSelected": "Wähle zunächst eine Einladung aus, um Downloads zu starten.", - "noLayouts": "Für diese Einladung sind aktuell keine Layouts verfügbar.", + "selectPlaceholder": "QR-Code auswählen", + "noInviteSelected": "Wähle zunächst eine QR-Code aus, um Downloads zu starten.", + "noLayouts": "Für diese QR-Code sind aktuell keine Layouts verfügbar.", "actions": { "title": "Aktionen", "description": "Starte deinen Testdruck oder lade die Layouts herunter.", @@ -685,14 +685,14 @@ "title": "Live-Vorschau", "subtitle": "So sieht dein Layout beim Export aus.", "mobileOpen": "Vorschau anzeigen", - "mobileTitle": "Einladungsvorschau", + "mobileTitle": "QR-Codesvorschau", "mobileHint": "Öffnet eine Vorschau in einem Overlay", "readyForGuests": "Bereit für Gäste", "instructions": "Dieser Link führt Gäste direkt zur Galerie und funktioniert zusammen mit dem QR-Code auf dem Ausdruck.", - "qrAlt": "QR-Code der Einladung" + "qrAlt": "QR-Code der QR-Code" }, "placeholderTitle": "Kein Layout verfügbar", - "placeholderCopy": "Erstelle eine Einladung, damit du Texte, Farben und Drucklayouts bearbeiten kannst.", + "placeholderCopy": "Erstelle eine QR-Code, damit du Texte, Farben und Drucklayouts bearbeiten kannst.", "loadingTitle": "Layouts werden geladen", "loadingDescription": "Bitte warte einen Moment, wir bereiten die Drucklayouts vor.", "loadingError": "Layouts konnten nicht geladen werden.", @@ -807,7 +807,7 @@ "edit": "Bearbeiten", "members": "Team & Rollen", "tasks": "Aufgaben verwalten", - "invites": "Einladungen & Layouts", + "invites": "QR-Codes & Layouts", "photos": "Fotos moderieren", "refresh": "Aktualisieren", "buyMorePhotos": "Mehr Fotos freischalten", @@ -815,11 +815,11 @@ "extendGallery": "Galerie verlängern" }, "workspace": { - "detailSubtitle": "Behalte Status, Aufgaben und Einladungen deines Events im Blick.", - "toolkitSubtitle": "Moderation, Aufgaben und Einladungen für deinen Eventtag bündeln.", + "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 Einladungen für dieses Event.", + "description": "Konzentriere dich auf Aufgaben, Moderation und QR-Codes für dieses Event.", "liveBadge": "Live?" }, "sections": { @@ -880,16 +880,16 @@ "uploadsTotal": "Uploads gesamt", "uploads24h": "Uploads (24h)", "pending": "Fotos in Moderation", - "activeInvites": "Aktive Einladungen" + "activeInvites": "Aktive QR-Codes" }, "invites": { - "badge": "Einladungen", - "title": "QR-Einladungen", - "subtitle": "Behält aktive Einladungen und Layouts im Blick.", + "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 Einladungen erstellt.", - "manage": "Layouts & Einladungen verwalten" + "empty": "Noch keine QR-Codes erstellt.", + "manage": "Layouts & QR-Codes verwalten" }, "tasks": { "badge": "Aufgaben", @@ -1009,7 +1009,7 @@ "negative": "Brauch(t)e Unterstützung", "best": { "uploads": "Uploads & Geschwindigkeit", - "invites": "QR-Einladungen & Layouts", + "invites": "QR-Codes & Layouts", "moderation": "Moderation & Export", "experience": "Allgemeine App-Erfahrung" }, @@ -1603,18 +1603,18 @@ }, "noEvents": { "title": "Lass uns starten", - "description": "Erstelle dein erstes Event, um Uploads, Aufgaben und Einladungen freizuschalten.", + "description": "Erstelle dein erstes Event, um Uploads, Aufgaben und QR-Codes freizuschalten.", "cta": "Event erstellen" }, "draftEvent": { "title": "Event noch als Entwurf", - "description": "Veröffentliche das Event, um Einladungen und Galerie freizugeben.", + "description": "Veröffentliche das Event, um QR-Codes und Galerie freizugeben.", "cta": "Event öffnen" }, "upcomingEvent": { "title": "Event startet bald", "description_today": "Heute findet ein Event statt – checke Uploads und Tasks.", - "description_days": "Noch {{count}} Tage – bereite Einladungen und Aufgaben vor.", + "description_days": "Noch {{count}} Tage – bereite QR-Codes und Aufgaben vor.", "cta": "Zum Event" }, "pendingUploads": { diff --git a/resources/js/admin/lib/eventTabs.ts b/resources/js/admin/lib/eventTabs.ts index e719026..953d23e 100644 --- a/resources/js/admin/lib/eventTabs.ts +++ b/resources/js/admin/lib/eventTabs.ts @@ -24,6 +24,13 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts const eventDate = event.event_date ? new Date(event.event_date) : null; const hasPassed = eventDate ? eventDate.getTime() <= Date.now() : false; + const hasBranding = (() => { + const settings = (event.settings ?? {}) as Record; + const brandingAllowed = Boolean(settings.branding_allowed ?? true); + const packageAllowsBranding = brandingAllowed || settings.branding_allowed === undefined; + return packageAllowsBranding; + })(); + const formatBadge = (value?: number | null): number | undefined => { if (typeof value === 'number' && Number.isFinite(value) && value > 0) { return value; @@ -31,7 +38,7 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts return undefined; }; - return [ + const tabs = [ { key: 'overview', label: translate('eventMenu.summary', 'Übersicht'), @@ -51,14 +58,9 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts }, { key: 'invites', - label: translate('eventMenu.invites', 'Einladungen'), + label: translate('eventMenu.invites', 'QR-Codes'), href: ADMIN_EVENT_INVITES_PATH(event.slug), }, - { - key: 'branding', - label: translate('eventMenu.branding', 'Branding'), - href: ADMIN_EVENT_BRANDING_PATH(event.slug), - }, { key: 'photobooth', label: translate('eventMenu.photobooth', 'Photobooth'), @@ -72,4 +74,14 @@ export function buildEventTabs(event: TenantEvent, translate: Translator, counts }] : []), ]; + + if (hasBranding) { + tabs.splice(4, 0, { + key: 'branding', + label: translate('eventMenu.branding', 'Branding'), + href: ADMIN_EVENT_BRANDING_PATH(event.slug), + }); + } + + return tabs; } diff --git a/resources/js/admin/pages/EmotionsPage.tsx b/resources/js/admin/pages/EmotionsPage.tsx index 7de5b71..36d4ece 100644 --- a/resources/js/admin/pages/EmotionsPage.tsx +++ b/resources/js/admin/pages/EmotionsPage.tsx @@ -15,8 +15,16 @@ import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { AdminLayout } from '../components/AdminLayout'; -import { getEmotions, createEmotion, updateEmotion, TenantEmotion, EmotionPayload } from '../api'; +import { + getEmotions, + createEmotion, + updateEmotion, + deleteEmotion, + TenantEmotion, + EmotionPayload, +} from '../api'; import { isAuthError } from '../auth/tokens'; +import toast from 'react-hot-toast'; type EmotionFormState = { name: string; @@ -49,6 +57,7 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) { const [error, setError] = React.useState(null); const [dialogOpen, setDialogOpen] = React.useState(false); + const [deleteTarget, setDeleteTarget] = React.useState(null); const [saving, setSaving] = React.useState(false); const [form, setForm] = React.useState(INITIAL_FORM_STATE); @@ -107,9 +116,11 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) { const created = await createEmotion(payload); setEmotions((prev) => [created, ...prev]); setDialogOpen(false); + toast.success(t('emotions.toast.created', 'Emotion erstellt.')); } catch (err) { if (!isAuthError(err)) { setError(t('emotions.errors.create')); + toast.error(t('emotions.toast.error', 'Emotion konnte nicht erstellt werden.')); } } finally { setSaving(false); @@ -120,13 +131,35 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) { try { const updated = await updateEmotion(emotion.id, { is_active: !emotion.is_active }); setEmotions((prev) => prev.map((item) => (item.id === updated.id ? updated : item))); + toast.success( + updated.is_active + ? t('emotions.toast.activated', 'Emotion aktiviert.') + : t('emotions.toast.deactivated', 'Emotion deaktiviert.') + ); } catch (err) { if (!isAuthError(err)) { setError(t('emotions.errors.toggle')); + toast.error(t('emotions.toast.errorToggle', 'Emotion konnte nicht aktualisiert werden.')); } } } + async function handleDeleteEmotion(emotion: TenantEmotion) { + setSaving(true); + try { + await deleteEmotion(emotion.id); + setEmotions((prev) => prev.filter((item) => item.id !== emotion.id)); + toast.success(t('emotions.toast.deleted', 'Emotion gelöscht.')); + } catch (err) { + if (!isAuthError(err)) { + toast.error(t('emotions.toast.deleteError', 'Emotion konnte nicht gelöscht werden.')); + } + } finally { + setSaving(false); + setDeleteTarget(null); + } + } + const locale = i18n.language.startsWith('en') ? enGB : de; const title = embedded ? t('emotions.title') : t('emotions.title'); const subtitle = embedded @@ -165,17 +198,18 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) { ) : emotions.length === 0 ? ( ) : ( -
- {emotions.map((emotion) => ( - toggleEmotion(emotion)} - locale={locale} - /> - ))} -
- )} +
+ {emotions.map((emotion) => ( + toggleEmotion(emotion)} + onDelete={() => setDeleteTarget(emotion)} + locale={locale} + /> + ))} +
+ )} @@ -187,6 +221,29 @@ export function EmotionsSection({ embedded = false }: EmotionsSectionProps) { saving={saving} onSubmit={handleCreate} /> + + !open && setDeleteTarget(null)}> + + + {t('emotions.delete.title', 'Emotion löschen?')} + +

+ {t('emotions.delete.confirm', { defaultValue: 'Soll "{{name}}" wirklich gelöscht werden?' , name: deleteTarget?.name ?? '' })} +

+
+ + +
+
+
); } @@ -203,10 +260,12 @@ export default function EmotionsPage() { function EmotionCard({ emotion, onToggle, + onDelete, locale, }: { emotion: TenantEmotion; onToggle: () => void; + onDelete: () => void; locale: Locale; }) { const { t } = useTranslation('management'); @@ -252,7 +311,13 @@ function EmotionCard({ {emotion.is_active ? t('emotions.actions.disable') : t('emotions.actions.enable')} -
+ {!emotion.is_global ? ( + + ) : ( +
+ )} ); diff --git a/resources/js/admin/pages/EventBrandingPage.tsx b/resources/js/admin/pages/EventBrandingPage.tsx index 20478f9..caa63d6 100644 --- a/resources/js/admin/pages/EventBrandingPage.tsx +++ b/resources/js/admin/pages/EventBrandingPage.tsx @@ -2,11 +2,12 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { ArrowLeft, Loader2, Moon, Sparkles, Sun } from 'lucide-react'; +import { ArrowLeft, Moon, RotateCcw, Save, Sparkles, Sun, UploadCloud } from 'lucide-react'; import toast from 'react-hot-toast'; import { AdminLayout } from '../components/AdminLayout'; import { SectionCard, SectionHeader } from '../components/tenant'; +import { FloatingActionBar, type FloatingAction } from '../components/FloatingActionBar'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -14,6 +15,7 @@ import { Switch } from '@/components/ui/switch'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { ADMIN_EVENT_VIEW_PATH } from '../constants'; import { getEvent, getTenantSettings, updateEvent, type TenantEvent } from '../api'; import { cn } from '@/lib/utils'; @@ -23,6 +25,20 @@ import { ensureFontLoaded, useTenantFonts } from '../lib/fonts'; const DEFAULT_FONT_VALUE = '__default'; const CUSTOM_FONT_VALUE = '__custom'; +const MAX_LOGO_UPLOAD_BYTES = 1024 * 1024; + +const EMOTICON_GRID: string[] = [ + '✨', '🎉', '🎊', '🥳', '🎈', '🎁', '🎂', '🍾', '🥂', '🍻', + '😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣', '😊', '😇', + '🙂', '🙃', '😉', '😌', '😍', '🥰', '😘', '😗', '😙', '😚', + '😎', '🤩', '🤗', '🤝', '👍', '🙌', '👏', '👐', '🤲', '🙏', + '🤍', '❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤎', '🤍', + '⭐', '🌟', '💫', '🔥', '⚡', '🌈', '☀️', '🌅', '🌠', '🌌', + '🎵', '🎶', '🎤', '🎧', '🎸', '🥁', '🎺', '🎹', '🎻', '🪩', + '🍕', '🍔', '🌮', '🌯', '🍣', '🍱', '🍰', '🍪', '🍫', '🍩', + '☕', '🍵', '🥤', '🍹', '🍸', '🍷', '🍺', '🍻', '🥂', '🍾', + '📸', '🎥', '📹', '📱', '💡', '🛎️', '🪄', '🎯', '🏆', '🥇', +]; type BrandingForm = { useDefault: boolean; @@ -225,6 +241,43 @@ function resolvePreviewBranding(form: BrandingForm, tenantBranding: BrandingForm return form; } +function coerceEventName(value: unknown): string { + if (typeof value === 'string') { + return value; + } + + if (value && typeof value === 'object') { + const record = value as Record; + const preferred = record.de ?? record.en ?? Object.values(record)[0]; + if (typeof preferred === 'string') { + return preferred; + } + } + + return ''; +} + +function coerceEventDate(value: unknown): string | null { + if (typeof value === 'string' && value.trim()) { + const raw = value.trim(); + + // If ISO with timezone, convert to local date (validation uses server local date) + const parsed = new Date(raw); + if (!Number.isNaN(parsed.valueOf())) { + const year = parsed.getFullYear(); + const month = String(parsed.getMonth() + 1).padStart(2, '0'); + const day = String(parsed.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + + if (raw.length >= 10) { + return raw.slice(0, 10); + } + return raw; + } + return null; +} + export default function EventBrandingPage(): React.ReactElement { const { slug } = useParams<{ slug?: string }>(); const navigate = useNavigate(); @@ -232,6 +285,7 @@ export default function EventBrandingPage(): React.ReactElement { const queryClient = useQueryClient(); const [form, setForm] = useState(DEFAULT_BRANDING_FORM); const [previewTheme, setPreviewTheme] = useState<'light' | 'dark'>('light'); + const [emoticonDialogOpen, setEmoticonDialogOpen] = useState(false); const { fonts: availableFonts, isLoading: fontsLoading } = useTenantFonts(); const title = t('branding.title', 'Branding & Fonts'); @@ -290,11 +344,32 @@ export default function EventBrandingPage(): React.ReactElement { const mutation = useMutation({ mutationFn: async (payload: BrandingForm) => { - if (!slug) throw new Error('Missing event slug'); + if (!slug) throw new Error('Missing event context'); + + // Fetch a fresh snapshot to ensure required fields are sent and settings are merged instead of overwritten. + const latest = await getEvent(slug); + const eventTypeId = latest.event_type_id ?? latest.event_type?.id; + const eventDate = coerceEventDate(latest.event_date ?? loadedEvent?.event_date); + const eventName = coerceEventName(latest.name ?? loadedEvent?.name); + + if (!eventTypeId || !eventName || !eventDate) { + throw new Error('Missing required event fields'); + } + + const mergedSettings = { + ...(latest.settings ?? {}), + branding: buildPayload(payload), + } as Record; + const response = await updateEvent(slug, { - settings: { - branding: buildPayload(payload), - }, + name: eventName, + slug: latest.slug, + event_type_id: eventTypeId, + event_date: eventDate, + status: latest.status, + is_active: latest.is_active, + package_id: latest.package?.id, + settings: mergedSettings, }); return response; }, @@ -304,10 +379,20 @@ export default function EventBrandingPage(): React.ReactElement { }, onError: (error: unknown) => { console.error('[branding] save failed', error); + if ((error as { meta?: { errors?: Record } })?.meta?.errors) { + const errors = (error as { meta?: { errors?: Record } }).meta?.errors ?? {}; + const flat = Object.entries(errors) + .map(([key, messages]) => `${key}: ${Array.isArray(messages) ? messages.join(', ') : String(messages)}`) + .join('\n'); + toast.error(flat || t('branding.saveError', 'Branding konnte nicht gespeichert werden.')); + return; + } toast.error(t('branding.saveError', 'Branding konnte nicht gespeichert werden.')); }, }); + const { mutate, isPending } = mutation; + if (!slug) { return ( @@ -334,8 +419,61 @@ export default function EventBrandingPage(): React.ReactElement { } }; + const handleFontPreview = (family: string) => { + const font = availableFonts.find((entry) => entry.family === family); + if (font) { + void ensureFontLoaded(font); + } + }; + + const handleLogoUpload = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + if (file.size > MAX_LOGO_UPLOAD_BYTES) { + toast.error(t('branding.logoTooLarge', 'Das Logo darf maximal 1 MB groß sein.')); + event.target.value = ''; + return; + } + + const reader = new FileReader(); + reader.onload = () => { + const dataUrl = typeof reader.result === 'string' ? reader.result : ''; + setForm((prev) => ({ ...prev, logo: { ...prev.logo, mode: 'upload', value: dataUrl } })); + }; + reader.readAsDataURL(file); + }; + + const handleEmoticonSelect = (value: string) => { + setForm((prev) => ({ ...prev, logo: { ...prev.logo, mode: 'emoticon', value } })); + }; + const previewBranding = resolvePreviewBranding(form, tenantBranding); + const fabActions = React.useMemo(() => { + if (!slug) return []; + + return [ + { + key: 'save', + label: isPending ? t('branding.saving', 'Speichern...') : t('branding.save', 'Branding speichern'), + icon: Save, + onClick: () => mutate(form), + loading: isPending, + disabled: isPending || eventLoading, + tone: 'primary', + }, + { + key: 'reset', + label: t('branding.reset', 'Auf Standard zurücksetzen'), + icon: RotateCcw, + onClick: () => setForm({ ...(tenantBranding ?? DEFAULT_BRANDING_FORM), useDefault: true }), + disabled: isPending, + tone: 'secondary', + }, + ]; + }, [slug, mutate, form, isPending, eventLoading, t, tenantBranding]); + return ( )} > -
+
{t('branding.fontDefault', 'Standard (Tenant)')} {availableFonts.map((font) => ( - {font.family} + handleFontPreview(font.family)} + onFocus={() => handleFontPreview(font.family)} + > + + {font.family} + AaBb + + ))} {t('branding.fontCustom', 'Eigene Schrift eingeben')} @@ -461,6 +609,7 @@ export default function EventBrandingPage(): React.ReactElement { onChange={(e) => setForm((prev) => ({ ...prev, typography: { ...prev.typography, heading: e.target.value } }))} disabled={form.useDefault} placeholder="z. B. Playfair Display" + style={form.typography.heading ? { fontFamily: form.typography.heading } : undefined} />
@@ -476,7 +625,17 @@ export default function EventBrandingPage(): React.ReactElement { {t('branding.fontDefault', 'Standard (Tenant)')} {availableFonts.map((font) => ( - {font.family} + handleFontPreview(font.family)} + onFocus={() => handleFontPreview(font.family)} + > + + {font.family} + AaBb + + ))} {t('branding.fontCustom', 'Eigene Schrift eingeben')} @@ -486,6 +645,7 @@ export default function EventBrandingPage(): React.ReactElement { onChange={(e) => setForm((prev) => ({ ...prev, typography: { ...prev.typography, body: e.target.value } }))} disabled={form.useDefault} placeholder="z. B. Inter, sans-serif" + style={form.typography.body ? { fontFamily: form.typography.body } : undefined} />
@@ -528,6 +688,72 @@ export default function EventBrandingPage(): React.ReactElement {
+ {form.logo.mode === 'emoticon' && ( +
+ + + + + + + + + {t('branding.logoEmoticonGrid', 'Schnellauswahl (Emoticons)')} + + +
+ {EMOTICON_GRID.map((emoji) => { + const isActive = form.logo.value === emoji; + return ( + + ); + })} +
+
+
+
+ )} + {form.logo.mode === 'upload' && ( +
+ +
+ + + + {t('branding.logoUploadHint', 'Max. 1 MB, PNG/SVG/JPG. Aktueller Wert wird ersetzt.')} + +
+
+ )}
setNewTaskTitle(e.target.value)} + placeholder={t('sections.library.quickTitle', 'Titel der Aufgabe')} + disabled={!tasksEnabled || creatingTask} + /> +