@cardinali-studio/adminkit 2.2.10 → 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 (50) hide show
  1. package/dist/api.d.ts +58 -2
  2. package/dist/api.js +39 -5
  3. package/dist/app/App.js +10 -4
  4. package/dist/app/app.css +5 -0
  5. package/dist/app/lib/publish.js +9 -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/MediaGrid.js +10 -1
  18. package/dist/form/media/image-conversion.d.ts +14 -0
  19. package/dist/form/media/image-conversion.js +163 -0
  20. package/dist/form/media/media-upload-queue.d.ts +31 -0
  21. package/dist/form/media/media-upload-queue.js +230 -0
  22. package/dist/form/media/useMediaLibrary.d.ts +7 -0
  23. package/dist/form/media/useMediaLibrary.js +123 -22
  24. package/dist/functions/_shared/assets.js +3 -2
  25. package/dist/functions/_shared/d1-media.js +317 -0
  26. package/dist/functions/api/assets/delete.js +19 -1
  27. package/dist/functions/api/assets/folders.js +5 -2
  28. package/dist/functions/api/assets/media.js +30 -0
  29. package/dist/functions/api/assets/move.js +22 -6
  30. package/dist/functions/api/assets/settings.js +40 -0
  31. package/dist/functions/api/assets/upload.js +4 -1
  32. package/dist/functions/api/assets/usage.js +5 -1
  33. package/dist/functions/api/assets.js +7 -4
  34. package/dist/functions/api/content-ci.js +8 -1
  35. package/dist/functions/api/site-access.js +78 -0
  36. package/dist/functions/index.js +6 -0
  37. package/dist/functions/site.js +4 -0
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.js +1 -0
  40. package/dist/media.d.ts +52 -0
  41. package/dist/media.js +103 -1
  42. package/dist/migrations/0004_site_access.sql +41 -0
  43. package/dist/persistence.d.ts +10 -0
  44. package/dist/persistence.js +129 -8
  45. package/dist/release.d.ts +3 -0
  46. package/dist/release.js +7 -0
  47. package/dist/site-access.d.ts +48 -0
  48. package/dist/site-access.js +180 -0
  49. package/dist/theme.css +13 -0
  50. package/package.json +4 -1
