@cardinali-studio/adminkit 2.2.33 → 2.2.35

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,24 @@ export declare const api: {
204
206
  enabled: boolean;
205
207
  } | null;
206
208
  activeReleaseId?: string | null;
209
+ previousReleaseId?: string | null;
210
+ verified?: boolean;
211
+ }>;
212
+ contentPreview: (target: ContentDocumentPublishTarget) => Promise<{
213
+ ok: true;
214
+ previewUrl: string;
215
+ expectedBaseReleaseId: string;
216
+ expectedDraftRevisionId: string;
217
+ previewChecksum: string;
218
+ }>;
219
+ contentRollback: (expectedCurrentReleaseId: string, targetReleaseId: string) => Promise<{
220
+ ok: true;
221
+ releaseId: string;
222
+ }>;
223
+ contentVerification: (releaseId: string) => Promise<{
224
+ ok: true;
225
+ publicOriginVerified: boolean;
226
+ reason: string;
207
227
  }>;
208
228
  contentPublish: (target: ContentPublishTarget) => Promise<{
209
229
  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
@@ -14,14 +14,16 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
14
14
  * separati (una collezione e un singleton), ma da qui dentro sono lo stesso gesto e non c'è
15
15
  * ragione di farli sembrare due funzioni diverse.
16
16
  */
17
- import { useEffect, useMemo, useState } from "react";
17
+ import { useEffect, useMemo, useState, useRef } from "react";
18
18
  /* L'ordine di questi tre import È la separazione motore/cliente, e non va scambiato:
19
19
  * i default del kit, poi l'identità di questo sito che li sovrascrive, poi l'impaginazione. */
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";
@@ -59,6 +61,8 @@ export function App() {
59
61
  }
60
62
  function CurrentAdminApp({ adminInterface, setAdminInterface, }) {
61
63
  const [route, go] = useHashRoute();
64
+ const [releaseOpen, setReleaseOpen] = useState(false);
65
+ const [rollbackConfirm, setRollbackConfirm] = useState(false);
62
66
  const { theme, toggle: toggleTheme } = useTheme();
63
67
  /* Si entra dalla Panoramica, non dalle Pagine: aprendo il pannello la domanda è quasi sempre
64
68
  «è successo qualcosa?», e la risposta stava sparsa in tre sezioni da visitare una per una. */
@@ -71,6 +75,7 @@ function CurrentAdminApp({ adminInterface, setAdminInterface, }) {
71
75
  const pages = usePages();
72
76
  const home = useHome(section === "home");
73
77
  const HomeEditorView = site().homeEditor ?? HomeEditor;
78
+ const MediaEditorView = site().mediaEditor;
74
79
  /* Anche la Panoramica: le sue card devono sapere quali funzioni aggiuntive sono attive, e
75
80
  lo stato è quello — non una seconda copia. È una lettura sola, e la Panoramica è la
76
81
  schermata da cui si entra, quindi arriva prima che serva. */
@@ -78,7 +83,7 @@ function CurrentAdminApp({ adminInterface, setAdminInterface, }) {
78
83
  /* La libreria media vive QUI, non dentro un componente: le cartelle stanno nella seconda
79
84
  colonna e la griglia nella terza, e sono due punti dello schermo lontani. Uno stato per
80
85
  ciascuno vorrebbe dire due idee diverse di quale cartella si sta guardando. */
81
- const media = useMediaLibrary({ mode: "manage", active: section === "media" });
86
+ const media = useMediaLibrary({ mode: "manage", active: section === "media" && !MediaEditorView });
82
87
  const mediaSettingsOpen = view === "media" && route[1] === "settings";
83
88
  /* Le richieste si caricano SUBITO, non entrando nella sezione: il numero «da gestire» nella
84
89
  prima colonna è il motivo per cui uno ci entra, e un badge che compare solo dopo essere
@@ -96,6 +101,20 @@ function CurrentAdminApp({ adminInterface, setAdminInterface, }) {
96
101
  finito uno, non all'avanzamento — altrimenti il pannello si ridisegnerebbe due volte al
97
102
  secondo per tutta la durata dell'upload. */
98
103
  const uploadCompletions = useUploadCompletions();
104
+ useEffect(() => { if (site().directPublication && section === "media")
105
+ void publish.reload(); }, [media.mediaOperations]);
106
+ const lastObservedRelease = useRef(null);
107
+ useEffect(() => {
108
+ if (lastObservedRelease.current && publish.activeReleaseId && lastObservedRelease.current !== publish.activeReleaseId) {
109
+ if (!pages.dirty)
110
+ void pages.load();
111
+ if (home.data && !home.dirty)
112
+ void home.load();
113
+ if (settings.data && !settings.dirty)
114
+ void settings.load();
115
+ }
116
+ lastObservedRelease.current = publish.activeReleaseId;
117
+ }, [publish.activeReleaseId]);
99
118
  useEffect(() => {
100
119
  if (section === "deliveries")
101
120
  void deliveries.load();
@@ -258,7 +277,7 @@ function CurrentAdminApp({ adminInterface, setAdminInterface, }) {
258
277
  || (section === "requests" && !openReq)
259
278
  || (section === "bookings" && !openBooking)
260
279
  || (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 })
280
+ 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: [site().directPublication && _jsxs("div", { className: "ak-release-control", children: [_jsxs("button", { className: "ak-release-trigger", "aria-haspopup": "dialog", "aria-expanded": releaseOpen, onClick: () => { setReleaseOpen(!releaseOpen); setRollbackConfirm(false); }, children: [_jsx("span", { className: "ak-release-dot" + (publish.verified ? " is-ok" : "") }), publish.configured ? (publish.verified ? "Online" : "In attesa") : "Non disponibile"] }), releaseOpen && _jsxs("section", { className: "ak-release-menu", role: "dialog", "aria-label": "Stato della pubblicazione", children: [_jsx("strong", { children: publish.verified ? "Online" : "Verifica in attesa" }), _jsx("p", { children: publish.verified ? "Il sito mostra gli ultimi contenuti pubblicati." : "I contenuti sono salvati in D1; verifica la versione visibile sul dominio pubblico." }), _jsx("dl", { children: _jsxs("div", { children: [_jsx("dt", { children: "Versione" }), _jsx("dd", { children: publish.activeReleaseId?.slice(0, 8) || "—" })] }) }), _jsx("button", { className: "ak-btn", onClick: () => void publish.verify(), children: "Verifica sito pubblico" }), publish.previousReleaseId && !rollbackConfirm && _jsx("button", { className: "ak-btn", disabled: publish.publishing || pages.dirty || home.dirty || settings.dirty, onClick: () => setRollbackConfirm(true), children: "Ripristina la versione precedente" }), rollbackConfirm && _jsxs("div", { children: [_jsx("p", { children: "Ripristinare la versione precedente? Le bozze restano salvate." }), _jsx("button", { className: "ak-btn ak-btn-primary", disabled: publish.publishing, onClick: () => void publish.rollback().then(async () => { await pages.load(); await home.load(); await settings.load(); setRollbackConfirm(false); }), children: "Conferma ripristino" }), _jsx("button", { className: "ak-btn", onClick: () => setRollbackConfirm(false), children: "Annulla" })] }), _jsx("button", { className: "ak-btn ak-btn-quiet", onClick: () => setReleaseOpen(false), children: "Chiudi" })] })] }), _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
281
  : !list ? _jsx("p", { className: "ak-hint", children: "Carico\u2026" })
263
282
  : _jsx(PagesList, { pages: list, current: editing?.slug, onEdit: (slug) => go(`/pages/${slug}`), languages: languages, onChange: pages.update, onRenamed: (from, to) => {
264
283
  if (editing?.slug === from)
@@ -283,13 +302,23 @@ function CurrentAdminApp({ adminInterface, setAdminInterface, }) {
283
302
  : deliveries.rows.length === 0 ? _jsx("p", { className: "ak-hint", children: "Nessun transfer, per ora." })
284
303
  : _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
304
  : !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,
305
+ : _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
306
  /* Dopo un salvataggio il conteggio delle modifiche in attesa è cambiato: senza
288
307
  rileggerlo, «Pubblica» comparirebbe solo al ricaricamento della pagina — cioè
289
308
  il pannello direbbe che non c'è niente da pubblicare subito dopo aver salvato
290
309
  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…"
310
+ 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")
311
+ await pages.load(); if (section === "home")
312
+ await home.load(); if (section === "settings")
313
+ await settings.load(); await publish.reload(); }), children: "Scarta bozza" })] }), ((contentPersistenceMode() === "d1" && d1ContextPending)
314
+ || (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) {
315
+ if (section === "pages")
316
+ await pages.load();
317
+ if (section === "home")
318
+ await home.load();
319
+ if (section === "settings")
320
+ await settings.load();
321
+ } }), title: publish.commits.map((c) => c.message).join("\n"), children: publish.publishing ? "Pubblico…"
293
322
  : contentPersistenceMode() === "d1" ? contextualPublishLabel
294
323
  : publish.ahead === 1 ? "Pubblica (1 modifica)"
295
324
  : `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 +333,9 @@ function CurrentAdminApp({ adminInterface, setAdminInterface, }) {
304
333
  se non ricaricando il pannello a mano. Le altre sezioni il bottone ce l'hanno. */
305
334
  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
335
  : 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"
336
+ : _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
337
  ? _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
338
+ : _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
339
  ? _jsx(MediaSettingsEditor, { lib: media })
311
340
  : _jsx(MediaGrid, { lib: media })), view === "trash" && _jsx(Trash, {}), section === "requests" && (requests.error ? _jsx("p", { className: "ak-error", children: requests.error })
312
341
  : openReq ? (_jsx(RequestDetail, { row: openReq, onMark: (h) => void requests.mark([openReq.id], h), onRemove: () => { void requests.remove([openReq.id]); go("/requests"); } }))
@@ -0,0 +1,7 @@
1
+ type BugReportOptions = {
2
+ siteName: string;
3
+ email: string;
4
+ whatsapp: string;
5
+ };
6
+ export declare function mountBugReport(options: BugReportOptions): void;
7
+ export {};
@@ -0,0 +1,212 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { domToBlob } from "modern-screenshot";
3
+ import { StrictMode, useEffect, useRef, useState } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ function BugIcon() {
6
+ return _jsxs("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", children: [_jsx("path", { d: "M9 4.5h6M10 2.5v2m4-2v2M7.5 9H4m3.5 4H4m12.5-4H20m-3.5 4H20M8 6.5h8v7.25a4 4 0 0 1-8 0V6.5Z" }), _jsx("path", { d: "M8 10.5h8M12 10.5v7" })] });
7
+ }
8
+ function DownloadIcon() {
9
+ return _jsx("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M12 3v11m0 0 4-4m-4 4-4-4M5 17v3h14v-3" }) });
10
+ }
11
+ function CloseIcon() {
12
+ return _jsx("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "m6 6 12 12M18 6 6 18" }) });
13
+ }
14
+ function MailIcon() {
15
+ return _jsx("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { d: "M3.5 6.5h17v12h-17zM4 7l8 6 8-6" }) });
16
+ }
17
+ function WhatsAppIcon() {
18
+ return _jsxs("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", children: [_jsx("path", { d: "M5.2 18.8 3.8 22l3.5-1.2A9 9 0 1 0 5.2 18.8Z" }), _jsx("path", { d: "M9 7.7c.2-.5.5-.5.8-.5h.5c.2 0 .4.1.5.4l.8 1.9c.1.3 0 .5-.2.7l-.6.7c-.2.2-.1.4 0 .6.7 1.2 1.6 2.1 2.8 2.7.2.1.4.1.6-.1l.8-1c.2-.2.4-.3.7-.2l1.8.9c.3.1.4.3.4.5 0 .4-.2 1.5-1.1 2.1-.8.5-1.8.7-3.4.2-1.2-.4-2.7-1.1-4.2-2.7-1.2-1.2-2.1-2.8-2.4-4-.3-1.2.2-1.9.5-2.2.3-.4.6-.5.8-.5" })] });
19
+ }
20
+ function IconButton({ children, label, onClick, disabled = false }) {
21
+ return _jsx("button", { type: "button", className: "ak-bug-icon-button", "aria-label": label, title: label, onClick: onClick, disabled: disabled, children: children });
22
+ }
23
+ async function capturePage() {
24
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
25
+ const scale = Math.min(1, 1600 / window.innerWidth, 1200 / window.innerHeight);
26
+ const blob = await domToBlob(document.body, {
27
+ type: "image/webp",
28
+ quality: .68,
29
+ backgroundColor: getComputedStyle(document.body).backgroundColor,
30
+ scale,
31
+ width: window.innerWidth,
32
+ height: window.innerHeight,
33
+ filter: (node) => !(node instanceof Element && node.closest("#ak-bug-report-root")),
34
+ features: { restoreScrollPosition: true },
35
+ fetch: { requestInit: { cache: "force-cache", credentials: "same-origin" } },
36
+ });
37
+ if (!blob)
38
+ throw new Error("Screenshot non disponibile");
39
+ const extension = blob.type === "image/webp" ? "webp" : "jpg";
40
+ const day = new Date().toISOString().slice(0, 10);
41
+ return { blob, objectUrl: URL.createObjectURL(blob), filename: `bug-report-${day}.${extension}` };
42
+ }
43
+ function currentPageLabel() {
44
+ const hash = location.hash.replace(/^#\/?/, "");
45
+ return hash ? `${location.pathname}#/${hash}` : location.pathname;
46
+ }
47
+ function BugReport({ siteName, email, whatsapp }) {
48
+ const [enabled, setEnabled] = useState(() => document.documentElement.dataset.adminInterface === "next");
49
+ const [open, setOpen] = useState(false);
50
+ const [capturing, setCapturing] = useState(false);
51
+ const [sending, setSending] = useState(null);
52
+ const [message, setMessage] = useState("");
53
+ const [screenshot, setScreenshot] = useState(null);
54
+ const [uploaded, setUploaded] = useState(null);
55
+ const [error, setError] = useState(null);
56
+ const dialogRef = useRef(null);
57
+ const messageRef = useRef(null);
58
+ useEffect(() => {
59
+ const observer = new MutationObserver(() => {
60
+ setEnabled(document.documentElement.dataset.adminInterface === "next");
61
+ });
62
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-admin-interface"] });
63
+ return () => observer.disconnect();
64
+ }, []);
65
+ useEffect(() => () => {
66
+ if (screenshot)
67
+ URL.revokeObjectURL(screenshot.objectUrl);
68
+ }, [screenshot]);
69
+ /* Ogni apertura è una nuova segnalazione. Lo screenshot viene già rigenerato; anche il testo
70
+ * deve perdere il report precedente quando il dialog viene chiuso, qualunque sia il gesto. */
71
+ useEffect(() => {
72
+ if (!open)
73
+ setMessage("");
74
+ }, [open]);
75
+ useEffect(() => {
76
+ if (!open)
77
+ return;
78
+ const app = document.getElementById("root");
79
+ if (app)
80
+ app.inert = true;
81
+ messageRef.current?.focus();
82
+ const keydown = (event) => {
83
+ if (event.key === "Escape" && !sending)
84
+ setOpen(false);
85
+ if (event.key !== "Tab")
86
+ return;
87
+ const focusable = [...(dialogRef.current?.querySelectorAll("button:not(:disabled), a[href], textarea:not(:disabled)") || [])];
88
+ if (!focusable.length)
89
+ return;
90
+ const first = focusable[0];
91
+ const last = focusable[focusable.length - 1];
92
+ if (event.shiftKey && document.activeElement === first) {
93
+ event.preventDefault();
94
+ last.focus();
95
+ }
96
+ else if (!event.shiftKey && document.activeElement === last) {
97
+ event.preventDefault();
98
+ first.focus();
99
+ }
100
+ };
101
+ addEventListener("keydown", keydown);
102
+ return () => {
103
+ if (app)
104
+ app.inert = false;
105
+ removeEventListener("keydown", keydown);
106
+ };
107
+ }, [open, sending]);
108
+ const begin = async () => {
109
+ if (capturing)
110
+ return;
111
+ setCapturing(true);
112
+ setError(null);
113
+ setUploaded(null);
114
+ try {
115
+ const next = await capturePage();
116
+ setScreenshot(next);
117
+ setOpen(true);
118
+ }
119
+ catch {
120
+ setScreenshot(null);
121
+ setOpen(true);
122
+ setError("Non sono riuscito a creare lo screenshot. Riprova.");
123
+ }
124
+ finally {
125
+ setCapturing(false);
126
+ }
127
+ };
128
+ const retry = async () => {
129
+ setOpen(false);
130
+ await begin();
131
+ };
132
+ const upload = async () => {
133
+ if (uploaded)
134
+ return uploaded;
135
+ if (!screenshot)
136
+ throw new Error("Crea prima lo screenshot.");
137
+ const form = new FormData();
138
+ form.set("image", screenshot.blob, screenshot.filename);
139
+ form.set("site", siteName);
140
+ form.set("page", location.href);
141
+ form.set("title", document.title);
142
+ const response = await fetch("/admin/api/bug-report", { method: "POST", body: form });
143
+ const result = await response.json().catch(() => null);
144
+ if (!response.ok || !result?.url)
145
+ throw new Error(result?.error || "Non sono riuscito a caricare lo screenshot.");
146
+ setUploaded(result);
147
+ return result;
148
+ };
149
+ const reportText = (screenshotUrl) => [
150
+ message.trim(),
151
+ "",
152
+ `Screenshot: ${screenshotUrl}`,
153
+ `Pagina: ${currentPageLabel()}`,
154
+ `AdminKit · nuova interfaccia`,
155
+ `Data: ${new Intl.DateTimeFormat("it-IT", { dateStyle: "medium", timeStyle: "short" }).format(new Date())}`,
156
+ ].join("\n");
157
+ const send = async (channel) => {
158
+ if (!message.trim()) {
159
+ setError("Scrivi una breve descrizione del problema.");
160
+ messageRef.current?.focus();
161
+ return;
162
+ }
163
+ if (!screenshot) {
164
+ setError("Crea prima lo screenshot.");
165
+ return;
166
+ }
167
+ const whatsappWindow = channel === "whatsapp" ? window.open("", "_blank") : null;
168
+ if (whatsappWindow)
169
+ whatsappWindow.opener = null;
170
+ setSending(channel);
171
+ setError(null);
172
+ try {
173
+ const saved = await upload();
174
+ const text = reportText(saved.url);
175
+ if (channel === "email") {
176
+ const subject = `Report Bug ${siteName}`;
177
+ location.href = `mailto:${encodeURIComponent(email)}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(text)}`;
178
+ }
179
+ else {
180
+ const target = `https://wa.me/${whatsapp.replace(/\D/g, "")}?text=${encodeURIComponent(text)}`;
181
+ if (whatsappWindow)
182
+ whatsappWindow.location.replace(target);
183
+ else
184
+ window.open(target, "_blank", "noopener,noreferrer");
185
+ }
186
+ }
187
+ catch (reason) {
188
+ whatsappWindow?.close();
189
+ setError(reason instanceof Error ? reason.message : "Non sono riuscito a preparare il report.");
190
+ }
191
+ finally {
192
+ setSending(null);
193
+ }
194
+ };
195
+ if (!enabled)
196
+ return null;
197
+ return _jsxs(_Fragment, { children: [_jsxs("button", { type: "button", className: "ak-bug-trigger", "aria-label": "Segnala un bug", title: "Segnala un bug", onClick: () => void begin(), disabled: capturing, children: [_jsx(BugIcon, {}), capturing && _jsx("span", { className: "ak-bug-capturing", "aria-hidden": "true" })] }), open && _jsx("div", { className: "ak-bug-backdrop", onMouseDown: (event) => {
198
+ if (event.target === event.currentTarget && !sending)
199
+ setOpen(false);
200
+ }, children: _jsxs("div", { className: "ak-bug-dialog", ref: dialogRef, role: "dialog", "aria-modal": "true", "aria-labelledby": "ak-bug-title", children: [_jsxs("header", { className: "ak-bug-head", children: [_jsxs("div", { children: [_jsx("span", { className: "ak-bug-kicker", children: "Segnalazione" }), _jsx("h2", { id: "ak-bug-title", children: "Riporta un problema" }), _jsx("p", { children: "Descrivi cosa non ha funzionato. La schermata ci aiuter\u00E0 a capirlo." })] }), _jsx(IconButton, { label: "Chiudi", onClick: () => setOpen(false), disabled: Boolean(sending), children: _jsx(CloseIcon, {}) })] }), _jsxs("div", { className: "ak-bug-content", children: [_jsxs("section", { className: "ak-bug-shot-card", "aria-label": "Screenshot del problema", children: [_jsxs("div", { className: "ak-bug-shot-head", children: [_jsx("strong", { children: "Schermata" }), screenshot && _jsxs("a", { className: "ak-bug-download", href: screenshot.objectUrl, download: screenshot.filename, children: [_jsx(DownloadIcon, {}), " Scarica"] })] }), screenshot
201
+ ? _jsx("img", { src: screenshot.objectUrl, alt: "Anteprima della schermata segnalata" })
202
+ : _jsxs("div", { className: "ak-bug-shot-missing", children: [_jsx("span", { children: "Screenshot non disponibile" }), _jsx("button", { type: "button", onClick: () => void retry(), children: "Riprova" })] }), screenshot && _jsxs("small", { children: [Math.max(1, Math.round(screenshot.blob.size / 1024)), " KB \u00B7 il link scade dopo 30 giorni"] })] }), _jsxs("label", { className: "ak-bug-message", children: [_jsx("span", { children: "Cosa \u00E8 successo?" }), _jsx("textarea", { ref: messageRef, value: message, onChange: (event) => setMessage(event.target.value), placeholder: "Es. Ho premuto Salva, ma il titolo \u00E8 tornato come prima\u2026", rows: 8, maxLength: 3000 }), _jsxs("small", { children: [message.length, "/3000"] })] })] }), error && _jsx("p", { className: "ak-bug-error", role: "alert", children: error }), _jsxs("footer", { className: "ak-bug-actions", children: [_jsx("span", { children: sending ? "Carico lo screenshot e preparo il messaggio…" : "Lo screenshot sarà caricato solo quando invii." }), _jsxs("div", { children: [_jsxs("button", { type: "button", className: "ak-bug-send is-email", disabled: Boolean(sending) || !screenshot, onClick: () => void send("email"), children: [_jsx(MailIcon, {}), sending === "email" ? "Preparo…" : "Invia email"] }), whatsapp && _jsxs("button", { type: "button", className: "ak-bug-send is-whatsapp", disabled: Boolean(sending) || !screenshot, onClick: () => void send("whatsapp"), children: [_jsx(WhatsAppIcon, {}), sending === "whatsapp" ? "Preparo…" : "Invia WhatsApp"] })] })] })] }) })] });
203
+ }
204
+ export function mountBugReport(options) {
205
+ let host = document.getElementById("ak-bug-report-root");
206
+ if (!host) {
207
+ host = document.createElement("div");
208
+ host.id = "ak-bug-report-root";
209
+ document.body.append(host);
210
+ }
211
+ createRoot(host).render(_jsx(StrictMode, { children: _jsx(BugReport, { ...options }) }));
212
+ }
package/dist/app/app.css CHANGED
@@ -2229,3 +2229,127 @@ body:has(.uk) .pane-body { padding-bottom: 190px; }
2229
2229
  .ak-conflict-image { display: grid; gap: 6px; margin-top: 5px; }.ak-conflict-image img { width: 72px; height: 48px; object-fit: cover; border-radius: 3px; }.ak-conflict-image code { overflow-wrap: anywhere; font-size: 11px; }
2230
2230
  .ak-conflict-dialog footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
2231
2231
  @media (max-width: 620px) { .ak-conflict-options { grid-template-columns: 1fr; } .ak-conflict-backdrop { padding: 12px; } }
2232
+
2233
+ /* Direct SSR publication status lives with the global site controls. */
2234
+ .ak-release-control{position:relative;flex:none}.ak-release-trigger{min-width:104px;height:48px;display:inline-flex;align-items:center;justify-content:center;gap:9px;padding:0 14px;border:0;border-radius:14px;background:color-mix(in srgb,var(--ak-rail-bg) 74%,var(--ak-line));color:var(--ak-rail-ink-on);cursor:pointer;font:600 12px/1 var(--ak-font);white-space:nowrap}.ak-release-dot{width:8px;height:8px;border-radius:50%;background:#c68b3c}.ak-release-dot.is-ok{background:#35a66f}.ak-release-menu{position:absolute;z-index:100;top:calc(100% + 10px);right:0;width:min(330px,calc(100vw - 24px));display:flex;flex-direction:column;gap:14px;padding:18px;border:0;border-radius:18px;background:var(--ak-panel);color:var(--ak-ink);box-shadow:var(--ak-lift-3);font:400 13px/1.5 var(--ak-font)}.ak-release-menu p{margin:0;color:var(--ak-ink-quiet)}.ak-release-menu dl{margin:0;padding:12px;border-radius:12px;background:var(--ak-inset)}.ak-release-menu dl>div{display:flex;justify-content:space-between;gap:16px}.ak-release-menu dd{margin:0;font-family:var(--ak-font-mono)}:root[data-admin-interface="next"] .rail{overflow:visible}
2235
+
2236
+ /* Bug report della nuova interfaccia: il root vive fuori dall'app per poter sfocare l'intero
2237
+ * pannello senza includere il dialog nello screenshot. Nella UI corrente non viene renderizzato. */
2238
+ .ak-bug-trigger {
2239
+ position: fixed; z-index: 70; right: 18px; bottom: 18px;
2240
+ width: 46px; height: 46px; display: grid; place-items: center;
2241
+ border: 0; border-radius: 15px; background: var(--ak-ink); color: var(--ak-panel);
2242
+ box-shadow: 0 5px 18px rgba(35, 30, 32, .18); cursor: pointer;
2243
+ transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease;
2244
+ }
2245
+ .ak-bug-trigger:hover { transform: translateY(-2px); box-shadow: 0 9px 25px rgba(35, 30, 32, .23); }
2246
+ .ak-bug-trigger:focus-visible { outline: 0; box-shadow: var(--ak-ring), 0 9px 25px rgba(35, 30, 32, .23); }
2247
+ .ak-bug-trigger:disabled { cursor: wait; opacity: .78; }
2248
+ .ak-bug-trigger svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
2249
+ .ak-bug-capturing {
2250
+ position: absolute; inset: 5px; border: 2px solid transparent; border-top-color: currentColor;
2251
+ border-radius: 12px; animation: ak-bug-spin .8s linear infinite;
2252
+ }
2253
+ @keyframes ak-bug-spin { to { transform: rotate(1turn); } }
2254
+
2255
+ .ak-bug-backdrop {
2256
+ position: fixed; z-index: 200; inset: 0; display: grid; place-items: center; padding: 24px;
2257
+ background: rgba(31, 29, 30, .2); backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px);
2258
+ animation: ak-bug-backdrop-in 180ms ease-out both;
2259
+ }
2260
+ @keyframes ak-bug-backdrop-in { from { opacity: 0; } }
2261
+ .ak-bug-dialog {
2262
+ width: min(780px, 100%); max-height: min(760px, calc(100vh - 48px)); overflow: auto;
2263
+ display: flex; flex-direction: column; gap: 20px; padding: 24px;
2264
+ border: 0; border-radius: 24px; background: var(--ak-panel); color: var(--ak-ink);
2265
+ box-shadow: 0 28px 80px -28px rgba(18, 15, 16, .48); font-family: var(--ak-font);
2266
+ animation: ak-bug-dialog-in 220ms cubic-bezier(.2, .8, .2, 1) both;
2267
+ }
2268
+ @keyframes ak-bug-dialog-in {
2269
+ from { opacity: 0; transform: translateY(12px) scale(.985); }
2270
+ }
2271
+ .ak-bug-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 24px; }
2272
+ .ak-bug-head > div { min-width: 0; }
2273
+ .ak-bug-kicker {
2274
+ display: block; margin-bottom: 6px; color: var(--ak-accent);
2275
+ font-size: 10px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase;
2276
+ }
2277
+ .ak-bug-head h2 { margin: 0; font-size: 22px; line-height: 1.15; letter-spacing: -.025em; }
2278
+ .ak-bug-head p { max-width: 560px; margin: 7px 0 0; color: var(--ak-ink-quiet); font-size: 13px; line-height: 1.45; }
2279
+ .ak-bug-icon-button {
2280
+ width: 40px; height: 40px; flex: none; display: grid; place-items: center;
2281
+ border: 0; border-radius: 12px; background: var(--ak-inset); color: var(--ak-ink-quiet); cursor: pointer;
2282
+ }
2283
+ .ak-bug-icon-button:hover { color: var(--ak-ink); background: var(--ak-inset-deep); }
2284
+ .ak-bug-icon-button:focus-visible { outline: 0; box-shadow: var(--ak-ring); }
2285
+ .ak-bug-icon-button:disabled { opacity: .5; cursor: default; }
2286
+ .ak-bug-icon-button svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; }
2287
+
2288
+ .ak-bug-content { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: 18px; }
2289
+ .ak-bug-shot-card {
2290
+ min-width: 0; display: flex; flex-direction: column; gap: 10px; padding: 12px;
2291
+ border-radius: 17px; background: var(--ak-inset);
2292
+ }
2293
+ .ak-bug-shot-head { min-height: 30px; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
2294
+ .ak-bug-shot-head strong { font-size: 12px; font-weight: 650; }
2295
+ .ak-bug-download {
2296
+ min-height: 30px; display: inline-flex; align-items: center; gap: 6px; padding: 0 9px;
2297
+ border-radius: 9px; background: var(--ak-panel); color: var(--ak-ink); font-size: 11px; font-weight: 600;
2298
+ text-decoration: none;
2299
+ }
2300
+ .ak-bug-download:hover { background: var(--ak-inset-deep); }
2301
+ .ak-bug-download:focus-visible { outline: 0; box-shadow: var(--ak-ring); }
2302
+ .ak-bug-download svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
2303
+ .ak-bug-shot-card img {
2304
+ width: 100%; aspect-ratio: 16 / 10; display: block; object-fit: contain;
2305
+ border-radius: 11px; background: var(--ak-inset-deep);
2306
+ }
2307
+ .ak-bug-shot-card small { color: var(--ak-ink-faint); font-size: 10px; line-height: 1.35; }
2308
+ .ak-bug-shot-missing {
2309
+ min-height: 185px; display: grid; place-items: center; align-content: center; gap: 10px;
2310
+ border-radius: 11px; color: var(--ak-ink-quiet); font-size: 12px; text-align: center;
2311
+ }
2312
+ .ak-bug-shot-missing button { border: 0; background: transparent; color: var(--ak-accent); font: 600 12px var(--ak-font); cursor: pointer; }
2313
+
2314
+ .ak-bug-message { min-width: 0; display: flex; flex-direction: column; gap: 8px; }
2315
+ .ak-bug-message > span { font-size: 12px; font-weight: 650; }
2316
+ .ak-bug-message textarea {
2317
+ min-height: 218px; width: 100%; resize: vertical; padding: 13px 14px;
2318
+ border: 1px solid transparent; border-radius: 15px; background: var(--ak-inset); color: var(--ak-ink);
2319
+ font: 400 13px/1.5 var(--ak-font);
2320
+ }
2321
+ .ak-bug-message textarea::placeholder { color: var(--ak-ink-faint); }
2322
+ .ak-bug-message textarea:focus { outline: 0; border-color: color-mix(in srgb, var(--ak-accent) 30%, transparent); box-shadow: var(--ak-ring); background: var(--ak-panel); }
2323
+ .ak-bug-message small { align-self: flex-end; color: var(--ak-ink-faint); font-size: 10px; }
2324
+ .ak-bug-error { margin: -5px 0 0; padding: 10px 12px; border-radius: 11px; background: color-mix(in srgb, var(--ak-danger) 11%, transparent); color: var(--ak-danger); font-size: 12px; }
2325
+
2326
+ .ak-bug-actions { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding-top: 18px; border-top: 1px solid var(--ak-line); }
2327
+ .ak-bug-actions > span { color: var(--ak-ink-faint); font-size: 10px; line-height: 1.4; }
2328
+ .ak-bug-actions > div { flex: none; display: flex; gap: 8px; }
2329
+ .ak-bug-send {
2330
+ min-height: 42px; display: inline-flex; align-items: center; justify-content: center; gap: 8px;
2331
+ padding: 0 14px; border: 0; border-radius: 12px; background: var(--ak-inset); color: var(--ak-ink);
2332
+ font: 650 12px/1 var(--ak-font); cursor: pointer;
2333
+ }
2334
+ .ak-bug-send.is-whatsapp { background: var(--ak-accent); color: var(--ak-accent-ink); }
2335
+ .ak-bug-send:hover { filter: brightness(.97); }
2336
+ .ak-bug-send:focus-visible { outline: 0; box-shadow: var(--ak-ring); }
2337
+ .ak-bug-send:disabled { opacity: .5; cursor: wait; }
2338
+ .ak-bug-send svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
2339
+
2340
+ @media (max-width: 680px) {
2341
+ .ak-bug-trigger { right: 12px; bottom: 12px; }
2342
+ .ak-bug-backdrop { padding: 10px; place-items: end center; }
2343
+ .ak-bug-dialog { max-height: calc(100vh - 20px); padding: 20px; border-radius: 22px; }
2344
+ .ak-bug-content { grid-template-columns: minmax(0, 1fr); }
2345
+ .ak-bug-shot-card img { max-height: 220px; }
2346
+ .ak-bug-message textarea { min-height: 140px; }
2347
+ .ak-bug-actions { align-items: stretch; flex-direction: column; }
2348
+ .ak-bug-actions > div { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
2349
+ }
2350
+
2351
+ @media (prefers-reduced-motion: reduce) {
2352
+ .ak-bug-trigger, .ak-bug-backdrop, .ak-bug-dialog { animation: none; transition: none; }
2353
+ }
2354
+ :root[data-admin-interface="next"] .rail-foot > .ak-release-control{width:auto;height:auto;overflow:visible}
2355
+ :root[data-admin-interface="next"] .rail-foot > .ak-release-control > .ak-release-trigger{width:auto;min-width:104px;display:inline-flex;gap:9px;padding:0 14px;color:var(--ak-rail-ink-on);font:600 12px/1 var(--ak-font);overflow:visible}
package/dist/app/index.js CHANGED
@@ -15,6 +15,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
15
15
  import { StrictMode } from "react";
16
16
  import { createRoot } from "react-dom/client";
17
17
  import { setStoragePrefix } from "../index.js";
18
+ import { mountBugReport } from "./BugReport.js";
18
19
  import { App } from "./App.js";
19
20
  import { applyTheme, initialTheme } from "./lib/theme.js";
20
21
  import { setSiteConfig } from "./site.js";
@@ -31,4 +32,6 @@ export function mountAdmin({ site, root, storagePrefix, workspace }) {
31
32
  throw new Error("adminkit: elemento di montaggio non trovato (atteso #root)");
32
33
  const Workspace = workspace ?? App;
33
34
  createRoot(el).render(_jsx(StrictMode, { children: _jsx(Workspace, {}) }));
35
+ if (site.bugReport)
36
+ mountBugReport({ siteName: site.name, email: site.bugReport.email, whatsapp: site.bugReport.whatsapp || "" });
34
37
  }
@@ -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,13 @@ 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
+ activeReleaseId: string | null;
24
+ verified: boolean;
25
+ rollback(): Promise<void>;
26
+ verify(): Promise<void>;
20
27
  }
21
28
  export declare function contentPublicationNotice(result: {
22
29
  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,67 @@ 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 [verified, setVerified] = useState(false);
29
+ const [approval, setApproval] = useState(null);
30
+ const [activeReleaseId, setActiveReleaseId] = useState(null);
31
+ const [previousReleaseId, setPreviousReleaseId] = useState(null);
32
+ const previewApproved = (target) => !!target && "documentId" in target && !!approval && approval.documentId === target.documentId && approval.expectedDraftRevisionId === target.expectedDraftRevisionId && approval.expectedBaseReleaseId === activeReleaseId;
33
+ const preview = async (target) => {
34
+ if (!("documentId" in target))
35
+ return;
36
+ setPublishing(true);
37
+ setNotice(null);
38
+ try {
39
+ const result = await api.contentPreview(target);
40
+ setApproval({ ...result, documentId: target.documentId });
41
+ window.open(result.previewUrl, "_blank", "noopener");
42
+ setNotice("Anteprima della bozza salvata pronta.");
43
+ }
44
+ catch (error) {
45
+ setApproval(null);
46
+ setNotice(error.message);
47
+ }
48
+ finally {
49
+ setPublishing(false);
50
+ }
51
+ };
52
+ const rollback = async () => {
53
+ if (!activeReleaseId || !previousReleaseId)
54
+ return;
55
+ setPublishing(true);
56
+ setNotice(null);
57
+ try {
58
+ await api.contentRollback(activeReleaseId, previousReleaseId);
59
+ setApproval(null);
60
+ await reload();
61
+ setNotice("Ripristinata la release precedente. Le bozze sono conservate.");
62
+ }
63
+ catch (error) {
64
+ setNotice(error.message);
65
+ }
66
+ finally {
67
+ setPublishing(false);
68
+ }
69
+ };
70
+ const verify = async () => {
71
+ if (!activeReleaseId)
72
+ return;
73
+ try {
74
+ const result = await api.contentVerification(activeReleaseId);
75
+ setVerified(result.publicOriginVerified);
76
+ setNotice(result.publicOriginVerified ? "Release verificata sul dominio pubblico." : result.reason);
77
+ }
78
+ catch (error) {
79
+ setNotice(error.message);
80
+ }
81
+ };
28
82
  const reload = useCallback(async () => {
29
83
  if (contentPersistenceMode() === "d1") {
30
84
  try {
31
85
  const d = await api.contentPublishState();
86
+ setActiveReleaseId(d.activeReleaseId ?? null);
87
+ setPreviousReleaseId(d.previousReleaseId ?? null);
88
+ setVerified(d.verified === true);
32
89
  setAhead(d.ahead ?? 0);
33
90
  setDocuments(d.documents ?? []);
34
91
  setSiteAccessPending(Boolean(d.siteAccess));
@@ -78,7 +135,10 @@ export function usePublish() {
78
135
  try {
79
136
  if (!target)
80
137
  throw new ApiError("Apri il contenuto che vuoi pubblicare.", "invalid_request", 400);
81
- const r = await api.contentPublish(target);
138
+ if (site().directPublication && !previewApproved(target))
139
+ throw new ApiError("Apri una nuova anteprima della bozza prima di pubblicare.", "stale_preview", 409);
140
+ const r = await api.contentPublish(site().directPublication ? { ...target, ...approval } : target);
141
+ setApproval(null);
82
142
  setNotice(contentPublicationNotice(r));
83
143
  await reload();
84
144
  }
@@ -113,9 +173,9 @@ export function usePublish() {
113
173
  finally {
114
174
  setPublishing(false);
115
175
  }
116
- }, [reload]);
176
+ }, [reload, approval, activeReleaseId]);
117
177
  return {
118
178
  ahead, commits, documents, siteAccessPending, configured, publishing, notice, error,
119
- reload, publish, clearNotice: () => setNotice(null),
179
+ reload, publish, preview, previewApproved, previousReleaseId, activeReleaseId, verified, rollback, verify, clearNotice: () => setNotice(null),
120
180
  };
121
181
  }
@@ -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,18 @@ 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;
55
+ /** Optional support workflow: screenshot stays local until the user sends. */
56
+ bugReport?: {
57
+ email: string;
58
+ whatsapp?: string;
59
+ };
42
60
  /** Optional site-owned Home surface. The kit keeps its legacy visual editor
43
61
  * as a fallback, while a site with a different document shape can render its
44
62
  * own schema-driven editor without teaching the shared package its fields. */
@@ -17,4 +17,6 @@ export declare function commitDirectRelease(db: D1DatabaseLike, input: {
17
17
  }[];
18
18
  authorSubject: string;
19
19
  now: number;
20
+ /** Trusted site-owned transactional preconditions; never from an HTTP payload. */
21
+ guards?: readonly D1Statement[];
20
22
  }): Promise<void>;
@@ -33,6 +33,7 @@ export async function commitDirectRelease(db, input) {
33
33
  const statements = [
34
34
  transactionAssertion(db, `EXISTS(SELECT 1 FROM content_site_state WHERE singleton=1 AND current_release_id=?) AND NOT EXISTS(SELECT 1 FROM content_release_site_access WHERE release_id=?) AND NOT EXISTS(SELECT 1 FROM production_deployment_attempts WHERE phase NOT IN ('succeeded','failed','superseded'))`, [expectedBaseReleaseId, expectedBaseReleaseId]),
35
35
  ];
36
+ statements.push(...(input.guards ?? []));
36
37
  for (const d of selectedDrafts)
37
38
  statements.push(transactionAssertion(db, "EXISTS(SELECT 1 FROM content_documents WHERE document_id=? AND draft_revision_id=?)", [d.documentId, d.revisionId]));
38
39
  for (const e of release.entries)
@@ -81,6 +81,12 @@ export function MediaFolders({ lib, trash, settings }) {
81
81
  trash.leave(); lib.go(p); };
82
82
  const drop = (dest) => (e) => {
83
83
  setOver(null);
84
+ const ids = readDragged(e.dataTransfer);
85
+ if (canMove && ids.length) {
86
+ e.preventDefault();
87
+ void lib.move(ids, dest);
88
+ return;
89
+ }
84
90
  if (canUpload && e.dataTransfer.files.length) {
85
91
  e.preventDefault();
86
92
  if (lib.mode === "pick")
@@ -92,7 +98,6 @@ export function MediaFolders({ lib, trash, settings }) {
92
98
  if (!canMove)
93
99
  return;
94
100
  e.preventDefault();
95
- const ids = readDragged(e.dataTransfer);
96
101
  if (ids.length)
97
102
  void lib.move(ids, dest);
98
103
  };
@@ -173,7 +173,7 @@ export function MediaGrid({ lib, notice, onPick, onCancel }) {
173
173
  e.dataTransfer.setData(DRAG_FOLDERS, JSON.stringify(dirs));
174
174
  e.dataTransfer.effectAllowed = "move";
175
175
  }, children: [_jsxs("div", { className: "mg-frame", onClick: () => { if (!locked)
176
- (manage ? lib.view(i) : lib.toggle(f.key)); }, children: [_jsx("img", { className: "mg-img", src: f.url, alt: "", loading: "lazy",
176
+ (manage ? lib.view(i) : lib.toggle(f.key)); }, children: [_jsx("img", { className: "mg-img", draggable: false, src: f.url, alt: "", loading: "lazy",
177
177
  /* Il rapporto è noto prima che l'immagine arrivi (lo dà l'elenco): la cella
178
178
  riserva subito l'ingombro giusto e la griglia non salta mentre si carica. */
179
179
  style: { aspectRatio: f.w && f.h ? `${f.w} / ${f.h}` : "3 / 2" } }), manage && altStatus && (_jsx("span", { className: `mg-alt-badge is-${altStatus}`, children: altStatus === "missing" ? "ALT da completare" : altStatus === "decorative" ? "Decorativa" : "ALT completo" })), _jsx("button", { className: "mg-check", "aria-label": on ? "Deseleziona" : "Seleziona", disabled: locked, onClick: (e) => { e.stopPropagation(); lib.toggle(f.key); }, children: on ? "✓" : "" })] }), _jsx("figcaption", { className: "mg-cap", title: f.name, children: f.name })] }, f.key));
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.35",
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",
@@ -108,6 +108,7 @@
108
108
  "zod": "^4.4.3"
109
109
  },
110
110
  "dependencies": {
111
- "heic-to": "^1.3.0"
111
+ "heic-to": "^1.3.0",
112
+ "modern-screenshot": "4.7.0"
112
113
  }
113
114
  }