@cardinali-studio/adminkit 2.1.0 → 2.2.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 CHANGED
@@ -1,7 +1,9 @@
1
+ import { type JsonValue } from "./persistence.js";
1
2
  export declare class ApiError extends Error {
2
3
  code: string;
3
4
  status: number;
4
- constructor(message: string, code: string, status: number);
5
+ details: unknown;
6
+ constructor(message: string, code: string, status: number, details?: unknown);
5
7
  }
6
8
  export interface AssetFile {
7
9
  key: string;
@@ -50,6 +52,45 @@ export interface RepoPage {
50
52
  sha?: string;
51
53
  }>;
52
54
  }
55
+ export type ContentSaveCommand = {
56
+ revisionId: string;
57
+ documentId: string;
58
+ baseRevisionId: string | null;
59
+ expectedDraftRevisionId?: string | null;
60
+ kind: string;
61
+ locale: string;
62
+ translationGroupId?: string | null;
63
+ payload: JsonValue | null;
64
+ tombstone: boolean;
65
+ schemaVersion: number;
66
+ };
67
+ export type ContentDocument = {
68
+ documentId: string;
69
+ kind: string;
70
+ locale: string;
71
+ draftRevisionId: string | null;
72
+ draftPayload: string | null;
73
+ draftTombstone: boolean | null;
74
+ publishedRevisionId: string | null;
75
+ publishedPayload: string | null;
76
+ publishedTombstone: boolean | null;
77
+ };
78
+ /** Builds the only D1 Save wire shape. The Worker recomputes these hashes too,
79
+ * so retries are deterministic rather than client-trusted. */
80
+ export declare function contentSaveRequest(command: ContentSaveCommand): Promise<{
81
+ payloadChecksum: string;
82
+ requestChecksum: string;
83
+ revisionId: string;
84
+ documentId: string;
85
+ baseRevisionId: string | null;
86
+ expectedDraftRevisionId?: string | null;
87
+ kind: string;
88
+ locale: string;
89
+ translationGroupId?: string | null;
90
+ payload: JsonValue | null;
91
+ tombstone: boolean;
92
+ schemaVersion: number;
93
+ }>;
53
94
  export declare const api: {
54
95
  pages: () => Promise<{
55
96
  ok: true;
@@ -71,6 +112,26 @@ export declare const api: {
71
112
  skipped: string[];
72
113
  shas?: Record<string, string>;
73
114
  }>;
115
+ contentState: () => Promise<{
116
+ ok: true;
117
+ state: {
118
+ current_release_id: string | null;
119
+ } | null;
120
+ }>;
121
+ contentDocument: (documentId: string) => Promise<{
122
+ ok: true;
123
+ document: ContentDocument;
124
+ }>;
125
+ saveContent: (command: ContentSaveCommand) => Promise<{
126
+ ok: true;
127
+ saved: true;
128
+ repeated: boolean;
129
+ revisionId: string;
130
+ }>;
131
+ discardContent: (documentId: string, expectedDraftRevisionId: string | null, baselineRevisionId: string | null) => Promise<{
132
+ ok: true;
133
+ discarded: true;
134
+ }>;
74
135
  publishState: () => Promise<{
75
136
  ok: true;
76
137
  configured: boolean;
package/dist/api.js CHANGED
@@ -3,14 +3,17 @@
3
3
  * `unauthenticated` (sessione scaduta), `conflict` (qualcuno ha salvato nel frattempo),
4
4
  * `unreadable` (un file che non sappiamo riprodurre). Prima qualunque errore diventava
5
5
  * "backend non collegato", che manda a cercare il guasto dalla parte sbagliata. */
6
+ import { canonicalChecksum, contentSaveRequestChecksum } from "./persistence.js";
6
7
  export class ApiError extends Error {
7
8
  code;
8
9
  status;
9
- constructor(message, code, status) {
10
+ details;
11
+ constructor(message, code, status, details = null) {
10
12
  super(message);
11
13
  this.name = "ApiError";
12
14
  this.code = code;
13
15
  this.status = status;
16
+ this.details = details;
14
17
  }
15
18
  }
16
19
  async function call(url, init) {
@@ -22,7 +25,7 @@ async function call(url, init) {
22
25
  catch { /* risposta non JSON */ }
23
26
  if (!res.ok || (body && body.ok === false)) {
24
27
  const code = body?.code ?? (res.status === 401 ? "unauthenticated" : "error");
25
- throw new ApiError(body?.error ?? `HTTP ${res.status}`, code, res.status);
28
+ throw new ApiError(body?.error ?? `HTTP ${res.status}`, code, res.status, body);
26
29
  }
27
30
  return body;
28
31
  }
@@ -31,6 +34,15 @@ const post = (url, data) => call(url, {
31
34
  headers: { "Content-Type": "application/json" },
32
35
  body: JSON.stringify(data),
33
36
  });
37
+ /** Builds the only D1 Save wire shape. The Worker recomputes these hashes too,
38
+ * so retries are deterministic rather than client-trusted. */
39
+ export async function contentSaveRequest(command) {
40
+ return {
41
+ ...command,
42
+ payloadChecksum: await canonicalChecksum(command.tombstone ? null : command.payload),
43
+ requestChecksum: await contentSaveRequestChecksum(command),
44
+ };
45
+ }
34
46
  export const api = {
35
47
  // ---- pagine
36
48
  /* L'elenco viene DAL REPO, non da `/index.json`, che è il sito pubblicato e quindi indietro
@@ -43,6 +55,11 @@ export const api = {
43
55
  /* `shas` è la versione di ogni file DOPO il commit: senza, il salvataggio successivo
44
56
  * partirebbe da una versione che non esiste più e verrebbe rifiutato. */
45
57
  savePages: (pages) => post("/admin/api/pages", { pages }),
58
+ // ---- D1 content persistence (inactive unless a site explicitly selects it)
59
+ contentState: () => call("/admin/api/content/state"),
60
+ contentDocument: (documentId) => call(`/admin/api/content/document?id=${encodeURIComponent(documentId)}`),
61
+ saveContent: async (command) => post("/admin/api/content/save", await contentSaveRequest(command)),
62
+ discardContent: (documentId, expectedDraftRevisionId, baselineRevisionId) => post("/admin/api/content/discard", { documentId, expectedDraftRevisionId, baselineRevisionId }),
46
63
  // ---- pubblicazione
47
64
  /* Salvare scrive sulla bozza e non fa partire nessuna build; è questo che manda online. Due
48
65
  * gesti separati, quindi due chiamate: una chiede quanto c'è di non pubblicato, l'altra lo
package/dist/app/app.css CHANGED
@@ -2093,3 +2093,24 @@ body:has(.uk) .pane-body { padding-bottom: 190px; }
2093
2093
  font: 400 14px/1.6 var(--ak-font); color: var(--ak-ink);
2094
2094
  }
2095
2095
  .feature-off-cta a { color: var(--ak-accent); text-decoration: underline; text-underline-offset: 2px; }
2096
+ /* Conflict resolution is deliberately a focused modal: accepting the current
2097
+ * saved value by default is fine for one-sided changes, but divergent fields
2098
+ * require an explicit radio choice before the composed preview can continue. */
2099
+ .ak-conflict-backdrop { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; padding: 24px; background: color-mix(in srgb, #111 55%, transparent); }
2100
+ .ak-conflict-dialog { width: min(760px, 100%); max-height: min(780px, 100%); overflow: auto; padding: 24px; border: 1px solid var(--ak-line, #d7d7d7); border-radius: 12px; background: var(--ak-surface, #fff); color: var(--ak-ink, #161616); box-shadow: 0 24px 70px rgba(0,0,0,.28); }
2101
+ .ak-conflict-dialog h2 { margin: 0; font-size: 20px; }
2102
+ .ak-conflict-dialog h2:focus { outline: none; }
2103
+ .ak-conflict-dialog header > p { margin: 8px 0 20px; color: var(--ak-muted, #606060); }
2104
+ .ak-conflict-fields { display: grid; gap: 14px; }
2105
+ .ak-conflict-field { padding: 16px; border: 1px solid var(--ak-line, #d7d7d7); border-radius: 8px; }
2106
+ .ak-conflict-field h3 { margin: 0 0 8px; font-size: 15px; }
2107
+ .ak-conflict-required { margin: 0 0 10px; color: #a33; font-weight: 600; }
2108
+ .ak-conflict-diff { margin: 0 0 10px; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--ak-muted, #606060); white-space: pre-wrap; }
2109
+ .ak-conflict-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
2110
+ .ak-conflict-options label { display: flex; gap: 8px; padding: 10px; border: 1px solid var(--ak-line, #d7d7d7); border-radius: 6px; cursor: pointer; }
2111
+ .ak-conflict-options input { margin-top: 3px; }
2112
+ .ak-conflict-options strong, .ak-conflict-value { display: block; }
2113
+ .ak-conflict-value { margin-top: 5px; overflow-wrap: anywhere; font-size: 13px; font-weight: 400; }
2114
+ .ak-conflict-image { display: grid; gap: 6px; margin-top: 5px; }.ak-conflict-image img { width: 72px; height: 48px; object-fit: cover; border-radius: 3px; }.ak-conflict-image code { overflow-wrap: anywhere; font-size: 11px; }
2115
+ .ak-conflict-dialog footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
2116
+ @media (max-width: 620px) { .ak-conflict-options { grid-template-columns: 1fr; } .ak-conflict-backdrop { padding: 12px; } }
@@ -0,0 +1,9 @@
1
+ import { type ContentSaveCommand } from "../../api.js";
2
+ export type ContentPersistenceClient = {
3
+ readonly mode: "github" | "d1";
4
+ state(): Promise<unknown>;
5
+ document(documentId: string): Promise<unknown>;
6
+ save(command: ContentSaveCommand): Promise<unknown>;
7
+ discard(documentId: string, expectedDraftRevisionId: string | null, baselineRevisionId: string | null): Promise<unknown>;
8
+ };
9
+ export declare function contentPersistenceClient(): ContentPersistenceClient;
@@ -0,0 +1,25 @@
1
+ /* Browser-side persistence boundary. Editors use this adapter rather than
2
+ * reaching for /pages or /content directly, so the migration switch remains
3
+ * one deliberate per-site decision. */
4
+ import { api } from "../../api.js";
5
+ import { contentPersistenceMode } from "../site.js";
6
+ const d1Client = {
7
+ mode: "d1",
8
+ state: () => api.contentState(),
9
+ document: (documentId) => api.contentDocument(documentId),
10
+ save: (command) => api.saveContent(command),
11
+ discard: (documentId, expectedDraftRevisionId, baselineRevisionId) => api.discardContent(documentId, expectedDraftRevisionId, baselineRevisionId),
12
+ };
13
+ /* Existing Git page/singleton adapters remain their own typed flows until the
14
+ * site supplies a lossless document mapping. A generic fallback would silently
15
+ * turn site-specific content into the wrong D1 document. */
16
+ const gitClient = {
17
+ mode: "github",
18
+ state: async () => ({ mode: "github" }),
19
+ document: async () => { throw new Error("content_persistence_inactive"); },
20
+ save: async () => { throw new Error("content_persistence_inactive"); },
21
+ discard: async () => { throw new Error("content_persistence_inactive"); },
22
+ };
23
+ export function contentPersistenceClient() {
24
+ return contentPersistenceMode() === "d1" ? d1Client : gitClient;
25
+ }
@@ -10,6 +10,8 @@
10
10
  */
11
11
  import { useCallback, useEffect, useState } from "react";
12
12
  import { ApiError } from "../../index.js";
13
+ import { api } from "../../api.js";
14
+ import { contentPersistenceMode, site } from "../site.js";
13
15
  async function call(url, init) {
14
16
  const res = await fetch(url, { cache: "no-store", ...init });
15
17
  const body = await res.json().catch(() => null);
@@ -24,9 +26,20 @@ export function useHome(active) {
24
26
  const [dirty, setDirty] = useState(false);
25
27
  const [saving, setSaving] = useState(false);
26
28
  const [notice, setNotice] = useState(null);
29
+ const [revision, setRevision] = useState(null);
27
30
  const load = useCallback(async () => {
28
31
  try {
29
- setData((await call("/admin/api/singleton?name=home")).data);
32
+ const mapped = site().homeDocument;
33
+ if (contentPersistenceMode() === "d1" && mapped) {
34
+ const { document } = await api.contentDocument(mapped.documentId);
35
+ const payload = document.draftPayload ?? document.publishedPayload;
36
+ if (!payload || (document.draftPayload !== null ? document.draftTombstone : document.publishedTombstone))
37
+ throw new Error("content_unavailable");
38
+ setData(JSON.parse(payload));
39
+ setRevision({ base: document.draftRevisionId ?? document.publishedRevisionId, expected: document.draftRevisionId });
40
+ }
41
+ else
42
+ setData((await call("/admin/api/singleton?name=home")).data);
30
43
  setError(null);
31
44
  setDirty(false);
32
45
  }
@@ -44,6 +57,15 @@ export function useHome(active) {
44
57
  setSaving(true);
45
58
  setNotice(null);
46
59
  try {
60
+ const mapped = site().homeDocument;
61
+ if (contentPersistenceMode() === "d1" && mapped && revision) {
62
+ const revisionId = crypto.randomUUID();
63
+ await api.saveContent({ revisionId, documentId: mapped.documentId, baseRevisionId: revision.base, expectedDraftRevisionId: revision.expected, kind: mapped.kind, locale: mapped.locale, schemaVersion: mapped.schemaVersion, payload: data, tombstone: false });
64
+ setRevision({ base: revisionId, expected: revisionId });
65
+ setNotice("Bozza D1 salvata");
66
+ setDirty(false);
67
+ return;
68
+ }
47
69
  await call("/admin/api/singleton", {
48
70
  method: "POST",
49
71
  headers: { "Content-Type": "application/json" },
@@ -61,7 +83,7 @@ export function useHome(active) {
61
83
  finally {
62
84
  setSaving(false);
63
85
  }
64
- }, [data]);
86
+ }, [data, revision]);
65
87
  const change = useCallback((next) => { setData(next); setDirty(true); }, []);
66
88
  return { data, error, dirty, saving, notice, load, change, save, setNotice };
67
89
  }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { useCallback, useEffect, useState } from "react";
9
9
  import { api, ApiError } from "../../index.js";
10
+ import { contentPersistenceMode } from "../site.js";
10
11
  export function usePublish() {
11
12
  const [ahead, setAhead] = useState(0);
12
13
  const [commits, setCommits] = useState([]);
@@ -15,6 +16,13 @@ export function usePublish() {
15
16
  const [notice, setNotice] = useState(null);
16
17
  const [error, setError] = useState(null);
17
18
  const reload = useCallback(async () => {
19
+ if (contentPersistenceMode() === "d1") {
20
+ setAhead(0);
21
+ setCommits([]);
22
+ setConfigured(false);
23
+ setError("La pubblicazione D1 non è ancora disponibile in questa versione del pannello.");
24
+ return;
25
+ }
18
26
  try {
19
27
  const d = await api.publishState();
20
28
  setAhead(d.ahead ?? 0);
@@ -33,6 +41,10 @@ export function usePublish() {
33
41
  * non serve a niente. È la stessa ragione dei badge di Richieste e Consegne. */
34
42
  useEffect(() => { void reload(); }, [reload]);
35
43
  const publish = useCallback(async () => {
44
+ if (contentPersistenceMode() === "d1") {
45
+ setNotice("La pubblicazione D1 non è ancora disponibile in questa versione del pannello.");
46
+ return;
47
+ }
36
48
  setPublishing(true);
37
49
  setNotice(null);
38
50
  try {
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useEffect, useRef } from "react";
3
- import { site } from "../site.js";
3
+ import { contentPersistenceMode, site } from "../site.js";
4
4
  import { Icon } from "../ui/icons";
5
5
  function Toggle({ checked, onChange, label, disabled = false, }) {
6
6
  return (
@@ -32,10 +32,11 @@ export const EXTRAS = [
32
32
  function SettingRow({ title, help, children, }) {
33
33
  return (_jsxs("div", { className: "st-row", children: [_jsxs("div", { className: "st-copy", children: [_jsx("h3", { children: title }), _jsx("p", { children: help })] }), _jsx("div", { className: "st-control", children: children })] }));
34
34
  }
35
- function Field({ value, onChange, maxLength, placeholder, type = "text", }) {
36
- return (_jsx("input", { className: "st-input", type: type, value: value, maxLength: maxLength, placeholder: placeholder, onChange: (event) => onChange(event.target.value) }));
35
+ function Field({ value, onChange, maxLength, placeholder, type = "text", disabled = false, }) {
36
+ return (_jsx("input", { className: "st-input", type: type, value: value, maxLength: maxLength, placeholder: placeholder, disabled: disabled, onChange: (event) => onChange(event.target.value) }));
37
37
  }
38
38
  export function SiteSettingsEditor({ value, onChange, pickMediaFiles, focus, }) {
39
+ const d1Content = contentPersistenceMode() === "d1";
39
40
  const update = (key, next) => onChange({ ...value, [key]: next });
40
41
  const extras = useRef(null);
41
42
  /* Salto SECCO (`behavior: "auto"`), non fluido, e al frame successivo.
@@ -57,7 +58,7 @@ export function SiteSettingsEditor({ value, onChange, pickMediaFiles, focus, })
57
58
  if (selected?.[0])
58
59
  update("favicon", selected[0].url);
59
60
  };
60
- return (_jsxs("section", { className: "settings-editor", children: [_jsxs("header", { className: "st-intro", children: [_jsx("span", { children: "Impostazioni generali" }), _jsx("h2", { children: "Sito" }), _jsx("p", { children: "Identit\u00E0, descrizione e accesso al sito pubblico." })] }), _jsxs("section", { className: "st-section", children: [_jsx("h2", { children: "Identit\u00E0 del sito" }), _jsx(SettingRow, { title: "Titolo del sito", help: "Il nome generale del sito, usato anche come nome del publisher nei metadati.", children: _jsx(Field, { value: value.siteTitle, maxLength: 140, placeholder: site().name, onChange: (next) => update("siteTitle", next) }) }), _jsxs(SettingRow, { title: "Descrizione breve", help: "Una frase sintetica usata come descrizione predefinita quando una pagina non ne possiede una specifica.", children: [_jsx("textarea", { className: "st-input st-textarea", rows: 3, value: value.shortDescription, maxLength: 255, onChange: (event) => update("shortDescription", event.target.value) }), _jsxs("span", { className: "st-count", children: [value.shortDescription.length, "/255"] })] }), _jsx(SettingRow, { title: "Favicon", help: "L\u2019icona mostrata nella scheda del browser. \u00C8 preferibile un\u2019immagine quadrata.", children: _jsxs("div", { className: "st-favicon", children: [_jsx("span", { className: "st-favicon-preview", children: value.favicon ? _jsx("img", { src: value.favicon, alt: "Anteprima favicon" }) : _jsx(Icon.pages, {}) }), _jsx("button", { className: "ak-btn", type: "button", onClick: () => void chooseFavicon(), children: value.favicon ? "Cambia favicon" : "Carica favicon" }), value.favicon && (_jsx("button", { className: "ak-btn ak-btn-quiet", type: "button", onClick: () => update("favicon", ""), children: "Rimuovi" }))] }) })] }), _jsxs("section", { className: "st-section", children: [_jsx("h2", { children: "Accesso al sito" }), _jsx(SettingRow, { title: "Password del sito", help: "Quando \u00E8 attiva, ogni pagina e ogni immagine pubblica richiedono la stessa password. L\u2019admin resta raggiungibile tramite il suo accesso protetto.", children: _jsxs("div", { className: "st-stack", children: [_jsx(Field, { type: "password", value: value.password, maxLength: 200, placeholder: value.passwordEnabled ? "Password già impostata · inseriscine una per cambiarla" : "Almeno 8 caratteri", onChange: (next) => update("password", next) }), _jsx(Toggle, { checked: value.passwordEnabled, label: "Protezione con password", onChange: (next) => update("passwordEnabled", next) }), value.passwordEnabled && (_jsx("p", { className: "st-inline-help", children: "Lascia il campo vuoto per mantenere la password attuale." }))] }) })] }), _jsxs("section", { className: "st-section" + (focus === "funzioni" ? " is-focused" : ""), ref: extras, children: [_jsx("h2", { children: "Funzioni aggiuntive" }), EXTRAS.map((extra) => (_jsx(SettingRow, { title: extra.title, help: extra.help, children: _jsx(Toggle, { checked: value.features[extra.key], disabled: true, label: value.features[extra.key] ? "Attiva su questo sito" : "Non attiva", onChange: () => { } }) }, extra.key))), _jsxs("p", { className: "st-inline-help st-extras-note", children: ["Queste funzioni si attivano separatamente. Per accenderle su questo sito scrivi a", " ", _jsx("strong", { children: site().support.name }), ":", " ", _jsx("a", { href: `mailto:${site().support.email}`, children: site().support.email }), "."] })] }), _jsxs("section", { className: "st-section", children: [_jsx("h2", { children: "Statistiche" }), _jsx(SettingRow, { title: "Tag Analytics", help: _jsxs(_Fragment, { children: ["Il codice fornito dal servizio di statistiche (Umami, Google Analytics, altro). Si incolla qui cos\u00EC com\u2019\u00E8: finisce dentro ", _jsx("code", { children: "<head>" }), " di ogni pagina del sito alla prossima pubblicazione. Le pagine di consegna ai clienti restano escluse. Per svuotarlo, cancella tutto e salva."] }), children: _jsxs("div", { className: "st-stack", children: [_jsx("textarea", { className: "st-input st-textarea st-code", rows: 6, value: value.analyticsTag, maxLength: 4000, spellCheck: false, autoCapitalize: "off", autoCorrect: "off", placeholder: '<script defer src="https://cloud.umami.is/script.js" data-website-id="…"></script>', onChange: (event) => update("analyticsTag", event.target.value) }), _jsxs("span", { className: "st-count", children: [value.analyticsTag.length, "/4000"] }), _jsxs("p", { className: "st-inline-help", children: ["\u00C8 codice eseguito su ogni pagina: incolla solo quello che ti ha dato il servizio. Un tag incompleto (per esempio senza ", _jsx("code", { children: "</script>" }), ") pu\u00F2 rompere il sito."] })] }) })] }), _jsxs("section", { className: "st-section", children: [_jsx("h2", { children: "Profilo" }), _jsx(SettingRow, { title: "Nome", help: "Il nome della persona proprietaria del sito. Sar\u00E0 disponibile per future funzioni del pannello.", children: _jsx(Field, { value: value.ownerName, maxLength: 120, placeholder: site().name, onChange: (next) => update("ownerName", next) }) }), _jsxs(SettingRow, { title: "Descrizione del sito", help: "Una descrizione generale pi\u00F9 estesa, conservata come informazione del profilo del sito.", children: [_jsx("textarea", { className: "st-input st-textarea", rows: 5, value: value.siteDescription, maxLength: 1200, onChange: (event) => update("siteDescription", event.target.value) }), _jsxs("span", { className: "st-count", children: [value.siteDescription.length, "/1200"] })] })] })] }));
61
+ return (_jsxs("section", { className: "settings-editor", children: [_jsxs("header", { className: "st-intro", children: [_jsx("span", { children: "Impostazioni generali" }), _jsx("h2", { children: "Sito" }), _jsx("p", { children: "Identit\u00E0, descrizione e accesso al sito pubblico." })] }), _jsxs("section", { className: "st-section", children: [_jsx("h2", { children: "Identit\u00E0 del sito" }), _jsx(SettingRow, { title: "Titolo del sito", help: "Il nome generale del sito, usato anche come nome del publisher nei metadati.", children: _jsx(Field, { value: value.siteTitle, maxLength: 140, placeholder: site().name, onChange: (next) => update("siteTitle", next) }) }), _jsxs(SettingRow, { title: "Descrizione breve", help: "Una frase sintetica usata come descrizione predefinita quando una pagina non ne possiede una specifica.", children: [_jsx("textarea", { className: "st-input st-textarea", rows: 3, value: value.shortDescription, maxLength: 255, onChange: (event) => update("shortDescription", event.target.value) }), _jsxs("span", { className: "st-count", children: [value.shortDescription.length, "/255"] })] }), _jsx(SettingRow, { title: "Favicon", help: "L\u2019icona mostrata nella scheda del browser. \u00C8 preferibile un\u2019immagine quadrata.", children: _jsxs("div", { className: "st-favicon", children: [_jsx("span", { className: "st-favicon-preview", children: value.favicon ? _jsx("img", { src: value.favicon, alt: "Anteprima favicon" }) : _jsx(Icon.pages, {}) }), _jsx("button", { className: "ak-btn", type: "button", onClick: () => void chooseFavicon(), children: value.favicon ? "Cambia favicon" : "Carica favicon" }), value.favicon && (_jsx("button", { className: "ak-btn ak-btn-quiet", type: "button", onClick: () => update("favicon", ""), children: "Rimuovi" }))] }) })] }), _jsxs("section", { className: "st-section", children: [_jsx("h2", { children: "Accesso al sito" }), _jsx(SettingRow, { title: "Password del sito", help: "Quando \u00E8 attiva, ogni pagina e ogni immagine pubblica richiedono la stessa password. L\u2019admin resta raggiungibile tramite il suo accesso protetto.", children: _jsxs("div", { className: "st-stack", children: [_jsx(Field, { type: "password", value: value.password, maxLength: 200, placeholder: value.passwordEnabled ? "Password già impostata · inseriscine una per cambiarla" : "Almeno 8 caratteri", disabled: d1Content, onChange: (next) => update("password", next) }), _jsx(Toggle, { checked: value.passwordEnabled, label: "Protezione con password", disabled: d1Content, onChange: (next) => update("passwordEnabled", next) }), d1Content && (_jsx("p", { className: "st-inline-help", children: "La protezione esistente resta attiva e viene gestita dall\u2019operatore durante la migrazione D1." })), value.passwordEnabled && (_jsx("p", { className: "st-inline-help", children: "Lascia il campo vuoto per mantenere la password attuale." }))] }) })] }), _jsxs("section", { className: "st-section" + (focus === "funzioni" ? " is-focused" : ""), ref: extras, children: [_jsx("h2", { children: "Funzioni aggiuntive" }), EXTRAS.map((extra) => (_jsx(SettingRow, { title: extra.title, help: extra.help, children: _jsx(Toggle, { checked: value.features[extra.key], disabled: true, label: value.features[extra.key] ? "Attiva su questo sito" : "Non attiva", onChange: () => { } }) }, extra.key))), _jsxs("p", { className: "st-inline-help st-extras-note", children: ["Queste funzioni si attivano separatamente. Per accenderle su questo sito scrivi a", " ", _jsx("strong", { children: site().support.name }), ":", " ", _jsx("a", { href: `mailto:${site().support.email}`, children: site().support.email }), "."] })] }), _jsxs("section", { className: "st-section", children: [_jsx("h2", { children: "Statistiche" }), _jsx(SettingRow, { title: "Tag Analytics", help: _jsxs(_Fragment, { children: ["Il codice fornito dal servizio di statistiche (Umami, Google Analytics, altro). Si incolla qui cos\u00EC com\u2019\u00E8: finisce dentro ", _jsx("code", { children: "<head>" }), " di ogni pagina del sito alla prossima pubblicazione. Le pagine di consegna ai clienti restano escluse. Per svuotarlo, cancella tutto e salva."] }), children: _jsxs("div", { className: "st-stack", children: [_jsx("textarea", { className: "st-input st-textarea st-code", rows: 6, value: value.analyticsTag, maxLength: 4000, spellCheck: false, autoCapitalize: "off", autoCorrect: "off", placeholder: '<script defer src="https://cloud.umami.is/script.js" data-website-id="…"></script>', onChange: (event) => update("analyticsTag", event.target.value) }), _jsxs("span", { className: "st-count", children: [value.analyticsTag.length, "/4000"] }), _jsxs("p", { className: "st-inline-help", children: ["\u00C8 codice eseguito su ogni pagina: incolla solo quello che ti ha dato il servizio. Un tag incompleto (per esempio senza ", _jsx("code", { children: "</script>" }), ") pu\u00F2 rompere il sito."] })] }) })] }), _jsxs("section", { className: "st-section", children: [_jsx("h2", { children: "Profilo" }), _jsx(SettingRow, { title: "Nome", help: "Il nome della persona proprietaria del sito. Sar\u00E0 disponibile per future funzioni del pannello.", children: _jsx(Field, { value: value.ownerName, maxLength: 120, placeholder: site().name, onChange: (next) => update("ownerName", next) }) }), _jsxs(SettingRow, { title: "Descrizione del sito", help: "Una descrizione generale pi\u00F9 estesa, conservata come informazione del profilo del sito.", children: [_jsx("textarea", { className: "st-input st-textarea", rows: 5, value: value.siteDescription, maxLength: 1200, onChange: (event) => update("siteDescription", event.target.value) }), _jsxs("span", { className: "st-count", children: [value.siteDescription.length, "/1200"] })] })] })] }));
61
62
  }
62
63
  export function WebsiteSeoSettingsEditor({ value, onChange, }) {
63
64
  const update = (key, next) => onChange({ ...value, [key]: next });
@@ -47,8 +47,19 @@ export interface SiteAdminConfig {
47
47
  * una sola verità per due letture, o il pannello promette ciò che il server nega.
48
48
  */
49
49
  modules?: Record<string, boolean>;
50
+ /** Git remains the compatibility default. D1 is an explicit, later site cutover. */
51
+ contentPersistence?: "github" | "d1";
52
+ /** Lossless D1 identity for the singleton home; required before its editor may leave Git. */
53
+ homeDocument?: {
54
+ documentId: string;
55
+ kind: string;
56
+ locale: string;
57
+ schemaVersion: number;
58
+ };
50
59
  }
51
60
  /** Chiamata da `mountAdmin` prima del primo render. */
52
61
  export declare function setSiteConfig(config: SiteAdminConfig): void;
53
62
  export declare function site(): SiteAdminConfig;
54
63
  export declare function pageType(type: unknown): PageTypeConfig | null;
64
+ /** A single mode decision for all browser persistence clients. */
65
+ export declare function contentPersistenceMode(): "github" | "d1";
package/dist/app/site.js CHANGED
@@ -19,3 +19,7 @@ export function site() {
19
19
  export function pageType(type) {
20
20
  return site().pages[String(type ?? "")] ?? null;
21
21
  }
22
+ /** A single mode decision for all browser persistence clients. */
23
+ export function contentPersistenceMode() {
24
+ return site().contentPersistence === "d1" ? "d1" : "github";
25
+ }
@@ -0,0 +1,8 @@
1
+ import { type ClassifiedConflictField, type Resolution } from "../../conflict.js";
2
+ /** Generic, non-merging conflict chooser. A site passes only already-approved
3
+ * fields/values; this component returns choices, never a fabricated document. */
4
+ export declare function ConflictResolutionDialog({ fields, onResolve, onCancel, }: {
5
+ fields: readonly ClassifiedConflictField[];
6
+ onResolve: (choices: Record<string, Resolution>) => void;
7
+ onCancel: () => void;
8
+ }): import("react").JSX.Element;
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { boundedTextDifference } from "../../conflict.js";
4
+ function text(value) {
5
+ if (typeof value === "string")
6
+ return value;
7
+ return JSON.stringify(value);
8
+ }
9
+ function FieldValue({ field, value }) {
10
+ if (field.presentation === "image" && typeof value === "string") {
11
+ return _jsxs("span", { className: "ak-conflict-image", children: [_jsx("img", { src: value, alt: "" }), _jsx("code", { children: value })] });
12
+ }
13
+ if (field.presentation === "complex")
14
+ return _jsx("pre", { className: "ak-conflict-value", children: text(value) });
15
+ return _jsx("span", { className: "ak-conflict-value", children: text(value) });
16
+ }
17
+ /** Generic, non-merging conflict chooser. A site passes only already-approved
18
+ * fields/values; this component returns choices, never a fabricated document. */
19
+ export function ConflictResolutionDialog({ fields, onResolve, onCancel, }) {
20
+ const [choices, setChoices] = useState(() => Object.fromEntries(fields.flatMap((field) => field.defaultResolution ? [[field.path, field.defaultResolution]] : [])));
21
+ const title = useRef(null);
22
+ useEffect(() => { title.current?.focus(); }, []);
23
+ const unresolved = fields.some((field) => field.kind === "both_changed" && !choices[field.path]);
24
+ return (_jsx("div", { className: "ak-conflict-backdrop", role: "presentation", onMouseDown: onCancel, children: _jsxs("section", { className: "ak-conflict-dialog", role: "dialog", "aria-modal": "true", "aria-labelledby": "ak-conflict-title", onMouseDown: (event) => event.stopPropagation(), onKeyDown: (event) => { if (event.key === "Escape")
25
+ onCancel(); }, children: [_jsxs("header", { children: [_jsx("h2", { id: "ak-conflict-title", ref: title, tabIndex: -1, children: "Risolvi le modifiche concorrenti" }), _jsx("p", { children: "Confronta ogni campo e scegli cosa mantenere. Nessuna scelta viene unita automaticamente." })] }), _jsx("div", { className: "ak-conflict-fields", children: fields.filter((field) => field.kind !== "unchanged").map((field) => {
26
+ const difference = field.presentation === "text" && typeof field.base === "string" && typeof field.current === "string"
27
+ ? boundedTextDifference(field.base, field.current) : null;
28
+ return _jsxs("article", { className: "ak-conflict-field", children: [_jsx("h3", { children: field.label }), field.kind === "both_changed" && _jsx("p", { className: "ak-conflict-required", children: "Scegli esplicitamente una versione." }), difference && (difference.added || difference.removed) && _jsxs("p", { className: "ak-conflict-diff", children: ["Salvato: \u2212", difference.removed || "—", " +", difference.added || "—", difference.truncated ? "…" : ""] }), _jsxs("div", { className: "ak-conflict-options", children: [_jsxs("label", { children: [_jsx("input", { type: "radio", name: field.path, checked: choices[field.path] === "saved", onChange: () => setChoices((previous) => ({ ...previous, [field.path]: "saved" })) }), _jsxs("span", { children: [_jsx("strong", { children: "Usa il salvato" }), _jsx(FieldValue, { field: field, value: field.current })] })] }), _jsxs("label", { children: [_jsx("input", { type: "radio", name: field.path, checked: choices[field.path] === "mine", onChange: () => setChoices((previous) => ({ ...previous, [field.path]: "mine" })) }), _jsxs("span", { children: [_jsx("strong", { children: "Tieni il mio" }), _jsx(FieldValue, { field: field, value: field.mine })] })] })] })] }, field.path);
29
+ }) }), _jsxs("footer", { children: [_jsx("button", { className: "ak-btn", type: "button", onClick: onCancel, children: "Annulla" }), _jsx("button", { className: "ak-btn ak-btn-primary", type: "button", disabled: unresolved, onClick: () => onResolve(choices), children: "Anteprima risultato" })] })] }) }));
30
+ }
@@ -0,0 +1,30 @@
1
+ import { type JsonValue } from "./persistence.js";
2
+ export type ConflictKind = "current_only" | "mine_only" | "same_change" | "both_changed" | "unchanged";
3
+ export type Resolution = "saved" | "mine";
4
+ /** Values arrive here only after the Worker/site presenter has approved them
5
+ * for display. Arrays, rich text and Media values remain one whole field: this
6
+ * module deliberately never attempts an automatic merge. */
7
+ export type ConflictField = {
8
+ path: string;
9
+ label: string;
10
+ presentation?: "text" | "image" | "complex";
11
+ base: JsonValue | undefined;
12
+ current: JsonValue | undefined;
13
+ mine: JsonValue | undefined;
14
+ };
15
+ export type ClassifiedConflictField = ConflictField & {
16
+ kind: ConflictKind;
17
+ defaultResolution: Resolution | null;
18
+ };
19
+ export declare function classifyConflictField(field: ConflictField): ClassifiedConflictField;
20
+ export declare function classifyConflict(fields: readonly ConflictField[]): ClassifiedConflictField[];
21
+ /** Returns choices only when every both-changed field has an explicit choice.
22
+ * It intentionally does not invent a field-level or text-level merge. */
23
+ export declare function resolveConflict(fields: readonly ClassifiedConflictField[], choices: Readonly<Record<string, Resolution | undefined>>): Record<string, JsonValue | undefined>;
24
+ /** Bounded, deliberately simple long-text cue for the UI. It does not claim to
25
+ * be a merge preview: values remain complete field replacements. */
26
+ export declare function boundedTextDifference(base: string, next: string, limit?: number): {
27
+ removed: string;
28
+ added: string;
29
+ truncated: boolean;
30
+ };
@@ -0,0 +1,48 @@
1
+ import { canonicalJson } from "./persistence.js";
2
+ function same(left, right) {
3
+ if (left === undefined || right === undefined)
4
+ return left === right;
5
+ return canonicalJson(left) === canonicalJson(right);
6
+ }
7
+ export function classifyConflictField(field) {
8
+ const savedChanged = !same(field.base, field.current);
9
+ const mineChanged = !same(field.base, field.mine);
10
+ if (!savedChanged && !mineChanged)
11
+ return { ...field, kind: "unchanged", defaultResolution: "saved" };
12
+ if (savedChanged && !mineChanged)
13
+ return { ...field, kind: "current_only", defaultResolution: "saved" };
14
+ if (!savedChanged && mineChanged)
15
+ return { ...field, kind: "mine_only", defaultResolution: "mine" };
16
+ if (same(field.current, field.mine))
17
+ return { ...field, kind: "same_change", defaultResolution: "saved" };
18
+ return { ...field, kind: "both_changed", defaultResolution: null };
19
+ }
20
+ export function classifyConflict(fields) {
21
+ return fields.map(classifyConflictField);
22
+ }
23
+ /** Returns choices only when every both-changed field has an explicit choice.
24
+ * It intentionally does not invent a field-level or text-level merge. */
25
+ export function resolveConflict(fields, choices) {
26
+ const resolved = {};
27
+ for (const field of fields) {
28
+ const choice = choices[field.path] ?? field.defaultResolution;
29
+ if (!choice)
30
+ throw new Error("unresolved_conflict");
31
+ resolved[field.path] = choice === "saved" ? field.current : field.mine;
32
+ }
33
+ return resolved;
34
+ }
35
+ /** Bounded, deliberately simple long-text cue for the UI. It does not claim to
36
+ * be a merge preview: values remain complete field replacements. */
37
+ export function boundedTextDifference(base, next, limit = 240) {
38
+ let prefix = 0;
39
+ while (prefix < base.length && prefix < next.length && base[prefix] === next[prefix])
40
+ prefix += 1;
41
+ let suffix = 0;
42
+ while (suffix < base.length - prefix && suffix < next.length - prefix && base[base.length - 1 - suffix] === next[next.length - 1 - suffix])
43
+ suffix += 1;
44
+ const removed = base.slice(prefix, base.length - suffix);
45
+ const added = next.slice(prefix, next.length - suffix);
46
+ const truncated = removed.length > limit || added.length > limit;
47
+ return { removed: removed.slice(0, limit), added: added.slice(0, limit), truncated };
48
+ }
@@ -0,0 +1,92 @@
1
+ import { type D1DatabaseLike } from "./persistence.js";
2
+ export declare const deploymentPhases: readonly ["waiting", "validating", "materializing", "building", "deploying", "verifying_deployment_url", "verifying_canonical", "succeeded", "failed", "ambiguous", "superseded"];
3
+ export type DeploymentPhase = typeof deploymentPhases[number];
4
+ export type DeploymentIntent = "content" | "code";
5
+ export type DeploymentAttempt = Readonly<{
6
+ attemptId: string;
7
+ intentKind: DeploymentIntent;
8
+ releaseId: string;
9
+ codeSha: string | null;
10
+ phase: DeploymentPhase;
11
+ claimTokenChecksum: string | null;
12
+ claimExpiresAt: number | null;
13
+ }>;
14
+ export type RegisterDeploymentAttempt = Readonly<{
15
+ attemptId: string;
16
+ intentKind: DeploymentIntent;
17
+ requestedReleaseId: string | null;
18
+ requestedCodeSha: string | null;
19
+ releaseId: string;
20
+ codeSha: string;
21
+ expectedFingerprint: string;
22
+ now: number;
23
+ }>;
24
+ export declare function isTerminalDeploymentPhase(phase: DeploymentPhase): boolean;
25
+ export declare function assertDeploymentTransition(from: DeploymentPhase, to: DeploymentPhase): void;
26
+ /** A claim is a single-use, short-lived capability. The CI shared credential
27
+ * authenticates the caller; the claim token binds its later calls to one exact
28
+ * attempt so a captured request cannot be replayed against another release. */
29
+ export declare function claimTokenChecksum(token: string): Promise<string>;
30
+ export declare function constantTimeSecretEqual(expected: string, provided: string): Promise<boolean>;
31
+ /** Pure state model used both to pin the allowed lifecycle in tests and to
32
+ * keep the SQL repository from growing an implicit transition graph. */
33
+ export declare class DeploymentAttemptKernel {
34
+ readonly attempts: Map<string, Readonly<{
35
+ attemptId: string;
36
+ intentKind: DeploymentIntent;
37
+ releaseId: string;
38
+ codeSha: string | null;
39
+ phase: DeploymentPhase;
40
+ claimTokenChecksum: string | null;
41
+ claimExpiresAt: number | null;
42
+ }>>;
43
+ claim(attemptId: string, releaseId: string, tokenChecksum: string, expiresAt: number, now: number): DeploymentAttempt;
44
+ advance(attemptId: string, releaseId: string, tokenChecksum: string, to: DeploymentPhase, now: number): DeploymentAttempt;
45
+ private authorized;
46
+ private required;
47
+ }
48
+ /** D1 adapter for the CI-only surface. It deliberately has no query that can
49
+ * select a "latest" release or an attempt from another site binding. */
50
+ export declare class D1DeploymentAttemptRepository {
51
+ private readonly db;
52
+ constructor(db: D1DatabaseLike);
53
+ get(attemptId: string): Promise<DeploymentAttempt | null>;
54
+ /** Registers the exact code/content pair before a workflow is dispatched.
55
+ * `active_slot` is the shared lane lock: code and content cannot overtake
56
+ * one another by merely reaching GitHub in a different queue order. */
57
+ register(input: RegisterDeploymentAttempt): Promise<"created" | "repeated">;
58
+ claim(input: {
59
+ attemptId: string;
60
+ releaseId: string;
61
+ tokenChecksum: string;
62
+ now: number;
63
+ expiresAt: number;
64
+ }): Promise<void>;
65
+ advance(input: {
66
+ attemptId: string;
67
+ releaseId: string;
68
+ tokenChecksum: string;
69
+ from: DeploymentPhase;
70
+ to: DeploymentPhase;
71
+ now: number;
72
+ }): Promise<void>;
73
+ fail(input: {
74
+ attemptId: string;
75
+ releaseId: string;
76
+ tokenChecksum: string;
77
+ phase: DeploymentPhase;
78
+ now: number;
79
+ code: string;
80
+ stage: string;
81
+ message: string;
82
+ }): Promise<void>;
83
+ /** Promotes the release and succeeds the exact verified attempt in one D1
84
+ * batch. Every mutating statement repeats the capability guard, so a stale
85
+ * claim cannot publish content even if a prior read was valid. */
86
+ finalize(input: {
87
+ attemptId: string;
88
+ releaseId: string;
89
+ tokenChecksum: string;
90
+ now: number;
91
+ }): Promise<void>;
92
+ }