282 lines
9.5 KiB
TypeScript
282 lines
9.5 KiB
TypeScript
import { useState } from 'react';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { api } from '../api';
|
|
import type { GalleryImage } from '../api/types';
|
|
|
|
type GalleryImageWithTarget = GalleryImage & {
|
|
target_name?: string;
|
|
target_common_name?: string;
|
|
};
|
|
|
|
function fmtDate(ts: number): string {
|
|
return new Date(ts * 1000).toLocaleDateString('fr-FR', {
|
|
year: 'numeric', month: 'short', day: 'numeric',
|
|
});
|
|
}
|
|
|
|
export default function Gallery() {
|
|
const [lightbox, setLightbox] = useState<GalleryImageWithTarget | null>(null);
|
|
const [filterTarget, setFilterTarget] = useState('');
|
|
const qc = useQueryClient();
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['gallery-all'],
|
|
queryFn: () => api.gallery.listAll(),
|
|
});
|
|
|
|
const images = data?.items ?? [];
|
|
|
|
// Unique targets for filter
|
|
const targets = Array.from(
|
|
new Map(images.map(img => [img.catalog_id, img.target_common_name ?? img.target_name ?? img.catalog_id])).entries()
|
|
).sort((a, b) => a[1].localeCompare(b[1]));
|
|
|
|
const filtered = filterTarget
|
|
? images.filter(img => img.catalog_id === filterTarget)
|
|
: images;
|
|
|
|
// Group by target
|
|
const grouped: Record<string, GalleryImageWithTarget[]> = {};
|
|
for (const img of filtered) {
|
|
const key = img.catalog_id;
|
|
if (!grouped[key]) grouped[key] = [];
|
|
grouped[key].push(img);
|
|
}
|
|
|
|
return (
|
|
<div style={{ padding: '24px 28px' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 20, marginBottom: 24 }}>
|
|
<h1 style={{
|
|
fontFamily: 'var(--font-display)',
|
|
fontSize: 20,
|
|
fontWeight: 700,
|
|
color: 'var(--text-hi)',
|
|
letterSpacing: '0.04em',
|
|
}}>
|
|
Gallery
|
|
</h1>
|
|
<span style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
|
{images.length} image{images.length !== 1 ? 's' : ''}
|
|
</span>
|
|
|
|
{targets.length > 1 && (
|
|
<select
|
|
value={filterTarget}
|
|
onChange={e => setFilterTarget(e.target.value)}
|
|
style={{
|
|
marginLeft: 'auto',
|
|
background: 'var(--bg-deep)',
|
|
border: '1px solid var(--border)',
|
|
color: 'var(--text-mid)',
|
|
fontFamily: 'var(--font-mono)',
|
|
fontSize: 11,
|
|
padding: '5px 10px',
|
|
borderRadius: 3,
|
|
}}
|
|
>
|
|
<option value="">All targets</option>
|
|
{targets.map(([id, label]) => (
|
|
<option key={id} value={id}>{label} ({id})</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
|
|
{isLoading && (
|
|
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
|
Loading images…
|
|
</div>
|
|
)}
|
|
|
|
{!isLoading && images.length === 0 && (
|
|
<div style={{
|
|
color: 'var(--text-lo)',
|
|
fontFamily: 'var(--font-mono)',
|
|
fontSize: 12,
|
|
padding: '40px 0',
|
|
textAlign: 'center',
|
|
}}>
|
|
No images yet. Upload images from the Targets page → Log & Gallery tab.
|
|
</div>
|
|
)}
|
|
|
|
{Object.entries(grouped).map(([catalogId, imgs]) => {
|
|
const first = imgs[0];
|
|
const targetLabel = first.target_common_name ?? first.target_name ?? catalogId;
|
|
return (
|
|
<div key={catalogId} style={{ marginBottom: 32 }}>
|
|
<div style={{
|
|
display: 'flex',
|
|
alignItems: 'baseline',
|
|
gap: 10,
|
|
marginBottom: 12,
|
|
borderBottom: '1px solid var(--border)',
|
|
paddingBottom: 8,
|
|
}}>
|
|
<span style={{
|
|
fontFamily: 'var(--font-display)',
|
|
fontSize: 14,
|
|
fontWeight: 700,
|
|
color: 'var(--text-hi)',
|
|
}}>
|
|
{targetLabel}
|
|
</span>
|
|
<span style={{
|
|
fontFamily: 'var(--font-mono)',
|
|
fontSize: 11,
|
|
color: 'var(--amber)',
|
|
}}>
|
|
{catalogId}
|
|
</span>
|
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)', marginLeft: 'auto' }}>
|
|
{imgs.length} image{imgs.length !== 1 ? 's' : ''}
|
|
</span>
|
|
</div>
|
|
|
|
<div style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
|
|
gap: 8,
|
|
}}>
|
|
{imgs.map(img => (
|
|
<div
|
|
key={img.id}
|
|
onClick={() => setLightbox(img)}
|
|
style={{
|
|
cursor: 'pointer',
|
|
borderRadius: 4,
|
|
overflow: 'hidden',
|
|
background: 'var(--bg-deep)',
|
|
border: '1px solid var(--border)',
|
|
transition: 'border-color 0.15s',
|
|
}}
|
|
onMouseEnter={e => (e.currentTarget.style.borderColor = 'var(--border-hi)')}
|
|
onMouseLeave={e => (e.currentTarget.style.borderColor = 'var(--border)')}
|
|
>
|
|
<div style={{ aspectRatio: '4/3', overflow: 'hidden' }}>
|
|
<img
|
|
src={img.url}
|
|
alt={img.caption ?? img.filename}
|
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
loading="lazy"
|
|
/>
|
|
</div>
|
|
<div style={{ padding: '6px 8px' }}>
|
|
{img.caption && (
|
|
<div style={{
|
|
color: 'var(--text-mid)',
|
|
fontFamily: 'var(--font-sans)',
|
|
fontSize: 11,
|
|
marginBottom: 2,
|
|
overflow: 'hidden',
|
|
whiteSpace: 'nowrap',
|
|
textOverflow: 'ellipsis',
|
|
}}>
|
|
{img.caption}
|
|
</div>
|
|
)}
|
|
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 10 }}>
|
|
{fmtDate(img.created_at)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Lightbox */}
|
|
{lightbox && (
|
|
<div
|
|
onClick={() => setLightbox(null)}
|
|
style={{
|
|
position: 'fixed',
|
|
inset: 0,
|
|
background: 'rgba(0,0,0,0.94)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
zIndex: 1000,
|
|
}}
|
|
>
|
|
<div
|
|
onClick={e => e.stopPropagation()}
|
|
style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}
|
|
>
|
|
<img
|
|
src={lightbox.url}
|
|
alt={lightbox.caption ?? lightbox.filename}
|
|
style={{ maxWidth: '100%', maxHeight: '85vh', borderRadius: 4, display: 'block' }}
|
|
/>
|
|
<div style={{
|
|
marginTop: 10,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 12,
|
|
}}>
|
|
<div>
|
|
<div style={{ color: 'var(--text-hi)', fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 600 }}>
|
|
{lightbox.target_common_name ?? lightbox.target_name ?? lightbox.catalog_id}
|
|
<span style={{ color: 'var(--amber)', fontFamily: 'var(--font-mono)', fontSize: 11, marginLeft: 8 }}>
|
|
{lightbox.catalog_id}
|
|
</span>
|
|
</div>
|
|
{lightbox.caption && (
|
|
<div style={{ color: 'var(--text-mid)', fontFamily: 'var(--font-sans)', fontSize: 12, marginTop: 2 }}>
|
|
{lightbox.caption}
|
|
</div>
|
|
)}
|
|
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 11, marginTop: 2 }}>
|
|
{fmtDate(lightbox.created_at)}
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
api.gallery.delete(lightbox.id).then(() => {
|
|
qc.invalidateQueries({ queryKey: ['gallery-all'] });
|
|
qc.invalidateQueries({ queryKey: ['gallery', lightbox.catalog_id] });
|
|
setLightbox(null);
|
|
});
|
|
}}
|
|
style={{
|
|
marginLeft: 'auto',
|
|
color: 'var(--danger)',
|
|
fontFamily: 'var(--font-mono)',
|
|
fontSize: 12,
|
|
border: '1px solid var(--danger)',
|
|
borderRadius: 3,
|
|
padding: '4px 10px',
|
|
}}
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
<button
|
|
onClick={() => setLightbox(null)}
|
|
style={{
|
|
position: 'absolute',
|
|
top: -12,
|
|
right: -12,
|
|
color: 'var(--text-hi)',
|
|
fontSize: 18,
|
|
background: 'var(--bg-panel)',
|
|
border: '1px solid var(--border-hi)',
|
|
borderRadius: '50%',
|
|
width: 28,
|
|
height: 28,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|