@elytracms/studio 0.0.57 → 0.0.59

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.
Files changed (109) hide show
  1. package/dist/components/ai/ai-chat-panel.js +44 -6
  2. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  3. package/dist/components/ai/chat-ui.d.ts +1 -1
  4. package/dist/components/ai/chat-ui.js +11 -2
  5. package/dist/components/ai/chat-ui.js.map +1 -1
  6. package/dist/components/ai/proposal-card.d.ts +30 -7
  7. package/dist/components/ai/proposal-card.js +81 -21
  8. package/dist/components/ai/proposal-card.js.map +1 -1
  9. package/dist/components/builder/blocks-model.js +12 -7
  10. package/dist/components/builder/blocks-model.js.map +1 -1
  11. package/dist/components/builder/canvas-dnd.d.ts +59 -0
  12. package/dist/components/builder/canvas-dnd.js +207 -0
  13. package/dist/components/builder/canvas-dnd.js.map +1 -0
  14. package/dist/components/builder/canvas-dom.d.ts +11 -0
  15. package/dist/components/builder/canvas-dom.js +17 -0
  16. package/dist/components/builder/canvas-dom.js.map +1 -0
  17. package/dist/components/builder/canvas-inline-editing.d.ts +91 -0
  18. package/dist/components/builder/canvas-inline-editing.js +242 -0
  19. package/dist/components/builder/canvas-inline-editing.js.map +1 -0
  20. package/dist/components/builder/canvas-render-context.d.ts +83 -0
  21. package/dist/components/builder/canvas-render-context.js +196 -0
  22. package/dist/components/builder/canvas-render-context.js.map +1 -0
  23. package/dist/components/builder/canvas.d.ts +4 -1
  24. package/dist/components/builder/canvas.js +32 -567
  25. package/dist/components/builder/canvas.js.map +1 -1
  26. package/dist/components/builder/composition-field-editor.js +14 -4
  27. package/dist/components/builder/composition-field-editor.js.map +1 -1
  28. package/dist/components/builder/insert-context.d.ts +5 -2
  29. package/dist/components/builder/insert-context.js +1 -3
  30. package/dist/components/builder/insert-context.js.map +1 -1
  31. package/dist/components/builder/node-factory.d.ts +12 -0
  32. package/dist/components/builder/node-factory.js +18 -1
  33. package/dist/components/builder/node-factory.js.map +1 -1
  34. package/dist/components/cms/asset-field.d.ts +22 -0
  35. package/dist/components/cms/asset-field.js +300 -0
  36. package/dist/components/cms/asset-field.js.map +1 -0
  37. package/dist/components/cms/detail-editor.js +12 -0
  38. package/dist/components/cms/detail-editor.js.map +1 -1
  39. package/dist/components/cms/document-diff-view.d.ts +12 -1
  40. package/dist/components/cms/document-diff-view.js +119 -42
  41. package/dist/components/cms/document-diff-view.js.map +1 -1
  42. package/dist/components/cms/field-control-lib.d.ts +43 -0
  43. package/dist/components/cms/field-control-lib.js +98 -0
  44. package/dist/components/cms/field-control-lib.js.map +1 -0
  45. package/dist/components/cms/field-control.d.ts +7 -35
  46. package/dist/components/cms/field-control.js +13 -556
  47. package/dist/components/cms/field-control.js.map +1 -1
  48. package/dist/components/cms/object-array-field.d.ts +29 -0
  49. package/dist/components/cms/object-array-field.js +205 -0
  50. package/dist/components/cms/object-array-field.js.map +1 -0
  51. package/dist/components/cms/rich-text-preview.d.ts +15 -0
  52. package/dist/components/cms/rich-text-preview.js +81 -0
  53. package/dist/components/cms/rich-text-preview.js.map +1 -0
  54. package/dist/components/cms/version-compare-text.d.ts +26 -0
  55. package/dist/components/cms/version-compare-text.js +60 -0
  56. package/dist/components/cms/version-compare-text.js.map +1 -1
  57. package/dist/components/project-session.d.ts +19 -0
  58. package/dist/components/project-session.js +29 -2
  59. package/dist/components/project-session.js.map +1 -1
  60. package/dist/components/use-hydration-phase.d.ts +6 -4
  61. package/dist/components/use-hydration-phase.js.map +1 -1
  62. package/dist/lib/ai/apply-content.js +52 -10
  63. package/dist/lib/ai/apply-content.js.map +1 -1
  64. package/dist/lib/ai/chat-context.d.ts +19 -3
  65. package/dist/lib/ai/chat-context.js +33 -4
  66. package/dist/lib/ai/chat-context.js.map +1 -1
  67. package/dist/lib/ai/chat-state.d.ts +6 -3
  68. package/dist/lib/ai/chat-state.js.map +1 -1
  69. package/dist/lib/ai/fake-provider.d.ts +60 -5
  70. package/dist/lib/ai/fake-provider.js +74 -6
  71. package/dist/lib/ai/fake-provider.js.map +1 -1
  72. package/dist/lib/ai/index.d.ts +2 -0
  73. package/dist/lib/ai/index.js +2 -0
  74. package/dist/lib/ai/index.js.map +1 -1
  75. package/dist/lib/ai/rich-text-input.d.ts +27 -0
  76. package/dist/lib/ai/rich-text-input.js +25 -0
  77. package/dist/lib/ai/rich-text-input.js.map +1 -0
  78. package/dist/lib/ai/shape.d.ts +12 -1
  79. package/dist/lib/ai/shape.js +46 -20
  80. package/dist/lib/ai/shape.js.map +1 -1
  81. package/dist/lib/ai/tools.d.ts +88 -13
  82. package/dist/lib/ai/tools.js +253 -14
  83. package/dist/lib/ai/tools.js.map +1 -1
  84. package/dist/lib/ai/types.d.ts +20 -0
  85. package/dist/lib/ai/types.js.map +1 -1
  86. package/dist/lib/ai/update-fields.d.ts +68 -0
  87. package/dist/lib/ai/update-fields.js +296 -0
  88. package/dist/lib/ai/update-fields.js.map +1 -0
  89. package/dist/lib/ai-current-document.d.ts +53 -0
  90. package/dist/lib/ai-current-document.js +54 -0
  91. package/dist/lib/ai-current-document.js.map +1 -0
  92. package/dist/lib/cms-views/view-model.d.ts +6 -6
  93. package/dist/lib/data/collection-content.d.ts +12 -7
  94. package/dist/lib/data/collection-content.js +6 -4
  95. package/dist/lib/data/collection-content.js.map +1 -1
  96. package/dist/lib/i18n/messages.en.js +10 -1
  97. package/dist/lib/i18n/messages.en.js.map +1 -1
  98. package/dist/lib/operations/session-hydration.d.ts +4 -1
  99. package/dist/lib/operations/session-hydration.js +4 -1
  100. package/dist/lib/operations/session-hydration.js.map +1 -1
  101. package/dist/lib/persistence/index.js +13 -1
  102. package/dist/lib/persistence/index.js.map +1 -1
  103. package/dist/lib/workspace/index.d.ts +1 -1
  104. package/dist/lib/workspace/index.js +1 -1
  105. package/dist/lib/workspace/index.js.map +1 -1
  106. package/dist/lib/workspace/workspace-source.d.ts +17 -22
  107. package/dist/lib/workspace/workspace-source.js +1 -49
  108. package/dist/lib/workspace/workspace-source.js.map +1 -1
  109. package/package.json +2 -2
