Files
bricktrack/src/components/katalog/photos-section.tsx
T
unknownandClaude Opus 5 856800b4e4 feat(katalog): set-katalog mit filtern, detailseite und uploads
Kachelliste mit Suche ueber Setnummer, Name und EAN sowie Filtern nach
Hersteller, Thema, Jahr und Besitzstatus. Besitz kommt aus genau einer
Stelle: src/lib/ownership.ts definiert "hat er nicht" als Katalogeintrag
ohne collection_item mit Status ungleich sold, als korrelierte
Subquery, damit mehrere Exemplare die Liste nicht verdoppeln.

Detailseite mit Metadaten, Anleitungen (PDF-Upload plus pdf.js-Viewer
ohne CDN, oder externer Link), Fotos, Sammlungseintraegen und den
Aktionen "Zur Sammlung" und "Auf Wunschliste".

Upload-Pipeline prueft Magic Bytes statt des gemeldeten MIME-Typs,
erzeugt Dateinamen serverseitig, wandelt Bilder in WebP inklusive
Thumbnail und liefert Dateien ueber /api/files aus - Set-Bilder
oeffentlich, Fotos und PDFs nur mit Session.

Dazu das Seed-Skript mit 3 Herstellern, 6 Themen, 10 Sets und
4 Sammlungseintraegen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 22:59:26 +02:00

139 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Camera, Loader2, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { addSetPhoto, deletePhoto } from '@/app/(app)/katalog/actions';
import { fileUrl } from '@/lib/images';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
export type PhotoRow = {
id: string;
filePath: string;
thumbnailPath: string | null;
caption: string | null;
};
export function PhotosSection({ setId, photos }: { setId: string; photos: PhotoRow[] }) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [addOpen, setAddOpen] = useState(false);
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
startTransition(async () => {
const result = await addSetPhoto(setId, formData);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Foto gespeichert.');
setAddOpen(false);
router.refresh();
});
}
function remove(id: string) {
startTransition(async () => {
const result = await deletePhoto(id);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Foto gelöscht.');
router.refresh();
});
}
return (
<div className="space-y-3">
{photos.length > 0 ? (
<ul className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{photos.map((item) => (
<li key={item.id} className="group relative">
<a
href={fileUrl(item.filePath)}
target="_blank"
rel="noreferrer"
className="block overflow-hidden rounded-lg border border-border"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={fileUrl(item.thumbnailPath ?? item.filePath)}
alt={item.caption ?? ''}
loading="lazy"
className="aspect-square w-full object-cover"
/>
</a>
<Button
variant="secondary"
size="icon-xs"
aria-label="Foto löschen"
className="absolute top-1 right-1 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
onClick={() => remove(item.id)}
disabled={pending}
>
<Trash2 className="text-destructive" />
</Button>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">
Noch keine eigenen Fotos – zum Beispiel vom fertig gebauten Modell.
</p>
)}
<Button variant="outline" onClick={() => setAddOpen(true)}>
<Camera />
Foto hinzufügen
</Button>
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Foto hinzufügen</DialogTitle>
</DialogHeader>
<form id="photo-form" onSubmit={submit} className="space-y-4">
<Field label="Bild" htmlFor="photo-file" required>
{/* capture lets the phone open the camera directly. */}
<Input
id="photo-file"
name="file"
type="file"
accept="image/jpeg,image/png,image/webp"
capture="environment"
required
/>
</Field>
<Field label="Bildunterschrift" htmlFor="caption">
<Input id="caption" name="caption" maxLength={160} />
</Field>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setAddOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button type="submit" form="photo-form" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : null}
Speichern
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}