@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,159 @@
1
+ <script lang="ts">
2
+ import { Link, useNavigate } from "@hvniel/svelte-router";
3
+ import LogOutIcon from "~icons/lucide/log-out";
4
+ import SettingsIcon from "~icons/lucide/settings";
5
+ import UserRoundIcon from "~icons/lucide/user-round";
6
+
7
+ import { Button, buttonVariants } from "@admin/components/ui/button";
8
+ import { Popover, PopoverContent, PopoverTrigger } from "@admin/components/ui/popover";
9
+ import { adminRoutePatterns } from "@admin/core/routing/admin-paths";
10
+ import { getAdminRuntime } from "@admin/core/runtime/admin-runtime.svelte";
11
+
12
+ const runtime = getAdminRuntime();
13
+ let { compact = false }: { compact?: boolean } = $props();
14
+ const navigate = useNavigate();
15
+ let open = $state(false);
16
+ let pending = $state(false);
17
+ let error = $state<string>();
18
+ const authCollection = $derived(runtime.authCollection);
19
+ const user = $derived(runtime.session?.user);
20
+ const logoutHost = { logout };
21
+ const accountAvatar = $derived(
22
+ runtime.brandComponents.find((component) => component.surface === "accountAvatar")
23
+ );
24
+ const settingsComponents = $derived(
25
+ runtime.shellComponents.filter((component) => component.position === "settingsMenu")
26
+ );
27
+ const displayName = $derived.by(() => {
28
+ const candidate = user?.name;
29
+ if (typeof candidate === "string" && candidate.trim().length > 0) return candidate;
30
+ const identityField = authCollection?.authSettings?.identityField;
31
+ const identity = identityField === undefined ? undefined : user?.[identityField];
32
+ if (typeof identity === "string" && identity.trim().length > 0) return identity;
33
+ return runtime.i18n.t("account:signedInUser");
34
+ });
35
+ const identity = $derived.by(() => {
36
+ const identityField = authCollection?.authSettings?.identityField;
37
+ const value = identityField === undefined ? undefined : user?.[identityField];
38
+ return typeof value === "string" && value !== displayName ? value : undefined;
39
+ });
40
+ const initials = $derived(
41
+ displayName
42
+ .split(/\s+/)
43
+ .filter(Boolean)
44
+ .slice(0, 2)
45
+ .map((part) => part.slice(0, 1).toLocaleUpperCase(runtime.i18n.language))
46
+ .join("") || "Z"
47
+ );
48
+
49
+ async function logout() {
50
+ pending = true;
51
+ error = undefined;
52
+ try {
53
+ await runtime.client.logout();
54
+ runtime.session = undefined;
55
+ open = false;
56
+ navigate(adminRoutePatterns.login, { replace: true });
57
+ } catch (cause) {
58
+ error = cause instanceof Error ? cause.message : runtime.i18n.t("account:signOutFailed");
59
+ } finally {
60
+ pending = false;
61
+ }
62
+ }
63
+ </script>
64
+
65
+ <Popover bind:open>
66
+ <PopoverTrigger
67
+ class={[
68
+ "flex items-center rounded-full text-left transition-colors hover:text-foreground focus-visible:outline-primary/60",
69
+ compact ? "size-7 justify-center" : "w-full gap-2.25 px-0.5 py-0",
70
+ ]}
71
+ aria-label={runtime.i18n.t("account:accountMenuLabel", { name: displayName })}
72
+ >
73
+ {#if accountAvatar !== undefined && runtime.manifest !== undefined}
74
+ <accountAvatar.component
75
+ manifest={runtime.manifest}
76
+ {user}
77
+ surface="accountAvatar"
78
+ i18n={runtime.i18n}
79
+ />
80
+ {:else}
81
+ <span
82
+ class="font-mono grid size-6 shrink-0 place-items-center rounded-full bg-sidebar-accent text-[8.5px] font-semibold tracking-[0.04em] text-sidebar-accent-foreground"
83
+ aria-hidden="true"
84
+ >
85
+ {initials}
86
+ </span>
87
+ {/if}
88
+ {#if !compact}
89
+ <span class="min-w-0 flex-1">
90
+ <span class="block truncate text-[12.5px] text-foreground">{displayName}</span>
91
+ <span class="block truncate text-[10.5px] text-foreground-sub">
92
+ {runtime.i18n.t("account:adminRole")}
93
+ </span>
94
+ </span>
95
+ <SettingsIcon class="size-3.5 shrink-0 text-foreground-faint" aria-hidden="true" />
96
+ {/if}
97
+ </PopoverTrigger>
98
+ <PopoverContent side="top" align="start" sideOffset={8} class="w-[212px] gap-1 p-2">
99
+ <div class="border-b border-control-border px-2.5 py-2 pb-3">
100
+ <p class="truncate text-[13px] font-medium text-foreground">{displayName}</p>
101
+ {#if identity !== undefined}
102
+ <p class="mt-0.5 truncate text-[10.5px] text-foreground-faint">{identity}</p>
103
+ {/if}
104
+ </div>
105
+ {#if error !== undefined}
106
+ <p class="px-2.5 py-2 text-[11.5px] leading-4 text-destructive" role="alert">{error}</p>
107
+ {/if}
108
+ <Link
109
+ to={adminRoutePatterns.account}
110
+ class={buttonVariants({
111
+ variant: "ghost",
112
+ class: "h-9 w-full justify-start gap-2 px-2.5 text-[12.5px]",
113
+ })}
114
+ onclick={() => (open = false)}
115
+ >
116
+ <UserRoundIcon class="size-3.5" aria-hidden="true" />
117
+ {runtime.i18n.t("account:profileAndPreferences")}
118
+ </Link>
119
+ <Link
120
+ to={adminRoutePatterns.accountSecurity}
121
+ class={buttonVariants({
122
+ variant: "ghost",
123
+ class: "h-9 w-full justify-start gap-2 px-2.5 text-[12.5px]",
124
+ })}
125
+ onclick={() => (open = false)}
126
+ >
127
+ <SettingsIcon class="size-3.5" aria-hidden="true" />
128
+ {runtime.i18n.t("account:accountSecurity")}
129
+ </Link>
130
+ {#if runtime.manifest !== undefined}
131
+ {#each settingsComponents as component (component.key)}
132
+ <component.component
133
+ manifest={runtime.manifest}
134
+ {user}
135
+ position="settingsMenu"
136
+ i18n={runtime.i18n}
137
+ />
138
+ {/each}
139
+ {/if}
140
+ {#if runtime.logoutButton !== undefined && runtime.manifest !== undefined}
141
+ <runtime.logoutButton.component
142
+ manifest={runtime.manifest}
143
+ {user}
144
+ host={logoutHost}
145
+ i18n={runtime.i18n}
146
+ />
147
+ {:else}
148
+ <Button
149
+ variant="ghost"
150
+ class="h-9 w-full justify-start gap-2 px-2.5 text-[12.5px]"
151
+ disabled={pending}
152
+ onclick={logout}
153
+ >
154
+ <LogOutIcon class="size-3.5" aria-hidden="true" />
155
+ {pending ? runtime.i18n.t("account:signingOut") : runtime.i18n.t("auth:logout")}
156
+ </Button>
157
+ {/if}
158
+ </PopoverContent>
159
+ </Popover>
@@ -0,0 +1,44 @@
1
+ <script lang="ts">
2
+ import { Link } from "@hvniel/svelte-router";
3
+
4
+ import { buttonVariants } from "@admin/components/ui/button";
5
+ import { adminRoutePatterns } from "@admin/core/routing/admin-paths";
6
+ import { getAdminRuntime } from "@admin/core/runtime/admin-runtime.svelte";
7
+
8
+ const runtime = getAdminRuntime();
9
+ let { active }: { active: "profile" | "security" } = $props();
10
+ </script>
11
+
12
+ <nav
13
+ class="flex gap-1 border-b border-control-border"
14
+ aria-label={runtime.i18n.t("account:accountMenu")}
15
+ >
16
+ <Link
17
+ to={adminRoutePatterns.account}
18
+ class={buttonVariants({
19
+ variant: "ghost",
20
+ class: [
21
+ "rounded-b-none border-b-2 px-3",
22
+ active === "profile"
23
+ ? "border-primary text-foreground-strong"
24
+ : "border-transparent text-foreground-muted hover:bg-control",
25
+ ],
26
+ })}
27
+ >
28
+ {runtime.i18n.t("account:profileAndPreferences")}
29
+ </Link>
30
+ <Link
31
+ to={adminRoutePatterns.accountSecurity}
32
+ class={buttonVariants({
33
+ variant: "ghost",
34
+ class: [
35
+ "rounded-b-none border-b-2 px-3",
36
+ active === "security"
37
+ ? "border-primary text-foreground-strong"
38
+ : "border-transparent text-foreground-muted hover:bg-control",
39
+ ],
40
+ })}
41
+ >
42
+ {runtime.i18n.t("account:security")}
43
+ </Link>
44
+ </nav>
@@ -0,0 +1,395 @@
1
+ <script lang="ts">
2
+ import { useNavigate } from "@hvniel/svelte-router";
3
+ import PaletteIcon from "~icons/lucide/palette";
4
+ import RotateCcwIcon from "~icons/lucide/rotate-ccw";
5
+ import UserRoundIcon from "~icons/lucide/user-round";
6
+
7
+ import { Banner } from "@admin/components/ui/banner";
8
+ import { Button } from "@admin/components/ui/button";
9
+ import { Select, SelectContent, SelectItem, SelectTrigger } from "@admin/components/ui/select";
10
+ import { Skeleton } from "@admin/components/ui/skeleton";
11
+ import { FormController, FormValidationError } from "@admin/core/forms/form-controller.svelte";
12
+ import { getNotificationCenter } from "@admin/core/notifications/notification-center.svelte";
13
+ import { adminRoutePatterns } from "@admin/core/routing/admin-paths";
14
+ import { getAdminRuntime, type ThemePreference } from "@admin/core/runtime/admin-runtime.svelte";
15
+ import FieldLayout from "@admin/fields/field-layout.svelte";
16
+ import AccountNavigation from "@admin/features/auth/account-navigation.svelte";
17
+
18
+ const runtime = getAdminRuntime();
19
+ const notifications = getNotificationCenter();
20
+ const navigate = useNavigate();
21
+ const form = new FormController({}, runtime.i18n);
22
+ let loading = $state(true);
23
+ let error = $state<string>();
24
+ let profilePending = $state(false);
25
+ let pendingThemeWrites = $state(0);
26
+ const themePending = $derived(pendingThemeWrites > 0);
27
+ let languagePending = $state(false);
28
+ let timeZonePending = $state(false);
29
+ let resetPending = $state(false);
30
+ const collection = $derived(runtime.authCollection);
31
+ const user = $derived(runtime.session?.user);
32
+ const contentLocale = $derived(runtime.contentLocale);
33
+ const selectedLanguageLabel = $derived.by(() => {
34
+ const language = runtime.i18n.languages.find((item) => item.code === runtime.i18n.language);
35
+ return language === undefined
36
+ ? runtime.i18n.language
37
+ : runtime.i18n.text(language.label, language.labelTranslations);
38
+ });
39
+ const selectedTimeZoneLabel = $derived.by(() => {
40
+ const timeZone = runtime.i18n.timeZones.find((item) => item.id === runtime.i18n.timeZone);
41
+ return timeZone === undefined
42
+ ? runtime.i18n.timeZone
43
+ : runtime.i18n.text(timeZone.label, timeZone.labelTranslations);
44
+ });
45
+ const accountComponents = $derived(
46
+ runtime.accountComponents.filter((component) => component.surface === "profile")
47
+ );
48
+ const replacement = $derived(
49
+ accountComponents.find((component) => component.position === "replace")
50
+ );
51
+ const beforeComponents = $derived(
52
+ accountComponents.filter((component) => component.position === "before")
53
+ );
54
+ const afterComponents = $derived(
55
+ accountComponents.filter(
56
+ (component) => component.position === undefined || component.position === "after"
57
+ )
58
+ );
59
+ const host = {
60
+ refreshUser,
61
+ logout,
62
+ notify: (tone: "success" | "error", title: string, message?: string) =>
63
+ notifications[tone]({ title, message }),
64
+ };
65
+
66
+ $effect(() => {
67
+ const slug = collection?.slug;
68
+ const id = user?.id;
69
+ const locale = contentLocale;
70
+ if (slug === undefined || id === undefined) return;
71
+ const abort = new AbortController();
72
+ void load(slug, id, locale, abort.signal);
73
+ return () => abort.abort();
74
+ });
75
+
76
+ $effect(() => runtime.registerContentLocaleBlocker(() => form.dirty || form.submitting));
77
+
78
+ async function load(slug: string, id: string, locale: string | undefined, signal: AbortSignal) {
79
+ loading = true;
80
+ error = undefined;
81
+ try {
82
+ const [document, access] = await Promise.all([
83
+ runtime.client.find(slug, id, { signal, locale }),
84
+ runtime.client.collectionAccess(slug, { id, signal, locale }),
85
+ ]);
86
+ if (signal.aborted) return;
87
+ form.reset(document);
88
+ form.setLocalization(locale, document._localization?.sources);
89
+ form.setAccess(access, "update");
90
+ } catch (cause) {
91
+ if (!signal.aborted) {
92
+ error =
93
+ cause instanceof Error ? cause.message : runtime.i18n.t("account:profileLoadFailed");
94
+ }
95
+ } finally {
96
+ if (!signal.aborted) loading = false;
97
+ }
98
+ }
99
+
100
+ async function saveProfile(event: SubmitEvent) {
101
+ event.preventDefault();
102
+ if (collection === undefined || user === undefined) return;
103
+ profilePending = true;
104
+ error = undefined;
105
+ try {
106
+ const updated = await form.submit(collection.fields, false, (values) =>
107
+ runtime.client.update(collection.slug, user.id, values, { locale: contentLocale })
108
+ );
109
+ form.reset(updated);
110
+ form.setLocalization(contentLocale, updated._localization?.sources);
111
+ if (runtime.session !== undefined) runtime.session = { ...runtime.session, user: updated };
112
+ runtime.documentsChanged();
113
+ notifications.success({ title: runtime.i18n.t("account:profileUpdated") });
114
+ } catch (cause) {
115
+ if (!(cause instanceof FormValidationError)) {
116
+ error =
117
+ cause instanceof Error ? cause.message : runtime.i18n.t("account:profileSaveFailed");
118
+ }
119
+ } finally {
120
+ profilePending = false;
121
+ }
122
+ }
123
+
124
+ async function updateTheme(preference: ThemePreference) {
125
+ pendingThemeWrites += 1;
126
+ error = undefined;
127
+ try {
128
+ await runtime.setTheme(preference);
129
+ notifications.success({ title: runtime.i18n.t("account:themeSaved") });
130
+ } catch (cause) {
131
+ error = cause instanceof Error ? cause.message : runtime.i18n.t("account:themeSaveFailed");
132
+ } finally {
133
+ pendingThemeWrites -= 1;
134
+ }
135
+ }
136
+
137
+ async function updateLanguage(language: string) {
138
+ if (!runtime.i18n.languages.some((item) => item.code === language)) return;
139
+ languagePending = true;
140
+ error = undefined;
141
+ try {
142
+ await runtime.i18n.setLanguage(language);
143
+ notifications.success({ title: runtime.i18n.t("account:languageSaved") });
144
+ } catch (cause) {
145
+ error = cause instanceof Error ? cause.message : runtime.i18n.t("account:languageSaveFailed");
146
+ } finally {
147
+ languagePending = false;
148
+ }
149
+ }
150
+
151
+ async function updateTimeZone(timeZone: string) {
152
+ if (!runtime.i18n.timeZones.some((item) => item.id === timeZone)) return;
153
+ timeZonePending = true;
154
+ error = undefined;
155
+ try {
156
+ await runtime.i18n.setTimeZone(timeZone);
157
+ notifications.success({ title: runtime.i18n.t("account:timeZoneSaved") });
158
+ } catch (cause) {
159
+ error = cause instanceof Error ? cause.message : runtime.i18n.t("account:timeZoneSaveFailed");
160
+ } finally {
161
+ timeZonePending = false;
162
+ }
163
+ }
164
+
165
+ async function resetPreferences() {
166
+ resetPending = true;
167
+ error = undefined;
168
+ try {
169
+ await runtime.resetPreferences();
170
+ notifications.success({ title: runtime.i18n.t("account:preferencesReset") });
171
+ } catch (cause) {
172
+ error =
173
+ cause instanceof Error ? cause.message : runtime.i18n.t("account:preferencesResetFailed");
174
+ } finally {
175
+ resetPending = false;
176
+ }
177
+ }
178
+
179
+ async function refreshUser() {
180
+ if (collection === undefined || user === undefined) return undefined;
181
+ const document = await runtime.client.find(collection.slug, user.id, { locale: contentLocale });
182
+ if (runtime.session !== undefined) runtime.session = { ...runtime.session, user: document };
183
+ form.reset(document);
184
+ form.setLocalization(contentLocale, document._localization?.sources);
185
+ return document;
186
+ }
187
+
188
+ async function logout() {
189
+ await runtime.client.logout();
190
+ runtime.session = undefined;
191
+ navigate(adminRoutePatterns.login, { replace: true });
192
+ }
193
+ </script>
194
+
195
+ {#snippet defaultView()}
196
+ <div class="mx-auto grid w-full max-w-[820px] gap-8 px-5 py-8 sm:px-8 sm:py-11">
197
+ <header class="grid gap-2">
198
+ <p class="font-mono text-[9.5px] tracking-[0.16em] text-foreground-faint uppercase">
199
+ {runtime.i18n.t("account:account")}
200
+ </p>
201
+ <h1 class="text-[28px] leading-tight font-semibold tracking-[-0.015em] text-foreground">
202
+ {runtime.i18n.t("account:profileAndPreferences")}
203
+ </h1>
204
+ <p class="max-w-[590px] text-[13.5px] leading-5.5 text-foreground-muted">
205
+ {runtime.i18n.t("account:profileDescription")}
206
+ </p>
207
+ </header>
208
+
209
+ {#if runtime.manifest !== undefined}
210
+ {#each beforeComponents as component (component.key)}
211
+ <component.component
212
+ manifest={runtime.manifest}
213
+ user={runtime.session?.user}
214
+ surface="profile"
215
+ {defaultView}
216
+ {host}
217
+ i18n={runtime.i18n}
218
+ />
219
+ {/each}
220
+ {/if}
221
+
222
+ <AccountNavigation active="profile" />
223
+
224
+ {#if error !== undefined}<Banner tone="destructive">{error}</Banner>{/if}
225
+
226
+ <section class="grid gap-4" aria-labelledby="profile-heading">
227
+ <header class="flex items-center gap-3">
228
+ <UserRoundIcon class="size-4 text-foreground-sub" aria-hidden="true" />
229
+ <div>
230
+ <h2 id="profile-heading" class="text-[15px] font-semibold text-foreground">
231
+ {runtime.i18n.t("account:profile")}
232
+ </h2>
233
+ <p class="text-[12px] text-foreground-faint">
234
+ {runtime.i18n.t("account:profileRules")}
235
+ </p>
236
+ </div>
237
+ </header>
238
+ {#if loading}
239
+ <div class="grid gap-4 rounded-[4px] border border-control-border bg-control p-5">
240
+ <Skeleton class="h-10 w-full" /><Skeleton class="h-24 w-full" />
241
+ </div>
242
+ {:else if collection !== undefined}
243
+ <form
244
+ class="grid gap-6 rounded-[4px] border border-control-border bg-control p-5"
245
+ onsubmit={saveProfile}
246
+ >
247
+ <FieldLayout fields={collection.fields} {form} />
248
+ <Button
249
+ class="justify-self-start"
250
+ type="submit"
251
+ disabled={profilePending || !form.dirty || form.access?.operations.update !== true}
252
+ >
253
+ {profilePending
254
+ ? runtime.i18n.t("account:savingProfile")
255
+ : runtime.i18n.t("account:saveProfile")}
256
+ </Button>
257
+ </form>
258
+ {/if}
259
+ </section>
260
+
261
+ <section class="grid gap-4" aria-labelledby="appearance-heading">
262
+ <header class="flex items-center gap-3">
263
+ <PaletteIcon class="size-4 text-foreground-sub" aria-hidden="true" />
264
+ <div>
265
+ <h2 id="appearance-heading" class="text-[15px] font-semibold text-foreground">
266
+ {runtime.i18n.t("account:appearance")}
267
+ </h2>
268
+ <p class="text-[12px] text-foreground-faint">
269
+ {runtime.i18n.t("account:preferencesStored")}
270
+ </p>
271
+ </div>
272
+ </header>
273
+ <div
274
+ class="grid gap-5 rounded-[4px] border border-control-border bg-control p-5 sm:grid-cols-[1fr_auto] sm:items-end"
275
+ >
276
+ <div class="grid gap-4 sm:grid-cols-2">
277
+ <label class="grid gap-2" for="account-theme">
278
+ <span class="ridu-field-label">{runtime.i18n.t("account:theme")}</span>
279
+ <Select
280
+ type="single"
281
+ value={runtime.themePreference}
282
+ disabled={resetPending}
283
+ onValueChange={(preference) => void updateTheme(preference as ThemePreference)}
284
+ >
285
+ <SelectTrigger
286
+ id="account-theme"
287
+ class="w-full"
288
+ aria-label={runtime.i18n.t("account:theme")}
289
+ aria-busy={themePending || undefined}
290
+ >
291
+ {runtime.themePreference === "system"
292
+ ? runtime.i18n.t("account:systemTheme")
293
+ : runtime.themePreference === "light"
294
+ ? runtime.i18n.t("account:lightTheme")
295
+ : runtime.i18n.t("account:darkTheme")}
296
+ </SelectTrigger>
297
+ <SelectContent>
298
+ <SelectItem value="system" label={runtime.i18n.t("account:systemTheme")} />
299
+ <SelectItem value="light" label={runtime.i18n.t("account:lightTheme")} />
300
+ <SelectItem value="dark" label={runtime.i18n.t("account:darkTheme")} />
301
+ </SelectContent>
302
+ </Select>
303
+ </label>
304
+ {#if runtime.i18n.languages.length > 1}
305
+ <label class="grid gap-2" for="account-language">
306
+ <span class="ridu-field-label">{runtime.i18n.t("account:interfaceLanguage")}</span>
307
+ <Select
308
+ type="single"
309
+ value={runtime.i18n.language}
310
+ disabled={resetPending || languagePending}
311
+ onValueChange={(language) => void updateLanguage(language)}
312
+ >
313
+ <SelectTrigger
314
+ id="account-language"
315
+ class="w-full"
316
+ aria-label={runtime.i18n.t("account:interfaceLanguage")}
317
+ aria-busy={languagePending || undefined}
318
+ >
319
+ {selectedLanguageLabel}
320
+ </SelectTrigger>
321
+ <SelectContent>
322
+ {#each runtime.i18n.languages as language (language.code)}
323
+ <SelectItem
324
+ value={language.code}
325
+ label={runtime.i18n.text(language.label, language.labelTranslations)}
326
+ />
327
+ {/each}
328
+ </SelectContent>
329
+ </Select>
330
+ </label>
331
+ {/if}
332
+ {#if runtime.i18n.timeZones.length > 0}
333
+ <label class="grid gap-2" for="account-timezone">
334
+ <span class="ridu-field-label">{runtime.i18n.t("account:timeZone")}</span>
335
+ <Select
336
+ type="single"
337
+ value={runtime.i18n.timeZone}
338
+ disabled={resetPending || timeZonePending}
339
+ onValueChange={(timeZone) => void updateTimeZone(timeZone)}
340
+ >
341
+ <SelectTrigger
342
+ id="account-timezone"
343
+ class="w-full"
344
+ aria-label={runtime.i18n.t("account:timeZone")}
345
+ aria-busy={timeZonePending || undefined}
346
+ >
347
+ {selectedTimeZoneLabel}
348
+ </SelectTrigger>
349
+ <SelectContent>
350
+ {#each runtime.i18n.timeZones as timeZone (timeZone.id)}
351
+ <SelectItem
352
+ value={timeZone.id}
353
+ label={runtime.i18n.text(timeZone.label, timeZone.labelTranslations)}
354
+ />
355
+ {/each}
356
+ </SelectContent>
357
+ </Select>
358
+ </label>
359
+ {/if}
360
+ </div>
361
+ <Button variant="outline" disabled={resetPending} onclick={resetPreferences}>
362
+ <RotateCcwIcon class="size-3.5" aria-hidden="true" />
363
+ {resetPending
364
+ ? runtime.i18n.t("account:resettingPreferences")
365
+ : runtime.i18n.t("account:resetAllPreferences")}
366
+ </Button>
367
+ </div>
368
+ </section>
369
+ {#if runtime.manifest !== undefined}
370
+ {#each afterComponents as component (component.key)}
371
+ <component.component
372
+ manifest={runtime.manifest}
373
+ user={runtime.session?.user}
374
+ surface="profile"
375
+ {defaultView}
376
+ {host}
377
+ i18n={runtime.i18n}
378
+ />
379
+ {/each}
380
+ {/if}
381
+ </div>
382
+ {/snippet}
383
+
384
+ {#if runtime.manifest !== undefined && replacement !== undefined}
385
+ <replacement.component
386
+ manifest={runtime.manifest}
387
+ user={runtime.session?.user}
388
+ surface="profile"
389
+ {defaultView}
390
+ {host}
391
+ i18n={runtime.i18n}
392
+ />
393
+ {:else}
394
+ {@render defaultView()}
395
+ {/if}