@cardinali-studio/adminkit 2.2.12 → 2.2.14

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.
package/dist/app/App.js CHANGED
@@ -219,7 +219,7 @@ export function App() {
219
219
  const p = all.slice(0, i + 1).join("/") + "/";
220
220
  return { prefix: p, label: media.labelOf(p) };
221
221
  });
222
- return (_jsx(ProsePages.Provider, { value: pageLinks, children: _jsxs("div", { className: "app" + (hasList(section) && !offModule ? "" : " is-two"), children: [_jsxs("nav", { className: "rail", children: [_jsxs("div", { className: "rail-brand", children: [_jsx("span", { className: "rail-brand-name", children: site().name }), _jsx("span", { className: "rail-brand-role", children: "pannello" })] }), _jsx("div", { className: "rail-nav", children: navGroups.map((g) => (_jsxs("div", { className: "rail-group", children: [g.title && _jsx("span", { className: "rail-group-title", children: g.title }), g.items.map((n) => (_jsxs("button", { className: "rail-item" + (section === n.id ? " is-on" : ""), onClick: () => go(`/${n.id}`), children: [_jsx(n.icon, {}), _jsx("span", { children: n.label }), "badge" in n && n.badge ? _jsx("span", { className: "rail-badge", children: n.badge }) : null] }, n.id)))] }, g.title ?? "top"))) }), _jsxs("div", { className: "rail-foot", children: [_jsxs("a", { className: "rail-item", href: "/", target: "_blank", rel: "noreferrer", children: [_jsx(Icon.out, {}), _jsx("span", { children: "Vedi il sito" })] }), _jsxs("button", { className: "rail-item", onClick: toggleTheme, children: [theme === "dark" ? _jsx(Icon.sun, {}) : _jsx(Icon.moon, {}), _jsx("span", { children: theme === "dark" ? "Tema chiaro" : "Tema scuro" })] }), _jsxs("button", { className: "rail-item" + (section === "settings" ? " is-on" : ""), onClick: () => go("/settings/site"), children: [_jsx(Icon.settings, {}), _jsx("span", { children: "Impostazioni" })] })] })] }), hasList(section) && !offModule && (_jsxs("div", { className: "col", children: [section === "pages" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: list && _jsx(NewPageButton, { pages: list, onCreate: (p) => pages.update([...list, p]) }) }), _jsx("div", { className: "col-body", children: pages.error ? _jsx("p", { className: "ak-error", children: pages.error })
222
+ return (_jsx(ProsePages.Provider, { value: pageLinks, children: _jsxs("div", { className: "app" + (hasList(section) && !offModule ? "" : " is-two"), children: [_jsxs("nav", { className: "rail", children: [_jsxs("div", { className: "rail-brand", children: [_jsx("span", { className: "rail-brand-name", children: site().name }), _jsx("span", { className: "rail-brand-role", children: "pannello" })] }), _jsx("div", { className: "rail-nav", children: navGroups.map((g) => (_jsxs("div", { className: "rail-group", children: [g.title && _jsx("span", { className: "rail-group-title", children: g.title }), g.items.map((n) => (_jsxs("button", { className: "rail-item" + (section === n.id ? " is-on" : ""), disabled: section === "media" && media.mutating, onClick: () => go(`/${n.id}`), children: [_jsx(n.icon, {}), _jsx("span", { children: n.label }), "badge" in n && n.badge ? _jsx("span", { className: "rail-badge", children: n.badge }) : null] }, n.id)))] }, g.title ?? "top"))) }), _jsxs("div", { className: "rail-foot", children: [_jsxs("a", { className: "rail-item", href: "/", target: "_blank", rel: "noreferrer", children: [_jsx(Icon.out, {}), _jsx("span", { children: "Vedi il sito" })] }), _jsxs("button", { className: "rail-item", onClick: toggleTheme, children: [theme === "dark" ? _jsx(Icon.sun, {}) : _jsx(Icon.moon, {}), _jsx("span", { children: theme === "dark" ? "Tema chiaro" : "Tema scuro" })] }), _jsxs("button", { className: "rail-item" + (section === "settings" ? " is-on" : ""), disabled: section === "media" && media.mutating, onClick: () => go("/settings/site"), children: [_jsx(Icon.settings, {}), _jsx("span", { children: "Impostazioni" })] })] })] }), hasList(section) && !offModule && (_jsxs("div", { className: "col", children: [section === "pages" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: list && _jsx(NewPageButton, { pages: list, onCreate: (p) => pages.update([...list, p]) }) }), _jsx("div", { className: "col-body", children: pages.error ? _jsx("p", { className: "ak-error", children: pages.error })
223
223
  : !list ? _jsx("p", { className: "ak-hint", children: "Carico\u2026" })
224
224
  : _jsx(PagesList, { pages: list, current: editing?.slug, onEdit: (slug) => go(`/pages/${slug}`), languages: languages, onChange: pages.update, onRenamed: (from, to) => {
225
225
  if (editing?.slug === from)
@@ -244,7 +244,7 @@ export function App() {
244
244
  : deliveries.rows.length === 0 ? _jsx("p", { className: "ak-hint", children: "Nessun transfer, per ora." })
245
245
  : _jsx(DeliveriesList, { rows: deliveries.rows, current: activeDeliveryId || quickSelectedId, onOpen: (id) => { setQuickSelectedId(undefined); go(`/deliveries/${id}`); } }) }), _jsxs("div", { className: "dl-col-foot", children: [deliveries.rows && _jsx(DeliveriesTotal, { rows: deliveries.rows }), _jsxs("button", { className: "col-item" + (deliverySettingsOpen ? " is-on" : ""), onClick: () => { setQuickSelectedId(undefined); go("/deliveries/settings"); }, children: [_jsx(Icon.settings, {}), _jsx("span", { children: "Impostazioni" })] })] })] })), section === "bookings" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: _jsx("span", { className: "col-title", children: bookings.rows ? `${bookings.rows.length} appuntamenti` : "Prenotazioni" }) }), _jsx("div", { className: "col-body", children: bookings.error ? _jsx("p", { className: "ak-error", children: bookings.error })
246
246
  : !bookings.rows ? _jsx("p", { className: "ak-hint", children: "Carico\u2026" })
247
- : _jsx(BookingsList, { rows: bookings.rows, current: bookingSettingsOpen ? "settings" : openBooking?.id, onOpen: (id) => go(`/bookings/${id}`), onAgenda: () => go("/bookings"), onSettings: () => go("/bookings/settings") }) })] })), section === "home" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: _jsx("span", { className: "col-title", children: "Sezioni" }) }), _jsx("div", { className: "col-body", children: _jsx("ul", { className: "col-list", children: homeGroups.map((g) => (_jsx("li", { children: _jsx("button", { className: "col-item" + (g === homeGroup ? " is-on" : ""), onClick: () => go(`/home/${groupSlug(g)}`), children: g || "Generale" }) }, g))) }) })] })), section === "settings" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: _jsx("span", { className: "col-title", children: "Impostazioni" }) }), _jsx("div", { className: "col-body", children: _jsxs("ul", { className: "col-list settings-list", children: [_jsx("li", { children: _jsxs("button", { className: "col-item settings-list-item" + (settingsView === "site" ? " is-on" : ""), onClick: () => go("/settings/site"), children: [_jsx("strong", { children: "Sito" }), _jsx("span", { children: "Favicon, identit\u00E0 e accesso generale." })] }) }), _jsx("li", { children: _jsxs("button", { className: "col-item settings-list-item" + (settingsView === "seo" ? " is-on" : ""), onClick: () => go("/settings/seo"), children: [_jsx("strong", { children: "SEO" }), _jsx("span", { children: "Motori di ricerca e crawler AI." })] }) })] }) })] }))] })), _jsxs("main", { className: "pane", children: [_jsxs("header", { className: "pane-head", children: [editing ? (_jsx(PageHeader, { page: editing, pages: list ?? [], onChange: replace, seoActive: seoEditing, onSeoToggle: () => go(`/pages/${editing.slug}${seoEditing ? "" : "/seo"}`), languages: languages, activeLanguage: pageLanguage, onLanguageChange: setPageLanguage })) : view === "media" && !mediaSettingsOpen ? (_jsxs("nav", { className: "pane-crumbs", "aria-label": "Percorso", children: [_jsx("button", { className: "pane-crumb" + (mediaCrumbs.length ? "" : " is-current"), onClick: () => media.go(""), children: "Senza cartella" }), mediaCrumbs.map((c, i) => (_jsxs("span", { className: "pane-crumb-part", children: [_jsx("span", { className: "pane-crumb-sep", "aria-hidden": "true", children: "\u203A" }), _jsx("button", { className: "pane-crumb" + (i === mediaCrumbs.length - 1 ? " is-current" : ""), onClick: () => media.go(c.prefix), children: c.label })] }, c.prefix)))] })) : (_jsx("h1", { className: "pane-title", children: heading })), view === "media" && !mediaSettingsOpen && _jsx("span", { className: "pane-count", children: media.shown.length }), _jsx("span", { className: "pane-spacer" }), view === "media" && !mediaSettingsOpen && (_jsx("button", { className: "ak-btn ak-btn-primary", onClick: () => media.setUploadOpen(true), children: "Carica immagini" })), saver?.notice && (_jsx("button", { className: "pane-notice", onClick: () => saver.setNotice(null), children: saver.notice })), section === "bookings" && bookings.notice && (_jsx("button", { className: "pane-notice", onClick: bookings.clearNotice, children: bookings.notice })), publish.notice && (_jsx("button", { className: "pane-notice", onClick: publish.clearNotice, children: publish.notice })), !publish.configured && publish.error && (_jsx("span", { className: "pane-notice", title: publish.error, children: publish.error })), saver && (_jsxs(_Fragment, { children: [saver.dirty && _jsx("span", { className: "pane-dot", title: "modifiche non salvate" }), _jsx("button", { className: "ak-btn", disabled: !saver.dirty || saver.saving,
247
+ : _jsx(BookingsList, { rows: bookings.rows, current: bookingSettingsOpen ? "settings" : openBooking?.id, onOpen: (id) => go(`/bookings/${id}`), onAgenda: () => go("/bookings"), onSettings: () => go("/bookings/settings") }) })] })), section === "home" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: _jsx("span", { className: "col-title", children: "Sezioni" }) }), _jsx("div", { className: "col-body", children: _jsx("ul", { className: "col-list", children: homeGroups.map((g) => (_jsx("li", { children: _jsx("button", { className: "col-item" + (g === homeGroup ? " is-on" : ""), onClick: () => go(`/home/${groupSlug(g)}`), children: g || "Generale" }) }, g))) }) })] })), section === "settings" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: _jsx("span", { className: "col-title", children: "Impostazioni" }) }), _jsx("div", { className: "col-body", children: _jsxs("ul", { className: "col-list settings-list", children: [_jsx("li", { children: _jsxs("button", { className: "col-item settings-list-item" + (settingsView === "site" ? " is-on" : ""), onClick: () => go("/settings/site"), children: [_jsx("strong", { children: "Sito" }), _jsx("span", { children: "Favicon, identit\u00E0 e accesso generale." })] }) }), _jsx("li", { children: _jsxs("button", { className: "col-item settings-list-item" + (settingsView === "seo" ? " is-on" : ""), onClick: () => go("/settings/seo"), children: [_jsx("strong", { children: "SEO" }), _jsx("span", { children: "Motori di ricerca e crawler AI." })] }) })] }) })] }))] })), _jsxs("main", { className: "pane", children: [_jsxs("header", { className: "pane-head", children: [editing ? (_jsx(PageHeader, { page: editing, pages: list ?? [], onChange: replace, seoActive: seoEditing, onSeoToggle: () => go(`/pages/${editing.slug}${seoEditing ? "" : "/seo"}`), languages: languages, activeLanguage: pageLanguage, onLanguageChange: setPageLanguage })) : view === "media" && !mediaSettingsOpen ? (_jsxs("nav", { className: "pane-crumbs", "aria-label": "Percorso", children: [_jsx("button", { className: "pane-crumb" + (mediaCrumbs.length ? "" : " is-current"), disabled: media.mutating, onClick: () => media.go(""), children: "Senza cartella" }), mediaCrumbs.map((c, i) => (_jsxs("span", { className: "pane-crumb-part", children: [_jsx("span", { className: "pane-crumb-sep", "aria-hidden": "true", children: "\u203A" }), _jsx("button", { className: "pane-crumb" + (i === mediaCrumbs.length - 1 ? " is-current" : ""), disabled: media.mutating, onClick: () => media.go(c.prefix), children: c.label })] }, c.prefix)))] })) : (_jsx("h1", { className: "pane-title", children: heading })), view === "media" && !mediaSettingsOpen && _jsx("span", { className: "pane-count", children: media.shown.length }), _jsx("span", { className: "pane-spacer" }), view === "media" && !mediaSettingsOpen && (_jsx("button", { className: "ak-btn ak-btn-primary", disabled: media.busy || media.mutating, onClick: () => media.setUploadOpen(true), children: "Carica immagini" })), saver?.notice && (_jsx("button", { className: "pane-notice", onClick: () => saver.setNotice(null), children: saver.notice })), section === "bookings" && bookings.notice && (_jsx("button", { className: "pane-notice", onClick: bookings.clearNotice, children: bookings.notice })), publish.notice && (_jsx("button", { className: "pane-notice", onClick: publish.clearNotice, children: publish.notice })), !publish.configured && publish.error && (_jsx("span", { className: "pane-notice", title: publish.error, children: publish.error })), saver && (_jsxs(_Fragment, { children: [saver.dirty && _jsx("span", { className: "pane-dot", title: "modifiche non salvate" }), _jsx("button", { className: "ak-btn", disabled: !saver.dirty || saver.saving,
248
248
  /* Dopo un salvataggio il conteggio delle modifiche in attesa è cambiato: senza
249
249
  rileggerlo, «Pubblica» comparirebbe solo al ricaricamento della pagina — cioè
250
250
  il pannello direbbe che non c'è niente da pubblicare subito dopo aver salvato
package/dist/app/app.css CHANGED
@@ -70,6 +70,7 @@ body { margin: 0; background: var(--ak-bg); color: var(--ak-ink); font-family: v
70
70
  font: 500 13px/1.2 var(--ak-font); color: inherit;
71
71
  }
72
72
  .rail-item:hover { background: var(--ak-rail-hover); color: var(--ak-rail-ink-on); }
73
+ .rail-item:disabled { opacity: .45; cursor: default; }
73
74
  .rail-item.is-on { background: var(--ak-rail-on); color: var(--ak-rail-ink-on); }
74
75
  .rail-item .ico { flex: none; opacity: .85; }
75
76
  .rail-item.is-on .ico { opacity: 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,14 +53,14 @@ 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
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" })] }));
@@ -70,7 +70,7 @@ const IcoSettings = () => (_jsxs("svg", { className: "mf-ico", viewBox: "0 0 16
70
70
  export function MediaFolders({ lib, trash, settings }) {
71
71
  const [over, setOver] = useState(null);
72
72
  const [draggingFolder, setDraggingFolder] = useState(false);
73
- const canDrop = lib.mode === "manage";
73
+ const canDrop = lib.mode === "manage" && !lib.busy && !lib.mutating;
74
74
  /* Nel cestino non si è in nessuna cartella: senza questo, «Tutte» resterebbe accesa insieme a
75
75
  «Cestino» e la colonna indicherebbe due posti nello stesso momento. */
76
76
  const here = (p) => !trash?.active && (p === "" ? lib.prefix === "" : lib.prefix.startsWith(p));
@@ -105,7 +105,7 @@ export function MediaFolders({ lib, trash, settings }) {
105
105
  removeEventListener("drop", end);
106
106
  };
107
107
  }, [canDrop]);
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", 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", {
109
109
  /* Accesa anche quando si è DENTRO una sua sottocartella: la colonna risponde a
110
110
  «dove sono», e stando in food/piatti-2024/ la risposta è ancora food. */
111
111
  className: "mf-item"
@@ -113,5 +113,5 @@ export function MediaFolders({ lib, trash, settings }) {
113
113
  + (over === f.prefix ? " is-over" : ""), onDragOver: (e) => { if (canDrop) {
114
114
  e.preventDefault();
115
115
  setOver(f.prefix);
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
+ } }, 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" })] }) }) }))] }));
117
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,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 {};
@@ -0,0 +1,92 @@
1
+ export function mediaMoveNeedsConfirmation(plan, multiFileThreshold = 2) {
2
+ return plan.folders.length > 0 || plan.directFiles.length >= multiFileThreshold;
3
+ }
4
+ export function mediaMoveConfirmation(plan, destinationLabel) {
5
+ const selectedParts = [
6
+ plan.directFiles.length
7
+ ? `${plan.directFiles.length} file ${plan.directFiles.length === 1 ? "selezionato direttamente" : "selezionati direttamente"}`
8
+ : "",
9
+ plan.folders.length ? `${plan.folders.length} ${plan.folders.length === 1 ? "cartella" : "cartelle"}` : "",
10
+ ].filter(Boolean).join(" e ");
11
+ return `Stai per spostare ${selectedParts} in ${destinationLabel}.\n\n`
12
+ + `File complessivi coinvolti, inclusi i discendenti: ${plan.totalFiles}.\n\n`
13
+ + "Gli indirizzi pubblici cambieranno e i riferimenti gestiti verranno aggiornati. Continuare?";
14
+ }
15
+ /**
16
+ * Esegue un piano già validato. Al primo errore non avvia altri gruppi né applica i riferimenti;
17
+ * le copie riuscite restano duplicati sicuri e gli originali restano disponibili.
18
+ */
19
+ export async function executeMediaMovePlan(plan, destination, executor, onProgress, chunkSize = 10) {
20
+ for (const folder of plan.folders) {
21
+ const created = await executor.createFolder(destination, folder.name, { label: folder.name });
22
+ if (created.exists || created.prefix !== folder.target) {
23
+ throw new Error(`La destinazione della cartella «${folder.name}» non è più disponibile.`);
24
+ }
25
+ for (const sub of [...folder.folders].sort((a, b) => a.prefix.length - b.prefix.length)) {
26
+ const rel = sub.prefix.slice(folder.source.length);
27
+ const subName = rel.replace(/\/$/, "").split("/").pop();
28
+ await executor.createFolder(folder.target + rel.replace(/[^/]+\/$/, ""), sub.label || subName, {
29
+ label: sub.label || subName,
30
+ });
31
+ }
32
+ }
33
+ const transfers = [];
34
+ for (const folder of plan.folders) {
35
+ const byDir = new Map();
36
+ for (const file of folder.files) {
37
+ const rel = file.key.slice(folder.source.length);
38
+ const cut = rel.lastIndexOf("/");
39
+ const dir = cut >= 0 ? rel.slice(0, cut + 1) : "";
40
+ const keys = byDir.get(dir) ?? [];
41
+ keys.push(file.key);
42
+ byDir.set(dir, keys);
43
+ }
44
+ for (const [dir, keys] of byDir)
45
+ transfers.push({ keys, destination: folder.target + dir });
46
+ }
47
+ if (plan.directFiles.length)
48
+ transfers.push({ keys: plan.directFiles, destination });
49
+ let done = 0;
50
+ const urlMap = {};
51
+ onProgress(done, plan.totalFiles);
52
+ for (const transfer of transfers) {
53
+ for (let i = 0; i < transfer.keys.length; i += chunkSize) {
54
+ const group = transfer.keys.slice(i, i + chunkSize);
55
+ let result;
56
+ try {
57
+ result = await executor.move(group, transfer.destination);
58
+ }
59
+ catch (error) {
60
+ const notStarted = Math.max(0, plan.totalFiles - done - group.length);
61
+ throw new Error(`Spostamento interrotto: ${done} file copiati, ${group.length} con esito non confermato, ${notStarted} non iniziati. `
62
+ + `Gli originali sono stati conservati. Dettaglio: ${error instanceof Error ? error.message : String(error)}`);
63
+ }
64
+ Object.assign(urlMap, result.urlMap ?? {});
65
+ done += result.moved ?? 0;
66
+ onProgress(done, plan.totalFiles);
67
+ if (result.failed?.length || (result.moved ?? 0) !== group.length) {
68
+ const failed = result.failed?.length ?? group.length - (result.moved ?? 0);
69
+ const notStarted = Math.max(0, plan.totalFiles - done - failed);
70
+ throw new Error(`Spostamento interrotto: ${done} file copiati, ${failed} falliti, ${notStarted} non iniziati. `
71
+ + "Gli originali sono stati conservati; le copie già create possono restare come duplicati.");
72
+ }
73
+ }
74
+ }
75
+ if (Object.keys(urlMap).length) {
76
+ let applied;
77
+ try {
78
+ applied = await executor.applyReferences(urlMap);
79
+ }
80
+ catch (error) {
81
+ throw new Error(`Tutte le ${done} copie sono state create, ma i riferimenti non sono stati applicati. `
82
+ + `Gli originali sono stati conservati e le copie possono restare come duplicati. Dettaglio: ${error instanceof Error ? error.message : String(error)}`);
83
+ }
84
+ if (applied.failed?.length) {
85
+ throw new Error(`Spostamento applicato con ${applied.failed.length} originali rimasti come duplicati.`);
86
+ }
87
+ }
88
+ if (plan.folders.length) {
89
+ await executor.removeSourceFolders(plan.folders.map((folder) => folder.source));
90
+ }
91
+ return { completed: done, total: plan.totalFiles };
92
+ }
@@ -1,5 +1,6 @@
1
1
  import { type AssetFile, type AssetFolder } from "../../api.js";
2
2
  import type { MediaOperationStatus } from "../../media.js";
3
+ export type MediaOperationState = "idle" | "planning" | "awaiting_confirmation" | "moving" | "deleting" | "renaming" | "creating_folder" | "journal";
3
4
  export type SortKey = "uploaded" | "capturedAt" | "name" | "size";
4
5
  export declare const SORT_LABELS: Record<SortKey, string>;
5
6
  export interface MediaLibrary {
@@ -10,7 +11,14 @@ export interface MediaLibrary {
10
11
  subfolders: AssetFolder[];
11
12
  shown: AssetFile[];
12
13
  selected: Set<string>;
14
+ selectableIds: string[];
15
+ selectedFiles: string[];
16
+ selectedFolders: string[];
17
+ allSelected: boolean;
18
+ someSelected: boolean;
13
19
  busy: boolean;
20
+ mutating: boolean;
21
+ operationState: MediaOperationState;
14
22
  error: string | null;
15
23
  mediaOperations: MediaOperationStatus[];
16
24
  /** Avanzamento di un'operazione lunga. `verb` è quello che si sta facendo ("Carico",
@@ -41,6 +49,7 @@ export interface MediaLibrary {
41
49
  step(delta: number): void;
42
50
  /** File e cartelle insieme: una cartella si riconosce dalla chiave, che finisce con "/". */
43
51
  move(ids: string[], dest: string): Promise<void>;
52
+ canMoveTo(ids: string[], dest: string): boolean;
44
53
  remove(ids: string[]): Promise<void>;
45
54
  /** `parent` esplicito: chi preme il pulsante sa dove sta creando, il pulsante non lo indovina. */
46
55
  newFolder(parent: string): Promise<void>;
@@ -13,11 +13,14 @@ import { api, ApiError } from "../../api.js";
13
13
  import { storageKey } from "../../config.js";
14
14
  import { contentPersistenceMode } from "../../app/site.js";
15
15
  import { startMediaUpload, useMediaUploadCompletions } from "./media-upload-queue.js";
16
+ import { invalidMediaMoveReason, isValidMediaMoveDestination, mediaFolderSlug, mediaSelectableItems, mediaSelectionState, normalizeMediaSelection, summarizeMediaSelection, tryBeginMediaMutation, } from "./media-model.js";
17
+ import { executeMediaMovePlan, mediaMoveConfirmation, mediaMoveNeedsConfirmation, } from "./media-move.js";
16
18
  /* Quanti file per richiesta nelle operazioni lunghe (cestina, sposta). Ogni file costa al
17
19
  * server tre operazioni su R2 — legge, riscrive altrove, cancella — su oggetti da qualche MB.
18
20
  * Dieci tiene una richiesta sotto i pochi secondi, che è la soglia oltre la quale chi guarda
19
21
  * pensa che sia bloccato. Alzarlo fa tornare il difetto: richieste lunghe che muoiono a metà. */
20
22
  const CHUNK = 10;
23
+ const MOVE_CONFIRM_FILE_THRESHOLD = 2;
21
24
  export const SORT_LABELS = {
22
25
  uploaded: "caricamento", capturedAt: "scatto", name: "nome", size: "peso",
23
26
  };
@@ -34,6 +37,7 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
34
37
  const [subfolders, setSubfolders] = useState([]);
35
38
  const [selected, setSelected] = useState(new Set());
36
39
  const [busy, setBusy] = useState(false);
40
+ const [operationState, setOperationState] = useState("idle");
37
41
  const [loaded, setLoaded] = useState(false);
38
42
  const [error, setError] = useState(null);
39
43
  const [mediaOperations, setMediaOperations] = useState([]);
@@ -45,6 +49,20 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
45
49
  const [settingsLoaded, setSettingsLoaded] = useState(false);
46
50
  const mediaUploadCompletions = useMediaUploadCompletions();
47
51
  const seenMediaUploadCompletions = useRef(mediaUploadCompletions);
52
+ /* Lo stato React disabilita la UI; la ref chiude anche la finestra nello stesso batch React,
53
+ * prima che il render successivo possa applicare i disabled. */
54
+ const mutationRef = useRef(false);
55
+ const mutating = operationState !== "idle";
56
+ const beginMutation = useCallback((state) => {
57
+ if (!tryBeginMediaMutation(mutationRef))
58
+ return false;
59
+ setOperationState(state);
60
+ return true;
61
+ }, []);
62
+ const endMutation = useCallback(() => {
63
+ mutationRef.current = false;
64
+ setOperationState("idle");
65
+ }, []);
48
66
  // Le preferenze di ordinamento restano dov'erano — il prefisso che il sito dichiara al
49
67
  // bootstrap ricostruisce le chiavi di prima: la memoria muscolare di chi usa il pannello
50
68
  // tutti i giorni non ha motivo di azzerarsi per una riorganizzazione dei sorgenti.
@@ -151,7 +169,9 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
151
169
  if (!active || seenMediaUploadCompletions.current === mediaUploadCompletions)
152
170
  return;
153
171
  seenMediaUploadCompletions.current = mediaUploadCompletions;
154
- if (loaded)
172
+ // Se nel frattempo c'è una mutazione, il suo reload finale comprende anche l'upload appena
173
+ // concluso. Un secondo load qui potrebbe invece cambiare busy e selezione a metà progresso.
174
+ if (loaded && !mutationRef.current)
155
175
  void load(prefix);
156
176
  }, [active, mediaUploadCompletions, loaded, load, prefix]);
157
177
  /* Chiudere o ricaricare mentre un'operazione è in corso la interrompe: il gruppo in volo non
@@ -172,11 +192,18 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
172
192
  return (x < y ? -1 : x > y ? 1 : 0) * dir;
173
193
  });
174
194
  }, [files, sortKey, sortDir, mode]);
195
+ const selectableItems = useMemo(() => mediaSelectableItems({ mode, prefix, shown, subfolders }), [mode, prefix, shown, subfolders]);
196
+ const selectableIds = useMemo(() => selectableItems.map((item) => item.id), [selectableItems]);
197
+ const selectableIdSet = useMemo(() => new Set(selectableIds), [selectableIds]);
198
+ const { allSelected, someSelected } = useMemo(() => mediaSelectionState(selectableIds, selected), [selectableIds, selected]);
199
+ const selectionSummary = useMemo(() => summarizeMediaSelection(selected), [selected]);
175
200
  /* Aggiornamento FUNZIONALE, non `setSelected(new Set(selected))`: due click ravvicinati
176
201
  * finiscono nello stesso batch di React e leggerebbero entrambi lo stesso stato vecchio,
177
202
  * quindi il secondo cancellerebbe il primo. Con un umano che clicca piano non si vedrebbe
178
203
  * quasi mai — motivo per cui è un bug peggiore, non migliore. */
179
204
  const toggle = useCallback((key) => {
205
+ if (mutationRef.current || !selectableIdSet.has(key))
206
+ return;
180
207
  setSelected((prev) => {
181
208
  const next = new Set(prev);
182
209
  if (next.has(key))
@@ -188,17 +215,20 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
188
215
  }
189
216
  return next;
190
217
  });
191
- }, [multiple, mode]);
218
+ }, [multiple, mode, selectableIdSet]);
192
219
  /* «Seleziona tutto» prende anche le SOTTOCARTELLE mostrate, non solo le foto: sono lì, si
193
220
  * vedono, e sarebbe strano che «tutto» ne escludesse una parte. Nel modale di scelta no —
194
221
  * là si scelgono foto. */
195
222
  const setAll = useCallback((on) => {
223
+ if (mutationRef.current)
224
+ return;
196
225
  if (!on)
197
226
  return setSelected(new Set());
198
- const ids = mode === "manage" ? subfolders.map((f) => f.prefix) : [];
199
- setSelected(new Set([...ids, ...shown.map((f) => f.key)]));
200
- }, [shown, subfolders, mode]);
227
+ setSelected(new Set(selectableIds));
228
+ }, [selectableIds]);
201
229
  const upload = useCallback(async (list) => {
230
+ if (mutationRef.current)
231
+ return false;
202
232
  const arr = Array.from(list);
203
233
  if (!arr.length)
204
234
  return true;
@@ -229,6 +259,8 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
229
259
  return true;
230
260
  }, [prefix, autoWebp, settingsLoaded]);
231
261
  const setAutoWebp = useCallback(async (on) => {
262
+ if (mutationRef.current)
263
+ return;
232
264
  const previous = autoWebp;
233
265
  setAutoWebpState(on);
234
266
  setSettingsBusy(true);
@@ -264,14 +296,24 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
264
296
  const name = prompt(`Nome della cartella${dove}`);
265
297
  if (!name || !name.trim())
266
298
  return;
299
+ if (!beginMutation("creating_folder"))
300
+ return;
301
+ setBusy(true);
302
+ let failure = null;
267
303
  try {
268
304
  await api.createFolder(parent, name.trim(), { label: name.trim() });
269
- await load(prefix);
270
305
  }
271
306
  catch (e) {
272
- setError(e instanceof ApiError ? e.message : String(e));
307
+ failure = e instanceof ApiError ? e.message : String(e);
308
+ }
309
+ finally {
310
+ setBusy(false);
311
+ await load(prefix);
312
+ endMutation();
313
+ if (failure)
314
+ setError(failure);
273
315
  }
274
- }, [prefix, load, labelOf]);
316
+ }, [prefix, load, labelOf, beginMutation, endMutation]);
275
317
  /* Nella selezione convivono file e cartelle, e si riconoscono da soli: la chiave di una
276
318
  * cartella finisce con "/". Ogni operazione da qui in poi le separa in due liste. */
277
319
  const filesIn = (ids) => ids.filter((id) => !id.endsWith("/"));
@@ -294,6 +336,8 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
294
336
  const remove = useCallback(async (ids) => {
295
337
  if (!ids.length)
296
338
  return;
339
+ if (!beginMutation("deleting"))
340
+ return;
297
341
  setBusy(true);
298
342
  // L'errore va tenuto da parte: `load` in coda azzera il messaggio, quindi va rimesso DOPO,
299
343
  // o l'operazione fallisce in silenzio e l'elenco aggiornato sembra dire che è andata bene.
@@ -371,10 +415,11 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
371
415
  // SEMPRE, anche dopo un errore: l'elenco di prima è una bugia, perché una parte dei file
372
416
  // può essere passata davvero.
373
417
  await load(prefix);
418
+ endMutation();
374
419
  if (failure)
375
420
  setError(failure);
376
421
  }
377
- }, [prefix, load, labelOf]);
422
+ }, [prefix, load, labelOf, beginMutation, endMutation]);
378
423
  /* SPOSTARE UNA CARTELLA è spostare tutto ciò che contiene: in R2 la cartella non esiste, è un
379
424
  * pezzo di chiave. Cambia quindi l'indirizzo pubblico di ogni foto dentro, ed è per questo che
380
425
  * i riferimenti nelle pagine si riscrivono nello stesso giro — altrimenti le gallery che le
@@ -447,72 +492,96 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
447
492
  await api.remove({ prefixes: [from] }, { skipRefs: true });
448
493
  return null;
449
494
  }, [labelOf]);
450
- /* Spostare NON è solo copiare l'oggetto: la Function sposta il file dentro R2 e nello stesso
451
- * giro riscrive i riferimenti /media/<vecchia> nelle pagine. Vale per i file come per le
452
- * cartelle, che nella selezione stanno insieme. */
495
+ /* Il planning legge ed espande TUTTA la selezione prima della prima mutazione. Oltre alla
496
+ * relazione sorgente/destinazione controlla le collisioni già visibili e calcola il totale
497
+ * fisico che verrà usato sia nella conferma sia nel progresso. */
498
+ const planMove = useCallback(async (ids, dest) => {
499
+ const normalized = normalizeMediaSelection(ids);
500
+ const invalid = invalidMediaMoveReason(normalized.ids, dest);
501
+ if (invalid)
502
+ throw new Error(invalid);
503
+ if (!dest && normalized.files.length)
504
+ throw new Error("I file devono stare in una cartella.");
505
+ const directFiles = normalized.files.filter((key) => {
506
+ const slash = key.lastIndexOf("/");
507
+ return key.slice(0, slash < 0 ? 0 : slash + 1) !== dest;
508
+ });
509
+ const [destination, ...expanded] = await Promise.all([
510
+ normalized.folders.length ? api.assets(dest) : Promise.resolve(null),
511
+ ...normalized.folders.map((source) => api.assets(source, { recursive: true })),
512
+ ]);
513
+ const occupied = new Set(destination?.folders.map((folder) => folder.prefix) ?? []);
514
+ const plannedTargets = new Set();
515
+ const plannedFolders = normalized.folders.map((source, index) => {
516
+ const name = labelOf(source).trim();
517
+ const slug = mediaFolderSlug(name);
518
+ if (!slug)
519
+ throw new Error(`La cartella «${source}» non ha un nome valido.`);
520
+ const target = `${dest}${slug}/`;
521
+ if (target === source)
522
+ throw new Error(`La cartella «${name}» si trova già in quella posizione.`);
523
+ if (occupied.has(target) || plannedTargets.has(target)) {
524
+ throw new Error(`C'è già una cartella «${name}» nella destinazione scelta.`);
525
+ }
526
+ plannedTargets.add(target);
527
+ const inside = expanded[index];
528
+ return { source, name, target, files: inside.files ?? [], folders: inside.folders ?? [] };
529
+ });
530
+ if (!directFiles.length && !plannedFolders.length)
531
+ throw new Error("Nessun elemento da spostare.");
532
+ return {
533
+ directFiles,
534
+ folders: plannedFolders,
535
+ totalFiles: directFiles.length + plannedFolders.reduce((total, folder) => total + folder.files.length, 0),
536
+ };
537
+ }, [labelOf]);
538
+ /* Spostare NON è solo copiare l'oggetto: il batch conserva gli originali, accumula una sola
539
+ * mappa dei riferimenti e la applica soltanto dopo che TUTTE le copie sono riuscite. Al primo
540
+ * errore il ciclo si ferma: gli elementi non ancora iniziati non vengono toccati. */
453
541
  const move = useCallback(async (ids, dest) => {
454
- if (!ids.length)
542
+ if (!ids.length || !beginMutation("planning"))
455
543
  return;
456
- const keys = filesIn(ids);
457
- const prefixes = foldersIn(ids);
458
544
  setBusy(true);
545
+ setError(null);
459
546
  let failure = null;
460
547
  try {
461
- for (const p of prefixes) {
462
- const err = await moveFolder(p, dest);
463
- if (err)
464
- failure = err;
465
- }
466
- if (keys.length) {
467
- // La radice non è una destinazione valida per un FILE: la Function la rifiuta, e senza
468
- // questo controllo il rifiuto arriverebbe come un errore criptico a metà operazione.
469
- if (!dest) {
470
- failure = "Le foto devono stare in una cartella.";
471
- }
472
- else {
473
- setProgress({ done: 0, total: keys.length, verb: "Sposto" });
474
- // A gruppi per la stessa ragione della cancellazione. Ogni gruppo COPIA conservando
475
- // gli originali e restituisce la mappa scelta dal server; la chiamata finale aggiorna
476
- // i riferimenti in un commit solo e soltanto dopo elimina gli originali.
477
- let done = 0;
478
- const urlMap = {};
479
- const copyFailures = [];
480
- for (let i = 0; i < keys.length; i += CHUNK) {
481
- const group = keys.slice(i, i + CHUNK);
482
- const r = await api.move(group, dest, { skipRefs: true });
483
- Object.assign(urlMap, r.urlMap ?? {});
484
- copyFailures.push(...(r.failed ?? []));
485
- done += r.moved ?? 0;
486
- setProgress({ done, total: keys.length, verb: "Sposto" });
487
- }
488
- const applied = Object.keys(urlMap).length ? await api.moveApplyRefs(urlMap) : null;
489
- const cleanupFailures = applied?.failed ?? [];
490
- if (copyFailures.length || cleanupFailures.length) {
491
- failure = [
492
- copyFailures.length ? `${copyFailures.length} file non spostati` : "",
493
- cleanupFailures.length ? `${cleanupFailures.length} originali rimasti come duplicati` : "",
494
- ].filter(Boolean).join("; ") + ".";
495
- }
496
- }
548
+ const normalized = normalizeMediaSelection(ids);
549
+ const plan = await planMove(normalized.ids, dest);
550
+ const needsConfirmation = mediaMoveNeedsConfirmation(plan, MOVE_CONFIRM_FILE_THRESHOLD);
551
+ if (needsConfirmation) {
552
+ setOperationState("awaiting_confirmation");
553
+ const destination = dest ? `«${labelOf(dest)}»` : "il primo livello";
554
+ if (!confirm(mediaMoveConfirmation(plan, destination)))
555
+ return;
497
556
  }
557
+ setOperationState("moving");
558
+ await executeMediaMovePlan(plan, dest, {
559
+ createFolder: (parent, name, opts) => api.createFolder(parent, name, opts),
560
+ move: (keys, destination) => api.move(keys, destination, { skipRefs: true }),
561
+ applyReferences: (urlMap) => api.moveApplyRefs(urlMap),
562
+ removeSourceFolders: async (prefixes) => { await api.remove({ prefixes }, { skipRefs: true }); },
563
+ }, (done, total) => setProgress({ done, total, verb: "Sposto" }), CHUNK);
498
564
  }
499
565
  catch (e) {
500
- failure = e instanceof ApiError ? e.message : String(e);
566
+ failure = e instanceof Error ? e.message : String(e);
501
567
  }
502
568
  finally {
503
569
  setProgress(null);
504
570
  setBusy(false);
505
571
  await load(prefix);
572
+ endMutation();
506
573
  if (failure)
507
574
  setError(failure);
508
575
  }
509
- }, [prefix, load, moveFolder]);
576
+ }, [prefix, load, labelOf, planMove, beginMutation, endMutation]);
510
577
  /* Rinominare è spostare nello stesso posto con un nome nuovo: stessa strada, stesse cautele.
511
578
  * Se il nome nuovo dà lo stesso slug (è cambiata solo una maiuscola, o un «&»), non si sposta
512
579
  * niente e si riscrive la sola etichetta. */
513
580
  const renameFolder = useCallback(async (from, label) => {
514
581
  if (!label.trim())
515
582
  return;
583
+ if (!beginMutation("renaming"))
584
+ return;
516
585
  setBusy(true);
517
586
  let failure = null;
518
587
  try {
@@ -525,15 +594,18 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
525
594
  setProgress(null);
526
595
  setBusy(false);
527
596
  await load(prefix);
597
+ endMutation();
528
598
  if (failure)
529
599
  setError(failure);
530
600
  }
531
- }, [prefix, load, moveFolder]);
601
+ }, [prefix, load, moveFolder, beginMutation, endMutation]);
532
602
  /** Cestina una cartella con tutto quello che contiene. È `remove` con una lista di uno. */
