@cardinali-studio/adminkit 1.0.0 → 2.1.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.d.ts 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;
@@ -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;
@@ -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]));
@@ -14,7 +14,6 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
14
14
  * scomparire mentre la si rinomina fa perdere il segno di dove si era.
15
15
  */
16
16
  import { useMemo, useState } from "react";
17
- import { ui } from "../../index.js";
18
17
  import { site } from "../site.js";
19
18
  import { slugify, uniqueSlug } from "../lib/slug";
20
19
  import { Icon } from "../ui/icons";
@@ -33,7 +32,7 @@ import { Icon } from "../ui/icons";
33
32
  * modulo andava bene: è il genere di cosa che cambia quando una dipendenza smette di essere
34
33
  * statica e diventa iniettata. */
35
34
  function tipiDisponibili() {
36
- const tipi = ui.get(site().page.type)?.options ?? [];
35
+ const tipi = Object.entries(site().pages).map(([value, page]) => ({ value, label: page.label }));
37
36
  /* La preselezione: "Pagina" se c'è (è la scelta di gran lunga più frequente), altrimenti la
38
37
  * prima opzione — così questo elenco non può restare senza un valore valido. */
39
38
  const iniziale = tipi.some((t) => t.value === "page") ? "page" : (tipi[0]?.value ?? "page");
@@ -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;
@@ -14,7 +14,7 @@ import { SortableContext, arrayMove, rectSortingStrategy, sortableKeyboardCoordi
14
14
  import { CSS } from "@dnd-kit/utilities";
15
15
  import { ProseField, SchemaForm } from "../../form/index.js";
16
16
  import { api } from "../../index.js";
17
- import { site } from "../site.js";
17
+ import { pageType } from "../site.js";
18
18
  import { uniqueSlug } from "../lib/slug";
19
19
  import { proseStats } from "../lib/text";
20
20
  import { Icon } from "../ui/icons";
@@ -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
- return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "pe-head-main", children: [_jsx("button", { type: "button", className: "pe-title", title: "Modifica nome e indirizzo", onClick: () => {
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,15 +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");
166
- const showBody = site().hasBody(page.type);
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");
176
+ const definition = pageType(page.type);
177
+ const showBody = definition?.hasBody === true;
167
178
  const isGallery = page.type === "gallery";
168
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]);
169
206
  /* APRENDO UNA PAGINA SI RILEGGE DAL REPO, non dal manifest pubblicato. */
170
207
  const [loadedFor, setLoadedFor] = useState(null);
171
208
  useEffect(() => {
172
- if (loadedFor === page.slug || !pristine)
209
+ if (translationActive || loadedFor === page.slug || !pristine)
173
210
  return;
174
211
  let alive = true;
175
212
  if (showBody)
@@ -195,7 +232,36 @@ export function PageEditor({ page, onChange, onRefresh, pristine, pickMedia, pic
195
232
  }
196
233
  });
197
234
  return () => { alive = false; };
198
- }, [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
+ };
199
265
  const commonOmissions = useMemo(() => [
200
266
  "title", "published", "inMenu",
201
267
  "seoTitle", "seoDescription", "ogTitle", "ogDescription", "ogImage",
@@ -205,29 +271,31 @@ export function PageEditor({ page, onChange, onRefresh, pristine, pickMedia, pic
205
271
  «Ritratto» e «Riconoscimenti» — campi di altri tipi di pagina, che compaiono perché il
206
272
  form disegna tutto ciò che lo schema dichiara e non sa quale tipo si stia modificando.
207
273
  Titolo, visibilità e SEO restano dove sono già: la barra in alto e la scheda SEO. */
208
- if (isPortfolio) {
274
+ if (isPortfolio && !translationActive) {
209
275
  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 })] }));
210
276
  }
211
277
  /* Stessa ragione delle due qui sopra: la pagina "chi sono" ha un'anatomia sua — biografia,
212
278
  ritratto, riconoscimenti — e il form generico ci metteva accanto «Lavori», «Edizioni» e
213
279
  «Sezioni», cioè i campi delle altre tipologie. Il corpo markdown è la biografia, quindi la
214
280
  textarea in fondo non serve più: sta dentro l'editor, in cima, dov'è il suo posto. */
215
- if (page.type === "about") {
281
+ if (page.type === "about" && !translationActive) {
216
282
  return (_jsx("div", { className: "ed ed-about", children: _jsx(AboutEditor, { page: page, onChange: onChange, bodyState: bodyState, pickMediaFiles: pickMediaFiles }) }));
217
283
  }
218
284
  /* La pagina workshop: l'introduzione e le edizioni, nell'ordine in cui escono sul sito —
219
285
  l'introduzione apre la colonna dei testi, sopra la prima locandina (WORKSHOP.md). Il corpo
220
286
  markdown è quell'introduzione, quindi non c'è nessuna textarea in fondo: sta in cima, dove
221
287
  la si legge. */
222
- if (page.type === "workshop") {
288
+ if (page.type === "workshop" && !translationActive) {
223
289
  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 })] }));
224
290
  }
225
- if (isGallery) {
291
+ if (isGallery && !translationActive) {
226
292
  /* Le foto si modificano DOVE stanno: dentro il loro blocco, che è anche dove vivono nei dati
227
293
  (`sections[].images`) e dove la griglia esce sul sito. Non c'è più un pannello staccato in
228
294
  fondo — c'era finché le foto stavano in un campo della pagina, e insieme a lui è sparito
229
295
  il caso in cui la stessa griglia si vedeva due volte. */
230
296
  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 })] }));
231
297
  }
232
- return (_jsxs("div", { className: "ed", children: [_jsx(SchemaForm, { shape: site().page, value: page, omit: commonOmissions, onChange: (next) => onChange({ ...next, slug: page.slug }), pickMedia: pickMedia, pickMediaFiles: pickMediaFiles }), 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 page.body === "string" ? page.body : "", onChange: (event) => onChange({ ...page, body: event.target.value }) })] }))] }));
298
+ return (_jsxs("div", { className: "ed", children: [translationActive && (_jsx("p", { className: "pe-translation-note", children: page.translations?.[activeLanguage]?.exists || page._translations?.[activeLanguage]
299
+ ? `Stai modificando ${activeLanguage.toUpperCase()}. I campi non traducibili restano visibili e vengono copiati dalla lingua predefinita al salvataggio.`
300
+ : `${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 }) })] }))] }));
233
301
  }
@@ -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) }), 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 })] }));
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
  }