@aphexcms/cms-core 7.0.0 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/dist/api/documents.d.ts +19 -0
  2. package/dist/api/documents.d.ts.map +1 -1
  3. package/dist/api/documents.js +21 -0
  4. package/dist/api/schemas/documents.d.ts +3 -0
  5. package/dist/api/schemas/documents.d.ts.map +1 -1
  6. package/dist/api/schemas/documents.js +12 -0
  7. package/dist/cli/generate-types.d.ts.map +1 -1
  8. package/dist/cli/generate-types.js +133 -19
  9. package/dist/cli/generate-types.js.map +1 -1
  10. package/dist/components/AdminApp.svelte +106 -52
  11. package/dist/components/AdminApp.svelte.d.ts.map +1 -1
  12. package/dist/components/admin/DocumentEditor.svelte +65 -2
  13. package/dist/components/admin/DocumentEditor.svelte.d.ts.map +1 -1
  14. package/dist/components/admin/confirm-dialog/ConfirmDialogHost.svelte +8 -6
  15. package/dist/components/admin/confirm-dialog/ConfirmDialogHost.svelte.d.ts.map +1 -1
  16. package/dist/components/admin/fields/ArrayField.svelte +149 -2
  17. package/dist/components/admin/fields/ArrayField.svelte.d.ts.map +1 -1
  18. package/dist/components/admin/fields/ReferenceField.svelte +198 -125
  19. package/dist/components/admin/fields/ReferenceField.svelte.d.ts +9 -2
  20. package/dist/components/admin/fields/ReferenceField.svelte.d.ts.map +1 -1
  21. package/dist/components/layout/Sidebar.svelte +1 -1
  22. package/dist/components/layout/sidebar/AppSidebar.svelte +2 -1
  23. package/dist/components/layout/sidebar/AppSidebar.svelte.d.ts.map +1 -1
  24. package/dist/components/layout/sidebar/NavMain.svelte +9 -2
  25. package/dist/components/layout/sidebar/NavMain.svelte.d.ts +2 -0
  26. package/dist/components/layout/sidebar/NavMain.svelte.d.ts.map +1 -1
  27. package/dist/db/interfaces/index.d.ts +3 -1
  28. package/dist/db/interfaces/index.d.ts.map +1 -1
  29. package/dist/db/interfaces/reference.d.ts +43 -0
  30. package/dist/db/interfaces/reference.d.ts.map +1 -0
  31. package/dist/db/interfaces/reference.js +4 -0
  32. package/dist/db/utils/reference-resolver.d.ts +4 -6
  33. package/dist/db/utils/reference-resolver.d.ts.map +1 -1
  34. package/dist/db/utils/reference-resolver.js +33 -31
  35. package/dist/document-refresh.svelte.d.ts +3 -0
  36. package/dist/document-refresh.svelte.d.ts.map +1 -0
  37. package/dist/document-refresh.svelte.js +21 -0
  38. package/dist/graphql/resolvers.d.ts.map +1 -1
  39. package/dist/graphql/resolvers.js +61 -1
  40. package/dist/graphql/schema.d.ts.map +1 -1
  41. package/dist/graphql/schema.js +10 -0
  42. package/dist/lib/api/documents.d.ts +19 -0
  43. package/dist/lib/api/documents.d.ts.map +1 -1
  44. package/dist/lib/api/documents.js +21 -0
  45. package/dist/lib/api/documents.js.map +1 -1
  46. package/dist/lib/api/schemas/documents.d.ts +3 -0
  47. package/dist/lib/api/schemas/documents.d.ts.map +1 -1
  48. package/dist/lib/api/schemas/documents.js +12 -0
  49. package/dist/lib/api/schemas/documents.js.map +1 -1
  50. package/dist/lib/db/interfaces/index.d.ts +3 -1
  51. package/dist/lib/db/interfaces/index.d.ts.map +1 -1
  52. package/dist/lib/db/interfaces/reference.d.ts +43 -0
  53. package/dist/lib/db/interfaces/reference.d.ts.map +1 -0
  54. package/dist/lib/db/interfaces/reference.js +5 -0
  55. package/dist/lib/db/interfaces/reference.js.map +1 -0
  56. package/dist/lib/db/utils/reference-resolver.d.ts +4 -6
  57. package/dist/lib/db/utils/reference-resolver.d.ts.map +1 -1
  58. package/dist/lib/db/utils/reference-resolver.js +33 -31
  59. package/dist/lib/db/utils/reference-resolver.js.map +1 -1
  60. package/dist/lib/document-refresh.svelte.d.ts +3 -0
  61. package/dist/lib/document-refresh.svelte.d.ts.map +1 -0
  62. package/dist/lib/document-refresh.svelte.js +22 -0
  63. package/dist/lib/document-refresh.svelte.js.map +1 -0
  64. package/dist/lib/graphql/resolvers.d.ts.map +1 -1
  65. package/dist/lib/graphql/resolvers.js +61 -1
  66. package/dist/lib/graphql/resolvers.js.map +1 -1
  67. package/dist/lib/graphql/schema.d.ts.map +1 -1
  68. package/dist/lib/graphql/schema.js +10 -0
  69. package/dist/lib/graphql/schema.js.map +1 -1
  70. package/dist/lib/local-api/collection-api.d.ts +10 -1
  71. package/dist/lib/local-api/collection-api.d.ts.map +1 -1
  72. package/dist/lib/local-api/collection-api.js +39 -1
  73. package/dist/lib/local-api/collection-api.js.map +1 -1
  74. package/dist/lib/local-api/index.d.ts +29 -0
  75. package/dist/lib/local-api/index.d.ts.map +1 -1
  76. package/dist/lib/local-api/index.js +66 -2
  77. package/dist/lib/local-api/index.js.map +1 -1
  78. package/dist/lib/schema-utils/validator.js +21 -11
  79. package/dist/lib/schema-utils/validator.js.map +1 -1
  80. package/dist/lib/server/api/index.d.ts.map +1 -1
  81. package/dist/lib/server/api/index.js +3 -1
  82. package/dist/lib/server/api/index.js.map +1 -1
  83. package/dist/lib/server/api/routes/documents-by-id.d.ts.map +1 -1
  84. package/dist/lib/server/api/routes/documents-by-id.js +23 -0
  85. package/dist/lib/server/api/routes/documents-by-id.js.map +1 -1
  86. package/dist/lib/server/api/routes/documents.d.ts.map +1 -1
  87. package/dist/lib/server/api/routes/documents.js +29 -1
  88. package/dist/lib/server/api/routes/documents.js.map +1 -1
  89. package/dist/lib/services/references-service.d.ts +35 -0
  90. package/dist/lib/services/references-service.d.ts.map +1 -0
  91. package/dist/lib/services/references-service.js +60 -0
  92. package/dist/lib/services/references-service.js.map +1 -0
  93. package/dist/lib/types/schemas.d.ts +8 -0
  94. package/dist/lib/types/schemas.d.ts.map +1 -1
  95. package/dist/lib/utils/preview.d.ts +2 -3
  96. package/dist/lib/utils/preview.d.ts.map +1 -1
  97. package/dist/lib/utils/preview.js +35 -8
  98. package/dist/lib/utils/preview.js.map +1 -1
  99. package/dist/lib/utils/reference-walk.d.ts +8 -0
  100. package/dist/lib/utils/reference-walk.d.ts.map +1 -0
  101. package/dist/lib/utils/reference-walk.js +43 -0
  102. package/dist/lib/utils/reference-walk.js.map +1 -0
  103. package/dist/local-api/collection-api.d.ts +10 -1
  104. package/dist/local-api/collection-api.d.ts.map +1 -1
  105. package/dist/local-api/collection-api.js +39 -1
  106. package/dist/local-api/index.d.ts +29 -0
  107. package/dist/local-api/index.d.ts.map +1 -1
  108. package/dist/local-api/index.js +66 -2
  109. package/dist/schema-utils/validator.js +21 -11
  110. package/dist/server/api/index.d.ts.map +1 -1
  111. package/dist/server/api/index.js +3 -1
  112. package/dist/server/api/routes/documents-by-id.d.ts.map +1 -1
  113. package/dist/server/api/routes/documents-by-id.js +23 -0
  114. package/dist/server/api/routes/documents.d.ts.map +1 -1
  115. package/dist/server/api/routes/documents.js +29 -1
  116. package/dist/services/references-service.d.ts +35 -0
  117. package/dist/services/references-service.d.ts.map +1 -0
  118. package/dist/services/references-service.js +59 -0
  119. package/dist/types/schemas.d.ts +8 -0
  120. package/dist/types/schemas.d.ts.map +1 -1
  121. package/dist/utils/preview.d.ts +2 -3
  122. package/dist/utils/preview.d.ts.map +1 -1
  123. package/dist/utils/preview.js +35 -8
  124. package/dist/utils/reference-walk.d.ts +8 -0
  125. package/dist/utils/reference-walk.d.ts.map +1 -0
  126. package/dist/utils/reference-walk.js +42 -0
  127. package/package.json +3 -3
