diff --git a/app/Http/Controllers/Api/Tenant/EventController.php b/app/Http/Controllers/Api/Tenant/EventController.php index bc139c4..2c5c1d4 100644 --- a/app/Http/Controllers/Api/Tenant/EventController.php +++ b/app/Http/Controllers/Api/Tenant/EventController.php @@ -7,6 +7,7 @@ use App\Http\Requests\Tenant\EventStoreRequest; use App\Http\Resources\Tenant\EventResource; use App\Models\Event; use App\Models\Tenant; +use App\Models\Photo; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; @@ -203,6 +204,75 @@ class EventController extends Controller ]); } + + public function stats(Request $request, Event $event): JsonResponse + { + $tenantId = $request->attributes->get('tenant_id'); + + if ($event->tenant_id !== $tenantId) { + return response()->json(['error' => 'Event not found'], 404); + } + + $totalPhotos = Photo::where('event_id', $event->id)->count(); + $featuredPhotos = Photo::where('event_id', $event->id)->where('is_featured', true)->count(); + $likes = Photo::where('event_id', $event->id)->sum('likes_count'); + $recentUploads = Photo::where('event_id', $event->id) + ->where('created_at', '>=', now()->subDays(7)) + ->count(); + + return response()->json([ + 'total' => $totalPhotos, + 'featured' => $featuredPhotos, + 'likes' => (int) $likes, + 'recent_uploads' => $recentUploads, + 'status' => $event->status, + 'is_active' => (bool) $event->is_active, + ]); + } + + public function toggle(Request $request, Event $event): JsonResponse + { + $tenantId = $request->attributes->get('tenant_id'); + + if ($event->tenant_id !== $tenantId) { + return response()->json(['error' => 'Event not found'], 404); + } + + $activate = ! (bool) $event->is_active; + $event->is_active = $activate; + + if ($activate) { + $event->status = 'published'; + } elseif ($event->status === 'published') { + $event->status = 'draft'; + } + + $event->save(); + $event->refresh()->load(['eventType', 'tenant']); + + return response()->json([ + 'message' => $activate ? 'Event activated' : 'Event deactivated', + 'data' => new EventResource($event), + 'is_active' => (bool) $event->is_active, + ]); + } + + public function createInvite(Request $request, Event $event): JsonResponse + { + $tenantId = $request->attributes->get('tenant_id'); + + if ($event->tenant_id !== $tenantId) { + return response()->json(['error' => 'Event not found'], 404); + } + + $token = (string) Str::uuid(); + $link = url("/e/{$event->slug}?invite={$token}"); + + return response()->json([ + 'link' => $link, + 'token' => $token, + ]); + } public function bulkUpdateStatus(Request $request): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); diff --git a/app/Http/Controllers/Api/Tenant/PhotoController.php b/app/Http/Controllers/Api/Tenant/PhotoController.php index 651e742..0977654 100644 --- a/app/Http/Controllers/Api/Tenant/PhotoController.php +++ b/app/Http/Controllers/Api/Tenant/PhotoController.php @@ -30,7 +30,7 @@ class PhotoController extends Controller ->firstOrFail(); $query = Photo::where('event_id', $event->id) - ->with(['likes', 'uploader']) + ->with('event')->withCount('likes') ->orderBy('created_at', 'desc'); // Filters @@ -117,7 +117,7 @@ class PhotoController extends Controller list($width, $height) = getimagesize($file->getRealPath()); $photo->update(['width' => $width, 'height' => $height]); - $photo->load(['event', 'uploader']); + $photo->load('event')->loadCount('likes'); return response()->json([ 'message' => 'Photo uploaded successfully. Awaiting moderation.', @@ -140,7 +140,7 @@ class PhotoController extends Controller return response()->json(['error' => 'Photo not found'], 404); } - $photo->load(['event', 'uploader', 'likes']); + $photo->load('event')->loadCount('likes'); $photo->increment('view_count'); return response()->json([ @@ -177,7 +177,7 @@ class PhotoController extends Controller $photo->update($validated); if ($validated['status'] ?? null === 'approved') { - $photo->load(['event', 'uploader']); + $photo->load('event')->loadCount('likes'); // Trigger event for new photo notification // event(new \App\Events\PhotoApproved($photo)); // Implement later } @@ -222,6 +222,40 @@ class PhotoController extends Controller /** * Bulk approve photos (admin only) */ + + public function feature(Request $request, string $eventSlug, Photo $photo): JsonResponse + { + $tenantId = $request->attributes->get('tenant_id'); + $event = Event::where('slug', $eventSlug) + ->where('tenant_id', $tenantId) + ->firstOrFail(); + + if ($photo->event_id !== $event->id) { + return response()->json(['error' => 'Photo not found'], 404); + } + + $photo->update(['is_featured' => true]); + $photo->refresh()->load('event')->loadCount('likes'); + + return response()->json(['message' => 'Photo marked as featured', 'data' => new PhotoResource($photo)]); + } + + public function unfeature(Request $request, string $eventSlug, Photo $photo): JsonResponse + { + $tenantId = $request->attributes->get('tenant_id'); + $event = Event::where('slug', $eventSlug) + ->where('tenant_id', $tenantId) + ->firstOrFail(); + + if ($photo->event_id !== $event->id) { + return response()->json(['error' => 'Photo not found'], 404); + } + + $photo->update(['is_featured' => false]); + $photo->refresh()->load('event')->loadCount('likes'); + + return response()->json(['message' => 'Photo removed from featured', 'data' => new PhotoResource($photo)]); + } public function bulkApprove(Request $request, string $eventSlug): JsonResponse { $tenantId = $request->attributes->get('tenant_id'); @@ -249,7 +283,7 @@ class PhotoController extends Controller // Load approved photos for response $photos = Photo::whereIn('id', $photoIds) ->where('event_id', $event->id) - ->with(['uploader', 'event']) + ->with('event')->withCount('likes') ->get(); // Trigger events @@ -321,7 +355,7 @@ class PhotoController extends Controller $photos = Photo::where('event_id', $event->id) ->where('status', 'pending') - ->with(['uploader', 'event']) + ->with('event')->withCount('likes') ->orderBy('created_at', 'desc') ->paginate($request->get('per_page', 20)); diff --git a/app/Http/Resources/Tenant/EventResource.php b/app/Http/Resources/Tenant/EventResource.php index c74693a..97871b0 100644 --- a/app/Http/Resources/Tenant/EventResource.php +++ b/app/Http/Resources/Tenant/EventResource.php @@ -26,6 +26,7 @@ class EventResource extends JsonResource 'custom_domain' => $showSensitive ? ($settings['custom_domain'] ?? null) : null, 'theme_color' => $settings['theme_color'] ?? null, 'status' => $this->status ?? 'draft', + 'is_active' => (bool) ($this->is_active ?? false), 'features' => $settings['features'] ?? [], 'event_type_id' => $this->event_type_id, 'created_at' => $this->created_at?->toISOString(), diff --git a/app/Http/Resources/Tenant/PhotoResource.php b/app/Http/Resources/Tenant/PhotoResource.php index cbf2c30..a85dff1 100644 --- a/app/Http/Resources/Tenant/PhotoResource.php +++ b/app/Http/Resources/Tenant/PhotoResource.php @@ -22,16 +22,18 @@ class PhotoResource extends JsonResource 'filename' => $this->filename, 'original_name' => $this->original_name, 'mime_type' => $this->mime_type, - 'size' => $this->size, + 'size' => (int) ($this->size ?? 0), 'url' => $showSensitive ? $this->getFullUrl() : $this->getThumbnailUrl(), 'thumbnail_url' => $this->getThumbnailUrl(), 'width' => $this->width, 'height' => $this->height, + 'is_featured' => (bool) ($this->is_featured ?? false), 'status' => $showSensitive ? $this->status : 'approved', 'moderation_notes' => $showSensitive ? $this->moderation_notes : null, - 'likes_count' => $this->likes_count, - 'is_liked' => $showSensitive ? $this->isLikedByTenant($tenantId) : false, + 'likes_count' => (int) ($this->likes_count ?? $this->likes()->count()), + 'is_liked' => false, 'uploaded_at' => $this->created_at->toISOString(), + 'uploader_name' => $this->guest_name ?? null, 'event' => [ 'id' => $this->event->id, 'name' => $this->event->name, diff --git a/resources/css/app.css b/resources/css/app.css index 9bf659f..2e23049 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -95,6 +95,41 @@ --sidebar-ring: oklch(0.87 0 0); } +.tenant-admin-theme { + --background: oklch(0.985 0.035 330); + --foreground: oklch(0.22 0.04 250); + --card: oklch(0.99 0.02 285); + --card-foreground: oklch(0.22 0.04 250); + --popover: oklch(0.99 0.02 285); + --popover-foreground: oklch(0.22 0.04 250); + --primary: oklch(0.68 0.23 330); + --primary-foreground: oklch(0.985 0.02 20); + --secondary: oklch(0.94 0.04 220); + --secondary-foreground: oklch(0.28 0.04 230); + --muted: oklch(0.95 0.03 250); + --muted-foreground: oklch(0.52 0.03 250); + --accent: oklch(0.93 0.06 345); + --accent-foreground: oklch(0.25 0.05 250); + --destructive: oklch(0.58 0.25 27); + --destructive-foreground: oklch(0.98 0.01 20); + --border: oklch(0.9 0.03 250); + --input: oklch(0.9 0.03 250); + --ring: oklch(0.72 0.16 330); + --chart-1: oklch(0.7 0.2 330); + --chart-2: oklch(0.66 0.18 230); + --chart-3: oklch(0.62 0.19 20); + --chart-4: oklch(0.72 0.18 120); + --chart-5: oklch(0.69 0.22 300); + --sidebar: oklch(0.98 0.03 320); + --sidebar-foreground: oklch(0.24 0.04 250); + --sidebar-primary: oklch(0.68 0.23 330); + --sidebar-primary-foreground: oklch(0.985 0.02 20); + --sidebar-accent: oklch(0.93 0.06 345); + --sidebar-accent-foreground: oklch(0.27 0.05 240); + --sidebar-border: oklch(0.9 0.03 250); + --sidebar-ring: oklch(0.72 0.16 330); +} + .dark { --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index bff136f..196f442 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -2,12 +2,62 @@ import { authorizedFetch } from './auth/tokens'; type JsonValue = Record; +export type TenantEvent = { + id: number; + name: string | Record; + slug: string; + event_date: string | null; + status: 'draft' | 'published' | 'archived'; + is_active?: boolean; + description?: string | null; + photo_count?: number; + like_count?: number; +}; + +export type TenantPhoto = { + id: number; + filename: string; + original_name: string | null; + mime_type: string | null; + size: number; + url: string; + thumbnail_url: string; + status: string; + is_featured: boolean; + likes_count: number; + uploaded_at: string; + uploader_name: string | null; +}; + +export type EventStats = { + total: number; + featured: number; + likes: number; + recent_uploads: number; + status: string; + is_active: boolean; +}; + +type EventListResponse = { data?: TenantEvent[] }; +type EventResponse = { data: TenantEvent }; +type CreatedEventResponse = { message: string; data: TenantEvent; balance: number }; +type PhotoResponse = { message: string; data: TenantPhoto }; + +type EventSavePayload = { + name: string; + slug: string; + date?: string; + status?: 'draft' | 'published' | 'archived'; + is_active?: boolean; +}; + async function jsonOrThrow(response: Response, message: string): Promise { if (!response.ok) { const body = await safeJson(response); console.error('[API]', message, response.status, body); throw new Error(message); } + return (await response.json()) as T; } @@ -19,85 +69,112 @@ async function safeJson(response: Response): Promise { } } -export async function getEvents(): Promise { - const response = await authorizedFetch('/api/v1/tenant/events'); - const data = await jsonOrThrow<{ data?: any[] }>(response, 'Failed to load events'); - return data.data ?? []; +function normalizeEvent(event: TenantEvent): TenantEvent { + return { + ...event, + is_active: typeof event.is_active === 'boolean' ? event.is_active : undefined, + }; } -export async function createEvent(payload: { name: string; slug: string; date?: string; is_active?: boolean }): Promise { +function normalizePhoto(photo: TenantPhoto): TenantPhoto { + return { + id: photo.id, + filename: photo.filename, + original_name: photo.original_name ?? null, + mime_type: photo.mime_type ?? null, + size: Number(photo.size ?? 0), + url: photo.url, + thumbnail_url: photo.thumbnail_url ?? photo.url, + status: photo.status ?? 'approved', + is_featured: Boolean(photo.is_featured), + likes_count: Number(photo.likes_count ?? 0), + uploaded_at: photo.uploaded_at, + uploader_name: photo.uploader_name ?? null, + }; +} + +function eventEndpoint(slug: string): string { + return `/api/v1/tenant/events/${encodeURIComponent(slug)}`; +} + +export async function getEvents(): Promise { + const response = await authorizedFetch('/api/v1/tenant/events'); + const data = await jsonOrThrow(response, 'Failed to load events'); + return (data.data ?? []).map(normalizeEvent); +} + +export async function createEvent(payload: EventSavePayload): Promise<{ event: TenantEvent; balance: number }> { const response = await authorizedFetch('/api/v1/tenant/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); - const data = await jsonOrThrow<{ id: number }>(response, 'Failed to create event'); - return data.id; + const data = await jsonOrThrow(response, 'Failed to create event'); + return { event: normalizeEvent(data.data), balance: data.balance }; } -export async function updateEvent( - id: number, - payload: Partial<{ name: string; slug: string; date?: string; is_active?: boolean }> -): Promise { - const response = await authorizedFetch(`/api/v1/tenant/events/${id}`, { +export async function updateEvent(slug: string, payload: Partial): Promise { + const response = await authorizedFetch(eventEndpoint(slug), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); - if (!response.ok) { - await safeJson(response); - throw new Error('Failed to update event'); - } + const data = await jsonOrThrow(response, 'Failed to update event'); + return normalizeEvent(data.data); } -export async function getEventPhotos(id: number): Promise { - const response = await authorizedFetch(`/api/v1/tenant/events/${id}/photos`); - const data = await jsonOrThrow<{ data?: any[] }>(response, 'Failed to load photos'); - return data.data ?? []; +export async function getEvent(slug: string): Promise { + const response = await authorizedFetch(eventEndpoint(slug)); + const data = await jsonOrThrow(response, 'Failed to load event'); + return normalizeEvent(data.data); } -export async function featurePhoto(id: number): Promise { - const response = await authorizedFetch(`/api/v1/tenant/photos/${id}/feature`, { method: 'POST' }); - if (!response.ok) { - await safeJson(response); - throw new Error('Failed to feature photo'); - } +export async function getEventPhotos(slug: string): Promise { + const response = await authorizedFetch(`${eventEndpoint(slug)}/photos`); + const data = await jsonOrThrow<{ data?: TenantPhoto[] }>(response, 'Failed to load photos'); + return (data.data ?? []).map(normalizePhoto); } -export async function unfeaturePhoto(id: number): Promise { - const response = await authorizedFetch(`/api/v1/tenant/photos/${id}/unfeature`, { method: 'POST' }); - if (!response.ok) { - await safeJson(response); - throw new Error('Failed to unfeature photo'); - } +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'); + return normalizePhoto(data.data); } -export async function getEvent(id: number): Promise { - const response = await authorizedFetch(`/api/v1/tenant/events/${id}`); - return jsonOrThrow(response, 'Failed to load event'); +export async function unfeaturePhoto(slug: string, id: number): Promise { + const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}/unfeature`, { method: 'POST' }); + const data = await jsonOrThrow(response, 'Failed to unfeature photo'); + return normalizePhoto(data.data); } -export async function toggleEvent(id: number): Promise { - const response = await authorizedFetch(`/api/v1/tenant/events/${id}/toggle`, { method: 'POST' }); - const data = await jsonOrThrow<{ is_active: boolean }>(response, 'Failed to toggle event'); - return !!data.is_active; -} - -export async function getEventStats(id: number): Promise<{ total: number; featured: number; likes: number }> { - const response = await authorizedFetch(`/api/v1/tenant/events/${id}/stats`); - return jsonOrThrow<{ total: number; featured: number; likes: number }>(response, 'Failed to load stats'); -} - -export async function createInviteLink(id: number): Promise { - const response = await authorizedFetch(`/api/v1/tenant/events/${id}/invites`, { method: 'POST' }); - const data = await jsonOrThrow<{ link: string }>(response, 'Failed to create invite'); - return data.link; -} - -export async function deletePhoto(id: number): Promise { - const response = await authorizedFetch(`/api/v1/tenant/photos/${id}`, { method: 'DELETE' }); +export async function deletePhoto(slug: string, id: number): Promise { + const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}`, { method: 'DELETE' }); if (!response.ok) { await safeJson(response); throw new Error('Failed to delete photo'); } } + +export async function toggleEvent(slug: string): Promise { + const response = await authorizedFetch(`${eventEndpoint(slug)}/toggle`, { method: 'POST' }); + const data = await jsonOrThrow<{ message: string; data: TenantEvent }>(response, 'Failed to toggle event'); + return normalizeEvent(data.data); +} + +export async function getEventStats(slug: string): Promise { + const response = await authorizedFetch(`${eventEndpoint(slug)}/stats`); + const data = await jsonOrThrow(response, 'Failed to load stats'); + return { + total: Number(data.total ?? 0), + featured: Number(data.featured ?? 0), + likes: Number(data.likes ?? 0), + recent_uploads: Number(data.recent_uploads ?? 0), + status: data.status ?? 'draft', + is_active: Boolean(data.is_active), + }; +} + +export async function createInviteLink(slug: string): Promise<{ link: string; token: string }> { + const response = await authorizedFetch(`${eventEndpoint(slug)}/invites`, { method: 'POST' }); + return jsonOrThrow<{ link: string; token: string }>(response, 'Failed to create invite'); +} diff --git a/resources/js/admin/components/AdminLayout.tsx b/resources/js/admin/components/AdminLayout.tsx new file mode 100644 index 0000000..9df0f98 --- /dev/null +++ b/resources/js/admin/components/AdminLayout.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { NavLink } from 'react-router-dom'; +import { cn } from '@/lib/utils'; + +const navItems = [ + { to: '/admin/events', label: 'Events' }, + { to: '/admin/settings', label: 'Einstellungen' }, +]; + +interface AdminLayoutProps { + title: string; + subtitle?: string; + actions?: React.ReactNode; + children: React.ReactNode; +} + +export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutProps) { + React.useEffect(() => { + document.body.classList.add('tenant-admin-theme'); + return () => { + document.body.classList.remove('tenant-admin-theme'); + }; + }, []); + + return ( +
+
+
+
+

