@cardinali-studio/adminkit 1.0.0

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 (181) hide show
  1. package/dist/api.d.ts +195 -0
  2. package/dist/api.js +118 -0
  3. package/dist/app/App.d.ts +3 -0
  4. package/dist/app/App.js +320 -0
  5. package/dist/app/app.css +2068 -0
  6. package/dist/app/index.d.ts +18 -0
  7. package/dist/app/index.js +33 -0
  8. package/dist/app/lib/bookings.d.ts +132 -0
  9. package/dist/app/lib/bookings.js +79 -0
  10. package/dist/app/lib/deliveries.d.ts +95 -0
  11. package/dist/app/lib/deliveries.js +104 -0
  12. package/dist/app/lib/delivery-drop.d.ts +1 -0
  13. package/dist/app/lib/delivery-drop.js +72 -0
  14. package/dist/app/lib/delivery-upload.d.ts +78 -0
  15. package/dist/app/lib/delivery-upload.js +585 -0
  16. package/dist/app/lib/groups.d.ts +9 -0
  17. package/dist/app/lib/groups.js +32 -0
  18. package/dist/app/lib/home.d.ts +11 -0
  19. package/dist/app/lib/home.js +67 -0
  20. package/dist/app/lib/overview.d.ts +82 -0
  21. package/dist/app/lib/overview.js +68 -0
  22. package/dist/app/lib/pages.d.ts +24 -0
  23. package/dist/app/lib/pages.js +156 -0
  24. package/dist/app/lib/publish.d.ts +18 -0
  25. package/dist/app/lib/publish.js +63 -0
  26. package/dist/app/lib/reorder.d.ts +3 -0
  27. package/dist/app/lib/reorder.js +31 -0
  28. package/dist/app/lib/requests.d.ts +10 -0
  29. package/dist/app/lib/requests.js +67 -0
  30. package/dist/app/lib/router.d.ts +1 -0
  31. package/dist/app/lib/router.js +14 -0
  32. package/dist/app/lib/settings.d.ts +29 -0
  33. package/dist/app/lib/settings.js +82 -0
  34. package/dist/app/lib/slug.d.ts +3 -0
  35. package/dist/app/lib/slug.js +23 -0
  36. package/dist/app/lib/text.d.ts +1 -0
  37. package/dist/app/lib/text.js +13 -0
  38. package/dist/app/lib/theme.d.ts +7 -0
  39. package/dist/app/lib/theme.js +43 -0
  40. package/dist/app/lib/upload-queue.d.ts +35 -0
  41. package/dist/app/lib/upload-queue.js +167 -0
  42. package/dist/app/routes/AboutEditor.d.ts +15 -0
  43. package/dist/app/routes/AboutEditor.js +122 -0
  44. package/dist/app/routes/Bookings.d.ts +21 -0
  45. package/dist/app/routes/Bookings.js +134 -0
  46. package/dist/app/routes/Deliveries.d.ts +37 -0
  47. package/dist/app/routes/Deliveries.js +563 -0
  48. package/dist/app/routes/GallerySectionsEditor.d.ts +12 -0
  49. package/dist/app/routes/GallerySectionsEditor.js +107 -0
  50. package/dist/app/routes/HomeEditor.d.ts +10 -0
  51. package/dist/app/routes/HomeEditor.js +188 -0
  52. package/dist/app/routes/Overview.d.ts +17 -0
  53. package/dist/app/routes/Overview.js +318 -0
  54. package/dist/app/routes/PageActions.d.ts +12 -0
  55. package/dist/app/routes/PageActions.js +93 -0
  56. package/dist/app/routes/PageEditor.d.ts +23 -0
  57. package/dist/app/routes/PageEditor.js +233 -0
  58. package/dist/app/routes/PageSeoEditor.d.ts +13 -0
  59. package/dist/app/routes/PageSeoEditor.js +102 -0
  60. package/dist/app/routes/PagesList.d.ts +10 -0
  61. package/dist/app/routes/PagesList.js +76 -0
  62. package/dist/app/routes/PortfolioWorksEditor.d.ts +12 -0
  63. package/dist/app/routes/PortfolioWorksEditor.js +139 -0
  64. package/dist/app/routes/Requests.d.ts +13 -0
  65. package/dist/app/routes/Requests.js +30 -0
  66. package/dist/app/routes/SiteSettingsEditor.d.ts +21 -0
  67. package/dist/app/routes/SiteSettingsEditor.js +66 -0
  68. package/dist/app/routes/UploadDock.d.ts +1 -0
  69. package/dist/app/routes/UploadDock.js +66 -0
  70. package/dist/app/routes/WorkshopEditionsEditor.d.ts +12 -0
  71. package/dist/app/routes/WorkshopEditionsEditor.js +101 -0
  72. package/dist/app/site.d.ts +37 -0
  73. package/dist/app/site.js +18 -0
  74. package/dist/app/ui/MediaSlot.d.ts +10 -0
  75. package/dist/app/ui/MediaSlot.js +20 -0
  76. package/dist/app/ui/OpenCard.d.ts +18 -0
  77. package/dist/app/ui/OpenCard.js +20 -0
  78. package/dist/app/ui/ParagraphEditor.d.ts +8 -0
  79. package/dist/app/ui/ParagraphEditor.js +37 -0
  80. package/dist/app/ui/dnd.d.ts +1 -0
  81. package/dist/app/ui/dnd.js +11 -0
  82. package/dist/app/ui/icons.d.ts +26 -0
  83. package/dist/app/ui/icons.js +45 -0
  84. package/dist/config.d.ts +5 -0
  85. package/dist/config.js +26 -0
  86. package/dist/form/MediaBrowser.d.ts +13 -0
  87. package/dist/form/MediaBrowser.js +24 -0
  88. package/dist/form/MediaPicker.d.ts +10 -0
  89. package/dist/form/MediaPicker.js +20 -0
  90. package/dist/form/SchemaForm.d.ts +21 -0
  91. package/dist/form/SchemaForm.js +67 -0
  92. package/dist/form/Trash.d.ts +1 -0
  93. package/dist/form/Trash.js +42 -0
  94. package/dist/form/fields/index.d.ts +1 -0
  95. package/dist/form/fields/index.js +110 -0
  96. package/dist/form/fields/objectList.d.ts +1 -0
  97. package/dist/form/fields/objectList.js +99 -0
  98. package/dist/form/index.d.ts +8 -0
  99. package/dist/form/index.js +8 -0
  100. package/dist/form/media/Lightbox.d.ts +4 -0
  101. package/dist/form/media/Lightbox.js +70 -0
  102. package/dist/form/media/MediaFolders.d.ts +20 -0
  103. package/dist/form/media/MediaFolders.js +116 -0
  104. package/dist/form/media/MediaGrid.d.ts +8 -0
  105. package/dist/form/media/MediaGrid.js +139 -0
  106. package/dist/form/media/UploadDialog.d.ts +4 -0
  107. package/dist/form/media/UploadDialog.js +37 -0
  108. package/dist/form/media/useMediaLibrary.d.ts +55 -0
  109. package/dist/form/media/useMediaLibrary.js +495 -0
  110. package/dist/form/prose/ProseBar.d.ts +21 -0
  111. package/dist/form/prose/ProseBar.js +111 -0
  112. package/dist/form/prose/ProseField.d.ts +11 -0
  113. package/dist/form/prose/ProseField.js +152 -0
  114. package/dist/form/prose/pages.d.ts +6 -0
  115. package/dist/form/prose/pages.js +13 -0
  116. package/dist/form/registry.d.ts +20 -0
  117. package/dist/form/registry.js +15 -0
  118. package/dist/frontmatter.d.ts +20 -0
  119. package/dist/frontmatter.js +202 -0
  120. package/dist/functions/_middleware.js +101 -0
  121. package/dist/functions/_shared/analytics-ga4.js +248 -0
  122. package/dist/functions/_shared/analytics-umami.js +119 -0
  123. package/dist/functions/_shared/assets.js +90 -0
  124. package/dist/functions/_shared/b2.js +300 -0
  125. package/dist/functions/_shared/delivery-crypto.js +99 -0
  126. package/dist/functions/_shared/delivery-purge.js +222 -0
  127. package/dist/functions/_shared/edge-cache.js +37 -0
  128. package/dist/functions/_shared/exif.js +53 -0
  129. package/dist/functions/_shared/github.js +138 -0
  130. package/dist/functions/_shared/imagesize.js +101 -0
  131. package/dist/functions/_shared/media-content.js +200 -0
  132. package/dist/functions/_shared/pending-media.js +98 -0
  133. package/dist/functions/_shared/refs.js +155 -0
  134. package/dist/functions/api/analytics.js +106 -0
  135. package/dist/functions/api/assets/delete.js +241 -0
  136. package/dist/functions/api/assets/folder.js +43 -0
  137. package/dist/functions/api/assets/folders.js +32 -0
  138. package/dist/functions/api/assets/move.js +253 -0
  139. package/dist/functions/api/assets/preview.js +17 -0
  140. package/dist/functions/api/assets/trash/delete.js +13 -0
  141. package/dist/functions/api/assets/trash/empty.js +16 -0
  142. package/dist/functions/api/assets/trash/restore.js +43 -0
  143. package/dist/functions/api/assets/trash.js +28 -0
  144. package/dist/functions/api/assets/upload.js +54 -0
  145. package/dist/functions/api/assets/usage.js +41 -0
  146. package/dist/functions/api/assets.js +94 -0
  147. package/dist/functions/api/deliveries.js +629 -0
  148. package/dist/functions/api/delivery/hero.js +21 -0
  149. package/dist/functions/api/delivery/image.js +35 -0
  150. package/dist/functions/api/delivery/multipart.js +261 -0
  151. package/dist/functions/api/pages/get.js +26 -0
  152. package/dist/functions/api/pages.js +354 -0
  153. package/dist/functions/api/publish.js +134 -0
  154. package/dist/functions/api/requests.js +57 -0
  155. package/dist/functions/api/settings.js +236 -0
  156. package/dist/functions/api/singleton.js +105 -0
  157. package/dist/functions/api/storage.js +125 -0
  158. package/dist/functions/index.js +124 -0
  159. package/dist/functions/site.js +38 -0
  160. package/dist/gallery.d.ts +28 -0
  161. package/dist/gallery.js +18 -0
  162. package/dist/index.d.ts +10 -0
  163. package/dist/index.js +10 -0
  164. package/dist/model.d.ts +2 -0
  165. package/dist/model.js +47 -0
  166. package/dist/normalize.d.ts +50 -0
  167. package/dist/normalize.js +120 -0
  168. package/dist/preview.d.ts +3 -0
  169. package/dist/preview.js +82 -0
  170. package/dist/prose.d.ts +17 -0
  171. package/dist/prose.js +205 -0
  172. package/dist/registries.d.ts +43 -0
  173. package/dist/registries.js +29 -0
  174. package/dist/serialize.d.ts +26 -0
  175. package/dist/serialize.js +136 -0
  176. package/dist/testing.d.ts +26 -0
  177. package/dist/testing.js +66 -0
  178. package/dist/theme.css +636 -0
  179. package/dist/yaml.d.ts +8 -0
  180. package/dist/yaml.js +90 -0
  181. package/package.json +97 -0