@@ -10,6 +10,7 @@
10
10
  import { getSchemaContext } from '../../../schema-context.svelte';
11
11
  import ObjectModal from '../ObjectModal.svelte';
12
12
  import ImageField from './ImageField.svelte';
13
+ import ReferenceField from './ReferenceField.svelte';
13
14
  import { getDefaultValueForFieldType } from '../../../utils/field-defaults';
14
15
  import { DragDropProvider } from '@dnd-kit/svelte';
15
16
  import { createSortable, isSortable } from '@dnd-kit/svelte/sortable';
@@ -29,6 +30,9 @@
29
30
  import { toast } from 'svelte-sonner';
30
31
  import AssetBrowserModal from '../AssetBrowserModal.svelte';
31
32
  import { resolvePreviewTitle, resolvePreviewSubtitle } from '../../../utils/preview';
33
+ import { documents } from '../../../api/documents';
34
+ import { SvelteMap } from 'svelte/reactivity';
35
+ import { getDocumentVersion } from '../../../document-refresh.svelte';
32
36
 
33
37
  interface Props {
34
38
  field: ArrayFieldType;
@@ -70,8 +74,12 @@
70
74
  }
71
75
 
72
76
  const isGridLayout = $derived(field.options?.layout === 'grid');
77
+ const isReferenceArray = $derived(
78
+ field.of && field.of.length > 0 && field.of[0]?.type === 'reference'
79
+ );
73
80
  const isPrimitiveArray = $derived(
74
- field.of &&
81
+ !isReferenceArray &&
82
+ field.of &&
75
83
  field.of.length > 0 &&
76
84
  field.of[0]?.type &&
77
85
  !field.of[0].fields &&
@@ -80,6 +88,48 @@
80
88
  const primitiveType = $derived(isPrimitiveArray ? field.of?.[0]?.type : null);
81
89
  const availableTypes = $derived(getArrayTypes(schemas, field));
82
90
 
91
+ // Synthetic ReferenceField definition reused for every row in an
92
+ // array-of-references. We forward the array's first `of[]` entry's `to` so
93
+ // the row picker honors the schema-author's allowed target types.
94
+ const referenceFieldShape = $derived(
95
+ isReferenceArray
96
+ ? {
97
+ name: field.name,
98
+ type: 'reference' as const,
99
+ title: field.title,
100
+ to: (field.of?.[0] as any)?.to ?? []
101
+ }
102
+ : null
103
+ );
104
+
105
+ // Batched hydration cache for reference rows. Without this, each row's
106
+ // ReferenceField fires its own getById on mount → N round-trips for an
107
+ // array of N refs. Instead we fetch all populated refs in a single call
108
+ // and hand each row its preloaded doc. The cache invalidates whenever
109
+ // any row's referenced doc bumps its document-refresh version.
110
+ const referenceCache = new SvelteMap<string, any>();
111
+ $effect(() => {
112
+ if (!isReferenceArray) return;
113
+ const ids = (Array.isArray(value) ? value : [])
114
+ .map((item: any) => item?._ref)
115
+ .filter((id: any): id is string => typeof id === 'string' && id.length > 0);
116
+ if (ids.length === 0) return;
117
+ // Subscribe to each id's version so external saves trigger a refetch.
118
+ for (const id of ids) getDocumentVersion(id);
119
+ (async () => {
120
+ try {
121
+ const res = await documents.getMany(ids);
122
+ if (res.success && res.data) {
123
+ for (const doc of res.data as any[]) {
124
+ if (doc?.id) referenceCache.set(doc.id, doc);
125
+ }
126
+ }
127
+ } catch {
128
+ // Per-row ReferenceField will fall back to its own fetch on miss.
129
+ }
130
+ })();
131
+ });
132
+
83
133
  // Modal state
84
134
  let modalOpen = $state(false);
85
135
  let editingIndex = $state<number | null>(null);
@@ -376,6 +426,29 @@
376
426
  onUpdate(newArray);
377
427
  }