Fotospiel Tenant Admin

+

{title}

+ {subtitle &&

{subtitle}

} +
+ {actions &&
{actions}
} +
+ +
+
+
{children}
+
+
+ ); +} diff --git a/resources/js/admin/pages/EventDetailPage.tsx b/resources/js/admin/pages/EventDetailPage.tsx index ddecc1d..d57d81c 100644 --- a/resources/js/admin/pages/EventDetailPage.tsx +++ b/resources/js/admin/pages/EventDetailPage.tsx @@ -1,124 +1,282 @@ import React from 'react'; -import { Button } from '@/components/ui/button'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { createInviteLink, getEvent, getEventStats, toggleEvent } from '../api'; +import { ArrowLeft, Camera, Heart, Loader2, RefreshCw, Share2, Sparkles } from 'lucide-react'; + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; + +import { AdminLayout } from '../components/AdminLayout'; +import { createInviteLink, getEvent, getEventStats, TenantEvent, EventStats as TenantEventStats, toggleEvent } from '../api'; import { isAuthError } from '../auth/tokens'; +interface State { + event: TenantEvent | null; + stats: TenantEventStats | null; + inviteLink: string | null; + error: string | null; + loading: boolean; + busy: boolean; +} + export default function EventDetailPage() { - const [sp] = useSearchParams(); - const id = Number(sp.get('id')); - const nav = useNavigate(); - const [ev, setEv] = React.useState(null); - const [stats, setStats] = React.useState<{ total: number; featured: number; likes: number } | null>(null); - const [invite, setInvite] = React.useState(null); + const [searchParams] = useSearchParams(); + const slug = searchParams.get('slug'); + const navigate = useNavigate(); + + const [state, setState] = React.useState({ + event: null, + stats: null, + inviteLink: null, + error: null, + loading: true, + busy: false, + }); const load = React.useCallback(async () => { - try { - const event = await getEvent(id); - setEv(event); - setStats(await getEventStats(id)); - } catch (error) { - if (!isAuthError(error)) { - console.error(error); - } + if (!slug) { + setState((prev) => ({ ...prev, loading: false, error: 'Kein Event-Slug angegeben.' })); + return; } - }, [id]); + + setState((prev) => ({ ...prev, loading: true, error: null })); + try { + const [eventData, statsData] = await Promise.all([getEvent(slug), getEventStats(slug)]); + setState((prev) => ({ + ...prev, + event: eventData, + stats: statsData, + loading: false, + inviteLink: prev.inviteLink, + })); + } catch (err) { + if (isAuthError(err)) return; + setState((prev) => ({ ...prev, error: 'Event konnte nicht geladen werden.', loading: false })); + } + }, [slug]); React.useEffect(() => { load(); }, [load]); - async function onToggle() { + async function handleToggle() { + if (!slug) return; + setState((prev) => ({ ...prev, busy: true, error: null })); try { - const isActive = await toggleEvent(id); - setEv((previous: any) => ({ ...(previous || {}), is_active: isActive })); - } catch (error) { - if (!isAuthError(error)) { - console.error(error); + const updated = await toggleEvent(slug); + setState((prev) => ({ + ...prev, + event: updated, + stats: prev.stats ? { ...prev.stats, status: updated.status, is_active: Boolean(updated.is_active) } : prev.stats, + busy: false, + })); + } catch (err) { + if (!isAuthError(err)) { + setState((prev) => ({ ...prev, error: 'Status konnte nicht angepasst werden.', busy: false })); + } else { + setState((prev) => ({ ...prev, busy: false })); } } } - async function onInvite() { + async function handleInvite() { + if (!slug) return; + setState((prev) => ({ ...prev, busy: true, error: null })); try { - const link = await createInviteLink(id); - setInvite(link); + const { link } = await createInviteLink(slug); + setState((prev) => ({ ...prev, inviteLink: link, busy: false })); try { await navigator.clipboard.writeText(link); } catch { - // clipboard may be unavailable + // clipboard may be unavailable, ignore silently } - } catch (error) { - if (!isAuthError(error)) { - console.error(error); + } catch (err) { + if (!isAuthError(err)) { + setState((prev) => ({ ...prev, error: 'Einladungslink konnte nicht erzeugt werden.', busy: false })); + } else { + setState((prev) => ({ ...prev, busy: false })); } } } - if (!ev) { - return
Lade ...
; + const { event, stats, inviteLink, error, loading, busy } = state; + + const actions = ( + <> + + {event && ( + + )} + + ); + + if (!slug) { + return ( + + + + Ohne gueltigen Slug koennen wir keine Daten laden. Kehre zur Event-Liste zurueck und waehle dort ein Event aus. + + + + ); } - const joinLink = `${window.location.origin}/e/${ev.slug}`; - const qrUrl = `/admin/qr?data=${encodeURIComponent(joinLink)}`; - return ( -
-
-

Event: {renderName(ev.name)}

-
- - + + {error && ( + + Aktion fehlgeschlagen + {error} + + )} + + {loading ? ( + + ) : event ? ( +
+ + + + Eventdaten + + + Grundlegende Informationen fuer Gaeste und Moderation. + + + + + + + +
+ + +
+
+
+ + + + + Einladungen + + + Generiere Links um Gaeste direkt in das Event zu fuehren. + + + + + {inviteLink && ( +

+ {inviteLink} +

+ )} +
+
+ + + + + Performance + + + Kennzahlen zu Uploads, Highlights und Interaktion. + + + + + + + + +
-
-
-
Slug: {ev.slug}
-
Datum: {ev.date ?? '-'}
-
Status: {ev.is_active ? 'Aktiv' : 'Inaktiv'}
-
-
- - - -
-
-
Join-Link
-
- - -
-
QR
- QR -
- - {invite && ( -
Erzeugt und kopiert: {invite}
- )} -
-
+ ) : ( + + Event nicht gefunden + Bitte pruefe den Slug und versuche es erneut. + + )} + + ); +} + +function DetailSkeleton() { + return ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ ))}
); } -function StatCard({ label, value }: { label: string; value: number }) { +function InfoRow({ label, value }: { label: string; value: string }) { return ( -
-
{value}
-
{label}
+
+ {label} + {value}
); } -function renderName(name: any): string { - if (typeof name === 'string') return name; - if (name && (name.de || name.en)) return name.de || name.en; - return JSON.stringify(name); +function StatChip({ label, value }: { label: string; value: string | number }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function formatDate(iso: string | null): string { + if (!iso) return 'Noch kein Datum'; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + return 'Unbekanntes Datum'; + } + return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' }); +} + +function renderName(name: TenantEvent['name']): string { + if (typeof name === 'string') { + return name; + } + if (name && typeof name === 'object') { + return name.de ?? name.en ?? Object.values(name)[0] ?? 'Unbenanntes Event'; + } + return 'Unbenanntes Event'; } diff --git a/resources/js/admin/pages/EventFormPage.tsx b/resources/js/admin/pages/EventFormPage.tsx index a01731a..1b20f69 100644 --- a/resources/js/admin/pages/EventFormPage.tsx +++ b/resources/js/admin/pages/EventFormPage.tsx @@ -1,57 +1,273 @@ import React from 'react'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; -import { createEvent, updateEvent } from '../api'; -import { isAuthError } from '../auth/tokens'; import { useNavigate, useSearchParams } from 'react-router-dom'; +import { ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react'; + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +import { AdminLayout } from '../components/AdminLayout'; +import { createEvent, getEvent, updateEvent } from '../api'; +import { isAuthError } from '../auth/tokens'; + +interface EventFormState { + name: string; + slug: string; + date: string; + isPublished: boolean; +} export default function EventFormPage() { - const [sp] = useSearchParams(); - const id = sp.get('id'); - const nav = useNavigate(); - const [name, setName] = React.useState(''); - const [slug, setSlug] = React.useState(''); - const [date, setDate] = React.useState(''); - const [active, setActive] = React.useState(true); + const [searchParams] = useSearchParams(); + const slugParam = searchParams.get('slug'); + const isEdit = Boolean(slugParam); + const navigate = useNavigate(); + + const [form, setForm] = React.useState({ + name: '', + slug: '', + date: '', + isPublished: false, + }); + const [autoSlug, setAutoSlug] = React.useState(true); + const [originalSlug, setOriginalSlug] = React.useState(null); + const [loading, setLoading] = React.useState(isEdit); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(null); - const isEdit = !!id; - async function save() { + React.useEffect(() => { + let cancelled = false; + if (!isEdit || !slugParam) { + setLoading(false); + return () => { + cancelled = true; + }; + } + + (async () => { + try { + const event = await getEvent(slugParam); + if (cancelled) return; + const name = normalizeName(event.name); + setForm({ + name, + slug: event.slug, + date: event.event_date ? event.event_date.slice(0, 10) : '', + isPublished: event.status === 'published', + }); + setOriginalSlug(event.slug); + setAutoSlug(false); + } catch (err) { + if (!isAuthError(err)) { + setError('Event konnte nicht geladen werden.'); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [isEdit, slugParam]); + + function handleNameChange(value: string) { + setForm((prev) => ({ ...prev, name: value })); + if (autoSlug) { + setForm((prev) => ({ ...prev, slug: slugify(value) })); + } + } + + function handleSlugChange(value: string) { + setAutoSlug(false); + setForm((prev) => ({ ...prev, slug: slugify(value) })); + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + const trimmedName = form.name.trim(); + const trimmedSlug = form.slug.trim(); + + if (!trimmedName) { + setError('Bitte gib einen Eventnamen ein.'); + return; + } + + if (!trimmedSlug) { + setError('Bitte waehle einen Slug fuer die Event-URL.'); + return; + } + setSaving(true); setError(null); + + const payload = { + name: trimmedName, + slug: trimmedSlug, + date: form.date || undefined, + status: form.isPublished ? 'published' : 'draft', + }; + try { if (isEdit) { - await updateEvent(Number(id), { name, slug, date, is_active: active }); + const targetSlug = originalSlug ?? slugParam!; + const updated = await updateEvent(targetSlug, payload); + setOriginalSlug(updated.slug); + navigate(`/admin/events/view?slug=${encodeURIComponent(updated.slug)}`); } else { - await createEvent({ name, slug, date, is_active: active }); + const { event: created } = await createEvent(payload); + navigate(`/admin/events/view?slug=${encodeURIComponent(created.slug)}`); } - nav('/admin/events'); - } catch (e) { - if (!isAuthError(e)) { - setError('Speichern fehlgeschlagen'); + } catch (err) { + if (!isAuthError(err)) { + setError('Speichern fehlgeschlagen. Bitte pruefe deine Eingaben.'); } } finally { setSaving(false); } } + const actions = ( + + ); + return ( -
-

{isEdit ? 'Event bearbeiten' : 'Neues Event'}

- {error &&
{error}
} - setName(e.target.value)} /> - setSlug(e.target.value)} /> - setDate(e.target.value)} /> - -
- - -
+ + {error && ( + + Hinweis + {error} + + )} + + + + + Eventdetails + + + Name, URL und Datum bestimmen das Auftreten deines Events im Gaesteportal. + + + + {loading ? ( + + ) : ( +
+
+
+ + handleNameChange(e.target.value)} + autoFocus + /> +
+
+ + handleSlugChange(e.target.value)} + /> +

Das Event ist spaeter unter /e/{form.slug || 'dein-event'} erreichbar.

+
+
+ + setForm((prev) => ({ ...prev, date: e.target.value }))} + /> +
+
+ +
+ setForm((prev) => ({ ...prev, isPublished: Boolean(checked) }))} + /> +
+ +

+ Aktiviere diese Option, wenn Gaeste das Event direkt sehen sollen. Du kannst den Status spaeter aendern. +

+
+
+ +
+ + +
+
+ )} +
+
+
+ ); +} + +function FormSkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ ))}
); } + +function slugify(value: string): string { + return value + .normalize('NFKD') + .toLowerCase() + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)+/g, '') + .slice(0, 60); +} + +function normalizeName(name: string | Record): string { + if (typeof name === 'string') { + return name; + } + return name.de ?? name.en ?? Object.values(name)[0] ?? ''; +} diff --git a/resources/js/admin/pages/EventPhotosPage.tsx b/resources/js/admin/pages/EventPhotosPage.tsx index f5abd2f..207236e 100644 --- a/resources/js/admin/pages/EventPhotosPage.tsx +++ b/resources/js/admin/pages/EventPhotosPage.tsx @@ -1,97 +1,199 @@ import React from 'react'; -import { useSearchParams } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { Camera, Loader2, Sparkles, Trash2 } from 'lucide-react'; + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; -import { deletePhoto, featurePhoto, getEventPhotos, unfeaturePhoto } from '../api'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; + +import { AdminLayout } from '../components/AdminLayout'; +import { deletePhoto, featurePhoto, getEventPhotos, TenantPhoto, unfeaturePhoto } from '../api'; import { isAuthError } from '../auth/tokens'; export default function EventPhotosPage() { - const [sp] = useSearchParams(); - const id = Number(sp.get('id')); - const [rows, setRows] = React.useState([]); + const [searchParams] = useSearchParams(); + const slug = searchParams.get('slug'); + const navigate = useNavigate(); + + const [photos, setPhotos] = React.useState([]); const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [busyId, setBusyId] = React.useState(null); const load = React.useCallback(async () => { + if (!slug) { + setLoading(false); + return; + } setLoading(true); + setError(null); try { - setRows(await getEventPhotos(id)); - } catch (error) { - if (!isAuthError(error)) { - console.error(error); + const data = await getEventPhotos(slug); + setPhotos(data); + } catch (err) { + if (!isAuthError(err)) { + setError('Fotos konnten nicht geladen werden.'); } } finally { setLoading(false); } - }, [id]); + }, [slug]); React.useEffect(() => { load(); }, [load]); - async function onFeature(photo: any) { + async function handleToggleFeature(photo: TenantPhoto) { + if (!slug) return; + setBusyId(photo.id); try { - await featurePhoto(photo.id); - await load(); - } catch (error) { - if (!isAuthError(error)) { - console.error(error); + const updated = photo.is_featured + ? await unfeaturePhoto(slug, photo.id) + : await featurePhoto(slug, photo.id); + setPhotos((prev) => prev.map((entry) => (entry.id === photo.id ? updated : entry))); + } catch (err) { + if (!isAuthError(err)) { + setError('Feature-Aktion fehlgeschlagen.'); } + } finally { + setBusyId(null); } } - async function onUnfeature(photo: any) { + async function handleDelete(photo: TenantPhoto) { + if (!slug) return; + setBusyId(photo.id); try { - await unfeaturePhoto(photo.id); - await load(); - } catch (error) { - if (!isAuthError(error)) { - console.error(error); + await deletePhoto(slug, photo.id); + setPhotos((prev) => prev.filter((entry) => entry.id !== photo.id)); + } catch (err) { + if (!isAuthError(err)) { + setError('Foto konnte nicht entfernt werden.'); } + } finally { + setBusyId(null); } } - async function onDelete(photo: any) { - try { - await deletePhoto(photo.id); - await load(); - } catch (error) { - if (!isAuthError(error)) { - console.error(error); - } - } + if (!slug) { + return ( + + + + Kein Slug in der URL gefunden. Kehre zur Event-Liste zurueck und waehle dort ein Event aus. + + + + + ); } + const actions = ( + + ); + return ( -
-

Fotos moderieren

- {loading &&
Lade ...
} -
- {rows.map((p) => ( -
- {p.caption -
- ?? {p.likes_count} -
- {p.is_featured ? ( - - ) : ( - - )} - -
+ + {error && ( + + Aktion fehlgeschlagen + {error} + + )} + + + + + Galerie + + + Klick auf ein Foto, um es hervorzuheben oder zu loeschen. + + + + {loading ? ( + + ) : photos.length === 0 ? ( + + ) : ( +
+ {photos.map((photo) => ( +
+
+ {photo.original_name + {photo.is_featured && ( + + Featured + + )} +
+
+
+ Likes: {photo.likes_count} + Uploader: {photo.uploader_name ?? 'Unbekannt'} +
+
+ + +
+
+
+ ))}
-
- ))} -
+ )} + + + + ); +} + +function GallerySkeleton() { + return ( +
+ {Array.from({ length: 6 }).map((_, index) => ( +
+ ))} +
+ ); +} + +function EmptyGallery() { + return ( +
+
+ +
+

Noch keine Fotos vorhanden

+

Motiviere deine Gaeste zum Hochladen - hier erscheint anschliessend die Galerie.

); } diff --git a/resources/js/admin/pages/EventsPage.tsx b/resources/js/admin/pages/EventsPage.tsx index 2330fbd..d3f6e01 100644 --- a/resources/js/admin/pages/EventsPage.tsx +++ b/resources/js/admin/pages/EventsPage.tsx @@ -1,14 +1,21 @@ import React from 'react'; -import { Button } from '@/components/ui/button'; import { Link, useNavigate } from 'react-router-dom'; -import { getEvents } from '../api'; +import { ArrowRight, CalendarDays, Plus, Settings, Sparkles } from 'lucide-react'; + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; + +import { AdminLayout } from '../components/AdminLayout'; +import { getEvents, TenantEvent } from '../api'; import { isAuthError } from '../auth/tokens'; export default function EventsPage() { - const [rows, setRows] = React.useState([]); + const [rows, setRows] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); - const nav = useNavigate(); + const navigate = useNavigate(); React.useEffect(() => { (async () => { @@ -16,7 +23,7 @@ export default function EventsPage() { setRows(await getEvents()); } catch (err) { if (!isAuthError(err)) { - setError('Laden fehlgeschlagen'); + setError('Laden fehlgeschlagen. Bitte spaeter erneut versuchen.'); } } finally { setLoading(false); @@ -24,47 +31,177 @@ export default function EventsPage() { })(); }, []); + const actions = ( + <> + + + + + + ); + return ( -
-
-

Meine Events

-
- - - - -
-
- {loading &&
Lade ...
} + {error && ( -
{error}
+ + Fehler beim Laden + {error} + )} -
- {rows.map((event) => ( -
-
-
{renderName(event.name)}
-
Slug: {event.slug} � Datum: {event.date ?? '-'}
-
-
- details - bearbeiten - fotos - - �ffnen - -
+ + + +
+ Uebersicht + + {rows.length === 0 + ? 'Noch keine Events - starte jetzt und lege dein erstes Event an.' + : `${rows.length} ${rows.length === 1 ? 'Event' : 'Events'} aktiv verwaltet.`} +
- ))} +
+ Tenant Dashboard +
+
+ + {loading ? ( + + ) : rows.length === 0 ? ( + navigate('/admin/events/new')} /> + ) : ( +
+ {rows.map((event) => ( + + ))} +
+ )} +
+
+ + ); +} + +function EventCard({ event }: { event: TenantEvent }) { + const slug = event.slug; + const isPublished = event.status === 'published'; + const photoCount = event.photo_count ?? 0; + const likeCount = event.like_count ?? 0; + + return ( +
+
+
+

{renderName(event.name)}

+

Slug: {event.slug}

+
+ + + {formatDate(event.event_date)} + + + Photos: {photoCount} + + + Likes: {likeCount} + +
+
+ + {isPublished ? 'Veroeffentlicht' : 'Entwurf'} + +
+ +
+ + + +
); } -function renderName(name: any): string { - if (typeof name === 'string') return name; - if (name && (name.de || name.en)) return name.de || name.en; - return JSON.stringify(name); +function LoadingState() { + return ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ ))} +
+ ); +} + +function EmptyState({ onCreate }: { onCreate: () => void }) { + return ( +
+
+ +
+
+

Noch kein Event angelegt

+

+ Starte jetzt mit deinem ersten Event und lade Gaeste in dein farbenfrohes Erlebnisportal ein. +

+
+ +
+ ); +} + +function formatDate(iso: string | null): string { + if (!iso) return 'Noch kein Datum'; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + return 'Unbekanntes Datum'; + } + return date.toLocaleDateString('de-DE', { + day: '2-digit', + month: 'short', + year: 'numeric', + }); +} + +function renderName(name: TenantEvent['name']): string { + if (typeof name === 'string') return name; + if (name && typeof name === 'object') { + return name.de ?? name.en ?? Object.values(name)[0] ?? 'Unbenanntes Event'; + } + return 'Unbenanntes Event'; } diff --git a/resources/js/admin/pages/SettingsPage.tsx b/resources/js/admin/pages/SettingsPage.tsx index c9bee06..1d083af 100644 --- a/resources/js/admin/pages/SettingsPage.tsx +++ b/resources/js/admin/pages/SettingsPage.tsx @@ -1,35 +1,79 @@ import React from 'react'; +import { LogOut, Palette } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; + import AppearanceToggleDropdown from '@/components/appearance-dropdown'; import { Button } from '@/components/ui/button'; -import { useNavigate } from 'react-router-dom'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; + +import { AdminLayout } from '../components/AdminLayout'; import { useAuth } from '../auth/context'; export default function SettingsPage() { - const nav = useNavigate(); + const navigate = useNavigate(); const { user, logout } = useAuth(); function handleLogout() { logout({ redirect: '/admin/login' }); } + const actions = ( + + ); + return ( -
-
-

Einstellungen

- {user && ( -

- Angemeldet als {user.name ?? user.email ?? 'Tenant Admin'} - Tenant #{user.tenant_id} -

- )} -
-
-
Darstellung
- -
-
- - -
-
+ + + + + Darstellung & Account + + + Gestalte den Admin-Bereich so farbenfroh wie dein Gaesteportal. + + + +
+

Darstellung

+

+ Wechsel zwischen Hell- und Dunkelmodus oder uebernimm automatisch die Systemeinstellung. +

+ +
+ +
+

Angemeldeter Account

+

+ {user ? ( + <> + Eingeloggt als {user.name ?? user.email ?? 'Tenant Admin'} + {user.tenant_id && <> - Tenant #{user.tenant_id}} + + ) : ( + 'Aktuell kein Benutzer geladen.' + )} +

+
+ + +
+
+
+
+
); } diff --git a/routes/api.php b/routes/api.php index c214d63..2af76dd 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Api\EventPublicController; use App\Http\Controllers\Api\Tenant\EventController; use App\Http\Controllers\Api\Tenant\SettingsController; use App\Http\Controllers\Api\Tenant\TaskController; +use App\Http\Controllers\Api\Tenant\PhotoController; use App\Http\Controllers\OAuthController; use App\Http\Controllers\RevenueCatWebhookController; use App\Http\Controllers\Tenant\CreditController; @@ -43,6 +44,24 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::match(['put', 'patch'], 'events/{event:slug}', [EventController::class, 'update'])->name('tenant.events.update'); }); + Route::prefix('events/{event:slug}')->group(function () { + Route::get('stats', [EventController::class, 'stats'])->name('tenant.events.stats'); + Route::post('toggle', [EventController::class, 'toggle'])->name('tenant.events.toggle'); + Route::post('invites', [EventController::class, 'createInvite'])->name('tenant.events.invites'); + + Route::get('photos', [PhotoController::class, 'index'])->name('tenant.events.photos.index'); + Route::post('photos', [PhotoController::class, 'store'])->name('tenant.events.photos.store'); + Route::get('photos/{photo}', [PhotoController::class, 'show'])->name('tenant.events.photos.show'); + Route::patch('photos/{photo}', [PhotoController::class, 'update'])->name('tenant.events.photos.update'); + Route::delete('photos/{photo}', [PhotoController::class, 'destroy'])->name('tenant.events.photos.destroy'); + Route::post('photos/{photo}/feature', [PhotoController::class, 'feature'])->name('tenant.events.photos.feature'); + Route::post('photos/{photo}/unfeature', [PhotoController::class, 'unfeature'])->name('tenant.events.photos.unfeature'); + Route::post('photos/bulk-approve', [PhotoController::class, 'bulkApprove'])->name('tenant.events.photos.bulk-approve'); + Route::post('photos/bulk-reject', [PhotoController::class, 'bulkReject'])->name('tenant.events.photos.bulk-reject'); + Route::get('photos/moderation', [PhotoController::class, 'forModeration'])->name('tenant.events.photos.for-moderation'); + Route::get('photos/stats', [PhotoController::class, 'stats'])->name('tenant.events.photos.stats'); + }); + Route::post('events/bulk-status', [EventController::class, 'bulkUpdateStatus'])->name('tenant.events.bulk-status'); Route::get('events/search', [EventController::class, 'search'])->name('tenant.events.search');