diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e03225..0ad176d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/). ## [Unreleased] +### Hinzugefügt + +- **Set aus einem Link übernehmen**, für alle Hersteller: Amazon, idealo, + geizhals, Hersteller-Shops, Brickfact oder Merlins Bricks. Bricktrack liest + Produktdaten (schema.org, Open Graph, bei Amazon die Produktseite) und, wenn + die Seite blockt, den Link selbst. Lego wird über Rebrickable ergänzt, + BlueBrixx und Cobi über Merlins Bricks. Das Ergebnis füllt das Formular vor, + das Bild wird beim Speichern lokal abgelegt. + +### Sicherheit + +- Abrufe im Auftrag eines Nutzers gehen nur an öffentliche Adressen: Jede + Adresse wird vor dem Verbindungsaufbau geprüft, Weiterleitungen ebenso, + Größe und Zeit sind begrenzt. + ## [0.4.0] - 2026-10-25 Ein neues Gesicht: Bricktrack sieht jetzt aus wie das, was es verwaltet. diff --git a/CLAUDE.md b/CLAUDE.md index 41a476e..19ca94f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,6 +63,8 @@ Bild-URLs, die der Nutzer selbst hinterlegt hat. | `src/lib/reminder-job.ts`, `src/lib/mailer.ts` | Versand und Zeitplan | | `src/lib/export.ts` | Export und Import des gesamten Bestands | | `src/lib/money.ts`, `src/lib/format.ts` | Cent-Umrechnung, dd.MM.yyyy | +| `src/lib/link-import.ts` | Set-Daten aus Link und Seite lesen (rein, testbar) | +| `src/lib/safe-fetch.ts`, `src/lib/net-guard.ts` | Abruf fremder URLs nur an oeffentliche Adressen | | `src/lib/design.ts`, `src/app/designs.css` | Designs: Namen und Cookie bzw. Tokens je `data-design` | | `src/components/brick/` | Noppen, Logo, Platzhalter fuer Sets ohne Bild | @@ -195,7 +197,9 @@ Session. nichts preis ausser den Wunschlisten-Daten - keine Nutzernamen, keine Sammlungsdaten, keine IDs anderer Objekte. 4. Externe API-Aufrufe (Rebrickable, ab Phase 2) laufen ausschliesslich - serverseitig. + serverseitig. URLs, die ein Nutzer eingibt (Link-Import, Bild-Uebernahme), + werden nur ueber `safeFetch` abgerufen - nie mit `fetch` direkt, sonst + erreicht der Server auch Geraete im Heimnetz. 5. Session-Cookies: `httpOnly`, `secure`, `sameSite=lax`. Rate-Limiting auf den Login-Endpunkten. 6. Der Export enthaelt **keine** Konten, Passwoerter oder Passkeys - und diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index 915b257..8d259c8 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -306,8 +306,35 @@ gib ihnen sprechende Namen. ## 11. Import und Scanner -Sets von Hand einzutippen ist der langsame Weg. Für Lego gibt es zwei -schnellere. +Sets von Hand einzutippen ist der langsame Weg. Es gibt drei schnellere. + +### Aus einem Link übernehmen – für alle Hersteller + +Auf **Katalog → Set anlegen** steht das Feld „Aus einem Link übernehmen". +Dort passt jeder Link zu einem Set hinein: Amazon, idealo, geizhals, der Shop +des Herstellers oder eine Set-Datenbank wie +[Brickfact](https://brickfact.com) (Lego) und +[Merlins Bricks](https://www.merlinsbricks.com) (BlueBrixx, Cobi und viele +mehr). „Übernehmen" füllt das Formular darunter aus – **gespeichert wird erst, +wenn du „Set anlegen" drückst**. Ein Blick auf Name und Nummer lohnt sich, +denn Shops schreiben gern das halbe Sortiment in den Titel. + +Was dabei passiert: + +1. Bricktrack liest die Seite und übernimmt Name, Setnummer, EAN, Bild, + Teile und Jahr, soweit sie dort stehen. +2. Manche Shops lassen sich nicht abfragen. Dann reicht oft schon der Link + selbst: In `amazon.de/LEGO-60398-Familienhaus-…` stecken Hersteller, + Nummer und Name. +3. Mit Hersteller und Nummer ergänzt Bricktrack den Rest: Lego über + Rebrickable (mit API-Key), BlueBrixx und Cobi über Merlins Bricks. + +Das Bild wird beim Speichern lokal abgelegt, die Quelle steht in den Notizen. +Gibt es den Hersteller noch nicht, wird er angelegt. Steht das Set schon im +Katalog, kommt statt des Formulars ein Link zum vorhandenen Eintrag. + +> Bricktrack ruft dafür nur öffentliche Adressen ab. Links ins eigene Netz – +> Router, NAS, andere Container – werden abgewiesen. ### Lego-Import über die Setnummer diff --git a/docs/bilder/02-start.png b/docs/bilder/02-start.png index 7f43ae3..05d013f 100644 Binary files a/docs/bilder/02-start.png and b/docs/bilder/02-start.png differ diff --git a/docs/bilder/03-verwaltung-hersteller.png b/docs/bilder/03-verwaltung-hersteller.png index 183ecc2..a416427 100644 Binary files a/docs/bilder/03-verwaltung-hersteller.png and b/docs/bilder/03-verwaltung-hersteller.png differ diff --git a/docs/bilder/05-fehlende-sets.png b/docs/bilder/05-fehlende-sets.png index cba66cc..3539d31 100644 Binary files a/docs/bilder/05-fehlende-sets.png and b/docs/bilder/05-fehlende-sets.png differ diff --git a/docs/bilder/06-mehrfachauswahl.png b/docs/bilder/06-mehrfachauswahl.png index 4ee6f3d..c05e66e 100644 Binary files a/docs/bilder/06-mehrfachauswahl.png and b/docs/bilder/06-mehrfachauswahl.png differ diff --git a/docs/bilder/07-wunschliste-dialog.png b/docs/bilder/07-wunschliste-dialog.png index 27d6ec0..d423f4f 100644 Binary files a/docs/bilder/07-wunschliste-dialog.png and b/docs/bilder/07-wunschliste-dialog.png differ diff --git a/docs/bilder/09-sammlung.png b/docs/bilder/09-sammlung.png index 4ebca63..bb83224 100644 Binary files a/docs/bilder/09-sammlung.png and b/docs/bilder/09-sammlung.png differ diff --git a/docs/bilder/10-wunschlisten.png b/docs/bilder/10-wunschlisten.png index cd9e6aa..fdcef8b 100644 Binary files a/docs/bilder/10-wunschlisten.png and b/docs/bilder/10-wunschlisten.png differ diff --git a/docs/bilder/11-wunschliste-bearbeiten.png b/docs/bilder/11-wunschliste-bearbeiten.png index 289ed5c..6584a66 100644 Binary files a/docs/bilder/11-wunschliste-bearbeiten.png and b/docs/bilder/11-wunschliste-bearbeiten.png differ diff --git a/docs/bilder/13-reservieren.png b/docs/bilder/13-reservieren.png index 832bdb9..26043cc 100644 Binary files a/docs/bilder/13-reservieren.png and b/docs/bilder/13-reservieren.png differ diff --git a/docs/bilder/15-wunschliste-reserviert.png b/docs/bilder/15-wunschliste-reserviert.png index 5bc421d..e39df29 100644 Binary files a/docs/bilder/15-wunschliste-reserviert.png and b/docs/bilder/15-wunschliste-reserviert.png differ diff --git a/docs/bilder/17-lego-import.png b/docs/bilder/17-lego-import.png index 69db989..5007027 100644 Binary files a/docs/bilder/17-lego-import.png and b/docs/bilder/17-lego-import.png differ diff --git a/docs/bilder/18-fehlteile.png b/docs/bilder/18-fehlteile.png index 63c926a..2f03d9d 100644 Binary files a/docs/bilder/18-fehlteile.png and b/docs/bilder/18-fehlteile.png differ diff --git a/docs/bilder/19-einladungen.png b/docs/bilder/19-einladungen.png index fc6aa12..f234a94 100644 Binary files a/docs/bilder/19-einladungen.png and b/docs/bilder/19-einladungen.png differ diff --git a/docs/bilder/21-sammler.png b/docs/bilder/21-sammler.png index 7b86943..454c589 100644 Binary files a/docs/bilder/21-sammler.png and b/docs/bilder/21-sammler.png differ diff --git a/docs/bilder/23-preise.png b/docs/bilder/23-preise.png index 18cac30..32dea35 100644 Binary files a/docs/bilder/23-preise.png and b/docs/bilder/23-preise.png differ 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) {
-