@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.
- package/dist/api.d.ts +195 -0
- package/dist/api.js +118 -0
- package/dist/app/App.d.ts +3 -0
- package/dist/app/App.js +320 -0
- package/dist/app/app.css +2068 -0
- package/dist/app/index.d.ts +18 -0
- package/dist/app/index.js +33 -0
- package/dist/app/lib/bookings.d.ts +132 -0
- package/dist/app/lib/bookings.js +79 -0
- package/dist/app/lib/deliveries.d.ts +95 -0
- package/dist/app/lib/deliveries.js +104 -0
- package/dist/app/lib/delivery-drop.d.ts +1 -0
- package/dist/app/lib/delivery-drop.js +72 -0
- package/dist/app/lib/delivery-upload.d.ts +78 -0
- package/dist/app/lib/delivery-upload.js +585 -0
- package/dist/app/lib/groups.d.ts +9 -0
- package/dist/app/lib/groups.js +32 -0
- package/dist/app/lib/home.d.ts +11 -0
- package/dist/app/lib/home.js +67 -0
- package/dist/app/lib/overview.d.ts +82 -0
- package/dist/app/lib/overview.js +68 -0
- package/dist/app/lib/pages.d.ts +24 -0
- package/dist/app/lib/pages.js +156 -0
- package/dist/app/lib/publish.d.ts +18 -0
- package/dist/app/lib/publish.js +63 -0
- package/dist/app/lib/reorder.d.ts +3 -0
- package/dist/app/lib/reorder.js +31 -0
- package/dist/app/lib/requests.d.ts +10 -0
- package/dist/app/lib/requests.js +67 -0
- package/dist/app/lib/router.d.ts +1 -0
- package/dist/app/lib/router.js +14 -0
- package/dist/app/lib/settings.d.ts +29 -0
- package/dist/app/lib/settings.js +82 -0
- package/dist/app/lib/slug.d.ts +3 -0
- package/dist/app/lib/slug.js +23 -0
- package/dist/app/lib/text.d.ts +1 -0
- package/dist/app/lib/text.js +13 -0
- package/dist/app/lib/theme.d.ts +7 -0
- package/dist/app/lib/theme.js +43 -0
- package/dist/app/lib/upload-queue.d.ts +35 -0
- package/dist/app/lib/upload-queue.js +167 -0
- package/dist/app/routes/AboutEditor.d.ts +15 -0
- package/dist/app/routes/AboutEditor.js +122 -0
- package/dist/app/routes/Bookings.d.ts +21 -0
- package/dist/app/routes/Bookings.js +134 -0
- package/dist/app/routes/Deliveries.d.ts +37 -0
- package/dist/app/routes/Deliveries.js +563 -0
- package/dist/app/routes/GallerySectionsEditor.d.ts +12 -0
- package/dist/app/routes/GallerySectionsEditor.js +107 -0
- package/dist/app/routes/HomeEditor.d.ts +10 -0
- package/dist/app/routes/HomeEditor.js +188 -0
- package/dist/app/routes/Overview.d.ts +17 -0
- package/dist/app/routes/Overview.js +318 -0
- package/dist/app/routes/PageActions.d.ts +12 -0
- package/dist/app/routes/PageActions.js +93 -0
- package/dist/app/routes/PageEditor.d.ts +23 -0
- package/dist/app/routes/PageEditor.js +233 -0
- package/dist/app/routes/PageSeoEditor.d.ts +13 -0
- package/dist/app/routes/PageSeoEditor.js +102 -0
- package/dist/app/routes/PagesList.d.ts +10 -0
- package/dist/app/routes/PagesList.js +76 -0
- package/dist/app/routes/PortfolioWorksEditor.d.ts +12 -0
- package/dist/app/routes/PortfolioWorksEditor.js +139 -0
- package/dist/app/routes/Requests.d.ts +13 -0
- package/dist/app/routes/Requests.js +30 -0
- package/dist/app/routes/SiteSettingsEditor.d.ts +21 -0
- package/dist/app/routes/SiteSettingsEditor.js +66 -0
- package/dist/app/routes/UploadDock.d.ts +1 -0
- package/dist/app/routes/UploadDock.js +66 -0
- package/dist/app/routes/WorkshopEditionsEditor.d.ts +12 -0
- package/dist/app/routes/WorkshopEditionsEditor.js +101 -0
- package/dist/app/site.d.ts +37 -0
- package/dist/app/site.js +18 -0
- package/dist/app/ui/MediaSlot.d.ts +10 -0
- package/dist/app/ui/MediaSlot.js +20 -0
- package/dist/app/ui/OpenCard.d.ts +18 -0
- package/dist/app/ui/OpenCard.js +20 -0
- package/dist/app/ui/ParagraphEditor.d.ts +8 -0
- package/dist/app/ui/ParagraphEditor.js +37 -0
- package/dist/app/ui/dnd.d.ts +1 -0
- package/dist/app/ui/dnd.js +11 -0
- package/dist/app/ui/icons.d.ts +26 -0
- package/dist/app/ui/icons.js +45 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +26 -0
- package/dist/form/MediaBrowser.d.ts +13 -0
- package/dist/form/MediaBrowser.js +24 -0
- package/dist/form/MediaPicker.d.ts +10 -0
- package/dist/form/MediaPicker.js +20 -0
- package/dist/form/SchemaForm.d.ts +21 -0
- package/dist/form/SchemaForm.js +67 -0
- package/dist/form/Trash.d.ts +1 -0
- package/dist/form/Trash.js +42 -0
- package/dist/form/fields/index.d.ts +1 -0
- package/dist/form/fields/index.js +110 -0
- package/dist/form/fields/objectList.d.ts +1 -0
- package/dist/form/fields/objectList.js +99 -0
- package/dist/form/index.d.ts +8 -0
- package/dist/form/index.js +8 -0
- package/dist/form/media/Lightbox.d.ts +4 -0
- package/dist/form/media/Lightbox.js +70 -0
- package/dist/form/media/MediaFolders.d.ts +20 -0
- package/dist/form/media/MediaFolders.js +116 -0
- package/dist/form/media/MediaGrid.d.ts +8 -0
- package/dist/form/media/MediaGrid.js +139 -0
- package/dist/form/media/UploadDialog.d.ts +4 -0
- package/dist/form/media/UploadDialog.js +37 -0
- package/dist/form/media/useMediaLibrary.d.ts +55 -0
- package/dist/form/media/useMediaLibrary.js +495 -0
- package/dist/form/prose/ProseBar.d.ts +21 -0
- package/dist/form/prose/ProseBar.js +111 -0
- package/dist/form/prose/ProseField.d.ts +11 -0
- package/dist/form/prose/ProseField.js +152 -0
- package/dist/form/prose/pages.d.ts +6 -0
- package/dist/form/prose/pages.js +13 -0
- package/dist/form/registry.d.ts +20 -0
- package/dist/form/registry.js +15 -0
- package/dist/frontmatter.d.ts +20 -0
- package/dist/frontmatter.js +202 -0
- package/dist/functions/_middleware.js +101 -0
- package/dist/functions/_shared/analytics-ga4.js +248 -0
- package/dist/functions/_shared/analytics-umami.js +119 -0
- package/dist/functions/_shared/assets.js +90 -0
- package/dist/functions/_shared/b2.js +300 -0
- package/dist/functions/_shared/delivery-crypto.js +99 -0
- package/dist/functions/_shared/delivery-purge.js +222 -0
- package/dist/functions/_shared/edge-cache.js +37 -0
- package/dist/functions/_shared/exif.js +53 -0
- package/dist/functions/_shared/github.js +138 -0
- package/dist/functions/_shared/imagesize.js +101 -0
- package/dist/functions/_shared/media-content.js +200 -0
- package/dist/functions/_shared/pending-media.js +98 -0
- package/dist/functions/_shared/refs.js +155 -0
- package/dist/functions/api/analytics.js +106 -0
- package/dist/functions/api/assets/delete.js +241 -0
- package/dist/functions/api/assets/folder.js +43 -0
- package/dist/functions/api/assets/folders.js +32 -0
- package/dist/functions/api/assets/move.js +253 -0
- package/dist/functions/api/assets/preview.js +17 -0
- package/dist/functions/api/assets/trash/delete.js +13 -0
- package/dist/functions/api/assets/trash/empty.js +16 -0
- package/dist/functions/api/assets/trash/restore.js +43 -0
- package/dist/functions/api/assets/trash.js +28 -0
- package/dist/functions/api/assets/upload.js +54 -0
- package/dist/functions/api/assets/usage.js +41 -0
- package/dist/functions/api/assets.js +94 -0
- package/dist/functions/api/deliveries.js +629 -0
- package/dist/functions/api/delivery/hero.js +21 -0
- package/dist/functions/api/delivery/image.js +35 -0
- package/dist/functions/api/delivery/multipart.js +261 -0
- package/dist/functions/api/pages/get.js +26 -0
- package/dist/functions/api/pages.js +354 -0
- package/dist/functions/api/publish.js +134 -0
- package/dist/functions/api/requests.js +57 -0
- package/dist/functions/api/settings.js +236 -0
- package/dist/functions/api/singleton.js +105 -0
- package/dist/functions/api/storage.js +125 -0
- package/dist/functions/index.js +124 -0
- package/dist/functions/site.js +38 -0
- package/dist/gallery.d.ts +28 -0
- package/dist/gallery.js +18 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/model.d.ts +2 -0
- package/dist/model.js +47 -0
- package/dist/normalize.d.ts +50 -0
- package/dist/normalize.js +120 -0
- package/dist/preview.d.ts +3 -0
- package/dist/preview.js +82 -0
- package/dist/prose.d.ts +17 -0
- package/dist/prose.js +205 -0
- package/dist/registries.d.ts +43 -0
- package/dist/registries.js +29 -0
- package/dist/serialize.d.ts +26 -0
- package/dist/serialize.js +136 -0
- package/dist/testing.d.ts +26 -0
- package/dist/testing.js +66 -0
- package/dist/theme.css +636 -0
- package/dist/yaml.d.ts +8 -0
- package/dist/yaml.js +90 -0
- package/package.json +97 -0
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
/* Lo STATO della libreria media, separato da come si vede.
|
|
2
|
+
*
|
|
3
|
+
* Serviva perché le due parti non stanno più nello stesso pezzo di schermo: le cartelle sono la
|
|
4
|
+
* seconda colonna dell'admin, la griglia è la terza, e in mezzo c'è la barra del guscio. Con
|
|
5
|
+
* tutto dentro un componente solo, l'unico modo di dividerle sarebbe stato duplicare lo stato —
|
|
6
|
+
* cioè avere due idee diverse di quale cartella si sta guardando.
|
|
7
|
+
*
|
|
8
|
+
* Il modale di scelta (`MediaBrowser`) chiama questo stesso hook e mette i due pezzi affiancati:
|
|
9
|
+
* una sola implementazione, due impaginazioni.
|
|
10
|
+
*/
|
|
11
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
12
|
+
import { api, uploadQueue, ApiError } from "../../api.js";
|
|
13
|
+
import { storageKey } from "../../config.js";
|
|
14
|
+
/* Quanti file per richiesta nelle operazioni lunghe (cestina, sposta). Ogni file costa al
|
|
15
|
+
* server tre operazioni su R2 — legge, riscrive altrove, cancella — su oggetti da qualche MB.
|
|
16
|
+
* Dieci tiene una richiesta sotto i pochi secondi, che è la soglia oltre la quale chi guarda
|
|
17
|
+
* pensa che sia bloccato. Alzarlo fa tornare il difetto: richieste lunghe che muoiono a metà. */
|
|
18
|
+
const CHUNK = 10;
|
|
19
|
+
export const SORT_LABELS = {
|
|
20
|
+
uploaded: "caricamento", capturedAt: "scatto", name: "nome", size: "peso",
|
|
21
|
+
};
|
|
22
|
+
/* Dove eravamo l'ultima volta. Stessa convenzione delle preferenze di ordinamento
|
|
23
|
+
* (`sortKey`/`sortDir`): una chiave sola, il nome del prefisso, niente di più.
|
|
24
|
+
*
|
|
25
|
+
* Il prefisso del namespace lo decide il sito che monta il kit (`setStoragePrefix` in
|
|
26
|
+
* `config.ts`): qui dentro non può esserci il nome di un cliente. */
|
|
27
|
+
const LAST_PREFIX = "lastPrefix";
|
|
28
|
+
export function useMediaLibrary({ mode, multiple = true, active = true, remember = false }) {
|
|
29
|
+
const [prefix, setPrefix] = useState("");
|
|
30
|
+
const [folders, setFolders] = useState([]);
|
|
31
|
+
const [files, setFiles] = useState([]);
|
|
32
|
+
const [subfolders, setSubfolders] = useState([]);
|
|
33
|
+
const [selected, setSelected] = useState(new Set());
|
|
34
|
+
const [busy, setBusy] = useState(false);
|
|
35
|
+
const [loaded, setLoaded] = useState(false);
|
|
36
|
+
const [error, setError] = useState(null);
|
|
37
|
+
const [progress, setProgress] = useState(null);
|
|
38
|
+
const [uploadOpen, setUploadOpen] = useState(false);
|
|
39
|
+
const [viewing, setViewing] = useState(null);
|
|
40
|
+
// Le preferenze di ordinamento restano dov'erano — il prefisso che il sito dichiara al
|
|
41
|
+
// bootstrap ricostruisce le chiavi di prima: la memoria muscolare di chi usa il pannello
|
|
42
|
+
// tutti i giorni non ha motivo di azzerarsi per una riorganizzazione dei sorgenti.
|
|
43
|
+
const [sortKey, setSortKey] = useState(() => localStorage.getItem(storageKey("sortKey")) || "uploaded");
|
|
44
|
+
const [sortDir, setSortDir] = useState(() => localStorage.getItem(storageKey("sortDir")) || "desc");
|
|
45
|
+
/* I nomi per esteso già visti. Servono al percorso in alto: dentro `food/piatti-2024/`
|
|
46
|
+
* l'elenco delle cartelle non contiene più né l'una né l'altra — `folders` ha il primo livello
|
|
47
|
+
* e `subfolders` i FIGLI di dove sei — quindi senza memoria il percorso mostrerebbe gli slug,
|
|
48
|
+
* cioè proprio ciò che l'etichetta serve a non far vedere. */
|
|
49
|
+
const seenLabels = useRef({});
|
|
50
|
+
/* `fallback` serve solo al primo caricamento con la cartella ricordata: quella cartella può
|
|
51
|
+
* essere stata rinominata o cestinata da allora, e riaprirsi su un prefisso che non esiste
|
|
52
|
+
* più mostra una griglia vuota senza dire perché. In quel caso si torna alla radice. */
|
|
53
|
+
const load = useCallback(async (p, fallback = false) => {
|
|
54
|
+
setBusy(true);
|
|
55
|
+
setError(null);
|
|
56
|
+
try {
|
|
57
|
+
const [a, f] = await Promise.all([api.assets(p), api.folders()]);
|
|
58
|
+
if (fallback && p && !(f.folders ?? []).some((x) => p === x.prefix || p.startsWith(x.prefix))) {
|
|
59
|
+
setBusy(false);
|
|
60
|
+
return load("");
|
|
61
|
+
}
|
|
62
|
+
for (const x of [...(f.folders ?? []), ...(a.folders ?? [])]) {
|
|
63
|
+
if (x.label)
|
|
64
|
+
seenLabels.current[x.prefix] = x.label;
|
|
65
|
+
}
|
|
66
|
+
if (p && a.label)
|
|
67
|
+
seenLabels.current[p] = a.label;
|
|
68
|
+
setFiles(a.files ?? []);
|
|
69
|
+
setSubfolders(a.folders ?? []);
|
|
70
|
+
setFolders(f.folders ?? []);
|
|
71
|
+
setPrefix(p);
|
|
72
|
+
setSelected(new Set());
|
|
73
|
+
setViewing(null);
|
|
74
|
+
setLoaded(true);
|
|
75
|
+
/* Scrive SOLO chi ricorda, cioè il modale di scelta. Scrivendo anche dalla vista «Media»
|
|
76
|
+
* il suo caricamento iniziale — che parte dalla radice — cancellerebbe la cartella
|
|
77
|
+
* ricordata: aprire i media una volta basterebbe a far ripartire il selettore da capo. */
|
|
78
|
+
if (remember) {
|
|
79
|
+
try {
|
|
80
|
+
localStorage.setItem(storageKey(LAST_PREFIX), p);
|
|
81
|
+
}
|
|
82
|
+
catch { /* niente da fare */ }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
setError(e instanceof ApiError ? e.message : String(e));
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
setBusy(false);
|
|
90
|
+
}
|
|
91
|
+
}, [remember]);
|
|
92
|
+
/* Caricamento PIGRO come per la home: chi apre l'admin sulle pagine non paga l'elenco del
|
|
93
|
+
* bucket. `loaded` e non `files.length`, altrimenti una cartella vuota ritenterebbe all'infinito.
|
|
94
|
+
*
|
|
95
|
+
* Chi sceglie una foto riparte dalla cartella dell'ultima volta: mettendo insieme una gallery
|
|
96
|
+
* si torna venti volte nella stessa cartella, e ricominciare ogni volta dalla radice è venti
|
|
97
|
+
* navigazioni identiche. La vista «Media» invece riparte dalla radice: là si guarda la
|
|
98
|
+
* libreria, e il punto di partenza è l'insieme.
|
|
99
|
+
*
|
|
100
|
+
* `error` è nella guardia perché un caricamento FALLITO non imposta `loaded`: senza, l'effetto
|
|
101
|
+
* si ritroverebbe nelle stesse condizioni che l'hanno fatto partire e riproverebbe in un ciclo
|
|
102
|
+
* — una raffica di richieste al bucket proprio mentre qualcosa non funziona. Chi vuole
|
|
103
|
+
* riprovare lo chiede: il pulsante di ricarica azzera `error` e questo effetto riparte. */
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!active || loaded || busy || error)
|
|
106
|
+
return;
|
|
107
|
+
let start = "";
|
|
108
|
+
if (remember) {
|
|
109
|
+
try {
|
|
110
|
+
start = localStorage.getItem(storageKey(LAST_PREFIX)) || "";
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
start = "";
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
void load(start, true);
|
|
117
|
+
}, [active, loaded, busy, error, load, remember]);
|
|
118
|
+
useEffect(() => { localStorage.setItem(storageKey("sortKey"), sortKey); localStorage.setItem(storageKey("sortDir"), sortDir); }, [sortKey, sortDir]);
|
|
119
|
+
/* Chiudere o ricaricare mentre un'operazione è in corso la interrompe: il gruppo in volo non
|
|
120
|
+
* arriva mai. Ora ogni gruppo è definitivo, quindi si perde poco — ma vale comunque la pena
|
|
121
|
+
* chiedere, perché è esattamente il gesto che uno fa quando "non sta succedendo niente". */
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!progress)
|
|
124
|
+
return;
|
|
125
|
+
const warn = (e) => { e.preventDefault(); };
|
|
126
|
+
addEventListener("beforeunload", warn);
|
|
127
|
+
return () => removeEventListener("beforeunload", warn);
|
|
128
|
+
}, [progress]);
|
|
129
|
+
const shown = useMemo(() => {
|
|
130
|
+
const list = mode === "pick" ? files.filter((f) => (f.type ?? "").startsWith("image/")) : files.slice();
|
|
131
|
+
const dir = sortDir === "asc" ? 1 : -1;
|
|
132
|
+
return list.sort((a, b) => {
|
|
133
|
+
const x = a[sortKey] ?? "", y = b[sortKey] ?? "";
|
|
134
|
+
return (x < y ? -1 : x > y ? 1 : 0) * dir;
|
|
135
|
+
});
|
|
136
|
+
}, [files, sortKey, sortDir, mode]);
|
|
137
|
+
/* Aggiornamento FUNZIONALE, non `setSelected(new Set(selected))`: due click ravvicinati
|
|
138
|
+
* finiscono nello stesso batch di React e leggerebbero entrambi lo stesso stato vecchio,
|
|
139
|
+
* quindi il secondo cancellerebbe il primo. Con un umano che clicca piano non si vedrebbe
|
|
140
|
+
* quasi mai — motivo per cui è un bug peggiore, non migliore. */
|
|
141
|
+
const toggle = useCallback((key) => {
|
|
142
|
+
setSelected((prev) => {
|
|
143
|
+
const next = new Set(prev);
|
|
144
|
+
if (next.has(key))
|
|
145
|
+
next.delete(key);
|
|
146
|
+
else {
|
|
147
|
+
if (!multiple && mode === "pick")
|
|
148
|
+
next.clear();
|
|
149
|
+
next.add(key);
|
|
150
|
+
}
|
|
151
|
+
return next;
|
|
152
|
+
});
|
|
153
|
+
}, [multiple, mode]);
|
|
154
|
+
/* «Seleziona tutto» prende anche le SOTTOCARTELLE mostrate, non solo le foto: sono lì, si
|
|
155
|
+
* vedono, e sarebbe strano che «tutto» ne escludesse una parte. Nel modale di scelta no —
|
|
156
|
+
* là si scelgono foto. */
|
|
157
|
+
const setAll = useCallback((on) => {
|
|
158
|
+
if (!on)
|
|
159
|
+
return setSelected(new Set());
|
|
160
|
+
const ids = mode === "manage" ? subfolders.map((f) => f.prefix) : [];
|
|
161
|
+
setSelected(new Set([...ids, ...shown.map((f) => f.key)]));
|
|
162
|
+
}, [shown, subfolders, mode]);
|
|
163
|
+
const upload = useCallback(async (list) => {
|
|
164
|
+
const arr = Array.from(list);
|
|
165
|
+
if (!arr.length)
|
|
166
|
+
return true;
|
|
167
|
+
setProgress({ done: 0, total: arr.length, verb: "Carico" });
|
|
168
|
+
const errs = [];
|
|
169
|
+
try {
|
|
170
|
+
await uploadQueue(prefix, arr, (done, total, _r, err) => {
|
|
171
|
+
setProgress({ done, total, verb: "Carico" });
|
|
172
|
+
if (err)
|
|
173
|
+
errs.push(err);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
setProgress(null);
|
|
178
|
+
// SEMPRE, anche se qualcosa è fallito: dopo un'operazione a metà l'elenco che si vede è
|
|
179
|
+
// una bugia, ed è peggio dell'errore.
|
|
180
|
+
await load(prefix);
|
|
181
|
+
}
|
|
182
|
+
// Dopo `load`, che azzera il messaggio: prima veniva scritto e subito cancellato.
|
|
183
|
+
if (errs.length)
|
|
184
|
+
setError(`Non caricati: ${errs.join("; ")}`);
|
|
185
|
+
return errs.length === 0;
|
|
186
|
+
}, [prefix, load]);
|
|
187
|
+
const labelOf = useCallback((p) => {
|
|
188
|
+
const f = folders.find((x) => x.prefix === p) ?? subfolders.find((x) => x.prefix === p);
|
|
189
|
+
return f?.label || seenLabels.current[p] || p.replace(/\/$/, "").split("/").pop() || "";
|
|
190
|
+
}, [folders, subfolders, prefix]);
|
|
191
|
+
/* Il nome si manda com'è stato scritto: lo slug della chiave lo calcola il server, e resta
|
|
192
|
+
* un dettaglio del bucket. Chi ha scritto «Hospitality & Interiors» continua a leggere
|
|
193
|
+
* «Hospitality & Interiors».
|
|
194
|
+
*
|
|
195
|
+
* DOVE si crea è un parametro, non lo stato della navigazione. Prima il pulsante nella colonna
|
|
196
|
+
* creava una cartella di primo livello o una sottocartella a seconda di dove ti trovavi: lo
|
|
197
|
+
* stesso pulsante, due effetti diversi, e nessun modo di saperlo prima di premerlo. Ora ce ne
|
|
198
|
+
* sono due, ognuno con il suo posto — la colonna crea al primo livello, quello sopra la
|
|
199
|
+
* griglia crea dentro la cartella aperta, a qualunque profondità. */
|
|
200
|
+
const newFolder = useCallback(async (parent) => {
|
|
201
|
+
const dove = parent ? ` dentro «${labelOf(parent)}»` : "";
|
|
202
|
+
const name = prompt(`Nome della cartella${dove}`);
|
|
203
|
+
if (!name || !name.trim())
|
|
204
|
+
return;
|
|
205
|
+
try {
|
|
206
|
+
await api.createFolder(parent, name.trim(), { label: name.trim() });
|
|
207
|
+
await load(prefix);
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
setError(e instanceof ApiError ? e.message : String(e));
|
|
211
|
+
}
|
|
212
|
+
}, [prefix, load, labelOf]);
|
|
213
|
+
/* Nella selezione convivono file e cartelle, e si riconoscono da soli: la chiave di una
|
|
214
|
+
* cartella finisce con "/". Ogni operazione da qui in poi le separa in due liste. */
|
|
215
|
+
const filesIn = (ids) => ids.filter((id) => !id.endsWith("/"));
|
|
216
|
+
const foldersIn = (ids) => ids.filter((id) => id.endsWith("/"));
|
|
217
|
+
/** Le chiavi dei file dentro una cartella, a QUALUNQUE profondità. */
|
|
218
|
+
const keysUnder = async (p) => (await api.assets(p, { recursive: true })).files.map((f) => f.key);
|
|
219
|
+
/* Cancellazione: prima si CHIEDE dove sono usati quei file, e lo si dice. Cancellare una foto
|
|
220
|
+
* che è nella home senza avvisare è il modo classico per far comparire un buco in produzione
|
|
221
|
+
* senza che nessuno colleghi le due cose.
|
|
222
|
+
*
|
|
223
|
+
* POI SI PROCEDE A GRUPPI, ed è la parte che conta. Cestinare è get + put + delete per file:
|
|
224
|
+
* ottanta foto in una richiesta sola ci mettono decine di secondi, la richiesta muore per
|
|
225
|
+
* strada — o la interrompe chi ricarica la pagina non vedendo succedere niente — e restano
|
|
226
|
+
* spostate a metà. A gruppi di CHUNK ogni richiesta dura pochi secondi ed è DEFINITIVA: quel
|
|
227
|
+
* che è passato è passato, e ricaricare non disfa niente.
|
|
228
|
+
*
|
|
229
|
+
* I riferimenti nelle pagine si puliscono UNA volta sola, prima, con `refsOnly`: è quella la
|
|
230
|
+
* parte che costa un commit, e un commit per gruppo vorrebbe dire otto build per una
|
|
231
|
+
* cancellazione. */
|
|
232
|
+
const remove = useCallback(async (ids) => {
|
|
233
|
+
if (!ids.length)
|
|
234
|
+
return;
|
|
235
|
+
setBusy(true);
|
|
236
|
+
// L'errore va tenuto da parte: `load` in coda azzera il messaggio, quindi va rimesso DOPO,
|
|
237
|
+
// o l'operazione fallisce in silenzio e l'elenco aggiornato sembra dire che è andata bene.
|
|
238
|
+
let failure = null;
|
|
239
|
+
try {
|
|
240
|
+
const prefixes = foldersIn(ids);
|
|
241
|
+
const keys = filesIn(ids);
|
|
242
|
+
// Una cartella si cancella con TUTTO quello che contiene, a qualunque profondità: fermarsi
|
|
243
|
+
// al primo livello lascerebbe file orfani sotto un prefisso che non si vede più.
|
|
244
|
+
for (const p of prefixes)
|
|
245
|
+
keys.push(...(await keysUnder(p)));
|
|
246
|
+
const quante = [
|
|
247
|
+
prefixes.length
|
|
248
|
+
? (prefixes.length === 1 ? `la cartella «${labelOf(prefixes[0])}»` : `${prefixes.length} cartelle`)
|
|
249
|
+
: "",
|
|
250
|
+
keys.length ? (keys.length === 1 ? "1 foto" : `${keys.length} foto`) : "",
|
|
251
|
+
].filter(Boolean).join(" e ");
|
|
252
|
+
/* La scansione è parte della sicurezza dell'operazione, non un controllo decorativo. Se
|
|
253
|
+
* GitHub non è leggibile non si procede: il server applica la stessa regola prima di
|
|
254
|
+
* toccare R2, ma fermarsi qui evita una conferma che verrebbe subito rifiutata. */
|
|
255
|
+
const u = await api.usage({ keys, prefixes });
|
|
256
|
+
if (u.blockers?.length) {
|
|
257
|
+
throw new Error("Prima di eliminare queste foto devi sostituirle nel contenuto:\n"
|
|
258
|
+
+ u.blockers.map((b) => `• ${b.label}: ${b.reason}`).join("\n"));
|
|
259
|
+
}
|
|
260
|
+
/* LA RIGA IN PIÙ QUANDO LE FOTO SONO IN USO, e non è pignoleria.
|
|
261
|
+
*
|
|
262
|
+
* Le foto stanno nel bucket, che è uno solo: non esiste una versione "in bozza" di un
|
|
263
|
+
* file. Eliminarne una la toglie SUBITO dal sito pubblicato, mentre la ripulitura dei
|
|
264
|
+
* riferimenti nelle pagine resta sulla bozza finché non si pubblica — quindi per quel
|
|
265
|
+
* tempo le pagine online chiedono una foto che non c'è più.
|
|
266
|
+
*
|
|
267
|
+
* La finestra esisteva anche prima (durava la build, un paio di minuti); ora dura fino
|
|
268
|
+
* alla pubblicazione, cioè quanto decide chi sta lavorando. Non si aggiunge macchinario
|
|
269
|
+
* per chiuderla: si dice, e si dice SOLO quando è vero — un avviso che compare sempre
|
|
270
|
+
* smette di essere letto, e allora non avvisa più di niente. */
|
|
271
|
+
const msg = u.count
|
|
272
|
+
? `Eliminare ${quante}?\n\nATTENZIONE: ${u.count} di queste foto sono usate in: ${u.pages.join(", ")}.\n`
|
|
273
|
+
+ `I riferimenti verranno rimossi da quelle sorgenti.\n\n`
|
|
274
|
+
+ `Le foto spariscono subito dal sito online; le pagine si sistemano quando pubblichi.`
|
|
275
|
+
: `Eliminare ${quante}?`;
|
|
276
|
+
if (!confirm(`${msg}\n\nLe foto vanno nel cestino e si possono recuperare.`))
|
|
277
|
+
return;
|
|
278
|
+
let refsToken;
|
|
279
|
+
if (keys.length) {
|
|
280
|
+
setProgress({ done: 0, total: keys.length, verb: "Cestino" });
|
|
281
|
+
const preflight = await api.remove({ keys }, { refsOnly: true });
|
|
282
|
+
if (!preflight.refsToken)
|
|
283
|
+
throw new Error("Il server non ha confermato la verifica dei riferimenti.");
|
|
284
|
+
refsToken = preflight.refsToken;
|
|
285
|
+
let done = 0;
|
|
286
|
+
for (let i = 0; i < keys.length; i += CHUNK) {
|
|
287
|
+
const group = keys.slice(i, i + CHUNK);
|
|
288
|
+
const r = await api.remove({ keys: group }, { skipRefs: true, refsToken: preflight.refsToken });
|
|
289
|
+
done += r.trashed ?? group.length;
|
|
290
|
+
setProgress({ done, total: keys.length, verb: "Cestino" });
|
|
291
|
+
}
|
|
292
|
+
if (done < keys.length) {
|
|
293
|
+
failure = `Cestinate ${done} foto su ${keys.length}: le altre sono ancora lì, riprova.`;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// La spazzata sui prefissi porta via i `.keep` — cioè le cartelle vuote rimaste.
|
|
297
|
+
if (prefixes.length) {
|
|
298
|
+
// Se sotto il prefisso comparisse nel frattempo un file non verificato, il server
|
|
299
|
+
// rifiuterebbe la richiesta: il token autorizza soltanto le chiavi del preflight.
|
|
300
|
+
await api.remove({ prefixes }, { skipRefs: true, ...(refsToken ? { refsToken } : {}) });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
catch (e) {
|
|
304
|
+
failure = e instanceof ApiError ? e.message : String(e);
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
setProgress(null);
|
|
308
|
+
setBusy(false);
|
|
309
|
+
// SEMPRE, anche dopo un errore: l'elenco di prima è una bugia, perché una parte dei file
|
|
310
|
+
// può essere passata davvero.
|
|
311
|
+
await load(prefix);
|
|
312
|
+
if (failure)
|
|
313
|
+
setError(failure);
|
|
314
|
+
}
|
|
315
|
+
}, [prefix, load, labelOf]);
|
|
316
|
+
/* SPOSTARE UNA CARTELLA è spostare tutto ciò che contiene: in R2 la cartella non esiste, è un
|
|
317
|
+
* pezzo di chiave. Cambia quindi l'indirizzo pubblico di ogni foto dentro, ed è per questo che
|
|
318
|
+
* i riferimenti nelle pagine si riscrivono nello stesso giro — altrimenti le gallery che le
|
|
319
|
+
* usano si svuotano.
|
|
320
|
+
*
|
|
321
|
+
* Lo slug della destinazione lo calcola il SERVER, creando la cartella: client e server non
|
|
322
|
+
* possono avere due idee diverse di come si fa uno slug. Le sottocartelle si ricreano prima di
|
|
323
|
+
* spostare i file, dalla più vicina alla radice, perché la loro etichetta vive nel `.keep` e
|
|
324
|
+
* non seguirebbe i file da sola.
|
|
325
|
+
*
|
|
326
|
+
* Restituisce un messaggio d'errore, o null se è andata. */
|
|
327
|
+
const moveFolder = useCallback(async (from, destParent, newLabel) => {
|
|
328
|
+
const name = (newLabel ?? labelOf(from)).trim();
|
|
329
|
+
if (!name)
|
|
330
|
+
return null;
|
|
331
|
+
if (destParent === from || destParent.startsWith(from)) {
|
|
332
|
+
return "Una cartella non si può spostare dentro sé stessa.";
|
|
333
|
+
}
|
|
334
|
+
const created = await api.createFolder(destParent, name, { label: name });
|
|
335
|
+
const dest = created.prefix;
|
|
336
|
+
if (dest === from) {
|
|
337
|
+
// Stesso posto e stesso slug: è cambiato solo il nome per esteso.
|
|
338
|
+
await api.createFolder(destParent, name, { label: name, relabel: true });
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
if (created.exists)
|
|
342
|
+
return `C'è già una cartella «${name}» in quella posizione.`;
|
|
343
|
+
const inside = await api.assets(from, { recursive: true });
|
|
344
|
+
for (const sub of [...inside.folders].sort((a, b) => a.prefix.length - b.prefix.length)) {
|
|
345
|
+
const rel = sub.prefix.slice(from.length); // "b/" oppure "b/c/"
|
|
346
|
+
const subName = rel.replace(/\/$/, "").split("/").pop();
|
|
347
|
+
await api.createFolder(dest + rel.replace(/[^/]+\/$/, ""), sub.label || subName, {
|
|
348
|
+
label: sub.label || subName,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
// I file si spostano raggruppati per sottocartella: `move` accetta UNA destinazione, e chi
|
|
352
|
+
// sta in "b/" deve ritrovarsi in "<nuova>/b/", non tutto appiattito nella radice.
|
|
353
|
+
const byDir = {};
|
|
354
|
+
for (const f of inside.files) {
|
|
355
|
+
const rel = f.key.slice(from.length);
|
|
356
|
+
const cut = rel.lastIndexOf("/");
|
|
357
|
+
const dir = cut >= 0 ? rel.slice(0, cut + 1) : "";
|
|
358
|
+
(byDir[dir] ||= []).push(f.key);
|
|
359
|
+
}
|
|
360
|
+
const total = inside.files.length;
|
|
361
|
+
const verb = newLabel ? "Rinomino" : "Sposto";
|
|
362
|
+
let done = 0;
|
|
363
|
+
setProgress({ done, total, verb });
|
|
364
|
+
const urlMap = {};
|
|
365
|
+
const copyFailures = [];
|
|
366
|
+
for (const [dir, keys] of Object.entries(byDir)) {
|
|
367
|
+
for (let i = 0; i < keys.length; i += CHUNK) {
|
|
368
|
+
const group = keys.slice(i, i + CHUNK);
|
|
369
|
+
const r = await api.move(group, dest + dir, { skipRefs: true });
|
|
370
|
+
Object.assign(urlMap, r.urlMap ?? {});
|
|
371
|
+
copyFailures.push(...(r.failed ?? []));
|
|
372
|
+
done += r.moved ?? 0;
|
|
373
|
+
setProgress({ done, total, verb });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const applied = Object.keys(urlMap).length ? await api.moveApplyRefs(urlMap) : null;
|
|
377
|
+
const cleanupFailures = applied?.failed ?? [];
|
|
378
|
+
if (copyFailures.length || cleanupFailures.length) {
|
|
379
|
+
return [
|
|
380
|
+
copyFailures.length ? `${copyFailures.length} file non sono stati copiati` : "",
|
|
381
|
+
cleanupFailures.length ? `${cleanupFailures.length} originali sono rimasti come duplicati` : "",
|
|
382
|
+
].filter(Boolean).join("; ") + ". La cartella originale è stata conservata.";
|
|
383
|
+
}
|
|
384
|
+
// I `.keep` della vecchia cartella, che altrimenti la lascerebbero nell'elenco, vuota.
|
|
385
|
+
await api.remove({ prefixes: [from] }, { skipRefs: true });
|
|
386
|
+
return null;
|
|
387
|
+
}, [labelOf]);
|
|
388
|
+
/* Spostare NON è solo copiare l'oggetto: la Function sposta il file dentro R2 e nello stesso
|
|
389
|
+
* giro riscrive i riferimenti /media/<vecchia> nelle pagine. Vale per i file come per le
|
|
390
|
+
* cartelle, che nella selezione stanno insieme. */
|
|
391
|
+
const move = useCallback(async (ids, dest) => {
|
|
392
|
+
if (!ids.length)
|
|
393
|
+
return;
|
|
394
|
+
const keys = filesIn(ids);
|
|
395
|
+
const prefixes = foldersIn(ids);
|
|
396
|
+
setBusy(true);
|
|
397
|
+
let failure = null;
|
|
398
|
+
try {
|
|
399
|
+
for (const p of prefixes) {
|
|
400
|
+
const err = await moveFolder(p, dest);
|
|
401
|
+
if (err)
|
|
402
|
+
failure = err;
|
|
403
|
+
}
|
|
404
|
+
if (keys.length) {
|
|
405
|
+
// La radice non è una destinazione valida per un FILE: la Function la rifiuta, e senza
|
|
406
|
+
// questo controllo il rifiuto arriverebbe come un errore criptico a metà operazione.
|
|
407
|
+
if (!dest) {
|
|
408
|
+
failure = "Le foto devono stare in una cartella.";
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
setProgress({ done: 0, total: keys.length, verb: "Sposto" });
|
|
412
|
+
// A gruppi per la stessa ragione della cancellazione. Ogni gruppo COPIA conservando
|
|
413
|
+
// gli originali e restituisce la mappa scelta dal server; la chiamata finale aggiorna
|
|
414
|
+
// i riferimenti in un commit solo e soltanto dopo elimina gli originali.
|
|
415
|
+
let done = 0;
|
|
416
|
+
const urlMap = {};
|
|
417
|
+
const copyFailures = [];
|
|
418
|
+
for (let i = 0; i < keys.length; i += CHUNK) {
|
|
419
|
+
const group = keys.slice(i, i + CHUNK);
|
|
420
|
+
const r = await api.move(group, dest, { skipRefs: true });
|
|
421
|
+
Object.assign(urlMap, r.urlMap ?? {});
|
|
422
|
+
copyFailures.push(...(r.failed ?? []));
|
|
423
|
+
done += r.moved ?? 0;
|
|
424
|
+
setProgress({ done, total: keys.length, verb: "Sposto" });
|
|
425
|
+
}
|
|
426
|
+
const applied = Object.keys(urlMap).length ? await api.moveApplyRefs(urlMap) : null;
|
|
427
|
+
const cleanupFailures = applied?.failed ?? [];
|
|
428
|
+
if (copyFailures.length || cleanupFailures.length) {
|
|
429
|
+
failure = [
|
|
430
|
+
copyFailures.length ? `${copyFailures.length} file non spostati` : "",
|
|
431
|
+
cleanupFailures.length ? `${cleanupFailures.length} originali rimasti come duplicati` : "",
|
|
432
|
+
].filter(Boolean).join("; ") + ".";
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
catch (e) {
|
|
438
|
+
failure = e instanceof ApiError ? e.message : String(e);
|
|
439
|
+
}
|
|
440
|
+
finally {
|
|
441
|
+
setProgress(null);
|
|
442
|
+
setBusy(false);
|
|
443
|
+
await load(prefix);
|
|
444
|
+
if (failure)
|
|
445
|
+
setError(failure);
|
|
446
|
+
}
|
|
447
|
+
}, [prefix, load, moveFolder]);
|
|
448
|
+
/* Rinominare è spostare nello stesso posto con un nome nuovo: stessa strada, stesse cautele.
|
|
449
|
+
* Se il nome nuovo dà lo stesso slug (è cambiata solo una maiuscola, o un «&»), non si sposta
|
|
450
|
+
* niente e si riscrive la sola etichetta. */
|
|
451
|
+
const renameFolder = useCallback(async (from, label) => {
|
|
452
|
+
if (!label.trim())
|
|
453
|
+
return;
|
|
454
|
+
setBusy(true);
|
|
455
|
+
let failure = null;
|
|
456
|
+
try {
|
|
457
|
+
failure = await moveFolder(from, from.replace(/[^/]+\/$/, ""), label);
|
|
458
|
+
}
|
|
459
|
+
catch (e) {
|
|
460
|
+
failure = e instanceof ApiError ? e.message : String(e);
|
|
461
|
+
}
|
|
462
|
+
finally {
|
|
463
|
+
setProgress(null);
|
|
464
|
+
setBusy(false);
|
|
465
|
+
await load(prefix);
|
|
466
|
+
if (failure)
|
|
467
|
+
setError(failure);
|
|
468
|
+
}
|
|
469
|
+
}, [prefix, load, moveFolder]);
|
|
470
|
+
/** Cestina una cartella con tutto quello che contiene. È `remove` con una lista di uno. */
|
|
471
|
+
const removeFolder = useCallback((target) => remove([target]), [remove]);
|
|
472
|
+
const step = useCallback((delta) => {
|
|
473
|
+
setViewing((i) => {
|
|
474
|
+
if (i === null)
|
|
475
|
+
return null;
|
|
476
|
+
const n = i + delta;
|
|
477
|
+
return n < 0 || n >= shown.length ? i : n;
|
|
478
|
+
});
|
|
479
|
+
}, [shown.length]);
|
|
480
|
+
return {
|
|
481
|
+
mode, multiple, prefix, folders, subfolders, shown, selected, busy, error, progress,
|
|
482
|
+
sortKey, sortDir, uploadOpen, viewing,
|
|
483
|
+
go: (p) => void load(p),
|
|
484
|
+
reload: () => void load(prefix),
|
|
485
|
+
toggle, setAll,
|
|
486
|
+
setSort: setSortKey,
|
|
487
|
+
flipDir: () => setSortDir((d) => (d === "asc" ? "desc" : "asc")),
|
|
488
|
+
upload,
|
|
489
|
+
setUploadOpen,
|
|
490
|
+
view: setViewing,
|
|
491
|
+
step,
|
|
492
|
+
move, remove, newFolder, renameFolder, removeFolder, labelOf,
|
|
493
|
+
clearError: () => setError(null),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Dove sta la selezione sullo schermo, in coordinate del viewport. */
|
|
2
|
+
export interface ProseSpot {
|
|
3
|
+
x: number;
|
|
4
|
+
top: number;
|
|
5
|
+
bottom: number;
|
|
6
|
+
}
|
|
7
|
+
export interface ProseBarProps {
|
|
8
|
+
spot: ProseSpot;
|
|
9
|
+
/** Com'è già formattato il testo selezionato: i bottoni lo mostrano accesi. */
|
|
10
|
+
bold: boolean;
|
|
11
|
+
italic: boolean;
|
|
12
|
+
/** L'indirizzo del link su cui è appoggiata la selezione, se ce n'è uno. */
|
|
13
|
+
href: string | null;
|
|
14
|
+
onBold: () => void;
|
|
15
|
+
onItalic: () => void;
|
|
16
|
+
onLink: (href: string) => void;
|
|
17
|
+
onUnlink: () => void;
|
|
18
|
+
/** Aperto il dialogo, il campo perde il fuoco: chi lo possiede deve tenersi la selezione. */
|
|
19
|
+
onHold: (holding: boolean) => void;
|
|
20
|
+
}
|
|
21
|
+
export declare function ProseBar({ spot, bold, italic, href, onBold, onItalic, onLink, onUnlink, onHold }: ProseBarProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/* La barra che compare sopra il testo selezionato, e il dialogo del link.
|
|
3
|
+
*
|
|
4
|
+
* ── Dove sta ───────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
* Appartiene alla PAGINA, non al campo: è disegnata in un portal su `document.body` e
|
|
6
|
+
* posizionata con `position: fixed`. Dentro il campo sarebbe prigioniera del suo `overflow` —
|
|
7
|
+
* su una selezione in cima al testo verrebbe tagliata a metà, o peggio, coprirebbe le parole che
|
|
8
|
+
* chi scrive sta guardando. Fuori, può sporgere dal riquadro e restare intera.
|
|
9
|
+
*
|
|
10
|
+
* Chi la posiziona è `ProseArea`, che sa dov'è la selezione; qui si riceve solo il punto e da che
|
|
11
|
+
* parte stare. La regola è una sola e vale sempre: **la barra non copre mai il testo
|
|
12
|
+
* selezionato** — sopra se c'è spazio, sotto altrimenti.
|
|
13
|
+
*
|
|
14
|
+
* ── Perché i bottoni non rubano il fuoco ──────────────────────────────────────────────────
|
|
15
|
+
* Ogni comando fa `preventDefault` sul `mousedown`. Senza, premere B toglierebbe il fuoco alla
|
|
16
|
+
* textarea, la selezione svanirebbe, e il comando si troverebbe niente da formattare. È il
|
|
17
|
+
* difetto classico di questo tipo di barra, e si paga una riga per non averlo.
|
|
18
|
+
*
|
|
19
|
+
* ── Il dialogo del link ────────────────────────────────────────────────────────────────────
|
|
20
|
+
* Tre schede, come in qualunque CMS che il cliente possa aver già usato: una pagina del sito, un
|
|
21
|
+
* indirizzo web, un'email. Le pagine arrivano dal contesto (`ProsePages`) e non da una prop,
|
|
22
|
+
* perché questo componente vive dentro widget annidati anche di tre livelli.
|
|
23
|
+
*
|
|
24
|
+
* NON c'è «apri in una nuova finestra»: il testo si salva come markdown, e il markdown non ha
|
|
25
|
+
* un modo di dirlo. Prometterlo con una casella che poi non produce niente è peggio che non
|
|
26
|
+
* offrirlo — se un giorno serve, si fa con un render hook sui link nel sito, non qui.
|
|
27
|
+
*/
|
|
28
|
+
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
29
|
+
import { createPortal } from "react-dom";
|
|
30
|
+
import { isSafeHref } from "../../prose.js";
|
|
31
|
+
import { ProsePages } from "./pages.js";
|
|
32
|
+
/* Quanto è alta la barra, per decidere se ci sta sopra la selezione. Misurarla davvero
|
|
33
|
+
vorrebbe dire disegnarla, leggerla e spostarla — un salto visibile a ogni selezione. È
|
|
34
|
+
un'altezza fissa dichiarata nel CSS (`--ak-prose-bar-h`), quindi qui si può sapere. */
|
|
35
|
+
const BAR_H = 40;
|
|
36
|
+
const GAP = 10;
|
|
37
|
+
export function ProseBar({ spot, bold, italic, href, onBold, onItalic, onLink, onUnlink, onHold }) {
|
|
38
|
+
const [dialog, setDialog] = useState(false);
|
|
39
|
+
const above = spot.top - GAP - BAR_H >= 8;
|
|
40
|
+
const close = () => { setDialog(false); onHold(false); };
|
|
41
|
+
/* Il dialogo si apre AL POSTO della barra: due superfici sovrapposte sullo stesso testo si
|
|
42
|
+
contendono lo sguardo, e quella che conta in quel momento è una sola. */
|
|
43
|
+
if (dialog) {
|
|
44
|
+
return (_jsx(LinkDialog, { href: href, onClose: close, onSave: (value) => { setDialog(false); onLink(value); }, onUnlink: () => { setDialog(false); onUnlink(); } }));
|
|
45
|
+
}
|
|
46
|
+
const hold = (event) => event.preventDefault();
|
|
47
|
+
return createPortal(_jsxs("div", { className: "ak-prose-bar", role: "toolbar", "aria-label": "Formattazione del testo", style: {
|
|
48
|
+
left: `${spot.x}px`,
|
|
49
|
+
top: `${above ? spot.top - GAP : spot.bottom + GAP}px`,
|
|
50
|
+
transform: `translate(-50%, ${above ? "-100%" : "0"})`,
|
|
51
|
+
}, children: [_jsx("button", { type: "button", className: "ak-prose-btn is-bold" + (bold ? " is-on" : ""), "aria-pressed": bold, onMouseDown: hold, onClick: onBold, title: "Grassetto", children: "B" }), _jsx("button", { type: "button", className: "ak-prose-btn is-italic" + (italic ? " is-on" : ""), "aria-pressed": italic, onMouseDown: hold, onClick: onItalic, title: "Corsivo", children: "I" }), _jsx("span", { className: "ak-prose-sep", "aria-hidden": "true" }), _jsxs("button", { type: "button", className: "ak-prose-btn is-link" + (href ? " is-on" : ""), onMouseDown: hold, onClick: () => { onHold(true); setDialog(true); }, title: href ? "Modifica il collegamento" : "Aggiungi un collegamento", children: [_jsx(LinkIcon, {}), " Link"] })] }), document.body);
|
|
52
|
+
}
|
|
53
|
+
function LinkIcon() {
|
|
54
|
+
return (_jsxs("svg", { viewBox: "0 0 16 16", width: "14", height: "14", "aria-hidden": "true", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", children: [_jsx("path", { d: "M6.8 9.2a2.6 2.6 0 0 0 3.7 0l2-2a2.6 2.6 0 0 0-3.7-3.7l-1 1" }), _jsx("path", { d: "M9.2 6.8a2.6 2.6 0 0 0-3.7 0l-2 2a2.6 2.6 0 0 0 3.7 3.7l1-1" })] }));
|
|
55
|
+
}
|
|
56
|
+
function tabOf(href) {
|
|
57
|
+
if (!href)
|
|
58
|
+
return "page";
|
|
59
|
+
if (href.startsWith("mailto:"))
|
|
60
|
+
return "mail";
|
|
61
|
+
if (/^https?:/i.test(href))
|
|
62
|
+
return "web";
|
|
63
|
+
return "page";
|
|
64
|
+
}
|
|
65
|
+
function LinkDialog({ href, onClose, onSave, onUnlink }) {
|
|
66
|
+
const pages = useContext(ProsePages);
|
|
67
|
+
const [tab, setTab] = useState(() => tabOf(href));
|
|
68
|
+
const [page, setPage] = useState(() => (tabOf(href) === "page" ? href ?? "" : ""));
|
|
69
|
+
const [web, setWeb] = useState(() => (tabOf(href) === "web" ? href ?? "" : ""));
|
|
70
|
+
const [mail, setMail] = useState(() => (tabOf(href) === "mail" ? (href ?? "").replace(/^mailto:/i, "") : ""));
|
|
71
|
+
const first = useRef(null);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
first.current?.focus();
|
|
74
|
+
const onKey = (event) => { if (event.key === "Escape")
|
|
75
|
+
onClose(); };
|
|
76
|
+
addEventListener("keydown", onKey);
|
|
77
|
+
return () => removeEventListener("keydown", onKey);
|
|
78
|
+
}, [onClose]);
|
|
79
|
+
/* Cosa si sta per scrivere, calcolato una volta: lo usano sia il bottone (per sapere se può
|
|
80
|
+
essere premuto) sia il salvataggio. Due espressioni separate sarebbero due occasioni di
|
|
81
|
+
dire cose diverse — un bottone attivo che poi non fa niente. */
|
|
82
|
+
const value = useMemo(() => {
|
|
83
|
+
if (tab === "page")
|
|
84
|
+
return page.trim();
|
|
85
|
+
if (tab === "mail") {
|
|
86
|
+
const address = mail.trim();
|
|
87
|
+
return address ? `mailto:${address}` : "";
|
|
88
|
+
}
|
|
89
|
+
const url = web.trim();
|
|
90
|
+
// Un indirizzo battuto senza schema è un indirizzo web, non un percorso interno: chi scrive
|
|
91
|
+
// `esempio.it` intende quel sito, e senza questo otterrebbe un link rotto.
|
|
92
|
+
return url && !/^[a-z]+:/i.test(url) && !url.startsWith("/") && !url.startsWith("#")
|
|
93
|
+
? `https://${url}`
|
|
94
|
+
: url;
|
|
95
|
+
}, [tab, page, web, mail]);
|
|
96
|
+
const valid = isSafeHref(value);
|
|
97
|
+
/* Un link interno che non corrisponde a nessuna pagina dell'elenco — una pagina tolta dal
|
|
98
|
+
menu, messa offline, o un'ancora scritta a mano — resta come voce sua. Senza, il menu
|
|
99
|
+
ripiegherebbe sulla riga vuota e «Aggiorna» cancellerebbe in silenzio un indirizzo che
|
|
100
|
+
nessuno voleva toccare. */
|
|
101
|
+
const options = useMemo(() => {
|
|
102
|
+
const start = tabOf(href) === "page" ? (href ?? "") : "";
|
|
103
|
+
return start && !pages.some((item) => item.href === start)
|
|
104
|
+
? [{ href: start, label: start }, ...pages]
|
|
105
|
+
: pages;
|
|
106
|
+
}, [pages, href]);
|
|
107
|
+
return createPortal(_jsx("div", { className: "ak-prose-modal", role: "presentation", onMouseDown: (e) => { if (e.target === e.currentTarget)
|
|
108
|
+
onClose(); }, children: _jsxs("div", { className: "ak-prose-card", role: "dialog", "aria-modal": "true", "aria-labelledby": "ak-link-title", children: [_jsxs("header", { children: [_jsx("h2", { id: "ak-link-title", children: "Collegamento" }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Chiudi", children: "\u00D7" })] }), _jsx("div", { className: "ak-prose-tabs", role: "tablist", children: [["page", "Pagina"], ["web", "Sito web"], ["mail", "Email"]].map(([id, label]) => (_jsx("button", { type: "button", role: "tab", "aria-selected": tab === id, className: "ak-prose-tab" + (tab === id ? " is-on" : ""), onClick: () => setTab(id), children: label }, id))) }), _jsxs("div", { className: "ak-prose-body", children: [tab === "page" && (options.length ? (_jsxs("select", { ref: first, className: "ak-input", value: page, onChange: (event) => setPage(event.target.value), children: [_jsx("option", { value: "", children: "Scegli una pagina\u2026" }), options.map((item) => (_jsx("option", { value: item.href, children: item.label }, item.href)))] })) : (_jsx("p", { className: "ak-hint", children: "Nessuna pagina a cui collegarsi." }))), tab === "web" && (_jsx("input", { ref: first, className: "ak-input", placeholder: "https://www.esempio.com", value: web, onChange: (event) => setWeb(event.target.value), onKeyDown: (event) => { if (event.key === "Enter" && valid)
|
|
109
|
+
onSave(value); } })), tab === "mail" && (_jsx("input", { ref: first, className: "ak-input", type: "email", placeholder: "nome@esempio.com", value: mail, onChange: (event) => setMail(event.target.value), onKeyDown: (event) => { if (event.key === "Enter" && valid)
|
|
110
|
+
onSave(value); } }))] }), _jsxs("footer", { children: [href ? (_jsx("button", { type: "button", className: "ak-prose-unlink", onClick: onUnlink, children: "Togli il collegamento" })) : _jsx("span", {}), _jsxs("div", { children: [_jsx("button", { type: "button", className: "ak-btn", onClick: onClose, children: "Annulla" }), _jsx("button", { type: "button", className: "ak-btn ak-btn-primary", disabled: !valid, onClick: () => onSave(value), children: href ? "Aggiorna" : "Crea" })] })] })] }) }), document.body);
|
|
111
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ProseFieldProps {
|
|
2
|
+
/** Il testo in markdown: è questo che si salva. */
|
|
3
|
+
value: string;
|
|
4
|
+
onChange: (next: string) => void;
|
|
5
|
+
className?: string;
|
|
6
|
+
/** Altezza minima, in righe. Il campo cresce col testo: non scorre mai dentro sé stesso. */
|
|
7
|
+
rows?: number;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
maxLength?: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function ProseField({ value, onChange, className, rows, placeholder, maxLength }: ProseFieldProps): import("react").JSX.Element;
|