@cardinali-studio/adminkit 2.2.33 → 2.2.34

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/api.d.ts CHANGED
@@ -71,6 +71,8 @@ export type ContentDocumentPublishTarget = {
71
71
  documentId: string;
72
72
  expectedDraftRevisionId: string | null;
73
73
  includeSiteAccess?: boolean;
74
+ expectedBaseReleaseId?: string;
75
+ previewChecksum?: string;
74
76
  };
75
77
  export type ContentMediaPublishTarget = {
76
78
  mediaOperationId: string;
@@ -204,6 +206,23 @@ export declare const api: {
204
206
  enabled: boolean;
205
207
  } | null;
206
208
  activeReleaseId?: string | null;
209
+ previousReleaseId?: string | null;
210
+ }>;
211
+ contentPreview: (target: ContentDocumentPublishTarget) => Promise<{
212
+ ok: true;
213
+ previewUrl: string;
214
+ expectedBaseReleaseId: string;
215
+ expectedDraftRevisionId: string;
216
+ previewChecksum: string;
217
+ }>;
218
+ contentRollback: (expectedCurrentReleaseId: string, targetReleaseId: string) => Promise<{
219
+ ok: true;
220
+ releaseId: string;
221
+ }>;
222
+ contentVerification: (releaseId: string) => Promise<{
223
+ ok: true;
224
+ publicOriginVerified: boolean;
225
+ reason: string;
207
226
  }>;
