@cardinali-studio/adminkit 2.2.11 → 2.2.13

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 (47) hide show
  1. package/dist/api.d.ts +43 -2
  2. package/dist/api.js +13 -3
  3. package/dist/app/App.js +11 -5
  4. package/dist/app/app.css +6 -0
  5. package/dist/app/lib/publish.js +4 -1
  6. package/dist/app/lib/settings.d.ts +2 -0
  7. package/dist/app/lib/settings.js +54 -8
  8. package/dist/app/routes/MediaSettingsEditor.d.ts +4 -0
  9. package/dist/app/routes/MediaSettingsEditor.js +4 -0
  10. package/dist/app/routes/SiteSettingsEditor.d.ts +3 -1
  11. package/dist/app/routes/SiteSettingsEditor.js +3 -2
  12. package/dist/app/routes/UploadDock.js +31 -9
  13. package/dist/app/site.d.ts +4 -0
  14. package/dist/deployment.js +14 -1
  15. package/dist/form/media/MediaFolders.d.ts +5 -1
  16. package/dist/form/media/MediaFolders.js +8 -7
  17. package/dist/form/media/MediaGrid.js +32 -17
  18. package/dist/form/media/UploadDialog.js +11 -5
  19. package/dist/form/media/image-conversion.d.ts +14 -0
  20. package/dist/form/media/image-conversion.js +163 -0
  21. package/dist/form/media/media-model.d.ts +41 -0
  22. package/dist/form/media/media-model.js +78 -0
  23. package/dist/form/media/media-move.d.ts +48 -0
  24. package/dist/form/media/media-move.js +92 -0
  25. package/dist/form/media/media-upload-queue.d.ts +31 -0
  26. package/dist/form/media/media-upload-queue.js +230 -0
  27. package/dist/form/media/useMediaLibrary.d.ts +12 -0
  28. package/dist/form/media/useMediaLibrary.js +215 -78
  29. package/dist/functions/_shared/assets.js +3 -2
  30. package/dist/functions/api/assets/folders.js +2 -1
  31. package/dist/functions/api/assets/settings.js +40 -0
  32. package/dist/functions/api/assets/upload.js +4 -1
  33. package/dist/functions/api/assets.js +4 -3
  34. package/dist/functions/api/site-access.js +78 -0
  35. package/dist/functions/index.js +4 -0
  36. package/dist/functions/site.js +4 -0
  37. package/dist/index.d.ts +1 -0
  38. package/dist/index.js +1 -0
  39. package/dist/migrations/0004_site_access.sql +41 -0
  40. package/dist/persistence.d.ts +1 -0
  41. package/dist/persistence.js +22 -4
  42. package/dist/release.d.ts +3 -0
  43. package/dist/release.js +7 -0
  44. package/dist/site-access.d.ts +48 -0
  45. package/dist/site-access.js +180 -0
  46. package/dist/theme.css +4 -0
  47. package/package.json +4 -1
