@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,19 @@
1
+ <script lang="ts">
2
+ import { Tabs as TabsPrimitive } from "bits-ui";
3
+ import { cn } from "@riducms/ui";
4
+
5
+ let {
6
+ ref = $bindable(null),
7
+ value = $bindable(""),
8
+ class: className,
9
+ ...restProps
10
+ }: TabsPrimitive.RootProps = $props();
11
+ </script>
12
+
13
+ <TabsPrimitive.Root
14
+ bind:ref
15
+ bind:value
16
+ data-slot="tabs"
17
+ class={cn("gap-2 group/tabs flex data-[orientation=horizontal]:flex-col", className)}
18
+ {...restProps}
19
+ />
@@ -0,0 +1 @@
1
+ export { Textarea } from "@riducms/ui";
@@ -0,0 +1,4 @@
1
+ export { default as Tooltip } from "@admin/components/ui/tooltip/tooltip.svelte";
2
+ export { default as TooltipContent } from "@admin/components/ui/tooltip/tooltip-content.svelte";
3
+ export { default as TooltipProvider } from "@admin/components/ui/tooltip/tooltip-provider.svelte";
4
+ export { default as TooltipTrigger } from "@admin/components/ui/tooltip/tooltip-trigger.svelte";
@@ -0,0 +1,50 @@
1
+ <script lang="ts">
2
+ import { Tooltip as TooltipPrimitive } from "bits-ui";
3
+ import { cn } from "@riducms/ui";
4
+ import type { WithoutChildrenOrChild } from "@riducms/ui";
5
+
6
+ let {
7
+ ref = $bindable(null),
8
+ class: className,
9
+ sideOffset = 0,
10
+ side = "top",
11
+ children,
12
+ arrowClasses,
13
+ portalProps,
14
+ ...restProps
15
+ }: TooltipPrimitive.ContentProps & {
16
+ arrowClasses?: string;
17
+ portalProps?: WithoutChildrenOrChild<TooltipPrimitive.PortalProps>;
18
+ } = $props();
19
+ </script>
20
+
21
+ <TooltipPrimitive.Portal {...portalProps}>
22
+ <TooltipPrimitive.Content
23
+ bind:ref
24
+ data-slot="tooltip-content"
25
+ {sideOffset}
26
+ {side}
27
+ class={cn(
28
+ "inline-flex items-center gap-1.5 rounded-xl px-3 py-1.5 text-xs has-data-[slot=kbd]:pe-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-lg data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-[state=instant-open]:animate-in data-[state=instant-open]:fade-in-0 data-[state=instant-open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 z-50 w-fit max-w-xs origin-(--bits-tooltip-content-transform-origin) bg-foreground text-background",
29
+ className
30
+ )}
31
+ {...restProps}
32
+ >
33
+ {@render children?.()}
34
+ <TooltipPrimitive.Arrow>
35
+ {#snippet child({ props })}
36
+ <div
37
+ class={cn(
38
+ "size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] data-[side=left]:translate-x-[-1.5px] data-[side=right]:translate-x-[1.5px] z-50 bg-foreground fill-foreground",
39
+ "data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%+2px)]",
40
+ "data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%+1px)]",
41
+ "data-[side=right]:translate-x-[calc(50%+2px)] data-[side=right]:translate-y-1/2",
42
+ "data-[side=left]:-translate-y-[calc(50%-3px)]",
43
+ arrowClasses
44
+ )}
45
+ {...props}
46
+ ></div>
47
+ {/snippet}
48
+ </TooltipPrimitive.Arrow>
49
+ </TooltipPrimitive.Content>
50
+ </TooltipPrimitive.Portal>
@@ -0,0 +1,7 @@
1
+ <script lang="ts">
2
+ import { Tooltip as TooltipPrimitive } from "bits-ui";
3
+
4
+ let { delayDuration = 0, ...restProps }: TooltipPrimitive.ProviderProps = $props();
5
+ </script>
6
+
7
+ <TooltipPrimitive.Provider {delayDuration} {...restProps} />
@@ -0,0 +1,7 @@
1
+ <script lang="ts" generics="T = never">
2
+ import { Tooltip as TooltipPrimitive } from "bits-ui";
3
+
4
+ let { ref = $bindable(null), ...restProps }: TooltipPrimitive.TriggerProps<T> = $props();
5
+ </script>
6
+
7
+ <TooltipPrimitive.Trigger bind:ref data-slot="tooltip-trigger" {...restProps} />
@@ -0,0 +1,7 @@
1
+ <script lang="ts" generics="T = never">
2
+ import { Tooltip as TooltipPrimitive } from "bits-ui";
3
+
4
+ let { open = $bindable(false), ...restProps }: TooltipPrimitive.RootProps<T> = $props();
5
+ </script>
6
+
7
+ <TooltipPrimitive.Root bind:open {...restProps} />
@@ -0,0 +1,45 @@
1
+ import {
2
+ createClient,
3
+ type CollectionContract,
4
+ type Version,
5
+ type ScheduledPublish,
6
+ type RiduClient,
7
+ } from "@riducms/sdk";
8
+ import type { FieldDocument } from "@riducms/plugin";
9
+
10
+ export type AdminDocument = FieldDocument;
11
+
12
+ type AdminCollection = CollectionContract & {
13
+ auth: true;
14
+ upload: true;
15
+ versions: true;
16
+ output: AdminDocument;
17
+ create: Record<string, unknown>;
18
+ update: Record<string, unknown>;
19
+ where: Record<string, unknown>;
20
+ select: Record<string, boolean>;
21
+ populate: Record<string, unknown>;
22
+ trash: true;
23
+ };
24
+
25
+ type AdminGlobal = {
26
+ versions: true;
27
+ output: AdminDocument;
28
+ update: Record<string, unknown>;
29
+ select: Record<string, boolean>;
30
+ populate: Record<string, unknown>;
31
+ };
32
+
33
+ export interface AdminConfig {
34
+ collections: Record<string, AdminCollection>;
35
+ globals: Record<string, AdminGlobal>;
36
+ }
37
+
38
+ export type AdminVersion = Version<AdminDocument>;
39
+ export type AdminScheduledPublish = ScheduledPublish;
40
+
41
+ export type AdminClient = RiduClient<AdminConfig>;
42
+
43
+ export function createAdminClient(baseURL = window.location.origin): AdminClient {
44
+ return createClient<AdminConfig>({ baseURL });
45
+ }
@@ -0,0 +1,63 @@
1
+ import type {
2
+ SchemaFieldCondition,
3
+ SchemaFieldConditionPredicate,
4
+ SchemaFieldConditionValue,
5
+ } from "@riducms/protocol";
6
+
7
+ export type FieldConditionValueReader = (path: string) => unknown;
8
+
9
+ /** Evaluates manifest-owned presentation metadata without mutating form values. */
10
+ export function evaluateFieldCondition(
11
+ condition: SchemaFieldCondition,
12
+ fieldPath: string,
13
+ read: FieldConditionValueReader
14
+ ): boolean {
15
+ switch (condition.kind) {
16
+ case "all":
17
+ return condition.conditions.every((child) => evaluateFieldCondition(child, fieldPath, read));
18
+ case "any":
19
+ return condition.conditions.some((child) => evaluateFieldCondition(child, fieldPath, read));
20
+ case "not":
21
+ return !evaluateFieldCondition(condition.conditions[0], fieldPath, read);
22
+ case "predicate":
23
+ return evaluatePredicate(condition.predicate, fieldPath, read);
24
+ }
25
+ }
26
+
27
+ export function fieldConditionValuePath(
28
+ scope: SchemaFieldConditionPredicate["scope"],
29
+ path: string,
30
+ fieldPath: string
31
+ ): string {
32
+ if (scope === "document") return path;
33
+ const separator = fieldPath.lastIndexOf(".");
34
+ return separator === -1 ? path : `${fieldPath.slice(0, separator)}.${path}`;
35
+ }
36
+
37
+ function evaluatePredicate(
38
+ predicate: SchemaFieldConditionPredicate,
39
+ fieldPath: string,
40
+ read: FieldConditionValueReader
41
+ ) {
42
+ const actual = read(fieldConditionValuePath(predicate.scope, predicate.path, fieldPath));
43
+ const matches = (expected: SchemaFieldConditionValue) => scalarMatches(actual, expected);
44
+ switch (predicate.operator) {
45
+ case "equals":
46
+ return predicate.values[0] !== undefined && matches(predicate.values[0]);
47
+ case "notEquals":
48
+ return predicate.values[0] !== undefined && !matches(predicate.values[0]);
49
+ case "oneOf":
50
+ return predicate.values.some(matches);
51
+ }
52
+ }
53
+
54
+ function scalarMatches(actual: unknown, expected: SchemaFieldConditionValue) {
55
+ switch (expected.type) {
56
+ case "string":
57
+ return typeof actual === "string" && actual === expected.value;
58
+ case "number":
59
+ return typeof actual === "number" && actual === Number(expected.value);
60
+ case "boolean":
61
+ return typeof actual === "boolean" && actual === (expected.value === "true");
62
+ }
63
+ }
@@ -0,0 +1,369 @@
1
+ import type { AccessCapabilitiesEnvelope, ValidationIssue } from "@riducms/protocol";
2
+ import type { SchemaField } from "@riducms/protocol";
3
+ import type { AdminI18n } from "@riducms/plugin";
4
+ import { RiduError } from "@riducms/sdk";
5
+ import { createAdminI18n } from "@riducms/translations";
6
+
7
+ import {
8
+ cloneFormValues,
9
+ localizationSource,
10
+ reconcileFormSchema,
11
+ recoverFormDraft,
12
+ shouldSubmitLocalizedPath,
13
+ type DetachedDraftValue,
14
+ type FormValues,
15
+ submissionFormValues,
16
+ } from "@admin/core/forms/form-schema";
17
+ import { validateFormValues } from "@admin/core/forms/form-validation";
18
+
19
+ export type { DetachedDraftValue, FormValues } from "@admin/core/forms/form-schema";
20
+
21
+ export interface FormReconciliationResult {
22
+ detached: DetachedDraftValue[];
23
+ restoredFields: number;
24
+ }
25
+
26
+ export interface FormResource {
27
+ collection: string;
28
+ id?: string;
29
+ global?: boolean;
30
+ }
31
+
32
+ export interface DerivedTextBinding {
33
+ follow(): void;
34
+ setManual(value: string): void;
35
+ }
36
+
37
+ interface DerivedTextBindingState {
38
+ sourcePath: string;
39
+ following: boolean;
40
+ derive: (source: unknown) => string;
41
+ stop: () => void;
42
+ }
43
+
44
+ export class FormValidationError extends Error {
45
+ constructor(
46
+ readonly issues: readonly ValidationIssue[],
47
+ i18n: AdminI18n = createAdminI18n()
48
+ ) {
49
+ super(i18n.t("errors:correctInvalidFields"));
50
+ this.name = "FormValidationError";
51
+ }
52
+ }
53
+
54
+ export class FormController {
55
+ values = $state<FormValues>({});
56
+ original = $state<FormValues>({});
57
+ issues = $state<ValidationIssue[]>([]);
58
+ submitting = $state(false);
59
+ revision = $state(0);
60
+ access = $state.raw<AccessCapabilitiesEnvelope>();
61
+ accessMode = $state<"create" | "update">("update");
62
+ writeBlocked = $state(false);
63
+ resource = $state.raw<FormResource>();
64
+ contentLocale = $state<string>();
65
+ localizationSources = $state.raw<Readonly<Record<string, string>>>({});
66
+ #registered = new Set<string>();
67
+ #pathObservers = new Map<string, Set<(value: unknown) => void>>();
68
+ #derivedTextBindings = new Map<string, DerivedTextBindingState>();
69
+ #revision = 0;
70
+ #i18n: AdminI18n;
71
+
72
+ constructor(values: FormValues = {}, i18n: AdminI18n = createAdminI18n()) {
73
+ this.#i18n = i18n;
74
+ this.reset(values);
75
+ }
76
+
77
+ get dirty() {
78
+ return JSON.stringify(this.values) !== JSON.stringify(this.original);
79
+ }
80
+
81
+ register(path: string) {
82
+ this.#registered.add(path);
83
+ return () => this.#registered.delete(path);
84
+ }
85
+
86
+ isRegistered(path: string) {
87
+ return this.#registered.has(path);
88
+ }
89
+
90
+ observe(path: string, observer: (value: unknown) => void) {
91
+ const observers = this.#pathObservers.get(path) ?? new Set<(value: unknown) => void>();
92
+ observers.add(observer);
93
+ this.#pathObservers.set(path, observers);
94
+ return () => {
95
+ observers.delete(observer);
96
+ if (observers.size === 0) this.#pathObservers.delete(path);
97
+ };
98
+ }
99
+
100
+ bindDerivedText(
101
+ path: string,
102
+ sourcePath: string,
103
+ derive: (source: unknown) => string,
104
+ follows: (current: unknown, source: unknown) => boolean
105
+ ): DerivedTextBinding {
106
+ let binding = this.#derivedTextBindings.get(path);
107
+ if (binding !== undefined && binding.sourcePath !== sourcePath) {
108
+ binding.stop();
109
+ this.#derivedTextBindings.delete(path);
110
+ binding = undefined;
111
+ }
112
+ if (binding === undefined) {
113
+ const source = this.get(sourcePath);
114
+ const state: DerivedTextBindingState = {
115
+ sourcePath,
116
+ following: follows(this.get(path), source),
117
+ derive,
118
+ stop: () => undefined,
119
+ };
120
+ state.stop = this.observe(sourcePath, (nextSource) => {
121
+ if (state.following) this.#setDerivedText(path, state.derive(nextSource));
122
+ });
123
+ this.#derivedTextBindings.set(path, state);
124
+ binding = state;
125
+ if (state.following) this.#setDerivedText(path, state.derive(source));
126
+ }
127
+ const active = binding;
128
+ return {
129
+ follow: () => {
130
+ active.following = true;
131
+ this.#setDerivedText(path, active.derive(this.get(active.sourcePath)));
132
+ },
133
+ setManual: (value) => {
134
+ active.following = false;
135
+ this.set(path, value);
136
+ },
137
+ };
138
+ }
139
+
140
+ get(path: string) {
141
+ return readPath(this.values, path);
142
+ }
143
+
144
+ snapshot() {
145
+ return cloneFormValues(this.values);
146
+ }
147
+
148
+ set(path: string, value: unknown) {
149
+ writePath(this.values, path, value);
150
+ this.issues = this.issues.filter(
151
+ (issue) => issue.path !== path && !issue.path.startsWith(`${path}.`)
152
+ );
153
+ for (const [observedPath, observers] of this.#pathObservers) {
154
+ if (
155
+ observedPath !== path &&
156
+ !observedPath.startsWith(`${path}.`) &&
157
+ !path.startsWith(`${observedPath}.`)
158
+ ) {
159
+ continue;
160
+ }
161
+ const observedValue = this.get(observedPath);
162
+ for (const observer of [...observers]) observer(observedValue);
163
+ }
164
+ }
165
+
166
+ issuesFor(path: string) {
167
+ return this.issues.filter((issue) => issue.path === path || issue.path.startsWith(`${path}.`));
168
+ }
169
+
170
+ setAccess(access: AccessCapabilitiesEnvelope | undefined, mode: "create" | "update") {
171
+ this.access = access;
172
+ this.accessMode = mode;
173
+ }
174
+
175
+ setResource(resource: FormResource | undefined) {
176
+ this.resource = resource;
177
+ }
178
+
179
+ setLocalization(locale: string | undefined, sources: Readonly<Record<string, string>> = {}) {
180
+ this.contentLocale = locale;
181
+ this.localizationSources = sources;
182
+ }
183
+
184
+ localizationSource(path: string) {
185
+ return localizationSource(path, this.values, this.original, this.localizationSources);
186
+ }
187
+
188
+ isInherited(path: string) {
189
+ const source = this.localizationSource(path);
190
+ return source !== undefined && source !== this.contentLocale;
191
+ }
192
+
193
+ fieldCapabilities(path: string, canonicalPath = authoredPath(path)) {
194
+ const access = this.access;
195
+ if (access === undefined) return undefined;
196
+ return access.fields[path] ?? access.fields[canonicalPath] ?? access.fields[authoredPath(path)];
197
+ }
198
+
199
+ canRead(path: string, canonicalPath?: string) {
200
+ return this.fieldCapabilities(path, canonicalPath)?.read ?? true;
201
+ }
202
+
203
+ canWrite(path: string, canonicalPath?: string) {
204
+ if (this.writeBlocked) return false;
205
+ const access = this.access;
206
+ if (access === undefined) return true;
207
+ const field = this.fieldCapabilities(path, canonicalPath);
208
+ return this.accessMode === "create"
209
+ ? (field?.create ?? access.operations.create)
210
+ : (field?.update ?? access.operations.update);
211
+ }
212
+
213
+ invalidateIndexedFieldCapabilities(path: string) {
214
+ const access = this.access;
215
+ if (access === undefined) return;
216
+ const fields = Object.fromEntries(
217
+ Object.entries(access.fields).filter(([candidate]) => !isIndexedChildPath(path, candidate))
218
+ );
219
+ if (Object.keys(fields).length === Object.keys(access.fields).length) return;
220
+ this.access = { ...access, fields };
221
+ }
222
+
223
+ reset(values: FormValues) {
224
+ this.#clearDerivedTextBindings();
225
+ this.values = cloneFormValues(values);
226
+ this.original = cloneFormValues(values);
227
+ this.localizationSources = {};
228
+ this.issues = [];
229
+ this.submitting = false;
230
+ this.revision = ++this.#revision;
231
+ }
232
+
233
+ reconcile(
234
+ previousFields: readonly SchemaField[],
235
+ nextFields: readonly SchemaField[],
236
+ initializeDefaults = false
237
+ ) {
238
+ this.#clearDerivedTextBindings();
239
+ const result = reconcileFormSchema(this, previousFields, nextFields, { initializeDefaults });
240
+ this.values = result.values;
241
+ this.original = result.original;
242
+ this.issues = [];
243
+ this.revision = ++this.#revision;
244
+ return { detached: result.detached, restoredFields: 0 };
245
+ }
246
+
247
+ recover(
248
+ draft: { values: FormValues; original: FormValues },
249
+ previousFields: readonly SchemaField[],
250
+ nextFields: readonly SchemaField[]
251
+ ) {
252
+ this.#clearDerivedTextBindings();
253
+ const result = recoverFormDraft(this, draft, previousFields, nextFields);
254
+ this.values = result.values;
255
+ this.original = result.original;
256
+ this.issues = [];
257
+ this.revision = ++this.#revision;
258
+ return { detached: result.detached, restoredFields: result.restoredFields };
259
+ }
260
+
261
+ async submit<T>(
262
+ fields: readonly SchemaField[],
263
+ requireMissing: boolean,
264
+ operation: (values: FormValues) => Promise<T>
265
+ ) {
266
+ const controllerRevision = this.#revision;
267
+ const include = (path: string, canonicalPath: string) =>
268
+ this.canRead(path, canonicalPath) &&
269
+ this.canWrite(path, canonicalPath) &&
270
+ shouldSubmitLocalizedPath(
271
+ path,
272
+ this.values,
273
+ this.original,
274
+ this.contentLocale,
275
+ this.localizationSources
276
+ );
277
+ const clientIssues = validateFormValues(fields, this.values, {
278
+ requireMissing,
279
+ include,
280
+ i18n: this.#i18n,
281
+ });
282
+ if (clientIssues.length > 0) {
283
+ this.issues = clientIssues;
284
+ throw new FormValidationError(clientIssues, this.#i18n);
285
+ }
286
+ this.submitting = true;
287
+ this.issues = [];
288
+ try {
289
+ const result = await operation(submissionFormValues(fields, this.values, include));
290
+ if (this.#revision === controllerRevision) {
291
+ this.original = cloneFormValues(this.values);
292
+ this.revision = ++this.#revision;
293
+ }
294
+ return result;
295
+ } catch (error) {
296
+ if (this.#revision === controllerRevision && error instanceof RiduError) {
297
+ this.issues = [...error.issues];
298
+ }
299
+ throw error;
300
+ } finally {
301
+ if (this.#revision === controllerRevision) this.submitting = false;
302
+ }
303
+ }
304
+
305
+ #setDerivedText(path: string, value: string) {
306
+ if (String(this.get(path) ?? "") !== value) this.set(path, value);
307
+ }
308
+
309
+ #clearDerivedTextBindings() {
310
+ for (const binding of this.#derivedTextBindings.values()) binding.stop();
311
+ this.#derivedTextBindings.clear();
312
+ }
313
+ }
314
+
315
+ function authoredPath(path: string) {
316
+ return path
317
+ .split(".")
318
+ .filter((segment) => !/^\d+$/.test(segment))
319
+ .join(".");
320
+ }
321
+
322
+ function isIndexedChildPath(parent: string, candidate: string) {
323
+ const prefix = parent === "" ? "" : `${parent}.`;
324
+ if (!candidate.startsWith(prefix)) return false;
325
+ return /^\d+$/.test(candidate.slice(prefix.length).split(".", 1)[0] ?? "");
326
+ }
327
+
328
+ function readPath(values: FormValues, path: string) {
329
+ let current: unknown = values;
330
+ for (const segment of path.split(".")) {
331
+ if (Array.isArray(current)) {
332
+ current = current[Number(segment)];
333
+ } else if (isRecord(current)) {
334
+ current = current[segment];
335
+ } else return undefined;
336
+ }
337
+ return current;
338
+ }
339
+
340
+ function writePath(values: FormValues, path: string, value: unknown) {
341
+ const segments = path.split(".");
342
+ let current: FormValues | unknown[] = values;
343
+ for (let position = 0; position < segments.length - 1; position += 1) {
344
+ const segment = segments[position];
345
+ if (segment === undefined) continue;
346
+ let next = getChild(current, segment);
347
+ if (!isRecord(next) && !Array.isArray(next)) {
348
+ const following = segments[position + 1];
349
+ next = /^\d+$/.test(following ?? "") ? [] : {};
350
+ setChild(current, segment, next);
351
+ }
352
+ current = next as FormValues | unknown[];
353
+ }
354
+ const last = segments.at(-1);
355
+ if (last !== undefined) setChild(current, last, value);
356
+ }
357
+
358
+ function getChild(container: FormValues | unknown[], segment: string) {
359
+ return Array.isArray(container) ? container[Number(segment)] : container[segment];
360
+ }
361
+
362
+ function setChild(container: FormValues | unknown[], segment: string, value: unknown) {
363
+ if (Array.isArray(container)) container[Number(segment)] = value;
364
+ else container[segment] = value;
365
+ }
366
+
367
+ function isRecord(value: unknown): value is FormValues {
368
+ return typeof value === "object" && value !== null && !Array.isArray(value);
369
+ }
@@ -0,0 +1,80 @@
1
+ import type { SchemaCollection } from "@riducms/protocol";
2
+
3
+ import { cloneFormValues, type FormValues } from "@admin/core/forms/form-schema";
4
+
5
+ const recoveryVersion = 1;
6
+ const storagePrefix = "ridu:form-recovery:";
7
+
8
+ export interface FormDraftCheckpoint {
9
+ version: typeof recoveryVersion;
10
+ collection: SchemaCollection;
11
+ documentID?: string;
12
+ values: FormValues;
13
+ original: FormValues;
14
+ createdAt: string;
15
+ }
16
+
17
+ export function saveFormDraft(
18
+ collection: SchemaCollection,
19
+ documentID: string | undefined,
20
+ values: FormValues,
21
+ original: FormValues
22
+ ): boolean {
23
+ try {
24
+ const checkpoint: FormDraftCheckpoint = {
25
+ version: recoveryVersion,
26
+ collection,
27
+ ...(documentID === undefined ? {} : { documentID }),
28
+ values: cloneFormValues(values),
29
+ original: cloneFormValues(original),
30
+ createdAt: new Date().toISOString(),
31
+ };
32
+ sessionStorage.setItem(storageKey(collection.id, documentID), JSON.stringify(checkpoint));
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ export function takeFormDraft(
40
+ collectionID: string,
41
+ documentID: string | undefined
42
+ ): FormDraftCheckpoint | undefined {
43
+ const key = storageKey(collectionID, documentID);
44
+ try {
45
+ const encoded = sessionStorage.getItem(key);
46
+ if (encoded === null) return undefined;
47
+ sessionStorage.removeItem(key);
48
+ const decoded: unknown = JSON.parse(encoded);
49
+ return isCheckpoint(decoded, collectionID, documentID) ? decoded : undefined;
50
+ } catch {
51
+ return undefined;
52
+ }
53
+ }
54
+
55
+ export function clearFormDraft(collectionID: string, documentID: string | undefined): void {
56
+ try {
57
+ sessionStorage.removeItem(storageKey(collectionID, documentID));
58
+ } catch {
59
+ // Recovery is best-effort when browser storage is unavailable.
60
+ }
61
+ }
62
+
63
+ function storageKey(collectionID: string, documentID: string | undefined) {
64
+ return `${storagePrefix}${collectionID}:${documentID ?? "new"}`;
65
+ }
66
+
67
+ function isCheckpoint(
68
+ value: unknown,
69
+ collectionID: string,
70
+ documentID: string | undefined
71
+ ): value is FormDraftCheckpoint {
72
+ if (!isRecord(value) || value.version !== recoveryVersion) return false;
73
+ if (!isRecord(value.collection) || value.collection.id !== collectionID) return false;
74
+ if (value.documentID !== documentID) return false;
75
+ return isRecord(value.values) && isRecord(value.original) && typeof value.createdAt === "string";
76
+ }
77
+
78
+ function isRecord(value: unknown): value is Record<string, unknown> {
79
+ return typeof value === "object" && value !== null && !Array.isArray(value);
80
+ }