@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.
Files changed (47) hide show
  1. package/dist/api.d.ts +43 -2
  2. package/dist/api.js +13 -3
  3. package/dist/app/App.js +11 -5
  4. package/dist/app/app.css +6 -0
  5. package/dist/app/lib/publish.js +4 -1
  6. package/dist/app/lib/settings.d.ts +2 -0
  7. package/dist/app/lib/settings.js +54 -8
  8. package/dist/app/routes/MediaSettingsEditor.d.ts +4 -0
  9. package/dist/app/routes/MediaSettingsEditor.js +4 -0
  10. package/dist/app/routes/SiteSettingsEditor.d.ts +3 -1
  11. package/dist/app/routes/SiteSettingsEditor.js +3 -2
  12. package/dist/app/routes/UploadDock.js +31 -9
  13. package/dist/app/site.d.ts +4 -0
  14. package/dist/deployment.js +14 -1
  15. package/dist/form/media/MediaFolders.d.ts +5 -1
  16. package/dist/form/media/MediaFolders.js +8 -7
  17. package/dist/form/media/MediaGrid.js +32 -17
  18. package/dist/form/media/UploadDialog.js +11 -5
  19. package/dist/form/media/image-conversion.d.ts +14 -0
  20. package/dist/form/media/image-conversion.js +163 -0
  21. package/dist/form/media/media-model.d.ts +41 -0
  22. package/dist/form/media/media-model.js +78 -0
  23. package/dist/form/media/media-move.d.ts +48 -0
  24. package/dist/form/media/media-move.js +92 -0
  25. package/dist/form/media/media-upload-queue.d.ts +31 -0
  26. package/dist/form/media/media-upload-queue.js +230 -0
  27. package/dist/form/media/useMediaLibrary.d.ts +12 -0
  28. package/dist/form/media/useMediaLibrary.js +215 -78
  29. package/dist/functions/_shared/assets.js +3 -2
  30. package/dist/functions/api/assets/folders.js +2 -1
  31. package/dist/functions/api/assets/settings.js +40 -0
  32. package/dist/functions/api/assets/upload.js +4 -1
  33. package/dist/functions/api/assets.js +4 -3
  34. package/dist/functions/api/site-access.js +78 -0
  35. package/dist/functions/index.js +4 -0
  36. package/dist/functions/site.js +4 -0
  37. package/dist/index.d.ts +1 -0
  38. package/dist/index.js +1 -0
  39. package/dist/migrations/0004_site_access.sql +41 -0
  40. package/dist/persistence.d.ts +1 -0
  41. package/dist/persistence.js +22 -4
  42. package/dist/release.d.ts +3 -0
  43. package/dist/release.js +7 -0
  44. package/dist/site-access.d.ts +48 -0
  45. package/dist/site-access.js +180 -0
  46. package/dist/theme.css +4 -0
  47. 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, uploadQueue, ApiError } from "../../api.js";
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
- const ids = mode === "manage" ? subfolders.map((f) => f.prefix) : [];
170
- setSelected(new Set([...ids, ...shown.map((f) => f.key)]));
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
- setProgress({ done: 0, total: arr.length, verb: "Carico" });
177
- const errs = [];
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
- await uploadQueue(prefix, arr, (done, total, _r, err) => {
180
- setProgress({ done, total, verb: "Carico" });
181
- if (err)
182
- errs.push(err);
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
- setProgress(null);
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
- // Dopo `load`, che azzera il messaggio: prima veniva scritto e subito cancellato.
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
- setError(e instanceof ApiError ? e.message : String(e));
307
+ failure = e instanceof ApiError ? e.message : String(e);
308
+ }
309
+ finally {
310
+ setBusy(false);
311
+ await load(prefix);
312
+ endMutation();
313
+ if (failure)
314
+ setError(failure);
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
- /* Spostare NON è solo copiare l'oggetto: la Function sposta il file dentro R2 e nello stesso
398
- * giro riscrive i riferimenti /media/<vecchia> nelle pagine. Vale per i file come per le
399
- * cartelle, che nella selezione stanno insieme. */
495
+ /* Il planning legge ed espande TUTTA la selezione prima della prima mutazione. Oltre alla
496
+ * relazione sorgente/destinazione controlla le collisioni già visibili e calcola il totale
497
+ * fisico che verrà usato sia nella conferma sia nel progresso. */
498
+ const planMove = useCallback(async (ids, dest) => {
499
+ const normalized = normalizeMediaSelection(ids);
500
+ const invalid = invalidMediaMoveReason(normalized.ids, dest);
501
+ if (invalid)
502
+ throw new Error(invalid);
503
+ if (!dest && normalized.files.length)
504
+ throw new Error("I file devono stare in una cartella.");
505
+ const directFiles = normalized.files.filter((key) => {
506
+ const slash = key.lastIndexOf("/");
507
+ return key.slice(0, slash < 0 ? 0 : slash + 1) !== dest;
508
+ });
509
+ const [destination, ...expanded] = await Promise.all([
510
+ normalized.folders.length ? api.assets(dest) : Promise.resolve(null),
511
+ ...normalized.folders.map((source) => api.assets(source, { recursive: true })),
512
+ ]);
513
+ const occupied = new Set(destination?.folders.map((folder) => folder.prefix) ?? []);
514
+ const plannedTargets = new Set();
515
+ const plannedFolders = normalized.folders.map((source, index) => {
516
+ const name = labelOf(source).trim();
517
+ const slug = mediaFolderSlug(name);
518
+ if (!slug)
519
+ throw new Error(`La cartella «${source}» non ha un nome valido.`);
520
+ const target = `${dest}${slug}/`;
521
+ if (target === source)
522
+ throw new Error(`La cartella «${name}» si trova già in quella posizione.`);
523
+ if (occupied.has(target) || plannedTargets.has(target)) {
524
+ throw new Error(`C'è già una cartella «${name}» nella destinazione scelta.`);
525
+ }
526
+ plannedTargets.add(target);
527
+ const inside = expanded[index];
528
+ return { source, name, target, files: inside.files ?? [], folders: inside.folders ?? [] };
529
+ });
530
+ if (!directFiles.length && !plannedFolders.length)
531
+ throw new Error("Nessun elemento da spostare.");
532
+ return {
533
+ directFiles,
534
+ folders: plannedFolders,
535
+ totalFiles: directFiles.length + plannedFolders.reduce((total, folder) => total + folder.files.length, 0),
536
+ };
537
+ }, [labelOf]);
538
+ /* Spostare NON è solo copiare l'oggetto: il batch conserva gli originali, accumula una sola
539
+ * mappa dei riferimenti e la applica soltanto dopo che TUTTE le copie sono riuscite. Al primo
540
+ * errore il ciclo si ferma: gli elementi non ancora iniziati non vengono toccati. */
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
- for (const p of prefixes) {
409
- const err = await moveFolder(p, dest);
410
- if (err)
411
- failure = err;
412
- }
413
- if (keys.length) {
414
- // La radice non è una destinazione valida per un FILE: la Function la rifiuta, e senza
415
- // questo controllo il rifiuto arriverebbe come un errore criptico a metà operazione.
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 ApiError ? e.message : String(e);
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, moveFolder]);
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, busy, error, mediaOperations, progress,
529
- sortKey, sortDir, uploadOpen, viewing,
530
- go: (p) => void load(p),
531
- reload: () => void load(prefix),
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, remove, newFolder, renameFolder, removeFolder, resumeMedia, cancelMedia, labelOf,
674
+ move,
675
+ canMoveTo: (ids, dest) => !mutationRef.current && isValidMediaMoveDestination(ids, dest),
676
+ remove, newFolder, renameFolder, removeFolder, resumeMedia, cancelMedia, labelOf,
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", svg: "image/svg+xml",
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/") || pending.has(k)) continue;
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
- if (ctype === "image/jpeg") {
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/") || pending.has(k)) continue;
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
+ }