@cardinali-studio/adminkit 2.2.25 → 2.2.34

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 (59) hide show
  1. package/dist/api.d.ts +19 -0
  2. package/dist/api.js +3 -0
  3. package/dist/app/App.js +22 -8
  4. package/dist/app/app.css +3 -3
  5. package/dist/app/delivery.d.ts +11 -0
  6. package/dist/app/delivery.js +46 -0
  7. package/dist/app/index.d.ts +4 -1
  8. package/dist/app/index.js +3 -2
  9. package/dist/app/lib/bookings.js +5 -0
  10. package/dist/app/lib/deliveries.d.ts +8 -0
  11. package/dist/app/lib/deliveries.js +7 -0
  12. package/dist/app/lib/delivery-network.d.ts +25 -0
  13. package/dist/app/lib/delivery-network.js +159 -0
  14. package/dist/app/lib/delivery-upload.d.ts +29 -11
  15. package/dist/app/lib/delivery-upload.js +408 -131
  16. package/dist/app/lib/home.js +7 -4
  17. package/dist/app/lib/pages.js +2 -1
  18. package/dist/app/lib/publish.d.ts +5 -0
  19. package/dist/app/lib/publish.js +61 -4
  20. package/dist/app/lib/requests.js +6 -0
  21. package/dist/app/lib/settings.js +7 -3
  22. package/dist/app/lib/upload-queue.d.ts +21 -3
  23. package/dist/app/lib/upload-queue.js +76 -19
  24. package/dist/app/routes/Deliveries.js +108 -29
  25. package/dist/app/routes/PageActions.d.ts +1 -1
  26. package/dist/app/routes/PageActions.js +5 -2
  27. package/dist/app/routes/PageEditor.js +6 -4
  28. package/dist/app/routes/PageSeoEditor.js +6 -1
  29. package/dist/app/routes/PagesList.js +5 -1
  30. package/dist/app/routes/UploadDock.js +6 -2
  31. package/dist/app/site.d.ts +13 -0
  32. package/dist/direct-publication.d.ts +20 -0
  33. package/dist/direct-publication.js +75 -0
  34. package/dist/functions/_shared/b2.js +22 -3
  35. package/dist/functions/_shared/delivery-crypto.js +5 -0
  36. package/dist/functions/_shared/delivery-limits.d.ts +9 -0
  37. package/dist/functions/_shared/delivery-limits.js +29 -0
  38. package/dist/functions/_shared/delivery-manifest-v2.sql +49 -0
  39. package/dist/functions/_shared/delivery-manifest.js +392 -0
  40. package/dist/functions/_shared/delivery-purge.js +17 -1
  41. package/dist/functions/api/deliveries.js +177 -49
  42. package/dist/functions/api/delivery/image.js +11 -6
  43. package/dist/functions/api/delivery/multipart.js +196 -64
  44. package/dist/functions/delivery-public/assets/client.js +246 -0
  45. package/dist/functions/delivery-public/assets/delivery.css +297 -0
  46. package/dist/functions/delivery-public/assets/gallery.js +243 -0
  47. package/dist/functions/delivery-public/assets/stage.js +107 -0
  48. package/dist/functions/delivery-public/index.js +637 -0
  49. package/dist/functions/delivery-public/migrations/0002_deliveries.sql +128 -0
  50. package/dist/functions/delivery-public/migrations/0003_delivery_quick_hero.sql +34 -0
  51. package/dist/functions/delivery-public/migrations/0004_delivery_file_expiry.sql +9 -0
  52. package/dist/functions/delivery-public/migrations/0006_delivery_manifest_v2.sql +49 -0
  53. package/dist/functions/delivery-public/migrations/0007_delivery_link_aliases.sql +8 -0
  54. package/dist/functions/delivery-public/migrations/0008_delivery_web_format.sql +2 -0
  55. package/dist/functions/delivery-public/migrations/0009_delivery_download_labels.sql +2 -0
  56. package/dist/functions/index.js +3 -0
  57. package/dist/index.d.ts +1 -0
  58. package/dist/index.js +1 -0
  59. package/package.json +10 -3
@@ -8,7 +8,7 @@
8
8
  * l'admin per riordinare le pagine non ha motivo di pagare una chiamata in più — e senza
9
9
  * GITHUB_TOKEN, in anteprima locale, quella chiamata è un 500.
10
10
  */