@@ -1,27 +1,22 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { AlertTriangle, ChevronDown, ChevronUp, GripVertical, ImagePlus, Link2, MoreHorizontal, Pencil, Plus, RotateCcw, RotateCw, SlidersHorizontal, Trash2, Upload, X } from 'lucide-react';
3
- import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { AlertTriangle, Link2, Pencil, Plus, RotateCw, X } from 'lucide-react';
3
+ import { useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { Link, STUDIO_PROJECT_ROUTE, studioPath } from '@elytracms/studio/router';
5
- import { BUILTIN_FIELD_CONTROLS, applySlugify, assetAcceptAttr, assetMatchesAccept, assetNode, assetSubfieldsOf, describeAccept, descendantIds, findSlugCollision, isFieldHidden, parentRelationField, readAssetId, resolveSlugPath, resolveUrlSegments, slugSourceValue, } from '@elytracms/core/cms-core';
5
+ import { BUILTIN_FIELD_CONTROLS, applySlugify, descendantIds, findSlugCollision, parentRelationField, resolveSlugPath, resolveUrlSegments, slugSourceValue, } from '@elytracms/core/cms-core';
6
6
  import { resolveDeploymentRoutes } from '@elytracms/studio/lib/workspace';
7
- import { Button, Card, Code, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Field, Input, Row, RowItem, Select, Spacer, Stack, Text, } from '@elytracms/studio/ui';
7
+ import { Button, Input, Row, RowItem, Select, Stack, Text, } from '@elytracms/studio/ui';
8
8
  import { cn } from '@elytracms/studio/lib/utils';
9
9
  import { localizedLabel, translate } from '@elytracms/studio/lib/i18n';
10
10
  import { activeLocaleValues } from '@elytracms/studio/lib/locale';
11
- import { applyAssetSelection, assetIdsOf, singleAssetIdOf } from '../assets/asset-selection.js';
12
- import { useOptionalAssets } from '../assets/assets-store.js';
13
- import { AssetThumb, FocalCropPreviews, FocalPointPicker, useAssetRecord } from '../assets/asset-thumb.js';
14
- import { newlyAddedAssetIds } from '../assets/focal-point.js';
15
- import { assetMetaFieldsOf, pruneMeta } from '../assets/asset-metadata.js';
16
- import { fieldLabel } from './cms-labels.js';
17
11
  import { useCollectionView } from '../collection-view/collection-view-context.js';
18
- import { useFieldDensity } from './field-density.js';
19
12
  import { useOptionalDocumentEditor } from './document-editor-context.js';
20
13
  import { toRefArray } from './cell.js';
21
14
  import { RelationPicker } from './relation-picker.js';
22
- import { AssetPicker } from './asset-picker.js';
23
15
  import { RichTextEditor } from './rich-text/index.js';
24
16
  import { DocumentUrlSegment } from './document-url-path.js';
17
+ import { AssetField } from './asset-field.js';
18
+ import { SegmentedOptions } from './field-control-lib.js';
19
+ import { ObjectField } from './object-array-field.js';
25
20
  import { FieldRendererBoundary, FieldRendererIssue, useFieldRenderers, } from './field-renderer.js';
26
21
  /**
27
22
  * The document-editor field control. Resolves a project's custom renderer by the
@@ -32,6 +27,12 @@ import { FieldRendererBoundary, FieldRendererIssue, useFieldRenderers, } from '.
32
27
  * naming a control with NO renderer and NO built-in name (a typo) shows the
33
28
  * built-in with a loud issue — never a silent fall-through; otherwise the built-in
34
29
  * renders alone.
30
+ *
31
+ * EC-330 split: the asset-field family lives in `asset-field.tsx`, the object /
32
+ * repeater editors in `object-array-field.tsx`, and their pure companions
33
+ * (reorder/summary/depth/table decisions, the `options` segmented control) in
34
+ * `field-control-lib.tsx` — this file keeps the dispatcher plus the slug and
35
+ * relation controls.
35
36
  */
36
37
  export function FieldControl(props) {
37
38
  const renderers = useFieldRenderers();
@@ -107,21 +108,6 @@ function BuiltInFieldControl({ field, value, onChange, readOnly, id }) {
107
108
  return (_jsx(Input, { id: id, disabled: readOnly, value: typeof value === 'string' ? value : '', onChange: (e) => onChange(e.target.value || undefined) }));
108
109
  }
109
110
  }
110
- /**
111
- * EC-295: the `options` control — a `select` shown as inline segmented buttons
112
- * (`[[Page]] [URL] [File]`) instead of a dropdown. Engine built-in (no consumer
113
- * registration); single-select only. Each option shows `label ?? value` and a
114
- * `title` tooltip from the option's `description`; the field `default` + `readOnly`
115
- * flow through the shared form. Picks one — to allow an empty/unset value use the
116
- * default dropdown control.
117
- */
118
- export function SegmentedOptions({ field, value, onChange, readOnly, id, }) {
119
- const current = typeof value === 'string' ? value : '';
120
- return (_jsx(Row, { gap: "1", wrap: true, role: "radiogroup", id: id, "aria-label": fieldLabel(field), children: field.options.map((opt) => {
121
- const active = current === opt.value;
122
- return (_jsx(Button, { type: "button", variant: active ? 'default' : 'outline', size: "sm", role: "radio", "aria-checked": active, title: localizedLabel(opt.description) || undefined, disabled: readOnly, onClick: () => onChange(opt.value), children: localizedLabel(opt.label, opt.value) }, opt.value));
123
- }) }));
124
- }
125
111
  /**
126
112
  * EC-315 — the smart slug / URL-segment control. A `text` field named `slug` (or any
127
113
  * `text` field with `form.control: 'slug'`) renders here instead of a bare input:
@@ -265,261 +251,6 @@ function SlugField({ field, value, onChange, readOnly, id, }) {
265
251
  (contextSegments.length > 0 ? `/${contextSegments.map((s) => s.text).join('/')}/` : '/');
266
252
  return (_jsxs(Stack, { gap: "2", children: [parts ? (_jsxs(Row, { gap: "1", align: "center", "data-testid": "slug-composed-path", children: [_jsx("span", { className: "inline-flex min-w-0 shrink-0 items-center rounded bg-muted/40 px-1.5 py-1 font-mono text-xs text-muted-foreground ring-1 ring-inset ring-border", title: prefixTitle, "data-testid": "slug-prefix", children: contextSegments.length === 0 ? (_jsx("span", { "aria-hidden": true, className: "text-muted-foreground/50", children: "/" })) : (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, className: "text-muted-foreground/50", children: contextSegments.length > 1 ? '…/' : '/' }), lastContext ? (_jsx(DocumentUrlSegment, { segment: lastContext, projectId: editor?.projectId, collectionId: source.collectionId })) : null, _jsx("span", { "aria-hidden": true, className: "text-muted-foreground/50", children: "/" })] })) }), input, regenerateButton] })) : (_jsxs(Row, { gap: "1", align: "center", children: [input, regenerateButton] })), collisionMessage ? (_jsxs(Row, { gap: "1", align: "center", "data-testid": "slug-collision", children: [_jsx(AlertTriangle, { className: "size-3.5 shrink-0 text-warning", "aria-hidden": true }), _jsx(Text, { size: "xs", tone: "muted", children: collisionMessage })] })) : null] }));
267
253
  }
268
- function asRecord(value) {
269
- return value !== null && typeof value === 'object' && !Array.isArray(value)
270
- ? value
271
- : {};
272
- }
273
- /**
274
- * Move `from` → `to`, returning a NEW array (all elements preserved). The repeater
275
- * applies the same `(from, to)` to its value array AND its parallel stable-key array,
276
- * so the two stay aligned through a reorder — a row's child/DOM state (an open
277
- * relation/asset picker, focus) never mis-associates (EC-253 C2, EC-294).
278
- */
279
- export function reorder(list, from, to) {
280
- const next = list.slice();
281
- const [moved] = next.splice(from, 1);
282
- if (moved !== undefined)
283
- next.splice(to, 0, moved);
284
- return next;
285
- }
286
- const SUMMARY_PREFERRED_FIELDS = ['title', 'label', 'name', 'heading', 'text', 'linkType'];
287
- /**
288
- * A scannable one-line summary for a collapsed repeater item (EC-294): the first
289
- * meaningful scalar subfield value — preferring title-ish field names — else `#n`.
290
- * Pure (no store): summarises from the item's own scalar values, so it works for
291
- * any object shape without resolving relations/assets.
292
- */
293
- export function objectItemSummary(fields, item, index) {
294
- const scalar = (name) => {
295
- const v = item[name];
296
- if (typeof v === 'string' && v.trim())
297
- return v.trim();
298
- if (typeof v === 'number' || typeof v === 'boolean')
299
- return String(v);
300
- return null;
301
- };
302
- const names = fields.map((f) => f.name);
303
- for (const name of SUMMARY_PREFERRED_FIELDS) {
304
- if (names.includes(name)) {
305
- const s = scalar(name);
306
- if (s)
307
- return truncateSummary(s);
308
- }
309
- }
310
- for (const name of names) {
311
- const s = scalar(name);
312
- if (s)
313
- return truncateSummary(s);
314
- }
315
- return `#${index + 1}`;
316
- }
317
- function truncateSummary(s, max = 60) {
318
- return s.length > max ? `${s.slice(0, max - 1)}…` : s;
319
- }
320
- // EC-305: nested-array depth, propagated via context so each array level can encode
321
- // its depth (rail colour + tint) without prop-drilling through FieldControl's many
322
- // branches. The detail editor renders top-level fields with the default depth 0.
323
- const ArrayDepthContext = createContext(0);
324
- const useArrayDepth = () => useContext(ArrayDepthContext);
325
- // (B) Depth-keyed visuals: a coloured left rail + a matching faint tint, DISTINCT
326
- // per level so nesting reads at a glance. The outermost array (depth 0) starts at
327
- // the brand primary — no more neutral, un-encoded first level — then a fixed
328
- // high-contrast rotation whose adjacent hues never collide (primary→amber→green→red;
329
- // note `info` is a near-primary blue, so it is deliberately NOT adjacent to primary).
330
- // It cycles (mod) so pathological depth still renders. Literal classes so Tailwind emits them.
331
- const DEPTH_RAIL = ['border-l-primary', 'border-l-warning', 'border-l-success', 'border-l-destructive'];
332
- const DEPTH_TINT = ['bg-primary/5', 'bg-warning/5', 'bg-success/5', 'bg-destructive/5'];
333
- // `d` is always ≥ 0, so `d % length` is in-bounds — the assertion is sound and gives
334
- // callers a definite `string` (cn tolerates undefined, but tests read the value).
335
- export const depthRail = (d) => DEPTH_RAIL[d % DEPTH_RAIL.length];
336
- export const depthTint = (d) => DEPTH_TINT[d % DEPTH_TINT.length];
337
- // (C) An object array renders as a compact TABLE when every subfield is a scalar (a
338
- // flat row) and there are few enough columns to read; richer/nested object types stay
339
- // as cards with depth encoding (B). Conditional (function-hidden) subfields force the
340
- // card path — a table has no place to show/hide a column per row.
341
- const TABULAR_FIELD_TYPES = new Set(['text', 'number', 'boolean', 'select', 'date']);
342
- const MAX_TABLE_COLUMNS = 4;
343
- export function isTabularObjectType(field) {
344
- return (field.fields.length > 0 &&
345
- field.fields.length <= MAX_TABLE_COLUMNS &&
346
- field.fields.every((f) => TABULAR_FIELD_TYPES.has(f.type) && typeof f.form?.hidden !== 'function'));
347
- }
348
- /**
349
- * Shared array state for an object repeater (EC-253/EC-294/EC-305): the value-only
350
- * items + a parallel stable-key list kept in lockstep through every structural op
351
- * (the EC-253 C2 key↔value lock), add/remove/move, and native drag-reorder. Used by
352
- * both the card list (B) and the table (C) so they behave identically.
353
- */
354
- function useObjectArray(field, value, onChange) {
355
- const items = Array.isArray(value) ? value : [];
356
- const seq = useRef(0);
357
- const keysRef = useRef([]);
358
- if (keysRef.current.length !== items.length) {
359
- keysRef.current = items.map((_, i) => keysRef.current[i] ?? seq.current++);
360
- }
361
- const keys = keysRef.current;
362
- const commit = (nextItems, nextKeys) => {
363
- keysRef.current = nextKeys;
364
- onChange(nextItems);
365
- };
366
- const move = (from, to) => commit(reorder(items, from, to), reorder(keys, from, to));
367
- const removeAt = (i) => commit(items.filter((_, j) => j !== i), keys.filter((_, j) => j !== i));
368
- const addItem = () => {
369
- const seed = {};
370
- for (const sub of field.fields)
371
- if (sub.default !== undefined)
372
- seed[sub.name] = sub.default;
373
- const key = seq.current++;
374
- commit([...items, seed], [...keys, key]);
375
- return key;
376
- };
377
- const setItem = (i, updated) => {
378
- const nextItems = items.slice();
379
- nextItems[i] = updated;
380
- commit(nextItems, keys);
381
- };
382
- const [dragIndex, setDragIndex] = useState(null);
383
- const [overIndex, setOverIndex] = useState(null);
384
- const endDrag = () => {
385
- setDragIndex(null);
386
- setOverIndex(null);
387
- };
388
- const drop = (to) => {
389
- if (dragIndex !== null && dragIndex !== to)
390
- move(dragIndex, to);
391
- endDrag();
392
- };
393
- return { items, keys, move, removeAt, addItem, setItem, dragIndex, overIndex, setDragIndex, setOverIndex, endDrag, drop };
394
- }
395
- /**
396
- * (E) Chrome reduction: the per-row move/remove controls collapse into one hover/
397
- * focus overflow menu, so a row reads as content, not a six-button toolbar. Drag is
398
- * the primary reorder; the arrows stay here for keyboard/a11y.
399
- */
400
- function ItemOverflowMenu({ index, count, onMove, onRemove, }) {
401
- return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "sm", "aria-label": translate('cms.field.itemActions'), children: _jsx(MoreHorizontal, { className: "size-3.5" }) }) }), _jsxs(DropdownMenuContent, { align: "end", children: [_jsxs(DropdownMenuItem, { disabled: index === 0, onClick: () => onMove(index, index - 1), children: [_jsx(ChevronUp, {}), " ", translate('cms.field.moveUp')] }), _jsxs(DropdownMenuItem, { disabled: index === count - 1, onClick: () => onMove(index, index + 1), children: [_jsx(ChevronDown, {}), " ", translate('cms.field.moveDown')] }), _jsxs(DropdownMenuItem, { onClick: onRemove, children: [_jsx(Trash2, {}), " ", translate('common.remove')] })] })] }));
402
- }
403
- /**
404
- * The nested subfield form for one object item (EC-253). Reuses {@link FieldControl}
405
- * recursively, so any subfield type — including a nested object/repeater — works for
406
- * free. Writing a subfield merges it back into the item and bubbles the new item up.
407
- */
408
- function ObjectItemForm({ fields, value, onChange, readOnly, idPrefix, }) {
409
- return (_jsx(Stack, { gap: "3", className: "mt-2", children: fields.map((sub) => {
410
- // EC-273: a conditionally-hidden subfield (its predicate keys off the item's
411
- // own sibling values) is omitted — e.g. a discriminated link's `href` shows
412
- // only when `linkType === 'href'`. Re-evaluated as the item is edited.
413
- if (isFieldHidden(sub, { values: value }))
414
- return null;
415
- const subId = `${idPrefix}-${sub.name}`;
416
- return (_jsx(Field, { htmlFor: subId, hint: localizedLabel(sub.form?.description) || undefined, label: _jsxs(Row, { gap: "2", children: [_jsx("span", { children: fieldLabel(sub) }), sub.validation?.required ? _jsx("span", { className: "text-destructive", children: "*" }) : null] }), children: _jsx(FieldControl, { id: subId, field: sub, value: value[sub.name], readOnly: readOnly, onChange: (next) => {
417
- const updated = { ...value };
418
- if (next === undefined)
419
- delete updated[sub.name];
420
- else
421
- updated[sub.name] = next;
422
- onChange(updated);
423
- } }) }, sub.name));
424
- }) }));
425
- }
426
- /**
427
- * Object / repeater field control (EC-253), legibility-rebuilt (EC-305). A single
428
- * object (`multiple: false`) renders a depth-encoded card; an array (`'many'`)
429
- * dispatches by shape: a **flat** object type (all scalar subfields) → a compact
430
- * editable TABLE (C); a richer/nested type → a card list with depth encoding (B),
431
- * summary-first collapse (D), and reduced per-row chrome (E). Editing stays in the
432
- * shared field form — no canvas escape (unlike `blocks`) — so props and document
433
- * fields share this control. Depth flows via {@link ArrayDepthContext}.
434
- */
435
- function ObjectField({ field, value, onChange, readOnly, id, }) {
436
- const depth = useArrayDepth();
437
- if (field.multiple === false) {
438
- return (_jsx(Card, { className: cn('border-l-2 p-3', depthRail(depth), depthTint(depth)), children: _jsx(ArrayDepthContext.Provider, { value: depth + 1, children: _jsx(ObjectItemForm, { fields: field.fields, value: asRecord(value), readOnly: readOnly, idPrefix: id, onChange: onChange }) }) }));
439
- }
440
- const props = { field, value, onChange, readOnly, id, depth };
441
- return isTabularObjectType(field) ? _jsx(ObjectTableField, { ...props }) : _jsx(ObjectCardList, { ...props });
442
- }
443
- /**
444
- * (B + D + E) The card list for a nested/rich object array: depth-encoded cards
445
- * (coloured rail + tint), items collapsed by default to a scannable summary outline
446
- * (D), accordion expansion (open one → collapse siblings), and the per-row controls
447
- * collapsed into one overflow menu (E). Drag-to-reorder; arrows live in the menu for
448
- * a11y. Nested arrays render one depth deeper via the context provider.
449
- */
450
- function ObjectCardList({ field, value, onChange, readOnly, id, depth }) {
451
- const arr = useObjectArray(field, value, onChange);
452
- const { items, keys } = arr;
453
- // (D) Default COLLAPSED: track which keys are EXPANDED (empty = all collapsed).
454
- // (B) Accordion: expanding one collapses the siblings.
455
- const [expandedKeys, setExpandedKeys] = useState(() => new Set());
456
- const toggle = (key) => setExpandedKeys((prev) => {
457
- if (prev.has(key)) {
458
- const next = new Set(prev);
459
- next.delete(key);
460
- return next;
461
- }
462
- return new Set([key]);
463
- });
464
- const allExpanded = items.length > 0 && keys.every((k) => expandedKeys.has(k));
465
- const onAdd = () => setExpandedKeys(new Set([arr.addItem()]));
466
- return (_jsx(ArrayDepthContext.Provider, { value: depth + 1, children: _jsxs(Stack, { gap: "2", children: [items.length > 1 && !readOnly ? (_jsx(Row, { justify: "end", children: _jsx(Button, { variant: "ghost", size: "sm", onClick: () => setExpandedKeys(allExpanded ? new Set() : new Set(keys)), children: translate(allExpanded ? 'cms.field.collapseAll' : 'cms.field.expandAll') }) })) : null, items.length === 0 ? (_jsx(Text, { size: "sm", tone: "subtle", children: translate('cms.field.noItems') })) : (items.map((item, i) => {
467
- // keysRef is reconciled to items.length, so keys[i] is always present;
468
- // `?? i` only satisfies noUncheckedIndexedAccess (never taken at runtime).
469
- const key = keys[i] ?? i;
470
- const record = asRecord(item);
471
- const isExpanded = expandedKeys.has(key);
472
- return (_jsxs(Card, { onDragOver: (e) => {
473
- if (arr.dragIndex === null)
474
- return;
475
- e.preventDefault();
476
- if (arr.overIndex !== i)
477
- arr.setOverIndex(i);
478
- }, onDrop: () => arr.drop(i), className: cn('border-l-2 p-0', depthRail(depth), depthTint(depth), arr.dragIndex === i ? 'opacity-50' : '', arr.overIndex === i && arr.dragIndex !== null && arr.dragIndex !== i
479
- ? 'border-l-primary ring-1 ring-primary/40'
480
- : ''), children: [_jsxs(Row, { className: "select-none border-b border-border/60 bg-muted/20 px-2 py-1.5", children: [!readOnly ? (_jsx("span", { draggable: true, onDragStart: (e) => {
481
- e.dataTransfer.effectAllowed = 'move';
482
- e.dataTransfer.setData('text/plain', String(i));
483
- arr.setDragIndex(i);
484
- }, onDragEnd: arr.endDrag, "aria-label": translate('cms.field.dragToReorder'), className: "cursor-grab text-muted-foreground/50 hover:text-muted-foreground active:cursor-grabbing", children: _jsx(GripVertical, { className: "size-3.5" }) })) : null, _jsxs("button", { type: "button", onClick: () => toggle(key), "aria-expanded": isExpanded, className: "flex min-w-0 flex-1 items-center gap-1.5 text-left", children: [_jsx(ChevronDown, { className: cn('size-3.5 shrink-0 text-muted-foreground transition-transform', isExpanded ? '' : '-rotate-90'), "aria-hidden": true }), _jsx(Text, { size: "sm", className: "min-w-0 truncate font-medium", children: objectItemSummary(field.fields, record, i) })] }), !readOnly ? (_jsx(ItemOverflowMenu, { index: i, count: items.length, onMove: arr.move, onRemove: () => arr.removeAt(i) })) : null] }), isExpanded ? (_jsx("div", { className: "px-3 pb-3", children: _jsx(ObjectItemForm, { fields: field.fields, value: record, readOnly: readOnly, idPrefix: `${id}-${i}`, onChange: (updated) => arr.setItem(i, updated) }) })) : null] }, key));
485
- })), !readOnly ? (_jsxs(Button, { variant: "outline", size: "sm", onClick: onAdd, children: [_jsx(Plus, { className: "size-3" }), " ", translate('cms.field.addItem')] })) : null] }) }));
486
- }
487
- /**
488
- * (C) A compact editable table for a FLAT object array — columns are the scalar
489
- * subfields, one row per item. Kills the card-stack sprawl for the commonest case
490
- * (option lists, opening hours, social links). Drag-reorder + an overflow menu per
491
- * row; cells reuse {@link FieldControl} so each subfield keeps its native control.
492
- */
493
- function ObjectTableField({ field, value, onChange, readOnly, id, depth }) {
494
- const arr = useObjectArray(field, value, onChange);
495
- const { items, keys } = arr;
496
- const cols = field.fields;
497
- const span = cols.length + (readOnly ? 0 : 2);
498
- return (_jsxs("div", { className: cn('overflow-hidden rounded-md border border-border border-l-2', depthRail(depth), depthTint(depth)), children: [_jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-border/60 bg-muted/30 text-left", children: [!readOnly ? _jsx("th", { className: "w-7" }) : null, cols.map((c) => (_jsxs("th", { className: "px-2 py-1.5 text-xs font-medium text-muted-foreground", children: [fieldLabel(c), c.validation?.required ? _jsx("span", { className: "text-destructive", children: " *" }) : null] }, c.name))), !readOnly ? _jsx("th", { className: "w-9" }) : null] }) }), _jsx("tbody", { children: items.length === 0 ? (_jsx("tr", { children: _jsx("td", { colSpan: span, className: "px-2 py-2 text-muted-foreground", children: _jsx(Text, { size: "sm", tone: "subtle", children: translate('cms.field.noItems') }) }) })) : (items.map((item, i) => {
499
- const key = keys[i] ?? i;
500
- const record = asRecord(item);
501
- return (_jsxs("tr", { onDragOver: (e) => {
502
- if (arr.dragIndex === null)
503
- return;
504
- e.preventDefault();
505
- if (arr.overIndex !== i)
506
- arr.setOverIndex(i);
507
- }, onDrop: () => arr.drop(i), className: cn('border-b border-border/40 align-top last:border-0', arr.dragIndex === i ? 'opacity-50' : '', arr.overIndex === i && arr.dragIndex !== null && arr.dragIndex !== i
508
- ? 'bg-primary/5'
509
- : ''), children: [!readOnly ? (_jsx("td", { className: "px-1 py-1", children: _jsx("span", { draggable: true, onDragStart: (e) => {
510
- e.dataTransfer.effectAllowed = 'move';
511
- e.dataTransfer.setData('text/plain', String(i));
512
- arr.setDragIndex(i);
513
- }, onDragEnd: arr.endDrag, "aria-label": translate('cms.field.dragToReorder'), className: "inline-flex cursor-grab text-muted-foreground/50 hover:text-muted-foreground active:cursor-grabbing", children: _jsx(GripVertical, { className: "size-3.5" }) }) })) : null, cols.map((c) => (_jsx("td", { className: "px-2 py-1", children: _jsx(FieldControl, { id: `${id}-${i}-${c.name}`, field: c, value: record[c.name], readOnly: readOnly, onChange: (next) => {
514
- const updated = { ...record };
515
- if (next === undefined)
516
- delete updated[c.name];
517
- else
518
- updated[c.name] = next;
519
- arr.setItem(i, updated);
520
- } }) }, c.name))), !readOnly ? (_jsx("td", { className: "px-1 py-1", children: _jsx(ItemOverflowMenu, { index: i, count: items.length, onMove: arr.move, onRemove: () => arr.removeAt(i) }) })) : null] }, key));
521
- })) })] }), !readOnly ? (_jsx("div", { className: "border-t border-border/60 p-1.5", children: _jsxs(Button, { variant: "ghost", size: "sm", onClick: () => arr.addItem(), children: [_jsx(Plus, { className: "size-3" }), " ", translate('common.add')] }) })) : null] }));
522
- }
523
254
  function RelationField({ field, value, onChange, readOnly, }) {
524
255
  const source = useCollectionView();
525
256
  const editor = useOptionalDocumentEditor();
@@ -553,278 +284,4 @@ function RelationField({ field, value, onChange, readOnly, }) {
553
284
  return (_jsx(RowItem, { "data-testid": "relation-value", actions: !readOnly ? (_jsxs(_Fragment, { children: [field.multiple === false ? (_jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": translate('cms.relation.change'), title: translate('cms.relation.change'), onClick: () => setOpen(true), children: _jsx(Pencil, {}) })) : null, _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": translate('common.remove'), title: translate('common.remove'), className: "hover:text-destructive", onClick: () => apply(refs.filter((r) => r.id !== ref.id)), children: _jsx(X, {}) })] })) : undefined, children: projectId ? (_jsx(Link, { to: STUDIO_PROJECT_ROUTE, params: { projectId, _splat: studioPath.cmsDocument(ref.collection, ref.id) }, title: translate('cms.relation.openTitle', { title }), className: "text-sm font-medium hover:text-primary hover:underline", children: label })) : (_jsx(Text, { as: "span", size: "sm", weight: "medium", children: label })) }, `${ref.id}-${i}`));
554
285
  }) })) : null, !readOnly && (refs.length === 0 || field.multiple) ? (_jsx(Row, { children: _jsxs(Button, { variant: "outline", size: "sm", onClick: () => setOpen(true), children: [_jsx(Plus, {}), " ", refs.length === 0 ? translate('cms.relation.pick') : translate('common.add')] }) })) : null, _jsx(RelationPicker, { targetCollection: field.target, multiple: field.multiple, value: refs, open: open, onOpenChange: setOpen, onChange: apply, excludeIds: excludeIds })] }));