533
603
  const removeFolder = useCallback((target) => remove([target]), [remove]);
534
604
  const resumeMedia = useCallback(async (operation) => {
535
605
  if (!operation.releaseId)
536
606
  return;
607
+ if (!beginMutation("journal"))
608
+ return;
537
609
  setBusy(true);
538
610
  setError(null);
539
611
  let failure = null;
@@ -546,13 +618,16 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
546
618
  finally {
547
619
  setBusy(false);
548
620
  await load(prefix);
621
+ endMutation();
549
622
  if (failure)
550
623
  setError(failure);
551
624
  }
552
- }, [load, prefix]);
625
+ }, [load, prefix, beginMutation, endMutation]);
553
626
  const cancelMedia = useCallback(async (operation) => {
554
627
  if (!confirm("Annullare questa operazione Media? Le copie già preparate resteranno disponibili nella libreria."))
555
628
  return;
629
+ if (!beginMutation("journal"))
630
+ return;
556
631
  setBusy(true);
557
632
  setError(null);
558
633
  let failure = null;
@@ -565,10 +640,11 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
565
640
  finally {
566
641
  setBusy(false);
567
642
  await load(prefix);
643
+ endMutation();
568
644
  if (failure)
569
645
  setError(failure);
570
646
  }
571
- }, [load, prefix]);
647
+ }, [load, prefix, beginMutation, endMutation]);
572
648
  const step = useCallback((delta) => {
573
649
  setViewing((i) => {
574
650
  if (i === null)
@@ -578,19 +654,26 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
578
654
  });
579
655
  }, [shown.length]);