11
- import { useCallback, useEffect, useState } from "react";
11
+ import { useCallback, useEffect, useState, useRef } from "react";
12
12
  import { ApiError } from "../../index.js";
13
13
  import { api } from "../../api.js";
14
14
  import { contentPersistenceMode, site } from "../site.js";
@@ -27,6 +27,7 @@ export function useHome(active) {
27
27
  const [saving, setSaving] = useState(false);
28
28
  const [notice, setNotice] = useState(null);
29
29
  const [revision, setRevision] = useState(null);
30
+ const pending = useRef(null);
30
31
  const load = useCallback(async () => {
31
32
  try {
32
33
  const mapped = site().homeDocument;
@@ -59,12 +60,14 @@ export function useHome(active) {
59
60
  try {
60
61
  const mapped = site().homeDocument;
61
62
  if (contentPersistenceMode() === "d1" && mapped && revision) {
62
- const revisionId = crypto.randomUUID();
63
+ const revisionId = pending.current?.revisionId ?? crypto.randomUUID();
63
64
  /* The Worker validates and may normalize a document before checking its signed
64
65
  * representation. Sign the site-owned canonical payload here too, otherwise an
65
66
  * obsolete/unknown client field becomes a misleading "documento non valido". */
66
67
  const payload = mapped.toPayload ? mapped.toPayload(data) : data;
67
- await api.saveContent({ revisionId, documentId: mapped.documentId, baseRevisionId: revision.base, expectedDraftRevisionId: revision.expected, kind: mapped.kind, locale: mapped.locale, schemaVersion: mapped.schemaVersion, payload: payload, tombstone: false });
68
+ pending.current ||= { revisionId, documentId: mapped.documentId, baseRevisionId: revision.base, expectedDraftRevisionId: revision.expected, kind: mapped.kind, locale: mapped.locale, schemaVersion: mapped.schemaVersion, payload: payload, tombstone: false };
69
+ await api.saveContent(pending.current);
70
+ pending.current = null;
68
71
  setRevision({ base: revisionId, expected: revisionId });
69
72
  setNotice("Bozza D1 salvata");
70
73
  setDirty(false);
@@ -88,7 +91,7 @@ export function useHome(active) {
88
91
  setSaving(false);
89
92
  }
90
93
  }, [data, revision]);
91
- const change = useCallback((next) => { setData(next); setDirty(true); }, []);
94
+ const change = useCallback((next) => { pending.current = null; setData(next); setDirty(true); }, []);
92
95
  const mapped = site().homeDocument;
93
96
  return { data, error, dirty, saving, notice, load, change, save, setNotice,
94
97
  publishTarget: contentPersistenceMode() === "d1" && mapped && revision
@@ -48,7 +48,8 @@ export function usePages() {
48
48
  }, []);
49
49
  useEffect(() => { void load(); }, [load]);
50
50
  const update = useCallback((next) => {
51
- next.forEach((p, i) => { p.weight = site().positionWeight(p, i, next); });
51
+ if (site().pagesReorderable !== false)
52
+ next.forEach((p, i) => { p.weight = site().positionWeight(p, i, next); });
52
53
  setPages(next);
53
54
  setDirty(true);
54
55
  }, []);
@@ -17,6 +17,11 @@ export interface PublishState {
17
17
  reload(): Promise<void>;
18
18
  publish(target?: ContentPublishTarget): Promise<void>;
19
19
  clearNotice(): void;
20
+ preview(target: ContentPublishTarget): Promise<void>;
21
+ previewApproved(target: ContentPublishTarget | null): boolean;
22
+ previousReleaseId: string | null;
23
+ rollback(): Promise<void>;
24
+ verify(): Promise<void>;
20
25
  }
21
26
  export declare function contentPublicationNotice(result: {
22
27
  published: boolean;
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { useCallback, useEffect, useState } from "react";
9
9
  import { api, ApiError } from "../../index.js";
10
- import { contentPersistenceMode } from "../site.js";
10
+ import { contentPersistenceMode, site } from "../site.js";
11
11
  export function contentPublicationNotice(result) {
12
12
  if (result.published === false) {
13
13
  return result.message ?? "Non c'erano modifiche salvate da pubblicare.";
@@ -25,10 +25,64 @@ export function usePublish() {
25
25
  const [publishing, setPublishing] = useState(false);
26
26
  const [notice, setNotice] = useState(null);
27
27
  const [error, setError] = useState(null);
28
+ const [approval, setApproval] = useState(null);
29
+ const [activeReleaseId, setActiveReleaseId] = useState(null);
30
+ const [previousReleaseId, setPreviousReleaseId] = useState(null);
31
+ const previewApproved = (target) => !!target && "documentId" in target && !!approval && approval.documentId === target.documentId && approval.expectedDraftRevisionId === target.expectedDraftRevisionId && approval.expectedBaseReleaseId === activeReleaseId;
32
+ const preview = async (target) => {
33
+ if (!("documentId" in target))
34
+ return;
35
+ setPublishing(true);
36
+ setNotice(null);
37
+ try {
38
+ const result = await api.contentPreview(target);
39
+ setApproval({ ...result, documentId: target.documentId });
40
+ window.open(result.previewUrl, "_blank", "noopener");
41
+ setNotice("Anteprima della bozza salvata pronta.");
42
+ }
43
+ catch (error) {
44
+ setApproval(null);
45
+ setNotice(error.message);
46
+ }
47
+ finally {
48
+ setPublishing(false);
49
+ }
50
+ };
51
+ const rollback = async () => {
52
+ if (!activeReleaseId || !previousReleaseId)
53
+ return;
54
+ setPublishing(true);
55
+ setNotice(null);
56
+ try {
57
+ await api.contentRollback(activeReleaseId, previousReleaseId);
58
+ setApproval(null);
59
+ await reload();
60
+ setNotice("Ripristinata la release precedente. Le bozze sono conservate.");
61
+ }
62
+ catch (error) {
63
+ setNotice(error.message);
64
+ }
65
+ finally {
66
+ setPublishing(false);
67
+ }
68
+ };
69
+ const verify = async () => {
70
+ if (!activeReleaseId)
71
+ return;
72
+ try {
73
+ const result = await api.contentVerification(activeReleaseId);
74
+ setNotice(result.publicOriginVerified ? "Release verificata sul dominio pubblico." : result.reason);
75
+ }
76
+ catch (error) {
77
+ setNotice(error.message);
78
+ }
79
+ };
28
80
  const reload = useCallback(async () => {
29
81
  if (contentPersistenceMode() === "d1") {
30
82
  try {
31
83
  const d = await api.contentPublishState();
84
+ setActiveReleaseId(d.activeReleaseId ?? null);
85
+ setPreviousReleaseId(d.previousReleaseId ?? null);
32
86
  setAhead(d.ahead ?? 0);
33
87
  setDocuments(d.documents ?? []);
34
88
  setSiteAccessPending(Boolean(d.siteAccess));
@@ -78,7 +132,10 @@ export function usePublish() {
78
132
  try {
79
133
  if (!target)
80
134
  throw new ApiError("Apri il contenuto che vuoi pubblicare.", "invalid_request", 400);
81
- const r = await api.contentPublish(target);
135
+ if (site().directPublication && !previewApproved(target))
136
+ throw new ApiError("Apri una nuova anteprima della bozza prima di pubblicare.", "stale_preview", 409);
137
+ const r = await api.contentPublish(site().directPublication ? { ...target, ...approval } : target);
138
+ setApproval(null);
82
139
  setNotice(contentPublicationNotice(r));
83
140
  await reload();
84
141
  }
@@ -113,9 +170,9 @@ export function usePublish() {
113
170
  finally {
114
171
  setPublishing(false);
115
172
  }
116
- }, [reload]);
173
+ }, [reload, approval, activeReleaseId]);
117
174
  return {
118
175
  ahead, commits, documents, siteAccessPending, configured, publishing, notice, error,
119
- reload, publish, clearNotice: () => setNotice(null),
176
+ reload, publish, preview, previewApproved, previousReleaseId, rollback, verify, clearNotice: () => setNotice(null),
120
177
  };
121
178
  }
@@ -1,3 +1,4 @@
1
+ import { site } from "../site.js";
1
2
  /* Le richieste dal form di contatto.
2
3
  *
3
4
  * L'archivio è la SORGENTE, non l'email: `functions/api/contact.js` salva PRIMA e manda dopo, e
@@ -15,6 +16,11 @@ export function useRequests() {
15
16
  const [error, setError] = useState(null);
16
17
  const [busy, setBusy] = useState(false);
17
18
  const load = useCallback(async () => {
19
+ if (site().modules?.requests !== true) {
20
+ setRows([]);
21
+ setError(null);
22
+ return;
23
+ }
18
24
  try {
19
25
  setRows((await api.requests()).requests);
20
26
  setError(null);
@@ -4,7 +4,7 @@
4
4
  * protegge il sito. La password digitata resta soltanto nello stato del form: il backend
5
5
  * restituisce sempre il campo vuoto e conserva esclusivamente la verifica crittografica.
6
6
  */
7
- import { useCallback, useEffect, useState } from "react";
7
+ import { useCallback, useEffect, useState, useRef } from "react";
8
8
  import { ApiError } from "../../index.js";
9
9
  import { api } from "../../api.js";
10
10
  import { contentPersistenceMode, site } from "../site.js";
@@ -43,6 +43,7 @@ export function useSiteSettings(active) {
43
43
  const { password: _password, passwordEnabled: _passwordEnabled, ...editorial } = settings;
44
44
  return JSON.stringify(editorial);
45
45
  };
46
+ const pending = useRef(null);
46
47
  const load = useCallback(async () => {
47
48
  try {
48
49
  const mapped = site().siteSettingsDocument;
@@ -88,9 +89,11 @@ export function useSiteSettings(active) {
88
89
  const mapped = site().siteSettingsDocument;
89
90
  if (contentPersistenceMode() === "d1" && mapped && revision) {
90
91
  if (editorialSnapshot(data) !== editorialBaseline) {
91
- const revisionId = crypto.randomUUID();
92
+ const revisionId = pending.current?.revisionId ?? crypto.randomUUID();
92
93
  const payload = mapped.toPayload ? mapped.toPayload(data) : data;
93
- await api.saveContent({ revisionId, documentId: mapped.documentId, baseRevisionId: revision.base, expectedDraftRevisionId: revision.expected, kind: mapped.kind, locale: mapped.locale, schemaVersion: mapped.schemaVersion, payload: payload, tombstone: false });
94
+ pending.current ||= { revisionId, documentId: mapped.documentId, baseRevisionId: revision.base, expectedDraftRevisionId: revision.expected, kind: mapped.kind, locale: mapped.locale, schemaVersion: mapped.schemaVersion, payload: payload, tombstone: false };
95
+ await api.saveContent(pending.current);
96
+ pending.current = null;
94
97
  setRevision({ base: revisionId, expected: revisionId });
95
98
  setEditorialBaseline(editorialSnapshot(data));
96
99
  }
@@ -124,6 +127,7 @@ export function useSiteSettings(active) {
124
127
  }
125
128
  }, [data, revision, accessState, editorialBaseline]);
126
129
  const change = useCallback((next) => {
130
+ pending.current = null;
127
131
  setData(next);
128
132
  setDirty(true);
129
133
  }, []);
@@ -1,5 +1,5 @@
1
- import { type DeliveryUploadProgress } from "./delivery-upload";
2
- export type UploadState = "running" | "paused" | "done" | "failed";
1
+ import { type DeliveryUploadProgress, type UploadControl } from "./delivery-upload.js";
2
+ export type UploadState = "running" | "paused" | "cancelling" | "done" | "failed";
3
3
  export interface UploadJob {
4
4
  /** L'id del pacchetto: uno per caricamento. */
5
5
  id: string;
@@ -11,6 +11,8 @@ export interface UploadJob {
11
11
  progress: DeliveryUploadProgress | null;
12
12
  error?: string;
13
13
  skipped?: number;
14
+ waitingForNetwork?: boolean;
15
+ retryable?: boolean;
14
16
  }
15
17
  interface StartInput {
16
18
  deliveryId: string;
@@ -20,16 +22,32 @@ interface StartInput {
20
22
  options: {
21
23
  galleryEnabled: boolean;
22
24
  webEnabled: boolean;
25
+ webFormat?: "webp" | "jpg";
26
+ manifestVersion?: 1 | 2;
23
27
  };
24
28
  }
25
29
  export declare function useUploads(): UploadJob[];
26
30
  export declare function useUploadCompletions(): number;
27
31
  export declare function uploadFor(deliveryId: string, list: UploadJob[]): UploadJob | undefined;
28
32
  export declare function isBusy(job: UploadJob | undefined): boolean;
29
- export declare function startUpload(input: StartInput): void;
33
+ export declare class Gate implements UploadControl {
34
+ private id;
35
+ readonly controller: AbortController;
36
+ readonly signal: AbortSignal;
37
+ private waitingRequests;
38
+ constructor(id: string);
39
+ networkWaiting(waiting: boolean): void;
40
+ paused: boolean;
41
+ cancelled: boolean;
42
+ private waiting;
43
+ gate(): Promise<void>;
44
+ wake(): void;
45
+ }
46
+ export declare function startUpload(input: StartInput): Promise<void>;
30
47
  export declare function pauseUpload(id: string): void;
31
48
  export declare function resumeUpload(id: string): void;
32
49
  export declare function cancelUpload(id: string): void;
50
+ export declare function retryUpload(id: string): Promise<void> | undefined;
33
51
  export declare function dismissUpload(id: string): void;
34
52
  export declare function hasRunningUploads(): boolean;
35
53
  export {};
@@ -15,12 +15,14 @@
15
15
  * App.tsx ricarica l'elenco quando uno finisce (`useUploadCompletions`)
16
16
  */
17
17
  import { useSyncExternalStore } from "react";
18
- import { UploadCancelled, uploadDeliveryPackage, } from "./delivery-upload";
19
- import { deliveryAction } from "./deliveries";
18
+ import { UploadCancelled, uploadDeliveryPackage, } from "./delivery-upload.js";
19
+ import { deliveryJson } from "./delivery-network.js";
20
20
  /* ---- lo stato ------------------------------------------------------------------------ */
21
21
  let jobs = [];
22
22
  let completions = 0;
23
23
  const gates = new Map();
24
+ const running = new Map();
25
+ const retries = new Map();
24
26
  const listeners = new Set();
25
27
  function emit() {
26
28
  jobs = [...jobs];
@@ -47,15 +49,27 @@ export function useUploadCompletions() {
47
49
  return useSyncExternalStore(subscribe, () => completions, () => completions);
48
50
  }
49
51
  export function uploadFor(deliveryId, list) {
50
- return list.find((job) => job.deliveryId === deliveryId);
52
+ const matching = list.filter((job) => job.deliveryId === deliveryId);
53
+ return matching.find(isBusy) || matching[matching.length - 1];
51
54
  }
52
55
  export function isBusy(job) {
53
- return !!job && (job.state === "running" || job.state === "paused");
56
+ return !!job && (job.state === "running" || job.state === "paused" || job.state === "cancelling");
54
57
  }
55
58
  /* ---- il cancello --------------------------------------------------------------------- */
56
59
  /* Pausa e annullamento passano da qui: il motore (delivery-upload.ts) chiama `gate()` a ogni
57
60
  file e a ogni parte, e questa promessa lo tiene fermo finché non si riprende. */
58
- class Gate {
61
+ export class Gate {
62
+ id;
63
+ controller = new AbortController();
64
+ signal = this.controller.signal;
65
+ waitingRequests = 0;
66
+ constructor(id) {
67
+ this.id = id;
68
+ }
69
+ networkWaiting(waiting) {
70
+ this.waitingRequests += waiting ? 1 : -1;
71
+ patch(this.id, { waitingForNetwork: this.waitingRequests > 0 });
72
+ }
59
73
  paused = false;
60
74
  cancelled = false;
61
75
  waiting = [];
@@ -77,7 +91,10 @@ class Gate {
77
91
  }
78
92
  /* ---- i comandi ------------------------------------------------------------------------ */
79
93
  export function startUpload(input) {
80
- const gate = new Gate();
94
+ const existing = running.get(input.packageId);
95
+ if (existing)
96
+ return existing;
97
+ const gate = new Gate(input.packageId);
81
98
  gates.set(input.packageId, gate);
82
99
  jobs = [...jobs.filter((job) => job.id !== input.packageId), {
83
100
  id: input.packageId,
@@ -89,33 +106,64 @@ export function startUpload(input) {
89
106
  progress: null,
90
107
  }];
91
108
  emit();
92
- void run(input, gate);
109
+ const promise = run(input, gate).finally(() => running.delete(input.packageId));
110
+ running.set(input.packageId, promise);
111
+ return promise;
93
112
  }
94
113
  async function run(input, gate) {
95
114
  const id = input.packageId;
96
115
  try {
97
- const result = await uploadDeliveryPackage(input.deliveryId, id, input.files, input.options, (progress) => patch(id, { progress }), gate);
116
+ const retry = retries.get(id);
117
+ if (retry?.cleanup)
118
+ throw new UploadCancelled();
119
+ const result = retry?.result || await uploadDeliveryPackage(input.deliveryId, id, input.files, input.options, (progress) => patch(id, { progress }), gate);
98
120
  /* Chiusura e attivazione stanno QUI e non nella schermata: se finiscono mentre il
99
121
  fotografo sta guardando un'altra sezione, devono succedere lo stesso. */
100
- await deliveryAction({
101
- action: "finalizePackage", id: input.deliveryId, packageId: id,
102
- manifest: result.manifest, fileCount: result.fileCount,
103
- sourceBytes: result.sourceBytes, storedBytes: result.storedBytes,
104
- });
105
- await deliveryAction({ action: "activate", id: input.deliveryId }).catch(() => { });
122
+ retries.set(id, { input, result });
123
+ await gate.gate();
124
+ if (result.manifest.version === 2) {
125
+ let done = false;
126
+ while (!done) {
127
+ await gate.gate();
128
+ const step = await deliveryJson("/admin/api/deliveries", {
129
+ action: "manifestFinalize", id: input.deliveryId, packageId: id,
130
+ }, gate);
131
+ done = step.done;
132
+ }
133
+ }
134
+ else
135
+ await deliveryJson("/admin/api/deliveries", {
136
+ action: "finalizePackage", id: input.deliveryId, packageId: id,
137
+ manifest: result.manifest, fileCount: result.fileCount,
138
+ sourceBytes: result.sourceBytes, storedBytes: result.storedBytes,
139
+ }, gate);
140
+ await gate.gate();
141
+ await deliveryJson("/admin/api/deliveries", { action: "activate", id: input.deliveryId, packageId: id }, gate);
142
+ await gate.gate();
143
+ retries.delete(id);
106
144
  patch(id, { state: "done", progress: null, skipped: result.manifest.skipped.length });
107
145
  }
108
146
  catch (error) {
109
147
  if (error instanceof UploadCancelled) {
110
148
  /* Annullare vuol dire «come se non l'avessi mai iniziato»: il pacchetto sparisce, e
111
149
  con lui il multipart aperto su B2. */
112
- await deliveryAction({
113
- action: "deletePackage", id: input.deliveryId, packageId: id,
114
- }).catch(() => { });
115
- jobs = jobs.filter((job) => job.id !== id);
150
+ patch(id, { state: "cancelling", progress: null });
151
+ try {
152
+ await deliveryJson("/admin/api/deliveries", {
153
+ action: "deletePackage", id: input.deliveryId, packageId: id,
154
+ }, { gate: async () => { }, networkWaiting: (waiting) => gate.networkWaiting(waiting) });
155
+ retries.delete(id);
156
+ jobs = jobs.filter((job) => job.id !== id);
157
+ }
158
+ catch (cleanupError) {
159
+ retries.set(id, { input, cleanup: true });
160
+ patch(id, { state: "failed", retryable: true, error: "Upload fermato. Riprova la rimozione dei file: " + readable(cleanupError) });
161
+ }
116
162
  }
117
163
  else {
118
- patch(id, { state: "failed", progress: null, error: readable(error) });
164
+ if (!retries.has(id))
165
+ retries.set(id, { input });
166
+ patch(id, { state: "failed", progress: null, retryable: true, error: readable(error) });
119
167
  }
120
168
  }
121
169
  finally {
@@ -144,12 +192,21 @@ export function cancelUpload(id) {
144
192
  if (!gate)
145
193
  return;
146
194
  gate.cancelled = true;
195
+ gate.controller.abort();
196
+ patch(id, { state: "cancelling" });
147
197
  gate.paused = false;
148
198
  gate.wake();
149
199
  }
150
200
  /* Toglie dalla vista un caricamento già finito. Su uno in corso non fa niente: per quello
151
201
  c'è `cancelUpload`, che è un'altra cosa e va detta con un'altra parola. */
202
+ export function retryUpload(id) {
203
+ const retry = retries.get(id);
204
+ if (retry)
205
+ return startUpload(retry.input);
206
+ }
152
207
  export function dismissUpload(id) {
208
+ if (!gates.has(id))
209
+ retries.delete(id);
153
210
  jobs = jobs.filter((job) => job.id === id ? isBusy(job) : true);
154
211
  emit();
155
212
  }