@cardinali-studio/adminkit 2.2.11 → 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 +43 -2
- package/dist/api.js +13 -3
- package/dist/app/App.js +10 -4
- package/dist/app/app.css +5 -0
- package/dist/app/lib/publish.js +4 -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/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 +3 -0
- package/dist/form/media/useMediaLibrary.js +72 -18
- package/dist/functions/_shared/assets.js +3 -2
- package/dist/functions/api/assets/folders.js +2 -1
- package/dist/functions/api/assets/settings.js +40 -0
- package/dist/functions/api/assets/upload.js +4 -1
- package/dist/functions/api/assets.js +4 -3
- package/dist/functions/api/site-access.js +78 -0
- package/dist/functions/index.js +4 -0
- package/dist/functions/site.js +4 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/migrations/0004_site_access.sql +41 -0
- package/dist/persistence.d.ts +1 -0
- package/dist/persistence.js +22 -4
- 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 +1 -0
- package/package.json +4 -1
|
@@ -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
|
+
}
|
|
@@ -25,6 +25,8 @@ export interface MediaLibrary {
|
|
|
25
25
|
sortDir: "asc" | "desc";
|
|
26
26
|
uploadOpen: boolean;
|
|
27
27
|
viewing: number | null;
|
|
28
|
+
autoWebp: boolean;
|
|
29
|
+
settingsBusy: boolean;
|
|
28
30
|
go(prefix: string): void;
|
|
29
31
|
reload(): void;
|
|
30
32
|
toggle(key: string): void;
|
|
@@ -34,6 +36,7 @@ export interface MediaLibrary {
|
|
|
34
36
|
/** Vero se sono passati tutti: chi ha aperto un dialogo lo chiude solo in quel caso. */
|
|
35
37
|
upload(files: File[] | FileList): Promise<boolean>;
|
|
36
38
|
setUploadOpen(open: boolean): void;
|
|
39
|
+
setAutoWebp(on: boolean): Promise<void>;
|
|
37
40
|
view(i: number | null): void;
|
|
38
41
|
step(delta: number): void;
|
|
39
42
|
/** File e cartelle insieme: una cartella si riconosce dalla chiave, che finisce con "/". */
|
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
* una sola implementazione, due impaginazioni.
|
|
10
10
|
*/
|
|
11
11
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
12
|
-
import { api,
|
|
12
|
+
import { api, ApiError } from "../../api.js";
|
|
13
13
|
import { storageKey } from "../../config.js";
|
|
14
14
|
import { contentPersistenceMode } from "../../app/site.js";
|
|
15
|
+
import { startMediaUpload, useMediaUploadCompletions } from "./media-upload-queue.js";
|
|
15
16
|
/* Quanti file per richiesta nelle operazioni lunghe (cestina, sposta). Ogni file costa al
|
|
16
17
|
* server tre operazioni su R2 — legge, riscrive altrove, cancella — su oggetti da qualche MB.
|
|
17
18
|
* Dieci tiene una richiesta sotto i pochi secondi, che è la soglia oltre la quale chi guarda
|
|
@@ -39,6 +40,11 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
|
|
|
39
40
|
const [progress, setProgress] = useState(null);
|
|
40
41
|
const [uploadOpen, setUploadOpen] = useState(false);
|
|
41
42
|
const [viewing, setViewing] = useState(null);
|
|
43
|
+
const [autoWebp, setAutoWebpState] = useState(true);
|
|
44
|
+
const [settingsBusy, setSettingsBusy] = useState(false);
|
|
45
|
+
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
|
46
|
+
const mediaUploadCompletions = useMediaUploadCompletions();
|
|
47
|
+
const seenMediaUploadCompletions = useRef(mediaUploadCompletions);
|
|
42
48
|
// Le preferenze di ordinamento restano dov'erano — il prefisso che il sito dichiara al
|
|
43
49
|
// bootstrap ricostruisce le chiavi di prima: la memoria muscolare di chi usa il pannello
|
|
44
50
|
// tutti i giorni non ha motivo di azzerarsi per una riorganizzazione dei sorgenti.
|
|
@@ -125,6 +131,29 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
|
|
|
125
131
|
void load(start, true);
|
|
126
132
|
}, [active, loaded, busy, error, load, remember]);
|
|
127
133
|
useEffect(() => { localStorage.setItem(storageKey("sortKey"), sortKey); localStorage.setItem(storageKey("sortDir"), sortDir); }, [sortKey, sortDir]);
|
|
134
|
+
/* Questa preferenza è del SITO, non del browser: il backend la conserva nel bucket Media.
|
|
135
|
+
* Finché non arriva la risposta vale il default ON, così anche i siti già esistenti hanno il
|
|
136
|
+
* nuovo comportamento senza migrazioni. Si carica solo nella vista di gestione: il picker non
|
|
137
|
+
* offre upload e non deve fare una richiesta che non userà. */
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (mode !== "manage" || !active || settingsLoaded)
|
|
140
|
+
return;
|
|
141
|
+
setSettingsBusy(true);
|
|
142
|
+
void api.mediaSettings()
|
|
143
|
+
.then((result) => { setAutoWebpState(result.settings.autoWebp); setSettingsLoaded(true); })
|
|
144
|
+
.catch((e) => setError(e instanceof ApiError ? e.message : String(e)))
|
|
145
|
+
.finally(() => setSettingsBusy(false));
|
|
146
|
+
}, [mode, active, settingsLoaded]);
|
|
147
|
+
/* Un upload può finire mentre si guarda un'altra sezione. Al ritorno ricarichiamo la
|
|
148
|
+
* cartella corrente una volta sola, senza riportare l'utente nella cartella in cui il job era
|
|
149
|
+
* partito. Lo stato della coda è esterno a React, quindi sopravvive alla navigazione. */
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (!active || seenMediaUploadCompletions.current === mediaUploadCompletions)
|
|
152
|
+
return;
|
|
153
|
+
seenMediaUploadCompletions.current = mediaUploadCompletions;
|
|
154
|
+
if (loaded)
|
|
155
|
+
void load(prefix);
|
|
156
|
+
}, [active, mediaUploadCompletions, loaded, load, prefix]);
|
|
128
157
|
/* Chiudere o ricaricare mentre un'operazione è in corso la interrompe: il gruppo in volo non
|
|
129
158
|
* arriva mai. Ora ogni gruppo è definitivo, quindi si perde poco — ma vale comunque la pena
|
|
130
159
|
* chiedere, perché è esattamente il gesto che uno fa quando "non sta succedendo niente". */
|
|
@@ -173,26 +202,50 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
|
|
|
173
202
|
const arr = Array.from(list);
|
|
174
203
|
if (!arr.length)
|
|
175
204
|
return true;
|
|
176
|
-
|
|
177
|
-
|
|
205
|
+
let convert = autoWebp;
|
|
206
|
+
/* Evita la piccola finestra fra apertura dei Media e arrivo dell'impostazione: se sul sito
|
|
207
|
+
la conversione è OFF, un upload immediato non deve partire col default ON. */
|
|
208
|
+
if (!settingsLoaded) {
|
|
209
|
+
try {
|
|
210
|
+
const result = await api.mediaSettings();
|
|
211
|
+
convert = result.settings.autoWebp;
|
|
212
|
+
setAutoWebpState(convert);
|
|
213
|
+
setSettingsLoaded(true);
|
|
214
|
+
}
|
|
215
|
+
catch (e) {
|
|
216
|
+
setError(e instanceof ApiError ? e.message : String(e));
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const destination = prefix
|
|
221
|
+
? seenLabels.current[prefix] || prefix.replace(/\/$/, "").split("/").pop() || prefix
|
|
222
|
+
: "Senza cartella";
|
|
223
|
+
startMediaUpload({
|
|
224
|
+
prefix,
|
|
225
|
+
title: `${arr.length} file · ${destination}`,
|
|
226
|
+
files: arr,
|
|
227
|
+
autoWebp: convert,
|
|
228
|
+
});
|
|
229
|
+
return true;
|
|
230
|
+
}, [prefix, autoWebp, settingsLoaded]);
|
|
231
|
+
const setAutoWebp = useCallback(async (on) => {
|
|
232
|
+
const previous = autoWebp;
|
|
233
|
+
setAutoWebpState(on);
|
|
234
|
+
setSettingsBusy(true);
|
|
235
|
+
setError(null);
|
|
178
236
|
try {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
237
|
+
const result = await api.saveMediaSettings(on);
|
|
238
|
+
setAutoWebpState(result.settings.autoWebp);
|
|
239
|
+
setSettingsLoaded(true);
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
setAutoWebpState(previous);
|
|
243
|
+
setError(e instanceof ApiError ? e.message : String(e));
|
|
184
244
|
}
|
|
185
245
|
finally {
|
|
186
|
-
|
|
187
|
-
// SEMPRE, anche se qualcosa è fallito: dopo un'operazione a metà l'elenco che si vede è
|
|
188
|
-
// una bugia, ed è peggio dell'errore.
|
|
189
|
-
await load(prefix);
|
|
246
|
+
setSettingsBusy(false);
|
|
190
247
|
}
|
|
191
|
-
|
|
192
|
-
if (errs.length)
|
|
193
|
-
setError(`Non caricati: ${errs.join("; ")}`);
|
|
194
|
-
return errs.length === 0;
|
|
195
|
-
}, [prefix, load]);
|
|
248
|
+
}, [autoWebp]);
|
|
196
249
|
const labelOf = useCallback((p) => {
|
|
197
250
|
const f = folders.find((x) => x.prefix === p) ?? subfolders.find((x) => x.prefix === p);
|
|
198
251
|
return f?.label || seenLabels.current[p] || p.replace(/\/$/, "").split("/").pop() || "";
|
|
@@ -526,7 +579,7 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
|
|
|
526
579
|
}, [shown.length]);
|
|
527
580
|
return {
|
|
528
581
|
mode, multiple, prefix, folders, subfolders, shown, selected, busy, error, mediaOperations, progress,
|
|
529
|
-
sortKey, sortDir, uploadOpen, viewing,
|
|
582
|
+
sortKey, sortDir, uploadOpen, viewing, autoWebp, settingsBusy,
|
|
530
583
|
go: (p) => void load(p),
|
|
531
584
|
reload: () => void load(prefix),
|
|
532
585
|
toggle, setAll,
|
|
@@ -534,6 +587,7 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
|
|
|
534
587
|
flipDir: () => setSortDir((d) => (d === "asc" ? "desc" : "asc")),
|
|
535
588
|
upload,
|
|
536
589
|
setUploadOpen,
|
|
590
|
+
setAutoWebp,
|
|
537
591
|
view: setViewing,
|
|
538
592
|
step,
|
|
539
593
|
move, remove, newFolder, renameFolder, removeFolder, resumeMedia, cancelMedia, labelOf,
|
|
@@ -32,8 +32,9 @@ export function sanitizeName(name) {
|
|
|
32
32
|
export function guessMime(key) {
|
|
33
33
|
const e = (key.split(".").pop() || "").toLowerCase();
|
|
34
34
|
return {
|
|
35
|
-
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", webp: "image/webp",
|
|
36
|
-
gif: "image/gif", avif: "image/avif",
|
|
35
|
+
jpg: "image/jpeg", jpeg: "image/jpeg", jfif: "image/jpeg", png: "image/png", webp: "image/webp",
|
|
36
|
+
gif: "image/gif", avif: "image/avif", heic: "image/heic", heif: "image/heif",
|
|
37
|
+
tif: "image/tiff", tiff: "image/tiff", bmp: "image/bmp", svg: "image/svg+xml",
|
|
37
38
|
mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", pdf: "application/pdf",
|
|
38
39
|
}[e] || "application/octet-stream";
|
|
39
40
|
}
|
|
@@ -13,7 +13,8 @@ export async function onRequestGet({ env }) {
|
|
|
13
13
|
const pending = contentPersistenceMode() === "d1" ? await d1PendingSourceKeys(env.CONTENT_DB) : await pendingSourceKeys(env.MEDIA);
|
|
14
14
|
const counts = {};
|
|
15
15
|
for (const k of keys) {
|
|
16
|
-
if (k.startsWith(TRASH) || k.startsWith("_pending-media/") ||
|
|
16
|
+
if (k.startsWith(TRASH) || k.startsWith("_pending-media/") ||
|
|
17
|
+
k.startsWith("_adminkit/") || pending.has(k)) continue;
|
|
17
18
|
const slash = k.indexOf("/");
|
|
18
19
|
if (slash < 0) continue; // file in root: non appartiene a una cartella
|
|
19
20
|
const top = k.slice(0, slash);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/* GET/POST /admin/api/assets/settings — preferenze condivise della libreria Media.
|
|
2
|
+
*
|
|
3
|
+
* Vivono nello stesso bucket delle immagini: sono per sito, visibili da qualunque browser e
|
|
4
|
+
* non richiedono un commit/build per cambiare un comportamento del solo pannello.
|
|
5
|
+
*/
|
|
6
|
+
import { json } from "../../_shared/assets.js";
|
|
7
|
+
|
|
8
|
+
const KEY = "_adminkit/settings.json";
|
|
9
|
+
const defaults = { autoWebp: true };
|
|
10
|
+
|
|
11
|
+
async function read(env) {
|
|
12
|
+
const object = await env.MEDIA.get(KEY);
|
|
13
|
+
if (!object) return defaults;
|
|
14
|
+
try {
|
|
15
|
+
const stored = await object.json();
|
|
16
|
+
return { autoWebp: stored?.autoWebp !== false };
|
|
17
|
+
} catch {
|
|
18
|
+
return defaults;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function onRequestGet({ env }) {
|
|
23
|
+
if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
|
|
24
|
+
return json({ ok: true, settings: await read(env) });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function onRequestPost({ request, env }) {
|
|
28
|
+
if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
|
|
29
|
+
let body;
|
|
30
|
+
try { body = await request.json(); }
|
|
31
|
+
catch { return json({ ok: false, error: "JSON non valido" }, 400); }
|
|
32
|
+
if (typeof body?.autoWebp !== "boolean") {
|
|
33
|
+
return json({ ok: false, error: "autoWebp deve essere booleano" }, 400);
|
|
34
|
+
}
|
|
35
|
+
const settings = { autoWebp: body.autoWebp };
|
|
36
|
+
await env.MEDIA.put(KEY, JSON.stringify(settings), {
|
|
37
|
+
httpMetadata: { contentType: "application/json" },
|
|
38
|
+
});
|
|
39
|
+
return json({ ok: true, settings });
|
|
40
|
+
}
|
|
@@ -22,7 +22,10 @@ export async function onRequestPost({ request, env }) {
|
|
|
22
22
|
|
|
23
23
|
// data di scatto (EXIF) per l'ordinamento "Data scatto"
|
|
24
24
|
const custom = {};
|
|
25
|
-
|
|
25
|
+
const suppliedCapturedAt = request.headers.get("X-Captured-At") || "";
|
|
26
|
+
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(suppliedCapturedAt)) {
|
|
27
|
+
custom.capturedAt = suppliedCapturedAt;
|
|
28
|
+
} else if (ctype === "image/jpeg") {
|
|
26
29
|
const cap = extractCapturedAt(body);
|
|
27
30
|
if (cap) custom.capturedAt = cap;
|
|
28
31
|
}
|