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,148 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState, useTransition } from 'react';
|
||||
import { Loader2, Search, X } from 'lucide-react';
|
||||
import { OWNERSHIP_FILTERS, OWNERSHIP_FILTER_LABELS } from '@/lib/ownership';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { NativeSelect } from '@/components/ui/native-select';
|
||||
|
||||
export type FilterOptions = {
|
||||
manufacturers: Array<{ id: string; name: string }>;
|
||||
themes: Array<{ id: string; name: string; manufacturerId: string }>;
|
||||
years: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters live in the URL, so every state of the catalog is a shareable link
|
||||
* and the back button behaves the way people expect.
|
||||
*/
|
||||
export function CatalogFilters({ options }: { options: FilterOptions }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const [query, setQuery] = useState(searchParams.get('q') ?? '');
|
||||
const manufacturerId = searchParams.get('hersteller') ?? '';
|
||||
const themeId = searchParams.get('thema') ?? '';
|
||||
const year = searchParams.get('jahr') ?? '';
|
||||
const ownership = searchParams.get('besitz') ?? 'alle';
|
||||
|
||||
function apply(changes: Record<string, string | null>) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (value === null || value === '') params.delete(key);
|
||||
else params.set(key, value);
|
||||
}
|
||||
// Any filter change starts over at page one.
|
||||
params.delete('seite');
|
||||
startTransition(() => router.push(`${pathname}?${params.toString()}`));
|
||||
}
|
||||
|
||||
// Debounced search so typing on a phone does not fire a request per keystroke.
|
||||
useEffect(() => {
|
||||
const current = searchParams.get('q') ?? '';
|
||||
if (query === current) return;
|
||||
|
||||
const timer = setTimeout(() => apply({ q: query || null }), 350);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query]);
|
||||
|
||||
const visibleThemes = manufacturerId
|
||||
? options.themes.filter((theme) => theme.manufacturerId === manufacturerId)
|
||||
: options.themes;
|
||||
|
||||
const hasFilters = Boolean(manufacturerId || themeId || year || query) || ownership !== 'alle';
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Setnummer, Name oder EAN"
|
||||
aria-label="Sets durchsuchen"
|
||||
className="pl-9"
|
||||
/>
|
||||
{pending ? (
|
||||
<Loader2 className="absolute top-1/2 right-3 size-4 -translate-y-1/2 animate-spin text-muted-foreground" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<NativeSelect
|
||||
aria-label="Besitzstatus"
|
||||
value={ownership}
|
||||
onChange={(event) => apply({ besitz: event.target.value })}
|
||||
>
|
||||
{OWNERSHIP_FILTERS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{OWNERSHIP_FILTER_LABELS[value]}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
|
||||
<NativeSelect
|
||||
aria-label="Hersteller"
|
||||
value={manufacturerId}
|
||||
onChange={(event) => apply({ hersteller: event.target.value || null, thema: null })}
|
||||
>
|
||||
<option value="">Alle Hersteller</option>
|
||||
{options.manufacturers.map((manufacturer) => (
|
||||
<option key={manufacturer.id} value={manufacturer.id}>
|
||||
{manufacturer.name}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
|
||||
<NativeSelect
|
||||
aria-label="Thema"
|
||||
value={themeId}
|
||||
onChange={(event) => apply({ thema: event.target.value || null })}
|
||||
>
|
||||
<option value="">Alle Themen</option>
|
||||
{visibleThemes.map((theme) => (
|
||||
<option key={theme.id} value={theme.id}>
|
||||
{theme.name}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
|
||||
<NativeSelect
|
||||
aria-label="Jahr"
|
||||
value={year}
|
||||
onChange={(event) => apply({ jahr: event.target.value || null })}
|
||||
>
|
||||
<option value="">Alle Jahre</option>
|
||||
{options.years.map((value) => (
|
||||
<option key={value} value={String(value)}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
{hasFilters ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setQuery('');
|
||||
startTransition(() => router.push(pathname));
|
||||
}}
|
||||
>
|
||||
<X />
|
||||
Filter zurücksetzen
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRef, useState, useTransition } from 'react';
|
||||
import { CheckCheck, Gift, Loader2, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from 'cn';
|
||||
import { addSetsToWishlist, createWishlist } from '@/app/(app)/wunschlisten/actions';
|
||||
import type { CatalogRow } from '@/lib/queries/catalog';
|
||||
import { PRIORITY_LABELS } from '@/lib/wishlist-logic';
|
||||
import { SetCard, SetCardContent } from './set-card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
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';
|
||||
|
||||
const LONG_PRESS_MS = 450;
|
||||
const NEW_LIST = '__neu__';
|
||||
|
||||
export type WishlistTarget = { id: string; title: string };
|
||||
|
||||
type Props = {
|
||||
sets: CatalogRow[];
|
||||
wishlists: WishlistTarget[];
|
||||
/** Preselected target, used by the embedded view on a wishlist page. */
|
||||
defaultWishlistId?: string;
|
||||
emptyState?: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* The catalog grid with its selection mode.
|
||||
*
|
||||
* On a phone a long press starts the selection, afterwards a tap toggles.
|
||||
* On a desktop the "Auswählen" button does the same job. Sets that are already
|
||||
* in the collection cannot be selected - they do not belong on a wishlist.
|
||||
*/
|
||||
export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }: Props) {
|
||||
const router = useRouter();
|
||||
const [selecting, setSelecting] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [targetId, setTargetId] = useState(defaultWishlistId ?? wishlists[0]?.id ?? NEW_LIST);
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [priority, setPriority] = useState('2');
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const longPressFired = useRef(false);
|
||||
|
||||
const selectable = sets.filter((item) => item.ownedCopies === 0);
|
||||
|
||||
function toggle(setId: string) {
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(setId)) next.delete(setId);
|
||||
else next.add(setId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function startLongPress(item: CatalogRow) {
|
||||
if (item.ownedCopies > 0) return;
|
||||
longPressFired.current = false;
|
||||
longPressTimer.current = setTimeout(() => {
|
||||
longPressFired.current = true;
|
||||
setSelecting(true);
|
||||
toggle(item.id);
|
||||
// A short buzz confirms the mode switch where the device supports it.
|
||||
navigator.vibrate?.(15);
|
||||
}, LONG_PRESS_MS);
|
||||
}
|
||||
|
||||
function cancelLongPress() {
|
||||
if (longPressTimer.current) clearTimeout(longPressTimer.current);
|
||||
longPressTimer.current = null;
|
||||
}
|
||||
|
||||
function endSelection() {
|
||||
setSelecting(false);
|
||||
setSelected(new Set());
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const setIds = [...selected];
|
||||
if (setIds.length === 0) return;
|
||||
|
||||
startTransition(async () => {
|
||||
let wishlistId = targetId;
|
||||
|
||||
if (wishlistId === NEW_LIST) {
|
||||
const created = await createWishlist({ title: newTitle, occasion: 'other' });
|
||||
if (!created.ok) {
|
||||
toast.error(created.error);
|
||||
return;
|
||||
}
|
||||
wishlistId = created.data.id;
|
||||
}
|
||||
|
||||
const result = await addSetsToWishlist({ wishlistId, setIds, priority });
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data.added === 0) toast.warning(result.data.message);
|
||||
else toast.success(result.data.message);
|
||||
|
||||
setDialogOpen(false);
|
||||
endSelection();
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
if (sets.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
|
||||
{emptyState ?? 'Keine Sets gefunden. Passe die Filter an oder leg ein neues Set an.'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selecting
|
||||
? 'Tippe die Sets an, die auf die Wunschliste sollen.'
|
||||
: 'Tipp: Kachel lange drücken, um mehrere Sets auszuwählen.'}
|
||||
</p>
|
||||
{selecting ? (
|
||||
<Button variant="ghost" size="sm" onClick={endSelection}>
|
||||
<X />
|
||||
Abbrechen
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelecting(true)}
|
||||
disabled={selectable.length === 0}
|
||||
>
|
||||
<CheckCheck />
|
||||
Auswählen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{sets.map((item) => {
|
||||
const owned = item.ownedCopies > 0;
|
||||
const isSelected = selected.has(item.id);
|
||||
|
||||
if (!selecting) {
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onPointerDown={() => startLongPress(item)}
|
||||
onPointerUp={cancelLongPress}
|
||||
onPointerLeave={cancelLongPress}
|
||||
onPointerCancel={cancelLongPress}
|
||||
onContextMenu={(event) => {
|
||||
// The long press must not open the browser context menu.
|
||||
if (longPressFired.current) event.preventDefault();
|
||||
}}
|
||||
onClickCapture={(event) => {
|
||||
if (longPressFired.current) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
longPressFired.current = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SetCard set={item} className="block h-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
disabled={owned}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => toggle(item.id)}
|
||||
title={owned ? 'Schon in der Sammlung' : undefined}
|
||||
className={cn(
|
||||
'relative rounded-xl text-left outline-none transition-opacity',
|
||||
'focus-visible:ring-3 focus-visible:ring-ring/50',
|
||||
owned && 'cursor-not-allowed opacity-50',
|
||||
isSelected && '[&>div]:border-primary [&>div]:ring-2 [&>div]:ring-primary/40',
|
||||
)}
|
||||
>
|
||||
<SetCardContent set={item} />
|
||||
{!owned ? (
|
||||
<span className="absolute top-2 right-2 rounded-md bg-background/90 p-1 shadow-sm">
|
||||
<Checkbox checked={isSelected} tabIndex={-1} aria-hidden />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selecting ? (
|
||||
<div className="fixed inset-x-0 bottom-0 z-50 border-t border-border bg-background/95 p-3 pb-[calc(env(safe-area-inset-bottom)+0.75rem)] backdrop-blur">
|
||||
<div className="mx-auto flex max-w-5xl items-center gap-3">
|
||||
<span className="text-sm font-medium">
|
||||
{selected.size} ausgewählt
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
size="lg"
|
||||
disabled={selected.size === 0}
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
<Gift />
|
||||
Zur Wunschliste hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{selected.size === 1
|
||||
? '1 Set auf die Wunschliste'
|
||||
: `${selected.size} Sets auf die Wunschliste`}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sets, die schon auf der Liste oder bereits in der Sammlung sind, werden
|
||||
übersprungen.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Field label="Wunschliste" htmlFor="target-wishlist" required>
|
||||
<NativeSelect
|
||||
id="target-wishlist"
|
||||
value={targetId}
|
||||
onChange={(event) => setTargetId(event.target.value)}
|
||||
>
|
||||
{wishlists.map((list) => (
|
||||
<option key={list.id} value={list.id}>
|
||||
{list.title}
|
||||
</option>
|
||||
))}
|
||||
<option value={NEW_LIST}>+ Neue Wunschliste anlegen</option>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
|
||||
{targetId === NEW_LIST ? (
|
||||
<Field label="Titel der neuen Liste" htmlFor="new-title" required>
|
||||
<Input
|
||||
id="new-title"
|
||||
value={newTitle}
|
||||
onChange={(event) => setNewTitle(event.target.value)}
|
||||
placeholder="Weihnachten 2026"
|
||||
maxLength={80}
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
<Field
|
||||
label="Priorität"
|
||||
htmlFor="priority"
|
||||
hint="Gilt für alle ausgewählten Sets und lässt sich später je Eintrag ändern."
|
||||
>
|
||||
<NativeSelect
|
||||
id="priority"
|
||||
value={priority}
|
||||
onChange={(event) => setPriority(event.target.value)}
|
||||
>
|
||||
{Object.entries(PRIORITY_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={pending}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={pending}>
|
||||
{pending ? <Loader2 className="animate-spin" /> : <Gift />}
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
total,
|
||||
pageSize,
|
||||
}: {
|
||||
page: number;
|
||||
total: number;
|
||||
pageSize: number;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const lastPage = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
if (lastPage <= 1) return null;
|
||||
|
||||
function hrefFor(target: number) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (target <= 1) params.delete('seite');
|
||||
else params.set('seite', String(target));
|
||||
const query = params.toString();
|
||||
return query ? `${pathname}?${query}` : pathname;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="flex items-center justify-between gap-3" aria-label="Seiten">
|
||||
<Button asChild variant="outline" disabled={page <= 1}>
|
||||
<Link href={hrefFor(page - 1)} aria-disabled={page <= 1} className={page <= 1 ? 'pointer-events-none opacity-50' : ''}>
|
||||
<ChevronLeft />
|
||||
Zurück
|
||||
</Link>
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Seite {page} von {lastPage}
|
||||
</span>
|
||||
<Button asChild variant="outline" disabled={page >= lastPage}>
|
||||
<Link
|
||||
href={hrefFor(page + 1)}
|
||||
aria-disabled={page >= lastPage}
|
||||
className={page >= lastPage ? 'pointer-events-none opacity-50' : ''}
|
||||
>
|
||||
Weiter
|
||||
<ChevronRight />
|
||||
</Link>
|
||||
</Button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
/**
|
||||
* Minimal pdf.js viewer for building instructions.
|
||||
*
|
||||
* The library and its worker are bundled with the app - no CDN, no external
|
||||
* request. One page at a time, because that is how you actually build a set,
|
||||
* and because rendering 200 pages at once would stall a phone.
|
||||
*/
|
||||
export function PdfViewer({ url, label }: { url: string; label: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const documentRef = useRef<any>(null);
|
||||
const renderTaskRef = useRef<{ cancel: () => void } | null>(null);
|
||||
const loadingTaskRef = useRef<{ destroy: () => Promise<void> } | null>(null);
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageCount, setPageCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const pdfjs = await import('pdfjs-dist');
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url,
|
||||
).toString();
|
||||
|
||||
const loadingTask = pdfjs.getDocument({ url });
|
||||
const document = await loadingTask.promise;
|
||||
if (cancelled) {
|
||||
void loadingTask.destroy();
|
||||
return;
|
||||
}
|
||||
loadingTaskRef.current = loadingTask;
|
||||
documentRef.current = document;
|
||||
setPageCount(document.numPages);
|
||||
setPage(1);
|
||||
} catch (cause) {
|
||||
console.error('[pdf] konnte nicht geladen werden', cause);
|
||||
if (!cancelled) setError('Die Anleitung konnte nicht geladen werden.');
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
renderTaskRef.current?.cancel();
|
||||
void loadingTaskRef.current?.destroy();
|
||||
loadingTaskRef.current = null;
|
||||
documentRef.current = null;
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
const renderPage = useCallback(async (pageNumber: number) => {
|
||||
const document = documentRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!document || !canvas || !container) return;
|
||||
|
||||
setLoading(true);
|
||||
renderTaskRef.current?.cancel();
|
||||
|
||||
try {
|
||||
const pdfPage = await document.getPage(pageNumber);
|
||||
const unscaled = pdfPage.getViewport({ scale: 1 });
|
||||
// Fit the width of the container, but stay sharp on high-DPI screens.
|
||||
const scale = (container.clientWidth || 600) / unscaled.width;
|
||||
const ratio = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const viewport = pdfPage.getViewport({ scale: scale * ratio });
|
||||
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = 'auto';
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return;
|
||||
|
||||
const task = pdfPage.render({ canvas, canvasContext: context, viewport });
|
||||
renderTaskRef.current = task;
|
||||
await task.promise;
|
||||
} catch (cause) {
|
||||
if ((cause as { name?: string })?.name !== 'RenderingCancelledException') {
|
||||
console.error('[pdf] Seite konnte nicht gezeichnet werden', cause);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (pageCount > 0) void renderPage(page);
|
||||
}, [page, pageCount, renderPage]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-border p-4 text-sm">
|
||||
<p className="text-destructive">{error}</p>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
PDF direkt öffnen
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div ref={containerRef} className="relative rounded-lg border border-border bg-muted/30 p-2">
|
||||
<canvas ref={canvasRef} aria-label={`${label}, Seite ${page}`} className="mx-auto block" />
|
||||
{loading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/60">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||
disabled={page <= 1 || loading}
|
||||
>
|
||||
<ChevronLeft />
|
||||
Zurück
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Seite {page} von {pageCount || '…'}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((current) => Math.min(pageCount, current + 1))}
|
||||
disabled={page >= pageCount || loading}
|
||||
>
|
||||
Weiter
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useTransition } from 'react';
|
||||
import { Gift, Loader2, Plus, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { deleteSet } from '@/app/(app)/katalog/actions';
|
||||
import { addSetToCollection } from '@/app/(app)/sammlung/actions';
|
||||
import { addSetsToWishlist, createWishlist } from '@/app/(app)/wunschlisten/actions';
|
||||
import { PRIORITY_LABELS } from '@/lib/wishlist-logic';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
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';
|
||||
|
||||
const NEW_LIST = '__neu__';
|
||||
|
||||
export function SetActions({
|
||||
setId,
|
||||
setName,
|
||||
owned,
|
||||
wishlists,
|
||||
}: {
|
||||
setId: string;
|
||||
setName: string;
|
||||
owned: boolean;
|
||||
wishlists: Array<{ id: string; title: string }>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [wishlistOpen, setWishlistOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [targetId, setTargetId] = useState(wishlists[0]?.id ?? NEW_LIST);
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [priority, setPriority] = useState('2');
|
||||
|
||||
function addToCollection() {
|
||||
startTransition(async () => {
|
||||
const result = await addSetToCollection(setId);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Zur Sammlung hinzugefügt.');
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function addToWishlist() {
|
||||
startTransition(async () => {
|
||||
let wishlistId = targetId;
|
||||
|
||||
if (wishlistId === NEW_LIST) {
|
||||
const created = await createWishlist({ title: newTitle, occasion: 'other' });
|
||||
if (!created.ok) {
|
||||
toast.error(created.error);
|
||||
return;
|
||||
}
|
||||
wishlistId = created.data.id;
|
||||
}
|
||||
|
||||
const result = await addSetsToWishlist({ wishlistId, setIds: [setId], priority });
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data.added === 0) toast.warning(result.data.message);
|
||||
else toast.success('Auf die Wunschliste gesetzt.');
|
||||
|
||||
setWishlistOpen(false);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function remove() {
|
||||
startTransition(async () => {
|
||||
const result = await deleteSet(setId);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Set gelöscht.');
|
||||
router.push('/katalog');
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="lg" onClick={addToCollection} disabled={pending}>
|
||||
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
|
||||
Zur Sammlung hinzufügen
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onClick={() => setWishlistOpen(true)}
|
||||
disabled={pending || owned}
|
||||
title={owned ? 'Das Set ist bereits in der Sammlung.' : undefined}
|
||||
>
|
||||
<Gift />
|
||||
Auf Wunschliste
|
||||
</Button>
|
||||
|
||||
<Button size="lg" variant="ghost" onClick={() => setDeleteOpen(true)} disabled={pending}>
|
||||
<Trash2 className="text-destructive" />
|
||||
Löschen
|
||||
</Button>
|
||||
|
||||
{owned ? (
|
||||
<p className="w-full text-xs text-muted-foreground">
|
||||
Sets aus der Sammlung lassen sich nicht auf eine Wunschliste setzen.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Dialog open={wishlistOpen} onOpenChange={setWishlistOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Auf Wunschliste setzen</DialogTitle>
|
||||
<DialogDescription>{setName}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Field label="Wunschliste" htmlFor="single-target" required>
|
||||
<NativeSelect
|
||||
id="single-target"
|
||||
value={targetId}
|
||||
onChange={(event) => setTargetId(event.target.value)}
|
||||
>
|
||||
{wishlists.map((list) => (
|
||||
<option key={list.id} value={list.id}>
|
||||
{list.title}
|
||||
</option>
|
||||
))}
|
||||
<option value={NEW_LIST}>+ Neue Wunschliste anlegen</option>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
|
||||
{targetId === NEW_LIST ? (
|
||||
<Field label="Titel der neuen Liste" htmlFor="single-new-title" required>
|
||||
<Input
|
||||
id="single-new-title"
|
||||
value={newTitle}
|
||||
onChange={(event) => setNewTitle(event.target.value)}
|
||||
placeholder="Weihnachten 2026"
|
||||
maxLength={80}
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
<Field label="Priorität" htmlFor="single-priority">
|
||||
<NativeSelect
|
||||
id="single-priority"
|
||||
value={priority}
|
||||
onChange={(event) => setPriority(event.target.value)}
|
||||
>
|
||||
{Object.entries(PRIORITY_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setWishlistOpen(false)} disabled={pending}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={addToWishlist} disabled={pending}>
|
||||
{pending ? <Loader2 className="animate-spin" /> : <Gift />}
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Set wirklich löschen?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
„{setName}“ wird aus dem Katalog entfernt – mit allen Sammlungseinträgen,
|
||||
Anleitungen, Fotos und Wunschlisten-Einträgen dazu. Das lässt sich nicht rückgängig
|
||||
machen.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={remove}>Löschen</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import Link from 'next/link';
|
||||
import { Check, Gift, ImageOff } from 'lucide-react';
|
||||
import { cn } from 'cn';
|
||||
import type { CatalogRow } from '@/lib/queries/catalog';
|
||||
import { formatEuro } from '@/lib/money';
|
||||
import { setImageSrc } from '@/lib/images';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
/**
|
||||
* One set in the catalog grid.
|
||||
*
|
||||
* Rendered as a plain block so it can be used both as a link (browsing) and
|
||||
* inside a selection wrapper (bulk add to a wishlist).
|
||||
*/
|
||||
export function SetCardContent({ set }: { set: CatalogRow }) {
|
||||
const image = setImageSrc(set);
|
||||
const owned = set.ownedCopies > 0;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-xl border border-border bg-card transition-colors">
|
||||
<div className="relative aspect-square bg-muted">
|
||||
{image ? (
|
||||
// Plain img: images are either local files or a URL the user entered,
|
||||
// and the optimizer must never fetch arbitrary remote hosts.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="size-full object-contain p-2"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full items-center justify-center text-muted-foreground">
|
||||
<ImageOff className="size-8" aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute top-2 left-2 flex flex-col items-start gap-1">
|
||||
{owned ? (
|
||||
<Badge className="gap-1 bg-emerald-600 text-white hover:bg-emerald-600">
|
||||
<Check className="size-3" aria-hidden />
|
||||
{set.ownedCopies > 1 ? `${set.ownedCopies}× vorhanden` : 'Vorhanden'}
|
||||
</Badge>
|
||||
) : null}
|
||||
{set.wishlistCount > 0 ? (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Gift className="size-3" aria-hidden />
|
||||
Auf Wunschliste
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1 p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{set.setNumber}
|
||||
</Badge>
|
||||
<span className="truncate">{set.manufacturerName}</span>
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm leading-snug font-medium">{set.name}</p>
|
||||
<div className="mt-auto flex items-center justify-between gap-2 pt-1 text-xs text-muted-foreground">
|
||||
<span className="truncate">{[set.themeName, set.year].filter(Boolean).join(' · ')}</span>
|
||||
{set.retailPriceCents !== null ? (
|
||||
<span className="shrink-0 font-medium text-foreground">
|
||||
{formatEuro(set.retailPriceCents)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SetCard({ set, className }: { set: CatalogRow; className?: string }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/katalog/${set.id}`}
|
||||
className={cn(
|
||||
'rounded-xl outline-none focus-visible:ring-3 focus-visible:ring-ring/50 [&>div]:hover:border-primary/40',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<SetCardContent set={set} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useTransition } from 'react';
|
||||
import { Loader2, Save } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { createSet, updateSet } from '@/app/(app)/katalog/actions';
|
||||
import { centsToInput } from '@/lib/money';
|
||||
import { setImageSrc } from '@/lib/images';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Field } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { NativeSelect } from '@/components/ui/native-select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
export type SetFormValues = {
|
||||
id: string;
|
||||
manufacturerId: string;
|
||||
themeId: string | null;
|
||||
setNumber: string;
|
||||
name: string;
|
||||
year: number | null;
|
||||
pieceCount: number | null;
|
||||
minifigCount: number | null;
|
||||
ean: string | null;
|
||||
ageRange: string | null;
|
||||
imageUrl: string | null;
|
||||
imagePath: string | null;
|
||||
retailPriceCents: number | null;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
manufacturers: Array<{ id: string; name: string }>;
|
||||
themes: Array<{ id: string; name: string; manufacturerId: string }>;
|
||||
set?: SetFormValues;
|
||||
/** Prefilled EAN coming from the barcode scanner (phase 2). */
|
||||
defaultEan?: string;
|
||||
};
|
||||
|
||||
export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [manufacturerId, setManufacturerId] = useState(
|
||||
set?.manufacturerId ?? manufacturers[0]?.id ?? '',
|
||||
);
|
||||
|
||||
const existingImage = set ? setImageSrc(set) : null;
|
||||
const availableThemes = themes.filter((theme) => theme.manufacturerId === manufacturerId);
|
||||
|
||||
function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
||||
startTransition(async () => {
|
||||
if (set) {
|
||||
const result = await updateSet(set.id, formData);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Set gespeichert.');
|
||||
router.push(`/katalog/${set.id}`);
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await createSet(formData);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Set angelegt.');
|
||||
router.push(`/katalog/${result.data.id}`);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Hersteller" htmlFor="manufacturerId" required>
|
||||
<NativeSelect
|
||||
id="manufacturerId"
|
||||
name="manufacturerId"
|
||||
value={manufacturerId}
|
||||
onChange={(event) => setManufacturerId(event.target.value)}
|
||||
required
|
||||
>
|
||||
{manufacturers.map((manufacturer) => (
|
||||
<option key={manufacturer.id} value={manufacturer.id}>
|
||||
{manufacturer.name}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
|
||||
<Field label="Thema" htmlFor="themeId" hint="Optional.">
|
||||
<NativeSelect id="themeId" name="themeId" defaultValue={set?.themeId ?? 'none'}>
|
||||
<option value="none">– kein Thema –</option>
|
||||
{availableThemes.map((theme) => (
|
||||
<option key={theme.id} value={theme.id}>
|
||||
{theme.name}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
|
||||
<Field label="Setnummer" htmlFor="setNumber" required hint="Die Nummer des Herstellers.">
|
||||
<Input
|
||||
id="setNumber"
|
||||
name="setNumber"
|
||||
defaultValue={set?.setNumber ?? ''}
|
||||
required
|
||||
maxLength={40}
|
||||
placeholder="60398"
|
||||
className="font-mono"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Name" htmlFor="name" required className="sm:col-span-1">
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
defaultValue={set?.name ?? ''}
|
||||
required
|
||||
maxLength={160}
|
||||
placeholder="Familienhaus und Elektroauto"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Jahr" htmlFor="year">
|
||||
<Input
|
||||
id="year"
|
||||
name="year"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
defaultValue={set?.year ?? ''}
|
||||
min={1930}
|
||||
max={new Date().getFullYear() + 5}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Teile" htmlFor="pieceCount">
|
||||
<Input
|
||||
id="pieceCount"
|
||||
name="pieceCount"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
defaultValue={set?.pieceCount ?? ''}
|
||||
min={0}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Minifiguren" htmlFor="minifigCount">
|
||||
<Input
|
||||
id="minifigCount"
|
||||
name="minifigCount"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
defaultValue={set?.minifigCount ?? ''}
|
||||
min={0}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="UVP" htmlFor="retailPriceCents" hint="In Euro, z. B. 89,99.">
|
||||
<Input
|
||||
id="retailPriceCents"
|
||||
name="retailPriceCents"
|
||||
inputMode="decimal"
|
||||
defaultValue={centsToInput(set?.retailPriceCents)}
|
||||
placeholder="89,99"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="EAN" htmlFor="ean" hint="Der Strichcode auf der Verpackung.">
|
||||
<Input
|
||||
id="ean"
|
||||
name="ean"
|
||||
inputMode="numeric"
|
||||
defaultValue={set?.ean ?? defaultEan ?? ''}
|
||||
maxLength={20}
|
||||
className="font-mono"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Alter" htmlFor="ageRange" hint="Zum Beispiel 6-12.">
|
||||
<Input id="ageRange" name="ageRange" defaultValue={set?.ageRange ?? ''} maxLength={20} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Bild-URL"
|
||||
htmlFor="imageUrl"
|
||||
hint="Wird nur verwendet, wenn kein Bild hochgeladen ist."
|
||||
>
|
||||
<Input
|
||||
id="imageUrl"
|
||||
name="imageUrl"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
defaultValue={set?.imageUrl ?? ''}
|
||||
placeholder="https://…"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="image">Bild hochladen</Label>
|
||||
{existingImage ? (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={existingImage}
|
||||
alt=""
|
||||
className="size-20 rounded-lg border border-border object-contain"
|
||||
/>
|
||||
{set?.imagePath ? (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox name="removeImage" value="on" />
|
||||
Hochgeladenes Bild entfernen
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<Input id="image" name="image" type="file" accept="image/jpeg,image/png,image/webp" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
JPEG, PNG oder WebP. Das Bild wird beim Speichern verkleinert und lokal abgelegt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Field label="Notizen" htmlFor="notes">
|
||||
<Textarea id="notes" name="notes" defaultValue={set?.notes ?? ''} rows={4} />
|
||||
</Field>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" size="lg" disabled={pending}>
|
||||
{pending ? <Loader2 className="animate-spin" /> : <Save />}
|
||||
{set ? 'Speichern' : 'Set anlegen'}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="lg" onClick={() => router.back()}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useTransition } from 'react';
|
||||
import { Loader2, Pencil, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { deleteCollectionItem, updateCollectionItem } from '@/app/(app)/sammlung/actions';
|
||||
import type { CollectionStatus, Completeness, Condition } from '@/db/types';
|
||||
import { formatDate, toDateInputValue } from '@/lib/format';
|
||||
import {
|
||||
COMPLETENESS_LABELS,
|
||||
CONDITION_LABELS,
|
||||
STATUS_LABELS,
|
||||
completenessLabel,
|
||||
conditionLabel,
|
||||
statusLabel,
|
||||
} from '@/lib/labels';
|
||||
import { centsToInput, formatEuro } from '@/lib/money';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
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';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
export type CollectionItemRow = {
|
||||
id: string;
|
||||
setId: string;
|
||||
status: CollectionStatus;
|
||||
completeness: Completeness;
|
||||
condition: Condition | null;
|
||||
locationId: string | null;
|
||||
locationName?: string | null;
|
||||
acquiredAt: Date | null;
|
||||
acquiredPriceCents: number | null;
|
||||
acquiredFrom: string | null;
|
||||
giftedBy: string | null;
|
||||
lentOutTo: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
export function CollectionItemsSection({
|
||||
items,
|
||||
locations,
|
||||
}: {
|
||||
items: CollectionItemRow[];
|
||||
locations: Array<{ id: string; name: string }>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [editing, setEditing] = useState<CollectionItemRow | null>(null);
|
||||
const [deleting, setDeleting] = useState<CollectionItemRow | null>(null);
|
||||
|
||||
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const item = editing;
|
||||
if (!item) return;
|
||||
|
||||
const input = Object.fromEntries(new FormData(event.currentTarget).entries());
|
||||
startTransition(async () => {
|
||||
const result = await updateCollectionItem(item.id, { ...input, setId: item.setId });
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Sammlungseintrag gespeichert.');
|
||||
setEditing(null);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function remove() {
|
||||
const item = deleting;
|
||||
if (!item) return;
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await deleteCollectionItem(item.id);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Sammlungseintrag entfernt.');
|
||||
setDeleting(null);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dieses Set ist noch nicht in der Sammlung.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="flex items-start gap-3 p-3">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={item.status === 'sold' ? 'outline' : 'default'}>
|
||||
{statusLabel(item.status)}
|
||||
</Badge>
|
||||
<Badge variant="secondary">{completenessLabel(item.completeness)}</Badge>
|
||||
{item.locationName ? (
|
||||
<span className="text-xs text-muted-foreground">{item.locationName}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{[
|
||||
item.acquiredAt ? `Seit ${formatDate(item.acquiredAt)}` : null,
|
||||
item.acquiredPriceCents !== null
|
||||
? `Gekauft für ${formatEuro(item.acquiredPriceCents)}`
|
||||
: null,
|
||||
item.giftedBy ? `Geschenk von ${item.giftedBy}` : null,
|
||||
item.lentOutTo ? `Verliehen an ${item.lentOutTo}` : null,
|
||||
item.condition ? conditionLabel(item.condition) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ') || 'Keine weiteren Angaben'}
|
||||
</p>
|
||||
{item.notes ? <p className="text-xs">{item.notes}</p> : null}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Bearbeiten"
|
||||
onClick={() => setEditing(item)}
|
||||
disabled={pending}
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Entfernen"
|
||||
onClick={() => setDeleting(item)}
|
||||
disabled={pending}
|
||||
>
|
||||
<Trash2 className="text-destructive" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Dialog open={editing !== null} onOpenChange={(open) => !open && setEditing(null)}>
|
||||
<DialogContent className="max-h-[85dvh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sammlungseintrag bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form id="collection-item-form" onSubmit={submit} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Status" htmlFor="status">
|
||||
<NativeSelect id="status" name="status" defaultValue={editing?.status ?? 'owned'}>
|
||||
{Object.entries(STATUS_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
|
||||
<Field label="Vollständigkeit" htmlFor="completeness">
|
||||
<NativeSelect
|
||||
id="completeness"
|
||||
name="completeness"
|
||||
defaultValue={editing?.completeness ?? 'unknown'}
|
||||
>
|
||||
{Object.entries(COMPLETENESS_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
|
||||
<Field label="Zustand" htmlFor="condition">
|
||||
<NativeSelect id="condition" name="condition" defaultValue={editing?.condition ?? 'none'}>
|
||||
<option value="none">– keine Angabe –</option>
|
||||
{Object.entries(CONDITION_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
|
||||
<Field label="Standort" htmlFor="locationId">
|
||||
<NativeSelect
|
||||
id="locationId"
|
||||
name="locationId"
|
||||
defaultValue={editing?.locationId ?? 'none'}
|
||||
>
|
||||
<option value="none">– kein Standort –</option>
|
||||
{locations.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
|
||||
<Field label="Im Besitz seit" htmlFor="acquiredAt">
|
||||
<Input
|
||||
id="acquiredAt"
|
||||
name="acquiredAt"
|
||||
type="date"
|
||||
defaultValue={toDateInputValue(editing?.acquiredAt)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Kaufpreis" htmlFor="acquiredPriceCents" hint="In Euro.">
|
||||
<Input
|
||||
id="acquiredPriceCents"
|
||||
name="acquiredPriceCents"
|
||||
inputMode="decimal"
|
||||
defaultValue={centsToInput(editing?.acquiredPriceCents)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Gekauft bei" htmlFor="acquiredFrom">
|
||||
<Input
|
||||
id="acquiredFrom"
|
||||
name="acquiredFrom"
|
||||
defaultValue={editing?.acquiredFrom ?? ''}
|
||||
maxLength={120}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Geschenkt von" htmlFor="giftedBy">
|
||||
<Input
|
||||
id="giftedBy"
|
||||
name="giftedBy"
|
||||
defaultValue={editing?.giftedBy ?? ''}
|
||||
maxLength={120}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Verliehen an" htmlFor="lentOutTo">
|
||||
<Input
|
||||
id="lentOutTo"
|
||||
name="lentOutTo"
|
||||
defaultValue={editing?.lentOutTo ?? ''}
|
||||
maxLength={120}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Notizen" htmlFor="item-notes">
|
||||
<Textarea id="item-notes" name="notes" defaultValue={editing?.notes ?? ''} rows={3} />
|
||||
</Field>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditing(null)} disabled={pending}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" form="collection-item-form" disabled={pending}>
|
||||
{pending ? <Loader2 className="animate-spin" /> : null}
|
||||
Speichern
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={deleting !== null} onOpenChange={(open) => !open && setDeleting(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Eintrag entfernen?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Das Set bleibt im Katalog, gilt danach aber wieder als „fehlt noch“.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={remove}>Entfernen</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user