@xiwebdesign/xicms-core 5.2.0 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/XiCMSProvider.d.ts +11 -2
  2. package/dist/XiCMSProvider.js +10 -5
  3. package/dist/XiCMSProvider.js.map +1 -1
  4. package/dist/admin/blockEditors/GenericEditor.js +18 -8
  5. package/dist/admin/blockEditors/GenericEditor.js.map +1 -1
  6. package/dist/admin/blockEditors/index.js +2 -2
  7. package/dist/admin/blockEditors/index.js.map +1 -1
  8. package/dist/admin/customBlocks/CustomBlocksPanel.js +12 -2
  9. package/dist/admin/customBlocks/CustomBlocksPanel.js.map +1 -1
  10. package/dist/admin/lib/pagesClient.js +4 -1
  11. package/dist/admin/lib/pagesClient.js.map +1 -1
  12. package/dist/admin/lib/upload.js +14 -7
  13. package/dist/admin/lib/upload.js.map +1 -1
  14. package/dist/admin/media/MediaManager.js +60 -28
  15. package/dist/admin/media/MediaManager.js.map +1 -1
  16. package/dist/admin/media/MediaPicker.js +25 -15
  17. package/dist/admin/media/MediaPicker.js.map +1 -1
  18. package/dist/admin/pages/PageEditor.d.ts +2 -1
  19. package/dist/admin/pages/PageEditor.js +16 -2
  20. package/dist/admin/pages/PageEditor.js.map +1 -1
  21. package/dist/admin/pages/PagesPanel.js +9 -5
  22. package/dist/admin/pages/PagesPanel.js.map +1 -1
  23. package/dist/admin/visual/VisualEditor.js +11 -2
  24. package/dist/admin/visual/VisualEditor.js.map +1 -1
  25. package/dist/agentFiles/files.d.ts +15 -3
  26. package/dist/agentFiles/files.js +57 -0
  27. package/dist/agentFiles/files.js.map +1 -1
  28. package/dist/auth/AuthProvider.js +3 -3
  29. package/dist/auth/AuthProvider.js.map +1 -1
  30. package/dist/blocks/builtins/Booking.js +1 -1
  31. package/dist/blocks/builtins/Booking.js.map +1 -1
  32. package/dist/blocks/builtins/Columns.js +1 -1
  33. package/dist/blocks/builtins/Columns.js.map +1 -1
  34. package/dist/blocks/builtins/Embed.js +2 -0
  35. package/dist/blocks/builtins/Embed.js.map +1 -1
  36. package/dist/blocks/builtins/Gallery.js +2 -2
  37. package/dist/blocks/builtins/Gallery.js.map +1 -1
  38. package/dist/blocks/builtins/Hero.js +46 -7
  39. package/dist/blocks/builtins/Hero.js.map +1 -1
  40. package/dist/blocks/builtins/ImageBlock.js +14 -3
  41. package/dist/blocks/builtins/ImageBlock.js.map +1 -1
  42. package/dist/blocks/builtins/Jobs.d.ts +2 -2
  43. package/dist/blocks/builtins/Jobs.js +1 -1
  44. package/dist/blocks/builtins/Jobs.js.map +1 -1
  45. package/dist/blocks/builtins/Newsletter.js +18 -5
  46. package/dist/blocks/builtins/Newsletter.js.map +1 -1
  47. package/dist/blocks/builtins/Pricing.js +3 -1
  48. package/dist/blocks/builtins/Pricing.js.map +1 -1
  49. package/dist/blocks/builtins/Sections.js +4 -4
  50. package/dist/blocks/builtins/Sections.js.map +1 -1
  51. package/dist/blocks/builtins/Slider.js +9 -4
  52. package/dist/blocks/builtins/Slider.js.map +1 -1
  53. package/dist/blocks/builtins/SymbolRef.js.map +1 -1
  54. package/dist/blocks/builtins/Video.js +1 -1
  55. package/dist/blocks/builtins/Video.js.map +1 -1
  56. package/dist/blocks/builtins/Widgets.js +2 -1
  57. package/dist/blocks/builtins/Widgets.js.map +1 -1
  58. package/dist/components/PageView.js +4 -3
  59. package/dist/components/PageView.js.map +1 -1
  60. package/dist/demo/index.d.ts +7 -0
  61. package/dist/demo/index.js +23 -0
  62. package/dist/demo/index.js.map +1 -0
  63. package/dist/index.d.ts +2 -4
  64. package/dist/index.js +0 -20
  65. package/dist/index.js.map +1 -1
  66. package/dist/modules/registry.d.ts +3 -4
  67. package/dist/modules/registry.js +2 -3
  68. package/dist/modules/registry.js.map +1 -1
  69. package/dist/schema/blocks.d.ts +27 -90
  70. package/dist/schema/blocks.js +4 -6
  71. package/dist/schema/blocks.js.map +1 -1
  72. package/dist/schema/settings.d.ts +1 -34
  73. package/dist/schema/settings.js +2 -8
  74. package/dist/schema/settings.js.map +1 -1
  75. package/dist/schema/siteConfig.d.ts +3 -71
  76. package/dist/schema/siteConfig.js +3 -8
  77. package/dist/schema/siteConfig.js.map +1 -1
  78. package/dist/schema/symbols.d.ts +2 -2
  79. package/dist/schema/symbols.js.map +1 -1
  80. package/dist/server/index.d.ts +1 -1
  81. package/dist/server/index.js +2 -0
  82. package/dist/server/index.js.map +1 -1
  83. package/dist/server/pages.d.ts +23 -3
  84. package/dist/server/pages.js +37 -3
  85. package/dist/server/pages.js.map +1 -1
  86. package/dist/server/seo.js +5 -3
  87. package/dist/server/seo.js.map +1 -1
  88. package/dist/settings/useSiteSettings.d.ts +15 -3
  89. package/dist/settings/useSiteSettings.js +26 -5
  90. package/dist/settings/useSiteSettings.js.map +1 -1
  91. package/dist/styles.css +64 -9
  92. package/package.json +6 -2
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/admin/customBlocks/CustomBlocksPanel.tsx"],"sourcesContent":["\"use client\";\n\n/**\n * Beheer-paneel \"Bouwstenen\" (5.0): custom blocks — per site gedefinieerde bloktypes zonder\n * deploy (primo-concept, veilige template-variant). Voor de developer-/agent-rol: velden als\n * Field-IR (JSON, gevalideerd vóór opslaan), een veilige mustache-subset-template en scoped CSS.\n * Opslaan loopt via de saveCustomBlock-callable (sanitize + audit); het blok verschijnt direct\n * in het toevoeg-menu van de editor (live registry-sync in XiCMSProvider).\n */\nimport { useEffect, useState } from \"react\";\nimport { collection, onSnapshot } from \"firebase/firestore\";\nimport { getFunctions, httpsCallable } from \"firebase/functions\";\nimport { getDb, getFirebaseApp } from \"../../firebase/client\";\nimport { functionsRegion } from \"../../firebase/region\";\nimport { CustomBlockSchema, type CustomBlock } from \"../../schema/customBlocks\";\nimport { FieldsSchema } from \"../../schema/fields\";\nimport { Button, Field, Input, Textarea } from \"../ui/primitives\";\nimport { useToast } from \"../ui/toast\";\nimport { useConfirm } from \"../ui/ConfirmDialog\";\n\nconst EMPTY: CustomBlock = {\n type: \"custom-\",\n label: \"\",\n icon: \"🧩\",\n fields: [],\n template: \"\",\n css: \"\",\n version: 1,\n};\n\nconst FIELDS_VOORBEELD = `[\n { \"key\": \"heading\", \"label\": \"Kop\", \"type\": \"text\" },\n { \"key\": \"items\", \"label\": \"Items\", \"type\": \"repeater\", \"fields\": [\n { \"key\": \"title\", \"label\": \"Titel\", \"type\": \"text\" },\n { \"key\": \"bodyHtml\", \"label\": \"Tekst\", \"type\": \"richtext\" }\n ] }\n]`;\n\nexport function CustomBlocksPanel({ siteId }: { siteId: string }) {\n const { toast } = useToast();\n const confirm = useConfirm();\n const [blocks, setBlocks] = useState<CustomBlock[]>([]);\n const [editing, setEditing] = useState<CustomBlock | null>(null);\n const [fieldsText, setFieldsText] = useState(\"[]\");\n const [busy, setBusy] = useState(false);\n\n useEffect(() => {\n const db = getDb();\n if (!db) return;\n return onSnapshot(collection(db, \"sites\", siteId, \"customBlocks\"), (snap) => {\n const out: CustomBlock[] = [];\n snap.forEach((d) => {\n const res = CustomBlockSchema.safeParse({ ...(d.data() as Record<string, unknown>), type: d.id });\n if (res.success) out.push(res.data);\n });\n setBlocks(out.sort((a, b) => a.label.localeCompare(b.label)));\n });\n }, [siteId]);\n\n function open(def: CustomBlock) {\n setEditing(def);\n setFieldsText(JSON.stringify(def.fields, null, 2));\n }\n\n async function save() {\n if (!editing) return;\n let fields: unknown;\n try {\n fields = JSON.parse(fieldsText);\n } catch {\n toast({ title: \"Velden zijn geen geldige JSON\", tone: \"error\" });\n return;\n }\n const parsedFields = FieldsSchema.safeParse(fields);\n if (!parsedFields.success) {\n toast({ title: \"Velden ongeldig\", description: parsedFields.error.issues[0]?.message, tone: \"error\" });\n return;\n }\n const candidate = CustomBlockSchema.safeParse({ ...editing, fields: parsedFields.data });\n if (!candidate.success) {\n toast({ title: \"Bouwsteen ongeldig\", description: candidate.error.issues[0]?.message, tone: \"error\" });\n return;\n }\n const app = getFirebaseApp();\n if (!app) return;\n setBusy(true);\n try {\n const fn = httpsCallable(getFunctions(app, functionsRegion()), \"saveCustomBlock\");\n const res = await fn({ siteId, ...candidate.data });\n toast({ title: \"Opgeslagen\", description: (res.data as { message?: string }).message, tone: \"success\" });\n setEditing(null);\n } catch (e) {\n toast({ title: \"Opslaan mislukt\", description: (e as Error).message, tone: \"error\" });\n } finally {\n setBusy(false);\n }\n }\n\n async function remove(type: string) {\n const ok = await confirm({ title: \"Bouwsteen verwijderen?\", description: \"Pagina's die dit blok gebruiken slaan het daarna over.\", confirmLabel: \"Verwijderen\", tone: \"danger\" });\n if (!ok) return;\n const app = getFirebaseApp();\n if (!app) return;\n try {\n await httpsCallable(getFunctions(app, functionsRegion()), \"deleteCustomBlock\")({ siteId, type });\n toast({ title: \"Verwijderd\", tone: \"success\" });\n } catch (e) {\n toast({ title: \"Verwijderen mislukt\", description: (e as Error).message, tone: \"error\" });\n }\n }\n\n if (editing) {\n return (\n <div className=\"grid max-w-3xl gap-3\">\n <h2 className=\"text-lg font-bold\">{editing.label || editing.type}</h2>\n <div className=\"grid grid-cols-2 gap-2\">\n <Field label=\"Bloktype\" hint='begint met \"custom-\"; niet meer te wijzigen na aanmaken'>\n <Input value={editing.type} onChange={(e) => setEditing({ ...editing, type: e.target.value })} disabled={blocks.some((b) => b.type === editing.type)} />\n </Field>\n <Field label=\"Naam in het menu\">\n <Input value={editing.label} onChange={(e) => setEditing({ ...editing, label: e.target.value })} />\n </Field>\n </div>\n <Field label=\"Icoon (emoji)\">\n <Input value={editing.icon} maxLength={16} onChange={(e) => setEditing({ ...editing, icon: e.target.value })} className=\"w-24\" />\n </Field>\n <Field label=\"Velden (Field-IR, JSON)\" hint={`Veldtypes: text, textarea, richtext (sleutel eindigt op Html), number, switch, select, date, image, url, link, group, repeater. Voorbeeld:\\n${FIELDS_VOORBEELD}`}>\n <Textarea rows={10} value={fieldsText} onChange={(e) => setFieldsText(e.target.value)} spellCheck={false} className=\"font-mono text-xs\" />\n </Field>\n <Field label=\"Template\" hint=\"Mustache-subset: {{veld}}, {{{veldHtml}}}, {{#each items}}…{{/each}}, {{#if veld}}…{{else}}…{{/if}}. Geen scripts — die worden bij opslaan gestript.\">\n <Textarea rows={10} value={editing.template} onChange={(e) => setEditing({ ...editing, template: e.target.value })} spellCheck={false} className=\"font-mono text-xs\" />\n </Field>\n <Field label=\"CSS\" hint='Automatisch gescoped op dit blok ([data-xicms-cb=\"…\"]).'>\n <Textarea rows={8} value={editing.css} onChange={(e) => setEditing({ ...editing, css: e.target.value })} spellCheck={false} className=\"font-mono text-xs\" />\n </Field>\n <div className=\"flex gap-2\">\n <Button type=\"button\" variant=\"primary\" disabled={busy} onClick={() => void save()}>{busy ? \"Opslaan…\" : \"Opslaan\"}</Button>\n <Button type=\"button\" variant=\"ghost\" onClick={() => setEditing(null)}>Annuleren</Button>\n </div>\n </div>\n );\n }\n\n return (\n <div className=\"grid gap-3\">\n <div className=\"flex items-center justify-between\">\n <h2 className=\"text-lg font-bold\">Bouwstenen</h2>\n <Button type=\"button\" variant=\"secondary\" size=\"sm\" onClick={() => open(EMPTY)}>+ Nieuwe bouwsteen</Button>\n </div>\n <p className=\"text-sm text-slate-500\">\n Eigen bloktypes voor deze site — zonder release. Definieer velden, een veilige template en CSS;\n het blok verschijnt direct in het toevoeg-menu van de pagina-editor.\n </p>\n {blocks.length === 0 && <p className=\"text-sm text-slate-500\">Nog geen bouwstenen.</p>}\n <ul className=\"grid gap-2\">\n {blocks.map((b) => (\n <li key={b.type} className=\"flex items-center justify-between rounded-lg border border-slate-200 p-3\">\n <div>\n <span className=\"mr-2\">{b.icon}</span>\n <strong>{b.label}</strong> <span className=\"text-xs text-slate-500\">({b.type} · v{b.version})</span>\n </div>\n <div className=\"flex gap-2\">\n <Button type=\"button\" variant=\"ghost\" size=\"sm\" onClick={() => open(b)}>Bewerken</Button>\n <Button type=\"button\" variant=\"ghost\" size=\"sm\" onClick={() => void remove(b.type)}>Verwijderen</Button>\n </div>\n </li>\n ))}\n </ul>\n </div>\n );\n}\n"],"mappings":";AAkHQ,cACA,YADA;AAzGR,SAAS,WAAW,gBAAgB;AACpC,SAAS,YAAY,kBAAkB;AACvC,SAAS,cAAc,qBAAqB;AAC5C,SAAS,OAAO,sBAAsB;AACtC,SAAS,uBAAuB;AAChC,SAAS,yBAA2C;AACpD,SAAS,oBAAoB;AAC7B,SAAS,QAAQ,OAAO,OAAO,gBAAgB;AAC/C,SAAS,gBAAgB;AACzB,SAAS,kBAAkB;AAE3B,MAAM,QAAqB;AAAA,EACzB,MAAM;AAAA,EACN,OAAO;AAAA,EACP,MAAM;AAAA,EACN,QAAQ,CAAC;AAAA,EACT,UAAU;AAAA,EACV,KAAK;AAAA,EACL,SAAS;AACX;AAEA,MAAM,mBAAmB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAQlB,SAAS,kBAAkB,EAAE,OAAO,GAAuB;AAChE,QAAM,EAAE,MAAM,IAAI,SAAS;AAC3B,QAAM,UAAU,WAAW;AAC3B,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAwB,CAAC,CAAC;AACtD,QAAM,CAAC,SAAS,UAAU,IAAI,SAA6B,IAAI;AAC/D,QAAM,CAAC,YAAY,aAAa,IAAI,SAAS,IAAI;AACjD,QAAM,CAAC,MAAM,OAAO,IAAI,SAAS,KAAK;AAEtC,YAAU,MAAM;AACd,UAAM,KAAK,MAAM;AACjB,QAAI,CAAC,GAAI;AACT,WAAO,WAAW,WAAW,IAAI,SAAS,QAAQ,cAAc,GAAG,CAAC,SAAS;AAC3E,YAAM,MAAqB,CAAC;AAC5B,WAAK,QAAQ,CAAC,MAAM;AAClB,cAAM,MAAM,kBAAkB,UAAU,EAAE,GAAI,EAAE,KAAK,GAA+B,MAAM,EAAE,GAAG,CAAC;AAChG,YAAI,IAAI,QAAS,KAAI,KAAK,IAAI,IAAI;AAAA,MACpC,CAAC;AACD,gBAAU,IAAI,KAAK,CAAC,GAAG,MAAM,EAAE,MAAM,cAAc,EAAE,KAAK,CAAC,CAAC;AAAA,IAC9D,CAAC;AAAA,EACH,GAAG,CAAC,MAAM,CAAC;AAEX,WAAS,KAAK,KAAkB;AAC9B,eAAW,GAAG;AACd,kBAAc,KAAK,UAAU,IAAI,QAAQ,MAAM,CAAC,CAAC;AAAA,EACnD;AAEA,iBAAe,OAAO;AACpB,QAAI,CAAC,QAAS;AACd,QAAI;AACJ,QAAI;AACF,eAAS,KAAK,MAAM,UAAU;AAAA,IAChC,QAAQ;AACN,YAAM,EAAE,OAAO,iCAAiC,MAAM,QAAQ,CAAC;AAC/D;AAAA,IACF;AACA,UAAM,eAAe,aAAa,UAAU,MAAM;AAClD,QAAI,CAAC,aAAa,SAAS;AACzB,YAAM,EAAE,OAAO,mBAAmB,aAAa,aAAa,MAAM,OAAO,CAAC,GAAG,SAAS,MAAM,QAAQ,CAAC;AACrG;AAAA,IACF;AACA,UAAM,YAAY,kBAAkB,UAAU,EAAE,GAAG,SAAS,QAAQ,aAAa,KAAK,CAAC;AACvF,QAAI,CAAC,UAAU,SAAS;AACtB,YAAM,EAAE,OAAO,sBAAsB,aAAa,UAAU,MAAM,OAAO,CAAC,GAAG,SAAS,MAAM,QAAQ,CAAC;AACrG;AAAA,IACF;AACA,UAAM,MAAM,eAAe;AAC3B,QAAI,CAAC,IAAK;AACV,YAAQ,IAAI;AACZ,QAAI;AACF,YAAM,KAAK,cAAc,aAAa,KAAK,gBAAgB,CAAC,GAAG,iBAAiB;AAChF,YAAM,MAAM,MAAM,GAAG,EAAE,QAAQ,GAAG,UAAU,KAAK,CAAC;AAClD,YAAM,EAAE,OAAO,cAAc,aAAc,IAAI,KAA8B,SAAS,MAAM,UAAU,CAAC;AACvG,iBAAW,IAAI;AAAA,IACjB,SAAS,GAAG;AACV,YAAM,EAAE,OAAO,mBAAmB,aAAc,EAAY,SAAS,MAAM,QAAQ,CAAC;AAAA,IACtF,UAAE;AACA,cAAQ,KAAK;AAAA,IACf;AAAA,EACF;AAEA,iBAAe,OAAO,MAAc;AAClC,UAAM,KAAK,MAAM,QAAQ,EAAE,OAAO,0BAA0B,aAAa,0DAA0D,cAAc,eAAe,MAAM,SAAS,CAAC;AAChL,QAAI,CAAC,GAAI;AACT,UAAM,MAAM,eAAe;AAC3B,QAAI,CAAC,IAAK;AACV,QAAI;AACF,YAAM,cAAc,aAAa,KAAK,gBAAgB,CAAC,GAAG,mBAAmB,EAAE,EAAE,QAAQ,KAAK,CAAC;AAC/F,YAAM,EAAE,OAAO,cAAc,MAAM,UAAU,CAAC;AAAA,IAChD,SAAS,GAAG;AACV,YAAM,EAAE,OAAO,uBAAuB,aAAc,EAAY,SAAS,MAAM,QAAQ,CAAC;AAAA,IAC1F;AAAA,EACF;AAEA,MAAI,SAAS;AACX,WACE,qBAAC,SAAI,WAAU,wBACb;AAAA,0BAAC,QAAG,WAAU,qBAAqB,kBAAQ,SAAS,QAAQ,MAAK;AAAA,MACjE,qBAAC,SAAI,WAAU,0BACb;AAAA,4BAAC,SAAM,OAAM,YAAW,MAAK,2DAC3B,8BAAC,SAAM,OAAO,QAAQ,MAAM,UAAU,CAAC,MAAM,WAAW,EAAE,GAAG,SAAS,MAAM,EAAE,OAAO,MAAM,CAAC,GAAG,UAAU,OAAO,KAAK,CAAC,MAAM,EAAE,SAAS,QAAQ,IAAI,GAAG,GACxJ;AAAA,QACA,oBAAC,SAAM,OAAM,oBACX,8BAAC,SAAM,OAAO,QAAQ,OAAO,UAAU,CAAC,MAAM,WAAW,EAAE,GAAG,SAAS,OAAO,EAAE,OAAO,MAAM,CAAC,GAAG,GACnG;AAAA,SACF;AAAA,MACA,oBAAC,SAAM,OAAM,iBACX,8BAAC,SAAM,OAAO,QAAQ,MAAM,WAAW,IAAI,UAAU,CAAC,MAAM,WAAW,EAAE,GAAG,SAAS,MAAM,EAAE,OAAO,MAAM,CAAC,GAAG,WAAU,QAAO,GACjI;AAAA,MACA,oBAAC,SAAM,OAAM,2BAA0B,MAAM;AAAA,EAA+I,gBAAgB,IAC1M,8BAAC,YAAS,MAAM,IAAI,OAAO,YAAY,UAAU,CAAC,MAAM,cAAc,EAAE,OAAO,KAAK,GAAG,YAAY,OAAO,WAAU,qBAAoB,GAC1I;AAAA,MACA,oBAAC,SAAM,OAAM,YAAW,MAAK,4KAC3B,8BAAC,YAAS,MAAM,IAAI,OAAO,QAAQ,UAAU,UAAU,CAAC,MAAM,WAAW,EAAE,GAAG,SAAS,UAAU,EAAE,OAAO,MAAM,CAAC,GAAG,YAAY,OAAO,WAAU,qBAAoB,GACvK;AAAA,MACA,oBAAC,SAAM,OAAM,OAAM,MAAK,gEACtB,8BAAC,YAAS,MAAM,GAAG,OAAO,QAAQ,KAAK,UAAU,CAAC,MAAM,WAAW,EAAE,GAAG,SAAS,KAAK,EAAE,OAAO,MAAM,CAAC,GAAG,YAAY,OAAO,WAAU,qBAAoB,GAC5J;AAAA,MACA,qBAAC,SAAI,WAAU,cACb;AAAA,4BAAC,UAAO,MAAK,UAAS,SAAQ,WAAU,UAAU,MAAM,SAAS,MAAM,KAAK,KAAK,GAAI,iBAAO,kBAAa,WAAU;AAAA,QACnH,oBAAC,UAAO,MAAK,UAAS,SAAQ,SAAQ,SAAS,MAAM,WAAW,IAAI,GAAG,uBAAS;AAAA,SAClF;AAAA,OACF;AAAA,EAEJ;AAEA,SACE,qBAAC,SAAI,WAAU,cACb;AAAA,yBAAC,SAAI,WAAU,qCACb;AAAA,0BAAC,QAAG,WAAU,qBAAoB,wBAAU;AAAA,MAC5C,oBAAC,UAAO,MAAK,UAAS,SAAQ,aAAY,MAAK,MAAK,SAAS,MAAM,KAAK,KAAK,GAAG,gCAAkB;AAAA,OACpG;AAAA,IACA,oBAAC,OAAE,WAAU,0BAAyB,uLAGtC;AAAA,IACC,OAAO,WAAW,KAAK,oBAAC,OAAE,WAAU,0BAAyB,kCAAoB;AAAA,IAClF,oBAAC,QAAG,WAAU,cACX,iBAAO,IAAI,CAAC,MACX,qBAAC,QAAgB,WAAU,4EACzB;AAAA,2BAAC,SACC;AAAA,4BAAC,UAAK,WAAU,QAAQ,YAAE,MAAK;AAAA,QAC/B,oBAAC,YAAQ,YAAE,OAAM;AAAA,QAAS;AAAA,QAAC,qBAAC,UAAK,WAAU,0BAAyB;AAAA;AAAA,UAAE,EAAE;AAAA,UAAK;AAAA,UAAK,EAAE;AAAA,UAAQ;AAAA,WAAC;AAAA,SAC/F;AAAA,MACA,qBAAC,SAAI,WAAU,cACb;AAAA,4BAAC,UAAO,MAAK,UAAS,SAAQ,SAAQ,MAAK,MAAK,SAAS,MAAM,KAAK,CAAC,GAAG,sBAAQ;AAAA,QAChF,oBAAC,UAAO,MAAK,UAAS,SAAQ,SAAQ,MAAK,MAAK,SAAS,MAAM,KAAK,OAAO,EAAE,IAAI,GAAG,yBAAW;AAAA,SACjG;AAAA,SARO,EAAE,IASX,CACD,GACH;AAAA,KACF;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../../src/admin/customBlocks/CustomBlocksPanel.tsx"],"sourcesContent":["\"use client\";\n\n/**\n * Beheer-paneel \"Bouwstenen\" (5.0): custom blocks — per site gedefinieerde bloktypes zonder\n * deploy (primo-concept, veilige template-variant). Voor de developer-/agent-rol: velden als\n * Field-IR (JSON, gevalideerd vóór opslaan), een veilige mustache-subset-template en scoped CSS.\n * Opslaan loopt via de saveCustomBlock-callable (sanitize + audit); het blok verschijnt direct\n * in het toevoeg-menu van de editor (live registry-sync in XiCMSProvider).\n */\nimport { useEffect, useState } from \"react\";\nimport { collection, onSnapshot } from \"firebase/firestore\";\nimport { getFunctions, httpsCallable } from \"firebase/functions\";\nimport { getDb, getFirebaseApp } from \"../../firebase/client\";\nimport { functionsRegion } from \"../../firebase/region\";\nimport { CustomBlockSchema, type CustomBlock } from \"../../schema/customBlocks\";\nimport { FieldsSchema } from \"../../schema/fields\";\nimport { Button, Field, Input, Textarea } from \"../ui/primitives\";\nimport { useToast } from \"../ui/toast\";\nimport { useConfirm } from \"../ui/ConfirmDialog\";\n\nconst EMPTY: CustomBlock = {\n type: \"custom-\",\n label: \"\",\n icon: \"🧩\",\n fields: [],\n template: \"\",\n css: \"\",\n version: 1,\n};\n\nconst FIELDS_VOORBEELD = `[\n { \"key\": \"heading\", \"label\": \"Kop\", \"type\": \"text\" },\n { \"key\": \"items\", \"label\": \"Items\", \"type\": \"repeater\", \"fields\": [\n { \"key\": \"title\", \"label\": \"Titel\", \"type\": \"text\" },\n { \"key\": \"bodyHtml\", \"label\": \"Tekst\", \"type\": \"richtext\" }\n ] }\n]`;\n\nexport function CustomBlocksPanel({ siteId }: { siteId: string }) {\n const { toast } = useToast();\n const confirm = useConfirm();\n const [blocks, setBlocks] = useState<CustomBlock[]>([]);\n const [editing, setEditing] = useState<CustomBlock | null>(null);\n const [fieldsText, setFieldsText] = useState(\"[]\");\n const [busy, setBusy] = useState(false);\n\n useEffect(() => {\n const db = getDb();\n if (!db) return;\n return onSnapshot(collection(db, \"sites\", siteId, \"customBlocks\"), (snap) => {\n const out: CustomBlock[] = [];\n snap.forEach((d) => {\n const res = CustomBlockSchema.safeParse({ ...(d.data() as Record<string, unknown>), type: d.id });\n if (res.success) out.push(res.data);\n });\n setBlocks(out.sort((a, b) => a.label.localeCompare(b.label)));\n });\n }, [siteId]);\n\n function open(def: CustomBlock) {\n setEditing(def);\n setFieldsText(JSON.stringify(def.fields, null, 2));\n }\n\n async function save() {\n if (!editing) return;\n let fields: unknown;\n try {\n fields = JSON.parse(fieldsText);\n } catch (e) {\n // Regel/kolom uit de parserfout halen: \"position N\" zonder context was ondebugbaar bij\n // een handgetypte Field-IR — één verkeerde komma blokkeerde het hele bouwsteen-opslaan.\n const msg = (e as Error).message;\n const pos = /position (\\d+)/.exec(msg);\n let where = \"\";\n if (pos) {\n const n = Number(pos[1]);\n const before = fieldsText.slice(0, n);\n const line = before.split(\"\\n\").length;\n const col = n - before.lastIndexOf(\"\\n\");\n where = ` (regel ${line}, kolom ${col})`;\n }\n toast({ title: \"Velden zijn geen geldige JSON\", description: `${msg}${where}`, tone: \"error\" });\n return;\n }\n const parsedFields = FieldsSchema.safeParse(fields);\n if (!parsedFields.success) {\n toast({ title: \"Velden ongeldig\", description: parsedFields.error.issues[0]?.message, tone: \"error\" });\n return;\n }\n const candidate = CustomBlockSchema.safeParse({ ...editing, fields: parsedFields.data });\n if (!candidate.success) {\n toast({ title: \"Bouwsteen ongeldig\", description: candidate.error.issues[0]?.message, tone: \"error\" });\n return;\n }\n const app = getFirebaseApp();\n if (!app) return;\n setBusy(true);\n try {\n const fn = httpsCallable(getFunctions(app, functionsRegion()), \"saveCustomBlock\");\n const res = await fn({ siteId, ...candidate.data });\n toast({ title: \"Opgeslagen\", description: (res.data as { message?: string }).message, tone: \"success\" });\n setEditing(null);\n } catch (e) {\n toast({ title: \"Opslaan mislukt\", description: (e as Error).message, tone: \"error\" });\n } finally {\n setBusy(false);\n }\n }\n\n async function remove(type: string) {\n const ok = await confirm({ title: \"Bouwsteen verwijderen?\", description: \"Pagina's die dit blok gebruiken slaan het daarna over.\", confirmLabel: \"Verwijderen\", tone: \"danger\" });\n if (!ok) return;\n const app = getFirebaseApp();\n if (!app) return;\n try {\n await httpsCallable(getFunctions(app, functionsRegion()), \"deleteCustomBlock\")({ siteId, type });\n toast({ title: \"Verwijderd\", tone: \"success\" });\n } catch (e) {\n toast({ title: \"Verwijderen mislukt\", description: (e as Error).message, tone: \"error\" });\n }\n }\n\n if (editing) {\n return (\n <div className=\"grid max-w-3xl gap-3\">\n <h2 className=\"text-lg font-bold\">{editing.label || editing.type}</h2>\n <div className=\"grid grid-cols-2 gap-2\">\n <Field label=\"Bloktype\" hint='begint met \"custom-\"; niet meer te wijzigen na aanmaken'>\n <Input value={editing.type} onChange={(e) => setEditing({ ...editing, type: e.target.value })} disabled={blocks.some((b) => b.type === editing.type)} />\n </Field>\n <Field label=\"Naam in het menu\">\n <Input value={editing.label} onChange={(e) => setEditing({ ...editing, label: e.target.value })} />\n </Field>\n </div>\n <Field label=\"Icoon (emoji)\">\n <Input value={editing.icon} maxLength={16} onChange={(e) => setEditing({ ...editing, icon: e.target.value })} className=\"w-24\" />\n </Field>\n <Field label=\"Velden (Field-IR, JSON)\" hint={`Veldtypes: text, textarea, richtext (sleutel eindigt op Html), number, switch, select, date, image, url, link, group, repeater. Voorbeeld:\\n${FIELDS_VOORBEELD}`}>\n <Textarea rows={10} value={fieldsText} onChange={(e) => setFieldsText(e.target.value)} spellCheck={false} className=\"font-mono text-xs\" />\n </Field>\n <Field label=\"Template\" hint=\"Mustache-subset: {{veld}}, {{{veldHtml}}}, {{#each items}}…{{/each}}, {{#if veld}}…{{else}}…{{/if}}. Geen scripts — die worden bij opslaan gestript.\">\n <Textarea rows={10} value={editing.template} onChange={(e) => setEditing({ ...editing, template: e.target.value })} spellCheck={false} className=\"font-mono text-xs\" />\n </Field>\n <Field label=\"CSS\" hint='Automatisch gescoped op dit blok ([data-xicms-cb=\"…\"]).'>\n <Textarea rows={8} value={editing.css} onChange={(e) => setEditing({ ...editing, css: e.target.value })} spellCheck={false} className=\"font-mono text-xs\" />\n </Field>\n <div className=\"flex gap-2\">\n <Button type=\"button\" variant=\"primary\" disabled={busy} onClick={() => void save()}>{busy ? \"Opslaan…\" : \"Opslaan\"}</Button>\n <Button type=\"button\" variant=\"ghost\" onClick={() => setEditing(null)}>Annuleren</Button>\n </div>\n </div>\n );\n }\n\n return (\n <div className=\"grid gap-3\">\n <div className=\"flex items-center justify-between\">\n <h2 className=\"text-lg font-bold\">Bouwstenen</h2>\n <Button type=\"button\" variant=\"secondary\" size=\"sm\" onClick={() => open(EMPTY)}>+ Nieuwe bouwsteen</Button>\n </div>\n <p className=\"text-sm text-slate-500\">\n Eigen bloktypes voor deze site — zonder release. Definieer velden, een veilige template en CSS;\n het blok verschijnt direct in het toevoeg-menu van de pagina-editor.\n </p>\n {blocks.length === 0 && <p className=\"text-sm text-slate-500\">Nog geen bouwstenen.</p>}\n <ul className=\"grid gap-2\">\n {blocks.map((b) => (\n <li key={b.type} className=\"flex items-center justify-between rounded-lg border border-slate-200 p-3\">\n <div>\n <span className=\"mr-2\">{b.icon}</span>\n <strong>{b.label}</strong> <span className=\"text-xs text-slate-500\">({b.type} · v{b.version})</span>\n </div>\n <div className=\"flex gap-2\">\n <Button type=\"button\" variant=\"ghost\" size=\"sm\" onClick={() => open(b)}>Bewerken</Button>\n <Button type=\"button\" variant=\"ghost\" size=\"sm\" onClick={() => void remove(b.type)}>Verwijderen</Button>\n </div>\n </li>\n ))}\n </ul>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -97,8 +97,11 @@ async function deletePage(siteId, pageId) {
97
97
  const db = getDb();
98
98
  if (!db) return;
99
99
  await deleteDoc(doc(db, "sites", siteId, "drafts", pageId));
100
+ const app = getFirebaseApp();
101
+ if (!app) return;
100
102
  try {
101
- await deleteDoc(doc(db, "sites", siteId, "pages", pageId));
103
+ const fn = httpsCallable(getFunctions(app, functionsRegion()), "deletePage");
104
+ await fn({ siteId, pageId });
102
105
  } catch {
103
106
  }
104
107
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/admin/lib/pagesClient.ts"],"sourcesContent":["\"use client\";\n\nimport {\n addDoc,\n collection,\n deleteDoc,\n doc,\n getDoc,\n getDocs,\n limit,\n onSnapshot,\n orderBy,\n query,\n serverTimestamp,\n setDoc,\n where,\n} from \"firebase/firestore\";\nimport { getFunctions, httpsCallable } from \"firebase/functions\";\nimport { getDb, getFirebaseApp } from \"../../firebase/client\";\nimport { functionsRegion } from \"../../firebase/region\";\nimport type { Page } from \"../../schema/pages\";\nimport { isDemoActive, demoIdForPath, resolveDemoPage, saveDemoDraft } from \"../../demo/demoStore\";\n\nexport type DraftPage = Page & { id: string; published?: boolean };\n\n/** Live-lijst van pagina's (werkversies in `drafts/`). */\nexport function subscribePages(siteId: string, cb: (pages: DraftPage[]) => void): () => void {\n const db = getDb();\n if (!db) {\n cb([]);\n return () => {};\n }\n const q = query(collection(db, \"sites\", siteId, \"drafts\"), orderBy(\"order\", \"asc\"));\n return onSnapshot(q, (snap) => {\n cb(snap.docs.map((d) => ({ id: d.id, ...(d.data() as Omit<DraftPage, \"id\">) })));\n });\n}\n\nexport function subscribeVersions(\n siteId: string,\n pageId: string,\n cb: (versions: { id: string; createdAt?: unknown; kind?: string; label?: string }[]) => void,\n): () => void {\n const db = getDb();\n if (!db) {\n cb([]);\n return () => {};\n }\n const q = query(collection(db, \"sites\", siteId, \"pages\", pageId, \"versions\"), orderBy(\"createdAt\", \"desc\"));\n return onSnapshot(q, (snap) => cb(snap.docs.map((d) => ({ id: d.id, ...(d.data() as Record<string, unknown>) }))));\n}\n\n/** Laadt de werkversie (draft) voor een pad — voor het inline bewerken op de live pagina. */\nexport async function getDraftByPath(siteId: string, path: string): Promise<DraftPage | null> {\n // Demo-modus: de werkversie komt uit de lokale browseropslag (seed + lokale bewerkingen).\n if (isDemoActive()) {\n const page = resolveDemoPage(siteId, path);\n return page ? ({ ...page, id: demoIdForPath(path) } as DraftPage) : null;\n }\n const db = getDb();\n if (!db) return null;\n const snap = await getDocs(\n query(collection(db, \"sites\", siteId, \"drafts\"), where(\"path\", \"==\", path), limit(1)),\n );\n const d = snap.docs[0];\n return d ? ({ id: d.id, ...(d.data() as Omit<DraftPage, \"id\">) }) : null;\n}\n\n/**\n * Live-listener op één werkversie — voor conflictdetectie in de visuele editor (twee tabs of\n * twee editors tegelijk). `fromServer` is false voor de lokale echo van een eigen write\n * (hasPendingWrites), zodat de aanroeper eigen saves kan negeren.\n */\nexport function subscribeDraft(\n siteId: string,\n pageId: string,\n cb: (draft: DraftPage | null, meta: { fromServer: boolean }) => void,\n): () => void {\n if (isDemoActive()) return () => {};\n const db = getDb();\n if (!db) return () => {};\n return onSnapshot(doc(db, \"sites\", siteId, \"drafts\", pageId), (snap) => {\n cb(\n snap.exists() ? ({ id: snap.id, ...(snap.data() as Omit<DraftPage, \"id\">) }) : null,\n { fromServer: !snap.metadata.hasPendingWrites },\n );\n });\n}\n\nexport async function createPage(siteId: string, input: Partial<Page> & { title: string; slug: string; path: string }) {\n const db = getDb();\n if (!db) throw new Error(\"Firebase niet geconfigureerd.\");\n const ref = await addDoc(collection(db, \"sites\", siteId, \"drafts\"), {\n parentId: null,\n order: Date.now(),\n status: \"draft\",\n blocks: [],\n published: false,\n ...input,\n updatedAt: serverTimestamp(),\n });\n return ref.id;\n}\n\nexport async function saveDraft(siteId: string, pageId: string, partial: Partial<Page>) {\n // Demo-modus: bewaar de bewerking alleen lokaal in de browser (niet in Firebase).\n if (isDemoActive()) {\n saveDemoDraft(siteId, pageId, partial);\n return;\n }\n const db = getDb();\n if (!db) return;\n await setDoc(\n doc(db, \"sites\", siteId, \"drafts\", pageId),\n { ...partial, updatedAt: serverTimestamp() },\n { merge: true },\n );\n}\n\nexport async function deletePage(siteId: string, pageId: string) {\n const db = getDb();\n if (!db) return;\n await deleteDoc(doc(db, \"sites\", siteId, \"drafts\", pageId));\n try {\n await deleteDoc(doc(db, \"sites\", siteId, \"pages\", pageId));\n } catch {\n /* gepubliceerde versie bestond niet of geen rechten — negeren */\n }\n}\n\n/**\n * Herstelt een eerdere versie naar de WERKVERSIE (draft). De gebruiker moet daarna zelf\n * publiceren — zo is herstellen veilig en omkeerbaar (de live site verandert pas bij Publiceren).\n * Geeft de herstelde velden terug zodat de editor zijn lokale state kan bijwerken.\n */\nexport async function restoreVersion(\n siteId: string,\n pageId: string,\n versionId: string,\n): Promise<Partial<Page>> {\n const db = getDb();\n if (!db) throw new Error(\"Firebase niet geconfigureerd.\");\n const snap = await getDoc(doc(db, \"sites\", siteId, \"pages\", pageId, \"versions\", versionId));\n if (!snap.exists()) throw new Error(\"Versie niet gevonden.\");\n const s = (snap.data().snapshot ?? {}) as Record<string, unknown>;\n const restored: Partial<Page> = {\n title: s.title as string,\n slug: s.slug as string,\n path: s.path as string,\n seo: s.seo as Page[\"seo\"],\n blocks: s.blocks as Page[\"blocks\"],\n order: s.order as number,\n };\n await saveDraft(siteId, pageId, restored);\n return restored;\n}\n\n/** Publiceren via de Cloud Function (server-side validatie + versie-snapshot + audit). */\nexport async function publishPage(siteId: string, pageId: string): Promise<void> {\n // Demo-modus: er wordt niets echt gepubliceerd; de lokale bewerking is al zichtbaar.\n if (isDemoActive()) {\n saveDemoDraft(siteId, pageId, {});\n return;\n }\n const app = getFirebaseApp();\n if (!app) throw new Error(\"Firebase niet geconfigureerd.\");\n const fn = httpsCallable(getFunctions(app, functionsRegion()), \"publishPage\");\n await fn({ siteId, pageId });\n}\n"],"mappings":";AAEA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,cAAc,qBAAqB;AAC5C,SAAS,OAAO,sBAAsB;AACtC,SAAS,uBAAuB;AAEhC,SAAS,cAAc,eAAe,iBAAiB,qBAAqB;AAKrE,SAAS,eAAe,QAAgB,IAA8C;AAC3F,QAAM,KAAK,MAAM;AACjB,MAAI,CAAC,IAAI;AACP,OAAG,CAAC,CAAC;AACL,WAAO,MAAM;AAAA,IAAC;AAAA,EAChB;AACA,QAAM,IAAI,MAAM,WAAW,IAAI,SAAS,QAAQ,QAAQ,GAAG,QAAQ,SAAS,KAAK,CAAC;AAClF,SAAO,WAAW,GAAG,CAAC,SAAS;AAC7B,OAAG,KAAK,KAAK,IAAI,CAAC,OAAO,EAAE,IAAI,EAAE,IAAI,GAAI,EAAE,KAAK,EAA4B,EAAE,CAAC;AAAA,EACjF,CAAC;AACH;AAEO,SAAS,kBACd,QACA,QACA,IACY;AACZ,QAAM,KAAK,MAAM;AACjB,MAAI,CAAC,IAAI;AACP,OAAG,CAAC,CAAC;AACL,WAAO,MAAM;AAAA,IAAC;AAAA,EAChB;AACA,QAAM,IAAI,MAAM,WAAW,IAAI,SAAS,QAAQ,SAAS,QAAQ,UAAU,GAAG,QAAQ,aAAa,MAAM,CAAC;AAC1G,SAAO,WAAW,GAAG,CAAC,SAAS,GAAG,KAAK,KAAK,IAAI,CAAC,OAAO,EAAE,IAAI,EAAE,IAAI,GAAI,EAAE,KAAK,EAA8B,EAAE,CAAC,CAAC;AACnH;AAGA,eAAsB,eAAe,QAAgB,MAAyC;AAE5F,MAAI,aAAa,GAAG;AAClB,UAAM,OAAO,gBAAgB,QAAQ,IAAI;AACzC,WAAO,OAAQ,EAAE,GAAG,MAAM,IAAI,cAAc,IAAI,EAAE,IAAkB;AAAA,EACtE;AACA,QAAM,KAAK,MAAM;AACjB,MAAI,CAAC,GAAI,QAAO;AAChB,QAAM,OAAO,MAAM;AAAA,IACjB,MAAM,WAAW,IAAI,SAAS,QAAQ,QAAQ,GAAG,MAAM,QAAQ,MAAM,IAAI,GAAG,MAAM,CAAC,CAAC;AAAA,EACtF;AACA,QAAM,IAAI,KAAK,KAAK,CAAC;AACrB,SAAO,IAAK,EAAE,IAAI,EAAE,IAAI,GAAI,EAAE,KAAK,EAA4B,IAAK;AACtE;AAOO,SAAS,eACd,QACA,QACA,IACY;AACZ,MAAI,aAAa,EAAG,QAAO,MAAM;AAAA,EAAC;AAClC,QAAM,KAAK,MAAM;AACjB,MAAI,CAAC,GAAI,QAAO,MAAM;AAAA,EAAC;AACvB,SAAO,WAAW,IAAI,IAAI,SAAS,QAAQ,UAAU,MAAM,GAAG,CAAC,SAAS;AACtE;AAAA,MACE,KAAK,OAAO,IAAK,EAAE,IAAI,KAAK,IAAI,GAAI,KAAK,KAAK,EAA4B,IAAK;AAAA,MAC/E,EAAE,YAAY,CAAC,KAAK,SAAS,iBAAiB;AAAA,IAChD;AAAA,EACF,CAAC;AACH;AAEA,eAAsB,WAAW,QAAgB,OAAsE;AACrH,QAAM,KAAK,MAAM;AACjB,MAAI,CAAC,GAAI,OAAM,IAAI,MAAM,+BAA+B;AACxD,QAAM,MAAM,MAAM,OAAO,WAAW,IAAI,SAAS,QAAQ,QAAQ,GAAG;AAAA,IAClE,UAAU;AAAA,IACV,OAAO,KAAK,IAAI;AAAA,IAChB,QAAQ;AAAA,IACR,QAAQ,CAAC;AAAA,IACT,WAAW;AAAA,IACX,GAAG;AAAA,IACH,WAAW,gBAAgB;AAAA,EAC7B,CAAC;AACD,SAAO,IAAI;AACb;AAEA,eAAsB,UAAU,QAAgB,QAAgB,SAAwB;AAEtF,MAAI,aAAa,GAAG;AAClB,kBAAc,QAAQ,QAAQ,OAAO;AACrC;AAAA,EACF;AACA,QAAM,KAAK,MAAM;AACjB,MAAI,CAAC,GAAI;AACT,QAAM;AAAA,IACJ,IAAI,IAAI,SAAS,QAAQ,UAAU,MAAM;AAAA,IACzC,EAAE,GAAG,SAAS,WAAW,gBAAgB,EAAE;AAAA,IAC3C,EAAE,OAAO,KAAK;AAAA,EAChB;AACF;AAEA,eAAsB,WAAW,QAAgB,QAAgB;AAC/D,QAAM,KAAK,MAAM;AACjB,MAAI,CAAC,GAAI;AACT,QAAM,UAAU,IAAI,IAAI,SAAS,QAAQ,UAAU,MAAM,CAAC;AAC1D,MAAI;AACF,UAAM,UAAU,IAAI,IAAI,SAAS,QAAQ,SAAS,MAAM,CAAC;AAAA,EAC3D,QAAQ;AAAA,EAER;AACF;AAOA,eAAsB,eACpB,QACA,QACA,WACwB;AACxB,QAAM,KAAK,MAAM;AACjB,MAAI,CAAC,GAAI,OAAM,IAAI,MAAM,+BAA+B;AACxD,QAAM,OAAO,MAAM,OAAO,IAAI,IAAI,SAAS,QAAQ,SAAS,QAAQ,YAAY,SAAS,CAAC;AAC1F,MAAI,CAAC,KAAK,OAAO,EAAG,OAAM,IAAI,MAAM,uBAAuB;AAC3D,QAAM,IAAK,KAAK,KAAK,EAAE,YAAY,CAAC;AACpC,QAAM,WAA0B;AAAA,IAC9B,OAAO,EAAE;AAAA,IACT,MAAM,EAAE;AAAA,IACR,MAAM,EAAE;AAAA,IACR,KAAK,EAAE;AAAA,IACP,QAAQ,EAAE;AAAA,IACV,OAAO,EAAE;AAAA,EACX;AACA,QAAM,UAAU,QAAQ,QAAQ,QAAQ;AACxC,SAAO;AACT;AAGA,eAAsB,YAAY,QAAgB,QAA+B;AAE/E,MAAI,aAAa,GAAG;AAClB,kBAAc,QAAQ,QAAQ,CAAC,CAAC;AAChC;AAAA,EACF;AACA,QAAM,MAAM,eAAe;AAC3B,MAAI,CAAC,IAAK,OAAM,IAAI,MAAM,+BAA+B;AACzD,QAAM,KAAK,cAAc,aAAa,KAAK,gBAAgB,CAAC,GAAG,aAAa;AAC5E,QAAM,GAAG,EAAE,QAAQ,OAAO,CAAC;AAC7B;","names":[]}
1
+ {"version":3,"sources":["../../../src/admin/lib/pagesClient.ts"],"sourcesContent":["\"use client\";\n\nimport {\n addDoc,\n collection,\n deleteDoc,\n doc,\n getDoc,\n getDocs,\n limit,\n onSnapshot,\n orderBy,\n query,\n serverTimestamp,\n setDoc,\n where,\n} from \"firebase/firestore\";\nimport { getFunctions, httpsCallable } from \"firebase/functions\";\nimport { getDb, getFirebaseApp } from \"../../firebase/client\";\nimport { functionsRegion } from \"../../firebase/region\";\nimport type { Page } from \"../../schema/pages\";\nimport { isDemoActive, demoIdForPath, resolveDemoPage, saveDemoDraft } from \"../../demo/demoStore\";\n\nexport type DraftPage = Page & { id: string; published?: boolean };\n\n/** Live-lijst van pagina's (werkversies in `drafts/`). */\nexport function subscribePages(siteId: string, cb: (pages: DraftPage[]) => void): () => void {\n const db = getDb();\n if (!db) {\n cb([]);\n return () => {};\n }\n const q = query(collection(db, \"sites\", siteId, \"drafts\"), orderBy(\"order\", \"asc\"));\n return onSnapshot(q, (snap) => {\n cb(snap.docs.map((d) => ({ id: d.id, ...(d.data() as Omit<DraftPage, \"id\">) })));\n });\n}\n\nexport function subscribeVersions(\n siteId: string,\n pageId: string,\n cb: (versions: { id: string; createdAt?: unknown; kind?: string; label?: string }[]) => void,\n): () => void {\n const db = getDb();\n if (!db) {\n cb([]);\n return () => {};\n }\n const q = query(collection(db, \"sites\", siteId, \"pages\", pageId, \"versions\"), orderBy(\"createdAt\", \"desc\"));\n return onSnapshot(q, (snap) => cb(snap.docs.map((d) => ({ id: d.id, ...(d.data() as Record<string, unknown>) }))));\n}\n\n/** Laadt de werkversie (draft) voor een pad — voor het inline bewerken op de live pagina. */\nexport async function getDraftByPath(siteId: string, path: string): Promise<DraftPage | null> {\n // Demo-modus: de werkversie komt uit de lokale browseropslag (seed + lokale bewerkingen).\n if (isDemoActive()) {\n const page = resolveDemoPage(siteId, path);\n return page ? ({ ...page, id: demoIdForPath(path) } as DraftPage) : null;\n }\n const db = getDb();\n if (!db) return null;\n const snap = await getDocs(\n query(collection(db, \"sites\", siteId, \"drafts\"), where(\"path\", \"==\", path), limit(1)),\n );\n const d = snap.docs[0];\n return d ? ({ id: d.id, ...(d.data() as Omit<DraftPage, \"id\">) }) : null;\n}\n\n/**\n * Live-listener op één werkversie — voor conflictdetectie in de visuele editor (twee tabs of\n * twee editors tegelijk). `fromServer` is false voor de lokale echo van een eigen write\n * (hasPendingWrites), zodat de aanroeper eigen saves kan negeren.\n */\nexport function subscribeDraft(\n siteId: string,\n pageId: string,\n cb: (draft: DraftPage | null, meta: { fromServer: boolean }) => void,\n): () => void {\n if (isDemoActive()) return () => {};\n const db = getDb();\n if (!db) return () => {};\n return onSnapshot(doc(db, \"sites\", siteId, \"drafts\", pageId), (snap) => {\n cb(\n snap.exists() ? ({ id: snap.id, ...(snap.data() as Omit<DraftPage, \"id\">) }) : null,\n { fromServer: !snap.metadata.hasPendingWrites },\n );\n });\n}\n\nexport async function createPage(siteId: string, input: Partial<Page> & { title: string; slug: string; path: string }) {\n const db = getDb();\n if (!db) throw new Error(\"Firebase niet geconfigureerd.\");\n const ref = await addDoc(collection(db, \"sites\", siteId, \"drafts\"), {\n parentId: null,\n order: Date.now(),\n status: \"draft\",\n blocks: [],\n published: false,\n ...input,\n updatedAt: serverTimestamp(),\n });\n return ref.id;\n}\n\nexport async function saveDraft(siteId: string, pageId: string, partial: Partial<Page>) {\n // Demo-modus: bewaar de bewerking alleen lokaal in de browser (niet in Firebase).\n if (isDemoActive()) {\n saveDemoDraft(siteId, pageId, partial);\n return;\n }\n const db = getDb();\n if (!db) return;\n await setDoc(\n doc(db, \"sites\", siteId, \"drafts\", pageId),\n { ...partial, updatedAt: serverTimestamp() },\n { merge: true },\n );\n}\n\nexport async function deletePage(siteId: string, pageId: string) {\n const db = getDb();\n if (!db) return;\n // Draft direct (client mag drafts beheren); de gepubliceerde pagina via de Cloud Function —\n // die verwijdert óók de versions-subcollectie (Firestore cascadeert niet; directe client-deletes\n // lieten wees-versiegeschiedenis achter en zijn nu in de rules dichtgezet).\n await deleteDoc(doc(db, \"sites\", siteId, \"drafts\", pageId));\n const app = getFirebaseApp();\n if (!app) return;\n try {\n const fn = httpsCallable(getFunctions(app, functionsRegion()), \"deletePage\");\n await fn({ siteId, pageId });\n } catch {\n /* gepubliceerde versie bestond niet — negeren */\n }\n}\n\n/**\n * Herstelt een eerdere versie naar de WERKVERSIE (draft). De gebruiker moet daarna zelf\n * publiceren — zo is herstellen veilig en omkeerbaar (de live site verandert pas bij Publiceren).\n * Geeft de herstelde velden terug zodat de editor zijn lokale state kan bijwerken.\n */\nexport async function restoreVersion(\n siteId: string,\n pageId: string,\n versionId: string,\n): Promise<Partial<Page>> {\n const db = getDb();\n if (!db) throw new Error(\"Firebase niet geconfigureerd.\");\n const snap = await getDoc(doc(db, \"sites\", siteId, \"pages\", pageId, \"versions\", versionId));\n if (!snap.exists()) throw new Error(\"Versie niet gevonden.\");\n const s = (snap.data().snapshot ?? {}) as Record<string, unknown>;\n const restored: Partial<Page> = {\n title: s.title as string,\n slug: s.slug as string,\n path: s.path as string,\n seo: s.seo as Page[\"seo\"],\n blocks: s.blocks as Page[\"blocks\"],\n order: s.order as number,\n };\n await saveDraft(siteId, pageId, restored);\n return restored;\n}\n\n/** Publiceren via de Cloud Function (server-side validatie + versie-snapshot + audit). */\nexport async function publishPage(siteId: string, pageId: string): Promise<void> {\n // Demo-modus: er wordt niets echt gepubliceerd; de lokale bewerking is al zichtbaar.\n if (isDemoActive()) {\n saveDemoDraft(siteId, pageId, {});\n return;\n }\n const app = getFirebaseApp();\n if (!app) throw new Error(\"Firebase niet geconfigureerd.\");\n const fn = httpsCallable(getFunctions(app, functionsRegion()), \"publishPage\");\n await fn({ siteId, pageId });\n}\n"],"mappings":"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","names":[]}
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { ref as storageRef, uploadBytes, getDownloadURL } from "firebase/storage";
3
- import { addDoc, collection, doc, serverTimestamp, updateDoc } from "firebase/firestore";
3
+ import { addDoc, collection, deleteDoc, doc, serverTimestamp, updateDoc } from "firebase/firestore";
4
4
  import { getBucket, getDb } from "../../firebase/client";