package/dist/api.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type JsonValue } from "./persistence.js";
2
+ import type { MediaOperationStatus } from "./media.js";
2
3
  export declare class ApiError extends Error {
3
4
  code: string;
4
5
  status: number;
@@ -75,6 +76,16 @@ export type ContentDocument = {
75
76
  publishedPayload: string | null;
76
77
  publishedTombstone: boolean | null;
77
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
+ };
78
89
  /** Builds the only D1 Save wire shape. The Worker recomputes these hashes too,
79
90
  * so retries are deterministic rather than client-trusted. */
80
91
  export declare function contentSaveRequest(command: ContentSaveCommand): Promise<{
@@ -132,6 +143,20 @@ export declare const api: {
132
143
  ok: true;
133
144
  discarded: true;
134
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
+ }>;
135
160
  /** D1 publication is site-owned: the site chooses which documents it can
136
161
  * materialize, while the panel only displays the pending immutable inputs. */
137
162
  contentPublishState: () => Promise<{
@@ -143,6 +168,10 @@ export declare const api: {
143
168
  kind: string;
144
169
  updatedAt: number;
145
170
  }[];
171
+ mediaOperations?: MediaOperationStatus[];
172
+ siteAccess?: {
173
+ enabled: boolean;
174
+ } | null;
146
175
  activeReleaseId?: string | null;
147
176
  }>;
148
177
  contentPublish: () => Promise<{
@@ -219,6 +248,19 @@ export declare const api: {
219
248
  }[];
220
249
  warning?: string;
221
250
  }>;
251
+ mediaOperations: () => Promise<{
252
+ ok: true;
253
+ operations: MediaOperationStatus[];
254
+ }>;
255
+ resumeMediaOperation: (operationId: string, releaseId: string) => Promise<{
256
+ ok: true;
257
+ completed: number;
258
+ failed: number;
259
+ }>;
260
+ cancelMediaOperation: (operationId: string) => Promise<{
261
+ ok: true;
262
+ cancelled: true;
263
+ }>;
222
264
  usage: (target: {
223
265
  keys?: string[];
224
266
  prefixes?: string[];
@@ -258,7 +300,21 @@ export declare const api: {
258
300
  ok: true;
259
301
  }>;
260
302
  /** L'upload NON è JSON: corpo grezzo e metadati negli header (vedi assets/upload.js). */
261
- 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
+ }>;
262
318
  trash: () => Promise<{
263
319
  ok: true;
264
320
  items: TrashItem[];
@@ -280,4 +336,4 @@ export declare const api: {
280
336
  removed: number;
281
337
  }>;
282
338
  };
283
- 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"),
@@ -78,14 +81,38 @@ export const api = {
78
81
  /** `label` è il nome per esteso; lo slug della chiave lo calcola il server e lo restituisce. */
79
82
  createFolder: (parent, name, opts = {}) => post("/admin/api/assets/folder", { parent, name, ...opts }),
80
83
  /** Con `skipRefs` copia senza cancellare gli originali e restituisce la mappa degli URL. */
81
- move: (keys, dest, opts = {}) => post("/admin/api/assets/move", { keys, dest, ...opts }),
84
+ move: async (keys, dest, opts = {}) => {
85
+ const operationId = crypto.randomUUID();
86
+ const destinationPrefix = dest.trim().endsWith("/") ? dest.trim() : `${dest.trim()}/`;
87
+ const sourceKeys = [...new Set(keys)].filter((key) => {
88
+ const slash = key.lastIndexOf("/");
89
+ const parent = slash < 0 ? "" : key.slice(0, slash + 1);
90
+ return parent !== destinationPrefix;
91
+ });
92
+ if (!sourceKeys.length)
93
+ return {
94
+ ok: true, moved: 0, finalized: 0, urlMap: {}, pagesUpdated: [], failed: [],
95
+ };
96
+ const requestChecksum = await canonicalChecksum({ action: "media.operation", operationId, type: "move", sourceKeys, sourcePrefix: null, destinationPrefix });
97
+ return post("/admin/api/assets/move", { keys: sourceKeys, dest: destinationPrefix, operationId, requestChecksum, ...opts });
98
+ },
82
99
  /** Riscrive i riferimenti, poi elimina gli originali le cui copie sono state verificate. */
83
100
  moveApplyRefs: (applyRefs) => post("/admin/api/assets/move", { applyRefs }),
101
+ mediaOperations: () => call("/admin/api/assets/media"),
102
+ resumeMediaOperation: (operationId, releaseId) => post("/admin/api/assets/media", { action: "resume", operationId, releaseId }),
103
+ cancelMediaOperation: (operationId) => post("/admin/api/assets/media", { action: "cancel", operationId }),
84
104
  usage: (target) => post("/admin/api/assets/usage", target),
85
105
  /* `refsOnly` pulisce solo i riferimenti nelle pagine (un commit, niente file spostati);
86
106
  * `skipRefs` sposta i file e non tocca il repo. Servono a spezzare una cancellazione grande
87
107
  * in più richieste senza fare un commit — cioè una build — per ogni gruppo. */
88
- remove: (target, opts = {}) => post("/admin/api/assets/delete", { ...target, ...opts }),
108
+ remove: async (target, opts = {}) => {
109
+ const operationId = crypto.randomUUID();
110
+ const sourceKeys = target.prefixes?.length ? null : [...new Set(target.keys ?? [])];
111
+ const requestChecksum = sourceKeys?.length
112
+ ? await canonicalChecksum({ action: "media.operation", operationId, type: "delete", sourceKeys, sourcePrefix: null, destinationPrefix: null })
113
+ : undefined;
114
+ return post("/admin/api/assets/delete", { ...target, operationId, ...(requestChecksum ? { requestChecksum } : {}), ...opts });
115
+ },
89
116
  // ---- richieste dal form di contatto
90
117
  requests: () => call("/admin/api/requests"),
91
118
  markRequest: (id, handled) => call("/admin/api/requests", {
@@ -99,15 +126,18 @@ export const api = {
99
126
  body: JSON.stringify({ id }),
100
127
  }),
101
128
  /** L'upload NON è JSON: corpo grezzo e metadati negli header (vedi assets/upload.js). */
102
- upload: (prefix, file) => call("/admin/api/assets/upload", {
129
+ upload: (prefix, file, metadata = {}) => call("/admin/api/assets/upload", {
103
130
  method: "POST",
104
131
  headers: {
105
132
  "Content-Type": file.type || "application/octet-stream",
106
133
  "X-Prefix": prefix,
107
134
  "X-Filename": encodeURIComponent(file.name),
135
+ ...(metadata.capturedAt ? { "X-Captured-At": metadata.capturedAt } : {}),
108
136
  },
109
137
  body: file,
110
138
  }),
139
+ mediaSettings: () => call("/admin/api/assets/settings"),
140
+ saveMediaSettings: (autoWebp) => post("/admin/api/assets/settings", { autoWebp }),
111
141
  // ---- cestino
112
142
  trash: () => call("/admin/api/assets/trash"),
113
143
  trashRestore: (trashKeys) => post("/admin/api/assets/trash/restore", { trashKeys }),
@@ -118,14 +148,18 @@ export const api = {
118
148
  * (app.js:922), il che su quaranta foto è tedioso senza motivo; tutte insieme invece saturano
119
149
  * la connessione e rendono il progresso illeggibile. Tre è il compromesso.
120
150
  * Un file che fallisce non ferma gli altri: si segnala e si prosegue. */
121
- export async function uploadQueue(prefix, files, onProgress, concurrency = 3) {
151
+ export async function uploadQueue(prefix, files, onProgress, concurrency = 3, prepare = async (file) => file) {
122
152
  const out = [];
123
153
  let done = 0, next = 0;
124
154
  const worker = async () => {
125
155
  while (next < files.length) {
126
156
  const file = files[next++];
127
157
  try {
128
- 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);
129
163
  out.push(r);
130
164
  onProgress(++done, files.length, r);
131
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; }
@@ -22,7 +22,15 @@ export function usePublish() {
22
22
  setAhead(d.ahead ?? 0);
23
23
  setCommits((d.documents ?? []).map((document) => ({
24
24
  message: `Modifica salvata (${document.kind})`, date: document.updatedAt ? new Date(document.updatedAt).toISOString() : null,
25
- })));
25
+ })).concat((d.mediaOperations ?? [])
26
+ .filter((operation) => operation.state === "ready")
27
+ .map((operation) => ({
28
+ message: operation.type === "move" ? "Spostamento Media pronto" : "Eliminazione Media pronta",
29
+ date: operation.updatedAt ? new Date(operation.updatedAt).toISOString() : null,
30
+ }))).concat(d.siteAccess ? [{
31
+ message: d.siteAccess.enabled ? "Attivazione o cambio password del sito" : "Disattivazione password del sito",
32
+ date: null,
33
+ }] : []));
26
34
  setConfigured(true);
27
35
  setError(null);
28
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;