580
656
  return {
581
- mode, multiple, prefix, folders, subfolders, shown, selected, busy, error, mediaOperations, progress,
657
+ mode, multiple, prefix, folders, subfolders, shown, selected, selectableIds,
658
+ selectedFiles: selectionSummary.files, selectedFolders: selectionSummary.folders,
659
+ allSelected, someSelected, busy, mutating, operationState, error, mediaOperations, progress,
582
660
  sortKey, sortDir, uploadOpen, viewing, autoWebp, settingsBusy,
583
- go: (p) => void load(p),
584
- reload: () => void load(prefix),
661
+ go: (p) => { if (!mutationRef.current)
662
+ void load(p); },
663
+ reload: () => { if (!mutationRef.current)
664
+ void load(prefix); },
585
665
  toggle, setAll,
586
666
  setSort: setSortKey,
587
667
  flipDir: () => setSortDir((d) => (d === "asc" ? "desc" : "asc")),
588
668
  upload,
589
- setUploadOpen,
669
+ setUploadOpen: (open) => { if (!open || !mutationRef.current)
670
+ setUploadOpen(open); },
590
671
  setAutoWebp,
591
672
  view: setViewing,
592
673
  step,
593
- move, remove, newFolder, renameFolder, removeFolder, resumeMedia, cancelMedia, labelOf,
674
+ move,
675
+ canMoveTo: (ids, dest) => !mutationRef.current && isValidMediaMoveDestination(ids, dest),
676
+ remove, newFolder, renameFolder, removeFolder, resumeMedia, cancelMedia, labelOf,
594
677
  clearError: () => setError(null),
595
678
  };