5
5
  import { addDemoMedia, isDemoActive } from "../../demo/demoStore";
6
6
  import { compressImage } from "./compressImage";
@@ -44,13 +44,20 @@ async function uploadImage(siteId, file) {
44
44
  });
45
45
  mediaId = docRef.id;
46
46
  }
47
- const sref = storageRef(bucket, path);
48
- await uploadBytes(sref, optimized);
49
- const url = await getDownloadURL(sref);
50
- if (db && mediaId) {
51
- await updateDoc(doc(db, "sites", siteId, "media", mediaId), { url });
47
+ try {
48
+ const sref = storageRef(bucket, path);
49
+ await uploadBytes(sref, optimized);
50
+ const url = await getDownloadURL(sref);
51
+ if (db && mediaId) {
52
+ await updateDoc(doc(db, "sites", siteId, "media", mediaId), { url });
53
+ }
54
+ return { url, mediaId, path };
55
+ } catch (err) {
56
+ if (db && mediaId) {
57
+ await deleteDoc(doc(db, "sites", siteId, "media", mediaId)).catch(() => void 0);
58
+ }
59
+ throw err;
52
60
  }
53
- return { url, mediaId, path };
54
61
  }
55
62
  export {
56
63
  MAX_STORED_BYTES,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/admin/lib/upload.ts"],"sourcesContent":["\"use client\";\n\nimport { ref as storageRef, uploadBytes, getDownloadURL } from \"firebase/storage\";\nimport { addDoc, collection, doc, serverTimestamp, updateDoc } from \"firebase/firestore\";\nimport { getBucket, getDb } from \"../../firebase/client\";\nimport { addDemoMedia, isDemoActive } from \"../../demo/demoStore\";\nimport { compressImage } from \"./compressImage\";\n\n/** Maximale bestandsgrootte vóór compressie (foolproof: voorkomt vastlopen op enorme bestanden). */\nexport const MAX_UPLOAD_BYTES = 15 * 1024 * 1024; // 15 MB\n/** Harde grens die de Storage-rules afdwingen (storage.rules): het GECOMPRIMEERDE bestand moet\n * hieronder blijven. We checken dit client-side na compressie zodat een slecht comprimeerbaar\n * bestand een nette melding geeft i.p.v. een cryptische rules-weigering (\"er gebeurt niets\"). */\nexport const MAX_STORED_BYTES = 10 * 1024 * 1024; // 10 MB — moet gelijk zijn aan storage.rules\n\n/**\n * Comprimeert een afbeelding (client-side, webp + resize) en uploadt 'm naar\n * `sites/{siteId}/media/...`. Registreert het media-doc (status \"processing\" — de sharp-Cloud-\n * Function vult srcset-varianten + zet \"ready\") en geeft de download-URL + media-id terug.\n * Door vóór upload te comprimeren bewaren we nooit het grote origineel → flinke storagebesparing.\n */\nexport async function uploadImage(\n siteId: string,\n file: File,\n): Promise<{ url: string; mediaId: string; path: string }> {\n const bucket = getBucket();\n const db = getDb();\n if (!file.type.startsWith(\"image/\")) throw new Error(\"Alleen afbeeldingen zijn toegestaan.\");\n if (file.size > MAX_UPLOAD_BYTES) {\n throw new Error(`Bestand te groot (max ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB).`);\n }\n\n // Demo-bewerkmodus: geen Firebase — de (gecomprimeerde) afbeelding wordt als data-URL in de\n // lokale demo-bibliotheek bewaard. Alles blijft in de browser, net als de demo-drafts.\n if (isDemoActive()) {\n const optimized = await compressImage(file);\n const dataUrl = await new Promise<string>((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result));\n reader.onerror = () => reject(new Error(\"Bestand lezen mislukt.\"));\n reader.readAsDataURL(optimized);\n });\n const media = addDemoMedia(siteId, { url: dataUrl, name: optimized.name });\n return { url: media.url, mediaId: media.id, path: media.path };\n }\n\n if (!bucket) throw new Error(\"Firebase Storage is niet geconfigureerd.\");\n const optimized = await compressImage(file);\n if (optimized.size > MAX_STORED_BYTES) {\n throw new Error(`Afbeelding blijft na verkleinen te groot (max ${Math.round(MAX_STORED_BYTES / 1024 / 1024)} MB). Kies een kleinere of minder gedetailleerde afbeelding.`);\n }\n const path = `sites/${siteId}/media/${Date.now()}-${optimized.name.replace(/[^\\w.-]+/g, \"_\")}`;\n // Media-doc VÓÓR de upload aanmaken: de variants-Cloud-Function zoekt het doc op `path` zodra\n // het bestand binnenkomt — was het doc er dan nog niet, dan bleef het item eeuwig \"processing\".\n let mediaId = \"\";\n if (db) {\n const docRef = await addDoc(collection(db, \"sites\", siteId, \"media\"), {\n url: \"\",\n path,\n name: optimized.name,\n contentType: optimized.type,\n size: optimized.size,\n alt: \"\",\n status: \"processing\",\n createdAt: serverTimestamp(),\n });\n mediaId = docRef.id;\n }\n const sref = storageRef(bucket, path);\n await uploadBytes(sref, optimized);\n const url = await getDownloadURL(sref);\n if (db && mediaId) {\n await updateDoc(doc(db, \"sites\", siteId, \"media\", mediaId), { url });\n }\n return { url, mediaId, path };\n}\n"],"mappings":";AAEA,SAAS,OAAO,YAAY,aAAa,sBAAsB;AAC/D,SAAS,QAAQ,YAAY,KAAK,iBAAiB,iBAAiB;AACpE,SAAS,WAAW,aAAa;AACjC,SAAS,cAAc,oBAAoB;AAC3C,SAAS,qBAAqB;AAGvB,MAAM,mBAAmB,KAAK,OAAO;AAIrC,MAAM,mBAAmB,KAAK,OAAO;AAQ5C,eAAsB,YACpB,QACA,MACyD;AACzD,QAAM,SAAS,UAAU;AACzB,QAAM,KAAK,MAAM;AACjB,MAAI,CAAC,KAAK,KAAK,WAAW,QAAQ,EAAG,OAAM,IAAI,MAAM,sCAAsC;AAC3F,MAAI,KAAK,OAAO,kBAAkB;AAChC,UAAM,IAAI,MAAM,yBAAyB,KAAK,MAAM,mBAAmB,OAAO,IAAI,CAAC,OAAO;AAAA,EAC5F;AAIA,MAAI,aAAa,GAAG;AAClB,UAAMA,aAAY,MAAM,cAAc,IAAI;AAC1C,UAAM,UAAU,MAAM,IAAI,QAAgB,CAAC,SAAS,WAAW;AAC7D,YAAM,SAAS,IAAI,WAAW;AAC9B,aAAO,SAAS,MAAM,QAAQ,OAAO,OAAO,MAAM,CAAC;AACnD,aAAO,UAAU,MAAM,OAAO,IAAI,MAAM,wBAAwB,CAAC;AACjE,aAAO,cAAcA,UAAS;AAAA,IAChC,CAAC;AACD,UAAM,QAAQ,aAAa,QAAQ,EAAE,KAAK,SAAS,MAAMA,WAAU,KAAK,CAAC;AACzE,WAAO,EAAE,KAAK,MAAM,KAAK,SAAS,MAAM,IAAI,MAAM,MAAM,KAAK;AAAA,EAC/D;AAEA,MAAI,CAAC,OAAQ,OAAM,IAAI,MAAM,0CAA0C;AACvE,QAAM,YAAY,MAAM,cAAc,IAAI;AAC1C,MAAI,UAAU,OAAO,kBAAkB;AACrC,UAAM,IAAI,MAAM,iDAAiD,KAAK,MAAM,mBAAmB,OAAO,IAAI,CAAC,8DAA8D;AAAA,EAC3K;AACA,QAAM,OAAO,SAAS,MAAM,UAAU,KAAK,IAAI,CAAC,IAAI,UAAU,KAAK,QAAQ,aAAa,GAAG,CAAC;AAG5F,MAAI,UAAU;AACd,MAAI,IAAI;AACN,UAAM,SAAS,MAAM,OAAO,WAAW,IAAI,SAAS,QAAQ,OAAO,GAAG;AAAA,MACpE,KAAK;AAAA,MACL;AAAA,MACA,MAAM,UAAU;AAAA,MAChB,aAAa,UAAU;AAAA,MACvB,MAAM,UAAU;AAAA,MAChB,KAAK;AAAA,MACL,QAAQ;AAAA,MACR,WAAW,gBAAgB;AAAA,IAC7B,CAAC;AACD,cAAU,OAAO;AAAA,EACnB;AACA,QAAM,OAAO,WAAW,QAAQ,IAAI;AACpC,QAAM,YAAY,MAAM,SAAS;AACjC,QAAM,MAAM,MAAM,eAAe,IAAI;AACrC,MAAI,MAAM,SAAS;AACjB,UAAM,UAAU,IAAI,IAAI,SAAS,QAAQ,SAAS,OAAO,GAAG,EAAE,IAAI,CAAC;AAAA,EACrE;AACA,SAAO,EAAE,KAAK,SAAS,KAAK;AAC9B;","names":["optimized"]}