555
286
  }
556
- /**
557
- * The asset field (EC-152 + EC-248). Beyond picking from the library it now shows
558
- * a focal-point-aware preview, uploads directly from the field (reusing the EC-152
559
- * upload path — no parallel uploader), and edits the selected image's alt + focal
560
- * point inline (persisted on the asset document, where delivery reads them, EC-230).
561
- * With no `<AssetsProvider>` (fixtures/playground) it degrades to the labelled-badge
562
- * picker — no preview/upload — so it stays usable without a real library.
563
- */
564
- function AssetField({ field, value, onChange, readOnly, }) {
565
- const source = useCollectionView();
566
- const assets = useOptionalAssets();
567
- const density = useFieldDensity();
568
- const compact = density === 'compact';
569
- const [open, setOpen] = useState(false);
570
- const [editing, setEditing] = useState(false);
571
- const [dragging, setDragging] = useState(false);
572
- // Names of files a drop/pick rejected against the field's `accept` (EC-301) — shown
573
- // as a transient inline issue, cleared on the next valid upload. Never a silent drop.
574
- const [rejected, setRejected] = useState([]);
575
- // Asset ids known when an upload-from-field started; the effect below diffs the
576
- // library against it to learn the new id (the `upload()` API returns nothing).
577
- const pendingRef = useRef(null);
578
- const assetCollectionId = source.assetCollectionId ?? 'asset';
579
- const many = field.multiple;
580
- const ids = many ? assetIdsOf(value) : [];
581
- const single = !many ? singleAssetIdOf(value) : null;
582
- // EC-301 per-use sub-fields: with `field.fields` the value stores asset ENVELOPES
583
- // carrying the sub-field siblings (`{ type:'asset', id, caption }`); without them it
584
- // stays bare ids (self-typed on save). Picks/edits preserve a placement's sub-fields.
585
- const subFields = field.fields ?? [];
586
- const hasSub = subFields.length > 0;
587
- // The raw `many` entries (envelopes or bare ids), index-aligned with `ids`, so each
588
- // gallery item can read/write its own sub-fields.
589
- const entries = many && Array.isArray(value) ? value : [];
590
- const libraryIds = useMemo(() => (assets?.status === 'ready' ? assets.assets.map((a) => a.id) : []), [assets]);
591
- // Auto-select an asset uploaded FROM this field once it lands in the library.
592
- useEffect(() => {
593
- if (!pendingRef.current)
594
- return;
595
- const added = newlyAddedAssetIds(pendingRef.current, libraryIds);
596
- if (added.length === 0)
597
- return;
598
- pendingRef.current = null;
599
- if (many) {
600
- const current = Array.isArray(value) ? value : [];
601
- onChange([...current, ...added.map((id) => (hasSub ? assetNode(id) : id))]);
602
- }
603
- else {
604
- onChange(hasSub ? assetNode(added[0], assetSubfieldsOf(value)) : applyAssetSelection(value, added[0], false));
605
- }
606
- // Reacts to the library set growing (an upload landing), not to unrelated
607
- // value edits — `value`/`onChange` are read from the closure on purpose.
608
- }, [libraryIds, many, hasSub, onChange, value]);
609
- function uploadFiles(files) {
610
- if (!assets || files.length === 0)
611
- return;
612
- // EC-301: a drag-drop bypasses the file dialog's `accept` filter — re-check each
613
- // file against the field's `accept` and surface a visible issue for the rejects
614
- // instead of silently uploading the wrong kind.
615
- const ok = [];
616
- const bad = [];
617
- for (const file of files) {
618
- if (assetMatchesAccept({ contentType: file.type, filename: file.name }, field.accept))
619
- ok.push(file);
620
- else
621
- bad.push(file.name);
622
- }
623
- setRejected(bad);
624
- if (ok.length === 0)
625
- return;
626
- // Remember the library set so the effect above can learn the new id and
627
- // auto-select it once the upload lands (the upload API returns nothing).
628
- pendingRef.current = new Set(libraryIds);
629
- assets.upload(ok);
630
- }
631
- // Apply a picked asset id, preserving a placement's per-use sub-fields (EC-301).
632
- // Without sub-fields it stays the plain id-list behaviour (self-typed on save).
633
- function applyPicked(picked) {
634
- if (!hasSub) {
635
- onChange(applyAssetSelection(value, picked, field.multiple));
636
- return;
637
- }
638
- if (many) {
639
- if (picked === null || entries.some((e) => readAssetId(e) === picked))
640
- return;
641
- onChange([...entries, assetNode(picked)]);
642
- }
643
- else {
644
- onChange(picked ? assetNode(picked, assetSubfieldsOf(value)) : undefined);
645
- }
646
- }
647
- const canUpload = !readOnly && assets !== null;
648
- // Drag-to-upload onto the field (EC-301): a depth counter keeps the overlay
649
- // steady as the pointer crosses child element boundaries. `dragenter`/`dragleave`
650
- // fire per descendant, so toggling on a bare `dragleave` blinks constantly; we
651
- // only hide the overlay when the counter returns to zero (the pointer has truly
652
- // left the field). The overlay is pointer-events-none, so it never fires its own
653
- // enter/leave to confuse the count.
654
- const dragDepth = useRef(0);
655
- function onDragEnter(e) {
656
- if (!canUpload)
657
- return;
658
- e.preventDefault();
659
- dragDepth.current += 1;
660
- if (!dragging)
661
- setDragging(true);
662
- }
663
- function onDragOver(e) {
664
- if (!canUpload)
665
- return;
666
- e.preventDefault(); // required so a drop is allowed
667
- }
668
- function onDragLeave() {
669
- if (!canUpload)
670
- return;
671
- dragDepth.current -= 1;
672
- if (dragDepth.current <= 0) {
673
- dragDepth.current = 0;
674
- setDragging(false);
675
- }
676
- }
677
- function onDrop(e) {
678
- if (!canUpload)
679
- return;
680
- e.preventDefault();
681
- dragDepth.current = 0;
682
- setDragging(false);
683
- uploadFiles(Array.from(e.dataTransfer.files));
684
- }
685
- // While the picker is open the field is behind the modal, so any drop is happening
686
- // INSIDE the dialog — detach the field's drag handlers entirely so a drop there can
687
- // only be handled by the picker, never double-counted by the field too (EC-301). This
688
- // is the belt to the picker's stopPropagation braces: the field cannot react at all.
689
- const dragProps = canUpload && !open ? { onDragEnter, onDragOver, onDragLeave, onDrop } : {};
690
- const selectButton = !readOnly ? (_jsxs(Button, { variant: "outline", size: "sm", onClick: () => setOpen(true), children: [_jsx(ImagePlus, {}), " ", translate('common.select')] })) : null;
691
- return (_jsxs(Stack, { gap: "2", className: cn('relative rounded-lg transition-shadow', dragging && 'ring-2 ring-primary ring-offset-2 ring-offset-background'), ...dragProps, children: [many && hasSub ? (_jsxs(Stack, { gap: "2", children: [entries.length > 0 ? (_jsx(Stack, { gap: "2", children: entries.map((entry, i) => {
692
- const aid = readAssetId(entry);
693
- if (!aid)
694
- return null;
695
- return (_jsx(AssetGalleryItem, { assetId: aid, collectionId: assetCollectionId, fields: subFields, value: assetSubfieldsOf(entry), readOnly: readOnly, idPrefix: `asset-${i}`, onChangeSubs: (next) => {
696
- const nextEntries = entries.slice();
697
- nextEntries[i] = assetNode(aid, next);
698
- onChange(nextEntries);
699
- }, onRemove: readOnly ? undefined : () => onChange(entries.filter((_, j) => j !== i)) }, `${aid}-${i}`));
700
- }) })) : (_jsx(EmptyAssetState, { compact: compact, canUpload: canUpload, onSelect: () => setOpen(true) })), _jsx(Row, { gap: "2", wrap: true, align: "center", children: selectButton })] })) : many ? (_jsxs(Stack, { gap: "2", children: [ids.length > 0 ? (_jsx("div", { className: cn('grid gap-2', compact ? 'grid-cols-2' : 'grid-cols-3 sm:grid-cols-4'), children: ids.map((aid, i) => (_jsx(AssetChip, { assetId: aid, collectionId: assetCollectionId, onRemove: readOnly ? undefined : () => onChange(ids.filter((x) => x !== aid)) }, `${aid}-${i}`))) })) : (_jsx(EmptyAssetState, { compact: compact, canUpload: canUpload, onSelect: () => setOpen(true) })), _jsx(Row, { gap: "2", wrap: true, align: "center", children: selectButton })] })) : single && hasSub ? (_jsx(Card, { className: "border-l-2 border-l-border p-3", children: _jsxs(Stack, { gap: "3", children: [_jsx(AssetSinglePreview, { assetId: single, compact: compact, readOnly: readOnly, hasLibrary: assets !== null, fallbackTitle: source.titleOf({ collection: assetCollectionId, id: single }), selectButton: selectButton, onEdit: () => setEditing(true), onRemove: () => onChange(applyAssetSelection(value, null, field.multiple)) }), _jsx("div", { className: "border-t border-border/60 pt-3", children: _jsx(ObjectItemForm, { fields: subFields, value: assetSubfieldsOf(value), readOnly: readOnly, idPrefix: "asset-sub", onChange: (next) => onChange(assetNode(single, next)) }) })] }) })) : single ? (_jsx(AssetSinglePreview, { assetId: single, compact: compact, readOnly: readOnly, hasLibrary: assets !== null, fallbackTitle: source.titleOf({ collection: assetCollectionId, id: single }), selectButton: selectButton, onEdit: () => setEditing(true), onRemove: () => onChange(applyAssetSelection(value, null, field.multiple)) })) : (_jsxs(Stack, { gap: "2", children: [_jsx(EmptyAssetState, { compact: compact, canUpload: canUpload, onSelect: () => setOpen(true) }), _jsx(Row, { gap: "2", wrap: true, align: "center", children: selectButton })] })), !many && single && assets ? (_jsx(AssetOptionsDialog, { assetId: single, readOnly: readOnly, open: editing, onOpenChange: setEditing })) : null, _jsx(AssetPicker, { value: single, open: open, onOpenChange: setOpen, accept: assetAcceptAttr(field.accept), onChange: applyPicked }), rejected.length > 0 ? (_jsxs(Row, { gap: "2", align: "center", children: [_jsx(AlertTriangle, { className: "size-4 shrink-0 text-warning" }), _jsx(Text, { size: "xs", tone: "muted", children: rejected.length === 1
701
- ? translate('cms.asset.rejected.one', {
702
- name: rejected[0],
703
- types: describeAccept(field.accept),
704
- })
705
- : translate('cms.asset.rejected.other', {
706
- count: rejected.length,
707
- types: describeAccept(field.accept),
708
- }) })] })) : null, dragging ? (_jsx("div", { className: "pointer-events-none absolute inset-0 z-10 grid place-items-center rounded-lg border-2 border-dashed border-primary bg-background/85", children: _jsxs(Row, { gap: "2", align: "center", children: [_jsx(Upload, { className: "size-4 text-primary" }), _jsx(Text, { size: "sm", className: "font-medium", children: translate('cms.asset.dropToUpload') })] }) })) : null] }));
709
- }
710
- /** Empty-state placeholder shown when an asset field has no selection (EC-301). */
711
- function EmptyAssetState({ compact, canUpload, onSelect, }) {
712
- return (_jsxs("button", { type: "button", onClick: onSelect, className: cn('grid w-full place-items-center gap-1 rounded-lg border border-dashed border-border bg-muted/30 text-center text-muted-foreground transition-colors hover:bg-muted/60', compact ? 'aspect-video' : 'aspect-[5/2]'), children: [_jsx(ImagePlus, { className: "size-6", "aria-hidden": true }), _jsx(Text, { size: "xs", tone: "muted", children: canUpload ? translate('cms.asset.selectOrDrop') : translate('cms.asset.selectAsset') })] }));
713
- }
714
- /** A large focal-aware preview of the selected asset (EC-301). */
715
- function AssetPreview({ assetId }) {
716
- const record = useAssetRecord(assetId);
717
- return _jsx(AssetThumb, { record: record, className: "aspect-[4/3] w-full" });
718
- }
719
- /** Filename + dimensions/type for the selected asset — the id is never shown (EC-301). */
720
- function AssetInfo({ assetId, fallbackTitle }) {
721
- const record = useAssetRecord(assetId);
722
- const name = record?.title ?? record?.filename ?? fallbackTitle ?? assetId;
723
- const dims = record?.width && record?.height ? `${record.width}×${record.height}` : null;
724
- const kind = record?.contentType?.split('/')[1]?.toUpperCase();
725
- const meta = [dims, kind].filter(Boolean).join(' · ');
726
- return (_jsxs(Stack, { gap: "1", children: [_jsx(Text, { size: "sm", className: "truncate font-medium", title: name, children: name }), meta ? (_jsx(Text, { size: "xs", tone: "subtle", children: meta })) : null] }));
727
- }
728
- /**
729
- * The selected image's alt text shown in the field (EC-301) — what an editor most
730
- * wants at a glance; edited in the options dialog. Reads `meta.alt` with the legacy
731
- * `alt` column as fallback, matching delivery.
732
- */
733
- function AssetAltLine({ assetId }) {
734
- const record = useAssetRecord(assetId);
735
- const metaAlt = typeof record?.meta?.alt === 'string' ? record.meta.alt : undefined;
736
- const alt = metaAlt ?? record?.alt;
737
- return (_jsxs(Row, { gap: "1", align: "baseline", className: "min-w-0", children: [_jsx(Text, { size: "xs", tone: "subtle", className: "shrink-0", children: translate('cms.asset.altLabel') }), _jsx(Text, { size: "xs", tone: alt ? 'muted' : 'subtle', className: cn('min-w-0 truncate', !alt && 'italic'), title: alt ?? undefined, children: alt || translate('cms.asset.notSet') })] }));
738
- }
739
- /** A single asset reference: focal-aware thumbnail + title, with an optional remove. */
740
- function AssetChip({ assetId, collectionId, onRemove, }) {
741
- const source = useCollectionView();
742
- const record = useAssetRecord(assetId);
743
- const title = source.titleOf({ collection: collectionId, id: assetId });
744
- return (_jsxs("div", { className: "group relative overflow-hidden rounded-lg border border-border bg-muted/40", children: [_jsx(AssetThumb, { record: record, alt: title, className: "aspect-square w-full rounded-none border-0" }), _jsxs(Row, { gap: "1", align: "center", className: "px-2 py-1", children: [_jsx(Text, { size: "xs", tone: "subtle", className: "min-w-0 flex-1 truncate", title: title, children: title }), onRemove ? (_jsx("button", { type: "button", "aria-label": translate('common.remove'), onClick: onRemove, className: "text-muted-foreground hover:text-destructive", children: _jsx(X, { className: "size-3.5" }) })) : null] })] }));
745
- }
746
- /**
747
- * The selected single asset's preview row (EC-301): large focal-aware preview + info
748
- * column (filename/dimensions, Select/Edit/Remove, alt). Extracted so it renders either
749
- * bare (a plain asset field) or inside the grouping card when the field carries per-use
750
- * sub-fields — the asset + its fields then read as one unit.
751
- */
752
- function AssetSinglePreview({ assetId, compact, readOnly, hasLibrary, fallbackTitle, selectButton, onEdit, onRemove, }) {
753
- return (_jsxs("div", { className: compact ? 'flex flex-col gap-3' : 'flex gap-4', children: [_jsx("div", { className: compact ? 'w-full' : 'w-1/2 shrink-0', children: _jsx(AssetPreview, { assetId: assetId }) }), _jsxs(Stack, { gap: "2", className: cn('min-w-0', !compact && 'flex-1'), children: [_jsx(AssetInfo, { assetId: assetId, fallbackTitle: fallbackTitle }), !readOnly ? (_jsxs(Row, { gap: "2", wrap: true, align: "center", children: [selectButton, hasLibrary ? (_jsxs(Button, { variant: "outline", size: "sm", onClick: onEdit, children: [_jsx(SlidersHorizontal, {}), " ", translate('common.edit')] })) : null, _jsxs(Button, { variant: "ghost", size: "sm", onClick: onRemove, children: [_jsx(Trash2, {}), " ", translate('common.remove')] })] })) : null, _jsx(AssetAltLine, { assetId: assetId })] })] }));
754
- }
755
- /**
756
- * One gallery placement when a `many` asset field declares per-use sub-fields (EC-301):
757
- * a thumbnail + filename + remove header over the placement's own sub-field form,
758
- * modelled on a repeater item card so a gallery's placements read as grouped units.
759
- */
760
- function AssetGalleryItem({ assetId, collectionId, fields, value, onChangeSubs, onRemove, readOnly, idPrefix, }) {
761
- const source = useCollectionView();
762
- const record = useAssetRecord(assetId);
763
- const title = source.titleOf({ collection: collectionId, id: assetId });
764
- return (_jsxs(Card, { className: "border-l-2 border-l-border p-0", children: [_jsxs(Row, { gap: "2", align: "center", className: "border-b border-border/60 bg-muted/30 px-2 py-1.5", children: [_jsx(AssetThumb, { record: record, alt: title, className: "size-10 shrink-0" }), _jsx(Text, { size: "sm", className: "min-w-0 flex-1 truncate font-medium", title: title, children: title }), onRemove ? (_jsx(Button, { variant: "ghost", size: "sm", "aria-label": translate('common.remove'), onClick: onRemove, children: _jsx(Trash2, { className: "size-3.5" }) })) : null] }), _jsx("div", { className: "px-3 pb-3", children: _jsx(ObjectItemForm, { fields: fields, value: value, onChange: onChangeSubs, readOnly: readOnly, idPrefix: idPrefix }) })] }));
765
- }
766
- /**
767
- * The image options dialog (EC-301): the focal point (engine-special visual
768
- * control) + the asset's **schema-driven** metadata, rendered from the
769
- * `kind:'asset'` collection's `fields` through the same generic field engine
770
- * documents use — so a project declares asset metadata (alt, copyright, credit, …)
771
- * in code, not hardcoded here. Edits persist on the asset, shared across every use.
772
- * Metadata is buffered locally and committed once on close (one write); the focal
773
- * point commits immediately (it drives a live visual). No-ops without a library.
774
- */
775
- function AssetOptionsDialog({ assetId, readOnly, open, onOpenChange, }) {
776
- const source = useCollectionView();
777
- const assets = useOptionalAssets();
778
- const record = useAssetRecord(assetId);
779
- const metaFields = useMemo(() => {
780
- const assetCollectionId = source.assetCollectionId ?? 'asset';
781
- const collection = source.collections.find((c) => c.id === assetCollectionId);
782
- return collection ? assetMetaFieldsOf(collection) : [];
783
- }, [source]);
784
- // Local edit buffers, seeded once per open from the record (so an external edit
785
- // still wins between opens). Metadata is non-localized in v1 (the asset
786
- // resolution path is locale-agnostic) — localized asset metadata is a follow-up.
787
- const [meta, setMeta] = useState({});
788
- const [focal, setFocal] = useState(undefined);
789
- const dirty = useRef(false);
790
- const seeded = useRef(false);
791
- useEffect(() => {
792
- if (!open) {
793
- seeded.current = false;
794
- return;
795
- }
796
- if (seeded.current || !record)
797
- return;
798
- // Seed from the meta bag, but lift the legacy `alt` column in when meta lacks
799
- // it (EC-301 transition): a not-yet-migrated asset still shows its alt, and
800
- // saving writes it into meta — a lazy migration-on-edit, matching delivery's
801
- // `meta.alt ?? record.alt` read fallback.
802
- const seedMeta = { ...(record.alt !== undefined ? { alt: record.alt } : {}), ...(record.meta ?? {}) };
803
- setMeta(seedMeta);
804
- setFocal(record.focalPoint);
805
- dirty.current = false;
806
- seeded.current = true;
807
- }, [open, record]);
808
- function handleOpenChange(next) {
809
- // Commit the buffered metadata once, on close (one write, not per keystroke).
810
- if (!next && !readOnly && assets && dirty.current) {
811
- void assets.updateMetadata(assetId, { meta: pruneMeta(meta) });
812
- dirty.current = false;
813
- }
814
- onOpenChange(next);
815
- }
816
- function setField(name, value) {
817
- dirty.current = true;
818
- setMeta((m) => ({ ...m, [name]: value }));
819
- }
820
- // The focal point drives a live crop preview → commit immediately (optimistic
821
- // marker), not deferred to close.
822
- function commitFocal(point) {
823
- if (readOnly || !assets)
824
- return;
825
- setFocal(point ?? undefined);
826
- void assets.updateMetadata(assetId, { focalPoint: point });
827
- }
828
- return (_jsx(Dialog, { open: open, onOpenChange: handleOpenChange, children: _jsxs(DialogContent, { className: "max-w-lg", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: translate('cms.asset.imageOptions.title') }), _jsx(DialogDescription, { children: translate('cms.asset.imageOptions.description') })] }), _jsxs(Stack, { gap: "3", children: [_jsxs(Stack, { gap: "2", children: [_jsxs(Row, { align: "center", children: [_jsx(Text, { size: "sm", className: "font-medium", children: translate('cms.asset.focalPoint') }), _jsx(Spacer, {}), focal && !readOnly ? (_jsxs(Button, { variant: "ghost", size: "sm", onClick: () => commitFocal(null), children: [_jsx(RotateCcw, {}), " ", translate('cms.asset.resetToCentre')] })) : null] }), _jsx(FocalPointPicker, { record: record, point: focal, onChange: commitFocal, onPreview: setFocal, disabled: readOnly }), _jsx(Text, { size: "xs", tone: "subtle", children: translate('cms.asset.focalPointHint') }), _jsx(FocalCropPreviews, { record: record, point: focal })] }), metaFields.map((f) => (_jsx(Field, { label: fieldLabel(f), hint: localizedLabel(f.form?.description) || undefined, children: _jsx(FieldControl, { id: `asset-meta-${f.name}`, field: f, value: meta[f.name], readOnly: readOnly, onChange: (v) => setField(f.name, v) }) }, f.name))), metaFields.length === 0 ? (_jsxs(Text, { size: "sm", tone: "subtle", children: ["No editable metadata fields are declared on the asset collection. Add fields (e.g.", _jsx(Code, { tone: "muted", children: " alt" }), ", ", _jsx(Code, { tone: "muted", children: "copyright" }), ") to it in code."] })) : null] })] }) }));
829
- }
830
287
  //# sourceMappingURL=field-control.js.map