596
679
  }
@@ -304,8 +304,10 @@ export class D1ContentRepository {
304
304
  .bind(input.releaseId, input.requestChecksum, input.manifestChecksum, input.schemaPackageVersion, input.materializerVersion, input.authorSubject, input.now, state.current_release_id, ...selected.flatMap((entry) => [entry.documentId, entry.revisionId]));
305
305
  const statements = [candidate];
306
306
  for (const [documentId, revisionId] of manifest)
307
- statements.push(this.db.prepare("INSERT INTO content_release_entries (release_id, document_id, revision_id) SELECT ?, ?, ? WHERE EXISTS (SELECT 1 FROM content_revisions WHERE document_id = ? AND revision_id = ?)")
308
- .bind(input.releaseId, documentId, revisionId, documentId, revisionId));
307
+ statements.push(this.db.prepare(`INSERT INTO content_release_entries (release_id, document_id, revision_id)
308
+ SELECT ?, ?, ? WHERE EXISTS (SELECT 1 FROM content_releases WHERE release_id = ? AND status = 'publishing')
309
+ AND EXISTS (SELECT 1 FROM content_revisions WHERE document_id = ? AND revision_id = ?)`)
310
+ .bind(input.releaseId, documentId, revisionId, input.releaseId, documentId, revisionId));
309
311
  const results = await this.db.batch(statements);
310
312
  if (results[0]?.meta?.changes !== 1)
311
313
  throw new Error("stale_revision");
@@ -362,14 +364,22 @@ export class D1ContentRepository {
362
364
  }
363
365
  const candidateIndex = statements.length;
364
366
  statements.push(candidate);
367
+ /* Every dependent write is guarded by the exact candidate. D1 batches are
368
+ * atomic on SQL errors, but a guarded INSERT that affects zero rows is not
369
+ * an error: without this predicate, a busy lane made these FK inserts
370
+ * reference a candidate that was never created and hid the intended
371
+ * deployment_lane_busy conflict behind a generic persistence failure. */
365
372
  for (const [documentId, revisionId] of manifest)
366
- statements.push(this.db.prepare("INSERT INTO content_release_entries (release_id, document_id, revision_id) SELECT ?, ?, ? WHERE EXISTS (SELECT 1 FROM content_revisions WHERE document_id = ? AND revision_id = ?)")
367
- .bind(input.releaseId, documentId, revisionId, documentId, revisionId));
373
+ statements.push(this.db.prepare(`INSERT INTO content_release_entries (release_id, document_id, revision_id)
374
+ SELECT ?, ?, ? WHERE EXISTS (SELECT 1 FROM content_releases WHERE release_id = ? AND status = 'publishing')
375
+ AND EXISTS (SELECT 1 FROM content_revisions WHERE document_id = ? AND revision_id = ?)`)
376
+ .bind(input.releaseId, documentId, revisionId, input.releaseId, documentId, revisionId));
368
377
  if (input.siteAccessVersionId)
369
378
  statements.push(this.db.prepare(`INSERT INTO content_release_site_access (release_id, version_id)
370
- SELECT ?, ? WHERE EXISTS (SELECT 1 FROM site_access_versions version JOIN site_access_state access_state ON access_state.singleton = 1
371
- WHERE version.version_id = ? AND version.state IN ('draft','active') AND version.version_id IN (access_state.draft_version_id, access_state.active_version_id))`)
372
- .bind(input.releaseId, input.siteAccessVersionId, input.siteAccessVersionId));
379
+ SELECT ?, ? WHERE EXISTS (SELECT 1 FROM content_releases WHERE release_id = ? AND status = 'publishing')
380
+ AND EXISTS (SELECT 1 FROM site_access_versions version JOIN site_access_state access_state ON access_state.singleton = 1
381
+ WHERE version.version_id = ? AND version.state IN ('draft','active') AND version.version_id IN (access_state.draft_version_id, access_state.active_version_id))`)
382
+ .bind(input.releaseId, input.siteAccessVersionId, input.releaseId, input.siteAccessVersionId));
373
383
  for (const operationId of mediaOperationIds) {
374
384
  /* `instr` is intentionally evaluated against the frozen release entries,
375
385
  * not drafts or a mutable current pointer. A URL is JSON-escaped neither
@@ -384,8 +394,9 @@ export class D1ContentRepository {
384
394
  AND revision.payload_json IS NOT NULL AND instr(revision.payload_json, '/media/' || source.source_key) > 0)`)
