@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
@@ -191,13 +191,26 @@ export class D1DeploymentAttemptRepository {
191
191
  WHERE draft_revision_id IN (SELECT revision_id FROM content_release_entries WHERE release_id = ?)
192
192
  AND document_id IN (SELECT document_id FROM content_release_entries WHERE release_id = ?) AND ${guard}`)
193
193
  .bind(input.now, input.releaseId, input.releaseId, input.attemptId, input.releaseId, input.tokenChecksum, input.now),
194
+ this.db.prepare(`UPDATE site_access_versions SET state = 'superseded', superseded_at = ?, rollback_expires_at = ?
195
+ WHERE state = 'active' AND version_id IS NOT (SELECT version_id FROM content_release_site_access WHERE release_id = ?)
196
+ AND EXISTS (SELECT 1 FROM content_release_site_access WHERE release_id = ?) AND ${guard}`)
197
+ .bind(input.now, input.now + 7 * 24 * 60 * 60 * 1000, input.releaseId, input.releaseId, input.attemptId, input.releaseId, input.tokenChecksum, input.now),
198
+ this.db.prepare(`UPDATE site_access_versions SET state = 'active', activated_at = ?
199
+ WHERE version_id = (SELECT version_id FROM content_release_site_access WHERE release_id = ?) AND state = 'draft' AND ${guard}`)
200
+ .bind(input.now, input.releaseId, input.attemptId, input.releaseId, input.tokenChecksum, input.now),
201
+ this.db.prepare(`UPDATE site_access_state SET
202
+ active_version_id = COALESCE((SELECT version_id FROM content_release_site_access WHERE release_id = ?), active_version_id),
203
+ draft_version_id = CASE WHEN draft_version_id = (SELECT version_id FROM content_release_site_access WHERE release_id = ?) THEN NULL ELSE draft_version_id END,
204
+ updated_at = ?
205
+ WHERE singleton = 1 AND EXISTS (SELECT 1 FROM content_release_site_access WHERE release_id = ?) AND ${guard}`)
206
+ .bind(input.releaseId, input.releaseId, input.now, input.releaseId, input.attemptId, input.releaseId, input.tokenChecksum, input.now),
194
207
  this.db.prepare(`UPDATE production_deployment_attempts SET phase = 'succeeded', active_slot = NULL, updated_at = ?, finished_at = ?
195
208
  WHERE attempt_id = ? AND release_id = ? AND phase = 'verifying_canonical' AND ci_claim_token_checksum = ?
196
209
  AND ci_claim_expires_at > ? AND EXISTS (SELECT 1 FROM content_site_state WHERE singleton = 1 AND current_release_id = ?)`)
197
210
  .bind(input.now, input.now, input.attemptId, input.releaseId, input.tokenChecksum, input.now, input.releaseId),
198
211
  ];
199
212
  const results = await this.db.batch(statements);
200
- if (results[0]?.meta?.changes !== 1 || results[1]?.meta?.changes !== 1 || results[3]?.meta?.changes !== 1) {
213
+ if (results[0]?.meta?.changes !== 1 || results[1]?.meta?.changes !== 1 || results[6]?.meta?.changes !== 1) {
201
214
  throw new Error("attempt_unavailable");
202
215
  }
203
216
  }
@@ -9,7 +9,7 @@ export declare function FolderMenu({ lib, prefix, label, openTo }: {
9
9
  label: string;
10
10
  openTo?: "left" | "auto";
11
11
  }): import("react").JSX.Element;
12
- export declare function MediaFolders({ lib, trash }: {
12
+ export declare function MediaFolders({ lib, trash, settings }: {
13
13
  lib: MediaLibrary;
14
14
  /** `leave` riporta ai media: dal cestino, cliccare una cartella deve anche USCIRE dal cestino. */
15
15
  trash?: {
@@ -17,4 +17,8 @@ export declare function MediaFolders({ lib, trash }: {
17
17
  open: () => void;
18
18
  leave: () => void;
19
19
  };
20
+ settings?: {
21
+ active: boolean;
22
+ open: () => void;
23
+ };
20
24
  }): import("react").JSX.Element;
@@ -63,10 +63,11 @@ export function FolderMenu({ lib, prefix, label, openTo = "left" }) {
63
63
  } }), _jsx("span", { className: "ak-hint", children: "Cambia anche l'indirizzo delle foto dentro. Le pagine del sito vengono aggiornate; i link scritti a mano altrove no." }), _jsxs("div", { className: "mf-menu-row", children: [_jsx("button", { className: "ak-btn ak-btn-primary", disabled: !name.trim(), onClick: () => { close(); void lib.renameFolder(prefix, name); }, children: "Rinomina" }), _jsx("button", { className: "ak-btn ak-btn-quiet", onClick: close, children: "Annulla" })] })] }))] }));
64
64
  }
65
65
  const IcoTrash = () => (_jsx("svg", { className: "mf-ico", viewBox: "0 0 16 16", width: "15", height: "15", "aria-hidden": "true", children: _jsx("path", { fill: "none", stroke: "currentColor", strokeWidth: "1.4", strokeLinecap: "round", strokeLinejoin: "round", d: "M3 4.5h10M6 4.5V3h4v1.5M4.5 4.5 5 13.5h6l.5-9" }) }));
66
+ const IcoSettings = () => (_jsxs("svg", { className: "mf-ico", viewBox: "0 0 16 16", width: "15", height: "15", "aria-hidden": "true", children: [_jsx("path", { fill: "none", stroke: "currentColor", strokeWidth: "1.35", strokeLinecap: "round", strokeLinejoin: "round", d: "M6.6 2.2h2.8l.4 1.5c.4.2.8.4 1.1.7l1.5-.4 1.4 2.4-1.1 1.1v1l1.1 1.1-1.4 2.4-1.5-.4c-.3.3-.7.5-1.1.7l-.4 1.5H6.6l-.4-1.5a5 5 0 0 1-1.1-.7l-1.5.4-1.4-2.4 1.1-1.1v-1L2.2 6.4 3.6 4l1.5.4c.3-.3.7-.5 1.1-.7l.4-1.5Z" }), _jsx("circle", { cx: "8", cy: "8", r: "2", fill: "none", stroke: "currentColor", strokeWidth: "1.35" })] }));
66
67
  /* `trash` è facoltativo perché il cestino è una ROTTA, e le rotte le conosce l'applicazione, non
67
68
  * questo componente. Sta qui in fondo, sotto «+ Cartella», perché è l'ultimo posto dove finiscono
68
69
  * le foto: nella colonna che elenca dove stanno, non in una barra da un'altra parte. */
69
- export function MediaFolders({ lib, trash }) {
70
+ export function MediaFolders({ lib, trash, settings }) {
70
71
  const [over, setOver] = useState(null);
71
72
  const [draggingFolder, setDraggingFolder] = useState(false);
72
73
  const canDrop = lib.mode === "manage";
@@ -112,5 +113,5 @@ export function MediaFolders({ lib, trash }) {
112
113
  + (over === f.prefix ? " is-over" : ""), onDragOver: (e) => { if (canDrop) {
113
114
  e.preventDefault();
114
115
  setOver(f.prefix);
115
- } }, onDragLeave: () => setOver((p) => (p === f.prefix ? null : p)), onDrop: drop(f.prefix), children: [_jsxs("button", { className: "mf-open", onClick: () => goTo(f.prefix), children: [_jsx("span", { className: "mf-name", children: f.label || f.name }), f.count != null && _jsx("span", { className: "mf-count", children: f.count })] }), canDrop && _jsx(FolderMenu, { lib: lib, prefix: f.prefix, label: f.label || f.name })] }, f.prefix))) }), draggingFolder && (_jsx("div", { className: "mf-root-drop" + (over === "__root" ? " is-over" : ""), onDragOver: (e) => { e.preventDefault(); setOver("__root"); }, onDragLeave: () => setOver((p) => (p === "__root" ? null : p)), onDrop: drop(""), children: "Porta al primo livello" })), canDrop && (_jsx("button", { className: "ak-btn ak-btn-quiet mf-new", onClick: () => void lib.newFolder(""), children: "+ Cartella" })), trash && (_jsx("ul", { className: "mf-list mf-tail", children: _jsx("li", { className: "mf-item" + (trash.active ? " is-on" : ""), children: _jsxs("button", { className: "mf-open", onClick: trash.open, children: [_jsx(IcoTrash, {}), _jsx("span", { className: "mf-name", children: "Cestino" })] }) }) }))] }));
116
+ } }, onDragLeave: () => setOver((p) => (p === f.prefix ? null : p)), onDrop: drop(f.prefix), children: [_jsxs("button", { className: "mf-open", onClick: () => goTo(f.prefix), children: [_jsx("span", { className: "mf-name", children: f.label || f.name }), f.count != null && _jsx("span", { className: "mf-count", children: f.count })] }), canDrop && _jsx(FolderMenu, { lib: lib, prefix: f.prefix, label: f.label || f.name })] }, f.prefix))) }), draggingFolder && (_jsx("div", { className: "mf-root-drop" + (over === "__root" ? " is-over" : ""), onDragOver: (e) => { e.preventDefault(); setOver("__root"); }, onDragLeave: () => setOver((p) => (p === "__root" ? null : p)), onDrop: drop(""), children: "Porta al primo livello" })), canDrop && (_jsx("button", { className: "ak-btn ak-btn-quiet mf-new", onClick: () => void lib.newFolder(""), children: "+ Cartella" })), trash && (_jsx("ul", { className: "mf-list mf-tail", children: _jsx("li", { className: "mf-item" + (trash.active ? " is-on" : ""), children: _jsxs("button", { className: "mf-open", onClick: trash.open, children: [_jsx(IcoTrash, {}), _jsx("span", { className: "mf-name", children: "Cestino" })] }) }) })), settings && (_jsx("ul", { className: "mf-list mf-settings-tail", children: _jsx("li", { className: "mf-item" + (settings.active ? " is-on" : ""), children: _jsxs("button", { className: "mf-open", onClick: settings.open, children: [_jsx(IcoSettings, {}), _jsx("span", { className: "mf-name", children: "Impostazioni" })] }) }) }))] }));
116
117
  }
@@ -23,6 +23,15 @@ import { SORT_LABELS } from "./useMediaLibrary.js";
23
23
  import { DRAG_TYPE, DRAG_FOLDERS, readDragged, FolderMenu } from "./MediaFolders.js";
24
24
  import { Lightbox } from "./Lightbox.js";
25
25
  import { UploadDialog } from "./UploadDialog.js";
26
+ const JOURNAL_LABELS = {
27
+ preparing: "Preparo le copie Media.",
28
+ ready: "Media pronta: pubblica per applicare la modifica.",
29
+ blocked_in_use: "Operazione bloccata: una bozza o la versione online usa ancora la sorgente.",
30
+ bound: "Pubblicazione con Media in corso.",
31
+ finalizing: "Pubblicazione riuscita: completo la pulizia Media.",
32
+ completed_with_warning: "Pubblicato con Media da completare.",
33
+ failed: "Operazione Media interrotta prima della pubblicazione.",
34
+ };
26
35
  /* Scaricare più file significa più download: il browser ne consente una raffica breve dallo
27
36
  * stesso gesto dell'utente, e non c'è modo di fare uno zip senza un endpoint che lo costruisca. */
28
37
  function download(urls) {
@@ -117,7 +126,7 @@ export function MediaGrid({ lib, onPick, onCancel }) {
117
126
  void lib.upload(e.dataTransfer.files);
118
127
  }, children: [dragDepth > 0 && (_jsx("div", { className: "mg-dropzone", children: _jsxs("span", { children: ["Rilascia per caricare in ", _jsx("strong", { children: lib.prefix ? lib.labelOf(lib.prefix) : "Senza cartella" })] }) })), _jsx("header", { className: "mg-bar", children: sel.size === 0 ? (_jsxs(_Fragment, { children: [(manage || lib.multiple) && (_jsxs("label", { className: "mg-all", children: [_jsx("input", { type: "checkbox", checked: false, ref: (el) => { if (el)
119
128
  el.indeterminate = false; }, onChange: () => lib.setAll(true) }), _jsx("span", { children: "Seleziona tutto" })] })), _jsx("span", { className: "ak-spacer" }), _jsxs("label", { className: "ak-hint", children: ["ordina per", " ", _jsx("select", { className: "mg-sort", value: lib.sortKey, onChange: (e) => lib.setSort(e.target.value), children: Object.entries(SORT_LABELS).map(([k, l]) => _jsx("option", { value: k, children: l }, k)) }), _jsx("button", { className: "ak-icon", onClick: lib.flipDir, title: "Inverti l'ordine", children: lib.sortDir === "asc" ? "↑" : "↓" })] })] })) : (_jsxs(_Fragment, { children: [_jsxs("label", { className: "mg-all", children: [_jsx("input", { type: "checkbox", checked: allOn, ref: (el) => { if (el)
120
- el.indeterminate = !allOn; }, onChange: () => lib.setAll(!allOn) }), _jsxs("span", { children: [sel.size, " selezionate"] })] }), !allOn && lib.shown.length > sel.size && (_jsxs("button", { className: "mg-act is-link", onClick: () => lib.setAll(true), children: ["Seleziona tutte le ", lib.shown.length] })), manage && (_jsxs(_Fragment, { children: [lib.shown.some((f) => sel.has(f.key)) && (_jsx("button", { className: "mg-act", onClick: () => download(lib.shown.filter((f) => sel.has(f.key)).map((f) => ({ url: f.url, name: f.name }))), children: "Scarica" })), _jsx(MoveMenu, { lib: lib, keys: [...sel] }), _jsx("button", { className: "mg-act is-danger", onClick: () => void lib.remove([...sel]), children: "Elimina" })] })), _jsx("span", { className: "ak-spacer" }), _jsx("button", { className: "mg-act", onClick: () => lib.setAll(false), children: "Deseleziona" })] })) }), lib.error && _jsx("p", { className: "ak-error", onClick: lib.clearError, children: lib.error }), lib.progress && (_jsxs("p", { className: "mg-progress", children: [lib.progress.verb, " ", lib.progress.done, "/", lib.progress.total, "\u2026 \u2014 non chiudere la pagina"] })), lib.prefix !== "" && (manage || lib.subfolders.length > 0) && (_jsxs("section", { className: "mg-section", children: [_jsxs("div", { className: "mg-section-head", children: [_jsx("h2", { className: "mg-section-title", children: "Cartelle" }), manage && (_jsx("button", { className: "mg-act", onClick: () => void lib.newFolder(lib.prefix), children: "+ Cartella" }))] }), lib.subfolders.length > 0 && (_jsx("div", { className: "mg", children: lib.subfolders.map((f) => (_jsx(FolderCell, { lib: lib, folder: f, manage: manage }, f.prefix))) }))] })), lib.prefix !== "" && (manage || lib.subfolders.length > 0) && lib.shown.length > 0 && (_jsx("h2", { className: "mg-section-title", children: "Foto" })), _jsx("div", { className: "mg", children: lib.shown.map((f, i) => {
129
+ el.indeterminate = !allOn; }, onChange: () => lib.setAll(!allOn) }), _jsxs("span", { children: [sel.size, " selezionate"] })] }), !allOn && lib.shown.length > sel.size && (_jsxs("button", { className: "mg-act is-link", onClick: () => lib.setAll(true), children: ["Seleziona tutte le ", lib.shown.length] })), manage && (_jsxs(_Fragment, { children: [lib.shown.some((f) => sel.has(f.key)) && (_jsx("button", { className: "mg-act", onClick: () => download(lib.shown.filter((f) => sel.has(f.key)).map((f) => ({ url: f.url, name: f.name }))), children: "Scarica" })), _jsx(MoveMenu, { lib: lib, keys: [...sel] }), _jsx("button", { className: "mg-act is-danger", onClick: () => void lib.remove([...sel]), children: "Elimina" })] })), _jsx("span", { className: "ak-spacer" }), _jsx("button", { className: "mg-act", onClick: () => lib.setAll(false), children: "Deseleziona" })] })) }), lib.error && _jsx("p", { className: "ak-error", onClick: lib.clearError, children: lib.error }), manage && lib.mediaOperations.length > 0 && (_jsx("section", { className: "mg-journal", "aria-live": "polite", "aria-label": "Stato operazioni Media", children: lib.mediaOperations.map((operation) => (_jsxs("div", { className: `mg-journal-row is-${operation.state}`, children: [_jsx("span", { className: "mg-journal-badge", children: operation.type === "move" ? "Spostamento" : "Eliminazione" }), _jsx("span", { children: JOURNAL_LABELS[operation.state] ?? "Operazione Media in attesa." }), operation.failedEntries > 0 && _jsxs("span", { className: "ak-hint", children: [operation.failedEntries, " file da riprovare"] }), _jsx("span", { className: "ak-spacer" }), operation.state === "completed_with_warning" && operation.releaseId && (_jsx("button", { className: "mg-act", disabled: lib.busy, onClick: () => void lib.resumeMedia(operation), children: "Riprendi" })), ["preparing", "ready", "blocked_in_use", "failed"].includes(operation.state) && !operation.releaseId && (_jsx("button", { className: "mg-act is-danger", disabled: lib.busy, onClick: () => void lib.cancelMedia(operation), children: "Annulla" }))] }, operation.operationId))) })), lib.progress && (_jsxs("p", { className: "mg-progress", children: [lib.progress.verb, " ", lib.progress.done, "/", lib.progress.total, "\u2026 \u2014 non chiudere la pagina"] })), lib.prefix !== "" && (manage || lib.subfolders.length > 0) && (_jsxs("section", { className: "mg-section", children: [_jsxs("div", { className: "mg-section-head", children: [_jsx("h2", { className: "mg-section-title", children: "Cartelle" }), manage && (_jsx("button", { className: "mg-act", onClick: () => void lib.newFolder(lib.prefix), children: "+ Cartella" }))] }), lib.subfolders.length > 0 && (_jsx("div", { className: "mg", children: lib.subfolders.map((f) => (_jsx(FolderCell, { lib: lib, folder: f, manage: manage }, f.prefix))) }))] })), lib.prefix !== "" && (manage || lib.subfolders.length > 0) && lib.shown.length > 0 && (_jsx("h2", { className: "mg-section-title", children: "Foto" })), _jsx("div", { className: "mg", children: lib.shown.map((f, i) => {
121
130
  const on = sel.has(f.key);
122
131
  return (_jsxs("figure", { className: "mg-item" + (on ? " is-on" : ""), draggable: manage, onDragStart: (e) => {
123
132
  // Si trascina la SELEZIONE se la foto ne fa parte, altrimenti solo quella.
@@ -0,0 +1,14 @@
1
+ export declare const MEDIA_WEBP_EDGE = 2500;
2
+ export declare const MEDIA_WEBP_QUALITY = 0.8;
3
+ export declare function isConvertibleImage(file: File): boolean;
4
+ export declare function webpName(name: string): string;
5
+ export declare function fitLongEdge(width: number, height: number, edge?: number): {
6
+ width: number;
7
+ height: number;
8
+ };
9
+ /** La conversione canvas rimuove i metadati. Conserviamo almeno DateTimeOriginal, già usato
10
+ * dalla libreria per l'ordinamento per scatto, leggendo solo l'inizio del JPEG. */
11
+ export declare function jpegCapturedAt(file: File): Promise<string | null>;
12
+ /** I video e gli altri file non-immagine restano intatti. Retry e fallback all'originale sono
13
+ * responsabilità della coda: questa funzione fa un singolo tentativo e fallisce in modo netto. */
14
+ export declare function convertMediaImage(file: File): Promise<File>;
@@ -0,0 +1,163 @@
1
+ /* Conversione prima dell'upload della libreria Media.
2
+ *
3
+ * Sta nel browser: il file originale non attraversa la rete e ogni worker della coda può
4
+ * convertire e caricare in parallelo. HEIC/HEIF è l'unica eccezione ai codec del browser e
5
+ * viene decodificato, solo quando serve, dal modulo dedicato caricato dinamicamente.
6
+ */
7
+ export const MEDIA_WEBP_EDGE = 2500;
8
+ export const MEDIA_WEBP_QUALITY = .8;
9
+ const IMAGE_EXT = /\.(?:avif|bmp|gif|heic|heif|jfif|jpe?g|png|tiff?|webp)$/i;
10
+ const HEIC_EXT = /\.(?:heic|heif)$/i;
11
+ export function isConvertibleImage(file) {
12
+ return file.type.startsWith("image/") || IMAGE_EXT.test(file.name);
13
+ }
14
+ export function webpName(name) {
15
+ const base = name.replace(/\.[^./\\]+$/, "") || "immagine";
16
+ return `${base}.webp`;
17
+ }
18
+ export function fitLongEdge(width, height, edge = MEDIA_WEBP_EDGE) {
19
+ const scale = Math.min(1, edge / Math.max(width, height));
20
+ return {
21
+ width: Math.max(1, Math.round(width * scale)),
22
+ height: Math.max(1, Math.round(height * scale)),
23
+ };
24
+ }
25
+ /** La conversione canvas rimuove i metadati. Conserviamo almeno DateTimeOriginal, già usato
26
+ * dalla libreria per l'ordinamento per scatto, leggendo solo l'inizio del JPEG. */
27
+ export async function jpegCapturedAt(file) {
28
+ if (!(file.type === "image/jpeg" || /\.(?:jpe?g|jfif)$/i.test(file.name)))
29
+ return null;
30
+ try {
31
+ const buffer = await file.slice(0, 512 * 1024).arrayBuffer();
32
+ const view = new DataView(buffer);
33
+ if (view.byteLength < 4 || view.getUint16(0) !== 0xffd8)
34
+ return null;
35
+ let offset = 2;
36
+ while (offset + 12 < view.byteLength) {
37
+ if (view.getUint8(offset) !== 0xff)
38
+ return null;
39
+ const marker = view.getUint8(offset + 1);
40
+ const length = view.getUint16(offset + 2);
41
+ if (marker === 0xe1) {
42
+ const app1 = offset + 4;
43
+ if (app1 + 14 >= view.byteLength || view.getUint32(app1) !== 0x45786966)
44
+ return null;
45
+ const tiff = app1 + 6;
46
+ const little = view.getUint16(tiff) === 0x4949;
47
+ const u16 = (at) => view.getUint16(at, little);
48
+ const u32 = (at) => view.getUint32(at, little);
49
+ const ifd0 = tiff + u32(tiff + 4);
50
+ let exif = 0;
51
+ for (let i = 0, count = u16(ifd0); i < count; i++) {
52
+ const entry = ifd0 + 2 + i * 12;
53
+ if (u16(entry) === 0x8769) {
54
+ exif = tiff + u32(entry + 8);
55
+ break;
56
+ }
57
+ }
58
+ if (!exif)
59
+ return null;
60
+ for (let i = 0, count = u16(exif); i < count; i++) {
61
+ const entry = exif + 2 + i * 12;
62
+ if (u16(entry) !== 0x9003)
63
+ continue;
64
+ const start = tiff + u32(entry + 8);
65
+ let raw = "";
66
+ for (let j = 0; j < 19 && start + j < view.byteLength; j++)
67
+ raw += String.fromCharCode(view.getUint8(start + j));
68
+ const match = raw.match(/^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})/);
69
+ return match ? `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}` : null;
70
+ }
71
+ return null;
72
+ }
73
+ if (marker === 0xda || length < 2)
74
+ return null;
75
+ offset += 2 + length;
76
+ }
77
+ }
78
+ catch { /* metadati assenti o corrotti: non impediscono una conversione valida */ }
79
+ return null;
80
+ }
81
+ async function bitmapOf(file) {
82
+ const heic = HEIC_EXT.test(file.name) || /image\/(?:hei[cf]|heif-sequence)/i.test(file.type);
83
+ const options = {
84
+ imageOrientation: "from-image",
85
+ /* `none` lascerebbe i numeri del profilo sorgente senza trasformazione: disegnarli poi su
86
+ un canvas sRGB è il percorso classico verso colori slavati o troppo saturi. */
87
+ colorSpaceConversion: "default",
88
+ premultiplyAlpha: "default",
89
+ };
90
+ if (heic) {
91
+ /* Prima il codec di sistema: quando Safari/il browser sa leggere HEIC, è la strada che ha
92
+ accesso completo al suo profilo ICC/NCLX. Il decoder WASM è il ripiego interoperabile. */
93
+ try {
94
+ return await createImageBitmap(file, options);
95
+ }
96
+ catch { /* HEIC non supportato nativamente */ }
97
+ /* La variante CSP non usa `unsafe-eval`, vietato normalmente dall'admin. Il chunk grande
98
+ del decoder non entra nel caricamento iniziale: viene scaricato soltanto al primo HEIC. */
99
+ const { heicTo } = await import("heic-to/csp");
100
+ return heicTo({ blob: file, type: "bitmap", options });
101
+ }
102
+ return createImageBitmap(file, options);
103
+ }
104
+ async function drawWebp(bitmap, width, height) {
105
+ if (typeof OffscreenCanvas !== "undefined") {
106
+ const canvas = new OffscreenCanvas(width, height);
107
+ /* Il WebP generato dal canvas normalmente non porta con sé il profilo sorgente. Rendendo
108
+ esplicitamente in sRGB, i pixel sono già corretti; la specifica WebP prescrive sRGB
109
+ quando ICCP manca. Non copiamo l'ICC originale su pixel ormai convertiti. */
110
+ const context = canvas.getContext("2d", { alpha: true, colorSpace: "srgb" });
111
+ if (!context)
112
+ throw new Error("Canvas 2D non disponibile");
113
+ context.imageSmoothingEnabled = true;
114
+ context.imageSmoothingQuality = "high";
115
+ try {
116
+ context.drawImage(bitmap, 0, 0, width, height);
117
+ const blob = await canvas.convertToBlob({ type: "image/webp", quality: MEDIA_WEBP_QUALITY });
118
+ if (blob.type !== "image/webp")
119
+ throw new Error("Questo browser non supporta la codifica WebP");
120
+ return blob;
121
+ }
122
+ finally {
123
+ canvas.width = 1;
124
+ canvas.height = 1;
125
+ }
126
+ }
127
+ const canvas = document.createElement("canvas");
128
+ canvas.width = width;
129
+ canvas.height = height;
130
+ const context = canvas.getContext("2d", { alpha: true, colorSpace: "srgb" });
131
+ if (!context)
132
+ throw new Error("Canvas 2D non disponibile");
133
+ context.imageSmoothingEnabled = true;
134
+ context.imageSmoothingQuality = "high";
135
+ try {
136
+ context.drawImage(bitmap, 0, 0, width, height);
137
+ return await new Promise((resolve, reject) => canvas.toBlob((blob) => blob?.type === "image/webp"
138
+ ? resolve(blob)
139
+ : reject(new Error("Questo browser non supporta la codifica WebP")), "image/webp", MEDIA_WEBP_QUALITY));
140
+ }
141
+ finally {
142
+ canvas.width = 1;
143
+ canvas.height = 1;
144
+ }
145
+ }
146
+ /** I video e gli altri file non-immagine restano intatti. Retry e fallback all'originale sono
147
+ * responsabilità della coda: questa funzione fa un singolo tentativo e fallisce in modo netto. */
148
+ export async function convertMediaImage(file) {
149
+ if (!isConvertibleImage(file))
150
+ return file;
151
+ const bitmap = await bitmapOf(file);
152
+ try {
153
+ const size = fitLongEdge(bitmap.width, bitmap.height);
154
+ const blob = await drawWebp(bitmap, size.width, size.height);
155
+ return new File([blob], webpName(file.name), {
156
+ type: "image/webp",
157
+ lastModified: file.lastModified,
158
+ });
159
+ }
160
+ finally {
161
+ bitmap.close();
162
+ }
163
+ }
@@ -0,0 +1,31 @@
1
+ export type MediaUploadState = "running" | "done" | "failed";
2
+ export interface MediaUploadJob {
3
+ id: string;
4
+ title: string;
5
+ prefix: string;
6
+ state: MediaUploadState;
7
+ total: number;
8
+ conversionDone: number;
9
+ uploadDone: number;
10
+ converted: number;
11
+ originals: number;
12
+ failed: number;
13
+ autoWebp: boolean;
14
+ errors: string[];
15
+ }
16
+ interface StartInput {
17
+ prefix: string;
18
+ title: string;
19
+ files: File[];
20
+ autoWebp: boolean;
21
+ }
22
+ export declare function useMediaUploads(): MediaUploadJob[];
23
+ export declare function useMediaUploadCompletions(): number;
24
+ /** Snapshot non mutabile per diagnostica e test della coda fuori da React. */
25
+ export declare function mediaUploadsSnapshot(): MediaUploadJob[];
26
+ export declare function dismissMediaUpload(id: string): void;
27
+ /** Accoda e torna subito: la finestra di scelta può chiudersi, il lavoro resta nel dock. */
28
+ export declare function startMediaUpload(input: StartInput): string;
29
+ export declare function adaptiveConversionConcurrency(files: File[], enabled?: boolean): number;
30
+ export declare function adaptiveUploadConcurrency(): number;
31
+ export {};
@@ -0,0 +1,230 @@
1
+ /* Upload persistenti della libreria Media.
2
+ *
3
+ * Come per i transfer, lo stato vive fuori da React: cambiare rotta nell'admin non smonta la
4
+ * conversione, non perde l'avanzamento e non nasconde gli errori. Le due fasi hanno pool
5
+ * distinti e una coda limitata fra loro: conversione e rete si sovrappongono, ma non si
6
+ * accumulano in RAM decine di WebP già pronti.
7
+ */
8
+ import { useSyncExternalStore } from "react";
9
+ import { api, ApiError } from "../../api.js";
10
+ import { convertMediaImage, isConvertibleImage, jpegCapturedAt } from "./image-conversion.js";
11
+ let jobs = [];
12
+ let completions = 0;
13
+ const listeners = new Set();
14
+ function emit() {
15
+ jobs = [...jobs];
16
+ for (const listener of listeners)
17
+ listener();
18
+ }
19
+ function patch(id, change) {
20
+ jobs = jobs.map((job) => job.id === id ? { ...job, ...change } : job);
21
+ emit();
22
+ }
23
+ function current(id) {
24
+ return jobs.find((job) => job.id === id);
25
+ }
26
+ function subscribe(listener) {
27
+ listeners.add(listener);
28
+ return () => { listeners.delete(listener); };
29
+ }
30
+ export function useMediaUploads() {
31
+ return useSyncExternalStore(subscribe, () => jobs, () => jobs);
32
+ }
33
+ export function useMediaUploadCompletions() {
34
+ return useSyncExternalStore(subscribe, () => completions, () => completions);
35
+ }
36
+ /** Snapshot non mutabile per diagnostica e test della coda fuori da React. */
37
+ export function mediaUploadsSnapshot() { return jobs; }
38
+ export function dismissMediaUpload(id) {
39
+ jobs = jobs.filter((job) => job.id !== id || job.state === "running");
40
+ emit();
41
+ }
42
+ /** Accoda e torna subito: la finestra di scelta può chiudersi, il lavoro resta nel dock. */
43
+ export function startMediaUpload(input) {
44
+ const id = crypto.randomUUID();
45
+ jobs = [...jobs, {
46
+ id, title: input.title, prefix: input.prefix, state: "running", total: input.files.length,
47
+ conversionDone: 0, uploadDone: 0, converted: 0, originals: 0, failed: 0,
48
+ autoWebp: input.autoWebp, errors: [],
49
+ }];
50
+ emit();
51
+ void run(id, input);
52
+ return id;
53
+ }
54
+ async function run(id, input) {
55
+ const conversionWorkers = adaptiveConversionConcurrency(input.files, input.autoWebp);
56
+ const uploadWorkers = adaptiveUploadConcurrency();
57
+ /* Due soli elementi pronti: abbastanza per non lasciare la rete in attesa, pochi abbastanza
58
+ * da non tenere in memoria un intero servizio fotografico già convertito. */
59
+ const ready = new BoundedQueue(2);
60
+ let next = 0;
61
+ const convert = async () => {
62
+ while (next < input.files.length) {
63
+ const file = input.files[next++];
64
+ const prepared = await prepare(file, input.autoWebp);
65
+ const job = current(id);
66
+ patch(id, {
67
+ conversionDone: job.conversionDone + 1,
68
+ converted: job.converted + (prepared.converted ? 1 : 0),
69
+ originals: job.originals + (prepared.originalFallback ? 1 : 0),
70
+ errors: prepared.conversionError
71
+ ? appendError(job.errors, `${file.name}: conversione non riuscita, carico l’originale (${prepared.conversionError})`)
72
+ : job.errors,
73
+ });
74
+ await ready.push(prepared);
75
+ }
76
+ };
77
+ const upload = async () => {
78
+ for (;;) {
79
+ const prepared = await ready.shift();
80
+ if (!prepared)
81
+ return;
82
+ try {
83
+ await uploadWithRetry(input.prefix, prepared.upload, prepared.capturedAt);
84
+ const job = current(id);
85
+ patch(id, { uploadDone: job.uploadDone + 1 });
86
+ }
87
+ catch (error) {
88
+ const job = current(id);
89
+ patch(id, {
90
+ uploadDone: job.uploadDone + 1,
91
+ failed: job.failed + 1,
92
+ errors: appendError(job.errors, `${prepared.source.name}: ${readable(error)}`),
93
+ });
94
+ }
95
+ }
96
+ };
97
+ try {
98
+ const consumers = Array.from({ length: Math.min(uploadWorkers, input.files.length) }, upload);
99
+ await Promise.all(Array.from({ length: Math.min(conversionWorkers, input.files.length) }, convert));
100
+ ready.close();
101
+ await Promise.all(consumers);
102
+ patch(id, { state: current(id).failed ? "failed" : "done" });
103
+ }
104
+ catch (error) {
105
+ /* Un errore della coda è un guasto nostro, non di una foto. Chiude comunque i consumer e
106
+ resta visibile: un job "in corso" per sempre sarebbe impossibile da diagnosticare. */
107
+ ready.close();
108
+ const job = current(id);
109
+ patch(id, { state: "failed", failed: Math.max(1, job.failed), errors: appendError(job.errors, readable(error)) });
110
+ }
111
+ finally {
112
+ completions += 1;
113
+ emit();
114
+ }
115
+ }
116
+ async function prepare(file, autoWebp) {
117
+ const capturedAt = await jpegCapturedAt(file);
118
+ if (!autoWebp || !isConvertibleImage(file)) {
119
+ return { source: file, upload: file, converted: false, originalFallback: false, capturedAt };
120
+ }
121
+ let last;
122
+ /* Tre tentativi reali. Fra uno e l'altro restituiamo il controllo al browser: dopo una
123
+ * pressione di memoria può liberare bitmap/canvas chiusi prima di riprovare. */
124
+ for (let attempt = 1; attempt <= 3; attempt++) {
125
+ try {
126
+ const converted = await convertMediaImage(file);
127
+ return { source: file, upload: converted, converted: true, originalFallback: false, capturedAt };
128
+ }
129
+ catch (error) {
130
+ last = error;
131
+ if (attempt < 3)
132
+ await delay(attempt * 180);
133
+ }
134
+ }
135
+ return {
136
+ source: file, upload: file, converted: false, originalFallback: true,
137
+ conversionError: readable(last), capturedAt,
138
+ };
139
+ }
140
+ async function uploadWithRetry(prefix, file, capturedAt) {
141
+ let last;
142
+ for (let attempt = 1; attempt <= 3; attempt++) {
143
+ try {
144
+ return await api.upload(prefix, file, { capturedAt });
145
+ }
146
+ catch (error) {
147
+ last = error;
148
+ if (attempt === 3 || !retryable(error))
149
+ throw error;
150
+ await delay(attempt * 300);
151
+ }
152
+ }
153
+ throw last;
154
+ }
155
+ function retryable(error) {
156
+ return !(error instanceof ApiError) || error.status === 408 || error.status === 429 || error.status >= 500;
157
+ }
158
+ export function adaptiveConversionConcurrency(files, enabled = true) {
159
+ if (!enabled || !files.some(isConvertibleImage))
160
+ return Math.min(4, Math.max(1, files.length));
161
+ const cores = typeof navigator === "undefined" ? 4 : navigator.hardwareConcurrency || 4;
162
+ const memory = typeof navigator === "undefined"
163
+ ? 4
164
+ : navigator.deviceMemory || 4;
165
+ let count = cores >= 12 && memory >= 8 ? 3 : cores >= 6 && memory >= 4 ? 2 : 1;
166
+ /* Il decoder HEIC ha un buffer compresso, un RGBA completo e il canvas di uscita. Due in
167
+ * parallelo sono già una quantità importante di memoria anche su una workstation. */
168
+ if (files.some((file) => /\.(?:heic|heif)$/i.test(file.name)))
169
+ count = Math.min(count, 2);
170
+ if (files.some((file) => file.size >= 30 * 1024 * 1024))
171
+ count = 1;
172
+ return Math.min(count, Math.max(1, files.length));
173
+ }
174
+ export function adaptiveUploadConcurrency() {
175
+ if (typeof navigator === "undefined")
176
+ return 3;
177
+ const connection = navigator.connection;
178
+ if (connection?.saveData || connection?.effectiveType === "slow-2g" || connection?.effectiveType === "2g")
179
+ return 2;
180
+ const memory = navigator.deviceMemory || 4;
181
+ return (navigator.hardwareConcurrency || 4) >= 8 && memory >= 8 ? 4 : 3;
182
+ }
183
+ function appendError(errors, next) {
184
+ /* Nel dock servono esempi utili, non duecento righe. Il conteggio completo resta in `failed`
185
+ e `originals`; le prime otto dicono quali file guardare. */
186
+ return errors.length >= 8 ? errors : [...errors, next];
187
+ }
188
+ function readable(error) {
189
+ return error instanceof Error ? error.message : String(error);
190
+ }
191
+ function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
192
+ class BoundedQueue {
193
+ capacity;
194
+ values = [];
195
+ readers = [];
196
+ writers = [];
197
+ closed = false;
198
+ constructor(capacity) {
199
+ this.capacity = capacity;
200
+ }
201
+ async push(value) {
202
+ while (!this.closed && this.values.length >= this.capacity && this.readers.length === 0) {
203
+ await new Promise((resolve) => this.writers.push(resolve));
204
+ }
205
+ if (this.closed)
206
+ throw new Error("coda Media chiusa");
207
+ const reader = this.readers.shift();
208
+ if (reader)
209
+ reader(value);
210
+ else
211
+ this.values.push(value);
212
+ }
213
+ async shift() {
214
+ const value = this.values.shift();
215
+ if (value) {
216
+ this.writers.shift()?.();
217
+ return value;
218
+ }
219
+ if (this.closed)
220
+ return null;
221
+ return new Promise((resolve) => this.readers.push(resolve));
222
+ }
223
+ close() {
224
+ this.closed = true;
225
+ for (const reader of this.readers.splice(0))
226
+ reader(null);
227
+ for (const writer of this.writers.splice(0))
228
+ writer();
229
+ }
230
+ }
@@ -1,4 +1,5 @@
1
1
  import { type AssetFile, type AssetFolder } from "../../api.js";
2
+ import type { MediaOperationStatus } from "../../media.js";
2
3
  export type SortKey = "uploaded" | "capturedAt" | "name" | "size";
3
4
  export declare const SORT_LABELS: Record<SortKey, string>;
4
5
  export interface MediaLibrary {
@@ -11,6 +12,7 @@ export interface MediaLibrary {
11
12
  selected: Set<string>;
12
13
  busy: boolean;
13
14
  error: string | null;
15
+ mediaOperations: MediaOperationStatus[];
14
16
  /** Avanzamento di un'operazione lunga. `verb` è quello che si sta facendo ("Carico",
15
17
  * "Cestino", "Sposto"): senza, un'attesa di trenta secondi non dice cosa sta succedendo e
16
18
  * chi guarda ricarica la pagina — interrompendo l'operazione a metà. */
@@ -23,6 +25,8 @@ export interface MediaLibrary {
23
25
  sortDir: "asc" | "desc";
24
26
  uploadOpen: boolean;
25
27
  viewing: number | null;
28
+ autoWebp: boolean;
29
+ settingsBusy: boolean;
26
30
  go(prefix: string): void;
27
31
  reload(): void;
28
32
  toggle(key: string): void;
@@ -32,6 +36,7 @@ export interface MediaLibrary {
32
36
  /** Vero se sono passati tutti: chi ha aperto un dialogo lo chiude solo in quel caso. */
33
37
  upload(files: File[] | FileList): Promise<boolean>;
34
38
  setUploadOpen(open: boolean): void;
39
+ setAutoWebp(on: boolean): Promise<void>;
35
40
  view(i: number | null): void;
36
41
  step(delta: number): void;
37
42
  /** File e cartelle insieme: una cartella si riconosce dalla chiave, che finisce con "/". */
@@ -43,6 +48,8 @@ export interface MediaLibrary {
43
48
  renameFolder(prefix: string, label: string): Promise<void>;
44
49
  /** Cestina la cartella e tutto ciò che contiene. */
45
50
  removeFolder(prefix: string): Promise<void>;
51
+ resumeMedia(operation: MediaOperationStatus): Promise<void>;
52
+ cancelMedia(operation: MediaOperationStatus): Promise<void>;
46
53
  /** Il nome da mostrare per un prefisso: l'etichetta se c'è, altrimenti lo slug. */
47
54
  labelOf(prefix: string): string;
48
55
  clearError(): void;