@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,525 @@
1
+ <script lang="ts">
2
+ import { getAdminI18n } from "@riducms/plugin";
3
+ import FileIcon from "~icons/lucide/file";
4
+ import PencilIcon from "~icons/lucide/pencil";
5
+
6
+ import { Button } from "@admin/components/ui/button";
7
+ import { Checkbox } from "@admin/components/ui/checkbox";
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogDescription,
12
+ DialogHeader,
13
+ DialogTitle,
14
+ } from "@admin/components/ui/dialog";
15
+ import { Slider } from "@admin/components/ui/slider";
16
+ import type { AdminDocument } from "@admin/core/api/admin-client";
17
+
18
+ type Variant = {
19
+ name: string;
20
+ label: string;
21
+ url: string;
22
+ width?: number;
23
+ height?: number;
24
+ filesize?: number;
25
+ };
26
+
27
+ let {
28
+ document,
29
+ editable = false,
30
+ updating = false,
31
+ onUpdate,
32
+ }: {
33
+ document: AdminDocument;
34
+ editable?: boolean;
35
+ updating?: boolean;
36
+ onUpdate?: (input: ImageEditInput) => Promise<boolean>;
37
+ } = $props();
38
+ const i18n = getAdminI18n();
39
+ type ImageEditInput = {
40
+ focalX: number;
41
+ focalY: number;
42
+ cropX: number;
43
+ cropY: number;
44
+ cropWidth: number;
45
+ cropHeight: number;
46
+ };
47
+
48
+ const filename = $derived(stringValue(document.filename) ?? i18n.t("uploads:untitledAsset"));
49
+ const mimeType = $derived(stringValue(document.mimeType));
50
+ const url = $derived(stringValue(document.url));
51
+ const alt = $derived(stringValue(document.alt) ?? filename);
52
+ const width = $derived(numberValue(document.width));
53
+ const height = $derived(numberValue(document.height));
54
+ const storedFocalX = $derived(numberValue(document.focalX) ?? 50);
55
+ const storedFocalY = $derived(numberValue(document.focalY) ?? 50);
56
+ const storedCropX = $derived(numberValue(document.cropX) ?? 0);
57
+ const storedCropY = $derived(numberValue(document.cropY) ?? 0);
58
+ const storedCropWidth = $derived(numberValue(document.cropWidth) ?? 0);
59
+ const storedCropHeight = $derived(numberValue(document.cropHeight) ?? 0);
60
+ const isImage = $derived(mimeType?.startsWith("image/") === true);
61
+ const variants = $derived(imageVariants(document, url, width, height));
62
+ let selectedName = $state("original");
63
+ let focalX = $state(50);
64
+ let focalY = $state(50);
65
+ let cropX = $state(0);
66
+ let cropY = $state(0);
67
+ let cropWidth = $state(0);
68
+ let cropHeight = $state(0);
69
+ let changed = $state(false);
70
+ let sizesOpen = $state(false);
71
+ let editorOpen = $state(false);
72
+ const cropEnabled = $derived(cropWidth > 0 && cropHeight > 0);
73
+ const selected = $derived(
74
+ variants.find((variant) => variant.name === selectedName) ?? variants[0]
75
+ );
76
+ $effect(() => {
77
+ if (changed) return;
78
+ focalX = storedFocalX;
79
+ focalY = storedFocalY;
80
+ cropX = storedCropX;
81
+ cropY = storedCropY;
82
+ cropWidth = storedCropWidth;
83
+ cropHeight = storedCropHeight;
84
+ });
85
+
86
+ function updateChanged() {
87
+ changed =
88
+ focalX !== storedFocalX ||
89
+ focalY !== storedFocalY ||
90
+ cropX !== storedCropX ||
91
+ cropY !== storedCropY ||
92
+ cropWidth !== storedCropWidth ||
93
+ cropHeight !== storedCropHeight;
94
+ }
95
+
96
+ function setFocal(axis: "x" | "y", value: number) {
97
+ if (!editable || updating) return;
98
+ if (axis === "x") {
99
+ focalX = cropEnabled ? Math.max(cropX, Math.min(cropX + cropWidth, value)) : value;
100
+ } else {
101
+ focalY = cropEnabled ? Math.max(cropY, Math.min(cropY + cropHeight, value)) : value;
102
+ }
103
+ updateChanged();
104
+ }
105
+
106
+ function setCrop(axis: "x" | "y" | "width" | "height", value: number) {
107
+ if (!editable || updating) return;
108
+ if (axis === "x") cropX = Math.min(value, 100 - cropWidth);
109
+ if (axis === "y") cropY = Math.min(value, 100 - cropHeight);
110
+ if (axis === "width") cropWidth = Math.min(value, 100 - cropX);
111
+ if (axis === "height") cropHeight = Math.min(value, 100 - cropY);
112
+ focalX = Math.max(cropX, Math.min(cropX + cropWidth, focalX));
113
+ focalY = Math.max(cropY, Math.min(cropY + cropHeight, focalY));
114
+ updateChanged();
115
+ }
116
+
117
+ function toggleCrop(enabled: boolean) {
118
+ if (!editable || updating) return;
119
+ if (enabled) {
120
+ cropX = storedCropWidth > 0 ? storedCropX : 10;
121
+ cropY = storedCropHeight > 0 ? storedCropY : 10;
122
+ cropWidth = storedCropWidth > 0 ? storedCropWidth : 80;
123
+ cropHeight = storedCropHeight > 0 ? storedCropHeight : 80;
124
+ focalX = Math.max(cropX, Math.min(cropX + cropWidth, focalX));
125
+ focalY = Math.max(cropY, Math.min(cropY + cropHeight, focalY));
126
+ } else {
127
+ cropX = 0;
128
+ cropY = 0;
129
+ cropWidth = 0;
130
+ cropHeight = 0;
131
+ }
132
+ updateChanged();
133
+ }
134
+
135
+ function chooseFocal(event: MouseEvent) {
136
+ if (!editable || updating || width === undefined || height === undefined) return;
137
+ // Keyboard-generated clicks do not carry meaningful viewport coordinates. Treat activation as
138
+ // the explicit centre action; arrow keys below provide precise adjustment.
139
+ if (event.detail === 0) {
140
+ setFocal("x", 50);
141
+ setFocal("y", 50);
142
+ return;
143
+ }
144
+ const bounds =
145
+ event.currentTarget instanceof HTMLElement
146
+ ? event.currentTarget.getBoundingClientRect()
147
+ : undefined;
148
+ if (bounds === undefined) return;
149
+ const scale = Math.min(bounds.width / width, bounds.height / height);
150
+ const renderedWidth = width * scale;
151
+ const renderedHeight = height * scale;
152
+ const offsetX = (bounds.width - renderedWidth) / 2;
153
+ const offsetY = (bounds.height - renderedHeight) / 2;
154
+ setFocal("x", clamp(((event.clientX - bounds.left - offsetX) / renderedWidth) * 100));
155
+ setFocal("y", clamp(((event.clientY - bounds.top - offsetY) / renderedHeight) * 100));
156
+ }
157
+
158
+ function adjustFocal(event: KeyboardEvent) {
159
+ if (!editable || updating) return;
160
+ const step = event.shiftKey ? 10 : 1;
161
+ if (event.key === "ArrowLeft") setFocal("x", clamp(focalX - step));
162
+ else if (event.key === "ArrowRight") setFocal("x", clamp(focalX + step));
163
+ else if (event.key === "ArrowUp") setFocal("y", clamp(focalY - step));
164
+ else if (event.key === "ArrowDown") setFocal("y", clamp(focalY + step));
165
+ else return;
166
+ event.preventDefault();
167
+ }
168
+
169
+ async function applyImageEdit() {
170
+ if (
171
+ onUpdate === undefined ||
172
+ !(await onUpdate({ focalX, focalY, cropX, cropY, cropWidth, cropHeight }))
173
+ )
174
+ return;
175
+ changed = false;
176
+ }
177
+
178
+ function startCropDrag(event: PointerEvent) {
179
+ if (!cropEnabled || !editable || updating) return;
180
+ event.preventDefault();
181
+ event.stopPropagation();
182
+ const target = event.currentTarget as HTMLElement;
183
+ const parent = target.parentElement;
184
+ if (parent === null) return;
185
+ const bounds = parent.getBoundingClientRect();
186
+ const startX = event.clientX;
187
+ const startY = event.clientY;
188
+ const originalX = cropX;
189
+ const originalY = cropY;
190
+ target.setPointerCapture(event.pointerId);
191
+ const move = (next: PointerEvent) => {
192
+ cropX = Math.max(
193
+ 0,
194
+ Math.min(100 - cropWidth, originalX + ((next.clientX - startX) / bounds.width) * 100)
195
+ );
196
+ cropY = Math.max(
197
+ 0,
198
+ Math.min(100 - cropHeight, originalY + ((next.clientY - startY) / bounds.height) * 100)
199
+ );
200
+ focalX = Math.max(cropX, Math.min(cropX + cropWidth, focalX));
201
+ focalY = Math.max(cropY, Math.min(cropY + cropHeight, focalY));
202
+ updateChanged();
203
+ };
204
+ const end = () => {
205
+ target.removeEventListener("pointermove", move);
206
+ target.removeEventListener("pointerup", end);
207
+ target.removeEventListener("pointercancel", end);
208
+ };
209
+ target.addEventListener("pointermove", move);
210
+ target.addEventListener("pointerup", end);
211
+ target.addEventListener("pointercancel", end);
212
+ }
213
+
214
+ function imageVariants(
215
+ document: AdminDocument,
216
+ originalURL: string | undefined,
217
+ originalWidth: number | undefined,
218
+ originalHeight: number | undefined
219
+ ): Variant[] {
220
+ const result: Variant[] = [];
221
+ if (originalURL !== undefined) {
222
+ result.push({
223
+ name: "original",
224
+ label: i18n.t("uploads:original"),
225
+ url: originalURL,
226
+ width: originalWidth,
227
+ height: originalHeight,
228
+ filesize: numberValue(document.filesize),
229
+ });
230
+ }
231
+ if (!recordValue(document.sizes)) return result;
232
+ for (const [name, raw] of Object.entries(document.sizes)) {
233
+ if (!recordValue(raw)) continue;
234
+ const variantURL = stringValue(raw.url);
235
+ if (variantURL === undefined) continue;
236
+ result.push({
237
+ name,
238
+ label: name.replaceAll("-", " "),
239
+ url: variantURL,
240
+ width: numberValue(raw.width),
241
+ height: numberValue(raw.height),
242
+ filesize: numberValue(raw.filesize),
243
+ });
244
+ }
245
+ return result;
246
+ }
247
+
248
+ function stringValue(value: unknown) {
249
+ return typeof value === "string" && value.length > 0 ? value : undefined;
250
+ }
251
+
252
+ function numberValue(value: unknown) {
253
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
254
+ }
255
+
256
+ function recordValue(value: unknown): value is Record<string, unknown> {
257
+ return typeof value === "object" && value !== null && !Array.isArray(value);
258
+ }
259
+
260
+ function clamp(value: number) {
261
+ return Math.max(0, Math.min(100, Math.round(value)));
262
+ }
263
+
264
+ function formatBytes(value: number | undefined) {
265
+ if (value === undefined) return undefined;
266
+ if (value < 1_024) return i18n.formatNumber(value, { style: "unit", unit: "byte" });
267
+ if (value < 1_048_576)
268
+ return i18n.formatNumber(value / 1_024, {
269
+ maximumFractionDigits: 0,
270
+ style: "unit",
271
+ unit: "kilobyte",
272
+ });
273
+ return i18n.formatNumber(value / 1_048_576, {
274
+ maximumFractionDigits: value < 10_485_760 ? 1 : 0,
275
+ style: "unit",
276
+ unit: "megabyte",
277
+ });
278
+ }
279
+
280
+ function cropControlLabel(key: unknown) {
281
+ if (key === "x") return i18n.t("uploads:left");
282
+ if (key === "y") return i18n.t("uploads:top");
283
+ if (key === "width") return i18n.t("uploads:width");
284
+ return i18n.t("uploads:height");
285
+ }
286
+ </script>
287
+
288
+ <section aria-label={i18n.t("uploads:assetPreview")} aria-busy={updating}>
289
+ <div
290
+ class="grid min-h-35 overflow-hidden rounded-[4px] border border-control-border bg-control sm:grid-cols-[140px_minmax(0,1fr)]"
291
+ >
292
+ <div class="grid min-h-28 place-items-center overflow-hidden bg-background sm:min-h-35">
293
+ {#if isImage && url !== undefined}
294
+ <img class="size-full object-cover" src={url} {alt} />
295
+ {:else}
296
+ <FileIcon class="size-7 text-foreground-faint" />
297
+ {/if}
298
+ </div>
299
+ <div
300
+ class="flex min-w-0 flex-col justify-center gap-3 border-t border-control-border p-4 sm:border-t-0 sm:border-s"
301
+ >
302
+ <div class="min-w-0">
303
+ <p class="truncate text-[13px] font-medium text-foreground-strong">{filename}</p>
304
+ <p class="mt-1 text-[11px] text-foreground-faint">
305
+ {#if formatBytes(numberValue(document.filesize)) !== undefined}{formatBytes(
306
+ numberValue(document.filesize)
307
+ )} ·
308
+ {/if}{#if width !== undefined && height !== undefined}{i18n.formatNumber(width)} × {i18n.formatNumber(
309
+ height
310
+ )} ·
311
+ {/if}{mimeType ?? i18n.t("uploads:file")}
312
+ </p>
313
+ </div>
314
+ <div class="flex flex-wrap gap-1.5">
315
+ {#if variants.length > 1}
316
+ <Button variant="secondary" size="xs" onclick={() => (sizesOpen = true)}>
317
+ {i18n.t("uploads:previewSizes")}
318
+ </Button>
319
+ {/if}
320
+ {#if editable && isImage}
321
+ <Button variant="secondary" size="xs" onclick={() => (editorOpen = true)}>
322
+ <PencilIcon class="size-3" />
323
+ {i18n.t("uploads:editImage")}
324
+ </Button>
325
+ {/if}
326
+ </div>
327
+ </div>
328
+ </div>
329
+
330
+ <Dialog bind:open={sizesOpen}>
331
+ <DialogContent class="h-[min(720px,calc(100vh-3rem))] sm:max-w-5xl">
332
+ <DialogHeader>
333
+ <DialogTitle>{i18n.t("uploads:sizesFor", { filename })}</DialogTitle>
334
+ <DialogDescription class="sr-only">
335
+ {i18n.t("uploads:previewGeneratedSizes")}
336
+ </DialogDescription>
337
+ </DialogHeader>
338
+ <div class="grid min-h-0 flex-1 gap-5 lg:grid-cols-[minmax(0,1fr)_260px]">
339
+ <div
340
+ class="grid min-h-80 place-items-center overflow-hidden border border-control-border bg-background"
341
+ >
342
+ {#if selected !== undefined}
343
+ <img class="max-h-full max-w-full object-contain" src={selected.url} {alt} />
344
+ {:else}
345
+ <FileIcon class="size-8 text-foreground-faint" />
346
+ {/if}
347
+ </div>
348
+ <div
349
+ class="grid content-start gap-2 overflow-y-auto"
350
+ aria-label={i18n.t("uploads:imageSizes")}
351
+ >
352
+ {#each variants as variant (variant.name)}
353
+ <button
354
+ type="button"
355
+ class="grid grid-cols-[64px_minmax(0,1fr)] items-center gap-3 rounded-[3px] border border-control-border bg-control p-2 text-start outline-none hover:border-control-border-hover focus-visible:border-ring aria-pressed:border-primary"
356
+ aria-label={variant.label}
357
+ aria-pressed={selectedName === variant.name}
358
+ onclick={() => (selectedName = variant.name)}
359
+ >
360
+ <img class="size-16 object-cover" src={variant.url} alt="" />
361
+ <span class="min-w-0">
362
+ <span class="block truncate text-[12.5px] font-medium capitalize text-foreground">
363
+ {variant.label}
364
+ </span>
365
+ <span class="mt-1 block text-[10px] text-foreground-faint">
366
+ {#if variant.width !== undefined && variant.height !== undefined}{i18n.formatNumber(
367
+ variant.width
368
+ )} ×
369
+ {i18n.formatNumber(
370
+ variant.height
371
+ )}{/if}{#if formatBytes(variant.filesize) !== undefined}
372
+ · {formatBytes(variant.filesize)}{/if}
373
+ </span>
374
+ </span>
375
+ </button>
376
+ {/each}
377
+ </div>
378
+ </div>
379
+ </DialogContent>
380
+ </Dialog>
381
+
382
+ <Dialog bind:open={editorOpen}>
383
+ <DialogContent class="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-5xl">
384
+ <DialogHeader>
385
+ <DialogTitle>{i18n.t("uploads:editFilename", { filename })}</DialogTitle>
386
+ <DialogDescription>
387
+ {i18n.t("uploads:imageEditDescription")}
388
+ </DialogDescription>
389
+ </DialogHeader>
390
+ <div class="grid gap-5 lg:grid-cols-[minmax(0,1fr)_320px]">
391
+ <button
392
+ type="button"
393
+ class="relative grid aspect-[3/2] w-full place-items-center overflow-hidden rounded-[3px] border border-control-border bg-background text-start disabled:cursor-default"
394
+ disabled={!editable || updating}
395
+ onclick={chooseFocal}
396
+ onkeydown={adjustFocal}
397
+ aria-describedby="ridu-focal-instructions"
398
+ aria-keyshortcuts="ArrowLeft ArrowRight ArrowUp ArrowDown"
399
+ aria-label={i18n.t("uploads:chooseFocalPoint")}
400
+ >
401
+ {#if url !== undefined}
402
+ <img class="pointer-events-none size-full object-contain" src={url} {alt} />
403
+ {/if}
404
+ <span
405
+ class="pointer-events-none absolute size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border border-media-foreground/80 ring-1 ring-media-overlay"
406
+ style="left: {focalX}%; top: {focalY}%"
407
+ aria-hidden="true"
408
+ >
409
+ <span
410
+ class="absolute top-1/2 left-1/2 h-px w-2.5 -translate-1/2 bg-media-foreground/80"
411
+ ></span>
412
+ <span
413
+ class="absolute top-1/2 left-1/2 h-2.5 w-px -translate-1/2 bg-media-foreground/80"
414
+ ></span>
415
+ </span>
416
+ {#if cropEnabled}
417
+ <span
418
+ class="absolute cursor-move touch-none border-2 border-primary bg-transparent shadow-[0_0_0_9999px_var(--media-overlay)]"
419
+ style="left: {cropX}%; top: {cropY}%; width: {cropWidth}%; height: {cropHeight}%"
420
+ onpointerdown={startCropDrag}
421
+ role="presentation"
422
+ ></span>
423
+ {/if}
424
+ </button>
425
+ <div
426
+ class="grid content-start gap-3 rounded-[3px] border border-control-border bg-control p-4"
427
+ >
428
+ <div class="flex items-start justify-between gap-4">
429
+ <div>
430
+ <p class="text-[12.5px] font-medium text-foreground">
431
+ {i18n.t("uploads:focalPoint")}
432
+ </p>
433
+ <p class="mt-1 text-[11px] leading-4 text-foreground-faint">
434
+ {i18n.t("uploads:focalPointDescription")}
435
+ </p>
436
+ </div>
437
+ <Button size="sm" disabled={!changed || updating} onclick={applyImageEdit}>
438
+ {updating ? i18n.t("uploads:regenerating") : i18n.t("uploads:applyImageEdit")}
439
+ </Button>
440
+ </div>
441
+ <div class="grid gap-3 sm:grid-cols-2">
442
+ <label class="grid gap-1.5 text-[11px] text-foreground-sub">
443
+ <span class="flex justify-between">
444
+ <span>{i18n.t("uploads:horizontal")}</span>
445
+ <span>{i18n.formatNumber(focalX / 100, { style: "percent" })}</span>
446
+ </span>
447
+ <Slider
448
+ value={focalX}
449
+ min={0}
450
+ max={100}
451
+ disabled={updating}
452
+ label={i18n.t("uploads:horizontalFocalPoint")}
453
+ onValueChange={(value) => setFocal("x", value)}
454
+ />
455
+ </label>
456
+ <label class="grid gap-1.5 text-[11px] text-foreground-sub">
457
+ <span class="flex justify-between">
458
+ <span>{i18n.t("uploads:vertical")}</span>
459
+ <span>{i18n.formatNumber(focalY / 100, { style: "percent" })}</span>
460
+ </span>
461
+ <Slider
462
+ value={focalY}
463
+ min={0}
464
+ max={100}
465
+ disabled={updating}
466
+ label={i18n.t("uploads:verticalFocalPoint")}
467
+ onValueChange={(value) => setFocal("y", value)}
468
+ />
469
+ </label>
470
+ </div>
471
+ <div class="border-t border-control-border pt-3">
472
+ <div class="flex items-center gap-2 text-[11px] text-foreground-sub">
473
+ <Checkbox
474
+ checked={cropEnabled}
475
+ disabled={updating}
476
+ aria-label={i18n.t("uploads:enableCrop")}
477
+ onCheckedChange={toggleCrop}
478
+ />
479
+ <span>{i18n.t("uploads:enableCrop")}</span>
480
+ </div>
481
+ {#if cropEnabled}
482
+ <p class="mt-2 text-[10.5px] text-foreground-faint">
483
+ {i18n.t("uploads:cropDescription")}
484
+ </p>
485
+ <div class="mt-3 grid gap-3 sm:grid-cols-2">
486
+ {#each [["x", "Left", cropX, 100 - cropWidth], ["y", "Top", cropY, 100 - cropHeight], ["width", "Width", cropWidth, 100 - cropX], ["height", "Height", cropHeight, 100 - cropY]] as control}
487
+ <label class="grid gap-1.5 text-[11px] text-foreground-sub">
488
+ <span class="flex justify-between">
489
+ <span>{cropControlLabel(control[0])}</span>
490
+ <span>
491
+ {i18n.formatNumber(Math.round(Number(control[2])) / 100, {
492
+ style: "percent",
493
+ })}
494
+ </span>
495
+ </span>
496
+ <Slider
497
+ value={Number(control[2])}
498
+ min={control[0] === "width" || control[0] === "height" ? 5 : 0}
499
+ max={Number(control[3])}
500
+ disabled={updating}
501
+ label={i18n.t("uploads:cropBoundary", {
502
+ label: cropControlLabel(control[0]),
503
+ })}
504
+ onValueChange={(value) =>
505
+ setCrop(control[0] as "x" | "y" | "width" | "height", value)}
506
+ />
507
+ </label>
508
+ {/each}
509
+ </div>
510
+ {/if}
511
+ </div>
512
+ </div>
513
+ </div>
514
+ <p id="ridu-focal-instructions" class="sr-only">
515
+ {i18n.t("uploads:focalKeyboardInstructions")}
516
+ </p>
517
+ <p class="sr-only" aria-live="polite" aria-atomic="true" data-focal-position-status>
518
+ {i18n.t("uploads:focalPosition", {
519
+ x: i18n.formatNumber(focalX / 100, { style: "percent" }),
520
+ y: i18n.formatNumber(focalY / 100, { style: "percent" }),
521
+ })}
522
+ </p>
523
+ </DialogContent>
524
+ </Dialog>
525
+ </section>
@@ -0,0 +1,93 @@
1
+ <script lang="ts">
2
+ import { useLocation, useNavigate } from "@hvniel/svelte-router";
3
+ import CheckIcon from "~icons/lucide/check";
4
+ import ChevronDownIcon from "~icons/lucide/chevron-down";
5
+
6
+ import { buttonVariants } from "@admin/components/ui/button";
7
+ import {
8
+ DropdownMenu,
9
+ DropdownMenuContent,
10
+ DropdownMenuItem,
11
+ DropdownMenuTrigger,
12
+ } from "@admin/components/ui/dropdown-menu";
13
+ import { getNotificationCenter } from "@admin/core/notifications/notification-center.svelte";
14
+ import { getAdminRuntime } from "@admin/core/runtime/admin-runtime.svelte";
15
+
16
+ const runtime = getAdminRuntime();
17
+ const notifications = getNotificationCenter();
18
+ const navigate = useNavigate();
19
+ const location = $derived(useLocation());
20
+ const localization = $derived(runtime.manifest?.application.localization);
21
+ const locale = $derived(
22
+ localization?.locales.find((candidate) => candidate.code === runtime.contentLocale)
23
+ );
24
+ const disabled = $derived(runtime.contentLocaleSwitchBlocked);
25
+
26
+ $effect(() => {
27
+ const requested = new URLSearchParams(location.search).get("locale");
28
+ if (
29
+ requested !== null &&
30
+ localization?.locales.some((candidate) => candidate.code === requested) === true &&
31
+ requested !== runtime.contentLocale
32
+ ) {
33
+ if (!runtime.adoptContentLocale(requested)) updateRouteLocale(runtime.contentLocale);
34
+ }
35
+ });
36
+
37
+ async function selectLocale(nextLocale: string) {
38
+ if (disabled || nextLocale === runtime.contentLocale) return;
39
+ const previousLocale = runtime.contentLocale;
40
+ updateRouteLocale(nextLocale);
41
+ try {
42
+ await runtime.setContentLocale(nextLocale);
43
+ } catch (cause) {
44
+ updateRouteLocale(runtime.contentLocale ?? previousLocale);
45
+ notifications.error({
46
+ title: runtime.i18n.t("errors:save"),
47
+ message: cause instanceof Error ? cause.message : undefined,
48
+ });
49
+ }
50
+ }
51
+
52
+ function updateRouteLocale(nextLocale: string | undefined) {
53
+ const search = new URLSearchParams(location.search);
54
+ if (nextLocale === undefined) search.delete("locale");
55
+ else search.set("locale", nextLocale);
56
+ navigate(
57
+ {
58
+ pathname: location.pathname,
59
+ search: search.size === 0 ? "" : `?${search}`,
60
+ hash: location.hash,
61
+ },
62
+ { replace: true }
63
+ );
64
+ }
65
+ </script>
66
+
67
+ {#if localization !== undefined && localization.locales.length > 1 && locale !== undefined}
68
+ <DropdownMenu>
69
+ <DropdownMenuTrigger
70
+ class={buttonVariants({ variant: "ghost", size: "sm", class: "gap-1.5 px-2.5" })}
71
+ {disabled}
72
+ aria-label={runtime.i18n.t("documents:contentLocale")}
73
+ title={disabled ? runtime.i18n.t("documents:saveBeforeLocaleSwitch") : undefined}
74
+ >
75
+ <span class="hidden text-foreground-faint sm:inline">
76
+ {runtime.i18n.t("documents:locale")}:
77
+ </span>
78
+ <span>{locale.label}</span>
79
+ <ChevronDownIcon class="size-3 text-foreground-faint" aria-hidden="true" />
80
+ </DropdownMenuTrigger>
81
+ <DropdownMenuContent class="min-w-48">
82
+ {#each localization.locales as candidate (candidate.code)}
83
+ <DropdownMenuItem onSelect={() => selectLocale(candidate.code)}>
84
+ <span class="min-w-0 flex-1 truncate">{candidate.label}</span>
85
+ <span class="text-foreground-faint">({candidate.code})</span>
86
+ {#if candidate.code === locale.code}
87
+ <CheckIcon class="size-3.5 text-foreground" aria-hidden="true" />
88
+ {/if}
89
+ </DropdownMenuItem>
90
+ {/each}
91
+ </DropdownMenuContent>
92
+ </DropdownMenu>
93
+ {/if}