@cardinali-studio/adminkit 2.0.0 → 2.1.0-rc.1
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 +8 -1
- package/dist/api.js +1 -1
- package/dist/app/App.js +8 -3
- package/dist/app/app.css +27 -0
- package/dist/app/lib/pages.d.ts +11 -0
- package/dist/app/lib/pages.js +17 -1
- package/dist/app/routes/PageEditor.d.ts +12 -2
- package/dist/app/routes/PageEditor.js +86 -14
- package/dist/app/routes/PageSeoEditor.d.ts +4 -1
- package/dist/app/routes/PageSeoEditor.js +2 -2
- package/dist/app/routes/PagesList.d.ts +5 -1
- package/dist/app/routes/PagesList.js +12 -6
- package/dist/app/site.d.ts +8 -0
- package/dist/form/SchemaForm.d.ts +3 -1
- package/dist/form/SchemaForm.js +14 -8
- package/dist/form/fields/objectList.js +9 -6
- package/dist/form/registry.d.ts +2 -0
- package/dist/functions/_shared/translations.js +18 -0
- package/dist/functions/api/pages/get.js +26 -9
- package/dist/functions/api/pages.js +84 -3
- package/dist/functions/site.js +9 -0
- package/dist/i18n.d.ts +26 -0
- package/dist/i18n.js +119 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
package/dist/api.d.ts
CHANGED
|
@@ -44,6 +44,11 @@ export interface RepoPage {
|
|
|
44
44
|
/** Lo `sha` del blob su GitHub: identifica la VERSIONE di questo file, non il suo contenuto. */
|
|
45
45
|
sha: string;
|
|
46
46
|
page: Record<string, unknown>;
|
|
47
|
+
/** Le sibling dichiarate dal sito, senza caricare il loro contenuto nell'elenco. */
|
|
48
|
+
translations?: Record<string, {
|
|
49
|
+
exists: boolean;
|
|
50
|
+
sha?: string;
|
|
51
|
+
}>;
|
|
47
52
|
}
|
|
48
53
|
export declare const api: {
|
|
49
54
|
pages: () => Promise<{
|
|
@@ -51,11 +56,13 @@ export declare const api: {
|
|
|
51
56
|
commit: string;
|
|
52
57
|
pages: RepoPage[];
|
|
53
58
|
}>;
|
|
54
|
-
pageBody: (slug: string) => Promise<{
|
|
59
|
+
pageBody: (slug: string, language?: string) => Promise<{
|
|
55
60
|
ok: true;
|
|
56
61
|
slug: string;
|
|
57
62
|
body: string;
|
|
58
63
|
page: Record<string, unknown>;
|
|
64
|
+
language?: string;
|
|
65
|
+
exists?: boolean;
|
|
59
66
|
}>;
|
|
60
67
|
savePages: (pages: unknown[]) => Promise<{
|
|
61
68
|
ok: true;
|
package/dist/api.js
CHANGED
|
@@ -39,7 +39,7 @@ export const api = {
|
|
|
39
39
|
pages: () => call("/admin/api/pages"),
|
|
40
40
|
/* Il corpo markdown, che nell'elenco non c'è: sono testi lunghi e servono solo alla pagina
|
|
41
41
|
* che si sta aprendo. `page` è il modello letto dallo stesso file. */
|
|
42
|
-
pageBody: (slug) => call(`/admin/api/pages/get?slug=${encodeURIComponent(slug)}`),
|
|
42
|
+
pageBody: (slug, language) => call(`/admin/api/pages/get?slug=${encodeURIComponent(slug)}${language ? `&lang=${encodeURIComponent(language)}` : ""}`),
|
|
43
43
|
/* `shas` è la versione di ogni file DOPO il commit: senza, il salvataggio successivo
|
|
44
44
|
* partirebbe da una versione che non esiste più e verrebbe rifiutato. */
|
|
45
45
|
savePages: (pages) => post("/admin/api/pages", { pages }),
|
package/dist/app/App.js
CHANGED
|
@@ -108,6 +108,11 @@ export function App() {
|
|
|
108
108
|
? list.find((p) => p.slug === route[1])
|
|
109
109
|
: undefined;
|
|
110
110
|
const seoEditing = !!editing && route[2] === "seo";
|
|
111
|
+
const languages = site().languages;
|
|
112
|
+
const [pageLanguage, setPageLanguage] = useState(() => languages?.default ?? "");
|
|
113
|
+
/* Ogni pagina si apre nella sua lingua predefinita. La scelta resta locale alla pagina aperta:
|
|
114
|
+
non deve cambiare Home, Media o la pagina successiva nell'elenco. */
|
|
115
|
+
useEffect(() => { setPageLanguage(languages?.default ?? ""); }, [editing?.slug, languages?.default]);
|
|
111
116
|
/* Le destinazioni interne del dialogo «Collegamento», dalle pagine già caricate: la barra di
|
|
112
117
|
formattazione compare dentro widget annidati anche di tre livelli, e le riceve da un
|
|
113
118
|
contesto invece che da una catena di prop (vedi adminkit form/prose/pages.ts). Le pagine
|
|
@@ -211,7 +216,7 @@ export function App() {
|
|
|
211
216
|
});
|
|
212
217
|
return (_jsx(ProsePages.Provider, { value: pageLinks, children: _jsxs("div", { className: "app" + (hasList(section) && !offModule ? "" : " is-two"), children: [_jsxs("nav", { className: "rail", children: [_jsxs("div", { className: "rail-brand", children: [_jsx("span", { className: "rail-brand-name", children: site().name }), _jsx("span", { className: "rail-brand-role", children: "pannello" })] }), _jsx("div", { className: "rail-nav", children: navGroups.map((g) => (_jsxs("div", { className: "rail-group", children: [g.title && _jsx("span", { className: "rail-group-title", children: g.title }), g.items.map((n) => (_jsxs("button", { className: "rail-item" + (section === n.id ? " is-on" : ""), onClick: () => go(`/${n.id}`), children: [_jsx(n.icon, {}), _jsx("span", { children: n.label }), "badge" in n && n.badge ? _jsx("span", { className: "rail-badge", children: n.badge }) : null] }, n.id)))] }, g.title ?? "top"))) }), _jsxs("div", { className: "rail-foot", children: [_jsxs("a", { className: "rail-item", href: "/", target: "_blank", rel: "noreferrer", children: [_jsx(Icon.out, {}), _jsx("span", { children: "Vedi il sito" })] }), _jsxs("button", { className: "rail-item", onClick: toggleTheme, children: [theme === "dark" ? _jsx(Icon.sun, {}) : _jsx(Icon.moon, {}), _jsx("span", { children: theme === "dark" ? "Tema chiaro" : "Tema scuro" })] }), _jsxs("button", { className: "rail-item" + (section === "settings" ? " is-on" : ""), onClick: () => go("/settings/site"), children: [_jsx(Icon.settings, {}), _jsx("span", { children: "Impostazioni" })] })] })] }), hasList(section) && !offModule && (_jsxs("div", { className: "col", children: [section === "pages" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: list && _jsx(NewPageButton, { pages: list, onCreate: (p) => pages.update([...list, p]) }) }), _jsx("div", { className: "col-body", children: pages.error ? _jsx("p", { className: "ak-error", children: pages.error })
|
|
213
218
|
: !list ? _jsx("p", { className: "ak-hint", children: "Carico\u2026" })
|
|
214
|
-
: _jsx(PagesList, { pages: list, current: editing?.slug, onEdit: (slug) => go(`/pages/${slug}`), onChange: pages.update, onRenamed: (from, to) => {
|
|
219
|
+
: _jsx(PagesList, { pages: list, current: editing?.slug, onEdit: (slug) => go(`/pages/${slug}`), languages: languages, onChange: pages.update, onRenamed: (from, to) => {
|
|
215
220
|
if (editing?.slug === from)
|
|
216
221
|
go(`/pages/${to}${seoEditing ? "/seo" : ""}`);
|
|
217
222
|
} }) })] })), section === "media" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: _jsx("span", { className: "col-title", children: "Cartelle" }) }), _jsx("div", { className: "col-body", children: _jsx(MediaFolders, { lib: media, trash: { active: view === "trash", open: () => go("/trash"), leave: () => go("/media") } }) })] })), section === "requests" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: pickedReq.size === 0 ? (_jsx("span", { className: "col-title", children: requests.rows ? `${requests.rows.length} richieste` : "Richieste" })) : (_jsxs("span", { className: "rq-bulk", children: [_jsxs("span", { className: "col-title", children: [pickedReq.size, " scelte"] }), _jsx("button", { className: "mg-act", onClick: () => { void requests.mark([...pickedReq], true); setPickedReq(new Set()); }, children: "Gestite" }), _jsx("button", { className: "mg-act is-danger", onClick: () => { void requests.remove([...pickedReq]); setPickedReq(new Set()); }, children: "Elimina" }), _jsx("button", { className: "mg-act", onClick: () => setPickedReq(new Set()), children: "\u00D7" })] })) }), _jsx("div", { className: "col-body", children: requests.error ? _jsx("p", { className: "ak-error", children: requests.error })
|
|
@@ -234,7 +239,7 @@ export function App() {
|
|
|
234
239
|
: deliveries.rows.length === 0 ? _jsx("p", { className: "ak-hint", children: "Nessun transfer, per ora." })
|
|
235
240
|
: _jsx(DeliveriesList, { rows: deliveries.rows, current: activeDeliveryId || quickSelectedId, onOpen: (id) => { setQuickSelectedId(undefined); go(`/deliveries/${id}`); } }) }), _jsxs("div", { className: "dl-col-foot", children: [deliveries.rows && _jsx(DeliveriesTotal, { rows: deliveries.rows }), _jsxs("button", { className: "col-item" + (deliverySettingsOpen ? " is-on" : ""), onClick: () => { setQuickSelectedId(undefined); go("/deliveries/settings"); }, children: [_jsx(Icon.settings, {}), _jsx("span", { children: "Impostazioni" })] })] })] })), section === "bookings" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: _jsx("span", { className: "col-title", children: bookings.rows ? `${bookings.rows.length} appuntamenti` : "Prenotazioni" }) }), _jsx("div", { className: "col-body", children: bookings.error ? _jsx("p", { className: "ak-error", children: bookings.error })
|
|
236
241
|
: !bookings.rows ? _jsx("p", { className: "ak-hint", children: "Carico\u2026" })
|
|
237
|
-
: _jsx(BookingsList, { rows: bookings.rows, current: bookingSettingsOpen ? "settings" : openBooking?.id, onOpen: (id) => go(`/bookings/${id}`), onAgenda: () => go("/bookings"), onSettings: () => go("/bookings/settings") }) })] })), section === "home" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: _jsx("span", { className: "col-title", children: "Sezioni" }) }), _jsx("div", { className: "col-body", children: _jsx("ul", { className: "col-list", children: homeGroups.map((g) => (_jsx("li", { children: _jsx("button", { className: "col-item" + (g === homeGroup ? " is-on" : ""), onClick: () => go(`/home/${groupSlug(g)}`), children: g || "Generale" }) }, g))) }) })] })), section === "settings" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: _jsx("span", { className: "col-title", children: "Impostazioni" }) }), _jsx("div", { className: "col-body", children: _jsxs("ul", { className: "col-list settings-list", children: [_jsx("li", { children: _jsxs("button", { className: "col-item settings-list-item" + (settingsView === "site" ? " is-on" : ""), onClick: () => go("/settings/site"), children: [_jsx("strong", { children: "Sito" }), _jsx("span", { children: "Favicon, identit\u00E0 e accesso generale." })] }) }), _jsx("li", { children: _jsxs("button", { className: "col-item settings-list-item" + (settingsView === "seo" ? " is-on" : ""), onClick: () => go("/settings/seo"), children: [_jsx("strong", { children: "SEO" }), _jsx("span", { children: "Motori di ricerca e crawler AI." })] }) })] }) })] }))] })), _jsxs("main", { className: "pane", children: [_jsxs("header", { className: "pane-head", children: [editing ? (_jsx(PageHeader, { page: editing, pages: list ?? [], onChange: replace, seoActive: seoEditing, onSeoToggle: () => go(`/pages/${editing.slug}${seoEditing ? "" : "/seo"}`) })) : view === "media" ? (_jsxs("nav", { className: "pane-crumbs", "aria-label": "Percorso", children: [_jsx("button", { className: "pane-crumb" + (mediaCrumbs.length ? "" : " is-current"), onClick: () => media.go(""), children: "Senza cartella" }), mediaCrumbs.map((c, i) => (_jsxs("span", { className: "pane-crumb-part", children: [_jsx("span", { className: "pane-crumb-sep", "aria-hidden": "true", children: "\u203A" }), _jsx("button", { className: "pane-crumb" + (i === mediaCrumbs.length - 1 ? " is-current" : ""), onClick: () => media.go(c.prefix), children: c.label })] }, c.prefix)))] })) : (_jsx("h1", { className: "pane-title", children: heading })), view === "media" && _jsx("span", { className: "pane-count", children: media.shown.length }), _jsx("span", { className: "pane-spacer" }), view === "media" && (_jsx("button", { className: "ak-btn ak-btn-primary", onClick: () => media.setUploadOpen(true), children: "Carica immagini" })), saver?.notice && (_jsx("button", { className: "pane-notice", onClick: () => saver.setNotice(null), children: saver.notice })), section === "bookings" && bookings.notice && (_jsx("button", { className: "pane-notice", onClick: bookings.clearNotice, children: bookings.notice })), publish.notice && (_jsx("button", { className: "pane-notice", onClick: publish.clearNotice, children: publish.notice })), !publish.configured && publish.error && (_jsx("span", { className: "pane-notice", title: publish.error, children: publish.error })), saver && (_jsxs(_Fragment, { children: [saver.dirty && _jsx("span", { className: "pane-dot", title: "modifiche non salvate" }), _jsx("button", { className: "ak-btn", disabled: !saver.dirty || saver.saving,
|
|
242
|
+
: _jsx(BookingsList, { rows: bookings.rows, current: bookingSettingsOpen ? "settings" : openBooking?.id, onOpen: (id) => go(`/bookings/${id}`), onAgenda: () => go("/bookings"), onSettings: () => go("/bookings/settings") }) })] })), section === "home" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: _jsx("span", { className: "col-title", children: "Sezioni" }) }), _jsx("div", { className: "col-body", children: _jsx("ul", { className: "col-list", children: homeGroups.map((g) => (_jsx("li", { children: _jsx("button", { className: "col-item" + (g === homeGroup ? " is-on" : ""), onClick: () => go(`/home/${groupSlug(g)}`), children: g || "Generale" }) }, g))) }) })] })), section === "settings" && (_jsxs(_Fragment, { children: [_jsx("div", { className: "col-head", children: _jsx("span", { className: "col-title", children: "Impostazioni" }) }), _jsx("div", { className: "col-body", children: _jsxs("ul", { className: "col-list settings-list", children: [_jsx("li", { children: _jsxs("button", { className: "col-item settings-list-item" + (settingsView === "site" ? " is-on" : ""), onClick: () => go("/settings/site"), children: [_jsx("strong", { children: "Sito" }), _jsx("span", { children: "Favicon, identit\u00E0 e accesso generale." })] }) }), _jsx("li", { children: _jsxs("button", { className: "col-item settings-list-item" + (settingsView === "seo" ? " is-on" : ""), onClick: () => go("/settings/seo"), children: [_jsx("strong", { children: "SEO" }), _jsx("span", { children: "Motori di ricerca e crawler AI." })] }) })] }) })] }))] })), _jsxs("main", { className: "pane", children: [_jsxs("header", { className: "pane-head", children: [editing ? (_jsx(PageHeader, { page: editing, pages: list ?? [], onChange: replace, seoActive: seoEditing, onSeoToggle: () => go(`/pages/${editing.slug}${seoEditing ? "" : "/seo"}`), languages: languages, activeLanguage: pageLanguage, onLanguageChange: setPageLanguage })) : view === "media" ? (_jsxs("nav", { className: "pane-crumbs", "aria-label": "Percorso", children: [_jsx("button", { className: "pane-crumb" + (mediaCrumbs.length ? "" : " is-current"), onClick: () => media.go(""), children: "Senza cartella" }), mediaCrumbs.map((c, i) => (_jsxs("span", { className: "pane-crumb-part", children: [_jsx("span", { className: "pane-crumb-sep", "aria-hidden": "true", children: "\u203A" }), _jsx("button", { className: "pane-crumb" + (i === mediaCrumbs.length - 1 ? " is-current" : ""), onClick: () => media.go(c.prefix), children: c.label })] }, c.prefix)))] })) : (_jsx("h1", { className: "pane-title", children: heading })), view === "media" && _jsx("span", { className: "pane-count", children: media.shown.length }), _jsx("span", { className: "pane-spacer" }), view === "media" && (_jsx("button", { className: "ak-btn ak-btn-primary", onClick: () => media.setUploadOpen(true), children: "Carica immagini" })), saver?.notice && (_jsx("button", { className: "pane-notice", onClick: () => saver.setNotice(null), children: saver.notice })), section === "bookings" && bookings.notice && (_jsx("button", { className: "pane-notice", onClick: bookings.clearNotice, children: bookings.notice })), publish.notice && (_jsx("button", { className: "pane-notice", onClick: publish.clearNotice, children: publish.notice })), !publish.configured && publish.error && (_jsx("span", { className: "pane-notice", title: publish.error, children: publish.error })), saver && (_jsxs(_Fragment, { children: [saver.dirty && _jsx("span", { className: "pane-dot", title: "modifiche non salvate" }), _jsx("button", { className: "ak-btn", disabled: !saver.dirty || saver.saving,
|
|
238
243
|
/* Dopo un salvataggio il conteggio delle modifiche in attesa è cambiato: senza
|
|
239
244
|
rileggerlo, «Pubblica» comparirebbe solo al ricaricamento della pagina — cioè
|
|
240
245
|
il pannello direbbe che non c'è niente da pubblicare subito dopo aver salvato
|
|
@@ -248,7 +253,7 @@ export function App() {
|
|
|
248
253
|
_jsx(Overview, { deliveries: deliveries.rows, expiring: deliveries.expiring, requests: requests.rows, pending: requests.pending, features: settings.data?.features ?? null, bookings: bookings.rows, bookingsPending: bookings.pending, bookingsError: bookings.error, go: go })), section === "bookings" && (bookings.error ? (_jsxs("div", { className: "pane-empty", children: [_jsx("p", { className: "ak-error", children: bookings.error }), _jsx("button", { className: "ak-btn", onClick: () => void bookings.load(), children: "Riprova" })] })) : !bookings.rows || !bookings.settings ? _jsx("p", { className: "pane-empty ak-hint", children: "Carico\u2026" })
|
|
249
254
|
: bookingSettingsOpen ? _jsx(BookingSettingsHub, { store: bookings })
|
|
250
255
|
: openBooking ? _jsx(BookingDetail, { row: openBooking, store: bookings })
|
|
251
|
-
: _jsx(BookingAgenda, { store: bookings, onOpen: (id) => go(`/bookings/${id}`) })), section === "pages" && (pages.error ? (_jsxs("div", { className: "pane-empty", children: [_jsx("p", { className: "ak-error", children: pages.error }), _jsx("button", { className: "ak-btn", onClick: () => void pages.load(), children: "Riprova" })] })) : editing ? (seoEditing ? (_jsx(PageSeoEditor, { page: editing, onChange: replace, onRefresh: pages.refresh, pristine: pages.isPristine(editing.slug), pickMediaFiles: pickMediaFiles, siteSettings: settings.data })) : (_jsx(PageEditor, { page: editing, onChange: replace, onRefresh: pages.refresh, pristine: pages.isPristine(editing.slug), pickMedia: pickMedia, pickMediaFiles: pickMediaFiles }))) : (_jsx("p", { className: "pane-empty ak-hint", children: list ? "Scegli una pagina dall'elenco, o creane una nuova." : "Carico…" }))), section === "home" && (
|
|
256
|
+
: _jsx(BookingAgenda, { store: bookings, onOpen: (id) => go(`/bookings/${id}`) })), section === "pages" && (pages.error ? (_jsxs("div", { className: "pane-empty", children: [_jsx("p", { className: "ak-error", children: pages.error }), _jsx("button", { className: "ak-btn", onClick: () => void pages.load(), children: "Riprova" })] })) : editing ? (seoEditing ? (_jsx(PageSeoEditor, { page: editing, onChange: replace, onRefresh: pages.refresh, pristine: pages.isPristine(editing.slug), pickMediaFiles: pickMediaFiles, siteSettings: settings.data, readOnly: !!languages && pageLanguage !== languages.default })) : (_jsx(PageEditor, { page: editing, onChange: replace, onRefresh: pages.refresh, pristine: pages.isPristine(editing.slug), pickMedia: pickMedia, pickMediaFiles: pickMediaFiles, activeLanguage: pageLanguage, defaultLanguage: languages?.default }))) : (_jsx("p", { className: "pane-empty ak-hint", children: list ? "Scegli una pagina dall'elenco, o creane una nuova." : "Carico…" }))), section === "home" && (
|
|
252
257
|
/* Con un errore di caricamento c'era solo la riga rossa: nessun modo di riprovare
|
|
253
258
|
se non ricaricando il pannello a mano. Le altre sezioni il bottone ce l'hanno. */
|
|
254
259
|
home.error ? (_jsxs("div", { className: "pane-empty", children: [_jsx("p", { className: "ak-error", children: home.error }), _jsx("button", { className: "ak-btn", onClick: () => void home.load(), children: "Riprova" })] }))
|
package/dist/app/app.css
CHANGED
|
@@ -305,6 +305,13 @@ body { margin: 0; background: var(--ak-bg); color: var(--ak-ink); font-family: v
|
|
|
305
305
|
.pg-icon { color: var(--ak-ink-quiet); }
|
|
306
306
|
.pg-off { color: var(--ak-danger); opacity: .7; }
|
|
307
307
|
.pg-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
308
|
+
.pg-language {
|
|
309
|
+
flex: none; min-width: 22px; padding: 3px 4px; border: 1px solid var(--ak-line); border-radius: 3px;
|
|
310
|
+
color: var(--ak-ink-quiet); font: 700 8px/1 var(--ak-font); letter-spacing: .05em; text-align: center;
|
|
311
|
+
}
|
|
312
|
+
.pg-language.is-saved { border-color: color-mix(in srgb, #168666 40%, var(--ak-line)); color: #168666; }
|
|
313
|
+
.pg-language.is-draft { border-color: color-mix(in srgb, var(--ak-accent) 44%, var(--ak-line)); color: var(--ak-accent); }
|
|
314
|
+
.pg-language.is-inherited { opacity: .5; }
|
|
308
315
|
|
|
309
316
|
/* ---- Azioni sulla riga: popover ancorato ------------------------------------------------ */
|
|
310
317
|
.pa { position: relative; flex: none; }
|
|
@@ -348,6 +355,7 @@ body { margin: 0; background: var(--ak-bg); color: var(--ak-ink); font-family: v
|
|
|
348
355
|
font: 600 17px/1.2 var(--ak-font); white-space: nowrap; text-align: left;
|
|
349
356
|
}
|
|
350
357
|
.pe-title:hover { color: var(--ak-accent); }
|
|
358
|
+
.pe-title:disabled { cursor: default; color: var(--ak-ink); }
|
|
351
359
|
.pe-title:focus-visible,
|
|
352
360
|
.pe-seo-button:focus-visible,
|
|
353
361
|
.pe-status:focus-visible,
|
|
@@ -366,6 +374,25 @@ body { margin: 0; background: var(--ak-bg); color: var(--ak-ink); font-family: v
|
|
|
366
374
|
color: var(--ak-accent);
|
|
367
375
|
}
|
|
368
376
|
.pe-seo-button .ico { width: 14px; height: 14px; }
|
|
377
|
+
.pe-seo-button:disabled,
|
|
378
|
+
.pe-status:disabled,
|
|
379
|
+
.pe-menu-toggle:has(input:disabled) { opacity: .46; cursor: not-allowed; }
|
|
380
|
+
.pe-language {
|
|
381
|
+
flex: none; display: inline-flex; overflow: hidden; border: 1px solid var(--ak-line); border-radius: 4px;
|
|
382
|
+
background: var(--ak-bg);
|
|
383
|
+
}
|
|
384
|
+
.pe-language button {
|
|
385
|
+
min-width: 33px; min-height: 27px; border: 0; border-left: 1px solid var(--ak-line); padding: 0 8px;
|
|
386
|
+
background: transparent; color: var(--ak-ink-quiet); cursor: pointer; font: 700 10px/1 var(--ak-font); letter-spacing: .06em;
|
|
387
|
+
}
|
|
388
|
+
.pe-language button:first-child { border-left: 0; }
|
|
389
|
+
.pe-language button:hover { color: var(--ak-ink); }
|
|
390
|
+
.pe-language button.is-active { background: var(--ak-panel); color: var(--ak-accent); box-shadow: inset 0 -2px var(--ak-accent); }
|
|
391
|
+
.pe-translation-note {
|
|
392
|
+
margin: 0; padding: 11px 13px; border: 1px solid color-mix(in srgb, var(--ak-accent) 36%, var(--ak-line));
|
|
393
|
+
border-radius: 6px; background: color-mix(in srgb, var(--ak-accent) 6%, var(--ak-panel)); color: var(--ak-ink-quiet);
|
|
394
|
+
font: 400 13px/1.45 var(--ak-font);
|
|
395
|
+
}
|
|
369
396
|
.pe-visibility { position: relative; flex: none; }
|
|
370
397
|
.pe-status {
|
|
371
398
|
display: inline-flex; align-items: center; gap: 7px; border: 0; padding: 5px 2px;
|
package/dist/app/lib/pages.d.ts
CHANGED
|
@@ -4,6 +4,17 @@ export type PageModel = Record<string, unknown> & {
|
|
|
4
4
|
/** Versione del file da cui questa pagina è stata letta. Torna al server nel salvataggio: se
|
|
5
5
|
* nel frattempo il file è cambiato, il server rifiuta invece di sovrascrivere. */
|
|
6
6
|
_sha?: string;
|
|
7
|
+
/** Presenza/versione delle sibling dichiarate; il loro contenuto è caricato solo aprendole. */
|
|
8
|
+
translations?: Record<string, {
|
|
9
|
+
exists: boolean;
|
|
10
|
+
sha?: string;
|
|
11
|
+
}>;
|
|
12
|
+
/** Modelli delle lingue aperte nell'editor, da scrivere nello stesso commit della pagina base. */
|
|
13
|
+
_translations?: Record<string, {
|
|
14
|
+
page: Record<string, unknown>;
|
|
15
|
+
body?: string;
|
|
16
|
+
_sha?: string;
|
|
17
|
+
}>;
|
|
7
18
|
};
|
|
8
19
|
export declare function usePages(): {
|
|
9
20
|
pages: PageModel[] | null;
|
package/dist/app/lib/pages.js
CHANGED
|
@@ -31,7 +31,7 @@ export function usePages() {
|
|
|
31
31
|
* andava riconciliato con `modelFromRaw` — un passaggio in meno, e una grafia in meno da
|
|
32
32
|
* far combaciare. L'ordine per peso lo dà già il server. */
|
|
33
33
|
const list = (d.pages ?? []).map((p) => ({
|
|
34
|
-
...p.page, slug: p.slug, url: p.url, _orig: p.slug, _sha: p.sha,
|
|
34
|
+
...p.page, slug: p.slug, url: p.url, _orig: p.slug, _sha: p.sha, translations: p.translations,
|
|
35
35
|
}));
|
|
36
36
|
base.current = new Map(list.map((p) => [p.slug, p]));
|
|
37
37
|
setPages(list);
|
|
@@ -91,6 +91,22 @@ export function usePages() {
|
|
|
91
91
|
const s = r.shas?.[p.slug];
|
|
92
92
|
if (s)
|
|
93
93
|
p._sha = s;
|
|
94
|
+
/* Le sibling hanno lo stesso ciclo di vita del file base: se non si aggiorna il loro
|
|
95
|
+
SHA qui, la modifica EN appena salvata parte già da una versione che non esiste più
|
|
96
|
+
e il Worker la rifiuta come conflitto al click successivo. */
|
|
97
|
+
for (const code of site().languages?.available ?? []) {
|
|
98
|
+
if (code === site().languages?.default)
|
|
99
|
+
continue;
|
|
100
|
+
const translationSha = r.shas?.[`${p.slug}.${code}`];
|
|
101
|
+
if (!translationSha)
|
|
102
|
+
continue;
|
|
103
|
+
p.translations = {
|
|
104
|
+
...(p.translations ?? {}),
|
|
105
|
+
[code]: { exists: true, sha: translationSha },
|
|
106
|
+
};
|
|
107
|
+
if (p._translations?.[code])
|
|
108
|
+
p._translations[code]._sha = translationSha;
|
|
109
|
+
}
|
|
94
110
|
});
|
|
95
111
|
// Da qui in poi il file contiene ciò che hanno queste pagine: tornano tutte "non toccate".
|
|
96
112
|
base.current = new Map(pages.map((p) => [p.slug, p]));
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import type { AssetFile } from "../../index.js";
|
|
2
2
|
import type { PageModel } from "../lib/pages";
|
|
3
|
-
export declare function PageHeader({ page, pages, onChange, seoActive, onSeoToggle, }: {
|
|
3
|
+
export declare function PageHeader({ page, pages, onChange, seoActive, onSeoToggle, languages, activeLanguage, onLanguageChange, }: {
|
|
4
4
|
page: PageModel;
|
|
5
5
|
pages: PageModel[];
|
|
6
6
|
onChange: (next: PageModel) => void;
|
|
7
7
|
seoActive: boolean;
|
|
8
8
|
onSeoToggle: () => void;
|
|
9
|
+
/** Assente nei siti monolingui: il controllo non viene nemmeno disegnato. */
|
|
10
|
+
languages?: {
|
|
11
|
+
default: string;
|
|
12
|
+
available: readonly string[];
|
|
13
|
+
};
|
|
14
|
+
activeLanguage?: string;
|
|
15
|
+
onLanguageChange?: (language: string) => void;
|
|
9
16
|
}): import("react").JSX.Element;
|
|
10
|
-
export declare function PageEditor({ page, onChange, onRefresh, pristine, pickMedia, pickMediaFiles, }: {
|
|
17
|
+
export declare function PageEditor({ page, onChange, onRefresh, pristine, pickMedia, pickMediaFiles, activeLanguage, defaultLanguage, }: {
|
|
11
18
|
page: PageModel;
|
|
12
19
|
onChange: (next: PageModel) => void;
|
|
13
20
|
/** Sostituisce la pagina con quella letta dal repo, senza contare come modifica dell'utente. */
|
|
@@ -20,4 +27,7 @@ export declare function PageEditor({ page, onChange, onRefresh, pristine, pickMe
|
|
|
20
27
|
pickMediaFiles?: (o: {
|
|
21
28
|
multiple: boolean;
|
|
22
29
|
}) => Promise<AssetFile[] | null>;
|
|
30
|
+
/** La lingua è scelta nella testata della pagina, non è un'impostazione globale del pannello. */
|
|
31
|
+
activeLanguage?: string;
|
|
32
|
+
defaultLanguage?: string;
|
|
23
33
|
}): import("react").JSX.Element;
|
|
@@ -22,7 +22,7 @@ import { GallerySectionsEditor } from "./GallerySectionsEditor";
|
|
|
22
22
|
import { PortfolioWorksEditor } from "./PortfolioWorksEditor";
|
|
23
23
|
import { AboutEditor } from "./AboutEditor";
|
|
24
24
|
import { WorkshopEditionsEditor } from "./WorkshopEditionsEditor";
|
|
25
|
-
export function PageHeader({ page, pages, onChange, seoActive, onSeoToggle, }) {
|
|
25
|
+
export function PageHeader({ page, pages, onChange, seoActive, onSeoToggle, languages, activeLanguage, onLanguageChange, }) {
|
|
26
26
|
const [titleOpen, setTitleOpen] = useState(false);
|
|
27
27
|
const [visibilityOpen, setVisibilityOpen] = useState(false);
|
|
28
28
|
const [title, setTitle] = useState(String(page.title ?? ""));
|
|
@@ -69,12 +69,13 @@ export function PageHeader({ page, pages, onChange, seoActive, onSeoToggle, }) {
|
|
|
69
69
|
setTitleOpen(false);
|
|
70
70
|
};
|
|
71
71
|
const online = page.published !== false;
|
|
72
|
-
|
|
72
|
+
const translationActive = !!languages && activeLanguage !== languages.default;
|
|
73
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "pe-head-main", children: [_jsx("button", { type: "button", className: "pe-title", title: "Modifica nome e indirizzo", disabled: translationActive, onClick: () => {
|
|
73
74
|
setTitle(String(page.title ?? ""));
|
|
74
75
|
setSlug(page.slug);
|
|
75
76
|
setVisibilityOpen(false);
|
|
76
77
|
setTitleOpen(true);
|
|
77
|
-
}, children: String(page.title ?? page.slug) }), _jsxs("button", { type: "button", className: "pe-seo-button" + (seoActive ? " is-active" : ""), "aria-pressed": seoActive, onClick: onSeoToggle, children: [_jsx(Icon.search, {}), "SEO"] }), _jsxs("div", { className: "pe-visibility", ref: visibilityRef, children: [_jsxs("button", { type: "button", className: "pe-status" + (online ? " is-online" : " is-offline"), "aria-haspopup": "menu", "aria-expanded": visibilityOpen, onClick: () => setVisibilityOpen((open) => !open), children: [_jsx("span", { className: "pe-status-dot" }), online ? "Online" : "Offline", _jsx("span", { className: "pe-chevron", "aria-hidden": "true", children: "\u2304" })] }), visibilityOpen && (_jsxs("div", { className: "pe-status-menu", role: "menu", children: [_jsx("span", { className: "pe-status-label", children: "Visibilit\u00E0 della pagina" }), _jsxs("button", { type: "button", className: online ? "is-selected" : "", onClick: () => {
|
|
78
|
+
}, children: String(page.title ?? page.slug) }), languages && (_jsx("div", { className: "pe-language", "aria-label": "Lingua del contenuto", children: languages.available.map((language) => (_jsx("button", { type: "button", className: activeLanguage === language ? "is-active" : "", "aria-pressed": activeLanguage === language, onClick: () => onLanguageChange?.(language), children: language.toUpperCase() }, language))) })), _jsxs("button", { type: "button", className: "pe-seo-button" + (seoActive ? " is-active" : ""), "aria-pressed": seoActive, disabled: translationActive, onClick: onSeoToggle, children: [_jsx(Icon.search, {}), "SEO"] }), _jsxs("div", { className: "pe-visibility", ref: visibilityRef, children: [_jsxs("button", { type: "button", className: "pe-status" + (online ? " is-online" : " is-offline"), "aria-haspopup": "menu", "aria-expanded": visibilityOpen, disabled: translationActive, onClick: () => setVisibilityOpen((open) => !open), children: [_jsx("span", { className: "pe-status-dot" }), online ? "Online" : "Offline", _jsx("span", { className: "pe-chevron", "aria-hidden": "true", children: "\u2304" })] }), visibilityOpen && (_jsxs("div", { className: "pe-status-menu", role: "menu", children: [_jsx("span", { className: "pe-status-label", children: "Visibilit\u00E0 della pagina" }), _jsxs("button", { type: "button", className: online ? "is-selected" : "", onClick: () => {
|
|
78
79
|
if (!online)
|
|
79
80
|
onChange({ ...page, published: true });
|
|
80
81
|
setVisibilityOpen(false);
|
|
@@ -82,7 +83,7 @@ export function PageHeader({ page, pages, onChange, seoActive, onSeoToggle, }) {
|
|
|
82
83
|
if (online)
|
|
83
84
|
onChange({ ...page, published: false });
|
|
84
85
|
setVisibilityOpen(false);
|
|
85
|
-
}, children: [_jsx("span", { className: "pe-radio", children: _jsx("span", {}) }), _jsxs("span", { children: [_jsx("strong", { children: "Offline" }), _jsx("small", { children: "La pagina non \u00E8 visibile sul sito." })] })] })] }))] }), _jsxs("label", { className: "pe-menu-toggle", children: [_jsx("input", { type: "checkbox", checked: !!page.inMenu, onChange: (event) => onChange({ ...page, inMenu: event.target.checked }) }), _jsx("span", { children: "Aggiungi al menu" })] })] }), titleOpen && (_jsx("div", { className: "pe-modal", role: "presentation", onMouseDown: (event) => {
|
|
86
|
+
}, children: [_jsx("span", { className: "pe-radio", children: _jsx("span", {}) }), _jsxs("span", { children: [_jsx("strong", { children: "Offline" }), _jsx("small", { children: "La pagina non \u00E8 visibile sul sito." })] })] })] }))] }), _jsxs("label", { className: "pe-menu-toggle", children: [_jsx("input", { type: "checkbox", checked: !!page.inMenu, disabled: translationActive, onChange: (event) => onChange({ ...page, inMenu: event.target.checked }) }), _jsx("span", { children: "Aggiungi al menu" })] })] }), titleOpen && (_jsx("div", { className: "pe-modal", role: "presentation", onMouseDown: (event) => {
|
|
86
87
|
if (event.target === event.currentTarget)
|
|
87
88
|
setTitleOpen(false);
|
|
88
89
|
}, children: _jsxs("div", { className: "pe-modal-card", role: "dialog", "aria-modal": "true", "aria-labelledby": "pe-title-dialog", children: [_jsxs("header", { children: [_jsx("h2", { id: "pe-title-dialog", children: "Modifica pagina" }), _jsx("button", { type: "button", onClick: () => setTitleOpen(false), "aria-label": "Chiudi", children: "\u00D7" })] }), _jsxs("div", { className: "pe-modal-fields", children: [_jsxs("label", { children: [_jsx("span", { children: "Nome pagina" }), _jsx("input", { autoFocus: true, value: title, onChange: (event) => setTitle(event.target.value), onKeyDown: (event) => {
|
|
@@ -161,16 +162,51 @@ function GalleryEditor({ value, onChange, pickMediaFiles, }) {
|
|
|
161
162
|
? { ...candidate, hidden: candidate.hidden ? undefined : true }
|
|
162
163
|
: candidate)), onRemove: () => onChange(list.filter((_, itemIndex) => itemIndex !== index)) }, item.src))) }) }), _jsx(DragOverlay, { children: activeItem && (_jsx("div", { className: "pe-photo-overlay", children: _jsx("img", { src: activeItem.src, alt: "" }) })) })] }))] }));
|
|
163
164
|
}
|
|
164
|
-
export function PageEditor({ page, onChange, onRefresh, pristine, pickMedia, pickMediaFiles, }) {
|
|
165
|
+
export function PageEditor({ page, onChange, onRefresh, pristine, pickMedia, pickMediaFiles, activeLanguage, defaultLanguage, }) {
|
|
165
166
|
const [bodyState, setBodyState] = useState("idle");
|
|
167
|
+
const translationActive = !!defaultLanguage && !!activeLanguage && activeLanguage !== defaultLanguage;
|
|
168
|
+
const translationKey = translationActive ? `${page.slug}:${activeLanguage}` : "";
|
|
169
|
+
/* Aprire una lingua non è una modifica: finché non si cambia davvero un campo, il modello
|
|
170
|
+
composto resta qui e il pulsante Salva non si accende. */
|
|
171
|
+
const [openedTranslations, setOpenedTranslations] = useState({});
|
|
172
|
+
const translation = translationActive
|
|
173
|
+
? page._translations?.[activeLanguage] ?? openedTranslations[translationKey]
|
|
174
|
+
: undefined;
|
|
175
|
+
const [translationState, setTranslationState] = useState("idle");
|
|
166
176
|
const definition = pageType(page.type);
|
|
167
177
|
const showBody = definition?.hasBody === true;
|
|
168
178
|
const isGallery = page.type === "gallery";
|
|
169
179
|
const isPortfolio = page.type === "portfolio";
|
|
180
|
+
/* Il Worker restituisce già il modello composto: l'editor può mostrarlo come una pagina
|
|
181
|
+
ordinaria. Aprirlo resta una lettura; il primo cambio lo sposta in `_translations`,
|
|
182
|
+
lasciando immutata la pagina predefinita e rendendo esplicita la futura scrittura. */
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
if (!translationActive || translation || !activeLanguage)
|
|
185
|
+
return;
|
|
186
|
+
let alive = true;
|
|
187
|
+
setTranslationState("loading");
|
|
188
|
+
api.pageBody(page.slug, activeLanguage)
|
|
189
|
+
.then((data) => {
|
|
190
|
+
if (!alive)
|
|
191
|
+
return;
|
|
192
|
+
setTranslationState("idle");
|
|
193
|
+
setOpenedTranslations((current) => ({
|
|
194
|
+
...current,
|
|
195
|
+
[translationKey]: {
|
|
196
|
+
page: data.page,
|
|
197
|
+
...(showBody ? { body: data.body } : {}),
|
|
198
|
+
_sha: page.translations?.[activeLanguage]?.sha,
|
|
199
|
+
},
|
|
200
|
+
}));
|
|
201
|
+
})
|
|
202
|
+
.catch(() => { if (alive)
|
|
203
|
+
setTranslationState("error"); });
|
|
204
|
+
return () => { alive = false; };
|
|
205
|
+
}, [translationActive, translation, activeLanguage, page.slug, page.translations, showBody, translationKey]);
|
|
170
206
|
/* APRENDO UNA PAGINA SI RILEGGE DAL REPO, non dal manifest pubblicato. */
|
|
171
207
|
const [loadedFor, setLoadedFor] = useState(null);
|
|
172
208
|
useEffect(() => {
|
|
173
|
-
if (loadedFor === page.slug || !pristine)
|
|
209
|
+
if (translationActive || loadedFor === page.slug || !pristine)
|
|
174
210
|
return;
|
|
175
211
|
let alive = true;
|
|
176
212
|
if (showBody)
|
|
@@ -196,39 +232,75 @@ export function PageEditor({ page, onChange, onRefresh, pristine, pickMedia, pic
|
|
|
196
232
|
}
|
|
197
233
|
});
|
|
198
234
|
return () => { alive = false; };
|
|
199
|
-
}, [page.slug, showBody, pristine, loadedFor]);
|
|
235
|
+
}, [page.slug, showBody, pristine, loadedFor, translationActive]);
|
|
236
|
+
if (translationActive && translationState === "loading" && !translation) {
|
|
237
|
+
return _jsx("p", { className: "pane-empty ak-hint", children: "Carico la traduzione\u2026" });
|
|
238
|
+
}
|
|
239
|
+
if (translationActive && translationState === "error" && !translation) {
|
|
240
|
+
return _jsx("p", { className: "pe-load-error", children: "Non sono riuscito a caricare la traduzione. Riprova o torna alla lingua predefinita." });
|
|
241
|
+
}
|
|
242
|
+
const shownPage = translationActive && translation
|
|
243
|
+
? { ...page, ...translation.page, ...(showBody ? { body: translation.body ?? "" } : {}) }
|
|
244
|
+
: page;
|
|
245
|
+
const changeShown = (next) => {
|
|
246
|
+
if (!translationActive || !activeLanguage) {
|
|
247
|
+
onChange(next);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const { _translations: _ignoredTranslations, translations: _ignoredPresence, _orig: _ignoredOrig, _sha: _ignoredSha, url: _ignoredUrl, body, ...model } = next;
|
|
251
|
+
const update = {
|
|
252
|
+
page: model,
|
|
253
|
+
...(showBody ? { body: typeof body === "string" ? body : "" } : {}),
|
|
254
|
+
_sha: translation?._sha ?? page.translations?.[activeLanguage]?.sha,
|
|
255
|
+
};
|
|
256
|
+
setOpenedTranslations((current) => ({ ...current, [translationKey]: update }));
|
|
257
|
+
onChange({
|
|
258
|
+
...page,
|
|
259
|
+
_translations: {
|
|
260
|
+
...(page._translations ?? {}),
|
|
261
|
+
[activeLanguage]: update,
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
};
|
|
265
|
+
/* Nella lingua predefinita il titolo vive nella testata, insieme a slug e visibilità. In una
|
|
266
|
+
traduzione quei controlli sono correttamente bloccati perché appartengono alla pagina base,
|
|
267
|
+
ma `title` può essere stato marcato traducibile nello schema: allora deve riapparire nel
|
|
268
|
+
form, altrimenti sarebbe dichiarabile ma impossibile da modificare. */
|
|
200
269
|
const commonOmissions = useMemo(() => [
|
|
201
|
-
|
|
270
|
+
...(translationActive ? [] : ["title"]),
|
|
271
|
+
"published", "inMenu",
|
|
202
272
|
"seoTitle", "seoDescription", "ogTitle", "ogDescription", "ogImage",
|
|
203
|
-
], []);
|
|
273
|
+
], [translationActive]);
|
|
204
274
|
/* Come la gallery: una pagina portfolio È la sua selezione di lavori, quindi la schermata
|
|
205
275
|
mostra quella e basta. Il `SchemaForm` generico qui elencava anche «Foto», «Edizioni»,
|
|
206
276
|
«Ritratto» e «Riconoscimenti» — campi di altri tipi di pagina, che compaiono perché il
|
|
207
277
|
form disegna tutto ciò che lo schema dichiara e non sa quale tipo si stia modificando.
|
|
208
278
|
Titolo, visibilità e SEO restano dove sono già: la barra in alto e la scheda SEO. */
|
|
209
|
-
if (isPortfolio) {
|
|
279
|
+
if (isPortfolio && !translationActive) {
|
|
210
280
|
return (_jsxs("div", { className: "ed ed-portfolio", children: [bodyState === "error" && (_jsx("p", { className: "pe-load-error", children: "Non sono riuscito ad aggiornare i dati della pagina dal sito." })), _jsx(PortfolioWorksEditor, { value: page.works, onChange: (works) => onChange({ ...page, works }), pickMediaFiles: pickMediaFiles })] }));
|
|
211
281
|
}
|
|
212
282
|
/* Stessa ragione delle due qui sopra: la pagina "chi sono" ha un'anatomia sua — biografia,
|
|
213
283
|
ritratto, riconoscimenti — e il form generico ci metteva accanto «Lavori», «Edizioni» e
|
|
214
284
|
«Sezioni», cioè i campi delle altre tipologie. Il corpo markdown è la biografia, quindi la
|
|
215
285
|
textarea in fondo non serve più: sta dentro l'editor, in cima, dov'è il suo posto. */
|
|
216
|
-
if (page.type === "about") {
|
|
286
|
+
if (page.type === "about" && !translationActive) {
|
|
217
287
|
return (_jsx("div", { className: "ed ed-about", children: _jsx(AboutEditor, { page: page, onChange: onChange, bodyState: bodyState, pickMediaFiles: pickMediaFiles }) }));
|
|
218
288
|
}
|
|
219
289
|
/* La pagina workshop: l'introduzione e le edizioni, nell'ordine in cui escono sul sito —
|
|
220
290
|
l'introduzione apre la colonna dei testi, sopra la prima locandina (WORKSHOP.md). Il corpo
|
|
221
291
|
markdown è quell'introduzione, quindi non c'è nessuna textarea in fondo: sta in cima, dove
|
|
222
292
|
la si legge. */
|
|
223
|
-
if (page.type === "workshop") {
|
|
293
|
+
if (page.type === "workshop" && !translationActive) {
|
|
224
294
|
return (_jsxs("div", { className: "ed ed-workshop", children: [_jsxs("section", { className: "wsh-intro", "aria-label": "Introduzione", children: [_jsxs("header", { className: "pw-head", children: [_jsxs("div", { children: [_jsx("h3", { children: "Introduzione" }), _jsx("p", { children: "Il testo che apre la pagina, sopra la prima edizione." })] }), _jsx("span", { className: "ed-count", children: bodyState === "loading" ? "Carico…" : proseStats(String(page.body ?? "")) })] }), bodyState === "error" && (_jsx("p", { className: "ak-error", children: "Non sono riuscito a caricare il testo dal sito. Ricarica prima di modificarlo." })), _jsx(ProseField, { className: "pw-field ed-prose", value: typeof page.body === "string" ? page.body : "", placeholder: "Racconta i workshop.\nVai a capo per iniziare un paragrafo nuovo.", onChange: (body) => onChange({ ...page, body }) })] }), _jsx(WorkshopEditionsEditor, { value: page.workshops, onChange: (workshops) => onChange({ ...page, workshops }), pickMediaFiles: pickMediaFiles })] }));
|
|
225
295
|
}
|
|
226
|
-
if (isGallery) {
|
|
296
|
+
if (isGallery && !translationActive) {
|
|
227
297
|
/* Le foto si modificano DOVE stanno: dentro il loro blocco, che è anche dove vivono nei dati
|
|
228
298
|
(`sections[].images`) e dove la griglia esce sul sito. Non c'è più un pannello staccato in
|
|
229
299
|
fondo — c'era finché le foto stavano in un campo della pagina, e insieme a lui è sparito
|
|
230
300
|
il caso in cui la stessa griglia si vedeva due volte. */
|
|
231
301
|
return (_jsxs("div", { className: "ed ed-gallery", children: [bodyState === "error" && (_jsx("p", { className: "pe-load-error", children: "Non sono riuscito ad aggiornare i dati della pagina dal sito." })), _jsx(GallerySectionsEditor, { value: page.sections, onChange: (sections) => onChange({ ...page, sections }), renderGallery: (images, onImages) => (_jsx(GalleryEditor, { value: images, onChange: onImages, pickMediaFiles: pickMediaFiles })), pickMediaFiles: pickMediaFiles })] }));
|
|
232
302
|
}
|
|
233
|
-
return (_jsxs("div", { className: "ed", children: [
|
|
303
|
+
return (_jsxs("div", { className: "ed", children: [translationActive && (_jsx("p", { className: "pe-translation-note", children: page.translations?.[activeLanguage]?.exists || page._translations?.[activeLanguage]
|
|
304
|
+
? `Stai modificando ${activeLanguage.toUpperCase()}. I campi non traducibili restano visibili e vengono copiati dalla lingua predefinita al salvataggio.`
|
|
305
|
+
: `${activeLanguage.toUpperCase()} è uguale all’italiano finché non modifichi e salvi una traduzione.` })), _jsx(SchemaForm, { shape: definition?.shape ?? {}, value: shownPage, omit: commonOmissions, onChange: (next) => changeShown({ ...next, slug: page.slug }), pickMedia: pickMedia, pickMediaFiles: pickMediaFiles, translationReadOnly: translationActive }), showBody && (_jsxs("fieldset", { className: "ak-group", children: [_jsx("legend", { className: "ak-legend", children: "testo della pagina" }), bodyState === "loading" && _jsx("p", { className: "ak-hint", children: "Carico\u2026" }), bodyState === "error" && _jsx("p", { className: "ak-error", children: "Non sono riuscito a caricare il testo." }), _jsx("textarea", { className: "ak-input ak-textarea", rows: 12, value: typeof shownPage.body === "string" ? shownPage.body : "", onChange: (event) => changeShown({ ...shownPage, body: event.target.value }) })] }))] }));
|
|
234
306
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AssetFile } from "../../index.js";
|
|
2
2
|
import type { PageModel } from "../lib/pages";
|
|
3
3
|
import type { SiteSettings } from "../lib/settings";
|
|
4
|
-
export declare function PageSeoEditor({ page, onChange, onRefresh, pristine, pickMediaFiles, siteSettings, }: {
|
|
4
|
+
export declare function PageSeoEditor({ page, onChange, onRefresh, pristine, pickMediaFiles, siteSettings, readOnly, }: {
|
|
5
5
|
page: PageModel;
|
|
6
6
|
onChange: (next: PageModel) => void;
|
|
7
7
|
onRefresh: (next: PageModel) => void;
|
|
@@ -10,4 +10,7 @@ export declare function PageSeoEditor({ page, onChange, onRefresh, pristine, pic
|
|
|
10
10
|
multiple: boolean;
|
|
11
11
|
}) => Promise<AssetFile[] | null>;
|
|
12
12
|
siteSettings?: SiteSettings | null;
|
|
13
|
+
/** La vista SEO non carica un modello tradotto: in una lingua secondaria non deve mai
|
|
14
|
+
scrivere per sbaglio i metadati della pagina predefinita. */
|
|
15
|
+
readOnly?: boolean;
|
|
13
16
|
}): import("react").JSX.Element;
|
|
@@ -48,7 +48,7 @@ function FieldHelp({ children, }) {
|
|
|
48
48
|
function Count({ value, recommended, max }) {
|
|
49
49
|
return (_jsxs("span", { className: "seo-count" + (value.length > recommended ? " is-long" : ""), children: [value.length, "/", max, " caratteri"] }));
|
|
50
50
|
}
|
|
51
|
-
export function PageSeoEditor({ page, onChange, onRefresh, pristine, pickMediaFiles, siteSettings, }) {
|
|
51
|
+
export function PageSeoEditor({ page, onChange, onRefresh, pristine, pickMediaFiles, siteSettings, readOnly = false, }) {
|
|
52
52
|
const [loadedFor, setLoadedFor] = useState(null);
|
|
53
53
|
const [loadError, setLoadError] = useState(false);
|
|
54
54
|
/* Arrivando direttamente a /seo la pagina normale non viene montata, quindi la lettura dal
|
|
@@ -98,5 +98,5 @@ export function PageSeoEditor({ page, onChange, onRefresh, pristine, pickMediaFi
|
|
|
98
98
|
if (selected?.[0])
|
|
99
99
|
onChange({ ...page, ogImage: selected[0].url });
|
|
100
100
|
};
|
|
101
|
-
return (_jsxs("section", { className: "seo-editor", children: [_jsxs("header", { className: "seo-intro", children: [_jsxs("div", { children: [_jsx("span", { className: "seo-kicker", children: "Impostazioni della pagina" }), _jsx("h2", { children: "Ottimizzazione SEO" }), _jsx("p", { children: "Controlla come questa pagina appare nei motori di ricerca e quando viene condivisa." })] }), _jsxs("span", { className: "seo-complete" + (complete ? " is-complete" : ""), children: [_jsx("span", { children: complete ? "✓" : "!" }), complete ? "SEO completa" : "Da completare"] })] }), loadError && (_jsx("p", { className: "pe-load-error", children: "Non sono riuscito ad aggiornare i dati SEO dal sito." })), _jsxs("section", { className: "seo-section", children: [_jsxs("div", { className: "seo-section-heading", children: [_jsx("span", { children: "Motori di ricerca" }), _jsxs("div", { children: [_jsx("h3", { children: "Anteprima del risultato" }), _jsx("p", { children: "Il contenuto si aggiorna mentre scrivi." })] })] }), _jsxs("div", { className: "seo-row seo-preview-row", children: [_jsx("span", { className: "seo-row-label", children: "Anteprima" }), _jsxs("div", { className: "seo-search-preview", "aria-label": "Anteprima del risultato di ricerca", children: [_jsx("span", { className: "seo-search-site", children: globalTitle }), _jsx("span", { className: "seo-search-url", children: pageUrl }), _jsx("strong", { children: searchTitle }), _jsx("p", { children: searchDescription })] }), _jsx(FieldHelp, { children: "\u00C8 una simulazione del risultato. Il motore di ricerca pu\u00F2 adattarne il testo." })] }), _jsxs("div", { className: "seo-row", children: [_jsxs("label", { className: "seo-field", children: [_jsx("span", { children: "Titolo SEO" }), _jsx("input", { value: seoTitle, maxLength: 120, placeholder: `${text(page.title)} — ${globalTitle}`, onChange: (event) => onChange({ ...page, seoTitle: event.target.value }) }), _jsx(Count, { value: seoTitle, recommended: 60, max: 120 })] }), _jsx(FieldHelp, { children: "Un titolo descrittivo e unico. Per una lettura completa nei risultati \u00E8 preferibile restare entro circa 60 caratteri." })] }), _jsxs("div", { className: "seo-row", children: [_jsxs("label", { className: "seo-field", children: [_jsx("span", { children: "Descrizione SEO" }), _jsx("textarea", { rows: 5, value: seoDescription, maxLength: 255, placeholder: "Descrivi il contenuto della pagina e il valore che offre.", onChange: (event) => onChange({ ...page, seoDescription: event.target.value }) }), _jsx(Count, { value: seoDescription, recommended: 160, max: 255 })] }), _jsx(FieldHelp, { children: "Riassumi la pagina con parole naturali e pertinenti. I primi 160 caratteri sono quelli che hanno pi\u00F9 probabilit\u00E0 di essere mostrati per intero." })] })] }), _jsxs("section", { className: "seo-section", children: [_jsxs("div", { className: "seo-section-heading", children: [_jsx("span", { children: "Open Graph" }), _jsxs("div", { children: [_jsx("h3", { children: "Anteprima della condivisione" }), _jsx("p", { children: "Usata da social, chat e applicazioni quando viene condiviso il link." })] })] }), _jsxs("div", { className: "seo-row seo-preview-row", children: [_jsx("span", { className: "seo-row-label", children: "Anteprima" }), _jsxs("article", { className: "seo-og-preview", "aria-label": "Anteprima Open Graph", children: [_jsx("div", { className: "seo-og-image", children: socialImage ? (_jsx("img", { src: socialImage, alt: "" })) : (_jsxs("span", { children: [_jsx(Icon.media, {}), " Nessuna immagine"] })) }), _jsxs("div", { className: "seo-og-copy", children: [_jsx("span", { children: site().publicBase.replace(/^https?:\/\//, "").toUpperCase() }), _jsx("strong", { children: socialTitle }), _jsx("p", { children: socialDescription })] })] }), _jsx(FieldHelp, { children: "L\u2019anteprima usa i campi Open Graph; quelli vuoti ereditano automaticamente i valori SEO." })] }), _jsxs("div", { className: "seo-row", children: [_jsxs("label", { className: "seo-field", children: [_jsx("span", { children: "Titolo Open Graph" }), _jsx("input", { value: ogTitle, maxLength: 120, placeholder: searchTitle, onChange: (event) => onChange({ ...page, ogTitle: event.target.value }) }), _jsx(Count, { value: ogTitle, recommended: 70, max: 120 })] }), _jsx(FieldHelp, { children: "Lascia vuoto per usare il titolo SEO. Puoi renderlo pi\u00F9 editoriale per la condivisione." })] }), _jsxs("div", { className: "seo-row", children: [_jsxs("label", { className: "seo-field", children: [_jsx("span", { children: "Descrizione Open Graph" }), _jsx("textarea", { rows: 4, value: ogDescription, maxLength: 300, placeholder: searchDescription, onChange: (event) => onChange({ ...page, ogDescription: event.target.value }) }), _jsx(Count, { value: ogDescription, recommended: 200, max: 300 })] }), _jsx(FieldHelp, { children: "Lascia vuoto per usare la descrizione SEO. Mantienila breve per evitare tagli nelle card." })] }), _jsxs("div", { className: "seo-row", children: [_jsxs("div", { className: "seo-field", children: [_jsx("span", { children: "Immagine Open Graph" }), _jsxs("div", { className: "seo-image-picker", children: [socialImage ? _jsx("img", { src: socialImage, alt: "" }) : _jsx("div", { children: _jsx(Icon.media, {}) }), _jsxs("div", { children: [_jsx("button", { type: "button", className: "ak-btn", onClick: () => void chooseImage(), children: explicitImage ? "Cambia immagine" : "Scegli immagine" }), explicitImage && (_jsx("button", { type: "button", className: "ak-btn ak-btn-quiet", onClick: () => onChange({ ...page, ogImage: "" }), children: "Usa automatica" })), _jsx("small", { children: explicitImage ? "Immagine personalizzata" : socialImage ? "Prima immagine pubblica della pagina" : "Nessun ripiego disponibile" })] })] })] }), _jsx(FieldHelp, { children: "Una foto orizzontale funziona meglio nelle condivisioni. Se non ne scegli una, viene usata la prima immagine pubblica della pagina." })] })] })] }));
|
|
101
|
+
return (_jsxs("section", { className: "seo-editor", children: [_jsxs("header", { className: "seo-intro", children: [_jsxs("div", { children: [_jsx("span", { className: "seo-kicker", children: "Impostazioni della pagina" }), _jsx("h2", { children: "Ottimizzazione SEO" }), _jsx("p", { children: "Controlla come questa pagina appare nei motori di ricerca e quando viene condivisa." })] }), _jsxs("span", { className: "seo-complete" + (complete ? " is-complete" : ""), children: [_jsx("span", { children: complete ? "✓" : "!" }), complete ? "SEO completa" : "Da completare"] })] }), loadError && (_jsx("p", { className: "pe-load-error", children: "Non sono riuscito ad aggiornare i dati SEO dal sito." })), readOnly && (_jsx("p", { className: "pe-translation-note", children: "I metadati SEO di questa lingua sono ereditati. Torna alla lingua predefinita per modificarli." })), _jsxs("section", { className: "seo-section", children: [_jsxs("div", { className: "seo-section-heading", children: [_jsx("span", { children: "Motori di ricerca" }), _jsxs("div", { children: [_jsx("h3", { children: "Anteprima del risultato" }), _jsx("p", { children: "Il contenuto si aggiorna mentre scrivi." })] })] }), _jsxs("div", { className: "seo-row seo-preview-row", children: [_jsx("span", { className: "seo-row-label", children: "Anteprima" }), _jsxs("div", { className: "seo-search-preview", "aria-label": "Anteprima del risultato di ricerca", children: [_jsx("span", { className: "seo-search-site", children: globalTitle }), _jsx("span", { className: "seo-search-url", children: pageUrl }), _jsx("strong", { children: searchTitle }), _jsx("p", { children: searchDescription })] }), _jsx(FieldHelp, { children: "\u00C8 una simulazione del risultato. Il motore di ricerca pu\u00F2 adattarne il testo." })] }), _jsxs("div", { className: "seo-row", children: [_jsxs("label", { className: "seo-field", children: [_jsx("span", { children: "Titolo SEO" }), _jsx("input", { value: seoTitle, maxLength: 120, disabled: readOnly, placeholder: `${text(page.title)} — ${globalTitle}`, onChange: (event) => onChange({ ...page, seoTitle: event.target.value }) }), _jsx(Count, { value: seoTitle, recommended: 60, max: 120 })] }), _jsx(FieldHelp, { children: "Un titolo descrittivo e unico. Per una lettura completa nei risultati \u00E8 preferibile restare entro circa 60 caratteri." })] }), _jsxs("div", { className: "seo-row", children: [_jsxs("label", { className: "seo-field", children: [_jsx("span", { children: "Descrizione SEO" }), _jsx("textarea", { rows: 5, value: seoDescription, maxLength: 255, disabled: readOnly, placeholder: "Descrivi il contenuto della pagina e il valore che offre.", onChange: (event) => onChange({ ...page, seoDescription: event.target.value }) }), _jsx(Count, { value: seoDescription, recommended: 160, max: 255 })] }), _jsx(FieldHelp, { children: "Riassumi la pagina con parole naturali e pertinenti. I primi 160 caratteri sono quelli che hanno pi\u00F9 probabilit\u00E0 di essere mostrati per intero." })] })] }), _jsxs("section", { className: "seo-section", children: [_jsxs("div", { className: "seo-section-heading", children: [_jsx("span", { children: "Open Graph" }), _jsxs("div", { children: [_jsx("h3", { children: "Anteprima della condivisione" }), _jsx("p", { children: "Usata da social, chat e applicazioni quando viene condiviso il link." })] })] }), _jsxs("div", { className: "seo-row seo-preview-row", children: [_jsx("span", { className: "seo-row-label", children: "Anteprima" }), _jsxs("article", { className: "seo-og-preview", "aria-label": "Anteprima Open Graph", children: [_jsx("div", { className: "seo-og-image", children: socialImage ? (_jsx("img", { src: socialImage, alt: "" })) : (_jsxs("span", { children: [_jsx(Icon.media, {}), " Nessuna immagine"] })) }), _jsxs("div", { className: "seo-og-copy", children: [_jsx("span", { children: site().publicBase.replace(/^https?:\/\//, "").toUpperCase() }), _jsx("strong", { children: socialTitle }), _jsx("p", { children: socialDescription })] })] }), _jsx(FieldHelp, { children: "L\u2019anteprima usa i campi Open Graph; quelli vuoti ereditano automaticamente i valori SEO." })] }), _jsxs("div", { className: "seo-row", children: [_jsxs("label", { className: "seo-field", children: [_jsx("span", { children: "Titolo Open Graph" }), _jsx("input", { value: ogTitle, maxLength: 120, disabled: readOnly, placeholder: searchTitle, onChange: (event) => onChange({ ...page, ogTitle: event.target.value }) }), _jsx(Count, { value: ogTitle, recommended: 70, max: 120 })] }), _jsx(FieldHelp, { children: "Lascia vuoto per usare il titolo SEO. Puoi renderlo pi\u00F9 editoriale per la condivisione." })] }), _jsxs("div", { className: "seo-row", children: [_jsxs("label", { className: "seo-field", children: [_jsx("span", { children: "Descrizione Open Graph" }), _jsx("textarea", { rows: 4, value: ogDescription, maxLength: 300, disabled: readOnly, placeholder: searchDescription, onChange: (event) => onChange({ ...page, ogDescription: event.target.value }) }), _jsx(Count, { value: ogDescription, recommended: 200, max: 300 })] }), _jsx(FieldHelp, { children: "Lascia vuoto per usare la descrizione SEO. Mantienila breve per evitare tagli nelle card." })] }), _jsxs("div", { className: "seo-row", children: [_jsxs("div", { className: "seo-field", children: [_jsx("span", { children: "Immagine Open Graph" }), _jsxs("div", { className: "seo-image-picker", children: [socialImage ? _jsx("img", { src: socialImage, alt: "" }) : _jsx("div", { children: _jsx(Icon.media, {}) }), _jsxs("div", { children: [_jsx("button", { type: "button", className: "ak-btn", disabled: readOnly, onClick: () => void chooseImage(), children: explicitImage ? "Cambia immagine" : "Scegli immagine" }), explicitImage && (_jsx("button", { type: "button", className: "ak-btn ak-btn-quiet", disabled: readOnly, onClick: () => onChange({ ...page, ogImage: "" }), children: "Usa automatica" })), _jsx("small", { children: explicitImage ? "Immagine personalizzata" : socialImage ? "Prima immagine pubblica della pagina" : "Nessun ripiego disponibile" })] })] })] }), _jsx(FieldHelp, { children: "Una foto orizzontale funziona meglio nelle condivisioni. Se non ne scegli una, viene usata la prima immagine pubblica della pagina." })] })] })] }));
|
|
102
102
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { PageModel } from "../lib/pages";
|
|
2
|
-
export declare function PagesList({ pages, current, onEdit, onChange, onRenamed, }: {
|
|
2
|
+
export declare function PagesList({ pages, current, onEdit, onChange, onRenamed, languages, }: {
|
|
3
3
|
pages: PageModel[];
|
|
4
4
|
current?: string;
|
|
5
5
|
onEdit: (slug: string) => void;
|
|
@@ -7,4 +7,8 @@ export declare function PagesList({ pages, current, onEdit, onChange, onRenamed,
|
|
|
7
7
|
/** Una rinomina cambia l'indirizzo: se la pagina è quella aperta nell'editor, la rotta punta
|
|
8
8
|
* a uno slug che non esiste più e l'editor resterebbe vuoto. Lo sa solo chi tiene la rotta. */
|
|
9
9
|
onRenamed?: (from: string, to: string) => void;
|
|
10
|
+
languages?: {
|
|
11
|
+
default: string;
|
|
12
|
+
available: readonly string[];
|
|
13
|
+
};
|
|
10
14
|
}): import("react").JSX.Element;
|
|
@@ -20,12 +20,18 @@ import { Icon } from "../ui/icons";
|
|
|
20
20
|
const ICONS = {
|
|
21
21
|
gallery: Icon.gallery, page: Icon.doc, contact: Icon.mail,
|
|
22
22
|
};
|
|
23
|
-
function Row({ p, pages, current, onEdit, onChange, onRemove, onDuplicate }) {
|
|
23
|
+
function Row({ p, pages, current, onEdit, onChange, onRemove, onDuplicate, languages }) {
|
|
24
24
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: p.slug });
|
|
25
25
|
const TypeIcon = ICONS[String(p.type)] ?? Icon.doc;
|
|
26
|
-
return (_jsxs("li", { ref: setNodeRef, className: "pg-row" + (isDragging ? " is-dragging" : "") + (p.slug === current ? " is-on" : ""), style: { transform: CSS.Transform.toString(transform), transition }, children: [_jsx("button", { className: "pg-handle", ...attributes, ...listeners, title: "Trascina per riordinare", children: _jsx(Icon.grip, {}) }), _jsxs("button", { className: "pg-open", onClick: () => onEdit(p.slug), children: [_jsx("span", { className: "pg-icon", children: _jsx(TypeIcon, {}) }), _jsx("span", { className: "pg-title", children: String(p.title ?? p.slug) }),
|
|
26
|
+
return (_jsxs("li", { ref: setNodeRef, className: "pg-row" + (isDragging ? " is-dragging" : "") + (p.slug === current ? " is-on" : ""), style: { transform: CSS.Transform.toString(transform), transition }, children: [_jsx("button", { className: "pg-handle", ...attributes, ...listeners, title: "Trascina per riordinare", children: _jsx(Icon.grip, {}) }), _jsxs("button", { className: "pg-open", onClick: () => onEdit(p.slug), children: [_jsx("span", { className: "pg-icon", children: _jsx(TypeIcon, {}) }), _jsx("span", { className: "pg-title", children: String(p.title ?? p.slug) }), languages?.available.filter((language) => language !== languages.default).map((language) => {
|
|
27
|
+
const saved = p.translations?.[language]?.exists;
|
|
28
|
+
const draft = !!p._translations?.[language];
|
|
29
|
+
return (_jsx("span", { className: "pg-language" + (saved ? " is-saved" : draft ? " is-draft" : " is-inherited"), title: saved ? `${language.toUpperCase()} tradotta` : draft
|
|
30
|
+
? `${language.toUpperCase()} modificata: salva per creare la traduzione`
|
|
31
|
+
: `${language.toUpperCase()} uguale alla lingua predefinita`, children: language.toUpperCase() }, language));
|
|
32
|
+
}), p.published === false && (_jsx("span", { className: "pg-off", title: "nascosta: non va online", children: _jsx(Icon.eyeOff, {}) }))] }), _jsx(RowMenu, { page: p, pages: pages, onChange: (next) => onChange(p.slug, next), onRemove: onRemove, onDuplicate: onDuplicate })] }));
|
|
27
33
|
}
|
|
28
|
-
function Group({ title, items, all, current, onEdit, onReorder, onChange, onRemove, onDuplicate, }) {
|
|
34
|
+
function Group({ title, items, all, current, onEdit, onReorder, onChange, onRemove, onDuplicate, languages, }) {
|
|
29
35
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
|
|
30
36
|
const onDragEnd = (e) => {
|
|
31
37
|
const { active, over } = e;
|
|
@@ -33,9 +39,9 @@ function Group({ title, items, all, current, onEdit, onReorder, onChange, onRemo
|
|
|
33
39
|
return;
|
|
34
40
|
onReorder(items.findIndex((p) => p.slug === active.id), items.findIndex((p) => p.slug === over.id));
|
|
35
41
|
};
|
|
36
|
-
return (_jsxs("section", { className: "pg-group", children: [_jsx("h2", { className: "pg-group-title", children: title }), items.length === 0 && _jsx("p", { className: "pg-empty", children: "nessuna" }), _jsx(DndContext, { sensors: sensors, collisionDetection: closestCenter, modifiers: [restrictToVerticalAxis], onDragEnd: onDragEnd, children: _jsx(SortableContext, { items: items.map((p) => p.slug), strategy: verticalListSortingStrategy, children: _jsx("ul", { className: "pg-list", children: items.map((p) => (_jsx(Row, { p: p, pages: all, current: current, onEdit: onEdit, onChange: onChange, onRemove: () => onRemove(p.slug), onDuplicate: () => onDuplicate(p.slug) }, p.slug))) }) }) })] }));
|
|
42
|
+
return (_jsxs("section", { className: "pg-group", children: [_jsx("h2", { className: "pg-group-title", children: title }), items.length === 0 && _jsx("p", { className: "pg-empty", children: "nessuna" }), _jsx(DndContext, { sensors: sensors, collisionDetection: closestCenter, modifiers: [restrictToVerticalAxis], onDragEnd: onDragEnd, children: _jsx(SortableContext, { items: items.map((p) => p.slug), strategy: verticalListSortingStrategy, children: _jsx("ul", { className: "pg-list", children: items.map((p) => (_jsx(Row, { p: p, pages: all, current: current, onEdit: onEdit, onChange: onChange, languages: languages, onRemove: () => onRemove(p.slug), onDuplicate: () => onDuplicate(p.slug) }, p.slug))) }) }) })] }));
|
|
37
43
|
}
|
|
38
|
-
export function PagesList({ pages, current, onEdit, onChange, onRenamed, }) {
|
|
44
|
+
export function PagesList({ pages, current, onEdit, onChange, onRenamed, languages, }) {
|
|
39
45
|
const inMenu = pages.filter((p) => !!p.inMenu);
|
|
40
46
|
const notInMenu = pages.filter((p) => !p.inMenu);
|
|
41
47
|
const reorder = (group, from, to) => {
|
|
@@ -71,6 +77,6 @@ export function PagesList({ pages, current, onEdit, onChange, onRenamed, }) {
|
|
|
71
77
|
const i = pages.indexOf(src);
|
|
72
78
|
onChange([...pages.slice(0, i + 1), copia, ...pages.slice(i + 1)]);
|
|
73
79
|
};
|
|
74
|
-
const shared = { all: pages, current, onEdit, onChange: replace, onRemove: remove, onDuplicate: duplicate };
|
|
80
|
+
const shared = { all: pages, current, onEdit, onChange: replace, onRemove: remove, onDuplicate: duplicate, languages };
|
|
75
81
|
return (_jsxs("div", { className: "pg", children: [_jsx(Group, { title: "Nel menu", items: inMenu, ...shared, onReorder: (f, t) => reorder(inMenu, f, t) }), _jsx(Group, { title: "Fuori dal menu", items: notInMenu, ...shared, onReorder: (f, t) => reorder(notInMenu, f, t) })] }));
|
|
76
82
|
}
|
package/dist/app/site.d.ts
CHANGED
|
@@ -10,6 +10,12 @@ export interface PageTypeConfig {
|
|
|
10
10
|
payload: z.ZodType;
|
|
11
11
|
hasBody: boolean;
|
|
12
12
|
}
|
|
13
|
+
/** Lingue dei contenuti, assenti nei siti monolingui. I codici sono quelli dei sibling Hugo
|
|
14
|
+
* (`index.md`, `index.en.md`): la lingua predefinita non porta alcun suffisso. */
|
|
15
|
+
export interface LanguagesConfig {
|
|
16
|
+
default: string;
|
|
17
|
+
available: readonly string[];
|
|
18
|
+
}
|
|
13
19
|
export interface SiteAdminConfig {
|
|
14
20
|
/** Il nome mostrato in cima al rail, e il ripiego del titolo nell'anteprima SEO. */
|
|
15
21
|
name: string;
|
|
@@ -28,6 +34,8 @@ export interface SiteAdminConfig {
|
|
|
28
34
|
};
|
|
29
35
|
/** Forme delle pagine della collezione, indicizzate dal loro `type` nel front matter. */
|
|
30
36
|
pages: Record<string, PageTypeConfig>;
|
|
37
|
+
/** Dichiararla abilita il contenuto bilingue; assente significa sito monolingue. */
|
|
38
|
+
languages?: LanguagesConfig;
|
|
31
39
|
/** Forma del singleton home: i suoi `group` diventano le voci della seconda colonna. */
|
|
32
40
|
home: Shape;
|
|
33
41
|
/** Peso di ordinamento di una pagina data la sua posizione nell'elenco. */
|
|
@@ -17,5 +17,7 @@ export interface SchemaFormProps {
|
|
|
17
17
|
}) => Promise<AssetFile[] | null>;
|
|
18
18
|
/** Campi da non mostrare, anche se dichiarati (es. quelli già gestiti dall'elenco). */
|
|
19
19
|
omit?: readonly string[];
|
|
20
|
+
/** Vero nella lingua secondaria: i campi senza il marchio schema restano visibili ma bloccati. */
|
|
21
|
+
translationReadOnly?: boolean;
|
|
20
22
|
}
|
|
21
|
-
export declare function SchemaForm({ shape, value, onChange, pickMedia, pickMediaFiles, omit }: SchemaFormProps): import("react").JSX.Element;
|
|
23
|
+
export declare function SchemaForm({ shape, value, onChange, pickMedia, pickMediaFiles, omit, translationReadOnly }: SchemaFormProps): import("react").JSX.Element;
|
package/dist/form/SchemaForm.js
CHANGED
|
@@ -9,6 +9,7 @@ import { useMemo } from "react";
|
|
|
9
9
|
import { useForm, Controller } from "react-hook-form";
|
|
10
10
|
import * as z from "zod";
|
|
11
11
|
import { ui } from "../registries.js";
|
|
12
|
+
import { isTranslatable } from "../i18n.js";
|
|
12
13
|
import { fieldFor } from "./registry.js";
|
|
13
14
|
import "./fields/index.js"; // registra gli otto widget
|
|
14
15
|
/* Resolver per React Hook Form scritto a mano invece di usare @hookform/resolvers: quel
|
|
@@ -29,7 +30,7 @@ export function zodResolver(schema) {
|
|
|
29
30
|
return { values: {}, errors: errors };
|
|
30
31
|
};
|
|
31
32
|
}
|
|
32
|
-
export function SchemaForm({ shape, value, onChange, pickMedia, pickMediaFiles, omit = [] }) {
|
|
33
|
+
export function SchemaForm({ shape, value, onChange, pickMedia, pickMediaFiles, omit = [], translationReadOnly = false }) {
|
|
33
34
|
const object = useMemo(() => z.object(shape), [shape]);
|
|
34
35
|
const { control, formState } = useForm({
|
|
35
36
|
values: value,
|
|
@@ -56,12 +57,17 @@ export function SchemaForm({ shape, value, onChange, pickMedia, pickMediaFiles,
|
|
|
56
57
|
return (_jsx("div", { className: "ak-form", children: groups.map((g) => (_jsxs("fieldset", { className: "ak-group", children: [g.name && _jsx("legend", { className: "ak-legend", children: g.name }), g.fields.map((name) => {
|
|
57
58
|
const meta = ui.get(shape[name]);
|
|
58
59
|
const Field = fieldFor(meta.widget);
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
60
|
+
const nested = meta.widget === "objectList";
|
|
61
|
+
const inherited = translationReadOnly && !isTranslatable(shape[name]);
|
|
62
|
+
return (_jsx(Controller, { name: name, control: control, render: ({ field }) => {
|
|
63
|
+
const control = (_jsx(Field, { name: name,
|
|
64
|
+
/* `schema` serve a `objectList`, l'unico widget che deve scendere nei figli.
|
|
65
|
+
Passarlo a tutti costa nulla e evita un canale a parte per un caso solo. */
|
|
66
|
+
meta: { ...meta, schema: shape[name] }, value: field.value, error: formState.errors?.[name]?.message, pickMedia: pickMedia, pickMediaFiles: pickMediaFiles, translationReadOnly: translationReadOnly, onChange: (v) => {
|
|
67
|
+
field.onChange(v);
|
|
68
|
+
onChange({ ...value, [name]: v });
|
|
69
|
+
} }));
|
|
70
|
+
return nested ? control : _jsx("fieldset", { disabled: inherited, children: control });
|
|
71
|
+
} }, name));
|
|
66
72
|
})] }, g.name || "_"))) }));
|
|
67
73
|
}
|
|
@@ -9,6 +9,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
9
9
|
*/
|
|
10
10
|
import * as z from "zod";
|
|
11
11
|
import { ui } from "../../registries.js";
|
|
12
|
+
import { isTranslatable } from "../../i18n.js";
|
|
12
13
|
import { registerField, fieldFor } from "../registry.js";
|
|
13
14
|
/** Sotto-schema di un campo Zod, se è un oggetto: serve a scendere di un livello. */
|
|
14
15
|
function shapeOf(schema) {
|
|
@@ -54,14 +55,15 @@ function blankFor(schema) {
|
|
|
54
55
|
}
|
|
55
56
|
/** Widget che rendono da soli un campo-array: qui la ricorsione non deve scendere. */
|
|
56
57
|
const SELF_RENDERING_LISTS = new Set(["r2Gallery", "r2Curated", "paragraphs"]);
|
|
57
|
-
function Node({ schema, value, onChange, pickMedia, pickMediaFiles, label, }) {
|
|
58
|
+
function Node({ schema, value, onChange, pickMedia, pickMediaFiles, label, translationReadOnly, inheritedEditable, }) {
|
|
58
59
|
const meta = ui.get(schema);
|
|
59
60
|
const shape = shapeOf(schema);
|
|
60
61
|
const element = elementOf(schema);
|
|
62
|
+
const editable = !translationReadOnly || inheritedEditable || isTranslatable(schema);
|
|
61
63
|
// Oggetto: un riquadro con dentro i suoi campi.
|
|
62
64
|
if (shape) {
|
|
63
65
|
const obj = (value ?? {});
|
|
64
|
-
return (_jsx("div", { className: "ol-obj", children: Object.entries(shape).map(([k, sub]) => (_jsx(Node, { schema: sub, label: ui.get(sub)?.label ?? k, value: obj[k], pickMedia: pickMedia, pickMediaFiles: pickMediaFiles, onChange: (v) => onChange({ ...obj, [k]: v }) }, k))) }));
|
|
66
|
+
return (_jsx("div", { className: "ol-obj", children: Object.entries(shape).map(([k, sub]) => (_jsx(Node, { schema: sub, label: ui.get(sub)?.label ?? k, value: obj[k], pickMedia: pickMedia, pickMediaFiles: pickMediaFiles, translationReadOnly: translationReadOnly, inheritedEditable: editable, onChange: (v) => onChange({ ...obj, [k]: v }) }, k))) }));
|
|
65
67
|
}
|
|
66
68
|
/* Lista: elementi riordinabili e rimovibili — A MENO che il campo dichiari un widget che sa
|
|
67
69
|
* già rendere una lista per conto suo. La ricorsione si ferma lì e disegna il widget.
|
|
@@ -82,18 +84,19 @@ function Node({ schema, value, onChange, pickMedia, pickMediaFiles, label, }) {
|
|
|
82
84
|
next.splice(to, 0, x);
|
|
83
85
|
onChange(next);
|
|
84
86
|
};
|
|
85
|
-
return (_jsxs("fieldset", { className: "ol-list", children: [_jsx("legend", { className: "ak-legend", children: label ?? meta?.label }), list.map((item, i) => (_jsxs("div", { className: "ol-item", children: [_jsxs("div", { className: "ol-item-bar", children: [_jsx("span", { className: "ak-hint", children: i + 1 }), _jsx("span", { className: "ak-spacer" }), _jsx("button", { type: "button", className: "ak-icon", title: "Su", onClick: () => move(i, i - 1), children: "\u2191" }), _jsx("button", { type: "button", className: "ak-icon", title: "Gi\u00F9", onClick: () => move(i, i + 1), children: "\u2193" }), _jsx("button", { type: "button", className: "ak-icon", title: "Rimuovi", onClick: () => onChange(list.filter((_, j) => j !== i)), children: "\u2715" })] }), _jsx(Node, { schema: element, value: item, pickMedia: pickMedia, pickMediaFiles: pickMediaFiles, onChange: (v) => onChange(list.map((x, j) => (j === i ? v : x))) })] }, i))), _jsx("button", { type: "button", className: "ak-btn", onClick: () => onChange([...list, blankFor(element)]), children: "+ Aggiungi" })] }));
|
|
87
|
+
return (_jsxs("fieldset", { className: "ol-list", children: [_jsx("legend", { className: "ak-legend", children: label ?? meta?.label }), list.map((item, i) => (_jsxs("div", { className: "ol-item", children: [_jsxs("div", { className: "ol-item-bar", children: [_jsx("span", { className: "ak-hint", children: i + 1 }), _jsx("span", { className: "ak-spacer" }), _jsx("button", { type: "button", disabled: !editable, className: "ak-icon", title: "Su", onClick: () => move(i, i - 1), children: "\u2191" }), _jsx("button", { type: "button", disabled: !editable, className: "ak-icon", title: "Gi\u00F9", onClick: () => move(i, i + 1), children: "\u2193" }), _jsx("button", { type: "button", disabled: !editable, className: "ak-icon", title: "Rimuovi", onClick: () => onChange(list.filter((_, j) => j !== i)), children: "\u2715" })] }), _jsx(Node, { schema: element, value: item, pickMedia: pickMedia, pickMediaFiles: pickMediaFiles, translationReadOnly: translationReadOnly, inheritedEditable: editable, onChange: (v) => onChange(list.map((x, j) => (j === i ? v : x))) })] }, i))), _jsx("button", { type: "button", disabled: !editable, className: "ak-btn", onClick: () => onChange([...list, blankFor(element)]), children: "+ Aggiungi" })] }));
|
|
86
88
|
}
|
|
87
89
|
// Foglia: la disegna il widget dichiarato, come qualsiasi altro campo.
|
|
88
90
|
const w = meta?.widget ?? "text";
|
|
89
91
|
const Field = fieldFor(w);
|
|
90
|
-
|
|
92
|
+
const control = (_jsx(Field, { name: label ?? "", meta: { ...(meta ?? { label: label ?? "", widget: w }), label: label ?? meta?.label ?? "" }, value: value, onChange: onChange, pickMedia: pickMedia, pickMediaFiles: pickMediaFiles }));
|
|
93
|
+
return _jsx("fieldset", { disabled: !editable, children: control });
|
|
91
94
|
}
|
|
92
95
|
/* Il widget registrato riceve lo SCHEMA del campo attraverso `meta.schema`, che SchemaForm gli
|
|
93
96
|
* passa: senza, non potrebbe sapere che forma abbiano i figli. */
|
|
94
|
-
registerField("objectList", ({ meta, value, onChange, pickMedia, pickMediaFiles }) => {
|
|
97
|
+
registerField("objectList", ({ meta, value, onChange, pickMedia, pickMediaFiles, translationReadOnly }) => {
|
|
95
98
|
const schema = meta.schema;
|
|
96
99
|
if (!schema)
|
|
97
100
|
return _jsx("p", { className: "ak-error", children: "objectList senza schema: va passato da SchemaForm." });
|
|
98
|
-
return (_jsxs("fieldset", { className: "ak-group ol", children: [_jsx("legend", { className: "ak-legend", children: meta.label }), meta.hint && _jsx("p", { className: "ak-hint", children: meta.hint }), _jsx(Node, { schema: schema, value: value, onChange: onChange, pickMedia: pickMedia, pickMediaFiles: pickMediaFiles })] }));
|
|
101
|
+
return (_jsxs("fieldset", { className: "ak-group ol", children: [_jsx("legend", { className: "ak-legend", children: meta.label }), meta.hint && _jsx("p", { className: "ak-hint", children: meta.hint }), _jsx(Node, { schema: schema, value: value, onChange: onChange, pickMedia: pickMedia, pickMediaFiles: pickMediaFiles, translationReadOnly: translationReadOnly })] }));
|
|
99
102
|
});
|
package/dist/form/registry.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export interface FieldProps {
|
|
|
6
6
|
value: unknown;
|
|
7
7
|
onChange: (v: unknown) => void;
|
|
8
8
|
error?: string;
|
|
9
|
+
/** La lingua secondaria delega ai widget ricorsivi i soli campi marcati traducibili. */
|
|
10
|
+
translationReadOnly?: boolean;
|
|
9
11
|
/** Aperto dai widget che scelgono media e vogliono solo il percorso (r2Image, r2Gallery). */
|
|
10
12
|
pickMedia?: (opts: {
|
|
11
13
|
multiple: boolean;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/* Convenzioni Hugo per le traduzioni di una pagina: `index.md` è la lingua predefinita,
|
|
2
|
+
* `index.en.md` una sibling. Tutto ciò che non è dichiarato in site.config resta invisibile
|
|
3
|
+
* qui: non si deve mai dedurre una lingua da un file trovato per caso. */
|
|
4
|
+
import { languages } from "../site.js";
|
|
5
|
+
|
|
6
|
+
export function languageConfig() {
|
|
7
|
+
return languages();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function pagePath(slug, language, defaultLanguage) {
|
|
11
|
+
const suffix = language === defaultLanguage ? "" : `.${language}`;
|
|
12
|
+
return `content/${slug}/index${suffix}.md`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function translatedLanguages() {
|
|
16
|
+
const config = languageConfig();
|
|
17
|
+
return config ? config.available.filter((language) => language !== config.default) : [];
|
|
18
|
+
}
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { json } from "../../_shared/assets.js";
|
|
5
5
|
import { getText } from "../../_shared/github.js";
|
|
6
|
-
import { splitFrontMatter, deserializeItem } from "../../../index.js";
|
|
6
|
+
import { splitFrontMatter, deserializeItem, translationModel } from "../../../index.js";
|
|
7
7
|
import { pageType } from "../../site.js";
|
|
8
|
+
import { languageConfig, pagePath } from "../../_shared/translations.js";
|
|
8
9
|
|
|
9
10
|
function typeOf(md) {
|
|
10
11
|
const m = (md || "").match(/^---\n([\s\S]*?)\n---/);
|
|
@@ -14,21 +15,37 @@ function typeOf(md) {
|
|
|
14
15
|
|
|
15
16
|
export async function onRequestGet({ request, env }) {
|
|
16
17
|
if (!env.GITHUB_TOKEN || !env.GITHUB_REPO) return json({ ok: false, error: "backend GitHub non configurato" }, 500);
|
|
17
|
-
const
|
|
18
|
+
const url = new URL(request.url);
|
|
19
|
+
const slug = url.searchParams.get("slug") || "";
|
|
18
20
|
if (!/^[a-z0-9-]+$/.test(slug)) return json({ ok: false, error: "slug non valido" }, 400);
|
|
19
21
|
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
+
const languages = languageConfig();
|
|
23
|
+
const requested = url.searchParams.get("lang") || languages?.default || "";
|
|
24
|
+
if (languages && !languages.available.includes(requested)) return json({ ok: false, error: "lingua non dichiarata" }, 400);
|
|
25
|
+
if (!languages && url.searchParams.has("lang")) return json({ ok: false, error: "questo sito è monolingue" }, 400);
|
|
22
26
|
|
|
23
|
-
const
|
|
24
|
-
const
|
|
27
|
+
const defaultLanguage = languages?.default || "";
|
|
28
|
+
const defaultText = await getText(env, pagePath(slug, defaultLanguage, defaultLanguage));
|
|
29
|
+
if (defaultText == null) return json({ ok: false, error: "pagina non trovata" }, 404);
|
|
30
|
+
const isDefault = !languages || requested === defaultLanguage;
|
|
31
|
+
const text = isDefault ? defaultText : await getText(env, pagePath(slug, requested, defaultLanguage));
|
|
32
|
+
|
|
33
|
+
const { body: defaultRawBody } = splitFrontMatter(defaultText);
|
|
34
|
+
const defaultBody = (defaultRawBody ?? defaultText).replace(/^\n+/, "").replace(/\s+$/, "");
|
|
25
35
|
|
|
26
36
|
// Il front matter lo legge lo schema, non uno scanner di righe scritto qui: prima questo
|
|
27
37
|
// file aveva la sua interpretazione, diversa da quella del serializzatore, e due letture
|
|
28
38
|
// dello stesso formato sono due occasioni di divergere.
|
|
29
|
-
const definition = pageType(typeOf(
|
|
39
|
+
const definition = pageType(typeOf(defaultText));
|
|
30
40
|
if (!definition) return json({ ok: false, error: "tipo pagina non gestito" }, 404);
|
|
31
|
-
const
|
|
41
|
+
const defaultModel = deserializeItem(defaultText, definition.shape, { slug });
|
|
42
|
+
const model = isDefault ? defaultModel : translationModel(
|
|
43
|
+
definition.shape,
|
|
44
|
+
defaultModel,
|
|
45
|
+
text ? deserializeItem(text, definition.shape, { slug }) : {},
|
|
46
|
+
);
|
|
47
|
+
const { body: rawBody } = splitFrontMatter(text);
|
|
48
|
+
const body = ((rawBody ?? (text ?? defaultBody))).replace(/^\n+/, "").replace(/\s+$/, "");
|
|
32
49
|
|
|
33
|
-
return json({ ok: true, slug, body, page: model });
|
|
50
|
+
return json({ ok: true, slug, body, page: model, language: requested || undefined, exists: isDefault || !!text });
|
|
34
51
|
}
|
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
* Sta dietro Cloudflare Access E dietro la verifica del JWT in functions/admin/_middleware.js.
|
|
17
17
|
* Secret/vars: GITHUB_TOKEN (secret), GITHUB_REPO, GITHUB_BRANCH.
|
|
18
18
|
*/
|
|
19
|
-
import { serializeItem, deserializeItem, checkFidelity } from "../../index.js";
|
|
19
|
+
import { serializeItem, deserializeItem, checkFidelity, translationModel, inheritedFieldPaths, withTranslationHeader } from "../../index.js";
|
|
20
20
|
import { site, pageType } from "../site.js";
|
|
21
21
|
import { getRepoSnapshot, getText, DRAFT } from "../_shared/github.js";
|
|
22
|
+
import { languageConfig, pagePath, translatedLanguages } from "../_shared/translations.js";
|
|
22
23
|
|
|
23
24
|
const GH = "https://api.github.com";
|
|
24
25
|
|
|
@@ -83,6 +84,8 @@ export async function onRequestGet({ env }) {
|
|
|
83
84
|
const snapshot = await getRepoSnapshot(env);
|
|
84
85
|
const blobs = snapshot.tree.filter((t) => t.type === "blob" && site().isManagedPath(t.path));
|
|
85
86
|
|
|
87
|
+
const language = languageConfig();
|
|
88
|
+
const shaByPath = new Map(snapshot.tree.filter((t) => t.type === "blob").map((t) => [t.path, t.sha]));
|
|
86
89
|
const letti = await Promise.all(blobs.map(async (t) => {
|
|
87
90
|
const text = await getText(env, t.path, snapshot.commitSha);
|
|
88
91
|
if (text == null) return null;
|
|
@@ -92,7 +95,16 @@ export async function onRequestGet({ env }) {
|
|
|
92
95
|
const slug = t.path.split("/")[1];
|
|
93
96
|
const definition = pageType(typeOf(text));
|
|
94
97
|
if (!definition) return null;
|
|
95
|
-
|
|
98
|
+
const translations = language
|
|
99
|
+
? Object.fromEntries(translatedLanguages().map((code) => {
|
|
100
|
+
const sha = shaByPath.get(pagePath(slug, code, language.default));
|
|
101
|
+
return [code, { exists: !!sha, ...(sha ? { sha } : {}) }];
|
|
102
|
+
}))
|
|
103
|
+
: undefined;
|
|
104
|
+
return {
|
|
105
|
+
slug, url: `/${slug}/`, sha: t.sha, page: deserializeItem(text, definition.shape, { slug }),
|
|
106
|
+
...(translations ? { translations } : {}),
|
|
107
|
+
};
|
|
96
108
|
}));
|
|
97
109
|
|
|
98
110
|
const pages = letti.filter(Boolean)
|
|
@@ -196,6 +208,8 @@ async function commitPages(args, attempt = 0) {
|
|
|
196
208
|
const managed = tree.filter((t) => site().isManagedPath(t.path));
|
|
197
209
|
const existingSlugs = new Set(managed.map((t) => t.path.split("/")[1]));
|
|
198
210
|
const shaOf = new Map(managed.map((t) => [t.path.split("/")[1], t.sha]));
|
|
211
|
+
const shaByPath = new Map(tree.filter((t) => t.type === "blob").map((t) => [t.path, t.sha]));
|
|
212
|
+
const language = languageConfig();
|
|
199
213
|
|
|
200
214
|
/* CONTROLLO DI VERSIONE PER FILE. Il client dice da quale versione è partito (`_sha`, lo sha
|
|
201
215
|
* del blob che gli ha dato l'elenco); se quel file nel frattempo è cambiato, la sua copia è
|
|
@@ -216,6 +230,17 @@ async function commitPages(args, attempt = 0) {
|
|
|
216
230
|
const attuale = shaOf.get(sourceSlug);
|
|
217
231
|
if (attuale && attuale !== p._sha) stale.push(sourceSlug);
|
|
218
232
|
}
|
|
233
|
+
if (language) {
|
|
234
|
+
for (const p of pages) {
|
|
235
|
+
const sourceSlug = existingSlugs.has(p.slug) ? p.slug : (p._orig || p.slug);
|
|
236
|
+
for (const code of translatedLanguages()) {
|
|
237
|
+
const update = p?._translations?.[code];
|
|
238
|
+
if (!update?._sha) continue;
|
|
239
|
+
const current = shaByPath.get(pagePath(sourceSlug, code, language.default));
|
|
240
|
+
if (current && current !== update._sha) stale.push(`${sourceSlug}.${code}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
219
244
|
if (stale.length) {
|
|
220
245
|
// Tutto o niente come gli altri rifiuti: il commit è atomico, e salvarne metà lascerebbe a
|
|
221
246
|
// terra l'altra metà senza che nessuno sappia quale.
|
|
@@ -273,6 +298,43 @@ async function commitPages(args, attempt = 0) {
|
|
|
273
298
|
: undefined,
|
|
274
299
|
}),
|
|
275
300
|
});
|
|
301
|
+
|
|
302
|
+
/* Una lingua secondaria arriva esplicitamente dal client solo se è stata aperta o
|
|
303
|
+
* modificata. Non creare file vuoti per ogni pagina: la prima apertura di EN li mostra
|
|
304
|
+
* già come ereditati, ma il file nasce al primo salvataggio di quella lingua. */
|
|
305
|
+
if (language) {
|
|
306
|
+
for (const code of translatedLanguages()) {
|
|
307
|
+
const update = p?._translations?.[code];
|
|
308
|
+
if (!update) continue;
|
|
309
|
+
const oldTranslation = sourceSlug
|
|
310
|
+
? await readFile(env, repo, branch, pagePath(sourceSlug, code, language.default))
|
|
311
|
+
: null;
|
|
312
|
+
if (oldTranslation) {
|
|
313
|
+
const fidelity = checkFidelity(oldTranslation, definition.shape, { slug: sourceSlug });
|
|
314
|
+
if (!fidelity.ok) { rejected.push(`${p.slug}.${code}`); continue; }
|
|
315
|
+
}
|
|
316
|
+
const candidate = update.page && typeof update.page === "object" ? update.page : update;
|
|
317
|
+
const translated = translationModel(definition.shape, p, candidate);
|
|
318
|
+
if (definition.hasBody) {
|
|
319
|
+
if (typeof update.body === "string") translated.body = update.body;
|
|
320
|
+
else if (!oldTranslation && typeof p.body === "string") translated.body = p.body;
|
|
321
|
+
}
|
|
322
|
+
const valid = definition.payload.safeParse(translated);
|
|
323
|
+
if (!valid.success) { invalidTypes.push(`${p.slug}.${code}`); continue; }
|
|
324
|
+
const content = withTranslationHeader(serializeItem(translated, {
|
|
325
|
+
shape: definition.shape,
|
|
326
|
+
oldMd: oldTranslation,
|
|
327
|
+
placeholderBody: definition.hasBody
|
|
328
|
+
? (m) => `Placeholder content for **${m.title || p.slug}**.`
|
|
329
|
+
: undefined,
|
|
330
|
+
}), {
|
|
331
|
+
language: code,
|
|
332
|
+
defaultLanguage: language.default,
|
|
333
|
+
inherited: inheritedFieldPaths(definition.shape),
|
|
334
|
+
});
|
|
335
|
+
entries.push({ path: pagePath(p.slug, code, language.default), mode: "100644", type: "blob", content });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
276
338
|
}
|
|
277
339
|
|
|
278
340
|
if (invalidTypes.length) {
|
|
@@ -313,6 +375,12 @@ async function commitPages(args, attempt = 0) {
|
|
|
313
375
|
const md = await readFile(env, repo, branch, `content/${slug}/index.md`);
|
|
314
376
|
if (md && site().excludedTypes.includes(typeOf(md))) { skipped.push(slug); continue; }
|
|
315
377
|
entries.push({ path: `content/${slug}/index.md`, mode: "100644", type: "blob", sha: null });
|
|
378
|
+
if (language) {
|
|
379
|
+
for (const code of translatedLanguages()) {
|
|
380
|
+
const path = pagePath(slug, code, language.default);
|
|
381
|
+
if (shaByPath.has(path)) entries.push({ path, mode: "100644", type: "blob", sha: null });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
316
384
|
}
|
|
317
385
|
|
|
318
386
|
// 5) tree, commit, ref
|
|
@@ -369,7 +437,20 @@ async function commitPages(args, attempt = 0) {
|
|
|
369
437
|
const t = await gh(env, `/repos/${repo}/git/trees/${newTreeSha}?recursive=1`);
|
|
370
438
|
if (t.ok) {
|
|
371
439
|
for (const e of (await t.json()).tree || []) {
|
|
372
|
-
if (e.type
|
|
440
|
+
if (e.type !== "blob") continue;
|
|
441
|
+
if (site().isManagedPath(e.path)) {
|
|
442
|
+
shas[e.path.split("/")[1]] = e.sha;
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
/* Le sibling non passano da isManagedPath (che seleziona il solo index.md), ma hanno
|
|
446
|
+
lo stesso controllo di versione. Rimandare anche il loro SHA evita un falso stale al
|
|
447
|
+
salvataggio successivo della stessa traduzione. */
|
|
448
|
+
if (language) {
|
|
449
|
+
for (const code of translatedLanguages()) {
|
|
450
|
+
const match = e.path.match(/^content\/([^/]+)\/index\.([a-z0-9-]+)\.md$/);
|
|
451
|
+
if (match?.[2] === code) shas[`${match[1]}.${code}`] = e.sha;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
373
454
|
}
|
|
374
455
|
}
|
|
375
456
|
} catch { /* il salvataggio è andato: senza gli sha il client ricarica, non perde niente */ }
|
package/dist/functions/site.js
CHANGED
|
@@ -42,3 +42,12 @@ export function moduleEnabled(name) {
|
|
|
42
42
|
export function pageType(type) {
|
|
43
43
|
return current?.pages?.[String(type ?? "")] ?? null;
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
/** `null` lascia il sito rigorosamente monolingue. Gli endpoint non inferiscono lingue dai file:
|
|
47
|
+
* una coppia `index.en.md` non dichiarata non deve comparire né essere riscritta per caso. */
|
|
48
|
+
export function languages() {
|
|
49
|
+
const config = current?.languages;
|
|
50
|
+
if (!config || typeof config.default !== "string" || !Array.isArray(config.available)) return null;
|
|
51
|
+
const available = [...new Set(config.available.filter((language) => typeof language === "string" && language))];
|
|
52
|
+
return available.includes(config.default) ? { default: config.default, available } : null;
|
|
53
|
+
}
|
package/dist/i18n.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
export type TranslationShape = Record<string, z.ZodType>;
|
|
3
|
+
export interface TranslationMeta {
|
|
4
|
+
translatable: boolean;
|
|
5
|
+
}
|
|
6
|
+
/** Registro separato da `ui`: la scelta influenza il Worker e il codec, non solo il form. */
|
|
7
|
+
export declare const i18n: z.core.$ZodRegistry<TranslationMeta, z.core.$ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>>;
|
|
8
|
+
/** Marca un campo (anche un array o un oggetto intero) come modificabile in una traduzione. */
|
|
9
|
+
export declare function translatable<T extends z.ZodType>(field: T): T;
|
|
10
|
+
export declare function isTranslatable(schema: z.ZodType): boolean;
|
|
11
|
+
/** Unisce una traduzione al modello predefinito senza lasciarle modificare dati ereditati.
|
|
12
|
+
*
|
|
13
|
+
* Gli array non traducibili mantengono la loro struttura predefinita; i loro elementi possono
|
|
14
|
+
* però contenere campi foglia traducibili. Se un'intera lista deve essere traducibile (titolo
|
|
15
|
+
* spezzato in righe, paragrafi, ecc.), va marcata la lista stessa, non ogni sua stringa. */
|
|
16
|
+
export declare function mergeTranslation(schema: z.ZodType, defaultValue: unknown, translationValue: unknown): unknown;
|
|
17
|
+
/** Modello completo della traduzione: utile sia al primo salvataggio sia a ogni risalvataggio. */
|
|
18
|
+
export declare function translationModel(shape: TranslationShape, defaultModel: Record<string, unknown>, translationModel?: Record<string, unknown>): Record<string, unknown>;
|
|
19
|
+
/** Percorsi dei valori che saranno sempre ricopiati dalla lingua predefinita. */
|
|
20
|
+
export declare function inheritedFieldPaths(shape: TranslationShape): string[];
|
|
21
|
+
/** Inserisce (o aggiorna) l'avviso che impedisce modifiche manuali ingannevoli ai dati ereditati. */
|
|
22
|
+
export declare function withTranslationHeader(markdown: string, options: {
|
|
23
|
+
language: string;
|
|
24
|
+
defaultLanguage: string;
|
|
25
|
+
inherited: readonly string[];
|
|
26
|
+
}): string;
|
package/dist/i18n.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/* Bilinguismo opzionale, interamente guidato dallo schema.
|
|
2
|
+
*
|
|
3
|
+
* Un campo traducibile non è una seconda forma di contenuto: è lo stesso campo, marcato nello
|
|
4
|
+
* schema. Il file della lingua secondaria resta completo, ma questo modulo ricopia dal modello
|
|
5
|
+
* della lingua predefinita ogni valore che non porta il marchio. Così i template Hugo leggono
|
|
6
|
+
* file ordinari e non devono conoscere alcuna logica di merge.
|
|
7
|
+
*/
|
|
8
|
+
import * as z from "zod";
|
|
9
|
+
/** Registro separato da `ui`: la scelta influenza il Worker e il codec, non solo il form. */
|
|
10
|
+
export const i18n = z.registry();
|
|
11
|
+
/** Marca un campo (anche un array o un oggetto intero) come modificabile in una traduzione. */
|
|
12
|
+
export function translatable(field) {
|
|
13
|
+
// Zod esprime `register()` con un conditional type che non conserva il nostro generico T;
|
|
14
|
+
// il registro è comunque quello tipizzato appena sopra e l'operazione non trasforma il campo.
|
|
15
|
+
field.register(i18n, { translatable: true });
|
|
16
|
+
return field;
|
|
17
|
+
}
|
|
18
|
+
export function isTranslatable(schema) {
|
|
19
|
+
return i18n.get(schema)?.translatable === true;
|
|
20
|
+
}
|
|
21
|
+
function defOf(schema) {
|
|
22
|
+
return schema?._zod?.def ?? schema?._def ?? null;
|
|
23
|
+
}
|
|
24
|
+
function shapeOf(schema) {
|
|
25
|
+
const def = defOf(schema);
|
|
26
|
+
if (def?.type === "object" || def?.typeName === "ZodObject") {
|
|
27
|
+
return (typeof def.shape === "function" ? def.shape() : def.shape) ?? null;
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
function elementOf(schema) {
|
|
32
|
+
const def = defOf(schema);
|
|
33
|
+
if (def?.type === "array" || def?.typeName === "ZodArray")
|
|
34
|
+
return def.element ?? def.type ?? null;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
function clone(value) {
|
|
38
|
+
if (Array.isArray(value))
|
|
39
|
+
return value.map(clone);
|
|
40
|
+
if (value && typeof value === "object") {
|
|
41
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, clone(v)]));
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
/** Unisce una traduzione al modello predefinito senza lasciarle modificare dati ereditati.
|
|
46
|
+
*
|
|
47
|
+
* Gli array non traducibili mantengono la loro struttura predefinita; i loro elementi possono
|
|
48
|
+
* però contenere campi foglia traducibili. Se un'intera lista deve essere traducibile (titolo
|
|
49
|
+
* spezzato in righe, paragrafi, ecc.), va marcata la lista stessa, non ogni sua stringa. */
|
|
50
|
+
export function mergeTranslation(schema, defaultValue, translationValue) {
|
|
51
|
+
if (isTranslatable(schema)) {
|
|
52
|
+
return clone(translationValue === undefined ? defaultValue : translationValue);
|
|
53
|
+
}
|
|
54
|
+
const shape = shapeOf(schema);
|
|
55
|
+
if (shape) {
|
|
56
|
+
const source = defaultValue && typeof defaultValue === "object" && !Array.isArray(defaultValue)
|
|
57
|
+
? defaultValue : {};
|
|
58
|
+
const translated = translationValue && typeof translationValue === "object" && !Array.isArray(translationValue)
|
|
59
|
+
? translationValue : {};
|
|
60
|
+
return Object.fromEntries(Object.entries(shape).map(([key, child]) => [
|
|
61
|
+
key,
|
|
62
|
+
mergeTranslation(child, source[key], translated[key]),
|
|
63
|
+
]));
|
|
64
|
+
}
|
|
65
|
+
const element = elementOf(schema);
|
|
66
|
+
if (element) {
|
|
67
|
+
const source = Array.isArray(defaultValue) ? defaultValue : [];
|
|
68
|
+
const translated = Array.isArray(translationValue) ? translationValue : [];
|
|
69
|
+
return source.map((value, index) => mergeTranslation(element, value, translated[index]));
|
|
70
|
+
}
|
|
71
|
+
return clone(defaultValue);
|
|
72
|
+
}
|
|
73
|
+
/** Modello completo della traduzione: utile sia al primo salvataggio sia a ogni risalvataggio. */
|
|
74
|
+
export function translationModel(shape, defaultModel, translationModel = {}) {
|
|
75
|
+
return Object.fromEntries(Object.entries(shape).map(([key, schema]) => [
|
|
76
|
+
key,
|
|
77
|
+
mergeTranslation(schema, defaultModel[key], translationModel[key]),
|
|
78
|
+
]));
|
|
79
|
+
}
|
|
80
|
+
/** Percorsi dei valori che saranno sempre ricopiati dalla lingua predefinita. */
|
|
81
|
+
export function inheritedFieldPaths(shape) {
|
|
82
|
+
const visit = (schema, path) => {
|
|
83
|
+
if (isTranslatable(schema))
|
|
84
|
+
return [];
|
|
85
|
+
const nested = shapeOf(schema);
|
|
86
|
+
if (nested)
|
|
87
|
+
return Object.entries(nested).flatMap(([key, child]) => visit(child, `${path}.${key}`));
|
|
88
|
+
const element = elementOf(schema);
|
|
89
|
+
if (element) {
|
|
90
|
+
const child = shapeOf(element);
|
|
91
|
+
return child
|
|
92
|
+
? Object.entries(child).flatMap(([key, nestedSchema]) => visit(nestedSchema, `${path}[].${key}`))
|
|
93
|
+
: [path];
|
|
94
|
+
}
|
|
95
|
+
return [path];
|
|
96
|
+
};
|
|
97
|
+
return Object.entries(shape).flatMap(([key, schema]) => visit(schema, key));
|
|
98
|
+
}
|
|
99
|
+
const HEADER_START = "# adminkit: translation metadata (generated)";
|
|
100
|
+
const HEADER_END = "# adminkit: end translation metadata";
|
|
101
|
+
/** Inserisce (o aggiorna) l'avviso che impedisce modifiche manuali ingannevoli ai dati ereditati. */
|
|
102
|
+
export function withTranslationHeader(markdown, options) {
|
|
103
|
+
if (!markdown.startsWith("---\n"))
|
|
104
|
+
throw new Error("Una traduzione richiede front matter YAML.");
|
|
105
|
+
const inherited = options.inherited.length ? options.inherited.join(", ") : "nessuno";
|
|
106
|
+
const header = [
|
|
107
|
+
HEADER_START,
|
|
108
|
+
`# Lingua: ${options.language}. I campi non traducibili sono ereditati da ${options.defaultLanguage}.`,
|
|
109
|
+
`# Campi ereditati: ${inherited}. Modificarli qui non ha effetto: il pannello li risincronizza.`,
|
|
110
|
+
HEADER_END,
|
|
111
|
+
].join("\n") + "\n";
|
|
112
|
+
let rest = markdown.slice(4);
|
|
113
|
+
if (rest.startsWith(`${HEADER_START}\n`)) {
|
|
114
|
+
const end = rest.indexOf(`${HEADER_END}\n`);
|
|
115
|
+
if (end >= 0)
|
|
116
|
+
rest = rest.slice(end + HEADER_END.length + 1);
|
|
117
|
+
}
|
|
118
|
+
return `---\n${header}${rest}`;
|
|
119
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cardinali-studio/adminkit",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.1.0-rc.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Motore riusabile di un pannello di amministrazione per siti Hugo su Cloudflare Pages: codec del front matter guidato dallo schema, form React, libreria media su R2. NIENTE di specifico di un sito qui dentro.",
|
|
6
6
|
"license": "UNLICENSED",
|