@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,602 @@
1
+ <script lang="ts">
2
+ import type { SchemaField } from "@riducms/protocol";
3
+ import { DragDropProvider, type DragDropEvents } from "@dnd-kit-svelte/svelte";
4
+ import ChevronDownIcon from "~icons/lucide/chevron-down";
5
+ import CirclePlusIcon from "~icons/lucide/circle-plus";
6
+ import EllipsisIcon from "~icons/lucide/ellipsis";
7
+ import GripVerticalIcon from "~icons/lucide/grip-vertical";
8
+ import LayoutTemplateIcon from "~icons/lucide/layout-template";
9
+ import SearchIcon from "~icons/lucide/search";
10
+
11
+ import { Button, buttonVariants } from "@admin/components/ui/button";
12
+ import {
13
+ Dialog,
14
+ DialogContent,
15
+ DialogDescription,
16
+ DialogHeader,
17
+ DialogTitle,
18
+ } from "@admin/components/ui/dialog";
19
+ import {
20
+ DropdownMenu,
21
+ DropdownMenuContent,
22
+ DropdownMenuItem,
23
+ DropdownMenuSeparator,
24
+ DropdownMenuTrigger,
25
+ } from "@admin/components/ui/dropdown-menu";
26
+ import { Input } from "@admin/components/ui/input";
27
+ import type { FormController } from "@admin/core/forms/form-controller.svelte";
28
+ import { initialFormValues } from "@admin/core/forms/form-schema";
29
+ import { immutableRowLabelSnapshot } from "@admin/core/plugins/row-label-registry";
30
+ import { getAdminRuntime } from "@admin/core/runtime/admin-runtime.svelte";
31
+ import FieldLayout from "@admin/fields/field-layout.svelte";
32
+ import FieldMessages from "@admin/fields/field-messages.svelte";
33
+ import {
34
+ compatibleClipboardValue,
35
+ createFieldClipboardPayload,
36
+ readFieldClipboard,
37
+ writeFieldClipboard,
38
+ } from "@admin/fields/field-clipboard";
39
+ import { scopeRepeatedRowField } from "@admin/fields/nested/scoped-field";
40
+ import SortableRow from "@admin/fields/nested/sortable-row.svelte";
41
+
42
+ let { field, form }: { field: SchemaField; form: FormController } = $props();
43
+ const runtime = getAdminRuntime();
44
+ const customRowLabel = $derived(runtime.rowLabels.resolve(field));
45
+ const rows = $derived((form.get(field.path) as Record<string, unknown>[] | undefined) ?? []);
46
+ const issues = $derived(form.issuesFor(field.path).filter((issue) => issue.path === field.path));
47
+ const hasMessage = $derived(issues.length > 0 || field.admin.description !== undefined);
48
+ const minRows = $derived(field.nested?.minRows ?? 0);
49
+ const maxRows = $derived(field.nested?.maxRows ?? 0);
50
+ const readOnly = $derived(field.admin.readOnly === true);
51
+ const canAdd = $derived(!readOnly && (maxRows === 0 || rows.length < maxRows));
52
+ let collapsed = $state(new Set<string>());
53
+ let clipboardMessage = $state("");
54
+ let blockPickerOpen = $state(false);
55
+ let blockQuery = $state("");
56
+ const visibleBlockTypes = $derived(
57
+ (field.blocks?.types ?? []).filter((block) =>
58
+ block.label
59
+ .toLocaleLowerCase(runtime.i18n.language)
60
+ .includes(blockQuery.trim().toLocaleLowerCase(runtime.i18n.language))
61
+ )
62
+ );
63
+
64
+ function changeBlockPickerOpen(open: boolean) {
65
+ blockPickerOpen = open;
66
+ if (!open) blockQuery = "";
67
+ }
68
+
69
+ function rowField(child: SchemaField, index: number, rowKey: string) {
70
+ return scopeRepeatedRowField(child, `${field.path}.${index}`, rowKey, readOnly);
71
+ }
72
+
73
+ function inheritedField(child: SchemaField) {
74
+ return readOnly ? { ...child, admin: { ...child.admin, readOnly: true } } : child;
75
+ }
76
+
77
+ const inheritedFields = $derived((field.nested?.fields ?? []).map(inheritedField));
78
+
79
+ function createRow(blockType?: string) {
80
+ const children =
81
+ field.type === "blocks"
82
+ ? (field.blocks?.types.find((block) => block.key === blockType)?.fields ?? [])
83
+ : (field.nested?.fields ?? []);
84
+ return {
85
+ ...initialFormValues(children),
86
+ _key: crypto.randomUUID(),
87
+ ...(field.type === "blocks" ? { blockType: blockType ?? "" } : {}),
88
+ };
89
+ }
90
+
91
+ function add(blockType?: string) {
92
+ if (readOnly || !canAdd) return;
93
+ if (field.type === "blocks" && blockType === undefined) {
94
+ blockPickerOpen = true;
95
+ return;
96
+ }
97
+ updateRows([...rows, createRow(blockType)]);
98
+ blockPickerOpen = false;
99
+ blockQuery = "";
100
+ }
101
+
102
+ function addBelow(index: number) {
103
+ if (readOnly || !canAdd) return;
104
+ const blockType =
105
+ field.type === "blocks"
106
+ ? String(rows[index]?.blockType ?? field.blocks?.types[0]?.key ?? "")
107
+ : undefined;
108
+ const row = createRow(blockType);
109
+ const next = [...rows];
110
+ next.splice(index + 1, 0, row);
111
+ updateRows(next);
112
+ }
113
+
114
+ async function copyField() {
115
+ await writeFieldClipboard(createFieldClipboardPayload(field, "field", form.get(field.path)));
116
+ clipboardMessage = runtime.i18n.t("fields:copied", { label: field.admin.label });
117
+ }
118
+
119
+ async function pasteField() {
120
+ if (readOnly) return;
121
+ const value = compatibleClipboardValue(await readFieldClipboard(), field, "field");
122
+ if (value === undefined) {
123
+ clipboardMessage = runtime.i18n.t("errors:clipboardField");
124
+ return;
125
+ }
126
+ if ((field.type === "array" || field.type === "blocks") && !Array.isArray(value)) {
127
+ clipboardMessage = runtime.i18n.t("errors:clipboardRows");
128
+ return;
129
+ }
130
+ if (
131
+ field.type === "group" &&
132
+ (value === null || typeof value !== "object" || Array.isArray(value))
133
+ ) {
134
+ clipboardMessage = runtime.i18n.t("errors:clipboardGroup");
135
+ return;
136
+ }
137
+ if (Array.isArray(value) && value.length < minRows) {
138
+ clipboardMessage = runtime.i18n.t("errors:clipboardMinRows", { count: minRows });
139
+ return;
140
+ }
141
+ if (Array.isArray(value) && maxRows > 0 && value.length > maxRows) {
142
+ clipboardMessage = runtime.i18n.t("errors:clipboardMaxRows", { count: maxRows });
143
+ return;
144
+ }
145
+ const pasted = Array.isArray(value)
146
+ ? value.map((row) =>
147
+ row !== null && typeof row === "object" && !Array.isArray(row)
148
+ ? { ...row, _key: crypto.randomUUID() }
149
+ : row
150
+ )
151
+ : value;
152
+ if (Array.isArray(pasted)) updateRows(pasted as Record<string, unknown>[]);
153
+ else form.set(field.path, pasted);
154
+ clipboardMessage = runtime.i18n.t("fields:pasted", { label: field.admin.label });
155
+ }
156
+
157
+ async function copyRow(index: number) {
158
+ await writeFieldClipboard(createFieldClipboardPayload(field, "row", rows[index]));
159
+ clipboardMessage = runtime.i18n.t("fields:copied", {
160
+ label: rowLabel(rows[index] ?? {}, index),
161
+ });
162
+ }
163
+
164
+ async function pasteRow(index: number) {
165
+ if (readOnly || !canAdd) return;
166
+ const value = compatibleClipboardValue(await readFieldClipboard(), field, "row");
167
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
168
+ clipboardMessage = runtime.i18n.t("errors:clipboardRow");
169
+ return;
170
+ }
171
+ const row = value as Record<string, unknown>;
172
+ row._key = crypto.randomUUID();
173
+ if (
174
+ field.type === "blocks" &&
175
+ !field.blocks?.types.some((block) => block.key === row.blockType)
176
+ ) {
177
+ clipboardMessage = runtime.i18n.t("errors:clipboardBlockType");
178
+ return;
179
+ }
180
+ const next = [...rows];
181
+ next.splice(index + 1, 0, row);
182
+ updateRows(next);
183
+ clipboardMessage = runtime.i18n.t("fields:pasted", { label: rowLabel(row, index + 1) });
184
+ }
185
+
186
+ function updateRows(next: Record<string, unknown>[]) {
187
+ if (readOnly) return;
188
+ form.invalidateIndexedFieldCapabilities(field.path);
189
+ form.set(field.path, next);
190
+ }
191
+
192
+ function duplicate(index: number) {
193
+ if (readOnly || !canAdd) return;
194
+ const copy = JSON.parse(JSON.stringify(rows[index] ?? {})) as Record<string, unknown>;
195
+ copy._key = crypto.randomUUID();
196
+ const next = [...rows];
197
+ next.splice(index + 1, 0, copy);
198
+ updateRows(next);
199
+ }
200
+
201
+ function rowLabel(row: Record<string, unknown>, index: number) {
202
+ const key = field.nested?.rowLabel;
203
+ const configured = key === undefined ? undefined : row[key];
204
+ if (configured !== undefined && configured !== null && String(configured).trim() !== "") {
205
+ return String(configured);
206
+ }
207
+ const number = runtime.i18n.formatNumber(index + 1);
208
+ return field.nested?.rowLabels?.singular === undefined
209
+ ? runtime.i18n.t("fields:rowNumber", { number })
210
+ : runtime.i18n.t("fields:labeledRowNumber", {
211
+ label: field.nested.rowLabels.singular,
212
+ number,
213
+ });
214
+ }
215
+
216
+ function visibleRowLabel(row: Record<string, unknown>, index: number) {
217
+ if (field.type !== "blocks") return rowLabel(row, index);
218
+ const key = field.nested?.rowLabel;
219
+ const configured = key === undefined ? undefined : row[key];
220
+ return configured === undefined || configured === null || String(configured).trim() === ""
221
+ ? runtime.i18n.t("fields:untitled", {
222
+ label: blockLabel(row) ?? runtime.i18n.t("fields:block"),
223
+ })
224
+ : String(configured);
225
+ }
226
+
227
+ function blockLabel(row: Record<string, unknown>) {
228
+ if (field.type !== "blocks") return undefined;
229
+ return field.blocks?.types.find((block) => block.key === row.blockType)?.label;
230
+ }
231
+
232
+ function toggleCollapsed(key: string) {
233
+ const next = new Set(collapsed);
234
+ next.has(key) ? next.delete(key) : next.add(key);
235
+ collapsed = next;
236
+ }
237
+
238
+ function collapseAll() {
239
+ collapsed = new Set(rows.map((row) => String(row._key)));
240
+ }
241
+
242
+ function showAll() {
243
+ collapsed = new Set();
244
+ }
245
+
246
+ function fieldsFor(row: Record<string, unknown>) {
247
+ if (field.type !== "blocks") return field.nested?.fields ?? [];
248
+ return field.blocks?.types.find((block) => block.key === row.blockType)?.fields ?? [];
249
+ }
250
+
251
+ function fieldsForRow(row: Record<string, unknown>, index: number) {
252
+ const rowKey = String(row._key);
253
+ return fieldsFor(row).map((child) => rowField(child, index, rowKey));
254
+ }
255
+
256
+ function reorder(event: Parameters<DragDropEvents["dragend"]>[0]) {
257
+ if (readOnly) return;
258
+ const source = String(event.operation.source?.id ?? "");
259
+ const target = String(event.operation.target?.id ?? "");
260
+ const from = rows.findIndex((row) => row._key === source);
261
+ const to = rows.findIndex((row) => row._key === target);
262
+ if (event.canceled || from < 0 || to < 0 || from === to) return;
263
+ const next = [...rows];
264
+ const [moved] = next.splice(from, 1);
265
+ if (moved !== undefined) next.splice(to, 0, moved);
266
+ updateRows(next);
267
+ }
268
+ </script>
269
+
270
+ {#snippet fieldActions()}
271
+ <DropdownMenu>
272
+ <DropdownMenuTrigger
273
+ class={buttonVariants({ variant: "ghost", size: "icon-xs" })}
274
+ aria-label={runtime.i18n.t("fields:openActions", { label: field.admin.label })}
275
+ >
276
+ <EllipsisIcon class="size-4" />
277
+ </DropdownMenuTrigger>
278
+ <DropdownMenuContent>
279
+ <DropdownMenuItem onSelect={copyField}>
280
+ {runtime.i18n.t("fields:copyField")}
281
+ </DropdownMenuItem>
282
+ <DropdownMenuItem disabled={field.admin.readOnly} onSelect={pasteField}>
283
+ {runtime.i18n.t("fields:pasteField")}
284
+ </DropdownMenuItem>
285
+ </DropdownMenuContent>
286
+ </DropdownMenu>
287
+ {/snippet}
288
+
289
+ {#if field.type === "group" && field.admin.namedTab}
290
+ <div data-field-path={field.path}>
291
+ <FieldLayout fields={inheritedFields} {form} />
292
+ </div>
293
+ {:else if field.type === "group"}
294
+ <section
295
+ class="grid gap-5"
296
+ data-field-path={field.path}
297
+ data-invalid={issues.length > 0}
298
+ aria-labelledby={`${field.id}-label`}
299
+ aria-describedby={hasMessage ? `${field.id}-message` : undefined}
300
+ >
301
+ <div class="flex items-start justify-between gap-3 border-b border-control-border pb-2.5">
302
+ <div class="min-w-0">
303
+ <h2 id={`${field.id}-label`} class="text-[17px] font-medium text-foreground">
304
+ {field.admin.label}{#if field.required}<span
305
+ class="ridu-field-required"
306
+ aria-hidden="true"
307
+ >
308
+ *
309
+ </span>{/if}
310
+ </h2>
311
+ {#if field.admin.readOnly}<span class="ridu-field-status mt-1">
312
+ {runtime.i18n.t("fields:readOnly")}
313
+ </span>{/if}
314
+ </div>
315
+ {@render fieldActions()}
316
+ </div>
317
+ <FieldMessages id="{field.id}-message" {issues} description={field.admin.description} />
318
+ <FieldLayout fields={inheritedFields} {form} />
319
+ {#if clipboardMessage !== ""}<p
320
+ class="font-mono text-[10px] text-foreground-faint"
321
+ aria-live="polite"
322
+ >
323
+ {clipboardMessage}
324
+ </p>{/if}
325
+ </section>
326
+ {:else}
327
+ <section
328
+ class="grid gap-3"
329
+ data-field-path={field.path}
330
+ data-invalid={issues.length > 0}
331
+ aria-labelledby={`${field.id}-label`}
332
+ aria-describedby={hasMessage ? `${field.id}-message` : undefined}
333
+ >
334
+ <div class="flex flex-wrap items-end justify-between gap-3">
335
+ <div class="min-w-0">
336
+ <h2 id={`${field.id}-label`} class="text-[19px] leading-7 font-medium text-foreground">
337
+ {field.admin.label}{#if field.required}<span
338
+ class="ridu-field-required"
339
+ aria-hidden="true"
340
+ >
341
+ *
342
+ </span>{/if}
343
+ </h2>
344
+ {#if field.admin.readOnly}<span class="ridu-field-status mt-1">
345
+ {runtime.i18n.t("fields:readOnly")}
346
+ </span>{/if}
347
+ </div>
348
+ <div class="flex items-center gap-1">
349
+ {#if rows.length > 0}
350
+ <Button size="xs" variant="ghost" class="px-1.5" onclick={collapseAll}>
351
+ {runtime.i18n.t("fields:collapseAll")}
352
+ </Button>
353
+ <Button size="xs" variant="ghost" class="px-1.5" onclick={showAll}>
354
+ {runtime.i18n.t("fields:showAll")}
355
+ </Button>
356
+ {/if}
357
+ {@render fieldActions()}
358
+ </div>
359
+ </div>
360
+ <FieldMessages id="{field.id}-message" {issues} description={field.admin.description} />
361
+ <DragDropProvider onDragEnd={reorder}>
362
+ <div class="grid gap-3">
363
+ {#each rows as row, index (String(row._key))}
364
+ {const rowKey = String(row._key)}
365
+ {const rowCollapsed = $derived(collapsed.has(rowKey))}
366
+ {const rowSnapshot = $derived(immutableRowLabelSnapshot(row))}
367
+ <SortableRow id={String(row._key)} {index} disabled={readOnly}>
368
+ {#snippet children(sortable)}
369
+ <div class="flex min-h-10 items-center justify-between gap-2 bg-control px-3 py-2">
370
+ <Button
371
+ variant="ghost"
372
+ size="icon-xs"
373
+ class="shrink-0 cursor-grab text-foreground-faint hover:bg-background hover:text-foreground active:cursor-grabbing"
374
+ aria-label={runtime.i18n.t("fields:drag", {
375
+ label: rowLabel(row, index),
376
+ })}
377
+ disabled={readOnly}
378
+ {@attach sortable.handleRef}
379
+ >
380
+ <GripVerticalIcon class="size-3.5 text-foreground-faint" />
381
+ </Button>
382
+ <div
383
+ id={`${field.id}-${rowKey}-row-label`}
384
+ class="flex min-w-0 flex-1 items-center gap-2 text-[12.5px] text-foreground-muted"
385
+ >
386
+ <span class="font-mono text-[10px] text-foreground-faint">
387
+ {runtime.i18n.formatNumber(index + 1, {
388
+ minimumIntegerDigits: 2,
389
+ useGrouping: false,
390
+ })}
391
+ </span>
392
+ {#if blockLabel(row) !== undefined}<span
393
+ class="rounded-[2px] bg-background px-1.5 py-0.5 text-[11px] text-foreground"
394
+ >
395
+ {blockLabel(row)}
396
+ </span>{/if}
397
+ <div class="min-w-0 flex-1 truncate">
398
+ {#if customRowLabel === undefined}
399
+ {visibleRowLabel(row, index)}
400
+ {:else}
401
+ <customRowLabel.component
402
+ {field}
403
+ row={rowSnapshot}
404
+ rowNumber={index + 1}
405
+ i18n={runtime.i18n}
406
+ config={field.nested?.rowLabelComponent?.config}
407
+ />
408
+ {/if}
409
+ </div>
410
+ </div>
411
+ <button
412
+ type="button"
413
+ class="group shrink-0 rounded-[3px] p-1 text-foreground-faint outline-none hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/70"
414
+ onclick={() => toggleCollapsed(rowKey)}
415
+ aria-label={runtime.i18n.t(rowCollapsed ? "fields:expand" : "fields:collapse")}
416
+ aria-describedby={`${field.id}-${rowKey}-row-label`}
417
+ aria-expanded={!rowCollapsed}
418
+ aria-controls={`${field.id}-${rowKey}-content`}
419
+ >
420
+ <ChevronDownIcon
421
+ class={[
422
+ "ms-auto size-3.5 shrink-0 text-foreground-faint transition-transform duration-200 motion-reduce:transition-none",
423
+ rowCollapsed &&
424
+ (runtime.i18n.direction === "rtl" ? "rotate-90" : "-rotate-90"),
425
+ ]}
426
+ />
427
+ </button>
428
+ <div class="flex items-center gap-0.5">
429
+ <DropdownMenu>
430
+ <DropdownMenuTrigger
431
+ class={buttonVariants({ variant: "ghost", size: "icon-xs" })}
432
+ aria-label={runtime.i18n.t("fields:openActions", {
433
+ label: rowLabel(row, index),
434
+ })}
435
+ >
436
+ <EllipsisIcon class="size-4" />
437
+ </DropdownMenuTrigger>
438
+ <DropdownMenuContent>
439
+ <DropdownMenuItem
440
+ disabled={readOnly || index === 0}
441
+ onSelect={() => {
442
+ const next = [...rows];
443
+ [next[index - 1], next[index]] = [next[index], next[index - 1]];
444
+ updateRows(next);
445
+ }}
446
+ >
447
+ {runtime.i18n.t("fields:moveUp")}
448
+ </DropdownMenuItem>
449
+ <DropdownMenuItem
450
+ disabled={readOnly || index === rows.length - 1}
451
+ onSelect={() => {
452
+ const next = [...rows];
453
+ [next[index], next[index + 1]] = [next[index + 1], next[index]];
454
+ updateRows(next);
455
+ }}
456
+ >
457
+ {runtime.i18n.t("fields:moveDown")}
458
+ </DropdownMenuItem>
459
+ <DropdownMenuItem
460
+ disabled={readOnly || !canAdd}
461
+ onSelect={() => addBelow(index)}
462
+ >
463
+ {runtime.i18n.t("fields:addBelow")}
464
+ </DropdownMenuItem>
465
+ <DropdownMenuSeparator />
466
+ <DropdownMenuItem onSelect={() => copyRow(index)}>
467
+ {runtime.i18n.t("fields:copyRow")}
468
+ </DropdownMenuItem>
469
+ <DropdownMenuItem
470
+ disabled={readOnly || !canAdd}
471
+ onSelect={() => pasteRow(index)}
472
+ >
473
+ {runtime.i18n.t("fields:pasteRow")}
474
+ </DropdownMenuItem>
475
+ <DropdownMenuItem
476
+ disabled={readOnly || !canAdd}
477
+ onSelect={() => duplicate(index)}
478
+ >
479
+ {runtime.i18n.t("fields:duplicate")}
480
+ </DropdownMenuItem>
481
+ <DropdownMenuSeparator />
482
+ <DropdownMenuItem
483
+ class="text-destructive data-[highlighted]:text-destructive"
484
+ disabled={readOnly || rows.length <= minRows}
485
+ onSelect={() =>
486
+ updateRows(rows.filter((_, rowIndex) => rowIndex !== index))}
487
+ >
488
+ {runtime.i18n.t("general:remove")}
489
+ </DropdownMenuItem>
490
+ </DropdownMenuContent>
491
+ </DropdownMenu>
492
+ </div>
493
+ </div>
494
+ <div
495
+ id={`${field.id}-${rowKey}-content`}
496
+ class={[
497
+ "grid transition-[grid-template-rows,opacity] duration-200 ease-out motion-reduce:transition-none",
498
+ rowCollapsed
499
+ ? "invisible grid-rows-[0fr] opacity-0"
500
+ : "grid-rows-[1fr] opacity-100",
501
+ ]}
502
+ aria-hidden={rowCollapsed}
503
+ inert={rowCollapsed}
504
+ >
505
+ <div class="min-h-0 overflow-hidden">
506
+ <div class="grid gap-5 p-4">
507
+ <FieldLayout fields={fieldsForRow(row, index)} {form} />
508
+ </div>
509
+ </div>
510
+ </div>
511
+ {/snippet}
512
+ </SortableRow>
513
+ {/each}
514
+ </div>
515
+ </DragDropProvider>
516
+ <Button
517
+ variant="ghost"
518
+ size="sm"
519
+ class="w-fit px-0 text-foreground-faint hover:bg-transparent hover:text-foreground"
520
+ disabled={field.admin.readOnly || !canAdd}
521
+ onclick={() => add()}
522
+ >
523
+ <CirclePlusIcon class="size-5" />
524
+ {field.type === "blocks"
525
+ ? runtime.i18n.t("fields:addBlock")
526
+ : field.nested?.rowLabels?.singular === undefined
527
+ ? runtime.i18n.t("fields:addRow")
528
+ : runtime.i18n.t("fields:add", { label: field.nested.rowLabels.singular })}
529
+ </Button>
530
+ {#if field.type === "array" && (minRows > 0 || maxRows > 0)}
531
+ <p class="font-mono text-[10px] text-foreground-faint">
532
+ {runtime.i18n.t("fields:rowLimits", {
533
+ count: rows.length,
534
+ minimum:
535
+ minRows > 0
536
+ ? runtime.i18n.t("fields:minimum", { count: minRows })
537
+ : runtime.i18n.t("fields:noMinimum"),
538
+ maximum:
539
+ maxRows > 0
540
+ ? runtime.i18n.t("fields:maximum", { count: maxRows })
541
+ : runtime.i18n.t("fields:noMaximum"),
542
+ })}
543
+ </p>
544
+ {/if}
545
+ {#if clipboardMessage !== ""}<p
546
+ class="font-mono text-[10px] text-foreground-faint"
547
+ aria-live="polite"
548
+ >
549
+ {clipboardMessage}
550
+ </p>{/if}
551
+ </section>
552
+ {/if}
553
+
554
+ {#if field.type === "blocks"}
555
+ <Dialog bind:open={() => blockPickerOpen, changeBlockPickerOpen}>
556
+ <DialogContent class="h-[calc(100vh-4rem)] sm:max-w-4xl">
557
+ <DialogHeader>
558
+ <DialogTitle class="text-[22px]">
559
+ {runtime.i18n.t("fields:add", { label: field.admin.label })}
560
+ </DialogTitle>
561
+ <DialogDescription class="sr-only">
562
+ {runtime.i18n.t("fields:chooseBlockFor", { label: field.admin.label })}
563
+ </DialogDescription>
564
+ </DialogHeader>
565
+ <div class="relative">
566
+ <SearchIcon
567
+ class="pointer-events-none absolute top-1/2 start-3 size-4 -translate-y-1/2 text-foreground-faint"
568
+ />
569
+ <Input
570
+ class="ps-9"
571
+ placeholder={runtime.i18n.t("fields:searchBlock")}
572
+ aria-label={runtime.i18n.t("fields:searchBlock")}
573
+ bind:value={blockQuery}
574
+ />
575
+ </div>
576
+ {#if visibleBlockTypes.length === 0}
577
+ <p class="py-10 text-center text-[13px] text-foreground-muted">
578
+ {runtime.i18n.t("fields:noBlocks")}
579
+ </p>
580
+ {:else}
581
+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
582
+ {#each visibleBlockTypes as block (block.key)}
583
+ <button
584
+ type="button"
585
+ class="group overflow-hidden rounded-[3px] border border-control-border bg-background text-start transition-colors hover:border-control-border-hover focus-visible:border-ring focus-visible:outline-none"
586
+ onclick={() => add(block.key)}
587
+ >
588
+ <span
589
+ class="flex aspect-video items-center justify-center border-b border-control-border bg-control text-foreground-faint transition-colors group-hover:bg-control-hover group-hover:text-foreground-muted"
590
+ >
591
+ <LayoutTemplateIcon class="size-8" />
592
+ </span>
593
+ <span class="block px-3 py-2.5 text-[13px] font-medium text-foreground">
594
+ {block.label}
595
+ </span>
596
+ </button>
597
+ {/each}
598
+ </div>
599
+ {/if}
600
+ </DialogContent>
601
+ </Dialog>
602
+ {/if}
@@ -0,0 +1,78 @@
1
+ import type { SchemaField } from "@riducms/protocol";
2
+
3
+ const canonicalAccessPath = Symbol.for("@riducms/admin/canonicalAccessPath");
4
+ type AccessScopedField = SchemaField & { [canonicalAccessPath]?: string };
5
+
6
+ export function fieldAccessPath(field: SchemaField) {
7
+ return (field as AccessScopedField)[canonicalAccessPath] ?? field.path;
8
+ }
9
+
10
+ /**
11
+ * Rebinds a manifest field tree to one concrete repeating-row path.
12
+ *
13
+ * Manifest paths deliberately omit runtime array indexes and block variants.
14
+ * The form controller, however, addresses the concrete row. Rebinding the
15
+ * complete subtree keeps nested groups/arrays/blocks on that same row while
16
+ * preserving every other piece of manifest metadata.
17
+ */
18
+ export function scopeRepeatedRowField(
19
+ field: SchemaField,
20
+ rowPath: string,
21
+ instance: string,
22
+ readOnly = false
23
+ ): SchemaField {
24
+ return scopeField(field, `${rowPath}.${field.name}`, instance, readOnly);
25
+ }
26
+
27
+ function scopeField(
28
+ field: SchemaField,
29
+ path: string,
30
+ instance: string,
31
+ readOnly: boolean
32
+ ): SchemaField {
33
+ const scoped: AccessScopedField = {
34
+ ...field,
35
+ [canonicalAccessPath]: fieldAccessPath(field),
36
+ id: `${field.id}-${instance}`,
37
+ path,
38
+ admin: scopeAdmin(field.admin, instance, readOnly),
39
+ ...(field.nested === undefined
40
+ ? {}
41
+ : {
42
+ nested: {
43
+ ...field.nested,
44
+ fields: field.nested.fields.map((child) =>
45
+ scopeField(child, `${path}.${child.name}`, instance, readOnly)
46
+ ),
47
+ },
48
+ }),
49
+ ...(field.blocks === undefined
50
+ ? {}
51
+ : {
52
+ blocks: {
53
+ ...field.blocks,
54
+ types: field.blocks.types.map((block) => ({
55
+ ...block,
56
+ fields: block.fields.map((child) =>
57
+ scopeField(child, `${path}.${child.name}`, instance, readOnly)
58
+ ),
59
+ })),
60
+ },
61
+ }),
62
+ };
63
+ return scoped;
64
+ }
65
+
66
+ function scopeAdmin(field: SchemaField["admin"], instance: string, readOnly: boolean) {
67
+ return {
68
+ ...field,
69
+ ...(readOnly ? { readOnly: true } : {}),
70
+ ...(field.row === undefined ? {} : { row: { id: `${field.row.id}-${instance}` } }),
71
+ ...(field.collapsible === undefined
72
+ ? {}
73
+ : { collapsible: { ...field.collapsible, id: `${field.collapsible.id}-${instance}` } }),
74
+ ...(field.tabGroup === undefined
75
+ ? {}
76
+ : { tabGroup: { id: `${field.tabGroup.id}-${instance}` } }),
77
+ };
78
+ }