@@ -44,7 +44,7 @@ export function FolderMenu({ lib, prefix, label, openTo = "left" }) {
44
44
  const [name, setName] = useState(label);
45
45
  const [side, setSide] = useState("left");
46
46
  const close = () => { setOpen(false); setRenaming(false); };
47
- return (_jsxs("span", { className: "mf-actions", children: [_jsx("button", { className: "mf-more", title: "Altro", onClick: (e) => {
47
+ return (_jsxs("span", { className: "mf-actions", children: [_jsx("button", { className: "mf-more", title: "Altro", disabled: lib.busy || lib.mutating, onClick: (e) => {
48
48
  e.stopPropagation();
49
49
  if (openTo === "auto") {
50
50
  const r = e.currentTarget.getBoundingClientRect();
@@ -53,23 +53,24 @@ export function FolderMenu({ lib, prefix, label, openTo = "left" }) {
53
53
  setName(label);
54
54
  setOpen((v) => !v);
55
55
  setRenaming(false);
56
- }, children: "\u2022\u2022\u2022" }), open && !renaming && (_jsxs("div", { className: `mf-menu is-${side}`, onMouseLeave: close, children: [_jsx("button", { onClick: () => setRenaming(true), children: "Rinomina\u2026" }), _jsx("button", { className: "mf-danger", onClick: () => { close(); void lib.removeFolder(prefix); }, children: "Elimina la cartella\u2026" })] })), open && renaming && (_jsxs("div", { className: `mf-menu mf-menu-form is-${side}`, children: [_jsx("input", { className: "ak-input", autoFocus: true, value: name, onChange: (e) => setName(e.target.value), onKeyDown: (e) => {
56
+ }, children: "\u2022\u2022\u2022" }), open && !renaming && (_jsxs("div", { className: `mf-menu is-${side}`, onMouseLeave: close, children: [_jsx("button", { disabled: lib.busy || lib.mutating, onClick: () => setRenaming(true), children: "Rinomina\u2026" }), _jsx("button", { disabled: lib.busy || lib.mutating, className: "mf-danger", onClick: () => { close(); void lib.removeFolder(prefix); }, children: "Elimina la cartella\u2026" })] })), open && renaming && (_jsxs("div", { className: `mf-menu mf-menu-form is-${side}`, children: [_jsx("input", { className: "ak-input", autoFocus: true, value: name, disabled: lib.busy || lib.mutating, onChange: (e) => setName(e.target.value), onKeyDown: (e) => {
57
57
  if (e.key === "Escape")
58
58
  close();
59
59
  if (e.key === "Enter" && name.trim()) {
60
60
  close();
61
61
  void lib.renameFolder(prefix, name);
62
62
  }
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" })] })] }))] }));
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: lib.busy || lib.mutating || !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
- const canDrop = lib.mode === "manage";
73
+ const canDrop = lib.mode === "manage" && !lib.busy && !lib.mutating;
73
74
  /* Nel cestino non si è in nessuna cartella: senza questo, «Tutte» resterebbe accesa insieme a
74
75
  «Cestino» e la colonna indicherebbe due posti nello stesso momento. */
75
76
  const here = (p) => !trash?.active && (p === "" ? lib.prefix === "" : lib.prefix.startsWith(p));
@@ -104,7 +105,7 @@ export function MediaFolders({ lib, trash }) {
104
105
  removeEventListener("drop", end);
105
106
  };
106
107
  }, [canDrop]);
107
- return (_jsxs("div", { className: "mf", children: [_jsx("ul", { className: "mf-list", children: _jsx("li", { className: "mf-item mf-loose" + (here("") ? " is-on" : ""), children: _jsx("button", { className: "mf-open", onClick: () => goTo(""), children: _jsx("span", { className: "mf-name", children: "Senza cartella" }) }) }) }), _jsx("ul", { className: "mf-list", children: lib.folders.map((f) => (_jsxs("li", {
108
+ return (_jsxs("div", { className: "mf", children: [_jsx("ul", { className: "mf-list", children: _jsx("li", { className: "mf-item mf-loose" + (here("") ? " is-on" : ""), children: _jsx("button", { className: "mf-open", disabled: lib.mutating, onClick: () => goTo(""), children: _jsx("span", { className: "mf-name", children: "Senza cartella" }) }) }) }), _jsx("ul", { className: "mf-list", children: lib.folders.map((f) => (_jsxs("li", {
108
109
  /* Accesa anche quando si è DENTRO una sua sottocartella: la colonna risponde a
109
110
  «dove sono», e stando in food/piatti-2024/ la risposta è ancora food. */
110
111
  className: "mf-item"
@@ -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", disabled: lib.mutating, 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", disabled: lib.busy || lib.mutating, 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", disabled: lib.mutating, 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", disabled: lib.mutating, onClick: settings.open, children: [_jsx(IcoSettings, {}), _jsx("span", { className: "mf-name", children: "Impostazioni" })] }) }) }))] }));
116
117
  }
@@ -23,6 +23,7 @@ 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
+ import { summarizeMediaSelection } from "./media-model.js";
26
27
  const JOURNAL_LABELS = {
27
28
  preparing: "Preparo le copie Media.",
28
29
  ready: "Media pronta: pubblica per applicare la modifica.",
@@ -49,8 +50,9 @@ function download(urls) {
49
50
  }
50
51
  function MoveMenu({ lib, keys }) {
51
52
  const [open, setOpen] = useState(false);
52
- const dest = lib.folders.filter((f) => f.prefix !== lib.prefix);
53
- return (_jsxs("span", { className: "mg-menu-wrap", children: [_jsx("button", { className: "mg-act", onClick: () => setOpen((v) => !v), disabled: !dest.length, children: "Sposta in\u2026" }), open && (_jsx("div", { className: "mg-menu", onMouseLeave: () => setOpen(false), children: dest.map((f) => (_jsxs("button", { onClick: () => { setOpen(false); void lib.move(keys, f.prefix); }, children: [f.label || f.name, " ", _jsx("span", { className: "mf-count", children: f.count })] }, f.prefix))) }))] }));
53
+ const locked = lib.busy || lib.mutating;
54
+ const dest = lib.folders.filter((f) => f.prefix !== lib.prefix && lib.canMoveTo(keys, f.prefix));
55
+ return (_jsxs("span", { className: "mg-menu-wrap", children: [_jsx("button", { className: "mg-act", onClick: () => setOpen((v) => !v), disabled: locked || !dest.length, children: "Sposta in\u2026" }), open && (_jsx("div", { className: "mg-menu", onMouseLeave: () => setOpen(false), children: dest.map((f) => (_jsxs("button", { onClick: () => { setOpen(false); void lib.move(keys, f.prefix); }, children: [f.label || f.name, " ", _jsx("span", { className: "mf-count", children: f.count })] }, f.prefix))) }))] }));
54
56
  }
55
57
  /* Una cartella dentro la griglia. Fa tre cose che un chip non poteva fare: si apre, si
56
58
  * seleziona insieme alle foto, e si trascina — dentro un'altra cartella qui, o sulla colonna di
@@ -60,7 +62,12 @@ function FolderCell({ lib, folder, manage }) {
60
62
  const [over, setOver] = useState(false);
61
63
  const on = lib.selected.has(folder.prefix);
62
64
  const name = folder.label || folder.name;
63
- return (_jsxs("figure", { className: "mg-item mg-folder" + (on ? " is-on" : "") + (over ? " is-over" : ""), draggable: manage, onDragStart: (e) => {
65
+ const locked = lib.busy || lib.mutating;
66
+ return (_jsxs("figure", { className: "mg-item mg-folder" + (on ? " is-on" : "") + (over ? " is-over" : ""), draggable: manage && !locked, onDragStart: (e) => {
67
+ if (locked) {
68
+ e.preventDefault();
69
+ return;
70
+ }
64
71
  const ids = on && lib.selected.size ? [...lib.selected] : [folder.prefix];
65
72
  // Due tipi, non uno: durante il trascinamento i valori non si leggono, i tipi sì —
66
73
  // ed è così che la colonna sa di avere in mano una cartella e offre il primo livello.
@@ -77,7 +84,7 @@ function FolderCell({ lib, folder, manage }) {
77
84
  "è roba nostra": il tipo dei nostri dati non è leggibile in ogni passaggio del trascinamento,
78
85
  e un bersaglio che non si accende sembra un bersaglio che non funziona. */
79
86
  onDragOver: (e) => {
80
- if (!manage || e.dataTransfer.types.includes("Files"))
87
+ if (!manage || locked || e.dataTransfer.types.includes("Files"))
81
88
  return;
82
89
  /* Si accende anche quando la cartella trascinata è questa stessa: durante il
83
90
  trascinamento i valori non sono leggibili, quindi non si può sapere. Il rifiuto
@@ -86,19 +93,22 @@ function FolderCell({ lib, folder, manage }) {
86
93
  setOver(true);
87
94
  }, onDragLeave: () => setOver(false), onDrop: (e) => {
88
95
  setOver(false);
89
- if (!manage)
96
+ if (!manage || locked)
90
97
  return;
91
98
  const ids = readDragged(e.dataTransfer);
92
99
  if (!ids.length)
93
100
  return; // non è roba nostra: lascia fare a chi sta sopra
94
101
  e.preventDefault();
95
102
  void lib.move(ids, folder.prefix);
96
- }, children: [_jsxs("div", { className: "mg-frame", onClick: () => lib.go(folder.prefix), children: [_jsx("svg", { className: "mg-folder-ico", viewBox: "0 0 48 40", "aria-hidden": "true", children: _jsx("path", { d: "M2 8a4 4 0 0 1 4-4h12l4 5h20a4 4 0 0 1 4 4v21a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8Z" }) }), manage && (_jsx("button", { className: "mg-check", "aria-label": on ? "Deseleziona" : "Seleziona", onClick: (e) => { e.stopPropagation(); lib.toggle(folder.prefix); }, children: on ? "✓" : "" }))] }), _jsx("figcaption", { className: "mg-cap", title: name, children: name }), manage && (_jsx("span", { className: "mg-folder-menu", children: _jsx(FolderMenu, { lib: lib, prefix: folder.prefix, label: name, openTo: "auto" }) }))] }));
103
+ }, children: [_jsxs("div", { className: "mg-frame", onClick: () => { if (!locked)
104
+ lib.go(folder.prefix); }, children: [_jsx("svg", { className: "mg-folder-ico", viewBox: "0 0 48 40", "aria-hidden": "true", children: _jsx("path", { d: "M2 8a4 4 0 0 1 4-4h12l4 5h20a4 4 0 0 1 4 4v21a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8Z" }) }), manage && (_jsx("button", { className: "mg-check", "aria-label": on ? "Deseleziona" : "Seleziona", disabled: locked, onClick: (e) => { e.stopPropagation(); lib.toggle(folder.prefix); }, children: on ? "✓" : "" }))] }), _jsx("figcaption", { className: "mg-cap", title: name, children: name }), manage && (_jsx("span", { className: "mg-folder-menu", children: _jsx(FolderMenu, { lib: lib, prefix: folder.prefix, label: name, openTo: "auto" }) }))] }));
97
105
  }
98
106
  export function MediaGrid({ lib, onPick, onCancel }) {
99
107
  const manage = lib.mode === "manage";
100
108
  const sel = lib.selected;
101
- const allOn = lib.shown.length > 0 && lib.shown.every((f) => sel.has(f.key));
109
+ const allOn = lib.allSelected;
110
+ const locked = lib.busy || lib.mutating;
111
+ const selection = summarizeMediaSelection(sel);
102
112
  /* `dragDepth` e non un booleano: `dragenter`/`dragleave` scattano anche passando da un figlio
103
113
  all'altro, quindi con un booleano la zona si spegne mentre ci si muove sopra. Si conta. */
104
114
  const [dragDepth, setDragDepth] = useState(0);
@@ -106,8 +116,8 @@ export function MediaGrid({ lib, onPick, onCancel }) {
106
116
  zona di caricamento non c'entra. Il controllo è "NON è roba nostra" invece di "è un file":
107
117
  `types` non contiene "Files" allo stesso modo in ogni browser, e con quel controllo il
108
118
  caricamento non partiva affatto dove il nome era diverso. */
109
- const external = (e) => manage && !e.dataTransfer.types.includes(DRAG_TYPE) && !e.dataTransfer.types.includes(DRAG_FOLDERS);
110
- return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "mg-wrap" + (dragDepth > 0 ? " is-dropping" : ""),
119
+ const external = (e) => manage && !locked && !e.dataTransfer.types.includes(DRAG_TYPE) && !e.dataTransfer.types.includes(DRAG_FOLDERS);
120
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "mg-wrap" + (dragDepth > 0 ? " is-dropping" : ""), "aria-busy": lib.mutating ? "true" : "false",
111
121
  /* Trascinare file dal Finder sulla griglia carica, senza passare dal dialogo: è il gesto
112
122
  più corto e va lasciato funzionare. Il dialogo serve a chi cerca un bottone.
113
123
  La zona è ALTA QUANTO IL PANNELLO (`.mg-wrap { min-height: 100% }`): prima era alta
@@ -120,15 +130,19 @@ export function MediaGrid({ lib, onPick, onCancel }) {
120
130
  e.preventDefault(); }, onDragLeave: (e) => { if (external(e))
121
131
  setDragDepth((n) => Math.max(0, n - 1)); }, onDrop: (e) => {
122
132
  setDragDepth(0);
123
- if (!manage || !e.dataTransfer.files.length)
133
+ if (!manage || locked || !e.dataTransfer.files.length)
124
134
  return;
125
135
  e.preventDefault();
126
136
  void lib.upload(e.dataTransfer.files);
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)
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)
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) => {
137
+ }, 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, disabled: locked || !lib.selectableIds.length, ref: (el) => { if (el)
138
+ 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, disabled: locked, 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", disabled: locked, 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, disabled: locked, ref: (el) => { if (el)
139
+ el.indeterminate = lib.someSelected; }, onChange: () => lib.setAll(!allOn) }), _jsx("span", { children: selection.label })] }), !allOn && lib.selectableIds.length > 0 && (_jsxs("button", { className: "mg-act is-link", disabled: locked, onClick: () => lib.setAll(true), children: ["Seleziona tutti gli elementi (", lib.selectableIds.length, ")"] })), manage && (_jsxs(_Fragment, { children: [lib.selectedFiles.length > 0 && (_jsxs("button", { className: "mg-act", disabled: locked, onClick: () => download(lib.shown.filter((f) => sel.has(f.key)).map((f) => ({ url: f.url, name: f.name }))), children: ["Scarica ", lib.selectedFiles.length, " file"] })), _jsx(MoveMenu, { lib: lib, keys: [...sel] }), _jsx("button", { className: "mg-act is-danger", disabled: locked, onClick: () => void lib.remove([...sel]), children: "Elimina" })] })), _jsx("span", { className: "ak-spacer" }), _jsx("button", { className: "mg-act", disabled: locked, 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: locked, 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: locked, 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", disabled: locked, 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) => {
130
140
  const on = sel.has(f.key);
131
- return (_jsxs("figure", { className: "mg-item" + (on ? " is-on" : ""), draggable: manage, onDragStart: (e) => {
141
+ return (_jsxs("figure", { className: "mg-item" + (on ? " is-on" : ""), draggable: manage && !locked, onDragStart: (e) => {
142
+ if (locked) {
143
+ e.preventDefault();
144
+ return;
145
+ }
132
146
  // Si trascina la SELEZIONE se la foto ne fa parte, altrimenti solo quella.
133
147
  const ids = on && sel.size ? [...sel] : [f.key];
134
148
  const files = ids.filter((i) => !i.endsWith("/"));
@@ -138,11 +152,12 @@ export function MediaGrid({ lib, onPick, onCancel }) {
138
152
  if (dirs.length)
139
153
  e.dataTransfer.setData(DRAG_FOLDERS, JSON.stringify(dirs));
140
154
  e.dataTransfer.effectAllowed = "move";
141
- }, children: [_jsxs("div", { className: "mg-frame", onClick: () => (manage ? lib.view(i) : lib.toggle(f.key)), children: [_jsx("img", { className: "mg-img", src: f.url, alt: "", loading: "lazy",
155
+ }, children: [_jsxs("div", { className: "mg-frame", onClick: () => { if (!locked)
156
+ (manage ? lib.view(i) : lib.toggle(f.key)); }, children: [_jsx("img", { className: "mg-img", src: f.url, alt: "", loading: "lazy",
142
157
  /* Il rapporto è noto prima che l'immagine arrivi (lo dà l'elenco): la cella
143
158
  riserva subito l'ingombro giusto e la griglia non salta mentre si carica. */
144
- style: { aspectRatio: f.w && f.h ? `${f.w} / ${f.h}` : "3 / 2" } }), _jsx("button", { className: "mg-check", "aria-label": on ? "Deseleziona" : "Seleziona", onClick: (e) => { e.stopPropagation(); lib.toggle(f.key); }, children: on ? "✓" : "" })] }), _jsx("figcaption", { className: "mg-cap", title: f.name, children: f.name })] }, f.key));
159
+ style: { aspectRatio: f.w && f.h ? `${f.w} / ${f.h}` : "3 / 2" } }), _jsx("button", { className: "mg-check", "aria-label": on ? "Deseleziona" : "Seleziona", disabled: locked, onClick: (e) => { e.stopPropagation(); lib.toggle(f.key); }, children: on ? "✓" : "" })] }), _jsx("figcaption", { className: "mg-cap", title: f.name, children: f.name })] }, f.key));
145
160
  }) }), !lib.busy && lib.shown.length === 0 && (_jsx("p", { className: "ak-hint", children: lib.prefix === "" && lib.folders.length
146
161
  ? "Nessuna foto fuori dalle cartelle. Scegline una qui a fianco."
147
- : "Nessun file qui." })), lib.mode === "pick" && (_jsxs("footer", { className: "mg-foot", children: [_jsxs("span", { className: "ak-hint", children: [sel.size, " selezionate"] }), _jsx("span", { className: "ak-spacer" }), _jsx("button", { className: "ak-btn ak-btn-quiet", onClick: () => onCancel?.(), children: "Annulla" }), _jsxs("button", { className: "ak-btn ak-btn-primary", disabled: !sel.size, onClick: () => onPick?.(lib.shown.filter((f) => sel.has(f.key))), children: ["Usa ", sel.size > 0 ? `(${sel.size})` : ""] })] }))] }), _jsx(Lightbox, { lib: lib }), manage && _jsx(UploadDialog, { lib: lib })] }));
162
+ : "Nessun file qui." })), lib.mode === "pick" && (_jsxs("footer", { className: "mg-foot", children: [_jsxs("span", { className: "ak-hint", children: [sel.size, " selezionate"] }), _jsx("span", { className: "ak-spacer" }), _jsx("button", { className: "ak-btn ak-btn-quiet", disabled: locked, onClick: () => onCancel?.(), children: "Annulla" }), _jsxs("button", { className: "ak-btn ak-btn-primary", disabled: locked || !sel.size, onClick: () => onPick?.(lib.shown.filter((f) => sel.has(f.key))), children: ["Usa ", sel.size > 0 ? `(${sel.size})` : ""] })] }))] }), _jsx(Lightbox, { lib: lib }), manage && _jsx(UploadDialog, { lib: lib })] }));
148
163
  }
