@cardinali-studio/adminkit 2.2.11 → 2.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/api.d.ts +43 -2
  2. package/dist/api.js +13 -3
  3. package/dist/app/App.js +10 -4
  4. package/dist/app/app.css +5 -0
  5. package/dist/app/lib/publish.js +4 -1
  6. package/dist/app/lib/settings.d.ts +2 -0
  7. package/dist/app/lib/settings.js +54 -8
  8. package/dist/app/routes/MediaSettingsEditor.d.ts +4 -0
  9. package/dist/app/routes/MediaSettingsEditor.js +4 -0
  10. package/dist/app/routes/SiteSettingsEditor.d.ts +3 -1
  11. package/dist/app/routes/SiteSettingsEditor.js +3 -2
  12. package/dist/app/routes/UploadDock.js +31 -9
  13. package/dist/app/site.d.ts +4 -0
  14. package/dist/deployment.js +14 -1
  15. package/dist/form/media/MediaFolders.d.ts +5 -1
  16. package/dist/form/media/MediaFolders.js +3 -2
  17. package/dist/form/media/image-conversion.d.ts +14 -0
  18. package/dist/form/media/image-conversion.js +163 -0
  19. package/dist/form/media/media-upload-queue.d.ts +31 -0
  20. package/dist/form/media/media-upload-queue.js +230 -0
  21. package/dist/form/media/useMediaLibrary.d.ts +3 -0
  22. package/dist/form/media/useMediaLibrary.js +72 -18
  23. package/dist/functions/_shared/assets.js +3 -2
  24. package/dist/functions/api/assets/folders.js +2 -1
  25. package/dist/functions/api/assets/settings.js +40 -0
  26. package/dist/functions/api/assets/upload.js +4 -1
  27. package/dist/functions/api/assets.js +4 -3
  28. package/dist/functions/api/site-access.js +78 -0
  29. package/dist/functions/index.js +4 -0
  30. package/dist/functions/site.js +4 -0
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.js +1 -0
  33. package/dist/migrations/0004_site_access.sql +41 -0
  34. package/dist/persistence.d.ts +1 -0
  35. package/dist/persistence.js +22 -4
  36. package/dist/release.d.ts +3 -0
  37. package/dist/release.js +7 -0
  38. package/dist/site-access.d.ts +48 -0
  39. package/dist/site-access.js +180 -0
  40. package/dist/theme.css +1 -0
  41. package/package.json +4 -1
package/dist/api.d.ts CHANGED
@@ -76,6 +76,16 @@ export type ContentDocument = {
76
76
  publishedPayload: string | null;
77
77
  publishedTombstone: boolean | null;
78
78
  };
79
+ export type SiteAccessPublicVersion = {
80
+ versionId: string;
81
+ enabled: boolean;
82
+ formatVersion: 1;
83
+ };
84
+ export type SiteAccessPublicState = {
85
+ active: SiteAccessPublicVersion | null;
86
+ draft: SiteAccessPublicVersion | null;
87
+ effective: SiteAccessPublicVersion | null;
88
+ };
79
89
  /** Builds the only D1 Save wire shape. The Worker recomputes these hashes too,
80
90
  * so retries are deterministic rather than client-trusted. */
