@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.
- package/dist/components/ai/ai-chat-panel.js +44 -6
- package/dist/components/ai/ai-chat-panel.js.map +1 -1
- package/dist/components/ai/chat-ui.d.ts +1 -1
- package/dist/components/ai/chat-ui.js +11 -2
- package/dist/components/ai/chat-ui.js.map +1 -1
- package/dist/components/ai/proposal-card.d.ts +30 -7
- package/dist/components/ai/proposal-card.js +81 -21
- package/dist/components/ai/proposal-card.js.map +1 -1
- package/dist/components/builder/blocks-model.js +12 -7
- package/dist/components/builder/blocks-model.js.map +1 -1
- package/dist/components/builder/canvas-dnd.d.ts +59 -0
- package/dist/components/builder/canvas-dnd.js +207 -0
- package/dist/components/builder/canvas-dnd.js.map +1 -0
- package/dist/components/builder/canvas-dom.d.ts +11 -0
- package/dist/components/builder/canvas-dom.js +17 -0
- package/dist/components/builder/canvas-dom.js.map +1 -0
- package/dist/components/builder/canvas-inline-editing.d.ts +91 -0
- package/dist/components/builder/canvas-inline-editing.js +242 -0
- package/dist/components/builder/canvas-inline-editing.js.map +1 -0
- package/dist/components/builder/canvas-render-context.d.ts +83 -0
- package/dist/components/builder/canvas-render-context.js +196 -0
- package/dist/components/builder/canvas-render-context.js.map +1 -0
- package/dist/components/builder/canvas.d.ts +4 -1
- package/dist/components/builder/canvas.js +32 -567
- package/dist/components/builder/canvas.js.map +1 -1
- package/dist/components/builder/composition-field-editor.js +14 -4
- package/dist/components/builder/composition-field-editor.js.map +1 -1
- package/dist/components/builder/insert-context.d.ts +5 -2
- package/dist/components/builder/insert-context.js +1 -3
- package/dist/components/builder/insert-context.js.map +1 -1
- package/dist/components/builder/node-factory.d.ts +12 -0
- package/dist/components/builder/node-factory.js +18 -1
- package/dist/components/builder/node-factory.js.map +1 -1
- package/dist/components/cms/asset-field.d.ts +22 -0
- package/dist/components/cms/asset-field.js +300 -0
- package/dist/components/cms/asset-field.js.map +1 -0
- package/dist/components/cms/detail-editor.js +12 -0
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/document-diff-view.d.ts +12 -1
- package/dist/components/cms/document-diff-view.js +119 -42
- package/dist/components/cms/document-diff-view.js.map +1 -1
- package/dist/components/cms/field-control-lib.d.ts +43 -0
- package/dist/components/cms/field-control-lib.js +98 -0
- package/dist/components/cms/field-control-lib.js.map +1 -0
- package/dist/components/cms/field-control.d.ts +7 -35
- package/dist/components/cms/field-control.js +13 -556
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/object-array-field.d.ts +29 -0
- package/dist/components/cms/object-array-field.js +205 -0
- package/dist/components/cms/object-array-field.js.map +1 -0
- package/dist/components/cms/rich-text-preview.d.ts +15 -0
- package/dist/components/cms/rich-text-preview.js +81 -0
- package/dist/components/cms/rich-text-preview.js.map +1 -0
- package/dist/components/cms/version-compare-text.d.ts +26 -0
- package/dist/components/cms/version-compare-text.js +60 -0
- package/dist/components/cms/version-compare-text.js.map +1 -1
- package/dist/components/project-session.d.ts +19 -0
- package/dist/components/project-session.js +29 -2
- package/dist/components/project-session.js.map +1 -1
- package/dist/components/use-hydration-phase.d.ts +6 -4
- package/dist/components/use-hydration-phase.js.map +1 -1
- package/dist/lib/ai/apply-content.js +52 -10
- package/dist/lib/ai/apply-content.js.map +1 -1
- package/dist/lib/ai/chat-context.d.ts +19 -3
- package/dist/lib/ai/chat-context.js +33 -4
- package/dist/lib/ai/chat-context.js.map +1 -1
- package/dist/lib/ai/chat-state.d.ts +6 -3
- package/dist/lib/ai/chat-state.js.map +1 -1
- package/dist/lib/ai/fake-provider.d.ts +60 -5
- package/dist/lib/ai/fake-provider.js +74 -6
- package/dist/lib/ai/fake-provider.js.map +1 -1
- package/dist/lib/ai/index.d.ts +2 -0
- package/dist/lib/ai/index.js +2 -0
- package/dist/lib/ai/index.js.map +1 -1
- package/dist/lib/ai/rich-text-input.d.ts +27 -0
- package/dist/lib/ai/rich-text-input.js +25 -0
- package/dist/lib/ai/rich-text-input.js.map +1 -0
- package/dist/lib/ai/shape.d.ts +12 -1
- package/dist/lib/ai/shape.js +46 -20
- package/dist/lib/ai/shape.js.map +1 -1
- package/dist/lib/ai/tools.d.ts +88 -13
- package/dist/lib/ai/tools.js +253 -14
- package/dist/lib/ai/tools.js.map +1 -1
- package/dist/lib/ai/types.d.ts +20 -0
- package/dist/lib/ai/types.js.map +1 -1
- package/dist/lib/ai/update-fields.d.ts +68 -0
- package/dist/lib/ai/update-fields.js +296 -0
- package/dist/lib/ai/update-fields.js.map +1 -0
- package/dist/lib/ai-current-document.d.ts +53 -0
- package/dist/lib/ai-current-document.js +54 -0
- package/dist/lib/ai-current-document.js.map +1 -0
- package/dist/lib/cms-views/view-model.d.ts +6 -6
- package/dist/lib/data/collection-content.d.ts +12 -7
- package/dist/lib/data/collection-content.js +6 -4
- package/dist/lib/data/collection-content.js.map +1 -1
- package/dist/lib/i18n/messages.en.js +10 -1
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/session-hydration.d.ts +4 -1
- package/dist/lib/operations/session-hydration.js +4 -1
- package/dist/lib/operations/session-hydration.js.map +1 -1
- package/dist/lib/persistence/index.js +13 -1
- package/dist/lib/persistence/index.js.map +1 -1
- package/dist/lib/workspace/index.d.ts +1 -1
- package/dist/lib/workspace/index.js +1 -1
- package/dist/lib/workspace/index.js.map +1 -1
- package/dist/lib/workspace/workspace-source.d.ts +17 -22
- package/dist/lib/workspace/workspace-source.js +1 -49
- package/dist/lib/workspace/workspace-source.js.map +1 -1
- 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,
|
|
3
|
-
import {
|
|
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,
|
|
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,
|
|
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
|