@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,358 @@
1
+ <script module lang="ts">
2
+ import type { AdminI18n } from "@riducms/plugin";
3
+ interface JoinColumn {
4
+ path: string;
5
+ label: string;
6
+ }
7
+
8
+ function joinDocuments(value: unknown) {
9
+ return Array.isArray(value)
10
+ ? value.filter(
11
+ (document): document is AdminDocument =>
12
+ typeof document === "object" && document !== null && typeof document.id === "string"
13
+ )
14
+ : [];
15
+ }
16
+
17
+ function joinColumns(
18
+ field: SchemaField,
19
+ collection: SchemaCollection | undefined,
20
+ i18n: AdminI18n
21
+ ): JoinColumn[] {
22
+ if (collection === undefined) return [{ path: "id", label: i18n.t("fields:id") }];
23
+ const configured = field.join?.defaultColumns ?? collection.admin.defaultColumns ?? [];
24
+ const fallback =
25
+ collection.admin.useAsTitle ??
26
+ collection.fields.find((candidate) => candidate.type === "text")?.path ??
27
+ "id";
28
+ const paths = configured.length > 0 ? configured : [fallback, "updatedAt"];
29
+ return paths.map((path) => ({
30
+ path,
31
+ label:
32
+ collection.fields.find((candidate) => candidate.path === path)?.admin.label ??
33
+ systemColumnLabel(path, i18n) ??
34
+ humanize(path, i18n.language),
35
+ }));
36
+ }
37
+
38
+ function joinDefaultValues(path: string, id: string) {
39
+ const values: Record<string, unknown> = {};
40
+ writePath(values, path, id);
41
+ return values;
42
+ }
43
+
44
+ function writePath(values: Record<string, unknown>, path: string, value: unknown) {
45
+ const segments = path.split(".").filter(Boolean);
46
+ let current = values;
47
+ for (const segment of segments.slice(0, -1)) {
48
+ const existing = current[segment];
49
+ if (typeof existing === "object" && existing !== null && !Array.isArray(existing)) {
50
+ current = existing as Record<string, unknown>;
51
+ } else {
52
+ const nested: Record<string, unknown> = {};
53
+ current[segment] = nested;
54
+ current = nested;
55
+ }
56
+ }
57
+ const last = segments.at(-1);
58
+ if (last !== undefined) current[last] = value;
59
+ }
60
+
61
+ function readPath(value: unknown, path: string): unknown {
62
+ let current = value;
63
+ for (const segment of path.split(".")) {
64
+ if (typeof current !== "object" || current === null || Array.isArray(current))
65
+ return undefined;
66
+ current = (current as Record<string, unknown>)[segment];
67
+ }
68
+ return current;
69
+ }
70
+
71
+ function displayValue(value: unknown, i18n: AdminI18n): string {
72
+ if (value === null || value === undefined || value === "") return "—";
73
+ if (typeof value === "boolean") return i18n.t(value ? "general:yes" : "general:no");
74
+ if (typeof value === "number") return i18n.formatNumber(value);
75
+ if (typeof value === "object") {
76
+ if (Array.isArray(value)) return i18n.t("fields:itemCount", { count: value.length });
77
+ const record = value as Record<string, unknown>;
78
+ return String(
79
+ record.title ?? record.name ?? record.email ?? record.id ?? i18n.t("fields:relatedDocument")
80
+ );
81
+ }
82
+ if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
83
+ const date = new Date(value);
84
+ if (!Number.isNaN(date.valueOf())) return i18n.formatDate(date, { dateStyle: "medium" });
85
+ }
86
+ return String(value);
87
+ }
88
+
89
+ function documentLabel(document: AdminDocument) {
90
+ return String(document.title ?? document.name ?? document.email ?? document.id);
91
+ }
92
+
93
+ function sortDocuments(documents: readonly AdminDocument[], sort: string, i18n: AdminI18n) {
94
+ if (sort === "") return [...documents];
95
+ const descending = sort.startsWith("-");
96
+ const path = descending ? sort.slice(1) : sort;
97
+ return [...documents].sort((left, right) => {
98
+ const leftValue = displayValue(readPath(left, path), i18n);
99
+ const rightValue = displayValue(readPath(right, path), i18n);
100
+ const comparison = leftValue.localeCompare(rightValue, i18n.language, { numeric: true });
101
+ return descending ? -comparison : comparison;
102
+ });
103
+ }
104
+
105
+ function humanize(value: string, language: string) {
106
+ return value
107
+ .split(".")
108
+ .at(-1)!
109
+ .replace(/([a-z\d])([A-Z])/g, "$1 $2")
110
+ .replaceAll(/[_-]+/g, " ")
111
+ .replace(/\b\w/g, (letter) => letter.toLocaleUpperCase(language));
112
+ }
113
+
114
+ function systemColumnLabel(path: string, i18n: AdminI18n) {
115
+ if (path === "id") return i18n.t("fields:id");
116
+ if (path === "_status") return i18n.t("fields:status");
117
+ if (path === "createdAt") return i18n.t("fields:createdAt");
118
+ if (path === "updatedAt") return i18n.t("fields:updatedAt");
119
+ return undefined;
120
+ }
121
+ </script>
122
+
123
+ <script lang="ts">
124
+ import { Link } from "@hvniel/svelte-router";
125
+ import type { FieldAuthoringHost } from "@riducms/plugin";
126
+ import type { SchemaCollection, SchemaField } from "@riducms/protocol";
127
+ import { onDestroy } from "svelte";
128
+ import ArrowDownIcon from "~icons/lucide/arrow-down";
129
+ import ArrowUpIcon from "~icons/lucide/arrow-up";
130
+ import ArrowUpRightIcon from "~icons/lucide/arrow-up-right";
131
+ import ListPlusIcon from "~icons/lucide/list-plus";
132
+
133
+ import { Button } from "@admin/components/ui/button";
134
+ import type { AdminDocument } from "@admin/core/api/admin-client";
135
+ import type { FormController } from "@admin/core/forms/form-controller.svelte";
136
+ import { getNotificationCenter } from "@admin/core/notifications/notification-center.svelte";
137
+ import { documentPath } from "@admin/core/routing/admin-paths";
138
+ import { getAdminRuntime } from "@admin/core/runtime/admin-runtime.svelte";
139
+ import FieldShell from "@admin/fields/field-shell.svelte";
140
+
141
+ let {
142
+ field,
143
+ form,
144
+ authoring,
145
+ }: { field: SchemaField; form: FormController; authoring?: FieldAuthoringHost } = $props();
146
+ const runtime = getAdminRuntime();
147
+ const notifications = getNotificationCenter();
148
+ const ReferenceBrowser = $derived(authoring?.referenceBrowser);
149
+ const targetCollection = $derived(
150
+ authoring?.collections.find((collection) => collection.id === field.join?.collectionId)
151
+ );
152
+ const sourceID = $derived(form.resource?.global ? undefined : form.resource?.id);
153
+ const targetOperations = $derived(
154
+ targetCollection === undefined ? undefined : runtime.collectionOperations[targetCollection.slug]
155
+ );
156
+ const canAttach = $derived(sourceID !== undefined && targetOperations?.update === true);
157
+ const canCreate = $derived(
158
+ sourceID !== undefined && field.join?.allowCreate !== false && targetOperations?.create === true
159
+ );
160
+ const canManage = $derived(!field.admin.readOnly && (canAttach || canCreate));
161
+ let browserOpen = $state(false);
162
+ let pending = $state(false);
163
+ let mutationError = $state<string>();
164
+ let mutationController: AbortController | undefined;
165
+ let documents = $state.raw<AdminDocument[]>([]);
166
+ let sortOverride = $state<string>();
167
+ const sort = $derived(sortOverride ?? field.join?.defaultSort ?? "");
168
+ const columns = $derived(joinColumns(field, targetCollection, runtime.i18n));
169
+ const sortedDocuments = $derived(sortDocuments(documents, sort, runtime.i18n));
170
+ onDestroy(() => mutationController?.abort());
171
+
172
+ $effect(() => {
173
+ form.revision;
174
+ documents = joinDocuments(form.get(field.path));
175
+ });
176
+
177
+ function toggleSort(path: string) {
178
+ sortOverride = sort === path ? `-${path}` : sort === `-${path}` ? "" : path;
179
+ }
180
+
181
+ async function commit(ids: string[]) {
182
+ const source = form.resource;
183
+ if (
184
+ source?.id === undefined ||
185
+ source.global ||
186
+ sourceID === undefined ||
187
+ targetCollection === undefined ||
188
+ field.join === undefined
189
+ )
190
+ return false;
191
+ pending = true;
192
+ mutationError = undefined;
193
+ const current = new Set(documents.map((document) => document.id));
194
+ const requested = new Set(ids);
195
+ const additions = ids.filter((id) => !current.has(id));
196
+ const removals = documents
197
+ .filter((document) => !requested.has(document.id))
198
+ .map((document) => document.id);
199
+ if (additions.length === 0 && removals.length === 0) {
200
+ pending = false;
201
+ return true;
202
+ }
203
+ mutationController = new AbortController();
204
+ try {
205
+ const result = await runtime.client.mutateJoin(
206
+ source.collection,
207
+ source.id,
208
+ field.path,
209
+ { additions, removals },
210
+ { signal: mutationController.signal, locale: form.contentLocale }
211
+ );
212
+ if (mutationController.signal.aborted) return false;
213
+ documents = joinDocuments(readPath(result.doc, field.path));
214
+ runtime.documentsChanged();
215
+ notifications.success({
216
+ title: runtime.i18n.t("fields:updated", { label: field.admin.label }),
217
+ message: runtime.i18n.t("fields:joinChanges", {
218
+ added: result.added,
219
+ removed: result.removed,
220
+ }),
221
+ });
222
+ return true;
223
+ } catch (cause) {
224
+ if (mutationController.signal.aborted) return false;
225
+ mutationError =
226
+ cause instanceof Error ? cause.message : runtime.i18n.t("errors:relatedDocumentsUpdate");
227
+ await refresh().catch(() => {});
228
+ notifications.error({
229
+ title: runtime.i18n.t("errors:updateUnconfirmed", { label: field.admin.label }),
230
+ message: mutationError,
231
+ });
232
+ return false;
233
+ } finally {
234
+ pending = false;
235
+ mutationController = undefined;
236
+ }
237
+ }
238
+
239
+ async function refresh() {
240
+ const source = form.resource;
241
+ if (source?.id === undefined) return;
242
+ const document = await runtime.client.find(source.collection, source.id, {
243
+ locale: form.contentLocale,
244
+ });
245
+ documents = joinDocuments(document[field.name]);
246
+ }
247
+
248
+ function setBrowserOpen(open: boolean) {
249
+ browserOpen = open;
250
+ }
251
+ </script>
252
+
253
+ <FieldShell
254
+ {field}
255
+ issues={mutationError === undefined
256
+ ? []
257
+ : [{ code: "join_update_failed", path: field.path, message: mutationError }]}
258
+ >
259
+ <div
260
+ class="overflow-hidden rounded-[4px] border border-control-border"
261
+ data-join-field={field.name}
262
+ >
263
+ <div
264
+ class="flex items-center justify-between gap-3 border-b border-control-border bg-control px-3 py-2"
265
+ >
266
+ <p class="font-mono text-[9.5px] tracking-[0.09em] text-foreground-faint uppercase">
267
+ {runtime.i18n.t("fields:showingRelated", {
268
+ count: documents.length,
269
+ label: targetCollection?.labels.plural ?? field.admin.label,
270
+ })}
271
+ </p>
272
+ {#if canManage}
273
+ <Button variant="outline" size="sm" disabled={pending} onclick={() => (browserOpen = true)}>
274
+ <ListPlusIcon class="size-3" />
275
+ {runtime.i18n.t(pending ? "fields:updating" : "fields:manageRelationships")}
276
+ </Button>
277
+ {/if}
278
+ </div>
279
+ {#if sortedDocuments.length > 0}
280
+ <div class="overflow-x-auto">
281
+ <table class="w-full min-w-[520px] border-collapse text-start text-[12px]">
282
+ <thead>
283
+ <tr class="border-b border-control-border bg-control">
284
+ {#each columns as column (column.path)}
285
+ <th class="px-3 py-2 font-medium text-foreground-faint">
286
+ <button
287
+ type="button"
288
+ class="inline-flex items-center gap-1.5 outline-none hover:text-foreground focus-visible:text-primary"
289
+ onclick={() => toggleSort(column.path)}
290
+ aria-label={runtime.i18n.t("fields:sortBy", { label: column.label })}
291
+ >
292
+ {column.label}
293
+ {#if sort === column.path}<ArrowUpIcon
294
+ class="size-3"
295
+ />{:else if sort === `-${column.path}`}<ArrowDownIcon class="size-3" />{/if}
296
+ </button>
297
+ </th>
298
+ {/each}
299
+ <th class="w-10 px-3 py-2">
300
+ <span class="sr-only">
301
+ {runtime.i18n.t("general:open")}
302
+ </span>
303
+ </th>
304
+ </tr>
305
+ </thead>
306
+ <tbody>
307
+ {#each sortedDocuments as document (document.id)}
308
+ <tr class="border-b border-control-border last:border-b-0 hover:bg-control">
309
+ {#each columns as column (column.path)}
310
+ <td class="max-w-64 px-3 py-2.5 text-foreground-muted">
311
+ <span class="block truncate">
312
+ {displayValue(readPath(document, column.path), runtime.i18n)}
313
+ </span>
314
+ </td>
315
+ {/each}
316
+ <td class="px-3 py-2.5">
317
+ <Link
318
+ class="inline-flex text-foreground-faint hover:text-primary"
319
+ to={documentPath(field.join?.collectionSlug ?? "", document.id)}
320
+ aria-label={runtime.i18n.t("fields:open", {
321
+ label: documentLabel(document),
322
+ })}
323
+ >
324
+ <ArrowUpRightIcon class="size-3.5 rtl:-rotate-90" />
325
+ </Link>
326
+ </td>
327
+ </tr>
328
+ {/each}
329
+ </tbody>
330
+ </table>
331
+ </div>
332
+ {:else}
333
+ <p class="px-3 py-4 text-[12px] text-foreground-faint">
334
+ {runtime.i18n.t("fields:noRelated", {
335
+ label: (targetCollection?.labels.plural ?? field.admin.label).toLocaleLowerCase(
336
+ runtime.i18n.language
337
+ ),
338
+ })}
339
+ </p>
340
+ {/if}
341
+ </div>
342
+ </FieldShell>
343
+
344
+ {#if browserOpen && targetCollection !== undefined && ReferenceBrowser !== undefined && sourceID !== undefined}
345
+ <ReferenceBrowser
346
+ bind:open={() => browserOpen, setBrowserOpen}
347
+ {field}
348
+ collection={targetCollection}
349
+ hasMany={true}
350
+ selectedIDs={documents.map((document) => document.id)}
351
+ readOnly={!canAttach && !canCreate}
352
+ defaultValues={joinDefaultValues(field.join?.on ?? "", sourceID)}
353
+ allowCreate={canCreate}
354
+ locale={form.contentLocale}
355
+ onCommit={commit}
356
+ onClose={() => setBrowserOpen(false)}
357
+ />
358
+ {/if}
@@ -0,0 +1,17 @@
1
+ import type { SchemaField, SchemaLocale } from "@riducms/protocol";
2
+
3
+ export function contentLocaleLabel(locales: readonly SchemaLocale[], code: string | undefined) {
4
+ if (code === undefined) return undefined;
5
+ return locales.find((locale) => locale.code === code)?.label ?? code;
6
+ }
7
+
8
+ export function withContentLocaleLabel(field: SchemaField, localeLabel: string | undefined) {
9
+ if (field.localized !== true || localeLabel === undefined) return field;
10
+ return {
11
+ ...field,
12
+ admin: {
13
+ ...field.admin,
14
+ label: `${field.admin.label} — ${localeLabel}`,
15
+ },
16
+ };
17
+ }