@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 +2 -2
- package/dist/app/app.css +1 -0
- package/dist/form/media/MediaFolders.js +6 -6
- package/dist/form/media/MediaGrid.js +32 -17
- package/dist/form/media/UploadDialog.js +11 -5
- package/dist/form/media/media-model.d.ts +41 -0
- package/dist/form/media/media-model.js +78 -0
- package/dist/form/media/media-move.d.ts +48 -0
- package/dist/form/media/media-move.js +92 -0
- package/dist/form/media/useMediaLibrary.d.ts +9 -0
- package/dist/form/media/useMediaLibrary.js +144 -61
- package/dist/persistence.js +20 -9
- package/dist/theme.css +3 -0
- package/package.json +1 -1
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
|
|
53
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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.
|
|
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 =
|
|
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: () =>
|
|
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) => {
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
/*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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) =>
|
|
584
|
-
|
|
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,
|
|
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
|
}
|
package/dist/persistence.js
CHANGED
|
@@ -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(
|
|
308
|
-
|
|
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(
|
|
367
|
-
|
|
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
|
|
371
|
-
|
|
372
|
-
|
|
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
|
|
388
|
-
|
|
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.
|
|
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",
|