@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,67 @@
1
+ /* Stato dei contenuti della home. Estratto da HomeEditor perché ora serve in due posti: il form
2
+ * (terza colonna) e il bottone «Salva e pubblica» (barra in alto), che non è più dentro l'editor.
3
+ *
4
+ * Resta un SINGLETON, con endpoint e salvataggio suoi: non passa da /admin/api/pages, il cui
5
+ * contratto è "ecco l'elenco completo, cancella il resto".
6
+ *
7
+ * Il caricamento è PIGRO: parte quando si entra nella home, non all'avvio del pannello. Chi apre
8
+ * l'admin per riordinare le pagine non ha motivo di pagare una chiamata in più — e senza
9
+ * GITHUB_TOKEN, in anteprima locale, quella chiamata è un 500.
10
+ */
11
+ import { useCallback, useEffect, useState } from "react";
12
+ import { ApiError } from "../../index.js";
13
+ async function call(url, init) {
14
+ const res = await fetch(url, { cache: "no-store", ...init });
15
+ const body = await res.json().catch(() => null);
16
+ if (!res.ok || body?.ok === false) {
17
+ throw new ApiError(body?.error ?? `HTTP ${res.status}`, body?.code ?? (res.status === 401 ? "unauthenticated" : "error"), res.status);
18
+ }
19
+ return body;
20
+ }
21
+ export function useHome(active) {
22
+ const [data, setData] = useState(null);
23
+ const [error, setError] = useState(null);
24
+ const [dirty, setDirty] = useState(false);
25
+ const [saving, setSaving] = useState(false);
26
+ const [notice, setNotice] = useState(null);
27
+ const load = useCallback(async () => {
28
+ try {
29
+ setData((await call("/admin/api/singleton?name=home")).data);
30
+ setError(null);
31
+ setDirty(false);
32
+ }
33
+ catch (e) {
34
+ setError(e.message);
35
+ }
36
+ }, []);
37
+ // Una volta caricata resta in memoria: tornare sulla home dopo un giro nelle pagine non deve
38
+ // buttare via le modifiche non salvate.
39
+ useEffect(() => { if (active && data === null && error === null)
40
+ void load(); }, [active, data, error, load]);
41
+ const save = useCallback(async () => {
42
+ if (!data)
43
+ return;
44
+ setSaving(true);
45
+ setNotice(null);
46
+ try {
47
+ await call("/admin/api/singleton", {
48
+ method: "POST",
49
+ headers: { "Content-Type": "application/json" },
50
+ body: JSON.stringify({ name: "home", data }),
51
+ });
52
+ setNotice("Pubblicato — build in corso");
53
+ setDirty(false);
54
+ }
55
+ catch (e) {
56
+ const err = e;
57
+ setNotice(err.code === "unauthenticated"
58
+ ? "Sessione scaduta: ricarica la pagina per rientrare."
59
+ : err.message);
60
+ }
61
+ finally {
62
+ setSaving(false);
63
+ }
64
+ }, [data]);
65
+ const change = useCallback((next) => { setData(next); setDirty(true); }, []);
66
+ return { data, error, dirty, saving, notice, load, change, save, setNotice };
67
+ }
@@ -0,0 +1,82 @@
1
+ export interface StorageR2 {
2
+ ok: boolean;
3
+ error?: string;
4
+ objects: number;
5
+ bytes: number;
6
+ trashObjects: number;
7
+ trashBytes: number;
8
+ partial: boolean;
9
+ cached?: boolean;
10
+ }
11
+ export interface StorageB2 {
12
+ ok: boolean;
13
+ error?: string;
14
+ bytes: number;
15
+ sourceBytes: number;
16
+ files: number;
17
+ packages: number;
18
+ heroes: number;
19
+ pendingBytes: number;
20
+ configured: boolean;
21
+ }
22
+ export interface StorageData {
23
+ r2: StorageR2;
24
+ b2: StorageB2;
25
+ }
26
+ export type AnalyticsRange = "24h" | "7d" | "30d";
27
+ export interface AnalyticsMetric {
28
+ value: number;
29
+ prev: number | null;
30
+ }
31
+ export interface AnalyticsPoint {
32
+ date: string;
33
+ pageviews: number;
34
+ sessions: number;
35
+ }
36
+ export interface AnalyticsEntry {
37
+ name: string;
38
+ count: number;
39
+ }
40
+ export interface AnalyticsSetting {
41
+ name: string;
42
+ secret: boolean;
43
+ }
44
+ export interface AnalyticsProviderInfo {
45
+ id: string;
46
+ label: string;
47
+ missing: AnalyticsSetting[];
48
+ }
49
+ export interface AnalyticsData {
50
+ configured: boolean;
51
+ providers?: AnalyticsProviderInfo[];
52
+ provider?: string;
53
+ providerLabel?: string;
54
+ range?: AnalyticsRange;
55
+ unit?: "hour" | "day";
56
+ startAt?: number;
57
+ endAt?: number;
58
+ totals?: {
59
+ pageviews: AnalyticsMetric;
60
+ visitors: AnalyticsMetric;
61
+ visits: AnalyticsMetric;
62
+ bounces: AnalyticsMetric;
63
+ totaltime: AnalyticsMetric;
64
+ };
65
+ series?: AnalyticsPoint[];
66
+ pages?: AnalyticsEntry[];
67
+ referrers?: AnalyticsEntry[];
68
+ }
69
+ export declare function useStorage(active: boolean): {
70
+ data: StorageData | null;
71
+ error: string | null;
72
+ busy: boolean;
73
+ load: (fresh?: boolean) => Promise<void>;
74
+ };
75
+ export declare function useAnalytics(active: boolean): {
76
+ data: AnalyticsData | null;
77
+ error: string | null;
78
+ busy: boolean;
79
+ range: AnalyticsRange;
80
+ setRange: import("react").Dispatch<import("react").SetStateAction<AnalyticsRange>>;
81
+ reload: () => Promise<void>;
82
+ };
@@ -0,0 +1,68 @@
1
+ /* I due caricamenti della Panoramica.
2
+ *
3
+ * Sono due e non uno perché falliscono in modi diversi: lo spazio è roba nostra (R2 e D1) e o
4
+ * c'è o c'è un guasto; le visite passano da Umami, che può essere non collegato, lento o giù —
5
+ * e in nessuno di quei casi il resto della schermata deve restare fermo ad aspettarlo.
6
+ *
7
+ * Quello che NON sta qui: transfer e richieste. Sono già caricati da `useDeliveries` e
8
+ * `useRequests` in App.tsx — subito, perché servono ai badge della prima colonna — e la
9
+ * Panoramica riceve quelle stesse liste. Rifare le query qui vorrebbe dire due sorgenti per lo
10
+ * stesso numero, cioè una delle due sempre vecchia.
11
+ */
12
+ import { useCallback, useEffect, useState } from "react";
13
+ export function useStorage(active) {
14
+ const [data, setData] = useState(null);
15
+ const [error, setError] = useState(null);
16
+ const [busy, setBusy] = useState(false);
17
+ const load = useCallback(async (fresh = false) => {
18
+ setBusy(true);
19
+ try {
20
+ setData(await getJson(`/admin/api/storage${fresh ? "?fresh=1" : ""}`));
21
+ setError(null);
22
+ }
23
+ catch (e) {
24
+ setError(message(e));
25
+ }
26
+ finally {
27
+ setBusy(false);
28
+ }
29
+ }, []);
30
+ /* Pigro come la home e i media, non immediato come le richieste: qui non c'è nessun badge
31
+ che debba esistere prima di entrare, e contare un bucket intero non si fa per niente. */
32
+ useEffect(() => { if (active && !data)
33
+ void load(); }, [active, data, load]);
34
+ return { data, error, busy, load };
35
+ }
36
+ export function useAnalytics(active) {
37
+ const [range, setRange] = useState("7d");
38
+ const [data, setData] = useState(null);
39
+ const [error, setError] = useState(null);
40
+ const [busy, setBusy] = useState(false);
41
+ const load = useCallback(async (want, fresh = false) => {
42
+ setBusy(true);
43
+ try {
44
+ setData(await getJson(`/admin/api/analytics?range=${want}${fresh ? "&fresh=1" : ""}`));
45
+ setError(null);
46
+ }
47
+ catch (e) {
48
+ /* `data` resta: cambiando periodo con Umami irraggiungibile, il grafico di prima è
49
+ un'informazione vecchia ma vera, e l'errore lo dice sopra. Svuotarlo lascerebbe un
50
+ riquadro bianco al posto di qualcosa che si stava guardando. */
51
+ setError(message(e));
52
+ }
53
+ finally {
54
+ setBusy(false);
55
+ }
56
+ }, []);
57
+ useEffect(() => { if (active)
58
+ void load(range); }, [active, range, load]);
59
+ return { data, error, busy, range, setRange, reload: () => load(range, true) };
60
+ }
61
+ async function getJson(url) {
62
+ const res = await fetch(url, { cache: "no-store" });
63
+ const body = await res.json().catch(() => ({}));
64
+ if (!res.ok || body.ok === false)
65
+ throw new Error(body.error || `HTTP ${res.status}`);
66
+ return body;
67
+ }
68
+ function message(e) { return e instanceof Error ? e.message : String(e); }
@@ -0,0 +1,24 @@
1
+ export type PageModel = Record<string, unknown> & {
2
+ slug: string;
3
+ _orig?: string | null;
4
+ /** Versione del file da cui questa pagina è stata letta. Torna al server nel salvataggio: se
5
+ * nel frattempo il file è cambiato, il server rifiuta invece di sovrascrivere. */
6
+ _sha?: string;
7
+ };
8
+ export declare function usePages(): {
9
+ pages: PageModel[] | null;
10
+ error: string | null;
11
+ dirty: boolean;
12
+ saving: boolean;
13
+ notice: string | null;
14
+ load: () => Promise<void>;
15
+ update: (next: PageModel[]) => void;
16
+ refresh: (next: PageModel) => void;
17
+ isPristine: (slug: string) => boolean;
18
+ save: () => Promise<void>;
19
+ setNotice: import("react").Dispatch<import("react").SetStateAction<string | null>>;
20
+ };
21
+ export declare function legacyDraft(): number | null;
22
+ /** I titoli che la bozza contiene, per poterla guardare prima di buttarla. */
23
+ export declare function legacyDraftTitles(): string[];
24
+ export declare function discardLegacyDraft(): void;
@@ -0,0 +1,156 @@
1
+ /* Stato dell'elenco pagine: caricamento dal repo, riordino, salvataggio.
2
+ *
3
+ * Due scelte da non perdere:
4
+ * 1. la sorgente autorevole è il REPO, letto da `GET /admin/api/pages`. Prima era `/index.json`,
5
+ * cioè il sito pubblicato: indietro di una build, quindi capace di far riscrivere una pagina
6
+ * con valori vecchi di minuti. Vedi ADMIN.md §3;
7
+ * 2. non si scrivono bozze in localStorage. Una bozza vive in UN browser, può essere vecchia di
8
+ * giorni, e avendo la precedenza bastava rinominare una pagina per rimandare al server uno
9
+ * stato stantio. Se ne trova una del vecchio admin la si mostra in sola lettura.
10
+ */
11
+ import { useCallback, useEffect, useRef, useState } from "react";
12
+ import { api, ApiError, storageKey } from "../../index.js";
13
+ import { site } from "../site.js";
14
+ export function usePages() {
15
+ const [pages, setPages] = useState(null);
16
+ /* Le pagine COM'ERANO quando sono state caricate, per riferimento. Serve a distinguere quelle
17
+ * che l'utente ha toccato da quelle che si porta dietro solo perché stanno nell'elenco: le
18
+ * seconde vengono spedite senza contenuto, e il server tiene quello che c'è nel file
19
+ * (`keepIfAbsent`). Senza, un elenco vecchio di qualche minuto riscrive con valori vecchi
20
+ * pagine che nessuno ha aperto. */
21
+ const base = useRef(new Map());
22
+ const [error, setError] = useState(null);
23
+ const [dirty, setDirty] = useState(false);
24
+ const [saving, setSaving] = useState(false);
25
+ const [notice, setNotice] = useState(null);
26
+ const load = useCallback(async () => {
27
+ try {
28
+ const d = await api.pages();
29
+ /* Il modello lo costruisce il server leggendo il file con lo schema: qui non si
30
+ * interpreta più niente. Prima arrivava il front matter grezzo minuscolizzato da Hugo e
31
+ * andava riconciliato con `modelFromRaw` — un passaggio in meno, e una grafia in meno da
32
+ * far combaciare. L'ordine per peso lo dà già il server. */
33
+ const list = (d.pages ?? []).map((p) => ({
34
+ ...p.page, slug: p.slug, url: p.url, _orig: p.slug, _sha: p.sha,
35
+ }));
36
+ base.current = new Map(list.map((p) => [p.slug, p]));
37
+ setPages(list);
38
+ setDirty(false);
39
+ setError(null);
40
+ }
41
+ catch (e) {
42
+ setError(e instanceof ApiError ? e.message : String(e));
43
+ }
44
+ }, []);
45
+ useEffect(() => { void load(); }, [load]);
46
+ const update = useCallback((next) => {
47
+ next.forEach((p, i) => { p.weight = site().positionWeight(p, i, next); });
48
+ setPages(next);
49
+ setDirty(true);
50
+ }, []);
51
+ /* Sostituisce una pagina con la versione appena letta dal repo, SENZA sporcare il pannello:
52
+ * non è una modifica dell'utente, è la stessa pagina vista da più vicino. Aggiorna anche il
53
+ * riferimento, così resta "non toccata" finché non la tocca davvero qualcuno. */
54
+ const refresh = useCallback((next) => {
55
+ base.current.set(next.slug, next);
56
+ setPages((prev) => prev?.map((p) => (p.slug === next.slug ? next : p)) ?? null);
57
+ }, []);
58
+ /** Vero se la pagina è ancora quella caricata: nessuno l'ha modificata in questa sessione. */
59
+ const isPristine = useCallback((slug) => {
60
+ const list = pages;
61
+ const now = list?.find((p) => p.slug === slug);
62
+ return !!now && base.current.get(slug) === now;
63
+ }, [pages]);
64
+ const save = useCallback(async () => {
65
+ if (!pages)
66
+ return;
67
+ setSaving(true);
68
+ setNotice(null);
69
+ try {
70
+ /* Le pagine NON toccate si spediscono senza contenuto: solo identità e posizione. Il
71
+ * server, per ogni campo assente, tiene quello che c'è nel file — è esattamente ciò per
72
+ * cui esiste `keepIfAbsent`. `inMenu` va mandato comunque: è lui a decidere in quale
73
+ * gruppo cade il peso calcolato dalla posizione.
74
+ *
75
+ * `_sha` viaggia in ENTRAMBE le forme, ed è la differenza con prima: dice al server da
76
+ * quale versione del file siamo partiti. Se nel frattempo quel file è cambiato — l'altra
77
+ * scheda aperta sulla stessa pagina, il caso che ADMIN.md §3 dichiarava scoperto — il
78
+ * server rifiuta invece di sovrascrivere. */
79
+ const payload = pages.map((p) => base.current.get(p.slug) === p
80
+ ? { slug: p.slug, _orig: p._orig, _sha: p._sha, inMenu: p.inMenu }
81
+ : p);
82
+ const r = await api.savePages(payload);
83
+ setNotice(`Salvato — ${r.files} file`);
84
+ setDirty(false);
85
+ /* Il file adesso contiene ciò che ha questa pagina, quindi la sua versione è un'altra: il
86
+ * server rimanda gli `sha` nuovi. Senza, il salvataggio successivo partirebbe da una
87
+ * versione che non esiste più e verrebbe rifiutato — un pannello che si blocca da solo
88
+ * dopo il primo salvataggio. */
89
+ pages.forEach((p) => {
90
+ p._orig = p.slug;
91
+ const s = r.shas?.[p.slug];
92
+ if (s)
93
+ p._sha = s;
94
+ });
95
+ // Da qui in poi il file contiene ciò che hanno queste pagine: tornano tutte "non toccate".
96
+ base.current = new Map(pages.map((p) => [p.slug, p]));
97
+ }
98
+ catch (e) {
99
+ const err = e;
100
+ setNotice(err.code === "conflict" ? "Il sito è stato modificato altrove. Ricarica e riprova."
101
+ : err.code === "unauthenticated" ? "Sessione scaduta: ricarica la pagina per rientrare."
102
+ // `unreadable` e `invalid` arrivano già con il nome della pagina e il campo da
103
+ // sistemare: il prefisso generico li allungherebbe senza aggiungere niente.
104
+ : err.code === "unreadable" || err.code === "invalid" || err.code === "stale" ? err.message
105
+ : `Salvataggio non riuscito: ${err.message}`);
106
+ }
107
+ finally {
108
+ setSaving(false);
109
+ }
110
+ }, [pages]);
111
+ return { pages, error, dirty, saving, notice, load, update, refresh, isPristine, save, setNotice };
112
+ }
113
+ /* Bozza lasciata dal VECCHIO pannello nel localStorage di questo browser: si mostra, non si usa.
114
+ *
115
+ * Perché si mostra invece di ignorarla in silenzio: era l'unico posto in cui poteva esserci una
116
+ * modifica mai pubblicata, e buttarla senza dirlo sarebbe stato perderla. Perché non si usa: una
117
+ * bozza vive in UN browser solo, può essere vecchia di giorni e non conoscere campi aggiunti nel
118
+ * frattempo — e avendo la precedenza bastava rinominare una pagina per rimandare al server uno
119
+ * stato stantio. È già successo.
120
+ *
121
+ * Nessuno scrive più queste chiavi: possono solo essere resti del pannello precedente. */
122
+ const LEGACY_KEYS = ["pages_draft", "pages_orphan"].map(storageKey);
123
+ export function legacyDraft() {
124
+ try {
125
+ const raw = LEGACY_KEYS.map((k) => localStorage.getItem(k)).find(Boolean);
126
+ if (!raw)
127
+ return null;
128
+ const d = JSON.parse(raw);
129
+ return Array.isArray(d) ? d.length : Array.isArray(d?.pages) ? d.pages.length : null;
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ }
135
+ /** I titoli che la bozza contiene, per poterla guardare prima di buttarla. */
136
+ export function legacyDraftTitles() {
137
+ try {
138
+ const raw = LEGACY_KEYS.map((k) => localStorage.getItem(k)).find(Boolean);
139
+ if (!raw)
140
+ return [];
141
+ const d = JSON.parse(raw);
142
+ const list = Array.isArray(d) ? d : Array.isArray(d?.pages) ? d.pages : [];
143
+ return list.map((p) => String(p?.title ?? p?.slug ?? "?"));
144
+ }
145
+ catch {
146
+ return [];
147
+ }
148
+ }
149
+ /* Un avviso su cui non si può agire diventa arredamento, e chi lo vede tutti i giorni smette di
150
+ * leggere anche i successivi. Da qui si butta. */
151
+ export function discardLegacyDraft() {
152
+ try {
153
+ LEGACY_KEYS.forEach((k) => localStorage.removeItem(k));
154
+ }
155
+ catch { /* niente da fare */ }
156
+ }
@@ -0,0 +1,18 @@
1
+ export interface PublishState {
2
+ /** Quanti commit ci sono sulla bozza che non sono ancora online. */
3
+ ahead: number;
4
+ /** I loro messaggi, dal più recente: rispondono a «cosa sto mandando online?». */
5
+ commits: {
6
+ message: string;
7
+ date: string | null;
8
+ }[];
9
+ /** Falso se il branch di bozza non esiste ancora (passo manuale di configurazione). */
10
+ configured: boolean;
11
+ publishing: boolean;
12
+ notice: string | null;
13
+ error: string | null;
14
+ reload(): Promise<void>;
15
+ publish(): Promise<void>;
16
+ clearNotice(): void;
17
+ }
18
+ export declare function usePublish(): PublishState;
@@ -0,0 +1,63 @@
1
+ /* Quanto c'è di salvato ma non ancora online, e il gesto per mandarcelo.
2
+ *
3
+ * Salvare e pubblicare erano la stessa cosa; ora sono due, e questo stato tiene la seconda. Il
4
+ * conteggio delle modifiche in attesa è ciò che rende il pulsante leggibile: «Pubblica» da solo
5
+ * non dice se c'è qualcosa da pubblicare, e un pulsante che si può premere a vuoto insegna a
6
+ * premerlo a caso.
7
+ */
8
+ import { useCallback, useEffect, useState } from "react";
9
+ import { api, ApiError } from "../../index.js";
10
+ export function usePublish() {
11
+ const [ahead, setAhead] = useState(0);
12
+ const [commits, setCommits] = useState([]);
13
+ const [configured, setConfigured] = useState(true);
14
+ const [publishing, setPublishing] = useState(false);
15
+ const [notice, setNotice] = useState(null);
16
+ const [error, setError] = useState(null);
17
+ const reload = useCallback(async () => {
18
+ try {
19
+ const d = await api.publishState();
20
+ setAhead(d.ahead ?? 0);
21
+ setCommits(d.commits ?? []);
22
+ setConfigured(d.configured !== false);
23
+ setError(d.configured === false ? d.error ?? null : null);
24
+ }
25
+ catch (e) {
26
+ // Non si azzera il conteggio su errore: mostrare «niente da pubblicare» perché una
27
+ // richiesta è fallita è la bugia peggiore che questo pannello possa dire.
28
+ setError(e instanceof ApiError ? e.message : String(e));
29
+ }
30
+ }, []);
31
+ /* Carica SUBITO, non entrando in una sezione: il numero di modifiche in attesa è il motivo
32
+ * per cui uno guarda quel pulsante, e un conteggio che compare solo dopo esserci passati sopra
33
+ * non serve a niente. È la stessa ragione dei badge di Richieste e Consegne. */
34
+ useEffect(() => { void reload(); }, [reload]);
35
+ const publish = useCallback(async () => {
36
+ setPublishing(true);
37
+ setNotice(null);
38
+ try {
39
+ const r = await api.publish();
40
+ if (r.published === false) {
41
+ setNotice(r.message ?? "Non c'era niente di nuovo da pubblicare.");
42
+ }
43
+ else {
44
+ /* L'attesa esiste ancora, ed è QUI: è l'unico punto del pannello in cui una cosa
45
+ * richiede minuti, ed è giusto che sia il momento in cui si manda online. Dirlo evita
46
+ * il ricaricamento nervoso che era il modo in cui si rompevano le cose. */
47
+ setNotice("Pubblicato. Il sito si aggiorna fra un paio di minuti.");
48
+ }
49
+ await reload();
50
+ }
51
+ catch (e) {
52
+ const err = e;
53
+ setNotice(err.code === "diverged" ? err.message : `Pubblicazione non riuscita: ${err.message}`);
54
+ }
55
+ finally {
56
+ setPublishing(false);
57
+ }
58
+ }, [reload]);
59
+ return {
60
+ ahead, commits, configured, publishing, notice, error,
61
+ reload, publish, clearNotice: () => setNotice(null),
62
+ };
63
+ }
@@ -0,0 +1,3 @@
1
+ export declare function movedOpenIndex(openAt: number | null, from: number, to: number): number | null;
2
+ /** Quale card resta aperta dopo aver eliminato quella in posizione `index`. */
3
+ export declare function removedOpenIndex(openAt: number | null, index: number): number | null;
@@ -0,0 +1,31 @@
1
+ /* Dove finisce la card aperta quando si sposta un elemento da `from` a `to`.
2
+ *
3
+ * Serve agli elenchi le cui chiavi di drag & drop sono POSIZIONALI (`lavoro-3`, `edizione-2`):
4
+ * i lavori del portfolio e le edizioni dei workshop non hanno un `id` nel front matter, e non
5
+ * glielo si aggiunge — sarebbe un campo in più in ogni file di contenuto solo per far
6
+ * funzionare l'interfaccia.
7
+ *
8
+ * Il prezzo di quella scelta è tutto qui: «quale card è aperta» non può essere una chiave,
9
+ * perché dopo un riordino l'indice punterebbe a un altro elemento. È aritmetica con quattro
10
+ * casi limite, quindi vive da sola e si verifica da sola (test/portfolio-works.test.js), invece
11
+ * che trascinando card a mano in due editor diversi.
12
+ */
13
+ export function movedOpenIndex(openAt, from, to) {
14
+ if (openAt === null)
15
+ return null;
16
+ if (openAt === from)
17
+ return to; // si sposta proprio quella aperta
18
+ if (openAt > from && openAt <= to)
19
+ return openAt - 1; // scavalcata verso il basso
20
+ if (openAt < from && openAt >= to)
21
+ return openAt + 1; // scavalcata verso l'alto
22
+ return openAt; // fuori dal tratto rimescolato
23
+ }
24
+ /** Quale card resta aperta dopo aver eliminato quella in posizione `index`. */
25
+ export function removedOpenIndex(openAt, index) {
26
+ // Eliminando la card aperta non se ne apre un'altra al suo posto: quello che resta sotto non
27
+ // è la cosa che si stava guardando.
28
+ if (openAt === null || openAt === index)
29
+ return null;
30
+ return openAt > index ? openAt - 1 : openAt;
31
+ }
@@ -0,0 +1,10 @@
1
+ import { type ContactRequestRow } from "../../index.js";
2
+ export declare function useRequests(): {
3
+ rows: ContactRequestRow[] | null;
4
+ error: string | null;
5
+ busy: boolean;
6
+ pending: number;
7
+ load: () => Promise<void>;
8
+ mark: (ids: number[], handled: boolean) => Promise<void>;
9
+ remove: (ids: number[]) => Promise<void>;
10
+ };
@@ -0,0 +1,67 @@
1
+ /* Le richieste dal form di contatto.
2
+ *
3
+ * L'archivio è la SORGENTE, non l'email: `functions/api/contact.js` salva PRIMA e manda dopo, e
4
+ * se l'invio fallisce chi ha scritto vede comunque la conferma. Quindi questo elenco è il posto
5
+ * in cui una richiesta esiste davvero, e va guardato.
6
+ *
7
+ * A differenza di home e media, questo si carica SUBITO e non quando si entra nella sezione: il
8
+ * pallino «da gestire» nella prima colonna è tutto il motivo per cui uno ci entra. Un badge che
9
+ * compare solo dopo essere andati a guardare non serve a niente. È una query sola su D1.
10
+ */
11
+ import { useCallback, useEffect, useState } from "react";
12
+ import { api, ApiError } from "../../index.js";
13
+ export function useRequests() {
14
+ const [rows, setRows] = useState(null);
15
+ const [error, setError] = useState(null);
16
+ const [busy, setBusy] = useState(false);
17
+ const load = useCallback(async () => {
18
+ try {
19
+ setRows((await api.requests()).requests);
20
+ setError(null);
21
+ }
22
+ catch (e) {
23
+ setError(e instanceof ApiError ? e.message : String(e));
24
+ }
25
+ }, []);
26
+ useEffect(() => { void load(); }, [load]);
27
+ /* Aggiornamenti FUNZIONALI: due click ravvicinati finiscono nello stesso batch di React e
28
+ * leggerebbero lo stesso stato vecchio, quindi il secondo cancellerebbe il primo. */
29
+ const mark = useCallback(async (ids, handled) => {
30
+ setBusy(true);
31
+ try {
32
+ await Promise.all(ids.map((id) => api.markRequest(id, handled)));
33
+ const at = handled ? new Date().toISOString() : null;
34
+ setRows((prev) => prev?.map((r) => (ids.includes(r.id) ? { ...r, handled_at: at } : r)) ?? null);
35
+ }
36
+ catch (e) {
37
+ setError(e instanceof ApiError ? e.message : String(e));
38
+ }
39
+ finally {
40
+ setBusy(false);
41
+ }
42
+ }, []);
43
+ /* Niente cestino, a differenza dei media: una richiesta è un testo di poche righe e chi la
44
+ * elimina lo sta facendo dopo averla letta. Ma una conferma sì — è l'unica copia. */
45
+ const remove = useCallback(async (ids) => {
46
+ if (!ids.length)
47
+ return;
48
+ const msg = ids.length === 1
49
+ ? "Eliminare questa richiesta? Non è recuperabile."
50
+ : `Eliminare ${ids.length} richieste? Non sono recuperabili.`;
51
+ if (!confirm(msg))
52
+ return;
53
+ setBusy(true);
54
+ try {
55
+ await Promise.all(ids.map((id) => api.deleteRequest(id)));
56
+ setRows((prev) => prev?.filter((r) => !ids.includes(r.id)) ?? null);
57
+ }
58
+ catch (e) {
59
+ setError(e instanceof ApiError ? e.message : String(e));
60
+ }
61
+ finally {
62
+ setBusy(false);
63
+ }
64
+ }, []);
65
+ const pending = rows?.filter((r) => !r.handled_at).length ?? 0;
66
+ return { rows, error, busy, pending, load, mark, remove };
67
+ }
@@ -0,0 +1 @@
1
+ export declare function useHashRoute(): [string[], (to: string) => void];
@@ -0,0 +1,14 @@
1
+ /* Router a hash. Resta a hash di proposito: senza, servirebbe una regola di rewrite SPA lato
2
+ * Pages e una configurazione di routing in più da tenere allineata alla policy Access. Una cosa
3
+ * in meno che può rompersi in produzione. */
4
+ import { useEffect, useState } from "react";
5
+ export function useHashRoute() {
6
+ const read = () => location.hash.replace(/^#\/?/, "").split("/").filter(Boolean);
7
+ const [parts, setParts] = useState(read);
8
+ useEffect(() => {
9
+ const on = () => setParts(read());
10
+ addEventListener("hashchange", on);
11
+ return () => removeEventListener("hashchange", on);
12
+ }, []);
13
+ return [parts, (to) => { location.hash = to; }];
14
+ }
@@ -0,0 +1,29 @@
1
+ export interface SiteFeatures {
2
+ analytics: boolean;
3
+ appointments: boolean;
4
+ }
5
+ export interface SiteSettings {
6
+ siteTitle: string;
7
+ shortDescription: string;
8
+ favicon: string;
9
+ ownerName: string;
10
+ siteDescription: string;
11
+ seoWebsiteTitle: string;
12
+ searchEngineIndexing: boolean;
13
+ blockAiTraining: boolean;
14
+ analyticsTag: string;
15
+ features: SiteFeatures;
16
+ passwordEnabled: boolean;
17
+ password: string;
18
+ }
19
+ export declare function useSiteSettings(active: boolean): {
20
+ data: SiteSettings | null;
21
+ error: string | null;
22
+ dirty: boolean;
23
+ saving: boolean;
24
+ notice: string | null;
25
+ load: () => Promise<void>;
26
+ change: (next: SiteSettings) => void;
27
+ save: () => Promise<void>;
28
+ setNotice: import("react").Dispatch<import("react").SetStateAction<string | null>>;
29
+ };