@cardinali-studio/adminkit 2.2.21 → 2.2.23

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),
package/dist/app/app.css CHANGED
@@ -707,6 +707,14 @@ body { margin: 0; background: var(--ak-bg); color: var(--ak-ink); font-family: v
707
707
  overflow: hidden; padding-inline: 2px; color: #8a8a91; text-align: center;
708
708
  font: 400 11px/1.3 var(--ak-font); text-overflow: ellipsis; white-space: nowrap;
709
709
  }
710
+ .pe-photo-alt {
711
+ align-self: center; display: inline-flex; align-items: center; gap: 5px; min-height: 24px;
712
+ padding: 3px 8px; border: 0; border-radius: 999px; background: var(--ak-inset);
713
+ color: var(--ak-ink-quiet); cursor: pointer; font: 500 10px/1 var(--ak-font);
714
+ }
715
+ .pe-photo-alt strong { color: #a66a13; font: 700 9px/1 var(--ak-font-mono); letter-spacing: .04em; }
716
+ .pe-photo-alt.is-set strong { color: #28745a; }
717
+ .pe-photo-alt:hover, .pe-photo-alt:focus-visible { background: var(--ak-hover); color: var(--ak-ink); outline: 0; }
710
718
  .pe-photo-actions {
711
719
  position: absolute; z-index: 3; top: 0; left: 50%; transform: translate(-50%, -18%);
712
720
  display: flex; align-items: center; padding: 5px; border: 1px solid var(--ak-line);
@@ -744,6 +752,13 @@ body { margin: 0; background: var(--ak-bg); color: var(--ak-ink); font-family: v
744
752
  cursor: grabbing;
745
753
  }
746
754
  .pe-photo-overlay img { max-width: 100%; max-height: 100%; display: block; }
755
+ .pe-alt-modal { width: min(620px, 100%); }
756
+ .pe-alt-preview {
757
+ min-width: 0; display: grid; grid-template-columns: 92px minmax(0,1fr); gap: 14px; align-items: center;
758
+ padding: 10px; border-radius: 8px; background: var(--ak-inset);
759
+ }
760
+ .pe-alt-preview img { width: 92px; height: 72px; display: block; object-fit: contain; border-radius: 5px; background: var(--ak-bg); }
761
+ .pe-alt-preview span { overflow: hidden; color: var(--ak-ink-quiet); font: 500 12px/1.3 var(--ak-font); text-overflow: ellipsis; white-space: nowrap; }
747
762
 
748
763
  /* Il masonry verticale usa il flusso CSS a colonne: riempie una colonna dall'alto verso il
749
764
  basso prima di passare alla successiva. `.is-masonry` resta come alias visuale per markup
package/dist/app/next.css CHANGED
@@ -707,6 +707,7 @@
707
707
  border-radius: 10px;
708
708
  box-shadow: var(--ak-lift-3);
709
709
  }
710
+ :root[data-admin-interface="next"] .pe-photo-alt { border-radius: 999px; }
710
711
  /* Tutte le azioni primarie usano la coppia di colori del tema. Alcuni componenti storici
711
712
  * fissavano il testo a bianco: in dark l'accento diventa chiaro e richiede invece inchiostro
712
713
  * scuro, come il pulsante di pubblicazione. */
@@ -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" })] })] })] })] }))] }));
@@ -1,6 +1,9 @@
1
1
  import type { PickerOptions } from "../../form/MediaPicker.js";
2
+ import type { GalleryImage } from "../../index.js";
2
3
  import type { AssetFile } from "../../index.js";
3
4
  import type { PageModel } from "../lib/pages";
5
+ import { type GalleryLayout } from "../lib/gallery-layout";
6
+ type CuratedItem = GalleryImage;
4
7
  export declare function PageHeader({ page, pages, onChange, seoActive, onSeoToggle, languages, activeLanguage, onLanguageChange, }: {
5
8
  page: PageModel;
6
9
  pages: PageModel[];
@@ -15,6 +18,12 @@ export declare function PageHeader({ page, pages, onChange, seoActive, onSeoTogg
15
18
  activeLanguage?: string;
16
19
  onLanguageChange?: (language: string) => void;
17
20
  }): import("react").JSX.Element;
21
+ export declare function GalleryEditor({ value, onChange, pickMediaFiles, layout, }: {
22
+ value: CuratedItem[];
23
+ onChange: (items: CuratedItem[]) => void;
24
+ pickMediaFiles?: (o: PickerOptions) => Promise<AssetFile[] | null>;
25
+ layout?: GalleryLayout;
26
+ }): import("react").JSX.Element;
18
27
  export declare function PageEditor({ page, onChange, onRefresh, pristine, pickMedia, pickMediaFiles, activeLanguage, defaultLanguage, }: {
19
28
  page: PageModel;
20
29
  onChange: (next: PageModel) => void;
@@ -30,3 +39,4 @@ export declare function PageEditor({ page, onChange, onRefresh, pristine, pickMe
30
39
  activeLanguage?: string;
31
40
  defaultLanguage?: string;
32
41
  }): import("react").JSX.Element;
42
+ export {};
@@ -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";
@@ -101,7 +101,7 @@ function imageName(src) {
101
101
  return raw;
102
102
  }
103
103
  }
104
- function GalleryCard({ item, verticalMasonry = false, sortable = true, activeId, overId, activeIndex, overIndex, onReplace, onToggle, onRemove, }) {
104
+ function GalleryCard({ item, verticalMasonry = false, sortable = true, activeId, overId, activeIndex, overIndex, onReplace, onEditAlt, onToggle, onRemove, }) {
105
105
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
106
106
  id: item.src,
107
107
  disabled: !sortable,
@@ -119,9 +119,9 @@ function GalleryCard({ item, verticalMasonry = false, sortable = true, activeId,
119
119
  isDragging ? "is-dragging" : "",
120
120
  insertBefore ? "is-insert-before" : "",
121
121
  insertAfter ? "is-insert-after" : "",
122
- ].filter(Boolean).join(" "), style: sortable ? { transform: CSS.Transform.toString(transform), transition } : undefined, children: [_jsxs("div", { className: "pe-photo-frame", style: frameStyle, ...(sortable ? attributes : {}), ...(sortable ? listeners : {}), children: [_jsx("img", { src: item.src, alt: imageName(item.src), draggable: false }), item.hidden && _jsx("span", { className: "pe-hidden-label", children: "Nascosta" })] }), _jsxs("div", { className: "pe-photo-actions", children: [_jsx("button", { type: "button", onClick: onReplace, title: "Cambia immagine", "aria-label": "Cambia immagine", children: _jsx(Icon.replace, {}) }), _jsx("button", { type: "button", onClick: onToggle, title: item.hidden ? "Rimetti online" : "Nascondi dalla gallery", "aria-label": item.hidden ? "Rimetti online" : "Nascondi dalla gallery", children: item.hidden ? _jsx(Icon.eye, {}) : _jsx(Icon.eyeOff, {}) }), _jsx("button", { type: "button", className: "is-danger", onClick: onRemove, title: "Rimuovi dalla gallery", "aria-label": "Rimuovi dalla gallery", children: _jsx(Icon.trash, {}) })] }), _jsx("figcaption", { title: imageName(item.src), children: imageName(item.src) })] }));
122
+ ].filter(Boolean).join(" "), style: sortable ? { transform: CSS.Transform.toString(transform), transition } : undefined, children: [_jsxs("div", { className: "pe-photo-frame", style: frameStyle, ...(sortable ? attributes : {}), ...(sortable ? listeners : {}), children: [_jsx("img", { src: item.src, alt: imageName(item.src), draggable: false }), item.hidden && _jsx("span", { className: "pe-hidden-label", children: "Nascosta" })] }), _jsxs("div", { className: "pe-photo-actions", children: [_jsx("button", { type: "button", onClick: onReplace, title: "Cambia immagine", "aria-label": "Cambia immagine", children: _jsx(Icon.replace, {}) }), _jsx("button", { type: "button", onClick: onToggle, title: item.hidden ? "Rimetti online" : "Nascondi dalla gallery", "aria-label": item.hidden ? "Rimetti online" : "Nascondi dalla gallery", children: item.hidden ? _jsx(Icon.eye, {}) : _jsx(Icon.eyeOff, {}) }), _jsx("button", { type: "button", className: "is-danger", onClick: onRemove, title: "Rimuovi dalla gallery", "aria-label": "Rimuovi dalla gallery", children: _jsx(Icon.trash, {}) })] }), _jsx("figcaption", { title: imageName(item.src), children: imageName(item.src) }), _jsxs("button", { type: "button", className: `pe-photo-alt${item.alt?.trim() ? " is-set" : ""}`, onClick: onEditAlt, "aria-label": `Modifica testo alternativo di ${imageName(item.src)}`, children: [_jsx("strong", { children: "ALT" }), _jsx("span", { children: item.alt?.trim() ? "Modifica" : "Vuoto" })] })] }));
123
123
  }
124
- function GalleryEditor({ value, onChange, pickMediaFiles, layout = "grid", }) {
124
+ export function GalleryEditor({ value, onChange, pickMediaFiles, layout = "grid", }) {
125
125
  const list = Array.isArray(value) ? value : [];
126
126
  const masonry = layout !== "grid";
127
127
  const verticalMasonry = layout === "masonry-vertical";
@@ -129,6 +129,7 @@ function GalleryEditor({ value, onChange, pickMediaFiles, layout = "grid", }) {
129
129
  const hiddenItems = masonry ? list.filter((item) => item.hidden) : [];
130
130
  const [activeId, setActiveId] = useState(null);
131
131
  const [overId, setOverId] = useState(null);
132
+ const [editingAlt, setEditingAlt] = useState(null);
132
133
  const [addPositionOpen, setAddPositionOpen] = useState(false);
133
134
  const addPositionRef = useRef(null);
134
135
  const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
@@ -165,9 +166,15 @@ function GalleryEditor({ value, onChange, pickMediaFiles, layout = "grid", }) {
165
166
  if (!picked?.length)
166
167
  return;
167
168
  const have = new Set(list.map((item) => item.src));
169
+ const locale = site().languages?.default ?? "default";
168
170
  const additions = picked
169
171
  .filter((file) => !have.has(file.url))
170
- .map((file) => ({ src: file.url, w: file.w ?? null, h: file.h ?? null }));
172
+ .map((file) => ({
173
+ src: file.url,
174
+ w: file.w ?? null,
175
+ h: file.h ?? null,
176
+ alt: assetAltText(file, locale),
177
+ }));
171
178
  if (additions.length)
172
179
  onChange(position === "start" ? [...additions, ...list] : [...list, ...additions]);
173
180
  };
@@ -180,8 +187,9 @@ function GalleryEditor({ value, onChange, pickMediaFiles, layout = "grid", }) {
180
187
  alert("Questa foto è già presente nella gallery.");
181
188
  return;
182
189
  }
190
+ const locale = site().languages?.default ?? "default";
183
191
  onChange(list.map((item, itemIndex) => itemIndex === index
184
- ? { ...item, src: file.url, w: file.w ?? null, h: file.h ?? null }
192
+ ? { ...item, src: file.url, w: file.w ?? null, h: file.h ?? null, alt: assetAltText(file, locale) }
185
193
  : item));
186
194
  };
187
195
  const finishDrag = ({ active, over }) => {
@@ -208,7 +216,22 @@ function GalleryEditor({ value, onChange, pickMediaFiles, layout = "grid", }) {
208
216
  const remove = (src) => onChange(list.filter((item) => item.src !== src));
209
217
  return (_jsxs("section", { className: `pe-gallery${masonry ? ` is-${layout}` : ""}`, "aria-label": "Foto della gallery", children: [_jsxs("div", { className: "pe-gallery-bar", children: [_jsxs("div", { className: "pe-gallery-summary", children: [_jsxs("strong", { children: [list.length, " ", list.length === 1 ? "foto" : "foto"] }), _jsx("span", { children: list.length === 0
210
218
  ? "Scegli le immagini da mostrare nella pagina."
211
- : `${visible} online${visible < list.length ? ` · ${list.length - visible} nascoste` : ""}` }), layout === "masonry-vertical" && list.length > 0 && (_jsx("span", { className: "pe-gallery-layout-note", children: "Masonry verticale \u00B7 3 colonne \u00B7 ordine dall\u2019alto verso il basso" })), layout === "masonry-horizontal" && list.length > 0 && (_jsx("span", { className: "pe-gallery-layout-note", children: "Masonry orizzontale \u00B7 6 colonne \u00B7 ordine da sinistra a destra" }))] }), list.length === 0 ? (_jsxs("button", { type: "button", className: "pe-add", onClick: () => void add("start"), children: ["Aggiungi foto ", _jsx(Icon.plus, {})] })) : (_jsxs("div", { className: "pe-add-group", ref: addPositionRef, children: [_jsxs("button", { type: "button", className: "pe-add", onClick: () => void add("start"), children: ["Aggiungi foto ", _jsx(Icon.plus, {})] }), _jsx("button", { type: "button", className: "pe-add-position", "aria-label": "Scegli dove aggiungere le foto", "aria-expanded": addPositionOpen, "aria-controls": "gallery-add-position", onClick: () => setAddPositionOpen((open) => !open), children: "\u2304" }), addPositionOpen && (_jsx("div", { className: "pe-add-menu", id: "gallery-add-position", children: _jsx("button", { type: "button", onClick: () => void add("end"), children: "Aggiungi in fondo" }) }))] }))] }), list.length === 0 ? (_jsxs("button", { type: "button", className: "pe-gallery-empty", onClick: () => void add("start"), children: [_jsx(Icon.gallery, {}), _jsx("strong", { children: "La gallery \u00E8 vuota" }), _jsx("span", { children: "Aggiungi le prime fotografie" })] })) : (_jsxs(DndContext, { sensors: sensors, collisionDetection: closestCenter, onDragStart: ({ active }) => setActiveId(String(active.id)), onDragOver: ({ over }) => setOverId(over ? String(over.id) : null), onDragCancel: () => { setActiveId(null); setOverId(null); }, onDragEnd: finishDrag, children: [_jsx(SortableContext, { items: shownItems.map((item) => item.src), strategy: rectSortingStrategy, children: _jsx("div", { className: "pe-photo-grid", children: shownItems.map((item) => (_jsx(GalleryCard, { item: item, verticalMasonry: verticalMasonry, activeId: activeId, overId: overId, activeIndex: activeIndex, overIndex: overIndex, onReplace: () => void replace(itemIndex(item.src)), onToggle: () => toggle(item.src), onRemove: () => remove(item.src) }, item.src))) }) }), _jsx(DragOverlay, { children: activeItem && (_jsx("div", { className: "pe-photo-overlay", children: _jsx("img", { src: activeItem.src, alt: "" }) })) }), masonry && hiddenItems.length > 0 && (_jsxs("section", { className: "pe-hidden-gallery", "aria-label": "Foto nascoste", children: [_jsxs("div", { className: "pe-hidden-gallery-head", children: [_jsx("strong", { children: "Nascoste" }), _jsx("span", { children: "Non occupano spazio nella gallery pubblica." })] }), _jsx("div", { className: "pe-hidden-grid", children: hiddenItems.map((item) => (_jsx(GalleryCard, { item: item, sortable: false, activeId: activeId, overId: overId, activeIndex: activeIndex, overIndex: overIndex, onReplace: () => void replace(itemIndex(item.src)), onToggle: () => toggle(item.src), onRemove: () => remove(item.src) }, item.src))) })] }))] }))] }));
219
+ : `${visible} online${visible < list.length ? ` · ${list.length - visible} nascoste` : ""}` }), layout === "masonry-vertical" && list.length > 0 && (_jsx("span", { className: "pe-gallery-layout-note", children: "Masonry verticale \u00B7 3 colonne \u00B7 ordine dall\u2019alto verso il basso" })), layout === "masonry-horizontal" && list.length > 0 && (_jsx("span", { className: "pe-gallery-layout-note", children: "Masonry orizzontale \u00B7 6 colonne \u00B7 ordine da sinistra a destra" }))] }), list.length === 0 ? (_jsxs("button", { type: "button", className: "pe-add", onClick: () => void add("start"), children: ["Aggiungi foto ", _jsx(Icon.plus, {})] })) : (_jsxs("div", { className: "pe-add-group", ref: addPositionRef, children: [_jsxs("button", { type: "button", className: "pe-add", onClick: () => void add("start"), children: ["Aggiungi foto ", _jsx(Icon.plus, {})] }), _jsx("button", { type: "button", className: "pe-add-position", "aria-label": "Scegli dove aggiungere le foto", "aria-expanded": addPositionOpen, "aria-controls": "gallery-add-position", onClick: () => setAddPositionOpen((open) => !open), children: "\u2304" }), addPositionOpen && (_jsx("div", { className: "pe-add-menu", id: "gallery-add-position", children: _jsx("button", { type: "button", onClick: () => void add("end"), children: "Aggiungi in fondo" }) }))] }))] }), list.length === 0 ? (_jsxs("button", { type: "button", className: "pe-gallery-empty", onClick: () => void add("start"), children: [_jsx(Icon.gallery, {}), _jsx("strong", { children: "La gallery \u00E8 vuota" }), _jsx("span", { children: "Aggiungi le prime fotografie" })] })) : (_jsxs(DndContext, { sensors: sensors, collisionDetection: closestCenter, onDragStart: ({ active }) => setActiveId(String(active.id)), onDragOver: ({ over }) => setOverId(over ? String(over.id) : null), onDragCancel: () => { setActiveId(null); setOverId(null); }, onDragEnd: finishDrag, children: [_jsx(SortableContext, { items: shownItems.map((item) => item.src), strategy: rectSortingStrategy, children: _jsx("div", { className: "pe-photo-grid", children: shownItems.map((item) => (_jsx(GalleryCard, { item: item, verticalMasonry: verticalMasonry, activeId: activeId, overId: overId, activeIndex: activeIndex, overIndex: overIndex, onReplace: () => void replace(itemIndex(item.src)), onEditAlt: () => setEditingAlt({ src: item.src, value: item.alt ?? "" }), onToggle: () => toggle(item.src), onRemove: () => remove(item.src) }, item.src))) }) }), _jsx(DragOverlay, { children: activeItem && (_jsx("div", { className: "pe-photo-overlay", children: _jsx("img", { src: activeItem.src, alt: "" }) })) }), masonry && hiddenItems.length > 0 && (_jsxs("section", { className: "pe-hidden-gallery", "aria-label": "Foto nascoste", children: [_jsxs("div", { className: "pe-hidden-gallery-head", children: [_jsx("strong", { children: "Nascoste" }), _jsx("span", { children: "Non occupano spazio nella gallery pubblica." })] }), _jsx("div", { className: "pe-hidden-grid", children: hiddenItems.map((item) => (_jsx(GalleryCard, { item: item, sortable: false, activeId: activeId, overId: overId, activeIndex: activeIndex, overIndex: overIndex, onReplace: () => void replace(itemIndex(item.src)), onEditAlt: () => setEditingAlt({ src: item.src, value: item.alt ?? "" }), onToggle: () => toggle(item.src), onRemove: () => remove(item.src) }, item.src))) })] }))] })), editingAlt && (_jsx("div", { className: "pe-modal", role: "presentation", onMouseDown: (event) => {
220
+ if (event.target === event.currentTarget)
221
+ setEditingAlt(null);
222
+ }, children: _jsxs("div", { className: "pe-modal-card pe-alt-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": "pe-alt-dialog", children: [_jsxs("header", { children: [_jsx("h2", { id: "pe-alt-dialog", children: "Testo alternativo" }), _jsx("button", { type: "button", onClick: () => setEditingAlt(null), "aria-label": "Chiudi", children: "\u00D7" })] }), _jsxs("div", { className: "pe-modal-fields", children: [_jsxs("div", { className: "pe-alt-preview", children: [_jsx("img", { src: editingAlt.src, alt: "" }), _jsx("span", { children: imageName(editingAlt.src) })] }), _jsxs("label", { children: [_jsx("span", { children: "Descrizione dell\u2019immagine" }), _jsx("input", { autoFocus: true, value: editingAlt.value, maxLength: 300, placeholder: "Es. Chef che impiatta un dessert al cioccolato", onChange: (event) => setEditingAlt({ ...editingAlt, value: event.target.value }), onKeyDown: (event) => {
223
+ if (event.key !== "Enter")
224
+ return;
225
+ onChange(list.map((item) => item.src === editingAlt.src
226
+ ? { ...item, alt: editingAlt.value.trim() }
227
+ : item));
228
+ setEditingAlt(null);
229
+ } }), _jsx("small", { children: "Descrivi ci\u00F2 che \u00E8 utile nel contesto della pagina. Lascialo vuoto soltanto se la foto \u00E8 decorativa." })] })] }), _jsxs("footer", { children: [_jsx("button", { type: "button", className: "ak-btn", onClick: () => setEditingAlt(null), children: "Annulla" }), _jsx("button", { type: "button", className: "ak-btn ak-btn-primary", onClick: () => {
230
+ onChange(list.map((item) => item.src === editingAlt.src
231
+ ? { ...item, alt: editingAlt.value.trim() }
232
+ : item));
233
+ setEditingAlt(null);
234
+ }, children: "Applica" })] })] }) }))] }));
212
235
  }
213
236
  export function PageEditor({ page, onChange, onRefresh, pristine, pickMedia, pickMediaFiles, activeLanguage, defaultLanguage, }) {
214
237
  const [bodyState, setBodyState] = useState("idle");
@@ -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;