@cardinali-studio/adminkit 2.2.21 → 2.2.22

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
@@ -6,6 +6,12 @@ export declare class ApiError extends Error {
6
6
  details: unknown;
7
7
  constructor(message: string, code: string, status: number, details?: unknown);
8
8
  }
9
+ export interface AssetAltText {
10
+ /** Vero significa che il sito deve emettere deliberatamente alt="". */
11
+ decorative: boolean;
12
+ /** Chiave "default" nei siti monolingui, codice lingua nei siti multilingui. */
13
+ texts: Record<string, string>;
14
+ }
9
15
  export interface AssetFile {
10
16
  key: string;
11
17
  name: string;
@@ -14,9 +20,13 @@ export interface AssetFile {
14
20
  uploaded?: string;
15
21
  capturedAt?: string;
16
22
  type?: string;
23
+ alt?: AssetAltText;
17
24
  w?: number | null;
18
25
  h?: number | null;
19
26
  }
27
+ /** Risolve il testo Media da copiare nel singolo utilizzo editoriale. Il campo della pagina
28
+ * resta un override: cambiare in seguito il default del file non riscrive contenuti esistenti. */
29
+ export declare function assetAltText(file: Pick<AssetFile, "alt">, locale?: string): string;
20
30
  export interface AssetFolder {
21
31
  prefix: string;
22
32
  name: string;
@@ -57,11 +67,15 @@ export interface RepoPage {
57
67
  sha?: string;
58
68
  }>;
59
69
  }
60
- export type ContentPublishTarget = {
70
+ export type ContentDocumentPublishTarget = {
61
71
  documentId: string;
62
72
  expectedDraftRevisionId: string | null;
63
73
  includeSiteAccess?: boolean;
64
74
  };
75
+ export type ContentMediaPublishTarget = {
76
+ mediaOperationId: string;
77
+ };
78
+ export type ContentPublishTarget = ContentDocumentPublishTarget | ContentMediaPublishTarget;
65
79
  export type ContentPublishDocument = {
66
80
  documentId: string;
67
81
  kind: string;
@@ -203,6 +217,17 @@ export declare const api: {
203
217
  dependentDocuments?: number;
204
218
  remainingAhead?: number;
205
219
  }>;
220
+ contentPublishMedia: (mediaOperationId: string) => Promise<{
221
+ ok: true;
222
+ published: boolean;
223
+ releaseId?: string;
224
+ attemptId?: string;
225
+ message?: string;
226
+ verified?: boolean;
227
+ observedReleaseId?: string | null;
228
+ documents?: number;
229
+ remainingAhead?: number;
230
+ }>;
206
231
  publishState: () => Promise<{
207
232
  ok: true;
208
233
  configured: boolean;
@@ -257,6 +282,9 @@ export declare const api: {
257
282
  error: string;
258
283
  }[];
259
284
  warning?: string;
285
+ operationId?: string;
286
+ requiresPublication?: boolean;
287
+ completed?: number;
260
288
  }>;
261
289
  /** Riscrive i riferimenti, poi elimina gli originali le cui copie sono state verificate. */
262
290
  moveApplyRefs: (applyRefs: Record<string, string>) => Promise<{
@@ -274,7 +302,12 @@ export declare const api: {
274
302
  ok: true;
275
303
  operations: MediaOperationStatus[];
276
304
  }>;
277
- resumeMediaOperation: (operationId: string, releaseId: string) => Promise<{
305
+ saveMediaAlt: (key: string, alt: AssetAltText) => Promise<{
306
+ ok: true;
307
+ key: string;
308
+ alt: AssetAltText;
309
+ }>;
310
+ resumeMediaOperation: (operationId: string, releaseId: string | null) => Promise<{
278
311
  ok: true;
279
312
  completed: number;
280
313
  failed: number;
@@ -313,6 +346,9 @@ export declare const api: {
313
346
  d1?: boolean;
314
347
  deferred?: boolean;
315
348
  operationId?: string;
349
+ requiresPublication?: boolean;
350
+ completed?: number;
351
+ failed?: number;
316
352
  }>;
317
353
  requests: () => Promise<{
318
354
  ok: true;
package/dist/api.js CHANGED
@@ -34,6 +34,13 @@ const post = (url, data) => call(url, {
34
34
  headers: { "Content-Type": "application/json" },
35
35
  body: JSON.stringify(data),
36
36
  });
37
+ /** Risolve il testo Media da copiare nel singolo utilizzo editoriale. Il campo della pagina
38
+ * resta un override: cambiare in seguito il default del file non riscrive contenuti esistenti. */
39
+ export function assetAltText(file, locale = "default") {
40
+ if (file.alt?.decorative)
41
+ return "";
42
+ return file.alt?.texts[locale] ?? file.alt?.texts.default ?? "";
43
+ }
37
44
  /** Builds the only D1 Save wire shape. The Worker recomputes these hashes too,
38
45
  * so retries are deterministic rather than client-trusted. */
39
46
  export async function contentSaveRequest(command) {
@@ -67,6 +74,7 @@ export const api = {
67
74
  * materialize, while the panel only displays the pending immutable inputs. */
68
75
  contentPublishState: () => call("/admin/api/content/publish"),
69
76
  contentPublish: (target) => post("/admin/api/content/publish", target),
77
+ contentPublishMedia: (mediaOperationId) => post("/admin/api/content/publish", { mediaOperationId }),
70
78
  // ---- pubblicazione
71
79
  /* Salvare scrive sulla bozza e non fa partire nessuna build; è questo che manda online. Due
72
80
  * gesti separati, quindi due chiamate: una chiede quanto c'è di non pubblicato, l'altra lo
@@ -99,6 +107,11 @@ export const api = {
99
107
  /** Riscrive i riferimenti, poi elimina gli originali le cui copie sono state verificate. */
100
108
  moveApplyRefs: (applyRefs) => post("/admin/api/assets/move", { applyRefs }),
101
109
  mediaOperations: () => call("/admin/api/assets/media"),
110
+ saveMediaAlt: (key, alt) => call("/admin/api/assets/media", {
111
+ method: "PATCH",
112
+ headers: { "Content-Type": "application/json" },
113
+ body: JSON.stringify({ key, alt }),
114
+ }),
102
115
  resumeMediaOperation: (operationId, releaseId) => post("/admin/api/assets/media", { action: "resume", operationId, releaseId }),
103
116
  cancelMediaOperation: (operationId) => post("/admin/api/assets/media", { action: "cancel", operationId }),
104
117
  usage: (target) => post("/admin/api/assets/usage", target),
@@ -1,4 +1,4 @@
1
- import type { AssetFile } from "../../index.js";
1
+ import { type AssetFile } from "../../index.js";
2
2
  import type { GalleryImage, PageSection, SectionType } from "../../index.js";
3
3
  export type GallerySection = PageSection;
4
4
  export type GallerySectionType = SectionType;
@@ -31,10 +31,12 @@ import { useEffect, useRef, useState } from "react";
31
31
  import { DndContext, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors, } from "@dnd-kit/core";
32
32
  import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable";
33
33
  import { CSS } from "@dnd-kit/utilities";
34
+ import { assetAltText } from "../../index.js";
34
35
  import { GALLERY_BLOCK_ID, emptySection } from "../../index.js";
35
36
  import { normalizeSections, withGalleryBlock } from "../../normalize.js";
36
37
  import { ParagraphEditor } from "../ui/ParagraphEditor";
37
38
  import { Icon } from "../ui/icons";
39
+ import { site } from "../site";
38
40
  function sectionId() {
39
41
  return `section-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
40
42
  }
@@ -58,8 +60,18 @@ function SortableSection({ section, index, onPatch, onDuplicate, onRemove, rende
58
60
  const chooseImage = async () => {
59
61
  const selected = await pickMediaFiles?.({ multiple: false });
60
62
  const file = selected?.[0];
61
- if (file)
62
- onPatch({ image: file.url, w: file.w ?? null, h: file.h ?? null });
63
+ if (file) {
64
+ const locale = site().languages?.default ?? "default";
65
+ onPatch({
66
+ image: file.url,
67
+ w: file.w ?? null,
68
+ h: file.h ?? null,
69
+ /* Il valore del Media è il default iniziale; questo campo resta modificabile e quindi
70
+ * costituisce l'override del singolo utilizzo. Una decorativa produce deliberatamente
71
+ * alt="", non il nome del file. */
72
+ alt: assetAltText(file, locale),
73
+ });
74
+ }
63
75
  };
64
76
  return (_jsxs("article", { ref: setNodeRef, className: ["gs-card", `is-${section.type}`, isDragging ? "is-dragging" : ""]
65
77
  .filter(Boolean).join(" "), style: { transform: CSS.Transform.toString(transform), transition }, children: [_jsxs("header", { className: "gs-card-head", children: [_jsx("button", { type: "button", className: "gs-drag", title: "Trascina per riordinare", "aria-label": `Riordina sezione ${labelOf(section.type)}`, ...attributes, ...listeners, children: _jsx(Icon.grip, {}) }), _jsx("span", { className: "gs-card-icon", children: _jsx(SectionIcon, { type: section.type }) }), _jsx("strong", { children: labelOf(section.type) }), _jsx("span", { className: "gs-position", children: index + 1 }), !isGallery && (_jsx("button", { type: "button", className: "gs-head-action", onClick: onDuplicate, children: "Duplica" })), (!isGallery || section.images.length > 0) && (_jsx("button", { type: "button", className: "gs-head-action is-danger", onClick: onRemove, children: "Elimina" }))] }), isGallery && (_jsx("div", { className: "gs-gallery", children: renderGallery?.(section.images, (images) => onPatch({ images })) })), section.type === "header" && (_jsxs("div", { className: "gs-fields", children: [_jsxs("label", { children: [_jsxs("span", { children: ["Occhiello ", _jsx("small", { children: "facoltativo" })] }), _jsx("input", { value: section.eyebrow, maxLength: 120, placeholder: "Es. Fotografia gastronomica", onChange: (event) => onPatch({ eyebrow: event.target.value }) })] }), _jsxs("label", { children: [_jsx("span", { children: "Titolo" }), _jsx("input", { value: section.heading, maxLength: 200, placeholder: "Titolo della sezione", onChange: (event) => onPatch({ heading: event.target.value }) })] }), _jsxs("label", { children: [_jsxs("span", { children: ["Introduzione ", _jsx("small", { children: "facoltativa" })] }), _jsx("textarea", { rows: 4, value: section.lead, maxLength: 800, placeholder: "Una breve apertura sotto il titolo.", onChange: (event) => onPatch({ lead: event.target.value }) })] })] })), section.type === "text" && (_jsx("div", { className: "gs-fields", children: _jsxs("label", { children: [_jsx("span", { children: "Testo" }), _jsx(ParagraphEditor, { value: section.paragraphs, onChange: (paragraphs) => onPatch({ paragraphs }) }), _jsx("small", { children: "Seleziona una parola per metterla in grassetto, in corsivo o per collegarla." })] }) })), section.type === "image" && (_jsxs("div", { className: "gs-image-editor", children: [_jsx("button", { type: "button", className: "gs-image-preview", onClick: () => void chooseImage(), children: section.image ? _jsx("img", { src: section.image, alt: "" }) : (_jsxs("span", { children: [_jsx(Icon.media, {}), " Scegli un\u2019immagine"] })) }), _jsxs("div", { className: "gs-image-fields", children: [_jsxs("div", { className: "gs-image-actions", children: [_jsx("button", { type: "button", className: "btn-soft", onClick: () => void chooseImage(), children: section.image ? "Cambia immagine" : "Scegli immagine" }), section.image && (_jsx("button", { type: "button", className: "btn-soft is-quiet", onClick: () => onPatch({ image: "", w: null, h: null }), children: "Rimuovi" }))] }), _jsxs("label", { children: [_jsx("span", { children: "Testo alternativo" }), _jsx("input", { value: section.alt, maxLength: 300, placeholder: "Descrive l\u2019immagine a chi non pu\u00F2 vederla", onChange: (event) => onPatch({ alt: event.target.value }) })] }), _jsxs("label", { children: [_jsxs("span", { children: ["Didascalia ", _jsx("small", { children: "facoltativa" })] }), _jsx("input", { value: section.caption, maxLength: 300, onChange: (event) => onPatch({ caption: event.target.value }) })] }), _jsxs("div", { className: "gs-layout", children: [_jsx("span", { children: "Dimensione" }), _jsxs("div", { className: "seg", role: "group", "aria-label": "Dimensione dell\u2019immagine", children: [_jsx("button", { type: "button", className: "seg-btn" + (section.layout === "wide" ? " is-on" : ""), onClick: () => onPatch({ layout: "wide" }), children: "Larga" }), _jsx("button", { type: "button", className: "seg-btn" + (section.layout === "contained" ? " is-on" : ""), onClick: () => onPatch({ layout: "contained" }), children: "Contenuta" })] })] })] })] }))] }));
@@ -13,8 +13,8 @@ import { DndContext, DragOverlay, KeyboardSensor, PointerSensor, closestCenter,
13
13
  import { SortableContext, arrayMove, rectSortingStrategy, sortableKeyboardCoordinates, useSortable, } from "@dnd-kit/sortable";
14
14
  import { CSS } from "@dnd-kit/utilities";
15
15
  import { ProseField, SchemaForm } from "../../form/index.js";
16
- import { api } from "../../index.js";
17
- import { pageType } from "../site.js";
16
+ import { api, assetAltText } from "../../index.js";
17
+ import { pageType, site } from "../site.js";
18
18
  import { normalizeGalleryLayout } from "../lib/gallery-layout";
19
19
  import { uniqueSlug } from "../lib/slug";
20
20
  import { proseStats } from "../lib/text";
@@ -165,9 +165,15 @@ function GalleryEditor({ value, onChange, pickMediaFiles, layout = "grid", }) {
165
165
  if (!picked?.length)
166
166
  return;
167
167
  const have = new Set(list.map((item) => item.src));
168
+ const locale = site().languages?.default ?? "default";
168
169
  const additions = picked
169
170
  .filter((file) => !have.has(file.url))
170
- .map((file) => ({ src: file.url, w: file.w ?? null, h: file.h ?? null }));
171
+ .map((file) => ({
172
+ src: file.url,
173
+ w: file.w ?? null,
174
+ h: file.h ?? null,
175
+ alt: assetAltText(file, locale),
176
+ }));
171
177
  if (additions.length)
172
178
  onChange(position === "start" ? [...additions, ...list] : [...list, ...additions]);
173
179
  };
@@ -180,8 +186,9 @@ function GalleryEditor({ value, onChange, pickMediaFiles, layout = "grid", }) {
180
186
  alert("Questa foto è già presente nella gallery.");
181
187
  return;
182
188
  }
189
+ const locale = site().languages?.default ?? "default";
183
190
  onChange(list.map((item, itemIndex) => itemIndex === index
184
- ? { ...item, src: file.url, w: file.w ?? null, h: file.h ?? null }
191
+ ? { ...item, src: file.url, w: file.w ?? null, h: file.h ?? null, alt: assetAltText(file, locale) }
185
192
  : item));
186
193
  };
187
194
  const finishDrag = ({ active, over }) => {
@@ -1,4 +1,4 @@
1
- import type { AssetFile } from "../../index.js";
1
+ import { type AssetFile } from "../../index.js";
2
2
  import { normalizeWorks, type Work, type WorkCard, type WorkCardType, type WorkInfo } from "../../normalize.js";
3
3
  export type { WorkCardType, WorkInfo, WorkCard, Work };
4
4
  export { normalizeWorks };
@@ -31,6 +31,7 @@ import { useEffect, useRef, useState } from "react";
31
31
  import { DndContext, closestCenter } from "@dnd-kit/core";
32
32
  import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable";
33
33
  import { CSS } from "@dnd-kit/utilities";
34
+ import { assetAltText } from "../../index.js";
34
35
  import { ParagraphEditor } from "../ui/ParagraphEditor";
35
36
  /* La casella immagine sta in `ui/`: la usano copertina e card qui, e il ritratto della pagina
36
37
  "chi sono". Vedi il commento in cima a quel file. */
@@ -41,6 +42,7 @@ import { OpenCard } from "../ui/OpenCard";
41
42
  import { useDragSensors } from "../ui/dnd";
42
43
  import { movedOpenIndex, removedOpenIndex } from "../lib/reorder";
43
44
  import { Icon } from "../ui/icons";
45
+ import { site } from "../site.js";
44
46
  import { normalizeWorks, emptyWork, emptyCard, CARD_KINDS, } from "../../normalize.js";
45
47
  export { normalizeWorks };
46
48
  const str = (value) => (typeof value === "string" ? value : "");
@@ -69,7 +71,10 @@ function SortableCard({ id, card, index, onPatch, onRemove, pickMediaFiles }) {
69
71
  const chooseImage = async () => {
70
72
  const file = (await pickMediaFiles?.({ multiple: false }))?.[0];
71
73
  if (file)
72
- onPatch({ image: file.url });
74
+ onPatch({
75
+ image: file.url,
76
+ alt: assetAltText(file, site().languages?.default ?? "default"),
77
+ });
73
78
  };
74
79
  return (_jsxs("article", { ref: setNodeRef, className: "pw-card" + (isDragging ? " is-dragging" : ""), style: { transform: CSS.Transform.toString(transform), transition }, children: [_jsxs("header", { className: "pw-card-head", children: [_jsx("button", { type: "button", className: "pw-grip", "aria-label": `Riordina card ${index + 1}`, ...attributes, ...listeners, children: _jsx(Icon.grip, {}) }), _jsx("span", { className: "pw-card-kind", children: kind.label }), _jsx("div", { className: "seg", role: "group", "aria-label": "Tipo di card", children: CARD_KINDS.map((option) => (_jsx("button", { type: "button", className: "seg-btn" + (card.type === option.type ? " is-on" : ""), onClick: () => onPatch({ type: option.type }), title: option.hint, children: option.label }, option.type))) }), _jsx("button", { type: "button", className: "pw-icon-btn is-danger", "aria-label": "Elimina card", onClick: onRemove, children: _jsx(Icon.trash, {}) })] }), _jsxs("div", { className: "pw-card-body" + (wantsImage ? " is-split" : ""), children: [wantsImage && (_jsx(MediaSlot, { value: card.image, ratio: "wide", empty: "Scegli un\u2019immagine", onPick: () => void chooseImage(), onClear: () => onPatch({ image: "" }) })), _jsxs("div", { className: "pw-card-text", children: [wantsImage && (_jsxs("label", { className: "pw-label", children: [_jsx("span", { children: "Testo alternativo" }), _jsx("input", { className: "pw-field", value: card.alt, maxLength: 200, placeholder: "Descrive l\u2019immagine a chi non pu\u00F2 vederla", onChange: (event) => onPatch({ alt: event.target.value }) })] })), wantsText && (_jsxs(_Fragment, { children: [_jsxs("label", { className: "pw-label", children: [_jsxs("span", { children: ["Occhiello ", _jsx("small", { children: "facoltativo" })] }), _jsx("input", { className: "pw-field", value: card.kicker, maxLength: 60, onChange: (event) => onPatch({ kicker: event.target.value }) })] }), _jsxs("label", { className: "pw-label", children: [_jsx("span", { children: "Titolo" }), _jsx("input", { className: "pw-field", value: card.heading, maxLength: 120, onChange: (event) => onPatch({ heading: event.target.value }) })] }), _jsxs("label", { className: "pw-label", children: [_jsx("span", { children: "Paragrafi" }), _jsx(ParagraphEditor, { className: "pw-field pw-textarea", rows: 5, value: card.body, placeholder: "Scrivi il testo della card.\nVai a capo per iniziare un paragrafo nuovo.", onChange: (body) => onPatch({ body }) })] })] }))] })] })] }));
75
80
  }
@@ -100,7 +105,10 @@ function SortableWork({ id, work, index, total, open, onToggle, onPatch, onDupli
100
105
  const chooseCover = async () => {
101
106
  const file = (await pickMediaFiles?.({ multiple: false }))?.[0];
102
107
  if (file)
103
- onPatch({ cover: file.url });
108
+ onPatch({
109
+ cover: file.url,
110
+ coverAlt: assetAltText(file, site().languages?.default ?? "default"),
111
+ });
104
112
  };
105
113
  const pieces = [
106
114
  work.cards.length ? `${work.cards.length} ${work.cards.length === 1 ? "card" : "card"}` : "",
@@ -109,7 +117,7 @@ function SortableWork({ id, work, index, total, open, onToggle, onPatch, onDupli
109
117
  return (
110
118
  /* Il guscio (maniglia, miniatura, titolo, apri/chiudi) sta in `ui/OpenCard`: è lo stesso
111
119
  delle edizioni dei workshop, e tenerlo qui avrebbe voluto dire ricopiarlo là. */
112
- _jsxs(OpenCard, { id: id, index: index, total: total, open: open, thumb: work.cover, title: work.title, empty: "Lavoro senza titolo", meta: pieces.join(" · "), kind: "questo lavoro", onToggle: onToggle, actions: _jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "btn-soft is-quiet", onClick: onDuplicate, children: "Duplica" }), _jsx("button", { type: "button", className: "btn-soft is-danger", onClick: onRemove, children: "Elimina" })] }), children: [_jsxs("div", { className: "pw-identity", children: [_jsx(MediaSlot, { value: work.cover, ratio: "cover", empty: "Scegli la copertina", onPick: () => void chooseCover(), onClear: () => onPatch({ cover: "" }) }), _jsxs("div", { className: "pw-identity-fields", children: [_jsxs("label", { className: "pw-label", children: [_jsx("span", { children: "Titolo" }), _jsx("input", { className: "pw-field pw-field-title", value: work.title, maxLength: 120, placeholder: "Il nome del lavoro", onChange: (event) => onPatch({ title: event.target.value }) })] }), _jsxs("label", { className: "pw-label", children: [_jsx("span", { children: "Descrizione" }), _jsx(ParagraphEditor, { className: "pw-field pw-textarea", rows: 7, value: work.description, placeholder: "Racconta il lavoro.\nVai a capo per iniziare un paragrafo nuovo.", onChange: (description) => onPatch({ description }) }), _jsx("small", { children: "Seleziona una parola per metterla in grassetto, in corsivo o per collegarla." })] })] })] }), _jsx(InfoList, { items: work.info, onChange: (info) => onPatch({ info }) }), _jsx(CardList, { items: work.cards, onChange: (cards) => onPatch({ cards }), pickMediaFiles: pickMediaFiles })] }));
120
+ _jsxs(OpenCard, { id: id, index: index, total: total, open: open, thumb: work.cover, title: work.title, empty: "Lavoro senza titolo", meta: pieces.join(" · "), kind: "questo lavoro", onToggle: onToggle, actions: _jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "btn-soft is-quiet", onClick: onDuplicate, children: "Duplica" }), _jsx("button", { type: "button", className: "btn-soft is-danger", onClick: onRemove, children: "Elimina" })] }), children: [_jsxs("div", { className: "pw-identity", children: [_jsx(MediaSlot, { value: work.cover, ratio: "cover", empty: "Scegli la copertina", onPick: () => void chooseCover(), onClear: () => onPatch({ cover: "" }) }), _jsxs("div", { className: "pw-identity-fields", children: [_jsxs("label", { className: "pw-label", children: [_jsx("span", { children: "Titolo" }), _jsx("input", { className: "pw-field pw-field-title", value: work.title, maxLength: 120, placeholder: "Il nome del lavoro", onChange: (event) => onPatch({ title: event.target.value }) })] }), _jsxs("label", { className: "pw-label", children: [_jsx("span", { children: "Testo alternativo della copertina" }), _jsx("input", { className: "pw-field", value: work.coverAlt, maxLength: 200, placeholder: "Descrive la copertina a chi non pu\u00F2 vederla", onChange: (event) => onPatch({ coverAlt: event.target.value }) })] }), _jsxs("label", { className: "pw-label", children: [_jsx("span", { children: "Descrizione" }), _jsx(ParagraphEditor, { className: "pw-field pw-textarea", rows: 7, value: work.description, placeholder: "Racconta il lavoro.\nVai a capo per iniziare un paragrafo nuovo.", onChange: (description) => onPatch({ description }) }), _jsx("small", { children: "Seleziona una parola per metterla in grassetto, in corsivo o per collegarla." })] })] })] }), _jsx(InfoList, { items: work.info, onChange: (info) => onPatch({ info }) }), _jsx(CardList, { items: work.cards, onChange: (cards) => onPatch({ cards }), pickMediaFiles: pickMediaFiles })] }));
113
121
  }
114
122
  /* ---- L'elenco ------------------------------------------------------------------------------ */
115
123
  export function PortfolioWorksEditor({ value, onChange, pickMediaFiles }) {
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /* I widget. Piccoli di proposito: il valore del sistema sta nello schema, non qui.
3
3
  * Nessuno di questi conosce il sito — etichette, opzioni e aiuti arrivano da `ui`. */
4
4
  import { useEffect, useState } from "react";
5
+ import { assetAltText } from "../../api.js";
5
6
  import { registerField } from "../registry.js";
6
7
  import { ProseField } from "../prose/ProseField.js";
7
8
  function Wrap({ meta, error, children }) {
@@ -100,7 +101,7 @@ registerField("r2Curated", ({ meta, value, onChange, error, pickMediaFiles }) =>
100
101
  const have = new Set(list.map((it) => it.src));
101
102
  const add = picked
102
103
  .filter((f) => !have.has(f.url))
103
- .map((f) => ({ src: f.url, w: f.w ?? null, h: f.h ?? null }));
104
+ .map((f) => ({ src: f.url, w: f.w ?? null, h: f.h ?? null, alt: assetAltText(f) }));
104
105
  if (add.length)
105
106
  onChange([...list, ...add]);
106
107
  }, children: "+ Aggiungi foto" })] }));
@@ -23,14 +23,21 @@ export function Lightbox({ lib }) {
23
23
  const i = lib.viewing;
24
24
  const file = i === null ? undefined : lib.shown[i];
25
25
  const [info, setInfo] = useState(false);
26
+ const [altOpen, setAltOpen] = useState(false);
27
+ const [altDraft, setAltDraft] = useState({ decorative: false, texts: {} });
28
+ const [altSaved, setAltSaved] = useState(false);
26
29
  const [usage, setUsage] = useState(null);
27
30
  const [usageError, setUsageError] = useState(false);
31
+ const altLocales = lib.altLocales ?? ["default"];
32
+ const defaultAltLocale = lib.defaultAltLocale ?? "default";
28
33
  // Tastiera: Esc chiude, le frecce scorrono. Su un visore a schermo intero è il modo in cui
29
34
  // ci si aspetta di muoversi, e senza si è costretti a mirare due bottoni piccoli.
30
35
  useEffect(() => {
31
36
  if (i === null)
32
37
  return;
33
38
  const on = (e) => {
39
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)
40
+ return;
34
41
  if (e.key === "Escape")
35
42
  lib.view(null);
36
43
  else if (e.key === "ArrowRight")
@@ -41,6 +48,10 @@ export function Lightbox({ lib }) {
41
48
  addEventListener("keydown", on);
42
49
  return () => removeEventListener("keydown", on);
43
50
  }, [i, lib]);
51
+ useEffect(() => {
52
+ setAltDraft(file?.alt ?? { decorative: false, texts: {} });
53
+ setAltSaved(false);
54
+ }, [file?.key]);
44
55
  // L'uso si richiede per la foto che si sta guardando, non una volta per tutte.
45
56
  useEffect(() => {
46
57
  setUsage(null);
@@ -62,9 +73,28 @@ export function Lightbox({ lib }) {
62
73
  if (i === null || !file)
63
74
  return null;
64
75
  const folder = lib.prefix.replace(/\/$/, "") || "tutte le foto";
76
+ const lightboxAlt = file.alt?.decorative
77
+ ? ""
78
+ : file.alt?.texts[defaultAltLocale] || file.name;
65
79
  return (_jsxs("div", { className: "lb", onClick: (e) => { if (e.target === e.currentTarget)
66
- lib.view(null); }, children: [_jsxs("header", { className: "lb-top", children: [_jsxs("button", { className: "lb-btn", onClick: () => lib.view(null), children: ["\u2039 Torna a ", folder] }), _jsxs("div", { className: "lb-id", children: [_jsx("span", { className: "lb-name", children: file.name }), _jsxs("span", { className: "lb-count", children: [i + 1, " / ", lib.shown.length] })] }), _jsxs("div", { className: "lb-tools", children: [_jsx("a", { className: "lb-btn", href: file.url, download: file.name, title: "Scarica", children: _jsx(IcoDown, {}) }), _jsx("button", { className: "lb-btn" + (info ? " is-on" : ""), onClick: () => setInfo((v) => !v), title: "Informazioni", children: _jsx(IcoInfo, {}) }), lib.mode === "manage" && (_jsx("button", { className: "lb-btn", onClick: () => void lib.remove([file.key]), title: "Cestina", children: _jsx(IcoTrash, {}) }))] })] }), _jsxs("div", { className: "lb-stage", onClick: (e) => { if (e.target === e.currentTarget)
67
- lib.view(null); }, children: [_jsx("button", { className: "lb-nav lb-prev", disabled: i === 0, onClick: () => lib.step(-1), "aria-label": "Precedente", children: "\u2039" }), _jsx("img", { className: "lb-img", src: file.url, alt: file.name }), _jsx("button", { className: "lb-nav lb-next", disabled: i === lib.shown.length - 1, onClick: () => lib.step(1), "aria-label": "Successiva", children: "\u203A" })] }), info && (_jsx("aside", { className: "lb-info", children: _jsxs("dl", { children: [_jsx("dt", { children: "File" }), _jsx("dd", { children: file.name }), _jsx("dt", { children: "Cartella" }), _jsx("dd", { children: lib.prefix || "(radice)" }), _jsx("dt", { children: "Dimensioni" }), _jsx("dd", { children: file.w && file.h ? `${file.w} × ${file.h} px` : "—" }), _jsx("dt", { children: "Peso" }), _jsx("dd", { children: fmtSize(file.size) }), _jsx("dt", { children: "Scatto" }), _jsx("dd", { children: fmtDate(file.capturedAt) }), _jsx("dt", { children: "Caricata" }), _jsx("dd", { children: fmtDate(file.uploaded) }), _jsx("dt", { children: "Usata in" }), _jsx("dd", { children: usageError
80
+ lib.view(null); }, children: [_jsxs("header", { className: "lb-top", children: [_jsxs("button", { className: "lb-btn", onClick: () => lib.view(null), children: ["\u2039 Torna a ", folder] }), _jsxs("div", { className: "lb-id", children: [_jsx("span", { className: "lb-name", children: file.name }), _jsxs("span", { className: "lb-count", children: [i + 1, " / ", lib.shown.length] })] }), _jsxs("div", { className: "lb-tools", children: [_jsx("a", { className: "lb-btn", href: file.url, download: file.name, title: "Scarica", children: _jsx(IcoDown, {}) }), lib.mode === "manage" && (file.type ?? "").startsWith("image/") && (_jsx("button", { className: "lb-btn lb-alt-button" + (altOpen ? " is-on" : ""), onClick: () => { setAltOpen((value) => !value); setInfo(false); lib.clearError(); }, title: "Testo alternativo", children: "ALT" })), _jsx("button", { className: "lb-btn" + (info ? " is-on" : ""), onClick: () => { setInfo((v) => !v); setAltOpen(false); }, title: "Informazioni", children: _jsx(IcoInfo, {}) }), lib.mode === "manage" && (_jsx("button", { className: "lb-btn", onClick: () => void lib.remove([file.key]), title: "Cestina", children: _jsx(IcoTrash, {}) }))] })] }), _jsxs("div", { className: "lb-stage", onClick: (e) => { if (e.target === e.currentTarget)
81
+ lib.view(null); }, children: [_jsx("button", { className: "lb-nav lb-prev", disabled: i === 0, onClick: () => lib.step(-1), "aria-label": "Precedente", children: "\u2039" }), _jsx("img", { className: "lb-img", src: file.url, alt: lightboxAlt }), _jsx("button", { className: "lb-nav lb-next", disabled: i === lib.shown.length - 1, onClick: () => lib.step(1), "aria-label": "Successiva", children: "\u203A" })] }), info && (_jsx("aside", { className: "lb-info", children: _jsxs("dl", { children: [_jsx("dt", { children: "File" }), _jsx("dd", { children: file.name }), _jsx("dt", { children: "Cartella" }), _jsx("dd", { children: lib.prefix || "(radice)" }), _jsx("dt", { children: "Dimensioni" }), _jsx("dd", { children: file.w && file.h ? `${file.w} × ${file.h} px` : "—" }), _jsx("dt", { children: "Peso" }), _jsx("dd", { children: fmtSize(file.size) }), _jsx("dt", { children: "Scatto" }), _jsx("dd", { children: fmtDate(file.capturedAt) }), _jsx("dt", { children: "Caricata" }), _jsx("dd", { children: fmtDate(file.uploaded) }), _jsx("dt", { children: "Usata in" }), _jsx("dd", { children: usageError
68
82
  ? "controllo non disponibile"
69
- : usage === null ? "…" : usage.length ? usage.join(", ") : "nessuna sorgente" })] }) }))] }));
83
+ : usage === null ? "…" : usage.length ? usage.join(", ") : "nessuna sorgente" })] }) })), altOpen && lib.mode === "manage" && (_jsxs("aside", { className: "lb-alt", "aria-label": "Testo alternativo dell'immagine", children: [_jsxs("header", { children: [_jsxs("div", { children: [_jsx("strong", { children: "Testo alternativo" }), _jsx("span", { children: "Predefinito per questo file" })] }), _jsx("button", { type: "button", onClick: () => setAltOpen(false), "aria-label": "Chiudi", children: "\u00D7" })] }), _jsxs("label", { className: "lb-alt-decorative", children: [_jsx("input", { type: "checkbox", checked: altDraft.decorative, disabled: lib.mutating, onChange: (event) => {
84
+ setAltSaved(false);
85
+ setAltDraft((current) => ({ ...current, decorative: event.target.checked }));
86
+ } }), _jsxs("span", { children: [_jsx("strong", { children: "Immagine decorativa" }), _jsxs("small", { children: ["Non aggiunge informazioni: il sito user\u00E0 ", _jsx("code", { children: "alt=\"\"" }), "."] })] })] }), !altDraft.decorative && (_jsx("div", { className: "lb-alt-fields", children: altLocales.map((locale) => {
87
+ const value = altDraft.texts[locale] ?? "";
88
+ return (_jsxs("label", { children: [_jsxs("span", { children: [locale === "default" ? "Testo" : `Testo · ${locale.toUpperCase()}`, _jsxs("small", { children: [value.length, " caratteri"] })] }), _jsx("textarea", { rows: 3, value: value, disabled: lib.mutating, placeholder: "Descrivi il significato dell\u2019immagine nel contesto della pagina", onChange: (event) => {
89
+ setAltSaved(false);
90
+ setAltDraft((current) => ({
91
+ ...current,
92
+ texts: { ...current.texts, [locale]: event.target.value },
93
+ }));
94
+ } })] }, locale));
95
+ }) })), _jsx("p", { className: "lb-alt-guidance", children: "Scrivi una frase breve e naturale. Evita \u201Cimmagine di\u201D, ripetizioni e liste di parole chiave. Lo stesso file pu\u00F2 essere sovrascritto dal singolo contenuto quando il contesto lo richiede." }), lib.error && _jsx("p", { className: "lb-alt-error", children: lib.error }), altSaved && _jsx("p", { className: "lb-alt-saved", role: "status", children: "Testo alternativo salvato." }), _jsx("footer", { children: _jsx("button", { type: "button", className: "ak-btn ak-btn-primary", disabled: lib.mutating, onClick: async () => {
96
+ const saved = await lib.saveAlt(file.key, altDraft);
97
+ if (saved)
98
+ setAltSaved(true);
99
+ }, children: lib.operationState === "saving_alt" ? "Salvataggio…" : "Salva" }) })] }))] }));
70
100
  }
@@ -23,14 +23,14 @@ import { SORT_LABELS } from "./useMediaLibrary.js";
23
23
  import { DRAG_TYPE, DRAG_FOLDERS, readDragged, FolderMenu } from "./MediaFolders.js";
24
24
  import { Lightbox } from "./Lightbox.js";
25
25
  import { UploadDialog } from "./UploadDialog.js";
26
- import { summarizeMediaSelection } from "./media-model.js";
26
+ import { mediaAltStatus, summarizeMediaSelection } from "./media-model.js";
27
27
  const JOURNAL_LABELS = {
28
28
  preparing: "Preparo le copie Media.",
29
- ready: "Media pronta: pubblica per applicare la modifica.",
29
+ ready: "Modifica Media pronta per la pubblicazione.",
30
30
  blocked_in_use: "Operazione bloccata: una bozza o la versione online usa ancora la sorgente.",
31
31
  bound: "Pubblicazione con Media in corso.",
32
32
  finalizing: "Pubblicazione riuscita: completo la pulizia Media.",
33
- completed_with_warning: "Pubblicato con Media da completare.",
33
+ completed_with_warning: "Modifica applicata con alcuni Media da completare.",
34
34
  failed: "Operazione Media interrotta prima della pubblicazione.",
35
35
  };
36
36
  /* Scaricare più file significa più download: il browser ne consente una raffica breve dallo
@@ -121,6 +121,7 @@ export function MediaGrid({ lib, notice, onPick, onCancel }) {
121
121
  const allOn = lib.allSelected;
122
122
  const locked = lib.busy || lib.mutating;
123
123
  const selection = summarizeMediaSelection(sel);
124
+ const altLocales = lib.altLocales ?? ["default"];
124
125
  /* `dragDepth` e non un booleano: `dragenter`/`dragleave` scattano anche passando da un figlio
125
126
  all'altro, quindi con un booleano la zona si spegne mentre ci si muove sopra. Si conta. */
126
127
  const [dragDepth, setDragDepth] = useState(0);
@@ -152,9 +153,11 @@ export function MediaGrid({ lib, notice, onPick, onCancel }) {
152
153
  }, children: [dragDepth > 0 && (_jsx("div", { className: "mg-dropzone", children: manage
153
154
  ? _jsxs("span", { children: ["Rilascia per caricare in ", _jsx("strong", { children: lib.prefix ? lib.labelOf(lib.prefix) : "Senza cartella" })] })
154
155
  : _jsx("span", { children: "Rilascia le immagini \u2014 sceglierai la cartella prima del caricamento" }) })), _jsx("header", { className: "mg-bar", children: sel.size === 0 ? (_jsxs(_Fragment, { children: [(manage || lib.multiple) && (_jsxs("label", { className: "mg-all", children: [_jsx("input", { type: "checkbox", checked: false, disabled: locked || !lib.selectableIds.length, ref: (el) => { if (el)
155
- el.indeterminate = false; }, onChange: () => lib.setAll(true) }), _jsx("span", { children: "Seleziona tutto" })] })), !manage && (_jsx("button", { className: "ak-btn ak-btn-quiet mg-upload", disabled: locked, onClick: () => lib.openUpload(), children: "Carica nuove foto" })), _jsx("span", { className: "ak-spacer" }), _jsxs("label", { className: "ak-hint", children: ["ordina per", " ", _jsx("select", { className: "mg-sort", value: lib.sortKey, disabled: locked, onChange: (e) => lib.setSort(e.target.value), children: Object.entries(SORT_LABELS).map(([k, l]) => _jsx("option", { value: k, children: l }, k)) }), _jsx("button", { className: "ak-icon", disabled: locked, onClick: lib.flipDir, title: "Inverti l'ordine", children: lib.sortDir === "asc" ? "↑" : "↓" })] })] })) : (_jsxs(_Fragment, { children: [_jsxs("label", { className: "mg-all", children: [_jsx("input", { type: "checkbox", checked: allOn, disabled: locked, ref: (el) => { if (el)
156
- el.indeterminate = lib.someSelected; }, onChange: () => lib.setAll(!allOn) }), _jsx("span", { children: selection.label })] }), !allOn && lib.selectableIds.length > 0 && (_jsxs("button", { className: "mg-act is-link", disabled: locked, onClick: () => lib.setAll(true), children: ["Seleziona tutti gli elementi (", lib.selectableIds.length, ")"] })), manage && (_jsxs(_Fragment, { children: [lib.selectedFiles.length > 0 && (_jsxs("button", { className: "mg-act", disabled: locked, onClick: () => download(lib.shown.filter((f) => sel.has(f.key)).map((f) => ({ url: f.url, name: f.name }))), children: ["Scarica ", lib.selectedFiles.length, " file"] })), _jsx(MoveMenu, { lib: lib, keys: [...sel] }), _jsx("button", { className: "mg-act is-danger", disabled: locked, onClick: () => void lib.remove([...sel]), children: "Elimina" })] })), _jsx("span", { className: "ak-spacer" }), _jsx("button", { className: "mg-act", disabled: locked, onClick: () => lib.setAll(false), children: "Deseleziona" })] })) }), notice && _jsx("p", { className: "mg-picker-notice", children: notice }), lib.error && _jsx("p", { className: "ak-error", onClick: lib.clearError, children: lib.error }), manage && lib.mediaOperations.length > 0 && (_jsx("section", { className: "mg-journal", "aria-live": "polite", "aria-label": "Stato operazioni Media", children: lib.mediaOperations.map((operation) => (_jsxs("div", { className: `mg-journal-row is-${operation.state}`, children: [_jsx("span", { className: "mg-journal-badge", children: operation.type === "move" ? "Spostamento" : "Eliminazione" }), _jsx("span", { children: JOURNAL_LABELS[operation.state] ?? "Operazione Media in attesa." }), operation.failedEntries > 0 && _jsxs("span", { className: "ak-hint", children: [operation.failedEntries, " file da riprovare"] }), _jsx("span", { className: "ak-spacer" }), operation.state === "completed_with_warning" && operation.releaseId && (_jsx("button", { className: "mg-act", disabled: locked, onClick: () => void lib.resumeMedia(operation), children: "Riprendi" })), ["preparing", "ready", "blocked_in_use", "failed"].includes(operation.state) && !operation.releaseId && (_jsx("button", { className: "mg-act is-danger", disabled: locked, onClick: () => void lib.cancelMedia(operation), children: "Annulla" }))] }, operation.operationId))) })), lib.progress && (_jsxs("p", { className: "mg-progress", children: [lib.progress.verb, " ", lib.progress.done, "/", lib.progress.total, "\u2026 \u2014 non chiudere la pagina"] })), lib.pickUploadJob?.state === "running" && (_jsxs("p", { className: "mg-progress", children: ["Nuove immagini: preparo ", lib.pickUploadJob.conversionDone, "/", lib.pickUploadJob.total, ", carico ", lib.pickUploadJob.uploadDone, "/", lib.pickUploadJob.total, "\u2026"] })), lib.prefix !== "" && (manage || lib.subfolders.length > 0) && (_jsxs("section", { className: "mg-section", children: [_jsxs("div", { className: "mg-section-head", children: [_jsx("h2", { className: "mg-section-title", children: "Cartelle" }), manage && (_jsx("button", { className: "mg-act", disabled: locked, onClick: () => void lib.newFolder(lib.prefix), children: "+ Cartella" }))] }), lib.subfolders.length > 0 && (_jsx("div", { className: "mg", children: lib.subfolders.map((f) => (_jsx(FolderCell, { lib: lib, folder: f, manage: manage, onExternalDrop: () => setDragDepth(0) }, f.prefix))) }))] })), lib.prefix !== "" && (manage || lib.subfolders.length > 0) && lib.shown.length > 0 && (_jsx("h2", { className: "mg-section-title", children: "Foto" })), _jsx("div", { className: "mg", children: lib.shown.map((f, i) => {
156
+ el.indeterminate = false; }, onChange: () => lib.setAll(true) }), _jsx("span", { children: "Seleziona tutto" })] })), !manage && (_jsx("button", { className: "ak-btn ak-btn-quiet mg-upload", disabled: locked, onClick: () => lib.openUpload(), children: "Carica nuove foto" })), _jsx("span", { className: "ak-spacer" }), manage && (_jsxs("label", { className: "ak-hint", children: ["testo alt", " ", _jsxs("select", { className: "mg-sort", value: lib.altFilter, disabled: locked, onChange: (event) => lib.setAltFilter(event.target.value), children: [_jsx("option", { value: "all", children: "tutti" }), _jsx("option", { value: "missing", children: "da completare" }), _jsx("option", { value: "described", children: "completo" }), _jsx("option", { value: "decorative", children: "decorativo" })] })] })), _jsxs("label", { className: "ak-hint", children: ["ordina per", " ", _jsx("select", { className: "mg-sort", value: lib.sortKey, disabled: locked, onChange: (e) => lib.setSort(e.target.value), children: Object.entries(SORT_LABELS).map(([k, l]) => _jsx("option", { value: k, children: l }, k)) }), _jsx("button", { className: "ak-icon", disabled: locked, onClick: lib.flipDir, title: "Inverti l'ordine", children: lib.sortDir === "asc" ? "↑" : "↓" })] })] })) : (_jsxs(_Fragment, { children: [_jsxs("label", { className: "mg-all", children: [_jsx("input", { type: "checkbox", checked: allOn, disabled: locked, ref: (el) => { if (el)
157
+ el.indeterminate = lib.someSelected; }, onChange: () => lib.setAll(!allOn) }), _jsx("span", { children: selection.label })] }), !allOn && lib.selectableIds.length > 0 && (_jsxs("button", { className: "mg-act is-link", disabled: locked, onClick: () => lib.setAll(true), children: ["Seleziona tutti gli elementi (", lib.selectableIds.length, ")"] })), manage && (_jsxs(_Fragment, { children: [lib.selectedFiles.length > 0 && (_jsxs("button", { className: "mg-act", disabled: locked, onClick: () => download(lib.shown.filter((f) => sel.has(f.key)).map((f) => ({ url: f.url, name: f.name }))), children: ["Scarica ", lib.selectedFiles.length, " file"] })), _jsx(MoveMenu, { lib: lib, keys: [...sel] }), _jsx("button", { className: "mg-act is-danger", disabled: locked, onClick: () => void lib.remove([...sel]), children: "Elimina" })] })), _jsx("span", { className: "ak-spacer" }), _jsx("button", { className: "mg-act", disabled: locked, onClick: () => lib.setAll(false), children: "Deseleziona" })] })) }), notice && _jsx("p", { className: "mg-picker-notice", children: notice }), lib.error && _jsx("p", { className: "ak-error", onClick: lib.clearError, children: lib.error }), manage && lib.mediaOperations.length > 0 && (_jsx("section", { className: "mg-journal", "aria-live": "polite", "aria-label": "Stato operazioni Media", children: lib.mediaOperations.map((operation) => (_jsxs("div", { className: `mg-journal-row is-${operation.state}`, children: [_jsx("span", { className: "mg-journal-badge", children: operation.type === "move" ? "Spostamento" : "Eliminazione" }), _jsx("span", { children: JOURNAL_LABELS[operation.state] ?? "Operazione Media in attesa." }), operation.failedEntries > 0 && _jsxs("span", { className: "ak-hint", children: [operation.failedEntries, " file da riprovare"] }), _jsx("span", { className: "ak-spacer" }), operation.state === "ready" && !operation.releaseId && (_jsx("button", { className: "mg-act", disabled: locked, onClick: () => void lib.publishMedia(operation), children: "Riprova pubblicazione" })), operation.state === "completed_with_warning" && (_jsx("button", { className: "mg-act", disabled: locked, onClick: () => void lib.resumeMedia(operation), children: "Riprendi" })), ["preparing", "ready", "blocked_in_use", "failed"].includes(operation.state) && !operation.releaseId && (_jsx("button", { className: "mg-act is-danger", disabled: locked, onClick: () => void lib.cancelMedia(operation), children: "Annulla" }))] }, operation.operationId))) })), lib.progress && (_jsxs("p", { className: "mg-progress", children: [lib.progress.verb, " ", lib.progress.done, "/", lib.progress.total, "\u2026 \u2014 non chiudere la pagina"] })), lib.pickUploadJob?.state === "running" && (_jsxs("p", { className: "mg-progress", children: ["Nuove immagini: preparo ", lib.pickUploadJob.conversionDone, "/", lib.pickUploadJob.total, ", carico ", lib.pickUploadJob.uploadDone, "/", lib.pickUploadJob.total, "\u2026"] })), lib.prefix !== "" && (manage || lib.subfolders.length > 0) && (_jsxs("section", { className: "mg-section", children: [_jsxs("div", { className: "mg-section-head", children: [_jsx("h2", { className: "mg-section-title", children: "Cartelle" }), manage && (_jsx("button", { className: "mg-act", disabled: locked, onClick: () => void lib.newFolder(lib.prefix), children: "+ Cartella" }))] }), lib.subfolders.length > 0 && (_jsx("div", { className: "mg", children: lib.subfolders.map((f) => (_jsx(FolderCell, { lib: lib, folder: f, manage: manage, onExternalDrop: () => setDragDepth(0) }, f.prefix))) }))] })), lib.prefix !== "" && (manage || lib.subfolders.length > 0) && lib.shown.length > 0 && (_jsx("h2", { className: "mg-section-title", children: "Foto" })), _jsx("div", { className: "mg", children: lib.shown.map((f, i) => {
157
158
  const on = sel.has(f.key);
159
+ const isImage = (f.type ?? "").startsWith("image/");
160
+ const altStatus = isImage ? mediaAltStatus(f, altLocales) : null;
158
161
  return (_jsxs("figure", { className: "mg-item" + (on ? " is-on" : ""), draggable: manage && !locked, onDragStart: (e) => {
159
162
  if (locked) {
160
163
  e.preventDefault();
@@ -173,7 +176,7 @@ export function MediaGrid({ lib, notice, onPick, onCancel }) {
173
176
  (manage ? lib.view(i) : lib.toggle(f.key)); }, children: [_jsx("img", { className: "mg-img", src: f.url, alt: "", loading: "lazy",
174
177
  /* Il rapporto è noto prima che l'immagine arrivi (lo dà l'elenco): la cella
175
178
  riserva subito l'ingombro giusto e la griglia non salta mentre si carica. */
176
- style: { aspectRatio: f.w && f.h ? `${f.w} / ${f.h}` : "3 / 2" } }), _jsx("button", { className: "mg-check", "aria-label": on ? "Deseleziona" : "Seleziona", disabled: locked, onClick: (e) => { e.stopPropagation(); lib.toggle(f.key); }, children: on ? "✓" : "" })] }), _jsx("figcaption", { className: "mg-cap", title: f.name, children: f.name })] }, f.key));
179
+ style: { aspectRatio: f.w && f.h ? `${f.w} / ${f.h}` : "3 / 2" } }), manage && altStatus && (_jsx("span", { className: `mg-alt-badge is-${altStatus}`, children: altStatus === "missing" ? "ALT da completare" : altStatus === "decorative" ? "Decorativa" : "ALT completo" })), _jsx("button", { className: "mg-check", "aria-label": on ? "Deseleziona" : "Seleziona", disabled: locked, onClick: (e) => { e.stopPropagation(); lib.toggle(f.key); }, children: on ? "✓" : "" })] }), _jsx("figcaption", { className: "mg-cap", title: f.name, children: f.name })] }, f.key));
177
180
  }) }), !lib.busy && lib.shown.length === 0 && (_jsx("p", { className: "ak-hint", children: lib.prefix === "" && lib.folders.length
178
181
  ? "Nessuna foto fuori dalle cartelle. Scegline una qui a fianco."
179
182
  : "Nessun file qui." })), lib.mode === "pick" && (_jsxs("footer", { className: "mg-foot", children: [_jsxs("span", { className: "ak-hint", children: [sel.size, " selezionate"] }), _jsx("span", { className: "ak-spacer" }), _jsx("button", { className: "ak-btn ak-btn-quiet", disabled: locked, onClick: () => onCancel?.(), children: "Annulla" }), _jsxs("button", { className: "ak-btn ak-btn-primary", disabled: locked || !sel.size, onClick: () => onPick?.(lib.shown.filter((f) => sel.has(f.key))), children: ["Usa ", sel.size > 0 ? `(${sel.size})` : ""] })] }))] }), _jsx(Lightbox, { lib: lib }), _jsx(UploadDialog, { lib: lib })] }));
@@ -1,5 +1,7 @@
1
1
  import type { AssetFile, AssetFolder } from "../../api.js";
2
2
  export type MediaSelectionKind = "file" | "folder";
3
+ export type MediaAltStatus = "missing" | "described" | "decorative";
4
+ export declare function mediaAltStatus(file: Pick<AssetFile, "alt">, locales: readonly string[]): MediaAltStatus;
3
5
  export interface MediaSelectableItem {
4
6
  id: string;
5
7
  kind: MediaSelectionKind;
@@ -1,3 +1,11 @@
1
+ export function mediaAltStatus(file, locales) {
2
+ if (file.alt?.decorative)
3
+ return "decorative";
4
+ const texts = file.alt?.texts ?? {};
5
+ return locales.every((locale) => typeof texts[locale] === "string" && texts[locale].trim())
6
+ ? "described"
7
+ : "missing";
8
+ }
1
9
  /** L'unico dominio su cui "Seleziona tutto" e la relativa checkbox possono ragionare. */
2
10
  export function mediaSelectableItems({ mode, prefix, shown, subfolders, }) {
3
11
  const visibleFolders = mode === "manage" && prefix !== ""
@@ -1,4 +1,4 @@
1
- import { api, type AssetFile, type AssetFolder } from "../../api.js";
1
+ import { api, type AssetAltText, type AssetFile, type AssetFolder } from "../../api.js";
2
2
  import type { MediaOperationStatus } from "../../media.js";
3
3
  import { type MediaUploadJob } from "./media-upload-queue.js";
4
4
  /** D1 registra l'intera cancellazione nel preflight: non esistono né il token Git né i
@@ -7,9 +7,12 @@ import { type MediaUploadJob } from "./media-upload-queue.js";
7
7
  export declare function deleteMediaKeys(keys: string[], remove: typeof api.remove, onProgress?: (done: number) => void): Promise<{
8
8
  done: number;
9
9
  refsToken?: string;
10
+ operationId?: string;
11
+ requiresPublication?: boolean;
10
12
  }>;
11
- export type MediaOperationState = "idle" | "planning" | "awaiting_confirmation" | "moving" | "deleting" | "renaming" | "creating_folder" | "journal";
13
+ export type MediaOperationState = "idle" | "planning" | "awaiting_confirmation" | "moving" | "deleting" | "renaming" | "creating_folder" | "saving_alt" | "journal";
12
14
  export type SortKey = "uploaded" | "capturedAt" | "name" | "size";
15
+ export type AltFilter = "all" | "missing" | "described" | "decorative";
13
16
  export declare const SORT_LABELS: Record<SortKey, string>;
14
17
  export interface MediaLibrary {
15
18
  mode: "manage" | "pick";
@@ -39,6 +42,9 @@ export interface MediaLibrary {
39
42
  } | null;
40
43
  sortKey: SortKey;
41
44
  sortDir: "asc" | "desc";
45
+ altFilter: AltFilter;
46
+ altLocales: readonly string[];
47
+ defaultAltLocale: string;
42
48
  uploadDraft: {
43
49
  files: File[];
44
50
  destination: string | null;
@@ -55,6 +61,7 @@ export interface MediaLibrary {
55
61
  setAll(on: boolean): void;
56
62
  setSort(k: SortKey): void;
57
63
  flipDir(): void;
64
+ setAltFilter(filter: AltFilter): void;
58
65
  /** Accoda lo STESSO lavoro usato da Media. `destination` è esplicita nel picker; in gestione
59
66
  * il ripiego resta la cartella aperta, che è il comportamento storico. */
60
67
  upload(files: File[] | FileList, destination?: string): Promise<string | null>;
@@ -63,6 +70,7 @@ export interface MediaLibrary {
63
70
  setUploadFiles(files: File[] | FileList): void;
64
71
  setUploadDestination(destination: string | null): void;
65
72
  setAutoWebp(on: boolean): Promise<void>;
73
+ saveAlt(key: string, alt: AssetAltText): Promise<boolean>;
66
74
  view(i: number | null): void;
67
75
  step(delta: number): void;
68
76
  /** File e cartelle insieme: una cartella si riconosce dalla chiave, che finisce con "/". */
@@ -76,6 +84,7 @@ export interface MediaLibrary {
76
84
  /** Cestina la cartella e tutto ciò che contiene. */
77
85
  removeFolder(prefix: string): Promise<void>;
78
86
  resumeMedia(operation: MediaOperationStatus): Promise<void>;
87
+ publishMedia(operation: MediaOperationStatus): Promise<void>;
79
88
  cancelMedia(operation: MediaOperationStatus): Promise<void>;
80
89
  /** Il nome da mostrare per un prefisso: l'etichetta se c'è, altrimenti lo slug. */
81
90
  labelOf(prefix: string): string;