diff --git a/src/app/(app)/katalog/actions.ts b/src/app/(app)/katalog/actions.ts index 82c180d..44cecc1 100644 --- a/src/app/(app)/katalog/actions.ts +++ b/src/app/(app)/katalog/actions.ts @@ -5,8 +5,10 @@ import { and, eq, ne } from 'drizzle-orm'; import { db } from '@/db'; import { instruction, photo, set } from '@/db/schema'; import { actionData, actionError, actionOk, formToObject, parseForm, type ActionResult } from '@/lib/action-result'; +import { env } from '@/env'; +import { safeFetch, SafeFetchError } from '@/lib/safe-fetch'; import { requireUser } from '@/lib/session'; -import { deleteStoredFile, storeImage, storePdf, UploadError } from '@/lib/uploads'; +import { deleteStoredFile, storeImage, storeImageBytes, storePdf, UploadError } from '@/lib/uploads'; import { optionalText, optionalUrl, setSchema } from '@/lib/validation'; import { z } from 'zod'; @@ -20,6 +22,23 @@ function revalidateSet(id?: string) { if (id) revalidatePath(`/katalog/${id}`); } +async function cacheRemoteImage(url: string, setId: string): Promise { + try { + const response = await safeFetch(url, { + accept: 'image/webp,image/png,image/jpeg;q=0.9,*/*;q=0.5', + maxBytes: env.MAX_UPLOAD_BYTES, + }); + if (response.status !== 200) return null; + return (await storeImageBytes(response.body, 'set-image', setId)).filePath; + } catch (error) { + if (error instanceof SafeFetchError || error instanceof UploadError) { + console.error('[katalog] Bild konnte nicht übernommen werden', url, error.message); + return null; + } + throw error; + } +} + function fileFrom(formData: FormData, field: string): File | null { const value = formData.get(field); return value instanceof File && value.size > 0 ? value : null; @@ -60,12 +79,17 @@ export async function createSet(formData: FormData): Promise /^https?:\/\//i.test(value), 'Nur http- und https-Links.'), +}); + +/** Manufacturer website for the ones we create on the fly. */ +const WEBSITES: Record = { + Lego: 'https://www.lego.com/de-de', + Playmobil: 'https://www.playmobil.de', + Cobi: 'https://cobi.pl', + BlueBrixx: 'https://www.bluebrixx.com', + 'Mega Bloks': 'https://www.megabrands.com', +}; + +/** Merlins Bricks keys its set pages by a brand prefix. */ +const MERLIN_PREFIX: Record = { BlueBrixx: 'bb', Cobi: 'cobi' }; + +function key(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +function ensureManufacturer(name: string): string { + const rows = db + .select({ id: manufacturer.id, name: manufacturer.name, slug: manufacturer.slug }) + .from(manufacturer) + .all(); + + const match = rows.find((row) => key(row.name) === key(name) || row.slug === key(name)); + if (match) return match.id; + + const id = crypto.randomUUID(); + const now = new Date(); + db.insert(manufacturer) + .values({ + id, + name, + slug: uniqueSlug(name, rows.map((row) => row.slug)), + website: WEBSITES[name] ?? null, + createdAt: now, + updatedAt: now, + }) + .run(); + return id; +} + +async function readPage(url: URL): Promise<{ hints: LinkHints | null; finalUrl: URL; blocked: boolean }> { + const response = await safeFetch(url); + const finalUrl = new URL(response.url); + const isHtml = /html|xml/i.test(response.contentType); + + if (response.status !== 200 || !isHtml) return { hints: null, finalUrl, blocked: true }; + return { hints: hintsFromHtml(response.body.toString('utf8'), finalUrl), finalUrl, blocked: false }; +} + +async function fromRebrickable(setNumber: string): Promise<(LinkHints & { minifigCount?: number }) | null> { + if (!isRebrickableEnabled()) return null; + try { + const remote = await fetchSet(setNumber); + if (!remote) return null; + const minifigs = await fetchMinifigCount(remote.setNumber); + return { + name: remote.name, + year: remote.year ?? undefined, + pieceCount: remote.pieceCount ?? undefined, + imageUrl: remote.imageUrl ?? undefined, + minifigCount: minifigs ?? undefined, + }; + } catch (error) { + if (error instanceof RebrickableError) return null; + throw error; + } +} + +async function fromMerlinsBricks(brand: string, setNumber: string): Promise { + const prefix = MERLIN_PREFIX[brand]; + if (!prefix || !/^[\w-]+$/.test(setNumber)) return null; + try { + const page = await readPage(new URL(`https://www.merlinsbricks.com/sets/${prefix}-${setNumber}/`)); + return page.hints; + } catch { + return null; + } +} + +export async function previewSetFromLink(input: unknown): Promise> { + await requireUser(); + + const parsed = inputSchema.safeParse(input); + if (!parsed.success) return actionError(parsed.error.issues[0]?.message ?? 'Ungültiger Link.'); + + const url = new URL(parsed.data.url); + const sources: string[] = []; + + // 1. The page itself, if it lets us in. A short link (amzn.eu/...) still + // tells us where it leads, even when the target then refuses. + let pageHints: LinkHints | null = null; + let finalUrl = url; + let pageBlocked = true; + try { + const page = await readPage(url); + pageHints = page.hints; + finalUrl = page.finalUrl; + pageBlocked = page.blocked; + } catch (error) { + if (!(error instanceof SafeFetchError)) throw error; + if (/intern/i.test(error.message)) return actionError(error.message); + } + if (pageHints) sources.push(sourceLabel(finalUrl)); + + // 2. The address. + const addressHints = mergeHints(hintsFromUrl(finalUrl), hintsFromUrl(url)); + if (!pageHints || pageBlocked) sources.push(`Link (${sourceLabel(finalUrl)})`); + + let hints = mergeHints(pageHints, addressHints); + let minifigCount: number | null = null; + + // 3. A set database for the brand. + if (hints.manufacturer === 'Lego' && hints.setNumber) { + const remote = await fromRebrickable(hints.setNumber); + if (remote) { + // The shop's German name beats Rebrickable's English one; Rebrickable's + // facts and clean picture beat the shop's. + hints = mergeHints({ name: pageHints?.name ?? addressHints.name, ean: hints.ean }, remote, hints); + minifigCount = remote.minifigCount ?? null; + sources.push('Rebrickable'); + } + } else if (hints.manufacturer && hints.setNumber && !/merlinsbricks\./.test(finalUrl.hostname)) { + const merlin = await fromMerlinsBricks(hints.manufacturer, hints.setNumber); + if (merlin) { + hints = mergeHints(hints, merlin); + sources.push('Merlins Bricks'); + } + } + + if (!hints.name && !hints.setNumber) { + return actionError( + pageBlocked + ? `${sourceLabel(finalUrl)} lässt keine Abfrage zu, und der Link selbst verrät nichts über das Set. Versuch es mit einem anderen Link oder trag das Set von Hand ein.` + : 'Auf der Seite stehen keine Produktdaten, mit denen sich etwas anfangen lässt.', + ); + } + + const manufacturerName = hints.manufacturer ?? null; + const manufacturerId = manufacturerName ? ensureManufacturer(manufacturerName) : null; + const setNumber = hints.setNumber ?? ''; + + const existing = + manufacturerId && setNumber + ? db + .select({ id: set.id }) + .from(set) + .where(and(eq(set.manufacturerId, manufacturerId), eq(set.setNumber, setNumber))) + .get() + : undefined; + + return actionData({ + manufacturerId, + manufacturerName, + setNumber, + name: hints.name ? cleanSetName(hints.name, manufacturerName ?? undefined, setNumber) : '', + year: hints.year ?? null, + pieceCount: hints.pieceCount ?? null, + minifigCount, + ean: hints.ean ?? '', + imageUrl: hints.imageUrl ?? '', + sourceUrl: url.toString(), + sources, + pageBlocked, + existingSetId: existing?.id ?? null, + }); +} diff --git a/src/app/(app)/katalog/neu/page.tsx b/src/app/(app)/katalog/neu/page.tsx index 8abdeab..e19c47d 100644 --- a/src/app/(app)/katalog/neu/page.tsx +++ b/src/app/(app)/katalog/neu/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from 'next'; import Link from 'next/link'; +import { NewSetEntry } from '@/components/katalog/link-import'; import { RebrickableImport } from '@/components/katalog/rebrickable-import'; -import { SetForm } from '@/components/katalog/set-form'; import { Button } from '@/components/ui/button'; import { listManufacturers, listThemes } from '@/lib/queries/master-data'; import { isRebrickableEnabled } from '@/lib/rebrickable'; @@ -25,26 +25,8 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea const importEnabled = isRebrickableEnabled(); - if (manufacturers.length === 0) { - return ( -
-

Neues Set

- - {/* The Lego import creates its manufacturer itself, so it works even - on a completely empty instance. */} - - -

- Für ein Set von Hand brauchst du zuerst einen Hersteller – Lego, Playmobil, Cobi oder - was auch immer im Kinderzimmer steht. -

- -
- ); - } - + // One layout for both cases: the link import may create the first + // manufacturer, and its draft must survive the refresh that follows. return (
@@ -54,15 +36,19 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea Sammlung hinzufügen“ fest.