385
395
  .bind(input.now, operationId, input.releaseId));
386
396
  statements.push(this.db.prepare(`INSERT INTO content_release_media_operations (release_id, operation_id)
387
- SELECT ?, ? WHERE EXISTS (SELECT 1 FROM media_operations WHERE operation_id = ? AND state = 'bound')`)
388
- .bind(input.releaseId, operationId, operationId));
397
+ SELECT ?, ? WHERE EXISTS (SELECT 1 FROM content_releases WHERE release_id = ? AND status = 'publishing')
398
+ AND EXISTS (SELECT 1 FROM media_operations WHERE operation_id = ? AND state = 'bound')`)
399
+ .bind(input.releaseId, operationId, input.releaseId, operationId));
389
400
  }
390
401
  statements.push(this.db.prepare(`INSERT INTO production_deployment_attempts
391
402
  (attempt_id, intent_kind, requested_release_id, requested_code_sha, code_sha, release_id, expected_fingerprint, phase, active_slot, created_at, updated_at)
package/dist/theme.css CHANGED
@@ -262,6 +262,7 @@ a.ak-btn { text-decoration: none; display: inline-flex; align-items: center; }
262
262
  border: 0; background: transparent; cursor: pointer;
263
263
  padding: 8px 10px; font: 500 13px/1.3 var(--ak-font); color: var(--ak-ink);
