@cardinali-studio/adminkit 2.2.25 → 2.2.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/api.d.ts +19 -0
  2. package/dist/api.js +3 -0
  3. package/dist/app/App.js +22 -8
  4. package/dist/app/app.css +3 -3
  5. package/dist/app/delivery.d.ts +11 -0
  6. package/dist/app/delivery.js +46 -0
  7. package/dist/app/index.d.ts +4 -1
  8. package/dist/app/index.js +3 -2
  9. package/dist/app/lib/bookings.js +5 -0
  10. package/dist/app/lib/deliveries.d.ts +8 -0
  11. package/dist/app/lib/deliveries.js +7 -0
  12. package/dist/app/lib/delivery-network.d.ts +25 -0
  13. package/dist/app/lib/delivery-network.js +159 -0
  14. package/dist/app/lib/delivery-upload.d.ts +29 -11
  15. package/dist/app/lib/delivery-upload.js +408 -131
  16. package/dist/app/lib/home.js +7 -4
  17. package/dist/app/lib/pages.js +2 -1
  18. package/dist/app/lib/publish.d.ts +5 -0
  19. package/dist/app/lib/publish.js +61 -4
  20. package/dist/app/lib/requests.js +6 -0
  21. package/dist/app/lib/settings.js +7 -3
  22. package/dist/app/lib/upload-queue.d.ts +21 -3
  23. package/dist/app/lib/upload-queue.js +76 -19
  24. package/dist/app/routes/Deliveries.js +108 -29
  25. package/dist/app/routes/PageActions.d.ts +1 -1
  26. package/dist/app/routes/PageActions.js +5 -2
  27. package/dist/app/routes/PageEditor.js +6 -4
  28. package/dist/app/routes/PageSeoEditor.js +6 -1
  29. package/dist/app/routes/PagesList.js +5 -1
  30. package/dist/app/routes/UploadDock.js +6 -2
  31. package/dist/app/site.d.ts +13 -0
  32. package/dist/direct-publication.d.ts +20 -0
  33. package/dist/direct-publication.js +75 -0
  34. package/dist/functions/_shared/b2.js +22 -3
  35. package/dist/functions/_shared/delivery-crypto.js +5 -0
  36. package/dist/functions/_shared/delivery-limits.d.ts +9 -0
  37. package/dist/functions/_shared/delivery-limits.js +29 -0
  38. package/dist/functions/_shared/delivery-manifest-v2.sql +49 -0
  39. package/dist/functions/_shared/delivery-manifest.js +392 -0
  40. package/dist/functions/_shared/delivery-purge.js +17 -1
  41. package/dist/functions/api/deliveries.js +177 -49
  42. package/dist/functions/api/delivery/image.js +11 -6
  43. package/dist/functions/api/delivery/multipart.js +196 -64
  44. package/dist/functions/delivery-public/assets/client.js +246 -0
  45. package/dist/functions/delivery-public/assets/delivery.css +297 -0
  46. package/dist/functions/delivery-public/assets/gallery.js +243 -0
  47. package/dist/functions/delivery-public/assets/stage.js +107 -0
  48. package/dist/functions/delivery-public/index.js +637 -0
  49. package/dist/functions/delivery-public/migrations/0002_deliveries.sql +128 -0
  50. package/dist/functions/delivery-public/migrations/0003_delivery_quick_hero.sql +34 -0
  51. package/dist/functions/delivery-public/migrations/0004_delivery_file_expiry.sql +9 -0
  52. package/dist/functions/delivery-public/migrations/0006_delivery_manifest_v2.sql +49 -0
  53. package/dist/functions/delivery-public/migrations/0007_delivery_link_aliases.sql +8 -0
  54. package/dist/functions/delivery-public/migrations/0008_delivery_web_format.sql +2 -0
  55. package/dist/functions/delivery-public/migrations/0009_delivery_download_labels.sql +2 -0
  56. package/dist/functions/index.js +3 -0
  57. package/dist/index.d.ts +1 -0
  58. package/dist/index.js +1 -0
  59. package/package.json +10 -3
@@ -22,7 +22,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
22
22
  * Il vocabolario completo (e il perché di ogni parola) sta in DELIVERY-REDESIGN.md §2.
23
23
  */
24
24
  import { useEffect, useRef, useState } from "react";
25
- import { deliverySelectionName, deliverySourceFingerprint, uploadDeliveryHeroObject, } from "../lib/delivery-upload";
25
+ import { deliverySelectionName, deliverySourceFingerprint, uploadDeliveryHeroObject, preflightDeliverySelection, } from "../lib/delivery-upload";
26
26
  import { isBusy, startUpload, uploadFor, useUploadCompletions, useUploads, } from "../lib/upload-queue";
27
27
  import { filesFromDrop } from "../lib/delivery-drop";
28
28
  /* ============================================================================================
@@ -56,12 +56,30 @@ export function DeliveryWorkspace({ delivery: fromServer, busy, onAction, onRefr
56
56
  const [heroes, setHeroes] = useState(fromServer?.heroes || []);
57
57
  const [heroesClean, setHeroesClean] = useState(fromServer?.heroes || []);
58
58
  const [saving, setSaving] = useState(false);
59
+ const [jpegEnabled, setJpegEnabled] = useState(false);
60
+ const [downloadLabelsEnabled, setDownloadLabelsEnabled] = useState(false);
61
+ useEffect(() => {
62
+ const controller = new AbortController();
63
+ fetch("/admin/api/deliveries?capabilities=1", { signal: controller.signal, credentials: "same-origin" })
64
+ .then(response => response.ok ? response.json() : null)
65
+ .then(value => {
66
+ if (!controller.signal.aborted) {
67
+ setJpegEnabled(value?.jpegEnabled === true);
68
+ setDownloadLabelsEnabled(value?.downloadLabelsEnabled === true);
69
+ }
70
+ })
71
+ .catch(() => { });
72
+ return () => controller.abort();
73
+ }, []);
59
74
  const [failure, setFailure] = useState(null);
60
75
  const [notice, setNotice] = useState(null);
61
76
  const [confirmDelete, setConfirmDelete] = useState(false);
62
77
  /* Anche «Rimuovi» su un pacchetto cancella i byte all'istante: chiede conferma
63
78
  * come il fondo pagina, ma dentro la riga, che è dove si è guardato. */
64
79
  const [confirmPackage, setConfirmPackage] = useState(null);
80
+ const [moreImages, setMoreImages] = useState({});
81
+ const [loadingImages, setLoadingImages] = useState(false);
82
+ const [previewAttempt, setPreviewAttempt] = useState(0);
65
83
  const [copied, setCopied] = useState(null);
66
84
  const folderRef = useRef(null);
67
85
  const filesRef = useRef(null);