1
+ {"version":3,"sources":["../../../src/admin/lib/upload.ts"],"sourcesContent":["\"use client\";\n\nimport { ref as storageRef, uploadBytes, getDownloadURL } from \"firebase/storage\";\nimport { addDoc, collection, deleteDoc, doc, serverTimestamp, updateDoc } from \"firebase/firestore\";\nimport { getBucket, getDb } from \"../../firebase/client\";\nimport { addDemoMedia, isDemoActive } from \"../../demo/demoStore\";\nimport { compressImage } from \"./compressImage\";\n\n/** Maximale bestandsgrootte vóór compressie (foolproof: voorkomt vastlopen op enorme bestanden). */\nexport const MAX_UPLOAD_BYTES = 15 * 1024 * 1024; // 15 MB\n/** Harde grens die de Storage-rules afdwingen (storage.rules): het GECOMPRIMEERDE bestand moet\n * hieronder blijven. We checken dit client-side na compressie zodat een slecht comprimeerbaar\n * bestand een nette melding geeft i.p.v. een cryptische rules-weigering (\"er gebeurt niets\"). */\nexport const MAX_STORED_BYTES = 10 * 1024 * 1024; // 10 MB — moet gelijk zijn aan storage.rules\n\n/**\n * Comprimeert een afbeelding (client-side, webp + resize) en uploadt 'm naar\n * `sites/{siteId}/media/...`. Registreert het media-doc (status \"processing\" — de sharp-Cloud-\n * Function vult srcset-varianten + zet \"ready\") en geeft de download-URL + media-id terug.\n * Door vóór upload te comprimeren bewaren we nooit het grote origineel → flinke storagebesparing.\n */\nexport async function uploadImage(\n siteId: string,\n file: File,\n): Promise<{ url: string; mediaId: string; path: string }> {\n const bucket = getBucket();\n const db = getDb();\n if (!file.type.startsWith(\"image/\")) throw new Error(\"Alleen afbeeldingen zijn toegestaan.\");\n if (file.size > MAX_UPLOAD_BYTES) {\n throw new Error(`Bestand te groot (max ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB).`);\n }\n\n // Demo-bewerkmodus: geen Firebase — de (gecomprimeerde) afbeelding wordt als data-URL in de\n // lokale demo-bibliotheek bewaard. Alles blijft in de browser, net als de demo-drafts.\n if (isDemoActive()) {\n const optimized = await compressImage(file);\n const dataUrl = await new Promise<string>((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result));\n reader.onerror = () => reject(new Error(\"Bestand lezen mislukt.\"));\n reader.readAsDataURL(optimized);\n });\n const media = addDemoMedia(siteId, { url: dataUrl, name: optimized.name });\n return { url: media.url, mediaId: media.id, path: media.path };\n }\n\n if (!bucket) throw new Error(\"Firebase Storage is niet geconfigureerd.\");\n const optimized = await compressImage(file);\n if (optimized.size > MAX_STORED_BYTES) {\n throw new Error(`Afbeelding blijft na verkleinen te groot (max ${Math.round(MAX_STORED_BYTES / 1024 / 1024)} MB). Kies een kleinere of minder gedetailleerde afbeelding.`);\n }\n const path = `sites/${siteId}/media/${Date.now()}-${optimized.name.replace(/[^\\w.-]+/g, \"_\")}`;\n // Media-doc VÓÓR de upload aanmaken: de variants-Cloud-Function zoekt het doc op `path` zodra\n // het bestand binnenkomt — was het doc er dan nog niet, dan bleef het item eeuwig \"processing\".\n let mediaId = \"\";\n if (db) {\n const docRef = await addDoc(collection(db, \"sites\", siteId, \"media\"), {\n url: \"\",\n path,\n name: optimized.name,\n contentType: optimized.type,\n size: optimized.size,\n alt: \"\",\n status: \"processing\",\n createdAt: serverTimestamp(),\n });\n mediaId = docRef.id;\n }\n try {\n const sref = storageRef(bucket, path);\n await uploadBytes(sref, optimized);\n const url = await getDownloadURL(sref);\n if (db && mediaId) {\n await updateDoc(doc(db, \"sites\", siteId, \"media\", mediaId), { url });\n }\n return { url, mediaId, path };\n } catch (err) {\n // Upload mislukt (offline/rules/quota): ruim het vooraf aangemaakte doc op, anders blijft er\n // eeuwig een kapot \"verwerken…\"-item in de bibliotheek staan.\n if (db && mediaId) {\n await deleteDoc(doc(db, \"sites\", siteId, \"media\", mediaId)).catch(() => undefined);\n }\n throw err;\n }\n}\n"],"mappings":";AAEA,SAAS,OAAO,YAAY,aAAa,sBAAsB;AAC/D,SAAS,QAAQ,YAAY,WAAW,KAAK,iBAAiB,iBAAiB;AAC/E,SAAS,WAAW,aAAa;AACjC,SAAS,cAAc,oBAAoB;AAC3C,SAAS,qBAAqB;AAGvB,MAAM,mBAAmB,KAAK,OAAO;AAIrC,MAAM,mBAAmB,KAAK,OAAO;AAQ5C,eAAsB,YACpB,QACA,MACyD;AACzD,QAAM,SAAS,UAAU;AACzB,QAAM,KAAK,MAAM;AACjB,MAAI,CAAC,KAAK,KAAK,WAAW,QAAQ,EAAG,OAAM,IAAI,MAAM,sCAAsC;AAC3F,MAAI,KAAK,OAAO,kBAAkB;AAChC,UAAM,IAAI,MAAM,yBAAyB,KAAK,MAAM,mBAAmB,OAAO,IAAI,CAAC,OAAO;AAAA,EAC5F;AAIA,MAAI,aAAa,GAAG;AAClB,UAAMA,aAAY,MAAM,cAAc,IAAI;AAC1C,UAAM,UAAU,MAAM,IAAI,QAAgB,CAAC,SAAS,WAAW;AAC7D,YAAM,SAAS,IAAI,WAAW;AAC9B,aAAO,SAAS,MAAM,QAAQ,OAAO,OAAO,MAAM,CAAC;AACnD,aAAO,UAAU,MAAM,OAAO,IAAI,MAAM,wBAAwB,CAAC;AACjE,aAAO,cAAcA,UAAS;AAAA,IAChC,CAAC;AACD,UAAM,QAAQ,aAAa,QAAQ,EAAE,KAAK,SAAS,MAAMA,WAAU,KAAK,CAAC;AACzE,WAAO,EAAE,KAAK,MAAM,KAAK,SAAS,MAAM,IAAI,MAAM,MAAM,KAAK;AAAA,EAC/D;AAEA,MAAI,CAAC,OAAQ,OAAM,IAAI,MAAM,0CAA0C;AACvE,QAAM,YAAY,MAAM,cAAc,IAAI;AAC1C,MAAI,UAAU,OAAO,kBAAkB;AACrC,UAAM,IAAI,MAAM,iDAAiD,KAAK,MAAM,mBAAmB,OAAO,IAAI,CAAC,8DAA8D;AAAA,EAC3K;AACA,QAAM,OAAO,SAAS,MAAM,UAAU,KAAK,IAAI,CAAC,IAAI,UAAU,KAAK,QAAQ,aAAa,GAAG,CAAC;AAG5F,MAAI,UAAU;AACd,MAAI,IAAI;AACN,UAAM,SAAS,MAAM,OAAO,WAAW,IAAI,SAAS,QAAQ,OAAO,GAAG;AAAA,MACpE,KAAK;AAAA,MACL;AAAA,MACA,MAAM,UAAU;AAAA,MAChB,aAAa,UAAU;AAAA,MACvB,MAAM,UAAU;AAAA,MAChB,KAAK;AAAA,MACL,QAAQ;AAAA,MACR,WAAW,gBAAgB;AAAA,IAC7B,CAAC;AACD,cAAU,OAAO;AAAA,EACnB;AACA,MAAI;AACF,UAAM,OAAO,WAAW,QAAQ,IAAI;AACpC,UAAM,YAAY,MAAM,SAAS;AACjC,UAAM,MAAM,MAAM,eAAe,IAAI;AACrC,QAAI,MAAM,SAAS;AACjB,YAAM,UAAU,IAAI,IAAI,SAAS,QAAQ,SAAS,OAAO,GAAG,EAAE,IAAI,CAAC;AAAA,IACrE;AACA,WAAO,EAAE,KAAK,SAAS,KAAK;AAAA,EAC9B,SAAS,KAAK;AAGZ,QAAI,MAAM,SAAS;AACjB,YAAM,UAAU,IAAI,IAAI,SAAS,QAAQ,SAAS,OAAO,CAAC,EAAE,MAAM,MAAM,MAAS;AAAA,IACnF;AACA,UAAM;AAAA,EACR;AACF;","names":["optimized"]}
@@ -5,6 +5,7 @@ import {
5
5
  collection,
6
6
  deleteDoc,
7
7
  doc,
8
+ limit,
8
9
  onSnapshot,
9
10
  orderBy,
10
11
  query,
@@ -18,11 +19,14 @@ import { useToast } from "../ui/toast";
18
19
  import { useConfirm } from "../ui/ConfirmDialog";
19
20
  import { uploadImage } from "../lib/upload";
20
21
  import { findMediaUsage } from "../lib/mediaUsage";
22
+ const PAGE_SIZE = 48;
21
23
  function MediaManager({ siteId }) {
22
24
  const { toast } = useToast();
23
25
  const confirm = useConfirm();
24
26
  const [items, setItems] = useState([]);
25
27
  const [busy, setBusy] = useState(false);
28
+ const [pageCount, setPageCount] = useState(1);
29
+ const [hasMore, setHasMore] = useState(false);
26
30
  const inputRef = useRef(null);
27
31
  useEffect(() => {
28
32
  if (isDemoActive()) {
@@ -33,14 +37,18 @@ function MediaManager({ siteId }) {
33
37
  }
34
38
  const db = getDb();
35
39
  if (!db) return;
36
- const q = query(collection(db, "sites", siteId, "media"), orderBy("createdAt", "desc"));
40
+ const max = pageCount * PAGE_SIZE;
41
+ const q = query(collection(db, "sites", siteId, "media"), orderBy("createdAt", "desc"), limit(max + 1));
37
42
  return onSnapshot(
38
43
  q,
39
- (snap) => setItems(snap.docs.map((d) => ({ id: d.id, ...d.data() }))),
44
+ (snap) => {
45
+ setHasMore(snap.docs.length > max);
46
+ setItems(snap.docs.slice(0, max).map((d) => ({ id: d.id, ...d.data() })));
47
+ },
40
48
  // Leesfouten (rules/App Check) niet stil slikken — anders lijkt de bibliotheek leeg.
41
49
  (err) => toast({ title: "Kon mediabibliotheek niet laden", description: err.message, tone: "error" })
42
50
  );
43
- }, [siteId, toast]);
51
+ }, [siteId, toast, pageCount]);
44
52
  async function onFiles(files) {
45
53
  setBusy(true);
46
54
  let ok = 0;
@@ -60,7 +68,13 @@ function MediaManager({ siteId }) {
60
68
  async function saveAlt(item, alt) {
61
69
  const db = getDb();
62
70
  if (!db) return;
63
- await updateDoc(doc(db, "sites", siteId, "media", item.id), { alt });
71
+ if ((item.alt ?? "") === alt) return;
72
+ try {
73
+ await updateDoc(doc(db, "sites", siteId, "media", item.id), { alt });
74
+ toast({ title: "Alt-tekst opgeslagen", tone: "success" });
75
+ } catch (e) {
76
+ toast({ title: "Alt-tekst opslaan mislukt", description: e.message, tone: "error" });
77
+ }
64
78
  }
65
79
  async function remove(item) {
66
80
  const db = getDb();
@@ -74,21 +88,25 @@ function MediaManager({ siteId }) {
74
88
  tone: "danger"
75
89
  });
76
90
  if (!ok) return;
77
- await deleteDoc(doc(db, "sites", siteId, "media", item.id));
78
- if (bucket) {
79
- const paths = [
80
- item.path,
81
- item.variants?.webp?.path,
82
- ...(item.variants?.sizes ?? []).map((v) => v.path)
83
- ].filter((p) => Boolean(p));
84
- await Promise.all(
85
- paths.map((p) => deleteObject(storageRef(bucket, p)).catch(
86
- () => void 0
87
- /* al weg */
88
- ))
89
- );
91
+ try {
92
+ await deleteDoc(doc(db, "sites", siteId, "media", item.id));
93
+ if (bucket) {
94
+ const paths = [
95
+ item.path,
96
+ item.variants?.webp?.path,
97
+ ...(item.variants?.sizes ?? []).map((v) => v.path)
98
+ ].filter((p) => Boolean(p));
99
+ await Promise.all(
100
+ paths.map((p) => deleteObject(storageRef(bucket, p)).catch(
101
+ () => void 0
102
+ /* al weg */
103
+ ))
104
+ );
105
+ }
106
+ toast({ title: "Verwijderd" });
107
+ } catch (e) {
108
+ toast({ title: "Verwijderen mislukt", description: e.message, tone: "error" });
90
109
  }
91
- toast({ title: "Verwijderd" });
92
110
  }
93
111
  return /* @__PURE__ */ jsxs("div", { className: "grid gap-4", children: [
94
112
  /* @__PURE__ */ jsxs("div", { children: [
@@ -124,24 +142,38 @@ function MediaManager({ siteId }) {
124
142
  ] }),
125
143
  m.status === "processing" ? /* @__PURE__ */ jsx(Badge, { tone: "amber", children: "verwerken" }) : /* @__PURE__ */ jsx(Badge, { tone: "green", children: "klaar" })
126
144
  ] }),
127
- /* @__PURE__ */ jsx(
128
- Input,
129
- {
130
- defaultValue: m.alt ?? "",
131
- placeholder: "Alt-tekst",
132
- className: "mb-2 h-8 text-xs",
133
- onBlur: (e) => void saveAlt(m, e.target.value)
134
- }
135
- ),
145
+ /* @__PURE__ */ jsx(AltInput, { item: m, onSave: saveAlt }),
136
146
  /* @__PURE__ */ jsxs("div", { className: "flex gap-1", children: [
137
147
  /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "sm", className: "text-xs", onClick: () => void navigator.clipboard?.writeText(m.url), children: "Kopieer URL" }),
138
148
  /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "sm", className: "text-xs", onClick: () => void remove(m), children: "Verwijder" })
139
149
  ] })
