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\Http\Resources\Tenant\EventResource;
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\Photo;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
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
|
public function bulkUpdateStatus(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class PhotoController extends Controller
|
|||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
$query = Photo::where('event_id', $event->id)
|
$query = Photo::where('event_id', $event->id)
|
||||||
->with(['likes', 'uploader'])
|
->with('event')->withCount('likes')
|
||||||
->orderBy('created_at', 'desc');
|
->orderBy('created_at', 'desc');
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
@@ -117,7 +117,7 @@ class PhotoController extends Controller
|
|||||||
list($width, $height) = getimagesize($file->getRealPath());
|
list($width, $height) = getimagesize($file->getRealPath());
|
||||||
$photo->update(['width' => $width, 'height' => $height]);
|
$photo->update(['width' => $width, 'height' => $height]);
|
||||||
|
|
||||||
$photo->load(['event', 'uploader']);
|
$photo->load('event')->loadCount('likes');
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Photo uploaded successfully. Awaiting moderation.',
|
'message' => 'Photo uploaded successfully. Awaiting moderation.',
|
||||||
@@ -140,7 +140,7 @@ class PhotoController extends Controller
|
|||||||
return response()->json(['error' => 'Photo not found'], 404);
|
return response()->json(['error' => 'Photo not found'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$photo->load(['event', 'uploader', 'likes']);
|
$photo->load('event')->loadCount('likes');
|
||||||
$photo->increment('view_count');
|
$photo->increment('view_count');
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -177,7 +177,7 @@ class PhotoController extends Controller
|
|||||||
$photo->update($validated);
|
$photo->update($validated);
|
||||||
|
|
||||||
if ($validated['status'] ?? null === 'approved') {
|
if ($validated['status'] ?? null === 'approved') {
|
||||||
$photo->load(['event', 'uploader']);
|
$photo->load('event')->loadCount('likes');
|
||||||
// Trigger event for new photo notification
|
// Trigger event for new photo notification
|
||||||
// event(new \App\Events\PhotoApproved($photo)); // Implement later
|
// event(new \App\Events\PhotoApproved($photo)); // Implement later
|
||||||
}
|
}
|
||||||
@@ -222,6 +222,40 @@ class PhotoController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Bulk approve photos (admin only)
|
* 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
|
public function bulkApprove(Request $request, string $eventSlug): JsonResponse
|
||||||
{
|
{
|
||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
@@ -249,7 +283,7 @@ class PhotoController extends Controller
|
|||||||
// Load approved photos for response
|
// Load approved photos for response
|
||||||
$photos = Photo::whereIn('id', $photoIds)
|
$photos = Photo::whereIn('id', $photoIds)
|
||||||
->where('event_id', $event->id)
|
->where('event_id', $event->id)
|
||||||
->with(['uploader', 'event'])
|
->with('event')->withCount('likes')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
// Trigger events
|
// Trigger events
|
||||||
@@ -321,7 +355,7 @@ class PhotoController extends Controller
|
|||||||
|
|
||||||
$photos = Photo::where('event_id', $event->id)
|
$photos = Photo::where('event_id', $event->id)
|
||||||
->where('status', 'pending')
|
->where('status', 'pending')
|
||||||
->with(['uploader', 'event'])
|
->with('event')->withCount('likes')
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->paginate($request->get('per_page', 20));
|
->paginate($request->get('per_page', 20));
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class EventResource extends JsonResource
|
|||||||
'custom_domain' => $showSensitive ? ($settings['custom_domain'] ?? null) : null,
|
'custom_domain' => $showSensitive ? ($settings['custom_domain'] ?? null) : null,
|
||||||
'theme_color' => $settings['theme_color'] ?? null,
|
'theme_color' => $settings['theme_color'] ?? null,
|
||||||
'status' => $this->status ?? 'draft',
|
'status' => $this->status ?? 'draft',
|
||||||
|
'is_active' => (bool) ($this->is_active ?? false),
|
||||||
'features' => $settings['features'] ?? [],
|
'features' => $settings['features'] ?? [],
|
||||||
'event_type_id' => $this->event_type_id,
|
'event_type_id' => $this->event_type_id,
|
||||||
'created_at' => $this->created_at?->toISOString(),
|
'created_at' => $this->created_at?->toISOString(),
|
||||||
|
|||||||
@@ -22,16 +22,18 @@ class PhotoResource extends JsonResource
|
|||||||
'filename' => $this->filename,
|
'filename' => $this->filename,
|
||||||
'original_name' => $this->original_name,
|
'original_name' => $this->original_name,
|
||||||
'mime_type' => $this->mime_type,
|
'mime_type' => $this->mime_type,
|
||||||
'size' => $this->size,
|
'size' => (int) ($this->size ?? 0),
|
||||||
'url' => $showSensitive ? $this->getFullUrl() : $this->getThumbnailUrl(),
|
'url' => $showSensitive ? $this->getFullUrl() : $this->getThumbnailUrl(),
|
||||||
'thumbnail_url' => $this->getThumbnailUrl(),
|
'thumbnail_url' => $this->getThumbnailUrl(),
|
||||||
'width' => $this->width,
|
'width' => $this->width,
|
||||||
'height' => $this->height,
|
'height' => $this->height,
|
||||||
|
'is_featured' => (bool) ($this->is_featured ?? false),
|
||||||
'status' => $showSensitive ? $this->status : 'approved',
|
'status' => $showSensitive ? $this->status : 'approved',
|
||||||
'moderation_notes' => $showSensitive ? $this->moderation_notes : null,
|
'moderation_notes' => $showSensitive ? $this->moderation_notes : null,
|
||||||
'likes_count' => $this->likes_count,
|
'likes_count' => (int) ($this->likes_count ?? $this->likes()->count()),
|
||||||
'is_liked' => $showSensitive ? $this->isLikedByTenant($tenantId) : false,
|
'is_liked' => false,
|
||||||
'uploaded_at' => $this->created_at->toISOString(),
|
'uploaded_at' => $this->created_at->toISOString(),
|
||||||
|
'uploader_name' => $this->guest_name ?? null,
|
||||||
'event' => [
|
'event' => [
|
||||||
'id' => $this->event->id,
|
'id' => $this->event->id,
|
||||||
'name' => $this->event->name,
|
'name' => $this->event->name,
|
||||||
|
|||||||
@@ -95,6 +95,41 @@
|
|||||||
--sidebar-ring: oklch(0.87 0 0);
|
--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 {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.145 0 0);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
|
|||||||
@@ -2,12 +2,62 @@ import { authorizedFetch } from './auth/tokens';
|
|||||||
|
|
||||||
type JsonValue = Record<string, any>;
|
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> {
|
async function jsonOrThrow<T>(response: Response, message: string): Promise<T> {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await safeJson(response);
|
const body = await safeJson(response);
|
||||||
console.error('[API]', message, response.status, body);
|
console.error('[API]', message, response.status, body);
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (await response.json()) as T;
|
return (await response.json()) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,85 +69,112 @@ async function safeJson(response: Response): Promise<JsonValue | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEvents(): Promise<any[]> {
|
function normalizeEvent(event: TenantEvent): TenantEvent {
|
||||||
const response = await authorizedFetch('/api/v1/tenant/events');
|
return {
|
||||||
const data = await jsonOrThrow<{ data?: any[] }>(response, 'Failed to load events');
|
...event,
|
||||||
return data.data ?? [];
|
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', {
|
const response = await authorizedFetch('/api/v1/tenant/events', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
const data = await jsonOrThrow<{ id: number }>(response, 'Failed to create event');
|
const data = await jsonOrThrow<CreatedEventResponse>(response, 'Failed to create event');
|
||||||
return data.id;
|
return { event: normalizeEvent(data.data), balance: data.balance };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateEvent(
|
export async function updateEvent(slug: string, payload: Partial<EventSavePayload>): Promise<TenantEvent> {
|
||||||
id: number,
|
const response = await authorizedFetch(eventEndpoint(slug), {
|
||||||
payload: Partial<{ name: string; slug: string; date?: string; is_active?: boolean }>
|
|
||||||
): Promise<void> {
|
|
||||||
const response = await authorizedFetch(`/api/v1/tenant/events/${id}`, {
|
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
const data = await jsonOrThrow<EventResponse>(response, 'Failed to update event');
|
||||||
await safeJson(response);
|
return normalizeEvent(data.data);
|
||||||
throw new Error('Failed to update event');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEventPhotos(id: number): Promise<any[]> {
|
export async function getEvent(slug: string): Promise<TenantEvent> {
|
||||||
const response = await authorizedFetch(`/api/v1/tenant/events/${id}/photos`);
|
const response = await authorizedFetch(eventEndpoint(slug));
|
||||||
const data = await jsonOrThrow<{ data?: any[] }>(response, 'Failed to load photos');
|
const data = await jsonOrThrow<EventResponse>(response, 'Failed to load event');
|
||||||
return data.data ?? [];
|
return normalizeEvent(data.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function featurePhoto(id: number): Promise<void> {
|
export async function getEventPhotos(slug: string): Promise<TenantPhoto[]> {
|
||||||
const response = await authorizedFetch(`/api/v1/tenant/photos/${id}/feature`, { method: 'POST' });
|
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos`);
|
||||||
if (!response.ok) {
|
const data = await jsonOrThrow<{ data?: TenantPhoto[] }>(response, 'Failed to load photos');
|
||||||
await safeJson(response);
|
return (data.data ?? []).map(normalizePhoto);
|
||||||
throw new Error('Failed to feature photo');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unfeaturePhoto(id: number): Promise<void> {
|
export async function featurePhoto(slug: string, id: number): Promise<TenantPhoto> {
|
||||||
const response = await authorizedFetch(`/api/v1/tenant/photos/${id}/unfeature`, { method: 'POST' });
|
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}/feature`, { method: 'POST' });
|
||||||
if (!response.ok) {
|
const data = await jsonOrThrow<PhotoResponse>(response, 'Failed to feature photo');
|
||||||
await safeJson(response);
|
return normalizePhoto(data.data);
|
||||||
throw new Error('Failed to unfeature photo');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEvent(id: number): Promise<any> {
|
export async function unfeaturePhoto(slug: string, id: number): Promise<TenantPhoto> {
|
||||||
const response = await authorizedFetch(`/api/v1/tenant/events/${id}`);
|
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}/unfeature`, { method: 'POST' });
|
||||||
return jsonOrThrow<any>(response, 'Failed to load event');
|
const data = await jsonOrThrow<PhotoResponse>(response, 'Failed to unfeature photo');
|
||||||
|
return normalizePhoto(data.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleEvent(id: number): Promise<boolean> {
|
export async function deletePhoto(slug: string, id: number): Promise<void> {
|
||||||
const response = await authorizedFetch(`/api/v1/tenant/events/${id}/toggle`, { method: 'POST' });
|
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}`, { method: 'DELETE' });
|
||||||
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' });
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
await safeJson(response);
|
await safeJson(response);
|
||||||
throw new Error('Failed to delete photo');
|
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 React from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
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';
|
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() {
|
export default function EventDetailPage() {
|
||||||
const [sp] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const id = Number(sp.get('id'));
|
const slug = searchParams.get('slug');
|
||||||
const nav = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [ev, setEv] = React.useState<any | null>(null);
|
|
||||||
const [stats, setStats] = React.useState<{ total: number; featured: number; likes: number } | null>(null);
|
const [state, setState] = React.useState<State>({
|
||||||
const [invite, setInvite] = React.useState<string | null>(null);
|
event: null,
|
||||||
|
stats: null,
|
||||||
|
inviteLink: null,
|
||||||
|
error: null,
|
||||||
|
loading: true,
|
||||||
|
busy: false,
|
||||||
|
});
|
||||||
|
|
||||||
const load = React.useCallback(async () => {
|
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 {
|
try {
|
||||||
const event = await getEvent(id);
|
const [eventData, statsData] = await Promise.all([getEvent(slug), getEventStats(slug)]);
|
||||||
setEv(event);
|
setState((prev) => ({
|
||||||
setStats(await getEventStats(id));
|
...prev,
|
||||||
} catch (error) {
|
event: eventData,
|
||||||
if (!isAuthError(error)) {
|
stats: statsData,
|
||||||
console.error(error);
|
loading: false,
|
||||||
|
inviteLink: prev.inviteLink,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
if (isAuthError(err)) return;
|
||||||
|
setState((prev) => ({ ...prev, error: 'Event konnte nicht geladen werden.', loading: false }));
|
||||||
}
|
}
|
||||||
}
|
}, [slug]);
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
async function onToggle() {
|
async function handleToggle() {
|
||||||
|
if (!slug) return;
|
||||||
|
setState((prev) => ({ ...prev, busy: true, error: null }));
|
||||||
try {
|
try {
|
||||||
const isActive = await toggleEvent(id);
|
const updated = await toggleEvent(slug);
|
||||||
setEv((previous: any) => ({ ...(previous || {}), is_active: isActive }));
|
setState((prev) => ({
|
||||||
} catch (error) {
|
...prev,
|
||||||
if (!isAuthError(error)) {
|
event: updated,
|
||||||
console.error(error);
|
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 {
|
try {
|
||||||
const link = await createInviteLink(id);
|
const { link } = await createInviteLink(slug);
|
||||||
setInvite(link);
|
setState((prev) => ({ ...prev, inviteLink: link, busy: false }));
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(link);
|
await navigator.clipboard.writeText(link);
|
||||||
} catch {
|
} catch {
|
||||||
// clipboard may be unavailable
|
// clipboard may be unavailable, ignore silently
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
if (!isAuthError(error)) {
|
if (!isAuthError(err)) {
|
||||||
console.error(error);
|
setState((prev) => ({ ...prev, error: 'Einladungslink konnte nicht erzeugt werden.', busy: false }));
|
||||||
|
} else {
|
||||||
|
setState((prev) => ({ ...prev, busy: false }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ev) {
|
const { event, stats, inviteLink, error, loading, busy } = state;
|
||||||
return <div className="p-4">Lade ...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const joinLink = `${window.location.origin}/e/${ev.slug}`;
|
const actions = (
|
||||||
const qrUrl = `/admin/qr?data=${encodeURIComponent(joinLink)}`;
|
<>
|
||||||
|
<Button
|
||||||
return (
|
variant="outline"
|
||||||
<div className="mx-auto max-w-3xl space-y-4 p-4">
|
onClick={() => navigate('/admin/events')}
|
||||||
<div className="flex items-center justify-between">
|
className="border-pink-200 text-pink-600 hover:bg-pink-50"
|
||||||
<h1 className="text-lg font-semibold">Event: {renderName(ev.name)}</h1>
|
>
|
||||||
<div className="flex gap-2">
|
<ArrowLeft className="h-4 w-4" /> Zurueck zur Liste
|
||||||
<Button variant="secondary" onClick={onToggle}>
|
|
||||||
{ev.is_active ? 'Deaktivieren' : 'Aktivieren'}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" onClick={() => nav(`/admin/events/photos?id=${id}`)}>
|
{event && (
|
||||||
Fotos moderieren
|
<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>
|
</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 (
|
return (
|
||||||
<div className="rounded border p-3">
|
<AdminLayout title="Event nicht gefunden" subtitle="Bitte waehle ein Event aus der Uebersicht." actions={actions}>
|
||||||
<div className="text-2xl font-semibold">{value}</div>
|
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
||||||
<div>{label}</div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderName(name: any): string {
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||||
if (typeof name === 'string') return name;
|
return (
|
||||||
if (name && (name.de || name.en)) return name.de || name.en;
|
<div className="flex flex-col gap-1 rounded-xl border border-pink-100 bg-white/70 px-3 py-2">
|
||||||
return JSON.stringify(name);
|
<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 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 { 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() {
|
export default function EventFormPage() {
|
||||||
const [sp] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const id = sp.get('id');
|
const slugParam = searchParams.get('slug');
|
||||||
const nav = useNavigate();
|
const isEdit = Boolean(slugParam);
|
||||||
const [name, setName] = React.useState('');
|
const navigate = useNavigate();
|
||||||
const [slug, setSlug] = React.useState('');
|
|
||||||
const [date, setDate] = React.useState('');
|
const [form, setForm] = React.useState<EventFormState>({
|
||||||
const [active, setActive] = React.useState(true);
|
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 [saving, setSaving] = React.useState(false);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
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);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: trimmedName,
|
||||||
|
slug: trimmedSlug,
|
||||||
|
date: form.date || undefined,
|
||||||
|
status: form.isPublished ? 'published' : 'draft',
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isEdit) {
|
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 {
|
} 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 (err) {
|
||||||
} catch (e) {
|
if (!isAuthError(err)) {
|
||||||
if (!isAuthError(e)) {
|
setError('Speichern fehlgeschlagen. Bitte pruefe deine Eingaben.');
|
||||||
setError('Speichern fehlgeschlagen');
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const actions = (
|
||||||
<div className="mx-auto max-w-md space-y-3 p-4">
|
<Button
|
||||||
<h1 className="text-lg font-semibold">{isEdit ? 'Event bearbeiten' : 'Neues Event'}</h1>
|
variant="outline"
|
||||||
{error && <div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">{error}</div>}
|
onClick={() => navigate('/admin/events')}
|
||||||
<Input placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
|
className="border-pink-200 text-pink-600 hover:bg-pink-50"
|
||||||
<Input placeholder="Slug" value={slug} onChange={(e) => setSlug(e.target.value)} />
|
>
|
||||||
<Input placeholder="Datum" type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
<ArrowLeft className="h-4 w-4" /> Zurueck zur Liste
|
||||||
<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>
|
||||||
<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>
|
||||||
|
<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>
|
</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 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 { 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';
|
import { isAuthError } from '../auth/tokens';
|
||||||
|
|
||||||
export default function EventPhotosPage() {
|
export default function EventPhotosPage() {
|
||||||
const [sp] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const id = Number(sp.get('id'));
|
const slug = searchParams.get('slug');
|
||||||
const [rows, setRows] = React.useState<any[]>([]);
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [photos, setPhotos] = React.useState<TenantPhoto[]>([]);
|
||||||
const [loading, setLoading] = React.useState(true);
|
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 () => {
|
const load = React.useCallback(async () => {
|
||||||
|
if (!slug) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
setRows(await getEventPhotos(id));
|
const data = await getEventPhotos(slug);
|
||||||
} catch (error) {
|
setPhotos(data);
|
||||||
if (!isAuthError(error)) {
|
} catch (err) {
|
||||||
console.error(error);
|
if (!isAuthError(err)) {
|
||||||
|
setError('Fotos konnten nicht geladen werden.');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [id]);
|
}, [slug]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
async function onFeature(photo: any) {
|
async function handleToggleFeature(photo: TenantPhoto) {
|
||||||
|
if (!slug) return;
|
||||||
|
setBusyId(photo.id);
|
||||||
try {
|
try {
|
||||||
await featurePhoto(photo.id);
|
const updated = photo.is_featured
|
||||||
await load();
|
? await unfeaturePhoto(slug, photo.id)
|
||||||
} catch (error) {
|
: await featurePhoto(slug, photo.id);
|
||||||
if (!isAuthError(error)) {
|
setPhotos((prev) => prev.map((entry) => (entry.id === photo.id ? updated : entry)));
|
||||||
console.error(error);
|
} 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 {
|
try {
|
||||||
await unfeaturePhoto(photo.id);
|
await deletePhoto(slug, photo.id);
|
||||||
await load();
|
setPhotos((prev) => prev.filter((entry) => entry.id !== photo.id));
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
if (!isAuthError(error)) {
|
if (!isAuthError(err)) {
|
||||||
console.error(error);
|
setError('Foto konnte nicht entfernt werden.');
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onDelete(photo: any) {
|
if (!slug) {
|
||||||
try {
|
return (
|
||||||
await deletePhoto(photo.id);
|
<AdminLayout title="Fotos moderieren" subtitle="Bitte waehle ein Event aus der Uebersicht." actions={null}>
|
||||||
await load();
|
<Card className="border-0 bg-white/85 shadow-xl shadow-pink-100/60">
|
||||||
} catch (error) {
|
<CardContent className="p-6 text-sm text-slate-600">
|
||||||
if (!isAuthError(error)) {
|
Kein Slug in der URL gefunden. Kehre zur Event-Liste zurueck und waehle dort ein Event aus.
|
||||||
console.error(error);
|
<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 (
|
return (
|
||||||
<div className="mx-auto max-w-5xl p-4">
|
<AdminLayout
|
||||||
<h1 className="mb-3 text-lg font-semibold">Fotos moderieren</h1>
|
title="Fotos moderieren"
|
||||||
{loading && <div>Lade ...</div>}
|
subtitle="Setze Highlights oder entferne unpassende Uploads."
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
actions={actions}
|
||||||
{rows.map((p) => (
|
>
|
||||||
<div key={p.id} className="rounded border p-2">
|
{error && (
|
||||||
<img
|
<Alert variant="destructive">
|
||||||
src={p.thumbnail_path || p.file_path}
|
<AlertTitle>Aktion fehlgeschlagen</AlertTitle>
|
||||||
className="mb-2 aspect-square w-full rounded object-cover"
|
<AlertDescription>{error}</AlertDescription>
|
||||||
alt={p.caption ?? 'Foto'}
|
</Alert>
|
||||||
/>
|
|
||||||
<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
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
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';
|
import { isAuthError } from '../auth/tokens';
|
||||||
|
|
||||||
export default function EventsPage() {
|
export default function EventsPage() {
|
||||||
const [rows, setRows] = React.useState<any[]>([]);
|
const [rows, setRows] = React.useState<TenantEvent[]>([]);
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const nav = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -16,7 +23,7 @@ export default function EventsPage() {
|
|||||||
setRows(await getEvents());
|
setRows(await getEvents());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isAuthError(err)) {
|
if (!isAuthError(err)) {
|
||||||
setError('Laden fehlgeschlagen');
|
setError('Laden fehlgeschlagen. Bitte spaeter erneut versuchen.');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -24,47 +31,177 @@ export default function EventsPage() {
|
|||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
const actions = (
|
||||||
<div className="mx-auto max-w-3xl p-4">
|
<>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<Button
|
||||||
<h1 className="text-lg font-semibold">Meine Events</h1>
|
className="bg-gradient-to-r from-pink-500 via-fuchsia-500 to-purple-500 text-white shadow-lg shadow-pink-500/20"
|
||||||
<div className="flex gap-2">
|
onClick={() => navigate('/admin/events/new')}
|
||||||
<Button variant="secondary" onClick={() => nav('/admin/events/new')}>
|
>
|
||||||
Neues Event
|
<Plus className="h-4 w-4" /> Neues Event
|
||||||
</Button>
|
</Button>
|
||||||
<Link to="/admin/settings">
|
<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>
|
</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 && (
|
{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) => (
|
{rows.map((event) => (
|
||||||
<div key={event.id} className="flex items-center justify-between p-3">
|
<EventCard key={event.id} event={event} />
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderName(name: any): string {
|
function LoadingState() {
|
||||||
if (typeof name === 'string') return name;
|
return (
|
||||||
if (name && (name.de || name.en)) return name.de || name.en;
|
<div className="space-y-3">
|
||||||
return JSON.stringify(name);
|
{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 React from 'react';
|
||||||
|
import { LogOut, Palette } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||||||
import { Button } from '@/components/ui/button';
|
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';
|
import { useAuth } from '../auth/context';
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const nav = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
logout({ redirect: '/admin/login' });
|
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 (
|
return (
|
||||||
<div className="mx-auto max-w-sm space-y-4 p-6">
|
<AdminLayout
|
||||||
<div>
|
title="Einstellungen"
|
||||||
<h1 className="text-lg font-semibold">Einstellungen</h1>
|
subtitle="Passe das Erscheinungsbild deines Dashboards an und verwalte deine Session."
|
||||||
{user && (
|
actions={actions}
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
>
|
||||||
Angemeldet als {user.name ?? user.email ?? 'Tenant Admin'} - Tenant #{user.tenant_id}
|
<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>
|
</p>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium">Darstellung</div>
|
|
||||||
<AppearanceToggleDropdown />
|
<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>
|
||||||
<div className="flex gap-2">
|
</section>
|
||||||
<Button variant="destructive" onClick={handleLogout}>Abmelden</Button>
|
</CardContent>
|
||||||
<Button variant="secondary" onClick={() => nav(-1)}>Zurück</Button>
|
</Card>
|
||||||
</div>
|
</AdminLayout>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use App\Http\Controllers\Api\EventPublicController;
|
|||||||
use App\Http\Controllers\Api\Tenant\EventController;
|
use App\Http\Controllers\Api\Tenant\EventController;
|
||||||
use App\Http\Controllers\Api\Tenant\SettingsController;
|
use App\Http\Controllers\Api\Tenant\SettingsController;
|
||||||
use App\Http\Controllers\Api\Tenant\TaskController;
|
use App\Http\Controllers\Api\Tenant\TaskController;
|
||||||
|
use App\Http\Controllers\Api\Tenant\PhotoController;
|
||||||
use App\Http\Controllers\OAuthController;
|
use App\Http\Controllers\OAuthController;
|
||||||
use App\Http\Controllers\RevenueCatWebhookController;
|
use App\Http\Controllers\RevenueCatWebhookController;
|
||||||
use App\Http\Controllers\Tenant\CreditController;
|
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::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::post('events/bulk-status', [EventController::class, 'bulkUpdateStatus'])->name('tenant.events.bulk-status');
|
||||||
Route::get('events/search', [EventController::class, 'search'])->name('tenant.events.search');
|
Route::get('events/search', [EventController::class, 'search'])->name('tenant.events.search');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user