264
264
  }
265
+ .mf-open:disabled { opacity: .5; cursor: default; }
265
266
  .mf-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
266
267
  .mf-count { font: 400 var(--ak-fs-small)/1 var(--ak-font); color: var(--ak-ink-quiet); font-variant-numeric: tabular-nums; }
267
268
  .mf-item:hover { background: var(--ak-hover); }
@@ -303,6 +304,7 @@ a.ak-btn { text-decoration: none; display: inline-flex; align-items: center; }
303
304
  }
304
305
  .mf-item:hover .mf-more, .mf-item.is-on .mf-more, .mf-more:focus-visible { opacity: 1; }
305
306
  .mf-more:hover { background: var(--ak-bg); color: var(--ak-ink); }
307
+ .mf-more:disabled, .mf-menu > button:disabled { opacity: .5; cursor: default; }
306
308
  .mf-menu {
307
309
  position: absolute; top: calc(100% + 2px); z-index: 30;
308
310
  min-width: 200px; padding: 4px; display: flex; flex-direction: column; gap: 1px;
@@ -335,6 +337,7 @@ a.ak-btn { text-decoration: none; display: inline-flex; align-items: center; }
335
337
  .mg-bar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; min-height: 32px; }
336
338
  .mg-all { display: flex; align-items: center; gap: 8px; cursor: pointer; font: 500 13px/1 var(--ak-font); }
337
339
  .mg-all input { width: 15px; height: 15px; margin: 0; }
340
+ .mg-all:has(input:disabled) { opacity: .5; cursor: default; }
338
341
  .mg-act {
339
342
  border: 0; background: transparent; cursor: pointer; padding: 4px 0;
340
343
  font: 500 13px/1 var(--ak-font); color: var(--ak-accent);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cardinali-studio/adminkit",
3
- "version": "2.2.12",
3
+ "version": "2.2.14",
4
4
  "type": "module",
5
5
  "description": "Motore riusabile di un pannello di amministrazione per siti Hugo su Cloudflare Pages: codec del front matter guidato dallo schema, form React, libreria media su R2. NIENTE di specifico di un sito qui dentro.",
6
6
  "license": "UNLICENSED",