Files
bricktrack/src/components/katalog/instructions-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

216 lines
7.1 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 { Download, ExternalLink, FileText, Loader2, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import {
addInstructionPdf,
addInstructionUrl,
deleteInstruction,
} from '@/app/(app)/katalog/actions';
import { fileUrl } from '@/lib/images';
import { PdfViewer } from './pdf-viewer';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
export type InstructionRow = {
id: string;
kind: 'pdf_upload' | 'external_url';
filePath: string | null;
url: string | null;
label: string | null;
fileSizeBytes: number | null;
};
function formatSize(bytes: number | null): string {
if (!bytes) return '';
const mb = bytes / (1024 * 1024);
return mb >= 1
? `${mb.toLocaleString('de-DE', { maximumFractionDigits: 1 })} MB`
: `${Math.round(bytes / 1024)} KB`;
}
export function InstructionsSection({
setId,
instructions,
}: {
setId: string;
instructions: InstructionRow[];
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [addOpen, setAddOpen] = useState(false);
const [kind, setKind] = useState<'pdf_upload' | 'external_url'>('pdf_upload');
const [viewing, setViewing] = useState<InstructionRow | null>(null);
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
startTransition(async () => {
const result =
kind === 'pdf_upload'
? await addInstructionPdf(setId, formData)
: await addInstructionUrl(setId, Object.fromEntries(formData.entries()));
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Anleitung hinzugefügt.');
setAddOpen(false);
router.refresh();
});
}
function remove(id: string) {
startTransition(async () => {
const result = await deleteInstruction(id);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Anleitung entfernt.');
router.refresh();
});
}
return (
<div className="space-y-3">
{instructions.length === 0 ? (
<p className="text-sm text-muted-foreground">
Noch keine Anleitung hinterlegt. Lade das PDF hoch oder verlinke es beim Hersteller.
</p>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{instructions.map((item) => (
<li key={item.id} className="flex items-center gap-3 p-3">
<FileText className="size-5 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{item.label ?? (item.kind === 'pdf_upload' ? 'Anleitung' : 'Externe Anleitung')}
</p>
<p className="text-xs text-muted-foreground">
{item.kind === 'pdf_upload' ? `PDF ${formatSize(item.fileSizeBytes)}` : 'Link'}
</p>
</div>
{item.kind === 'pdf_upload' && item.filePath ? (
<>
<Button variant="outline" size="sm" onClick={() => setViewing(item)}>
Ansehen
</Button>
<Button asChild variant="ghost" size="icon-sm" aria-label="Herunterladen">
<a href={fileUrl(item.filePath)} download>
<Download />
</a>
</Button>
</>
) : item.url ? (
<Button asChild variant="outline" size="sm">
<a href={item.url} target="_blank" rel="noreferrer noopener">
Öffnen
<ExternalLink />
</a>
</Button>
) : null}
<Button
variant="ghost"
size="icon-sm"
aria-label="Anleitung entfernen"
onClick={() => remove(item.id)}
disabled={pending}
>
<Trash2 className="text-destructive" />
</Button>
</li>
))}
</ul>
)}
<Button variant="outline" onClick={() => setAddOpen(true)}>
<Plus />
Anleitung hinzufügen
</Button>
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Anleitung hinzufügen</DialogTitle>
<DialogDescription>
Ein Set kann mehrere Anleitungen haben – zum Beispiel „Buch 1 von 3“.
</DialogDescription>
</DialogHeader>
<form id="instruction-form" onSubmit={submit} className="space-y-4">
<Field label="Art" htmlFor="instruction-kind">
<NativeSelect
id="instruction-kind"
value={kind}
onChange={(event) => setKind(event.target.value as typeof kind)}
>
<option value="pdf_upload">PDF hochladen</option>
<option value="external_url">Link zur Anleitung</option>
</NativeSelect>
</Field>
<Field label="Bezeichnung" htmlFor="label" hint="Optional, z. B. „Buch 1 von 3“.">
<Input id="label" name="label" maxLength={80} />
</Field>
{kind === 'pdf_upload' ? (
<Field label="PDF-Datei" htmlFor="file" required>
<Input id="file" name="file" type="file" accept="application/pdf" required />
</Field>
) : (
<Field label="Adresse" htmlFor="url" required>
<Input
id="url"
name="url"
type="url"
inputMode="url"
required
placeholder="https://www.lego.com/…"
/>
</Field>
)}
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setAddOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button type="submit" form="instruction-form" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : null}
Hinzufügen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={viewing !== null} onOpenChange={(open) => !open && setViewing(null)}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{viewing?.label ?? 'Anleitung'}</DialogTitle>
</DialogHeader>
{viewing?.filePath ? (
<PdfViewer url={fileUrl(viewing.filePath)} label={viewing.label ?? 'Anleitung'} />
) : null}
</DialogContent>
</Dialog>
</div>
);
}