Initial Commit
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user