@@ -76,8 +94,34 @@ export function DeliveryWorkspace({ delivery: fromServer, busy, onAction, onRefr
76
94
  const jobs = useUploads();
77
95
  const job = delivery ? uploadFor(delivery.id, jobs) : undefined;
78
96
  const uploading = isBusy(job) || startingRef.current;
79
- const dirty = !same(draft, clean) || !sameHeroes(heroes, heroesClean);
80
- const photos = packages.flatMap((pkg) => pkg.images.map((image) => ({ pkg, image })));
97
+ const heroesDirty = !sameHeroes(heroes, heroesClean);
98
+ const dirty = !same(draft, clean) || heroesDirty;
99
+ const photos = packages.filter(pkg => pkg.state === "ready").flatMap((pkg) => [...pkg.images, ...(moreImages[pkg.id]?.images || [])].map((image) => ({ pkg, image })));
100
+ async function loadMoreImages() {
101
+ if (loadingImages || !delivery)
102
+ return;
103
+ setLoadingImages(true);
104
+ try {
105
+ for (const pkg of packages) {
106
+ const cursor = moreImages[pkg.id] ? moreImages[pkg.id].nextCursor : pkg.nextCursor;
107
+ if (cursor == null)
108
+ continue;
109
+ const page = await onAction({
110
+ action: "manifestPage", id: delivery.id, packageId: pkg.id, after: cursor,
111
+ }, { reload: false });
112
+ setMoreImages(current => ({ ...current, [pkg.id]: {
113
+ images: [...(current[pkg.id]?.images || []), ...page.images], nextCursor: page.nextCursor,
114
+ } }));
115
+ }
116
+ }
117
+ catch (error) {
118
+ setFailure(errorText(error));
119
+ }
120
+ finally {
121
+ setLoadingImages(false);
122
+ }
123
+ }
124
+ useEffect(() => { setMoreImages({}); }, [delivery?.id]);
81
125
  useEffect(() => {
82
126
  if (!fromServer)
83
127
  return;
@@ -105,7 +149,8 @@ export function DeliveryWorkspace({ delivery: fromServer, busy, onAction, onRefr
105
149
  const change = (patch) => setDraft((current) => ({ ...current, ...patch }));
106
150
  /* ---- caricamento ------------------------------------------------------------------- */
107
151
  /* Qui si prepara e si consegna alla coda: transfer, pacchetto, impronta. Poi si esce
108
- subito. Il caricamento vero, la chiusura del pacchetto e l'attivazione li fa la coda,
152
+ dalla preparazione; la promessa resta aperta per conservare il file input fino a lettura finita.
153
+ Il caricamento vero, la chiusura del pacchetto e l'attivazione li fa la coda,
109
154
  che non muore quando questa schermata sparisce. */
110
155
  const beginUpload = async (files, resume = null) => {
111
156
  if (!files.length || startingRef.current)
@@ -115,16 +160,23 @@ export function DeliveryWorkspace({ delivery: fromServer, busy, onAction, onRefr
115
160
  setNotice(null);
116
161
  let target = delivery;
117
162
  let packageId = resume?.id || "";
118
- let options = { galleryEnabled: draft.gallery, webEnabled: draft.light };
163
+ let options = resume ? { galleryEnabled: resume.galleryEnabled, webEnabled: resume.webEnabled, webFormat: resume.webFormat || "webp" } : { galleryEnabled: draft.gallery, webEnabled: draft.light, webFormat: draft.light ? draft.webFormat : "webp" };
119
164
  try {
165
+ // Verifica i limiti fisici prima della richiesta delle capacità del sito.
166
+ preflightDeliverySelection(files, { ...options, manifestVersion: 2 });
167
+ const capabilities = resume ? { manifestVersion: resume.manifestVersion || 1 } :
168
+ await onAction({ action: "capabilities" }, { reload: false });
169
+ options.manifestVersion = capabilities.manifestVersion;
170
+ const { sourceBytes } = preflightDeliverySelection(files, options);
120
171
  /* L'impronta si legge PRIMA di creare qualsiasi cosa: se il browser ha perso il
121
172
  permesso su un file, non resta un transfer a metà né un oggetto orfano. */
122
- const fingerprint = await deliverySourceFingerprint(files);
173
+ const fingerprint = await deliverySourceFingerprint(files, options.manifestVersion);
123
174
  const title = draft.title.trim() || deliverySelectionName(files) || "Transfer";
124
175
  if (!target) {
125
176
  change({ title });
126
177
  target = (await onAction({
127
178
  action: "quickCreate", title, filesExpiresAt: expiryFrom(draft.duration),
179
+ ...(downloadLabelsEnabled ? { downloadOriginalsLabel: draft.downloadOriginalsLabel, downloadWebLabel: draft.downloadWebLabel } : {}),
128
180
  }, { reload: false })).delivery;
129
181
  setMade(target);
130
182
  }
@@ -133,18 +185,25 @@ export function DeliveryWorkspace({ delivery: fromServer, busy, onAction, onRefr
133
185
  throw new Error("Questi non sono gli stessi file di prima. Riseleziona esattamente la cartella " +
134
186
  "(o i file) del caricamento interrotto.");
135
187
  }
136
- options = { galleryEnabled: resume.galleryEnabled, webEnabled: resume.webEnabled };
188
+ options = { galleryEnabled: resume.galleryEnabled, webEnabled: resume.webEnabled, webFormat: resume.webFormat || "webp", manifestVersion: resume.manifestVersion || 1 };
137
189
  }
138
190
  else {
139
191
  const created = await onAction({
140
192
  action: "createPackage", id: target.id, label: title,
141
193
  ...options, sourceFingerprint: fingerprint, fileCount: files.length,
142
- sourceBytes: files.reduce((sum, file) => sum + file.size, 0),
194
+ sourceBytes,
143
195
  }, { reload: false });
144
196
  packageId = created.package.id;
197
+ options.webFormat = created.package.webFormat || "webp";
198
+ }
199
+ const completion = startUpload({ deliveryId: target.id, packageId, title, files, options });
200
+ try {
201
+ await refreshAll(target.id);
202
+ }
203
+ finally {
204
+ startingRef.current = false;
205
+ await completion;
145
206
  }
146
- startUpload({ deliveryId: target.id, packageId, title, files, options });
147
- await refreshAll(target.id);
148
207
  }
149
208
  catch (error) {
150
209
  setFailure(uploadFailure(error) + (packageId
@@ -184,7 +243,7 @@ export function DeliveryWorkspace({ delivery: fromServer, busy, onAction, onRefr
184
243
  if (files.length)
185
244
  void beginUpload(files, resume).finally(() => { input.value = ""; });
186
245
  };
187
- const choose = (input, resume = null) => {
246
+ const choose = (input, resume = resumeRef.current) => {
188
247
  resumeRef.current = resume;
189
248
  if (input)
190
249
  input.value = "";
@@ -203,7 +262,8 @@ export function DeliveryWorkspace({ delivery: fromServer, busy, onAction, onRefr
203
262
  clientName: draft.clientName, description: draft.description,
204
263
  licenseNotes: draft.licenseNotes, ctaEnabled: draft.ctaEnabled,
205
264
  ctaLabel: draft.ctaLabel, ctaUrl: draft.ctaUrl, syncPackageLabel: true,
206
- expiresAt: null,
265
+ ...(downloadLabelsEnabled ? { downloadOriginalsLabel: draft.downloadOriginalsLabel, downloadWebLabel: draft.downloadWebLabel } : {}),
266
+ expiresAt: delivery.expiresAt,
207
267
  filesExpiresAt: draft.duration === "keep"
208
268
  ? delivery.filesExpiresAt : expiryFrom(draft.duration),
209
269
  }, { reload: false })).delivery;
@@ -223,8 +283,9 @@ export function DeliveryWorkspace({ delivery: fromServer, busy, onAction, onRefr
223
283
  action: "createLink", id: delivery.id, label: "Link del cliente", pin: draft.pin,
224
284
  }, { reload: false });
225
285
  }
226
- if (ready)
227
- await onAction({ action: "activate", id: delivery.id }, { reload: false }).catch(() => { });
286
+ if (ready && delivery.status !== "archived") {
287
+ await onAction({ action: "activate", id: delivery.id }, { reload: false });
288
+ }
228
289
  setClean({ ...draft, duration: "keep" });
229
290
  setDraft((current) => ({ ...current, duration: "keep" }));
230
291
  setHeroesClean(heroes);
@@ -324,35 +385,47 @@ export function DeliveryWorkspace({ delivery: fromServer, busy, onAction, onRefr
324
385
  const state = delivery ? transferState(delivery) : null;
325
386
  const expiry = draft.duration === "keep"
326
387
  ? delivery?.filesExpiresAt || null : expiryFrom(draft.duration);
327
- return (_jsxs("div", { className: "dl-work", children: [_jsxs("header", { className: "dl-work-head", children: [_jsxs("div", { className: "dl-work-title", children: [state && _jsx("span", { className: "dl-pill is-" + state.kind, children: state.label }), _jsx("h2", { children: draft.title.trim() || (delivery ? delivery.title : "Nuovo transfer") })] }), _jsxs("div", { className: "dl-work-actions", children: [link && (_jsx("a", { className: "ak-btn", href: link.url, target: "_blank", rel: "noreferrer", children: "Guarda come lo vede il cliente" })), link && (_jsx("button", { className: "ak-btn ak-btn-primary", disabled: !dirty || saving || busy, onClick: () => void save(), children: saving ? "Salvo…" : "Salva" }))] })] }), (failure || notice) && (_jsxs("p", { className: failure ? "ak-error dl-feedback" : "dl-feedback is-ok", children: [failure || notice, _jsx("button", { type: "button", "aria-label": "Chiudi", onClick: () => { setFailure(null); setNotice(null); }, children: "\u00D7" })] })), _jsxs("div", { className: "dl-grid", children: [_jsxs("div", { className: "dl-main", children: [_jsxs("section", { className: "dl-card", children: [_jsxs("div", { className: "dl-pick", children: [_jsxs("button", { className: "dl-pick-tile", disabled: uploading, onClick: () => choose(filesRef.current), children: [_jsx(FileIcon, {}), "Aggiungi file"] }), _jsxs("button", { className: "dl-pick-tile", disabled: uploading, onClick: () => choose(folderRef.current), children: [_jsx(FolderIcon, {}), "Aggiungi una cartella"] }), _jsx("input", { ref: folderRef, type: "file", multiple: true, hidden: true, onChange: picked }), _jsx("input", { ref: filesRef, type: "file", multiple: true, hidden: true, onChange: picked })] }), _jsx(DropArea, { disabled: uploading, resuming: !!resumeRef.current, onFiles: (files) => {
388
+ return (_jsxs("div", { className: "dl-work", children: [_jsxs("header", { className: "dl-work-head", children: [_jsxs("div", { className: "dl-work-title", children: [state && _jsx("span", { className: "dl-pill is-" + state.kind, children: state.label }), _jsx("h2", { children: draft.title.trim() || (delivery ? delivery.title : "Nuovo transfer") })] }), _jsxs("div", { className: "dl-work-actions", children: [link && (_jsx("a", { className: "ak-btn", href: link.url, target: "_blank", rel: "noreferrer", children: "Guarda come lo vede il cliente" })), link && (_jsx("button", { className: "ak-btn ak-btn-primary", disabled: !dirty || saving || busy, onClick: () => void save(), children: saving ? "Salvo…" : "Salva" }))] })] }), (failure || notice) && (_jsxs("p", { className: failure ? "ak-error dl-feedback" : "dl-feedback is-ok", children: [failure || notice, _jsx("button", { type: "button", "aria-label": "Chiudi", onClick: () => { setFailure(null); setNotice(null); }, children: "\u00D7" })] })), _jsxs("div", { className: "dl-grid", children: [_jsxs("div", { className: "dl-main", children: [_jsxs("section", { className: "dl-card", children: [_jsxs("div", { className: "dl-pick", children: [_jsxs("button", { className: "dl-pick-tile", disabled: uploading, onClick: () => choose(filesRef.current), children: [_jsx(FileIcon, {}), "Aggiungi file"] }), _jsxs("button", { className: "dl-pick-tile", disabled: uploading, onClick: () => choose(folderRef.current), children: [_jsx(FolderIcon, {}), "Aggiungi una cartella"] }), _jsx("input", { ref: folderRef, type: "file", multiple: true, hidden: true, onChange: picked }), _jsx("input", { ref: filesRef, type: "file", multiple: true, hidden: true, onChange: picked })] }), _jsx("p", { className: "ak-help", children: "Fino a 150 GB di file per invio." }), _jsx(DropArea, { disabled: uploading, resuming: !!resumeRef.current, onFiles: (files) => {
328
389
  const resume = resumeRef.current;
329
390
  resumeRef.current = null;
330
391
  void beginUpload(files, resume);
331
- }, onError: (error) => setFailure(errorText(error)) }), job && isBusy(job) && _jsx(Progress, { job: job }), !!packages.length && (_jsx("ul", { className: "dl-files", children: packages.map((pkg) => (_jsxs("li", { className: "dl-file is-" + pkg.state + (confirmPackage === pkg.id ? " is-confirming" : ""), children: [_jsx("span", { className: "dl-file-icon", "aria-hidden": "true", children: pkg.state === "ready" ? "✓" : "…" }), confirmPackage === pkg.id ? (_jsxs(_Fragment, { children: [_jsxs("span", { className: "dl-file-main", children: [_jsxs("strong", { children: ["Rimuovo ", pkg.fileCount || "queste", " foto?"] }), _jsx("span", { children: "Spariscono adesso, e non si torna indietro." })] }), _jsxs("span", { className: "dl-file-actions", children: [_jsx("button", { className: "ak-btn", onClick: () => setConfirmPackage(null), children: "Annulla" }), _jsx("button", { className: "ak-btn dl-danger-btn", disabled: uploading, onClick: () => {
392
+ }, onError: (error) => setFailure(errorText(error)) }), job && isBusy(job) && _jsx(Progress, { job: job }), !!packages.length && (_jsx("ul", { className: "dl-files", children: packages.map((pkg) => (_jsxs("li", { className: "dl-file is-" + pkg.state + (confirmPackage === pkg.id ? " is-confirming" : ""), children: [_jsx("span", { className: "dl-file-icon", "aria-hidden": "true", children: pkg.state === "ready" ? "✓" : "…" }), confirmPackage === pkg.id ? (_jsxs(_Fragment, { children: [_jsxs("span", { className: "dl-file-main", children: [_jsxs("strong", { children: ["Rimuovo ", pkg.fileCount || "questi", " file?"] }), _jsx("span", { children: "Spariscono adesso, e non si torna indietro." })] }), _jsxs("span", { className: "dl-file-actions", children: [_jsx("button", { className: "ak-btn", onClick: () => setConfirmPackage(null), children: "Annulla" }), _jsx("button", { className: "ak-btn dl-danger-btn", disabled: uploading, onClick: () => {
332
393
  setConfirmPackage(null);
333
394
  void run({ action: "deletePackage", id: delivery?.id, packageId: pkg.id }, "File rimossi");
334
- }, children: "S\u00EC, rimuovi" })] })] })) : (_jsxs(_Fragment, { children: [_jsxs("span", { className: "dl-file-main", children: [_jsxs("strong", { children: [pkg.fileCount || "—", " foto \u00B7 ", formatBytes(pkg.sourceBytes)] }), _jsx("span", { children: pkg.state === "ready"
395
+ }, children: "S\u00EC, rimuovi" })] })] })) : (_jsxs(_Fragment, { children: [_jsxs("span", { className: "dl-file-main", children: [_jsxs("strong", { children: [pkg.fileCount || "—", " file \u00B7 ", formatBytes(pkg.sourceBytes)] }), _jsx("span", { children: pkg.state === "ready"
335
396
  ? [pkg.galleryEnabled ? "si vedono sulla pagina" : "", pkg.webEnabled ? "versione leggera" : ""]
336
397
  .filter(Boolean).join(" · ") || "solo originali"
337
398
  : job?.id === pkg.id && isBusy(job)
338
399
  ? (job.state === "paused" ? "caricamento in pausa" : "caricamento in corso")
339
- : "caricamento interrotto" })] }), _jsxs("span", { className: "dl-file-actions", children: [pkg.state !== "ready" && !(job?.id === pkg.id && isBusy(job)) && (_jsx("button", { className: "ak-btn", disabled: uploading, onClick: () => { resumeRef.current = pkg; setNotice(resumeHint); }, children: "Riprendi" })), !(job?.id === pkg.id && isBusy(job)) && (_jsx("button", { className: "ak-btn ak-btn-quiet", disabled: uploading, onClick: () => setConfirmPackage(pkg.id), children: "Rimuovi" }))] })] }))] }, pkg.id))) }))] }), _jsxs("section", { className: "dl-card dl-about", children: [_jsx(Field, { label: "Nome del transfer", hint: "Lo vede il cliente, ed \u00E8 il nome dello ZIP che scarica.", children: _jsx("input", { value: draft.title, placeholder: "Es. Matrimonio Rossi", onChange: (e) => change({ title: e.target.value }) }) }), _jsx(Field, { label: "Per", hint: "Il nome del cliente, grande in cima alla pagina.", children: _jsx("input", { value: draft.clientName, placeholder: "Es. Marta Rossi", onChange: (e) => change({ clientName: e.target.value }) }) }), _jsx(Field, { label: "Messaggio", wide: true, hint: "Due righe di accompagnamento. Si pu\u00F2 lasciare vuoto.", children: _jsx("textarea", { rows: 3, value: draft.description, onChange: (e) => change({ description: e.target.value }) }) })] }), link ? (_jsxs("section", { className: "dl-card dl-link", children: [_jsxs("div", { className: "dl-link-head", children: [_jsx("span", { className: "dl-step", children: "Il link per il cliente" }), _jsx("code", { children: link.url })] }), _jsxs("div", { className: "dl-link-actions", children: [_jsx("button", { className: "ak-btn ak-btn-primary", onClick: () => void copy(link.url, "link"), children: copied === "link" ? "Copiato" : "Copia il link" }), _jsx("button", { className: "ak-btn", onClick: () => void copy(messageFor(draft, link, expiry), "messaggio"), children: copied === "messaggio" ? "Copiato" : "Copia il messaggio" }), _jsx("a", { className: "ak-btn", target: "_blank", rel: "noreferrer", href: `https://wa.me/?text=${encodeURIComponent(messageFor(draft, link, expiry))}`, children: "Apri in WhatsApp" })] }), _jsx("p", { className: "dl-link-stats", children: linkStory(link) }), draft.pin && (_jsxs("p", { className: "dl-link-stats", children: ["Codice di accesso ", _jsx("strong", { children: draft.pin }), " \u2014 \u00E8 gi\u00E0 dentro il messaggio."] }))] })) : (_jsxs("section", { className: "dl-card dl-get", children: [_jsx("button", { className: "ak-btn ak-btn-primary dl-get-btn", disabled: !delivery || saving || busy, onClick: () => void save(), children: saving ? "Preparo…" : "Ottieni il link" }), _jsx("p", { children: delivery
340
- ? "Il link vale già mentre l'upload continua: chi lo apre vede che le foto stanno arrivando."
400
+ : "caricamento interrotto" })] }), _jsxs("span", { className: "dl-file-actions", children: [pkg.state !== "ready" && !(job?.id === pkg.id && isBusy(job)) && (_jsx("button", { className: "ak-btn", disabled: uploading, onClick: () => { resumeRef.current = pkg; setNotice(resumeHint); }, children: "Riprendi" })), !(job?.id === pkg.id && isBusy(job)) && (_jsx("button", { className: "ak-btn ak-btn-quiet", disabled: uploading, onClick: () => setConfirmPackage(pkg.id), children: "Rimuovi" }))] })] }))] }, pkg.id))) }))] }), _jsxs("section", { className: "dl-card dl-about", children: [_jsx(Field, { label: "Nome del transfer", hint: "Lo vede il cliente, ed \u00E8 il nome dello ZIP che scarica.", children: _jsx("input", { value: draft.title, placeholder: "Es. Matrimonio Rossi", onChange: (e) => change({ title: e.target.value }) }) }), _jsx(Field, { label: "Per", hint: "Il nome del cliente, grande in cima alla pagina.", children: _jsx("input", { value: draft.clientName, placeholder: "Es. Marta Rossi", onChange: (e) => change({ clientName: e.target.value }) }) }), _jsx(Field, { label: "Messaggio", wide: true, hint: "Due righe di accompagnamento. Si pu\u00F2 lasciare vuoto.", children: _jsx("textarea", { rows: 3, value: draft.description, onChange: (e) => change({ description: e.target.value }) }) })] }), link ? (_jsxs("section", { className: "dl-card dl-link", children: [_jsxs("div", { className: "dl-link-head", children: [_jsx("span", { className: "dl-step", children: "Il link per il cliente" }), _jsx("code", { children: link.url })] }), _jsxs("div", { className: "dl-link-actions", children: [delivery?.canShortenLinks && link.token.length > 16 && (_jsx("button", { className: "ak-btn", disabled: dirty || saving || busy, title: dirty ? "Salva prima le modifiche del transfer" : "Il vecchio indirizzo continuerà a funzionare", onClick: async () => {
401
+ setSaving(true);
402
+ try {
403
+ await run({ action: "shortenLink", id: delivery.id, linkId: link.id }, "Link accorciato. Anche il vecchio indirizzo resta valido.");
404
+ }
405
+ finally {
406
+ setSaving(false);
407
+ }
408
+ }, children: "Accorcia link" })), _jsx("button", { className: "ak-btn ak-btn-primary", onClick: () => void copy(link.url, "link"), children: copied === "link" ? "Copiato" : "Copia il link" }), _jsx("button", { className: "ak-btn", onClick: () => void copy(messageFor(draft, link, expiry), "messaggio"), children: copied === "messaggio" ? "Copiato" : "Copia il messaggio" }), _jsx("a", { className: "ak-btn", target: "_blank", rel: "noreferrer", href: `https://wa.me/?text=${encodeURIComponent(messageFor(draft, link, expiry))}`, children: "Apri in WhatsApp" })] }), _jsx("p", { className: "dl-link-stats", children: linkStory(link) }), draft.pin && (_jsxs("p", { className: "dl-link-stats", children: ["Codice di accesso ", _jsx("strong", { children: draft.pin }), " \u2014 \u00E8 gi\u00E0 dentro il messaggio."] }))] })) : (_jsxs("section", { className: "dl-card dl-get", children: [_jsx("button", { className: "ak-btn ak-btn-primary dl-get-btn", disabled: !delivery || saving || busy, onClick: () => void save(), children: saving ? "Preparo…" : "Ottieni il link" }), _jsx("p", { children: delivery
409
+ ? "Il link vale già mentre l'upload continua: chi lo apre vede che i file stanno arrivando."
341
410
  : "Aggiungi i file: il caricamento parte subito e il resto si compila mentre va." })] }))] }), _jsxs("div", { className: "dl-side", children: [_jsxs(Group, { title: "Chi pu\u00F2 aprirlo", children: [_jsxs("label", { className: "dl-radio", children: [_jsx("input", { type: "radio", name: "dl-access", checked: !draft.pin, onChange: () => change({ pin: "" }) }), _jsxs("span", { children: [_jsx("strong", { children: "Chiunque abbia il link" }), _jsx("small", { children: "Il link \u00E8 lungo e casuale: non si indovina." })] })] }), _jsxs("label", { className: "dl-radio", children: [_jsx("input", { type: "radio", name: "dl-access", checked: !!draft.pin, onChange: () => change({ pin: draft.pin || makeCode() }) }), _jsxs("span", { children: [_jsx("strong", { children: "Serve anche un codice" }), _jsx("small", { children: "Da mandare insieme al link. Dopo dieci errori si blocca per un'ora." })] })] }), !!draft.pin && (_jsxs("div", { className: "dl-code", children: [_jsx("input", { value: draft.pin, spellCheck: false, onChange: (e) => change({ pin: e.target.value.toUpperCase() }) }), _jsx("button", { className: "ak-btn", onClick: () => change({ pin: makeCode() }), children: "Altro" })] }))] }), _jsxs(Group, { title: "Cosa riceve", note: packages.length
342
411
  ? "Si decide prima di caricare: per cambiarle bisognerebbe ricaricare i file."
343
- : undefined, children: [_jsxs("label", { className: "dl-check", children: [_jsx("input", { type: "checkbox", checked: draft.gallery, disabled: !!packages.length, onChange: (e) => change({ gallery: e.target.checked }) }), _jsxs("span", { children: [_jsx("strong", { children: "Vede le foto sulla pagina" }), _jsx("small", { children: "Anteprime e ingrandimento, senza scaricare niente. Da telefono \u00E8 la differenza fra un link e un Transfer." })] })] }), _jsxs("label", { className: "dl-check", children: [_jsx("input", { type: "checkbox", checked: draft.light, disabled: !!packages.length, onChange: (e) => change({ light: e.target.checked }) }), _jsxs("span", { children: [_jsx("strong", { children: "Pu\u00F2 scaricare una versione leggera" }), _jsx("small", { children: "Un secondo download, adatto a social e web. Gli originali ci sono sempre." })] })] })] }), _jsxs(Group, { title: "Per quanto resta scaricabile", children: [_jsxs("select", { className: "dl-select", value: draft.duration, onChange: (e) => change({ duration: e.target.value }), children: [delivery && _jsx("option", { value: "keep", children: delivery.filesExpiresAt
412
+ : undefined, children: [_jsxs("label", { className: "dl-check", children: [_jsx("input", { type: "checkbox", checked: draft.gallery, disabled: !!packages.length, onChange: (e) => change({ gallery: e.target.checked }) }), _jsxs("span", { children: [_jsx("strong", { children: "Vede le foto sulla pagina" }), _jsx("small", { children: "Anteprime e ingrandimento, senza scaricare niente. Da telefono \u00E8 la differenza fra un link e un Transfer." })] })] }), _jsxs("label", { className: "dl-check", children: [_jsx("input", { type: "checkbox", checked: draft.light, disabled: !!packages.length, onChange: (e) => change({ light: e.target.checked }) }), _jsxs("span", { children: [_jsx("strong", { children: "Pu\u00F2 scaricare una versione leggera" }), _jsx("small", { children: "Un secondo download, adatto a social e web. Gli originali ci sono sempre." })] })] }), draft.light && (_jsxs("label", { className: "dl-field", children: [_jsx("span", { children: "Formato della versione leggera" }), _jsxs("select", { className: "dl-select", "aria-label": "Formato della versione leggera", value: draft.webFormat, disabled: !!packages.length || uploading, onChange: (e) => change({ webFormat: e.target.value === "jpg" ? "jpg" : "webp" }), children: [_jsx("option", { value: "webp", children: "WebP \u2014 file pi\u00F9 piccoli" }), _jsx("option", { value: "jpg", disabled: !jpegEnabled && draft.webFormat !== "jpg", children: "JPG \u2014 massima compatibilit\u00E0" })] }), _jsxs("small", { children: ["2048 px sul lato lungo, senza ingrandire le foto pi\u00F9 piccole. Qualit\u00E0 ", draft.webFormat === "jpg" ? "82%" : "80%", "."] })] }))] }), downloadLabelsEnabled && (_jsxs(Group, { title: "Testi dei pulsanti di download", note: "Lascia vuoto per usare il testo predefinito. Puoi cambiarli anche dopo l\u2019invio, senza ricaricare i file.", children: [delivery?.status !== "archived" && (_jsx(Field, { label: "Pulsante degli originali", children: _jsx("input", { value: draft.downloadOriginalsLabel, maxLength: 80, placeholder: "Scarica i file", onChange: (e) => change({ downloadOriginalsLabel: e.target.value }) }) })), (packages.length ? packages.some(pkg => pkg.webEnabled) : draft.light) && (_jsx(Field, { label: "Pulsante della versione leggera", children: _jsx("input", { value: draft.downloadWebLabel, maxLength: 80, placeholder: "Versione leggera", onChange: (e) => change({ downloadWebLabel: e.target.value }) }) }))] })), _jsxs(Group, { title: "Per quanto resta scaricabile", children: [_jsxs("select", { className: "dl-select", value: draft.duration, onChange: (e) => change({ duration: e.target.value }), children: [delivery && _jsx("option", { value: "keep", children: delivery.filesExpiresAt
344
413
  ? `Fino al ${formatDate(delivery.filesExpiresAt)}`
345
414
  : "Per sempre" }), _jsx("option", { value: "7", children: "7 giorni" }), _jsx("option", { value: "30", children: "30 giorni" }), _jsx("option", { value: "180", children: "6 mesi" }), _jsx("option", { value: "365", children: "1 anno" }), _jsx("option", { value: "forever", children: "Per sempre" })] }), _jsx("p", { className: "dl-note", children: expiry
346
415
  ? `Dopo il ${formatDate(expiry)} il link continua ad aprirsi, ma i file non si scaricano più.`
347
416
  : "I file restano finché non li togli tu." })] }), _jsxs(Group, { title: "Alla fine della pagina", children: [_jsxs("label", { className: "dl-check", children: [_jsx("input", { type: "checkbox", checked: draft.ctaEnabled, onChange: (e) => change({ ctaEnabled: e.target.checked }) }), _jsxs("span", { children: [_jsx("strong", { children: "Chiedi una recensione" }), _jsx("small", { children: "Un pulsante in fondo, dopo le fotografie." })] })] }), draft.ctaEnabled && (_jsxs(_Fragment, { children: [_jsx(Field, { label: "Testo del pulsante", children: _jsx("input", { value: draft.ctaLabel, onChange: (e) => change({ ctaLabel: e.target.value }) }) }), _jsx(Field, { label: "Indirizzo", hint: "Il link alla tua pagina delle recensioni.", children: _jsx("input", { type: "url", value: draft.ctaUrl, placeholder: "https://\u2026", onChange: (e) => change({ ctaUrl: e.target.value }) }) })] })), _jsx(Field, { label: "Note d'uso", hint: "Cosa pu\u00F2 farci il cliente. Appaiono in fondo alla pagina.", children: _jsx("textarea", { rows: 3, value: draft.licenseNotes, onChange: (e) => change({ licenseNotes: e.target.value }) }) })] })] })] }), !!delivery && (_jsxs("section", { className: "dl-card dl-cover", children: [_jsxs("div", { className: "dl-cover-head", children: [_jsxs("div", { children: [_jsx("span", { className: "dl-step", children: "Copertina" }), _jsxs("p", { children: ["Le foto grandi in cima alla pagina del cliente, fino a otto.", " ", heroes.length
348
417
  ? `Ne hai scelte ${heroes.length}.`
349
- : "Nessuna scelta: si userà quella predefinita."] })] }), _jsxs("div", { className: "dl-cover-actions", children: [_jsx("button", { className: "ak-btn", disabled: saving || !ready, onClick: () => { if (coverRef.current)
350
- coverRef.current.value = ""; coverRef.current?.click(); }, children: "Carica un'immagine" }), !!heroes.length && (_jsx("button", { className: "ak-btn ak-btn-quiet", onClick: () => setHeroes([]), children: "Usa quella predefinita" })), _jsx("input", { ref: coverRef, type: "file", accept: "image/jpeg,image/png,image/webp", multiple: true, hidden: true, onChange: (event) => void uploadCovers(event) })] })] }), photos.length ? (_jsx("div", { className: "dl-cover-grid", children: photos.map(({ pkg, image }) => {
418
+ : "Nessuna scelta: si userà quella predefinita."] }), _jsx("p", { role: "status", children: heroesDirty
419
+ ? "Scelta non ancora salvata: premi Salva modifiche per mostrarla al cliente."
420
+ : heroes.length
421
+ ? "Copertine salvate. Una sola immagine resta fissa; più immagini compongono la sequenza."
422
+ : "Scegli le copertine qui: la galleria sotto nella pagina del cliente è separata." })] }), _jsxs("div", { className: "dl-cover-actions", children: [!!photos.length && (_jsx("button", { className: "ak-btn", onClick: () => setPreviewAttempt(value => value + 1), title: "Riprova a caricare le miniature senza cambiare le copertine selezionate", children: "Ricarica anteprime" })), _jsx("button", { className: "ak-btn ak-btn-primary", disabled: !dirty || saving || busy, onClick: () => void save(), children: saving ? "Salvo…" : "Salva modifiche" }), _jsx("button", { className: "ak-btn", disabled: saving || !ready, onClick: () => { if (coverRef.current)
423
+ coverRef.current.value = ""; coverRef.current?.click(); }, children: "Carica un'immagine" }), !!heroes.length && (_jsx("button", { className: "ak-btn ak-btn-quiet", disabled: saving, onClick: () => setHeroes([]), children: "Usa quella predefinita" })), _jsx("input", { ref: coverRef, type: "file", accept: "image/jpeg,image/png,image/webp", multiple: true, hidden: true, onChange: (event) => void uploadCovers(event) })] })] }), packages.some(pkg => (moreImages[pkg.id] ? moreImages[pkg.id].nextCursor : pkg.nextCursor) != null) && (_jsx("button", { className: "ak-btn", disabled: loadingImages, onClick: () => void loadMoreImages(), children: loadingImages ? "Caricamento…" : "Mostra altre foto" })), photos.length ? (_jsx("div", { className: "dl-cover-grid", children: photos.map(({ pkg, image }) => {
351
424
  const chosen = heroes.some((item) => item.type === "package" && item.packageId === pkg.id && item.index === image.index);
352
- return (_jsxs("button", { type: "button", className: chosen ? "is-chosen" : "", "aria-pressed": chosen, onClick: () => toggleCover(pkg.id, image.index, image.previewUrl), children: [_jsx("img", { src: image.previewUrl, alt: image.name, loading: "lazy" }), chosen && _jsx("em", { "aria-hidden": "true", children: "\u2713" })] }, `${pkg.id}-${image.index}`));
425
+ return (_jsxs("button", { type: "button", className: chosen ? "is-chosen" : "", disabled: saving, "aria-pressed": chosen, onClick: () => toggleCover(pkg.id, image.index, image.previewUrl), children: [_jsx("img", { src: previewAttempt ? `${image.previewUrl}&previewRetry=${previewAttempt}` : image.previewUrl, alt: image.name, width: image.width, height: image.height, loading: "lazy" }), chosen && _jsx("em", { "aria-hidden": "true", children: "\u2713" })] }, `${pkg.id}-${image.index}`));
353
426
  }) })) : (_jsx("p", { className: "dl-note dl-cover-empty", children: heroes.some((item) => item.type === "object")
354
427
  ? `${heroes.length} immagini caricate a mano.`
355
- : "Questo transfer non ha anteprime da cui scegliere: carica un'immagine, oppure resta quella predefinita." }))] })), !!delivery && (_jsxs("section", { className: "dl-danger", children: [_jsxs("div", { children: [_jsx("button", { className: "ak-btn ak-btn-quiet", disabled: busy, onClick: () => void duplicate(), children: "Usa come modello" }), delivery.status === "active" && (_jsx("button", { className: "ak-btn ak-btn-quiet", disabled: busy, onClick: () => void run({ action: "archive", id: delivery.id }, "Originali tolti: recuperabili per sette giorni"), children: "Libera spazio: togli gli originali" }))] }), confirmDelete ? (_jsxs("div", { className: "dl-confirm", children: [_jsxs("p", { children: ["Elimino ", _jsx("strong", { children: delivery.title }), photos.length ? ` (${countPhotos(packages)} foto, ${formatBytes(delivery.storedBytes)})` : "", "? Il link smette subito di funzionare e i file vengono cancellati adesso. Non si torna indietro."] }), _jsx("button", { className: "ak-btn", onClick: () => setConfirmDelete(false), children: "Annulla" }), _jsx("button", { className: "ak-btn dl-danger-btn", onClick: () => void remove(), children: "S\u00EC, elimina" })] })) : (_jsx("button", { className: "ak-btn ak-btn-quiet", onClick: () => setConfirmDelete(true), children: "Elimina il transfer" }))] }))] }));
428
+ : "Questo transfer non ha anteprime da cui scegliere: carica un'immagine, oppure resta quella predefinita." }))] })), !!delivery && (_jsxs("section", { className: "dl-danger", children: [_jsxs("div", { children: [_jsx("button", { className: "ak-btn ak-btn-quiet", disabled: busy, onClick: () => void duplicate(), children: "Usa come modello" }), delivery.status === "active" && (_jsx("button", { className: "ak-btn ak-btn-quiet", disabled: busy, onClick: () => void run({ action: "archive", id: delivery.id }, "Originali tolti: recuperabili per sette giorni"), children: "Libera spazio: togli gli originali" }))] }), confirmDelete ? (_jsxs("div", { className: "dl-confirm", children: [_jsxs("p", { children: ["Elimino ", _jsx("strong", { children: delivery.title }), photos.length ? ` (${countPhotos(packages)} file, ${formatBytes(delivery.storedBytes)})` : "", "? Il link smette subito di funzionare e i file vengono cancellati adesso. Non si torna indietro."] }), _jsx("button", { className: "ak-btn", onClick: () => setConfirmDelete(false), children: "Annulla" }), _jsx("button", { className: "ak-btn dl-danger-btn", onClick: () => void remove(), children: "S\u00EC, elimina" })] })) : (_jsx("button", { className: "ak-btn ak-btn-quiet", onClick: () => setConfirmDelete(true), children: "Elimina il transfer" }))] }))] }));
356
429
  }
357
430
  /* ============================================================================================
358
431
  Impostazioni
@@ -419,9 +492,11 @@ function Progress({ job }) {
419
492
  }
420
493
  const done = progress.totalFiles ? Math.min(1, progress.doneFiles / progress.totalFiles) : 0;
421
494
  const phase = job.state === "paused" ? "In pausa"
422
- : progress.phase === "originals" ? "Carico le foto"
423
- : progress.phase === "derivatives" ? "Preparo le anteprime"
424
- : "Ultimi controlli";
495
+ : job.state === "cancelling" ? "Annullamento in corso"
496
+ : job.waitingForNetwork ? "Attendo la connessione e riprovo…"
497
+ : progress.phase === "originals" ? "Carico gli originali"
498
+ : progress.phase === "derivatives" ? "Preparo le anteprime"
499
+ : "Ultimi controlli";
425
500
  const seconds = progress.transferStartedAt
426
501
  ? Math.max(1, (Date.now() - progress.transferStartedAt) / 1000) : 0;
427
502
  const mbps = seconds ? progress.transferredBytes * 8 / seconds / 1_000_000 : 0;
@@ -440,12 +515,15 @@ function draftOf(delivery, keep) {
440
515
  return {
441
516
  title: "", clientName: "", description: "", licenseNotes: "",
442
517
  ctaEnabled: false, ctaLabel: "Lascia una recensione", ctaUrl: "",
443
- duration: "30", pin: "", gallery: true, light: true,
518
+ duration: "30", pin: "", gallery: true, light: true, webFormat: "webp",
519
+ downloadOriginalsLabel: "", downloadWebLabel: "",
444
520
  };
445
521
  }
446
522
  const link = primaryLink(delivery);
447
523
  return {
448
524
  title: delivery.title,
525
+ downloadOriginalsLabel: delivery.downloadOriginalsLabel || "",
526
+ downloadWebLabel: delivery.downloadWebLabel || "",
449
527
  clientName: delivery.clientName,
450
528
  description: delivery.description,
451
529
  licenseNotes: delivery.licenseNotes,
@@ -458,6 +536,7 @@ function draftOf(delivery, keep) {
458
536
  transfer che ha già i file, la verità è quella dei file. */
459
537
  gallery: delivery.packages[0]?.galleryEnabled ?? keep?.gallery ?? true,
460
538
  light: delivery.packages[0]?.webEnabled ?? keep?.light ?? true,
539
+ webFormat: delivery.packages.length ? (delivery.packages[0].webFormat || "webp") : keep?.webFormat || "webp",
461
540
  };
462
541
  }
463
542
  function same(a, b) {
@@ -506,7 +585,7 @@ function makeCode() {
506
585
  function messageFor(draft, link, expiry) {
507
586
  const who = draft.clientName.trim().split(/\s+/)[0];
508
587
  return [
509
- `Ciao${who ? ` ${who}` : ""}, le fotografie di ${draft.title.trim() || "questo lavoro"} sono pronte:`,
588
+ `Ciao${who ? ` ${who}` : ""}, i file di ${draft.title.trim() || "questo lavoro"} sono pronti:`,
510
589
  link.url,
511
590
  draft.pin ? `Codice di accesso: ${draft.pin}` : "",
512
591
  expiry ? `Puoi scaricarle fino al ${formatDate(expiry)}.` : "",
@@ -9,4 +9,4 @@ export declare function RowMenu({ page, pages, onChange, onRemove, onDuplicate,
9
9
  onChange: (next: PageModel) => void;
10
10
  onRemove: () => void;
11
11
  onDuplicate: () => void;
12
- }): import("react").JSX.Element;
12
+ }): import("react").JSX.Element | null;
@@ -32,7 +32,7 @@ import { Icon } from "../ui/icons";
32
32
  * modulo andava bene: è il genere di cosa che cambia quando una dipendenza smette di essere
33
33
  * statica e diventa iniettata. */
34
34
  function tipiDisponibili() {
35
- const tipi = Object.entries(site().pages).map(([value, page]) => ({ value, label: page.label }));
35
+ const tipi = Object.entries(site().pages).filter(([, page]) => page.creatable !== false).map(([value, page]) => ({ value, label: page.label }));
36
36
  /* La preselezione: "Pagina" se c'è (è la scelta di gran lunga più frequente), altrimenti la
37
37
  * prima opzione — così questo elenco non può restare senza un valore valido. */
38
38
  const iniziale = tipi.some((t) => t.value === "page") ? "page" : (tipi[0]?.value ?? "page");
@@ -56,6 +56,7 @@ export function NewPageButton({ pages, onCreate }) {
56
56
  isHome: false,
57
57
  inMenu: false,
58
58
  r2Prefix: "",
59
+ ...site().pages[type]?.create?.(t, uniqueSlug(t, pages.map((p) => p.slug))),
59
60
  _orig: null, // pagina nuova: nessuna sorgente da cui conservare
60
61
  });
61
62
  setTitle("");
@@ -73,10 +74,12 @@ export function RowMenu({ page, pages, onChange, onRemove, onDuplicate, }) {
73
74
  const [open, setOpen] = useState(false);
74
75
  const [renaming, setRenaming] = useState(false);
75
76
  const [title, setTitle] = useState(String(page.title ?? ""));
77
+ if (site().pages[String(page.type)]?.fixed)
78
+ return null;
76
79
  const taken = pages.filter((p) => p.slug !== page.slug).map((p) => p.slug);
77
80
  const nuovo = slugify(title);
78
81
  const cambia = nuovo !== page.slug;
79
- return (_jsxs("span", { className: "pa", children: [_jsx("button", { className: "pa-more", onClick: () => { setOpen(!open); setRenaming(false); }, title: "Altro", children: _jsx(Icon.more, {}) }), open && !renaming && (_jsxs("div", { className: "pa-menu", onMouseLeave: () => setOpen(false), children: [_jsx("button", { onClick: () => { setTitle(String(page.title ?? "")); setRenaming(true); }, children: "Rinomina\u2026" }), _jsx("button", { onClick: () => { onChange({ ...page, inMenu: !page.inMenu }); setOpen(false); }, children: page.inMenu ? "Togli dal menu" : "Aggiungi al menu" }), _jsx("button", { onClick: () => { onChange({ ...page, published: page.published === false }); setOpen(false); }, children: page.published === false ? "Pubblica" : "Nascondi" }), _jsx("button", { onClick: () => { onDuplicate(); setOpen(false); }, children: "Duplica" }), _jsx("button", { className: "pa-danger", onClick: () => {
82
+ return (_jsxs("span", { className: "pa", children: [_jsx("button", { className: "pa-more", onClick: () => { setOpen(!open); setRenaming(false); }, title: "Altro", children: _jsx(Icon.more, {}) }), open && !renaming && (_jsxs("div", { className: "pa-menu", onMouseLeave: () => setOpen(false), children: [_jsx("button", { onClick: () => { setTitle(String(page.title ?? "")); setRenaming(true); }, children: "Rinomina\u2026" }), site().pages[String(page.type)]?.menuEditable !== false && _jsx("button", { onClick: () => { onChange({ ...page, inMenu: !page.inMenu }); setOpen(false); }, children: page.inMenu ? "Togli dal menu" : "Aggiungi al menu" }), _jsx("button", { onClick: () => { onChange({ ...page, published: page.published === false }); setOpen(false); }, children: page.published === false ? "Pubblica" : "Nascondi" }), _jsx("button", { onClick: () => { onDuplicate(); setOpen(false); }, children: "Duplica" }), _jsx("button", { className: "pa-danger", onClick: () => {
80
83
  if (confirm(`Eliminare "${page.title}"?\n\nLa pagina e il suo testo spariscono dal sito al prossimo salvataggio. ` +
81
84
  `Le foto restano nel bucket.`))
82
85
  onRemove();
@@ -69,14 +69,15 @@ export function PageHeader({ page, pages, onChange, seoActive, onSeoToggle, lang
69
69
  });
70
70
  setTitleOpen(false);
71
71
  };
72
+ const fixed = pageType(page.type)?.fixed === true;
72
73
  const online = page.published !== false;
73
74
  const translationActive = !!languages && activeLanguage !== languages.default;
74
- return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "pe-head-main", children: [_jsx("button", { type: "button", className: "pe-title", title: "Modifica nome e indirizzo", disabled: translationActive, onClick: () => {
75
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "pe-head-main", children: [_jsx("button", { type: "button", className: "pe-title", title: "Modifica nome e indirizzo", disabled: translationActive || fixed, onClick: () => {
75
76
  setTitle(String(page.title ?? ""));
76
77
  setSlug(page.slug);
77
78
  setVisibilityOpen(false);
78
79
  setTitleOpen(true);
79
- }, children: String(page.title ?? page.slug) }), languages && (_jsx("div", { className: "pe-language", "aria-label": "Lingua del contenuto", children: languages.available.map((language) => (_jsx("button", { type: "button", className: activeLanguage === language ? "is-active" : "", "aria-pressed": activeLanguage === language, onClick: () => onLanguageChange?.(language), children: language.toUpperCase() }, language))) })), _jsxs("button", { type: "button", className: "pe-seo-button" + (seoActive ? " is-active" : ""), "aria-pressed": seoActive, disabled: translationActive, onClick: onSeoToggle, children: [_jsx(Icon.search, {}), "SEO"] }), _jsxs("div", { className: "pe-visibility", ref: visibilityRef, children: [_jsxs("button", { type: "button", className: "pe-status" + (online ? " is-online" : " is-offline"), "aria-haspopup": "menu", "aria-expanded": visibilityOpen, disabled: translationActive, onClick: () => setVisibilityOpen((open) => !open), children: [_jsx("span", { className: "pe-status-dot" }), online ? "Online" : "Offline", _jsx("span", { className: "pe-chevron", "aria-hidden": "true", children: "\u2304" })] }), visibilityOpen && (_jsxs("div", { className: "pe-status-menu", role: "menu", children: [_jsx("span", { className: "pe-status-label", children: "Visibilit\u00E0 della pagina" }), _jsxs("button", { type: "button", className: online ? "is-selected" : "", onClick: () => {
80
+ }, children: String(page.title ?? page.slug) }), languages && (_jsx("div", { className: "pe-language", "aria-label": "Lingua del contenuto", children: languages.available.map((language) => (_jsx("button", { type: "button", className: activeLanguage === language ? "is-active" : "", "aria-pressed": activeLanguage === language, onClick: () => onLanguageChange?.(language), children: language.toUpperCase() }, language))) })), _jsxs("button", { type: "button", className: "pe-seo-button" + (seoActive ? " is-active" : ""), "aria-pressed": seoActive, disabled: translationActive, onClick: onSeoToggle, children: [_jsx(Icon.search, {}), "SEO"] }), _jsxs("div", { className: "pe-visibility", ref: visibilityRef, children: [_jsxs("button", { type: "button", className: "pe-status" + (online ? " is-online" : " is-offline"), "aria-haspopup": "menu", "aria-expanded": visibilityOpen, disabled: translationActive || fixed, onClick: () => setVisibilityOpen((open) => !open), children: [_jsx("span", { className: "pe-status-dot" }), online ? "Online" : "Offline", _jsx("span", { className: "pe-chevron", "aria-hidden": "true", children: "\u2304" })] }), visibilityOpen && (_jsxs("div", { className: "pe-status-menu", role: "menu", children: [_jsx("span", { className: "pe-status-label", children: "Visibilit\u00E0 della pagina" }), _jsxs("button", { type: "button", className: online ? "is-selected" : "", onClick: () => {
80
81
  if (!online)
81
82
  onChange({ ...page, published: true });
82
83
  setVisibilityOpen(false);
@@ -84,7 +85,7 @@ export function PageHeader({ page, pages, onChange, seoActive, onSeoToggle, lang
84
85
  if (online)
85
86
  onChange({ ...page, published: false });
86
87
  setVisibilityOpen(false);
87
- }, children: [_jsx("span", { className: "pe-radio", children: _jsx("span", {}) }), _jsxs("span", { children: [_jsx("strong", { children: "Offline" }), _jsx("small", { children: "La pagina non \u00E8 visibile sul sito." })] })] })] }))] }), _jsxs("label", { className: "pe-menu-toggle", children: [_jsx("input", { type: "checkbox", checked: !!page.inMenu, disabled: translationActive, onChange: (event) => onChange({ ...page, inMenu: event.target.checked }) }), _jsx("span", { children: "Aggiungi al menu" })] })] }), titleOpen && (_jsx("div", { className: "pe-modal", role: "presentation", onMouseDown: (event) => {
88
+ }, children: [_jsx("span", { className: "pe-radio", children: _jsx("span", {}) }), _jsxs("span", { children: [_jsx("strong", { children: "Offline" }), _jsx("small", { children: "La pagina non \u00E8 visibile sul sito." })] })] })] }))] }), pageType(page.type)?.menuEditable !== false && _jsxs("label", { className: "pe-menu-toggle", children: [_jsx("input", { type: "checkbox", checked: !!page.inMenu, disabled: translationActive || fixed, onChange: (event) => onChange({ ...page, inMenu: event.target.checked }) }), _jsx("span", { children: "Aggiungi al menu" })] })] }), titleOpen && (_jsx("div", { className: "pe-modal", role: "presentation", onMouseDown: (event) => {
88
89
  if (event.target === event.currentTarget)
89
90
  setTitleOpen(false);
90
91
  }, children: _jsxs("div", { className: "pe-modal-card", role: "dialog", "aria-modal": "true", "aria-labelledby": "pe-title-dialog", children: [_jsxs("header", { children: [_jsx("h2", { id: "pe-title-dialog", children: "Modifica pagina" }), _jsx("button", { type: "button", onClick: () => setTitleOpen(false), "aria-label": "Chiudi", children: "\u00D7" })] }), _jsxs("div", { className: "pe-modal-fields", children: [_jsxs("label", { children: [_jsx("span", { children: "Nome pagina" }), _jsx("input", { autoFocus: true, value: title, onChange: (event) => setTitle(event.target.value), onKeyDown: (event) => {
@@ -340,7 +341,8 @@ export function PageEditor({ page, onChange, onRefresh, pristine, pickMedia, pic
340
341
  ...(translationActive ? [] : ["title"]),
341
342
  "published", "inMenu",
342
343
  "seoTitle", "seoDescription", "ogTitle", "ogDescription", "ogImage",
343
- ], [translationActive]);
344
+ ...(definition?.seoShape ? ["seo"] : []),
345
+ ], [translationActive, definition]);
344
346
  /* Come la gallery: una pagina portfolio È la sua selezione di lavori, quindi la schermata
345
347
  mostra quella e basta. Il `SchemaForm` generico qui elencava anche «Foto», «Edizioni»,
346
348
  «Ritratto» e «Riconoscimenti» — campi di altri tipi di pagina, che compaiono perché il
@@ -8,7 +8,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  import { useEffect, useState } from "react";
9
9
  import { api } from "../../index.js";
10
10
  import { Icon } from "../ui/icons";
11
- import { site } from "../site.js";
11
+ import { SchemaForm } from "../../form/index.js";
12
+ import { pageType, site } from "../site.js";
12
13
  /* ⚠️ Non costanti di modulo: `site()` è disponibile solo dopo `mountAdmin`, e un `const` qui
13
14
  * verrebbe valutato all'import — cioè prima. Si leggono dentro il componente.
14
15
  *
@@ -93,6 +94,10 @@ export function PageSeoEditor({ page, onChange, onRefresh, pristine, pickMediaFi
93
94
  const socialImage = explicitImage || automaticImage(page);
94
95
  const pageUrl = `${site().publicBase}/${page.slug}/`;
95
96
  const complete = !!seoTitle.trim() && !!seoDescription.trim() && !!socialImage;
97
+ const customShape = pageType(page.type)?.seoShape;
98
+ if (customShape)
99
+ return _jsxs("section", { className: "seo-editor", children: [_jsx("h2", { children: "SEO e GEO" }), loadError && _jsx("p", { className: "ak-error", children: "Non sono riuscito a rileggere la pagina." }), _jsx(SchemaForm, { shape: customShape, value: (page.seo ?? {}), onChange: (seo) => { if (!readOnly)
100
+ onChange({ ...page, seo }); }, pickMediaFiles: pickMediaFiles })] });
96
101
  const chooseImage = async () => {
97
102
  const selected = await pickMediaFiles?.({ multiple: false });
98
103
  if (selected?.[0])
@@ -14,6 +14,7 @@ import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
14
14
  import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
15
15
  import { KeyboardSensor } from "@dnd-kit/core";
16
16
  import { CSS } from "@dnd-kit/utilities";
17
+ import { site } from "../site.js";
17
18
  import { RowMenu } from "./PageActions";
18
19
  import { uniqueSlug } from "../lib/slug";
19
20
  import { Icon } from "../ui/icons";
@@ -23,7 +24,7 @@ const ICONS = {
23
24
  function Row({ p, pages, current, onEdit, onChange, onRemove, onDuplicate, languages }) {
24
25
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: p.slug });
25
26
  const TypeIcon = ICONS[String(p.type)] ?? Icon.doc;
26
- return (_jsxs("li", { ref: setNodeRef, className: "pg-row" + (isDragging ? " is-dragging" : "") + (p.slug === current ? " is-on" : ""), style: { transform: CSS.Transform.toString(transform), transition }, children: [_jsx("button", { className: "pg-handle", ...attributes, ...listeners, title: "Trascina per riordinare", children: _jsx(Icon.grip, {}) }), _jsxs("button", { className: "pg-open", onClick: () => onEdit(p.slug), children: [_jsx("span", { className: "pg-icon", children: _jsx(TypeIcon, {}) }), _jsx("span", { className: "pg-title", children: String(p.title ?? p.slug) }), languages?.available.filter((language) => language !== languages.default).map((language) => {
27
+ return (_jsxs("li", { ref: setNodeRef, className: "pg-row" + (isDragging ? " is-dragging" : "") + (p.slug === current ? " is-on" : ""), style: { transform: CSS.Transform.toString(transform), transition }, children: [_jsx("button", { disabled: site().pagesReorderable === false, className: "pg-handle", ...attributes, ...listeners, title: "Trascina per riordinare", children: _jsx(Icon.grip, {}) }), _jsxs("button", { className: "pg-open", onClick: () => onEdit(p.slug), children: [_jsx("span", { className: "pg-icon", children: _jsx(TypeIcon, {}) }), _jsx("span", { className: "pg-title", children: String(p.title ?? p.slug) }), languages?.available.filter((language) => language !== languages.default).map((language) => {
27
28
  const saved = p.translations?.[language]?.exists;
28
29
  const draft = !!p._translations?.[language];
29
30
  return (_jsx("span", { className: "pg-language" + (saved ? " is-saved" : draft ? " is-draft" : " is-inherited"), title: saved ? `${language.toUpperCase()} tradotta` : draft
@@ -37,6 +38,8 @@ function Group({ title, items, all, current, onEdit, onReorder, onChange, onRemo
37
38
  const { active, over } = e;
38
39
  if (!over || active.id === over.id)
39
40
  return;
41
+ if (site().pagesReorderable === false)
42
+ return;
40
43
  onReorder(items.findIndex((p) => p.slug === active.id), items.findIndex((p) => p.slug === over.id));
41
44
  };
42
45
  return (_jsxs("section", { className: "pg-group", children: [_jsx("h2", { className: "pg-group-title", children: title }), items.length === 0 && _jsx("p", { className: "pg-empty", children: "nessuna" }), _jsx(DndContext, { sensors: sensors, collisionDetection: closestCenter, modifiers: [restrictToVerticalAxis], onDragEnd: onDragEnd, children: _jsx(SortableContext, { items: items.map((p) => p.slug), strategy: verticalListSortingStrategy, children: _jsx("ul", { className: "pg-list", children: items.map((p) => (_jsx(Row, { p: p, pages: all, current: current, onEdit: onEdit, onChange: onChange, languages: languages, onRemove: () => onRemove(p.slug), onDuplicate: () => onDuplicate(p.slug) }, p.slug))) }) }) })] }));
@@ -73,6 +76,7 @@ export function PagesList({ pages, current, onEdit, onChange, onRenamed, languag
73
76
  inMenu: false, // una copia non entra nel menu da sola
74
77
  published: false, // né va online prima che qualcuno l'abbia guardata
75
78
  _orig: null, // pagina nuova: niente da cui conservare
79
+ _sha: undefined, _documentId: undefined, _draftRevisionId: null, _publishedRevisionId: null,
76
80
  };
77
81
  const i = pages.indexOf(src);
78
82
  onChange([...pages.slice(0, i + 1), copia, ...pages.slice(i + 1)]);
@@ -11,7 +11,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
11
11
  * sempre.
12
12
  */
13
13
  import { useEffect, useState } from "react";
14
- import { cancelUpload, dismissUpload, isBusy, pauseUpload, resumeUpload, useUploads, } from "../lib/upload-queue";
14
+ import { cancelUpload, dismissUpload, isBusy, pauseUpload, resumeUpload, retryUpload, useUploads, } from "../lib/upload-queue";
15
15
  import { dismissMediaUpload, useMediaUploads, } from "../../form/media/media-upload-queue.js";
16
16
  export function UploadDock() {
17
17
  const jobs = useUploads();
@@ -28,7 +28,7 @@ export function UploadDock() {
28
28
  }, [busy]);
29
29
  if (!jobs.length && !mediaJobs.length)
30
30
  return null;
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)))] }))] }));
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) && job.state !== "cancelling" && (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" }))), job.state === "failed" && job.retryable && _jsx("button", { className: "ak-btn", onClick: () => void retryUpload(job.id), children: "Riprova" }), !isBusy(job) && (_jsx("button", { className: "ak-btn ak-btn-quiet", onClick: () => dismissUpload(job.id), children: "Togli dall'elenco" }))] })] }, job.id)))] }))] }));
32
32
  }
33
33
  function MediaJob({ job }) {
34
34
  const conversionPercent = job.total ? (job.conversionDone / job.total) * 100 : 0;
@@ -68,10 +68,14 @@ function line(job) {
68
68
  ? `Pronto · ${job.skipped} senza anteprima`
69
69
  : "Pronto";
70
70
  }
71
+ if (job.state === "cancelling")
72
+ return "Annullamento in corso";
71
73
  if (job.state === "failed")
72
74
  return "Interrotto";
73
75
  if (job.state === "paused")
74
76
  return "In pausa";
77
+ if (job.waitingForNetwork)
78
+ return "Attendo la connessione e riprovo…";
75
79
  const progress = job.progress;
76
80
  if (!progress)
77
81
  return "Preparo…";
@@ -10,6 +10,12 @@ export interface PageTypeConfig {
10
10
  object: z.ZodType;
11
11
  payload: z.ZodType;
12
12
  hasBody: boolean;
13
+ /** Fixed routes cannot be renamed, hidden, duplicated or removed. */
14
+ fixed?: boolean;
15
+ menuEditable?: boolean;
16
+ creatable?: boolean;
17
+ create?: (title: string, slug: string) => Record<string, unknown>;
18
+ seoShape?: Shape;
13
19
  }
14
20
  /** Lingue dei contenuti, assenti nei siti monolingui. I codici sono quelli dei sibling Hugo
15
21
  * (`index.md`, `index.en.md`): la lingua predefinita non porta alcun suffisso. */
@@ -39,6 +45,13 @@ export interface SiteAdminConfig {
39
45
  languages?: LanguagesConfig;
40
46
  /** Forma del singleton home: i suoi `group` diventano le voci della seconda colonna. */
41
47
  home: Shape;
48
+ /** Schema-owned fields rendered inside the native Settings surface. */
49
+ settingsShape?: Shape;
50
+ pagesReorderable?: boolean;
51
+ /** Optional storage-policy surface inside the native Media pane. */
52
+ mediaEditor?: ComponentType;
53
+ /** Preview approval, public observation and rollback for direct SSR publishing. */
54
+ directPublication?: boolean;
42
55
  /** Optional site-owned Home surface. The kit keeps its legacy visual editor
43
56
  * as a fallback, while a site with a different document shape can render its
44
57
  * own schema-driven editor without teaching the shared package its fields. */
@@ -0,0 +1,20 @@
1
+ import { type D1DatabaseLike, type D1Statement } from "./persistence.js";
2
+ import { type ImmutableReleaseExport } from "./release.js";
3
+ /** A SELECT guard belongs inside the batch: a failed conditional must abort
4
+ * every mutation, including when an UPDATE would silently affect zero rows. */
5
+ export declare function transactionAssertion(db: D1DatabaseLike, condition: string, values?: unknown[]): D1Statement;
6
+ export declare function previewCandidateChecksum(baseReleaseId: string, entries: readonly {
7
+ documentId: string;
8
+ revisionId: string;
9
+ payloadChecksum: string;
10
+ }[]): Promise<string>;
11
+ export declare function commitDirectRelease(db: D1DatabaseLike, input: {
12
+ release: ImmutableReleaseExport;
13
+ expectedBaseReleaseId: string;
14
+ selectedDrafts: readonly {
15
+ documentId: string;
16
+ revisionId: string;
17
+ }[];
18
+ authorSubject: string;
19
+ now: number;
20
+ }): Promise<void>;