378
428
 
429
+ // Reference array — append an empty reference row. The row renders the
430
+ // ReferenceField empty state (search picker), so the user picks the target
431
+ // document inline without an extra modal step.
432
+ function handleAddReference() {
433
+ if (readonly) return;
434
+ const newItem = { _type: 'reference', _key: generateKey(), _ref: null };
435
+ onUpdate([...keyedItems, newItem]);
436
+ }
437
+
438
+ function handleUpdateReference(
439
+ index: number,
440
+ newRef: { _type: 'reference'; _ref: string; _key?: string } | null
441
+ ) {
442
+ if (readonly) return;
443
+ const newArray = [...keyedItems];
444
+ // Preserve the row's _key (stable across drag/drop) and only swap the _ref.
445
+ newArray[index] = {
446
+ ...newArray[index],
447
+ _ref: newRef?._ref ?? null
448
+ };
449
+ onUpdate(newArray);
450
+ }
451
+
379
452
  // Write-through: propagate every field change from the modal to the array immediately
380
453
  function handleModalUpdate(updatedData: Record<string, any>) {
381
454
  if (editingIndex === null || !editingType) return;
@@ -421,7 +494,81 @@
421
494
  }
422
495
  </script>
423
496
 
424
- {#if isPrimitiveArray}
497
+ {#if isReferenceArray && referenceFieldShape}
498
+ <!-- Reference array — each row reuses ReferenceField. Drag handle on the
499
+ left, ReferenceField fills the row, an X button on empty rows lets the
500
+ user discard a never-picked row (populated rows use ReferenceField's
501
+ own context menu, which we override via onRemove to drop the row). -->
502
+ {#if keyedItems.length > 0}
503
+ {#key dndKey}
504
+ <DragDropProvider onDragEnd={handleDragEnd}>
505
+ <div class="space-y-1">
506
+ {#each keyedItems as item, index (item._key)}
507
+ {@const sortable = createSortable({ id: item._key, index, disabled: readonly })}
508
+ <div
509
+ {@attach sortable.attach}
510
+ class="flex items-center gap-1"
511
+ class:opacity-50={sortable.isDragging}
512
+ >
513
+ {#if !readonly}
514
+ <button
515
+ {@attach sortable.attachHandle}
516
+ class="text-muted-foreground hover:text-foreground flex h-9 w-7 shrink-0 cursor-grab items-center justify-center active:cursor-grabbing"
517
+ >
518
+ <GripVertical class="h-4 w-4" />
519
+ </button>
520
+ {/if}
521
+ <div class="min-w-0 flex-1">
522
+ <ReferenceField
523
+ field={referenceFieldShape}
524
+ value={item._ref ? { _type: 'reference', _ref: item._ref } : null}
525
+ onUpdate={(newRef) => handleUpdateReference(index, newRef)}
526
+ {onOpenReference}
527
+ {readonly}
528
+ onRemove={() => handleRemoveItem(index)}
529
+ preloadedDoc={item._ref ? referenceCache.get(item._ref) : undefined}
530
+ />
531
+ </div>
532
+ {#if !readonly && !item._ref}
533
+ <DropdownMenu.Root>
534
+ <DropdownMenu.Trigger>
535
+ {#snippet child({ props })}
536
+ <button
537
+ {...props}
538
+ class="text-muted-foreground hover:text-foreground flex h-9 w-7 shrink-0 items-center justify-center rounded transition-colors"
539
+ aria-label="Row options"
540
+ >
541
+ <Ellipsis class="h-4 w-4" />
542
+ </button>
543
+ {/snippet}
544
+ </DropdownMenu.Trigger>
545
+ <DropdownMenu.Content align="end">
546
+ <DropdownMenu.Item
547
+ class="text-destructive focus:text-destructive"
548
+ onclick={() => handleRemoveItem(index)}
549
+ >
550
+ <Trash2 class="mr-2 h-4 w-4" />
551
+ Remove
552
+ </DropdownMenu.Item>
553
+ </DropdownMenu.Content>
554
+ </DropdownMenu.Root>
555
+ {/if}
556
+ </div>
557
+ {/each}
558
+ </div>
559
+ </DragDropProvider>
560
+ {/key}
561
+ {/if}
562
+ {#if !readonly}
563
+ <button
564
+ class="border-border/50 text-muted-foreground hover:text-foreground hover:bg-muted/50 mt-1 flex h-10 w-full items-center justify-center gap-2 rounded border border-dashed text-sm transition-colors"
565
+ onclick={handleAddReference}
566
+ >
567
+ <Plus class="h-4 w-4" />
568
+ Add reference...
569
+ </button>
570
+ {/if}
571
+ {:else if isPrimitiveArray}
425
572
  <!-- Primitive array UI with DnD -->
426
573
  {#if arrayValue.length === 0}
427
574
  <div
@@ -1 +1 @@
1
- {"version":3,"file":"ArrayField.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/admin/fields/ArrayField.svelte.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,UAAU,IAAI,cAAc,EAAc,MAAM,wBAAwB,CAAC;AA0BtF,UAAU,KAAK;IACd,KAAK,EAAE,cAAc,CAAC;IACtB,KAAK,EAAE,GAAG,CAAC;IACX,QAAQ,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,IAAI,CAAC;IAC/B,eAAe,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,IAAI,CAAC;IACrE,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,CAAC;CACxB;AA8xBF,QAAA,MAAM,UAAU,2CAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
1
+ {"version":3,"file":"ArrayField.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/admin/fields/ArrayField.svelte.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,UAAU,IAAI,cAAc,EAAc,MAAM,wBAAwB,CAAC;AA8BtF,UAAU,KAAK;IACd,KAAK,EAAE,cAAc,CAAC;IACtB,KAAK,EAAE,GAAG,CAAC;IACX,QAAQ,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,IAAI,CAAC;IAC/B,eAAe,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,IAAI,CAAC;IACrE,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,cAAc,CAAC,EAAE,MAAM,CAAC;CACxB;AAq5BF,QAAA,MAAM,UAAU,2CAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
@@ -1,31 +1,45 @@
1
1
  <script lang="ts">
2
2
  import CheckIcon from '@lucide/svelte/icons/check';
3
3
  import PlusIcon from '@lucide/svelte/icons/plus';
4
- import { tick } from 'svelte';
5
4
  import * as Command from '@aphexcms/ui/shadcn/command';
6
- import * as Popover from '@aphexcms/ui/shadcn/popover';
7
5
  import * as DropdownMenu from '@aphexcms/ui/shadcn/dropdown-menu';
8
6
  import { Button } from '@aphexcms/ui/shadcn/button';
7
+ import { Input } from '@aphexcms/ui/shadcn/input';
9
8
  import { cn } from '@aphexcms/ui/utils';
10
- import { ExternalLink, Ellipsis, Trash2, FileText } from '@lucide/svelte';
9
+ import { ChevronDown, ExternalLink, Ellipsis, Trash2, FileText } from '@lucide/svelte';
11
10
  import type { Field, ReferenceField as ReferenceFieldType } from '../../../types/schemas';
12
11
  import { documents } from '../../../api/documents';
13
12
  import { getSchemaContext } from '../../../schema-context.svelte';
14
13
  import { getSchemaByName } from '../../../schema-utils/utils';
15
14
  import { toast } from 'svelte-sonner';
16
- import { cmsLogger } from '../../../utils/logger';
17
15
  import { pluralize } from '../../../utils/pluralize';
18
16
  import { resolvePreviewTitle, resolvePreviewSubtitle } from '../../../utils/preview';
17
+ import { getDocumentVersion } from '../../../document-refresh.svelte';
18
+
19
+ type ReferenceValue = { _type: 'reference'; _ref: string; _key?: string };
19
20
 
20
21
  interface Props {
21
22
  field: Field;
22
- value: string | null; // Document ID
23
- onUpdate: (value: string | null) => void;
23
+ value: ReferenceValue | null; // Reference wrapper — singular and array share this shape
24
+ onUpdate: (value: ReferenceValue | null) => void;
24
25
  onOpenReference?: (documentId: string, documentType: string) => void;
25
26
  readonly?: boolean;
27
+ onRemove?: () => void; // When set, the "Remove" menu item calls this instead of clearing the value (used by array-of-references rows)
28
+ preloadedDoc?: unknown; // When provided AND its id matches `value._ref`, skip the per-row getById and use this doc as `selectedDocument` (used by ArrayField to batch-hydrate reference rows in one HTTP call)
26
29
  }
27
30
 
28
- let { field, value, onUpdate, onOpenReference, readonly = false }: Props = $props();
31
+ let {
32
+ field,
33
+ value,
34
+ onUpdate,
35
+ onOpenReference,
36
+ readonly = false,
37
+ onRemove,
38
+ preloadedDoc
39
+ }: Props = $props();
40
+
41
+ // Pull the target ID off the wrapper (or null when nothing's selected).
42
+ const refId = $derived(value?._ref ?? null);
29
43
 
30
44
  // Cast to reference field type
31
45
  const referenceField = field as ReferenceFieldType;
@@ -41,14 +55,26 @@
41
55
  let selectedDocument = $state<any>(null);
42
56
  let loading = $state(false);
43
57
  let creating = $state(false);
44
- let triggerRef = $state<HTMLButtonElement>(null!);
58
+ let query = $state('');
45
59
 
46
- // Load selected document details when value changes
60
+ // Load selected document details when value changes — and re-load whenever
61
+ // the referenced document is saved elsewhere (the version counter bumps),
62
+ // so the row reflects edits made in a side-panel editor without needing
63
+ // the user to navigate away and back. When the parent (e.g. ArrayField)
64
+ // hands us a preloaded doc whose id matches `value`, use it directly and
65
+ // skip the network call.
47
66
  $effect(() => {
67
+ const id = refId;
68
+ // Subscribe to the document's version so this effect re-fires on save.
69
+ getDocumentVersion(id);
70
+ if (id && preloadedDoc && (preloadedDoc as any).id === id) {
71
+ selectedDocument = preloadedDoc;
72
+ return;
73
+ }
48
74
  async function loadDocument() {
49
- if (value) {
75
+ if (id) {
50
76
  try {
51
- const doc = await documents.getById(value);
77
+ const doc = await documents.getById(id);
52
78
  if (doc.success) {
53
79
  selectedDocument = doc.data;
54
80
  }
@@ -63,46 +89,84 @@
63
89
  loadDocument();
64
90
  });
65
91
 
66
- // Load documents when dropdown opens
92
+ // Reset query whenever the popover closes so the next open starts empty.
67
93
  $effect(() => {
68
- async function loadDocuments() {
69
- if (open && targetType) {
70
- cmsLogger.debug(
71
- '[ReferenceField] Opening select box, loading documents for type:',
72
- targetType
73
- );
74
- loading = true;
75
- try {
76
- const result = await documents.list({
77
- docType: targetType,
78
- limit: 10
79
- });
80
- cmsLogger.debug('[ReferenceField] Documents loaded:', result);
81
- if (result.success && result.data) {
82
- searchResults = result.data;
83
- cmsLogger.debug('[ReferenceField] Search results:', searchResults.length, 'documents');
84
- }
85
- } catch (err) {
86
- toast.error('Failed to load documents');
87
- searchResults = [];
88
- } finally {
89
- loading = false;
90
- }
94
+ if (!open) {
95
+ query = '';
96
+ searchResults = [];
97
+ }
98
+ });
99
+
100
+ // All docs cache — fetched once when the dropdown opens, reused for
101
+ // empty-query browsing and client-side filtering while typing.
102
+ let allDocs = $state<any[]>([]);
103
+ let allDocsFetched = $state(false);
104
+
105
+ async function fetchAllDocs() {
106
+ if (allDocsFetched || !targetType) return;
107
+ loading = true;
108
+ try {
109
+ const result = await documents.list({ docType: targetType, limit: 20 });
110
+ if (result.success && result.data) {
111
+ allDocs = result.data;
91
112
  }
113
+ } catch {
114
+ toast.error('Failed to load documents');
115
+ } finally {
116
+ allDocsFetched = true;
117
+ loading = false;
118
+ }
119
+ }
120
+
121
+ // Fetch docs when dropdown opens; reset cache when it closes.
122
+ $effect(() => {
123
+ if (open) {
124
+ fetchAllDocs();
125
+ } else {
126
+ query = '';
127
+ searchResults = [];
128
+ allDocs = [];
129
+ allDocsFetched = false;
92
130
  }
93
- loadDocuments();
131
+ });
132
+
133
+ // Filter results client-side as the user types. Empty query shows all.
134
+ $effect(() => {
135
+ const q = query.trim().toLowerCase();
136
+ if (!open) return;
137
+ if (!q) {
138
+ searchResults = allDocs;
139
+ return;
140
+ }
141
+ searchResults = allDocs.filter((doc: any) => {
142
+ const title = resolvePreviewTitle(doc, targetSchema).toLowerCase();
143
+ const subtitle = (resolvePreviewSubtitle(doc, targetSchema) ?? '').toLowerCase();
144
+ return title.includes(q) || subtitle.includes(q);
145
+ });
94
146
  });
95
147
 
96
148
  function closeAndFocusTrigger() {
97
149
  open = false;
98
- tick().then(() => {
99
- triggerRef?.focus();
100
- });
150
+ }
151
+
152
+ // Tiny click-outside action for the inline results dropdown — closes the
153
+ // dropdown when the user clicks anywhere outside the wrapper. Cheaper
154
+ // than reaching for a Popover and avoids the focus-stealing problem.
155
+ function clickOutside(node: HTMLElement, callback: () => void) {
156
+ function handle(e: MouseEvent) {
157
+ if (!node.contains(e.target as Node)) callback();
158
+ }
159
+ document.addEventListener('mousedown', handle);
160
+ return {
161
+ destroy() {
162
+ document.removeEventListener('mousedown', handle);
163
+ }
164
+ };
101
165
  }
102
166
 
103
167
  function selectDocument(doc: any) {
104
168
  if (readonly) return;
105
- onUpdate(doc.id);
169
+ onUpdate({ _type: 'reference', _ref: doc.id });
106
170
  closeAndFocusTrigger();
107
171
  }
108
172
 
@@ -125,13 +189,11 @@
125
189
  try {
126
190
  const result = await documents.create({
127
191
  type: targetType,
128
- data: {
129
- title: 'Untitled'
130
- }
192
+ data: {}
131
193
  });
132
194
 
133
195
  if (result.success && result.data) {
134
- onUpdate(result.data.id);
196
+ onUpdate({ _type: 'reference', _ref: result.data.id });
135
197
  closeAndFocusTrigger();
136
198
  }
137
199
  } catch (err) {
@@ -179,9 +241,9 @@
179
241
  >
180
242
  {/if}
181
243
  </span>
182
- {#if selectedDocument.status === 'published'}
244
+ {#if selectedDocument._meta?.status === 'published'}
183
245
  <span class="h-1.5 w-1.5 shrink-0 rounded-full bg-green-500" title="Published"></span>
184
- {:else if selectedDocument.status === 'unpublished'}
246
+ {:else if selectedDocument._meta?.status === 'unpublished'}
185
247
  <span class="bg-muted-foreground/60 h-1.5 w-1.5 shrink-0 rounded-full" title="Unpublished"
186
248
  ></span>
187
249
  {:else}
@@ -210,7 +272,7 @@
210
272
  <DropdownMenu.Separator />
211
273
  <DropdownMenu.Item
212
274
  class="text-destructive focus:text-destructive"
213
- onclick={clearSelection}
275
+ onclick={() => (onRemove ? onRemove() : clearSelection())}
214
276
  >
215
277
  <Trash2 class="mr-2 h-4 w-4" />
216
278
  Remove
@@ -227,86 +289,97 @@
227
289
  <p class="text-muted-foreground text-sm">No reference selected</p>
228
290
  </div>
229
291
  {:else}
230
- <!-- Empty state: dashed "Add reference" button that opens a search popover -->
231
- <Popover.Root bind:open>
232
- <Popover.Trigger bind:ref={triggerRef}>
233
- {#snippet child({ props })}
292
+ <!-- Inline empty state: search input + chevron + Create button. The input
293
+ is the primary focus target — the results dropdown is a sibling div
294
+ positioned absolutely under the input wrapper, with click-outside
295
+ closing it. No popover wrapping the input, so no double-click. -->
296
+ <div class="flex items-center gap-1">
297
+ <div class="relative min-w-0 flex-1" use:clickOutside={() => (open = false)}>
298
+ <div
299
+ class="border-input bg-background focus-within:ring-ring flex h-9 items-center rounded-md border focus-within:ring-1"
300
+ >
301
+ <Input
302
+ bind:value={query}
303
+ placeholder="Type to search..."
304
+ onfocus={() => (open = true)}
305
+ class="h-8 border-0 bg-transparent shadow-none focus-visible:ring-0"
306
+ />
234
307
  <button
235
- {...props}
236
- class="border-border/50 text-muted-foreground hover:text-foreground hover:bg-muted/50 flex h-10 w-full cursor-pointer items-center justify-center gap-2 rounded border border-dashed text-sm transition-colors"
237
- role="combobox"
238
- aria-expanded={open}
308
+ type="button"
309
+ onclick={() => (open = !open)}
310
+ class="text-muted-foreground hover:text-foreground flex h-8 w-8 shrink-0 items-center justify-center"
311
+ aria-label="Toggle results"
239
312
  >
240
- <PlusIcon class="h-4 w-4" />
241
- Add {targetType || 'reference'}...
313
+ <ChevronDown class="h-4 w-4" />
242
314
  </button>
243
- {/snippet}
244
- </Popover.Trigger>
245
- <Popover.Content class="!z-[9999] w-[400px] p-0">
246
- <Command.Root>
247
- <Command.List>
248
- {#if loading}
249
- <Command.Loading>Loading...</Command.Loading>
250
- {:else if searchResults.length === 0}
251
- <Command.Empty>
252
- <div class="flex flex-col items-center gap-2 py-4">
253
- <p class="text-muted-foreground text-sm">
254
- No {pluralize(targetType || '')} found
255
- </p>
256
- <Button size="sm" onclick={createNewDocument} disabled={creating} class="gap-1">
257
- <PlusIcon class="h-3 w-3" />
258
- {creating ? 'Creating...' : `Create new ${targetType}`}
259
- </Button>
260
- </div>
261
- </Command.Empty>
262
- {:else}
263
- <Command.Group>
264
- {#each searchResults as doc (doc.id)}
265
- <Command.Item
266
- value={doc.id}
267
- onSelect={() => selectDocument(doc)}
268
- class="flex items-center justify-between"
269
- >
270
- <div class="flex min-w-0 flex-1 items-center gap-2">
271
- <CheckIcon
272
- class={cn('h-4 w-4 shrink-0', value !== doc.id && 'text-transparent')}
273
- />
274
- <span class="flex min-w-0 flex-1 flex-col">
275
- <span class="truncate text-sm">{getDocumentTitle(doc)}</span>
276
- {#if getDocumentSubtitle(doc)}
277
- <span class="text-muted-foreground truncate text-xs"
278
- >{getDocumentSubtitle(doc)}</span
279
- >
280
- {/if}
281
- </span>
315
+ </div>
316
+ {#if open}
317
+ <div
318
+ class="bg-popover text-popover-foreground absolute top-full left-0 z-[9999] mt-1 w-[400px] max-w-[min(400px,calc(100vw-2rem))] rounded-md border p-0 shadow-md"
319
+ >
320
+ <Command.Root shouldFilter={false}>
321
+ <Command.List class="max-h-[300px] overflow-y-auto">
322
+ {#if loading}
323
+ <Command.Loading>Loading...</Command.Loading>
324
+ {:else if searchResults.length === 0}
325
+ <Command.Empty>
326
+ <div class="text-muted-foreground py-4 text-center text-sm">
327
+ {query.trim()
328
+ ? `No ${pluralize(targetType || '')} match "${query}"`
329
+ : `No ${pluralize(targetType || '')} found`}
282
330
  </div>
283
- {#if doc.status === 'published'}
284
- <span class="h-1.5 w-1.5 shrink-0 rounded-full bg-green-500" title="Published"
285
- ></span>
286
- {:else if doc.status === 'unpublished'}
287
- <span
288
- class="bg-muted-foreground/60 h-1.5 w-1.5 shrink-0 rounded-full"
289
- title="Unpublished"
290
- ></span>
291
- {:else}
292
- <span class="h-1.5 w-1.5 shrink-0 rounded-full bg-amber-500" title="Draft"
293
- ></span>
294
- {/if}
295
- </Command.Item>
296
- {/each}
297
- </Command.Group>
298
- <Command.Separator />
299
- <Command.Group>
300
- <Command.Item onSelect={createNewDocument} class="justify-center">
301
- <div class="flex items-center gap-1">
302
- <PlusIcon class="h-3 w-3" />
303
- {creating ? 'Creating...' : `Create new ${targetType}`}
304
- </div>
305
- </Command.Item>
306
- </Command.Group>
307
- {/if}
308
- </Command.List>
309
- </Command.Root>
310
- </Popover.Content>
311
- </Popover.Root>
331
+ </Command.Empty>
332
+ {:else}
333
+ <Command.Group>
334
+ {#each searchResults as doc (doc.id)}
335
+ <Command.Item
336
+ value={doc.id}
337
+ onSelect={() => selectDocument(doc)}
338
+ class="flex items-center justify-between"
339
+ >
340
+ <div class="flex min-w-0 flex-1 items-center gap-2">
341
+ <CheckIcon
342
+ class={cn('h-4 w-4 shrink-0', refId !== doc.id && 'text-transparent')}
343
+ />
344
+ <span class="flex min-w-0 flex-1 flex-col">
345
+ <span class="truncate text-sm">{getDocumentTitle(doc)}</span>
346
+ {#if getDocumentSubtitle(doc)}
347
+ <span class="text-muted-foreground truncate text-xs"
348
+ >{getDocumentSubtitle(doc)}</span
349
+ >
350
+ {/if}
351
+ </span>
352
+ </div>
353
+ {#if doc._meta?.status === 'published'}
354
+ <span class="h-1.5 w-1.5 shrink-0 rounded-full bg-green-500" title="Published"
355
+ ></span>
356
+ {:else if doc._meta?.status === 'unpublished'}
357
+ <span
358
+ class="bg-muted-foreground/60 h-1.5 w-1.5 shrink-0 rounded-full"
359
+ title="Unpublished"
360
+ ></span>
361
+ {:else}
362
+ <span class="h-1.5 w-1.5 shrink-0 rounded-full bg-amber-500" title="Draft"
363
+ ></span>
364
+ {/if}
365
+ </Command.Item>
366
+ {/each}
367
+ </Command.Group>
368
+ {/if}
369
+ </Command.List>
370
+ </Command.Root>
371
+ </div>
372
+ {/if}
373
+ </div>
374
+ <Button
375
+ variant="outline"
376
+ size="sm"
377
+ onclick={createNewDocument}
378
+ disabled={creating}
379
+ class="h-9 shrink-0 gap-1"
380
+ >
381
+ <PlusIcon class="h-4 w-4" />
382
+ {creating ? 'Creating...' : 'Create...'}
383
+ </Button>
384
+ </div>
312
385
  {/if}
@@ -1,10 +1,17 @@
1
1
  import type { Field } from '../../../types/schemas.js';
2
+ type ReferenceValue = {
3
+ _type: 'reference';
4
+ _ref: string;
5
+ _key?: string;
6
+ };
2
7
  interface Props {
3
8
  field: Field;
4
- value: string | null;
5
- onUpdate: (value: string | null) => void;
9
+ value: ReferenceValue | null;
10
+ onUpdate: (value: ReferenceValue | null) => void;
6
11
  onOpenReference?: (documentId: string, documentType: string) => void;
7
12
  readonly?: boolean;
13
+ onRemove?: () => void;
14
+ preloadedDoc?: unknown;
8
15
  }
9
16
  declare const ReferenceField: import("svelte").Component<Props, {}, "">;
10
17
  type ReferenceField = ReturnType<typeof ReferenceField>;
@@ -1 +1 @@
1
- {"version":3,"file":"ReferenceField.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/admin/fields/ReferenceField.svelte.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,KAAK,EAAwC,MAAM,wBAAwB,CAAC;AAUzF,UAAU,KAAK;IACd,KAAK,EAAE,KAAK,CAAC;IACb,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IACzC,eAAe,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,IAAI,CAAC;IACrE,QAAQ,CAAC,EAAE,OAAO,CAAC;CACnB;AAiRF,QAAA,MAAM,cAAc,2CAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
1
+ {"version":3,"file":"ReferenceField.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/admin/fields/ReferenceField.svelte.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,KAAK,EAAwC,MAAM,wBAAwB,CAAC;AAUzF,KAAK,cAAc,GAAG;IAAE,KAAK,EAAE,WAAW,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAE1E,UAAU,KAAK;IACd,KAAK,EAAE,KAAK,CAAC;IACb,KAAK,EAAE,cAAc,GAAG,IAAI,CAAC;IAC7B,QAAQ,EAAE,CAAC,KAAK,EAAE,cAAc,GAAG,IAAI,KAAK,IAAI,CAAC;IACjD,eAAe,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,IAAI,CAAC;IACrE,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,YAAY,CAAC,EAAE,OAAO,CAAC;CACvB;AAqUF,QAAA,MAAM,cAAc,2CAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
@@ -49,7 +49,7 @@
49
49
  </script>
50
50
 
51
51
  <ModeWatcher />
52
- <Toaster />
52
+ <Toaster closeButton />
53
53
  <SidebarProvider class="h-screen">
54
54
  <AppSidebar {data} {onSignOut} />
55
55
  <SidebarInset class="flex h-full min-w-0 flex-col">
@@ -31,7 +31,8 @@
31
31
  title: 'Content',
32
32
  url: '/admin',
33
33
  icon: undefined,
34
- isActive: false
34
+ isActive: false,
35
+ exact: true
35
36
  }
36
37
  ]
37
38
  );
@@ -1 +1 @@
1
- {"version":3,"file":"AppSidebar.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/layout/sidebar/AppSidebar.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,OAAO,EAKP,MAAM,6BAA6B,CAAC;AAItC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,QAAQ,CAAC;AAG5C,KAAK,KAAK,GAAG,cAAc,CAAC,OAAO,OAAO,CAAC,GAAG;IAC7C,IAAI,EAAE,WAAW,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC,CAAC;AAqDH,QAAA,MAAM,UAAU,2CAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
1
+ {"version":3,"file":"AppSidebar.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/components/layout/sidebar/AppSidebar.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,OAAO,EAKP,MAAM,6BAA6B,CAAC;AAItC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,QAAQ,CAAC;AAG5C,KAAK,KAAK,GAAG,cAAc,CAAC,OAAO,OAAO,CAAC,GAAG;IAC7C,IAAI,EAAE,WAAW,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC,CAAC;AAsDH,QAAA,MAAM,UAAU,2CAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}