208
227
  contentPublish: (target: ContentPublishTarget) => Promise<{
209
228
  ok: true;
package/dist/api.js CHANGED
@@ -73,6 +73,9 @@ export const api = {
73
73
  /** D1 publication is site-owned: the site chooses which documents it can
74
74
  * materialize, while the panel only displays the pending immutable inputs. */
75
75
  contentPublishState: () => call("/admin/api/content/publish"),
76
+ contentPreview: (target) => post("/admin/api/content/preview", target),
77
+ contentRollback: (expectedCurrentReleaseId, targetReleaseId) => post("/admin/api/content/rollback", { expectedCurrentReleaseId, targetReleaseId }),
78
+ contentVerification: (releaseId) => call("/admin/api/publication-status?releaseId=" + encodeURIComponent(releaseId)),
76
79
  contentPublish: (target) => post("/admin/api/content/publish", target),
77
80
  contentPublishMedia: (mediaOperationId) => post("/admin/api/content/publish", { mediaOperationId }),
78
81
  // ---- pubblicazione
package/dist/app/App.js CHANGED
@@ -20,8 +20,10 @@ import { useEffect, useMemo, useState } from "react";
20
20
  import "../theme.css";
21
21
  import "./app.css";
22
22
  import "./next.css";
23
- import { MediaFolders, MediaGrid, ProsePages, Trash, useMediaLibrary, usePicker } from "../form/index.js";
23
+ import { MediaFolders, MediaGrid, ProsePages, Trash, SchemaForm, useMediaLibrary, usePicker } from "../form/index.js";
24
24
  import { Preview, isLocalPreview } from "../preview.js";
25
+ import { api } from "../api.js";
26
+ import { ui } from "../registries.js";
25
27
  import { contentPersistenceMode, site } from "./site.js";
26
28
  import { useHashRoute } from "./lib/router";
27
29
  import { usePages, legacyDraft, legacyDraftTitles, discardLegacyDraft } from "./lib/pages";
@@ -71,6 +73,7 @@ function CurrentAdminApp({ adminInterface, setAdminInterface, }) {
71
73
  const pages = usePages();
72
74
  const home = useHome(section === "home");
73
75
  const HomeEditorView = site().homeEditor ?? HomeEditor;
76
+ const MediaEditorView = site().mediaEditor;
74
77
  /* Anche la Panoramica: le sue card devono sapere quali funzioni aggiuntive sono attive, e
75
78
  lo stato è quello — non una seconda copia. È una lettura sola, e la Panoramica è la
76
79
  schermata da cui si entra, quindi arriva prima che serva. */
@@ -78,7 +81,7 @@ function CurrentAdminApp({ adminInterface, setAdminInterface, }) {
78
81
  /* La libreria media vive QUI, non dentro un componente: le cartelle stanno nella seconda
79
82
  colonna e la griglia nella terza, e sono due punti dello schermo lontani. Uno stato per
80
83
  ciascuno vorrebbe dire due idee diverse di quale cartella si sta guardando. */
81
- const media = useMediaLibrary({ mode: "manage", active: section === "media" });
84
+ const media = useMediaLibrary({ mode: "manage", active: section === "media" && !MediaEditorView });
82
85
  const mediaSettingsOpen = view === "media" && route[1] === "settings";
83
86
  /* Le richieste si caricano SUBITO, non entrando nella sezione: il numero «da gestire» nella
84
87
  prima colonna è il motivo per cui uno ci entra, e un badge che compare solo dopo essere
@@ -258,7 +261,7 @@ function CurrentAdminApp({ adminInterface, setAdminInterface, }) {
258
261
  || (section === "requests" && !openReq)
259
262
  || (section === "bookings" && !openBooking)
260
263
  || (section === "media" && (view === "trash" || mediaSettingsOpen || mediaCrumbs.length === 0));
261
- return (_jsx(ProsePages.Provider, { value: pageLinks, children: _jsxs("div", { className: "app" + (hasList(section) && !offModule ? "" : " is-two"), children: [_jsxs("nav", { className: "rail", children: [_jsxs("div", { className: "rail-brand", children: [_jsx("span", { className: "rail-brand-name", children: site().name }), _jsx("span", { className: "rail-brand-role", children: "pannello" })] }), _jsx("div", { className: "rail-nav", children: navGroups.map((g) => (_jsxs("div", { className: "rail-group", children: [g.title && _jsx("span", { className: "rail-group-title", children: g.title }), g.items.map((n) => (_jsxs("button", { className: "rail-item" + (section === n.id ? " is-on" : ""), "aria-label": n.label, disabled: section === "media" && media.mutating, onClick: () => go(`/${n.id}`), children: [_jsx(n.icon, {}), _jsx("span", { children: n.label }), "badge" in n && n.badge ? _jsx("span", { className: "rail-badge", children: n.badge }) : null] }, n.id)))] }, g.title ?? "top"))) }), _jsxs("div", { className: "rail-foot", children: [_jsxs("a", { className: "rail-item", href: "/", target: "_blank", rel: "noreferrer", "aria-label": "Vedi il sito", children: [_jsx(Icon.out, {}), _jsx("span", { children: "Vedi il sito" })] }), _jsxs("button", { className: "rail-item", onClick: toggleTheme, "aria-label": theme === "dark" ? "Tema chiaro" : "Tema scuro", children: [theme === "dark" ? _jsx(Icon.sun, {}) : _jsx(Icon.moon, {}), _jsx("span", { children: theme === "dark" ? "Tema chiaro" : "Tema scuro" })] }), _jsxs("button", { className: "rail-item" + (section === "settings" ? " is-on" : ""), "aria-label": "Impostazioni", disabled: section === "media" && media.mutating, onClick: () => go("/settings/site"), children: [_jsx(Icon.settings, {}), _jsx("span", { children: "Impostazioni" })] })] })] }), hasList(section) && !offModule && (_jsxs("div", { className: "col", children: [section === "pages" && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "col-head col-head-actions", children: [_jsx("span", { className: "col-title", children: "Pagine" }), list && _jsx(NewPageButton, { pages: list, onCreate: (p) => pages.update([...list, p]) })] }), _jsx("div", { className: "col-body", children: pages.error ? _jsx("p", { className: "ak-error", children: pages.error })
264
+ return (_jsx(ProsePages.Provider, { value: pageLinks, children: _jsxs("div", { className: "app" + (hasList(section) && !(section === "media" && MediaEditorView) && !offModule ? "" : " is-two"), children: [_jsxs("nav", { className: "rail", children: [_jsxs("div", { className: "rail-brand", children: [_jsx("span", { className: "rail-brand-name", children: site().name }), _jsx("span", { className: "rail-brand-role", children: "pannello" })] }), _jsx("div", { className: "rail-nav", children: navGroups.map((g) => (_jsxs("div", { className: "rail-group", children: [g.title && _jsx("span", { className: "rail-group-title", children: g.title }), g.items.map((n) => (_jsxs("button", { className: "rail-item" + (section === n.id ? " is-on" : ""), "aria-label": n.label, disabled: section === "media" && media.mutating, onClick: () => go(`/${n.id}`), children: [_jsx(n.icon, {}), _jsx("span", { children: n.label }), "badge" in n && n.badge ? _jsx("span", { className: "rail-badge", children: n.badge }) : null] }, n.id)))] }, g.title ?? "top"))) }), _jsxs("div", { className: "rail-foot", children: [_jsxs("a", { className: "rail-item", href: "/", target: "_blank", rel: "noreferrer", "aria-label": "Vedi il sito", children: [_jsx(Icon.out, {}), _jsx("span", { children: "Vedi il sito" })] }), _jsxs("button", { className: "rail-item", onClick: toggleTheme, "aria-label": theme === "dark" ? "Tema chiaro" : "Tema scuro", children: [theme === "dark" ? _jsx(Icon.sun, {}) : _jsx(Icon.moon, {}), _jsx("span", { children: theme === "dark" ? "Tema chiaro" : "Tema scuro" })] }), _jsxs("button", { className: "rail-item" + (section === "settings" ? " is-on" : ""), "aria-label": "Impostazioni", disabled: section === "media" && media.mutating, onClick: () => go("/settings/site"), children: [_jsx(Icon.settings, {}), _jsx("span", { children: "Impostazioni" })] })] })] }), hasList(section) && !(section === "media" && MediaEditorView) && !offModule && (_jsxs("div", { className: "col", children: [section === "pages" && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "col-head col-head-actions", children: [_jsx("span", { className: "col-title", children: "Pagine" }), list && _jsx(NewPageButton, { pages: list, onCreate: (p) => pages.update([...list, p]) })] }), _jsx("div", { className: "col-body", children: pages.error ? _jsx("p", { className: "ak-error", children: pages.error })
262
265
  : !list ? _jsx("p", { className: "ak-hint", children: "Carico\u2026" })
263
266
  : _jsx(PagesList, { pages: list, current: editing?.slug, onEdit: (slug) => go(`/pages/${slug}`), languages: languages, onChange: pages.update, onRenamed: (from, to) => {
264
267
  if (editing?.slug === from)
@@ -283,13 +286,24 @@ function CurrentAdminApp({ adminInterface, setAdminInterface, }) {
283
286
  : deliveries.rows.length === 0 ? _jsx("p", { className: "ak-hint", children: "Nessun transfer, per ora." })
284
287
  : _jsx(DeliveriesList, { rows: deliveries.rows, current: activeDeliveryId || quickSelectedId, onOpen: (id) => { setQuickSelectedId(undefined); go(`/deliveries/${id}`); } }) }), _jsxs("div", { className: "dl-col-foot", children: [deliveries.rows && _jsx(DeliveriesTotal, { rows: deliveries.rows }), _jsxs("button", { className: "col-item" + (deliverySettingsOpen ? " is-on" : ""), onClick: () => { setQuickSelectedId(undefined); go("/deliveries/settings"); }, children: [_jsx(Icon.settings, {}), _jsx("span", { children: "Impostazioni" })] })] })] })), section === "bookings" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: _jsx("span", { className: "col-title", children: bookings.rows ? `${bookings.rows.length} appuntamenti` : "Prenotazioni" }) }), _jsx("div", { className: "col-body", children: bookings.error ? _jsx("p", { className: "ak-error", children: bookings.error })
285
288
  : !bookings.rows ? _jsx("p", { className: "ak-hint", children: "Carico\u2026" })
286
- : _jsx(BookingsList, { rows: bookings.rows, current: bookingSettingsOpen ? "settings" : openBooking?.id, onOpen: (id) => go(`/bookings/${id}`), onAgenda: () => go("/bookings"), onSettings: () => go("/bookings/settings") }) })] })), section === "home" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: _jsx("span", { className: "col-title", children: "Sezioni" }) }), _jsx("div", { className: "col-body", children: _jsx("ul", { className: "col-list", children: homeGroups.map((g) => (_jsx("li", { children: _jsx("button", { className: "col-item" + (g === homeGroup ? " is-on" : ""), onClick: () => go(`/home/${groupSlug(g)}`), children: g || "Generale" }) }, g))) }) })] })), section === "settings" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: _jsx("span", { className: "col-title", children: "Impostazioni" }) }), _jsx("div", { className: "col-body", children: _jsxs("ul", { className: "col-list settings-list", children: [_jsx("li", { children: _jsxs("button", { className: "col-item settings-list-item" + (settingsView === "site" ? " is-on" : ""), onClick: () => go("/settings/site"), children: [_jsx("strong", { children: "Sito" }), _jsx("span", { children: "Favicon, identit\u00E0 e accesso generale." })] }) }), _jsx("li", { children: _jsxs("button", { className: "col-item settings-list-item" + (settingsView === "seo" ? " is-on" : ""), onClick: () => go("/settings/seo"), children: [_jsx("strong", { children: "SEO" }), _jsx("span", { children: "Motori di ricerca e crawler AI." })] }) })] }) })] }))] })), _jsxs("main", { className: "pane", children: [_jsxs("header", { className: "pane-head", children: [editing ? (_jsx(PageHeader, { page: editing, pages: list ?? [], onChange: replace, seoActive: seoEditing, onSeoToggle: () => go(`/pages/${editing.slug}${seoEditing ? "" : "/seo"}`), languages: languages, activeLanguage: pageLanguage, onLanguageChange: setPageLanguage })) : view === "media" && !mediaSettingsOpen && mediaCrumbs.length > 0 ? (_jsxs("nav", { className: "pane-crumbs", "aria-label": "Percorso", children: [_jsx("button", { className: "pane-crumb" + (mediaCrumbs.length ? "" : " is-current"), disabled: media.mutating, onClick: () => media.go(""), children: "Senza cartella" }), mediaCrumbs.map((c, i) => (_jsxs("span", { className: "pane-crumb-part", children: [_jsx("span", { className: "pane-crumb-sep", "aria-hidden": "true", children: "\u203A" }), _jsx("button", { className: "pane-crumb" + (i === mediaCrumbs.length - 1 ? " is-current" : ""), disabled: media.mutating, onClick: () => media.go(c.prefix), children: c.label })] }, c.prefix)))] })) : !redundantPaneContext ? (_jsx("h1", { className: "pane-title", children: heading })) : null, view === "media" && !mediaSettingsOpen && _jsx("span", { className: "pane-count", children: media.shown.length }), _jsx("span", { className: "pane-spacer" }), view === "media" && !mediaSettingsOpen && (_jsx("button", { className: "ak-btn ak-btn-primary", disabled: media.busy || media.mutating, onClick: () => media.openUpload(), children: "Carica immagini" })), saver?.notice && (_jsx("button", { className: "pane-notice", onClick: () => saver.setNotice(null), children: saver.notice })), section === "bookings" && bookings.notice && (_jsx("button", { className: "pane-notice", onClick: bookings.clearNotice, children: bookings.notice })), publish.notice && (_jsx("button", { className: "pane-notice", onClick: publish.clearNotice, children: publish.notice })), !publish.configured && publish.error && (_jsx("span", { className: "pane-notice", title: publish.error, children: publish.error })), saver && (_jsxs(_Fragment, { children: [saver.dirty && _jsx("span", { className: "pane-dot", title: "modifiche non salvate" }), _jsx("button", { className: "ak-btn", disabled: !saver.dirty || saver.saving,
289
+ : _jsx(BookingsList, { rows: bookings.rows, current: bookingSettingsOpen ? "settings" : openBooking?.id, onOpen: (id) => go(`/bookings/${id}`), onAgenda: () => go("/bookings"), onSettings: () => go("/bookings/settings") }) })] })), section === "home" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: _jsx("span", { className: "col-title", children: "Sezioni" }) }), _jsx("div", { className: "col-body", children: _jsx("ul", { className: "col-list", children: homeGroups.map((g) => (_jsx("li", { children: _jsx("button", { className: "col-item" + (g === homeGroup ? " is-on" : ""), onClick: () => go(`/home/${groupSlug(g)}`), children: g || "Generale" }) }, g))) }) })] })), section === "settings" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: _jsx("span", { className: "col-title", children: "Impostazioni" }) }), _jsx("div", { className: "col-body", children: _jsxs("ul", { className: "col-list settings-list", children: [_jsx("li", { children: _jsxs("button", { className: "col-item settings-list-item" + (settingsView === "site" ? " is-on" : ""), onClick: () => go("/settings/site"), children: [_jsx("strong", { children: "Sito" }), _jsx("span", { children: "Favicon, identit\u00E0 e accesso generale." })] }) }), _jsx("li", { children: _jsxs("button", { className: "col-item settings-list-item" + (settingsView === "seo" ? " is-on" : ""), onClick: () => go("/settings/seo"), children: [_jsx("strong", { children: "SEO" }), _jsx("span", { children: "Motori di ricerca e crawler AI." })] }) })] }) })] }))] })), _jsxs("main", { className: "pane", children: [_jsxs("header", { className: "pane-head", children: [editing ? (_jsx(PageHeader, { page: editing, pages: list ?? [], onChange: replace, seoActive: seoEditing, onSeoToggle: () => go(`/pages/${editing.slug}${seoEditing ? "" : "/seo"}`), languages: languages, activeLanguage: pageLanguage, onLanguageChange: setPageLanguage })) : view === "media" && !mediaSettingsOpen && mediaCrumbs.length > 0 ? (_jsxs("nav", { className: "pane-crumbs", "aria-label": "Percorso", children: [_jsx("button", { className: "pane-crumb" + (mediaCrumbs.length ? "" : " is-current"), disabled: media.mutating, onClick: () => media.go(""), children: "Senza cartella" }), mediaCrumbs.map((c, i) => (_jsxs("span", { className: "pane-crumb-part", children: [_jsx("span", { className: "pane-crumb-sep", "aria-hidden": "true", children: "\u203A" }), _jsx("button", { className: "pane-crumb" + (i === mediaCrumbs.length - 1 ? " is-current" : ""), disabled: media.mutating, onClick: () => media.go(c.prefix), children: c.label })] }, c.prefix)))] })) : !redundantPaneContext ? (_jsx("h1", { className: "pane-title", children: heading })) : null, view === "media" && !mediaSettingsOpen && _jsx("span", { className: "pane-count", children: media.shown.length }), _jsx("span", { className: "pane-spacer" }), view === "media" && !mediaSettingsOpen && !MediaEditorView && (_jsx("button", { className: "ak-btn ak-btn-primary", disabled: media.busy || media.mutating, onClick: () => media.openUpload(), children: "Carica immagini" })), saver?.notice && (_jsx("button", { className: "pane-notice", onClick: () => saver.setNotice(null), children: saver.notice })), section === "bookings" && bookings.notice && (_jsx("button", { className: "pane-notice", onClick: bookings.clearNotice, children: bookings.notice })), publish.notice && (_jsx("button", { className: "pane-notice", onClick: publish.clearNotice, children: publish.notice })), !publish.configured && publish.error && (_jsx("span", { className: "pane-notice", title: publish.error, children: publish.error })), saver && (_jsxs(_Fragment, { children: [saver.dirty && _jsx("span", { className: "pane-dot", title: "modifiche non salvate" }), _jsx("button", { className: "ak-btn", disabled: !saver.dirty || saver.saving,
287
290
  /* Dopo un salvataggio il conteggio delle modifiche in attesa è cambiato: senza
288
291
  rileggerlo, «Pubblica» comparirebbe solo al ricaricamento della pagina — cioè
289
292
  il pannello direbbe che non c'è niente da pubblicare subito dopo aver salvato
290
293
  qualcosa da pubblicare. */
291
- onClick: () => void saver.save().then(() => publish.reload()), children: saver.saving ? "Salvo…" : "Salva" })] })), contentPersistenceMode() === "d1" && d1ContextPending && otherPendingCount > 0 && (_jsx("span", { className: "pane-count", title: "Queste modifiche restano in bozza", children: otherPendingCount === 1 ? "1 altra bozza" : `${otherPendingCount} altre bozze` })), ((contentPersistenceMode() === "d1" && d1ContextPending)
292
- || (contentPersistenceMode() !== "d1" && publish.ahead > 0)) && (_jsx("button", { className: "ak-btn ak-btn-primary", disabled: publish.publishing, onClick: () => void publish.publish(d1PublishTarget ?? undefined), title: publish.commits.map((c) => c.message).join("\n"), children: publish.publishing ? "Pubblico…"
294
+ onClick: () => void saver.save().then(() => publish.reload()), children: saver.saving ? "Salvo…" : "Salva" })] })), contentPersistenceMode() === "d1" && d1ContextPending && otherPendingCount > 0 && (_jsx("span", { className: "pane-count", title: "Queste modifiche restano in bozza", children: otherPendingCount === 1 ? "1 altra bozza" : `${otherPendingCount} altre bozze` })), site().directPublication && _jsxs(_Fragment, { children: [d1ContextPending && d1PublishTarget && _jsx("button", { className: "ak-btn", disabled: publish.publishing || !!saver?.dirty || !!saver?.saving, onClick: () => void publish.preview(d1PublishTarget), children: "Anteprima" }), d1ContextPending && d1PublishTarget && _jsx("button", { className: "ak-btn", disabled: publish.publishing || !!saver?.dirty || !!saver?.saving, onClick: () => void api.discardContent(d1PublishTarget.documentId, d1PublishTarget.expectedDraftRevisionId, null).then(async () => { if (section === "pages")
295
+ await pages.load(); if (section === "home")
296
+ await home.load(); if (section === "settings")
297
+ await settings.load(); await publish.reload(); }), children: "Scarta bozza" }), _jsx("button", { className: "ak-btn", onClick: () => void publish.verify(), children: "Verifica sito pubblico" }), publish.previousReleaseId && _jsx("button", { className: "ak-btn", disabled: publish.publishing || pages.dirty || home.dirty || settings.dirty, onClick: () => { if (confirm("Ripristinare la release precedente del sito? Le bozze restano salvate."))
298
+ void publish.rollback().then(async () => { await pages.load(); await home.load(); await settings.load(); }); }, children: "Ripristina precedente" })] }), ((contentPersistenceMode() === "d1" && d1ContextPending)
299
+ || (contentPersistenceMode() !== "d1" && publish.ahead > 0)) && (_jsx("button", { className: "ak-btn ak-btn-primary", disabled: publish.publishing || !!saver?.dirty || !!saver?.saving || (site().directPublication === true && !publish.previewApproved(d1PublishTarget)), onClick: () => void publish.publish(d1PublishTarget ?? undefined).then(async () => { if (!saver?.dirty) {
300
+ if (section === "pages")
301
+ await pages.load();
302
+ if (section === "home")
303
+ await home.load();
304
+ if (section === "settings")
305
+ await settings.load();
306
+ } }), title: publish.commits.map((c) => c.message).join("\n"), children: publish.publishing ? "Pubblico…"
293
307
  : contentPersistenceMode() === "d1" ? contextualPublishLabel
294
308
  : publish.ahead === 1 ? "Pubblica (1 modifica)"
295
309
  : `Pubblica (${publish.ahead} modifiche)` }))] }), offModule ? _jsx(FeatureOff, { info: offModule }) : _jsxs(_Fragment, { children: [draft !== null && (_jsxs("div", { className: "app-legacy", children: [_jsxs("p", { className: "app-legacy-row", children: [_jsxs("span", { children: ["Questo browser conserva una bozza del ", _jsx("strong", { children: "vecchio" }), " pannello (", draft, " pagine). Non viene usata: quello che si vede qui \u00E8 il sito pubblicato."] }), _jsx("button", { className: "ak-btn ak-btn-quiet", onClick: () => setDraftOpen((v) => !v), children: draftOpen ? "Nascondi" : "Cosa contiene" }), _jsx("button", { className: "ak-btn ak-btn-quiet", onClick: () => { discardLegacyDraft(); setDraft(null); }, children: "Butta" })] }), draftOpen && _jsx("p", { className: "app-legacy-list", children: legacyDraftTitles().join(" · ") })] })), _jsxs("div", { className: "pane-body" + (wide ? " is-wide" : ""), children: [section === "overview" && (
@@ -304,9 +318,9 @@ function CurrentAdminApp({ adminInterface, setAdminInterface, }) {
304
318
  se non ricaricando il pannello a mano. Le altre sezioni il bottone ce l'hanno. */
305
319
  home.error ? (_jsxs("div", { className: "pane-empty", children: [_jsx("p", { className: "ak-error", children: home.error }), _jsx("button", { className: "ak-btn", onClick: () => void home.load(), children: "Riprova" })] }))
306
320
  : home.data ? _jsx(HomeEditorView, { data: home.data, group: homeGroup, onChange: home.change, pickMedia: pickMedia })
307
- : _jsx("p", { className: "ak-hint", children: "Carico\u2026" })), section === "settings" && (settings.error ? (settingsView === "site" ? (_jsx(SiteSettingsUnavailable, { error: settings.error, onRetry: () => void settings.load(), adminInterface: adminInterface, onAdminInterfaceChange: setAdminInterface })) : (_jsxs("div", { className: "pane-empty", children: [_jsx("p", { className: "ak-error", children: settings.error }), _jsx("button", { className: "ak-btn", onClick: () => void settings.load(), children: "Riprova" })] }))) : settings.data ? (settingsView === "seo"
321
+ : _jsx("p", { className: "ak-hint", children: "Carico\u2026" })), section === "settings" && (settings.error ? (settingsView === "site" ? (_jsx(SiteSettingsUnavailable, { error: settings.error, onRetry: () => void settings.load(), adminInterface: adminInterface, onAdminInterfaceChange: setAdminInterface })) : (_jsxs("div", { className: "pane-empty", children: [_jsx("p", { className: "ak-error", children: settings.error }), _jsx("button", { className: "ak-btn", onClick: () => void settings.load(), children: "Riprova" })] }))) : settings.data ? site().settingsShape ? _jsx(SchemaForm, { shape: site().settingsShape, omit: Object.entries(site().settingsShape).filter(([, field]) => settingsView === "seo" ? ui.get(field)?.group !== "seo" : ui.get(field)?.group === "seo").map(([key]) => key), value: settings.data, onChange: (next) => settings.change(next), pickMedia: pickMedia, pickMediaFiles: pickMediaFiles }) : (settingsView === "seo"
308
322
  ? _jsx(WebsiteSeoSettingsEditor, { value: settings.data, onChange: settings.change })
309
- : _jsx(SiteSettingsEditor, { value: settings.data, onChange: settings.change, pickMediaFiles: pickMediaFiles, adminInterface: adminInterface, onAdminInterfaceChange: setAdminInterface, focus: route[1], passwordDraftPending: settings.accessDraftPending, onDiscardPasswordDraft: () => void settings.discardSiteAccess().then(() => publish.reload()) })) : _jsx("p", { className: "pane-empty ak-hint", children: "Carico\u2026" })), view === "media" && (mediaSettingsOpen
323
+ : _jsx(SiteSettingsEditor, { value: settings.data, onChange: settings.change, pickMediaFiles: pickMediaFiles, adminInterface: adminInterface, onAdminInterfaceChange: setAdminInterface, focus: route[1], passwordDraftPending: settings.accessDraftPending, onDiscardPasswordDraft: () => void settings.discardSiteAccess().then(() => publish.reload()) })) : _jsx("p", { className: "pane-empty ak-hint", children: "Carico\u2026" })), view === "media" && (MediaEditorView ? _jsx(MediaEditorView, {}) : mediaSettingsOpen
310
324
  ? _jsx(MediaSettingsEditor, { lib: media })
311
325
  : _jsx(MediaGrid, { lib: media })), view === "trash" && _jsx(Trash, {}), section === "requests" && (requests.error ? _jsx("p", { className: "ak-error", children: requests.error })
312
326
  : openReq ? (_jsx(RequestDetail, { row: openReq, onMark: (h) => void requests.mark([openReq.id], h), onRemove: () => { void requests.remove([openReq.id]); go("/requests"); } }))
@@ -1,3 +1,4 @@
1
+ import { site } from "../site.js";
1
2
  import { useCallback, useEffect, useState } from "react";
2
3
  async function call(path, init) {
3
4
  const response = await fetch(`/admin/api/booking${path}`, {
@@ -17,6 +18,10 @@ export function useBookings() {
17
18
  loading: true, busy: false, error: null, notice: null,
18
19
  });
19
20
  const load = useCallback(async () => {
21
+ if (site().modules?.booking !== true) {
22
+ setState(value => ({ ...value, rows: [], pending: 0, upcoming: 0, loading: false, error: null }));
23
+ return;
24
+ }
20
25
  setState((value) => ({ ...value, loading: true, error: null }));
21
26
  try {
22
27
  const [list, types, settings, availability, google, activation, blocks] = await Promise.all([
@@ -1,3 +1,4 @@
1
+ import { site } from "../site.js";
1
2
  import { useCallback, useEffect, useState } from "react";
2
3
  import { ApiError } from "../../index.js";
3
4
  export function useDeliveries(activeId) {
@@ -8,6 +9,12 @@ export function useDeliveries(activeId) {
8
9
  const [error, setError] = useState(null);
9
10
  const [busy, setBusy] = useState(false);
10
11
  const load = useCallback(async () => {
12
+ if (site().modules?.delivery !== true) {
13
+ setRows([]);
14
+ setExpiring(0);
15
+ setError(null);
16
+ return;
17
+ }
11
18
  try {
12
19
  const out = await getJson("/admin/api/deliveries");
13
20
  setRows(out.deliveries);
@@ -8,7 +8,7 @@
8
8
  * l'admin per riordinare le pagine non ha motivo di pagare una chiamata in più — e senza
9
9
  * GITHUB_TOKEN, in anteprima locale, quella chiamata è un 500.
10
10
  */
11
- import { useCallback, useEffect, useState } from "react";
11
+ import { useCallback, useEffect, useState, useRef } from "react";
12
12
  import { ApiError } from "../../index.js";
13
13
  import { api } from "../../api.js";
14
14
  import { contentPersistenceMode, site } from "../site.js";
@@ -27,6 +27,7 @@ export function useHome(active) {
27
27
  const [saving, setSaving] = useState(false);
28
28
  const [notice, setNotice] = useState(null);
29
29
  const [revision, setRevision] = useState(null);
30
+ const pending = useRef(null);
30
31
  const load = useCallback(async () => {
31
32
  try {
32
33
  const mapped = site().homeDocument;
@@ -59,12 +60,14 @@ export function useHome(active) {
59
60
  try {
60
61
  const mapped = site().homeDocument;
61
62
  if (contentPersistenceMode() === "d1" && mapped && revision) {
62
- const revisionId = crypto.randomUUID();
63
+ const revisionId = pending.current?.revisionId ?? crypto.randomUUID();
63
64
  /* The Worker validates and may normalize a document before checking its signed
64
65
  * representation. Sign the site-owned canonical payload here too, otherwise an
65
66
  * obsolete/unknown client field becomes a misleading "documento non valido". */
66
67
  const payload = mapped.toPayload ? mapped.toPayload(data) : data;
67
- await api.saveContent({ revisionId, documentId: mapped.documentId, baseRevisionId: revision.base, expectedDraftRevisionId: revision.expected, kind: mapped.kind, locale: mapped.locale, schemaVersion: mapped.schemaVersion, payload: payload, tombstone: false });
68
+ pending.current ||= { revisionId, documentId: mapped.documentId, baseRevisionId: revision.base, expectedDraftRevisionId: revision.expected, kind: mapped.kind, locale: mapped.locale, schemaVersion: mapped.schemaVersion, payload: payload, tombstone: false };
69
+ await api.saveContent(pending.current);
70
+ pending.current = null;
68
71
  setRevision({ base: revisionId, expected: revisionId });
69
72
  setNotice("Bozza D1 salvata");
70
73
  setDirty(false);
@@ -88,7 +91,7 @@ export function useHome(active) {
88
91
  setSaving(false);
89
92
  }
90
93
  }, [data, revision]);
91
- const change = useCallback((next) => { setData(next); setDirty(true); }, []);
94
+ const change = useCallback((next) => { pending.current = null; setData(next); setDirty(true); }, []);
92
95
  const mapped = site().homeDocument;
93
96
  return { data, error, dirty, saving, notice, load, change, save, setNotice,
94
97
  publishTarget: contentPersistenceMode() === "d1" && mapped && revision
@@ -48,7 +48,8 @@ export function usePages() {
48
48
  }, []);
49
49
  useEffect(() => { void load(); }, [load]);
50
50
  const update = useCallback((next) => {
51
- next.forEach((p, i) => { p.weight = site().positionWeight(p, i, next); });
51
+ if (site().pagesReorderable !== false)
52
+ next.forEach((p, i) => { p.weight = site().positionWeight(p, i, next); });
52
53
  setPages(next);
53
54
  setDirty(true);
54
55
  }, []);
@@ -17,6 +17,11 @@ export interface PublishState {
17
17
  reload(): Promise<void>;
18
18
  publish(target?: ContentPublishTarget): Promise<void>;
19
19
  clearNotice(): void;
20
+ preview(target: ContentPublishTarget): Promise<void>;
21
+ previewApproved(target: ContentPublishTarget | null): boolean;
22
+ previousReleaseId: string | null;
23
+ rollback(): Promise<void>;
24
+ verify(): Promise<void>;
20
25
  }
21
26
  export declare function contentPublicationNotice(result: {
22
27
  published: boolean;
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { useCallback, useEffect, useState } from "react";
9
9
  import { api, ApiError } from "../../index.js";
10
- import { contentPersistenceMode } from "../site.js";
10
+ import { contentPersistenceMode, site } from "../site.js";
11
11
  export function contentPublicationNotice(result) {
12
12
  if (result.published === false) {
13
13
  return result.message ?? "Non c'erano modifiche salvate da pubblicare.";
@@ -25,10 +25,64 @@ export function usePublish() {
25
25
  const [publishing, setPublishing] = useState(false);
26
26
  const [notice, setNotice] = useState(null);
27
27
  const [error, setError] = useState(null);
28
+ const [approval, setApproval] = useState(null);
29
+ const [activeReleaseId, setActiveReleaseId] = useState(null);
30
+ const [previousReleaseId, setPreviousReleaseId] = useState(null);
31
+ const previewApproved = (target) => !!target && "documentId" in target && !!approval && approval.documentId === target.documentId && approval.expectedDraftRevisionId === target.expectedDraftRevisionId && approval.expectedBaseReleaseId === activeReleaseId;
32
+ const preview = async (target) => {
33
+ if (!("documentId" in target))
34
+ return;
35
+ setPublishing(true);
36
+ setNotice(null);
37
+ try {
38
+ const result = await api.contentPreview(target);
39
+ setApproval({ ...result, documentId: target.documentId });
40
+ window.open(result.previewUrl, "_blank", "noopener");
41
+ setNotice("Anteprima della bozza salvata pronta.");
42
+ }
43
+ catch (error) {
44
+ setApproval(null);
45
+ setNotice(error.message);
46
+ }
47
+ finally {
48
+ setPublishing(false);
49
+ }
50
+ };
51
+ const rollback = async () => {
52
+ if (!activeReleaseId || !previousReleaseId)
53
+ return;
54
+ setPublishing(true);
55
+ setNotice(null);
56
+ try {
57
+ await api.contentRollback(activeReleaseId, previousReleaseId);
58
+ setApproval(null);
59
+ await reload();
60
+ setNotice("Ripristinata la release precedente. Le bozze sono conservate.");
61
+ }
62
+ catch (error) {
63
+ setNotice(error.message);
64
+ }
65
+ finally {
66
+ setPublishing(false);
67
+ }
68
+ };
69
+ const verify = async () => {
70
+ if (!activeReleaseId)
71
+ return;
72
+ try {
73
+ const result = await api.contentVerification(activeReleaseId);
74
+ setNotice(result.publicOriginVerified ? "Release verificata sul dominio pubblico." : result.reason);
75
+ }
76
+ catch (error) {
77
+ setNotice(error.message);
78
+ }
79
+ };
28
80
  const reload = useCallback(async () => {
29
81
  if (contentPersistenceMode() === "d1") {
30
82
  try {
31
83
  const d = await api.contentPublishState();
84
+ setActiveReleaseId(d.activeReleaseId ?? null);
85
+ setPreviousReleaseId(d.previousReleaseId ?? null);
32
86
  setAhead(d.ahead ?? 0);
33
87
  setDocuments(d.documents ?? []);
34
88
  setSiteAccessPending(Boolean(d.siteAccess));
@@ -78,7 +132,10 @@ export function usePublish() {
78
132
  try {
79
133
  if (!target)
80
134
  throw new ApiError("Apri il contenuto che vuoi pubblicare.", "invalid_request", 400);
81
- const r = await api.contentPublish(target);
135
+ if (site().directPublication && !previewApproved(target))
136
+ throw new ApiError("Apri una nuova anteprima della bozza prima di pubblicare.", "stale_preview", 409);
137
+ const r = await api.contentPublish(site().directPublication ? { ...target, ...approval } : target);
138
+ setApproval(null);
82
139
  setNotice(contentPublicationNotice(r));
83
140
  await reload();
84
141
  }
@@ -113,9 +170,9 @@ export function usePublish() {
113
170
  finally {
114
171
  setPublishing(false);
115
172
  }
116
- }, [reload]);
173
+ }, [reload, approval, activeReleaseId]);
117
174
  return {
118
175
  ahead, commits, documents, siteAccessPending, configured, publishing, notice, error,
119
- reload, publish, clearNotice: () => setNotice(null),
176
+ reload, publish, preview, previewApproved, previousReleaseId, rollback, verify, clearNotice: () => setNotice(null),
120
177
  };
121
178
  }
@@ -1,3 +1,4 @@
1
+ import { site } from "../site.js";
1
2
  /* Le richieste dal form di contatto.
2
3
  *
3
4
  * L'archivio è la SORGENTE, non l'email: `functions/api/contact.js` salva PRIMA e manda dopo, e
@@ -15,6 +16,11 @@ export function useRequests() {
15
16
  const [error, setError] = useState(null);
16
17
  const [busy, setBusy] = useState(false);
17
18
  const load = useCallback(async () => {
19
+ if (site().modules?.requests !== true) {
20
+ setRows([]);
21
+ setError(null);
22
+ return;
23
+ }
18
24
  try {
19
25
  setRows((await api.requests()).requests);
20
26
  setError(null);
@@ -4,7 +4,7 @@
4
4
  * protegge il sito. La password digitata resta soltanto nello stato del form: il backend
5
5
  * restituisce sempre il campo vuoto e conserva esclusivamente la verifica crittografica.
6
6
  */
7
- import { useCallback, useEffect, useState } from "react";
7
+ import { useCallback, useEffect, useState, useRef } from "react";
8
8
  import { ApiError } from "../../index.js";
9
9
  import { api } from "../../api.js";
10
10
  import { contentPersistenceMode, site } from "../site.js";
@@ -43,6 +43,7 @@ export function useSiteSettings(active) {
43
43
  const { password: _password, passwordEnabled: _passwordEnabled, ...editorial } = settings;
44
44
  return JSON.stringify(editorial);
45
45
  };
46
+ const pending = useRef(null);
46
47
  const load = useCallback(async () => {
47
48
  try {
48
49
  const mapped = site().siteSettingsDocument;
@@ -88,9 +89,11 @@ export function useSiteSettings(active) {
88
89
  const mapped = site().siteSettingsDocument;
89
90
  if (contentPersistenceMode() === "d1" && mapped && revision) {
90
91
  if (editorialSnapshot(data) !== editorialBaseline) {
91
- const revisionId = crypto.randomUUID();
92
+ const revisionId = pending.current?.revisionId ?? crypto.randomUUID();
92
93
  const payload = mapped.toPayload ? mapped.toPayload(data) : data;
93
- await api.saveContent({ revisionId, documentId: mapped.documentId, baseRevisionId: revision.base, expectedDraftRevisionId: revision.expected, kind: mapped.kind, locale: mapped.locale, schemaVersion: mapped.schemaVersion, payload: payload, tombstone: false });
94
+ pending.current ||= { revisionId, documentId: mapped.documentId, baseRevisionId: revision.base, expectedDraftRevisionId: revision.expected, kind: mapped.kind, locale: mapped.locale, schemaVersion: mapped.schemaVersion, payload: payload, tombstone: false };
95
+ await api.saveContent(pending.current);
96
+ pending.current = null;
94
97
  setRevision({ base: revisionId, expected: revisionId });
95
98
  setEditorialBaseline(editorialSnapshot(data));
96
99
  }
@@ -124,6 +127,7 @@ export function useSiteSettings(active) {
124
127
  }
125
128
  }, [data, revision, accessState, editorialBaseline]);
126
129
  const change = useCallback((next) => {
130
+ pending.current = null;
127
131
  setData(next);
128
132
  setDirty(true);
129
133
  }, []);
@@ -9,4 +9,4 @@ export declare function RowMenu({ page, pages, onChange, onRemove, onDuplicate,
9
9
  onChange: (next: PageModel) => void;
10
10
  onRemove: () => void;
11
11
  onDuplicate: () => void;
12
- }): import("react").JSX.Element;
12
+ }): import("react").JSX.Element | null;
@@ -32,7 +32,7 @@ import { Icon } from "../ui/icons";
32
32
  * modulo andava bene: è il genere di cosa che cambia quando una dipendenza smette di essere
33
33
  * statica e diventa iniettata. */
34
34
  function tipiDisponibili() {
35
- const tipi = Object.entries(site().pages).map(([value, page]) => ({ value, label: page.label }));
35
+ const tipi = Object.entries(site().pages).filter(([, page]) => page.creatable !== false).map(([value, page]) => ({ value, label: page.label }));
36
36
  /* La preselezione: "Pagina" se c'è (è la scelta di gran lunga più frequente), altrimenti la
37
37
  * prima opzione — così questo elenco non può restare senza un valore valido. */
38
38
  const iniziale = tipi.some((t) => t.value === "page") ? "page" : (tipi[0]?.value ?? "page");
@@ -56,6 +56,7 @@ export function NewPageButton({ pages, onCreate }) {
56
56
  isHome: false,
57
57
  inMenu: false,
58
58
  r2Prefix: "",
59
+ ...site().pages[type]?.create?.(t, uniqueSlug(t, pages.map((p) => p.slug))),
59
60
  _orig: null, // pagina nuova: nessuna sorgente da cui conservare
60
61
  });
61
62
  setTitle("");
@@ -73,10 +74,12 @@ export function RowMenu({ page, pages, onChange, onRemove, onDuplicate, }) {
73
74
  const [open, setOpen] = useState(false);
74
75
  const [renaming, setRenaming] = useState(false);
75
76
  const [title, setTitle] = useState(String(page.title ?? ""));
77
+ if (site().pages[String(page.type)]?.fixed)
78
+ return null;
76
79
  const taken = pages.filter((p) => p.slug !== page.slug).map((p) => p.slug);
77
80
  const nuovo = slugify(title);
78
81
  const cambia = nuovo !== page.slug;
79
- return (_jsxs("span", { className: "pa", children: [_jsx("button", { className: "pa-more", onClick: () => { setOpen(!open); setRenaming(false); }, title: "Altro", children: _jsx(Icon.more, {}) }), open && !renaming && (_jsxs("div", { className: "pa-menu", onMouseLeave: () => setOpen(false), children: [_jsx("button", { onClick: () => { setTitle(String(page.title ?? "")); setRenaming(true); }, children: "Rinomina\u2026" }), _jsx("button", { onClick: () => { onChange({ ...page, inMenu: !page.inMenu }); setOpen(false); }, children: page.inMenu ? "Togli dal menu" : "Aggiungi al menu" }), _jsx("button", { onClick: () => { onChange({ ...page, published: page.published === false }); setOpen(false); }, children: page.published === false ? "Pubblica" : "Nascondi" }), _jsx("button", { onClick: () => { onDuplicate(); setOpen(false); }, children: "Duplica" }), _jsx("button", { className: "pa-danger", onClick: () => {
82
+ return (_jsxs("span", { className: "pa", children: [_jsx("button", { className: "pa-more", onClick: () => { setOpen(!open); setRenaming(false); }, title: "Altro", children: _jsx(Icon.more, {}) }), open && !renaming && (_jsxs("div", { className: "pa-menu", onMouseLeave: () => setOpen(false), children: [_jsx("button", { onClick: () => { setTitle(String(page.title ?? "")); setRenaming(true); }, children: "Rinomina\u2026" }), site().pages[String(page.type)]?.menuEditable !== false && _jsx("button", { onClick: () => { onChange({ ...page, inMenu: !page.inMenu }); setOpen(false); }, children: page.inMenu ? "Togli dal menu" : "Aggiungi al menu" }), _jsx("button", { onClick: () => { onChange({ ...page, published: page.published === false }); setOpen(false); }, children: page.published === false ? "Pubblica" : "Nascondi" }), _jsx("button", { onClick: () => { onDuplicate(); setOpen(false); }, children: "Duplica" }), _jsx("button", { className: "pa-danger", onClick: () => {
80
83
  if (confirm(`Eliminare "${page.title}"?\n\nLa pagina e il suo testo spariscono dal sito al prossimo salvataggio. ` +
81
84
  `Le foto restano nel bucket.`))
82
85
  onRemove();
@@ -69,14 +69,15 @@ export function PageHeader({ page, pages, onChange, seoActive, onSeoToggle, lang
69
69
  });
70
70
  setTitleOpen(false);
71
71
  };
72
+ const fixed = pageType(page.type)?.fixed === true;
72
73
  const online = page.published !== false;
73
74
  const translationActive = !!languages && activeLanguage !== languages.default;
74
- return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "pe-head-main", children: [_jsx("button", { type: "button", className: "pe-title", title: "Modifica nome e indirizzo", disabled: translationActive, onClick: () => {
75
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "pe-head-main", children: [_jsx("button", { type: "button", className: "pe-title", title: "Modifica nome e indirizzo", disabled: translationActive || fixed, onClick: () => {
75
76
  setTitle(String(page.title ?? ""));
76
77
  setSlug(page.slug);
77
78
  setVisibilityOpen(false);
78
79
  setTitleOpen(true);
79
- }, children: String(page.title ?? page.slug) }), languages && (_jsx("div", { className: "pe-language", "aria-label": "Lingua del contenuto", children: languages.available.map((language) => (_jsx("button", { type: "button", className: activeLanguage === language ? "is-active" : "", "aria-pressed": activeLanguage === language, onClick: () => onLanguageChange?.(language), children: language.toUpperCase() }, language))) })), _jsxs("button", { type: "button", className: "pe-seo-button" + (seoActive ? " is-active" : ""), "aria-pressed": seoActive, disabled: translationActive, onClick: onSeoToggle, children: [_jsx(Icon.search, {}), "SEO"] }), _jsxs("div", { className: "pe-visibility", ref: visibilityRef, children: [_jsxs("button", { type: "button", className: "pe-status" + (online ? " is-online" : " is-offline"), "aria-haspopup": "menu", "aria-expanded": visibilityOpen, disabled: translationActive, onClick: () => setVisibilityOpen((open) => !open), children: [_jsx("span", { className: "pe-status-dot" }), online ? "Online" : "Offline", _jsx("span", { className: "pe-chevron", "aria-hidden": "true", children: "\u2304" })] }), visibilityOpen && (_jsxs("div", { className: "pe-status-menu", role: "menu", children: [_jsx("span", { className: "pe-status-label", children: "Visibilit\u00E0 della pagina" }), _jsxs("button", { type: "button", className: online ? "is-selected" : "", onClick: () => {
80
+ }, children: String(page.title ?? page.slug) }), languages && (_jsx("div", { className: "pe-language", "aria-label": "Lingua del contenuto", children: languages.available.map((language) => (_jsx("button", { type: "button", className: activeLanguage === language ? "is-active" : "", "aria-pressed": activeLanguage === language, onClick: () => onLanguageChange?.(language), children: language.toUpperCase() }, language))) })), _jsxs("button", { type: "button", className: "pe-seo-button" + (seoActive ? " is-active" : ""), "aria-pressed": seoActive, disabled: translationActive, onClick: onSeoToggle, children: [_jsx(Icon.search, {}), "SEO"] }), _jsxs("div", { className: "pe-visibility", ref: visibilityRef, children: [_jsxs("button", { type: "button", className: "pe-status" + (online ? " is-online" : " is-offline"), "aria-haspopup": "menu", "aria-expanded": visibilityOpen, disabled: translationActive || fixed, onClick: () => setVisibilityOpen((open) => !open), children: [_jsx("span", { className: "pe-status-dot" }), online ? "Online" : "Offline", _jsx("span", { className: "pe-chevron", "aria-hidden": "true", children: "\u2304" })] }), visibilityOpen && (_jsxs("div", { className: "pe-status-menu", role: "menu", children: [_jsx("span", { className: "pe-status-label", children: "Visibilit\u00E0 della pagina" }), _jsxs("button", { type: "button", className: online ? "is-selected" : "", onClick: () => {
80
81
  if (!online)
81
82
  onChange({ ...page, published: true });
82
83
  setVisibilityOpen(false);
@@ -84,7 +85,7 @@ export function PageHeader({ page, pages, onChange, seoActive, onSeoToggle, lang
84
85
  if (online)
85
86
  onChange({ ...page, published: false });
86
87
  setVisibilityOpen(false);
87
- }, children: [_jsx("span", { className: "pe-radio", children: _jsx("span", {}) }), _jsxs("span", { children: [_jsx("strong", { children: "Offline" }), _jsx("small", { children: "La pagina non \u00E8 visibile sul sito." })] })] })] }))] }), _jsxs("label", { className: "pe-menu-toggle", children: [_jsx("input", { type: "checkbox", checked: !!page.inMenu, disabled: translationActive, onChange: (event) => onChange({ ...page, inMenu: event.target.checked }) }), _jsx("span", { children: "Aggiungi al menu" })] })] }), titleOpen && (_jsx("div", { className: "pe-modal", role: "presentation", onMouseDown: (event) => {
88
+ }, children: [_jsx("span", { className: "pe-radio", children: _jsx("span", {}) }), _jsxs("span", { children: [_jsx("strong", { children: "Offline" }), _jsx("small", { children: "La pagina non \u00E8 visibile sul sito." })] })] })] }))] }), pageType(page.type)?.menuEditable !== false && _jsxs("label", { className: "pe-menu-toggle", children: [_jsx("input", { type: "checkbox", checked: !!page.inMenu, disabled: translationActive || fixed, onChange: (event) => onChange({ ...page, inMenu: event.target.checked }) }), _jsx("span", { children: "Aggiungi al menu" })] })] }), titleOpen && (_jsx("div", { className: "pe-modal", role: "presentation", onMouseDown: (event) => {
88
89
  if (event.target === event.currentTarget)
89
90
  setTitleOpen(false);
90
91
  }, children: _jsxs("div", { className: "pe-modal-card", role: "dialog", "aria-modal": "true", "aria-labelledby": "pe-title-dialog", children: [_jsxs("header", { children: [_jsx("h2", { id: "pe-title-dialog", children: "Modifica pagina" }), _jsx("button", { type: "button", onClick: () => setTitleOpen(false), "aria-label": "Chiudi", children: "\u00D7" })] }), _jsxs("div", { className: "pe-modal-fields", children: [_jsxs("label", { children: [_jsx("span", { children: "Nome pagina" }), _jsx("input", { autoFocus: true, value: title, onChange: (event) => setTitle(event.target.value), onKeyDown: (event) => {
@@ -340,7 +341,8 @@ export function PageEditor({ page, onChange, onRefresh, pristine, pickMedia, pic
340
341
  ...(translationActive ? [] : ["title"]),
341
342
  "published", "inMenu",
342
343
  "seoTitle", "seoDescription", "ogTitle", "ogDescription", "ogImage",
343
- ], [translationActive]);
344
+ ...(definition?.seoShape ? ["seo"] : []),
345
+ ], [translationActive, definition]);
344
346
  /* Come la gallery: una pagina portfolio È la sua selezione di lavori, quindi la schermata
345
347
  mostra quella e basta. Il `SchemaForm` generico qui elencava anche «Foto», «Edizioni»,
346
348
  «Ritratto» e «Riconoscimenti» — campi di altri tipi di pagina, che compaiono perché il
@@ -8,7 +8,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  import { useEffect, useState } from "react";
9
9
  import { api } from "../../index.js";
10
10
  import { Icon } from "../ui/icons";
11
- import { site } from "../site.js";
11
+ import { SchemaForm } from "../../form/index.js";
12
+ import { pageType, site } from "../site.js";
12
13
  /* ⚠️ Non costanti di modulo: `site()` è disponibile solo dopo `mountAdmin`, e un `const` qui
13
14
  * verrebbe valutato all'import — cioè prima. Si leggono dentro il componente.
14
15
  *
@@ -93,6 +94,10 @@ export function PageSeoEditor({ page, onChange, onRefresh, pristine, pickMediaFi
93
94
  const socialImage = explicitImage || automaticImage(page);
94
95
  const pageUrl = `${site().publicBase}/${page.slug}/`;
95
96
  const complete = !!seoTitle.trim() && !!seoDescription.trim() && !!socialImage;
97
+ const customShape = pageType(page.type)?.seoShape;
98
+ if (customShape)
99
+ return _jsxs("section", { className: "seo-editor", children: [_jsx("h2", { children: "SEO e GEO" }), loadError && _jsx("p", { className: "ak-error", children: "Non sono riuscito a rileggere la pagina." }), _jsx(SchemaForm, { shape: customShape, value: (page.seo ?? {}), onChange: (seo) => { if (!readOnly)
100
+ onChange({ ...page, seo }); }, pickMediaFiles: pickMediaFiles })] });
96
101
  const chooseImage = async () => {
97
102
  const selected = await pickMediaFiles?.({ multiple: false });
98
103
  if (selected?.[0])
@@ -14,6 +14,7 @@ import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
14
14
  import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
15
15
  import { KeyboardSensor } from "@dnd-kit/core";
16
16
  import { CSS } from "@dnd-kit/utilities";
17
+ import { site } from "../site.js";
17
18
  import { RowMenu } from "./PageActions";
18
19
  import { uniqueSlug } from "../lib/slug";
19
20
  import { Icon } from "../ui/icons";
@@ -23,7 +24,7 @@ const ICONS = {
23
24
  function Row({ p, pages, current, onEdit, onChange, onRemove, onDuplicate, languages }) {
24
25
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: p.slug });
25
26
  const TypeIcon = ICONS[String(p.type)] ?? Icon.doc;
26
- return (_jsxs("li", { ref: setNodeRef, className: "pg-row" + (isDragging ? " is-dragging" : "") + (p.slug === current ? " is-on" : ""), style: { transform: CSS.Transform.toString(transform), transition }, children: [_jsx("button", { className: "pg-handle", ...attributes, ...listeners, title: "Trascina per riordinare", children: _jsx(Icon.grip, {}) }), _jsxs("button", { className: "pg-open", onClick: () => onEdit(p.slug), children: [_jsx("span", { className: "pg-icon", children: _jsx(TypeIcon, {}) }), _jsx("span", { className: "pg-title", children: String(p.title ?? p.slug) }), languages?.available.filter((language) => language !== languages.default).map((language) => {
27
+ return (_jsxs("li", { ref: setNodeRef, className: "pg-row" + (isDragging ? " is-dragging" : "") + (p.slug === current ? " is-on" : ""), style: { transform: CSS.Transform.toString(transform), transition }, children: [_jsx("button", { disabled: site().pagesReorderable === false, className: "pg-handle", ...attributes, ...listeners, title: "Trascina per riordinare", children: _jsx(Icon.grip, {}) }), _jsxs("button", { className: "pg-open", onClick: () => onEdit(p.slug), children: [_jsx("span", { className: "pg-icon", children: _jsx(TypeIcon, {}) }), _jsx("span", { className: "pg-title", children: String(p.title ?? p.slug) }), languages?.available.filter((language) => language !== languages.default).map((language) => {
27
28
  const saved = p.translations?.[language]?.exists;
28
29
  const draft = !!p._translations?.[language];
29
30
  return (_jsx("span", { className: "pg-language" + (saved ? " is-saved" : draft ? " is-draft" : " is-inherited"), title: saved ? `${language.toUpperCase()} tradotta` : draft
@@ -37,6 +38,8 @@ function Group({ title, items, all, current, onEdit, onReorder, onChange, onRemo
37
38
  const { active, over } = e;
38
39
  if (!over || active.id === over.id)
39
40
  return;
41
+ if (site().pagesReorderable === false)
42
+ return;
40
43
  onReorder(items.findIndex((p) => p.slug === active.id), items.findIndex((p) => p.slug === over.id));
41
44
  };
42
45
  return (_jsxs("section", { className: "pg-group", children: [_jsx("h2", { className: "pg-group-title", children: title }), items.length === 0 && _jsx("p", { className: "pg-empty", children: "nessuna" }), _jsx(DndContext, { sensors: sensors, collisionDetection: closestCenter, modifiers: [restrictToVerticalAxis], onDragEnd: onDragEnd, children: _jsx(SortableContext, { items: items.map((p) => p.slug), strategy: verticalListSortingStrategy, children: _jsx("ul", { className: "pg-list", children: items.map((p) => (_jsx(Row, { p: p, pages: all, current: current, onEdit: onEdit, onChange: onChange, languages: languages, onRemove: () => onRemove(p.slug), onDuplicate: () => onDuplicate(p.slug) }, p.slug))) }) }) })] }));
@@ -73,6 +76,7 @@ export function PagesList({ pages, current, onEdit, onChange, onRenamed, languag
73
76
  inMenu: false, // una copia non entra nel menu da sola
74
77
  published: false, // né va online prima che qualcuno l'abbia guardata
75
78
  _orig: null, // pagina nuova: niente da cui conservare
79
+ _sha: undefined, _documentId: undefined, _draftRevisionId: null, _publishedRevisionId: null,
76
80
  };
77
81
  const i = pages.indexOf(src);
78
82
  onChange([...pages.slice(0, i + 1), copia, ...pages.slice(i + 1)]);
@@ -10,6 +10,12 @@ export interface PageTypeConfig {
10
10
  object: z.ZodType;
11
11
  payload: z.ZodType;
12
12
  hasBody: boolean;
13
+ /** Fixed routes cannot be renamed, hidden, duplicated or removed. */
14
+ fixed?: boolean;
15
+ menuEditable?: boolean;
16
+ creatable?: boolean;
17
+ create?: (title: string, slug: string) => Record<string, unknown>;
18
+ seoShape?: Shape;
13
19
  }
14
20
  /** Lingue dei contenuti, assenti nei siti monolingui. I codici sono quelli dei sibling Hugo
15
21
  * (`index.md`, `index.en.md`): la lingua predefinita non porta alcun suffisso. */
@@ -39,6 +45,13 @@ export interface SiteAdminConfig {
39
45
  languages?: LanguagesConfig;
40
46
  /** Forma del singleton home: i suoi `group` diventano le voci della seconda colonna. */
41
47
  home: Shape;
48
+ /** Schema-owned fields rendered inside the native Settings surface. */
49
+ settingsShape?: Shape;
50
+ pagesReorderable?: boolean;
51
+ /** Optional storage-policy surface inside the native Media pane. */
52
+ mediaEditor?: ComponentType;
53
+ /** Preview approval, public observation and rollback for direct SSR publishing. */
54
+ directPublication?: boolean;
42
55
  /** Optional site-owned Home surface. The kit keeps its legacy visual editor
43
56
  * as a fallback, while a site with a different document shape can render its
44
57
  * own schema-driven editor without teaching the shared package its fields. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardinali-studio/adminkit",
3
- "version": "2.2.33",
3
+ "version": "2.2.34",
4
4
  "type": "module",
5
5
  "description": "Motore riusabile di un pannello di amministrazione per siti Hugo su Cloudflare Pages: codec del front matter guidato dallo schema, form React, libreria media su R2. NIENTE di specifico di un sito qui dentro.",
6
6
  "license": "0BSD",