Introduce an admin portal (React + Nginx), WebSocket routing, and API versioning middleware with `/api/v1/` prefix deprecation. Add master API key authentication, new Prometheus metrics for AI token consumption and active WebSockets, and extend S3 config with a public endpoint URL. Update test paths and fixtures to align with the new routing structure.
127 lines
5.6 KiB
TypeScript
127 lines
5.6 KiB
TypeScript
import { useParams, Link } from 'react-router-dom';
|
|
import { PageLayout } from '@/components/layout/PageLayout';
|
|
import { PlanBadge } from '@/components/shared/PlanBadge';
|
|
import { StatusBadge } from '@/components/shared/StatusBadge';
|
|
import { Pagination } from '@/components/shared/Pagination';
|
|
import { EmptyState } from '@/components/shared/EmptyState';
|
|
import { ImageGridSkeleton } from '@/components/shared/Skeleton';
|
|
import { SearchInput } from '@/components/shared/SearchInput';
|
|
import { ImageLoader } from '@/components/shared/ImageLoader';
|
|
import { useImages } from '@/hooks/useImages';
|
|
import { useClient } from '@/hooks/useClients';
|
|
import { formatDate, formatBytes, truncate } from '@/utils/formatters';
|
|
import { ArrowLeft, ExternalLink, Images, Camera } from 'lucide-react';
|
|
import { useState, useCallback } from 'react';
|
|
|
|
export default function ClientGallery() {
|
|
const { clientId } = useParams<{ clientId: string }>();
|
|
const { data: client } = useClient(clientId);
|
|
const [page, setPage] = useState(1);
|
|
const [search, setSearch] = useState('');
|
|
|
|
const { data, isLoading } = useImages({
|
|
client_id: clientId,
|
|
search: search || undefined,
|
|
page,
|
|
page_size: 20,
|
|
sort_by: 'uploaded_at',
|
|
sort_order: 'desc',
|
|
});
|
|
|
|
const images = data?.items ?? [];
|
|
const total = data?.total ?? 0;
|
|
const totalPages = data?.pages ?? 1;
|
|
|
|
const handleSearch = useCallback((value: string) => {
|
|
setSearch(value);
|
|
setPage(1);
|
|
}, []);
|
|
|
|
return (
|
|
<PageLayout
|
|
title={client ? `Images de "${client.name}"` : 'Galerie client'}
|
|
description={
|
|
client
|
|
? `${total} image${total !== 1 ? 's' : ''} · ${formatBytes(client.storage_used_bytes)} utilisés`
|
|
: undefined
|
|
}
|
|
actions={
|
|
<div className="flex items-center gap-2">
|
|
{client && <PlanBadge plan={client.plan} />}
|
|
{client && (
|
|
<Link to={`/admin/clients/${client.id}`} className="btn-ghost text-sm flex items-center gap-1">
|
|
Détail client <ExternalLink size={12} />
|
|
</Link>
|
|
)}
|
|
<Link to="/gallery" className="btn-ghost text-sm flex items-center gap-1">
|
|
<ArrowLeft size={14} /> Toutes les images
|
|
</Link>
|
|
</div>
|
|
}
|
|
>
|
|
{/* Search */}
|
|
<div className="mb-6">
|
|
<SearchInput
|
|
value={search}
|
|
onChange={handleSearch}
|
|
placeholder="Rechercher dans les images de ce client…"
|
|
className="max-w-md"
|
|
/>
|
|
</div>
|
|
|
|
{/* Grid */}
|
|
{isLoading ? (
|
|
<ImageGridSkeleton count={8} />
|
|
) : images.length === 0 ? (
|
|
<EmptyState
|
|
title="Aucune image"
|
|
description={search ? 'Aucun résultat pour cette recherche' : 'Ce client n\'a pas encore uploadé d\'images'}
|
|
icon={<Images size={32} className="text-content-muted" />}
|
|
/>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
{images.map((img) => (
|
|
<Link
|
|
key={img.id}
|
|
to={`/gallery/image/${img.id}`}
|
|
className="card-compact group hover:border-border-light transition-all duration-200 hover:-translate-y-0.5"
|
|
>
|
|
<ImageLoader
|
|
imageId={img.id}
|
|
alt={img.original_name}
|
|
className="mb-3"
|
|
/>
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<p className="text-sm font-medium text-content-primary truncate">{truncate(img.original_name, 25)}</p>
|
|
<StatusBadge status={img.processing_status} />
|
|
</div>
|
|
{img.ai_tags?.length > 0 && (
|
|
<div className="flex flex-wrap gap-1">
|
|
{img.ai_tags?.slice(0, 3).map((tag) => (
|
|
<span key={tag} className="text-[10px] bg-surface-overlay text-content-muted px-1.5 py-0.5 rounded">{tag}</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-2 text-xs text-content-muted">
|
|
<span>{formatDate(img.uploaded_at)}</span>
|
|
{img.exif_make && (
|
|
<span className="flex items-center gap-1"><Camera size={10} /> {img.exif_make}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="flex justify-center mt-6">
|
|
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
|
</div>
|
|
)}
|
|
</PageLayout>
|
|
);
|
|
}
|