rework of the event admin UI
This commit is contained in:
@@ -1,23 +1,39 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { Camera, Loader2, Sparkles, Trash2, AlertTriangle, ShoppingCart } from 'lucide-react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Camera,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Filter,
|
||||
Loader2,
|
||||
Search,
|
||||
ShoppingCart,
|
||||
Star,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import toast from 'react-hot-toast';
|
||||
import { AddonsPicker } from '../components/Addons/AddonsPicker';
|
||||
import { AddonSummaryList } from '../components/Addons/AddonSummaryList';
|
||||
import { getAddonCatalog, getEvent, type EventAddonCatalogItem, type EventAddonSummary } from '../api';
|
||||
import { getAddonCatalog, type EventAddonCatalogItem, type EventAddonSummary } from '../api';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { createEventAddonCheckout, deletePhoto, featurePhoto, getEventPhotos, TenantPhoto, unfeaturePhoto } from '../api';
|
||||
import { createEventAddonCheckout, deletePhoto, featurePhoto, getEvent, getEventPhotos, TenantEvent, TenantPhoto, unfeaturePhoto } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { buildLimitWarnings, type EventLimitSummary } from '../lib/limitWarnings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ADMIN_EVENTS_PATH, ADMIN_EVENT_VIEW_PATH, ADMIN_EVENT_PHOTOBOOTH_PATH } from '../constants';
|
||||
import { buildEventTabs } from '../lib/eventTabs';
|
||||
|
||||
export default function EventPhotosPage() {
|
||||
const params = useParams<{ slug?: string }>();
|
||||
@@ -38,11 +54,28 @@ export default function EventPhotosPage() {
|
||||
const [limits, setLimits] = React.useState<EventLimitSummary | null>(null);
|
||||
const [addons, setAddons] = React.useState<EventAddonCatalogItem[]>([]);
|
||||
const [eventAddons, setEventAddons] = React.useState<EventAddonSummary[]>([]);
|
||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [statusFilter, setStatusFilter] = React.useState<'all' | 'featured' | 'hidden' | 'photobooth'>('all');
|
||||
const [selectedIds, setSelectedIds] = React.useState<number[]>([]);
|
||||
const [bulkBusy, setBulkBusy] = React.useState(false);
|
||||
const photoboothUploads = React.useMemo(
|
||||
() => photos.filter((photo) => photo.ingest_source === 'photobooth').length,
|
||||
[photos],
|
||||
);
|
||||
|
||||
const eventTabs = React.useMemo(() => {
|
||||
if (!event || !slug) {
|
||||
return [];
|
||||
}
|
||||
const translateMenu = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
return buildEventTabs(event, translateMenu, {
|
||||
photos: photos.length,
|
||||
tasks: event.tasks_count ?? 0,
|
||||
invites: event.active_invites_count ?? event.total_invites_count ?? 0,
|
||||
});
|
||||
}, [event, photos.length, slug, t]);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!slug) {
|
||||
setLoading(false);
|
||||
@@ -59,6 +92,7 @@ export default function EventPhotosPage() {
|
||||
setPhotos(photoResult.photos);
|
||||
setLimits(photoResult.limits ?? null);
|
||||
setEventAddons(eventData.addons ?? []);
|
||||
setEvent(eventData);
|
||||
setAddons(catalog);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
@@ -117,6 +151,91 @@ export default function EventPhotosPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleVisibility(photo: TenantPhoto, visible: boolean) {
|
||||
// No dedicated visibility endpoint available; emulate by filtering locally.
|
||||
setPhotos((prev) =>
|
||||
prev.map((entry) =>
|
||||
entry.id === photo.id ? { ...entry, status: visible ? 'visible' : 'hidden' } : entry,
|
||||
),
|
||||
);
|
||||
setSelectedIds((prev) => prev.filter((id) => id !== photo.id));
|
||||
}
|
||||
|
||||
const filteredPhotos = React.useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
return photos.filter((photo) => {
|
||||
const matchesSearch =
|
||||
term.length === 0 ||
|
||||
(photo.original_name ?? '').toLowerCase().includes(term) ||
|
||||
(photo.filename ?? '').toLowerCase().includes(term);
|
||||
if (!matchesSearch) {
|
||||
return false;
|
||||
}
|
||||
if (statusFilter === 'featured') {
|
||||
return Boolean(photo.is_featured);
|
||||
}
|
||||
if (statusFilter === 'hidden') {
|
||||
return photo.status === 'hidden';
|
||||
}
|
||||
if (statusFilter === 'photobooth') {
|
||||
return photo.ingest_source === 'photobooth';
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [photos, search, statusFilter]);
|
||||
|
||||
const toggleSelect = React.useCallback((photoId: number) => {
|
||||
setSelectedIds((prev) => (prev.includes(photoId) ? prev.filter((id) => id !== photoId) : [...prev, photoId]));
|
||||
}, []);
|
||||
|
||||
const selectAllVisible = React.useCallback(() => {
|
||||
setSelectedIds(filteredPhotos.map((photo) => photo.id));
|
||||
}, [filteredPhotos]);
|
||||
|
||||
const clearSelection = React.useCallback(() => {
|
||||
setSelectedIds([]);
|
||||
}, []);
|
||||
|
||||
const selectedPhotos = React.useMemo(
|
||||
() => photos.filter((photo) => selectedIds.includes(photo.id)),
|
||||
[photos, selectedIds],
|
||||
);
|
||||
|
||||
const handleBulkVisibility = React.useCallback(
|
||||
async (visible: boolean) => {
|
||||
if (!selectedPhotos.length) return;
|
||||
setBulkBusy(true);
|
||||
await Promise.all(selectedPhotos.map((photo) => handleToggleVisibility(photo, visible)));
|
||||
setBulkBusy(false);
|
||||
},
|
||||
[selectedPhotos],
|
||||
);
|
||||
|
||||
const handleBulkFeature = React.useCallback(
|
||||
async (featured: boolean) => {
|
||||
if (!slug || !selectedPhotos.length) return;
|
||||
setBulkBusy(true);
|
||||
for (const photo of selectedPhotos) {
|
||||
setBusyId(photo.id);
|
||||
try {
|
||||
const updated = featured
|
||||
? await featurePhoto(slug, photo.id)
|
||||
: await unfeaturePhoto(slug, photo.id);
|
||||
setPhotos((prev) => prev.map((entry) => (entry.id === photo.id ? updated : entry)));
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, 'Feature-Aktion fehlgeschlagen.'));
|
||||
}
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
setSelectedIds([]);
|
||||
setBulkBusy(false);
|
||||
},
|
||||
[selectedPhotos, slug],
|
||||
);
|
||||
|
||||
if (!slug) {
|
||||
return (
|
||||
<AdminLayout title="Fotos moderieren" subtitle="Bitte wähle ein Event aus der Übersicht." actions={null}>
|
||||
@@ -147,6 +266,8 @@ export default function EventPhotosPage() {
|
||||
title={t('photos.moderation.title', 'Fotos moderieren')}
|
||||
subtitle={t('photos.moderation.subtitle', 'Setze Highlights oder entferne unpassende Uploads.')}
|
||||
actions={actions}
|
||||
tabs={eventTabs}
|
||||
currentTabKey="photos"
|
||||
>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
@@ -195,55 +316,38 @@ export default function EventPhotosPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<GalleryToolbar
|
||||
search={search}
|
||||
onSearch={setSearch}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
totalCount={filteredPhotos.length}
|
||||
selectionCount={selectedIds.length}
|
||||
onSelectAll={selectAllVisible}
|
||||
onClearSelection={clearSelection}
|
||||
onBulkHide={() => { void handleBulkVisibility(false); }}
|
||||
onBulkShow={() => { void handleBulkVisibility(true); }}
|
||||
onBulkFeature={() => { void handleBulkFeature(true); }}
|
||||
onBulkUnfeature={() => { void handleBulkFeature(false); }}
|
||||
busy={bulkBusy}
|
||||
/>
|
||||
{loading ? (
|
||||
<GallerySkeleton />
|
||||
) : photos.length === 0 ? (
|
||||
) : filteredPhotos.length === 0 ? (
|
||||
<EmptyGallery
|
||||
title={t('photos.gallery.emptyTitle', 'Noch keine Fotos vorhanden')}
|
||||
description={t('photos.gallery.emptyDescription', 'Motiviere deine Gäste zum Hochladen - hier erscheint anschließend die Galerie.')}
|
||||
/>
|
||||
) : (
|
||||
<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 ?? undefined} 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" />}
|
||||
Löschen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<PhotoGrid
|
||||
photos={filteredPhotos}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelect={toggleSelect}
|
||||
onToggleFeature={(photo) => { void handleToggleFeature(photo); }}
|
||||
onToggleVisibility={(photo, visible) => { void handleToggleVisibility(photo, visible); }}
|
||||
onDelete={(photo) => { void handleDelete(photo); }}
|
||||
busyId={busyId}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -251,6 +355,36 @@ export default function EventPhotosPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const LIMIT_WARNING_DISMISS_KEY = 'tenant-admin:dismissed-limit-warnings';
|
||||
|
||||
function readDismissedLimitWarnings(): Set<string> {
|
||||
if (typeof window === 'undefined') {
|
||||
return new Set();
|
||||
}
|
||||
try {
|
||||
const raw = window.localStorage.getItem(LIMIT_WARNING_DISMISS_KEY);
|
||||
if (!raw) {
|
||||
return new Set();
|
||||
}
|
||||
const parsed = JSON.parse(raw) as string[];
|
||||
return new Set(parsed);
|
||||
} catch (error) {
|
||||
console.warn('[LimitWarnings] Failed to parse dismissed warnings', error);
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function persistDismissedLimitWarnings(ids: Set<string>) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(LIMIT_WARNING_DISMISS_KEY, JSON.stringify(Array.from(ids)));
|
||||
} catch (error) {
|
||||
console.warn('[LimitWarnings] Failed to persist dismissed warnings', error);
|
||||
}
|
||||
}
|
||||
|
||||
function LimitWarningsBanner({
|
||||
limits,
|
||||
translate,
|
||||
@@ -264,6 +398,9 @@ function LimitWarningsBanner({
|
||||
}) {
|
||||
const warnings = React.useMemo(() => buildLimitWarnings(limits, translate), [limits, translate]);
|
||||
const [busyScope, setBusyScope] = React.useState<string | null>(null);
|
||||
const [dismissedIds, setDismissedIds] = React.useState<Set<string>>(() => readDismissedLimitWarnings());
|
||||
const { t: tCommon } = useTranslation('common');
|
||||
const dismissLabel = tCommon('actions.dismiss', { defaultValue: 'Hinweis ausblenden' });
|
||||
|
||||
const handleCheckout = React.useCallback(
|
||||
async (scopeOrKey: 'photos' | 'gallery' | string) => {
|
||||
@@ -298,47 +435,71 @@ function LimitWarningsBanner({
|
||||
[eventSlug, addons],
|
||||
);
|
||||
|
||||
if (!warnings.length) {
|
||||
const handleDismiss = React.useCallback((warningId: string) => {
|
||||
setDismissedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(warningId);
|
||||
persistDismissedLimitWarnings(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const visibleWarnings = warnings.filter((warning) => !dismissedIds.has(warning.id));
|
||||
|
||||
if (!visibleWarnings.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6 space-y-2">
|
||||
{warnings.map((warning) => (
|
||||
{visibleWarnings.map((warning) => (
|
||||
<Alert
|
||||
key={warning.id}
|
||||
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<AlertDescription className="flex items-center gap-2 text-sm">
|
||||
<div className="flex flex-1 items-center gap-2 text-sm">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{warning.message}
|
||||
</AlertDescription>
|
||||
{warning.scope === 'photos' || warning.scope === 'gallery' ? (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void handleCheckout(warning.scope as 'photos' | 'gallery'); }}
|
||||
disabled={busyScope === warning.scope}
|
||||
>
|
||||
<ShoppingCart className="mr-2 h-4 w-4" />
|
||||
{warning.scope === 'photos'
|
||||
? translate('buyMorePhotos', { defaultValue: 'Mehr Fotos freischalten' })
|
||||
: translate('extendGallery', { defaultValue: 'Galerie verlängern' })}
|
||||
</Button>
|
||||
<div className="text-xs text-slate-500">
|
||||
<AddonsPicker
|
||||
addons={addons}
|
||||
scope={warning.scope as 'photos' | 'gallery'}
|
||||
onCheckout={(key) => { void handleCheckout(key); }}
|
||||
busy={busyScope === warning.scope}
|
||||
t={(key, fallback) => translate(key, { defaultValue: fallback })}
|
||||
/>
|
||||
<AlertDescription className="flex-1 text-sm">
|
||||
{warning.message}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
{warning.scope === 'photos' || warning.scope === 'gallery' ? (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void handleCheckout(warning.scope as 'photos' | 'gallery'); }}
|
||||
disabled={busyScope === warning.scope}
|
||||
>
|
||||
<ShoppingCart className="mr-2 h-4 w-4" />
|
||||
{warning.scope === 'photos'
|
||||
? translate('buyMorePhotos', { defaultValue: 'Mehr Fotos freischalten' })
|
||||
: translate('extendGallery', { defaultValue: 'Galerie verlängern' })}
|
||||
</Button>
|
||||
{warning.scope !== 'guests' ? (
|
||||
<AddonsPicker
|
||||
addons={addons}
|
||||
scope={warning.scope as 'photos' | 'gallery'}
|
||||
onCheckout={(key) => { void handleCheckout(key); }}
|
||||
busy={busyScope === warning.scope}
|
||||
t={(key, fallback) => translate(key, { defaultValue: fallback })}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={dismissLabel}
|
||||
className="text-slate-500 hover:text-slate-800"
|
||||
onClick={() => handleDismiss(warning.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
))}
|
||||
@@ -367,3 +528,243 @@ function EmptyGallery({ title, description }: { title: string; description: stri
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GalleryToolbar({
|
||||
search,
|
||||
onSearch,
|
||||
statusFilter,
|
||||
onStatusFilterChange,
|
||||
totalCount,
|
||||
selectionCount,
|
||||
onSelectAll,
|
||||
onClearSelection,
|
||||
onBulkHide,
|
||||
onBulkShow,
|
||||
onBulkFeature,
|
||||
onBulkUnfeature,
|
||||
busy,
|
||||
}: {
|
||||
search: string;
|
||||
onSearch: (value: string) => void;
|
||||
statusFilter: 'all' | 'featured' | 'hidden' | 'photobooth';
|
||||
onStatusFilterChange: (value: 'all' | 'featured' | 'hidden' | 'photobooth') => void;
|
||||
totalCount: number;
|
||||
selectionCount: number;
|
||||
onSelectAll: () => void;
|
||||
onClearSelection: () => void;
|
||||
onBulkHide: () => void;
|
||||
onBulkShow: () => void;
|
||||
onBulkFeature: () => void;
|
||||
onBulkUnfeature: () => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const filters = [
|
||||
{ key: 'all', label: t('photos.filters.all', 'Alle') },
|
||||
{ key: 'featured', label: t('photos.filters.featured', 'Highlights') },
|
||||
{ key: 'hidden', label: t('photos.filters.hidden', 'Versteckt') },
|
||||
{ key: 'photobooth', label: t('photos.filters.photobooth', 'Photobooth') },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="mb-4 space-y-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-1 items-center gap-2 rounded-full border border-slate-200 px-3 py-1">
|
||||
<Search className="h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => onSearch(event.target.value)}
|
||||
placeholder={t('photos.filters.search', 'Uploads durchsuchen …')}
|
||||
className="h-8 border-0 bg-transparent text-sm focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filters.map((filter) => (
|
||||
<Button
|
||||
key={filter.key}
|
||||
variant={statusFilter === filter.key ? 'secondary' : 'outline'}
|
||||
className="rounded-full"
|
||||
onClick={() => onStatusFilterChange(filter.key)}
|
||||
size="sm"
|
||||
>
|
||||
{filter.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-slate-500">
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>{t('photos.filters.count', '{{count}} Uploads', { count: totalCount })}</span>
|
||||
{selectionCount > 0 ? (
|
||||
<>
|
||||
<Badge variant="outline" className="border-slate-200 text-slate-700">
|
||||
{t('photos.filters.selected', '{{count}} ausgewählt', { count: selectionCount })}
|
||||
</Badge>
|
||||
<Button variant="outline" size="sm" onClick={onClearSelection}>
|
||||
{t('photos.filters.clearSelection', 'Auswahl aufheben')}
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={onBulkHide} disabled={busy}>
|
||||
<EyeOff className="mr-2 h-4 w-4" />
|
||||
{t('photos.actions.hide', 'Verstecken')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={onBulkShow} disabled={busy}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
{t('photos.actions.show', 'Einblenden')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={onBulkFeature} disabled={busy}>
|
||||
<Star className="mr-2 h-4 w-4" />
|
||||
{t('photos.actions.feature', 'Highlight')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onBulkUnfeature} disabled={busy}>
|
||||
<Star className="mr-2 h-4 w-4" />
|
||||
{t('photos.actions.unfeature', 'Highlight entfernen')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" onClick={onSelectAll}>
|
||||
{t('photos.filters.selectAll', 'Alle auswählen')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PhotoGrid({
|
||||
photos,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
onToggleFeature,
|
||||
onToggleVisibility,
|
||||
onDelete,
|
||||
busyId,
|
||||
}: {
|
||||
photos: TenantPhoto[];
|
||||
selectedIds: number[];
|
||||
onToggleSelect: (id: number) => void;
|
||||
onToggleFeature: (photo: TenantPhoto) => void;
|
||||
onToggleVisibility: (photo: TenantPhoto, visible: boolean) => void;
|
||||
onDelete: (photo: TenantPhoto) => void;
|
||||
busyId: number | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{photos.map((photo) => (
|
||||
<PhotoCard
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
selected={selectedIds.includes(photo.id)}
|
||||
onToggleSelect={() => onToggleSelect(photo.id)}
|
||||
onToggleFeature={() => onToggleFeature(photo)}
|
||||
onToggleVisibility={(visible) => onToggleVisibility(photo, visible)}
|
||||
onDelete={() => onDelete(photo)}
|
||||
busy={busyId === photo.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PhotoCard({
|
||||
photo,
|
||||
selected,
|
||||
onToggleSelect,
|
||||
onToggleFeature,
|
||||
onToggleVisibility,
|
||||
onDelete,
|
||||
busy,
|
||||
}: {
|
||||
photo: TenantPhoto;
|
||||
selected: boolean;
|
||||
onToggleSelect: () => void;
|
||||
onToggleFeature: () => void;
|
||||
onToggleVisibility: (visible: boolean) => void;
|
||||
onDelete: () => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const hidden = photo.status === 'hidden';
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white/90 p-4 shadow-sm">
|
||||
<div className="relative mb-3">
|
||||
<button
|
||||
type="button"
|
||||
className={`absolute left-3 top-3 z-10 rounded-full border border-white bg-white/90 px-3 py-1 text-xs font-semibold transition ${
|
||||
selected ? 'text-sky-600' : 'text-slate-500'
|
||||
}`}
|
||||
onClick={onToggleSelect}
|
||||
>
|
||||
{selected ? t('photos.gallery.selected', 'Ausgewählt') : t('photos.gallery.select', 'Markieren')}
|
||||
</button>
|
||||
<img
|
||||
src={photo.thumbnail_url ?? photo.url ?? undefined}
|
||||
alt={photo.original_name ?? 'Foto'}
|
||||
className={`h-56 w-full rounded-2xl object-cover ${hidden ? 'opacity-60' : ''}`}
|
||||
/>
|
||||
{photo.is_featured && (
|
||||
<span className="absolute right-3 top-3 rounded-full bg-pink-500/90 px-3 py-1 text-xs font-semibold text-white shadow">
|
||||
Highlight
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-slate-700">
|
||||
<div className="flex items-center justify-between text-xs text-slate-500">
|
||||
<span>{t('photos.gallery.likes', 'Likes: {{count}}', { count: photo.likes_count })}</span>
|
||||
<span>{t('photos.gallery.uploader', 'Uploader: {{name}}', { name: photo.uploader_name ?? 'Unbekannt' })}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" disabled={busy} onClick={() => onToggleVisibility(!hidden)}>
|
||||
{busy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : hidden ? (
|
||||
<>
|
||||
<Eye className="h-4 w-4" /> {t('photos.actions.show', 'Einblenden')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EyeOff className="h-4 w-4" /> {t('photos.actions.hide', 'Verstecken')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant={photo.is_featured ? 'secondary' : 'outline'}
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={onToggleFeature}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : photo.is_featured ? (
|
||||
<>
|
||||
<Star className="h-4 w-4" /> {t('photos.actions.unfeature', 'Highlight entfernen')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Star className="h-4 w-4" /> {t('photos.actions.feature', 'Als Highlight setzen')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={onDelete} disabled={busy}>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
{t('photos.actions.delete', 'Löschen')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!photo.url) return;
|
||||
navigator.clipboard.writeText(photo.url).then(() => {
|
||||
toast.success(t('photos.actions.copySuccess', 'Link kopiert'));
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" /> {t('photos.actions.copy', 'Link kopieren')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user