@elytracms/studio 0.0.21 → 0.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,16 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ChevronDown, ChevronUp, Crosshair, GripVertical, Link2, Pencil, Plus, Trash2, Upload, X } from 'lucide-react';
2
+ import { AlertTriangle, ChevronDown, ChevronUp, GripVertical, ImagePlus, Link2, Pencil, Plus, RotateCcw, SlidersHorizontal, Trash2, Upload, X } from 'lucide-react';
3
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, descendantIds, isFieldHidden, parentRelationField } from '@elytracms/core/cms-core';
6
- import { Badge, Button, Card, Code, Field, Input, Row, Select, Spacer, Stack, Text } from '@elytracms/studio/ui';
5
+ import { BUILTIN_FIELD_CONTROLS, assetAcceptAttr, assetMatchesAccept, assetNode, assetSubfieldsOf, describeAccept, descendantIds, isFieldHidden, parentRelationField, readAssetId, } from '@elytracms/core/cms-core';
6
+ import { Badge, Button, Card, Code, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Field, Input, Row, Select, Spacer, Stack, Text, } from '@elytracms/studio/ui';
7
7
  import { cn } from '@elytracms/studio/lib/utils';
8
8
  import { applyAssetSelection, assetIdsOf, singleAssetIdOf } from '../assets/asset-selection';
9
9
  import { useOptionalAssets } from '../assets/assets-store';
10
- import { AssetThumb, FocalPointPicker, useAssetRecord } from '../assets/asset-thumb';
10
+ import { AssetThumb, FocalCropPreviews, FocalPointPicker, useAssetRecord } from '../assets/asset-thumb';
11
11
  import { newlyAddedAssetIds } from '../assets/focal-point';
12
12
  import { fieldLabel, useCms } from './cms-store';
13
+ import { useFieldDensity } from './field-density';
13
14
  import { useOptionalDocumentEditor } from './document-editor-context';
14
15
  import { toRefArray } from './cell';
15
16
  import { RelationPicker } from './relation-picker';
@@ -287,14 +288,6 @@ function RelationField({ field, value, onChange, readOnly, }) {
287
288
  return (_jsxs(Badge, { variant: "info", children: [projectId ? (_jsxs(Link, { to: STUDIO_PROJECT_ROUTE, params: { projectId, _splat: studioPath.cmsDocument(ref.collection, ref.id) }, title: `Open ${title}`, className: "inline-flex items-center gap-1 hover:underline", children: [_jsx(Link2, { className: "size-3" }), " ", title] })) : (_jsxs("span", { className: "inline-flex items-center gap-1", children: [_jsx(Link2, { className: "size-3" }), " ", title] })), !readOnly ? (_jsx("button", { type: "button", "aria-label": "Remove", onClick: () => apply(refs.filter((r) => r.id !== ref.id)), className: "ml-1 hover:text-destructive", children: _jsx(X, { className: "size-3" }) })) : null] }, `${ref.id}-${i}`));
288
289
  }), !readOnly ? (_jsxs(Button, { variant: "outline", size: "sm", onClick: () => setOpen(true), children: [_jsx(Pencil, {}), " ", refs.length > 0 ? 'Change' : 'Pick'] })) : null, _jsx(RelationPicker, { targetCollection: field.target, cardinality: field.cardinality, value: refs, open: open, onOpenChange: setOpen, onChange: apply, excludeIds: excludeIds })] }));
289
290
  }
