@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,364 @@
1
+ import type { PluginMessageCatalog, TranslationLanguage } from "@riducms/translations";
2
+ import {
3
+ createAdminI18n,
4
+ en,
5
+ resolvePreferredLanguage,
6
+ type AdminI18n,
7
+ type AdminTranslationKey,
8
+ type TranslationVariables,
9
+ } from "@riducms/translations";
10
+ import type {
11
+ AuthSession,
12
+ SchemaAdminLanguage,
13
+ SchemaAdminLocalizationSettings,
14
+ } from "@riducms/protocol";
15
+ import { RiduError } from "@riducms/sdk";
16
+
17
+ import type { AdminClient } from "@admin/core/api/admin-client";
18
+ import {
19
+ getPreferenceWriteQueue,
20
+ preferenceOwnerID,
21
+ } from "@admin/core/preferences/preference-write-queue";
22
+ import type { AdminDocument } from "@admin/core/api/admin-client";
23
+
24
+ const languagePreferenceKey = "admin-language";
25
+ const timeZonePreferenceKey = "admin-timezone";
26
+ const browserLanguageKey = "ridu:admin-language";
27
+ const browserTimeZoneKey = "ridu:admin-timezone";
28
+
29
+ export class AdminI18nController implements AdminI18n {
30
+ #language = $state("en");
31
+ #timeZone = $state<string>();
32
+ #settings = $state.raw<SchemaAdminLocalizationSettings>();
33
+ #catalogs: readonly TranslationLanguage[];
34
+ #catalogByCode: ReadonlyMap<string, TranslationLanguage>;
35
+ #messages: Readonly<Record<string, PluginMessageCatalog>>;
36
+ #translatorCache: AdminI18n | undefined;
37
+ #translatorRevision = $state(0);
38
+ #preferenceRequest = 0;
39
+ #languageWrite = 0;
40
+ #timeZoneWrite = 0;
41
+ #confirmedLanguage = "en";
42
+ #confirmedTimeZone: string | undefined;
43
+
44
+ constructor(
45
+ private readonly client: AdminClient,
46
+ private readonly session: () => AuthSession<AdminDocument> | undefined,
47
+ catalogs: readonly TranslationLanguage[] = [en],
48
+ messages: Readonly<Record<string, PluginMessageCatalog>> = {}
49
+ ) {
50
+ this.#catalogs = catalogs.length === 0 ? [en] : [...catalogs];
51
+ this.#catalogByCode = new Map(this.#catalogs.map((catalog) => [catalog.code, catalog]));
52
+ this.#messages = messages;
53
+ const stored = readBrowserPreference(browserLanguageKey);
54
+ this.#language = resolvePreferredLanguage(
55
+ [
56
+ ...(stored === undefined ? [] : [stored]),
57
+ ...(typeof navigator === "undefined" ? [] : navigator.languages),
58
+ ],
59
+ this.#catalogs.map((catalog) => catalog.code),
60
+ this.#catalogByCode.has("en") ? "en" : (this.#catalogs[0]?.code ?? "en")
61
+ );
62
+ this.#confirmedLanguage = this.#language;
63
+ this.#applyDocumentLanguage();
64
+ }
65
+
66
+ get language() {
67
+ return this.#language;
68
+ }
69
+
70
+ get direction(): "ltr" | "rtl" {
71
+ return (this.languages.find((language) => language.code === this.#language)?.rtl ??
72
+ this.#catalogByCode.get(this.#language)?.rtl) === true
73
+ ? "rtl"
74
+ : "ltr";
75
+ }
76
+
77
+ get timeZone() {
78
+ return this.#timeZone;
79
+ }
80
+
81
+ get languages(): readonly SchemaAdminLanguage[] {
82
+ return (
83
+ this.#settings?.languages ??
84
+ this.#catalogs.map(({ code, label, rtl }) => ({ code, label, ...(rtl ? { rtl } : {}) }))
85
+ );
86
+ }
87
+
88
+ get timeZones() {
89
+ return this.#settings?.timeZones ?? [];
90
+ }
91
+
92
+ get #translator() {
93
+ // Components may first call t() after a translator was prepared during bootstrap. Reading an
94
+ // explicit revision keeps those calls reactive even when the cached translator itself does not
95
+ // need to read #language or #timeZone again.
96
+ void this.#translatorRevision;
97
+ return (this.#translatorCache ??= createAdminI18n({
98
+ languages: this.#configuredCatalogsFor(this.languages),
99
+ language: this.#language,
100
+ fallbackLanguage: this.#fallbackLanguage,
101
+ ...(this.#timeZone === undefined ? {} : { timeZone: this.#timeZone }),
102
+ pluginMessages: this.#messages,
103
+ }));
104
+ }
105
+
106
+ get #fallbackLanguage() {
107
+ return (
108
+ this.#settings?.defaultLanguage ??
109
+ (this.#catalogByCode.has("en") ? "en" : (this.#catalogs[0]?.code ?? "en"))
110
+ );
111
+ }
112
+
113
+ #configuredCatalogsFor(languages: readonly SchemaAdminLanguage[]) {
114
+ return languages.map((language) => {
115
+ const catalog = this.#catalogByCode.get(language.code);
116
+ if (catalog === undefined) {
117
+ throw new Error(
118
+ `Admin language ${language.code} is configured in Go but no matching static catalog was passed to mountAdmin.`
119
+ );
120
+ }
121
+ if ((catalog.rtl === true) !== (language.rtl === true)) {
122
+ throw new Error(
123
+ `Admin language ${language.code} has conflicting RTL settings in Go and its static catalog.`
124
+ );
125
+ }
126
+ return { ...catalog, label: language.label, rtl: language.rtl === true };
127
+ });
128
+ }
129
+
130
+ text = (canonical: string, translations?: Readonly<Record<string, string>>) =>
131
+ this.#translator.text(canonical, translations);
132
+
133
+ t = (key: AdminTranslationKey, variables?: TranslationVariables) =>
134
+ this.#translator.t(key, variables);
135
+
136
+ formatDate = (value: Date | string | number, options?: Intl.DateTimeFormatOptions) =>
137
+ this.#translator.formatDate(value, options);
138
+
139
+ formatNumber = (value: number, options?: Intl.NumberFormatOptions) =>
140
+ this.#translator.formatNumber(value, options);
141
+
142
+ formatRelativeTime = (value: number, unit: Intl.RelativeTimeFormatUnit) =>
143
+ this.#translator.formatRelativeTime(value, unit);
144
+
145
+ formatList = (values: readonly string[], options?: Intl.ListFormatOptions) =>
146
+ this.#translator.formatList(values, options);
147
+
148
+ configure(settings: SchemaAdminLocalizationSettings | undefined) {
149
+ const languages =
150
+ settings?.languages ??
151
+ this.#catalogs.map(({ code, label, rtl }) => ({ code, label, ...(rtl ? { rtl } : {}) }));
152
+ const fallbackLanguage =
153
+ settings?.defaultLanguage ??
154
+ (this.#catalogByCode.has("en") ? "en" : (this.#catalogs[0]?.code ?? "en"));
155
+ const supported = languages.map((language) => language.code);
156
+ const stored = readBrowserPreference(browserLanguageKey);
157
+ const preferences = [
158
+ ...(stored === undefined ? [] : [stored]),
159
+ ...(typeof navigator === "undefined" ? [] : navigator.languages),
160
+ ];
161
+ const language = resolvePreferredLanguage(preferences, supported, fallbackLanguage);
162
+ const browserTimeZone = readBrowserPreference(browserTimeZoneKey) ?? detectedTimeZone();
163
+ const timeZone = this.#resolvedTimeZone(browserTimeZone, settings);
164
+ const translator = createAdminI18n({
165
+ languages: this.#configuredCatalogsFor(languages),
166
+ language,
167
+ fallbackLanguage,
168
+ ...(timeZone === undefined ? {} : { timeZone }),
169
+ pluginMessages: this.#messages,
170
+ });
171
+
172
+ // Commit only after every catalog, direction, locale, and timezone validates.
173
+ this.#settings = settings;
174
+ this.#language = language;
175
+ this.#timeZone = timeZone;
176
+ this.#confirmedLanguage = language;
177
+ this.#confirmedTimeZone = timeZone;
178
+ this.#translatorCache = translator;
179
+ this.#translatorRevision += 1;
180
+ this.#languageWrite += 1;
181
+ this.#timeZoneWrite += 1;
182
+ this.#applyDocumentLanguage();
183
+ }
184
+
185
+ async loadPreferences() {
186
+ const request = ++this.#preferenceRequest;
187
+ this.#languageWrite += 1;
188
+ this.#timeZoneWrite += 1;
189
+ const sessionID = this.session()?.id;
190
+ if (sessionID === undefined) return;
191
+ const [language, timeZone] = await Promise.all([
192
+ this.#readPreference(languagePreferenceKey),
193
+ this.#readPreference(timeZonePreferenceKey),
194
+ ]);
195
+ if (request !== this.#preferenceRequest || this.session()?.id !== sessionID) return;
196
+ if (typeof language === "string" && this.languages.some((item) => item.code === language)) {
197
+ this.#language = language;
198
+ this.#confirmedLanguage = language;
199
+ writeBrowserPreference(browserLanguageKey, language);
200
+ }
201
+ if (typeof timeZone === "string" && this.timeZones.some((item) => item.id === timeZone)) {
202
+ this.#timeZone = timeZone;
203
+ this.#confirmedTimeZone = timeZone;
204
+ writeBrowserPreference(browserTimeZoneKey, timeZone);
205
+ }
206
+ this.#invalidateTranslator();
207
+ this.#applyDocumentLanguage();
208
+ }
209
+
210
+ async setLanguage(language: string) {
211
+ if (!this.languages.some((item) => item.code === language)) {
212
+ throw new Error(`Admin language ${language} is not configured.`);
213
+ }
214
+ this.#preferenceRequest += 1;
215
+ const write = ++this.#languageWrite;
216
+ const sessionID = this.session()?.id;
217
+ const ownerID = preferenceOwnerID(this.session());
218
+ this.#language = language;
219
+ this.#invalidateTranslator();
220
+ this.#applyDocumentLanguage();
221
+ writeBrowserPreference(browserLanguageKey, language);
222
+ try {
223
+ await this.#writePreference(languagePreferenceKey, language);
224
+ if (this.session()?.id !== sessionID || preferenceOwnerID(this.session()) !== ownerID) {
225
+ throw new Error("The signed-in session changed before the preference was saved.");
226
+ }
227
+ this.#confirmedLanguage = language;
228
+ } catch (error) {
229
+ if (
230
+ write === this.#languageWrite &&
231
+ this.session()?.id === sessionID &&
232
+ preferenceOwnerID(this.session()) === ownerID
233
+ ) {
234
+ this.#language = this.#confirmedLanguage;
235
+ this.#invalidateTranslator();
236
+ this.#applyDocumentLanguage();
237
+ writeBrowserPreference(browserLanguageKey, this.#confirmedLanguage);
238
+ }
239
+ throw error;
240
+ }
241
+ }
242
+
243
+ async setTimeZone(timeZone: string) {
244
+ if (!this.timeZones.some((item) => item.id === timeZone)) {
245
+ throw new Error(`Admin timezone ${timeZone} is not configured.`);
246
+ }
247
+ this.#preferenceRequest += 1;
248
+ const write = ++this.#timeZoneWrite;
249
+ const sessionID = this.session()?.id;
250
+ const ownerID = preferenceOwnerID(this.session());
251
+ this.#timeZone = timeZone;
252
+ this.#invalidateTranslator();
253
+ writeBrowserPreference(browserTimeZoneKey, timeZone);
254
+ try {
255
+ await this.#writePreference(timeZonePreferenceKey, timeZone);
256
+ if (this.session()?.id !== sessionID || preferenceOwnerID(this.session()) !== ownerID) {
257
+ throw new Error("The signed-in session changed before the preference was saved.");
258
+ }
259
+ this.#confirmedTimeZone = timeZone;
260
+ } catch (error) {
261
+ if (
262
+ write === this.#timeZoneWrite &&
263
+ this.session()?.id === sessionID &&
264
+ preferenceOwnerID(this.session()) === ownerID
265
+ ) {
266
+ this.#timeZone = this.#confirmedTimeZone;
267
+ this.#invalidateTranslator();
268
+ if (this.#confirmedTimeZone === undefined) removeBrowserPreference(browserTimeZoneKey);
269
+ else writeBrowserPreference(browserTimeZoneKey, this.#confirmedTimeZone);
270
+ }
271
+ throw error;
272
+ }
273
+ }
274
+
275
+ reset() {
276
+ this.#preferenceRequest += 1;
277
+ this.#languageWrite += 1;
278
+ this.#timeZoneWrite += 1;
279
+ removeBrowserPreference(browserLanguageKey);
280
+ removeBrowserPreference(browserTimeZoneKey);
281
+ this.configure(this.#settings);
282
+ }
283
+
284
+ async #readPreference(key: string): Promise<unknown> {
285
+ try {
286
+ return await this.client.preference<unknown>(key);
287
+ } catch (error) {
288
+ if (error instanceof RiduError && error.code === "not_found") return undefined;
289
+ return undefined;
290
+ }
291
+ }
292
+
293
+ async #writePreference(key: string, value: string) {
294
+ const owner = preferenceOwnerID(this.session());
295
+ if (owner === "") {
296
+ await this.client.setPreference(key, value);
297
+ return;
298
+ }
299
+ const sessionID = this.session()?.id;
300
+ const result = await getPreferenceWriteQueue().enqueue(
301
+ JSON.stringify([owner, key]),
302
+ owner,
303
+ () => this.session()?.id === sessionID,
304
+ () => this.client.setPreference(key, value)
305
+ );
306
+ if (!result.dispatched)
307
+ throw new Error("The signed-in session changed before the preference was saved.");
308
+ }
309
+
310
+ #resolvedTimeZone(
311
+ candidate: string | undefined,
312
+ settings: SchemaAdminLocalizationSettings | undefined = this.#settings
313
+ ) {
314
+ const configured = settings?.timeZones ?? [];
315
+ if (configured.length === 0) return undefined;
316
+ if (candidate !== undefined && configured.some((item) => item.id === candidate))
317
+ return candidate;
318
+ return settings?.defaultTimeZone ?? configured[0]?.id;
319
+ }
320
+
321
+ #invalidateTranslator() {
322
+ this.#translatorCache = undefined;
323
+ this.#translatorRevision += 1;
324
+ }
325
+
326
+ #applyDocumentLanguage() {
327
+ if (typeof document === "undefined") return;
328
+ document.documentElement.lang = this.#language;
329
+ document.documentElement.dir = this.direction;
330
+ document.documentElement.dataset.adminLanguage = this.#language;
331
+ }
332
+ }
333
+
334
+ function readBrowserPreference(key: string) {
335
+ try {
336
+ return globalThis.localStorage?.getItem(key) ?? undefined;
337
+ } catch {
338
+ return undefined;
339
+ }
340
+ }
341
+
342
+ function writeBrowserPreference(key: string, value: string) {
343
+ try {
344
+ globalThis.localStorage?.setItem(key, value);
345
+ } catch {
346
+ // Browser persistence is an enhancement; the server preference remains authoritative.
347
+ }
348
+ }
349
+
350
+ function removeBrowserPreference(key: string) {
351
+ try {
352
+ globalThis.localStorage?.removeItem(key);
353
+ } catch {
354
+ // Browser persistence is an enhancement; the server preference remains authoritative.
355
+ }
356
+ }
357
+
358
+ function detectedTimeZone() {
359
+ try {
360
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
361
+ } catch {
362
+ return undefined;
363
+ }
364
+ }
@@ -0,0 +1,111 @@
1
+ import type { AdminI18n } from "@riducms/plugin";
2
+ import type { SchemaCollection, SchemaField } from "@riducms/protocol";
3
+
4
+ /** Returns detached display metadata for the active interface language. */
5
+ export function localizeSchemaField(field: SchemaField, i18n: AdminI18n): SchemaField {
6
+ return {
7
+ ...field,
8
+ admin: {
9
+ ...field.admin,
10
+ label: i18n.text(field.admin.label, field.admin.labelTranslations),
11
+ ...(field.admin.description === undefined
12
+ ? {}
13
+ : {
14
+ description: i18n.text(field.admin.description, field.admin.descriptionTranslations),
15
+ }),
16
+ ...(field.admin.placeholder === undefined
17
+ ? {}
18
+ : {
19
+ placeholder: i18n.text(field.admin.placeholder, field.admin.placeholderTranslations),
20
+ }),
21
+ ...(field.admin.collapsible === undefined
22
+ ? {}
23
+ : {
24
+ collapsible: {
25
+ ...field.admin.collapsible,
26
+ label: i18n.text(
27
+ field.admin.collapsible.label,
28
+ field.admin.collapsible.labelTranslations
29
+ ),
30
+ },
31
+ }),
32
+ },
33
+ ...(field.select === undefined
34
+ ? {}
35
+ : {
36
+ select: {
37
+ ...field.select,
38
+ choices: field.select.choices.map((choice) => ({
39
+ ...choice,
40
+ label: i18n.text(choice.label, choice.labelTranslations),
41
+ })),
42
+ },
43
+ }),
44
+ ...(field.nested === undefined
45
+ ? {}
46
+ : {
47
+ nested: {
48
+ ...field.nested,
49
+ fields: field.nested.fields.map((child) => localizeSchemaField(child, i18n)),
50
+ ...(field.nested.rowLabels === undefined
51
+ ? {}
52
+ : {
53
+ rowLabels: {
54
+ ...field.nested.rowLabels,
55
+ singular: i18n.text(
56
+ field.nested.rowLabels.singular,
57
+ field.nested.rowLabels.singularTranslations
58
+ ),
59
+ plural: i18n.text(
60
+ field.nested.rowLabels.plural,
61
+ field.nested.rowLabels.pluralTranslations
62
+ ),
63
+ },
64
+ }),
65
+ },
66
+ }),
67
+ ...(field.blocks === undefined
68
+ ? {}
69
+ : {
70
+ blocks: {
71
+ ...field.blocks,
72
+ types: field.blocks.types.map((block) => ({
73
+ ...block,
74
+ label: i18n.text(block.label, block.labelTranslations),
75
+ fields: block.fields.map((child) => localizeSchemaField(child, i18n)),
76
+ })),
77
+ },
78
+ }),
79
+ };
80
+ }
81
+
82
+ export function localizeSchemaCollection(
83
+ collection: SchemaCollection,
84
+ i18n: AdminI18n
85
+ ): SchemaCollection {
86
+ return {
87
+ ...collection,
88
+ labels: {
89
+ ...collection.labels,
90
+ singular: i18n.text(collection.labels.singular, collection.labels.singularTranslations),
91
+ plural: i18n.text(collection.labels.plural, collection.labels.pluralTranslations),
92
+ },
93
+ admin: {
94
+ ...collection.admin,
95
+ ...(collection.admin.group === undefined
96
+ ? {}
97
+ : {
98
+ group: i18n.text(collection.admin.group, collection.admin.groupTranslations),
99
+ }),
100
+ ...(collection.admin.description === undefined
101
+ ? {}
102
+ : {
103
+ description: i18n.text(
104
+ collection.admin.description,
105
+ collection.admin.descriptionTranslations
106
+ ),
107
+ }),
108
+ },
109
+ fields: collection.fields.map((field) => localizeSchemaField(field, i18n)),
110
+ };
111
+ }
@@ -0,0 +1,69 @@
1
+ import { createContext } from "svelte";
2
+ import { toast } from "svelte-sonner";
3
+
4
+ import ValidationToast from "@admin/core/notifications/validation-toast.svelte";
5
+
6
+ export type NotificationTone = "success" | "warning" | "error";
7
+
8
+ export interface NotificationInput {
9
+ title: string;
10
+ message?: string;
11
+ duration?: number;
12
+ }
13
+
14
+ export interface ValidationNotificationInput {
15
+ labels: readonly string[];
16
+ }
17
+
18
+ export class NotificationCenter {
19
+ #ids = new Set<number | string>();
20
+
21
+ success = (input: NotificationInput) => this.#show("success", input);
22
+
23
+ warning = (input: NotificationInput) => this.#show("warning", input);
24
+
25
+ error = (input: NotificationInput) => this.#show("error", input);
26
+
27
+ validation = ({ labels }: ValidationNotificationInput) => {
28
+ let id: number | string;
29
+ const uniqueLabels = [...new Set(labels)];
30
+ id = toast.error(ValidationToast, {
31
+ componentProps: { labels: uniqueLabels },
32
+ duration: 7_000,
33
+ important: true,
34
+ icon: null,
35
+ onDismiss: () => this.#ids.delete(id),
36
+ onAutoClose: () => this.#ids.delete(id),
37
+ });
38
+ this.#ids.add(id);
39
+ return id;
40
+ };
41
+
42
+ dismiss = (id: number | string) => {
43
+ toast.dismiss(id);
44
+ this.#ids.delete(id);
45
+ };
46
+
47
+ destroy() {
48
+ for (const id of this.#ids) toast.dismiss(id);
49
+ this.#ids.clear();
50
+ }
51
+
52
+ #show(tone: NotificationTone, input: NotificationInput) {
53
+ let id: number | string;
54
+ const options = {
55
+ description: input.message,
56
+ duration: input.duration ?? (tone === "success" ? 4_500 : 7_000),
57
+ important: tone === "error",
58
+ onDismiss: () => this.#ids.delete(id),
59
+ onAutoClose: () => this.#ids.delete(id),
60
+ };
61
+ id = toast[tone](input.title, options);
62
+ this.#ids.add(id);
63
+ return id;
64
+ }
65
+ }
66
+
67
+ const [getNotificationCenter, setNotificationCenter] = createContext<NotificationCenter>();
68
+
69
+ export { getNotificationCenter, setNotificationCenter };
@@ -0,0 +1,18 @@
1
+ <script lang="ts">
2
+ let { labels }: { labels: readonly string[] } = $props();
3
+ </script>
4
+
5
+ {#if labels.length === 1}
6
+ <span>
7
+ The following field is invalid: <strong>{labels[0]}</strong>
8
+ </span>
9
+ {:else}
10
+ <div>
11
+ <p>The following fields are invalid ({labels.length}):</p>
12
+ <ul class="mt-1 list-[circle] space-y-0.5 ps-5">
13
+ {#each labels as label (label)}
14
+ <li class="ps-0.5">{label}</li>
15
+ {/each}
16
+ </ul>
17
+ </div>
18
+ {/if}
@@ -0,0 +1,60 @@
1
+ import type { FieldPlugin } from "@riducms/plugin";
2
+ import type { SchemaField } from "@riducms/protocol";
3
+
4
+ export class FieldRegistry {
5
+ #plugins = new Map<string, FieldPlugin>();
6
+
7
+ register(plugin: FieldPlugin) {
8
+ const identity = pluginIdentity(plugin);
9
+ if (this.#plugins.has(identity))
10
+ throw new Error(`Field plugin ${identity} is already registered`);
11
+ this.#plugins.set(identity, plugin);
12
+ }
13
+
14
+ resolve(field: SchemaField) {
15
+ const component = field.admin.component;
16
+ const identity =
17
+ component !== undefined
18
+ ? `${field.type}:${component.plugin}:${component.component}`
19
+ : field.plugin === undefined
20
+ ? field.type
21
+ : `${field.type}:${field.plugin.key}`;
22
+ const plugin = this.#plugins.get(identity);
23
+ if (plugin === undefined || !plugin.canRender(field)) {
24
+ throw new Error(`No admin field plugin can render ${field.path} (${identity})`);
25
+ }
26
+ return plugin;
27
+ }
28
+ }
29
+
30
+ function pluginIdentity(plugin: FieldPlugin) {
31
+ if (plugin.componentKey !== undefined)
32
+ return `${plugin.type}:${plugin.key ?? "<missing>"}:${plugin.componentKey}`;
33
+ return plugin.key === undefined ? plugin.type : `${plugin.type}:${plugin.key}`;
34
+ }
35
+
36
+ export function createCoreFieldRegistry(plugins: readonly FieldPlugin[] = []): FieldRegistry {
37
+ const registry = new FieldRegistry();
38
+ registry.register({ type: "text", canRender: (field) => field.text !== undefined });
39
+ registry.register({ type: "code", canRender: (field) => field.code !== undefined });
40
+ registry.register({ type: "select", canRender: (field) => field.select !== undefined });
41
+ registry.register({ type: "radio", canRender: (field) => field.select !== undefined });
42
+ registry.register({ type: "point", canRender: (field) => field.point !== undefined });
43
+ registry.register({ type: "ui", canRender: (field) => field.ui !== undefined });
44
+ registry.register({ type: "join", canRender: (field) => field.join !== undefined });
45
+ registry.register({ type: "virtual", canRender: (field) => field.virtual !== undefined });
46
+ for (const type of ["textarea", "email", "date", "number", "checkbox", "json"] as const) {
47
+ registry.register({ type, canRender: () => true });
48
+ }
49
+ for (const type of ["group", "array"] as const) {
50
+ registry.register({ type, canRender: (field) => field.nested !== undefined });
51
+ }
52
+ registry.register({ type: "blocks", canRender: (field) => field.blocks !== undefined });
53
+ registry.register({
54
+ type: "relationship",
55
+ canRender: (field) => field.relationship !== undefined,
56
+ });
57
+ registry.register({ type: "upload", canRender: (field) => field.upload !== undefined });
58
+ for (const plugin of plugins) registry.register(plugin);
59
+ return registry;
60
+ }
@@ -0,0 +1,62 @@
1
+ import type { RowLabelPlugin, RowLabelSnapshot, RowLabelValue } from "@riducms/plugin";
2
+ import type { SchemaField } from "@riducms/protocol";
3
+
4
+ export class RowLabelRegistry {
5
+ #plugins = new Map<string, RowLabelPlugin>();
6
+
7
+ register(plugin: RowLabelPlugin) {
8
+ const identity = rowLabelIdentity(plugin.key, plugin.componentKey);
9
+ if (this.#plugins.has(identity)) {
10
+ throw new Error(`Admin row label component ${identity} is already registered`);
11
+ }
12
+ this.#plugins.set(identity, plugin);
13
+ }
14
+
15
+ resolve(field: SchemaField): RowLabelPlugin | undefined {
16
+ const component = field.nested?.rowLabelComponent;
17
+ if (component === undefined) return undefined;
18
+ const identity = rowLabelIdentity(component.plugin, component.component);
19
+ const plugin = this.#plugins.get(identity);
20
+ if (plugin === undefined) {
21
+ throw new Error(
22
+ `No admin row label component can render field ${field.path} (plugin "${component.plugin}", component "${component.component}")`
23
+ );
24
+ }
25
+ return plugin;
26
+ }
27
+ }
28
+
29
+ export function createRowLabelRegistry(plugins: readonly RowLabelPlugin[] = []): RowLabelRegistry {
30
+ const registry = new RowLabelRegistry();
31
+ for (const plugin of plugins) registry.register(plugin);
32
+ return registry;
33
+ }
34
+
35
+ export function immutableRowLabelSnapshot(row: Record<string, unknown>): RowLabelSnapshot {
36
+ return cloneRowLabelObject(row);
37
+ }
38
+
39
+ function rowLabelIdentity(plugin: string, component: string) {
40
+ return `${plugin}:${component}`;
41
+ }
42
+
43
+ function cloneRowLabelObject(value: Record<string, unknown>): RowLabelSnapshot {
44
+ const cloned: Record<string, RowLabelValue> = {};
45
+ for (const [key, child] of Object.entries(value)) cloned[key] = cloneRowLabelValue(child);
46
+ return Object.freeze(cloned);
47
+ }
48
+
49
+ function cloneRowLabelValue(value: unknown): RowLabelValue {
50
+ if (
51
+ value === undefined ||
52
+ value === null ||
53
+ typeof value === "string" ||
54
+ typeof value === "number" ||
55
+ typeof value === "boolean"
56
+ ) {
57
+ return value;
58
+ }
59
+ if (Array.isArray(value)) return Object.freeze(value.map(cloneRowLabelValue));
60
+ if (typeof value === "object") return cloneRowLabelObject(value as Record<string, unknown>);
61
+ return undefined;
62
+ }