@xiwebdesign/xicms-core 4.17.1 → 5.0.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.
- package/dist/XiCMSProvider.d.ts +7 -0
- package/dist/XiCMSProvider.js +32 -7
- package/dist/XiCMSProvider.js.map +1 -1
- package/dist/admin/blockEditors/GenericEditor.d.ts +22 -0
- package/dist/admin/blockEditors/GenericEditor.js +201 -0
- package/dist/admin/blockEditors/GenericEditor.js.map +1 -0
- package/dist/admin/blockEditors/SymbolRefEditor.d.ts +2 -0
- package/dist/admin/blockEditors/SymbolRefEditor.js +55 -0
- package/dist/admin/blockEditors/SymbolRefEditor.js.map +1 -0
- package/dist/admin/blockEditors/index.d.ts +11 -6
- package/dist/admin/blockEditors/index.js +49 -620
- package/dist/admin/blockEditors/index.js.map +1 -1
- package/dist/admin/customBlocks/CustomBlocksPanel.d.ts +3 -0
- package/dist/admin/customBlocks/CustomBlocksPanel.js +145 -0
- package/dist/admin/customBlocks/CustomBlocksPanel.js.map +1 -0
- package/dist/admin/lib/compressImage.d.ts +4 -2
- package/dist/admin/lib/compressImage.js +7 -5
- package/dist/admin/lib/compressImage.js.map +1 -1
- package/dist/admin/lib/mediaUsage.d.ts +4 -3
- package/dist/admin/lib/mediaUsage.js +21 -10
- package/dist/admin/lib/mediaUsage.js.map +1 -1
- package/dist/admin/lib/symbolsClient.d.ts +13 -0
- package/dist/admin/lib/symbolsClient.js +51 -0
- package/dist/admin/lib/symbolsClient.js.map +1 -0
- package/dist/admin/lib/upload.d.ts +4 -0
- package/dist/admin/lib/upload.js +26 -6
- package/dist/admin/lib/upload.js.map +1 -1
- package/dist/admin/lib/zodFields.d.ts +18 -0
- package/dist/admin/lib/zodFields.js +65 -2
- package/dist/admin/lib/zodFields.js.map +1 -1
- package/dist/admin/media/MediaManager.js +23 -7
- package/dist/admin/media/MediaManager.js.map +1 -1
- package/dist/admin/media/MediaPicker.js +12 -2
- package/dist/admin/media/MediaPicker.js.map +1 -1
- package/dist/admin/panelRegistry.js +3 -0
- package/dist/admin/panelRegistry.js.map +1 -1
- package/dist/admin/settings/SettingsPanel.js +6 -1
- package/dist/admin/settings/SettingsPanel.js.map +1 -1
- package/dist/admin/settings/SnapshotsCard.d.ts +3 -0
- package/dist/admin/settings/SnapshotsCard.js +98 -0
- package/dist/admin/settings/SnapshotsCard.js.map +1 -0
- package/dist/admin/visual/TranslationPanel.d.ts +13 -0
- package/dist/admin/visual/TranslationPanel.js +73 -0
- package/dist/admin/visual/TranslationPanel.js.map +1 -0
- package/dist/admin/visual/VisualEditor.js +146 -18
- package/dist/admin/visual/VisualEditor.js.map +1 -1
- package/dist/admin.css +1 -1
- package/dist/agentFiles/catalog.d.ts +21 -0
- package/dist/agentFiles/catalog.js +78 -0
- package/dist/agentFiles/catalog.js.map +1 -0
- package/dist/agentFiles/files.d.ts +59 -0
- package/dist/agentFiles/files.js +109 -0
- package/dist/agentFiles/files.js.map +1 -0
- package/dist/blocks/builtins/Agenda.d.ts +6 -0
- package/dist/blocks/builtins/Agenda.js +14 -6
- package/dist/blocks/builtins/Agenda.js.map +1 -1
- package/dist/blocks/builtins/BeforeAfter.js +1 -0
- package/dist/blocks/builtins/BeforeAfter.js.map +1 -1
- package/dist/blocks/builtins/Breadcrumbs.js +5 -1
- package/dist/blocks/builtins/Breadcrumbs.js.map +1 -1
- package/dist/blocks/builtins/Cards.js +4 -2
- package/dist/blocks/builtins/Cards.js.map +1 -1
- package/dist/blocks/builtins/Contact.js +2 -1
- package/dist/blocks/builtins/Contact.js.map +1 -1
- package/dist/blocks/builtins/Cta.js +1 -0
- package/dist/blocks/builtins/Cta.js.map +1 -1
- package/dist/blocks/builtins/Elements.js +1 -0
- package/dist/blocks/builtins/Elements.js.map +1 -1
- package/dist/blocks/builtins/Faq.js +5 -4
- package/dist/blocks/builtins/Faq.js.map +1 -1
- package/dist/blocks/builtins/Gallery.js +2 -2
- package/dist/blocks/builtins/Gallery.js.map +1 -1
- package/dist/blocks/builtins/Hero.js +2 -2
- package/dist/blocks/builtins/Hero.js.map +1 -1
- package/dist/blocks/builtins/Jobs.d.ts +8 -1
- package/dist/blocks/builtins/Jobs.js +38 -21
- package/dist/blocks/builtins/Jobs.js.map +1 -1
- package/dist/blocks/builtins/MapBlock.js +31 -4
- package/dist/blocks/builtins/MapBlock.js.map +1 -1
- package/dist/blocks/builtins/PdfBlock.js +2 -3
- package/dist/blocks/builtins/PdfBlock.js.map +1 -1
- package/dist/blocks/builtins/Portfolio.js +2 -2
- package/dist/blocks/builtins/Portfolio.js.map +1 -1
- package/dist/blocks/builtins/Pricing.d.ts +2 -0
- package/dist/blocks/builtins/Pricing.js +18 -10
- package/dist/blocks/builtins/Pricing.js.map +1 -1
- package/dist/blocks/builtins/Sections.js +4 -2
- package/dist/blocks/builtins/Sections.js.map +1 -1
- package/dist/blocks/builtins/Slider.js +21 -3
- package/dist/blocks/builtins/Slider.js.map +1 -1
- package/dist/blocks/builtins/SymbolRef.d.ts +2 -0
- package/dist/blocks/builtins/SymbolRef.js +61 -0
- package/dist/blocks/builtins/SymbolRef.js.map +1 -0
- package/dist/blocks/builtins/Testimonials.js +21 -18
- package/dist/blocks/builtins/Testimonials.js.map +1 -1
- package/dist/blocks/builtins/WhatsappChat.js +1 -1
- package/dist/blocks/builtins/WhatsappChat.js.map +1 -1
- package/dist/blocks/builtins/Widgets.js +2 -3
- package/dist/blocks/builtins/Widgets.js.map +1 -1
- package/dist/blocks/builtins/lib/ics.js +1 -1
- package/dist/blocks/builtins/lib/ics.js.map +1 -1
- package/dist/blocks/builtins/lib/openingHours.d.ts +4 -1
- package/dist/blocks/builtins/lib/openingHours.js +11 -5
- package/dist/blocks/builtins/lib/openingHours.js.map +1 -1
- package/dist/blocks/customTemplate/definition.d.ts +7 -0
- package/dist/blocks/customTemplate/definition.js +34 -0
- package/dist/blocks/customTemplate/definition.js.map +1 -0
- package/dist/blocks/customTemplate/interpret.d.ts +10 -0
- package/dist/blocks/customTemplate/interpret.js +136 -0
- package/dist/blocks/customTemplate/interpret.js.map +1 -0
- package/dist/blocks/registry.d.ts +8 -0
- package/dist/blocks/registry.js.map +1 -1
- package/dist/demo/demoStore.d.ts +11 -1
- package/dist/demo/demoStore.js +50 -0
- package/dist/demo/demoStore.js.map +1 -1
- package/dist/firebase/client.js +2 -1
- package/dist/firebase/client.js.map +1 -1
- package/dist/i18n/overlay.d.ts +61 -0
- package/dist/i18n/overlay.js +90 -0
- package/dist/i18n/overlay.js.map +1 -0
- package/dist/index.d.ts +11 -9
- package/dist/index.js +34 -9
- package/dist/index.js.map +1 -1
- package/dist/modules/builtins.js +3 -2
- package/dist/modules/builtins.js.map +1 -1
- package/dist/modules/consent/useConsent.js +15 -3
- package/dist/modules/consent/useConsent.js.map +1 -1
- package/dist/schema/blocks.d.ts +183 -3
- package/dist/schema/blocks.js +12 -1
- package/dist/schema/blocks.js.map +1 -1
- package/dist/schema/customBlocks.d.ts +47 -0
- package/dist/schema/customBlocks.js +32 -0
- package/dist/schema/customBlocks.js.map +1 -0
- package/dist/schema/fields.d.ts +122 -0
- package/dist/schema/fields.js +120 -0
- package/dist/schema/fields.js.map +1 -0
- package/dist/schema/forms.d.ts +6 -6
- package/dist/schema/pages.d.ts +15 -1
- package/dist/schema/pages.js +6 -2
- package/dist/schema/pages.js.map +1 -1
- package/dist/schema/siteConfig.d.ts +14 -8
- package/dist/schema/siteConfig.js +2 -1
- package/dist/schema/siteConfig.js.map +1 -1
- package/dist/schema/symbols.d.ts +205 -0
- package/dist/schema/symbols.js +18 -0
- package/dist/schema/symbols.js.map +1 -0
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.js +24 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/pages.d.ts +21 -0
- package/dist/server/pages.js +77 -0
- package/dist/server/pages.js.map +1 -1
- package/dist/server/seo.d.ts +12 -3
- package/dist/server/seo.js +23 -11
- package/dist/server/seo.js.map +1 -1
- package/dist/styles.css +62 -6
- package/package.json +1 -1
- package/dist/components/EditableImage.d.ts +0 -18
- package/dist/components/EditableImage.js +0 -62
- package/dist/components/EditableImage.js.map +0 -1
- package/dist/components/EditableRichText.d.ts +0 -14
- package/dist/components/EditableRichText.js +0 -26
- package/dist/components/EditableRichText.js.map +0 -1
- package/dist/components/EditableText.d.ts +0 -21
- package/dist/components/EditableText.js +0 -25
- package/dist/components/EditableText.js.map +0 -1
- package/dist/content/ContentProvider.d.ts +0 -23
- package/dist/content/ContentProvider.js +0 -86
- package/dist/content/ContentProvider.js.map +0 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { collection, onSnapshot } from "firebase/firestore";
|
|
5
|
+
import { getFunctions, httpsCallable } from "firebase/functions";
|
|
6
|
+
import { getDb, getFirebaseApp } from "../../firebase/client";
|
|
7
|
+
import { functionsRegion } from "../../firebase/region";
|
|
8
|
+
import { CustomBlockSchema } from "../../schema/customBlocks";
|
|
9
|
+
import { FieldsSchema } from "../../schema/fields";
|
|
10
|
+
import { Button, Field, Input, Textarea } from "../ui/primitives";
|
|
11
|
+
import { useToast } from "../ui/toast";
|
|
12
|
+
import { useConfirm } from "../ui/ConfirmDialog";
|
|
13
|
+
const EMPTY = {
|
|
14
|
+
type: "custom-",
|
|
15
|
+
label: "",
|
|
16
|
+
icon: "\u{1F9E9}",
|
|
17
|
+
fields: [],
|
|
18
|
+
template: "",
|
|
19
|
+
css: "",
|
|
20
|
+
version: 1
|
|
21
|
+
};
|
|
22
|
+
const FIELDS_VOORBEELD = `[
|
|
23
|
+
{ "key": "heading", "label": "Kop", "type": "text" },
|
|
24
|
+
{ "key": "items", "label": "Items", "type": "repeater", "fields": [
|
|
25
|
+
{ "key": "title", "label": "Titel", "type": "text" },
|
|
26
|
+
{ "key": "bodyHtml", "label": "Tekst", "type": "richtext" }
|
|
27
|
+
] }
|
|
28
|
+
]`;
|
|
29
|
+
function CustomBlocksPanel({ siteId }) {
|
|
30
|
+
const { toast } = useToast();
|
|
31
|
+
const confirm = useConfirm();
|
|
32
|
+
const [blocks, setBlocks] = useState([]);
|
|
33
|
+
const [editing, setEditing] = useState(null);
|
|
34
|
+
const [fieldsText, setFieldsText] = useState("[]");
|
|
35
|
+
const [busy, setBusy] = useState(false);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const db = getDb();
|
|
38
|
+
if (!db) return;
|
|
39
|
+
return onSnapshot(collection(db, "sites", siteId, "customBlocks"), (snap) => {
|
|
40
|
+
const out = [];
|
|
41
|
+
snap.forEach((d) => {
|
|
42
|
+
const res = CustomBlockSchema.safeParse({ ...d.data(), type: d.id });
|
|
43
|
+
if (res.success) out.push(res.data);
|
|
44
|
+
});
|
|
45
|
+
setBlocks(out.sort((a, b) => a.label.localeCompare(b.label)));
|
|
46
|
+
});
|
|
47
|
+
}, [siteId]);
|
|
48
|
+
function open(def) {
|
|
49
|
+
setEditing(def);
|
|
50
|
+
setFieldsText(JSON.stringify(def.fields, null, 2));
|
|
51
|
+
}
|
|
52
|
+
async function save() {
|
|
53
|
+
if (!editing) return;
|
|
54
|
+
let fields;
|
|
55
|
+
try {
|
|
56
|
+
fields = JSON.parse(fieldsText);
|
|
57
|
+
} catch {
|
|
58
|
+
toast({ title: "Velden zijn geen geldige JSON", tone: "error" });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const parsedFields = FieldsSchema.safeParse(fields);
|
|
62
|
+
if (!parsedFields.success) {
|
|
63
|
+
toast({ title: "Velden ongeldig", description: parsedFields.error.issues[0]?.message, tone: "error" });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const candidate = CustomBlockSchema.safeParse({ ...editing, fields: parsedFields.data });
|
|
67
|
+
if (!candidate.success) {
|
|
68
|
+
toast({ title: "Bouwsteen ongeldig", description: candidate.error.issues[0]?.message, tone: "error" });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const app = getFirebaseApp();
|
|
72
|
+
if (!app) return;
|
|
73
|
+
setBusy(true);
|
|
74
|
+
try {
|
|
75
|
+
const fn = httpsCallable(getFunctions(app, functionsRegion()), "saveCustomBlock");
|
|
76
|
+
const res = await fn({ siteId, ...candidate.data });
|
|
77
|
+
toast({ title: "Opgeslagen", description: res.data.message, tone: "success" });
|
|
78
|
+
setEditing(null);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
toast({ title: "Opslaan mislukt", description: e.message, tone: "error" });
|
|
81
|
+
} finally {
|
|
82
|
+
setBusy(false);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function remove(type) {
|
|
86
|
+
const ok = await confirm({ title: "Bouwsteen verwijderen?", description: "Pagina's die dit blok gebruiken slaan het daarna over.", confirmLabel: "Verwijderen", tone: "danger" });
|
|
87
|
+
if (!ok) return;
|
|
88
|
+
const app = getFirebaseApp();
|
|
89
|
+
if (!app) return;
|
|
90
|
+
try {
|
|
91
|
+
await httpsCallable(getFunctions(app, functionsRegion()), "deleteCustomBlock")({ siteId, type });
|
|
92
|
+
toast({ title: "Verwijderd", tone: "success" });
|
|
93
|
+
} catch (e) {
|
|
94
|
+
toast({ title: "Verwijderen mislukt", description: e.message, tone: "error" });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (editing) {
|
|
98
|
+
return /* @__PURE__ */ jsxs("div", { className: "grid max-w-3xl gap-3", children: [
|
|
99
|
+
/* @__PURE__ */ jsx("h2", { className: "text-lg font-bold", children: editing.label || editing.type }),
|
|
100
|
+
/* @__PURE__ */ jsxs("div", { className: "grid grid-cols-2 gap-2", children: [
|
|
101
|
+
/* @__PURE__ */ jsx(Field, { label: "Bloktype", hint: 'begint met "custom-"; niet meer te wijzigen na aanmaken', children: /* @__PURE__ */ jsx(Input, { value: editing.type, onChange: (e) => setEditing({ ...editing, type: e.target.value }), disabled: blocks.some((b) => b.type === editing.type) }) }),
|
|
102
|
+
/* @__PURE__ */ jsx(Field, { label: "Naam in het menu", children: /* @__PURE__ */ jsx(Input, { value: editing.label, onChange: (e) => setEditing({ ...editing, label: e.target.value }) }) })
|
|
103
|
+
] }),
|
|
104
|
+
/* @__PURE__ */ jsx(Field, { label: "Icoon (emoji)", children: /* @__PURE__ */ jsx(Input, { value: editing.icon, maxLength: 16, onChange: (e) => setEditing({ ...editing, icon: e.target.value }), className: "w-24" }) }),
|
|
105
|
+
/* @__PURE__ */ jsx(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:
|
|
106
|
+
${FIELDS_VOORBEELD}`, children: /* @__PURE__ */ jsx(Textarea, { rows: 10, value: fieldsText, onChange: (e) => setFieldsText(e.target.value), spellCheck: false, className: "font-mono text-xs" }) }),
|
|
107
|
+
/* @__PURE__ */ jsx(Field, { label: "Template", hint: "Mustache-subset: {{veld}}, {{{veldHtml}}}, {{#each items}}\u2026{{/each}}, {{#if veld}}\u2026{{else}}\u2026{{/if}}. Geen scripts \u2014 die worden bij opslaan gestript.", children: /* @__PURE__ */ jsx(Textarea, { rows: 10, value: editing.template, onChange: (e) => setEditing({ ...editing, template: e.target.value }), spellCheck: false, className: "font-mono text-xs" }) }),
|
|
108
|
+
/* @__PURE__ */ jsx(Field, { label: "CSS", hint: 'Automatisch gescoped op dit blok ([data-xicms-cb="\u2026"]).', children: /* @__PURE__ */ jsx(Textarea, { rows: 8, value: editing.css, onChange: (e) => setEditing({ ...editing, css: e.target.value }), spellCheck: false, className: "font-mono text-xs" }) }),
|
|
109
|
+
/* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
|
|
110
|
+
/* @__PURE__ */ jsx(Button, { type: "button", variant: "primary", disabled: busy, onClick: () => void save(), children: busy ? "Opslaan\u2026" : "Opslaan" }),
|
|
111
|
+
/* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", onClick: () => setEditing(null), children: "Annuleren" })
|
|
112
|
+
] })
|
|
113
|
+
] });
|
|
114
|
+
}
|
|
115
|
+
return /* @__PURE__ */ jsxs("div", { className: "grid gap-3", children: [
|
|
116
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between", children: [
|
|
117
|
+
/* @__PURE__ */ jsx("h2", { className: "text-lg font-bold", children: "Bouwstenen" }),
|
|
118
|
+
/* @__PURE__ */ jsx(Button, { type: "button", variant: "secondary", size: "sm", onClick: () => open(EMPTY), children: "+ Nieuwe bouwsteen" })
|
|
119
|
+
] }),
|
|
120
|
+
/* @__PURE__ */ jsx("p", { className: "text-sm text-slate-500", children: "Eigen bloktypes voor deze site \u2014 zonder release. Definieer velden, een veilige template en CSS; het blok verschijnt direct in het toevoeg-menu van de pagina-editor." }),
|
|
121
|
+
blocks.length === 0 && /* @__PURE__ */ jsx("p", { className: "text-sm text-slate-500", children: "Nog geen bouwstenen." }),
|
|
122
|
+
/* @__PURE__ */ jsx("ul", { className: "grid gap-2", children: blocks.map((b) => /* @__PURE__ */ jsxs("li", { className: "flex items-center justify-between rounded-lg border border-slate-200 p-3", children: [
|
|
123
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
124
|
+
/* @__PURE__ */ jsx("span", { className: "mr-2", children: b.icon }),
|
|
125
|
+
/* @__PURE__ */ jsx("strong", { children: b.label }),
|
|
126
|
+
" ",
|
|
127
|
+
/* @__PURE__ */ jsxs("span", { className: "text-xs text-slate-500", children: [
|
|
128
|
+
"(",
|
|
129
|
+
b.type,
|
|
130
|
+
" \xB7 v",
|
|
131
|
+
b.version,
|
|
132
|
+
")"
|
|
133
|
+
] })
|
|
134
|
+
] }),
|
|
135
|
+
/* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
|
|
136
|
+
/* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => open(b), children: "Bewerken" }),
|
|
137
|
+
/* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => void remove(b.type), children: "Verwijderen" })
|
|
138
|
+
] })
|
|
139
|
+
] }, b.type)) })
|
|
140
|
+
] });
|
|
141
|
+
}
|
|
142
|
+
export {
|
|
143
|
+
CustomBlocksPanel
|
|
144
|
+
};
|
|
145
|
+
//# sourceMappingURL=CustomBlocksPanel.js.map
|
|
@@ -0,0 +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":"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","names":[]}
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
* zijde) en exporteert als webp. Dit bespaart fors op Storage-kosten (een 5MB-foto wordt ~200KB)
|
|
4
4
|
* en houdt de opgeslagen URL stabiel (we uploaden meteen het kleine bestand).
|
|
5
5
|
*
|
|
6
|
-
* Vector/animatie (SVG/GIF) en al kleine bestanden worden ongemoeid gelaten. Bij
|
|
7
|
-
* de functie terug op het
|
|
6
|
+
* Vector/animatie (SVG/GIF) en al kleine bestanden worden ongemoeid gelaten. Bij een fout in de
|
|
7
|
+
* compressie-stap valt de functie terug op het origineel — MAAR een afbeelding die de browser
|
|
8
|
+
* überhaupt niet kan decoderen (HEIC van een iPhone bv.) wordt geweigerd met een duidelijke fout:
|
|
9
|
+
* stil doorsturen zou een bestand uploaden dat bij vrijwel geen enkele bezoeker rendert.
|
|
8
10
|
*/
|
|
9
11
|
export declare function compressImage(file: File, opts?: {
|
|
10
12
|
maxSize?: number;
|
|
@@ -5,8 +5,8 @@ async function compressImage(file, opts = {}) {
|
|
|
5
5
|
if (typeof document === "undefined") return file;
|
|
6
6
|
if (!file.type.startsWith("image/")) return file;
|
|
7
7
|
if (file.type === "image/svg+xml" || file.type === "image/gif") return file;
|
|
8
|
+
const bitmap = await loadBitmap(file);
|
|
8
9
|
try {
|
|
9
|
-
const bitmap = await loadBitmap(file);
|
|
10
10
|
const scale = Math.min(1, maxSize / Math.max(bitmap.width, bitmap.height));
|
|
11
11
|
const w = Math.max(1, Math.round(bitmap.width * scale));
|
|
12
12
|
const h = Math.max(1, Math.round(bitmap.height * scale));
|
|
@@ -21,8 +21,10 @@ async function compressImage(file, opts = {}) {
|
|
|
21
21
|
(resolve) => canvas.toBlob(resolve, "image/webp", quality)
|
|
22
22
|
);
|
|
23
23
|
if (!blob || blob.size >= file.size) return file;
|
|
24
|
-
const
|
|
25
|
-
|
|
24
|
+
const type = blob.type || "image/webp";
|
|
25
|
+
const ext = type === "image/webp" ? ".webp" : type === "image/png" ? ".png" : ".jpg";
|
|
26
|
+
const name = file.name.replace(/\.[^.]+$/, "") + ext;
|
|
27
|
+
return new File([blob], name, { type, lastModified: Date.now() });
|
|
26
28
|
} catch {
|
|
27
29
|
return file;
|
|
28
30
|
}
|
|
@@ -30,7 +32,7 @@ async function compressImage(file, opts = {}) {
|
|
|
30
32
|
async function loadBitmap(file) {
|
|
31
33
|
if (typeof createImageBitmap === "function") {
|
|
32
34
|
try {
|
|
33
|
-
return await createImageBitmap(file);
|
|
35
|
+
return await createImageBitmap(file, { imageOrientation: "from-image" });
|
|
34
36
|
} catch {
|
|
35
37
|
}
|
|
36
38
|
}
|
|
@@ -39,7 +41,7 @@ async function loadBitmap(file) {
|
|
|
39
41
|
const img = new Image();
|
|
40
42
|
await new Promise((resolve, reject) => {
|
|
41
43
|
img.onload = () => resolve();
|
|
42
|
-
img.onerror = () => reject(new Error("
|
|
44
|
+
img.onerror = () => reject(new Error("Dit bestandsformaat kan de browser niet lezen (bv. HEIC) \u2014 sla de foto op als JPG of PNG en probeer opnieuw."));
|
|
43
45
|
img.src = url;
|
|
44
46
|
});
|
|
45
47
|
return img;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/admin/lib/compressImage.ts"],"sourcesContent":["\"use client\";\n\n/**\n * Comprimeert een afbeelding in de browser vóór upload: schaalt naar max `maxSize` px (langste\n * zijde) en exporteert als webp. Dit bespaart fors op Storage-kosten (een 5MB-foto wordt ~200KB)\n * en houdt de opgeslagen URL stabiel (we uploaden meteen het kleine bestand).\n *\n * Vector/animatie (SVG/GIF) en al kleine bestanden worden ongemoeid gelaten. Bij
|
|
1
|
+
{"version":3,"sources":["../../../src/admin/lib/compressImage.ts"],"sourcesContent":["\"use client\";\n\n/**\n * Comprimeert een afbeelding in de browser vóór upload: schaalt naar max `maxSize` px (langste\n * zijde) en exporteert als webp. Dit bespaart fors op Storage-kosten (een 5MB-foto wordt ~200KB)\n * en houdt de opgeslagen URL stabiel (we uploaden meteen het kleine bestand).\n *\n * Vector/animatie (SVG/GIF) en al kleine bestanden worden ongemoeid gelaten. Bij een fout in de\n * compressie-stap valt de functie terug op het origineel — MAAR een afbeelding die de browser\n * überhaupt niet kan decoderen (HEIC van een iPhone bv.) wordt geweigerd met een duidelijke fout:\n * stil doorsturen zou een bestand uploaden dat bij vrijwel geen enkele bezoeker rendert.\n */\nexport async function compressImage(\n file: File,\n opts: { maxSize?: number; quality?: number } = {},\n): Promise<File> {\n const maxSize = opts.maxSize ?? 2048;\n const quality = opts.quality ?? 0.82;\n\n if (typeof document === \"undefined\") return file; // alleen client\n if (!file.type.startsWith(\"image/\")) return file;\n if (file.type === \"image/svg+xml\" || file.type === \"image/gif\") return file;\n\n const bitmap = await loadBitmap(file); // gooit bij niet-decodeerbaar formaat (bv. HEIC)\n\n try {\n const scale = Math.min(1, maxSize / Math.max(bitmap.width, bitmap.height));\n const w = Math.max(1, Math.round(bitmap.width * scale));\n const h = Math.max(1, Math.round(bitmap.height * scale));\n\n const canvas = document.createElement(\"canvas\");\n canvas.width = w;\n canvas.height = h;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return file;\n ctx.drawImage(bitmap as CanvasImageSource, 0, 0, w, h);\n if (\"close\" in bitmap && typeof bitmap.close === \"function\") bitmap.close();\n\n const blob = await new Promise<Blob | null>((resolve) =>\n canvas.toBlob(resolve, \"image/webp\", quality),\n );\n if (!blob || blob.size >= file.size) return file; // compressie hielp niet\n // Safari kan stil een ander type teruggeven dan gevraagd — label het bestand dan eerlijk.\n const type = blob.type || \"image/webp\";\n const ext = type === \"image/webp\" ? \".webp\" : type === \"image/png\" ? \".png\" : \".jpg\";\n const name = file.name.replace(/\\.[^.]+$/, \"\") + ext;\n return new File([blob], name, { type, lastModified: Date.now() });\n } catch {\n return file; // compressie faalde, maar het beeld ís decodeerbaar → origineel is veilig\n }\n}\n\nasync function loadBitmap(file: File): Promise<ImageBitmap | HTMLImageElement> {\n if (typeof createImageBitmap === \"function\") {\n try {\n // EXIF-oriëntatie expliciet respecteren (default in moderne browsers; hardening voor oude).\n return await createImageBitmap(file, { imageOrientation: \"from-image\" });\n } catch {\n /* val terug op <img> */\n }\n }\n const url = URL.createObjectURL(file);\n try {\n const img = new Image();\n await new Promise<void>((resolve, reject) => {\n img.onload = () => resolve();\n img.onerror = () =>\n reject(new Error(\"Dit bestandsformaat kan de browser niet lezen (bv. HEIC) — sla de foto op als JPG of PNG en probeer opnieuw.\"));\n img.src = url;\n });\n return img;\n } finally {\n URL.revokeObjectURL(url);\n }\n}\n"],"mappings":";AAYA,eAAsB,cACpB,MACA,OAA+C,CAAC,GACjC;AACf,QAAM,UAAU,KAAK,WAAW;AAChC,QAAM,UAAU,KAAK,WAAW;AAEhC,MAAI,OAAO,aAAa,YAAa,QAAO;AAC5C,MAAI,CAAC,KAAK,KAAK,WAAW,QAAQ,EAAG,QAAO;AAC5C,MAAI,KAAK,SAAS,mBAAmB,KAAK,SAAS,YAAa,QAAO;AAEvE,QAAM,SAAS,MAAM,WAAW,IAAI;AAEpC,MAAI;AACF,UAAM,QAAQ,KAAK,IAAI,GAAG,UAAU,KAAK,IAAI,OAAO,OAAO,OAAO,MAAM,CAAC;AACzE,UAAM,IAAI,KAAK,IAAI,GAAG,KAAK,MAAM,OAAO,QAAQ,KAAK,CAAC;AACtD,UAAM,IAAI,KAAK,IAAI,GAAG,KAAK,MAAM,OAAO,SAAS,KAAK,CAAC;AAEvD,UAAM,SAAS,SAAS,cAAc,QAAQ;AAC9C,WAAO,QAAQ;AACf,WAAO,SAAS;AAChB,UAAM,MAAM,OAAO,WAAW,IAAI;AAClC,QAAI,CAAC,IAAK,QAAO;AACjB,QAAI,UAAU,QAA6B,GAAG,GAAG,GAAG,CAAC;AACrD,QAAI,WAAW,UAAU,OAAO,OAAO,UAAU,WAAY,QAAO,MAAM;AAE1E,UAAM,OAAO,MAAM,IAAI;AAAA,MAAqB,CAAC,YAC3C,OAAO,OAAO,SAAS,cAAc,OAAO;AAAA,IAC9C;AACA,QAAI,CAAC,QAAQ,KAAK,QAAQ,KAAK,KAAM,QAAO;AAE5C,UAAM,OAAO,KAAK,QAAQ;AAC1B,UAAM,MAAM,SAAS,eAAe,UAAU,SAAS,cAAc,SAAS;AAC9E,UAAM,OAAO,KAAK,KAAK,QAAQ,YAAY,EAAE,IAAI;AACjD,WAAO,IAAI,KAAK,CAAC,IAAI,GAAG,MAAM,EAAE,MAAM,cAAc,KAAK,IAAI,EAAE,CAAC;AAAA,EAClE,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,eAAe,WAAW,MAAqD;AAC7E,MAAI,OAAO,sBAAsB,YAAY;AAC3C,QAAI;AAEF,aAAO,MAAM,kBAAkB,MAAM,EAAE,kBAAkB,aAAa,CAAC;AAAA,IACzE,QAAQ;AAAA,IAER;AAAA,EACF;AACA,QAAM,MAAM,IAAI,gBAAgB,IAAI;AACpC,MAAI;AACF,UAAM,MAAM,IAAI,MAAM;AACtB,UAAM,IAAI,QAAc,CAAC,SAAS,WAAW;AAC3C,UAAI,SAAS,MAAM,QAAQ;AAC3B,UAAI,UAAU,MACZ,OAAO,IAAI,MAAM,mHAA8G,CAAC;AAClI,UAAI,MAAM;AAAA,IACZ,CAAC;AACD,WAAO;AAAA,EACT,UAAE;AACA,QAAI,gBAAgB,GAAG;AAAA,EACzB;AACF;","names":[]}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Zoekt (goedkoop) of een media-item in
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Zoekt (goedkoop) of een media-item ergens in gebruik is, zodat we kunnen waarschuwen vóór
|
|
3
|
+
* verwijderen (anders breekt een live pagina met een dode afbeelding). Gescand worden:
|
|
4
|
+
* gepubliceerde pagina's, concepten (drafts), nieuwsartikelen (covers/inline beelden) en de
|
|
5
|
+
* site-instellingen (og-beeld e.d.). Sites zijn klein → een JSON-substring-scan is ruim voldoende.
|
|
5
6
|
*/
|
|
6
7
|
export declare function findMediaUsage(siteId: string, item: {
|
|
7
8
|
id: string;
|
|
@@ -1,22 +1,33 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { collection, getDocs } from "firebase/firestore";
|
|
2
|
+
import { collection, doc, getDoc, getDocs } from "firebase/firestore";
|
|
3
3
|
import { getDb } from "../../firebase/client";
|
|
4
4
|
async function findMediaUsage(siteId, item) {
|
|
5
5
|
const db = getDb();
|
|
6
6
|
if (!db) return [];
|
|
7
|
+
const matches = (blob) => item.url && blob.includes(item.url) || blob.includes(item.id);
|
|
8
|
+
const hits = [];
|
|
7
9
|
try {
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
const [pages, drafts, news, settings] = await Promise.all([
|
|
11
|
+
getDocs(collection(db, "sites", siteId, "pages")),
|
|
12
|
+
getDocs(collection(db, "sites", siteId, "drafts")).catch(() => null),
|
|
13
|
+
getDocs(collection(db, "sites", siteId, "news")).catch(() => null),
|
|
14
|
+
getDoc(doc(db, "sites", siteId, "settings", "public")).catch(() => null)
|
|
15
|
+
]);
|
|
16
|
+
for (const d of pages.docs) {
|
|
11
17
|
const data = d.data();
|
|
12
|
-
|
|
13
|
-
if (item.url && blob.includes(item.url) || blob.includes(item.id)) {
|
|
14
|
-
hits.push(String(data.path ?? data.title ?? d.id));
|
|
15
|
-
}
|
|
18
|
+
if (matches(JSON.stringify(data.blocks ?? []))) hits.push(String(data.path ?? data.title ?? d.id));
|
|
16
19
|
}
|
|
17
|
-
|
|
20
|
+
for (const d of drafts?.docs ?? []) {
|
|
21
|
+
const data = d.data();
|
|
22
|
+
if (matches(JSON.stringify(data.blocks ?? []))) hits.push(`${String(data.path ?? d.id)} (concept)`);
|
|
23
|
+
}
|
|
24
|
+
for (const d of news?.docs ?? []) {
|
|
25
|
+
if (matches(JSON.stringify(d.data()))) hits.push(`nieuws: ${String(d.data().title ?? d.id)}`);
|
|
26
|
+
}
|
|
27
|
+
if (settings?.exists() && matches(JSON.stringify(settings.data()))) hits.push("site-instellingen");
|
|
28
|
+
return [...new Set(hits)];
|
|
18
29
|
} catch {
|
|
19
|
-
return
|
|
30
|
+
return hits;
|
|
20
31
|
}
|
|
21
32
|
}
|
|
22
33
|
export {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/admin/lib/mediaUsage.ts"],"sourcesContent":["\"use client\";\n\nimport { collection, getDocs } from \"firebase/firestore\";\nimport { getDb } from \"../../firebase/client\";\n\n/**\n * Zoekt (goedkoop) of een media-item in
|
|
1
|
+
{"version":3,"sources":["../../../src/admin/lib/mediaUsage.ts"],"sourcesContent":["\"use client\";\n\nimport { collection, doc, getDoc, getDocs } from \"firebase/firestore\";\nimport { getDb } from \"../../firebase/client\";\n\n/**\n * Zoekt (goedkoop) of een media-item ergens in gebruik is, zodat we kunnen waarschuwen vóór\n * verwijderen (anders breekt een live pagina met een dode afbeelding). Gescand worden:\n * gepubliceerde pagina's, concepten (drafts), nieuwsartikelen (covers/inline beelden) en de\n * site-instellingen (og-beeld e.d.). Sites zijn klein → een JSON-substring-scan is ruim voldoende.\n */\nexport async function findMediaUsage(\n siteId: string,\n item: { id: string; url?: string },\n): Promise<string[]> {\n const db = getDb();\n if (!db) return [];\n const matches = (blob: string) => (item.url && blob.includes(item.url)) || blob.includes(item.id);\n const hits: string[] = [];\n try {\n const [pages, drafts, news, settings] = await Promise.all([\n getDocs(collection(db, \"sites\", siteId, \"pages\")),\n getDocs(collection(db, \"sites\", siteId, \"drafts\")).catch(() => null),\n getDocs(collection(db, \"sites\", siteId, \"news\")).catch(() => null),\n getDoc(doc(db, \"sites\", siteId, \"settings\", \"public\")).catch(() => null),\n ]);\n for (const d of pages.docs) {\n const data = d.data() as Record<string, unknown>;\n if (matches(JSON.stringify(data.blocks ?? []))) hits.push(String(data.path ?? data.title ?? d.id));\n }\n for (const d of drafts?.docs ?? []) {\n const data = d.data() as Record<string, unknown>;\n if (matches(JSON.stringify(data.blocks ?? []))) hits.push(`${String(data.path ?? d.id)} (concept)`);\n }\n for (const d of news?.docs ?? []) {\n if (matches(JSON.stringify(d.data()))) hits.push(`nieuws: ${String((d.data() as { title?: string }).title ?? d.id)}`);\n }\n if (settings?.exists() && matches(JSON.stringify(settings.data()))) hits.push(\"site-instellingen\");\n return [...new Set(hits)];\n } catch {\n return hits;\n }\n}\n"],"mappings":";AAEA,SAAS,YAAY,KAAK,QAAQ,eAAe;AACjD,SAAS,aAAa;AAQtB,eAAsB,eACpB,QACA,MACmB;AACnB,QAAM,KAAK,MAAM;AACjB,MAAI,CAAC,GAAI,QAAO,CAAC;AACjB,QAAM,UAAU,CAAC,SAAkB,KAAK,OAAO,KAAK,SAAS,KAAK,GAAG,KAAM,KAAK,SAAS,KAAK,EAAE;AAChG,QAAM,OAAiB,CAAC;AACxB,MAAI;AACF,UAAM,CAAC,OAAO,QAAQ,MAAM,QAAQ,IAAI,MAAM,QAAQ,IAAI;AAAA,MACxD,QAAQ,WAAW,IAAI,SAAS,QAAQ,OAAO,CAAC;AAAA,MAChD,QAAQ,WAAW,IAAI,SAAS,QAAQ,QAAQ,CAAC,EAAE,MAAM,MAAM,IAAI;AAAA,MACnE,QAAQ,WAAW,IAAI,SAAS,QAAQ,MAAM,CAAC,EAAE,MAAM,MAAM,IAAI;AAAA,MACjE,OAAO,IAAI,IAAI,SAAS,QAAQ,YAAY,QAAQ,CAAC,EAAE,MAAM,MAAM,IAAI;AAAA,IACzE,CAAC;AACD,eAAW,KAAK,MAAM,MAAM;AAC1B,YAAM,OAAO,EAAE,KAAK;AACpB,UAAI,QAAQ,KAAK,UAAU,KAAK,UAAU,CAAC,CAAC,CAAC,EAAG,MAAK,KAAK,OAAO,KAAK,QAAQ,KAAK,SAAS,EAAE,EAAE,CAAC;AAAA,IACnG;AACA,eAAW,KAAK,QAAQ,QAAQ,CAAC,GAAG;AAClC,YAAM,OAAO,EAAE,KAAK;AACpB,UAAI,QAAQ,KAAK,UAAU,KAAK,UAAU,CAAC,CAAC,CAAC,EAAG,MAAK,KAAK,GAAG,OAAO,KAAK,QAAQ,EAAE,EAAE,CAAC,YAAY;AAAA,IACpG;AACA,eAAW,KAAK,MAAM,QAAQ,CAAC,GAAG;AAChC,UAAI,QAAQ,KAAK,UAAU,EAAE,KAAK,CAAC,CAAC,EAAG,MAAK,KAAK,WAAW,OAAQ,EAAE,KAAK,EAAyB,SAAS,EAAE,EAAE,CAAC,EAAE;AAAA,IACtH;AACA,QAAI,UAAU,OAAO,KAAK,QAAQ,KAAK,UAAU,SAAS,KAAK,CAAC,CAAC,EAAG,MAAK,KAAK,mBAAmB;AACjG,WAAO,CAAC,GAAG,IAAI,IAAI,IAAI,CAAC;AAAA,EAC1B,QAAQ;AACN,WAAO;AAAA,EACT;AACF;","names":[]}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type StoredSymbol } from "../../schema/symbols";
|
|
2
|
+
export declare function subscribeSymbols(siteId: string, cb: (symbols: StoredSymbol[]) => void): () => void;
|
|
3
|
+
export declare function getSymbolOnce(siteId: string, symbolId: string): Promise<StoredSymbol | null>;
|
|
4
|
+
export declare function saveSymbol(input: {
|
|
5
|
+
siteId: string;
|
|
6
|
+
symbolId?: string;
|
|
7
|
+
name: string;
|
|
8
|
+
blocks: Record<string, unknown>[];
|
|
9
|
+
}): Promise<{
|
|
10
|
+
id: string;
|
|
11
|
+
message?: string;
|
|
12
|
+
}>;
|
|
13
|
+
export declare function deleteSymbol(siteId: string, symbolId: string): Promise<void>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { collection, doc, getDoc, onSnapshot } from "firebase/firestore";
|
|
3
|
+
import { getFunctions, httpsCallable } from "firebase/functions";
|
|
4
|
+
import { getDb, getFirebaseApp } from "../../firebase/client";
|
|
5
|
+
import { functionsRegion } from "../../firebase/region";
|
|
6
|
+
import { SymbolSchema } from "../../schema/symbols";
|
|
7
|
+
function subscribeSymbols(siteId, cb) {
|
|
8
|
+
const db = getDb();
|
|
9
|
+
if (!db) {
|
|
10
|
+
cb([]);
|
|
11
|
+
return () => void 0;
|
|
12
|
+
}
|
|
13
|
+
return onSnapshot(
|
|
14
|
+
collection(db, "sites", siteId, "symbols"),
|
|
15
|
+
(snap) => {
|
|
16
|
+
const out = [];
|
|
17
|
+
snap.forEach((d) => {
|
|
18
|
+
const res = SymbolSchema.safeParse(d.data());
|
|
19
|
+
if (res.success) out.push({ id: d.id, ...res.data });
|
|
20
|
+
});
|
|
21
|
+
cb(out.sort((a, b) => a.name.localeCompare(b.name)));
|
|
22
|
+
},
|
|
23
|
+
() => cb([])
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
async function getSymbolOnce(siteId, symbolId) {
|
|
27
|
+
const db = getDb();
|
|
28
|
+
if (!db) return null;
|
|
29
|
+
const snap = await getDoc(doc(db, "sites", siteId, "symbols", symbolId));
|
|
30
|
+
if (!snap.exists()) return null;
|
|
31
|
+
const res = SymbolSchema.safeParse(snap.data());
|
|
32
|
+
return res.success ? { id: snap.id, ...res.data } : null;
|
|
33
|
+
}
|
|
34
|
+
async function saveSymbol(input) {
|
|
35
|
+
const app = getFirebaseApp();
|
|
36
|
+
if (!app) throw new Error("Firebase niet geconfigureerd.");
|
|
37
|
+
const fn = httpsCallable(getFunctions(app, functionsRegion()), "saveSymbol");
|
|
38
|
+
return (await fn(input)).data;
|
|
39
|
+
}
|
|
40
|
+
async function deleteSymbol(siteId, symbolId) {
|
|
41
|
+
const app = getFirebaseApp();
|
|
42
|
+
if (!app) throw new Error("Firebase niet geconfigureerd.");
|
|
43
|
+
await httpsCallable(getFunctions(app, functionsRegion()), "deleteSymbol")({ siteId, symbolId });
|
|
44
|
+
}
|
|
45
|
+
export {
|
|
46
|
+
deleteSymbol,
|
|
47
|
+
getSymbolOnce,
|
|
48
|
+
saveSymbol,
|
|
49
|
+
subscribeSymbols
|
|
50
|
+
};
|
|
51
|
+
//# sourceMappingURL=symbolsClient.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/admin/lib/symbolsClient.ts"],"sourcesContent":["\"use client\";\n\n/** Dataclient voor symbolen (5.0): live lijst + opslaan/verwijderen via de callables. */\nimport { collection, doc, getDoc, onSnapshot } from \"firebase/firestore\";\nimport { getFunctions, httpsCallable } from \"firebase/functions\";\nimport { getDb, getFirebaseApp } from \"../../firebase/client\";\nimport { functionsRegion } from \"../../firebase/region\";\nimport { SymbolSchema, type StoredSymbol } from \"../../schema/symbols\";\n\nexport function subscribeSymbols(siteId: string, cb: (symbols: StoredSymbol[]) => void): () => void {\n const db = getDb();\n if (!db) {\n cb([]);\n return () => undefined;\n }\n return onSnapshot(\n collection(db, \"sites\", siteId, \"symbols\"),\n (snap) => {\n const out: StoredSymbol[] = [];\n snap.forEach((d) => {\n const res = SymbolSchema.safeParse(d.data());\n if (res.success) out.push({ id: d.id, ...res.data });\n });\n cb(out.sort((a, b) => a.name.localeCompare(b.name)));\n },\n () => cb([]),\n );\n}\n\nexport async function getSymbolOnce(siteId: string, symbolId: string): Promise<StoredSymbol | null> {\n const db = getDb();\n if (!db) return null;\n const snap = await getDoc(doc(db, \"sites\", siteId, \"symbols\", symbolId));\n if (!snap.exists()) return null;\n const res = SymbolSchema.safeParse(snap.data());\n return res.success ? { id: snap.id, ...res.data } : null;\n}\n\nexport async function saveSymbol(input: {\n siteId: string;\n symbolId?: string;\n name: string;\n blocks: Record<string, unknown>[];\n}): Promise<{ id: string; message?: string }> {\n const app = getFirebaseApp();\n if (!app) throw new Error(\"Firebase niet geconfigureerd.\");\n const fn = httpsCallable<typeof input, { id: string; message?: string }>(getFunctions(app, functionsRegion()), \"saveSymbol\");\n return (await fn(input)).data;\n}\n\nexport async function deleteSymbol(siteId: string, symbolId: string): Promise<void> {\n const app = getFirebaseApp();\n if (!app) throw new Error(\"Firebase niet geconfigureerd.\");\n await httpsCallable(getFunctions(app, functionsRegion()), \"deleteSymbol\")({ siteId, symbolId });\n}\n"],"mappings":";AAGA,SAAS,YAAY,KAAK,QAAQ,kBAAkB;AACpD,SAAS,cAAc,qBAAqB;AAC5C,SAAS,OAAO,sBAAsB;AACtC,SAAS,uBAAuB;AAChC,SAAS,oBAAuC;AAEzC,SAAS,iBAAiB,QAAgB,IAAmD;AAClG,QAAM,KAAK,MAAM;AACjB,MAAI,CAAC,IAAI;AACP,OAAG,CAAC,CAAC;AACL,WAAO,MAAM;AAAA,EACf;AACA,SAAO;AAAA,IACL,WAAW,IAAI,SAAS,QAAQ,SAAS;AAAA,IACzC,CAAC,SAAS;AACR,YAAM,MAAsB,CAAC;AAC7B,WAAK,QAAQ,CAAC,MAAM;AAClB,cAAM,MAAM,aAAa,UAAU,EAAE,KAAK,CAAC;AAC3C,YAAI,IAAI,QAAS,KAAI,KAAK,EAAE,IAAI,EAAE,IAAI,GAAG,IAAI,KAAK,CAAC;AAAA,MACrD,CAAC;AACD,SAAG,IAAI,KAAK,CAAC,GAAG,MAAM,EAAE,KAAK,cAAc,EAAE,IAAI,CAAC,CAAC;AAAA,IACrD;AAAA,IACA,MAAM,GAAG,CAAC,CAAC;AAAA,EACb;AACF;AAEA,eAAsB,cAAc,QAAgB,UAAgD;AAClG,QAAM,KAAK,MAAM;AACjB,MAAI,CAAC,GAAI,QAAO;AAChB,QAAM,OAAO,MAAM,OAAO,IAAI,IAAI,SAAS,QAAQ,WAAW,QAAQ,CAAC;AACvE,MAAI,CAAC,KAAK,OAAO,EAAG,QAAO;AAC3B,QAAM,MAAM,aAAa,UAAU,KAAK,KAAK,CAAC;AAC9C,SAAO,IAAI,UAAU,EAAE,IAAI,KAAK,IAAI,GAAG,IAAI,KAAK,IAAI;AACtD;AAEA,eAAsB,WAAW,OAKa;AAC5C,QAAM,MAAM,eAAe;AAC3B,MAAI,CAAC,IAAK,OAAM,IAAI,MAAM,+BAA+B;AACzD,QAAM,KAAK,cAA8D,aAAa,KAAK,gBAAgB,CAAC,GAAG,YAAY;AAC3H,UAAQ,MAAM,GAAG,KAAK,GAAG;AAC3B;AAEA,eAAsB,aAAa,QAAgB,UAAiC;AAClF,QAAM,MAAM,eAAe;AAC3B,MAAI,CAAC,IAAK,OAAM,IAAI,MAAM,+BAA+B;AACzD,QAAM,cAAc,aAAa,KAAK,gBAAgB,CAAC,GAAG,cAAc,EAAE,EAAE,QAAQ,SAAS,CAAC;AAChG;","names":[]}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/** Maximale bestandsgrootte vóór compressie (foolproof: voorkomt vastlopen op enorme bestanden). */
|
|
2
2
|
export declare const MAX_UPLOAD_BYTES: number;
|
|
3
|
+
/** Harde grens die de Storage-rules afdwingen (storage.rules): het GECOMPRIMEERDE bestand moet
|
|
4
|
+
* hieronder blijven. We checken dit client-side na compressie zodat een slecht comprimeerbaar
|
|
5
|
+
* bestand een nette melding geeft i.p.v. een cryptische rules-weigering ("er gebeurt niets"). */
|
|
6
|
+
export declare const MAX_STORED_BYTES: number;
|
|
3
7
|
/**
|
|
4
8
|
* Comprimeert een afbeelding (client-side, webp + resize) en uploadt 'm naar
|
|
5
9
|
* `sites/{siteId}/media/...`. Registreert het media-doc (status "processing" — de sharp-Cloud-
|
package/dist/admin/lib/upload.js
CHANGED
|
@@ -1,26 +1,39 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { ref as storageRef, uploadBytes, getDownloadURL } from "firebase/storage";
|
|
3
|
-
import { addDoc, collection, serverTimestamp } from "firebase/firestore";
|
|
3
|
+
import { addDoc, collection, doc, serverTimestamp, updateDoc } from "firebase/firestore";
|
|
4
4
|
import { getBucket, getDb } from "../../firebase/client";
|
|
5
|
+
import { addDemoMedia, isDemoActive } from "../../demo/demoStore";
|
|
5
6
|
import { compressImage } from "./compressImage";
|
|
6
7
|
const MAX_UPLOAD_BYTES = 15 * 1024 * 1024;
|
|
8
|
+
const MAX_STORED_BYTES = 10 * 1024 * 1024;
|
|
7
9
|
async function uploadImage(siteId, file) {
|
|
8
10
|
const bucket = getBucket();
|
|
9
11
|
const db = getDb();
|
|
10
|
-
if (!bucket) throw new Error("Firebase Storage is niet geconfigureerd.");
|
|
11
12
|
if (!file.type.startsWith("image/")) throw new Error("Alleen afbeeldingen zijn toegestaan.");
|
|
12
13
|
if (file.size > MAX_UPLOAD_BYTES) {
|
|
13
14
|
throw new Error(`Bestand te groot (max ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB).`);
|
|
14
15
|
}
|
|
16
|
+
if (isDemoActive()) {
|
|
17
|
+
const optimized2 = await compressImage(file);
|
|
18
|
+
const dataUrl = await new Promise((resolve, reject) => {
|
|
19
|
+
const reader = new FileReader();
|
|
20
|
+
reader.onload = () => resolve(String(reader.result));
|
|
21
|
+
reader.onerror = () => reject(new Error("Bestand lezen mislukt."));
|
|
22
|
+
reader.readAsDataURL(optimized2);
|
|
23
|
+
});
|
|
24
|
+
const media = addDemoMedia(siteId, { url: dataUrl, name: optimized2.name });
|
|
25
|
+
return { url: media.url, mediaId: media.id, path: media.path };
|
|
26
|
+
}
|
|
27
|
+
if (!bucket) throw new Error("Firebase Storage is niet geconfigureerd.");
|
|
15
28
|
const optimized = await compressImage(file);
|
|
29
|
+
if (optimized.size > MAX_STORED_BYTES) {
|
|
30
|
+
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.`);
|
|
31
|
+
}
|
|
16
32
|
const path = `sites/${siteId}/media/${Date.now()}-${optimized.name.replace(/[^\w.-]+/g, "_")}`;
|
|
17
|
-
const sref = storageRef(bucket, path);
|
|
18
|
-
await uploadBytes(sref, optimized);
|
|
19
|
-
const url = await getDownloadURL(sref);
|
|
20
33
|
let mediaId = "";
|
|
21
34
|
if (db) {
|
|
22
35
|
const docRef = await addDoc(collection(db, "sites", siteId, "media"), {
|
|
23
|
-
url,
|
|
36
|
+
url: "",
|
|
24
37
|
path,
|
|
25
38
|
name: optimized.name,
|
|
26
39
|
contentType: optimized.type,
|
|
@@ -31,9 +44,16 @@ async function uploadImage(siteId, file) {
|
|
|
31
44
|
});
|
|
32
45
|
mediaId = docRef.id;
|
|
33
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 });
|
|
52
|
+
}
|
|
34
53
|
return { url, mediaId, path };
|
|
35
54
|
}
|
|
36
55
|
export {
|
|
56
|
+
MAX_STORED_BYTES,
|
|
37
57
|
MAX_UPLOAD_BYTES,
|
|
38
58
|
uploadImage
|
|
39
59
|
};
|
|
@@ -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, serverTimestamp } from \"firebase/firestore\";\nimport { getBucket, getDb } from \"../../firebase/client\";\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\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 (!
|
|
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"]}
|
|
@@ -3,10 +3,17 @@
|
|
|
3
3
|
* - de `GenericEditor` (fallback-editor voor blokken zonder eigen `Editor`), en
|
|
4
4
|
* - het `ModuleSettingsForm` (instellingen van een module in /beheer).
|
|
5
5
|
*
|
|
6
|
+
* Twee lagen:
|
|
7
|
+
* - `introspectObjectSchema` (legacy, plat): simpele kinds — blijft bestaan voor module-settings.
|
|
8
|
+
* - `introspectToFields` (5.0): levert de rijke **Field-IR** (schema/fields.ts, primo-port) met
|
|
9
|
+
* échte repeaters/groepen (recursief), richtext-/image-/link-detectie en optionele per-block
|
|
10
|
+
* hints (`defineBlock({ fields })`). De GenericEditor 2.0 rendert deze IR.
|
|
11
|
+
*
|
|
6
12
|
* Een veld krijgt zijn label uit `.describe("…")` op het schema; zonder beschrijving valt het
|
|
7
13
|
* terug op de veldsleutel. Puur (geen React) — apart testbaar.
|
|
8
14
|
*/
|
|
9
15
|
import type { ZodTypeAny } from "zod";
|
|
16
|
+
import type { Field } from "../../schema/fields";
|
|
10
17
|
export interface SchemaField {
|
|
11
18
|
key: string;
|
|
12
19
|
kind: "number" | "boolean" | "string" | "enum" | "json";
|
|
@@ -25,3 +32,14 @@ export interface SchemaField {
|
|
|
25
32
|
* aanbieden; een aanroeper die alleen simpele velden wil, filtert op `kind !== "json"`.
|
|
26
33
|
*/
|
|
27
34
|
export declare function introspectObjectSchema(schema: ZodTypeAny): SchemaField[];
|
|
35
|
+
export type { FieldHints } from "../../schema/fields";
|
|
36
|
+
import type { FieldHints } from "../../schema/fields";
|
|
37
|
+
/**
|
|
38
|
+
* Leidt de rijke Field-IR af uit een Zod-objectschema (5.0). Verschillen met de platte laag:
|
|
39
|
+
* - `z.array(z.object)` → **repeater** met recursief geïntrospecteerde subvelden;
|
|
40
|
+
* - genest `z.object` → **group** (of **link** als de vorm exact `{label, href}` is);
|
|
41
|
+
* - stringvelden `html`/`*Html` → **richtext**, beeld-sleutels → **image**, URL-sleutels → **url**;
|
|
42
|
+
* - al het overige (records, scalar-arrays, unions) → **json** (laatste redmiddel).
|
|
43
|
+
* `hints` verfijnt per (dotted) sleutel: type-overrides (date/slider/…) en `showIf`.
|
|
44
|
+
*/
|
|
45
|
+
export declare function introspectToFields(schema: ZodTypeAny, hints?: FieldHints, prefix?: string): Field[];
|