140
150
  ] }, m.id)),
141
151
  items.length === 0 && /* @__PURE__ */ jsx("p", { className: "text-sm text-slate-500", children: "Nog geen media." })
142
- ] })
152
+ ] }),
153
+ hasMore && /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(Button, { variant: "secondary", size: "sm", onClick: () => setPageCount((c) => c + 1), children: "Meer laden\u2026" }) })
143
154
  ] });
144
155
  }
156
+ function AltInput({ item, onSave }) {
157
+ const [value, setValue] = useState(item.alt ?? "");
158
+ const [focused, setFocused] = useState(false);
159
+ useEffect(() => {
160
+ if (!focused) setValue(item.alt ?? "");
161
+ }, [item.alt, focused]);
162
+ return /* @__PURE__ */ jsx(
163
+ Input,
164
+ {
165
+ value,
166
+ placeholder: "Alt-tekst",
167
+ className: "mb-2 h-8 text-xs",
168
+ onChange: (e) => setValue(e.target.value),
169
+ onFocus: () => setFocused(true),
170
+ onBlur: () => {
171
+ setFocused(false);
172
+ void onSave(item, value);
173
+ }
174
+ }
175
+ );
176
+ }
145
177
  export {
146
178
  MediaManager
147
179
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/admin/media/MediaManager.tsx"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n collection,\n deleteDoc,\n doc,\n onSnapshot,\n orderBy,\n query,\n updateDoc,\n} from \"firebase/firestore\";\nimport { deleteObject, ref as storageRef } from \"firebase/storage\";\nimport { getBucket, getDb } from \"../../firebase/client\";\nimport { DEMO_EVENT, isDemoActive, listDemoMediaAll } from \"../../demo/demoStore\";\nimport type { MediaItem } from \"../../content/types\";\nimport { Button, Card, Badge, Input } from \"../ui/primitives\";\nimport { useToast } from \"../ui/toast\";\nimport { useConfirm } from \"../ui/ConfirmDialog\";\nimport { uploadImage } from \"../lib/upload\";\nimport { findMediaUsage } from \"../lib/mediaUsage\";\n\nexport function MediaManager({ siteId }: { siteId: string }) {\n const { toast } = useToast();\n const confirm = useConfirm();\n const [items, setItems] = useState<MediaItem[]>([]);\n const [busy, setBusy] = useState(false);\n const inputRef = useRef<HTMLInputElement>(null);\n\n useEffect(() => {\n // Demo-modus: lokale bibliotheek (geseede foto's + eigen \"uploads\") i.p.v. Firestore.\n if (isDemoActive()) {\n const sync = () => setItems(listDemoMediaAll(siteId));\n sync();\n window.addEventListener(DEMO_EVENT, sync);\n return () => window.removeEventListener(DEMO_EVENT, sync);\n }\n const db = getDb();\n if (!db) return;\n const q = query(collection(db, \"sites\", siteId, \"media\"), orderBy(\"createdAt\", \"desc\"));\n return onSnapshot(\n q,\n (snap) => setItems(snap.docs.map((d) => ({ id: d.id, ...(d.data() as Omit<MediaItem, \"id\">) }))),\n // Leesfouten (rules/App Check) niet stil slikken — anders lijkt de bibliotheek leeg.\n (err) => toast({ title: \"Kon mediabibliotheek niet laden\", description: err.message, tone: \"error\" }),\n );\n }, [siteId, toast]);\n\n async function onFiles(files: FileList) {\n setBusy(true);\n let ok = 0;\n const errors: string[] = [];\n for (const file of Array.from(files)) {\n try {\n await uploadImage(siteId, file);\n ok++;\n } catch (e) {\n errors.push(`${file.name}: ${(e as Error).message}`);\n }\n }\n if (ok > 0) toast({ title: `${ok} foto('s) geüpload`, description: \"Automatisch verkleind; varianten worden op de achtergrond gemaakt.\", tone: \"success\" });\n if (errors.length) toast({ title: \"Sommige uploads mislukten\", description: errors.join(\" · \"), tone: \"error\" });\n setBusy(false);\n }\n\n async function saveAlt(item: MediaItem, alt: string) {\n const db = getDb();\n if (!db) return;\n await updateDoc(doc(db, \"sites\", siteId, \"media\", item.id), { alt });\n }\n\n async function remove(item: MediaItem) {\n const db = getDb();\n const bucket = getBucket();\n if (!db) return;\n const usedOn = await findMediaUsage(siteId, item);\n const ok = await confirm({\n title: \"Afbeelding verwijderen?\",\n description:\n usedOn.length > 0\n ? `Let op: deze afbeelding wordt gebruikt op ${usedOn.length} gepubliceerde pagina('s) (${usedOn.join(\", \")}). Daar verschijnt dan een gebroken afbeelding.`\n : \"De afbeelding wordt definitief verwijderd.\",\n confirmLabel: \"Verwijderen\",\n tone: \"danger\",\n });\n if (!ok) return;\n await deleteDoc(doc(db, \"sites\", siteId, \"media\", item.id));\n if (bucket) {\n // Ook de srcset-varianten opruimen die de Cloud Function heeft gemaakt — anders blijven\n // die (publieke) bestanden voor altijd in Storage staan.\n const paths = [\n item.path,\n item.variants?.webp?.path,\n ...(item.variants?.sizes ?? []).map((v) => v.path),\n ].filter((p): p is string => Boolean(p));\n await Promise.all(\n paths.map((p) => deleteObject(storageRef(bucket, p)).catch(() => undefined /* al weg */)),\n );\n }\n toast({ title: \"Verwijderd\" });\n }\n\n return (\n <div className=\"grid gap-4\">\n <div>\n <Button disabled={busy} onClick={() => inputRef.current?.click()}>\n {busy ? \"Uploaden…\" : \"Foto's uploaden\"}\n </Button>\n <p className=\"mt-1 text-xs text-slate-500\">\n Je kunt meerdere foto&apos;s tegelijk kiezen. Ze worden automatisch verkleind en geoptimaliseerd\n (webp) — dat houdt je site snel en bespaart opslag. Max. 15 MB per bestand.\n </p>\n <input\n ref={inputRef}\n type=\"file\"\n accept=\"image/*\"\n multiple\n hidden\n onChange={(e) => {\n if (e.target.files?.length) void onFiles(e.target.files);\n e.target.value = \"\";\n }}\n />\n </div>\n <div className=\"grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4\">\n {items.map((m) => (\n <Card key={m.id} className=\"p-3\">\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img src={m.url} alt={m.alt ?? m.name} className=\"mb-2 h-28 w-full rounded-md object-cover\" />\n <div className=\"mb-2 flex items-center justify-between gap-1\">\n <span className=\"truncate text-xs text-slate-500\">\n {m.name}\n {(m as { width?: number; height?: number }).width && (\n <span className=\"text-slate-400\"> · {(m as { width?: number }).width}×{(m as { height?: number }).height}</span>\n )}\n </span>\n {m.status === \"processing\" ? <Badge tone=\"amber\">verwerken</Badge> : <Badge tone=\"green\">klaar</Badge>}\n </div>\n <Input\n defaultValue={m.alt ?? \"\"}\n placeholder=\"Alt-tekst\"\n className=\"mb-2 h-8 text-xs\"\n onBlur={(e) => void saveAlt(m, e.target.value)}\n />\n <div className=\"flex gap-1\">\n <Button variant=\"ghost\" size=\"sm\" className=\"text-xs\" onClick={() => void navigator.clipboard?.writeText(m.url)}>\n Kopieer URL\n </Button>\n <Button variant=\"ghost\" size=\"sm\" className=\"text-xs\" onClick={() => void remove(m)}>\n Verwijder\n </Button>\n </div>\n </Card>\n ))}\n {items.length === 0 && <p className=\"text-sm text-slate-500\">Nog geen media.</p>}\n </div>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/admin/media/MediaManager.tsx"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n collection,\n deleteDoc,\n doc,\n limit,\n onSnapshot,\n orderBy,\n query,\n updateDoc,\n} from \"firebase/firestore\";\nimport { deleteObject, ref as storageRef } from \"firebase/storage\";\nimport { getBucket, getDb } from \"../../firebase/client\";\nimport { DEMO_EVENT, isDemoActive, listDemoMediaAll } from \"../../demo/demoStore\";\nimport type { MediaItem } from \"../../content/types\";\nimport { Button, Card, Badge, Input } from \"../ui/primitives\";\nimport { useToast } from \"../ui/toast\";\nimport { useConfirm } from \"../ui/ConfirmDialog\";\nimport { uploadImage } from \"../lib/upload\";\nimport { findMediaUsage } from \"../lib/mediaUsage\";\n\n/** Paginagrootte van de bibliotheek: begrensd laden i.p.v. álles (kosten + geheugen bij 100+ foto's). */\nconst PAGE_SIZE = 48;\n\nexport function MediaManager({ siteId }: { siteId: string }) {\n const { toast } = useToast();\n const confirm = useConfirm();\n const [items, setItems] = useState<MediaItem[]>([]);\n const [busy, setBusy] = useState(false);\n const [pageCount, setPageCount] = useState(1);\n const [hasMore, setHasMore] = useState(false);\n const inputRef = useRef<HTMLInputElement>(null);\n\n useEffect(() => {\n // Demo-modus: lokale bibliotheek (geseede foto's + eigen \"uploads\") i.p.v. Firestore.\n if (isDemoActive()) {\n const sync = () => setItems(listDemoMediaAll(siteId));\n sync();\n window.addEventListener(DEMO_EVENT, sync);\n return () => window.removeEventListener(DEMO_EVENT, sync);\n }\n const db = getDb();\n if (!db) return;\n // limit + \"meer laden\" (pageCount groeit): geen volledige collectie-read per open.\n const max = pageCount * PAGE_SIZE;\n const q = query(collection(db, \"sites\", siteId, \"media\"), orderBy(\"createdAt\", \"desc\"), limit(max + 1));\n return onSnapshot(\n q,\n (snap) => {\n setHasMore(snap.docs.length > max);\n setItems(snap.docs.slice(0, max).map((d) => ({ id: d.id, ...(d.data() as Omit<MediaItem, \"id\">) })));\n },\n // Leesfouten (rules/App Check) niet stil slikken — anders lijkt de bibliotheek leeg.\n (err) => toast({ title: \"Kon mediabibliotheek niet laden\", description: err.message, tone: \"error\" }),\n );\n }, [siteId, toast, pageCount]);\n\n async function onFiles(files: FileList) {\n setBusy(true);\n let ok = 0;\n const errors: string[] = [];\n for (const file of Array.from(files)) {\n try {\n await uploadImage(siteId, file);\n ok++;\n } catch (e) {\n errors.push(`${file.name}: ${(e as Error).message}`);\n }\n }\n if (ok > 0) toast({ title: `${ok} foto('s) geüpload`, description: \"Automatisch verkleind; varianten worden op de achtergrond gemaakt.\", tone: \"success\" });\n if (errors.length) toast({ title: \"Sommige uploads mislukten\", description: errors.join(\" · \"), tone: \"error\" });\n setBusy(false);\n }\n\n async function saveAlt(item: MediaItem, alt: string) {\n const db = getDb();\n if (!db) return;\n if ((item.alt ?? \"\") === alt) return; // niets gewijzigd → geen write/toast\n try {\n await updateDoc(doc(db, \"sites\", siteId, \"media\", item.id), { alt });\n toast({ title: \"Alt-tekst opgeslagen\", tone: \"success\" });\n } catch (e) {\n // Niet stil slikken: de klant dacht anders dat de alt-tekst opgeslagen was.\n toast({ title: \"Alt-tekst opslaan mislukt\", description: (e as Error).message, tone: \"error\" });\n }\n }\n\n async function remove(item: MediaItem) {\n const db = getDb();\n const bucket = getBucket();\n if (!db) return;\n const usedOn = await findMediaUsage(siteId, item);\n const ok = await confirm({\n title: \"Afbeelding verwijderen?\",\n description:\n usedOn.length > 0\n ? `Let op: deze afbeelding wordt gebruikt op ${usedOn.length} gepubliceerde pagina('s) (${usedOn.join(\", \")}). Daar verschijnt dan een gebroken afbeelding.`\n : \"De afbeelding wordt definitief verwijderd.\",\n confirmLabel: \"Verwijderen\",\n tone: \"danger\",\n });\n if (!ok) return;\n try {\n await deleteDoc(doc(db, \"sites\", siteId, \"media\", item.id));\n if (bucket) {\n // Ook de srcset-varianten opruimen die de Cloud Function heeft gemaakt — anders blijven\n // die (publieke) bestanden voor altijd in Storage staan.\n const paths = [\n item.path,\n item.variants?.webp?.path,\n ...(item.variants?.sizes ?? []).map((v) => v.path),\n ].filter((p): p is string => Boolean(p));\n await Promise.all(\n paths.map((p) => deleteObject(storageRef(bucket, p)).catch(() => undefined /* al weg */)),\n );\n }\n toast({ title: \"Verwijderd\" });\n } catch (e) {\n // Rules/offline-fout niet stil slikken — anders lijkt verwijderen \"niets te doen\".\n toast({ title: \"Verwijderen mislukt\", description: (e as Error).message, tone: \"error\" });\n }\n }\n\n return (\n <div className=\"grid gap-4\">\n <div>\n <Button disabled={busy} onClick={() => inputRef.current?.click()}>\n {busy ? \"Uploaden…\" : \"Foto's uploaden\"}\n </Button>\n <p className=\"mt-1 text-xs text-slate-500\">\n Je kunt meerdere foto&apos;s tegelijk kiezen. Ze worden automatisch verkleind en geoptimaliseerd\n (webp) — dat houdt je site snel en bespaart opslag. Max. 15 MB per bestand.\n </p>\n <input\n ref={inputRef}\n type=\"file\"\n accept=\"image/*\"\n multiple\n hidden\n onChange={(e) => {\n if (e.target.files?.length) void onFiles(e.target.files);\n e.target.value = \"\";\n }}\n />\n </div>\n <div className=\"grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4\">\n {items.map((m) => (\n <Card key={m.id} className=\"p-3\">\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img src={m.url} alt={m.alt ?? m.name} className=\"mb-2 h-28 w-full rounded-md object-cover\" />\n <div className=\"mb-2 flex items-center justify-between gap-1\">\n <span className=\"truncate text-xs text-slate-500\">\n {m.name}\n {(m as { width?: number; height?: number }).width && (\n <span className=\"text-slate-400\"> · {(m as { width?: number }).width}×{(m as { height?: number }).height}</span>\n )}\n </span>\n {m.status === \"processing\" ? <Badge tone=\"amber\">verwerken</Badge> : <Badge tone=\"green\">klaar</Badge>}\n </div>\n <AltInput item={m} onSave={saveAlt} />\n <div className=\"flex gap-1\">\n <Button variant=\"ghost\" size=\"sm\" className=\"text-xs\" onClick={() => void navigator.clipboard?.writeText(m.url)}>\n Kopieer URL\n </Button>\n <Button variant=\"ghost\" size=\"sm\" className=\"text-xs\" onClick={() => void remove(m)}>\n Verwijder\n </Button>\n </div>\n </Card>\n ))}\n {items.length === 0 && <p className=\"text-sm text-slate-500\">Nog geen media.</p>}\n </div>\n {hasMore && (\n <div>\n <Button variant=\"secondary\" size=\"sm\" onClick={() => setPageCount((c) => c + 1)}>\n Meer laden…\n </Button>\n </div>\n )}\n </div>\n );\n}\n\n/** Controlled alt-veld: volgt externe updates (realtime snapshot) en slaat alleen échte wijzigingen op. */\nfunction AltInput({ item, onSave }: { item: MediaItem; onSave: (item: MediaItem, alt: string) => Promise<void> }) {\n const [value, setValue] = useState(item.alt ?? \"\");\n // Extern gewijzigd (andere tab/verwerking) → veld bijtrekken, behalve terwijl de klant zelf typt.\n const [focused, setFocused] = useState(false);\n useEffect(() => {\n if (!focused) setValue(item.alt ?? \"\");\n }, [item.alt, focused]);\n return (\n <Input\n value={value}\n placeholder=\"Alt-tekst\"\n className=\"mb-2 h-8 text-xs\"\n onChange={(e) => setValue(e.target.value)}\n onFocus={() => setFocused(true)}\n onBlur={() => {\n setFocused(false);\n void onSave(item, value);\n }}\n />\n );\n}\n"],"mappings":"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","names":[]}
@@ -1,13 +1,14 @@
1
1
  "use client";
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useMemo, useRef, useState } from "react";
4
- import { collection, onSnapshot, orderBy, query } from "firebase/firestore";
4
+ import { collection, limit, onSnapshot, orderBy, query } from "firebase/firestore";
5
5
  import { getDb } from "../../firebase/client";
