@cardinali-studio/adminkit 2.2.11 → 2.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.d.ts +43 -2
- package/dist/api.js +13 -3
- package/dist/app/App.js +11 -5
- package/dist/app/app.css +6 -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 +8 -7
- package/dist/form/media/MediaGrid.js +32 -17
- package/dist/form/media/UploadDialog.js +11 -5
- package/dist/form/media/image-conversion.d.ts +14 -0
- package/dist/form/media/image-conversion.js +163 -0
- 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/media-upload-queue.d.ts +31 -0
- package/dist/form/media/media-upload-queue.js +230 -0
- package/dist/form/media/useMediaLibrary.d.ts +12 -0
- package/dist/form/media/useMediaLibrary.js +215 -78
- 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 +4 -0
- package/package.json +4 -1
|
@@ -9,14 +9,18 @@
|
|
|
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";
|
|
16
|
+
import { invalidMediaMoveReason, isValidMediaMoveDestination, mediaFolderSlug, mediaSelectableItems, mediaSelectionState, normalizeMediaSelection, summarizeMediaSelection, tryBeginMediaMutation, } from "./media-model.js";
|
|
17
|
+
import { executeMediaMovePlan, mediaMoveConfirmation, mediaMoveNeedsConfirmation, } from "./media-move.js";
|
|
15
18
|
/* Quanti file per richiesta nelle operazioni lunghe (cestina, sposta). Ogni file costa al
|
|
16
19
|
* server tre operazioni su R2 — legge, riscrive altrove, cancella — su oggetti da qualche MB.
|
|
17
20
|
* Dieci tiene una richiesta sotto i pochi secondi, che è la soglia oltre la quale chi guarda
|
|
18
21
|
* pensa che sia bloccato. Alzarlo fa tornare il difetto: richieste lunghe che muoiono a metà. */
|
|
19
22
|
const CHUNK = 10;
|
|
23
|
+
const MOVE_CONFIRM_FILE_THRESHOLD = 2;
|
|
20
24
|
export const SORT_LABELS = {
|
|
21
25
|
uploaded: "caricamento", capturedAt: "scatto", name: "nome", size: "peso",
|
|
22
26
|
};
|
|
@@ -33,12 +37,32 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
|
|
|
33
37
|
const [subfolders, setSubfolders] = useState([]);
|
|
34
38
|
const [selected, setSelected] = useState(new Set());
|
|
35
39
|
const [busy, setBusy] = useState(false);
|
|
40
|
+
const [operationState, setOperationState] = useState("idle");
|
|
36
41
|
const [loaded, setLoaded] = useState(false);
|
|
37
42
|
const [error, setError] = useState(null);
|
|
38
43
|
const [mediaOperations, setMediaOperations] = useState([]);
|
|
39
44
|
const [progress, setProgress] = useState(null);
|
|
40
45
|
const [uploadOpen, setUploadOpen] = useState(false);
|
|
41
46
|
const [viewing, setViewing] = useState(null);
|
|
47
|
+
const [autoWebp, setAutoWebpState] = useState(true);
|
|
48
|
+
const [settingsBusy, setSettingsBusy] = useState(false);
|
|
49
|
+
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
|
50
|
+
const mediaUploadCompletions = useMediaUploadCompletions();
|
|
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
|
+
}, []);
|
|
42
66
|
// Le preferenze di ordinamento restano dov'erano — il prefisso che il sito dichiara al
|
|
43
67
|
// bootstrap ricostruisce le chiavi di prima: la memoria muscolare di chi usa il pannello
|
|
44
68
|
// tutti i giorni non ha motivo di azzerarsi per una riorganizzazione dei sorgenti.
|
|
@@ -125,6 +149,31 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
|
|
|
125
149
|
void load(start, true);
|
|
126
150
|
}, [active, loaded, busy, error, load, remember]);
|
|
127
151
|
useEffect(() => { localStorage.setItem(storageKey("sortKey"), sortKey); localStorage.setItem(storageKey("sortDir"), sortDir); }, [sortKey, sortDir]);
|
|
152
|
+
/* Questa preferenza è del SITO, non del browser: il backend la conserva nel bucket Media.
|
|
153
|
+
* Finché non arriva la risposta vale il default ON, così anche i siti già esistenti hanno il
|
|
154
|
+
* nuovo comportamento senza migrazioni. Si carica solo nella vista di gestione: il picker non
|
|
155
|
+
* offre upload e non deve fare una richiesta che non userà. */
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
if (mode !== "manage" || !active || settingsLoaded)
|
|
158
|
+
return;
|
|
159
|
+
setSettingsBusy(true);
|
|
160
|
+
void api.mediaSettings()
|
|
161
|
+
.then((result) => { setAutoWebpState(result.settings.autoWebp); setSettingsLoaded(true); })
|
|
162
|
+
.catch((e) => setError(e instanceof ApiError ? e.message : String(e)))
|
|
163
|
+
.finally(() => setSettingsBusy(false));
|
|
164
|
+
}, [mode, active, settingsLoaded]);
|
|
165
|
+
/* Un upload può finire mentre si guarda un'altra sezione. Al ritorno ricarichiamo la
|
|
166
|
+
* cartella corrente una volta sola, senza riportare l'utente nella cartella in cui il job era
|
|
167
|
+
* partito. Lo stato della coda è esterno a React, quindi sopravvive alla navigazione. */
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
if (!active || seenMediaUploadCompletions.current === mediaUploadCompletions)
|
|
170
|
+
return;
|
|
171
|
+
seenMediaUploadCompletions.current = mediaUploadCompletions;
|
|
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)
|
|
175
|
+
void load(prefix);
|
|
176
|
+
}, [active, mediaUploadCompletions, loaded, load, prefix]);
|
|
128
177
|
/* Chiudere o ricaricare mentre un'operazione è in corso la interrompe: il gruppo in volo non
|
|
129
178
|
* arriva mai. Ora ogni gruppo è definitivo, quindi si perde poco — ma vale comunque la pena
|
|
130
179
|
* chiedere, perché è esattamente il gesto che uno fa quando "non sta succedendo niente". */
|
|
@@ -143,11 +192,18 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
|
|
|
143
192
|
return (x < y ? -1 : x > y ? 1 : 0) * dir;
|
|
144
193
|
});
|
|
145
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]);
|
|
146
200
|
/* Aggiornamento FUNZIONALE, non `setSelected(new Set(selected))`: due click ravvicinati
|
|
147
201
|
* finiscono nello stesso batch di React e leggerebbero entrambi lo stesso stato vecchio,
|
|
148
202
|
* quindi il secondo cancellerebbe il primo. Con un umano che clicca piano non si vedrebbe
|
|
149
203
|
* quasi mai — motivo per cui è un bug peggiore, non migliore. */
|
|
150
204
|
const toggle = useCallback((key) => {
|
|
205
|
+
if (mutationRef.current || !selectableIdSet.has(key))
|
|
206
|
+
return;
|
|
151
207
|
setSelected((prev) => {
|
|
152
208
|
const next = new Set(prev);
|
|
153
209
|
if (next.has(key))
|
|
@@ -159,40 +215,69 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
|
|
|
159
215
|
}
|
|
160
216
|
return next;
|
|
161
217
|
});
|
|
162
|
-
}, [multiple, mode]);
|
|
218
|
+
}, [multiple, mode, selectableIdSet]);
|
|
163
219
|
/* «Seleziona tutto» prende anche le SOTTOCARTELLE mostrate, non solo le foto: sono lì, si
|
|
164
220
|
* vedono, e sarebbe strano che «tutto» ne escludesse una parte. Nel modale di scelta no —
|
|
165
221
|
* là si scelgono foto. */
|
|
166
222
|
const setAll = useCallback((on) => {
|
|
223
|
+
if (mutationRef.current)
|
|
224
|
+
return;
|
|
167
225
|
if (!on)
|
|
168
226
|
return setSelected(new Set());
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}, [shown, subfolders, mode]);
|
|
227
|
+
setSelected(new Set(selectableIds));
|
|
228
|
+
}, [selectableIds]);
|
|
172
229
|
const upload = useCallback(async (list) => {
|
|
230
|
+
if (mutationRef.current)
|
|
231
|
+
return false;
|
|
173
232
|
const arr = Array.from(list);
|
|
174
233
|
if (!arr.length)
|
|
175
234
|
return true;
|
|
176
|
-
|
|
177
|
-
|
|
235
|
+
let convert = autoWebp;
|
|
236
|
+
/* Evita la piccola finestra fra apertura dei Media e arrivo dell'impostazione: se sul sito
|
|
237
|
+
la conversione è OFF, un upload immediato non deve partire col default ON. */
|
|
238
|
+
if (!settingsLoaded) {
|
|
239
|
+
try {
|
|
240
|
+
const result = await api.mediaSettings();
|
|
241
|
+
convert = result.settings.autoWebp;
|
|
242
|
+
setAutoWebpState(convert);
|
|
243
|
+
setSettingsLoaded(true);
|
|
244
|
+
}
|
|
245
|
+
catch (e) {
|
|
246
|
+
setError(e instanceof ApiError ? e.message : String(e));
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const destination = prefix
|
|
251
|
+
? seenLabels.current[prefix] || prefix.replace(/\/$/, "").split("/").pop() || prefix
|
|
252
|
+
: "Senza cartella";
|
|
253
|
+
startMediaUpload({
|
|
254
|
+
prefix,
|
|
255
|
+
title: `${arr.length} file · ${destination}`,
|
|
256
|
+
files: arr,
|
|
257
|
+
autoWebp: convert,
|
|
258
|
+
});
|
|
259
|
+
return true;
|
|
260
|
+
}, [prefix, autoWebp, settingsLoaded]);
|
|
261
|
+
const setAutoWebp = useCallback(async (on) => {
|
|
262
|
+
if (mutationRef.current)
|
|
263
|
+
return;
|
|
264
|
+
const previous = autoWebp;
|
|
265
|
+
setAutoWebpState(on);
|
|
266
|
+
setSettingsBusy(true);
|
|
267
|
+
setError(null);
|
|
178
268
|
try {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
269
|
+
const result = await api.saveMediaSettings(on);
|
|
270
|
+
setAutoWebpState(result.settings.autoWebp);
|
|
271
|
+
setSettingsLoaded(true);
|
|
272
|
+
}
|
|
273
|
+
catch (e) {
|
|
274
|
+
setAutoWebpState(previous);
|
|
275
|
+
setError(e instanceof ApiError ? e.message : String(e));
|
|
184
276
|
}
|
|
185
277
|
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);
|
|
278
|
+
setSettingsBusy(false);
|
|
190
279
|
}
|
|
191
|
-
|
|
192
|
-
if (errs.length)
|
|
193
|
-
setError(`Non caricati: ${errs.join("; ")}`);
|
|
194
|
-
return errs.length === 0;
|
|
195
|
-
}, [prefix, load]);
|
|
280
|
+
}, [autoWebp]);
|
|
196
281
|
const labelOf = useCallback((p) => {
|
|
197
282
|
const f = folders.find((x) => x.prefix === p) ?? subfolders.find((x) => x.prefix === p);
|
|
198
283
|
return f?.label || seenLabels.current[p] || p.replace(/\/$/, "").split("/").pop() || "";
|
|
@@ -211,14 +296,24 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
|
|
|
211
296
|
const name = prompt(`Nome della cartella${dove}`);
|
|
212
297
|
if (!name || !name.trim())
|
|
213
298
|
return;
|
|
299
|
+
if (!beginMutation("creating_folder"))
|
|
300
|
+
return;
|
|
301
|
+
setBusy(true);
|
|
302
|
+
let failure = null;
|
|
214
303
|
try {
|
|
215
304
|
await api.createFolder(parent, name.trim(), { label: name.trim() });
|
|
216
|
-
await load(prefix);
|
|
217
305
|
}
|
|
218
306
|
catch (e) {
|
|
219
|
-
|
|
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);
|
|
220
315
|
}
|
|
221
|
-
}, [prefix, load, labelOf]);
|
|
316
|
+
}, [prefix, load, labelOf, beginMutation, endMutation]);
|
|
222
317
|
/* Nella selezione convivono file e cartelle, e si riconoscono da soli: la chiave di una
|
|
223
318
|
* cartella finisce con "/". Ogni operazione da qui in poi le separa in due liste. */
|
|
224
319
|
const filesIn = (ids) => ids.filter((id) => !id.endsWith("/"));
|
|
@@ -241,6 +336,8 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
|
|
|
241
336
|
const remove = useCallback(async (ids) => {
|
|
242
337
|
if (!ids.length)
|
|
243
338
|
return;
|
|
339
|
+
if (!beginMutation("deleting"))
|
|
340
|
+
return;
|
|
244
341
|
setBusy(true);
|
|
245
342
|
// L'errore va tenuto da parte: `load` in coda azzera il messaggio, quindi va rimesso DOPO,
|
|
246
343
|
// o l'operazione fallisce in silenzio e l'elenco aggiornato sembra dire che è andata bene.
|
|
@@ -318,10 +415,11 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
|
|
|
318
415
|
// SEMPRE, anche dopo un errore: l'elenco di prima è una bugia, perché una parte dei file
|
|
319
416
|
// può essere passata davvero.
|
|
320
417
|
await load(prefix);
|
|
418
|
+
endMutation();
|
|
321
419
|
if (failure)
|
|
322
420
|
setError(failure);
|
|
323
421
|
}
|
|
324
|
-
}, [prefix, load, labelOf]);
|
|
422
|
+
}, [prefix, load, labelOf, beginMutation, endMutation]);
|
|
325
423
|
/* SPOSTARE UNA CARTELLA è spostare tutto ciò che contiene: in R2 la cartella non esiste, è un
|
|
326
424
|
* pezzo di chiave. Cambia quindi l'indirizzo pubblico di ogni foto dentro, ed è per questo che
|
|
327
425
|
* i riferimenti nelle pagine si riscrivono nello stesso giro — altrimenti le gallery che le
|
|
@@ -394,72 +492,96 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
|
|
|
394
492
|
await api.remove({ prefixes: [from] }, { skipRefs: true });
|
|
395
493
|
return null;
|
|
396
494
|
}, [labelOf]);
|
|
397
|
-
/*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
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. */
|
|
400
541
|
const move = useCallback(async (ids, dest) => {
|
|
401
|
-
if (!ids.length)
|
|
542
|
+
if (!ids.length || !beginMutation("planning"))
|
|
402
543
|
return;
|
|
403
|
-
const keys = filesIn(ids);
|
|
404
|
-
const prefixes = foldersIn(ids);
|
|
405
544
|
setBusy(true);
|
|
545
|
+
setError(null);
|
|
406
546
|
let failure = null;
|
|
407
547
|
try {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
if (!dest) {
|
|
417
|
-
failure = "Le foto devono stare in una cartella.";
|
|
418
|
-
}
|
|
419
|
-
else {
|
|
420
|
-
setProgress({ done: 0, total: keys.length, verb: "Sposto" });
|
|
421
|
-
// A gruppi per la stessa ragione della cancellazione. Ogni gruppo COPIA conservando
|
|
422
|
-
// gli originali e restituisce la mappa scelta dal server; la chiamata finale aggiorna
|
|
423
|
-
// i riferimenti in un commit solo e soltanto dopo elimina gli originali.
|
|
424
|
-
let done = 0;
|
|
425
|
-
const urlMap = {};
|
|
426
|
-
const copyFailures = [];
|
|
427
|
-
for (let i = 0; i < keys.length; i += CHUNK) {
|
|
428
|
-
const group = keys.slice(i, i + CHUNK);
|
|
429
|
-
const r = await api.move(group, dest, { skipRefs: true });
|
|
430
|
-
Object.assign(urlMap, r.urlMap ?? {});
|
|
431
|
-
copyFailures.push(...(r.failed ?? []));
|
|
432
|
-
done += r.moved ?? 0;
|
|
433
|
-
setProgress({ done, total: keys.length, verb: "Sposto" });
|
|
434
|
-
}
|
|
435
|
-
const applied = Object.keys(urlMap).length ? await api.moveApplyRefs(urlMap) : null;
|
|
436
|
-
const cleanupFailures = applied?.failed ?? [];
|
|
437
|
-
if (copyFailures.length || cleanupFailures.length) {
|
|
438
|
-
failure = [
|
|
439
|
-
copyFailures.length ? `${copyFailures.length} file non spostati` : "",
|
|
440
|
-
cleanupFailures.length ? `${cleanupFailures.length} originali rimasti come duplicati` : "",
|
|
441
|
-
].filter(Boolean).join("; ") + ".";
|
|
442
|
-
}
|
|
443
|
-
}
|
|
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;
|
|
444
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);
|
|
445
564
|
}
|
|
446
565
|
catch (e) {
|
|
447
|
-
failure = e instanceof
|
|
566
|
+
failure = e instanceof Error ? e.message : String(e);
|
|
448
567
|
}
|
|
449
568
|
finally {
|
|
450
569
|
setProgress(null);
|
|
451
570
|
setBusy(false);
|
|
452
571
|
await load(prefix);
|
|
572
|
+
endMutation();
|
|
453
573
|
if (failure)
|
|
454
574
|
setError(failure);
|
|
455
575
|
}
|
|
456
|
-
}, [prefix, load,
|
|
576
|
+
}, [prefix, load, labelOf, planMove, beginMutation, endMutation]);
|
|
457
577
|
/* Rinominare è spostare nello stesso posto con un nome nuovo: stessa strada, stesse cautele.
|
|
458
578
|
* Se il nome nuovo dà lo stesso slug (è cambiata solo una maiuscola, o un «&»), non si sposta
|
|
459
579
|
* niente e si riscrive la sola etichetta. */
|
|
460
580
|
const renameFolder = useCallback(async (from, label) => {
|
|
461
581
|
if (!label.trim())
|
|
462
582
|
return;
|
|
583
|
+
if (!beginMutation("renaming"))
|
|
584
|
+
return;
|
|
463
585
|
setBusy(true);
|
|
464
586
|
let failure = null;
|
|
465
587
|
try {
|
|
@@ -472,15 +594,18 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
|
|
|
472
594
|
setProgress(null);
|
|
473
595
|
setBusy(false);
|
|
474
596
|
await load(prefix);
|
|
597
|
+
endMutation();
|
|
475
598
|
if (failure)
|
|
476
599
|
setError(failure);
|
|
477
600
|
}
|
|
478
|
-
}, [prefix, load, moveFolder]);
|
|
601
|
+
}, [prefix, load, moveFolder, beginMutation, endMutation]);
|
|
479
602
|
/** Cestina una cartella con tutto quello che contiene. È `remove` con una lista di uno. */
|
|
480
603
|
const removeFolder = useCallback((target) => remove([target]), [remove]);
|
|
481
604
|
const resumeMedia = useCallback(async (operation) => {
|
|
482
605
|
if (!operation.releaseId)
|
|
483
606
|
return;
|
|
607
|
+
if (!beginMutation("journal"))
|
|
608
|
+
return;
|
|
484
609
|
setBusy(true);
|
|
485
610
|
setError(null);
|
|
486
611
|
let failure = null;
|
|
@@ -493,13 +618,16 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
|
|
|
493
618
|
finally {
|
|
494
619
|
setBusy(false);
|
|
495
620
|
await load(prefix);
|
|
621
|
+
endMutation();
|
|
496
622
|
if (failure)
|
|
497
623
|
setError(failure);
|
|
498
624
|
}
|
|
499
|
-
}, [load, prefix]);
|
|
625
|
+
}, [load, prefix, beginMutation, endMutation]);
|
|
500
626
|
const cancelMedia = useCallback(async (operation) => {
|
|
501
627
|
if (!confirm("Annullare questa operazione Media? Le copie già preparate resteranno disponibili nella libreria."))
|
|
502
628
|
return;
|
|
629
|
+
if (!beginMutation("journal"))
|
|
630
|
+
return;
|
|
503
631
|
setBusy(true);
|
|
504
632
|
setError(null);
|
|
505
633
|
let failure = null;
|
|
@@ -512,10 +640,11 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
|
|
|
512
640
|
finally {
|
|
513
641
|
setBusy(false);
|
|
514
642
|
await load(prefix);
|
|
643
|
+
endMutation();
|
|
515
644
|
if (failure)
|
|
516
645
|
setError(failure);
|
|
517
646
|
}
|
|
518
|
-
}, [load, prefix]);
|
|
647
|
+
}, [load, prefix, beginMutation, endMutation]);
|
|
519
648
|
const step = useCallback((delta) => {
|
|
520
649
|
setViewing((i) => {
|
|
521
650
|
if (i === null)
|
|
@@ -525,18 +654,26 @@ export function useMediaLibrary({ mode, multiple = true, active = true, remember
|
|
|
525
654
|
});
|
|
526
655
|
}, [shown.length]);
|
|
527
656
|
return {
|
|
528
|
-
mode, multiple, prefix, folders, subfolders, shown, selected,
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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,
|
|
660
|
+
sortKey, sortDir, uploadOpen, viewing, autoWebp, settingsBusy,
|
|
661
|
+
go: (p) => { if (!mutationRef.current)
|
|
662
|
+
void load(p); },
|
|
663
|
+
reload: () => { if (!mutationRef.current)
|
|
664
|
+
void load(prefix); },
|
|
532
665
|
toggle, setAll,
|
|
533
666
|
setSort: setSortKey,
|
|
534
667
|
flipDir: () => setSortDir((d) => (d === "asc" ? "desc" : "asc")),
|
|
535
668
|
upload,
|
|
536
|
-
setUploadOpen
|
|
669
|
+
setUploadOpen: (open) => { if (!open || !mutationRef.current)
|
|
670
|
+
setUploadOpen(open); },
|
|
671
|
+
setAutoWebp,
|
|
537
672
|
view: setViewing,
|
|
538
673
|
step,
|
|
539
|
-
move,
|
|
674
|
+
move,
|
|
675
|
+
canMoveTo: (ids, dest) => !mutationRef.current && isValidMediaMoveDestination(ids, dest),
|
|
676
|
+
remove, newFolder, renameFolder, removeFolder, resumeMedia, cancelMedia, labelOf,
|
|
540
677
|
clearError: () => setError(null),
|
|
541
678
|
};
|
|
542
679
|
}
|
|
@@ -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
|
}
|
|
@@ -25,7 +25,8 @@ export async function onRequestGet({ request, env }) {
|
|
|
25
25
|
const subPrefixes = new Set();
|
|
26
26
|
const files = [];
|
|
27
27
|
for (const k of all) {
|
|
28
|
-
if (k.startsWith("_trash/") || k.startsWith("_pending-media/") ||
|
|
28
|
+
if (k.startsWith("_trash/") || k.startsWith("_pending-media/") ||
|
|
29
|
+
k.startsWith("_adminkit/") || pending.has(k)) continue;
|
|
29
30
|
const rel = k.slice(prefix.length);
|
|
30
31
|
const cut = rel.lastIndexOf("/");
|
|
31
32
|
if (cut >= 0) {
|
|
@@ -48,7 +49,7 @@ export async function onRequestGet({ request, env }) {
|
|
|
48
49
|
// suo `.keep` (vedi assets/folder.js). Chi non ce l'ha mostra lo slug, come sempre.
|
|
49
50
|
const folders = await Promise.all(
|
|
50
51
|
(listed.delimitedPrefixes || [])
|
|
51
|
-
.filter((p) => p !== "_trash/" && p !== "_pending-media/")
|
|
52
|
+
.filter((p) => p !== "_trash/" && p !== "_pending-media/" && p !== "_adminkit/")
|
|
52
53
|
.map(async (p) => {
|
|
53
54
|
const keep = await env.MEDIA.head(p + ".keep");
|
|
54
55
|
return {
|
|
@@ -66,7 +67,7 @@ export async function onRequestGet({ request, env }) {
|
|
|
66
67
|
// oggi. È un ripiego, non la strada normale: una volta ricaricate, il ramo non scatta più.
|
|
67
68
|
const raw = (listed.objects || [])
|
|
68
69
|
.filter((o) => o.key !== prefix && !o.key.endsWith("/.keep") && !o.key.endsWith("/") &&
|
|
69
|
-
!o.key.startsWith("_pending-media/") && !pending.has(o.key));
|
|
70
|
+
!o.key.startsWith("_pending-media/") && !o.key.startsWith("_adminkit/") && !pending.has(o.key));
|
|
70
71
|
|
|
71
72
|
const files = await Promise.all(raw.map(async (o) => {
|
|
72
73
|
const cm = o.customMetadata || {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { canonicalChecksum } from "../../persistence.js";
|
|
2
|
+
import { createSiteAccessVersion, D1SiteAccessRepository } from "../../site-access.js";
|
|
3
|
+
import { siteAccessId } from "../site.js";
|
|
4
|
+
import { json } from "../_shared/assets.js";
|
|
5
|
+
|
|
6
|
+
const MAX_BODY_BYTES = 2_000;
|
|
7
|
+
const id = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
8
|
+
|
|
9
|
+
function response(data, status = 200) {
|
|
10
|
+
const result = json(data, status);
|
|
11
|
+
result.headers.set("Cache-Control", "no-store");
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function editor(data, env) {
|
|
16
|
+
const payload = data?.cloudflareAccess?.JWT?.payload;
|
|
17
|
+
if (payload?.sub) return { subject: String(payload.sub), label: String(payload.email || payload.name || "Admin").slice(0, 200) };
|
|
18
|
+
if (env.ADMIN_LOCAL === "1") return { subject: "local-preview", label: "Anteprima locale" };
|
|
19
|
+
throw new Error("unauthenticated");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function required(env) {
|
|
23
|
+
const siteId = siteAccessId();
|
|
24
|
+
if (!env.CONTENT_DB || !siteId) throw new Error("site_access_unavailable");
|
|
25
|
+
return { siteId, repository: new D1SiteAccessRepository(env.CONTENT_DB) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function publicState(state) {
|
|
29
|
+
const clean = (value) => value ? { versionId: value.versionId, enabled: value.enabled, formatVersion: value.formatVersion } : null;
|
|
30
|
+
return { active: clean(state.active), draft: clean(state.draft), effective: clean(state.draft ?? state.active) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function safeError(error) {
|
|
34
|
+
const code = error?.message;
|
|
35
|
+
const status = code === "unauthenticated" ? 401 : code === "invalid_request" || code === "invalid_site_access_password" ? 400
|
|
36
|
+
: code === "stale_site_access" ? 409 : 503;
|
|
37
|
+
const message = code === "unauthenticated" ? "Sessione non valida: ricarica la pagina per rientrare."
|
|
38
|
+
: code === "invalid_site_access_password" ? "La password deve contenere almeno 8 caratteri."
|
|
39
|
+
: code === "stale_site_access" ? "La protezione è stata modificata altrove: ricarica la pagina."
|
|
40
|
+
: status === 400 ? "Richiesta non valida." : "La gestione della password non è disponibile.";
|
|
41
|
+
return response({ ok: false, code: status >= 500 ? "site_access_unavailable" : code, error: message }, status);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function onRequestGet({ env, data }) {
|
|
45
|
+
try {
|
|
46
|
+
editor(data, env);
|
|
47
|
+
const { repository } = required(env);
|
|
48
|
+
await repository.initialize(Date.now());
|
|
49
|
+
return response({ ok: true, state: publicState(await repository.state()) });
|
|
50
|
+
} catch (error) { return safeError(error); }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function onRequestPost({ request, env, data }) {
|
|
54
|
+
try {
|
|
55
|
+
const origin = request.headers.get("Origin");
|
|
56
|
+
if (!origin || new URL(origin).origin !== new URL(request.url).origin) throw new Error("invalid_request");
|
|
57
|
+
if (Number(request.headers.get("Content-Length") || 0) > MAX_BODY_BYTES) throw new Error("invalid_request");
|
|
58
|
+
const raw = await request.text();
|
|
59
|
+
if (new TextEncoder().encode(raw).byteLength > MAX_BODY_BYTES) throw new Error("invalid_request");
|
|
60
|
+
const body = JSON.parse(raw);
|
|
61
|
+
const author = editor(data, env);
|
|
62
|
+
const { siteId, repository } = required(env);
|
|
63
|
+
await repository.initialize(Date.now());
|
|
64
|
+
if (body?.action === "discard") {
|
|
65
|
+
if (!id.test(body.expectedDraftVersionId || "")) throw new Error("invalid_request");
|
|
66
|
+
await repository.discard(body.expectedDraftVersionId, Date.now());
|
|
67
|
+
return response({ ok: true, discarded: true, state: publicState(await repository.state()) });
|
|
68
|
+
}
|
|
69
|
+
if (body?.action !== "save" || typeof body.enabled !== "boolean"
|
|
70
|
+
|| (body.expectedDraftVersionId !== null && !id.test(body.expectedDraftVersionId || ""))) throw new Error("invalid_request");
|
|
71
|
+
const versionId = crypto.randomUUID();
|
|
72
|
+
const access = await createSiteAccessVersion({ versionId, siteId, enabled: body.enabled, password: body.password, keyring: env.SITE_ACCESS_KEYRING });
|
|
73
|
+
const requestChecksum = await canonicalChecksum({ action: "site-access.save", versionId, enabled: access.enabled, descriptorChecksum: access.descriptorChecksum });
|
|
74
|
+
await repository.saveDraft({ access, expectedDraftVersionId: body.expectedDraftVersionId, requestChecksum,
|
|
75
|
+
authorSubject: author.subject, authorLabel: author.label, now: Date.now() });
|
|
76
|
+
return response({ ok: true, saved: true, state: publicState(await repository.state()) });
|
|
77
|
+
} catch (error) { return safeError(error); }
|
|
78
|
+
}
|