feat(tenant-admin): refresh event management experience
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
60
resources/js/admin/components/AdminLayout.tsx
Normal file
60
resources/js/admin/components/AdminLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 () => {
|
||||
if (!slug) {
|
||||
setState((prev) => ({ ...prev, loading: false, error: 'Kein Event-Slug angegeben.' }));
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, loading: true, error: null }));
|
||||
try {
|
||||
const event = await getEvent(id);
|
||||
setEv(event);
|
||||
setStats(await getEventStats(id));
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
console.error(error);
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
}, [id]);
|
||||
}, [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 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'}
|
||||
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>
|
||||
<Button variant="secondary" onClick={() => nav(`/admin/events/photos?id=${id}`)}>
|
||||
Fotos moderieren
|
||||
{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>
|
||||
</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>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: number }) {
|
||||
if (!slug) {
|
||||
return (
|
||||
<div className="rounded border p-3">
|
||||
<div className="text-2xl font-semibold">{value}</div>
|
||||
<div>{label}</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
) : (
|
||||
<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 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 InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<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 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';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
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'}
|
||||
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>
|
||||
<Button variant="secondary" onClick={() => nav(-1)}>Abbrechen</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<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] ?? '';
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
)}
|
||||
<Button size="sm" variant="destructive" onClick={() => onDelete(p)}>
|
||||
L<EFBFBD>schen
|
||||
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
})();
|
||||
}, []);
|
||||
|
||||
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
|
||||
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="secondary">Einstellungen</Button>
|
||||
<Button variant="outline" className="border-pink-200 text-pink-600 hover:bg-pink-50">
|
||||
<Settings className="h-4 w-4" /> Einstellungen
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{loading && <div>Lade ...</div>}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<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">
|
||||
|
||||
<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) => (
|
||||
<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>
|
||||
</div>
|
||||
<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';
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium">Darstellung</div>
|
||||
<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>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="destructive" onClick={handleLogout}>Abmelden</Button>
|
||||
<Button variant="secondary" onClick={() => nav(-1)}>Zurück</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user