@@ -15,23 +15,29 @@ export function UploadDialog({ lib }) {
15
15
  if (!lib.uploadOpen)
16
16
  return null;
17
17
  const send = async (list) => {
18
- if (!list || !list.length)
18
+ if (lib.mutating || !list || !list.length)
19
19
  return;
20
20
  // `upload` dice se sono passati tutti. Guardare `lib.error` da qui non funzionerebbe: è il
21
21
  // valore catturato prima del render, quindi sarebbe sempre uguale a sé stesso.
22
22
  if (await lib.upload(list))
23
23
  lib.setUploadOpen(false);
24
24
  };
25
- return (_jsx("div", { className: "up", onClick: (e) => { if (e.target === e.currentTarget)
26
- lib.setUploadOpen(false); }, children: _jsxs("div", { className: "up-panel", children: [_jsx("button", { className: "up-close", onClick: () => lib.setUploadOpen(false), "aria-label": "Chiudi", children: "\u00D7" }), _jsxs("div", { className: "up-drop" + (over ? " is-over" : ""),
25
+ return (_jsx("div", { className: "up", onClick: (e) => { if (!lib.mutating && e.target === e.currentTarget)
26
+ lib.setUploadOpen(false); }, children: _jsxs("div", { className: "up-panel", children: [_jsx("button", { className: "up-close", disabled: lib.mutating, onClick: () => lib.setUploadOpen(false), "aria-label": "Chiudi", children: "\u00D7" }), _jsxs("div", { className: "up-drop" + (over ? " is-over" : ""),
27
27
  /* `stopPropagation` oltre a `preventDefault`: il dialogo è una sovrapposizione, ma
28
28
  per React resta un nodo dell'albero e l'evento continuerebbe a salire. Oggi sopra
29
29
  non c'è più nessuna zona di rilascio (il dialogo è fuori da `.mg-wrap`), e questa
30
30
  riga serve a che non torni a essercene una senza che nessuno se ne accorga. */
31
- onDragOver: (e) => { e.preventDefault(); e.stopPropagation(); setOver(true); }, onDragLeave: () => setOver(false), onDrop: (e) => {
31
+ onDragOver: (e) => { if (!lib.mutating) {
32
+ e.preventDefault();
33
+ e.stopPropagation();
34
+ setOver(true);
35
+ } }, onDragLeave: () => setOver(false), onDrop: (e) => {
36
+ if (lib.mutating)
37
+ return;
32
38
  e.preventDefault();
33
39
  e.stopPropagation();
34
40
  setOver(false);
35
41
  void send(e.dataTransfer.files);
36
- }, children: [_jsxs("svg", { className: "up-cloud", viewBox: "0 0 48 32", width: "56", height: "38", "aria-hidden": "true", children: [_jsx("path", { fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinejoin: "round", d: "M38 30a9 9 0 0 0 .6-18A13 13 0 0 0 13 13.6 8 8 0 0 0 10 30" }), _jsx("path", { fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", d: "M24 30V12m-6 6 6-6 6 6" })] }), _jsx("p", { className: "up-hint", children: "Trascina qui le foto da caricare" }), _jsx("input", { ref: input, type: "file", multiple: true, accept: "image/*,video/*", hidden: true, onChange: (e) => void send(e.target.files) }), _jsx("button", { className: "ak-btn ak-btn-primary", onClick: () => input.current?.click(), children: "Scegli i file" })] }), _jsxs("p", { className: "up-dest", children: ["Finiranno in ", _jsx("strong", { children: lib.prefix ? lib.labelOf(lib.prefix) : "Senza cartella (la radice del bucket)" })] }), lib.progress && (_jsxs("p", { className: "ak-hint", children: [lib.progress.verb, " ", lib.progress.done, "/", lib.progress.total, "\u2026"] })), lib.error && _jsx("p", { className: "ak-error", children: lib.error })] }) }));
42
+ }, children: [_jsxs("svg", { className: "up-cloud", viewBox: "0 0 48 32", width: "56", height: "38", "aria-hidden": "true", children: [_jsx("path", { fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinejoin: "round", d: "M38 30a9 9 0 0 0 .6-18A13 13 0 0 0 13 13.6 8 8 0 0 0 10 30" }), _jsx("path", { fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", d: "M24 30V12m-6 6 6-6 6 6" })] }), _jsx("p", { className: "up-hint", children: "Trascina qui le foto da caricare" }), _jsx("input", { ref: input, type: "file", multiple: true, accept: "image/*,video/*", hidden: true, disabled: lib.mutating, onChange: (e) => void send(e.target.files) }), _jsx("button", { className: "ak-btn ak-btn-primary", disabled: lib.mutating, onClick: () => input.current?.click(), children: "Scegli i file" })] }), _jsxs("p", { className: "up-dest", children: ["Finiranno in ", _jsx("strong", { children: lib.prefix ? lib.labelOf(lib.prefix) : "Senza cartella (la radice del bucket)" })] }), lib.progress && (_jsxs("p", { className: "ak-hint", children: [lib.progress.verb, " ", lib.progress.done, "/", lib.progress.total, "\u2026"] })), lib.error && _jsx("p", { className: "ak-error", children: lib.error })] }) }));
37
43
  }
@@ -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,41 @@
1
+ import type { AssetFile, AssetFolder } from "../../api.js";
2
+ export type MediaSelectionKind = "file" | "folder";
3
+ export interface MediaSelectableItem {
4
+ id: string;
5
+ kind: MediaSelectionKind;
6
+ }
7
+ export interface MediaSelectionSummary {
8
+ files: string[];
9
+ folders: string[];
10
+ total: number;
11
+ label: string;
12
+ }
13
+ /** L'unico dominio su cui "Seleziona tutto" e la relativa checkbox possono ragionare. */
14
+ export declare function mediaSelectableItems({ mode, prefix, shown, subfolders, }: {
15
+ mode: "manage" | "pick";
16
+ prefix: string;
17
+ shown: Pick<AssetFile, "key">[];
18
+ subfolders: Pick<AssetFolder, "prefix">[];
19
+ }): MediaSelectableItem[];
20
+ export declare function mediaSelectionState(selectableIds: string[], selected: ReadonlySet<string>): {
21
+ selectedCount: number;
22
+ allSelected: boolean;
23
+ someSelected: boolean;
24
+ };
25
+ export declare function summarizeMediaSelection(ids: Iterable<string>): MediaSelectionSummary;
26
+ /**
27
+ * Deduplica la selezione e rimuove discendenti già coperti da una cartella selezionata.
28
+ * L'ordine delle cartelle va dalla più esterna alla più interna per rendere il risultato stabile.
29
+ */
30
+ export declare function normalizeMediaSelection(ids: Iterable<string>): {
31
+ files: string[];
32
+ folders: string[];
33
+ ids: string[];
34
+ };
35
+ export declare function invalidMediaMoveReason(ids: Iterable<string>, destination: string): string | null;
36
+ export declare function isValidMediaMoveDestination(ids: Iterable<string>, destination: string): boolean;
37
+ export declare function mediaFolderSlug(name: string): string;
38
+ /** Guardia sincrona condivisibile con una ref React; il secondo avvio perde immediatamente. */
39
+ export declare function tryBeginMediaMutation(lock: {
40
+ current: boolean;
41
+ }): boolean;
@@ -0,0 +1,78 @@
1
+ /** L'unico dominio su cui "Seleziona tutto" e la relativa checkbox possono ragionare. */
2
+ export function mediaSelectableItems({ mode, prefix, shown, subfolders, }) {
3
+ const visibleFolders = mode === "manage" && prefix !== ""
4
+ ? subfolders.map((folder) => ({ id: folder.prefix, kind: "folder" }))
5
+ : [];
6
+ return [
7
+ ...visibleFolders,
8
+ ...shown.map((file) => ({ id: file.key, kind: "file" })),
9
+ ];
10
+ }
11
+ export function mediaSelectionState(selectableIds, selected) {
12
+ const selectedCount = selectableIds.reduce((count, id) => count + Number(selected.has(id)), 0);
13
+ return {
14
+ selectedCount,
15
+ allSelected: selectableIds.length > 0 && selectedCount === selectableIds.length,
16
+ someSelected: selectedCount > 0 && selectedCount < selectableIds.length,
17
+ };
18
+ }
19
+ export function summarizeMediaSelection(ids) {
20
+ const unique = [...new Set(ids)];
21
+ const files = unique.filter((id) => !id.endsWith("/"));
22
+ const folders = unique.filter((id) => id.endsWith("/"));
23
+ const parts = [
24
+ files.length ? `${files.length} file` : "",
25
+ folders.length ? `${folders.length} ${folders.length === 1 ? "cartella" : "cartelle"}` : "",
26
+ ].filter(Boolean);
27
+ const agreement = folders.length && !files.length
28
+ ? (folders.length === 1 ? "selezionata" : "selezionate")
29
+ : (unique.length === 1 ? "selezionato" : "selezionati");
30
+ return {
31
+ files,
32
+ folders,
33
+ total: unique.length,
34
+ label: `${parts.join(" e ")} ${agreement}`,
35
+ };
36
+ }
37
+ /**
38
+ * Deduplica la selezione e rimuove discendenti già coperti da una cartella selezionata.
39
+ * L'ordine delle cartelle va dalla più esterna alla più interna per rendere il risultato stabile.
40
+ */
41
+ export function normalizeMediaSelection(ids) {
42
+ const unique = [...new Set(ids)].filter((id) => typeof id === "string" && id.length > 0);
43
+ const folders = unique.filter((id) => id.endsWith("/")).sort((a, b) => a.length - b.length || a.localeCompare(b));
44
+ const roots = [];
45
+ for (const folder of folders) {
46
+ if (!roots.some((root) => folder.startsWith(root)))
47
+ roots.push(folder);
48
+ }
49
+ const files = unique
50
+ .filter((id) => !id.endsWith("/") && !roots.some((root) => id.startsWith(root)))
51
+ .sort();
52
+ return { files, folders: roots, ids: [...roots, ...files] };
53
+ }
54
+ export function invalidMediaMoveReason(ids, destination) {
55
+ const { folders } = normalizeMediaSelection(ids);
56
+ for (const source of folders) {
57
+ if (destination === source) {
58
+ return `La destinazione «${source}» coincide con una cartella selezionata.`;
59
+ }
60
+ if (destination.startsWith(source)) {
61
+ return `La destinazione «${destination}» si trova dentro la cartella selezionata «${source}».`;
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+ export function isValidMediaMoveDestination(ids, destination) {
67
+ return invalidMediaMoveReason(ids, destination) === null;
68
+ }
69
+ export function mediaFolderSlug(name) {
70
+ return name.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
71
+ }
72
+ /** Guardia sincrona condivisibile con una ref React; il secondo avvio perde immediatamente. */
73
+ export function tryBeginMediaMutation(lock) {
74
+ if (lock.current)
75
+ return false;
76
+ lock.current = true;
77
+ return true;
78
+ }
@@ -0,0 +1,48 @@
1
+ import type { AssetFile, AssetFolder } from "../../api.js";
2
+ export interface PlannedFolderMove {
3
+ source: string;
4
+ name: string;
5
+ target: string;
6
+ files: AssetFile[];
7
+ folders: AssetFolder[];
8
+ }
9
+ export interface MediaMovePlan {
10
+ directFiles: string[];
11
+ folders: PlannedFolderMove[];
12
+ totalFiles: number;
13
+ }
14
+ interface MoveResult {
15
+ moved?: number;
16
+ urlMap?: Record<string, string>;
17
+ failed?: {
18
+ key: string;
19
+ error: string;
20
+ }[];
21
+ }
22
+ export interface MediaMoveExecutor {
23
+ createFolder(parent: string, name: string, opts: {
24
+ label: string;
25
+ }): Promise<{
26
+ prefix: string;
27
+ exists?: boolean;
28
+ }>;
29
+ move(keys: string[], destination: string): Promise<MoveResult>;
30
+ applyReferences(urlMap: Record<string, string>): Promise<{
31
+ failed?: {
32
+ key: string;
33
+ error: string;
34
+ }[];
35
+ }>;
36
+ removeSourceFolders(prefixes: string[]): Promise<void>;
37
+ }
38
+ export declare function mediaMoveNeedsConfirmation(plan: MediaMovePlan, multiFileThreshold?: number): boolean;
39
+ export declare function mediaMoveConfirmation(plan: MediaMovePlan, destinationLabel: string): string;
40
+ /**
41
+ * Esegue un piano già validato. Al primo errore non avvia altri gruppi né applica i riferimenti;
42
+ * le copie riuscite restano duplicati sicuri e gli originali restano disponibili.
43
+ */
44
+ export declare function executeMediaMovePlan(plan: MediaMovePlan, destination: string, executor: MediaMoveExecutor, onProgress: (done: number, total: number) => void, chunkSize?: number): Promise<{
45
+ completed: number;
46
+ total: number;
47
+ }>;
48
+ export {};