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