+ {/* The Lego import creates its manufacturer itself, so it works even + on a completely empty instance. */} -
- - oder von Hand - -
- - + +

+ Für ein Set von Hand brauchst du zuerst einen Hersteller – Lego, Playmobil, Cobi oder + was auch immer im Kinderzimmer steht. +

+ +
); } diff --git a/src/components/katalog/link-import.tsx b/src/components/katalog/link-import.tsx new file mode 100644 index 0000000..2db3bed --- /dev/null +++ b/src/components/katalog/link-import.tsx @@ -0,0 +1,155 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; +import { Link2, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { previewSetFromLink, type LinkPreview } from '@/app/(app)/katalog/link-actions'; +import { SetForm, type SetPrefill } from '@/components/katalog/set-form'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Field } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; + +function toPrefill(preview: LinkPreview): SetPrefill { + return { + manufacturerId: preview.manufacturerId ?? undefined, + setNumber: preview.setNumber || undefined, + name: preview.name || undefined, + year: preview.year, + pieceCount: preview.pieceCount, + minifigCount: preview.minifigCount, + ean: preview.ean || null, + imageUrl: preview.imageUrl || null, + cacheImage: Boolean(preview.imageUrl), + notes: `Übernommen aus: ${preview.sourceUrl}`, + }; +} + +/** + * "Neues Set" with the link import on top: paste a link from a shop, a price + * comparison or a set database, check the prefilled form, save. + */ +export function NewSetEntry({ + manufacturers, + themes, + defaultEan, + children, +}: { + manufacturers: Array<{ id: string; name: string }>; + themes: Array<{ id: string; name: string; manufacturerId: string }>; + defaultEan?: string; + /** Shown instead of the form while there is no manufacturer yet. */ + children?: React.ReactNode; +}) { + const router = useRouter(); + const [url, setUrl] = useState(''); + const [prefill, setPrefill] = useState(undefined); + const [version, setVersion] = useState(0); + const [existing, setExisting] = useState<{ id: string; label: string } | null>(null); + const [pending, startTransition] = useTransition(); + + function submit(event: React.FormEvent) { + event.preventDefault(); + setExisting(null); + + startTransition(async () => { + const result = await previewSetFromLink({ url }); + if (!result.ok) { + toast.error(result.error); + return; + } + + const preview = result.data; + if (preview.existingSetId) { + setExisting({ + id: preview.existingSetId, + label: [preview.setNumber, preview.name].filter(Boolean).join(' · '), + }); + return; + } + + setPrefill(toPrefill(preview)); + setVersion((value) => value + 1); + // A manufacturer may just have been created for the draft. + router.refresh(); + + const missing = [ + preview.manufacturerId ? null : 'Hersteller', + preview.setNumber ? null : 'Setnummer', + preview.name ? null : 'Name', + ].filter(Boolean); + + toast.success( + missing.length === 0 + ? `Übernommen aus ${preview.sources.join(', ')}. Bitte kurz prüfen und speichern.` + : `Übernommen aus ${preview.sources.join(', ')} – ${missing.join(', ')} bitte ergänzen.`, + ); + if (preview.pageBlocked) { + toast.info('Die Seite selbst ließ sich nicht lesen – die Angaben stammen aus dem Link.'); + } + }); + } + + return ( + <> + + + Aus einem Link übernehmen + + Link zu Amazon, idealo, geizhals, zum Shop des Herstellers oder zu einer Set-Datenbank + einfügen. Die Angaben landen im Formular unten – gespeichert wird erst, wenn du es + sagst. + + + +
+ + setUrl(event.target.value)} + placeholder="https://…" + maxLength={2000} + /> + + +
+ + {existing ? ( +

+ Das Set ist schon im Katalog:{' '} + + {existing.label} + +

+ ) : null} +
+
+ +
+ + {prefill ? 'Bitte prüfen und speichern' : 'oder von Hand'} + +
+ + {manufacturers.length === 0 ? ( + children + ) : ( + + )} + + ); +} diff --git a/src/components/katalog/set-form.tsx b/src/components/katalog/set-form.tsx index 0b2ac7e..eeee908 100644 --- a/src/components/katalog/set-form.tsx +++ b/src/components/katalog/set-form.tsx @@ -47,19 +47,26 @@ export type SetFormValues = { notes: string | null; }; +/** A draft for a new set, e.g. from the link import. */ +export type SetPrefill = Partial> & { + /** Download imageUrl on save and keep it locally. */ + cacheImage?: boolean; +}; + 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; + prefill?: SetPrefill; }; -export function SetForm({ manufacturers, themes, set, defaultEan }: Props) { +export function SetForm({ manufacturers, themes, set, defaultEan, prefill }: Props) { const router = useRouter(); const [pending, startTransition] = useTransition(); const [manufacturerId, setManufacturerId] = useState( - set?.manufacturerId ?? manufacturers[0]?.id ?? '', + set?.manufacturerId ?? prefill?.manufacturerId ?? manufacturers[0]?.id ?? '', ); const [duplicates, setDuplicates] = useState(null); const pendingForm = useRef(null); @@ -167,7 +174,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) { @@ -204,7 +211,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) { name="pieceCount" type="number" inputMode="numeric" - defaultValue={set?.pieceCount ?? ''} + defaultValue={set?.pieceCount ?? prefill?.pieceCount ?? ''} min={0} /> @@ -215,7 +222,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) { name="minifigCount" type="number" inputMode="numeric" - defaultValue={set?.minifigCount ?? ''} + defaultValue={set?.minifigCount ?? prefill?.minifigCount ?? ''} min={0} /> @@ -235,7 +242,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) { id="ean" name="ean" inputMode="numeric" - defaultValue={set?.ean ?? defaultEan ?? ''} + defaultValue={set?.ean ?? prefill?.ean ?? defaultEan ?? ''} maxLength={20} className="font-mono" /> @@ -256,10 +263,18 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) { name="imageUrl" type="url" inputMode="url" - defaultValue={set?.imageUrl ?? ''} + defaultValue={set?.imageUrl ?? prefill?.imageUrl ?? ''} placeholder="https://…" /> + {prefill?.cacheImage && prefill.imageUrl ? ( + <> + +

+ Das Bild aus dem Link wird beim Speichern heruntergeladen und lokal abgelegt. +

+ + ) : null}
@@ -286,7 +301,12 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
-