290
- /** Translate a field's `accept` list into the file input's `accept` attribute. */
291
- function acceptAttr(accept) {
292
- if (!accept || accept.length === 0)
293
- return undefined;
294
- // The field vocabulary is a coarse kind list (`image`, `file`); map the common
295
- // `image` kind to the MIME wildcard the browser file dialog filters on.
296
- return accept.map((a) => (a === 'image' ? 'image/*' : a)).join(',');
297
- }
298
291
  /**
299
292
  * The asset field (EC-152 + EC-248). Beyond picking from the library it now shows
300
293
  * a focal-point-aware preview, uploads directly from the field (reusing the EC-152
@@ -306,9 +299,14 @@ function acceptAttr(accept) {
306
299
  function AssetField({ field, value, onChange, readOnly, }) {
307
300
  const cms = useCms();
308
301
  const assets = useOptionalAssets();
302
+ const density = useFieldDensity();
303
+ const compact = density === 'compact';
309
304
  const [open, setOpen] = useState(false);
310
305
  const [editing, setEditing] = useState(false);
311
- const fileInputRef = useRef(null);
306
+ const [dragging, setDragging] = useState(false);
307
+ // Names of files a drop/pick rejected against the field's `accept` (EC-301) — shown
308
+ // as a transient inline issue, cleared on the next valid upload. Never a silent drop.
309
+ const [rejected, setRejected] = useState([]);
312
310
  // Asset ids known when an upload-from-field started; the effect below diffs the
313
311
  // library against it to learn the new id (the `upload()` API returns nothing).
314
312
  const pendingRef = useRef(null);
@@ -316,6 +314,14 @@ function AssetField({ field, value, onChange, readOnly, }) {
316
314
  const many = field.cardinality === 'many';
317
315
  const ids = many ? assetIdsOf(value) : [];
318
316
  const single = !many ? singleAssetIdOf(value) : null;
317
+ // EC-301 per-use sub-fields: with `field.fields` the value stores asset ENVELOPES
318
+ // carrying the sub-field siblings (`{ type:'asset', id, caption }`); without them it
319
+ // stays bare ids (self-typed on save). Picks/edits preserve a placement's sub-fields.
320
+ const subFields = field.fields ?? [];
321
+ const hasSub = subFields.length > 0;
322
+ // The raw `many` entries (envelopes or bare ids), index-aligned with `ids`, so each
323
+ // gallery item can read/write its own sub-fields.
324
+ const entries = many && Array.isArray(value) ? value : [];
319
325
  const libraryIds = useMemo(() => (assets?.status === 'ready' ? assets.assets.map((a) => a.id) : []), [assets]);
320
326
  // Auto-select an asset uploaded FROM this field once it lands in the library.
321
327
  useEffect(() => {
@@ -325,61 +331,245 @@ function AssetField({ field, value, onChange, readOnly, }) {
325
331
  if (added.length === 0)
326
332
  return;
327
333
  pendingRef.current = null;
328
- if (many)
329
- onChange([...ids, ...added]);
330
- else
331
- onChange(applyAssetSelection(value, added[0], 'one'));
334
+ if (many) {
335
+ const current = Array.isArray(value) ? value : [];
336
+ onChange([...current, ...added.map((id) => (hasSub ? assetNode(id) : id))]);
337
+ }
338
+ else {
339
+ onChange(hasSub ? assetNode(added[0], assetSubfieldsOf(value)) : applyAssetSelection(value, added[0], 'one'));
340
+ }
332
341
  // Reacts to the library set growing (an upload landing), not to unrelated
333
- // value edits — `ids`/`value`/`onChange` are read from the closure on purpose.
334
- }, [libraryIds, ids, many, onChange, value]);
335
- function pickFiles() {
336
- fileInputRef.current?.click();
337
- }
338
- function onFiles(e) {
339
- const files = e.target.files;
340
- if (!assets || !files || files.length === 0)
342
+ // value edits — `value`/`onChange` are read from the closure on purpose.
343
+ }, [libraryIds, many, hasSub, onChange, value]);
344
+ function uploadFiles(files) {
345
+ if (!assets || files.length === 0)
346
+ return;
347
+ // EC-301: a drag-drop bypasses the file dialog's `accept` filter — re-check each
348
+ // file against the field's `accept` and surface a visible issue for the rejects
349
+ // instead of silently uploading the wrong kind.
350
+ const ok = [];
351
+ const bad = [];
352
+ for (const file of files) {
353
+ if (assetMatchesAccept({ contentType: file.type, filename: file.name }, field.accept))
354
+ ok.push(file);
355
+ else
356
+ bad.push(file.name);
357
+ }
358
+ setRejected(bad);
359
+ if (ok.length === 0)
341
360
  return;
361
+ // Remember the library set so the effect above can learn the new id and
362
+ // auto-select it once the upload lands (the upload API returns nothing).
342
363
  pendingRef.current = new Set(libraryIds);
343
- assets.upload(Array.from(files));
344
- e.target.value = ''; // allow re-uploading the same file
364
+ assets.upload(ok);
365
+ }
366
+ // Apply a picked asset id, preserving a placement's per-use sub-fields (EC-301).
367
+ // Without sub-fields it stays the plain id-list behaviour (self-typed on save).
368
+ function applyPicked(picked) {
369
+ if (!hasSub) {
370
+ onChange(applyAssetSelection(value, picked, field.cardinality));
371
+ return;
372
+ }
373
+ if (many) {
374
+ if (picked === null || entries.some((e) => readAssetId(e) === picked))
375
+ return;
376
+ onChange([...entries, assetNode(picked)]);
377
+ }
378
+ else {
379
+ onChange(picked ? assetNode(picked, assetSubfieldsOf(value)) : undefined);
380
+ }
345
381
  }
346
382
  const canUpload = !readOnly && assets !== null;
347
- return (_jsxs(Stack, { gap: "2", children: [_jsxs(Row, { gap: "2", wrap: true, align: "center", children: [many ? (ids.length > 0 ? (ids.map((aid, i) => (_jsx(AssetChip, { assetId: aid, collectionId: assetCollectionId, onRemove: readOnly ? undefined : () => onChange(ids.filter((x) => x !== aid)) }, `${aid}-${i}`)))) : (_jsx(Text, { size: "sm", tone: "subtle", children: "No assets" }))) : single ? (_jsx(AssetChip, { assetId: single, collectionId: assetCollectionId })) : (_jsx(Text, { size: "sm", tone: "subtle", children: "No asset" })), _jsx(Spacer, {}), field.accept && field.accept.length > 0 ? (_jsxs(Code, { tone: "muted", children: ["accepts ", field.accept.join(', ')] })) : null, !readOnly ? (_jsxs(Button, { variant: "outline", size: "sm", onClick: () => setOpen(true), children: [_jsx(Pencil, {}), " ", many ? 'Add' : single ? 'Change' : 'Pick'] })) : null, canUpload ? (_jsxs(Button, { variant: "outline", size: "sm", onClick: pickFiles, children: [_jsx(Upload, {}), " Upload"] })) : null, _jsx("input", { ref: fileInputRef, type: "file", multiple: many, accept: acceptAttr(field.accept), onChange: onFiles, className: "hidden" })] }), !many && single && assets ? (_jsxs(Stack, { gap: "2", children: [!readOnly ? (_jsx(Row, { gap: "2", children: _jsxs(Button, { variant: "ghost", size: "sm", "aria-expanded": editing, onClick: () => setEditing((v) => !v), children: [_jsx(Crosshair, {}), " ", editing ? 'Hide image settings' : 'Image settings'] }) })) : null, editing ? _jsx(AssetMetaEditor, { assetId: single, readOnly: readOnly }) : null] })) : null, _jsx(AssetPicker, { value: single, open: open, onOpenChange: setOpen, onChange: (next) => onChange(applyAssetSelection(value, next, field.cardinality)) })] }));
383
+ // Drag-to-upload onto the field (EC-301): a depth counter keeps the overlay
384
+ // steady as the pointer crosses child element boundaries. `dragenter`/`dragleave`
385
+ // fire per descendant, so toggling on a bare `dragleave` blinks constantly; we
386
+ // only hide the overlay when the counter returns to zero (the pointer has truly
387
+ // left the field). The overlay is pointer-events-none, so it never fires its own
388
+ // enter/leave to confuse the count.
389
+ const dragDepth = useRef(0);
390
+ function onDragEnter(e) {
391
+ if (!canUpload)
392
+ return;
393
+ e.preventDefault();
394
+ dragDepth.current += 1;
395
+ if (!dragging)
396
+ setDragging(true);
397
+ }
398
+ function onDragOver(e) {
399
+ if (!canUpload)
400
+ return;
401
+ e.preventDefault(); // required so a drop is allowed
402
+ }
403
+ function onDragLeave() {
404
+ if (!canUpload)
405
+ return;
406
+ dragDepth.current -= 1;
407
+ if (dragDepth.current <= 0) {
408
+ dragDepth.current = 0;
409
+ setDragging(false);
410
+ }
411
+ }
412
+ function onDrop(e) {
413
+ if (!canUpload)
414
+ return;
415
+ e.preventDefault();
416
+ dragDepth.current = 0;
417
+ setDragging(false);
418
+ uploadFiles(Array.from(e.dataTransfer.files));
419
+ }
420
+ const dragProps = canUpload ? { onDragEnter, onDragOver, onDragLeave, onDrop } : {};
421
+ const selectButton = !readOnly ? (_jsxs(Button, { variant: "outline", size: "sm", onClick: () => setOpen(true), children: [_jsx(ImagePlus, {}), " Select"] })) : null;
422
+ 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 ? (
423
+ // Gallery with per-use sub-fields (EC-301): a card per placement — thumbnail +
424
+ // its own sub-field form — reusing the object/repeater item rendering.
425
+ _jsxs(Stack, { gap: "2", children: [entries.length > 0 ? (_jsx(Stack, { gap: "2", children: entries.map((entry, i) => {
426
+ const aid = readAssetId(entry);
427
+ if (!aid)
428
+ return null;
429
+ return (_jsx(AssetGalleryItem, { assetId: aid, collectionId: assetCollectionId, fields: subFields, value: assetSubfieldsOf(entry), readOnly: readOnly, idPrefix: `asset-${i}`, onChangeSubs: (next) => {
430
+ const nextEntries = entries.slice();
431
+ nextEntries[i] = assetNode(aid, next);
432
+ onChange(nextEntries);
433
+ }, onRemove: readOnly ? undefined : () => onChange(entries.filter((_, j) => j !== i)) }, `${aid}-${i}`));
434
+ }) })) : (_jsx(EmptyAssetState, { compact: compact, canUpload: canUpload, onSelect: () => setOpen(true) })), _jsx(Row, { gap: "2", wrap: true, align: "center", children: selectButton })] })) : many ? (
435
+ // Gallery: a grid of focal-aware previews + the single Select entry.
436
+ _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 ? (_jsxs(Stack, { gap: "3", children: [_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: single }) }), _jsxs(Stack, { gap: "2", className: cn('min-w-0', !compact && 'flex-1'), children: [_jsx(AssetInfo, { assetId: single, fallbackTitle: cms.titleOf({ collection: assetCollectionId, id: single }) }), !readOnly ? (_jsxs(Row, { gap: "2", wrap: true, align: "center", children: [selectButton, assets ? (_jsxs(Button, { variant: "outline", size: "sm", onClick: () => setEditing(true), children: [_jsx(SlidersHorizontal, {}), " Edit"] })) : null, _jsxs(Button, { variant: "ghost", size: "sm", onClick: () => onChange(applyAssetSelection(value, null, field.cardinality)), children: [_jsx(Trash2, {}), " Remove"] })] })) : null, _jsx(AssetAltLine, { assetId: single })] })] }), hasSub ? (_jsx(AssetSubfields, { fields: subFields, value: assetSubfieldsOf(value), readOnly: readOnly, idPrefix: "asset-sub", onChange: (next) => onChange(assetNode(single, next)) })) : null] })) : (_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" }), _jsxs(Text, { size: "xs", tone: "muted", children: [rejected.length === 1 ? `“${rejected[0]}”` : `${rejected.length} files`, " skipped \u2014 only", ' ', describeAccept(field.accept), " allowed here."] })] })) : 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: "Drop to upload" })] }) })) : null] }));
437
+ }
438
+ /** Empty-state placeholder shown when an asset field has no selection (EC-301). */
439
+ function EmptyAssetState({ compact, canUpload, onSelect, }) {
440
+ 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 ? 'Select or drop an image' : 'Select an asset' })] }));
441
+ }
442
+ /** A large focal-aware preview of the selected asset (EC-301). */
443
+ function AssetPreview({ assetId }) {
444
+ const record = useAssetRecord(assetId);
445
+ return _jsx(AssetThumb, { record: record, className: "aspect-[4/3] w-full" });
446
+ }
447
+ /** Filename + dimensions/type for the selected asset — the id is never shown (EC-301). */
448
+ function AssetInfo({ assetId, fallbackTitle }) {
449
+ const record = useAssetRecord(assetId);
450
+ const name = record?.title ?? record?.filename ?? fallbackTitle ?? assetId;
451
+ const dims = record?.width && record?.height ? `${record.width}×${record.height}` : null;
452
+ const kind = record?.contentType?.split('/')[1]?.toUpperCase();
453
+ const meta = [dims, kind].filter(Boolean).join(' · ');
454
+ 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] }));
455
+ }
456
+ /**
457
+ * The selected image's alt text shown in the field (EC-301) — what an editor most
458
+ * wants at a glance; edited in the options dialog. Reads `meta.alt` with the legacy
459
+ * `alt` column as fallback, matching delivery.
460
+ */
461
+ function AssetAltLine({ assetId }) {
462
+ const record = useAssetRecord(assetId);
463
+ const metaAlt = typeof record?.meta?.alt === 'string' ? record.meta.alt : undefined;
464
+ const alt = metaAlt ?? record?.alt;
465
+ return (_jsxs(Row, { gap: "1", align: "baseline", className: "min-w-0", children: [_jsx(Text, { size: "xs", tone: "subtle", className: "shrink-0", children: "Alt:" }), _jsx(Text, { size: "xs", tone: alt ? 'muted' : 'subtle', className: cn('min-w-0 truncate', !alt && 'italic'), title: alt ?? undefined, children: alt || 'not set' })] }));
348
466
  }
