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