6
6
  import { DEMO_EVENT, isDemoActive, listDemoMediaAll } from "../../demo/demoStore";
7
7
  import { Button, Input } from "../ui/primitives";
8
8
  import { Dialog, DialogContent } from "../ui/dialog";
9
9
  import { useToast } from "../ui/toast";
10
10
  import { uploadImage } from "../lib/upload";
11
+ const PICKER_PAGE_SIZE = 48;
11
12
  function MediaPickerDialog({
12
13
  siteId,
13
14
  open,
@@ -18,6 +19,8 @@ function MediaPickerDialog({
18
19
  const [items, setItems] = useState([]);
19
20
  const [q, setQ] = useState("");
20
21
  const [busy, setBusy] = useState(false);
22
+ const [pageCount, setPageCount] = useState(1);
23
+ const [hasMore, setHasMore] = useState(false);
21
24
  const inputRef = useRef(null);
22
25
  useEffect(() => {
23
26
  if (!open) return;
@@ -29,15 +32,19 @@ function MediaPickerDialog({
29
32
  }
30
33
  const db = getDb();
31
34
  if (!db) return;
32
- const qy = query(collection(db, "sites", siteId, "media"), orderBy("createdAt", "desc"));
35
+ const max = pageCount * PICKER_PAGE_SIZE;
36
+ const qy = query(collection(db, "sites", siteId, "media"), orderBy("createdAt", "desc"), limit(max + 1));
33
37
  return onSnapshot(
34
38
  qy,
35
- (snap) => setItems(snap.docs.map((d) => ({ id: d.id, ...d.data() }))),
39
+ (snap) => {
40
+ setHasMore(snap.docs.length > max);
41
+ setItems(snap.docs.slice(0, max).map((d) => ({ id: d.id, ...d.data() })));
42
+ },
36
43
  // Zonder onError worden lees-/rules-/App-Check-fouten stil geslikt → de kiezer toont dan
37
44
  // onterecht "Nog geen afbeeldingen". Nu een duidelijke melding.
38
45
  (err) => toast({ title: "Kon mediabibliotheek niet laden", description: err.message, tone: "error" })
39
46
  );
40
- }, [open, siteId, toast]);
47
+ }, [open, siteId, toast, pageCount]);
41
48
  const filtered = useMemo(() => {
42
49
  const term = q.trim().toLowerCase();
43
50
  if (!term) return items;
@@ -70,17 +77,20 @@ function MediaPickerDialog({
70
77
  e.target.value = "";
71
78
  } })
72
79
  ] }),
73
- /* @__PURE__ */ jsx("div", { className: "max-h-[55vh] overflow-y-auto", children: filtered.length === 0 ? /* @__PURE__ */ jsx("p", { className: "py-8 text-center text-sm text-slate-500", children: items.length === 0 ? "Nog geen afbeeldingen \u2014 upload er een." : "Geen resultaten." }) : /* @__PURE__ */ jsx("div", { className: "grid grid-cols-3 gap-2 sm:grid-cols-4", children: filtered.map((m) => /* @__PURE__ */ jsx(
74
- "button",
75
- {
76
- type: "button",
77
- onClick: () => pick(m),
78
- title: m.alt || m.name,
79
- className: "group relative overflow-hidden rounded-md border border-slate-200 focus:outline-none focus:ring-2 focus:ring-indigo-500",
80
- children: /* @__PURE__ */ jsx("img", { src: m.url, alt: m.alt ?? m.name, className: "aspect-square w-full object-cover transition group-hover:opacity-80" })
81
- },
82
- m.id
83
- )) }) })
80
+ /* @__PURE__ */ jsxs("div", { className: "max-h-[55vh] overflow-y-auto", children: [
81
+ filtered.length === 0 ? /* @__PURE__ */ jsx("p", { className: "py-8 text-center text-sm text-slate-500", children: items.length === 0 ? "Nog geen afbeeldingen \u2014 upload er een." : "Geen resultaten." }) : /* @__PURE__ */ jsx("div", { className: "grid grid-cols-3 gap-2 sm:grid-cols-4", children: filtered.map((m) => /* @__PURE__ */ jsx(
82
+ "button",
83
+ {
84
+ type: "button",
85
+ onClick: () => pick(m),
86
+ title: m.alt || m.name,
87
+ className: "group relative overflow-hidden rounded-md border border-slate-200 focus:outline-none focus:ring-2 focus:ring-indigo-500",
88
+ children: /* @__PURE__ */ jsx("img", { src: m.url, alt: m.alt ?? m.name, className: "aspect-square w-full object-cover transition group-hover:opacity-80" })
89
+ },
90
+ m.id
91
+ )) }),
92
+ hasMore && !q.trim() && /* @__PURE__ */ jsx("p", { className: "mt-2 text-center", children: /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setPageCount((c) => c + 1), children: "Meer laden\u2026" }) })
93
+ ] })
84
94
  ] }) });