@@ -0,0 +1,253 @@
1
+ /* POST /admin/api/assets/move — sposta file in un'altra cartella e aggiorna i riferimenti.
2
+ *
3
+ * Il protocollo a gruppi è intenzionalmente in due fasi:
4
+ * skipRefs COPIA i file e restituisce la mappa, ma conserva gli originali;
5
+ * applyRefs verifica tutte le copie, aggiorna il repo e solo dopo cancella gli originali.
6
+ *
7
+ * Così un errore o una scheda chiusa a metà lascia al massimo dei duplicati. Il sito continua a
8
+ * trovare i vecchi URL finché il commit non è riuscito, e trova i nuovi quando gli originali
9
+ * vengono eliminati. R2 e GitHub non offrono una transazione comune: questa sequenza mantiene
10
+ * vera l'invariante importante senza fingere che la transazione esista.
11
+ */
12
+ import { json, isKey, isPrefix, uniqueKey } from "../../_shared/assets.js";
13
+ import { mediaUrl } from "../../_shared/refs.js";
14
+ import { rewriteMediaReferences } from "../../_shared/media-content.js";
15
+ import { getBase } from "../../_shared/github.js";
16
+ import { finalizePendingMedia, recordPendingMedia } from "../../_shared/pending-media.js";
17
+
18
+ function mediaKey(url) {
19
+ if (typeof url !== "string" || !url.startsWith("/media/")) return null;
20
+ try {
21
+ const key = url.slice("/media/".length).split("/").map(decodeURIComponent).join("/");
22
+ return isKey(key) && mediaUrl(key) === url ? key : null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ export function validatedMoveMap(value) {
29
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
30
+ const entries = [];
31
+ for (const [oldUrl, newUrl] of Object.entries(value)) {
32
+ const oldKey = mediaKey(oldUrl);
33
+ const newKey = mediaKey(newUrl);
34
+ if (!oldKey || !newKey || oldKey === newKey) return null;
35
+ entries.push({ oldUrl, newUrl, oldKey, newKey });
36
+ }
37
+ return entries;
38
+ }
39
+
40
+ /** Copia ogni file senza cancellare l'originale. Gli errori di un elemento non nascondono
41
+ * la mappa degli elementi già copiati: il client potrà completare quelli riusciti. */
42
+ export async function prepareMoves(bucket, keys, dest) {
43
+ const urlMap = {};
44
+ const prepared = [];
45
+ const failed = [];
46
+ for (const key of keys) {
47
+ try {
48
+ const base = key.split("/").pop();
49
+ const parent = key.slice(0, key.length - base.length);
50
+ if (parent === dest) continue;
51
+ const obj = await bucket.get(key);
52
+ if (!obj) {
53
+ failed.push({ key, error: "file non trovato" });
54
+ continue;
55
+ }
56
+ const newKey = await uniqueKey(bucket, dest, base);
57
+ await bucket.put(newKey, obj.body, {
58
+ httpMetadata: obj.httpMetadata || {},
59
+ customMetadata: obj.customMetadata || undefined,
60
+ });
61
+ urlMap[mediaUrl(key)] = mediaUrl(newKey);
62
+ prepared.push({ oldKey: key, newKey });
63
+ } catch (error) {
64
+ failed.push({ key, error: error.message || String(error) });
65
+ }
66
+ }
67
+ return { moved: prepared.length, urlMap, prepared, failed };
68
+ }
69
+
70
+ async function missingCopies(bucket, entries) {
71
+ const missing = [];
72
+ for (const entry of entries) {
73
+ try {
74
+ if (!await bucket.head(entry.newKey)) missing.push(entry.newKey);
75
+ } catch {
76
+ missing.push(entry.newKey);
77
+ }
78
+ }
79
+ return missing;
80
+ }
81
+
82
+ /** Da chiamare solo dopo il commit dei riferimenti. Un fallimento lascia un duplicato innocuo. */
83
+ export async function finalizeMoves(bucket, entries) {
84
+ let finalized = 0;
85
+ const failed = [];
86
+ for (const entry of entries) {
87
+ try {
88
+ await bucket.delete(entry.oldKey);
89
+ finalized++;
90
+ } catch (error) {
91
+ failed.push({ key: entry.oldKey, error: error.message || String(error) });
92
+ }
93
+ }
94
+ return { finalized, failed };
95
+ }
96
+
97
+ async function abortPrepared(bucket, entries) {
98
+ for (const entry of entries) {
99
+ try { await bucket.delete(entry.newKey); } catch { /* l'originale esiste ancora */ }
100
+ }
101
+ }
102
+
103
+ function rewriteError(error) {
104
+ const blocked = Array.isArray(error.blockers) ? error.blockers : undefined;
105
+ return json({
106
+ ok: false,
107
+ code: error.code || "media_refs_failed",
108
+ error: error.message || String(error),
109
+ ...(blocked ? { blockers: blocked } : {}),
110
+ }, error.code === "media_refs_blocked" || error.code === "conflict" ? 409 : 502);
111
+ }
112
+
113
+ async function confirmRewrittenCommit(env, commitSha) {
114
+ try {
115
+ return (await getBase(env)).commitSha === commitSha
116
+ ? null
117
+ : json({
118
+ ok: false,
119
+ code: "conflict",
120
+ error: "Il sito è cambiato dopo l'aggiornamento dei riferimenti. Gli originali sono stati conservati.",
121
+ }, 409);
122
+ } catch (error) {
123
+ return json({
124
+ ok: false,
125
+ code: "github_read_failed",
126
+ error: `Non riesco a confermare il commit dei riferimenti: ${error.message || String(error)}`,
127
+ }, 502);
128
+ }
129
+ }
130
+
131
+ export async function onRequestPost({ request, env }) {
132
+ if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
133
+ if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) {
134
+ return json({
135
+ ok: false,
136
+ code: "github_unavailable",
137
+ error: "Non posso spostare media senza aggiornare i riferimenti nel sito.",
138
+ }, 503);
139
+ }
140
+ let body;
141
+ try { body = await request.json(); } catch { return json({ ok: false, error: "JSON non valido" }, 400); }
142
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
143
+ return json({ ok: false, error: "Corpo richiesta non valido" }, 400);
144
+ }
145
+
146
+ /* Seconda fase: le copie esistono già. Prima le verifichiamo TUTTE, poi aggiorniamo il repo,
147
+ * infine cancelliamo gli originali. */
148
+ if (Object.hasOwn(body, "applyRefs")) {
149
+ const entries = validatedMoveMap(body.applyRefs);
150
+ if (!entries) return json({ ok: false, error: "mappa spostamenti non valida" }, 400);
151
+ if (!entries.length) return json({ ok: true, moved: 0, pagesUpdated: [], failed: [] });
152
+
153
+ const missing = await missingCopies(env.MEDIA, entries);
154
+ if (missing.length) {
155
+ return json({
156
+ ok: false,
157
+ code: "move_copies_missing",
158
+ error: `Operazione annullata: ${missing.length} copie di destinazione non sono disponibili.`,
159
+ missing,
160
+ }, 409);
161
+ }
162
+
163
+ let rewritten;
164
+ try {
165
+ rewritten = await rewriteMediaReferences(
166
+ env,
167
+ { map: Object.fromEntries(entries.map((e) => [e.oldUrl, e.newUrl])) },
168
+ "admin: sposta immagini",
169
+ );
170
+ } catch (error) {
171
+ return rewriteError(error);
172
+ }
173
+
174
+ const conflict = await confirmRewrittenCommit(env, rewritten.verifiedCommit);
175
+ if (conflict) return conflict;
176
+ await recordPendingMedia(env.MEDIA, "move", entries, rewritten.verifiedCommit);
177
+ await finalizePendingMedia(env).catch(() => null);
178
+ return json({
179
+ ok: true,
180
+ moved: entries.length,
181
+ finalized: 0,
182
+ failed: [],
183
+ pagesUpdated: rewritten.labels,
184
+ warning: "Gli originali resteranno disponibili fino alla prossima pubblicazione.",
185
+ });
186
+ }
187
+
188
+ let dest = String(body.dest || "").trim();
189
+ if (dest && !dest.endsWith("/")) dest += "/";
190
+ if (!dest || !isPrefix(dest) || dest.startsWith("_trash/")) {
191
+ return json({ ok: false, error: "destinazione non valida" }, 400);
192
+ }
193
+
194
+ const keys = (Array.isArray(body.keys) ? body.keys : [])
195
+ .filter(isKey)
196
+ .filter((k) => !k.startsWith("_trash/"));
197
+ if (!keys.length) return json({ ok: true, moved: 0, urlMap: {}, pagesUpdated: [], failed: [] });
198
+
199
+ const prepared = await prepareMoves(env.MEDIA, keys, dest);
200
+
201
+ // Prima fase dei gruppi: il chiamante accumula le mappe e chiude con applyRefs.
202
+ if (body.skipRefs === true) {
203
+ return json({
204
+ ok: true,
205
+ moved: prepared.moved,
206
+ urlMap: prepared.urlMap,
207
+ pagesUpdated: [],
208
+ failed: prepared.failed,
209
+ });
210
+ }
211
+
212
+ // Compatibilità con la chiamata singola: stessa sequenza sicura, tutta in una richiesta.
213
+ const entries = prepared.prepared.map((e) => ({
214
+ ...e,
215
+ oldUrl: mediaUrl(e.oldKey),
216
+ newUrl: mediaUrl(e.newKey),
217
+ }));
218
+ if (!entries.length) {
219
+ return json({
220
+ ok: true,
221
+ moved: 0,
222
+ urlMap: prepared.urlMap,
223
+ pagesUpdated: [],
224
+ failed: prepared.failed,
225
+ });
226
+ }
227
+
228
+ let rewritten;
229
+ try {
230
+ rewritten = await rewriteMediaReferences(
231
+ env,
232
+ { map: prepared.urlMap },
233
+ "admin: sposta immagini",
234
+ );
235
+ } catch (error) {
236
+ await abortPrepared(env.MEDIA, entries);
237
+ return rewriteError(error);
238
+ }
239
+ const conflict = await confirmRewrittenCommit(env, rewritten.verifiedCommit);
240
+ if (conflict) return conflict;
241
+ await recordPendingMedia(env.MEDIA, "move", entries, rewritten.verifiedCommit);
242
+ await finalizePendingMedia(env).catch(() => null);
243
+ const failed = [...prepared.failed];
244
+ return json({
245
+ ok: true,
246
+ moved: entries.length,
247
+ finalized: 0,
248
+ urlMap: prepared.urlMap,
249
+ pagesUpdated: rewritten.labels,
250
+ failed,
251
+ warning: "Gli originali resteranno disponibili fino alla prossima pubblicazione.",
252
+ });
253
+ }
@@ -0,0 +1,17 @@
1
+ /* GET /admin/api/assets/preview?key=... — anteprima gated di un oggetto R2
2
+ * (usata per i thumbnail del cestino, che NON è pubblico). Dietro Access.
3
+ */
4
+ import { guessMime } from "../../_shared/assets.js";
5
+
6
+ export async function onRequestGet({ request, env }) {
7
+ if (!env.MEDIA) return new Response("no binding", { status: 500 });
8
+ const key = new URL(request.url).searchParams.get("key") || "";
9
+ if (!key) return new Response("Not found", { status: 404 });
10
+ const obj = await env.MEDIA.get(key);
11
+ if (!obj) return new Response("Not found", { status: 404 });
12
+ const headers = new Headers();
13
+ obj.writeHttpMetadata(headers);
14
+ if (!headers.get("Content-Type")) headers.set("Content-Type", guessMime(key));
15
+ headers.set("Cache-Control", "private, max-age=60");
16
+ return new Response(obj.body, { headers });
17
+ }
@@ -0,0 +1,13 @@
1
+ /* POST /admin/api/assets/trash/delete — eliminazione DEFINITIVA dal cestino
2
+ * (senza attendere i 30 giorni). Body: { trashKeys: [...] }. Dietro Access.
3
+ */
4
+ import { json } from "../../../_shared/assets.js";
5
+
6
+ export async function onRequestPost({ request, env }) {
7
+ if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
8
+ let body;
9
+ try { body = await request.json(); } catch { return json({ ok: false, error: "JSON non valido" }, 400); }
10
+ const keys = (body.trashKeys || []).filter((k) => k && k.startsWith("_trash/"));
11
+ if (keys.length) await env.MEDIA.delete(keys);
12
+ return json({ ok: true, removed: keys.length });
13
+ }
@@ -0,0 +1,16 @@
1
+ /* POST /admin/api/assets/trash/empty — svuota il cestino (elimina tutto subito,
2
+ * senza attendere i 30 giorni). Dietro Access.
3
+ */
4
+ import { json, TRASH, listKeysUnder } from "../../../_shared/assets.js";
5
+
6
+ export async function onRequestPost({ env }) {
7
+ if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
8
+ const keys = await listKeysUnder(env.MEDIA, TRASH);
9
+ let removed = 0;
10
+ for (let i = 0; i < keys.length; i += 1000) {
11
+ const batch = keys.slice(i, i + 1000);
12
+ await env.MEDIA.delete(batch);
13
+ removed += batch.length;
14
+ }
15
+ return json({ ok: true, removed });
16
+ }
@@ -0,0 +1,43 @@
1
+ /* POST /admin/api/assets/trash/restore — ripristina file dal cestino alla
2
+ * posizione originale. Se la posizione è occupata, ripristina con suffisso.
3
+ * Body: { trashKeys: [...] }. Dietro Access.
4
+ * NB: ripristina il FILE; il riferimento nelle pagine (rimosso all'eliminazione)
5
+ * va rimesso a mano dall'editor della pagina.
6
+ */
7
+ import { json } from "../../../_shared/assets.js";
8
+
9
+ export async function onRequestPost({ request, env }) {
10
+ if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
11
+ let body;
12
+ try { body = await request.json(); } catch { return json({ ok: false, error: "JSON non valido" }, 400); }
13
+ const keys = (body.trashKeys || []).filter((k) => k && k.startsWith("_trash/"));
14
+
15
+ const restored = [], conflicts = [];
16
+ for (const tk of keys) {
17
+ const obj = await env.MEDIA.get(tk);
18
+ if (!obj) continue;
19
+ const orig = (obj.customMetadata && obj.customMetadata.originalKey) || null;
20
+ if (!orig) continue;
21
+ let target = orig;
22
+ if (await env.MEDIA.head(orig)) {
23
+ const dot = orig.lastIndexOf(".");
24
+ const base = dot > 0 ? orig.slice(0, dot) : orig;
25
+ const ext = dot > 0 ? orig.slice(dot) : "";
26
+ target = `${base}-ripristinato${ext}`;
27
+ conflicts.push({ originalKey: orig, restoredAs: target });
28
+ }
29
+ const {
30
+ originalKey: _originalKey,
31
+ originalName: _originalName,
32
+ deletedAt: _deletedAt,
33
+ ...metadata
34
+ } = obj.customMetadata || {};
35
+ await env.MEDIA.put(target, obj.body, {
36
+ httpMetadata: obj.httpMetadata || {},
37
+ customMetadata: Object.keys(metadata).length ? metadata : undefined,
38
+ });
39
+ await env.MEDIA.delete(tk);
40
+ restored.push(target);
41
+ }
42
+ return json({ ok: true, restored, conflicts });
43
+ }
@@ -0,0 +1,28 @@
1
+ /* GET /admin/api/assets/trash — elenca i file nel cestino (con data eliminazione,
2
+ * chiave originale, anteprima gated). Dietro Access.
3
+ */
4
+ import { json, TRASH } from "../../_shared/assets.js";
5
+
6
+ export async function onRequestGet({ env }) {
7
+ if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
8
+ const items = [];
9
+ let cursor;
10
+ do {
11
+ const listed = await env.MEDIA.list({ prefix: TRASH, cursor, limit: 1000, include: ["customMetadata", "httpMetadata"] });
12
+ for (const o of (listed.objects || [])) {
13
+ const cm = o.customMetadata || {};
14
+ items.push({
15
+ trashKey: o.key,
16
+ originalKey: cm.originalKey || "",
17
+ name: cm.originalName || o.key.slice(TRASH.length),
18
+ deletedAt: cm.deletedAt || "",
19
+ size: o.size,
20
+ type: (o.httpMetadata && o.httpMetadata.contentType) || "",
21
+ previewUrl: "/admin/api/assets/preview?key=" + encodeURIComponent(o.key),
22
+ });
23
+ }
24
+ cursor = listed.truncated ? listed.cursor : undefined;
25
+ } while (cursor);
26
+ items.sort((a, b) => (a.deletedAt < b.deletedAt ? 1 : -1));
27
+ return json({ ok: true, items });
28
+ }
@@ -0,0 +1,54 @@
1
+ /* POST /admin/api/assets/upload — carica un file nel bucket.
2
+ * Header: X-Prefix (cartella di destinazione, es. "food/"), X-Filename, Content-Type.
3
+ * Corpo: bytes grezzi del file. Dietro Access.
4
+ */
5
+ import { isPrefix, json, guessMime, uniqueKey } from "../../_shared/assets.js";
6
+ import { extractCapturedAt } from "../../_shared/exif.js";
7
+ import { sizeFromBuffer } from "../../_shared/imagesize.js";
8
+
9
+ export async function onRequestPost({ request, env }) {
10
+ if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
11
+ const prefix = (request.headers.get("X-Prefix") || "").trim();
12
+ if (!isPrefix(prefix)) return json({ ok: false, error: `prefisso non valido: ${prefix}` }, 400);
13
+
14
+ const filename = request.headers.get("X-Filename") || "file";
15
+ let ctype = request.headers.get("Content-Type") || "";
16
+
17
+ const body = await request.arrayBuffer();
18
+ if (!body || body.byteLength === 0) return json({ ok: false, error: "file vuoto" }, 400);
19
+
20
+ const key = await uniqueKey(env.MEDIA, prefix, filename);
21
+ if (!ctype || ctype === "application/octet-stream") ctype = guessMime(key);
22
+
23
+ // data di scatto (EXIF) per l'ordinamento "Data scatto"
24
+ const custom = {};
25
+ if (ctype === "image/jpeg") {
26
+ const cap = extractCapturedAt(body);
27
+ if (cap) custom.capturedAt = cap;
28
+ }
29
+
30
+ // Dimensioni: si calcolano QUI, una volta sola, e restano nei metadati dell'oggetto.
31
+ // Servono alle pagine gallery, che le scrivono nel front matter per riservare l'ingombro
32
+ // esatto di ogni cella. Rileggerle a ogni elenco costerebbe una lettura per file — che è
33
+ // esattamente quello che faceva il vecchio endpoint pubblico, ed è il motivo per cui aveva
34
+ // bisogno di una cache all'edge. `move.js` ricopia customMetadata, quindi sopravvivono a
35
+ // uno spostamento; il ripiego per i file caricati PRIMA di questa modifica sta in assets.js.
36
+ const wh = sizeFromBuffer(body);
37
+ if (wh) { custom.w = String(wh[0]); custom.h = String(wh[1]); }
38
+
39
+ await env.MEDIA.put(key, body, {
40
+ httpMetadata: { contentType: ctype },
41
+ customMetadata: Object.keys(custom).length ? custom : undefined,
42
+ });
43
+ return json({
44
+ ok: true,
45
+ key,
46
+ name: key.slice(prefix.length),
47
+ size: body.byteLength,
48
+ type: ctype,
49
+ capturedAt: custom.capturedAt || null,
50
+ w: wh ? wh[0] : null,
51
+ h: wh ? wh[1] : null,
52
+ url: "/media/" + key.split("/").map(encodeURIComponent).join("/"),
53
+ });
54
+ }
@@ -0,0 +1,41 @@
1
+ /* POST /admin/api/assets/usage — avviso "in uso in queste sorgenti".
2
+ * Body: { keys?: [...], prefixes?: [...] }. Legge dallo stesso commit pagine,
3
+ * singleton e impostazioni, poi cerca gli URL /media/<key>.
4
+ */
5
+ import { json, isKey, isPrefix, listKeysUnder } from "../../_shared/assets.js";
6
+ import { mediaUrl } from "../../_shared/refs.js";
7
+ import {
8
+ readMediaSources, summarizeMediaUsage, planMediaRewrite,
9
+ } from "../../_shared/media-content.js";
10
+
11
+ export async function onRequestPost({ request, env }) {
12
+ if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
13
+ if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) return json({ ok: false, error: "backend GitHub non configurato" }, 500);
14
+ let body;
15
+ try { body = await request.json(); } catch { return json({ ok: false, error: "JSON non valido" }, 400); }
16
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
17
+ return json({ ok: false, error: "Corpo richiesta non valido" }, 400);
18
+ }
19
+
20
+ try {
21
+ const keys = new Set((Array.isArray(body.keys) ? body.keys : []).filter(isKey));
22
+ for (const pre of (Array.isArray(body.prefixes) ? body.prefixes : []).filter((p) => p && isPrefix(p))) {
23
+ for (const k of await listKeysUnder(env.MEDIA, pre)) if (!k.endsWith("/.keep")) keys.add(k);
24
+ }
25
+ const keyList = [...keys].filter((k) => !k.startsWith("_trash/"));
26
+ if (!keyList.length) {
27
+ return json({ ok: true, usage: {}, pages: [], count: 0, selectedCount: 0, blockers: [] });
28
+ }
29
+
30
+ const { sources } = await readMediaSources(env);
31
+ const summary = summarizeMediaUsage(sources, keyList, mediaUrl);
32
+ const plan = planMediaRewrite(sources, { removeUrls: keyList.map(mediaUrl) });
33
+ return json({ ok: true, ...summary, blockers: plan.blockers });
34
+ } catch (error) {
35
+ return json({
36
+ ok: false,
37
+ code: error.code || "github_read_failed",
38
+ error: `Non riesco a verificare i riferimenti media: ${error.message || String(error)}`,
39
+ }, 502);
40
+ }
41
+ }
@@ -0,0 +1,94 @@
1
+ /* GET /admin/api/assets?prefix=food/ — elenca cartelle e file sotto un prefisso.
2
+ * Dietro Cloudflare Access (è sotto /admin/*).
3
+ */
4
+ import { isPrefix, json, listKeysUnder } from "../_shared/assets.js";
5
+ import { sizeFromBucket } from "../_shared/imagesize.js";
6
+ import { pendingSourceKeys } from "../_shared/pending-media.js";
7
+
8
+ const IMG = /\.(webp|jpe?g|png|avif|gif)$/i;
9
+
10
+ export async function onRequestGet({ request, env }) {
11
+ if (!env.MEDIA) return json({ ok: false, error: "Binding MEDIA non configurato" }, 500);
12
+ const url = new URL(request.url);
13
+ let prefix = (url.searchParams.get("prefix") || "").trim();
14
+ if (!isPrefix(prefix)) return json({ ok: false, error: `prefisso non valido: ${prefix}` }, 400);
15
+ const pending = await pendingSourceKeys(env.MEDIA);
16
+
17
+ /* `recursive=1` guarda TUTTO quello che c'è sotto, a qualunque profondità: serve a chi deve
18
+ * spostare o cancellare una cartella intera, che altrimenti si porterebbe dietro solo il primo
19
+ * livello e lascerebbe orfano il resto. Restituisce le stesse due liste — cartelle (con la
20
+ * loro etichetta) e file — così chi chiama non impara una forma nuova. */
21
+ if (url.searchParams.get("recursive") === "1") {
22
+ const all = await listKeysUnder(env.MEDIA, prefix);
23
+ const subPrefixes = new Set();
24
+ const files = [];
25
+ for (const k of all) {
26
+ if (k.startsWith("_trash/") || k.startsWith("_pending-media/") || pending.has(k)) continue;
27
+ const rel = k.slice(prefix.length);
28
+ const cut = rel.lastIndexOf("/");
29
+ if (cut >= 0) {
30
+ // ogni livello intermedio conta come cartella: a/b/c.jpg dà "a/" e "a/b/"
31
+ let acc = prefix;
32
+ for (const seg of rel.slice(0, cut).split("/")) { acc += seg + "/"; subPrefixes.add(acc); }
33
+ }
34
+ if (!k.endsWith("/.keep")) files.push({ key: k, name: rel });
35
+ }
36
+ const folders = await Promise.all([...subPrefixes].map(async (p) => {
37
+ const keep = await env.MEDIA.head(p + ".keep");
38
+ return { prefix: p, name: p.slice(prefix.length).replace(/\/$/, ""), label: keep?.customMetadata?.label || "" };
39
+ }));
40
+ return json({ ok: true, prefix, recursive: true, folders, files });
41
+ }
42
+
43
+ const listed = await env.MEDIA.list({ prefix, delimiter: "/", include: ["httpMetadata", "customMetadata"] });
44
+
45
+ // `label` è il nome per esteso scelto da chi ha creato la cartella, e sta nei metadati del
46
+ // suo `.keep` (vedi assets/folder.js). Chi non ce l'ha mostra lo slug, come sempre.
47
+ const folders = await Promise.all(
48
+ (listed.delimitedPrefixes || [])
49
+ .filter((p) => p !== "_trash/" && p !== "_pending-media/")
50
+ .map(async (p) => {
51
+ const keep = await env.MEDIA.head(p + ".keep");
52
+ return {
53
+ prefix: p,
54
+ name: p.slice(prefix.length).replace(/\/$/, ""),
55
+ label: keep?.customMetadata?.label || "",
56
+ };
57
+ })
58
+ );
59
+
60
+ // Le DIMENSIONI arrivano dai metadati, dove le scrive upload.js al caricamento. Per i file
61
+ // messi nel bucket prima che quel codice esistesse i metadati non ci sono: si leggono al volo
62
+ // dall'intestazione (poche decine di byte a file) invece di lasciarle vuote — senza, le foto
63
+ // già caricate non sarebbero selezionabili in una gallery, che è il caso di tutte quelle di
64
+ // oggi. È un ripiego, non la strada normale: una volta ricaricate, il ramo non scatta più.
65
+ const raw = (listed.objects || [])
66
+ .filter((o) => o.key !== prefix && !o.key.endsWith("/.keep") && !o.key.endsWith("/") &&
67
+ !o.key.startsWith("_pending-media/") && !pending.has(o.key));
68
+
69
+ const files = await Promise.all(raw.map(async (o) => {
70
+ const cm = o.customMetadata || {};
71
+ let w = cm.w ? Number(cm.w) : null;
72
+ let h = cm.h ? Number(cm.h) : null;
73
+ if (!(w > 0 && h > 0) && IMG.test(o.key)) {
74
+ const wh = await sizeFromBucket(env.MEDIA, o.key);
75
+ if (wh) { w = wh[0]; h = wh[1]; }
76
+ }
77
+ return {
78
+ key: o.key,
79
+ name: o.key.slice(prefix.length),
80
+ size: o.size,
81
+ url: "/media/" + o.key.split("/").map(encodeURIComponent).join("/"),
82
+ uploaded: o.uploaded,
83
+ capturedAt: cm.capturedAt || null,
84
+ type: (o.httpMetadata && o.httpMetadata.contentType) || "",
85
+ w: w > 0 ? w : null,
86
+ h: h > 0 ? h : null,
87
+ };
88
+ }));
89
+
90
+ /* Anche l'etichetta della cartella che si sta guardando: senza, il percorso in alto
91
+ * mostrerebbe il suo slug — cioè proprio la cosa che l'etichetta serve a nascondere. */
92
+ const own = prefix ? await env.MEDIA.head(prefix + ".keep") : null;
93
+ return json({ ok: true, prefix, label: own?.customMetadata?.label || "", folders, files });
94
+ }