@riducms/admin 0.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 (226) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +31 -0
  3. package/THIRD_PARTY_NOTICES.md +217 -0
  4. package/package.json +62 -0
  5. package/src/app/admin-core-view-route.svelte +89 -0
  6. package/src/app/admin-layout.svelte +457 -0
  7. package/src/app/admin-navigation-progress.svelte +204 -0
  8. package/src/app/admin-page-title.svelte +94 -0
  9. package/src/app/admin-provider-layer.svelte +26 -0
  10. package/src/app/admin-router-root.svelte +9 -0
  11. package/src/app/admin-routes.svelte +116 -0
  12. package/src/app/deferred-redirect.svelte +23 -0
  13. package/src/app/loading-boundary.svelte +70 -0
  14. package/src/app/not-found-route.svelte +29 -0
  15. package/src/app-meta.ts +2 -0
  16. package/src/app.css +467 -0
  17. package/src/app.svelte +88 -0
  18. package/src/assets/ridu-logo-arch.svg +3 -0
  19. package/src/assets/ridu-word-logo.svg +7 -0
  20. package/src/components/brand/ridu-logo.svelte +24 -0
  21. package/src/components/code-block/code-block.svelte +107 -0
  22. package/src/components/json-tree/json-tree-node.svelte +77 -0
  23. package/src/components/json-tree/json-viewer.svelte +113 -0
  24. package/src/components/ui/banner/banner.svelte +41 -0
  25. package/src/components/ui/banner/index.ts +5 -0
  26. package/src/components/ui/bulk-action-bar/bulk-action-bar.svelte +50 -0
  27. package/src/components/ui/bulk-action-bar/index.ts +1 -0
  28. package/src/components/ui/button/index.ts +8 -0
  29. package/src/components/ui/calendar/calendar-caption.svelte +72 -0
  30. package/src/components/ui/calendar/calendar-cell.svelte +20 -0
  31. package/src/components/ui/calendar/calendar-day.svelte +27 -0
  32. package/src/components/ui/calendar/calendar-grid-row.svelte +13 -0
  33. package/src/components/ui/calendar/calendar-grid.svelte +17 -0
  34. package/src/components/ui/calendar/calendar-head-cell.svelte +20 -0
  35. package/src/components/ui/calendar/calendar-header.svelte +20 -0
  36. package/src/components/ui/calendar/calendar-heading.svelte +17 -0
  37. package/src/components/ui/calendar/calendar-month-select.svelte +45 -0
  38. package/src/components/ui/calendar/calendar-month.svelte +16 -0
  39. package/src/components/ui/calendar/calendar-months.svelte +20 -0
  40. package/src/components/ui/calendar/calendar-nav.svelte +20 -0
  41. package/src/components/ui/calendar/calendar-next-button.svelte +30 -0
  42. package/src/components/ui/calendar/calendar-prev-button.svelte +30 -0
  43. package/src/components/ui/calendar/calendar-year-select.svelte +44 -0
  44. package/src/components/ui/calendar/calendar.svelte +118 -0
  45. package/src/components/ui/calendar/index.ts +1 -0
  46. package/src/components/ui/checkbox/checkbox.svelte +35 -0
  47. package/src/components/ui/checkbox/index.ts +1 -0
  48. package/src/components/ui/confirmation-dialog/confirmation-dialog.svelte +37 -0
  49. package/src/components/ui/confirmation-dialog/index.ts +1 -0
  50. package/src/components/ui/date-value-control/date-value-calendar.svelte +37 -0
  51. package/src/components/ui/date-value-control/date-value-control-loader.svelte +36 -0
  52. package/src/components/ui/date-value-control/date-value-control.svelte +177 -0
  53. package/src/components/ui/date-value-control/index.ts +3 -0
  54. package/src/components/ui/dialog/dialog-close.svelte +11 -0
  55. package/src/components/ui/dialog/dialog-content.svelte +52 -0
  56. package/src/components/ui/dialog/dialog-description.svelte +20 -0
  57. package/src/components/ui/dialog/dialog-footer.svelte +20 -0
  58. package/src/components/ui/dialog/dialog-header.svelte +20 -0
  59. package/src/components/ui/dialog/dialog-overlay.svelte +20 -0
  60. package/src/components/ui/dialog/dialog-title.svelte +17 -0
  61. package/src/components/ui/dialog/dialog-trigger.svelte +11 -0
  62. package/src/components/ui/dialog/dialog.svelte +7 -0
  63. package/src/components/ui/dialog/index.ts +8 -0
  64. package/src/components/ui/dropdown-menu/dropdown-menu-content.svelte +27 -0
  65. package/src/components/ui/dropdown-menu/dropdown-menu-item.svelte +20 -0
  66. package/src/components/ui/dropdown-menu/dropdown-menu-separator.svelte +17 -0
  67. package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +16 -0
  68. package/src/components/ui/dropdown-menu/dropdown-menu.svelte +7 -0
  69. package/src/components/ui/dropdown-menu/index.ts +5 -0
  70. package/src/components/ui/empty-state/empty-state.svelte +34 -0
  71. package/src/components/ui/empty-state/index.ts +1 -0
  72. package/src/components/ui/input/index.ts +1 -0
  73. package/src/components/ui/kbd/index.ts +1 -0
  74. package/src/components/ui/kbd/kbd.svelte +24 -0
  75. package/src/components/ui/pagination/index.ts +1 -0
  76. package/src/components/ui/pagination/pagination.svelte +88 -0
  77. package/src/components/ui/popover/index.ts +3 -0
  78. package/src/components/ui/popover/popover-content.svelte +29 -0
  79. package/src/components/ui/popover/popover-trigger.svelte +11 -0
  80. package/src/components/ui/popover/popover.svelte +7 -0
  81. package/src/components/ui/radio-group/index.ts +3 -0
  82. package/src/components/ui/radio-group/radio-card-item.svelte +38 -0
  83. package/src/components/ui/radio-group/radio-group-item.svelte +17 -0
  84. package/src/components/ui/radio-group/radio-group.svelte +19 -0
  85. package/src/components/ui/select/index.ts +4 -0
  86. package/src/components/ui/select/select-content.svelte +38 -0
  87. package/src/components/ui/select/select-item.svelte +40 -0
  88. package/src/components/ui/select/select-scroll-down-button.svelte +23 -0
  89. package/src/components/ui/select/select-scroll-up-button.svelte +23 -0
  90. package/src/components/ui/select/select-trigger.svelte +29 -0
  91. package/src/components/ui/select/select.svelte +11 -0
  92. package/src/components/ui/sheet/index.ts +6 -0
  93. package/src/components/ui/sheet/sheet-close.svelte +7 -0
  94. package/src/components/ui/sheet/sheet-content.svelte +77 -0
  95. package/src/components/ui/sheet/sheet-description.svelte +17 -0
  96. package/src/components/ui/sheet/sheet-overlay.svelte +36 -0
  97. package/src/components/ui/sheet/sheet-title.svelte +17 -0
  98. package/src/components/ui/sheet/sheet-trigger.svelte +7 -0
  99. package/src/components/ui/sheet/sheet.svelte +7 -0
  100. package/src/components/ui/skeleton/index.ts +1 -0
  101. package/src/components/ui/skeleton/skeleton.svelte +31 -0
  102. package/src/components/ui/slider/index.ts +3 -0
  103. package/src/components/ui/slider/slider-content.svelte +59 -0
  104. package/src/components/ui/slider/slider.svelte +35 -0
  105. package/src/components/ui/sonner/index.ts +1 -0
  106. package/src/components/ui/sonner/sonner.svelte +55 -0
  107. package/src/components/ui/status-indicator/index.ts +5 -0
  108. package/src/components/ui/status-indicator/status-indicator.svelte +54 -0
  109. package/src/components/ui/table/index.ts +6 -0
  110. package/src/components/ui/table/table-body.svelte +20 -0
  111. package/src/components/ui/table/table-cell.svelte +23 -0
  112. package/src/components/ui/table/table-head.svelte +23 -0
  113. package/src/components/ui/table/table-header.svelte +20 -0
  114. package/src/components/ui/table/table-row.svelte +23 -0
  115. package/src/components/ui/table/table.svelte +22 -0
  116. package/src/components/ui/tabs/index.ts +8 -0
  117. package/src/components/ui/tabs/tabs-content.svelte +17 -0
  118. package/src/components/ui/tabs/tabs-list.svelte +40 -0
  119. package/src/components/ui/tabs/tabs-trigger.svelte +23 -0
  120. package/src/components/ui/tabs/tabs.svelte +19 -0
  121. package/src/components/ui/textarea/index.ts +1 -0
  122. package/src/components/ui/tooltip/index.ts +4 -0
  123. package/src/components/ui/tooltip/tooltip-content.svelte +50 -0
  124. package/src/components/ui/tooltip/tooltip-provider.svelte +7 -0
  125. package/src/components/ui/tooltip/tooltip-trigger.svelte +7 -0
  126. package/src/components/ui/tooltip/tooltip.svelte +7 -0
  127. package/src/core/api/admin-client.ts +45 -0
  128. package/src/core/forms/field-condition.ts +63 -0
  129. package/src/core/forms/form-controller.svelte.ts +369 -0
  130. package/src/core/forms/form-draft-recovery.ts +80 -0
  131. package/src/core/forms/form-schema.ts +609 -0
  132. package/src/core/forms/form-validation.ts +750 -0
  133. package/src/core/i18n/admin-i18n.svelte.ts +364 -0
  134. package/src/core/i18n/localized-schema.ts +111 -0
  135. package/src/core/notifications/notification-center.svelte.ts +69 -0
  136. package/src/core/notifications/validation-toast.svelte +18 -0
  137. package/src/core/plugins/field-registry.ts +60 -0
  138. package/src/core/plugins/row-label-registry.ts +62 -0
  139. package/src/core/preferences/preference-write-queue.ts +107 -0
  140. package/src/core/routing/admin-paths.ts +137 -0
  141. package/src/core/runtime/admin-runtime.svelte.ts +625 -0
  142. package/src/features/auth/account-menu.svelte +159 -0
  143. package/src/features/auth/account-navigation.svelte +44 -0
  144. package/src/features/auth/account-route.svelte +395 -0
  145. package/src/features/auth/account-security-controller.svelte.ts +119 -0
  146. package/src/features/auth/account-security-route.svelte +460 -0
  147. package/src/features/auth/auth-brand.svelte +17 -0
  148. package/src/features/auth/auth-frame.svelte +18 -0
  149. package/src/features/auth/create-first-user-controller.svelte.ts +220 -0
  150. package/src/features/auth/create-first-user-route.svelte +174 -0
  151. package/src/features/auth/forgot-password-route.svelte +78 -0
  152. package/src/features/auth/login-route.svelte +221 -0
  153. package/src/features/auth/request-verification-route.svelte +73 -0
  154. package/src/features/auth/reset-password-route.svelte +107 -0
  155. package/src/features/auth/verify-email-route.svelte +60 -0
  156. package/src/features/collections/collection-list-bulk-actions.svelte +143 -0
  157. package/src/features/collections/collection-list-bulk-editor.svelte +157 -0
  158. package/src/features/collections/collection-list-controller.svelte.ts +717 -0
  159. package/src/features/collections/collection-list-filter-popover.svelte +238 -0
  160. package/src/features/collections/collection-list-preferences-controller.svelte.ts +393 -0
  161. package/src/features/collections/collection-list-results.svelte +410 -0
  162. package/src/features/collections/collection-list-route.svelte +951 -0
  163. package/src/features/collections/collection-list-workspace-toolbar.svelte +409 -0
  164. package/src/features/collections/collection-table-skeleton.svelte +44 -0
  165. package/src/features/collections/list-workspace.ts +265 -0
  166. package/src/features/dashboard/dashboard-route.svelte +141 -0
  167. package/src/features/development/development-tools.svelte +217 -0
  168. package/src/features/documents/document-api-view-loader.svelte +31 -0
  169. package/src/features/documents/document-api-view.svelte +547 -0
  170. package/src/features/documents/document-controller.svelte.ts +1252 -0
  171. package/src/features/documents/document-field-partition.ts +18 -0
  172. package/src/features/documents/document-field-sections.svelte +46 -0
  173. package/src/features/documents/document-heading.ts +20 -0
  174. package/src/features/documents/document-route-dirty.ts +43 -0
  175. package/src/features/documents/document-route-loader.svelte +22 -0
  176. package/src/features/documents/document-route.svelte +1124 -0
  177. package/src/features/documents/live-preview-panel.svelte +390 -0
  178. package/src/features/documents/live-preview.ts +61 -0
  179. package/src/features/documents/upload-document-preview-loader.svelte +40 -0
  180. package/src/features/documents/upload-document-preview.svelte +525 -0
  181. package/src/features/localization/content-locale-switcher.svelte +93 -0
  182. package/src/features/navigation/admin-breadcrumbs.svelte +124 -0
  183. package/src/features/navigation/admin-command-menu.svelte +245 -0
  184. package/src/features/navigation/admin-resource-groups.ts +72 -0
  185. package/src/features/reference-browser/reference-browser-loader.svelte +15 -0
  186. package/src/features/reference-browser/reference-browser-workflow.svelte.ts +691 -0
  187. package/src/features/reference-browser/reference-browser.svelte +844 -0
  188. package/src/features/uploads/bulk-upload-route.svelte +624 -0
  189. package/src/features/uploads/bulk-upload.ts +39 -0
  190. package/src/features/uploads/upload-metadata-fields.svelte +112 -0
  191. package/src/features/versions/version-diff.ts +72 -0
  192. package/src/features/versions/version-history-route.svelte +581 -0
  193. package/src/fields/field-clipboard.ts +132 -0
  194. package/src/fields/field-layout.svelte +60 -0
  195. package/src/fields/field-layout.ts +57 -0
  196. package/src/fields/field-messages.svelte +29 -0
  197. package/src/fields/field-renderer.svelte +152 -0
  198. package/src/fields/field-shell.svelte +30 -0
  199. package/src/fields/field-tab-layout.svelte +99 -0
  200. package/src/fields/join/join-field.svelte +358 -0
  201. package/src/fields/localized-field-presentation.ts +17 -0
  202. package/src/fields/nested/nested-field.svelte +602 -0
  203. package/src/fields/nested/scoped-field.ts +78 -0
  204. package/src/fields/nested/sortable-row.svelte +32 -0
  205. package/src/fields/point/point-field.svelte +52 -0
  206. package/src/fields/radio/radio-field.svelte +34 -0
  207. package/src/fields/relationship/relationship-field-controller.svelte.ts +245 -0
  208. package/src/fields/relationship/relationship-field.svelte +258 -0
  209. package/src/fields/relationship/relationship-lookup-controller.svelte.ts +129 -0
  210. package/src/fields/relationship/relationship-query.ts +88 -0
  211. package/src/fields/relationship/relationship-quick-picker-controller.svelte.ts +198 -0
  212. package/src/fields/relationship/relationship-quick-picker.svelte +327 -0
  213. package/src/fields/relationship/relationship-value.ts +47 -0
  214. package/src/fields/scalar/date-control-value.ts +60 -0
  215. package/src/fields/scalar/date-value.ts +26 -0
  216. package/src/fields/scalar/scalar-field.svelte +137 -0
  217. package/src/fields/select/select-field.svelte +132 -0
  218. package/src/fields/select/select-value.ts +14 -0
  219. package/src/fields/text/slug-field.svelte +87 -0
  220. package/src/fields/text/slug.ts +38 -0
  221. package/src/fields/text/text-field.svelte +41 -0
  222. package/src/fields/ui/ui-field.svelte +12 -0
  223. package/src/fields/virtual/virtual-field.svelte +28 -0
  224. package/src/index.ts +49 -0
  225. package/src/main.ts +12 -0
  226. package/src/vite-env.d.ts +9 -0