85
95
  }
86
96
  function MediaPickerButton({
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/admin/media/MediaPicker.tsx"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { collection, onSnapshot, orderBy, query } from \"firebase/firestore\";\nimport { getDb } from \"../../firebase/client\";\nimport { DEMO_EVENT, isDemoActive, listDemoMediaAll } from \"../../demo/demoStore\";\nimport type { MediaItem } from \"../../content/types\";\nimport { Button, Input } from \"../ui/primitives\";\nimport { Dialog, DialogContent } from \"../ui/dialog\";\nimport { useToast } from \"../ui/toast\";\nimport { uploadImage } from \"../lib/upload\";\n\nexport interface PickedMedia {\n url: string;\n mediaId: string;\n alt?: string;\n}\n\n/** Gecontroleerde mediabibliotheek-kiezer (open/onOpenChange extern) — zoek + klik om te kiezen, of\n * upload direct. Gebruikt door de knop hieronder én door de inline beeld-vervangen in de editor. */\nexport function MediaPickerDialog({\n siteId,\n open,\n onOpenChange,\n onSelect,\n}: {\n siteId: string;\n open: boolean;\n onOpenChange: (v: boolean) => void;\n onSelect: (m: PickedMedia) => void;\n}) {\n const { toast } = useToast();\n const [items, setItems] = useState<MediaItem[]>([]);\n const [q, setQ] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const inputRef = useRef<HTMLInputElement>(null);\n\n useEffect(() => {\n if (!open) return;\n // Demo-modus: lokale bibliotheek (geseede foto's + eigen \"uploads\") i.p.v. Firestore.\n if (isDemoActive()) {\n const sync = () => setItems(listDemoMediaAll(siteId));\n sync();\n window.addEventListener(DEMO_EVENT, sync);\n return () => window.removeEventListener(DEMO_EVENT, sync);\n }\n const db = getDb();\n if (!db) return;\n const qy = query(collection(db, \"sites\", siteId, \"media\"), orderBy(\"createdAt\", \"desc\"));\n return onSnapshot(\n qy,\n (snap) => setItems(snap.docs.map((d) => ({ id: d.id, ...(d.data() as Omit<MediaItem, \"id\">) }))),\n // Zonder onError worden lees-/rules-/App-Check-fouten stil geslikt → de kiezer toont dan\n // onterecht \"Nog geen afbeeldingen\". Nu een duidelijke melding.\n (err) => toast({ title: \"Kon mediabibliotheek niet laden\", description: err.message, tone: \"error\" }),\n );\n }, [open, siteId, toast]);\n\n const filtered = useMemo(() => {\n const term = q.trim().toLowerCase();\n if (!term) return items;\n return items.filter((m) => `${m.name ?? \"\"} ${m.alt ?? \"\"}`.toLowerCase().includes(term));\n }, [items, q]);\n\n function pick(m: MediaItem) {\n onSelect({ url: m.url, mediaId: m.id, alt: m.alt });\n onOpenChange(false);\n }\n\n async function onUpload(files: FileList | null) {\n const f = files?.[0];\n if (!f) return;\n setBusy(true);\n try {\n const { url, mediaId } = await uploadImage(siteId, f);\n onSelect({ url, mediaId, alt: \"\" });\n onOpenChange(false);\n } catch (err) {\n toast({ title: \"Upload mislukt\", description: (err as Error).message, tone: \"error\" });\n } finally {\n setBusy(false);\n }\n }\n\n return (\n <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent title=\"Afbeelding kiezen\" className=\"max-w-2xl\">\n <div className=\"mb-3 flex items-center gap-2\">\n <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder=\"Zoek in bibliotheek…\" className=\"h-9\" />\n <Button type=\"button\" disabled={busy} onClick={() => inputRef.current?.click()}>\n {busy ? \"Uploaden…\" : \"Upload nieuwe\"}\n </Button>\n <input ref={inputRef} type=\"file\" accept=\"image/*\" hidden onChange={(e) => { void onUpload(e.target.files); e.target.value = \"\"; }} />\n </div>\n <div className=\"max-h-[55vh] overflow-y-auto\">\n {filtered.length === 0 ? (\n <p className=\"py-8 text-center text-sm text-slate-500\">\n {items.length === 0 ? \"Nog geen afbeeldingen — upload er een.\" : \"Geen resultaten.\"}\n </p>\n ) : (\n <div className=\"grid grid-cols-3 gap-2 sm:grid-cols-4\">\n {filtered.map((m) => (\n <button\n key={m.id}\n type=\"button\"\n onClick={() => pick(m)}\n title={m.alt || m.name}\n className=\"group relative overflow-hidden rounded-md border border-slate-200 focus:outline-none focus:ring-2 focus:ring-indigo-500\"\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img src={m.url} alt={m.alt ?? m.name} className=\"aspect-square w-full object-cover transition group-hover:opacity-80\" />\n </button>\n ))}\n </div>\n )}\n </div>\n </DialogContent>\n </Dialog>\n );\n}\n\n/**\n * Knop die de mediabibliotheek-kiezer opent: zoek + klik om een bestaande afbeelding te kiezen, of\n * upload er direct een nieuwe in. Vervangt het plakken van losse URL's.\n */\nexport function MediaPickerButton({\n siteId,\n onSelect,\n label = \"Kies / upload\",\n size = \"sm\",\n variant = \"secondary\",\n}: {\n siteId: string;\n onSelect: (m: PickedMedia) => void;\n label?: string;\n size?: \"sm\" | \"md\";\n variant?: \"primary\" | \"secondary\" | \"ghost\";\n}) {\n const [open, setOpen] = useState(false);\n return (\n <>\n <Button type=\"button\" variant={variant} size={size} onClick={() => setOpen(true)}>{label}</Button>\n <MediaPickerDialog siteId={siteId} open={open} onOpenChange={setOpen} onSelect={onSelect} />\n </>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/admin/media/MediaPicker.tsx"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { collection, limit, onSnapshot, orderBy, query } from \"firebase/firestore\";\nimport { getDb } from \"../../firebase/client\";\nimport { DEMO_EVENT, isDemoActive, listDemoMediaAll } from \"../../demo/demoStore\";\nimport type { MediaItem } from \"../../content/types\";\nimport { Button, Input } from \"../ui/primitives\";\nimport { Dialog, DialogContent } from \"../ui/dialog\";\nimport { useToast } from \"../ui/toast\";\nimport { uploadImage } from \"../lib/upload\";\n\nexport interface PickedMedia {\n url: string;\n mediaId: string;\n alt?: string;\n}\n\n/** Paginagrootte: begrensd laden i.p.v. de hele bibliotheek bij elke kiezer-open. */\nconst PICKER_PAGE_SIZE = 48;\n\n/** Gecontroleerde mediabibliotheek-kiezer (open/onOpenChange extern) — zoek + klik om te kiezen, of\n * upload direct. Gebruikt door de knop hieronder én door de inline beeld-vervangen in de editor. */\nexport function MediaPickerDialog({\n siteId,\n open,\n onOpenChange,\n onSelect,\n}: {\n siteId: string;\n open: boolean;\n onOpenChange: (v: boolean) => void;\n onSelect: (m: PickedMedia) => void;\n}) {\n const { toast } = useToast();\n const [items, setItems] = useState<MediaItem[]>([]);\n const [q, setQ] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [pageCount, setPageCount] = useState(1);\n const [hasMore, setHasMore] = useState(false);\n const inputRef = useRef<HTMLInputElement>(null);\n\n useEffect(() => {\n if (!open) return;\n // Demo-modus: lokale bibliotheek (geseede foto's + eigen \"uploads\") i.p.v. Firestore.\n if (isDemoActive()) {\n const sync = () => setItems(listDemoMediaAll(siteId));\n sync();\n window.addEventListener(DEMO_EVENT, sync);\n return () => window.removeEventListener(DEMO_EVENT, sync);\n }\n const db = getDb();\n if (!db) return;\n // Begrensd laden (+\"meer laden\"): geen volledige collectie-read bij elke kiezer-open.\n const max = pageCount * PICKER_PAGE_SIZE;\n const qy = query(collection(db, \"sites\", siteId, \"media\"), orderBy(\"createdAt\", \"desc\"), limit(max + 1));\n return onSnapshot(\n qy,\n (snap) => {\n setHasMore(snap.docs.length > max);\n setItems(snap.docs.slice(0, max).map((d) => ({ id: d.id, ...(d.data() as Omit<MediaItem, \"id\">) })));\n },\n // Zonder onError worden lees-/rules-/App-Check-fouten stil geslikt → de kiezer toont dan\n // onterecht \"Nog geen afbeeldingen\". Nu een duidelijke melding.\n (err) => toast({ title: \"Kon mediabibliotheek niet laden\", description: err.message, tone: \"error\" }),\n );\n }, [open, siteId, toast, pageCount]);\n\n const filtered = useMemo(() => {\n const term = q.trim().toLowerCase();\n if (!term) return items;\n return items.filter((m) => `${m.name ?? \"\"} ${m.alt ?? \"\"}`.toLowerCase().includes(term));\n }, [items, q]);\n\n function pick(m: MediaItem) {\n onSelect({ url: m.url, mediaId: m.id, alt: m.alt });\n onOpenChange(false);\n }\n\n async function onUpload(files: FileList | null) {\n const f = files?.[0];\n if (!f) return;\n setBusy(true);\n try {\n const { url, mediaId } = await uploadImage(siteId, f);\n onSelect({ url, mediaId, alt: \"\" });\n onOpenChange(false);\n } catch (err) {\n toast({ title: \"Upload mislukt\", description: (err as Error).message, tone: \"error\" });\n } finally {\n setBusy(false);\n }\n }\n\n return (\n <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent title=\"Afbeelding kiezen\" className=\"max-w-2xl\">\n <div className=\"mb-3 flex items-center gap-2\">\n <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder=\"Zoek in bibliotheek…\" className=\"h-9\" />\n <Button type=\"button\" disabled={busy} onClick={() => inputRef.current?.click()}>\n {busy ? \"Uploaden…\" : \"Upload nieuwe\"}\n </Button>\n <input ref={inputRef} type=\"file\" accept=\"image/*\" hidden onChange={(e) => { void onUpload(e.target.files); e.target.value = \"\"; }} />\n </div>\n <div className=\"max-h-[55vh] overflow-y-auto\">\n {filtered.length === 0 ? (\n <p className=\"py-8 text-center text-sm text-slate-500\">\n {items.length === 0 ? \"Nog geen afbeeldingen — upload er een.\" : \"Geen resultaten.\"}\n </p>\n ) : (\n <div className=\"grid grid-cols-3 gap-2 sm:grid-cols-4\">\n {filtered.map((m) => (\n <button\n key={m.id}\n type=\"button\"\n onClick={() => pick(m)}\n title={m.alt || m.name}\n className=\"group relative overflow-hidden rounded-md border border-slate-200 focus:outline-none focus:ring-2 focus:ring-indigo-500\"\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img src={m.url} alt={m.alt ?? m.name} className=\"aspect-square w-full object-cover transition group-hover:opacity-80\" />\n </button>\n ))}\n </div>\n )}\n {hasMore && !q.trim() && (\n <p className=\"mt-2 text-center\">\n <Button type=\"button\" variant=\"ghost\" size=\"sm\" onClick={() => setPageCount((c) => c + 1)}>\n Meer laden…\n </Button>\n </p>\n )}\n </div>\n </DialogContent>\n </Dialog>\n );\n}\n\n/**\n * Knop die de mediabibliotheek-kiezer opent: zoek + klik om een bestaande afbeelding te kiezen, of\n * upload er direct een nieuwe in. Vervangt het plakken van losse URL's.\n */\nexport function MediaPickerButton({\n siteId,\n onSelect,\n label = \"Kies / upload\",\n size = \"sm\",\n variant = \"secondary\",\n}: {\n siteId: string;\n onSelect: (m: PickedMedia) => void;\n label?: string;\n size?: \"sm\" | \"md\";\n variant?: \"primary\" | \"secondary\" | \"ghost\";\n}) {\n const [open, setOpen] = useState(false);\n return (\n <>\n <Button type=\"button\" variant={variant} size={size} onClick={() => setOpen(true)}>{label}</Button>\n <MediaPickerDialog siteId={siteId} open={open} onOpenChange={setOpen} onSelect={onSelect} />\n </>\n );\n}\n"],"mappings":"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","names":[]}
@@ -1,5 +1,6 @@
1
1
  import { type DraftPage } from "../lib/pagesClient";
