@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.
- package/convex/assets.ts +2 -0
- package/convex/content.ts +6 -0
- package/convex/delivery.ts +5 -0
- package/convex/schema.ts +2 -0
- package/dist/components/assets/asset-metadata.d.ts +7 -0
- package/dist/components/assets/asset-metadata.js +3 -0
- package/dist/components/assets/asset-metadata.js.map +1 -1
- package/dist/components/assets/asset-thumb.d.ts +19 -6
- package/dist/components/assets/asset-thumb.js +48 -10
- package/dist/components/assets/asset-thumb.js.map +1 -1
- package/dist/components/assets/assets-store.js +9 -3
- package/dist/components/assets/assets-store.js.map +1 -1
- package/dist/components/builder/property-inspector.js +2 -1
- package/dist/components/builder/property-inspector.js.map +1 -1
- package/dist/components/cms/asset-picker.d.ts +6 -2
- package/dist/components/cms/asset-picker.js +70 -12
- package/dist/components/cms/asset-picker.js.map +1 -1
- package/dist/components/cms/detail-editor.js +1 -1
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/field-control.js +238 -48
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/field-density.d.ts +14 -0
- package/dist/components/cms/field-density.js +10 -0
- package/dist/components/cms/field-density.js.map +1 -0
- package/dist/lib/persistence/convex-adapter.js +2 -0
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/package.json +2 -2
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { ChevronDown, ChevronUp,
|
|
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
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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 — `
|
|
334
|
-
}, [libraryIds,
|
|
335
|
-
function
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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(
|
|
344
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
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
|
-
|
|
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
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
if (!assets)
|
|
372
|
-
return null;
|
|
373
|
-
function commitFocal(point) {
|
|
374
|
-
if (readOnly)
|
|
537
|
+
if (!open) {
|
|
538
|
+
seeded.current = false;
|
|
375
539
|
return;
|
|
376
|
-
|
|
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
|
|
379
|
-
|
|
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
|
-
|
|
570
|
+
setFocal(point ?? undefined);
|
|
571
|
+
void assets.updateMetadata(assetId, { focalPoint: point });
|
|
382
572
|
}
|
|
383
|
-
return (
|
|
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
|