81
91
  export declare function contentSaveRequest(command: ContentSaveCommand): Promise<{
@@ -133,6 +143,20 @@ export declare const api: {
133
143
  ok: true;
134
144
  discarded: true;
135
145
  }>;
146
+ siteAccessState: () => Promise<{
147
+ ok: true;
148
+ state: SiteAccessPublicState;
149
+ }>;
150
+ saveSiteAccess: (enabled: boolean, password: string, expectedDraftVersionId: string | null) => Promise<{
151
+ ok: true;
152
+ saved: true;
153
+ state: SiteAccessPublicState;
154
+ }>;
155
+ discardSiteAccess: (expectedDraftVersionId: string) => Promise<{
156
+ ok: true;
157
+ discarded: true;
158
+ state: SiteAccessPublicState;
159
+ }>;
136
160
  /** D1 publication is site-owned: the site chooses which documents it can
137
161
  * materialize, while the panel only displays the pending immutable inputs. */
138
162
  contentPublishState: () => Promise<{
@@ -145,6 +169,9 @@ export declare const api: {
145
169
  updatedAt: number;
146
170
  }[];
147
171
  mediaOperations?: MediaOperationStatus[];
172
+ siteAccess?: {
173
+ enabled: boolean;
174
+ } | null;
148
175
  activeReleaseId?: string | null;
149
176
  }>;
150
177
  contentPublish: () => Promise<{
@@ -273,7 +300,21 @@ export declare const api: {
273
300
  ok: true;
274
301
  }>;
275
302
  /** L'upload NON è JSON: corpo grezzo e metadati negli header (vedi assets/upload.js). */
276
- upload: (prefix: string, file: File) => Promise<AssetFile>;
303
+ upload: (prefix: string, file: File, metadata?: {
304
+ capturedAt?: string | null;
305
+ }) => Promise<AssetFile>;
306
+ mediaSettings: () => Promise<{
307
+ ok: true;
308
+ settings: {
309
+ autoWebp: boolean;
310
+ };
311
+ }>;
312
+ saveMediaSettings: (autoWebp: boolean) => Promise<{
313
+ ok: true;
314
+ settings: {
315
+ autoWebp: boolean;
316
+ };
317
+ }>;
277
318
  trash: () => Promise<{
278
319
  ok: true;
279
320
  items: TrashItem[];
@@ -295,4 +336,4 @@ export declare const api: {
295
336
  removed: number;
296
337
  }>;
297
338
  };
298
- export declare function uploadQueue(prefix: string, files: File[], onProgress: (done: number, total: number, last?: AssetFile, error?: string) => void, concurrency?: number): Promise<AssetFile[]>;
339
+ export declare function uploadQueue(prefix: string, files: File[], onProgress: (done: number, total: number, last?: AssetFile, error?: string) => void, concurrency?: number, prepare?: (file: File) => Promise<File>): Promise<AssetFile[]>;
package/dist/api.js CHANGED
@@ -60,6 +60,9 @@ export const api = {
60
60
  contentDocument: (documentId) => call(`/admin/api/content/document?id=${encodeURIComponent(documentId)}`),
61
61
  saveContent: async (command) => post("/admin/api/content/save", await contentSaveRequest(command)),
62
62
  discardContent: (documentId, expectedDraftRevisionId, baselineRevisionId) => post("/admin/api/content/discard", { documentId, expectedDraftRevisionId, baselineRevisionId }),
63
+ siteAccessState: () => call("/admin/api/content/site-access"),
64
+ saveSiteAccess: (enabled, password, expectedDraftVersionId) => post("/admin/api/content/site-access", { action: "save", enabled, password, expectedDraftVersionId }),
65
+ discardSiteAccess: (expectedDraftVersionId) => post("/admin/api/content/site-access", { action: "discard", expectedDraftVersionId }),
63
66
  /** D1 publication is site-owned: the site chooses which documents it can
64
67
  * materialize, while the panel only displays the pending immutable inputs. */
65
68
  contentPublishState: () => call("/admin/api/content/publish"),
@@ -123,15 +126,18 @@ export const api = {
123
126
  body: JSON.stringify({ id }),
124
127
  }),
125
128
  /** L'upload NON è JSON: corpo grezzo e metadati negli header (vedi assets/upload.js). */
126
- upload: (prefix, file) => call("/admin/api/assets/upload", {
129
+ upload: (prefix, file, metadata = {}) => call("/admin/api/assets/upload", {
127
130
  method: "POST",
128
131
  headers: {
129
132
  "Content-Type": file.type || "application/octet-stream",
130
133
  "X-Prefix": prefix,
131
134
  "X-Filename": encodeURIComponent(file.name),
135
+ ...(metadata.capturedAt ? { "X-Captured-At": metadata.capturedAt } : {}),
132
136
  },
133
137
  body: file,
134
138
  }),
139
+ mediaSettings: () => call("/admin/api/assets/settings"),
140
+ saveMediaSettings: (autoWebp) => post("/admin/api/assets/settings", { autoWebp }),
135
141
  // ---- cestino
136
142
  trash: () => call("/admin/api/assets/trash"),
137
143
  trashRestore: (trashKeys) => post("/admin/api/assets/trash/restore", { trashKeys }),
@@ -142,14 +148,18 @@ export const api = {
142
148
  * (app.js:922), il che su quaranta foto è tedioso senza motivo; tutte insieme invece saturano
143
149
  * la connessione e rendono il progresso illeggibile. Tre è il compromesso.
144
150
  * Un file che fallisce non ferma gli altri: si segnala e si prosegue. */
145
- export async function uploadQueue(prefix, files, onProgress, concurrency = 3) {
151
+ export async function uploadQueue(prefix, files, onProgress, concurrency = 3, prepare = async (file) => file) {
146
152
  const out = [];
147
153
  let done = 0, next = 0;
148
154
  const worker = async () => {
149
155
  while (next < files.length) {
150
156
  const file = files[next++];
151
157
  try {
152
- const r = await api.upload(prefix, file);
158
+ /* Preparazione e rete stanno nello STESSO worker: tre immagini vengono convertite in
159
+ * parallelo, e appena una è pronta parte il suo upload mentre le altre continuano. Non
160
+ * teniamo in memoria quaranta WebP aspettando che l'intero batch sia convertito. */
161
+ const ready = await prepare(file);
162
+ const r = await api.upload(prefix, ready);
153
163
  out.push(r);
154
164
  onProgress(++done, files.length, r);
155
165
  }
package/dist/app/App.js CHANGED
@@ -32,6 +32,7 @@ import { PagesList } from "./routes/PagesList";
32
32
  import { PageEditor, PageHeader } from "./routes/PageEditor";
33
33
  import { PageSeoEditor } from "./routes/PageSeoEditor";
34
34
  import { SiteSettingsEditor, WebsiteSeoSettingsEditor } from "./routes/SiteSettingsEditor";
35
+ import { MediaSettingsEditor } from "./routes/MediaSettingsEditor";
35
36
  import { HomeEditor } from "./routes/HomeEditor";
36
37
  import { RequestsList, RequestDetail } from "./routes/Requests";
37
38
  import { useRequests } from "./lib/requests";
@@ -67,6 +68,7 @@ export function App() {
67
68
  colonna e la griglia nella terza, e sono due punti dello schermo lontani. Uno stato per
68
69
  ciascuno vorrebbe dire due idee diverse di quale cartella si sta guardando. */
69
70
  const media = useMediaLibrary({ mode: "manage", active: section === "media" });
71
+ const mediaSettingsOpen = view === "media" && route[1] === "settings";
70
72
  /* Le richieste si caricano SUBITO, non entrando nella sezione: il numero «da gestire» nella
71
73
  prima colonna è il motivo per cui uno ci entra, e un badge che compare solo dopo essere
72
74
  andati a guardare non serve a niente. */
@@ -195,7 +197,9 @@ export function App() {
195
197
  : section === "pages" ? (editing ? String(editing.title ?? editing.slug) : "Pagine")
196
198
  : section === "home" ? (homeGroup || "Home")
197
199
  : view === "trash" ? "Cestino"
198
- : section === "media" ? (media.prefix ? media.labelOf(media.prefix) : "Senza cartella")
200
+ : section === "media" ? (mediaSettingsOpen
201
+ ? "Impostazioni Media"
202
+ : media.prefix ? media.labelOf(media.prefix) : "Senza cartella")
199
203
  /* Consegne non ripete il titolo qui: il nome del transfer sta nella sua schermata, dove
200
204
  si modifica, e si aggiorna mentre lo si scrive. Averlo in due posti significa averne
201
205
  uno dei due sempre vecchio. */
@@ -220,7 +224,7 @@ export function App() {
220
224
  : _jsx(PagesList, { pages: list, current: editing?.slug, onEdit: (slug) => go(`/pages/${slug}`), languages: languages, onChange: pages.update, onRenamed: (from, to) => {
221
225
  if (editing?.slug === from)
222
226
  go(`/pages/${to}${seoEditing ? "/seo" : ""}`);
223
- } }) })] })), section === "media" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: _jsx("span", { className: "col-title", children: "Cartelle" }) }), _jsx("div", { className: "col-body", children: _jsx(MediaFolders, { lib: media, trash: { active: view === "trash", open: () => go("/trash"), leave: () => go("/media") } }) })] })), section === "requests" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: pickedReq.size === 0 ? (_jsx("span", { className: "col-title", children: requests.rows ? `${requests.rows.length} richieste` : "Richieste" })) : (_jsxs("span", { className: "rq-bulk", children: [_jsxs("span", { className: "col-title", children: [pickedReq.size, " scelte"] }), _jsx("button", { className: "mg-act", onClick: () => { void requests.mark([...pickedReq], true); setPickedReq(new Set()); }, children: "Gestite" }), _jsx("button", { className: "mg-act is-danger", onClick: () => { void requests.remove([...pickedReq]); setPickedReq(new Set()); }, children: "Elimina" }), _jsx("button", { className: "mg-act", onClick: () => setPickedReq(new Set()), children: "\u00D7" })] })) }), _jsx("div", { className: "col-body", children: requests.error ? _jsx("p", { className: "ak-error", children: requests.error })
227
+ } }) })] })), section === "media" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: _jsx("span", { className: "col-title", children: "Cartelle" }) }), _jsx("div", { className: "col-body", children: _jsx(MediaFolders, { lib: media, trash: { active: view === "trash", open: () => go("/trash"), leave: () => go("/media") }, settings: { active: mediaSettingsOpen, open: () => go("/media/settings") } }) })] })), section === "requests" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: pickedReq.size === 0 ? (_jsx("span", { className: "col-title", children: requests.rows ? `${requests.rows.length} richieste` : "Richieste" })) : (_jsxs("span", { className: "rq-bulk", children: [_jsxs("span", { className: "col-title", children: [pickedReq.size, " scelte"] }), _jsx("button", { className: "mg-act", onClick: () => { void requests.mark([...pickedReq], true); setPickedReq(new Set()); }, children: "Gestite" }), _jsx("button", { className: "mg-act is-danger", onClick: () => { void requests.remove([...pickedReq]); setPickedReq(new Set()); }, children: "Elimina" }), _jsx("button", { className: "mg-act", onClick: () => setPickedReq(new Set()), children: "\u00D7" })] })) }), _jsx("div", { className: "col-body", children: requests.error ? _jsx("p", { className: "ak-error", children: requests.error })
224
228
  : !requests.rows ? _jsx("p", { className: "ak-hint", children: "Carico\u2026" })
225
229
  : requests.rows.length === 0 ? _jsx("p", { className: "ak-hint", children: "Nessuna richiesta, per ora." })
226
230
  : _jsx(RequestsList, { rows: requests.rows, current: openReq?.id, picked: pickedReq, onOpen: (id) => go(`/requests/${id}`), onPick: (id) => setPickedReq((prev) => {
@@ -240,7 +244,7 @@ export function App() {
240
244
  : deliveries.rows.length === 0 ? _jsx("p", { className: "ak-hint", children: "Nessun transfer, per ora." })
241
245
  : _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 })
242
246
  : !bookings.rows ? _jsx("p", { className: "ak-hint", children: "Carico\u2026" })
243
- : _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" ? (_jsxs("nav", { className: "pane-crumbs", "aria-label": "Percorso", children: [_jsx("button", { className: "pane-crumb" + (mediaCrumbs.length ? "" : " is-current"), 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" : ""), onClick: () => media.go(c.prefix), children: c.label })] }, c.prefix)))] })) : (_jsx("h1", { className: "pane-title", children: heading })), view === "media" && _jsx("span", { className: "pane-count", children: media.shown.length }), _jsx("span", { className: "pane-spacer" }), view === "media" && (_jsx("button", { className: "ak-btn ak-btn-primary", onClick: () => media.setUploadOpen(true), 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,
247
+ : _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 ? (_jsxs("nav", { className: "pane-crumbs", "aria-label": "Percorso", children: [_jsx("button", { className: "pane-crumb" + (mediaCrumbs.length ? "" : " is-current"), 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" : ""), onClick: () => media.go(c.prefix), children: c.label })] }, c.prefix)))] })) : (_jsx("h1", { className: "pane-title", children: heading })), 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", onClick: () => media.setUploadOpen(true), 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,
244
248
  /* Dopo un salvataggio il conteggio delle modifiche in attesa è cambiato: senza
245
249
  rileggerlo, «Pubblica» comparirebbe solo al ricaricamento della pagina — cioè
246
250
  il pannello direbbe che non c'è niente da pubblicare subito dopo aver salvato
@@ -261,7 +265,9 @@ export function App() {
261
265
  : home.data ? _jsx(HomeEditorView, { data: home.data, group: homeGroup, onChange: home.change, pickMedia: pickMedia })
262
266
  : _jsx("p", { className: "ak-hint", children: "Carico\u2026" })), section === "settings" && (settings.error ? (_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"
263
267
  ? _jsx(WebsiteSeoSettingsEditor, { value: settings.data, onChange: settings.change })
264
- : _jsx(SiteSettingsEditor, { value: settings.data, onChange: settings.change, pickMediaFiles: pickMediaFiles, focus: route[1] })) : _jsx("p", { className: "pane-empty ak-hint", children: "Carico\u2026" })), view === "media" && _jsx(MediaGrid, { lib: media }), view === "trash" && _jsx(Trash, {}), section === "requests" && (requests.error ? _jsx("p", { className: "ak-error", children: requests.error })
268
+ : _jsx(SiteSettingsEditor, { value: settings.data, onChange: settings.change, pickMediaFiles: pickMediaFiles, 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
269
+ ? _jsx(MediaSettingsEditor, { lib: media })
270
+ : _jsx(MediaGrid, { lib: media })), view === "trash" && _jsx(Trash, {}), section === "requests" && (requests.error ? _jsx("p", { className: "ak-error", children: requests.error })
265
271
  : openReq ? (_jsx(RequestDetail, { row: openReq, onMark: (h) => void requests.mark([openReq.id], h), onRemove: () => { void requests.remove([openReq.id]); go("/requests"); } }))
266
272
  : _jsx("p", { className: "pane-empty ak-hint", children: requests.rows?.length ? "Scegli una richiesta dall'elenco." : "" })), section === "deliveries" && (deliveries.error ? _jsx("p", { className: "ak-error", children: deliveries.error })
267
273
  : deliverySettingsOpen ? (_jsx(DeliverySettings, { settings: deliveries.settings, onAction: deliveries.action, pickMedia: pickMedia })) : activeDeliveryId && !deliveries.detail ? (_jsx("p", { className: "pane-empty ak-hint", children: "Carico\u2026" })) : (
package/dist/app/app.css CHANGED
@@ -235,6 +235,7 @@ body { margin: 0; background: var(--ak-bg); color: var(--ak-ink); font-family: v
235
235
  .st-inline-help {
236
236
  margin: -3px 0 0; color: var(--ak-ink-quiet); font: 400 11px/1.45 var(--ak-font);
237
237
  }
238
+ .media-settings-note { margin-top: 13px; max-width: 520px; }
238
239
  .st-favicon { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
239
240
  .st-favicon-preview {
240
241
  width: 48px; height: 48px; display: grid; place-items: center; flex: none;
@@ -1165,6 +1166,10 @@ body { margin: 0; background: var(--ak-bg); color: var(--ak-ink); font-family: v
1165
1166
  .uk-job.is-failed .uk-job-top span { color: #a33; }
1166
1167
  .uk-track { height: 4px; overflow: hidden; border-radius: 3px; background: var(--ak-line); }
1167
1168
  .uk-track i { display: block; height: 100%; background: var(--ak-accent); transition: width .25s; }
1169
+ .uk-phase { display: grid; gap: 4px; }
1170
+ .uk-phase > span { color: var(--ak-ink-quiet); font: 500 10px/1.2 var(--ak-font); }
1171
+ .uk-media-job .uk-phase:nth-of-type(3) .uk-track i { opacity: .65; }
1172
+ .uk-warning { margin: 0; color: #8a5a12; font-size: 11px; line-height: 1.45; }
1168
1173
  .uk-job.is-paused .uk-track i { background: var(--ak-ink-quiet); }
1169
1174
  .uk-error { margin: 0; color: #a33; font-size: 11px; line-height: 1.45; }
1170
1175
  .uk-job-actions { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
@@ -27,7 +27,10 @@ export function usePublish() {
27
27
  .map((operation) => ({
28
28
  message: operation.type === "move" ? "Spostamento Media pronto" : "Eliminazione Media pronta",
29
29
  date: operation.updatedAt ? new Date(operation.updatedAt).toISOString() : null,
30
- }))));
30
+ }))).concat(d.siteAccess ? [{
31
+ message: d.siteAccess.enabled ? "Attivazione o cambio password del sito" : "Disattivazione password del sito",
32
+ date: null,
33
+ }] : []));
31
34
  setConfigured(true);
32
35
  setError(null);
33
36
  }
@@ -26,4 +26,6 @@ export declare function useSiteSettings(active: boolean): {
26
26
  change: (next: SiteSettings) => void;
27
27
  save: () => Promise<void>;
28
28
  setNotice: import("react").Dispatch<import("react").SetStateAction<string | null>>;
29
+ accessDraftPending: boolean;
30
+ discardSiteAccess: () => Promise<void>;
29
31
  };
@@ -37,6 +37,12 @@ export function useSiteSettings(active) {
37
37
  const [saving, setSaving] = useState(false);
38
38
  const [notice, setNotice] = useState(null);
39
39
  const [revision, setRevision] = useState(null);
40
+ const [accessState, setAccessState] = useState(null);
41
+ const [editorialBaseline, setEditorialBaseline] = useState("");
42
+ const editorialSnapshot = (settings) => {
43
+ const { password: _password, passwordEnabled: _passwordEnabled, ...editorial } = settings;
44
+ return JSON.stringify(editorial);
45
+ };
40
46
  const load = useCallback(async () => {
41
47
  try {
42
48
  const mapped = site().siteSettingsDocument;
@@ -49,12 +55,18 @@ export function useSiteSettings(active) {
49
55
  const settings = mapped.fromPayload ? mapped.fromPayload(stored) : stored;
50
56
  if (!settings || typeof settings !== "object" || Array.isArray(settings))
51
57
  throw new Error("content_unavailable");
52
- setData({ ...empty, ...settings, password: "" });
58
+ const access = site().siteAccess ? (await api.siteAccessState()).state : null;
59
+ const loaded = { ...empty, ...settings, passwordEnabled: access?.effective?.enabled ?? false, password: "" };
60
+ setData(loaded);
61
+ setAccessState(access);
62
+ setEditorialBaseline(editorialSnapshot(loaded));
53
63
  setRevision({ base: document.draftRevisionId ?? document.publishedRevisionId, expected: document.draftRevisionId });
54
64
  }
55
65
  else {
56
66
  const result = await call("/admin/api/settings");
57
- setData({ ...empty, ...result.settings, password: "" });
67
+ const loaded = { ...empty, ...result.settings, password: "" };
68
+ setData(loaded);
69
+ setEditorialBaseline(editorialSnapshot(loaded));
58
70
  }
59
71
  setError(null);
60
72
  setDirty(false);
@@ -75,10 +87,19 @@ export function useSiteSettings(active) {
75
87
  try {
76
88
  const mapped = site().siteSettingsDocument;
77
89
  if (contentPersistenceMode() === "d1" && mapped && revision) {
78
- const revisionId = crypto.randomUUID();
79
- const payload = mapped.toPayload ? mapped.toPayload(data) : data;
80
- 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 });
81
- setRevision({ base: revisionId, expected: revisionId });
90
+ if (editorialSnapshot(data) !== editorialBaseline) {
91
+ const revisionId = crypto.randomUUID();
92
+ 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
+ setRevision({ base: revisionId, expected: revisionId });
95
+ setEditorialBaseline(editorialSnapshot(data));
96
+ }
97
+ const effectiveEnabled = accessState?.effective?.enabled ?? false;
98
+ if (site().siteAccess && (data.password !== "" || data.passwordEnabled !== effectiveEnabled)) {
99
+ const result = await api.saveSiteAccess(data.passwordEnabled, data.password, accessState?.draft?.versionId ?? null);
100
+ setAccessState(result.state);
101
+ setData({ ...data, password: "", passwordEnabled: result.state.effective?.enabled ?? false });
102
+ }
82
103
  setDirty(false);
83
104
  setNotice("Bozza D1 salvata");
84
105
  return;
@@ -101,10 +122,35 @@ export function useSiteSettings(active) {
101
122
  finally {
102
123
  setSaving(false);
103
124
  }
104
- }, [data, revision]);
125
+ }, [data, revision, accessState, editorialBaseline]);
105
126
  const change = useCallback((next) => {
106
127
  setData(next);
107
128
  setDirty(true);
108
129
  }, []);
109
- return { data, error, dirty, saving, notice, load, change, save, setNotice };
130
+ const discardSiteAccess = useCallback(async () => {
131
+ if (!accessState?.draft)
132
+ return;
133
+ setSaving(true);
134
+ setNotice(null);
135
+ try {
136
+ const result = await api.discardSiteAccess(accessState.draft.versionId);
137
+ setAccessState(result.state);
138
+ setData((current) => {
139
+ if (!current)
140
+ return current;
141
+ const next = { ...current, password: "", passwordEnabled: result.state.active?.enabled ?? false };
142
+ setDirty(editorialSnapshot(next) !== editorialBaseline);
143
+ return next;
144
+ });
145
+ setNotice("Modifica della password annullata");
146
+ }
147
+ catch (error) {
148
+ setNotice(error.message);
149
+ }
150
+ finally {
151
+ setSaving(false);
152
+ }
153
+ }, [accessState, editorialBaseline]);
154
+ return { data, error, dirty, saving, notice, load, change, save, setNotice,
155
+ accessDraftPending: Boolean(accessState?.draft), discardSiteAccess };
110
156
  }
@@ -0,0 +1,4 @@
1
+ import type { MediaLibrary } from "../../form/media/useMediaLibrary.js";
2
+ export declare function MediaSettingsEditor({ lib }: {
3
+ lib: MediaLibrary;
4
+ }): import("react").JSX.Element;
@@ -0,0 +1,4 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ export function MediaSettingsEditor({ lib }) {
3
+ return (_jsxs("section", { className: "settings-editor", children: [_jsxs("header", { className: "st-intro", children: [_jsx("span", { children: "Impostazioni Media" }), _jsx("h2", { children: "Caricamento immagini" }), _jsx("p", { children: "Ottimizza le immagini prima che vengano inviate alla libreria del sito." })] }), _jsxs("section", { className: "st-section", children: [_jsx("h2", { children: "Ottimizzazione automatica" }), _jsxs("div", { className: "st-row", children: [_jsxs("div", { className: "st-copy", children: [_jsx("h3", { children: "Converti le immagini in WebP" }), _jsx("p", { children: "JPG, JPEG, PNG, WebP, HEIC e gli altri formati immagine compatibili vengono convertiti in WebP, qualit\u00E0 80, con lato lungo massimo di 2500 px. I file pi\u00F9 piccoli non vengono ingranditi. Video e file non immagine restano invariati." })] }), _jsxs("div", { className: "st-control", children: [_jsxs("label", { className: "st-toggle" + (lib.settingsBusy ? " is-locked" : ""), children: [_jsx("input", { type: "checkbox", checked: lib.autoWebp, disabled: lib.settingsBusy, onChange: (event) => void lib.setAutoWebp(event.target.checked) }), _jsx("span", { className: "st-toggle-track", "aria-hidden": "true", children: _jsx("span", {}) }), _jsxs("span", { children: [lib.autoWebp ? "On" : "Off", " \u00B7 Conversione automatica"] })] }), _jsx("p", { className: "st-inline-help media-settings-note", children: "La conversione (da una a tre immagini) e l\u2019upload (da due a quattro file) usano pool adattivi e procedono in parallelo in base al dispositivo e alla connessione. L\u2019impostazione vale per questo sito e per tutti gli utenti del pannello." })] })] })] }), lib.error && _jsx("p", { className: "ak-error", onClick: lib.clearError, children: lib.error })] }));
4
+ }
@@ -7,13 +7,15 @@ export declare const EXTRAS: {
7
7
  card: string;
8
8
  short: string;
9
9
  }[];
10
- export declare function SiteSettingsEditor({ value, onChange, pickMediaFiles, focus, }: {
10
+ export declare function SiteSettingsEditor({ value, onChange, pickMediaFiles, focus, passwordDraftPending, onDiscardPasswordDraft, }: {
11
11
  value: SiteSettings;
12
12
  onChange: (next: SiteSettings) => void;
13
13
  pickMediaFiles: (o: {
14
14
  multiple: boolean;
15
15
  }) => Promise<AssetFile[] | null>;
16
16
  focus?: string;
17
+ passwordDraftPending?: boolean;
18
+ onDiscardPasswordDraft?: () => void;
17
19
  }): import("react").JSX.Element;
18
20
  export declare function WebsiteSeoSettingsEditor({ value, onChange, }: {
19
21
  value: SiteSettings;
@@ -35,8 +35,9 @@ function SettingRow({ title, help, children, }) {
35
35
  function Field({ value, onChange, maxLength, placeholder, type = "text", disabled = false, }) {
36
36
  return (_jsx("input", { className: "st-input", type: type, value: value, maxLength: maxLength, placeholder: placeholder, disabled: disabled, onChange: (event) => onChange(event.target.value) }));
37
37
  }
38
- export function SiteSettingsEditor({ value, onChange, pickMediaFiles, focus, }) {
38
+ export function SiteSettingsEditor({ value, onChange, pickMediaFiles, focus, passwordDraftPending = false, onDiscardPasswordDraft, }) {
39
39
  const d1Content = contentPersistenceMode() === "d1";
40
+ const managedSiteAccess = d1Content && Boolean(site().siteAccess);
40
41
  const update = (key, next) => onChange({ ...value, [key]: next });
41
42
  const extras = useRef(null);
42
43
  /* Salto SECCO (`behavior: "auto"`), non fluido, e al frame successivo.
@@ -58,7 +59,7 @@ export function SiteSettingsEditor({ value, onChange, pickMediaFiles, focus, })
58
59
  if (selected?.[0])
59
60
  update("favicon", selected[0].url);
60
61
  };
61
- return (_jsxs("section", { className: "settings-editor", children: [_jsxs("header", { className: "st-intro", children: [_jsx("span", { children: "Impostazioni generali" }), _jsx("h2", { children: "Sito" }), _jsx("p", { children: "Identit\u00E0, descrizione e accesso al sito pubblico." })] }), _jsxs("section", { className: "st-section", children: [_jsx("h2", { children: "Identit\u00E0 del sito" }), _jsx(SettingRow, { title: "Titolo del sito", help: "Il nome generale del sito, usato anche come nome del publisher nei metadati.", children: _jsx(Field, { value: value.siteTitle, maxLength: 140, placeholder: site().name, onChange: (next) => update("siteTitle", next) }) }), _jsxs(SettingRow, { title: "Descrizione breve", help: "Una frase sintetica usata come descrizione predefinita quando una pagina non ne possiede una specifica.", children: [_jsx("textarea", { className: "st-input st-textarea", rows: 3, value: value.shortDescription, maxLength: 255, onChange: (event) => update("shortDescription", event.target.value) }), _jsxs("span", { className: "st-count", children: [value.shortDescription.length, "/255"] })] }), _jsx(SettingRow, { title: "Favicon", help: "L\u2019icona mostrata nella scheda del browser. \u00C8 preferibile un\u2019immagine quadrata.", children: _jsxs("div", { className: "st-favicon", children: [_jsx("span", { className: "st-favicon-preview", children: value.favicon ? _jsx("img", { src: value.favicon, alt: "Anteprima favicon" }) : _jsx(Icon.pages, {}) }), _jsx("button", { className: "ak-btn", type: "button", onClick: () => void chooseFavicon(), children: value.favicon ? "Cambia favicon" : "Carica favicon" }), value.favicon && (_jsx("button", { className: "ak-btn ak-btn-quiet", type: "button", onClick: () => update("favicon", ""), children: "Rimuovi" }))] }) })] }), _jsxs("section", { className: "st-section", children: [_jsx("h2", { children: "Accesso al sito" }), _jsx(SettingRow, { title: "Password del sito", help: "Quando \u00E8 attiva, ogni pagina e ogni immagine pubblica richiedono la stessa password. L\u2019admin resta raggiungibile tramite il suo accesso protetto.", children: _jsxs("div", { className: "st-stack", children: [_jsx(Field, { type: "password", value: value.password, maxLength: 200, placeholder: value.passwordEnabled ? "Password già impostata · inseriscine una per cambiarla" : "Almeno 8 caratteri", disabled: d1Content, onChange: (next) => update("password", next) }), _jsx(Toggle, { checked: value.passwordEnabled, label: "Protezione con password", disabled: d1Content, onChange: (next) => update("passwordEnabled", next) }), d1Content && (_jsx("p", { className: "st-inline-help", children: "La protezione esistente resta attiva e viene gestita dall\u2019operatore durante la migrazione D1." })), value.passwordEnabled && (_jsx("p", { className: "st-inline-help", children: "Lascia il campo vuoto per mantenere la password attuale." }))] }) })] }), _jsxs("section", { className: "st-section" + (focus === "funzioni" ? " is-focused" : ""), ref: extras, children: [_jsx("h2", { children: "Funzioni aggiuntive" }), EXTRAS.map((extra) => (_jsx(SettingRow, { title: extra.title, help: extra.help, children: _jsx(Toggle, { checked: value.features[extra.key], disabled: true, label: value.features[extra.key] ? "Attiva su questo sito" : "Non attiva", onChange: () => { } }) }, extra.key))), _jsxs("p", { className: "st-inline-help st-extras-note", children: ["Queste funzioni si attivano separatamente. Per accenderle su questo sito scrivi a", " ", _jsx("strong", { children: site().support.name }), ":", " ", _jsx("a", { href: `mailto:${site().support.email}`, children: site().support.email }), "."] })] }), _jsxs("section", { className: "st-section", children: [_jsx("h2", { children: "Statistiche" }), _jsx(SettingRow, { title: "Tag Analytics", help: _jsxs(_Fragment, { children: ["Il codice fornito dal servizio di statistiche (Umami, Google Analytics, altro). Si incolla qui cos\u00EC com\u2019\u00E8: finisce dentro ", _jsx("code", { children: "<head>" }), " di ogni pagina del sito alla prossima pubblicazione. Le pagine di consegna ai clienti restano escluse. Per svuotarlo, cancella tutto e salva."] }), children: _jsxs("div", { className: "st-stack", children: [_jsx("textarea", { className: "st-input st-textarea st-code", rows: 6, value: value.analyticsTag, maxLength: 4000, spellCheck: false, autoCapitalize: "off", autoCorrect: "off", placeholder: '<script defer src="https://cloud.umami.is/script.js" data-website-id="…"></script>', onChange: (event) => update("analyticsTag", event.target.value) }), _jsxs("span", { className: "st-count", children: [value.analyticsTag.length, "/4000"] }), _jsxs("p", { className: "st-inline-help", children: ["\u00C8 codice eseguito su ogni pagina: incolla solo quello che ti ha dato il servizio. Un tag incompleto (per esempio senza ", _jsx("code", { children: "</script>" }), ") pu\u00F2 rompere il sito."] })] }) })] }), _jsxs("section", { className: "st-section", children: [_jsx("h2", { children: "Profilo" }), _jsx(SettingRow, { title: "Nome", help: "Il nome della persona proprietaria del sito. Sar\u00E0 disponibile per future funzioni del pannello.", children: _jsx(Field, { value: value.ownerName, maxLength: 120, placeholder: site().name, onChange: (next) => update("ownerName", next) }) }), _jsxs(SettingRow, { title: "Descrizione del sito", help: "Una descrizione generale pi\u00F9 estesa, conservata come informazione del profilo del sito.", children: [_jsx("textarea", { className: "st-input st-textarea", rows: 5, value: value.siteDescription, maxLength: 1200, onChange: (event) => update("siteDescription", event.target.value) }), _jsxs("span", { className: "st-count", children: [value.siteDescription.length, "/1200"] })] })] })] }));
62
+ return (_jsxs("section", { className: "settings-editor", children: [_jsxs("header", { className: "st-intro", children: [_jsx("span", { children: "Impostazioni generali" }), _jsx("h2", { children: "Sito" }), _jsx("p", { children: "Identit\u00E0, descrizione e accesso al sito pubblico." })] }), _jsxs("section", { className: "st-section", children: [_jsx("h2", { children: "Identit\u00E0 del sito" }), _jsx(SettingRow, { title: "Titolo del sito", help: "Il nome generale del sito, usato anche come nome del publisher nei metadati.", children: _jsx(Field, { value: value.siteTitle, maxLength: 140, placeholder: site().name, onChange: (next) => update("siteTitle", next) }) }), _jsxs(SettingRow, { title: "Descrizione breve", help: "Una frase sintetica usata come descrizione predefinita quando una pagina non ne possiede una specifica.", children: [_jsx("textarea", { className: "st-input st-textarea", rows: 3, value: value.shortDescription, maxLength: 255, onChange: (event) => update("shortDescription", event.target.value) }), _jsxs("span", { className: "st-count", children: [value.shortDescription.length, "/255"] })] }), _jsx(SettingRow, { title: "Favicon", help: "L\u2019icona mostrata nella scheda del browser. \u00C8 preferibile un\u2019immagine quadrata.", children: _jsxs("div", { className: "st-favicon", children: [_jsx("span", { className: "st-favicon-preview", children: value.favicon ? _jsx("img", { src: value.favicon, alt: "Anteprima favicon" }) : _jsx(Icon.pages, {}) }), _jsx("button", { className: "ak-btn", type: "button", onClick: () => void chooseFavicon(), children: value.favicon ? "Cambia favicon" : "Carica favicon" }), value.favicon && (_jsx("button", { className: "ak-btn ak-btn-quiet", type: "button", onClick: () => update("favicon", ""), children: "Rimuovi" }))] }) })] }), _jsxs("section", { className: "st-section", children: [_jsx("h2", { children: "Accesso al sito" }), _jsx(SettingRow, { title: "Password del sito", help: "Quando \u00E8 attiva, ogni pagina e ogni immagine pubblica richiedono la stessa password. L\u2019admin resta raggiungibile tramite il suo accesso protetto.", children: _jsxs("div", { className: "st-stack", children: [_jsx(Field, { type: "password", value: value.password, maxLength: 200, placeholder: value.passwordEnabled ? "Password già impostata · inseriscine una per cambiarla" : "Almeno 8 caratteri", disabled: d1Content && !managedSiteAccess, onChange: (next) => update("password", next) }), _jsx(Toggle, { checked: value.passwordEnabled, label: "Protezione con password", disabled: d1Content && !managedSiteAccess, onChange: (next) => update("passwordEnabled", next) }), d1Content && !managedSiteAccess && (_jsx("p", { className: "st-inline-help", children: "La protezione esistente resta attiva e viene gestita dall\u2019operatore durante la migrazione D1." })), managedSiteAccess && (_jsx("p", { className: "st-inline-help", children: "La modifica resta in bozza e diventa effettiva solo dopo una pubblicazione completata." })), managedSiteAccess && passwordDraftPending && onDiscardPasswordDraft && (_jsx("button", { className: "ak-btn ak-btn-quiet", type: "button", onClick: onDiscardPasswordDraft, children: "Annulla la modifica salvata" })), value.passwordEnabled && (_jsx("p", { className: "st-inline-help", children: "Lascia il campo vuoto per mantenere la password attuale." }))] }) })] }), _jsxs("section", { className: "st-section" + (focus === "funzioni" ? " is-focused" : ""), ref: extras, children: [_jsx("h2", { children: "Funzioni aggiuntive" }), EXTRAS.map((extra) => (_jsx(SettingRow, { title: extra.title, help: extra.help, children: _jsx(Toggle, { checked: value.features[extra.key], disabled: true, label: value.features[extra.key] ? "Attiva su questo sito" : "Non attiva", onChange: () => { } }) }, extra.key))), _jsxs("p", { className: "st-inline-help st-extras-note", children: ["Queste funzioni si attivano separatamente. Per accenderle su questo sito scrivi a", " ", _jsx("strong", { children: site().support.name }), ":", " ", _jsx("a", { href: `mailto:${site().support.email}`, children: site().support.email }), "."] })] }), _jsxs("section", { className: "st-section", children: [_jsx("h2", { children: "Statistiche" }), _jsx(SettingRow, { title: "Tag Analytics", help: _jsxs(_Fragment, { children: ["Il codice fornito dal servizio di statistiche (Umami, Google Analytics, altro). Si incolla qui cos\u00EC com\u2019\u00E8: finisce dentro ", _jsx("code", { children: "<head>" }), " di ogni pagina del sito alla prossima pubblicazione. Le pagine di consegna ai clienti restano escluse. Per svuotarlo, cancella tutto e salva."] }), children: _jsxs("div", { className: "st-stack", children: [_jsx("textarea", { className: "st-input st-textarea st-code", rows: 6, value: value.analyticsTag, maxLength: 4000, spellCheck: false, autoCapitalize: "off", autoCorrect: "off", placeholder: '<script defer src="https://cloud.umami.is/script.js" data-website-id="…"></script>', onChange: (event) => update("analyticsTag", event.target.value) }), _jsxs("span", { className: "st-count", children: [value.analyticsTag.length, "/4000"] }), _jsxs("p", { className: "st-inline-help", children: ["\u00C8 codice eseguito su ogni pagina: incolla solo quello che ti ha dato il servizio. Un tag incompleto (per esempio senza ", _jsx("code", { children: "</script>" }), ") pu\u00F2 rompere il sito."] })] }) })] }), _jsxs("section", { className: "st-section", children: [_jsx("h2", { children: "Profilo" }), _jsx(SettingRow, { title: "Nome", help: "Il nome della persona proprietaria del sito. Sar\u00E0 disponibile per future funzioni del pannello.", children: _jsx(Field, { value: value.ownerName, maxLength: 120, placeholder: site().name, onChange: (next) => update("ownerName", next) }) }), _jsxs(SettingRow, { title: "Descrizione del sito", help: "Una descrizione generale pi\u00F9 estesa, conservata come informazione del profilo del sito.", children: [_jsx("textarea", { className: "st-input st-textarea", rows: 5, value: value.siteDescription, maxLength: 1200, onChange: (event) => update("siteDescription", event.target.value) }), _jsxs("span", { className: "st-count", children: [value.siteDescription.length, "/1200"] })] })] })] }));
62
63
  }
63
64
  export function WebsiteSeoSettingsEditor({ value, onChange, }) {
64
65
  const update = (key, next) => onChange({ ...value, [key]: next });
@@ -12,11 +12,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
12
12
  */
13
13
  import { useEffect, useState } from "react";
14
14
  import { cancelUpload, dismissUpload, isBusy, pauseUpload, resumeUpload, useUploads, } from "../lib/upload-queue";
15
+ import { dismissMediaUpload, useMediaUploads, } from "../../form/media/media-upload-queue.js";
15
16
  export function UploadDock() {
16
17
  const jobs = useUploads();
18
+ const mediaJobs = useMediaUploads();
17
19
  const [open, setOpen] = useState(true);
18
20
  const [confirmStop, setConfirmStop] = useState(null);
19
- const busy = jobs.some((job) => job.state === "running");
21
+ const busy = jobs.some(isBusy) || mediaJobs.some((job) => job.state === "running");
20
22
  useEffect(() => {
21
23
  if (!busy)
22
24
  return;
@@ -24,21 +26,41 @@ export function UploadDock() {
24
26
  window.addEventListener("beforeunload", warn);
25
27
  return () => window.removeEventListener("beforeunload", warn);
26
28
  }, [busy]);
27
- if (!jobs.length)
29
+ if (!jobs.length && !mediaJobs.length)
28
30
  return null;
29
- return (_jsxs("aside", { className: "uk" + (open ? "" : " is-closed"), "aria-label": "Caricamenti", children: [_jsxs("button", { className: "uk-head", onClick: () => setOpen((value) => !value), "aria-expanded": open, children: [_jsx("span", { className: "uk-head-text", children: summary(jobs) }), _jsx("span", { className: "uk-chevron", "aria-hidden": "true", children: open ? "▾" : "▴" })] }), open && (_jsx("ul", { className: "uk-list", children: jobs.map((job) => (_jsxs("li", { className: "uk-job is-" + job.state, children: [_jsxs("div", { className: "uk-job-top", children: [_jsx("strong", { children: job.title }), _jsx("span", { children: line(job) })] }), isBusy(job) && (_jsx("div", { className: "uk-track", children: _jsx("i", { style: { width: `${percent(job)}%` } }) })), job.error && _jsx("p", { className: "uk-error", children: job.error }), _jsxs("div", { className: "uk-job-actions", children: [job.state === "running" && (_jsx("button", { className: "ak-btn", onClick: () => pauseUpload(job.id), children: "Pausa" })), job.state === "paused" && (_jsx("button", { className: "ak-btn", onClick: () => resumeUpload(job.id), children: "Riprendi" })), isBusy(job) && (confirmStop === job.id ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "uk-confirm", children: "I file gi\u00E0 caricati verranno buttati." }), _jsx("button", { className: "ak-btn", onClick: () => setConfirmStop(null), children: "No" }), _jsx("button", { className: "ak-btn dl-danger-btn", onClick: () => { cancelUpload(job.id); setConfirmStop(null); }, children: "Annulla il caricamento" })] })) : (_jsx("button", { className: "ak-btn ak-btn-quiet", onClick: () => setConfirmStop(job.id), children: "Annulla" }))), !isBusy(job) && (_jsx("button", { className: "ak-btn ak-btn-quiet", onClick: () => dismissUpload(job.id), children: "Togli dall'elenco" }))] })] }, job.id))) }))] }));
31
+ return (_jsxs("aside", { className: "uk" + (open ? "" : " is-closed"), "aria-label": "Caricamenti", children: [_jsxs("button", { className: "uk-head", onClick: () => setOpen((value) => !value), "aria-expanded": open, children: [_jsx("span", { className: "uk-head-text", children: summary(jobs, mediaJobs) }), _jsx("span", { className: "uk-chevron", "aria-hidden": "true", children: open ? "▾" : "▴" })] }), open && (_jsxs("ul", { className: "uk-list", children: [mediaJobs.map((job) => _jsx(MediaJob, { job: job }, job.id)), jobs.map((job) => (_jsxs("li", { className: "uk-job is-" + job.state, children: [_jsxs("div", { className: "uk-job-top", children: [_jsx("strong", { children: job.title }), _jsx("span", { children: line(job) })] }), isBusy(job) && (_jsx("div", { className: "uk-track", children: _jsx("i", { style: { width: `${percent(job)}%` } }) })), job.error && _jsx("p", { className: "uk-error", children: job.error }), _jsxs("div", { className: "uk-job-actions", children: [job.state === "running" && (_jsx("button", { className: "ak-btn", onClick: () => pauseUpload(job.id), children: "Pausa" })), job.state === "paused" && (_jsx("button", { className: "ak-btn", onClick: () => resumeUpload(job.id), children: "Riprendi" })), isBusy(job) && (confirmStop === job.id ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "uk-confirm", children: "I file gi\u00E0 caricati verranno buttati." }), _jsx("button", { className: "ak-btn", onClick: () => setConfirmStop(null), children: "No" }), _jsx("button", { className: "ak-btn dl-danger-btn", onClick: () => { cancelUpload(job.id); setConfirmStop(null); }, children: "Annulla il caricamento" })] })) : (_jsx("button", { className: "ak-btn ak-btn-quiet", onClick: () => setConfirmStop(job.id), children: "Annulla" }))), !isBusy(job) && (_jsx("button", { className: "ak-btn ak-btn-quiet", onClick: () => dismissUpload(job.id), children: "Togli dall'elenco" }))] })] }, job.id)))] }))] }));
30
32
  }