349
467
  /** A single asset reference: focal-aware thumbnail + title, with an optional remove. */
350
468
  function AssetChip({ assetId, collectionId, onRemove, }) {
351
469
  const cms = useCms();
352
470
  const record = useAssetRecord(assetId);
353
471
  const title = cms.titleOf({ collection: collectionId, id: assetId });
354
- return (_jsxs("span", { className: "inline-flex min-w-0 items-center gap-1.5 rounded border border-border bg-muted/40 py-0.5 pl-0.5 pr-2", children: [_jsx(AssetThumb, { record: record, alt: title, className: "size-7" }), _jsx(Text, { size: "xs", tone: "subtle", className: "max-w-[10rem] truncate", children: title }), onRemove ? (_jsx("button", { type: "button", "aria-label": "Remove", onClick: onRemove, className: "hover:text-destructive", children: _jsx(X, { className: "size-3" }) })) : null] }));
472
+ 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": "Remove", onClick: onRemove, className: "text-muted-foreground hover:text-destructive", children: _jsx(X, { className: "size-3.5" }) })) : null] })] }));
473
+ }
474
+ /**
475
+ * The per-use sub-field group shown below a single asset (EC-301): a placement's own
476
+ * metadata (a `caption` here), distinct from the asset-level metadata in the options
477
+ * dialog. Reuses the object/repeater item form, so any sub-field type works for free.
478
+ */
479
+ function AssetSubfields({ fields, value, onChange, readOnly, idPrefix, }) {
480
+ return (_jsxs(Card, { className: "border-l-2 border-l-border px-3 pb-3 pt-2", children: [_jsx(Text, { size: "xs", tone: "subtle", className: "font-medium uppercase tracking-wide", children: "This use" }), _jsx(ObjectItemForm, { fields: fields, value: value, onChange: onChange, readOnly: readOnly, idPrefix: idPrefix })] }));
481
+ }
482
+ /**
483
+ * One gallery placement when a `many` asset field declares per-use sub-fields (EC-301):
484
+ * a thumbnail + filename + remove header over the placement's own sub-field form. The
485
+ * `many` analogue of {@link AssetSubfields}, modelled on a repeater item card.
486
+ */
487
+ function AssetGalleryItem({ assetId, collectionId, fields, value, onChangeSubs, onRemove, readOnly, idPrefix, }) {
488
+ const cms = useCms();
489
+ const record = useAssetRecord(assetId);
490
+ const title = cms.titleOf({ collection: collectionId, id: assetId });
491
+ 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": "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 }) })] }));
355
492
  }