@@ -0,0 +1,327 @@
1
+ <script lang="ts">
2
+ import { Combobox } from "bits-ui";
3
+ import { tick } from "svelte";
4
+ import ChevronDownIcon from "~icons/lucide/chevron-down";
5
+ import ArrowRightIcon from "~icons/lucide/arrow-right";
6
+ import ImageIcon from "~icons/lucide/image";
7
+ import PencilIcon from "~icons/lucide/pencil";
8
+ import PlusIcon from "~icons/lucide/plus";
9
+ import XIcon from "~icons/lucide/x";
10
+ import { TooltipContent, TooltipRoot, TooltipTrigger } from "@riducms/ui";
11
+
12
+ import { Skeleton } from "@admin/components/ui/skeleton";
13
+ import { getAdminRuntime } from "@admin/core/runtime/admin-runtime.svelte";
14
+
15
+ import {
16
+ RelationshipQuickPickerController,
17
+ type RelationshipQuickPickerTarget,
18
+ } from "@admin/fields/relationship/relationship-quick-picker-controller.svelte";
19
+
20
+ let {
21
+ id,
22
+ label,
23
+ targets,
24
+ selectedTarget,
25
+ selectedID,
26
+ selectedLabel,
27
+ selectedInitials,
28
+ placeholder,
29
+ readOnly = false,
30
+ locale,
31
+ onPick,
32
+ onRemove,
33
+ onBrowse,
34
+ onEdit,
35
+ upload = false,
36
+ invalid = false,
37
+ describedBy,
38
+ }: {
39
+ id: string;
40
+ label: string;
41
+ targets: readonly RelationshipQuickPickerTarget[];
42
+ selectedTarget?: string;
43
+ selectedID?: string;
44
+ selectedLabel?: string;
45
+ selectedInitials?: string;
46
+ placeholder?: string;
47
+ readOnly?: boolean;
48
+ locale?: string;
49
+ onPick: (target: string, id: string) => void;
50
+ onRemove: () => void;
51
+ onBrowse: (target: string) => void;
52
+ onEdit?: () => void;
53
+ upload?: boolean;
54
+ invalid?: boolean;
55
+ describedBy?: string;
56
+ } = $props();
57
+
58
+ const runtime = getAdminRuntime();
59
+ const controller = new RelationshipQuickPickerController({
60
+ runtime,
61
+ get targets() {
62
+ return targets;
63
+ },
64
+ get selectedTarget() {
65
+ return selectedTarget;
66
+ },
67
+ get selectedID() {
68
+ return selectedID;
69
+ },
70
+ get locale() {
71
+ return locale;
72
+ },
73
+ onPick: (target, value) => onPick(target, value),
74
+ onRemove: () => onRemove(),
75
+ onBrowse: (target) => onBrowse(target),
76
+ });
77
+ const { comboboxItems, groups, open, query, selectedValue } = $derived(controller);
78
+ const {
79
+ browseAll,
80
+ changeOpen,
81
+ documentLabel,
82
+ initials,
83
+ pick,
84
+ remove: removeSelection,
85
+ setQuery,
86
+ toggleOpen,
87
+ } = controller;
88
+ const selectedCollection = $derived(
89
+ targets.find((target) => target.collection.slug === selectedTarget)?.collection ??
90
+ targets[0]?.collection
91
+ );
92
+ const searchLabel = $derived(
93
+ targets.length === 1 ? (selectedCollection?.labels.plural ?? label) : label
94
+ );
95
+ let triggerElement = $state<HTMLButtonElement | null>(null);
96
+ let searchInput = $state<HTMLInputElement | null>(null);
97
+
98
+ async function togglePicker() {
99
+ if (!toggleOpen()) return;
100
+ await tick();
101
+ searchInput?.focus();
102
+ }
103
+
104
+ function beginSearch(event: Event) {
105
+ setQuery((event.currentTarget as HTMLInputElement).value);
106
+ }
107
+
108
+ function remove(event: MouseEvent) {
109
+ event.stopPropagation();
110
+ removeSelection();
111
+ }
112
+
113
+ function browse(event: MouseEvent, target?: string) {
114
+ event.stopPropagation();
115
+ browseAll(target);
116
+ }
117
+
118
+ function edit(event: MouseEvent) {
119
+ event.stopPropagation();
120
+ onEdit?.();
121
+ }
122
+ </script>
123
+
124
+ <Combobox.Root
125
+ type="single"
126
+ value={selectedValue}
127
+ inputValue={query}
128
+ items={comboboxItems}
129
+ {open}
130
+ onOpenChange={changeOpen}
131
+ onValueChange={pick}
132
+ allowDeselect={false}
133
+ loop
134
+ >
135
+ <div
136
+ class="flex h-10.5 min-w-0 rounded-[3px] border border-control-border bg-control transition-[background-color,border-color] duration-150 hover:bg-background focus-within:border-primary/65 aria-invalid:border-destructive/65"
137
+ aria-invalid={invalid}
138
+ >
139
+ <button
140
+ bind:this={triggerElement}
141
+ {id}
142
+ type="button"
143
+ class="flex min-w-0 flex-1 items-center gap-2 rounded-s-[3px] px-2.75 text-start outline-none disabled:cursor-not-allowed disabled:opacity-45"
144
+ disabled={readOnly}
145
+ aria-label={label}
146
+ aria-describedby={describedBy}
147
+ aria-haspopup="listbox"
148
+ aria-expanded={open}
149
+ onclick={togglePicker}
150
+ >
151
+ {#if selectedID !== undefined}
152
+ <span
153
+ class="font-mono grid size-[19px] shrink-0 place-items-center rounded-full bg-sidebar-accent text-[8px] font-semibold tracking-[0.04em] text-sidebar-accent-foreground"
154
+ aria-hidden="true"
155
+ >
156
+ {selectedInitials}
157
+ </span>
158
+ <span class="min-w-0 flex-1 truncate text-[13px] text-foreground-strong">
159
+ {selectedLabel}
160
+ </span>
161
+ {:else}
162
+ {#if upload}<ImageIcon class="size-3.5 shrink-0 text-foreground-sub" />{/if}
163
+ <span class="min-w-0 flex-1 truncate text-[13px] text-foreground-placeholder">
164
+ {placeholder ??
165
+ runtime.i18n.t(upload ? "fields:choose" : "fields:select", {
166
+ label: (
167
+ selectedCollection?.labels.singular ??
168
+ runtime.i18n.t(upload ? "fields:asset" : "fields:document")
169
+ ).toLocaleLowerCase(runtime.i18n.language),
170
+ })}
171
+ </span>
172
+ {/if}
173
+ <ChevronDownIcon class="size-3.5 shrink-0 text-foreground-sub" aria-hidden="true" />
174
+ </button>
175
+
176
+ {#if selectedID !== undefined && onEdit !== undefined}
177
+ <TooltipRoot>
178
+ <TooltipTrigger>
179
+ {#snippet child({ props })}
180
+ <button
181
+ {...props}
182
+ type="button"
183
+ class="grid w-9 shrink-0 place-items-center border-s border-control-border text-foreground-sub outline-none transition-colors hover:bg-background hover:text-foreground focus-visible:text-primary disabled:cursor-not-allowed disabled:opacity-45"
184
+ disabled={readOnly}
185
+ onpointerdown={(event) => event.preventDefault()}
186
+ onclick={edit}
187
+ aria-label={runtime.i18n.t("fields:edit", { label: selectedLabel ?? "" })}
188
+ >
189
+ <PencilIcon class="size-3.25" />
190
+ </button>
191
+ {/snippet}
192
+ </TooltipTrigger>
193
+ <TooltipContent>{runtime.i18n.t("fields:editRelationship")}</TooltipContent>
194
+ </TooltipRoot>
195
+ {/if}
196
+
197
+ {#if selectedID !== undefined && !readOnly}
198
+ <TooltipRoot>
199
+ <TooltipTrigger>
200
+ {#snippet child({ props })}
201
+ <button
202
+ {...props}
203
+ type="button"
204
+ class="grid w-9 shrink-0 place-items-center border-s border-control-border text-foreground-sub outline-none transition-colors hover:bg-destructive/7 hover:text-destructive focus-visible:text-destructive"
205
+ onpointerdown={(event) => event.preventDefault()}
206
+ onclick={remove}
207
+ aria-label={runtime.i18n.t("fields:remove", { label: selectedLabel ?? "" })}
208
+ >
209
+ <XIcon class="size-3.5" />
210
+ </button>
211
+ {/snippet}
212
+ </TooltipTrigger>
213
+ <TooltipContent>{runtime.i18n.t("fields:removeRelationship")}</TooltipContent>
214
+ </TooltipRoot>
215
+ {/if}
216
+
217
+ <TooltipRoot>
218
+ <TooltipTrigger>
219
+ {#snippet child({ props })}
220
+ <button
221
+ {...props}
222
+ type="button"
223
+ class="grid w-9 shrink-0 place-items-center rounded-e-[3px] border-s border-control-border text-foreground-sub outline-none transition-colors hover:bg-background hover:text-foreground focus-visible:text-primary"
224
+ onpointerdown={(event) => event.preventDefault()}
225
+ onclick={(event) => browse(event, selectedCollection?.slug)}
226
+ aria-label={runtime.i18n.t("fields:browse", {
227
+ label: (
228
+ selectedCollection?.labels.plural ?? runtime.i18n.t("fields:relationships")
229
+ ).toLocaleLowerCase(runtime.i18n.language),
230
+ })}
231
+ >
232
+ <PlusIcon class="size-3.5" />
233
+ </button>
234
+ {/snippet}
235
+ </TooltipTrigger>
236
+ <TooltipContent>{runtime.i18n.t("fields:addRelationship")}</TooltipContent>
237
+ </TooltipRoot>
238
+ </div>
239
+
240
+ <Combobox.Portal>
241
+ <Combobox.Content
242
+ customAnchor={triggerElement}
243
+ align="start"
244
+ sideOffset={4}
245
+ class="ridu-popover-enter z-50 flex flex-col overflow-hidden rounded-[4px] border border-control-border bg-popover p-1.5 text-popover-foreground shadow-lg outline-none"
246
+ style="width: var(--bits-combobox-anchor-width);"
247
+ >
248
+ <Combobox.Input
249
+ bind:ref={searchInput}
250
+ aria-label={runtime.i18n.t("fields:searchLabel", { label: searchLabel })}
251
+ placeholder={runtime.i18n.t("fields:search", {
252
+ label: searchLabel.toLocaleLowerCase(runtime.i18n.language),
253
+ })}
254
+ class="mb-1.25 h-[29px] w-full rounded-[3px] border border-control-border bg-control px-[9px] text-[12.5px] text-foreground caret-primary outline-none placeholder:text-foreground-faint focus-visible:border-primary/55"
255
+ oninput={beginSearch}
256
+ />
257
+
258
+ <Combobox.Viewport class="grid max-h-64 min-h-8 overflow-y-auto">
259
+ {#each groups as group (group.collection.id)}
260
+ <div role="group" aria-label={group.collection.labels.plural}>
261
+ {#if groups.length > 1}
262
+ <p
263
+ class="px-2 pt-2 pb-1 text-[10px] font-medium tracking-[0.08em] text-foreground-faint uppercase"
264
+ >
265
+ {group.collection.labels.plural}
266
+ </p>
267
+ {/if}
268
+ {#if group.status === "initial" || group.status === "loading"}
269
+ <div
270
+ class="grid gap-1 p-0.5"
271
+ aria-label={runtime.i18n.t("fields:loading", {
272
+ label: group.collection.labels.plural,
273
+ })}
274
+ >
275
+ <Skeleton class="h-[31px]" />
276
+ <Skeleton class="h-[31px]" />
277
+ </div>
278
+ {:else if group.error !== undefined}
279
+ <p class="px-3 py-2 text-[12px] leading-4 text-destructive" role="alert">
280
+ {group.error}
281
+ </p>
282
+ {:else if group.docs.length === 0}
283
+ <p class="px-3 py-2 text-[12px] text-foreground-faint">
284
+ {runtime.i18n.t("fields:noMatch", {
285
+ label: group.collection.labels.plural.toLocaleLowerCase(runtime.i18n.language),
286
+ })}
287
+ </p>
288
+ {:else}
289
+ {#each group.docs as document (document.id)}
290
+ {const optionLabel = documentLabel(group.collection, document)}
291
+ <Combobox.Item
292
+ value={JSON.stringify([group.collection.slug, document.id])}
293
+ label={optionLabel}
294
+ class="flex w-full cursor-default items-center gap-[9px] rounded-[3px] px-2 py-1.5 text-start text-[12.5px] text-foreground-muted outline-none select-none data-[highlighted]:bg-control data-[selected]:text-foreground-strong"
295
+ >
296
+ {#snippet children({ selected })}
297
+ <span
298
+ class="font-mono grid size-[19px] shrink-0 place-items-center rounded-full bg-sidebar-accent text-[8px] font-semibold text-sidebar-accent-foreground"
299
+ aria-hidden="true"
300
+ >
301
+ {initials(optionLabel)}
302
+ </span>
303
+ <span class="min-w-0 flex-1 truncate">{optionLabel}</span>
304
+ <span class="font-mono text-[9.5px] text-primary" aria-hidden="true">
305
+ {selected ? "✓" : ""}
306
+ </span>
307
+ {/snippet}
308
+ </Combobox.Item>
309
+ {/each}
310
+ {/if}
311
+ <button
312
+ type="button"
313
+ class="mt-0.5 flex w-full items-center gap-1 px-2 py-1.5 text-start text-[11.5px] text-primary outline-none hover:text-primary-hover focus-visible:text-primary-hover"
314
+ onpointerdown={(event) => event.preventDefault()}
315
+ onclick={(event) => browse(event, group.collection.slug)}
316
+ >
317
+ {runtime.i18n.t("fields:browseAll", {
318
+ label: group.collection.labels.plural.toLocaleLowerCase(runtime.i18n.language),
319
+ })}
320
+ <ArrowRightIcon class="size-3 rtl:rotate-180" aria-hidden="true" />
321
+ </button>
322
+ </div>
323
+ {/each}
324
+ </Combobox.Viewport>
325
+ </Combobox.Content>
326
+ </Combobox.Portal>
327
+ </Combobox.Root>
@@ -0,0 +1,47 @@
1
+ export interface PolymorphicReference {
2
+ relationTo: string;
3
+ id: string;
4
+ }
5
+
6
+ export function selectedRelationshipIDs(
7
+ value: unknown,
8
+ hasMany: boolean,
9
+ polymorphic: boolean,
10
+ target: string
11
+ ): string[] {
12
+ const values = hasMany ? (Array.isArray(value) ? value : []) : [value];
13
+ return values.flatMap((item) => {
14
+ if (!polymorphic && typeof item === "string" && item !== "") return [item];
15
+ if (!isPolymorphicReference(item) || item.relationTo !== target) return [];
16
+ return [item.id];
17
+ });
18
+ }
19
+
20
+ export function updateRelationshipValue(
21
+ current: unknown,
22
+ ids: readonly string[],
23
+ hasMany: boolean,
24
+ polymorphic: boolean,
25
+ target: string
26
+ ): unknown {
27
+ if (!polymorphic) return hasMany ? [...ids] : (ids[0] ?? "");
28
+
29
+ const previous = hasMany
30
+ ? Array.isArray(current)
31
+ ? current.filter((item) => isPolymorphicReference(item) && item.relationTo !== target)
32
+ : []
33
+ : [];
34
+ const references = ids.map((id) => ({ relationTo: target, id }));
35
+ return hasMany ? [...previous, ...references] : (references[0] ?? null);
36
+ }
37
+
38
+ function isPolymorphicReference(value: unknown): value is PolymorphicReference {
39
+ return (
40
+ typeof value === "object" &&
41
+ value !== null &&
42
+ "relationTo" in value &&
43
+ typeof value.relationTo === "string" &&
44
+ "id" in value &&
45
+ typeof value.id === "string"
46
+ );
47
+ }
@@ -0,0 +1,60 @@
1
+ import {
2
+ getLocalTimeZone,
3
+ parseAbsolute,
4
+ parseDate,
5
+ parseTime,
6
+ toCalendarDate,
7
+ toCalendarDateTime,
8
+ type DateValue,
9
+ type Time,
10
+ } from "@internationalized/date";
11
+ import type { SchemaDatePickerAppearance } from "@riducms/protocol";
12
+
13
+ /**
14
+ * Converts a Ridu wire value into the timezone-free value used by Bits' date
15
+ * segments. Day-only values deliberately never pass through `Date`.
16
+ */
17
+ export function datePickerValue(
18
+ value: unknown,
19
+ appearance: SchemaDatePickerAppearance | undefined,
20
+ timeZone = getLocalTimeZone()
21
+ ): DateValue | undefined {
22
+ const encoded = String(value ?? "");
23
+ if (encoded === "") return undefined;
24
+ try {
25
+ if (appearance === "dayAndTime") {
26
+ return toCalendarDateTime(parseAbsolute(encoded, timeZone));
27
+ }
28
+ return parseDate(encoded.slice(0, 10));
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ }
33
+
34
+ /** Converts a Bits date selection to Ridu's canonical wire value. */
35
+ export function datePickerFormValue(
36
+ value: DateValue | undefined,
37
+ appearance: SchemaDatePickerAppearance | undefined,
38
+ timeZone = getLocalTimeZone()
39
+ ) {
40
+ if (value === undefined) return "";
41
+ if (appearance === "dayAndTime") {
42
+ return toCalendarDateTime(value).toDate(timeZone).toISOString();
43
+ }
44
+ return toCalendarDate(value).toString();
45
+ }
46
+
47
+ /** Time-only fields remain pure clock values; a timezone must never be introduced. */
48
+ export function timeFieldValue(value: unknown): Time | undefined {
49
+ const encoded = String(value ?? "");
50
+ if (encoded === "") return undefined;
51
+ try {
52
+ return parseTime(encoded);
53
+ } catch {
54
+ return undefined;
55
+ }
56
+ }
57
+
58
+ export function timeFieldFormValue(value: Time | undefined) {
59
+ return value?.toString().slice(0, 5) ?? "";
60
+ }
@@ -0,0 +1,26 @@
1
+ import type { SchemaDatePickerAppearance } from "@riducms/protocol";
2
+ import type { AdminI18n } from "@riducms/plugin";
3
+
4
+ export function formatDateDisplay(
5
+ value: unknown,
6
+ appearance: SchemaDatePickerAppearance | undefined,
7
+ i18n: AdminI18n
8
+ ) {
9
+ const encoded = String(value ?? "");
10
+ if (encoded === "") return "—";
11
+ if (appearance === "timeOnly") return encoded.slice(0, 5);
12
+ const date = new Date(
13
+ appearance === "dayOnly" || appearance === undefined ? `${encoded}T00:00:00Z` : encoded
14
+ );
15
+ if (Number.isNaN(date.valueOf())) return encoded;
16
+ if (appearance === "dayAndTime") {
17
+ return i18n.formatDate(date, {
18
+ dateStyle: "medium",
19
+ timeStyle: "short",
20
+ });
21
+ }
22
+ return i18n.formatDate(date, {
23
+ dateStyle: "medium",
24
+ timeZone: "UTC",
25
+ });
26
+ }
@@ -0,0 +1,137 @@
1
+ <script lang="ts">
2
+ import type { SchemaField, ValidationIssue } from "@riducms/protocol";
3
+
4
+ import { Checkbox } from "@admin/components/ui/checkbox";
5
+ import { DateValueControl } from "@admin/components/ui/date-value-control";
6
+ import { Input } from "@admin/components/ui/input";
7
+ import { Textarea } from "@admin/components/ui/textarea";
8
+ import type { FormController } from "@admin/core/forms/form-controller.svelte";
9
+ import { getAdminRuntime } from "@admin/core/runtime/admin-runtime.svelte";
10
+ import FieldMessages from "@admin/fields/field-messages.svelte";
11
+ import FieldShell from "@admin/fields/field-shell.svelte";
12
+
13
+ let { field, form }: { field: SchemaField; form: FormController } = $props();
14
+ const runtime = getAdminRuntime();
15
+ const value = $derived(form.get(field.path));
16
+ const issues = $derived(form.issuesFor(field.path));
17
+ let jsonError = $state<string>();
18
+ const displayIssues = $derived<ValidationIssue[]>(
19
+ jsonError === undefined
20
+ ? issues
21
+ : [{ code: "invalid_json", path: field.path, message: jsonError }]
22
+ );
23
+ const hasMessage = $derived(displayIssues.length > 0 || field.admin.description !== undefined);
24
+ const dateAppearance = $derived(field.date?.pickerAppearance);
25
+ const stringConstraints = $derived(field.type === "code" ? field.code : field.textarea);
26
+
27
+ $effect(() => form.register(field.path));
28
+
29
+ function setJSON(encoded: string) {
30
+ try {
31
+ form.set(field.path, JSON.parse(encoded));
32
+ jsonError = undefined;
33
+ } catch {
34
+ jsonError = runtime.i18n.t("errors:validJSON");
35
+ }
36
+ }
37
+ </script>
38
+
39
+ {#if field.type === "checkbox"}
40
+ <div class="grid gap-2" data-field-path={field.path} data-invalid={displayIssues.length > 0}>
41
+ <label
42
+ class="flex min-h-6 w-fit cursor-pointer items-center gap-2 text-[13px] text-foreground-muted hover:text-foreground has-disabled:cursor-not-allowed has-disabled:text-foreground-faint"
43
+ for={field.id}
44
+ >
45
+ <Checkbox
46
+ id={field.id}
47
+ required={field.required}
48
+ aria-invalid={displayIssues.length > 0}
49
+ aria-describedby={hasMessage ? `${field.id}-message` : undefined}
50
+ aria-errormessage={displayIssues.length > 0 ? `${field.id}-message` : undefined}
51
+ disabled={field.admin.readOnly}
52
+ bind:checked={() => Boolean(value), (next) => form.set(field.path, next)}
53
+ />
54
+ <span>{field.admin.label}</span>
55
+ {#if field.required}<span class="ridu-field-required" aria-hidden="true">*</span>{/if}
56
+ {#if field.admin.readOnly}<span class="ridu-field-status">
57
+ {runtime.i18n.t("fields:readOnly")}
58
+ </span>{/if}
59
+ </label>
60
+ <FieldMessages
61
+ id="{field.id}-message"
62
+ issues={displayIssues}
63
+ description={field.admin.description}
64
+ />
65
+ </div>
66
+ {:else}
67
+ <FieldShell {field} issues={displayIssues}>
68
+ {#if field.type === "textarea" || field.type === "code"}
69
+ <Textarea
70
+ id={field.id}
71
+ name={field.path}
72
+ rows={1}
73
+ required={field.required}
74
+ minlength={stringConstraints?.minLength}
75
+ maxlength={stringConstraints?.maxLength}
76
+ placeholder={field.admin.placeholder}
77
+ aria-invalid={displayIssues.length > 0}
78
+ aria-describedby={hasMessage ? `${field.id}-message` : undefined}
79
+ aria-errormessage={displayIssues.length > 0 ? `${field.id}-message` : undefined}
80
+ value={String(value ?? "")}
81
+ readonly={field.admin.readOnly}
82
+ class={field.type === "code"
83
+ ? "min-h-14 resize-y field-sizing-fixed font-mono text-[12.5px] leading-5"
84
+ : undefined}
85
+ data-language={field.code?.language}
86
+ oninput={(event) => form.set(field.path, event.currentTarget.value)}
87
+ />
88
+ {:else if field.type === "json"}
89
+ <Textarea
90
+ id={field.id}
91
+ name={field.path}
92
+ required={field.required}
93
+ aria-invalid={displayIssues.length > 0}
94
+ aria-describedby={hasMessage ? `${field.id}-message` : undefined}
95
+ aria-errormessage={displayIssues.length > 0 ? `${field.id}-message` : undefined}
96
+ class="min-h-40 font-mono"
97
+ value={JSON.stringify(value ?? {}, null, 2)}
98
+ readonly={field.admin.readOnly}
99
+ oninput={(event) => setJSON(event.currentTarget.value)}
100
+ />
101
+ {:else if field.type === "date"}
102
+ <DateValueControl
103
+ id={field.id}
104
+ name={field.path}
105
+ appearance={dateAppearance}
106
+ value={String(value ?? "")}
107
+ readonly={field.admin.readOnly}
108
+ required={field.required}
109
+ invalid={displayIssues.length > 0}
110
+ label={field.admin.label}
111
+ describedBy={hasMessage ? `${field.id}-message` : undefined}
112
+ onValueChange={(next) => form.set(field.path, next)}
113
+ />
114
+ {:else}
115
+ <Input
116
+ id={field.id}
117
+ name={field.path}
118
+ required={field.required}
119
+ aria-invalid={displayIssues.length > 0}
120
+ aria-describedby={hasMessage ? `${field.id}-message` : undefined}
121
+ aria-errormessage={displayIssues.length > 0 ? `${field.id}-message` : undefined}
122
+ type={field.type === "number" ? "number" : field.type === "email" ? "email" : "text"}
123
+ min={field.type === "number" ? field.number?.min : undefined}
124
+ max={field.type === "number" ? field.number?.max : undefined}
125
+ step={field.type === "number" ? field.number?.step : undefined}
126
+ placeholder={field.admin.placeholder}
127
+ value={String(value ?? "")}
128
+ readonly={field.admin.readOnly}
129
+ oninput={(event) =>
130
+ form.set(
131
+ field.path,
132
+ field.type === "number" ? event.currentTarget.valueAsNumber : event.currentTarget.value
133
+ )}
134
+ />
135
+ {/if}
136
+ </FieldShell>
137
+ {/if}