@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,32 @@
1
+ <script lang="ts">
2
+ import { useSortable } from "@dnd-kit-svelte/svelte/sortable";
3
+ import type { Snippet } from "svelte";
4
+
5
+ let {
6
+ id,
7
+ index,
8
+ disabled = false,
9
+ children,
10
+ }: {
11
+ id: string;
12
+ index: number;
13
+ disabled?: boolean;
14
+ children: Snippet<[ReturnType<typeof useSortable>]>;
15
+ } = $props();
16
+ const sortable = useSortable({
17
+ id: () => id,
18
+ index: () => index,
19
+ group: "ridu-array",
20
+ disabled: () => disabled,
21
+ });
22
+ </script>
23
+
24
+ <section
25
+ class={[
26
+ "grid overflow-hidden rounded-[4px] border border-control-border bg-background transition-colors hover:border-control-border-hover",
27
+ sortable.isDragging.current && "opacity-50",
28
+ ]}
29
+ {@attach sortable.ref}
30
+ >
31
+ {@render children(sortable)}
32
+ </section>
@@ -0,0 +1,52 @@
1
+ <script lang="ts">
2
+ import type { SchemaField } from "@riducms/protocol";
3
+
4
+ import { Input } from "@admin/components/ui/input";
5
+ import type { FormController } from "@admin/core/forms/form-controller.svelte";
6
+ import { getAdminRuntime } from "@admin/core/runtime/admin-runtime.svelte";
7
+ import FieldShell from "@admin/fields/field-shell.svelte";
8
+
9
+ let { field, form }: { field: SchemaField; form: FormController } = $props();
10
+ const runtime = getAdminRuntime();
11
+ const value = $derived(
12
+ Array.isArray(form.get(field.path)) ? (form.get(field.path) as number[]) : []
13
+ );
14
+ const issues = $derived(form.issuesFor(field.path));
15
+
16
+ $effect(() => form.register(field.path));
17
+
18
+ function setCoordinate(index: number, next: number) {
19
+ const coordinates = [Number(value[0] ?? 0), Number(value[1] ?? 0)];
20
+ coordinates[index] = next;
21
+ form.set(field.path, coordinates);
22
+ }
23
+ </script>
24
+
25
+ <FieldShell {field} {issues}>
26
+ <div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
27
+ <label class="grid gap-1.5 text-[12px] text-foreground-muted">
28
+ {runtime.i18n.t("fields:longitude")}
29
+ <Input
30
+ type="number"
31
+ min="-180"
32
+ max="180"
33
+ step="any"
34
+ value={value[0] ?? ""}
35
+ readonly={field.admin.readOnly}
36
+ oninput={(event) => setCoordinate(0, event.currentTarget.valueAsNumber)}
37
+ />
38
+ </label>
39
+ <label class="grid gap-1.5 text-[12px] text-foreground-muted">
40
+ {runtime.i18n.t("fields:latitude")}
41
+ <Input
42
+ type="number"
43
+ min="-90"
44
+ max="90"
45
+ step="any"
46
+ value={value[1] ?? ""}
47
+ readonly={field.admin.readOnly}
48
+ oninput={(event) => setCoordinate(1, event.currentTarget.valueAsNumber)}
49
+ />
50
+ </label>
51
+ </div>
52
+ </FieldShell>
@@ -0,0 +1,34 @@
1
+ <script lang="ts">
2
+ import type { SchemaField } from "@riducms/protocol";
3
+
4
+ import { RadioCardItem, RadioGroup } from "@admin/components/ui/radio-group";
5
+ import type { FormController } from "@admin/core/forms/form-controller.svelte";
6
+ import FieldShell from "@admin/fields/field-shell.svelte";
7
+
8
+ let { field, form }: { field: SchemaField; form: FormController } = $props();
9
+ const value = $derived(String(form.get(field.path) ?? ""));
10
+ const issues = $derived(form.issuesFor(field.path));
11
+ const hasMessage = $derived(issues.length > 0 || field.admin.description !== undefined);
12
+
13
+ $effect(() => form.register(field.path));
14
+ </script>
15
+
16
+ <FieldShell {field} {issues}>
17
+ <RadioGroup
18
+ id={field.id}
19
+ class="flex flex-wrap gap-x-7 gap-y-1"
20
+ name={field.path}
21
+ {value}
22
+ required={field.required}
23
+ readonly={field.admin.readOnly}
24
+ aria-labelledby={`${field.id}-label`}
25
+ aria-invalid={issues.length > 0}
26
+ aria-describedby={hasMessage ? `${field.id}-message` : undefined}
27
+ aria-errormessage={issues.length > 0 ? `${field.id}-message` : undefined}
28
+ onValueChange={(next) => form.set(field.path, next)}
29
+ >
30
+ {#each field.select?.choices ?? [] as choice (choice.value)}
31
+ <RadioCardItem value={choice.value} label={choice.label} />
32
+ {/each}
33
+ </RadioGroup>
34
+ </FieldShell>
@@ -0,0 +1,245 @@
1
+ import type { SchemaField } from "@riducms/protocol";
2
+
3
+ import type { AdminDocument } from "@admin/core/api/admin-client";
4
+ import type { FormController } from "@admin/core/forms/form-controller.svelte";
5
+ import type { AdminRuntime } from "@admin/core/runtime/admin-runtime.svelte";
6
+ import { localizeSchemaCollection } from "@admin/core/i18n/localized-schema";
7
+
8
+ import {
9
+ selectedRelationshipIDs,
10
+ updateRelationshipValue,
11
+ } from "@admin/fields/relationship/relationship-value";
12
+
13
+ interface RelationshipFieldControllerOptions {
14
+ runtime: AdminRuntime;
15
+ get field(): SchemaField;
16
+ get form(): FormController;
17
+ }
18
+
19
+ export class RelationshipFieldController {
20
+ selectedTarget = $state("");
21
+ documents = $state.raw<Record<string, AdminDocument>>({});
22
+ hydrationError = $state<string>();
23
+ browserOpen = $state(false);
24
+ initialDocument = $state.raw<AdminDocument>();
25
+ initialDocumentID = $state<string>();
26
+ #formRevision = 0;
27
+
28
+ constructor(readonly options: RelationshipFieldControllerOptions) {
29
+ this.selectedTarget =
30
+ selectedRelationshipTarget(options.form.get(options.field.path)) ??
31
+ relationshipTargets(options.field)[0]?.collectionSlug ??
32
+ "";
33
+
34
+ $effect(() => this.options.form.register(this.options.field.path));
35
+
36
+ $effect(() => {
37
+ const revision = this.options.form.revision;
38
+ if (revision === this.#formRevision) return;
39
+ this.#formRevision = revision;
40
+ const target = selectedRelationshipTarget(this.options.form.get(this.options.field.path));
41
+ if (
42
+ target !== undefined &&
43
+ this.targets.some((candidate) => candidate.collectionSlug === target)
44
+ ) {
45
+ this.selectedTarget = target;
46
+ }
47
+ });
48
+
49
+ $effect(() => {
50
+ if (this.targets.some((target) => target.collectionSlug === this.selectedTarget)) return;
51
+ this.selectedTarget = this.targets[0]?.collectionSlug ?? "";
52
+ });
53
+
54
+ $effect(() => {
55
+ const slug = this.currentTarget?.collectionSlug;
56
+ const ids = [...this.selectedIDs];
57
+ const revision = this.options.runtime.documentRevision;
58
+ if (slug === undefined || ids.length === 0) {
59
+ this.documents = {};
60
+ this.hydrationError = undefined;
61
+ return;
62
+ }
63
+ const request = new AbortController();
64
+ this.#hydrateSelected(slug, ids, revision, request.signal);
65
+ return () => request.abort();
66
+ });
67
+ }
68
+
69
+ get targets() {
70
+ return relationshipTargets(this.options.field);
71
+ }
72
+
73
+ get hasMany() {
74
+ const field = this.options.field;
75
+ return field.relationship?.hasMany ?? field.upload?.hasMany ?? false;
76
+ }
77
+
78
+ get polymorphic() {
79
+ return this.options.field.relationship?.polymorphic === true;
80
+ }
81
+
82
+ get currentTarget() {
83
+ return (
84
+ this.targets.find((target) => target.collectionSlug === this.selectedTarget) ??
85
+ this.targets[0]
86
+ );
87
+ }
88
+
89
+ get targetCollection() {
90
+ const collection = this.options.runtime.manifest?.collections.find(
91
+ (collection) => collection.slug === this.currentTarget?.collectionSlug
92
+ );
93
+ return collection === undefined
94
+ ? undefined
95
+ : localizeSchemaCollection(collection, this.options.runtime.i18n);
96
+ }
97
+
98
+ get selectedIDs() {
99
+ return selectedRelationshipIDs(
100
+ this.options.form.get(this.options.field.path),
101
+ this.hasMany,
102
+ this.polymorphic,
103
+ this.selectedTarget
104
+ );
105
+ }
106
+
107
+ get selectedID() {
108
+ return this.selectedIDs[0];
109
+ }
110
+
111
+ get issues() {
112
+ return this.options.form.issuesFor(this.options.field.path);
113
+ }
114
+
115
+ selectTarget = (target: string) => {
116
+ this.selectedTarget = target;
117
+ };
118
+
119
+ label = (id: string) => {
120
+ const document = this.documents[id];
121
+ if (document === undefined) return id;
122
+ if (this.targetCollection?.capabilities.upload) return String(document.filename ?? id);
123
+ const titleField = findDisplayField(this.targetCollection?.fields ?? []);
124
+ return String((titleField === undefined ? undefined : document[titleField.name]) ?? id);
125
+ };
126
+
127
+ initials = (id: string) =>
128
+ this.label(id)
129
+ .trim()
130
+ .split(/\s+/)
131
+ .filter(Boolean)
132
+ .slice(0, 2)
133
+ .map((part) => part[0]?.toLocaleUpperCase(this.options.runtime.i18n.language) ?? "")
134
+ .join("");
135
+
136
+ mediaURL = (document: AdminDocument | undefined) =>
137
+ typeof document?.url === "string" ? document.url : undefined;
138
+
139
+ isImage = (document: AdminDocument | undefined) =>
140
+ typeof document?.mimeType === "string" && document.mimeType.startsWith("image/");
141
+
142
+ openBrowser = (documentID?: string) => {
143
+ this.initialDocumentID = documentID;
144
+ this.initialDocument = documentID === undefined ? undefined : this.documents[documentID];
145
+ this.browserOpen = true;
146
+ };
147
+
148
+ setBrowserOpen = (open: boolean) => {
149
+ this.browserOpen = open;
150
+ };
151
+
152
+ commit = (ids: string[]) => {
153
+ const field = this.options.field;
154
+ const form = this.options.form;
155
+ form.set(
156
+ field.path,
157
+ updateRelationshipValue(
158
+ form.get(field.path),
159
+ ids,
160
+ this.hasMany,
161
+ this.polymorphic,
162
+ this.selectedTarget
163
+ )
164
+ );
165
+ };
166
+
167
+ remove = (id: string) => {
168
+ this.commit(this.selectedIDs.filter((candidate) => candidate !== id));
169
+ };
170
+
171
+ async #hydrateSelected(
172
+ slug: string,
173
+ ids: readonly string[],
174
+ revision: number,
175
+ signal: AbortSignal
176
+ ) {
177
+ const results = await Promise.allSettled(
178
+ ids.map((id) =>
179
+ this.options.runtime.client.find(slug, id, {
180
+ signal,
181
+ locale: this.options.form.contentLocale,
182
+ })
183
+ )
184
+ );
185
+ if (signal.aborted || revision !== this.options.runtime.documentRevision) return;
186
+ const documents: Record<string, AdminDocument> = {};
187
+ let failed = false;
188
+ for (let index = 0; index < results.length; index += 1) {
189
+ const result = results[index];
190
+ const id = ids[index];
191
+ if (result?.status === "fulfilled" && id !== undefined) documents[id] = result.value;
192
+ else failed = true;
193
+ }
194
+ this.documents = documents;
195
+ this.hydrationError = failed
196
+ ? this.options.runtime.i18n.t("errors:selectedDocumentsLoad")
197
+ : undefined;
198
+ }
199
+ }
200
+
201
+ function selectedRelationshipTarget(value: unknown): string | undefined {
202
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
203
+ const target = (value as Record<string, unknown>).relationTo;
204
+ return typeof target === "string" ? target : undefined;
205
+ }
206
+ if (Array.isArray(value)) {
207
+ for (const candidate of value) {
208
+ const target: string | undefined = selectedRelationshipTarget(candidate);
209
+ if (target !== undefined) return target;
210
+ }
211
+ }
212
+ return undefined;
213
+ }
214
+
215
+ function relationshipTargets(field: SchemaField) {
216
+ if (field.upload !== undefined) {
217
+ return [
218
+ {
219
+ collectionId: field.upload.collectionId,
220
+ collectionSlug: field.upload.collectionSlug,
221
+ },
222
+ ];
223
+ }
224
+ if ((field.relationship?.targets?.length ?? 0) > 0) return field.relationship?.targets ?? [];
225
+ if (
226
+ field.relationship?.collectionId !== undefined &&
227
+ field.relationship.collectionSlug !== undefined
228
+ ) {
229
+ return [
230
+ {
231
+ collectionId: field.relationship.collectionId,
232
+ collectionSlug: field.relationship.collectionSlug,
233
+ },
234
+ ];
235
+ }
236
+ return [];
237
+ }
238
+
239
+ function findDisplayField(fields: readonly SchemaField[]) {
240
+ return (
241
+ fields.find((field) => field.name === "title" || field.name === "name") ??
242
+ fields.find((field) => field.type === "text" && field.name !== "email") ??
243
+ fields.find((field) => field.type === "text" || field.type === "email")
244
+ );
245
+ }
@@ -0,0 +1,258 @@
1
+ <script lang="ts">
2
+ import type { FieldAuthoringHost } from "@riducms/plugin";
3
+ import type { SchemaField } from "@riducms/protocol";
4
+ import FileIcon from "~icons/lucide/file";
5
+ import PencilIcon from "~icons/lucide/pencil";
6
+ import PlusIcon from "~icons/lucide/plus";
7
+ import SearchIcon from "~icons/lucide/search";
8
+ import XIcon from "~icons/lucide/x";
9
+
10
+ import { Button } from "@admin/components/ui/button";
11
+ import type { FormController } from "@admin/core/forms/form-controller.svelte";
12
+ import { localizeSchemaCollection } from "@admin/core/i18n/localized-schema";
13
+ import { getAdminRuntime } from "@admin/core/runtime/admin-runtime.svelte";
14
+ import FieldShell from "@admin/fields/field-shell.svelte";
15
+
16
+ import { RelationshipFieldController } from "@admin/fields/relationship/relationship-field-controller.svelte";
17
+ import RelationshipQuickPicker from "@admin/fields/relationship/relationship-quick-picker.svelte";
18
+ import { relationshipOptionFilters } from "@admin/fields/relationship/relationship-query";
19
+
20
+ let {
21
+ field,
22
+ form,
23
+ authoring,
24
+ }: { field: SchemaField; form: FormController; authoring?: FieldAuthoringHost } = $props();
25
+ const runtime = getAdminRuntime();
26
+ const ReferenceBrowser = $derived(authoring?.referenceBrowser);
27
+ const controller = new RelationshipFieldController({
28
+ runtime,
29
+ get field() {
30
+ return field;
31
+ },
32
+ get form() {
33
+ return form;
34
+ },
35
+ });
36
+ const {
37
+ browserOpen,
38
+ documents,
39
+ hasMany,
40
+ hydrationError,
41
+ initialDocument,
42
+ initialDocumentID,
43
+ issues,
44
+ polymorphic,
45
+ selectedID,
46
+ selectedIDs,
47
+ selectedTarget,
48
+ targetCollection,
49
+ targets,
50
+ } = $derived(controller);
51
+ const {
52
+ commit,
53
+ initials,
54
+ isImage,
55
+ label,
56
+ mediaURL,
57
+ openBrowser,
58
+ remove,
59
+ selectTarget,
60
+ setBrowserOpen,
61
+ } = controller;
62
+ const hasMessage = $derived(issues.length > 0 || field.admin.description !== undefined);
63
+ const optionFilter = $derived(
64
+ relationshipOptionFilters(field, form.values, targetCollection?.slug)
65
+ );
66
+ const quickPickerTargets = $derived(
67
+ targets.flatMap((target) => {
68
+ const collection = runtime.manifest?.collections.find(
69
+ (candidate) => candidate.slug === target.collectionSlug
70
+ );
71
+ return collection === undefined
72
+ ? []
73
+ : [
74
+ {
75
+ collection: localizeSchemaCollection(collection, runtime.i18n),
76
+ filter: relationshipOptionFilters(field, form.values, collection.slug),
77
+ },
78
+ ];
79
+ })
80
+ );
81
+
82
+ function pickRelationship(target: string, id: string) {
83
+ selectTarget(target);
84
+ commit([id]);
85
+ }
86
+
87
+ function browseRelationships(target: string) {
88
+ selectTarget(target);
89
+ openBrowser();
90
+ }
91
+ </script>
92
+
93
+ <FieldShell {field} {issues}>
94
+ {#if targetCollection === undefined}
95
+ <div
96
+ class="rounded-[4px] border border-destructive/25 bg-destructive/7 p-3 text-[12.5px] text-destructive"
97
+ role="alert"
98
+ >
99
+ {runtime.i18n.t("errors:relationshipTargetUnavailable")}
100
+ </div>
101
+ {:else if field.upload !== undefined && !hasMany && selectedID !== undefined}
102
+ {const document = documents[selectedID]}
103
+ <div
104
+ class="flex min-h-17 items-center gap-2.5 rounded-[4px] border border-control-border bg-control p-2"
105
+ >
106
+ <button
107
+ type="button"
108
+ class="group flex min-w-0 flex-1 items-center gap-2.5 rounded-[3px] text-start outline-none focus-visible:ring-2 focus-visible:ring-ring/70"
109
+ onclick={() => openBrowser(selectedID)}
110
+ aria-label={runtime.i18n.t("fields:edit", { label: label(selectedID) })}
111
+ >
112
+ <span
113
+ class="grid size-14 shrink-0 place-items-center overflow-hidden rounded-[3px] border border-control-border bg-background"
114
+ >
115
+ {#if isImage(document) && mediaURL(document) !== undefined}
116
+ <img
117
+ class="size-full object-cover"
118
+ src={mediaURL(document)}
119
+ alt={typeof document?.alt === "string" ? document.alt : ""}
120
+ />
121
+ {:else}
122
+ <FileIcon class="size-5 text-foreground-faint" />
123
+ {/if}
124
+ </span>
125
+ <span class="min-w-0 flex-1">
126
+ <span class="block truncate text-[13px] text-foreground-strong group-hover:text-primary">
127
+ {label(selectedID)}
128
+ </span>
129
+ <span class="font-mono mt-0.5 block truncate text-[9.5px] text-foreground-faint">
130
+ {selectedID}
131
+ </span>
132
+ <span class="mt-1.5 flex items-center gap-1 text-[11px] text-foreground-sub">
133
+ <PencilIcon class="size-3" />
134
+ {runtime.i18n.t("fields:inspectEdit")}
135
+ </span>
136
+ </span>
137
+ </button>
138
+ {#if !field.admin.readOnly}
139
+ <div class="flex shrink-0 flex-wrap justify-end gap-1">
140
+ <Button variant="outline" size="xs" onclick={() => openBrowser()}>
141
+ <SearchIcon class="size-3" />
142
+ {runtime.i18n.t("general:replace")}
143
+ </Button>
144
+ <Button variant="ghost" size="xs" onclick={() => commit([])}>
145
+ <XIcon class="size-3" />
146
+ {runtime.i18n.t("general:remove")}
147
+ </Button>
148
+ </div>
149
+ {/if}
150
+ </div>
151
+ {:else}
152
+ {#if !hasMany}
153
+ <RelationshipQuickPicker
154
+ id={field.id}
155
+ label={field.admin.label}
156
+ targets={quickPickerTargets}
157
+ {selectedTarget}
158
+ {selectedID}
159
+ selectedLabel={selectedID === undefined ? undefined : label(selectedID)}
160
+ selectedInitials={selectedID === undefined ? undefined : initials(selectedID)}
161
+ placeholder={field.admin.placeholder}
162
+ readOnly={field.admin.readOnly}
163
+ locale={form.contentLocale}
164
+ upload={field.upload !== undefined}
165
+ invalid={issues.length > 0}
166
+ describedBy={hasMessage ? `${field.id}-message` : undefined}
167
+ onPick={pickRelationship}
168
+ onRemove={() => commit([])}
169
+ onBrowse={browseRelationships}
170
+ onEdit={selectedID === undefined ? undefined : () => openBrowser(selectedID)}
171
+ />
172
+ {:else}
173
+ <div
174
+ class="flex min-h-10 min-w-0 rounded-[3px] border border-control-border bg-control transition-colors hover:border-control-border-hover aria-invalid:border-destructive/65"
175
+ aria-invalid={issues.length > 0}
176
+ >
177
+ {#if selectedIDs.length === 0}
178
+ <button
179
+ id={field.id}
180
+ type="button"
181
+ class="flex min-h-10 min-w-0 flex-1 items-center px-3 text-start text-[13px] text-foreground-placeholder outline-none hover:text-foreground focus-visible:text-primary"
182
+ onclick={() => openBrowser()}
183
+ aria-describedby={hasMessage ? `${field.id}-message` : undefined}
184
+ >
185
+ <span class="px-1.5 text-[13px] text-foreground-placeholder">
186
+ {field.admin.placeholder ??
187
+ runtime.i18n.t("fields:select", {
188
+ label: targetCollection.labels.singular.toLocaleLowerCase(runtime.i18n.language),
189
+ })}
190
+ </span>
191
+ </button>
192
+ {:else}
193
+ <div class="flex min-w-0 flex-1 flex-wrap items-center gap-1.5 p-1.5">
194
+ {#each selectedIDs as id (id)}
195
+ <div
196
+ class="inline-flex max-w-full items-center gap-1 rounded-[3px] border border-control-border bg-background py-0.5 pe-0.5 ps-2 text-[12px] text-foreground-muted"
197
+ >
198
+ <button
199
+ type="button"
200
+ class="min-w-0 flex-1 truncate text-start outline-none hover:text-foreground focus-visible:text-primary"
201
+ onclick={() => openBrowser(id)}
202
+ aria-label={runtime.i18n.t("fields:edit", { label: label(id) })}
203
+ >
204
+ {label(id)}
205
+ </button>
206
+ {#if !field.admin.readOnly}
207
+ <Button
208
+ variant="ghost"
209
+ size="icon-xs"
210
+ onclick={() => remove(id)}
211
+ aria-label={runtime.i18n.t("fields:remove", { label: label(id) })}
212
+ >
213
+ <XIcon class="size-3" />
214
+ </Button>
215
+ {/if}
216
+ </div>
217
+ {/each}
218
+ </div>
219
+ {/if}
220
+ <Button
221
+ variant="ghost"
222
+ size="icon"
223
+ class="h-auto min-h-10 w-10 rounded-s-none border-s border-control-border"
224
+ onclick={() => openBrowser()}
225
+ aria-describedby={hasMessage ? `${field.id}-message` : undefined}
226
+ aria-label={runtime.i18n.t("fields:browse", {
227
+ label: targetCollection.labels.plural.toLocaleLowerCase(runtime.i18n.language),
228
+ })}
229
+ >
230
+ {#if selectedIDs.length === 0}<PlusIcon class="size-3.5" />{:else}<SearchIcon
231
+ class="size-3.5"
232
+ />{/if}
233
+ </Button>
234
+ </div>
235
+ {/if}
236
+ {/if}
237
+
238
+ {#if hydrationError !== undefined}
239
+ <p class="ridu-field-error" role="alert">{hydrationError}</p>
240
+ {/if}
241
+ </FieldShell>
242
+
243
+ {#if browserOpen && targetCollection !== undefined && ReferenceBrowser !== undefined}
244
+ <ReferenceBrowser
245
+ bind:open={() => browserOpen, setBrowserOpen}
246
+ {field}
247
+ collection={targetCollection}
248
+ {hasMany}
249
+ {selectedIDs}
250
+ readOnly={field.admin.readOnly}
251
+ {initialDocument}
252
+ {initialDocumentID}
253
+ {optionFilter}
254
+ locale={form.contentLocale}
255
+ onCommit={commit}
256
+ onClose={() => setBrowserOpen(false)}
257
+ />
258
+ {/if}