2
- export declare function PageEditor({ siteId, page }: {
2
+ export declare function PageEditor({ siteId, page, allPages }: {
3
3
  siteId: string;
4
4
  page: DraftPage;
5
+ allPages?: DraftPage[];
5
6
  }): import("react").JSX.Element;
@@ -22,7 +22,7 @@ function formatVersionDate(createdAt) {
22
22
  if (!d || Number.isNaN(d.getTime())) return "";
23
23
  return d.toLocaleString("nl-NL", { dateStyle: "medium", timeStyle: "short" });
24
24
  }
25
- function PageEditor({ siteId, page }) {
25
+ function PageEditor({ siteId, page, allPages }) {
26
26
  const registry = useRegistry();
27
27
  const { activeIds, defs } = useModules();
28
28
  const { toast } = useToast();
@@ -176,7 +176,21 @@ function PageEditor({ siteId, page }) {
176
176
  saveState === "error" && /* @__PURE__ */ jsx("div", { className: "mb-3 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700", children: "Niet alle wijzigingen zijn opgeslagen. Controleer je verbinding en klik \u201COpnieuw opslaan\u201D." }),
177
177
  /* @__PURE__ */ jsxs("div", { className: "grid gap-3 md:grid-cols-2", children: [
178
178
  /* @__PURE__ */ jsx(Field, { label: "Titel", children: /* @__PURE__ */ jsx(Input, { value: draft.title, onChange: (e) => setTitle(e.target.value) }) }),
179
- /* @__PURE__ */ jsx(Field, { label: "Pad", hint: "bv. / of /over-ons", children: /* @__PURE__ */ jsx(Input, { value: draft.path, onChange: (e) => update({ ...draft, path: e.target.value }) }) }),
179
+ /* @__PURE__ */ jsxs(Field, { label: "Pad", hint: "bv. / of /over-ons", children: [
180
+ /* @__PURE__ */ jsx(
181
+ Input,
182
+ {
183
+ value: draft.path,
184
+ onChange: (e) => update({ ...draft, path: e.target.value }),
185
+ onBlur: () => {
186
+ const parts = draft.path.split("/").map((s) => slugify(s)).filter(Boolean);
187
+ const normalized = "/" + parts.join("/");
188
+ if (normalized !== draft.path) update({ ...draft, path: normalized });
189
+ }
190
+ }
191
+ ),
192
+ allPages?.some((p) => p.id !== draft.id && p.path === draft.path) && /* @__PURE__ */ jsx("p", { className: "mt-1 text-xs text-red-600", role: "alert", children: "\u26A0 Er bestaat al een pagina met dit pad \u2014 \xE9\xE9n van beide wordt dan onbereikbaar." })
193
+ ] }),
180
194
  /* @__PURE__ */ jsx(Field, { label: "SEO-titel", children: /* @__PURE__ */ jsx(Input, { value: draft.seo?.title ?? "", onChange: (e) => update({ ...draft, seo: { ...draft.seo, title: e.target.value } }) }) }),
181
195
  /* @__PURE__ */ jsx(Field, { label: "SEO-omschrijving", children: /* @__PURE__ */ jsx(Textarea, { rows: 2, value: draft.seo?.description ?? "", onChange: (e) => update({ ...draft, seo: { ...draft.seo, description: e.target.value } }) }) })
182
196
  ] })