356
493
  /**
357
- * Inline editor for a selected image's authoring metadata (EC-248): the focal
358
- * point (click-to-place over the preview) and alt text, both persisted on the
359
- * asset document via the EC-152 metadata path. Alt commits on blur; the focal
360
- * point commits per placement. No-ops without a mounted asset library.
494
+ * System columns the upload pipeline owns — never editable metadata, so they're
495
+ * excluded from the asset collection's schema-driven options form (EC-301). The
496
+ * filename + dimensions already show in the field's info column.
361
497
  */
362
- function AssetMetaEditor({ assetId, readOnly }) {
498
+ const SYSTEM_ASSET_FIELDS = new Set([
499
+ 'filename',
500
+ 'url',
501
+ 'contentType',
502
+ 'byteSize',
503
+ 'width',
504
+ 'height',
505
+ 'storageKey',
506
+ 'title',
507
+ ]);
508
+ /** Drop `undefined` values so the persisted meta bag stays JSON/Convex-clean. */
509
+ function pruneMeta(meta) {
510
+ return Object.fromEntries(Object.entries(meta).filter(([, v]) => v !== undefined));
511
+ }
512
+ /**
513
+ * The image options dialog (EC-301): the focal point (engine-special visual
514
+ * control) + the asset's **schema-driven** metadata, rendered from the
515
+ * `kind:'asset'` collection's `fields` through the same generic field engine
516
+ * documents use — so a project declares asset metadata (alt, copyright, credit, …)
517
+ * in code, not hardcoded here. Edits persist on the asset, shared across every use.
518
+ * Metadata is buffered locally and committed once on close (one write); the focal
519
+ * point commits immediately (it drives a live visual). No-ops without a library.
520
+ */
521
+ function AssetOptionsDialog({ assetId, readOnly, open, onOpenChange, }) {
522
+ const cms = useCms();
363
523
  const assets = useOptionalAssets();
364
524
  const record = useAssetRecord(assetId);
365
- const [alt, setAlt] = useState(record?.alt ?? '');
366
- const recordAlt = record?.alt ?? '';
367
- // Re-seed the local buffer when the underlying asset (or its stored alt) changes.
525
+ const metaFields = useMemo(() => {
526
+ const collection = cms.collections.find((c) => c.kind === 'asset');
527
+ return (collection?.fields ?? []).filter((f) => !SYSTEM_ASSET_FIELDS.has(f.name) && !f.form?.readOnly);
528
+ }, [cms]);
529
+ // Local edit buffers, seeded once per open from the record (so an external edit
530
+ // still wins between opens). Metadata is non-localized in v1 (the asset
531
+ // resolution path is locale-agnostic) — localized asset metadata is a follow-up.
532
+ const [meta, setMeta] = useState({});
533
+ const [focal, setFocal] = useState(undefined);
534
+ const dirty = useRef(false);
535
+ const seeded = useRef(false);
368
536
  useEffect(() => {
369
- setAlt(recordAlt);
370
- }, [assetId, recordAlt]);
371
- if (!assets)
372
- return null;
373
- function commitFocal(point) {
374
- if (readOnly)
537
+ if (!open) {
538
+ seeded.current = false;
375
539
  return;
376
- void assets.updateMetadata(assetId, { focalPoint: point });
540
+ }
541
+ if (seeded.current || !record)
542
+ return;
543
+ // Seed from the meta bag, but lift the legacy `alt` column in when meta lacks
544
+ // it (EC-301 transition): a not-yet-migrated asset still shows its alt, and
545
+ // saving writes it into meta — a lazy migration-on-edit, matching delivery's
546
+ // `meta.alt ?? record.alt` read fallback.
547
+ const seedMeta = { ...(record.alt !== undefined ? { alt: record.alt } : {}), ...(record.meta ?? {}) };
548
+ setMeta(seedMeta);
549
+ setFocal(record.focalPoint);
550
+ dirty.current = false;
551
+ seeded.current = true;
552
+ }, [open, record]);
553
+ function handleOpenChange(next) {
554
+ // Commit the buffered metadata once, on close (one write, not per keystroke).
555
+ if (!next && !readOnly && assets && dirty.current) {
556
+ void assets.updateMetadata(assetId, { meta: pruneMeta(meta) });
557
+ dirty.current = false;
558
+ }
559
+ onOpenChange(next);
377
560
  }
378
- function commitAlt() {
379
- if (readOnly || alt === recordAlt)
561
+ function setField(name, value) {
562
+ dirty.current = true;
563
+ setMeta((m) => ({ ...m, [name]: value }));
564
+ }
565
+ // The focal point drives a live crop preview → commit immediately (optimistic
566
+ // marker), not deferred to close.
567
+ function commitFocal(point) {
568
+ if (readOnly || !assets)
380
569
  return;
381
- void assets.updateMetadata(assetId, { alt });
570
+ setFocal(point ?? undefined);
571
+ void assets.updateMetadata(assetId, { focalPoint: point });
382
572
  }
383
- return (_jsxs(Stack, { gap: "2", className: "rounded-md border border-border bg-muted/30 p-3", children: [_jsx(Field, { label: "Focal point", hint: "Click the image to set the focal point used when it\u2019s cropped.", children: _jsx(FocalPointPicker, { record: record, point: record?.focalPoint, onChange: commitFocal, disabled: readOnly }) }), record?.focalPoint && !readOnly ? (_jsx(Row, { children: _jsx(Button, { variant: "ghost", size: "sm", onClick: () => commitFocal(null), children: "Reset to centre" }) })) : null, _jsx(Field, { label: "Alt text", hint: "Describes the image for screen readers and search.", children: _jsx(Input, { value: alt, readOnly: readOnly, onChange: (e) => setAlt(e.target.value), onBlur: commitAlt, placeholder: "Describe this image\u2026" }) })] }));
573
+ return (_jsx(Dialog, { open: open, onOpenChange: handleOpenChange, children: _jsxs(DialogContent, { className: "max-w-lg", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: "Image options" }), _jsx(DialogDescription, { children: "Focal point and metadata \u2014 shared across every use of this image." })] }), _jsxs(Stack, { gap: "3", children: [_jsxs(Stack, { gap: "2", children: [_jsxs(Row, { align: "center", children: [_jsx(Text, { size: "sm", className: "font-medium", children: "Focal point" }), _jsx(Spacer, {}), focal && !readOnly ? (_jsxs(Button, { variant: "ghost", size: "sm", onClick: () => commitFocal(null), children: [_jsx(RotateCcw, {}), " Reset to centre"] })) : null] }), _jsx(FocalPointPicker, { record: record, point: focal, onChange: commitFocal, onPreview: setFocal, disabled: readOnly }), _jsx(Text, { size: "xs", tone: "subtle", children: "Click or drag on the image to set the focal point used when it\u2019s cropped." }), _jsx(FocalCropPreviews, { record: record, point: focal })] }), metaFields.map((f) => (_jsx(Field, { label: fieldLabel(f), hint: f.form?.description, 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] })] }) }));
384
574
  }
385
575
  //# sourceMappingURL=field-control.js.map