@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,139 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /* I lavori di una pagina portfolio.
3
+ *
4
+ * ── L'idea ─────────────────────────────────────────────────────────────────────────────────
5
+ * Un lavoro è una COSA, non un gruppo di campi: si riconosce dalla sua copertina e dal suo
6
+ * titolo, come sul sito. L'elenco chiuso è quindi fatto di card che mostrano quelle due cose e
7
+ * niente altro, e si apre quella su cui si sta lavorando. Prima era una lista di `fieldset`
8
+ * annidati generati dallo schema: per capire quale lavoro si stesse modificando bisognava
9
+ * leggere il campo "Titolo", cioè leggere un form per sapere dove ci si trova.
10
+ *
11
+ * Una alla volta (`openAt`): con più card aperte insieme l'elenco perde la sua funzione, che è
12
+ * far vedere l'ordine dei lavori — l'ordine è il contenuto della pagina, non un dettaglio.
13
+ *
14
+ * ── Le chiavi del drag & drop ──────────────────────────────────────────────────────────────
15
+ * Le sezioni delle gallery hanno un `id` scritto nel front matter; i lavori NO, e non glielo si
16
+ * aggiunge: sarebbe un campo in più in ogni file di contenuto — e nei file di un cliente — solo
17
+ * per far funzionare l'interfaccia. Le chiavi sono quindi posizionali (`lavoro-3`), che a
18
+ * dnd-kit basta perché durante un trascinamento la lista non cambia.
19
+ *
20
+ * Il prezzo è che «quale card è aperta» non può essere una chiave: dopo un riordino o una
21
+ * eliminazione l'indice punterebbe a un altro lavoro. Per questo `openAt` viene ricalcolato in
22
+ * ogni operazione che sposta gli indici — è esplicito apposta, perché è l'unico punto in cui
23
+ * la scelta delle chiavi posizionali si paga.
24
+ *
25
+ * ── L'aspetto ──────────────────────────────────────────────────────────────────────────────
26
+ * Nessun tratto: le superfici si staccano dal fondo per colore e per ombra. Le regole stanno in
27
+ * `app.css` sotto `.pw-*`. È il linguaggio nuovo del pannello e per ora vive qui: applicarlo
28
+ * di colpo a tutte le sezioni avrebbe voluto dire cambiare l'admin intero in un commit.
29
+ */
30
+ import { useEffect, useRef, useState } from "react";
31
+ import { DndContext, closestCenter } from "@dnd-kit/core";
32
+ import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable";
33
+ import { CSS } from "@dnd-kit/utilities";
34
+ import { ParagraphEditor } from "../ui/ParagraphEditor";
35
+ /* La casella immagine sta in `ui/`: la usano copertina e card qui, e il ritratto della pagina
36
+ "chi sono". Vedi il commento in cima a quel file. */
37
+ import { MediaSlot } from "../ui/MediaSlot";
38
+ /* Guscio della card, sensori e aritmetica del riordino: condivisi con l'editor delle edizioni
39
+ dei workshop, che è lo stesso elenco con dentro un'altra cosa. */
40
+ import { OpenCard } from "../ui/OpenCard";
41
+ import { useDragSensors } from "../ui/dnd";
42
+ import { movedOpenIndex, removedOpenIndex } from "../lib/reorder";
43
+ import { Icon } from "../ui/icons";
44
+ import { normalizeWorks, emptyWork, emptyCard, CARD_KINDS, } from "../../normalize.js";
45
+ export { normalizeWorks };
46
+ const str = (value) => (typeof value === "string" ? value : "");
47
+ const list = (value) => (Array.isArray(value) ? value : []);
48
+ /* ---- Le informazioni (editore, autori, anno…) ------------------------------------------- */
49
+ function SortableInfo({ id, entry, onPatch, onRemove }) {
50
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
51
+ return (_jsxs("li", { ref: setNodeRef, className: "pw-info-row" + (isDragging ? " is-dragging" : ""), style: { transform: CSS.Transform.toString(transform), transition }, children: [_jsx("button", { type: "button", className: "pw-grip", "aria-label": "Riordina informazione", ...attributes, ...listeners, children: _jsx(Icon.grip, {}) }), _jsx("input", { className: "pw-field pw-field-label", value: entry.label, maxLength: 40, placeholder: "Editore", onChange: (event) => onPatch({ label: event.target.value }) }), _jsx("input", { className: "pw-field", value: entry.value, maxLength: 200, placeholder: "Callwey (DE)", onChange: (event) => onPatch({ value: event.target.value }) }), _jsx("button", { type: "button", className: "pw-icon-btn is-danger", "aria-label": "Elimina informazione", onClick: onRemove, children: _jsx(Icon.trash, {}) })] }));
52
+ }
53
+ function InfoList({ items, onChange }) {
54
+ const sensors = useDragSensors();
55
+ const ids = items.map((_, index) => `info-${index}`);
56
+ const finish = ({ active, over }) => {
57
+ if (!over || active.id === over.id)
58
+ return;
59
+ onChange(arrayMove(items, ids.indexOf(String(active.id)), ids.indexOf(String(over.id))));
60
+ };
61
+ return (_jsxs("section", { className: "pw-block", children: [_jsxs("header", { className: "pw-block-head", children: [_jsx("h4", { children: "Informazioni" }), _jsxs("button", { type: "button", className: "pw-add-quiet", onClick: () => onChange([...items, { label: "", value: "" }]), children: [_jsx(Icon.plus, {}), " Aggiungi"] })] }), items.length === 0 ? (_jsx("p", { className: "pw-empty-note", children: "Editore, autori, anno\u2026 Compaiono in colonna accanto alla descrizione." })) : (_jsx(DndContext, { sensors: sensors, collisionDetection: closestCenter, onDragEnd: finish, children: _jsx(SortableContext, { items: ids, strategy: verticalListSortingStrategy, children: _jsx("ul", { className: "pw-info-list", children: items.map((entry, index) => (_jsx(SortableInfo, { id: ids[index], entry: entry, onPatch: (patch) => onChange(items.map((item, i) => (i === index ? { ...item, ...patch } : item))), onRemove: () => onChange(items.filter((_, i) => i !== index)) }, ids[index]))) }) }) }))] }));
62
+ }
63
+ /* ---- Le card del dettaglio --------------------------------------------------------------- */
64
+ function SortableCard({ id, card, index, onPatch, onRemove, pickMediaFiles }) {
65
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
66
+ const kind = CARD_KINDS.find((k) => k.type === card.type) ?? CARD_KINDS[0];
67
+ const wantsImage = card.type !== "text";
68
+ const wantsText = card.type !== "image";
69
+ const chooseImage = async () => {
70
+ const file = (await pickMediaFiles?.({ multiple: false }))?.[0];
71
+ if (file)
72
+ onPatch({ image: file.url });
73
+ };
74
+ return (_jsxs("article", { ref: setNodeRef, className: "pw-card" + (isDragging ? " is-dragging" : ""), style: { transform: CSS.Transform.toString(transform), transition }, children: [_jsxs("header", { className: "pw-card-head", children: [_jsx("button", { type: "button", className: "pw-grip", "aria-label": `Riordina card ${index + 1}`, ...attributes, ...listeners, children: _jsx(Icon.grip, {}) }), _jsx("span", { className: "pw-card-kind", children: kind.label }), _jsx("div", { className: "seg", role: "group", "aria-label": "Tipo di card", children: CARD_KINDS.map((option) => (_jsx("button", { type: "button", className: "seg-btn" + (card.type === option.type ? " is-on" : ""), onClick: () => onPatch({ type: option.type }), title: option.hint, children: option.label }, option.type))) }), _jsx("button", { type: "button", className: "pw-icon-btn is-danger", "aria-label": "Elimina card", onClick: onRemove, children: _jsx(Icon.trash, {}) })] }), _jsxs("div", { className: "pw-card-body" + (wantsImage ? " is-split" : ""), children: [wantsImage && (_jsx(MediaSlot, { value: card.image, ratio: "wide", empty: "Scegli un\u2019immagine", onPick: () => void chooseImage(), onClear: () => onPatch({ image: "" }) })), _jsxs("div", { className: "pw-card-text", children: [wantsImage && (_jsxs("label", { className: "pw-label", children: [_jsx("span", { children: "Testo alternativo" }), _jsx("input", { className: "pw-field", value: card.alt, maxLength: 200, placeholder: "Descrive l\u2019immagine a chi non pu\u00F2 vederla", onChange: (event) => onPatch({ alt: event.target.value }) })] })), wantsText && (_jsxs(_Fragment, { children: [_jsxs("label", { className: "pw-label", children: [_jsxs("span", { children: ["Occhiello ", _jsx("small", { children: "facoltativo" })] }), _jsx("input", { className: "pw-field", value: card.kicker, maxLength: 60, onChange: (event) => onPatch({ kicker: event.target.value }) })] }), _jsxs("label", { className: "pw-label", children: [_jsx("span", { children: "Titolo" }), _jsx("input", { className: "pw-field", value: card.heading, maxLength: 120, onChange: (event) => onPatch({ heading: event.target.value }) })] }), _jsxs("label", { className: "pw-label", children: [_jsx("span", { children: "Paragrafi" }), _jsx(ParagraphEditor, { className: "pw-field pw-textarea", rows: 5, value: card.body, placeholder: "Scrivi il testo della card.\nVai a capo per iniziare un paragrafo nuovo.", onChange: (body) => onPatch({ body }) })] })] }))] })] })] }));
75
+ }
76
+ function CardList({ items, onChange, pickMediaFiles }) {
77
+ const sensors = useDragSensors();
78
+ const [menuOpen, setMenuOpen] = useState(false);
79
+ const menuRef = useRef(null);
80
+ const ids = items.map((_, index) => `card-${index}`);
81
+ useEffect(() => {
82
+ if (!menuOpen)
83
+ return;
84
+ const close = (event) => {
85
+ if (!menuRef.current?.contains(event.target))
86
+ setMenuOpen(false);
87
+ };
88
+ addEventListener("pointerdown", close);
89
+ return () => removeEventListener("pointerdown", close);
90
+ }, [menuOpen]);
91
+ const finish = ({ active, over }) => {
92
+ if (!over || active.id === over.id)
93
+ return;
94
+ onChange(arrayMove(items, ids.indexOf(String(active.id)), ids.indexOf(String(over.id))));
95
+ };
96
+ return (_jsxs("section", { className: "pw-block", children: [_jsxs("header", { className: "pw-block-head", children: [_jsx("h4", { children: "Contenuto del dettaglio" }), _jsxs("div", { className: "pw-add-wrap", ref: menuRef, children: [_jsxs("button", { type: "button", className: "pw-add-quiet", "aria-haspopup": "menu", "aria-expanded": menuOpen, onClick: () => setMenuOpen((open) => !open), children: [_jsx(Icon.plus, {}), " Aggiungi"] }), menuOpen && (_jsx("div", { className: "pw-menu", role: "menu", children: CARD_KINDS.map((option) => (_jsxs("button", { type: "button", onClick: () => { onChange([...items, emptyCard(option.type)]); setMenuOpen(false); }, children: [_jsx("span", { className: "pw-menu-icon", children: option.type === "text" ? _jsx(Icon.doc, {}) : _jsx(Icon.media, {}) }), _jsxs("span", { children: [_jsx("strong", { children: option.label }), _jsx("small", { children: option.hint })] })] }, option.type))) }))] })] }), items.length === 0 ? (_jsx("p", { className: "pw-empty-note", children: "Le immagini e i testi che si vedono aprendo il lavoro sul sito." })) : (_jsx(DndContext, { sensors: sensors, collisionDetection: closestCenter, onDragEnd: finish, children: _jsx(SortableContext, { items: ids, strategy: verticalListSortingStrategy, children: _jsx("div", { className: "pw-card-list", children: items.map((card, index) => (_jsx(SortableCard, { id: ids[index], card: card, index: index, pickMediaFiles: pickMediaFiles, onPatch: (patch) => onChange(items.map((item, i) => (i === index ? { ...item, ...patch } : item))), onRemove: () => onChange(items.filter((_, i) => i !== index)) }, ids[index]))) }) }) }))] }));
97
+ }
98
+ /* ---- Il lavoro ---------------------------------------------------------------------------- */
99
+ function SortableWork({ id, work, index, total, open, onToggle, onPatch, onDuplicate, onRemove, pickMediaFiles }) {
100
+ const chooseCover = async () => {
101
+ const file = (await pickMediaFiles?.({ multiple: false }))?.[0];
102
+ if (file)
103
+ onPatch({ cover: file.url });
104
+ };
105
+ const pieces = [
106
+ work.cards.length ? `${work.cards.length} ${work.cards.length === 1 ? "card" : "card"}` : "",
107
+ work.info.length ? `${work.info.length} ${work.info.length === 1 ? "informazione" : "informazioni"}` : "",
108
+ ].filter(Boolean);
109
+ return (
110
+ /* Il guscio (maniglia, miniatura, titolo, apri/chiudi) sta in `ui/OpenCard`: è lo stesso
111
+ delle edizioni dei workshop, e tenerlo qui avrebbe voluto dire ricopiarlo là. */
112
+ _jsxs(OpenCard, { id: id, index: index, total: total, open: open, thumb: work.cover, title: work.title, empty: "Lavoro senza titolo", meta: pieces.join(" · "), kind: "questo lavoro", onToggle: onToggle, actions: _jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "btn-soft is-quiet", onClick: onDuplicate, children: "Duplica" }), _jsx("button", { type: "button", className: "btn-soft is-danger", onClick: onRemove, children: "Elimina" })] }), children: [_jsxs("div", { className: "pw-identity", children: [_jsx(MediaSlot, { value: work.cover, ratio: "cover", empty: "Scegli la copertina", onPick: () => void chooseCover(), onClear: () => onPatch({ cover: "" }) }), _jsxs("div", { className: "pw-identity-fields", children: [_jsxs("label", { className: "pw-label", children: [_jsx("span", { children: "Titolo" }), _jsx("input", { className: "pw-field pw-field-title", value: work.title, maxLength: 120, placeholder: "Il nome del lavoro", onChange: (event) => onPatch({ title: event.target.value }) })] }), _jsxs("label", { className: "pw-label", children: [_jsx("span", { children: "Descrizione" }), _jsx(ParagraphEditor, { className: "pw-field pw-textarea", rows: 7, value: work.description, placeholder: "Racconta il lavoro.\nVai a capo per iniziare un paragrafo nuovo.", onChange: (description) => onPatch({ description }) }), _jsx("small", { children: "Seleziona una parola per metterla in grassetto, in corsivo o per collegarla." })] })] })] }), _jsx(InfoList, { items: work.info, onChange: (info) => onPatch({ info }) }), _jsx(CardList, { items: work.cards, onChange: (cards) => onPatch({ cards }), pickMediaFiles: pickMediaFiles })] }));
113
+ }
114
+ /* ---- L'elenco ------------------------------------------------------------------------------ */
115
+ export function PortfolioWorksEditor({ value, onChange, pickMediaFiles }) {
116
+ const works = normalizeWorks(value);
117
+ const [openAt, setOpenAt] = useState(null);
118
+ const sensors = useDragSensors();
119
+ const ids = works.map((_, index) => `lavoro-${index}`);
120
+ const replace = (next, nextOpen) => {
121
+ setOpenAt(nextOpen);
122
+ onChange(next);
123
+ };
124
+ /* Ogni operazione che sposta gli indici ricalcola anche `openAt`: con chiavi posizionali è
125
+ l'unico modo perché la card aperta resti quella che si stava guardando. */
126
+ const finish = ({ active, over }) => {
127
+ if (!over || active.id === over.id)
128
+ return;
129
+ const from = ids.indexOf(String(active.id));
130
+ const to = ids.indexOf(String(over.id));
131
+ if (from < 0 || to < 0)
132
+ return;
133
+ replace(arrayMove(works, from, to), movedOpenIndex(openAt, from, to));
134
+ };
135
+ const add = () => {
136
+ replace([...works, emptyWork()], works.length);
137
+ };
138
+ return (_jsxs("section", { className: "pw", "aria-label": "Lavori del portfolio", children: [_jsxs("header", { className: "pw-head", children: [_jsxs("div", { children: [_jsx("h3", { children: "Lavori" }), _jsx("p", { children: "L\u2019ordine \u00E8 quello con cui si scorrono sul sito. Apri un lavoro per modificarlo." })] }), _jsxs("button", { type: "button", className: "btn-soft is-primary", onClick: add, children: [_jsx(Icon.plus, {}), " Aggiungi lavoro"] })] }), works.length === 0 ? (_jsxs("button", { type: "button", className: "pw-blank", onClick: add, children: [_jsx(Icon.gallery, {}), _jsx("strong", { children: "Nessun lavoro, per ora" }), _jsx("span", { children: "Aggiungine uno: bastano un titolo e una copertina." })] })) : (_jsx(DndContext, { sensors: sensors, collisionDetection: closestCenter, onDragEnd: finish, children: _jsx(SortableContext, { items: ids, strategy: verticalListSortingStrategy, children: _jsx("div", { className: "pw-list", children: works.map((work, index) => (_jsx(SortableWork, { id: ids[index], work: work, index: index, total: works.length, open: openAt === index, pickMediaFiles: pickMediaFiles, onToggle: () => setOpenAt(openAt === index ? null : index), onPatch: (patch) => onChange(works.map((item, i) => (i === index ? { ...item, ...patch } : item))), onDuplicate: () => replace(works.flatMap((item, i) => (i === index ? [item, { ...item }] : [item])), index + 1), onRemove: () => replace(works.filter((_, i) => i !== index), removedOpenIndex(openAt, index)) }, ids[index]))) }) }) }))] }));
139
+ }
@@ -0,0 +1,13 @@
1
+ import type { ContactRequestRow } from "../../index.js";
2
+ export declare function RequestsList({ rows, current, picked, onOpen, onPick, }: {
3
+ rows: ContactRequestRow[];
4
+ current?: number;
5
+ picked: Set<number>;
6
+ onOpen: (id: number) => void;
7
+ onPick: (id: number) => void;
8
+ }): import("react").JSX.Element;
9
+ export declare function RequestDetail({ row, onMark, onRemove, }: {
10
+ row: ContactRequestRow;
11
+ onMark: (handled: boolean) => void;
12
+ onRemove: () => void;
13
+ }): import("react").JSX.Element;
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /* Le richieste: elenco a sinistra, messaggio a destra.
3
+ *
4
+ * Sono lette più che modificate, e la lettura è tutto: il messaggio va in chiaro, per intero e
5
+ * senza troncamenti. Nell'elenco invece basta sapere chi ha scritto, quando, e le prime parole —
6
+ * è così che si ritrova quella di tre settimane fa.
7
+ *
8
+ * Quelle già gestite restano nell'elenco, smorzate: sparire sarebbe perdere il filo di cosa è
9
+ * arrivato. Il pallino segna quelle ancora da gestire, ed è la stessa informazione che la prima
10
+ * colonna mostra come numero.
11
+ */
12
+ import { useState } from "react";
13
+ const fmtShort = (iso) => new Date(iso).toLocaleDateString("it-IT", { year: "numeric", month: "2-digit", day: "2-digit" });
14
+ const fmtLong = (iso) => new Date(iso).toLocaleString("it-IT", {
15
+ day: "2-digit", month: "long", year: "numeric", hour: "2-digit", minute: "2-digit",
16
+ });
17
+ const initials = (name) => name.trim().split(/\s+/).slice(0, 2).map((w) => w[0] ?? "").join("").toUpperCase() || "?";
18
+ export function RequestsList({ rows, current, picked, onOpen, onPick, }) {
19
+ return (_jsx("ul", { className: "rq-list", children: rows.map((r) => (_jsxs("li", { className: "rq-row"
20
+ + (r.id === current ? " is-on" : "")
21
+ + (r.handled_at ? " is-handled" : ""), children: [_jsx("input", { className: "rq-pick", type: "checkbox", "aria-label": `Scegli la richiesta di ${r.name}`, checked: picked.has(r.id), onChange: () => onPick(r.id) }), _jsxs("button", { className: "rq-open", onClick: () => onOpen(r.id), children: [_jsxs("span", { className: "rq-line", children: [_jsx("span", { className: "rq-who", children: r.name }), !r.handled_at && _jsx("span", { className: "rq-new", title: "da gestire" })] }), _jsx("span", { className: "rq-when", children: fmtShort(r.created_at) }), _jsx("span", { className: "rq-snip", children: r.message })] })] }, r.id))) }));
22
+ }
23
+ export function RequestDetail({ row, onMark, onRemove, }) {
24
+ const [menu, setMenu] = useState(false);
25
+ const handled = !!row.handled_at;
26
+ // `mailto` con l'oggetto già pronto: rispondere è il gesto normale dopo aver letto, e non
27
+ // deve costare un copia-incolla.
28
+ const reply = `mailto:${row.email}?subject=${encodeURIComponent("Re: la tua richiesta")}`;
29
+ return (_jsxs("article", { className: "rq-detail" + (handled ? " is-handled" : ""), children: [_jsxs("header", { className: "rq-head", children: [_jsx("span", { className: "rq-avatar", "aria-hidden": "true", children: initials(row.name) }), _jsx("span", { className: "rq-name", children: row.name }), handled && _jsx("span", { className: "rq-tag", children: "gestita" }), _jsx("span", { className: "ak-spacer" }), _jsx("time", { className: "rq-date", dateTime: row.created_at, children: fmtLong(row.created_at) }), _jsxs("span", { className: "rq-menu-wrap", children: [_jsx("button", { className: "ak-icon", onClick: () => setMenu((v) => !v), title: "Altro", children: "\u2022\u2022\u2022" }), menu && (_jsxs("div", { className: "pa-menu", onMouseLeave: () => setMenu(false), children: [_jsx("button", { onClick: () => { onMark(!handled); setMenu(false); }, children: handled ? "Rimetti da gestire" : "Segna gestita" }), _jsx("button", { className: "pa-danger", onClick: () => { onRemove(); setMenu(false); }, children: "Elimina\u2026" })] }))] })] }), _jsxs("dl", { className: "rq-fields", children: [_jsx("dt", { children: "Nome" }), _jsx("dd", { children: row.name }), _jsx("dt", { children: "Email" }), _jsx("dd", { children: _jsx("a", { className: "rq-mail", href: reply, children: row.email }) }), _jsx("dt", { children: "Arrivata" }), _jsx("dd", { children: fmtLong(row.created_at) }), _jsx("dt", { children: "Messaggio" }), _jsx("dd", { className: "rq-msg", children: row.message })] }), _jsxs("footer", { className: "rq-actions", children: [_jsx("a", { className: "ak-btn ak-btn-primary", href: reply, children: "Rispondi" }), _jsx("button", { className: "ak-btn ak-btn-quiet", onClick: () => onMark(!handled), children: handled ? "Rimetti da gestire" : "Segna gestita" })] })] }));
30
+ }
@@ -0,0 +1,21 @@
1
+ import type { AssetFile } from "../../index.js";
2
+ import type { SiteFeatures, SiteSettings } from "../lib/settings";
3
+ export declare const EXTRAS: {
4
+ key: keyof SiteFeatures;
5
+ title: string;
6
+ help: string;
7
+ card: string;
8
+ short: string;
9
+ }[];
10
+ export declare function SiteSettingsEditor({ value, onChange, pickMediaFiles, focus, }: {
11
+ value: SiteSettings;
12
+ onChange: (next: SiteSettings) => void;
13
+ pickMediaFiles: (o: {
14
+ multiple: boolean;
15
+ }) => Promise<AssetFile[] | null>;
16
+ focus?: string;
17
+ }): import("react").JSX.Element;
18
+ export declare function WebsiteSeoSettingsEditor({ value, onChange, }: {
19
+ value: SiteSettings;
20
+ onChange: (next: SiteSettings) => void;
21
+ }): import("react").JSX.Element;
@@ -0,0 +1,66 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useRef } from "react";
3
+ import { site } from "../site.js";
4
+ import { Icon } from "../ui/icons";
5
+ function Toggle({ checked, onChange, label, disabled = false, }) {
6
+ return (
7
+ /* `disabled` sull'`input`, non solo una classe: così non si preme, non prende il focus da
8
+ tastiera e i lettori di schermo lo annunciano come disattivato. Uno stile che *sembra*
9
+ spento ma resta cliccabile è la versione peggiore di tutte — cambia stato sullo schermo
10
+ e non cambia niente sul sito. */
11
+ _jsxs("label", { className: "st-toggle" + (disabled ? " is-locked" : ""), children: [_jsx("input", { type: "checkbox", checked: checked, disabled: disabled, onChange: (event) => onChange(event.target.checked) }), _jsx("span", { className: "st-toggle-track", "aria-hidden": "true", children: _jsx("span", {}) }), _jsxs("span", { children: [checked ? "On" : "Off", " \u00B7 ", label] })] }));
12
+ }
13
+ /* Le funzioni aggiuntive, in un posto solo: le usa questa sezione per l'elenco e la Panoramica
14
+ per il testo delle card bloccate. Le chiavi sono quelle di `SiteFeatures`, quindi
15
+ aggiungerne una qui e non nel tipo non compila. */
16
+ export const EXTRAS = [
17
+ {
18
+ key: "analytics",
19
+ title: "Analytics nella dashboard",
20
+ help: "Le visite del sito — pagine viste, visitatori, provenienze — direttamente nella Panoramica, senza aprire il servizio di statistiche.",
21
+ card: "Visite al sito",
22
+ short: "Le visite del sito, dentro il pannello.",
23
+ },
24
+ {
25
+ key: "appointments",
26
+ title: "Appuntamenti",
27
+ help: "Una pagina pubblica dove i clienti prenotano un incontro, con sincronizzazione del calendario e conferma da parte tua.",
28
+ card: "Appuntamenti",
29
+ short: "Prenotazioni online sincronizzate col calendario.",
30
+ },
31
+ ];
32
+ function SettingRow({ title, help, children, }) {
33
+ return (_jsxs("div", { className: "st-row", children: [_jsxs("div", { className: "st-copy", children: [_jsx("h3", { children: title }), _jsx("p", { children: help })] }), _jsx("div", { className: "st-control", children: children })] }));
34
+ }
35
+ function Field({ value, onChange, maxLength, placeholder, type = "text", }) {
36
+ return (_jsx("input", { className: "st-input", type: type, value: value, maxLength: maxLength, placeholder: placeholder, onChange: (event) => onChange(event.target.value) }));
37
+ }
38
+ export function SiteSettingsEditor({ value, onChange, pickMediaFiles, focus, }) {
39
+ const update = (key, next) => onChange({ ...value, [key]: next });
40
+ const extras = useRef(null);
41
+ /* Salto SECCO (`behavior: "auto"`), non fluido, e al frame successivo.
42
+ Provato prima con `smooth`: parte e viene annullato dal render che segue il montaggio —
43
+ misurato, la pagina si spostava di tre pixel e si fermava. Il risultato era il peggiore
44
+ possibile: l'evidenziazione compariva su una sezione fuori schermo, cioè il pulsante
45
+ «Attiva» sembrava aver portato sulla schermata sbagliata. Un salto secco arriva sempre, e
46
+ a dire «è questa che cercavi» ci pensa il velo d'accento di `.is-focused`. */
47
+ useEffect(() => {
48
+ if (focus !== "funzioni")
49
+ return;
50
+ const frame = requestAnimationFrame(() => {
51
+ extras.current?.scrollIntoView({ behavior: "auto", block: "center" });
52
+ });
53
+ return () => cancelAnimationFrame(frame);
54
+ }, [focus]);
55
+ const chooseFavicon = async () => {
56
+ const selected = await pickMediaFiles({ multiple: false });
57
+ if (selected?.[0])
58
+ update("favicon", selected[0].url);
59
+ };
60
+ return (_jsxs("section", { className: "settings-editor", children: [_jsxs("header", { className: "st-intro", children: [_jsx("span", { children: "Impostazioni generali" }), _jsx("h2", { children: "Sito" }), _jsx("p", { children: "Identit\u00E0, descrizione e accesso al sito pubblico." })] }), _jsxs("section", { className: "st-section", children: [_jsx("h2", { children: "Identit\u00E0 del sito" }), _jsx(SettingRow, { title: "Titolo del sito", help: "Il nome generale del sito, usato anche come nome del publisher nei metadati.", children: _jsx(Field, { value: value.siteTitle, maxLength: 140, placeholder: site().name, onChange: (next) => update("siteTitle", next) }) }), _jsxs(SettingRow, { title: "Descrizione breve", help: "Una frase sintetica usata come descrizione predefinita quando una pagina non ne possiede una specifica.", children: [_jsx("textarea", { className: "st-input st-textarea", rows: 3, value: value.shortDescription, maxLength: 255, onChange: (event) => update("shortDescription", event.target.value) }), _jsxs("span", { className: "st-count", children: [value.shortDescription.length, "/255"] })] }), _jsx(SettingRow, { title: "Favicon", help: "L\u2019icona mostrata nella scheda del browser. \u00C8 preferibile un\u2019immagine quadrata.", children: _jsxs("div", { className: "st-favicon", children: [_jsx("span", { className: "st-favicon-preview", children: value.favicon ? _jsx("img", { src: value.favicon, alt: "Anteprima favicon" }) : _jsx(Icon.pages, {}) }), _jsx("button", { className: "ak-btn", type: "button", onClick: () => void chooseFavicon(), children: value.favicon ? "Cambia favicon" : "Carica favicon" }), value.favicon && (_jsx("button", { className: "ak-btn ak-btn-quiet", type: "button", onClick: () => update("favicon", ""), children: "Rimuovi" }))] }) })] }), _jsxs("section", { className: "st-section", children: [_jsx("h2", { children: "Accesso al sito" }), _jsx(SettingRow, { title: "Password del sito", help: "Quando \u00E8 attiva, ogni pagina e ogni immagine pubblica richiedono la stessa password. L\u2019admin resta raggiungibile tramite il suo accesso protetto.", children: _jsxs("div", { className: "st-stack", children: [_jsx(Field, { type: "password", value: value.password, maxLength: 200, placeholder: value.passwordEnabled ? "Password già impostata · inseriscine una per cambiarla" : "Almeno 8 caratteri", onChange: (next) => update("password", next) }), _jsx(Toggle, { checked: value.passwordEnabled, label: "Protezione con password", onChange: (next) => update("passwordEnabled", next) }), value.passwordEnabled && (_jsx("p", { className: "st-inline-help", children: "Lascia il campo vuoto per mantenere la password attuale." }))] }) })] }), _jsxs("section", { className: "st-section" + (focus === "funzioni" ? " is-focused" : ""), ref: extras, children: [_jsx("h2", { children: "Funzioni aggiuntive" }), EXTRAS.map((extra) => (_jsx(SettingRow, { title: extra.title, help: extra.help, children: _jsx(Toggle, { checked: value.features[extra.key], disabled: true, label: value.features[extra.key] ? "Attiva su questo sito" : "Non attiva", onChange: () => { } }) }, extra.key))), _jsxs("p", { className: "st-inline-help st-extras-note", children: ["Queste funzioni si attivano separatamente. Per accenderle su questo sito scrivi a", " ", _jsx("strong", { children: site().support.name }), ":", " ", _jsx("a", { href: `mailto:${site().support.email}`, children: site().support.email }), "."] })] }), _jsxs("section", { className: "st-section", children: [_jsx("h2", { children: "Statistiche" }), _jsx(SettingRow, { title: "Tag Analytics", help: _jsxs(_Fragment, { children: ["Il codice fornito dal servizio di statistiche (Umami, Google Analytics, altro). Si incolla qui cos\u00EC com\u2019\u00E8: finisce dentro ", _jsx("code", { children: "<head>" }), " di ogni pagina del sito alla prossima pubblicazione. Le pagine di consegna ai clienti restano escluse. Per svuotarlo, cancella tutto e salva."] }), children: _jsxs("div", { className: "st-stack", children: [_jsx("textarea", { className: "st-input st-textarea st-code", rows: 6, value: value.analyticsTag, maxLength: 4000, spellCheck: false, autoCapitalize: "off", autoCorrect: "off", placeholder: '<script defer src="https://cloud.umami.is/script.js" data-website-id="…"></script>', onChange: (event) => update("analyticsTag", event.target.value) }), _jsxs("span", { className: "st-count", children: [value.analyticsTag.length, "/4000"] }), _jsxs("p", { className: "st-inline-help", children: ["\u00C8 codice eseguito su ogni pagina: incolla solo quello che ti ha dato il servizio. Un tag incompleto (per esempio senza ", _jsx("code", { children: "</script>" }), ") pu\u00F2 rompere il sito."] })] }) })] }), _jsxs("section", { className: "st-section", children: [_jsx("h2", { children: "Profilo" }), _jsx(SettingRow, { title: "Nome", help: "Il nome della persona proprietaria del sito. Sar\u00E0 disponibile per future funzioni del pannello.", children: _jsx(Field, { value: value.ownerName, maxLength: 120, placeholder: site().name, onChange: (next) => update("ownerName", next) }) }), _jsxs(SettingRow, { title: "Descrizione del sito", help: "Una descrizione generale pi\u00F9 estesa, conservata come informazione del profilo del sito.", children: [_jsx("textarea", { className: "st-input st-textarea", rows: 5, value: value.siteDescription, maxLength: 1200, onChange: (event) => update("siteDescription", event.target.value) }), _jsxs("span", { className: "st-count", children: [value.siteDescription.length, "/1200"] })] })] })] }));
61
+ }
62
+ export function WebsiteSeoSettingsEditor({ value, onChange, }) {
63
+ const update = (key, next) => onChange({ ...value, [key]: next });
64
+ const sitemap = `${site().publicBase}/sitemap.xml`;
65
+ return (_jsxs("section", { className: "settings-editor", children: [_jsxs("header", { className: "st-intro", children: [_jsx("span", { children: "Visibilit\u00E0 globale" }), _jsx("h2", { children: "SEO" }), _jsx("p", { children: "Controlla l\u2019indicizzazione del sito e l\u2019accesso dei crawler usati per addestrare sistemi AI." })] }), _jsxs("section", { className: "st-section", children: [_jsx("h2", { children: "Motori di ricerca" }), _jsx(SettingRow, { title: "Visibilit\u00E0 del sito", help: "Se disattivata, robots.txt e tutte le pagine chiedono ai motori di ricerca di non indicizzare il sito.", children: _jsx(Toggle, { checked: value.searchEngineIndexing, label: value.searchEngineIndexing ? "Indicizzazione consentita" : "Sito nascosto ai motori di ricerca", onChange: (next) => update("searchEngineIndexing", next) }) }), _jsxs(SettingRow, { title: "SEO website title", help: "Il titolo globale della home e il riferimento usato dai metadati del sito.", children: [_jsx(Field, { value: value.seoWebsiteTitle, maxLength: 140, placeholder: value.siteTitle, onChange: (next) => update("seoWebsiteTitle", next) }), _jsxs("span", { className: "st-count", children: [value.seoWebsiteTitle.length, "/140"] })] }), _jsx(SettingRow, { title: "Sitemap", help: "La sitemap viene generata automaticamente e aggiornata a ogni pubblicazione.", children: _jsxs("div", { className: "st-sitemap", children: [_jsx("code", { children: sitemap }), _jsxs("a", { className: "ak-btn", href: sitemap, target: "_blank", rel: "noreferrer", children: ["Apri sitemap ", _jsx(Icon.out, {})] })] }) })] }), _jsxs("section", { className: "st-section", children: [_jsx("h2", { children: "Crawler AI" }), _jsx(SettingRow, { title: "Block AI Training Crawlers", help: "Blocca in robots.txt GPTBot, ClaudeBot, Google-Extended, CCBot e altri crawler noti per l\u2019addestramento. Le pagine includono anche le direttive noai e noimageai.", children: _jsxs("div", { className: "st-stack", children: [_jsx(Toggle, { checked: value.blockAiTraining, label: value.blockAiTraining ? "Crawler di training bloccati" : "Crawler di training consentiti", onChange: (next) => update("blockAiTraining", next) }), _jsx("p", { className: "st-inline-help", children: "I crawler rispettano queste direttive volontariamente; il blocco non impedisce l\u2019accesso ai normali visitatori n\u00E9 ai motori di ricerca." })] }) })] })] }));
66
+ }
@@ -0,0 +1 @@
1
+ export declare function UploadDock(): import("react").JSX.Element | null;
@@ -0,0 +1,66 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /* Il riquadro dei caricamenti, in basso a destra.
3
+ *
4
+ * Esiste perché un upload da quaranta giga dura ore, e per tutto quel tempo il pannello deve
5
+ * restare usabile: lo stato vive in `lib/upload-queue.ts`, fuori da React, e questo è solo il
6
+ * modo di guardarlo. Si può ridurre a una riga, ma non chiudere mentre sta lavorando —
7
+ * chiuderlo darebbe l'idea di aver fermato qualcosa.
8
+ *
9
+ * L'avviso di chiusura della finestra sta qui e non nella schermata del transfer: il
10
+ * caricamento sopravvive alla navigazione, quindi a sorvegliarlo dev'essere qualcosa che c'è
11
+ * sempre.
12
+ */
13
+ import { useEffect, useState } from "react";
14
+ import { cancelUpload, dismissUpload, isBusy, pauseUpload, resumeUpload, useUploads, } from "../lib/upload-queue";
15
+ export function UploadDock() {
16
+ const jobs = useUploads();
17
+ const [open, setOpen] = useState(true);
18
+ const [confirmStop, setConfirmStop] = useState(null);
19
+ const busy = jobs.some((job) => job.state === "running");
20
+ useEffect(() => {
21
+ if (!busy)
22
+ return;
23
+ const warn = (event) => { event.preventDefault(); event.returnValue = ""; };
24
+ window.addEventListener("beforeunload", warn);
25
+ return () => window.removeEventListener("beforeunload", warn);
26
+ }, [busy]);
27
+ if (!jobs.length)
28
+ return null;
29
+ return (_jsxs("aside", { className: "uk" + (open ? "" : " is-closed"), "aria-label": "Caricamenti", children: [_jsxs("button", { className: "uk-head", onClick: () => setOpen((value) => !value), "aria-expanded": open, children: [_jsx("span", { className: "uk-head-text", children: summary(jobs) }), _jsx("span", { className: "uk-chevron", "aria-hidden": "true", children: open ? "▾" : "▴" })] }), open && (_jsx("ul", { className: "uk-list", children: jobs.map((job) => (_jsxs("li", { className: "uk-job is-" + job.state, children: [_jsxs("div", { className: "uk-job-top", children: [_jsx("strong", { children: job.title }), _jsx("span", { children: line(job) })] }), isBusy(job) && (_jsx("div", { className: "uk-track", children: _jsx("i", { style: { width: `${percent(job)}%` } }) })), job.error && _jsx("p", { className: "uk-error", children: job.error }), _jsxs("div", { className: "uk-job-actions", children: [job.state === "running" && (_jsx("button", { className: "ak-btn", onClick: () => pauseUpload(job.id), children: "Pausa" })), job.state === "paused" && (_jsx("button", { className: "ak-btn", onClick: () => resumeUpload(job.id), children: "Riprendi" })), isBusy(job) && (confirmStop === job.id ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "uk-confirm", children: "I file gi\u00E0 caricati verranno buttati." }), _jsx("button", { className: "ak-btn", onClick: () => setConfirmStop(null), children: "No" }), _jsx("button", { className: "ak-btn dl-danger-btn", onClick: () => { cancelUpload(job.id); setConfirmStop(null); }, children: "Annulla il caricamento" })] })) : (_jsx("button", { className: "ak-btn ak-btn-quiet", onClick: () => setConfirmStop(job.id), children: "Annulla" }))), !isBusy(job) && (_jsx("button", { className: "ak-btn ak-btn-quiet", onClick: () => dismissUpload(job.id), children: "Togli dall'elenco" }))] })] }, job.id))) }))] }));
30
+ }
31
+ function summary(jobs) {
32
+ const running = jobs.filter((job) => job.state === "running").length;
33
+ const paused = jobs.filter((job) => job.state === "paused").length;
34
+ const failed = jobs.filter((job) => job.state === "failed").length;
35
+ if (running)
36
+ return running === 1 ? "Caricamento in corso" : `${running} caricamenti in corso`;
37
+ if (paused)
38
+ return paused === 1 ? "Caricamento in pausa" : `${paused} caricamenti in pausa`;
39
+ if (failed)
40
+ return failed === 1 ? "Caricamento interrotto" : `${failed} caricamenti interrotti`;
41
+ return jobs.length === 1 ? "Caricamento completato" : "Caricamenti completati";
42
+ }
43
+ function line(job) {
44
+ if (job.state === "done") {
45
+ return job.skipped
46
+ ? `Pronto · ${job.skipped} senza anteprima`
47
+ : "Pronto";
48
+ }
49
+ if (job.state === "failed")
50
+ return "Interrotto";
51
+ if (job.state === "paused")
52
+ return "In pausa";
53
+ const progress = job.progress;
54
+ if (!progress)
55
+ return "Preparo…";
56
+ const phase = progress.phase === "originals" ? "Carico le foto"
57
+ : progress.phase === "derivatives" ? "Preparo le anteprime"
58
+ : "Ultimi controlli";
59
+ return `${phase} · ${progress.doneFiles} di ${progress.totalFiles}`;
60
+ }
61
+ function percent(job) {
62
+ const progress = job.progress;
63
+ if (!progress || !progress.totalFiles)
64
+ return 3;
65
+ return Math.max(3, Math.min(100, (progress.doneFiles / progress.totalFiles) * 100));
66
+ }
@@ -0,0 +1,12 @@
1
+ import type { AssetFile } from "../../index.js";
2
+ import { normalizeWorkshops, type Workshop, type WorkshopBlock } from "../../normalize.js";
3
+ export { normalizeWorkshops };
4
+ export type { Workshop, WorkshopBlock };
5
+ type Picker = (o: {
6
+ multiple: boolean;
7
+ }) => Promise<AssetFile[] | null>;
8
+ export declare function WorkshopEditionsEditor({ value, onChange, pickMediaFiles }: {
9
+ value: unknown;
10
+ onChange: (next: Workshop[]) => void;
11
+ pickMediaFiles?: Picker;
12
+ }): import("react").JSX.Element;
@@ -0,0 +1,101 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /* Le edizioni di una pagina workshop.
3
+ *
4
+ * ── L'idea ─────────────────────────────────────────────────────────────────────────────────
5
+ * Quarta tipologia con un editor suo, per la ragione delle altre tre: nel `SchemaForm` generico
6
+ * accanto alle edizioni comparivano «Lavori», «Sezioni», «Ritratto» e «Riconoscimenti» — campi
7
+ * di portfolio, gallery e "chi sono", che il form disegna perché lo schema li dichiara e lui non
8
+ * sa quale tipo di pagina si stia modificando.
9
+ *
10
+ * Un'edizione è una COSA — una locandina, un titolo, dove e quando — e si riconosce da quelle,
11
+ * come sul sito. L'elenco chiuso mostra quelle e basta, e si apre quella su cui si lavora. Il
12
+ * guscio della card è lo stesso dei lavori del portfolio (`ui/OpenCard`): sono lo stesso elenco
13
+ * con dentro un'altra cosa, e una seconda copia sarebbe stata la prima occasione per divergere.
14
+ *
15
+ * ── I blocchi ──────────────────────────────────────────────────────────────────────────────
16
+ * Dentro un'edizione, `blocks` è la sua articolazione: OBIETTIVO, PROGRAMMA PRIMO GIORNO,
17
+ * MATERIALI… Ognuno ha un titolo FACOLTATIVO — un blocco senza titolo è un paragrafo di coda
18
+ * (nella pagina di oggi, la riga sui contatti) e il template semplicemente non emette
19
+ * l'intestazione. Il campo lo dice, invece di lasciarlo scoprire: «vuoto: il testo resta senza
20
+ * intestazione».
21
+ *
22
+ * ── Perché l'introduzione non è qui ────────────────────────────────────────────────────────
23
+ * Il corpo markdown della pagina È l'introduzione, e sta sopra questo elenco, disegnata da
24
+ * `PageEditor` — nello stesso ordine in cui esce sul sito (WORKSHOP.md: l'introduzione apre la
25
+ * colonna dei testi, sopra la prima edizione).
26
+ *
27
+ * ── Le chiavi del drag & drop ──────────────────────────────────────────────────────────────
28
+ * Posizionali (`edizione-2`), come i lavori: le edizioni non hanno un `id` nel front matter e
29
+ * non glielo si aggiunge — sarebbe un campo in più nei file di contenuto solo per far
30
+ * funzionare l'interfaccia. Il prezzo è che «quale card è aperta» va ricalcolato a ogni
31
+ * operazione che sposta gli indici: lo fanno `movedOpenIndex`/`removedOpenIndex`.
32
+ */
33
+ import { useState } from "react";
34
+ import { DndContext, closestCenter } from "@dnd-kit/core";
35
+ import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable";
36
+ import { CSS } from "@dnd-kit/utilities";
37
+ import { ProseField } from "../../form/index.js";
38
+ import { normalizeWorkshops } from "../../normalize.js";
39
+ export { normalizeWorkshops };
40
+ import { ParagraphEditor } from "../ui/ParagraphEditor";
41
+ import { MediaSlot } from "../ui/MediaSlot";
42
+ import { OpenCard } from "../ui/OpenCard";
43
+ import { useDragSensors } from "../ui/dnd";
44
+ import { movedOpenIndex, removedOpenIndex } from "../lib/reorder";
45
+ import { Icon } from "../ui/icons";
46
+ const str = (value) => (typeof value === "string" ? value : "");
47
+ const list = (value) => (Array.isArray(value) ? value : []);
48
+ function emptyEdition() {
49
+ return { image: "", kicker: "", title: "", lead: "", blocks: [] };
50
+ }
51
+ /* ---- Un blocco di testo -------------------------------------------------------------------- */
52
+ function SortableBlock({ id, block, index, onPatch, onRemove }) {
53
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
54
+ return (_jsxs("article", { ref: setNodeRef, className: "pw-card" + (isDragging ? " is-dragging" : ""), style: { transform: CSS.Transform.toString(transform), transition }, children: [_jsxs("header", { className: "pw-card-head wsh-block-head", children: [_jsx("button", { type: "button", className: "pw-grip", "aria-label": `Riordina blocco ${index + 1}`, ...attributes, ...listeners, children: _jsx(Icon.grip, {}) }), _jsx("input", { className: "pw-field wsh-block-title", value: block.heading, maxLength: 120, placeholder: "Titolo del blocco \u2014 facoltativo", "aria-label": "Titolo del blocco", onChange: (event) => onPatch({ heading: event.target.value }) }), _jsx("button", { type: "button", className: "pw-icon-btn is-danger", "aria-label": "Elimina blocco", onClick: onRemove, children: _jsx(Icon.trash, {}) })] }), _jsx("div", { className: "pw-card-body", children: _jsx(ParagraphEditor, { className: "pw-field pw-textarea", rows: 6, value: block.body, placeholder: "Scrivi il testo del blocco.\nVai a capo per iniziare un paragrafo nuovo.", onChange: (body) => onPatch({ body }) }) })] }));
55
+ }
56
+ function BlockList({ items, onChange }) {
57
+ const sensors = useDragSensors();
58
+ const ids = items.map((_, index) => `blocco-${index}`);
59
+ const finish = ({ active, over }) => {
60
+ if (!over || active.id === over.id)
61
+ return;
62
+ onChange(arrayMove(items, ids.indexOf(String(active.id)), ids.indexOf(String(over.id))));
63
+ };
64
+ return (_jsxs("section", { className: "pw-block", children: [_jsxs("header", { className: "pw-block-head", children: [_jsx("h4", { children: "Blocchi di testo" }), _jsxs("button", { type: "button", className: "pw-add-quiet", onClick: () => onChange([...items, { heading: "", body: [] }]), children: [_jsx(Icon.plus, {}), " Aggiungi blocco"] })] }), items.length === 0 ? (_jsx("p", { className: "pw-empty-note", children: "Obiettivo, programma, materiali\u2026 Si leggono in colonna accanto alla locandina. Il titolo si pu\u00F2 lasciare vuoto: il testo resta senza intestazione." })) : (_jsx(DndContext, { sensors: sensors, collisionDetection: closestCenter, onDragEnd: finish, children: _jsx(SortableContext, { items: ids, strategy: verticalListSortingStrategy, children: _jsx("div", { className: "pw-card-list", children: items.map((block, index) => (_jsx(SortableBlock, { id: ids[index], block: block, index: index, onPatch: (patch) => onChange(items.map((item, i) => (i === index ? { ...item, ...patch } : item))), onRemove: () => onChange(items.filter((_, i) => i !== index)) }, ids[index]))) }) }) }))] }));
65
+ }
66
+ /* ---- Un'edizione ---------------------------------------------------------------------------- */
67
+ function Edition({ id, edition, index, total, open, onToggle, onPatch, onDuplicate, onRemove, pickMediaFiles }) {
68
+ const choosePoster = async () => {
69
+ const file = (await pickMediaFiles?.({ multiple: false }))?.[0];
70
+ if (file)
71
+ onPatch({ image: file.url });
72
+ };
73
+ /* A card chiusa si legge dove e quando (l'occhiello), che è ciò che distingue un'edizione da
74
+ un'altra dello stesso workshop. Il conteggio dei blocchi solo se l'occhiello manca: due
75
+ numeri e una data nella stessa riga sono già troppo. */
76
+ const meta = edition.kicker
77
+ || (edition.blocks.length ? `${edition.blocks.length} ${edition.blocks.length === 1 ? "blocco" : "blocchi"}` : "");
78
+ return (_jsxs(OpenCard, { id: id, index: index, total: total, open: open, thumb: edition.image, title: edition.title, empty: "Edizione senza titolo", meta: meta, kind: "questa edizione", onToggle: onToggle, actions: _jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "btn-soft is-quiet", onClick: onDuplicate, children: "Duplica" }), _jsx("button", { type: "button", className: "btn-soft is-danger", onClick: onRemove, children: "Elimina" })] }), children: [_jsxs("div", { className: "pw-identity", children: [_jsx(MediaSlot, { value: edition.image, ratio: "cover", empty: "Scegli la locandina", onPick: () => void choosePoster(), onClear: () => onPatch({ image: "" }) }), _jsxs("div", { className: "pw-identity-fields", children: [_jsxs("label", { className: "pw-label", children: [_jsxs("span", { children: ["Dove e quando ", _jsx("small", { children: "facoltativo" })] }), _jsx("input", { className: "pw-field", value: edition.kicker, maxLength: 80, placeholder: "Roma \u00B7 28\u201329 giugno 2022", onChange: (event) => onPatch({ kicker: event.target.value }) })] }), _jsxs("label", { className: "pw-label", children: [_jsx("span", { children: "Titolo" }), _jsx("input", { className: "pw-field pw-field-title", value: edition.title, maxLength: 120, placeholder: "Il nome del workshop", onChange: (event) => onPatch({ title: event.target.value }) })] }), _jsxs("label", { className: "pw-label", children: [_jsxs("span", { children: ["Apertura ", _jsx("small", { children: "facoltativa" })] }), _jsx(ProseField, { className: "pw-field pw-textarea", rows: 4, value: edition.lead, maxLength: 600, placeholder: "La frase in corsivo sotto al titolo.", onChange: (lead) => onPatch({ lead }) })] })] })] }), _jsx(BlockList, { items: edition.blocks, onChange: (blocks) => onPatch({ blocks }) })] }));
79
+ }
80
+ /* ---- L'elenco -------------------------------------------------------------------------------- */
81
+ export function WorkshopEditionsEditor({ value, onChange, pickMediaFiles }) {
82
+ const editions = normalizeWorkshops(value);
83
+ const [openAt, setOpenAt] = useState(null);
84
+ const sensors = useDragSensors();
85
+ const ids = editions.map((_, index) => `edizione-${index}`);
86
+ const replace = (next, nextOpen) => {
87
+ setOpenAt(nextOpen);
88
+ onChange(next);
89
+ };
90
+ const finish = ({ active, over }) => {
91
+ if (!over || active.id === over.id)
92
+ return;
93
+ const from = ids.indexOf(String(active.id));
94
+ const to = ids.indexOf(String(over.id));
95
+ if (from < 0 || to < 0)
96
+ return;
97
+ replace(arrayMove(editions, from, to), movedOpenIndex(openAt, from, to));
98
+ };
99
+ const add = () => replace([...editions, emptyEdition()], editions.length);
100
+ return (_jsxs("section", { className: "pw", "aria-label": "Edizioni dei workshop", children: [_jsxs("header", { className: "pw-head", children: [_jsxs("div", { children: [_jsx("h3", { children: "Edizioni" }), _jsx("p", { children: "L\u2019ordine \u00E8 quello in cui si leggono sul sito. La locandina resta ferma accanto al testo della sua edizione." })] }), _jsxs("button", { type: "button", className: "btn-soft is-primary", onClick: add, children: [_jsx(Icon.plus, {}), " Aggiungi edizione"] })] }), editions.length === 0 ? (_jsxs("button", { type: "button", className: "pw-blank", onClick: add, children: [_jsx(Icon.media, {}), _jsx("strong", { children: "Nessuna edizione, per ora" }), _jsx("span", { children: "Aggiungine una: bastano una locandina e un titolo." })] })) : (_jsx(DndContext, { sensors: sensors, collisionDetection: closestCenter, onDragEnd: finish, children: _jsx(SortableContext, { items: ids, strategy: verticalListSortingStrategy, children: _jsx("div", { className: "pw-list", children: editions.map((edition, index) => (_jsx(Edition, { id: ids[index], edition: edition, index: index, total: editions.length, open: openAt === index, pickMediaFiles: pickMediaFiles, onToggle: () => setOpenAt(openAt === index ? null : index), onPatch: (patch) => onChange(editions.map((item, i) => (i === index ? { ...item, ...patch } : item))), onDuplicate: () => replace(editions.flatMap((item, i) => (i === index ? [item, { ...item }] : [item])), index + 1), onRemove: () => replace(editions.filter((_, i) => i !== index), removedOpenIndex(openAt, index)) }, ids[index]))) }) }) }))] }));
101
+ }
@@ -0,0 +1,37 @@
1
+ import type * as z from "zod";
2
+ export type Shape = Record<string, z.ZodType>;
3
+ export interface SiteAdminConfig {
4
+ /** Il nome mostrato in cima al rail, e il ripiego del titolo nell'anteprima SEO. */
5
+ name: string;
6
+ /** L'indirizzo pubblico del sito, senza barra finale: serve alle anteprime SEO. */
7
+ publicBase: string;
8
+ /** Il nome del progetto Cloudflare Pages, mostrato nei comandi che la Panoramica suggerisce. */
9
+ pagesProject: string;
10
+ /**
11
+ * A chi scrive il cliente per accendere una funzione aggiuntiva: nome e indirizzo di chi gli
12
+ * ha fatto il sito. Erano cablati nel pannello — cioè il nome e l'email di una persona dentro
13
+ * un pacchetto pubblicato su npm.
14
+ */
15
+ support: {
16
+ name: string;
17
+ email: string;
18
+ };
19
+ /** Forma del front matter di una pagina della collezione. */
20
+ page: Shape;
21
+ /** Forma del singleton home: i suoi `group` diventano le voci della seconda colonna. */
22
+ home: Shape;
23
+ /** Peso di ordinamento di una pagina data la sua posizione nell'elenco. */
24
+ positionWeight: (item: Record<string, unknown>, index: number, all: Record<string, unknown>[]) => number;
25
+ /** Quali tipi di pagina hanno un corpo markdown oltre al front matter. */
26
+ hasBody: (type: unknown) => boolean;
27
+ /**
28
+ * Le funzioni aggiuntive accese su questo sito. Ciò che non è dichiarato è SPENTO: un
29
+ * interruttore dimenticato deve dare una funzione assente, non una accesa per sbaglio.
30
+ * Lo legge il rail (quali voci mostrare) e lo smistatore lato Worker (quali rotte esistono):
31
+ * una sola verità per due letture, o il pannello promette ciò che il server nega.
32
+ */
33
+ modules?: Record<string, boolean>;
34
+ }
35
+ /** Chiamata da `mountAdmin` prima del primo render. */
36
+ export declare function setSiteConfig(config: SiteAdminConfig): void;
37
+ export declare function site(): SiteAdminConfig;
@@ -0,0 +1,18 @@
1
+ /* NB: il modello delle sezioni gallery (`GALLERY_BLOCK_ID`, `emptySection`, i tipi) NON è qui.
2
+ * Stava nello schema del sito ma è finito nel nucleo del kit (`src/gallery.ts`): l'editor delle
3
+ * sezioni è una capacità del motore, e non poteva restare separato dal modello che modifica.
4
+ * È l'unica cosa che questa estrazione ha spostato invece di limitarsi a esporla. */
5
+ let current = null;
6
+ /** Chiamata da `mountAdmin` prima del primo render. */
7
+ export function setSiteConfig(config) {
8
+ current = config;
9
+ }
10
+ /* Un accesso che FALLISCE RUMOROSAMENTE. L'alternativa — restituire valori di ripiego — darebbe
11
+ * un pannello che si monta e mostra campi sbagliati, che è molto peggio di uno che non parte. */
12
+ export function site() {
13
+ if (!current) {
14
+ throw new Error("adminkit: la configurazione del sito non è stata impostata. " +
15
+ "Il pannello si monta con `mountAdmin({ site: … })`, mai renderizzando <App /> direttamente.");
16
+ }
17
+ return current;
18
+ }
@@ -0,0 +1,10 @@
1
+ /** Le proporzioni del riquadro. `portrait` è il ritratto della pagina "chi sono": in verticale
2
+ * perché una foto di una persona in un riquadro largo si guarda al centro di due bande vuote. */
3
+ export type MediaSlotRatio = "cover" | "wide" | "portrait";
4
+ export declare function MediaSlot({ value, ratio, empty, onPick, onClear }: {
5
+ value: string;
6
+ ratio: MediaSlotRatio;
7
+ empty: string;
8
+ onPick: () => void;
9
+ onClear: () => void;
10
+ }): import("react").JSX.Element;
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /* Una casella immagine: l'anteprima È il bersaglio, i comandi stanno sopra e compaiono al
3
+ * passaggio.
4
+ *
5
+ * Vive in `ui/` e non dentro un editor per la stessa ragione di `ParagraphEditor`: la usano i
6
+ * lavori del portfolio (copertina e immagini delle card) e la pagina "chi sono" (il ritratto).
7
+ * La seconda copia sarebbe stata la prima occasione per farle divergere.
8
+ *
9
+ * Le classi restano `.pw-*`: sono già scritte, condivise, e rinominarle avrebbe voluto dire
10
+ * toccare l'aspetto di un editor che funziona per fare posto a un secondo che ancora non c'era.
11
+ * È la stessa scelta già fatta per `.seg` (il segmentato di gallery e portfolio).
12
+ *
13
+ * Su un dispositivo touch il passaggio del mouse non esiste: `@media (hover: none)` in app.css
14
+ * tiene i comandi sempre visibili. Senza, sull'iPad la rimozione di un'immagine sarebbe
15
+ * irraggiungibile.
16
+ */
17
+ import { Icon } from "./icons";
18
+ export function MediaSlot({ value, ratio, empty, onPick, onClear }) {
19
+ return (_jsxs("div", { className: "pw-media" + (value ? " has-image" : ""), children: [_jsx("button", { type: "button", className: "pw-thumb " + (ratio === "cover" ? "is-cover" : ratio === "portrait" ? "is-portrait" : "is-large"), onClick: onPick, "aria-label": value ? "Cambia immagine" : empty, children: value ? _jsx("img", { src: value, alt: "" }) : _jsxs("span", { className: "pw-thumb-empty", children: [_jsx(Icon.media, {}), " ", empty] }) }), value && (_jsxs(_Fragment, { children: [_jsx("span", { className: "pw-media-hint", "aria-hidden": "true", children: "Cambia" }), _jsx("button", { type: "button", className: "pw-media-clear", "aria-label": "Rimuovi immagine", onClick: onClear, children: _jsx(Icon.trash, {}) })] }))] }));
20
+ }