feat(tenant-admin): refresh event management experience

This commit is contained in:
Codex Agent
2025-09-26 12:17:25 +02:00
parent 215d19f07e
commit 6215ebbaa0
13 changed files with 1255 additions and 300 deletions

View File

@@ -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');

View File

@@ -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));

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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);

View File

@@ -2,12 +2,62 @@ import { authorizedFetch } from './auth/tokens';
type JsonValue = Record<string, any>;
export type TenantEvent = {
id: number;
name: string | Record<string, string>;
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<T>(response: Response, message: string): Promise<T> {
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<JsonValue | null> {
}
}
export async function getEvents(): Promise<any[]> {
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<number> {
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<TenantEvent[]> {
const response = await authorizedFetch('/api/v1/tenant/events');
const data = await jsonOrThrow<EventListResponse>(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<CreatedEventResponse>(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<void> {
const response = await authorizedFetch(`/api/v1/tenant/events/${id}`, {
export async function updateEvent(slug: string, payload: Partial<EventSavePayload>): Promise<TenantEvent> {
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<EventResponse>(response, 'Failed to update event');
return normalizeEvent(data.data);
}
export async function getEventPhotos(id: number): Promise<any[]> {
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<TenantEvent> {
const response = await authorizedFetch(eventEndpoint(slug));
const data = await jsonOrThrow<EventResponse>(response, 'Failed to load event');
return normalizeEvent(data.data);
}
export async function featurePhoto(id: number): Promise<void> {
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<TenantPhoto[]> {
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<void> {
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<TenantPhoto> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}/feature`, { method: 'POST' });
const data = await jsonOrThrow<PhotoResponse>(response, 'Failed to feature photo');
return normalizePhoto(data.data);
}
export async function getEvent(id: number): Promise<any> {
const response = await authorizedFetch(`/api/v1/tenant/events/${id}`);
return jsonOrThrow<any>(response, 'Failed to load event');
export async function unfeaturePhoto(slug: string, id: number): Promise<TenantPhoto> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}/unfeature`, { method: 'POST' });
const data = await jsonOrThrow<PhotoResponse>(response, 'Failed to unfeature photo');
return normalizePhoto(data.data);
}
export async function toggleEvent(id: number): Promise<boolean> {
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<string> {
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<void> {
const response = await authorizedFetch(`/api/v1/tenant/photos/${id}`, { method: 'DELETE' });
export async function deletePhoto(slug: string, id: number): Promise<void> {
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<TenantEvent> {
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<EventStats> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/stats`);
const data = await jsonOrThrow<EventStats>(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');
}

View File

@@ -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 (
<div className="min-h-screen bg-gradient-to-br from-pink-100 via-amber-50 to-sky-100 text-slate-900">
<header className="border-b border-white/60 bg-white/80 shadow-sm backdrop-blur-md">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-6 py-6 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.35em] text-pink-600">Fotospiel Tenant Admin</p>
<h1 className="text-3xl font-semibold text-slate-900">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-slate-600">{subtitle}</p>}
</div>
{actions && <div className="flex flex-wrap gap-2">{actions}</div>}
</div>
<nav className="mx-auto flex w-full max-w-6xl gap-3 px-6 pb-4 text-sm font-medium">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
cn(
'rounded-full px-4 py-2 transition-colors',
isActive
? 'bg-pink-500 text-white shadow-md shadow-pink-500/30'
: 'bg-white/70 text-slate-600 hover:bg-white hover:text-slate-900'
)
}
>
{item.label}
</NavLink>
))}
</nav>
</header>
<main className="mx-auto w-full max-w-6xl px-6 py-10">
<div className="grid gap-6">{children}</div>
</main>
</div>
);
}

View File

@@ -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<any | null>(null);
const [stats, setStats] = React.useState<{ total: number; featured: number; likes: number } | null>(null);
const [invite, setInvite] = React.useState<string | null>(null);
const [searchParams] = useSearchParams();
const slug = searchParams.get('slug');
const navigate = useNavigate();
const [state, setState] = React.useState<State>({
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 <div className="p-4">Lade ...</div>;
const { event, stats, inviteLink, error, loading, busy } = state;
const actions = (
<>
<Button
variant="outline"
onClick={() => navigate('/admin/events')}
className="border-pink-200 text-pink-600 hover:bg-pink-50"
>
<ArrowLeft className="h-4 w-4" /> Zurueck zur Liste
</Button>
{event && (
<Button
variant="outline"
onClick={() => navigate(`/admin/events/edit?slug=${encodeURIComponent(event.slug)}`)}
className="border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50"
>
Bearbeiten
</Button>
)}
</>
);
if (!slug) {
return (
<AdminLayout title="Event nicht gefunden" subtitle="Bitte waehle ein Event aus der Uebersicht." actions={actions}>
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardContent className="p-6 text-sm text-slate-600">
Ohne gueltigen Slug koennen wir keine Daten laden. Kehre zur Event-Liste zurueck und waehle dort ein Event aus.
</CardContent>
</Card>
</AdminLayout>
);
}
const joinLink = `${window.location.origin}/e/${ev.slug}`;
const qrUrl = `/admin/qr?data=${encodeURIComponent(joinLink)}`;
return (
<div className="mx-auto max-w-3xl space-y-4 p-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold">Event: {renderName(ev.name)}</h1>
<div className="flex gap-2">
<Button variant="secondary" onClick={onToggle}>
{ev.is_active ? 'Deaktivieren' : 'Aktivieren'}
</Button>
<Button variant="secondary" onClick={() => nav(`/admin/events/photos?id=${id}`)}>
Fotos moderieren
</Button>
<AdminLayout
title={event ? renderName(event.name) : 'Event wird geladen'}
subtitle="Verwalte Status, Einladungen und Statistiken deines Events."
actions={actions}
>
{error && (
<Alert variant="destructive">
<AlertTitle>Aktion fehlgeschlagen</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{loading ? (
<DetailSkeleton />
) : event ? (
<div className="grid gap-6 lg:grid-cols-[2fr,1fr]">
<Card className="border-0 bg-white/90 shadow-xl shadow-pink-100/60">
<CardHeader className="space-y-2">
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-pink-500" /> Eventdaten
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Grundlegende Informationen fuer Gaeste und Moderation.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm text-slate-700">
<InfoRow label="Slug" value={event.slug} />
<InfoRow label="Status" value={event.status === 'published' ? 'Veroeffentlicht' : event.status} />
<InfoRow label="Datum" value={formatDate(event.event_date)} />
<InfoRow label="Aktiv" value={event.is_active ? 'Aktiv' : 'Deaktiviert'} />
<div className="flex flex-wrap gap-3 pt-2">
<Button
onClick={handleToggle}
disabled={busy}
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{event.is_active ? 'Deaktivieren' : 'Aktivieren'}
</Button>
<Button
variant="outline"
onClick={() => navigate(`/admin/events/photos?slug=${encodeURIComponent(event.slug)}`)}
className="border-sky-200 text-sky-700 hover:bg-sky-50"
>
<Camera className="h-4 w-4" /> Fotos moderieren
</Button>
</div>
</CardContent>
</Card>
<Card className="border-0 bg-white/90 shadow-xl shadow-amber-100/60">
<CardHeader className="space-y-2">
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Share2 className="h-5 w-5 text-amber-500" /> Einladungen
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Generiere Links um Gaeste direkt in das Event zu fuehren.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-slate-700">
<Button onClick={handleInvite} disabled={busy} className="w-full">
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Share2 className="h-4 w-4" />}
Einladungslink kopieren
</Button>
{inviteLink && (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 font-mono text-xs text-amber-800">
{inviteLink}
</p>
)}
</CardContent>
</Card>
<Card className="border-0 bg-white/90 shadow-xl shadow-sky-100/60 lg:col-span-2">
<CardHeader className="space-y-2">
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Heart className="h-5 w-5 text-sky-500" /> Performance
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Kennzahlen zu Uploads, Highlights und Interaktion.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2 md:grid-cols-4">
<StatChip label="Fotos" value={stats?.total ?? 0} />
<StatChip label="Featured" value={stats?.featured ?? 0} />
<StatChip label="Likes" value={stats?.likes ?? 0} />
<StatChip label="Uploads (7 Tage)" value={stats?.recent_uploads ?? 0} />
</CardContent>
</Card>
</div>
</div>
<div className="rounded border p-3 text-sm">
<div>Slug: {ev.slug}</div>
<div>Datum: {ev.date ?? '-'}</div>
<div>Status: {ev.is_active ? 'Aktiv' : 'Inaktiv'}</div>
</div>
<div className="grid grid-cols-1 gap-3 text-center text-sm sm:grid-cols-3">
<StatCard label="Fotos" value={stats?.total ?? 0} />
<StatCard label="Featured" value={stats?.featured ?? 0} />
<StatCard label="Likes gesamt" value={stats?.likes ?? 0} />
</div>
<div className="rounded border p-3 text-sm">
<div className="mb-2 font-medium">Join-Link</div>
<div className="mb-2 flex items-center gap-2">
<input className="w-full rounded border p-2 text-sm" value={joinLink} readOnly />
<Button variant="secondary" onClick={() => navigator.clipboard.writeText(joinLink)}>
Kopieren
</Button>
</div>
<div className="mb-2 font-medium">QR</div>
<img src={qrUrl} alt="QR" width={200} height={200} className="rounded border" />
<div className="mt-3">
<Button variant="secondary" onClick={onInvite}>
Einladungslink erzeugen
</Button>
{invite && (
<div className="mt-2 text-xs text-muted-foreground">Erzeugt und kopiert: {invite}</div>
)}
</div>
</div>
) : (
<Alert variant="destructive">
<AlertTitle>Event nicht gefunden</AlertTitle>
<AlertDescription>Bitte pruefe den Slug und versuche es erneut.</AlertDescription>
</Alert>
)}
</AdminLayout>
);
}
function DetailSkeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="h-40 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
))}
</div>
);
}
function StatCard({ label, value }: { label: string; value: number }) {
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="rounded border p-3">
<div className="text-2xl font-semibold">{value}</div>
<div>{label}</div>
<div className="flex flex-col gap-1 rounded-xl border border-pink-100 bg-white/70 px-3 py-2">
<span className="text-xs uppercase tracking-wide text-slate-500">{label}</span>
<span className="text-sm font-medium text-slate-800">{value}</span>
</div>
);
}
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 (
<div className="rounded-2xl border border-sky-100 bg-white/80 px-4 py-3 text-center shadow-sm">
<div className="text-xs uppercase tracking-wide text-slate-500">{label}</div>
<div className="text-lg font-semibold text-slate-900">{value}</div>
</div>
);
}
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';
}

View File

@@ -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<EventFormState>({
name: '',
slug: '',
date: '',
isPublished: false,
});
const [autoSlug, setAutoSlug] = React.useState(true);
const [originalSlug, setOriginalSlug] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(isEdit);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(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<HTMLFormElement>) {
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 = (
<Button
variant="outline"
onClick={() => navigate('/admin/events')}
className="border-pink-200 text-pink-600 hover:bg-pink-50"
>
<ArrowLeft className="h-4 w-4" /> Zurueck zur Liste
</Button>
);
return (
<div className="mx-auto max-w-md space-y-3 p-4">
<h1 className="text-lg font-semibold">{isEdit ? 'Event bearbeiten' : 'Neues Event'}</h1>
{error && <div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">{error}</div>}
<Input placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
<Input placeholder="Slug" value={slug} onChange={(e) => setSlug(e.target.value)} />
<Input placeholder="Datum" type="date" value={date} onChange={(e) => setDate(e.target.value)} />
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} /> Aktiv
</label>
<div className="flex gap-2">
<Button onClick={save} disabled={saving || !name || !slug}>
{saving ? 'Speichern <20>' : 'Speichern'}
</Button>
<Button variant="secondary" onClick={() => nav(-1)}>Abbrechen</Button>
</div>
<AdminLayout
title={isEdit ? 'Event bearbeiten' : 'Neues Event erstellen'}
subtitle="Fuelle die wichtigsten Angaben aus und teile dein Event mit Gaesten."
actions={actions}
>
{error && (
<Alert variant="destructive">
<AlertTitle>Hinweis</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Card className="border-0 bg-white/85 shadow-xl shadow-fuchsia-100/60">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Sparkles className="h-5 w-5 text-pink-500" /> Eventdetails
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Name, URL und Datum bestimmen das Auftreten deines Events im Gaesteportal.
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<FormSkeleton />
) : (
<form className="space-y-6" onSubmit={handleSubmit}>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="event-name">Eventname</Label>
<Input
id="event-name"
placeholder="z. B. Sommerfest 2025"
value={form.name}
onChange={(e) => handleNameChange(e.target.value)}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="event-slug">Slug / URL-Endung</Label>
<Input
id="event-slug"
placeholder="sommerfest-2025"
value={form.slug}
onChange={(e) => handleSlugChange(e.target.value)}
/>
<p className="text-xs text-slate-500">Das Event ist spaeter unter /e/{form.slug || 'dein-event'} erreichbar.</p>
</div>
<div className="space-y-2">
<Label htmlFor="event-date">Datum</Label>
<Input
id="event-date"
type="date"
value={form.date}
onChange={(e) => setForm((prev) => ({ ...prev, date: e.target.value }))}
/>
</div>
</div>
<div className="flex items-start gap-3 rounded-xl bg-pink-50/60 p-4">
<Checkbox
id="event-published"
checked={form.isPublished}
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, isPublished: Boolean(checked) }))}
/>
<div>
<Label htmlFor="event-published" className="text-sm font-medium text-slate-800">
Event sofort veroeffentlichen
</Label>
<p className="text-xs text-slate-600">
Aktiviere diese Option, wenn Gaeste das Event direkt sehen sollen. Du kannst den Status spaeter aendern.
</p>
</div>
</div>
<div className="flex flex-wrap gap-3">
<Button
type="submit"
disabled={saving || !form.name.trim() || !form.slug.trim()}
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
>
{saving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" /> Speichert
</>
) : (
<>
<Save className="h-4 w-4" /> Speichern
</>
)}
</Button>
<Button variant="ghost" type="button" onClick={() => navigate(-1)}>
Abbrechen
</Button>
</div>
</form>
)}
</CardContent>
</Card>
</AdminLayout>
);
}
function FormSkeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="h-12 animate-pulse rounded-lg bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
))}
</div>
);
}
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, string>): string {
if (typeof name === 'string') {
return name;
}
return name.de ?? name.en ?? Object.values(name)[0] ?? '';
}

View File

@@ -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<any[]>([]);
const [searchParams] = useSearchParams();
const slug = searchParams.get('slug');
const navigate = useNavigate();
const [photos, setPhotos] = React.useState<TenantPhoto[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [busyId, setBusyId] = React.useState<number | null>(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 (
<AdminLayout title="Fotos moderieren" subtitle="Bitte waehle ein Event aus der Uebersicht." actions={null}>
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
<CardContent className="p-6 text-sm text-slate-600">
Kein Slug in der URL gefunden. Kehre zur Event-Liste zurueck und waehle dort ein Event aus.
<Button className="mt-4" onClick={() => navigate('/admin/events')}>
Zurueck zur Liste
</Button>
</CardContent>
</Card>
</AdminLayout>
);
}
const actions = (
<Button
variant="outline"
onClick={() => navigate(`/admin/events/view-slug=${encodeURIComponent(slug)}`)}
className="border-pink-200 text-pink-600 hover:bg-pink-50"
>
Zurueck zum Event
</Button>
);
return (
<div className="mx-auto max-w-5xl p-4">
<h1 className="mb-3 text-lg font-semibold">Fotos moderieren</h1>
{loading && <div>Lade ...</div>}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{rows.map((p) => (
<div key={p.id} className="rounded border p-2">
<img
src={p.thumbnail_path || p.file_path}
className="mb-2 aspect-square w-full rounded object-cover"
alt={p.caption ?? 'Foto'}
/>
<div className="flex items-center justify-between text-sm">
<span>?? {p.likes_count}</span>
<div className="flex gap-1">
{p.is_featured ? (
<Button size="sm" variant="secondary" onClick={() => onUnfeature(p)}>
Unfeature
</Button>
) : (
<Button size="sm" variant="secondary" onClick={() => onFeature(p)}>
Feature
</Button>
)}
<Button size="sm" variant="destructive" onClick={() => onDelete(p)}>
L<EFBFBD>schen
</Button>
</div>
<AdminLayout
title="Fotos moderieren"
subtitle="Setze Highlights oder entferne unpassende Uploads."
actions={actions}
>
{error && (
<Alert variant="destructive">
<AlertTitle>Aktion fehlgeschlagen</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Card className="border-0 bg-white/85 shadow-xl shadow-sky-100/60">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Camera className="h-5 w-5 text-sky-500" /> Galerie
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Klick auf ein Foto, um es hervorzuheben oder zu loeschen.
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<GallerySkeleton />
) : photos.length === 0 ? (
<EmptyGallery />
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
{photos.map((photo) => (
<div key={photo.id} className="rounded-2xl border border-white/80 bg-white/90 p-3 shadow-sm">
<div className="relative overflow-hidden rounded-xl">
<img src={photo.thumbnail_url || photo.url} alt={photo.original_name ?? 'Foto'} className="aspect-square w-full object-cover" />
{photo.is_featured && (
<span className="absolute left-3 top-3 rounded-full bg-pink-500/90 px-3 py-1 text-xs font-semibold text-white shadow">
Featured
</span>
)}
</div>
<div className="mt-3 flex flex-col gap-2 text-sm text-slate-700">
<div className="flex items-center justify-between text-xs text-slate-500">
<span>Likes: {photo.likes_count}</span>
<span>Uploader: {photo.uploader_name ?? 'Unbekannt'}</span>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
className="border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50"
onClick={() => handleToggleFeature(photo)}
disabled={busyId === photo.id}
>
{busyId === photo.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
{photo.is_featured ? 'Featured entfernen' : 'Als Highlight setzen'}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDelete(photo)}
disabled={busyId === photo.id}
>
{busyId === photo.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
Loeschen
</Button>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</AdminLayout>
);
}
function GallerySkeleton() {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="aspect-square animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40" />
))}
</div>
);
}
function EmptyGallery() {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-sky-200 bg-white/70 p-10 text-center">
<div className="rounded-full bg-sky-100 p-3 text-sky-600 shadow-inner shadow-sky-200/80">
<Camera className="h-5 w-5" />
</div>
<h3 className="text-lg font-semibold text-slate-900">Noch keine Fotos vorhanden</h3>
<p className="text-sm text-slate-600">Motiviere deine Gaeste zum Hochladen - hier erscheint anschliessend die Galerie.</p>
</div>
);
}

View File

@@ -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<any[]>([]);
const [rows, setRows] = React.useState<TenantEvent[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(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 = (
<>
<Button
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
onClick={() => navigate('/admin/events/new')}
>
<Plus className="h-4 w-4" /> Neues Event
</Button>
<Link to="/admin/settings">
<Button variant="outline" className="border-pink-200 text-pink-600 hover:bg-pink-50">
<Settings className="h-4 w-4" /> Einstellungen
</Button>
</Link>
</>
);
return (
<div className="mx-auto max-w-3xl p-4">
<div className="mb-3 flex items-center justify-between">
<h1 className="text-lg font-semibold">Meine Events</h1>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => nav('/admin/events/new')}>
Neues Event
</Button>
<Link to="/admin/settings">
<Button variant="secondary">Einstellungen</Button>
</Link>
</div>
</div>
{loading && <div>Lade ...</div>}
<AdminLayout
title="Deine Events"
subtitle="Plane Momente, die in Erinnerung bleiben. Hier verwaltest du alles rund um deine Veranstaltungen."
actions={actions}
>
{error && (
<div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">{error}</div>
<Alert variant="destructive">
<AlertTitle>Fehler beim Laden</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="divide-y rounded border">
{rows.map((event) => (
<div key={event.id} className="flex items-center justify-between p-3">
<div className="text-sm">
<div className="font-medium">{renderName(event.name)}</div>
<div className="text-muted-foreground">Slug: {event.slug} <EFBFBD> Datum: {event.date ?? '-'}</div>
</div>
<div className="flex items-center gap-2 text-sm underline">
<Link to={`/admin/events/view?id=${event.id}`}>details</Link>
<Link to={`/admin/events/edit?id=${event.id}`}>bearbeiten</Link>
<Link to={`/admin/events/photos?id=${event.id}`}>fotos</Link>
<a href={`/e/${event.slug}`} target="_blank" rel="noreferrer">
<EFBFBD>ffnen
</a>
</div>
<Card className="border-0 bg-white/80 shadow-xl shadow-pink-100/60">
<CardHeader className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="text-xl font-semibold text-slate-900">Uebersicht</CardTitle>
<CardDescription className="text-slate-600">
{rows.length === 0
? 'Noch keine Events - starte jetzt und lege dein erstes Event an.'
: `${rows.length} ${rows.length === 1 ? 'Event' : 'Events'} aktiv verwaltet.`}
</CardDescription>
</div>
))}
<div className="flex items-center gap-2 text-sm text-pink-600">
<Sparkles className="h-4 w-4" /> Tenant Dashboard
</div>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<LoadingState />
) : rows.length === 0 ? (
<EmptyState onCreate={() => navigate('/admin/events/new')} />
) : (
<div className="space-y-4">
{rows.map((event) => (
<EventCard key={event.id} event={event} />
))}
</div>
)}
</CardContent>
</Card>
</AdminLayout>
);
}
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 (
<div className="rounded-2xl border border-white/80 bg-white/90 p-5 shadow-md shadow-pink-200/40 transition-transform hover:-translate-y-0.5 hover:shadow-lg">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<h3 className="text-lg font-semibold text-slate-900">{renderName(event.name)}</h3>
<p className="text-sm text-slate-600">Slug: {event.slug}</p>
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-600">
<span className="inline-flex items-center gap-1 rounded-full bg-pink-100 px-3 py-1 font-medium text-pink-700">
<CalendarDays className="h-3.5 w-3.5" />
{formatDate(event.event_date)}
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-sky-100 px-3 py-1 font-medium text-sky-700">
Photos: {photoCount}
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-3 py-1 font-medium text-amber-700">
Likes: {likeCount}
</span>
</div>
</div>
<Badge
className={
isPublished
? 'bg-emerald-500/90 text-white shadow shadow-emerald-500/30'
: 'bg-slate-200 text-slate-700'
}
>
{isPublished ? 'Veroeffentlicht' : 'Entwurf'}
</Badge>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button asChild variant="outline" className="border-pink-200 text-pink-700 hover:bg-pink-50">
<Link to={`/admin/events/view?slug=${encodeURIComponent(slug)}`}>
Details <ArrowRight className="ml-1 h-3.5 w-3.5" />
</Link>
</Button>
<Button asChild variant="outline" className="border-fuchsia-200 text-fuchsia-700 hover:bg-fuchsia-50">
<Link to={`/admin/events/edit?slug=${encodeURIComponent(slug)}`}>Bearbeiten</Link>
</Button>
<Button asChild variant="outline" className="border-sky-200 text-sky-700 hover:bg-sky-50">
<Link to={`/admin/events/photos?slug=${encodeURIComponent(slug)}`}>Fotos moderieren</Link>
</Button>
<Button asChild variant="outline" className="border-amber-200 text-amber-600 hover:bg-amber-50">
<a href={`/e/${slug}`} target="_blank" rel="noreferrer">
Oeffnen im Gastportal
</a>
</Button>
</div>
</div>
);
}
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 (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="h-24 animate-pulse rounded-2xl bg-gradient-to-r from-white/40 via-white/60 to-white/40"
/>
))}
</div>
);
}
function EmptyState({ onCreate }: { onCreate: () => void }) {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-pink-200 bg-white/70 p-10 text-center">
<div className="rounded-full bg-pink-100 p-3 text-pink-600 shadow-inner shadow-pink-200/80">
<Plus className="h-5 w-5" />
</div>
<div className="space-y-1">
<h3 className="text-lg font-semibold text-slate-900">Noch kein Event angelegt</h3>
<p className="text-sm text-slate-600">
Starte jetzt mit deinem ersten Event und lade Gaeste in dein farbenfrohes Erlebnisportal ein.
</p>
</div>
<Button
onClick={onCreate}
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
>
<Plus className="mr-1 h-4 w-4" /> Event erstellen
</Button>
</div>
);
}
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';
}

View File

@@ -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 = (
<Button
variant="outline"
onClick={() => navigate('/admin/events')}
className="border-pink-200 text-pink-600 hover:bg-pink-50"
>
Zurueck zur Uebersicht
</Button>
);
return (
<div className="mx-auto max-w-sm space-y-4 p-6">
<div>
<h1 className="text-lg font-semibold">Einstellungen</h1>
{user && (
<p className="mt-1 text-sm text-muted-foreground">
Angemeldet als {user.name ?? user.email ?? 'Tenant Admin'} - Tenant #{user.tenant_id}
</p>
)}
</div>
<div>
<div className="text-sm font-medium">Darstellung</div>
<AppearanceToggleDropdown />
</div>
<div className="flex gap-2">
<Button variant="destructive" onClick={handleLogout}>Abmelden</Button>
<Button variant="secondary" onClick={() => nav(-1)}>Zurück</Button>
</div>
</div>
<AdminLayout
title="Einstellungen"
subtitle="Passe das Erscheinungsbild deines Dashboards an und verwalte deine Session."
actions={actions}
>
<Card className="max-w-2xl border-0 bg-white/85 shadow-xl shadow-amber-100/60">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl text-slate-900">
<Palette className="h-5 w-5 text-amber-500" /> Darstellung & Account
</CardTitle>
<CardDescription className="text-sm text-slate-600">
Gestalte den Admin-Bereich so farbenfroh wie dein Gaesteportal.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<section className="space-y-2">
<h2 className="text-sm font-semibold text-slate-800">Darstellung</h2>
<p className="text-sm text-slate-600">
Wechsel zwischen Hell- und Dunkelmodus oder uebernimm automatisch die Systemeinstellung.
</p>
<AppearanceToggleDropdown />
</section>
<section className="space-y-2">
<h2 className="text-sm font-semibold text-slate-800">Angemeldeter Account</h2>
<p className="text-sm text-slate-600">
{user ? (
<>
Eingeloggt als <span className="font-medium text-slate-900">{user.name ?? user.email ?? 'Tenant Admin'}</span>
{user.tenant_id && <> - Tenant #{user.tenant_id}</>}
</>
) : (
'Aktuell kein Benutzer geladen.'
)}
</p>
<div className="flex flex-wrap gap-3 pt-2">
<Button variant="destructive" onClick={handleLogout} className="flex items-center gap-2">
<LogOut className="h-4 w-4" /> Abmelden
</Button>
<Button variant="ghost" onClick={() => navigate(-1)}>
Abbrechen
</Button>
</div>
</section>
</CardContent>
</Card>
</AdminLayout>
);
}

View File

@@ -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');