@xiwebdesign/xicms-core 7.23.0 → 7.25.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/admin/AdminApp.js +2 -1
- package/dist/admin/AdminApp.js.map +1 -1
- package/dist/admin/CoreVersion.d.ts +6 -0
- package/dist/admin/CoreVersion.js +42 -1
- package/dist/admin/CoreVersion.js.map +1 -1
- package/dist/admin/SuperadminPanel.js +19 -3
- package/dist/admin/SuperadminPanel.js.map +1 -1
- package/dist/admin/dashboard/DashboardPanel.js +46 -9
- package/dist/admin/dashboard/DashboardPanel.js.map +1 -1
- package/dist/admin/forms/FormsPanel.d.ts +9 -0
- package/dist/admin/forms/FormsPanel.js +102 -16
- package/dist/admin/forms/FormsPanel.js.map +1 -1
- package/dist/admin/forms/gezien.d.ts +3 -0
- package/dist/admin/forms/gezien.js +22 -0
- package/dist/admin/forms/gezien.js.map +1 -0
- package/dist/admin/lib/kleur.d.ts +32 -0
- package/dist/admin/lib/kleur.js +60 -0
- package/dist/admin/lib/kleur.js.map +1 -0
- package/dist/admin/lib/pagesClient.d.ts +32 -0
- package/dist/admin/lib/pagesClient.js +60 -1
- package/dist/admin/lib/pagesClient.js.map +1 -1
- package/dist/admin/lib/seoHulp.d.ts +28 -0
- package/dist/admin/lib/seoHulp.js +33 -0
- package/dist/admin/lib/seoHulp.js.map +1 -0
- package/dist/admin/media/MediaManager.js +133 -51
- package/dist/admin/media/MediaManager.js.map +1 -1
- package/dist/admin/pages/PageEditor.d.ts +14 -0
- package/dist/admin/pages/PageEditor.js +192 -123
- package/dist/admin/pages/PageEditor.js.map +1 -1
- package/dist/admin/pages/PagesPanel.js +88 -40
- package/dist/admin/pages/PagesPanel.js.map +1 -1
- package/dist/admin/settings/SettingsPanel.js +100 -19
- package/dist/admin/settings/SettingsPanel.js.map +1 -1
- package/dist/admin/shop/OrdersTab.js +171 -3
- package/dist/admin/shop/OrdersTab.js.map +1 -1
- package/dist/admin/shop/ProductsTab.js +1 -1
- package/dist/admin/shop/ProductsTab.js.map +1 -1
- package/dist/admin/shop/ShopPanel.js +5 -2
- package/dist/admin/shop/ShopPanel.js.map +1 -1
- package/dist/admin/stats/StatsPanel.js +30 -2
- package/dist/admin/stats/StatsPanel.js.map +1 -1
- package/dist/admin/stats/lib.d.ts +14 -1
- package/dist/admin/stats/lib.js +46 -2
- package/dist/admin/stats/lib.js.map +1 -1
- package/dist/admin/visual/VisualEditor.js +80 -4
- package/dist/admin/visual/VisualEditor.js.map +1 -1
- package/dist/admin.css +1 -1
- package/dist/blocks/builtins/FormBlockRender.js +8 -2
- package/dist/blocks/builtins/FormBlockRender.js.map +1 -1
- package/dist/blocks/builtins/lib/submitForm.d.ts +3 -1
- package/dist/blocks/builtins/lib/submitForm.js +2 -2
- package/dist/blocks/builtins/lib/submitForm.js.map +1 -1
- package/dist/components/EditBar.d.ts +3 -1
- package/dist/components/EditBar.js +30 -4
- package/dist/components/EditBar.js.map +1 -1
- package/dist/components/News.js +8 -4
- package/dist/components/News.js.map +1 -1
- package/dist/components/PageView.d.ts +5 -0
- package/dist/components/PageView.js +24 -2
- package/dist/components/PageView.js.map +1 -1
- package/dist/components/ResponsiveImage.d.ts +1 -1
- package/dist/components/ResponsiveImage.js +3 -1
- package/dist/components/ResponsiveImage.js.map +1 -1
- package/dist/components/site/SiteNav.d.ts +29 -0
- package/dist/components/site/SiteNav.js +82 -0
- package/dist/components/site/SiteNav.js.map +1 -0
- package/dist/content/types.d.ts +8 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/media/ImageContext.d.ts +1 -6
- package/dist/media/ImageContext.js +6 -1
- package/dist/media/ImageContext.js.map +1 -1
- package/dist/releaseNotes.d.ts +15 -0
- package/dist/releaseNotes.js +28 -0
- package/dist/releaseNotes.js.map +1 -0
- package/dist/schema/mail.d.ts +20 -0
- package/dist/schema/mail.js +14 -0
- package/dist/schema/mail.js.map +1 -1
- package/dist/schema/settings.d.ts +105 -0
- package/dist/schema/settings.js +45 -5
- package/dist/schema/settings.js.map +1 -1
- package/dist/schema/shop.d.ts +2 -0
- package/dist/schema/shop.js +12 -2
- package/dist/schema/shop.js.map +1 -1
- package/dist/schema/siteConfig.d.ts +9 -0
- package/dist/server/blockData.js +6 -2
- package/dist/server/blockData.js.map +1 -1
- package/dist/server/draft.d.ts +11 -0
- package/dist/server/draft.js +20 -1
- package/dist/server/draft.js.map +1 -1
- package/dist/server/index.d.ts +3 -1
- package/dist/server/index.js +7 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/pages.js +4 -1
- package/dist/server/pages.js.map +1 -1
- package/dist/server/previewToken.d.ts +34 -0
- package/dist/server/previewToken.js +44 -0
- package/dist/server/previewToken.js.map +1 -0
- package/dist/server/redirects.d.ts +15 -0
- package/dist/server/redirects.js +15 -0
- package/dist/server/redirects.js.map +1 -0
- package/dist/styles.css +110 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -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 { callable } from \"../../firebase/functionsClient\";\nimport { getDb, getFirebaseApp } from \"../../firebase/client\";\nimport type { Page } from \"../../schema/pages\";\nimport { isDemoActive, demoIdForPath, resolveDemoPage, saveDemoDraft } from \"../../demo/demoStore\";\nimport { sanitizeDraftBlocks } from \"./sanitizeDraft\";\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 // Rich text uit een werkversie is nooit server-side gesaneerd (zie sanitizeDraft.ts).\n return d ? sanitizeDraftBlocks({ id: d.id, ...(d.data() as Omit<DraftPage, \"id\">) }) : null;\n}\n\n/**\n * De GEPUBLICEERDE versie van één pagina — dat wat een gewone bezoeker ziet.\n *\n * De editor heeft dit nodig om te kunnen zeggen wát er veranderd is. Zonder deze tweede lezing\n * weet hij alleen of de werkversie sinds de laatste autosave nog is aangeraakt, en dat is een\n * andere vraag: je kunt een uur geleden iets hebben gewijzigd, keurig opgeslagen, en nog steeds\n * niets live hebben staan. \"Opgeslagen\" en \"gepubliceerd\" zijn twee toestanden, en de knop\n * rechtsonder gaat over de tweede.\n *\n * Publieke leesrechten volstaan: `sites/{id}/pages/{id}` is precies de content die ook in de\n * HTML van de live pagina staat. Geen werkversies, geen inzendingen.\n */\nexport async function getPublishedPage(siteId: string, pageId: string): Promise<Page | null> {\n /**\n * In de demo is \"live\" wat er in de SEED stond — de pagina zoals de prospect hem aantrof.\n *\n * Dit gaf eerst `null` terug (\"nog nooit gepubliceerd\"), en dan telt de vergelijking élk blok\n * als nieuw: op groenhof stond de publiceerknop meteen blauw met \"12 wijzigingen\" en droeg elk\n * blok een rode kantlijn, zonder dat er iets was aangeraakt. Precies het signaal dat \"gewijzigd\"\n * betekenis moet geven, uitgehold op de site die het systeem moet verkopen. Tegen de seed\n * vergeleken start de demo rustig, en kleurt er pas iets zodra de bezoeker echt typt.\n */\n if (isDemoActive()) {\n const { getSeed, demoPathForId } = await import(\"../../demo/demoStore\");\n const pad = demoPathForId(pageId);\n return pad ? getSeed(siteId, pad) : null;\n }\n const db = getDb();\n if (!db) return null;\n const snap = await getDoc(doc(db, \"sites\", siteId, \"pages\", pageId));\n return snap.exists() ? (snap.data() as Page) : 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() ? sanitizeDraftBlocks({ id: snap.id, ...(snap.data() as Omit<DraftPage, \"id\">) }) : null,\n { fromServer: !snap.metadata.hasPendingWrites },\n );\n });\n}\n\n/**\n * Firestore WEIGERT `undefined` als veldwaarde: `setDoc({ seo: undefined })` gooit\n * \"Unsupported field value: undefined\" en de hele schrijfactie mislukt. De editors geven hun\n * pagina-state als geheel door (`{ title, slug, path, seo, blocks }`), en bij een nieuwe pagina is\n * `seo` gewoon nog niet ingevuld — dus liep élke eerste publicatie van een nieuwe pagina stuk op\n * de autosave die eraan voorafgaat. Gevonden op de staging-deploy van groenhof: \"Publiceren\n * mislukt — Unsupported field value: undefined (found in field seo)\".\n *\n * Weglaten is hier ook de JUISTE semantiek: deze writes zijn `merge: true`, en \"veld niet\n * meegestuurd\" betekent daar \"laat staan wat er stond\". Een veld écht wissen doe je met\n * `deleteField()`, niet met `undefined` — dat onderscheid blijft zo intact.\n */\nfunction zonderUndefined<T extends Record<string, unknown>>(o: T): Partial<T> {\n return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as Partial<T>;\n}\n\nexport async function createPage(siteId: string, input: Partial<Page> & { title: string; slug: string; path: string }) {\n /**\n * In de demo bestaat er geen Firestore om naar te schrijven.\n *\n * Deze functie was als enige van dit bestand de demo-tak vergeten — `getDraftByPath`,\n * `saveDraft` en `publishPage` hebben hem alle drie. Gevolg: wie in een demo op\n * \"+ Pagina aanmaken\" drukte, kreeg \"Missing or insufficient permissions\" van de echte\n * database. Op een site die bestaat om te laten zien hoe prettig het bewerken werkt.\n */\n if (isDemoActive()) {\n const id = demoIdForPath(input.path);\n saveDemoDraft(siteId, id, { blocks: [], status: \"draft\", ...zonderUndefined(input) } as Partial<Page>);\n return id;\n }\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 ...zonderUndefined(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 { ...zonderUndefined(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 = callable(app, \"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 = callable(app, \"publishPage\");\n await fn({ siteId, pageId });\n}\n"],"mappings":"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","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 { callable } from \"../../firebase/functionsClient\";\nimport { getDb, getFirebaseApp } from \"../../firebase/client\";\nimport type { Page } from \"../../schema/pages\";\nimport { isDemoActive, demoIdForPath, resolveDemoPage, saveDemoDraft } from \"../../demo/demoStore\";\nimport { sanitizeDraftBlocks } from \"./sanitizeDraft\";\n\nexport type DraftPage = Page & {\n id: string;\n published?: boolean;\n /** Firestore-Timestamp (editor) of ISO-string (import-scripts). */\n updatedAt?: unknown;\n lastPublishedAt?: unknown;\n /** 7.24: in de prullenbak; wordt na 30 dagen definitief verwijderd (purgeTrashedPages). */\n trashed?: boolean;\n trashedAt?: unknown;\n};\n\nfunction ms(v: unknown): number {\n if (v && typeof (v as { toDate?: () => Date }).toDate === \"function\") return (v as { toDate: () => Date }).toDate().getTime();\n if (typeof v === \"string\") {\n const t = Date.parse(v);\n return Number.isNaN(t) ? 0 : t;\n }\n return 0;\n}\n\n/**\n * Staat er op deze pagina iets dat nog niet live is? (7.24)\n *\n * Nooit gepubliceerd = concept (dat is een eigen toestand, geen \"wijziging\"). Gepubliceerd en\n * daarna opnieuw opgeslagen = wijzigingen die niemand ziet. De marge van 1,5 s vangt de\n * autosave die `publishPage` vlak vóór het publiceren zelf doet (die zet `updatedAt` een tel\n * eerder dan `lastPublishedAt`).\n */\nexport function heeftOngepubliceerdeWijzigingen(p: Pick<DraftPage, \"published\" | \"updatedAt\" | \"lastPublishedAt\">): boolean {\n if (!p.published) return false;\n const u = ms(p.updatedAt);\n const l = ms(p.lastPublishedAt);\n if (!u || !l) return false;\n return u - l > 1500;\n}\n\n/** Leesbare toestand voor badges: live / gewijzigd / concept / prullenbak. */\nexport function paginaToestand(p: DraftPage): \"prullenbak\" | \"gewijzigd\" | \"live\" | \"concept\" {\n if (p.trashed) return \"prullenbak\";\n if (heeftOngepubliceerdeWijzigingen(p)) return \"gewijzigd\";\n return p.published ? \"live\" : \"concept\";\n}\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 // Rich text uit een werkversie is nooit server-side gesaneerd (zie sanitizeDraft.ts).\n return d ? sanitizeDraftBlocks({ id: d.id, ...(d.data() as Omit<DraftPage, \"id\">) }) : null;\n}\n\n/**\n * De GEPUBLICEERDE versie van één pagina — dat wat een gewone bezoeker ziet.\n *\n * De editor heeft dit nodig om te kunnen zeggen wát er veranderd is. Zonder deze tweede lezing\n * weet hij alleen of de werkversie sinds de laatste autosave nog is aangeraakt, en dat is een\n * andere vraag: je kunt een uur geleden iets hebben gewijzigd, keurig opgeslagen, en nog steeds\n * niets live hebben staan. \"Opgeslagen\" en \"gepubliceerd\" zijn twee toestanden, en de knop\n * rechtsonder gaat over de tweede.\n *\n * Publieke leesrechten volstaan: `sites/{id}/pages/{id}` is precies de content die ook in de\n * HTML van de live pagina staat. Geen werkversies, geen inzendingen.\n */\nexport async function getPublishedPage(siteId: string, pageId: string): Promise<Page | null> {\n /**\n * In de demo is \"live\" wat er in de SEED stond — de pagina zoals de prospect hem aantrof.\n *\n * Dit gaf eerst `null` terug (\"nog nooit gepubliceerd\"), en dan telt de vergelijking élk blok\n * als nieuw: op groenhof stond de publiceerknop meteen blauw met \"12 wijzigingen\" en droeg elk\n * blok een rode kantlijn, zonder dat er iets was aangeraakt. Precies het signaal dat \"gewijzigd\"\n * betekenis moet geven, uitgehold op de site die het systeem moet verkopen. Tegen de seed\n * vergeleken start de demo rustig, en kleurt er pas iets zodra de bezoeker echt typt.\n */\n if (isDemoActive()) {\n const { getSeed, demoPathForId } = await import(\"../../demo/demoStore\");\n const pad = demoPathForId(pageId);\n return pad ? getSeed(siteId, pad) : null;\n }\n const db = getDb();\n if (!db) return null;\n const snap = await getDoc(doc(db, \"sites\", siteId, \"pages\", pageId));\n return snap.exists() ? (snap.data() as Page) : 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() ? sanitizeDraftBlocks({ id: snap.id, ...(snap.data() as Omit<DraftPage, \"id\">) }) : null,\n { fromServer: !snap.metadata.hasPendingWrites },\n );\n });\n}\n\n/**\n * Firestore WEIGERT `undefined` als veldwaarde: `setDoc({ seo: undefined })` gooit\n * \"Unsupported field value: undefined\" en de hele schrijfactie mislukt. De editors geven hun\n * pagina-state als geheel door (`{ title, slug, path, seo, blocks }`), en bij een nieuwe pagina is\n * `seo` gewoon nog niet ingevuld — dus liep élke eerste publicatie van een nieuwe pagina stuk op\n * de autosave die eraan voorafgaat. Gevonden op de staging-deploy van groenhof: \"Publiceren\n * mislukt — Unsupported field value: undefined (found in field seo)\".\n *\n * Weglaten is hier ook de JUISTE semantiek: deze writes zijn `merge: true`, en \"veld niet\n * meegestuurd\" betekent daar \"laat staan wat er stond\". Een veld écht wissen doe je met\n * `deleteField()`, niet met `undefined` — dat onderscheid blijft zo intact.\n */\nfunction zonderUndefined<T extends Record<string, unknown>>(o: T): Partial<T> {\n return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as Partial<T>;\n}\n\nexport async function createPage(siteId: string, input: Partial<Page> & { title: string; slug: string; path: string }) {\n /**\n * In de demo bestaat er geen Firestore om naar te schrijven.\n *\n * Deze functie was als enige van dit bestand de demo-tak vergeten — `getDraftByPath`,\n * `saveDraft` en `publishPage` hebben hem alle drie. Gevolg: wie in een demo op\n * \"+ Pagina aanmaken\" drukte, kreeg \"Missing or insufficient permissions\" van de echte\n * database. Op een site die bestaat om te laten zien hoe prettig het bewerken werkt.\n */\n if (isDemoActive()) {\n const id = demoIdForPath(input.path);\n saveDemoDraft(siteId, id, { blocks: [], status: \"draft\", ...zonderUndefined(input) } as Partial<Page>);\n return id;\n }\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 ...zonderUndefined(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 { ...zonderUndefined(partial), updatedAt: serverTimestamp() },\n { merge: true },\n );\n}\n\n/**\n * Pagina dupliceren (7.24): nieuwe draft met dezelfde blokken (verse `_key`s), als concept, met\n * \"-kopie\" achter pad en titel zodat hij nergens mee botst.\n */\nexport async function duplicatePage(siteId: string, bron: DraftPage): Promise<string> {\n const nieuweKey = () => (globalThis.crypto?.randomUUID?.() ?? `b${Date.now()}${Math.random()}`).slice(0, 12);\n const blocks = (bron.blocks ?? []).map((b) => ({ ...(b as Record<string, unknown>), _key: nieuweKey() })) as Page[\"blocks\"];\n const slug = `${bron.slug || \"pagina\"}-kopie`;\n const path = bron.path === \"/\" ? \"/home-kopie\" : `${bron.path}-kopie`;\n return createPage(siteId, {\n title: `${bron.title} (kopie)`,\n slug,\n path,\n blocks,\n ...(bron.seo ? { seo: bron.seo } : {}),\n ...(bron.translations ? { translations: bron.translations } : {}),\n });\n}\n\n/** Offline halen (7.24): de live versie verdwijnt, de werkversie blijft. Via de callable. */\nexport async function unpublishPage(siteId: string, pageId: string): Promise<void> {\n if (isDemoActive()) return;\n const app = getFirebaseApp();\n if (!app) throw new Error(\"Firebase niet geconfigureerd.\");\n await callable(app, \"unpublishPage\")({ siteId, pageId });\n}\n\n/**\n * Naar de prullenbak (7.24): offline halen + markeren. Geen delete — een klant die per ongeluk\n * op \"verwijderen\" drukte kon tot 7.24 alleen via een herstelpunt terug. Na 30 dagen ruimt de\n * backend definitief op (purgeTrashedPages).\n */\nexport async function trashPage(siteId: string, page: DraftPage): Promise<void> {\n const db = getDb();\n if (!db) return;\n if (page.published) {\n // Nog live? Eerst offline. Een pagina die niet live staat heeft niets om offline te halen.\n await unpublishPage(siteId, page.id).catch(() => undefined);\n }\n await setDoc(doc(db, \"sites\", siteId, \"drafts\", page.id), { trashed: true, trashedAt: serverTimestamp(), updatedAt: serverTimestamp() }, { merge: true });\n}\n\n/** Terug uit de prullenbak: als concept (publiceren is een bewuste tweede stap). */\nexport async function restorePage(siteId: string, pageId: string): Promise<void> {\n const db = getDb();\n if (!db) return;\n await setDoc(doc(db, \"sites\", siteId, \"drafts\", pageId), { trashed: false, trashedAt: null, updatedAt: serverTimestamp() }, { merge: true });\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 = callable(app, \"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 = callable(app, \"publishPage\");\n await fn({ siteId, pageId });\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEO-hulp op de plek waar de klant werkt (7.24): lengtemeters en een stil signaal in de
|
|
3
|
+
* paginalijst. Geen scores of grafieken — één regel die zegt wat Google ermee doet.
|
|
4
|
+
*
|
|
5
|
+
* Grenzen: Google toont ongeveer 50–60 tekens van een titel en ~155 van een omschrijving; daarna
|
|
6
|
+
* knipt hij af met "…". Te kort is óók een signaal: een omschrijving van acht woorden vult het
|
|
7
|
+
* zoekresultaat niet en wordt vaak vervangen door een willekeurig stuk pagina.
|
|
8
|
+
*/
|
|
9
|
+
export declare const TITEL_MAX = 60;
|
|
10
|
+
export declare const OMSCHRIJVING_MIN = 70;
|
|
11
|
+
export declare const OMSCHRIJVING_MAX = 155;
|
|
12
|
+
export type Meter = {
|
|
13
|
+
n: number;
|
|
14
|
+
max: number;
|
|
15
|
+
toon: "leeg" | "goed" | "krap" | "lang";
|
|
16
|
+
tekst: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function titelMeter(titel: string | undefined, terugval?: string): Meter;
|
|
19
|
+
export declare function omschrijvingMeter(omschrijving: string | undefined): Meter;
|
|
20
|
+
/** Eén kort signaal voor de paginalijst, of null als er niets te melden is. */
|
|
21
|
+
export declare function seoSignaal(p: {
|
|
22
|
+
title?: string;
|
|
23
|
+
seo?: {
|
|
24
|
+
title?: string;
|
|
25
|
+
description?: string;
|
|
26
|
+
noindex?: boolean;
|
|
27
|
+
};
|
|
28
|
+
}): string | null;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const TITEL_MAX = 60;
|
|
2
|
+
const OMSCHRIJVING_MIN = 70;
|
|
3
|
+
const OMSCHRIJVING_MAX = 155;
|
|
4
|
+
function titelMeter(titel, terugval) {
|
|
5
|
+
const t = (titel ?? "").trim() || (terugval ?? "").trim();
|
|
6
|
+
const n = t.length;
|
|
7
|
+
if (n === 0) return { n, max: TITEL_MAX, toon: "leeg", tekst: "Geen titel \u2014 Google verzint er dan zelf een" };
|
|
8
|
+
if (n > TITEL_MAX) return { n, max: TITEL_MAX, toon: "lang", tekst: `${n}/${TITEL_MAX} \u2014 wordt afgekapt in zoekresultaten` };
|
|
9
|
+
if (n < 20) return { n, max: TITEL_MAX, toon: "krap", tekst: `${n}/${TITEL_MAX} \u2014 kort; noem wat de pagina is \xE9n voor wie` };
|
|
10
|
+
return { n, max: TITEL_MAX, toon: "goed", tekst: `${n}/${TITEL_MAX}` };
|
|
11
|
+
}
|
|
12
|
+
function omschrijvingMeter(omschrijving) {
|
|
13
|
+
const n = (omschrijving ?? "").trim().length;
|
|
14
|
+
if (n === 0) return { n, max: OMSCHRIJVING_MAX, toon: "leeg", tekst: "Geen omschrijving \u2014 Google kiest dan zelf een stuk tekst" };
|
|
15
|
+
if (n > OMSCHRIJVING_MAX) return { n, max: OMSCHRIJVING_MAX, toon: "lang", tekst: `${n}/${OMSCHRIJVING_MAX} \u2014 wordt afgekapt` };
|
|
16
|
+
if (n < OMSCHRIJVING_MIN) return { n, max: OMSCHRIJVING_MAX, toon: "krap", tekst: `${n}/${OMSCHRIJVING_MAX} \u2014 mag langer: \xE9\xE9n of twee volle zinnen` };
|
|
17
|
+
return { n, max: OMSCHRIJVING_MAX, toon: "goed", tekst: `${n}/${OMSCHRIJVING_MAX}` };
|
|
18
|
+
}
|
|
19
|
+
function seoSignaal(p) {
|
|
20
|
+
if (p.seo?.noindex) return "niet in Google";
|
|
21
|
+
if (!(p.seo?.description ?? "").trim()) return "mist omschrijving";
|
|
22
|
+
if (titelMeter(p.seo?.title, p.title).toon === "lang") return "titel te lang";
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
export {
|
|
26
|
+
OMSCHRIJVING_MAX,
|
|
27
|
+
OMSCHRIJVING_MIN,
|
|
28
|
+
TITEL_MAX,
|
|
29
|
+
omschrijvingMeter,
|
|
30
|
+
seoSignaal,
|
|
31
|
+
titelMeter
|
|
32
|
+
};
|
|
33
|
+
//# sourceMappingURL=seoHulp.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/admin/lib/seoHulp.ts"],"sourcesContent":["/**\n * SEO-hulp op de plek waar de klant werkt (7.24): lengtemeters en een stil signaal in de\n * paginalijst. Geen scores of grafieken — één regel die zegt wat Google ermee doet.\n *\n * Grenzen: Google toont ongeveer 50–60 tekens van een titel en ~155 van een omschrijving; daarna\n * knipt hij af met \"…\". Te kort is óók een signaal: een omschrijving van acht woorden vult het\n * zoekresultaat niet en wordt vaak vervangen door een willekeurig stuk pagina.\n */\nexport const TITEL_MAX = 60;\nexport const OMSCHRIJVING_MIN = 70;\nexport const OMSCHRIJVING_MAX = 155;\n\nexport type Meter = { n: number; max: number; toon: \"leeg\" | \"goed\" | \"krap\" | \"lang\"; tekst: string };\n\nexport function titelMeter(titel: string | undefined, terugval?: string): Meter {\n const t = (titel ?? \"\").trim() || (terugval ?? \"\").trim();\n const n = t.length;\n if (n === 0) return { n, max: TITEL_MAX, toon: \"leeg\", tekst: \"Geen titel — Google verzint er dan zelf een\" };\n if (n > TITEL_MAX) return { n, max: TITEL_MAX, toon: \"lang\", tekst: `${n}/${TITEL_MAX} — wordt afgekapt in zoekresultaten` };\n if (n < 20) return { n, max: TITEL_MAX, toon: \"krap\", tekst: `${n}/${TITEL_MAX} — kort; noem wat de pagina is én voor wie` };\n return { n, max: TITEL_MAX, toon: \"goed\", tekst: `${n}/${TITEL_MAX}` };\n}\n\nexport function omschrijvingMeter(omschrijving: string | undefined): Meter {\n const n = (omschrijving ?? \"\").trim().length;\n if (n === 0) return { n, max: OMSCHRIJVING_MAX, toon: \"leeg\", tekst: \"Geen omschrijving — Google kiest dan zelf een stuk tekst\" };\n if (n > OMSCHRIJVING_MAX) return { n, max: OMSCHRIJVING_MAX, toon: \"lang\", tekst: `${n}/${OMSCHRIJVING_MAX} — wordt afgekapt` };\n if (n < OMSCHRIJVING_MIN) return { n, max: OMSCHRIJVING_MAX, toon: \"krap\", tekst: `${n}/${OMSCHRIJVING_MAX} — mag langer: één of twee volle zinnen` };\n return { n, max: OMSCHRIJVING_MAX, toon: \"goed\", tekst: `${n}/${OMSCHRIJVING_MAX}` };\n}\n\n/** Eén kort signaal voor de paginalijst, of null als er niets te melden is. */\nexport function seoSignaal(p: { title?: string; seo?: { title?: string; description?: string; noindex?: boolean } }): string | null {\n if (p.seo?.noindex) return \"niet in Google\";\n if (!(p.seo?.description ?? \"\").trim()) return \"mist omschrijving\";\n if (titelMeter(p.seo?.title, p.title).toon === \"lang\") return \"titel te lang\";\n return null;\n}\n"],"mappings":"AAQO,MAAM,YAAY;AAClB,MAAM,mBAAmB;AACzB,MAAM,mBAAmB;AAIzB,SAAS,WAAW,OAA2B,UAA0B;AAC9E,QAAM,KAAK,SAAS,IAAI,KAAK,MAAM,YAAY,IAAI,KAAK;AACxD,QAAM,IAAI,EAAE;AACZ,MAAI,MAAM,EAAG,QAAO,EAAE,GAAG,KAAK,WAAW,MAAM,QAAQ,OAAO,mDAA8C;AAC5G,MAAI,IAAI,UAAW,QAAO,EAAE,GAAG,KAAK,WAAW,MAAM,QAAQ,OAAO,GAAG,CAAC,IAAI,SAAS,2CAAsC;AAC3H,MAAI,IAAI,GAAI,QAAO,EAAE,GAAG,KAAK,WAAW,MAAM,QAAQ,OAAO,GAAG,CAAC,IAAI,SAAS,qDAA6C;AAC3H,SAAO,EAAE,GAAG,KAAK,WAAW,MAAM,QAAQ,OAAO,GAAG,CAAC,IAAI,SAAS,GAAG;AACvE;AAEO,SAAS,kBAAkB,cAAyC;AACzE,QAAM,KAAK,gBAAgB,IAAI,KAAK,EAAE;AACtC,MAAI,MAAM,EAAG,QAAO,EAAE,GAAG,KAAK,kBAAkB,MAAM,QAAQ,OAAO,gEAA2D;AAChI,MAAI,IAAI,iBAAkB,QAAO,EAAE,GAAG,KAAK,kBAAkB,MAAM,QAAQ,OAAO,GAAG,CAAC,IAAI,gBAAgB,yBAAoB;AAC9H,MAAI,IAAI,iBAAkB,QAAO,EAAE,GAAG,KAAK,kBAAkB,MAAM,QAAQ,OAAO,GAAG,CAAC,IAAI,gBAAgB,qDAA0C;AACpJ,SAAO,EAAE,GAAG,KAAK,kBAAkB,MAAM,QAAQ,OAAO,GAAG,CAAC,IAAI,gBAAgB,GAAG;AACrF;AAGO,SAAS,WAAW,GAAyG;AAClI,MAAI,EAAE,KAAK,QAAS,QAAO;AAC3B,MAAI,EAAE,EAAE,KAAK,eAAe,IAAI,KAAK,EAAG,QAAO;AAC/C,MAAI,WAAW,EAAE,KAAK,OAAO,EAAE,KAAK,EAAE,SAAS,OAAQ,QAAO;AAC9D,SAAO;AACT;","names":[]}
|
|
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
|
|
4
4
|
import {
|
|
5
5
|
collection,
|
|
6
6
|
deleteDoc,
|
|
7
|
+
deleteField,
|
|
7
8
|
doc,
|
|
8
9
|
limit,
|
|
9
10
|
onSnapshot,
|
|
@@ -30,6 +31,8 @@ function MediaManager({ siteId }) {
|
|
|
30
31
|
const [pageCount, setPageCount] = useState(1);
|
|
31
32
|
const [hasMore, setHasMore] = useState(false);
|
|
32
33
|
const inputRef = useRef(null);
|
|
34
|
+
const [zoek, setZoek] = useState("");
|
|
35
|
+
const [sleepOver, setSleepOver] = useState(false);
|
|
33
36
|
useEffect(() => {
|
|
34
37
|
if (isDemoActive()) {
|
|
35
38
|
const sync = () => setItems(listDemoMediaAll(siteId));
|
|
@@ -67,6 +70,16 @@ function MediaManager({ siteId }) {
|
|
|
67
70
|
if (errors.length) toast({ title: "Sommige uploads mislukten", description: errors.join(" \xB7 "), tone: "error" });
|
|
68
71
|
setBusy(false);
|
|
69
72
|
}
|
|
73
|
+
async function zetFocus(item, focal) {
|
|
74
|
+
const db = getDb();
|
|
75
|
+
if (!db) return;
|
|
76
|
+
try {
|
|
77
|
+
await updateDoc(doc(db, "sites", siteId, "media", item.id), focal ? { focal } : { focal: deleteField() });
|
|
78
|
+
toast({ title: focal ? "Focuspunt opgeslagen" : "Focuspunt verwijderd", description: focal ? "Bij bijsnijden blijft dit deel in beeld." : void 0, tone: "success" });
|
|
79
|
+
} catch (e) {
|
|
80
|
+
toast({ title: "Focuspunt opslaan mislukt", description: e.message, tone: "error" });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
70
83
|
async function saveAlt(item, alt) {
|
|
71
84
|
const db = getDb();
|
|
72
85
|
if (!db) return;
|
|
@@ -155,60 +168,91 @@ function MediaManager({ siteId }) {
|
|
|
155
168
|
if (gelukt) toast({ title: `${gelukt} afbeelding(en) opgeruimd`, description: `\xB1${mb} MB opslag vrijgemaakt.`, tone: "success" });
|
|
156
169
|
if (fouten.length) toast({ title: "Niet alles kon weg", description: fouten.join(" \xB7 "), tone: "error" });
|
|
157
170
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
" ",
|
|
169
|
-
/* @__PURE__ */ jsx(Button, { variant: "ghost", onClick: () => setOngebruikt(null), children: "Annuleren" })
|
|
170
|
-
] }) : null),
|
|
171
|
-
/* @__PURE__ */ jsx("p", { className: "mt-1 text-xs text-slate-500", children: "Je kunt meerdere foto's tegelijk kiezen. Ze worden automatisch verkleind en geoptimaliseerd (webp) \u2014 dat houdt je site snel en bespaart opslag. Max. 15 MB per bestand." }),
|
|
172
|
-
/* @__PURE__ */ jsx(
|
|
173
|
-
"input",
|
|
174
|
-
{
|
|
175
|
-
ref: inputRef,
|
|
176
|
-
type: "file",
|
|
177
|
-
accept: "image/*",
|
|
178
|
-
multiple: true,
|
|
179
|
-
hidden: true,
|
|
180
|
-
onChange: (e) => {
|
|
181
|
-
if (e.target.files?.length) void onFiles(e.target.files);
|
|
182
|
-
e.target.value = "";
|
|
183
|
-
}
|
|
171
|
+
const term = zoek.trim().toLowerCase();
|
|
172
|
+
const getoond = term ? items.filter((m) => `${m.name ?? ""} ${m.alt ?? ""}`.toLowerCase().includes(term)) : items;
|
|
173
|
+
return /* @__PURE__ */ jsxs(
|
|
174
|
+
"div",
|
|
175
|
+
{
|
|
176
|
+
className: `grid gap-4 rounded-xl transition ${sleepOver ? "outline-2 outline-dashed outline-brand-500 outline-offset-4" : ""}`,
|
|
177
|
+
onDragOver: (e) => {
|
|
178
|
+
if (e.dataTransfer.types.includes("Files")) {
|
|
179
|
+
e.preventDefault();
|
|
180
|
+
setSleepOver(true);
|
|
184
181
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
182
|
+
},
|
|
183
|
+
onDragLeave: () => setSleepOver(false),
|
|
184
|
+
onDrop: (e) => {
|
|
185
|
+
if (!e.dataTransfer.files?.length) return;
|
|
186
|
+
e.preventDefault();
|
|
187
|
+
setSleepOver(false);
|
|
188
|
+
void onFiles(e.dataTransfer.files);
|
|
189
|
+
},
|
|
190
|
+
children: [
|
|
191
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
192
|
+
/* @__PURE__ */ jsx(Button, { disabled: busy, onClick: () => inputRef.current?.click(), children: busy ? "Uploaden\u2026" : "Foto's uploaden" }),
|
|
193
|
+
" ",
|
|
194
|
+
!isDemoActive() && items.length > 0 && (ongebruikt === null ? /* @__PURE__ */ jsx(Button, { variant: "ghost", disabled: scant || busy, onClick: () => void scanOngebruikt(), children: scant ? "Zoeken\u2026" : "\u{1F9F9} Zoek ongebruikte foto's" }) : ongebruikt.length > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
195
|
+
/* @__PURE__ */ jsxs(Button, { variant: "secondary", disabled: busy, onClick: () => void ruimOngebruiktOp(), children: [
|
|
196
|
+
"\u{1F9F9} ",
|
|
197
|
+
ongebruikt.length,
|
|
198
|
+
" ongebruikte verwijderen"
|
|
199
|
+
] }),
|
|
200
|
+
" ",
|
|
201
|
+
/* @__PURE__ */ jsx(Button, { variant: "ghost", onClick: () => setOngebruikt(null), children: "Annuleren" })
|
|
202
|
+
] }) : null),
|
|
203
|
+
/* @__PURE__ */ jsxs("p", { className: "mt-1 text-xs text-slate-500", children: [
|
|
204
|
+
"Je kunt meerdere foto's tegelijk kiezen of ze hierheen slepen. Ze worden automatisch verkleind en geoptimaliseerd (webp) \u2014 dat houdt je site snel en bespaart opslag. Max. 15 MB per bestand. Klik op een foto om het ",
|
|
205
|
+
/* @__PURE__ */ jsx("strong", { children: "focuspunt" }),
|
|
206
|
+
" te kiezen: dat deel blijft in beeld als de foto wordt bijgesneden (bijvoorbeeld een brede kopfoto op een telefoon)."
|
|
199
207
|
] }),
|
|
200
|
-
|
|
208
|
+
items.length > 8 && /* @__PURE__ */ jsx("div", { className: "mt-3 max-w-xs", children: /* @__PURE__ */ jsx(Input, { value: zoek, onChange: (e) => setZoek(e.target.value), placeholder: "Zoek op naam of alt-tekst\u2026", "aria-label": "Zoek in mediabibliotheek" }) }),
|
|
209
|
+
/* @__PURE__ */ jsx(
|
|
210
|
+
"input",
|
|
211
|
+
{
|
|
212
|
+
ref: inputRef,
|
|
213
|
+
type: "file",
|
|
214
|
+
accept: "image/*",
|
|
215
|
+
multiple: true,
|
|
216
|
+
hidden: true,
|
|
217
|
+
onChange: (e) => {
|
|
218
|
+
if (e.target.files?.length) void onFiles(e.target.files);
|
|
219
|
+
e.target.value = "";
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
)
|
|
201
223
|
] }),
|
|
202
|
-
/* @__PURE__ */
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
224
|
+
/* @__PURE__ */ jsxs("div", { className: "grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4", children: [
|
|
225
|
+
getoond.map((m) => /* @__PURE__ */ jsxs(Card, { className: "p-3", children: [
|
|
226
|
+
/* @__PURE__ */ jsx(FocusFoto, { item: m, onFocus: (f) => void zetFocus(m, f) }),
|
|
227
|
+
/* @__PURE__ */ jsxs("div", { className: "mb-2 flex items-center justify-between gap-1", children: [
|
|
228
|
+
/* @__PURE__ */ jsxs("span", { className: "truncate text-xs text-slate-500", children: [
|
|
229
|
+
m.name,
|
|
230
|
+
m.width && /* @__PURE__ */ jsxs("span", { className: "text-slate-400", children: [
|
|
231
|
+
" \xB7 ",
|
|
232
|
+
m.width,
|
|
233
|
+
"\xD7",
|
|
234
|
+
m.height
|
|
235
|
+
] })
|
|
236
|
+
] }),
|
|
237
|
+
m.status === "processing" ? /* @__PURE__ */ jsx(Badge, { tone: "amber", children: "verwerken" }) : ongebruikt?.includes(m.id) ? /* @__PURE__ */ jsx(Badge, { tone: "amber", children: "ongebruikt" }) : /* @__PURE__ */ jsx(Badge, { tone: "green", children: "klaar" })
|
|
238
|
+
] }),
|
|
239
|
+
/* @__PURE__ */ jsx(AltInput, { item: m, onSave: saveAlt }),
|
|
240
|
+
/* @__PURE__ */ jsxs("div", { className: "flex gap-1", children: [
|
|
241
|
+
/* @__PURE__ */ jsx(Button, { variant: "ghost", size: "sm", className: "text-xs", onClick: () => void navigator.clipboard?.writeText(m.url), children: "Kopieer URL" }),
|
|
242
|
+
/* @__PURE__ */ jsx(Button, { variant: "ghost", size: "sm", className: "text-xs", onClick: () => void remove(m), children: "Verwijder" })
|
|
243
|
+
] })
|
|
244
|
+
] }, m.id)),
|
|
245
|
+
items.length === 0 && /* @__PURE__ */ jsx("p", { className: "text-sm text-slate-500", children: "Nog geen media." }),
|
|
246
|
+
items.length > 0 && getoond.length === 0 && /* @__PURE__ */ jsxs("p", { className: "text-sm text-slate-500", children: [
|
|
247
|
+
"Geen foto's gevonden voor \u201C",
|
|
248
|
+
zoek,
|
|
249
|
+
"\u201D."
|
|
250
|
+
] })
|
|
251
|
+
] }),
|
|
252
|
+
hasMore && /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(Button, { variant: "secondary", size: "sm", onClick: () => setPageCount((c) => c + 1), children: "Meer laden\u2026" }) })
|
|
253
|
+
]
|
|
254
|
+
}
|
|
255
|
+
);
|
|
212
256
|
}
|
|
213
257
|
function AltInput({ item, onSave }) {
|
|
214
258
|
const [value, setValue] = useState(item.alt ?? "");
|
|
@@ -231,6 +275,44 @@ function AltInput({ item, onSave }) {
|
|
|
231
275
|
}
|
|
232
276
|
);
|
|
233
277
|
}
|
|
278
|
+
function FocusFoto({ item, onFocus }) {
|
|
279
|
+
const f = item.focal;
|
|
280
|
+
return /* @__PURE__ */ jsxs(
|
|
281
|
+
"button",
|
|
282
|
+
{
|
|
283
|
+
type: "button",
|
|
284
|
+
className: "relative mb-2 block h-28 w-full cursor-crosshair overflow-hidden rounded-md focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:outline-none",
|
|
285
|
+
title: f ? "Focuspunt: klik om te verplaatsen, klik op het punt om te verwijderen" : "Klik om het focuspunt te kiezen",
|
|
286
|
+
"aria-label": f ? `Focuspunt van ${item.name} aanpassen` : `Focuspunt van ${item.name} kiezen`,
|
|
287
|
+
onClick: (e) => {
|
|
288
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
289
|
+
const x = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
|
|
290
|
+
const y = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
|
|
291
|
+
if (f && Math.abs(f.x - x) < 0.08 && Math.abs(f.y - y) < 0.12) onFocus(null);
|
|
292
|
+
else onFocus({ x: Math.round(x * 100) / 100, y: Math.round(y * 100) / 100 });
|
|
293
|
+
},
|
|
294
|
+
children: [
|
|
295
|
+
/* @__PURE__ */ jsx(
|
|
296
|
+
"img",
|
|
297
|
+
{
|
|
298
|
+
src: item.url,
|
|
299
|
+
alt: item.alt ?? item.name,
|
|
300
|
+
className: "h-full w-full object-cover",
|
|
301
|
+
style: f ? { objectPosition: `${Math.round(f.x * 100)}% ${Math.round(f.y * 100)}%` } : void 0
|
|
302
|
+
}
|
|
303
|
+
),
|
|
304
|
+
f && /* @__PURE__ */ jsx(
|
|
305
|
+
"span",
|
|
306
|
+
{
|
|
307
|
+
"aria-hidden": "true",
|
|
308
|
+
className: "pointer-events-none absolute h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-brand-500 shadow",
|
|
309
|
+
style: { left: `${f.x * 100}%`, top: `${f.y * 100}%` }
|
|
310
|
+
}
|
|
311
|
+
)
|
|
312
|
+
]
|
|
313
|
+
}
|
|
314
|
+
);
|
|
315
|
+
}
|
|
234
316
|
export {
|
|
235
317
|
MediaManager
|
|
236
318
|
};
|
|
@@ -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 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, findUnusedMedia } 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 /** Ids van aantoonbaar ongebruikte afbeeldingen (na een expliciete scan); null = nog niet gescand. */\n const [ongebruikt, setOngebruikt] = useState<string[] | null>(null);\n const [scant, setScant] = 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 verwijderItem(item);\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 /** Doc + origineel + alle varianten weg — gedeeld door los verwijderen en de opruimer. */\n async function verwijderItem(item: MediaItem) {\n const db = getDb();\n const bucket = getBucket();\n if (!db) throw new Error(\"Geen databaseverbinding.\");\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 }\n\n /**\n * Opruimer (7.14): Storage groeide anders onbeperkt — elke ooit geüploade foto (plus zijn\n * webp/srcset-varianten) bleef staan, ook als hij allang uit elk blok was gehaald. De scan is\n * expliciet (één knop, één gecombineerde content-read via findUnusedMedia) en het verwijderen\n * vraagt altijd eerst bevestiging met aantal en omvang.\n */\n async function scanOngebruikt() {\n setScant(true);\n try {\n const ids = await findUnusedMedia(siteId, items);\n setOngebruikt(ids);\n if (ids.length === 0) toast({ title: \"Niets op te ruimen\", description: \"Elke afbeelding in de bibliotheek wordt ergens gebruikt.\", tone: \"success\" });\n } catch (e) {\n toast({ title: \"Scan mislukt\", description: (e as Error).message, tone: \"error\" });\n } finally {\n setScant(false);\n }\n }\n\n async function ruimOngebruiktOp() {\n const doelen = items.filter((it) => ongebruikt?.includes(it.id));\n if (doelen.length === 0) return;\n const mb = (doelen.reduce((som, it) => som + (it.size ?? 0), 0) / (1024 * 1024)).toFixed(1);\n const ok = await confirm({\n title: `${doelen.length} ongebruikte afbeelding(en) verwijderen?`,\n description: `Samen ±${mb} MB, inclusief alle automatisch gemaakte varianten. Dit kan niet ongedaan worden gemaakt.`,\n confirmLabel: \"Opruimen\",\n tone: \"danger\",\n });\n if (!ok) return;\n setBusy(true);\n let gelukt = 0;\n const fouten: string[] = [];\n for (const it of doelen) {\n try { await verwijderItem(it); gelukt++; } catch (e) { fouten.push(`${it.name}: ${(e as Error).message}`); }\n }\n setOngebruikt(null);\n setBusy(false);\n if (gelukt) toast({ title: `${gelukt} afbeelding(en) opgeruimd`, description: `±${mb} MB opslag vrijgemaakt.`, tone: \"success\" });\n if (fouten.length) toast({ title: \"Niet alles kon weg\", description: fouten.join(\" · \"), tone: \"error\" });\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 {!isDemoActive() && items.length > 0 && (\n ongebruikt === null ? (\n <Button variant=\"ghost\" disabled={scant || busy} onClick={() => void scanOngebruikt()}>\n {scant ? \"Zoeken…\" : \"🧹 Zoek ongebruikte foto's\"}\n </Button>\n ) : ongebruikt.length > 0 ? (\n <>\n <Button variant=\"secondary\" disabled={busy} onClick={() => void ruimOngebruiktOp()}>\n 🧹 {ongebruikt.length} ongebruikte verwijderen\n </Button>{\" \"}\n <Button variant=\"ghost\" onClick={() => setOngebruikt(null)}>Annuleren</Button>\n </>\n ) : null\n )}\n <p className=\"mt-1 text-xs text-slate-500\">\n Je kunt meerdere foto'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\" ? (\n <Badge tone=\"amber\">verwerken</Badge>\n ) : ongebruikt?.includes(m.id) ? (\n <Badge tone=\"amber\">ongebruikt</Badge>\n ) : (\n <Badge tone=\"green\">klaar</Badge>\n )}\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
|
+
{"version":3,"sources":["../../../src/admin/media/MediaManager.tsx"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n collection,\n deleteDoc,\n deleteField,\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, findUnusedMedia } 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 /** Ids van aantoonbaar ongebruikte afbeeldingen (na een expliciete scan); null = nog niet gescand. */\n const [ongebruikt, setOngebruikt] = useState<string[] | null>(null);\n const [scant, setScant] = useState(false);\n const [pageCount, setPageCount] = useState(1);\n const [hasMore, setHasMore] = useState(false);\n const inputRef = useRef<HTMLInputElement>(null);\n /** 7.24: zoeken op naam/alt — bij 100+ foto's is scrollen geen zoeken meer. */\n const [zoek, setZoek] = useState(\"\");\n const [sleepOver, setSleepOver] = useState(false);\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 /**\n * Focuspunt (7.24): klik op de plek in de foto die in beeld moet blijven. Bewaard als fracties;\n * de renderers vertalen dat naar `object-position` (Img/ResponsiveImage). Nog een klik = anders,\n * \"midden\" = weg.\n */\n async function zetFocus(item: MediaItem, focal: { x: number; y: number } | null) {\n const db = getDb();\n if (!db) return;\n try {\n await updateDoc(doc(db, \"sites\", siteId, \"media\", item.id), focal ? { focal } : { focal: deleteField() });\n toast({ title: focal ? \"Focuspunt opgeslagen\" : \"Focuspunt verwijderd\", description: focal ? \"Bij bijsnijden blijft dit deel in beeld.\" : undefined, tone: \"success\" });\n } catch (e) {\n toast({ title: \"Focuspunt opslaan mislukt\", description: (e as Error).message, tone: \"error\" });\n }\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 verwijderItem(item);\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 /** Doc + origineel + alle varianten weg — gedeeld door los verwijderen en de opruimer. */\n async function verwijderItem(item: MediaItem) {\n const db = getDb();\n const bucket = getBucket();\n if (!db) throw new Error(\"Geen databaseverbinding.\");\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 }\n\n /**\n * Opruimer (7.14): Storage groeide anders onbeperkt — elke ooit geüploade foto (plus zijn\n * webp/srcset-varianten) bleef staan, ook als hij allang uit elk blok was gehaald. De scan is\n * expliciet (één knop, één gecombineerde content-read via findUnusedMedia) en het verwijderen\n * vraagt altijd eerst bevestiging met aantal en omvang.\n */\n async function scanOngebruikt() {\n setScant(true);\n try {\n const ids = await findUnusedMedia(siteId, items);\n setOngebruikt(ids);\n if (ids.length === 0) toast({ title: \"Niets op te ruimen\", description: \"Elke afbeelding in de bibliotheek wordt ergens gebruikt.\", tone: \"success\" });\n } catch (e) {\n toast({ title: \"Scan mislukt\", description: (e as Error).message, tone: \"error\" });\n } finally {\n setScant(false);\n }\n }\n\n async function ruimOngebruiktOp() {\n const doelen = items.filter((it) => ongebruikt?.includes(it.id));\n if (doelen.length === 0) return;\n const mb = (doelen.reduce((som, it) => som + (it.size ?? 0), 0) / (1024 * 1024)).toFixed(1);\n const ok = await confirm({\n title: `${doelen.length} ongebruikte afbeelding(en) verwijderen?`,\n description: `Samen ±${mb} MB, inclusief alle automatisch gemaakte varianten. Dit kan niet ongedaan worden gemaakt.`,\n confirmLabel: \"Opruimen\",\n tone: \"danger\",\n });\n if (!ok) return;\n setBusy(true);\n let gelukt = 0;\n const fouten: string[] = [];\n for (const it of doelen) {\n try { await verwijderItem(it); gelukt++; } catch (e) { fouten.push(`${it.name}: ${(e as Error).message}`); }\n }\n setOngebruikt(null);\n setBusy(false);\n if (gelukt) toast({ title: `${gelukt} afbeelding(en) opgeruimd`, description: `±${mb} MB opslag vrijgemaakt.`, tone: \"success\" });\n if (fouten.length) toast({ title: \"Niet alles kon weg\", description: fouten.join(\" · \"), tone: \"error\" });\n }\n\n const term = zoek.trim().toLowerCase();\n const getoond = term ? items.filter((m) => `${m.name ?? \"\"} ${m.alt ?? \"\"}`.toLowerCase().includes(term)) : items;\n\n return (\n <div\n className={`grid gap-4 rounded-xl transition ${sleepOver ? \"outline-2 outline-dashed outline-brand-500 outline-offset-4\" : \"\"}`}\n // Slepen vanaf het bureaublad (7.24): de hele bibliotheek is een dropzone. Alleen bestanden\n // (geen tekst-drags), en de browser mag het bestand niet zelf openen.\n onDragOver={(e) => {\n if (e.dataTransfer.types.includes(\"Files\")) {\n e.preventDefault();\n setSleepOver(true);\n }\n }}\n onDragLeave={() => setSleepOver(false)}\n onDrop={(e) => {\n if (!e.dataTransfer.files?.length) return;\n e.preventDefault();\n setSleepOver(false);\n void onFiles(e.dataTransfer.files);\n }}\n >\n <div>\n <Button disabled={busy} onClick={() => inputRef.current?.click()}>\n {busy ? \"Uploaden…\" : \"Foto's uploaden\"}\n </Button>{\" \"}\n {!isDemoActive() && items.length > 0 && (\n ongebruikt === null ? (\n <Button variant=\"ghost\" disabled={scant || busy} onClick={() => void scanOngebruikt()}>\n {scant ? \"Zoeken…\" : \"🧹 Zoek ongebruikte foto's\"}\n </Button>\n ) : ongebruikt.length > 0 ? (\n <>\n <Button variant=\"secondary\" disabled={busy} onClick={() => void ruimOngebruiktOp()}>\n 🧹 {ongebruikt.length} ongebruikte verwijderen\n </Button>{\" \"}\n <Button variant=\"ghost\" onClick={() => setOngebruikt(null)}>Annuleren</Button>\n </>\n ) : null\n )}\n <p className=\"mt-1 text-xs text-slate-500\">\n Je kunt meerdere foto's tegelijk kiezen of ze hierheen slepen. Ze worden automatisch verkleind en\n geoptimaliseerd (webp) — dat houdt je site snel en bespaart opslag. Max. 15 MB per bestand.\n Klik op een foto om het <strong>focuspunt</strong> te kiezen: dat deel blijft in beeld als de foto\n wordt bijgesneden (bijvoorbeeld een brede kopfoto op een telefoon).\n </p>\n {items.length > 8 && (\n <div className=\"mt-3 max-w-xs\">\n <Input value={zoek} onChange={(e) => setZoek(e.target.value)} placeholder=\"Zoek op naam of alt-tekst…\" aria-label=\"Zoek in mediabibliotheek\" />\n </div>\n )}\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 {getoond.map((m) => (\n <Card key={m.id} className=\"p-3\">\n <FocusFoto item={m} onFocus={(f) => void zetFocus(m, f)} />\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\" ? (\n <Badge tone=\"amber\">verwerken</Badge>\n ) : ongebruikt?.includes(m.id) ? (\n <Badge tone=\"amber\">ongebruikt</Badge>\n ) : (\n <Badge tone=\"green\">klaar</Badge>\n )}\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 {items.length > 0 && getoond.length === 0 && <p className=\"text-sm text-slate-500\">Geen foto's gevonden voor “{zoek}”.</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\n/**\n * Thumbnail met focuspunt-kiezer: klik = zet het punt op die plek, klik op het punt zelf = weg.\n * Het beeld toont `object-position` op het gekozen punt, zodat je meteen ziet wat het doet.\n */\nfunction FocusFoto({ item, onFocus }: { item: MediaItem; onFocus: (f: { x: number; y: number } | null) => void }) {\n const f = item.focal;\n return (\n <button\n type=\"button\"\n className=\"relative mb-2 block h-28 w-full cursor-crosshair overflow-hidden rounded-md focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:outline-none\"\n title={f ? \"Focuspunt: klik om te verplaatsen, klik op het punt om te verwijderen\" : \"Klik om het focuspunt te kiezen\"}\n aria-label={f ? `Focuspunt van ${item.name} aanpassen` : `Focuspunt van ${item.name} kiezen`}\n onClick={(e) => {\n const r = e.currentTarget.getBoundingClientRect();\n const x = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));\n const y = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));\n // Klik (vrijwel) op het bestaande punt = verwijderen.\n if (f && Math.abs(f.x - x) < 0.08 && Math.abs(f.y - y) < 0.12) onFocus(null);\n else onFocus({ x: Math.round(x * 100) / 100, y: Math.round(y * 100) / 100 });\n }}\n >\n {/* eslint-disable-next-line @next/next/no-img-element */}\n <img\n src={item.url}\n alt={item.alt ?? item.name}\n className=\"h-full w-full object-cover\"\n style={f ? { objectPosition: `${Math.round(f.x * 100)}% ${Math.round(f.y * 100)}%` } : undefined}\n />\n {f && (\n <span\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-brand-500 shadow\"\n style={{ left: `${f.x * 100}%`, top: `${f.y * 100}%` }}\n />\n )}\n </button>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
import { type DraftPage } from "../lib/pagesClient.js";
|
|
2
|
+
/** Badge voor de toestand van een pagina — gedeeld met de lijst. */
|
|
3
|
+
export declare function ToestandBadge({ p }: {
|
|
4
|
+
p: DraftPage;
|
|
5
|
+
}): import("react").JSX.Element;
|
|
6
|
+
/**
|
|
7
|
+
* Pagina-instellingen (7.24) — wat je NIET op de pagina zelf ziet: titel, adres, SEO, versies,
|
|
8
|
+
* en de levenscyclus (publiceren, offline halen, dupliceren, prullenbak).
|
|
9
|
+
*
|
|
10
|
+
* Tot 7.24 stond hier een complete tweede editor: elk blok als formulier, met eigen ↑↓🗑, een
|
|
11
|
+
* eigen publiceerknop zónder de "wat is er veranderd"-diff en zonder undo. Het dashboard stuurde
|
|
12
|
+
* de klant intussen naar de site zelf ("bewerk op de pagina") — twee manieren voor één ding, met
|
|
13
|
+
* twee gedragingen. De blokken bewerk je nu uitsluitend op de site (knop hieronder); dit scherm
|
|
14
|
+
* gaat over de pagina als geheel.
|
|
15
|
+
*/
|
|
2
16
|
export declare function PageEditor({ siteId, page, allPages }: {
|
|
3
17
|
siteId: string;
|
|
4
18
|
page: DraftPage;
|