diff --git a/app/Http/Controllers/Api/Tenant/AdminPushSubscriptionController.php b/app/Http/Controllers/Api/Tenant/AdminPushSubscriptionController.php new file mode 100644 index 0000000..2b59304 --- /dev/null +++ b/app/Http/Controllers/Api/Tenant/AdminPushSubscriptionController.php @@ -0,0 +1,65 @@ +attributes->get('tenant') ?? $request->user()?->tenant; + + if (! $tenant) { + return response()->json([ + 'error' => [ + 'code' => 'tenant_context_missing', + 'title' => 'Tenant context missing', + 'message' => 'Unable to resolve tenant for push subscriptions.', + ], + ], 403); + } + + $payload = array_merge($request->validated(), [ + 'language' => $request->getPreferredLanguage() ?? $request->headers->get('Accept-Language'), + 'user_agent' => (string) $request->userAgent(), + ]); + + $subscription = $subscriptions->register($tenant, $request->user(), $payload); + + return response()->json([ + 'id' => $subscription->id, + 'status' => $subscription->status, + ], 201); + } + + public function destroy( + AdminPushSubscriptionDeleteRequest $request, + TenantAdminPushSubscriptionService $subscriptions + ): JsonResponse { + $tenant = $request->attributes->get('tenant') ?? $request->user()?->tenant; + + if (! $tenant) { + return response()->json([ + 'error' => [ + 'code' => 'tenant_context_missing', + 'title' => 'Tenant context missing', + 'message' => 'Unable to resolve tenant for push subscriptions.', + ], + ], 403); + } + + $validated = $request->validated(); + $revoked = $subscriptions->revoke($tenant, $validated['endpoint'], $request->user()); + + return response()->json([ + 'status' => $revoked ? 'revoked' : 'not_found', + ]); + } +} diff --git a/app/Http/Requests/Tenant/AdminPushSubscriptionDeleteRequest.php b/app/Http/Requests/Tenant/AdminPushSubscriptionDeleteRequest.php new file mode 100644 index 0000000..1ea93f2 --- /dev/null +++ b/app/Http/Requests/Tenant/AdminPushSubscriptionDeleteRequest.php @@ -0,0 +1,28 @@ +|string> + */ + public function rules(): array + { + return [ + 'endpoint' => ['required', 'url', 'max:500'], + ]; + } +} diff --git a/app/Http/Requests/Tenant/AdminPushSubscriptionStoreRequest.php b/app/Http/Requests/Tenant/AdminPushSubscriptionStoreRequest.php new file mode 100644 index 0000000..50411ba --- /dev/null +++ b/app/Http/Requests/Tenant/AdminPushSubscriptionStoreRequest.php @@ -0,0 +1,33 @@ +|string> + */ + public function rules(): array + { + return [ + 'endpoint' => ['required', 'url', 'max:500'], + 'keys.p256dh' => ['required', 'string', 'max:255'], + 'keys.auth' => ['required', 'string', 'max:255'], + 'expiration_time' => ['nullable'], + 'content_encoding' => ['nullable', 'string', 'max:32'], + 'device_id' => ['nullable', 'string', 'max:120'], + ]; + } +} diff --git a/app/Jobs/SendTenantAdminPushNotification.php b/app/Jobs/SendTenantAdminPushNotification.php new file mode 100644 index 0000000..175ed12 --- /dev/null +++ b/app/Jobs/SendTenantAdminPushNotification.php @@ -0,0 +1,125 @@ +onQueue('notifications'); + } + + /** + * Execute the job. + */ + public function handle( + AdminWebPushDispatcher $dispatcher, + TenantAdminPushSubscriptionService $subscriptions + ): void { + if (! config('push.enabled')) { + return; + } + + $notification = TenantNotificationLog::query()->find($this->notificationLogId); + + if (! $notification) { + return; + } + + $targets = TenantAdminPushSubscription::query() + ->where('tenant_id', $notification->tenant_id) + ->where('status', 'active') + ->get(); + + if ($targets->isEmpty()) { + return; + } + + $context = $notification->context ?? []; + $eventId = Arr::get($context, 'event_id') ?? Arr::get($context, 'eventId'); + $event = $eventId ? Event::query()->select(['id', 'name', 'slug'])->find($eventId) : null; + $eventName = $this->resolveEventName($event?->name); + + $title = $eventName + ? sprintf('%s · %s', $eventName, Str::headline(str_replace('_', ' ', $notification->type))) + : Str::headline(str_replace('_', ' ', $notification->type)); + + $payload = [ + 'title' => $title, + 'body' => $notification->status === 'failed' + ? ($notification->failure_reason ?: 'Benachrichtigung fehlgeschlagen') + : 'Neue Admin-Benachrichtigung', + 'data' => [ + 'notification_id' => $notification->id, + 'event_id' => $event?->id, + 'url' => "/event-admin/mobile/notifications/{$notification->id}", + ], + ]; + + foreach ($targets as $target) { + try { + $report = $dispatcher->send($target, $payload); + + if ($report === null) { + continue; + } + + if ($report->isSuccess()) { + $subscriptions->markDelivered($target); + + continue; + } + + if ($report->isSubscriptionExpired()) { + $target->update(['status' => 'revoked']); + } + + $subscriptions->markFailed($target, $report->getReason()); + } catch (\Throwable $exception) { + Log::channel('notifications')->warning('Admin web push delivery failed', [ + 'subscription_id' => $target->id, + 'tenant_id' => $notification->tenant_id, + 'reason' => $exception->getMessage(), + ]); + + $subscriptions->markFailed($target, $exception->getMessage()); + } + } + } + + private function resolveEventName(mixed $name): ?string + { + if (is_string($name)) { + return $name; + } + + if (is_array($name)) { + foreach ($name as $value) { + if (is_string($value) && $value !== '') { + return $value; + } + } + } + + return null; + } +} diff --git a/app/Models/TenantAdminPushSubscription.php b/app/Models/TenantAdminPushSubscription.php new file mode 100644 index 0000000..65c9c14 --- /dev/null +++ b/app/Models/TenantAdminPushSubscription.php @@ -0,0 +1,51 @@ + */ + use HasFactory; + + protected $fillable = [ + 'tenant_id', + 'user_id', + 'device_id', + 'endpoint', + 'endpoint_hash', + 'public_key', + 'auth_token', + 'content_encoding', + 'status', + 'expires_at', + 'last_seen_at', + 'last_notified_at', + 'last_failed_at', + 'failure_count', + 'language', + 'user_agent', + 'meta', + ]; + + protected $casts = [ + 'expires_at' => 'datetime', + 'last_seen_at' => 'datetime', + 'last_notified_at' => 'datetime', + 'last_failed_at' => 'datetime', + 'meta' => 'array', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Services/Packages/TenantNotificationLogger.php b/app/Services/Packages/TenantNotificationLogger.php index ba30aa0..33f38d4 100644 --- a/app/Services/Packages/TenantNotificationLogger.php +++ b/app/Services/Packages/TenantNotificationLogger.php @@ -2,6 +2,7 @@ namespace App\Services\Packages; +use App\Jobs\SendTenantAdminPushNotification; use App\Models\Tenant; use App\Models\TenantNotificationLog; use Illuminate\Support\Arr; @@ -32,6 +33,8 @@ class TenantNotificationLogger 'recipient' => $log->recipient, ]); + SendTenantAdminPushNotification::dispatch($log->id); + return $log; } } diff --git a/app/Services/Push/AdminWebPushDispatcher.php b/app/Services/Push/AdminWebPushDispatcher.php new file mode 100644 index 0000000..98cf739 --- /dev/null +++ b/app/Services/Push/AdminWebPushDispatcher.php @@ -0,0 +1,82 @@ +client ??= $this->buildClient(); + + if (! $client) { + return null; + } + + try { + $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + Log::channel('notifications')->warning('Unable to encode admin push payload', [ + 'reason' => $exception->getMessage(), + ]); + + $body = '{}'; + } + + try { + return $client->sendOneNotification( + WebPushSubscription::create([ + 'endpoint' => $subscription->endpoint, + 'publicKey' => $subscription->public_key, + 'authToken' => $subscription->auth_token, + 'contentEncoding' => $subscription->content_encoding ?? 'aes128gcm', + ]), + $body + ); + } catch (\Throwable $exception) { + Log::channel('notifications')->warning('Admin web push transport error', [ + 'tenant_id' => $subscription->tenant_id, + 'subscription_id' => $subscription->id, + 'reason' => $exception->getMessage(), + ]); + + return null; + } + } + + private function buildClient(): ?WebPush + { + $vapid = config('push.vapid', []); + + if (empty($vapid['public_key']) || empty($vapid['private_key'])) { + Log::channel('notifications')->warning('Admin web push skipped because VAPID keys are missing.'); + + return null; + } + + $client = new WebPush([ + 'VAPID' => [ + 'subject' => $vapid['subject'] ?? config('app.url'), + 'publicKey' => $vapid['public_key'], + 'privateKey' => $vapid['private_key'], + ], + ]); + + $client->setDefaultOptions([ + 'TTL' => (int) config('push.ttl', 900), + ]); + + return $client; + } +} diff --git a/app/Services/TenantAdminPushSubscriptionService.php b/app/Services/TenantAdminPushSubscriptionService.php new file mode 100644 index 0000000..071c5df --- /dev/null +++ b/app/Services/TenantAdminPushSubscriptionService.php @@ -0,0 +1,153 @@ +sanitizeIdentifier(Arr::get($payload, 'device_id')); + $contentEncoding = (string) ($payload['content_encoding'] ?? Arr::get($payload, 'encoding', 'aes128gcm')); + $language = (string) ($payload['language'] ?? null); + $userAgent = (string) ($payload['user_agent'] ?? null); + $expiresAt = $this->normalizeExpiration(Arr::get($payload, 'expiration_time')); + + $endpointHash = hash('sha256', $endpoint); + $data = [ + 'tenant_id' => $tenant->id, + 'user_id' => $user?->id, + 'device_id' => $deviceId, + 'public_key' => $publicKey, + 'auth_token' => $authToken, + 'content_encoding' => $contentEncoding ?: 'aes128gcm', + 'status' => 'active', + 'expires_at' => $expiresAt, + 'last_seen_at' => now(), + 'language' => $language !== '' ? substr($language, 0, 12) : null, + 'user_agent' => $userAgent !== '' ? substr($userAgent, 0, 255) : null, + 'failure_count' => 0, + ]; + + $subscription = TenantAdminPushSubscription::query() + ->where('endpoint_hash', $endpointHash) + ->first(); + + if ($subscription) { + $subscription->fill($data); + $subscription->status = 'active'; + $subscription->endpoint = $endpoint; + $subscription->save(); + + return $subscription; + } + + return TenantAdminPushSubscription::create(array_merge($data, [ + 'endpoint' => $endpoint, + 'endpoint_hash' => $endpointHash, + ])); + } + + public function revoke(Tenant $tenant, string $endpoint, ?User $user = null): bool + { + $hash = hash('sha256', (string) $endpoint); + + $subscription = TenantAdminPushSubscription::query() + ->where('tenant_id', $tenant->id) + ->when($user, fn ($query) => $query->where('user_id', $user->id)) + ->where(function ($query) use ($hash, $endpoint) { + $query->where('endpoint_hash', $hash) + ->orWhere('endpoint', $endpoint); + }) + ->first(); + + if (! $subscription) { + return false; + } + + $subscription->update([ + 'status' => 'revoked', + 'last_failed_at' => now(), + ]); + + return true; + } + + public function markFailed(TenantAdminPushSubscription $subscription, ?string $message = null): void + { + $subscription->fill([ + 'last_failed_at' => now(), + 'failure_count' => min(65535, $subscription->failure_count + 1), + ]); + + if ($subscription->failure_count >= 3) { + $subscription->status = 'revoked'; + } + + $meta = $subscription->meta ?? []; + if ($message) { + $meta['last_error'] = substr($message, 0, 255); + } + + $subscription->meta = $meta; + $subscription->save(); + } + + public function markDelivered(TenantAdminPushSubscription $subscription): void + { + $subscription->fill([ + 'last_notified_at' => now(), + 'last_failed_at' => null, + 'failure_count' => 0, + ])->save(); + } + + private function sanitizeIdentifier(?string $value): ?string + { + if ($value === null) { + return null; + } + + $sanitized = preg_replace('/[^A-Za-z0-9 _\-]/', '', $value) ?? ''; + $sanitized = trim(mb_substr($sanitized, 0, 120)); + + return $sanitized === '' ? null : $sanitized; + } + + private function normalizeExpiration(mixed $value): ?CarbonImmutable + { + if ($value === null || $value === '') { + return null; + } + + if (is_numeric($value)) { + $seconds = (int) round(((float) $value) / 1000); + + return CarbonImmutable::createFromTimestampUTC($seconds); + } + + try { + return CarbonImmutable::parse((string) $value); + } catch (\Throwable) { + return null; + } + } +} diff --git a/database/factories/TenantAdminPushSubscriptionFactory.php b/database/factories/TenantAdminPushSubscriptionFactory.php new file mode 100644 index 0000000..555dd37 --- /dev/null +++ b/database/factories/TenantAdminPushSubscriptionFactory.php @@ -0,0 +1,41 @@ + + */ +class TenantAdminPushSubscriptionFactory extends Factory +{ + protected $model = TenantAdminPushSubscription::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $endpoint = $this->faker->url(); + + return [ + 'tenant_id' => Tenant::factory(), + 'user_id' => User::factory(), + 'device_id' => (string) Str::uuid(), + 'endpoint' => $endpoint, + 'endpoint_hash' => hash('sha256', $endpoint), + 'public_key' => base64_encode(random_bytes(32)), + 'auth_token' => base64_encode(random_bytes(16)), + 'content_encoding' => 'aes128gcm', + 'status' => 'active', + 'language' => 'de', + 'user_agent' => 'Mozilla/5.0', + ]; + } +} diff --git a/database/migrations/2025_12_28_141038_create_tenant_admin_push_subscriptions_table.php b/database/migrations/2025_12_28_141038_create_tenant_admin_push_subscriptions_table.php new file mode 100644 index 0000000..a64da63 --- /dev/null +++ b/database/migrations/2025_12_28_141038_create_tenant_admin_push_subscriptions_table.php @@ -0,0 +1,48 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('device_id', 120)->nullable(); + $table->string('endpoint', 500)->unique(); + $table->string('endpoint_hash', 128)->index(); + $table->string('public_key', 255); + $table->string('auth_token', 255); + $table->string('content_encoding', 32)->default('aes128gcm'); + $table->string('status', 32)->default('active'); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('last_seen_at')->nullable(); + $table->timestamp('last_notified_at')->nullable(); + $table->timestamp('last_failed_at')->nullable(); + $table->unsignedSmallInteger('failure_count')->default(0); + $table->string('language', 12)->nullable(); + $table->string('user_agent', 255)->nullable(); + $table->json('meta')->nullable(); + $table->timestamps(); + + $table->index(['tenant_id', 'status']); + $table->index(['tenant_id', 'user_id']); + $table->index(['tenant_id', 'device_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tenant_admin_push_subscriptions'); + } +}; diff --git a/database/seeders/TenantAdminPushSubscriptionSeeder.php b/database/seeders/TenantAdminPushSubscriptionSeeder.php new file mode 100644 index 0000000..bc6a1f7 --- /dev/null +++ b/database/seeders/TenantAdminPushSubscriptionSeeder.php @@ -0,0 +1,19 @@ +count(3) + ->create(); + } +} diff --git a/public/admin-apple-touch-icon.png b/public/admin-apple-touch-icon.png new file mode 100644 index 0000000..ae9267c Binary files /dev/null and b/public/admin-apple-touch-icon.png differ diff --git a/public/admin-badge.png b/public/admin-badge.png new file mode 100644 index 0000000..12aae67 Binary files /dev/null and b/public/admin-badge.png differ diff --git a/public/admin-icon-192-maskable.png b/public/admin-icon-192-maskable.png new file mode 100644 index 0000000..9edbb2e Binary files /dev/null and b/public/admin-icon-192-maskable.png differ diff --git a/public/admin-icon-192.png b/public/admin-icon-192.png new file mode 100644 index 0000000..d11e2a5 Binary files /dev/null and b/public/admin-icon-192.png differ diff --git a/public/admin-icon-512-maskable.png b/public/admin-icon-512-maskable.png new file mode 100644 index 0000000..4020189 Binary files /dev/null and b/public/admin-icon-512-maskable.png differ diff --git a/public/admin-icon-512.png b/public/admin-icon-512.png new file mode 100644 index 0000000..81ba08b Binary files /dev/null and b/public/admin-icon-512.png differ diff --git a/public/admin-sw.js b/public/admin-sw.js index 4b8099b..6b996a3 100644 --- a/public/admin-sw.js +++ b/public/admin-sw.js @@ -97,3 +97,51 @@ self.addEventListener('fetch', (event) => { ); } }); + +self.addEventListener('push', (event) => { + const payload = event.data?.json?.() ?? {}; + + event.waitUntil( + (async () => { + const title = payload.title ?? 'Neue Admin-Benachrichtigung'; + const options = { + body: payload.body ?? '', + icon: '/admin-icon-192.png', + badge: '/admin-badge.png', + data: payload.data ?? {}, + }; + + await self.registration.showNotification(title, options); + + const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + clients.forEach((client) => client.postMessage({ type: 'admin-notification-refresh' })); + })() + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const targetUrl = event.notification.data?.url || '/event-admin/mobile/notifications'; + + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { + for (const client of clientList) { + if ('focus' in client) { + client.navigate(targetUrl); + return client.focus(); + } + } + if (self.clients.openWindow) { + return self.clients.openWindow(targetUrl); + } + }) + ); +}); + +self.addEventListener('pushsubscriptionchange', (event) => { + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { + clientList.forEach((client) => client.postMessage({ type: 'push-subscription-change' })); + }) + ); +}); diff --git a/public/manifest.json b/public/manifest.json index 531806b..bf9da74 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -19,20 +19,26 @@ "purpose": "any" }, { - "src": "/favicon.svg", - "sizes": "any", - "type": "image/svg+xml", - "purpose": "maskable" - }, - { - "src": "/apple-touch-icon.png", - "sizes": "180x180", + "src": "/admin-icon-192.png", + "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/apple-touch-icon.png", - "sizes": "180x180", + "src": "/admin-icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/admin-icon-192-maskable.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/admin-icon-512-maskable.png", + "sizes": "512x512", "type": "image/png", "purpose": "maskable" } diff --git a/resources/js/admin/DevTenantSwitcher.tsx b/resources/js/admin/DevTenantSwitcher.tsx index 4848b01..743a0a7 100644 --- a/resources/js/admin/DevTenantSwitcher.tsx +++ b/resources/js/admin/DevTenantSwitcher.tsx @@ -59,6 +59,19 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D return null; } + async function handleLogin(key: string) { + if (!helper) { + return; + } + setLoggingIn(key); + try { + await helper.loginAs(key); + } catch (error) { + console.error('[DevAuth] Switch failed', error); + setLoggingIn(null); + } + } + if (variant === 'inline') { if (collapsed) { return ( @@ -133,25 +146,14 @@ export function DevTenantSwitcher({ bottomOffset = 16, variant = 'floating' }: D icon={} borderRadius={999} position="fixed" - right="$4" - zIndex={1000} - onPress={() => setCollapsed(false)} - style={{ bottom: bottomOffset + 70 }} - > - Demo tenants - - ); -} - - async function handleLogin(key: string) { - if (!helper) return; - setLoggingIn(key); - try { - await helper.loginAs(key); - } catch (error) { - console.error('[DevAuth] Switch failed', error); - setLoggingIn(null); - } + right="$4" + zIndex={1000} + onPress={() => setCollapsed(false)} + style={{ bottom: bottomOffset + 70 }} + > + Demo tenants + + ); } return ( diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 2d927aa..5d75450 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -673,6 +673,15 @@ export type EventToolkitNotification = { }; type CreatedEventResponse = { message: string; data: JsonValue; balance: number }; type PhotoResponse = { message: string; data: TenantPhoto }; +type AdminPushSubscriptionPayload = { + endpoint: string; + keys: { + p256dh: string; + auth: string; + }; + expirationTime?: number | null; + contentEncoding?: string | null; +}; type EventSavePayload = { name: string; @@ -1498,6 +1507,43 @@ export async function getEventPhotos( }; } +export async function getEventPhoto(slug: string, id: number): Promise { + const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}`); + const data = await jsonOrThrow(response, 'Failed to load photo'); + return normalizePhoto(data.data); +} + +export async function registerAdminPushSubscription(subscription: PushSubscription, deviceId?: string): Promise { + const json = subscription.toJSON() as AdminPushSubscriptionPayload; + const response = await authorizedFetch('/api/v1/tenant/notifications/push-subscriptions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + endpoint: json.endpoint, + keys: json.keys, + expiration_time: json.expirationTime ?? null, + content_encoding: json.contentEncoding ?? null, + device_id: deviceId ?? null, + }), + }); + + await jsonOrThrow<{ id: number; status: string }>(response, 'Failed to register push subscription', { suppressToast: true }); +} + +export async function unregisterAdminPushSubscription(endpoint: string): Promise { + const response = await authorizedFetch('/api/v1/tenant/notifications/push-subscriptions', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ endpoint }), + }); + + await jsonOrThrow<{ status: string }>(response, 'Failed to unregister push subscription', { suppressToast: true }); +} + export async function featurePhoto(slug: string, id: number): Promise { const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}/feature`, { method: 'POST' }); const data = await jsonOrThrow(response, 'Failed to feature photo'); diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 5234323..5cd5d23 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -1955,7 +1955,14 @@ "unfeatureSuccess": "Highlight entfernt", "featureFailed": "Highlight konnte nicht geändert werden.", "approveSuccess": "Foto freigegeben", - "approveFailed": "Freigabe fehlgeschlagen." + "approveFailed": "Freigabe fehlgeschlagen.", + "queued": "Aktion gespeichert. Wird synchronisiert, sobald du online bist.", + "queueTitle": "Änderungen warten auf Sync", + "queueOnline": "{{count}} Aktionen bereit zur Synchronisierung.", + "queueOffline": "{{count}} Aktionen gespeichert – offline.", + "queueSync": "Sync", + "queueWaiting": "Offline", + "syncFailed": "Synchronisierung fehlgeschlagen. Bitte später erneut versuchen." }, "mobileProfile": { "title": "Profil", @@ -1976,6 +1983,39 @@ "tenantBadge": "Tenant #{{id}}", "notificationsTitle": "Benachrichtigungen", "notificationsLoading": "Lade Einstellungen ...", + "pushTitle": "App Push", + "pushUnsupported": "Push-Benachrichtigungen werden auf diesem Gerät nicht unterstützt.", + "pushDenied": "Benachrichtigungen sind im Browser blockiert.", + "pushActive": "Push aktiv", + "pushInactive": "Push deaktiviert", + "pushLoading": "Lädt ...", + "deviceTitle": "Gerät & Berechtigungen", + "deviceDescription": "Halte die Admin-App schnell, offline-bereit und für Benachrichtigungen freigeschaltet.", + "deviceLoading": "Gerätestatus wird geprüft ...", + "deviceStorageAction": "Offline-Schutz aktivieren", + "deviceStorageError": "Offline-Schutz konnte nicht aktiviert werden.", + "deviceStatusValues": { + "granted": "Erlaubt", + "denied": "Blockiert", + "prompt": "Berechtigung nötig", + "unsupported": "Nicht unterstützt", + "persisted": "Geschützt", + "available": "Nicht geschützt" + }, + "deviceStatus": { + "notifications": { + "label": "Benachrichtigungen", + "description": "Erlaubt Warnungen und Admin-Updates." + }, + "camera": { + "label": "Kamera", + "description": "Für QR-Scans und schnelle Aufnahmen." + }, + "storage": { + "label": "Offline-Speicher", + "description": "Schützt zwischengespeicherte Daten vor Löschung." + } + }, "pref": {} }, "events": { diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index b25c670..ae35b14 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -1975,7 +1975,14 @@ "unfeatureSuccess": "Highlight removed", "featureFailed": "Highlight could not be changed", "approveSuccess": "Photo approved", - "approveFailed": "Approval failed." + "approveFailed": "Approval failed.", + "queued": "Action saved. Syncs when you are back online.", + "queueTitle": "Changes waiting to sync", + "queueOnline": "{{count}} actions ready to sync.", + "queueOffline": "{{count}} actions saved offline.", + "queueSync": "Sync", + "queueWaiting": "Offline", + "syncFailed": "Sync failed. Please try again later." }, "mobileProfile": { "title": "Profile", @@ -1996,6 +2003,39 @@ "tenantBadge": "Tenant #{{id}}", "notificationsTitle": "Notifications", "notificationsLoading": "Loading settings ...", + "pushTitle": "App Push", + "pushUnsupported": "Push notifications are not supported on this device.", + "pushDenied": "Notifications are blocked in your browser.", + "pushActive": "Push active", + "pushInactive": "Push disabled", + "pushLoading": "Loading ...", + "deviceTitle": "Device & permissions", + "deviceDescription": "Keep the admin app fast, offline-ready, and allowed to send alerts.", + "deviceLoading": "Checking device status ...", + "deviceStorageAction": "Enable offline protection", + "deviceStorageError": "Offline storage could not be enabled.", + "deviceStatusValues": { + "granted": "Allowed", + "denied": "Blocked", + "prompt": "Needs permission", + "unsupported": "Not supported", + "persisted": "Protected", + "available": "Not protected" + }, + "deviceStatus": { + "notifications": { + "label": "Notifications", + "description": "Allow alerts and admin updates." + }, + "camera": { + "label": "Camera", + "description": "Needed for QR scans and quick capture." + }, + "storage": { + "label": "Offline storage", + "description": "Protect cached data from eviction." + } + }, "pref": {} }, "events": { diff --git a/resources/js/admin/lib/device.ts b/resources/js/admin/lib/device.ts new file mode 100644 index 0000000..b1a3ed5 --- /dev/null +++ b/resources/js/admin/lib/device.ts @@ -0,0 +1,17 @@ +export function getAdminDeviceId(): string { + const key = 'admin-device-id'; + let id = localStorage.getItem(key); + if (!id) { + id = generateId(); + localStorage.setItem(key, id); + } + return id; +} + +function generateId(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (crypto.getRandomValues(new Uint8Array(1))[0] & 0xf) >> 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} diff --git a/resources/js/admin/lib/runtime-config.ts b/resources/js/admin/lib/runtime-config.ts new file mode 100644 index 0000000..1a1efd1 --- /dev/null +++ b/resources/js/admin/lib/runtime-config.ts @@ -0,0 +1,23 @@ +type PushConfig = { + enabled: boolean; + vapidPublicKey: string | null; +}; + +type AdminRuntimeConfig = { + push: PushConfig; +}; + +export function getAdminRuntimeConfig(): AdminRuntimeConfig { + const raw = typeof window !== 'undefined' ? window.__ADMIN_RUNTIME_CONFIG__ : undefined; + + return { + push: { + enabled: Boolean(raw?.push?.enabled), + vapidPublicKey: raw?.push?.vapidPublicKey ?? null, + }, + }; +} + +export function getAdminPushConfig(): PushConfig { + return getAdminRuntimeConfig().push; +} diff --git a/resources/js/admin/mobile/EventPhotosPage.tsx b/resources/js/admin/mobile/EventPhotosPage.tsx index 85464e2..3607906 100644 --- a/resources/js/admin/mobile/EventPhotosPage.tsx +++ b/resources/js/admin/mobile/EventPhotosPage.tsx @@ -11,6 +11,7 @@ import { MobileCard, PillBadge, CTAButton, SkeletonCard } from './components/Pri import { MobileField, MobileInput, MobileSelect } from './components/FormControls'; import { getEventPhotos, + getEventPhoto, updatePhotoVisibility, featurePhoto, unfeaturePhoto, @@ -33,11 +34,21 @@ import { buildLimitWarnings } from '../lib/limitWarnings'; import { adminPath } from '../constants'; import { scopeDefaults, selectAddonKeyForScope } from './addons'; import { LegalConsentSheet } from './components/LegalConsentSheet'; +import { triggerHaptic } from './lib/haptics'; +import { useOnlineStatus } from './hooks/useOnlineStatus'; +import { + enqueuePhotoAction, + loadPhotoQueue, + removePhotoAction, + replacePhotoQueue, + type PhotoModerationAction, +} from './lib/photoModerationQueue'; +import { ADMIN_EVENT_PHOTOS_PATH } from '../constants'; type FilterKey = 'all' | 'featured' | 'hidden' | 'pending'; export default function MobileEventPhotosPage() { - const { slug: slugParam } = useParams<{ slug?: string }>(); + const { slug: slugParam, photoId: photoIdParam } = useParams<{ slug?: string; photoId?: string }>(); const { activeEvent, selectEvent } = useEventContext(); const slug = slugParam ?? activeEvent?.slug ?? null; const navigate = useNavigate(); @@ -57,7 +68,9 @@ export default function MobileEventPhotosPage() { const [uploaderFilter, setUploaderFilter] = React.useState(''); const [onlyFeatured, setOnlyFeatured] = React.useState(false); const [onlyHidden, setOnlyHidden] = React.useState(false); - const [lightbox, setLightbox] = React.useState(null); + const [lightboxId, setLightboxId] = React.useState(null); + const [pendingPhotoId, setPendingPhotoId] = React.useState(null); + const [syncingQueue, setSyncingQueue] = React.useState(false); const [selectionMode, setSelectionMode] = React.useState(false); const [selectedIds, setSelectedIds] = React.useState([]); const [bulkBusy, setBulkBusy] = React.useState(false); @@ -68,6 +81,9 @@ export default function MobileEventPhotosPage() { const [consentOpen, setConsentOpen] = React.useState(false); const [consentTarget, setConsentTarget] = React.useState<{ scope: 'photos' | 'gallery' | 'guests'; addonKey: string } | null>(null); const [consentBusy, setConsentBusy] = React.useState(false); + const [queuedActions, setQueuedActions] = React.useState(() => loadPhotoQueue()); + const online = useOnlineStatus(); + const syncingQueueRef = React.useRef(false); const theme = useTheme(); const text = String(theme.color?.val ?? '#111827'); const muted = String(theme.gray?.val ?? '#4b5563'); @@ -78,12 +94,47 @@ export default function MobileEventPhotosPage() { const surface = String(theme.surface?.val ?? '#ffffff'); const backdrop = String(theme.gray12?.val ?? '#0f172a'); + const lightboxIndex = React.useMemo(() => { + if (lightboxId === null) { + return -1; + } + return photos.findIndex((photo) => photo.id === lightboxId); + }, [photos, lightboxId]); + const lightbox = lightboxIndex >= 0 ? photos[lightboxIndex] : null; + const basePhotosPath = slug ? ADMIN_EVENT_PHOTOS_PATH(slug) : adminPath('/mobile/events'); + const parsedPhotoId = React.useMemo(() => { + if (!photoIdParam) { + return null; + } + const parsed = Number(photoIdParam); + return Number.isFinite(parsed) ? parsed : null; + }, [photoIdParam]); + React.useEffect(() => { - if (lightbox) { + if (lightboxId !== null && lightboxIndex === -1 && !loading && pendingPhotoId !== lightboxId) { + setLightboxId(null); + } + }, [lightboxId, lightboxIndex, loading, pendingPhotoId]); + + React.useEffect(() => { + if (lightboxId !== null) { setSelectionMode(false); setSelectedIds([]); } - }, [lightbox]); + }, [lightboxId]); + + React.useEffect(() => { + if (parsedPhotoId === null) { + if (!photoIdParam) { + setLightboxId(null); + } + setPendingPhotoId(null); + return; + } + + setLightboxId(parsedPhotoId); + setPendingPhotoId(parsedPhotoId); + }, [parsedPhotoId, photoIdParam]); React.useEffect(() => { if (slugParam && activeEvent?.slug !== slugParam) { @@ -150,66 +201,255 @@ export default function MobileEventPhotosPage() { setPage(1); }, [filter, slug]); - async function toggleVisibility(photo: TenantPhoto) { - if (!slug) return; - setBusyId(photo.id); - try { - const updated = await updatePhotoVisibility(slug, photo.id, photo.status === 'hidden'); - setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p))); - setLightbox((prev) => (prev && prev.id === photo.id ? updated : prev)); - toast.success( - updated.status === 'hidden' - ? t('mobilePhotos.hideSuccess', 'Photo hidden') - : t('mobilePhotos.showSuccess', 'Photo shown'), - ); - } catch (err) { - if (!isAuthError(err)) { - setError(getApiErrorMessage(err, t('mobilePhotos.visibilityFailed', 'Sichtbarkeit konnte nicht geändert werden.'))); - toast.error(t('mobilePhotos.visibilityFailed', 'Sichtbarkeit konnte nicht geändert werden.')); + const updateQueueState = React.useCallback((queue: PhotoModerationAction[]) => { + replacePhotoQueue(queue); + setQueuedActions(queue); + }, []); + + const applyOptimisticUpdate = React.useCallback((photoId: number, action: PhotoModerationAction['action']) => { + setPhotos((prev) => + prev.map((photo) => { + if (photo.id !== photoId) { + return photo; + } + + if (action === 'approve') { + return { ...photo, status: 'approved' }; + } + + if (action === 'hide') { + return { ...photo, status: 'hidden' }; + } + + if (action === 'show') { + return { ...photo, status: 'approved' }; + } + + if (action === 'feature') { + return { ...photo, is_featured: true }; + } + + if (action === 'unfeature') { + return { ...photo, is_featured: false }; + } + + return photo; + }), + ); + }, []); + + const enqueueModerationAction = React.useCallback( + (action: PhotoModerationAction['action'], photoId: number) => { + if (!slug) { + return; } - } finally { - setBusyId(null); + const nextQueue = enqueuePhotoAction({ eventSlug: slug, photoId, action }); + setQueuedActions(nextQueue); + applyOptimisticUpdate(photoId, action); + toast.success(t('mobilePhotos.queued', 'Aktion gespeichert. Wird synchronisiert, sobald du online bist.')); + triggerHaptic('selection'); + }, + [applyOptimisticUpdate, slug, t], + ); + + const syncQueuedActions = React.useCallback( + async (options?: { silent?: boolean }) => { + if (!online || syncingQueueRef.current) { + return; + } + + const queue = loadPhotoQueue(); + if (queue.length === 0) { + return; + } + + syncingQueueRef.current = true; + setSyncingQueue(true); + + let remaining = queue; + + for (const entry of queue) { + try { + let updated: TenantPhoto | null = null; + if (entry.action === 'approve') { + updated = await updatePhotoStatus(entry.eventSlug, entry.photoId, 'approved'); + } else if (entry.action === 'hide') { + updated = await updatePhotoVisibility(entry.eventSlug, entry.photoId, true); + } else if (entry.action === 'show') { + updated = await updatePhotoVisibility(entry.eventSlug, entry.photoId, false); + } else if (entry.action === 'feature') { + updated = await featurePhoto(entry.eventSlug, entry.photoId); + } else if (entry.action === 'unfeature') { + updated = await unfeaturePhoto(entry.eventSlug, entry.photoId); + } + + remaining = removePhotoAction(remaining, entry.id); + + if (updated && entry.eventSlug === slug) { + setPhotos((prev) => prev.map((photo) => (photo.id === updated!.id ? updated! : photo))); + } + } catch (err) { + if (!options?.silent) { + toast.error(t('mobilePhotos.syncFailed', 'Synchronisierung fehlgeschlagen. Bitte später erneut versuchen.')); + } + if (isAuthError(err)) { + break; + } + } + } + + updateQueueState(remaining); + setSyncingQueue(false); + syncingQueueRef.current = false; + }, + [online, slug, t, updateQueueState], + ); + + React.useEffect(() => { + if (online) { + void syncQueuedActions({ silent: true }); } + }, [online, syncQueuedActions]); + + const setLightboxWithUrl = React.useCallback( + (photoId: number | null, options?: { replace?: boolean }) => { + setLightboxId(photoId); + if (!slug) { + return; + } + const nextPath = photoId ? `${basePhotosPath}/${photoId}` : basePhotosPath; + if (location.pathname !== nextPath) { + navigate(`${nextPath}${location.search}`, { replace: options?.replace ?? false }); + } + }, + [basePhotosPath, location.pathname, location.search, navigate, slug], + ); + + const handleModerationAction = React.useCallback( + async (action: PhotoModerationAction['action'], photo: TenantPhoto) => { + if (!slug) { + return; + } + if (!online) { + enqueueModerationAction(action, photo.id); + return; + } + + setBusyId(photo.id); + + const successMessage = () => { + if (action === 'approve') { + return t('mobilePhotos.approveSuccess', 'Photo approved'); + } + if (action === 'hide') { + return t('mobilePhotos.hideSuccess', 'Photo hidden'); + } + if (action === 'show') { + return t('mobilePhotos.showSuccess', 'Photo shown'); + } + if (action === 'feature') { + return t('mobilePhotos.featureSuccess', 'Als Highlight markiert'); + } + return t('mobilePhotos.unfeatureSuccess', 'Highlight entfernt'); + }; + + const errorMessage = () => { + if (action === 'approve') { + return t('mobilePhotos.approveFailed', 'Freigabe fehlgeschlagen.'); + } + if (action === 'hide' || action === 'show') { + return t('mobilePhotos.visibilityFailed', 'Sichtbarkeit konnte nicht geändert werden.'); + } + return t('mobilePhotos.featureFailed', 'Feature konnte nicht geändert werden.'); + }; + + try { + let updated: TenantPhoto; + if (action === 'approve') { + updated = await updatePhotoStatus(slug, photo.id, 'approved'); + } else if (action === 'hide') { + updated = await updatePhotoVisibility(slug, photo.id, true); + } else if (action === 'show') { + updated = await updatePhotoVisibility(slug, photo.id, false); + } else if (action === 'feature') { + updated = await featurePhoto(slug, photo.id); + } else { + updated = await unfeaturePhoto(slug, photo.id); + } + + setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p))); + toast.success(successMessage()); + triggerHaptic(action === 'approve' ? 'success' : 'medium'); + } catch (err) { + if (!isAuthError(err)) { + setError(getApiErrorMessage(err, errorMessage())); + toast.error(errorMessage()); + } + } finally { + setBusyId(null); + } + }, + [enqueueModerationAction, online, slug, t], + ); + + React.useEffect(() => { + if (!slug || pendingPhotoId === null) { + return; + } + + if (photos.some((photo) => photo.id === pendingPhotoId)) { + setPendingPhotoId(null); + return; + } + + if (loading) { + return; + } + + let active = true; + void (async () => { + try { + const fetched = await getEventPhoto(slug, pendingPhotoId); + if (!active) { + return; + } + setPhotos((prev) => { + if (prev.some((photo) => photo.id === fetched.id)) { + return prev; + } + return [fetched, ...prev]; + }); + } catch (err) { + if (!isAuthError(err)) { + toast.error(t('mobilePhotos.loadFailed', 'Fotos konnten nicht geladen werden.')); + } + if (active) { + setLightboxWithUrl(null, { replace: true }); + } + } finally { + if (active) { + setPendingPhotoId(null); + } + } + })(); + + return () => { + active = false; + }; + }, [pendingPhotoId, slug, photos, loading, t, setLightboxWithUrl]); + + async function toggleVisibility(photo: TenantPhoto) { + const action = photo.status === 'hidden' ? 'show' : 'hide'; + await handleModerationAction(action, photo); } async function toggleFeature(photo: TenantPhoto) { - if (!slug) return; - setBusyId(photo.id); - try { - const updated = photo.is_featured ? await unfeaturePhoto(slug, photo.id) : await featurePhoto(slug, photo.id); - setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p))); - setLightbox((prev) => (prev && prev.id === photo.id ? updated : prev)); - toast.success( - updated.is_featured - ? t('mobilePhotos.featureSuccess', 'Als Highlight markiert') - : t('mobilePhotos.unfeatureSuccess', 'Highlight entfernt'), - ); - } catch (err) { - if (!isAuthError(err)) { - setError(getApiErrorMessage(err, t('mobilePhotos.featureFailed', 'Feature konnte nicht geändert werden.'))); - toast.error(t('mobilePhotos.featureFailed', 'Feature konnte nicht geändert werden.')); - } - } finally { - setBusyId(null); - } + const action = photo.is_featured ? 'unfeature' : 'feature'; + await handleModerationAction(action, photo); } async function approvePhoto(photo: TenantPhoto) { - if (!slug) return; - setBusyId(photo.id); - try { - const updated = await updatePhotoStatus(slug, photo.id, 'approved'); - setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p))); - setLightbox((prev) => (prev && prev.id === photo.id ? updated : prev)); - toast.success(t('mobilePhotos.approveSuccess', 'Photo approved')); - } catch (err) { - if (!isAuthError(err)) { - setError(getApiErrorMessage(err, t('mobilePhotos.approveFailed', 'Freigabe fehlgeschlagen.'))); - toast.error(t('mobilePhotos.approveFailed', 'Freigabe fehlgeschlagen.')); - } - } finally { - setBusyId(null); - } + await handleModerationAction('approve', photo); } const selectedPhotos = React.useMemo( @@ -221,6 +461,12 @@ export default function MobileEventPhotosPage() { const hasVisibleSelection = selectedPhotos.some((photo) => photo.status !== 'hidden'); const hasFeaturedSelection = selectedPhotos.some((photo) => photo.is_featured); const hasUnfeaturedSelection = selectedPhotos.some((photo) => !photo.is_featured); + const queuedEventCount = React.useMemo(() => { + if (!slug) { + return queuedActions.length; + } + return queuedActions.filter((action) => action.eventSlug === slug).length; + }, [queuedActions, slug]); function toggleSelection(id: number) { setSelectedIds((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id])); @@ -231,6 +477,32 @@ export default function MobileEventPhotosPage() { setSelectionMode(false); } + const handleLightboxDragEnd = React.useCallback( + (_event: PointerEvent, info: { offset: { x: number; y: number } }) => { + if (lightboxIndex < 0) { + return; + } + const { x, y } = info.offset; + const absX = Math.abs(x); + const absY = Math.abs(y); + const swipeThreshold = 80; + const dismissThreshold = 90; + + if (absY > absX && y > dismissThreshold) { + setLightboxWithUrl(null, { replace: true }); + return; + } + + if (absX > swipeThreshold) { + const nextIndex = x < 0 ? lightboxIndex + 1 : lightboxIndex - 1; + if (nextIndex >= 0 && nextIndex < photos.length) { + setLightboxWithUrl(photos[nextIndex]?.id ?? null, { replace: true }); + } + } + }, + [lightboxIndex, photos, setLightboxWithUrl], + ); + async function applyBulkAction(action: 'approve' | 'hide' | 'show' | 'feature' | 'unfeature') { if (!slug || bulkBusy || selectedPhotos.length === 0) return; setBulkBusy(true); @@ -246,6 +518,19 @@ export default function MobileEventPhotosPage() { setBulkBusy(false); return; } + + if (!online) { + let nextQueue: PhotoModerationAction[] = []; + targets.forEach((photo) => { + nextQueue = enqueuePhotoAction({ eventSlug: slug, photoId: photo.id, action }); + applyOptimisticUpdate(photo.id, action); + }); + setQueuedActions(nextQueue); + toast.success(t('mobilePhotos.queued', 'Aktion gespeichert. Wird synchronisiert, sobald du online bist.')); + triggerHaptic('selection'); + setBulkBusy(false); + return; + } try { const results = await Promise.allSettled( targets.map(async (photo) => { @@ -271,8 +556,8 @@ export default function MobileEventPhotosPage() { if (updates.length) { setPhotos((prev) => prev.map((photo) => updates.find((update) => update.id === photo.id) ?? photo)); - setLightbox((prev) => (prev ? updates.find((update) => update.id === prev.id) ?? prev : prev)); toast.success(t('mobilePhotos.bulkUpdated', 'Bulk update applied')); + triggerHaptic('success'); } } catch { toast.error(t('mobilePhotos.bulkFailed', 'Bulk update failed')); @@ -375,6 +660,35 @@ export default function MobileEventPhotosPage() { ) : null} + {queuedEventCount > 0 ? ( + + + + + {t('mobilePhotos.queueTitle', 'Änderungen warten auf Sync')} + + + {online + ? t('mobilePhotos.queueOnline', '{{count}} Aktionen bereit zum Synchronisieren.', { + count: queuedEventCount, + }) + : t('mobilePhotos.queueOffline', '{{count}} Aktionen gespeichert – offline.', { + count: queuedEventCount, + })} + + + syncQueuedActions()} + tone="ghost" + fullWidth={false} + disabled={!online} + loading={syncingQueue} + /> + + + ) : null} + (selectionMode ? toggleSelection(photo.id) : setLightbox(photo))} + onPress={() => (selectionMode ? toggleSelection(photo.id) : setLightboxWithUrl(photo.id))} > - + + + {lightbox.uploader_name || t('events.members.roles.guest', 'Guest')} @@ -657,7 +980,11 @@ export default function MobileEventPhotosPage() { style={{ flex: 1, minWidth: 140 }} /> - setLightbox(null)} /> + setLightboxWithUrl(null, { replace: true })} + /> diff --git a/resources/js/admin/mobile/NotificationsPage.tsx b/resources/js/admin/mobile/NotificationsPage.tsx index 9803e7f..3ad8c50 100644 --- a/resources/js/admin/mobile/NotificationsPage.tsx +++ b/resources/js/admin/mobile/NotificationsPage.tsx @@ -1,10 +1,11 @@ import React from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Bell, RefreshCcw } from 'lucide-react'; +import { Bell, Check, ChevronRight, RefreshCcw } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Pressable } from '@tamagui/react-native-web-lite'; +import { motion, useAnimationControls, type PanInfo } from 'framer-motion'; import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileCard, PillBadge, SkeletonCard, CTAButton } from './components/Primitives'; import { MobileSelect } from './components/FormControls'; @@ -15,6 +16,8 @@ import toast from 'react-hot-toast'; import { MobileSheet } from './components/Sheet'; import { getEvents, TenantEvent } from '../api'; import { useTheme } from '@tamagui/core'; +import { triggerHaptic } from './lib/haptics'; +import { adminPath } from '../constants'; type NotificationItem = { id: string; @@ -27,6 +30,94 @@ type NotificationItem = { scope: 'photos' | 'guests' | 'gallery' | 'events' | 'package' | 'general'; }; +type NotificationSwipeRowProps = { + item: NotificationItem; + onOpen: (item: NotificationItem) => void; + onMarkRead: (item: NotificationItem) => void; + children: React.ReactNode; +}; + +function NotificationSwipeRow({ item, onOpen, onMarkRead, children }: NotificationSwipeRowProps) { + const { t } = useTranslation('management'); + const theme = useTheme(); + const controls = useAnimationControls(); + const dragged = React.useRef(false); + const markBg = String(theme.green3?.val ?? '#dcfce7'); + const markText = String(theme.green10?.val ?? '#166534'); + const detailBg = String(theme.blue3?.val ?? '#dbeafe'); + const detailText = String(theme.blue10?.val ?? '#1d4ed8'); + + const handleDrag = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => { + dragged.current = Math.abs(info.offset.x) > 6; + }; + + const handleDragEnd = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => { + const swipeThreshold = 64; + const offsetX = info.offset.x; + if (offsetX > swipeThreshold && !item.is_read) { + void onMarkRead(item); + } else if (offsetX < -swipeThreshold) { + onOpen(item); + } + dragged.current = false; + void controls.start({ x: 0, transition: { type: 'spring', stiffness: 320, damping: 26 } }); + }; + + const handlePress = () => { + if (dragged.current) { + dragged.current = false; + return; + } + onOpen(item); + }; + + return ( +
+ + + + + {item.is_read ? t('notificationLogs.read', 'Read') : t('notificationLogs.markRead', 'Mark read')} + + + + + Details + + + + + + + {children} + +
+ ); +} + function formatLog( log: NotificationLogEntry, t: (key: string, defaultValue?: string, options?: Record) => string, @@ -207,6 +298,8 @@ async function loadNotifications( export default function MobileNotificationsPage() { const navigate = useNavigate(); + const location = useLocation(); + const { notificationId } = useParams<{ notificationId?: string }>(); const { t } = useTranslation('management'); const search = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : ''); const slug = search.get('event') ?? undefined; @@ -251,6 +344,20 @@ export default function MobileNotificationsPage() { void reload(); }, [reload]); + React.useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (event.data?.type === 'admin-notification-refresh') { + void reload(); + } + }; + + navigator.serviceWorker?.addEventListener('message', handleMessage); + + return () => { + navigator.serviceWorker?.removeEventListener('message', handleMessage); + }; + }, [reload]); + React.useEffect(() => { (async () => { try { @@ -287,19 +394,58 @@ export default function MobileNotificationsPage() { const showFilterNotice = Boolean(slug) && filtered.length === 0 && notifications.length > 0; - const markSelectedRead = async () => { + const markNotificationRead = React.useCallback( + async (item: NotificationItem, options?: { close?: boolean }) => { + const id = Number(item.id); + if (!Number.isFinite(id)) return; + try { + await markNotificationLogs([id], 'read'); + await reload(); + triggerHaptic('success'); + if (options?.close) { + setDetailOpen(false); + setSelectedNotification(null); + } + } catch { + toast.error(t('notificationLogs.markFailed', 'Could not update notifications.')); + } + }, + [reload, t], + ); + + const markSelectedRead = React.useCallback(async () => { if (!selectedNotification) return; - const id = Number(selectedNotification.id); - if (!Number.isFinite(id)) return; - try { - await markNotificationLogs([id], 'read'); - await reload(); - setDetailOpen(false); - setSelectedNotification(null); - } catch { - toast.error(t('notificationLogs.markFailed', 'Could not update notifications.')); + await markNotificationRead(selectedNotification, { close: true }); + }, [markNotificationRead, selectedNotification]); + + const notificationListPath = adminPath('/mobile/notifications'); + + const openNotification = React.useCallback( + (item: NotificationItem) => { + setSelectedNotification(item); + setDetailOpen(true); + if (notificationId !== String(item.id)) { + navigate(`${notificationListPath}/${item.id}${location.search}`, { replace: false }); + } + triggerHaptic('light'); + }, + [location.search, navigate, notificationId, notificationListPath], + ); + + React.useEffect(() => { + if (!notificationId || loading) { + return; } - }; + const targetId = Number(notificationId); + if (!Number.isFinite(targetId)) { + return; + } + const target = notifications.find((item) => Number(item.id) === targetId); + if (target) { + setSelectedNotification(target); + setDetailOpen(true); + } + }, [notificationId, notifications, loading]); return ( { - navigate('/admin/mobile/notifications', { replace: true }); + navigate(notificationListPath, { replace: true }); }} > @@ -340,7 +486,15 @@ export default function MobileNotificationsPage() { navigate(`/admin/mobile/notifications?${new URLSearchParams({ status: e.target.value, scope: scopeParam, event: slug ?? '' }).toString()}`)} + onChange={(e) => + navigate( + `${notificationListPath}?${new URLSearchParams({ + status: e.target.value, + scope: scopeParam, + event: slug ?? '', + }).toString()}`, + ) + } compact style={{ minWidth: 120 }} > @@ -350,7 +504,15 @@ export default function MobileNotificationsPage() { navigate(`/admin/mobile/notifications?${new URLSearchParams({ scope: e.target.value, status: statusParam, event: slug ?? '' }).toString()}`)} + onChange={(e) => + navigate( + `${notificationListPath}?${new URLSearchParams({ + scope: e.target.value, + status: statusParam, + event: slug ?? '', + }).toString()}`, + ) + } compact style={{ minWidth: 140 }} > @@ -369,6 +531,7 @@ export default function MobileNotificationsPage() { try { await markNotificationLogs(unreadIds, 'read'); void reload(); + triggerHaptic('success'); } catch { toast.error(t('notificationLogs.markFailed', 'Could not update notifications.')); } @@ -401,12 +564,11 @@ export default function MobileNotificationsPage() { ) : null} {statusFiltered.map((item) => ( - { - setSelectedNotification(item); - setDetailOpen(true); - }} + item={item} + onOpen={openNotification} + onMarkRead={markNotificationRead} > @@ -432,7 +594,7 @@ export default function MobileNotificationsPage() { {item.time} - + ))}
)} @@ -442,6 +604,9 @@ export default function MobileNotificationsPage() { onClose={() => { setDetailOpen(false); setSelectedNotification(null); + if (notificationId) { + navigate(`${notificationListPath}${location.search}`, { replace: true }); + } }} title={selectedNotification?.title ?? t('mobileNotifications.title', 'Notifications')} footer={ @@ -489,7 +654,7 @@ export default function MobileNotificationsPage() { onPress={() => { setShowEventPicker(false); if (ev.slug) { - navigate(`/admin/mobile/notifications?event=${ev.slug}`); + navigate(`${notificationListPath}?event=${ev.slug}`); } }} > diff --git a/resources/js/admin/mobile/SettingsPage.tsx b/resources/js/admin/mobile/SettingsPage.tsx index 9b29268..360ccf1 100644 --- a/resources/js/admin/mobile/SettingsPage.tsx +++ b/resources/js/admin/mobile/SettingsPage.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Shield, Bell, User } from 'lucide-react'; +import { Shield, Bell, User, Smartphone } from 'lucide-react'; import { YStack, XStack } from '@tamagui/stacks'; import { YGroup } from '@tamagui/group'; import { ListItem } from '@tamagui/list-item'; @@ -18,6 +18,9 @@ import { } from '../api'; import { getApiErrorMessage } from '../lib/apiError'; import { adminPath } from '../constants'; +import { useAdminPushSubscription } from './hooks/useAdminPushSubscription'; +import { useDevicePermissions } from './hooks/useDevicePermissions'; +import { type PermissionStatus, type StorageStatus } from './lib/devicePermissions'; type PreferenceKey = keyof NotificationPreferences; @@ -47,6 +50,48 @@ export default function MobileSettingsPage() { const [loading, setLoading] = React.useState(true); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(null); + const [storageSaving, setStorageSaving] = React.useState(false); + const [storageError, setStorageError] = React.useState(null); + const pushState = useAdminPushSubscription(); + const devicePermissions = useDevicePermissions(); + + const pushDescription = React.useMemo(() => { + if (!pushState.supported) { + return t('mobileSettings.pushUnsupported', 'Push wird auf diesem Gerät nicht unterstützt.'); + } + if (pushState.permission === 'denied') { + return t('mobileSettings.pushDenied', 'Benachrichtigungen sind im Browser blockiert.'); + } + if (pushState.subscribed) { + return t('mobileSettings.pushActive', 'Push aktiv'); + } + return t('mobileSettings.pushInactive', 'Push deaktiviert'); + }, [pushState.permission, pushState.subscribed, pushState.supported, t]); + + const permissionTone = (status: PermissionStatus) => { + if (status === 'granted') { + return 'success'; + } + if (status === 'denied' || status === 'prompt') { + return 'warning'; + } + return 'muted'; + }; + + const storageTone = (status: StorageStatus) => { + if (status === 'persisted') { + return 'success'; + } + if (status === 'available') { + return 'warning'; + } + return 'muted'; + }; + + const permissionLabel = (status: PermissionStatus) => + t(`mobileSettings.deviceStatusValues.${status}`, status); + const storageLabel = (status: StorageStatus) => + t(`mobileSettings.deviceStatusValues.${status}`, status); React.useEffect(() => { (async () => { @@ -71,6 +116,12 @@ export default function MobileSettingsPage() { })(); }, [t]); + React.useEffect(() => { + if (devicePermissions.storage === 'persisted') { + setStorageError(null); + } + }, [devicePermissions.storage]); + const togglePref = (key: PreferenceKey) => { setPreferences((prev) => ({ ...prev, @@ -98,6 +149,20 @@ export default function MobileSettingsPage() { setPreferences(defaults); }; + const handleStoragePersist = async () => { + setStorageSaving(true); + const granted = await devicePermissions.requestPersistentStorage(); + setStorageSaving(false); + if (granted) { + setStorageError(null); + void devicePermissions.refresh(); + } else { + setStorageError( + t('mobileSettings.deviceStorageError', 'Offline-Schutz konnte nicht aktiviert werden.') + ); + } + }; + return ( navigate(-1)}> {error ? ( @@ -140,6 +205,43 @@ export default function MobileSettingsPage() { ) : ( + + + {t('mobileSettings.pushTitle', 'App Push')} + + } + subTitle={ + + {pushState.loading + ? t('mobileSettings.pushLoading', 'Lädt ...') + : pushDescription} + + } + iconAfter={ + { + if (value) { + void pushState.enable(); + } else { + void pushState.disable(); + } + }} + disabled={!pushState.supported || pushState.permission === 'denied' || pushState.loading} + aria-label={t('mobileSettings.pushTitle', 'App Push')} + > + + + } + /> + {AVAILABLE_PREFS.map((key, index) => ( )} + {pushState.error ? ( + + {pushState.error} + + ) : null} handleSave()} /> handleReset()} /> + + + + + {t('mobileSettings.deviceTitle', 'Device & permissions')} + + + + {t('mobileSettings.deviceDescription', 'Check permissions so the admin app stays fast and offline-ready.')} + + {devicePermissions.loading ? ( + + {t('mobileSettings.deviceLoading', 'Checking device status ...')} + + ) : ( + + + + + {t('mobileSettings.deviceStatus.notifications.label', 'Notifications')} + + + {t('mobileSettings.deviceStatus.notifications.description', 'Allow alerts and admin updates.')} + + + + {permissionLabel(devicePermissions.notifications)} + + + + + + {t('mobileSettings.deviceStatus.camera.label', 'Camera')} + + + {t('mobileSettings.deviceStatus.camera.description', 'Needed for QR scans and quick capture.')} + + + + {permissionLabel(devicePermissions.camera)} + + + + + + {t('mobileSettings.deviceStatus.storage.label', 'Offline storage')} + + + {t('mobileSettings.deviceStatus.storage.description', 'Protect cached data from eviction.')} + + + + {storageLabel(devicePermissions.storage)} + + + + )} + {devicePermissions.storage === 'available' ? ( + handleStoragePersist()} + disabled={storageSaving} + /> + ) : null} + {storageError ? ( + + {storageError} + + ) : null} + + diff --git a/resources/js/admin/mobile/hooks/useAdminPushSubscription.ts b/resources/js/admin/mobile/hooks/useAdminPushSubscription.ts new file mode 100644 index 0000000..30d4075 --- /dev/null +++ b/resources/js/admin/mobile/hooks/useAdminPushSubscription.ts @@ -0,0 +1,170 @@ +import React from 'react'; +import { getAdminPushConfig } from '../../lib/runtime-config'; +import { registerAdminPushSubscription, unregisterAdminPushSubscription } from '../../api'; +import { getAdminDeviceId } from '../../lib/device'; + +type PushSubscriptionState = { + supported: boolean; + permission: NotificationPermission; + subscribed: boolean; + loading: boolean; + error: string | null; + enable: () => Promise; + disable: () => Promise; + refresh: () => Promise; +}; + +export function useAdminPushSubscription(): PushSubscriptionState { + const pushConfig = React.useMemo(() => getAdminPushConfig(), []); + const supported = React.useMemo(() => { + return typeof window !== 'undefined' + && typeof navigator !== 'undefined' + && typeof Notification !== 'undefined' + && 'serviceWorker' in navigator + && 'PushManager' in window + && pushConfig.enabled; + }, [pushConfig.enabled]); + + const [permission, setPermission] = React.useState(() => { + if (typeof Notification === 'undefined') { + return 'default'; + } + + return Notification.permission; + }); + const [subscription, setSubscription] = React.useState(null); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + const refresh = React.useCallback(async () => { + if (!supported) { + return; + } + + try { + const registration = await navigator.serviceWorker.ready; + const current = await registration.pushManager.getSubscription(); + setSubscription(current); + setPermission(Notification.permission); + } catch (err) { + console.warn('Unable to refresh admin push subscription', err); + setSubscription(null); + } + }, [supported]); + + React.useEffect(() => { + if (!supported) { + return; + } + + void refresh(); + + const handleMessage = (event: MessageEvent) => { + if (event.data?.type === 'push-subscription-change') { + void refresh(); + } + }; + + navigator.serviceWorker?.addEventListener('message', handleMessage); + + return () => { + navigator.serviceWorker?.removeEventListener('message', handleMessage); + }; + }, [refresh, supported]); + + const enable = React.useCallback(async () => { + if (!supported) { + setError('Push-Benachrichtigungen werden auf diesem Gerät nicht unterstützt.'); + + return; + } + + setLoading(true); + setError(null); + + try { + const permissionResult = await Notification.requestPermission(); + setPermission(permissionResult); + + if (permissionResult !== 'granted') { + throw new Error('Bitte erlaube Benachrichtigungen, um Push zu aktivieren.'); + } + + const registration = await navigator.serviceWorker.ready; + const existing = await registration.pushManager.getSubscription(); + + if (existing) { + await registerAdminPushSubscription(existing, getAdminDeviceId()); + setSubscription(existing); + + return; + } + + if (!pushConfig.vapidPublicKey) { + throw new Error('Push-Konfiguration ist nicht vollständig.'); + } + + const newSubscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(pushConfig.vapidPublicKey).buffer as ArrayBuffer, + }); + + await registerAdminPushSubscription(newSubscription, getAdminDeviceId()); + setSubscription(newSubscription); + } catch (err) { + const message = err instanceof Error ? err.message : 'Push konnte nicht aktiviert werden.'; + setError(message); + console.error(err); + await refresh(); + } finally { + setLoading(false); + } + }, [pushConfig.vapidPublicKey, refresh, supported]); + + const disable = React.useCallback(async () => { + if (!supported || !subscription) { + return; + } + + setLoading(true); + setError(null); + + try { + await unregisterAdminPushSubscription(subscription.endpoint); + await subscription.unsubscribe(); + setSubscription(null); + } catch (err) { + const message = err instanceof Error ? err.message : 'Push konnte nicht deaktiviert werden.'; + setError(message); + console.error(err); + } finally { + setLoading(false); + } + }, [subscription, supported]); + + return { + supported, + permission, + subscribed: Boolean(subscription), + loading, + error, + enable, + disable, + refresh, + }; +} + +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = typeof window !== 'undefined' + ? window.atob(base64) + : Buffer.from(base64, 'base64').toString('binary'); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; i += 1) { + outputArray[i] = rawData.charCodeAt(i); + } + + return outputArray; +} diff --git a/resources/js/admin/mobile/hooks/useDevicePermissions.ts b/resources/js/admin/mobile/hooks/useDevicePermissions.ts new file mode 100644 index 0000000..9ecb7dc --- /dev/null +++ b/resources/js/admin/mobile/hooks/useDevicePermissions.ts @@ -0,0 +1,97 @@ +import React from 'react'; +import { + normalizePermissionState, + resolveStorageStatus, + type PermissionStatus, + type StorageStatus, +} from '../lib/devicePermissions'; + +type DevicePermissionsState = { + notifications: PermissionStatus; + camera: PermissionStatus; + storage: StorageStatus; +}; + +type DevicePermissionsHook = DevicePermissionsState & { + loading: boolean; + refresh: () => Promise; + requestPersistentStorage: () => Promise; +}; + +export function useDevicePermissions(): DevicePermissionsHook { + const [permissions, setPermissions] = React.useState({ + notifications: 'unsupported', + camera: 'unsupported', + storage: 'unsupported', + }); + const [loading, setLoading] = React.useState(true); + + const refresh = React.useCallback(async () => { + setLoading(true); + + try { + if (typeof window === 'undefined' || typeof navigator === 'undefined') { + return; + } + + let notificationState: PermissionStatus = 'unsupported'; + if ('Notification' in window) { + notificationState = normalizePermissionState(Notification.permission); + } + + let cameraState: PermissionStatus = 'unsupported'; + if (navigator.permissions?.query) { + try { + const cameraPermission = await navigator.permissions.query({ + name: 'camera' as PermissionName, + }); + cameraState = normalizePermissionState(cameraPermission.state); + } catch { + cameraState = 'unsupported'; + } + } + + const storageSupported = Boolean(navigator.storage?.persisted); + let persisted: boolean | null = null; + if (storageSupported) { + persisted = await navigator.storage.persisted(); + } + + setPermissions({ + notifications: notificationState, + camera: cameraState, + storage: resolveStorageStatus(persisted, storageSupported), + }); + } finally { + setLoading(false); + } + }, []); + + const requestPersistentStorage = React.useCallback(async () => { + if (typeof navigator === 'undefined' || !navigator.storage?.persist) { + return false; + } + + try { + const granted = await navigator.storage.persist(); + setPermissions((prev) => ({ + ...prev, + storage: granted ? 'persisted' : 'available', + })); + return granted; + } catch { + return false; + } + }, []); + + React.useEffect(() => { + void refresh(); + }, [refresh]); + + return { + ...permissions, + loading, + refresh, + requestPersistentStorage, + }; +} diff --git a/resources/js/admin/mobile/lib/devicePermissions.test.ts b/resources/js/admin/mobile/lib/devicePermissions.test.ts new file mode 100644 index 0000000..af1cde7 --- /dev/null +++ b/resources/js/admin/mobile/lib/devicePermissions.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { normalizePermissionState, resolveStorageStatus } from './devicePermissions'; + +describe('normalizePermissionState', () => { + it('maps default to prompt', () => { + expect(normalizePermissionState('default')).toBe('prompt'); + }); + + it('maps undefined to unsupported', () => { + expect(normalizePermissionState(undefined)).toBe('unsupported'); + }); + + it('passes through granted', () => { + expect(normalizePermissionState('granted')).toBe('granted'); + }); + + it('passes through denied', () => { + expect(normalizePermissionState('denied')).toBe('denied'); + }); +}); + +describe('resolveStorageStatus', () => { + it('returns unsupported when not supported', () => { + expect(resolveStorageStatus(null, false)).toBe('unsupported'); + }); + + it('returns persisted when granted', () => { + expect(resolveStorageStatus(true, true)).toBe('persisted'); + }); + + it('returns available when supported but not persisted', () => { + expect(resolveStorageStatus(false, true)).toBe('available'); + }); +}); diff --git a/resources/js/admin/mobile/lib/devicePermissions.ts b/resources/js/admin/mobile/lib/devicePermissions.ts new file mode 100644 index 0000000..235d778 --- /dev/null +++ b/resources/js/admin/mobile/lib/devicePermissions.ts @@ -0,0 +1,28 @@ +export type PermissionStatus = 'granted' | 'denied' | 'prompt' | 'unsupported'; +export type StorageStatus = 'persisted' | 'available' | 'unsupported'; + +type RawPermissionState = PermissionState | 'default' | null | undefined; + +export function normalizePermissionState(state: RawPermissionState): PermissionStatus { + if (!state) { + return 'unsupported'; + } + + if (state === 'default') { + return 'prompt'; + } + + return state; +} + +export function resolveStorageStatus(persisted: boolean | null, supported: boolean): StorageStatus { + if (!supported) { + return 'unsupported'; + } + + if (persisted) { + return 'persisted'; + } + + return 'available'; +} diff --git a/resources/js/admin/mobile/lib/haptics.test.ts b/resources/js/admin/mobile/lib/haptics.test.ts new file mode 100644 index 0000000..ad4b90f --- /dev/null +++ b/resources/js/admin/mobile/lib/haptics.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it, vi } from 'vitest'; +import { triggerHaptic } from './haptics'; + +describe('triggerHaptic', () => { + it('uses navigator.vibrate when available', () => { + const vibrate = vi.fn(); + Object.defineProperty(navigator, 'vibrate', { value: vibrate, configurable: true }); + + triggerHaptic(); + + expect(vibrate).toHaveBeenCalled(); + }); + + it('does nothing when vibrate is unavailable', () => { + Object.defineProperty(navigator, 'vibrate', { value: undefined, configurable: true }); + + expect(() => triggerHaptic('success')).not.toThrow(); + }); +}); diff --git a/resources/js/admin/mobile/lib/haptics.ts b/resources/js/admin/mobile/lib/haptics.ts new file mode 100644 index 0000000..448dc7c --- /dev/null +++ b/resources/js/admin/mobile/lib/haptics.ts @@ -0,0 +1,19 @@ +export type HapticStyle = 'light' | 'medium' | 'heavy' | 'success' | 'warning' | 'error' | 'selection'; + +const PATTERNS: Record = { + light: 10, + medium: 18, + heavy: 26, + success: [10, 28, 10], + warning: [22, 30, 16], + error: [30, 30, 30], + selection: 12, +}; + +export function triggerHaptic(style: HapticStyle = 'selection'): void { + if (typeof navigator === 'undefined' || typeof navigator.vibrate !== 'function') { + return; + } + + navigator.vibrate(PATTERNS[style] ?? PATTERNS.selection); +} diff --git a/resources/js/admin/mobile/lib/photoModerationQueue.test.ts b/resources/js/admin/mobile/lib/photoModerationQueue.test.ts new file mode 100644 index 0000000..80ede26 --- /dev/null +++ b/resources/js/admin/mobile/lib/photoModerationQueue.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + enqueuePhotoAction, + loadPhotoQueue, + removePhotoAction, + replacePhotoQueue, + type PhotoModerationAction, +} from './photoModerationQueue'; + +describe('photoModerationQueue', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('enqueues and loads actions', () => { + const queue = enqueuePhotoAction({ eventSlug: 'demo-event', photoId: 12, action: 'approve' }); + + expect(queue).toHaveLength(1); + const loaded = loadPhotoQueue(); + expect(loaded).toHaveLength(1); + expect(loaded[0]?.eventSlug).toBe('demo-event'); + expect(loaded[0]?.photoId).toBe(12); + }); + + it('removes actions by id', () => { + const queue = enqueuePhotoAction({ eventSlug: 'demo-event', photoId: 12, action: 'approve' }); + const next = removePhotoAction(queue, queue[0]!.id); + + expect(next).toHaveLength(0); + expect(loadPhotoQueue()).toHaveLength(0); + }); + + it('replaces the queue', () => { + enqueuePhotoAction({ eventSlug: 'demo-event', photoId: 12, action: 'approve' }); + const next: PhotoModerationAction[] = [ + { + id: 'fixed', + eventSlug: 'another', + photoId: 99, + action: 'hide', + createdAt: new Date().toISOString(), + }, + ]; + replacePhotoQueue(next); + + expect(loadPhotoQueue()).toHaveLength(1); + expect(loadPhotoQueue()[0]?.id).toBe('fixed'); + }); +}); diff --git a/resources/js/admin/mobile/lib/photoModerationQueue.ts b/resources/js/admin/mobile/lib/photoModerationQueue.ts new file mode 100644 index 0000000..e04acb7 --- /dev/null +++ b/resources/js/admin/mobile/lib/photoModerationQueue.ts @@ -0,0 +1,69 @@ +export type PhotoModerationAction = { + id: string; + eventSlug: string; + photoId: number; + action: 'approve' | 'hide' | 'show' | 'feature' | 'unfeature'; + createdAt: string; +}; + +const STORAGE_KEY = 'fotospiel-admin-photo-queue'; + +function buildId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + + return `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`; +} + +export function loadPhotoQueue(): PhotoModerationAction[] { + if (typeof window === 'undefined') { + return []; + } + + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as PhotoModerationAction[]) : []; + } catch { + return []; + } +} + +export function savePhotoQueue(queue: PhotoModerationAction[]): void { + if (typeof window === 'undefined') { + return; + } + + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(queue)); + } catch { + // Ignore persistence failures. + } +} + +export function enqueuePhotoAction(action: Omit): PhotoModerationAction[] { + const queue = loadPhotoQueue(); + const entry: PhotoModerationAction = { + ...action, + id: buildId(), + createdAt: new Date().toISOString(), + }; + const next = [...queue, entry]; + savePhotoQueue(next); + return next; +} + +export function removePhotoAction(queue: PhotoModerationAction[], id: string): PhotoModerationAction[] { + const next = queue.filter((item) => item.id !== id); + savePhotoQueue(next); + return next; +} + +export function replacePhotoQueue(queue: PhotoModerationAction[]): PhotoModerationAction[] { + savePhotoQueue(queue); + return queue; +} diff --git a/resources/js/admin/router.tsx b/resources/js/admin/router.tsx index 1c18003..10b660a 100644 --- a/resources/js/admin/router.tsx +++ b/resources/js/admin/router.tsx @@ -132,12 +132,14 @@ export const router = createBrowserRouter([ { path: 'mobile/events/:slug/qr', element: }, { path: 'mobile/events/:slug/qr/customize/:tokenId?', element: }, { path: 'mobile/events/:slug/photos', element: }, + { path: 'mobile/events/:slug/photos/:photoId', element: }, { path: 'mobile/events/:slug/recap', element: }, { path: 'mobile/events/:slug/members', element: }, { path: 'mobile/events/:slug/tasks', element: }, { path: 'mobile/events/:slug/photobooth', element: }, { path: 'mobile/events/:slug/guest-notifications', element: }, { path: 'mobile/notifications', element: }, + { path: 'mobile/notifications/:notificationId', element: }, { path: 'mobile/profile', element: }, { path: 'mobile/billing', element: }, { path: 'mobile/settings', element: }, diff --git a/resources/js/admin/types/global.d.ts b/resources/js/admin/types/global.d.ts new file mode 100644 index 0000000..db715a5 --- /dev/null +++ b/resources/js/admin/types/global.d.ts @@ -0,0 +1,12 @@ +export {}; + +declare global { + interface Window { + __ADMIN_RUNTIME_CONFIG__?: { + push?: { + enabled?: boolean; + vapidPublicKey?: string | null; + }; + }; + } +} diff --git a/resources/views/admin.blade.php b/resources/views/admin.blade.php index f466c9e..7c31f11 100644 --- a/resources/views/admin.blade.php +++ b/resources/views/admin.blade.php @@ -10,7 +10,7 @@ - + @viteReactRefresh @vite(['resources/css/app.css', 'resources/js/admin/main.tsx']) @php @@ -22,9 +22,16 @@ 'siteId' => (string) $matomoConfig['site_id_admin'], ] : ['enabled' => false]; + $adminRuntimeConfig = [ + 'push' => [ + 'enabled' => config('push.enabled', false), + 'vapidPublicKey' => config('push.vapid.public_key'), + ], + ]; @endphp