31
- function summary(jobs) {
32
- const running = jobs.filter((job) => job.state === "running").length;
33
+ function MediaJob({ job }) {
34
+ const conversionPercent = job.total ? (job.conversionDone / job.total) * 100 : 0;
35
+ const uploadPercent = job.total ? (job.uploadDone / job.total) * 100 : 0;
36
+ return (_jsxs("li", { className: "uk-job uk-media-job is-" + job.state, children: [_jsxs("div", { className: "uk-job-top", children: [_jsx("strong", { children: job.title }), _jsx("span", { children: mediaLine(job) })] }), _jsxs("div", { className: "uk-phase", children: [_jsxs("span", { children: [job.autoWebp ? "Conversione" : "Preparazione", " \u00B7 ", job.conversionDone, "/", job.total] }), _jsx("div", { className: "uk-track", children: _jsx("i", { style: { width: `${Math.max(3, conversionPercent)}%` } }) })] }), _jsxs("div", { className: "uk-phase", children: [_jsxs("span", { children: ["Upload \u00B7 ", job.uploadDone, "/", job.total] }), _jsx("div", { className: "uk-track", children: _jsx("i", { style: { width: `${Math.max(3, uploadPercent)}%` } }) })] }), job.originals > 0 && (_jsx("p", { className: "uk-warning", children: job.originals === 1 ? "1 originale caricato dopo tre tentativi di conversione." :
37
+ `${job.originals} originali caricati dopo tre tentativi di conversione.` })), job.errors.map((error, index) => _jsx("p", { className: "uk-error", children: error }, `${index}-${error}`)), job.state !== "running" && (_jsx("div", { className: "uk-job-actions", children: _jsx("button", { className: "ak-btn ak-btn-quiet", onClick: () => dismissMediaUpload(job.id), children: "Togli dall\u2019elenco" }) }))] }));
38
+ }
39
+ function mediaLine(job) {
40
+ if (job.state === "failed")
41
+ return `Completato con ${job.failed} ${job.failed === 1 ? "errore" : "errori"}`;
42
+ if (job.state === "done")
43
+ return job.originals ? "Completato con originali" : "Completato";
44
+ if (job.uploadDone < job.conversionDone)
45
+ return "Conversione e upload in parallelo";
46
+ return job.autoWebp ? "Converto le immagini" : "Preparo i file";
47
+ }
48
+ function summary(jobs, mediaJobs) {
49
+ const running = jobs.filter((job) => job.state === "running").length +
50
+ mediaJobs.filter((job) => job.state === "running").length;
33
51
  const paused = jobs.filter((job) => job.state === "paused").length;
34
- const failed = jobs.filter((job) => job.state === "failed").length;
52
+ const interrupted = jobs.filter((job) => job.state === "failed").length;
53
+ const partial = mediaJobs.filter((job) => job.state === "failed").length;
54
+ const total = jobs.length + mediaJobs.length;
35
55
  if (running)
36
56
  return running === 1 ? "Caricamento in corso" : `${running} caricamenti in corso`;
37
57
  if (paused)
38
58
  return paused === 1 ? "Caricamento in pausa" : `${paused} caricamenti in pausa`;
39
- if (failed)
40
- return failed === 1 ? "Caricamento interrotto" : `${failed} caricamenti interrotti`;
41
- return jobs.length === 1 ? "Caricamento completato" : "Caricamenti completati";
59
+ if (interrupted)
60
+ return interrupted === 1 ? "Caricamento interrotto" : `${interrupted} caricamenti interrotti`;
61
+ if (partial)
62
+ return partial === 1 ? "Caricamento completato con errori" : `${partial} caricamenti con errori`;
63
+ return total === 1 ? "Caricamento completato" : "Caricamenti completati";
42
64
  }
43
65
  function line(job) {
44
66
  if (job.state === "done") {
@@ -82,6 +82,10 @@ export interface SiteAdminConfig {
82
82
  fromPayload?: (payload: unknown) => Record<string, unknown>;
83
83
  toPayload?: (settings: Record<string, unknown>) => unknown;
84
84
  };
85
+ /** Separate versioned D1 access control. Passwords never enter an editorial document. */
86
+ siteAccess?: {
87
+ siteId: string;
88
+ };
85
89
  }
86
90
  /** Chiamata da `mountAdmin` prima del primo render. */
87
91
  export declare function setSiteConfig(config: SiteAdminConfig): void;
@@ -191,13 +191,26 @@ export class D1DeploymentAttemptRepository {
191
191
  WHERE draft_revision_id IN (SELECT revision_id FROM content_release_entries WHERE release_id = ?)
192
192
  AND document_id IN (SELECT document_id FROM content_release_entries WHERE release_id = ?) AND ${guard}`)
193
193
  .bind(input.now, input.releaseId, input.releaseId, input.attemptId, input.releaseId, input.tokenChecksum, input.now),
194
+ this.db.prepare(`UPDATE site_access_versions SET state = 'superseded', superseded_at = ?, rollback_expires_at = ?
195
+ WHERE state = 'active' AND version_id IS NOT (SELECT version_id FROM content_release_site_access WHERE release_id = ?)
196
+ AND EXISTS (SELECT 1 FROM content_release_site_access WHERE release_id = ?) AND ${guard}`)
197
+ .bind(input.now, input.now + 7 * 24 * 60 * 60 * 1000, input.releaseId, input.releaseId, input.attemptId, input.releaseId, input.tokenChecksum, input.now),
198
+ this.db.prepare(`UPDATE site_access_versions SET state = 'active', activated_at = ?
199
+ WHERE version_id = (SELECT version_id FROM content_release_site_access WHERE release_id = ?) AND state = 'draft' AND ${guard}`)
200
+ .bind(input.now, input.releaseId, input.attemptId, input.releaseId, input.tokenChecksum, input.now),
201
+ this.db.prepare(`UPDATE site_access_state SET
202
+ active_version_id = COALESCE((SELECT version_id FROM content_release_site_access WHERE release_id = ?), active_version_id),
203
+ draft_version_id = CASE WHEN draft_version_id = (SELECT version_id FROM content_release_site_access WHERE release_id = ?) THEN NULL ELSE draft_version_id END,
204
+ updated_at = ?
205
+ WHERE singleton = 1 AND EXISTS (SELECT 1 FROM content_release_site_access WHERE release_id = ?) AND ${guard}`)
206
+ .bind(input.releaseId, input.releaseId, input.now, input.releaseId, input.attemptId, input.releaseId, input.tokenChecksum, input.now),
194
207
  this.db.prepare(`UPDATE production_deployment_attempts SET phase = 'succeeded', active_slot = NULL, updated_at = ?, finished_at = ?
195
208
  WHERE attempt_id = ? AND release_id = ? AND phase = 'verifying_canonical' AND ci_claim_token_checksum = ?
196
209
  AND ci_claim_expires_at > ? AND EXISTS (SELECT 1 FROM content_site_state WHERE singleton = 1 AND current_release_id = ?)`)
197
210
  .bind(input.now, input.now, input.attemptId, input.releaseId, input.tokenChecksum, input.now, input.releaseId),
198
211
  ];
199
212
  const results = await this.db.batch(statements);
200
- if (results[0]?.meta?.changes !== 1 || results[1]?.meta?.changes !== 1 || results[3]?.meta?.changes !== 1) {
213
+ if (results[0]?.meta?.changes !== 1 || results[1]?.meta?.changes !== 1 || results[6]?.meta?.changes !== 1) {
201
214
  throw new Error("attempt_unavailable");
202
215
  }
203
216
  }
@@ -9,7 +9,7 @@ export declare function FolderMenu({ lib, prefix, label, openTo }: {
9
9
  label: string;
10
10
  openTo?: "left" | "auto";
11
11
  }): import("react").JSX.Element;
12
- export declare function MediaFolders({ lib, trash }: {
12
+ export declare function MediaFolders({ lib, trash, settings }: {
13
13
  lib: MediaLibrary;
14
14
  /** `leave` riporta ai media: dal cestino, cliccare una cartella deve anche USCIRE dal cestino. */
15
15
  trash?: {
@@ -17,4 +17,8 @@ export declare function MediaFolders({ lib, trash }: {
17
17
  open: () => void;
18
18
  leave: () => void;
19
19
  };
20
+ settings?: {
21
+ active: boolean;
22
+ open: () => void;
23
+ };
20
24
  }): import("react").JSX.Element;
@@ -63,10 +63,11 @@ export function FolderMenu({ lib, prefix, label, openTo = "left" }) {
63
63
  } }), _jsx("span", { className: "ak-hint", children: "Cambia anche l'indirizzo delle foto dentro. Le pagine del sito vengono aggiornate; i link scritti a mano altrove no." }), _jsxs("div", { className: "mf-menu-row", children: [_jsx("button", { className: "ak-btn ak-btn-primary", disabled: !name.trim(), onClick: () => { close(); void lib.renameFolder(prefix, name); }, children: "Rinomina" }), _jsx("button", { className: "ak-btn ak-btn-quiet", onClick: close, children: "Annulla" })] })] }))] }));
64
64
  }
65
65
  const IcoTrash = () => (_jsx("svg", { className: "mf-ico", viewBox: "0 0 16 16", width: "15", height: "15", "aria-hidden": "true", children: _jsx("path", { fill: "none", stroke: "currentColor", strokeWidth: "1.4", strokeLinecap: "round", strokeLinejoin: "round", d: "M3 4.5h10M6 4.5V3h4v1.5M4.5 4.5 5 13.5h6l.5-9" }) }));
66
+ const IcoSettings = () => (_jsxs("svg", { className: "mf-ico", viewBox: "0 0 16 16", width: "15", height: "15", "aria-hidden": "true", children: [_jsx("path", { fill: "none", stroke: "currentColor", strokeWidth: "1.35", strokeLinecap: "round", strokeLinejoin: "round", d: "M6.6 2.2h2.8l.4 1.5c.4.2.8.4 1.1.7l1.5-.4 1.4 2.4-1.1 1.1v1l1.1 1.1-1.4 2.4-1.5-.4c-.3.3-.7.5-1.1.7l-.4 1.5H6.6l-.4-1.5a5 5 0 0 1-1.1-.7l-1.5.4-1.4-2.4 1.1-1.1v-1L2.2 6.4 3.6 4l1.5.4c.3-.3.7-.5 1.1-.7l.4-1.5Z" }), _jsx("circle", { cx: "8", cy: "8", r: "2", fill: "none", stroke: "currentColor", strokeWidth: "1.35" })] }));
66
67
  /* `trash` è facoltativo perché il cestino è una ROTTA, e le rotte le conosce l'applicazione, non
67
68
  * questo componente. Sta qui in fondo, sotto «+ Cartella», perché è l'ultimo posto dove finiscono
68
69
  * le foto: nella colonna che elenca dove stanno, non in una barra da un'altra parte. */
69
- export function MediaFolders({ lib, trash }) {
70
+ export function MediaFolders({ lib, trash, settings }) {
70
71
  const [over, setOver] = useState(null);
71
72
  const [draggingFolder, setDraggingFolder] = useState(false);
72
73
  const canDrop = lib.mode === "manage";
@@ -112,5 +113,5 @@ export function MediaFolders({ lib, trash }) {
112
113
  + (over === f.prefix ? " is-over" : ""), onDragOver: (e) => { if (canDrop) {
113
114
  e.preventDefault();
114
115
  setOver(f.prefix);
115
- } }, onDragLeave: () => setOver((p) => (p === f.prefix ? null : p)), onDrop: drop(f.prefix), children: [_jsxs("button", { className: "mf-open", onClick: () => goTo(f.prefix), children: [_jsx("span", { className: "mf-name", children: f.label || f.name }), f.count != null && _jsx("span", { className: "mf-count", children: f.count })] }), canDrop && _jsx(FolderMenu, { lib: lib, prefix: f.prefix, label: f.label || f.name })] }, f.prefix))) }), draggingFolder && (_jsx("div", { className: "mf-root-drop" + (over === "__root" ? " is-over" : ""), onDragOver: (e) => { e.preventDefault(); setOver("__root"); }, onDragLeave: () => setOver((p) => (p === "__root" ? null : p)), onDrop: drop(""), children: "Porta al primo livello" })), canDrop && (_jsx("button", { className: "ak-btn ak-btn-quiet mf-new", onClick: () => void lib.newFolder(""), children: "+ Cartella" })), trash && (_jsx("ul", { className: "mf-list mf-tail", children: _jsx("li", { className: "mf-item" + (trash.active ? " is-on" : ""), children: _jsxs("button", { className: "mf-open", onClick: trash.open, children: [_jsx(IcoTrash, {}), _jsx("span", { className: "mf-name", children: "Cestino" })] }) }) }))] }));
116
+ } }, onDragLeave: () => setOver((p) => (p === f.prefix ? null : p)), onDrop: drop(f.prefix), children: [_jsxs("button", { className: "mf-open", onClick: () => goTo(f.prefix), children: [_jsx("span", { className: "mf-name", children: f.label || f.name }), f.count != null && _jsx("span", { className: "mf-count", children: f.count })] }), canDrop && _jsx(FolderMenu, { lib: lib, prefix: f.prefix, label: f.label || f.name })] }, f.prefix))) }), draggingFolder && (_jsx("div", { className: "mf-root-drop" + (over === "__root" ? " is-over" : ""), onDragOver: (e) => { e.preventDefault(); setOver("__root"); }, onDragLeave: () => setOver((p) => (p === "__root" ? null : p)), onDrop: drop(""), children: "Porta al primo livello" })), canDrop && (_jsx("button", { className: "ak-btn ak-btn-quiet mf-new", onClick: () => void lib.newFolder(""), children: "+ Cartella" })), trash && (_jsx("ul", { className: "mf-list mf-tail", children: _jsx("li", { className: "mf-item" + (trash.active ? " is-on" : ""), children: _jsxs("button", { className: "mf-open", onClick: trash.open, children: [_jsx(IcoTrash, {}), _jsx("span", { className: "mf-name", children: "Cestino" })] }) }) })), settings && (_jsx("ul", { className: "mf-list mf-settings-tail", children: _jsx("li", { className: "mf-item" + (settings.active ? " is-on" : ""), children: _jsxs("button", { className: "mf-open", onClick: settings.open, children: [_jsx(IcoSettings, {}), _jsx("span", { className: "mf-name", children: "Impostazioni" })] }) }) }))] }));
116
117
  }