@getdom/studio 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 (213) hide show
  1. package/LICENSE.md +109 -0
  2. package/README.md +44 -0
  3. package/SKILL.md +83 -0
  4. package/package.json +73 -0
  5. package/public/llms.txt +57 -0
  6. package/public/skills.sh +11 -0
  7. package/src/pages/_layout/componentCatalog.generated.js +4514 -0
  8. package/src/pages/_layout/componentInspector.js +60 -0
  9. package/src/pages/_layout/componentManager.js +192 -0
  10. package/src/pages/_layout/componentRuntimeRecords.js +49 -0
  11. package/src/pages/_layout/inspector/componentRegistry.js +639 -0
  12. package/src/pages/_layout/inspector/serialize.js +134 -0
  13. package/src/pages/_layout/inspector/useInspector.js +293 -0
  14. package/src/pages/components/accordion/DomAccordion.vue +39 -0
  15. package/src/pages/components/breadcrumbs/DomBreadcrumbs.vue +347 -0
  16. package/src/pages/components/button/DomButton.vue +132 -0
  17. package/src/pages/components/command-palette/DomCommandPalette.vue +482 -0
  18. package/src/pages/components/context-menu/DomContextMenu.vue +178 -0
  19. package/src/pages/components/dialog/DomDialog.vue +271 -0
  20. package/src/pages/components/dialog/DomDialogSchemaForm.vue +72 -0
  21. package/src/pages/components/dialog/DomDialogStack.vue +289 -0
  22. package/src/pages/components/dialog/useDialogs.js +253 -0
  23. package/src/pages/components/drawer/DomDrawer.vue +159 -0
  24. package/src/pages/components/dropdown/DomDropdown.vue +261 -0
  25. package/src/pages/components/icon-button/DomIconButton.vue +155 -0
  26. package/src/pages/components/lightbox/DomLightbox.vue +1561 -0
  27. package/src/pages/components/menu/DomMenu.vue +359 -0
  28. package/src/pages/components/menu/DomMenuItem.vue +233 -0
  29. package/src/pages/components/menu/DomMenuLabel.vue +19 -0
  30. package/src/pages/components/menu/DomMenuSeparator.vue +14 -0
  31. package/src/pages/components/menu/menuContext.js +11 -0
  32. package/src/pages/components/menu/menuPlacement.js +26 -0
  33. package/src/pages/components/menu/submenu.js +58 -0
  34. package/src/pages/components/pagination/DomPagination.vue +265 -0
  35. package/src/pages/components/playground/DomRenderer.vue +48 -0
  36. package/src/pages/components/popover/DomPopover.vue +244 -0
  37. package/src/pages/components/profile-menu/DomProfileMenu.vue +408 -0
  38. package/src/pages/components/profile-menu/DomProfileMenuItemContent.vue +45 -0
  39. package/src/pages/components/scroll-menu/DomScrollMenu.vue +430 -0
  40. package/src/pages/components/scroll-menu/DomScrollMenuItem.vue +212 -0
  41. package/src/pages/components/scroll-menu/scrollMenuContext.js +22 -0
  42. package/src/pages/components/splitter-panel/DomSplitterPanel.vue +283 -0
  43. package/src/pages/components/stepper/DomStepper.vue +386 -0
  44. package/src/pages/components/tabs/DomTabs.vue +149 -0
  45. package/src/pages/components/theme-switcher/DomThemeSwitcher.vue +226 -0
  46. package/src/pages/components/toast/DomToastItem.vue +52 -0
  47. package/src/pages/components/toast/DomToastStack.vue +768 -0
  48. package/src/pages/components/toast/useToasts.js +237 -0
  49. package/src/pages/components/toolbar/DomToolbar.vue +158 -0
  50. package/src/pages/components/tooltip/DomTooltip.vue +69 -0
  51. package/src/pages/components/tooltip/vDomTooltip.js +106 -0
  52. package/src/pages/components/tree-view/DomTreeView.vue +961 -0
  53. package/src/pages/components/tree-view/treeSchemas.js +129 -0
  54. package/src/pages/components/tree-view/treeUtils.js +228 -0
  55. package/src/pages/data/_shared/calculateVirtualWindow.js +101 -0
  56. package/src/pages/data/_shared/resourceRecord.js +29 -0
  57. package/src/pages/data/activity-timeline/DomActivityTimeline.vue +417 -0
  58. package/src/pages/data/agent-chat/DomAgentChatShell.vue +565 -0
  59. package/src/pages/data/agent-chat/DomAgentConversation.vue +737 -0
  60. package/src/pages/data/agent-chat/DomAgentConversationList.vue +319 -0
  61. package/src/pages/data/agent-chat/examples/agentChatMockApi.js +620 -0
  62. package/src/pages/data/agent-chat/examples/useAgentChatDemoStore.js +730 -0
  63. package/src/pages/data/data-grid/DataGridVirtualRows.vue +220 -0
  64. package/src/pages/data/data-grid/DomDataGrid.vue +1643 -0
  65. package/src/pages/data/document-editor/DomDocumentEditor.vue +3030 -0
  66. package/src/pages/data/document-editor/documentEditorCodeBlock.js +282 -0
  67. package/src/pages/data/document-editor/examples/documentEditorMockApi.js +272 -0
  68. package/src/pages/data/document-editor/examples/useDocumentEditorDemoStore.js +284 -0
  69. package/src/pages/data/filter-bar/DomFilterBar.vue +533 -0
  70. package/src/pages/data/kanban/DomKanban.vue +1703 -0
  71. package/src/pages/data/kanban/kanbanApi.js +80 -0
  72. package/src/pages/data/media-browser/DomMediaBrowser.vue +2344 -0
  73. package/src/pages/data/media-browser/examples/mediaBrowserMockApi.js +653 -0
  74. package/src/pages/data/media-browser/examples/useMediaBrowserDemoStore.js +1157 -0
  75. package/src/pages/data/month-calendar/DomMonthCalendar.vue +1718 -0
  76. package/src/pages/data/week-planner/DomWeekPlanner.vue +2308 -0
  77. package/src/pages/dev/code-block/DomCodeBlock.vue +263 -0
  78. package/src/pages/dev/json-viewer/DomJsonViewer.vue +962 -0
  79. package/src/pages/forms/_shared/DomFieldLoadingSpinner.vue +12 -0
  80. package/src/pages/forms/_shared/options.js +61 -0
  81. package/src/pages/forms/_shared/useOptionMenu.js +59 -0
  82. package/src/pages/forms/autocomplete/DomAutocomplete.vue +180 -0
  83. package/src/pages/forms/calendar/DomCalendar.vue +442 -0
  84. package/src/pages/forms/checkbox/DomCheckbox.vue +69 -0
  85. package/src/pages/forms/class-toggle-input/DomClassToggleInput.vue +316 -0
  86. package/src/pages/forms/code-input/DomCodeInput.vue +202 -0
  87. package/src/pages/forms/color-input/DomColorInput.vue +118 -0
  88. package/src/pages/forms/combobox/DomCombobox.vue +237 -0
  89. package/src/pages/forms/confirmation-code-input/DomConfirmationCodeInput.vue +345 -0
  90. package/src/pages/forms/date-picker/DomDatePicker.vue +299 -0
  91. package/src/pages/forms/date-time-picker/DomDateTimePicker.vue +630 -0
  92. package/src/pages/forms/email-input/DomEmailInput.vue +55 -0
  93. package/src/pages/forms/field/DomField.vue +267 -0
  94. package/src/pages/forms/field/fieldLayout.js +1 -0
  95. package/src/pages/forms/field/fieldProps.js +114 -0
  96. package/src/pages/forms/field/layouts/DomGridFieldLayout.vue +38 -0
  97. package/src/pages/forms/field/useField.js +343 -0
  98. package/src/pages/forms/field/validators.js +364 -0
  99. package/src/pages/forms/file-upload/DomFileUpload.vue +361 -0
  100. package/src/pages/forms/form/DomForm.vue +985 -0
  101. package/src/pages/forms/form/adapters/fieldBuilder.js +45 -0
  102. package/src/pages/forms/form/adapters/jsonSchema.js +264 -0
  103. package/src/pages/forms/form/adapters/shared.js +53 -0
  104. package/src/pages/forms/form/adapters/zod.js +235 -0
  105. package/src/pages/forms/form/formApi.js +38 -0
  106. package/src/pages/forms/form/formDefinition.js +392 -0
  107. package/src/pages/forms/form/schemaAdapter.js +54 -0
  108. package/src/pages/forms/icon-selector/DomIconSelector.vue +460 -0
  109. package/src/pages/forms/icon-selector/iconSelectorItems.js +490 -0
  110. package/src/pages/forms/input-group/DomInputGroup.vue +232 -0
  111. package/src/pages/forms/json-input/DomJsonInput.vue +227 -0
  112. package/src/pages/forms/json-list-input/DomJsonListInput.vue +206 -0
  113. package/src/pages/forms/listbox/DomListbox.vue +105 -0
  114. package/src/pages/forms/money-input/DomMoneyInput.vue +147 -0
  115. package/src/pages/forms/native-select/DomNativeSelect.vue +67 -0
  116. package/src/pages/forms/number-input/DomNumberInput.vue +47 -0
  117. package/src/pages/forms/password-input/DomPasswordInput.vue +218 -0
  118. package/src/pages/forms/password-input/pwnedPasswords.js +47 -0
  119. package/src/pages/forms/password-strength/DomPasswordStrength.vue +128 -0
  120. package/src/pages/forms/password-strength/passwordStrength.js +92 -0
  121. package/src/pages/forms/position-input/DomPositionInput.vue +83 -0
  122. package/src/pages/forms/radio-group/DomRadioGroup.vue +102 -0
  123. package/src/pages/forms/range-input/DomRangeInput.vue +296 -0
  124. package/src/pages/forms/select/DomSelect.vue +479 -0
  125. package/src/pages/forms/select-input/DomSelectInput.vue +155 -0
  126. package/src/pages/forms/tag-combobox/DomTagCombobox.vue +358 -0
  127. package/src/pages/forms/text-input/DomTextInput.vue +48 -0
  128. package/src/pages/forms/textarea-composer/DomTextareaComposer.vue +234 -0
  129. package/src/pages/forms/textarea-input/DomTextareaInput.vue +86 -0
  130. package/src/pages/forms/textarea-input/useTextareaAutosize.js +147 -0
  131. package/src/pages/forms/time-selector/DomTimeSelector.vue +389 -0
  132. package/src/pages/forms/time-selector/timeUtils.js +194 -0
  133. package/src/pages/forms/toggle/DomToggle.vue +158 -0
  134. package/src/pages/forms/toggle-button/DomToggleButton.vue +88 -0
  135. package/src/pages/forms/toggle-button-group/DomToggleButtonGroup.vue +340 -0
  136. package/src/pages/forms/url-input/DomUrlInput.vue +55 -0
  137. package/src/pages/forms/wysiwyg/DomWysiwyg.vue +204 -0
  138. package/src/pages/lib/css/color.js +245 -0
  139. package/src/pages/lib/headless/accordion.d.ts +7 -0
  140. package/src/pages/lib/headless/accordion.js +246 -0
  141. package/src/pages/lib/headless/autocomplete.d.ts +4 -0
  142. package/src/pages/lib/headless/autocomplete.js +33 -0
  143. package/src/pages/lib/headless/base.d.ts +25 -0
  144. package/src/pages/lib/headless/base.js +127 -0
  145. package/src/pages/lib/headless/checkbox.d.ts +19 -0
  146. package/src/pages/lib/headless/checkbox.js +99 -0
  147. package/src/pages/lib/headless/combobox.d.ts +7 -0
  148. package/src/pages/lib/headless/combobox.js +488 -0
  149. package/src/pages/lib/headless/dialog.d.ts +23 -0
  150. package/src/pages/lib/headless/dialog.js +384 -0
  151. package/src/pages/lib/headless/drawer.d.ts +21 -0
  152. package/src/pages/lib/headless/drawer.js +682 -0
  153. package/src/pages/lib/headless/dropdown.d.ts +19 -0
  154. package/src/pages/lib/headless/dropdown.js +412 -0
  155. package/src/pages/lib/headless/floating-effect.js +118 -0
  156. package/src/pages/lib/headless/floating.d.ts +34 -0
  157. package/src/pages/lib/headless/floating.js +382 -0
  158. package/src/pages/lib/headless/index.d.ts +19 -0
  159. package/src/pages/lib/headless/index.js +19 -0
  160. package/src/pages/lib/headless/listbox.d.ts +19 -0
  161. package/src/pages/lib/headless/listbox.js +126 -0
  162. package/src/pages/lib/headless/menu.d.ts +18 -0
  163. package/src/pages/lib/headless/menu.js +117 -0
  164. package/src/pages/lib/headless/popover-panel.d.ts +18 -0
  165. package/src/pages/lib/headless/popover-panel.js +309 -0
  166. package/src/pages/lib/headless/popover.d.ts +19 -0
  167. package/src/pages/lib/headless/popover.js +391 -0
  168. package/src/pages/lib/headless/radio-group.d.ts +19 -0
  169. package/src/pages/lib/headless/radio-group.js +100 -0
  170. package/src/pages/lib/headless/tabs.d.ts +18 -0
  171. package/src/pages/lib/headless/tabs.js +119 -0
  172. package/src/pages/lib/headless/toast.d.ts +13 -0
  173. package/src/pages/lib/headless/toast.js +65 -0
  174. package/src/pages/lib/headless/toggle.d.ts +19 -0
  175. package/src/pages/lib/headless/toggle.js +162 -0
  176. package/src/pages/lib/headless/tooltip-controller.d.ts +22 -0
  177. package/src/pages/lib/headless/tooltip-controller.js +549 -0
  178. package/src/pages/lib/headless/tooltip.d.ts +19 -0
  179. package/src/pages/lib/headless/tooltip.js +153 -0
  180. package/src/pages/lib/headless/transitions.d.ts +5 -0
  181. package/src/pages/lib/headless/transitions.js +89 -0
  182. package/src/pages/lib/vue/_internal/DomLiquidGlassSurface.vue +310 -0
  183. package/src/pages/lib/vue/_internal/liquidGlass.js +112 -0
  184. package/src/pages/lib/vue/_internal/liquidGlassConfig.js +10 -0
  185. package/src/pages/lib/vue/codeHighlighter.js +132 -0
  186. package/src/pages/lib/vue/floatingTeleportTarget.js +23 -0
  187. package/src/pages/lib/vue/index.d.ts +6806 -0
  188. package/src/pages/lib/vue/index.js +218 -0
  189. package/src/pages/lib/vue/useTheme.js +116 -0
  190. package/src/pages/mobile/action-sheet/DomActionSheet.vue +186 -0
  191. package/src/pages/mobile/bottom-nav/DomAppBottomNav.vue +94 -0
  192. package/src/pages/mobile/list-item/DomAppListItem.vue +90 -0
  193. package/src/pages/mobile/safe-area/DomSafeArea.vue +39 -0
  194. package/src/pages/mobile/shell/DomAppShell.vue +51 -0
  195. package/src/pages/mobile/stack/AppStackScreen.vue +63 -0
  196. package/src/pages/mobile/stack/DomAppStack.vue +711 -0
  197. package/src/pages/mobile/top-bar/DomAppTopBar.vue +67 -0
  198. package/src/pages/visual/alert/DomAlert.vue +363 -0
  199. package/src/pages/visual/avatar/DomAvatar.vue +110 -0
  200. package/src/pages/visual/avatar-stack/DomAvatarStack.vue +89 -0
  201. package/src/pages/visual/badge/DomBadge.vue +105 -0
  202. package/src/pages/visual/card/DomCard.vue +65 -0
  203. package/src/pages/visual/card-rail/DomCardRail.vue +99 -0
  204. package/src/pages/visual/empty-state/DomEmptyState.vue +88 -0
  205. package/src/pages/visual/progress/DomProgress.vue +212 -0
  206. package/src/pages/visual/progress/DomRadialProgress.vue +186 -0
  207. package/src/pages/visual/progress/DomSegmentedProgress.vue +350 -0
  208. package/src/pages/visual/skeleton/DomSkeleton.vue +174 -0
  209. package/src/pages/visual/spinner/DomSpinner.vue +122 -0
  210. package/src/pages/visual/status-pill/DomStatusPill.vue +75 -0
  211. package/src/pages/visual/text-diff/DomTextDiff.vue +577 -0
  212. package/src/pages/visual/text-diff/textDiff.js +300 -0
  213. package/src/style.css +446 -0
@@ -0,0 +1,1157 @@
1
+ import { computed, reactive } from 'vue';
2
+ import { defineStore } from 'pinia';
3
+ import { createMediaBrowserMockApi } from './mediaBrowserMockApi.js';
4
+
5
+ const DEFAULT_BASE_URL = '/api/media';
6
+ const DEFAULT_FILES_BASE_URL = '/api/files';
7
+ let logCounter = 0;
8
+ let tempCounter = 0;
9
+
10
+ export const useMediaBrowserDemoStore = defineMediaBrowserStore('media-browser-demo', {
11
+ rootId: 'media_root_brand',
12
+ baseUrl: DEFAULT_BASE_URL,
13
+ filesBaseUrl: DEFAULT_FILES_BASE_URL,
14
+ api: createMediaBrowserMockApi(),
15
+ });
16
+
17
+ /**
18
+ * Creates a configured Pinia media browser store.
19
+ *
20
+ * @param {string} storeId Pinia store id.
21
+ * @param {Record<string, unknown>} options Store configuration.
22
+ * @returns {Function} Pinia use-store function.
23
+ */
24
+ export function defineMediaBrowserStore(storeId = 'media-browser', options = {}) {
25
+ return defineStore(storeId, () => createMediaBrowserStore(options));
26
+ }
27
+
28
+ /**
29
+ * Creates the Pinia setup store used by reusable media browser integrations.
30
+ *
31
+ * @param {Record<string, unknown>} options Store configuration.
32
+ * @returns {Record<string, unknown>} Store state, getters, and actions.
33
+ */
34
+ function createMediaBrowserStore(options = {}) {
35
+ const config = normalizeStoreOptions(options);
36
+ const api = config.api || createMediaBrowserRestApi(config);
37
+ const state = reactive(createInitialStoreState(config));
38
+
39
+ seedRootState(state);
40
+
41
+ const currentPath = computed(() => state.currentPath);
42
+ const currentFolder = computed(() => state.rowsById[state.currentFolderId] || state.rowsById[state.rootId] || null);
43
+ const currentFolderId = computed(() => currentFolder.value?.ulid || state.rootId);
44
+ const items = computed(() => visibleItemsForPath(state, state.currentPath));
45
+ const breadcrumbs = computed(() => state.breadcrumbsByPath[state.currentPath] || deriveBreadcrumbs(state.currentPath, state.rootId));
46
+ const selectedKeys = computed(() => state.selectedKeys);
47
+ const selectedItems = computed(() => state.selectedKeys.map((key) => state.rowsById[key]).filter(Boolean));
48
+ const mode = computed(() => state.mode);
49
+ const loading = computed(() => Boolean(state.loadingPaths[state.currentPath]));
50
+ const emptyLoading = computed(() => loading.value && items.value.length === 0);
51
+ const uploadProgress = computed(() => state.uploadProgress);
52
+ const requestLog = computed(() => state.requestLog);
53
+ const lastRequest = computed(() => state.lastRequest);
54
+ const lastResponse = computed(() => state.lastResponse);
55
+
56
+ /**
57
+ * Loads a folder from cache immediately, then refreshes it through the API.
58
+ *
59
+ * @param {string} path Folder path to load.
60
+ * @returns {Promise<void>} Resolves when the folder request settles.
61
+ */
62
+ async function loadFolder(path = '/') {
63
+ const nextPath = normalizePath(path);
64
+ beginFolderNavigation(state, nextPath);
65
+
66
+ const requestToken = nextRequestToken(state, nextPath);
67
+ state.loadingPaths[nextPath] = true;
68
+
69
+ const request = {
70
+ rootId: state.rootId,
71
+ path: nextPath,
72
+ };
73
+ const log = logRequest('GET', `${config.baseUrl}/roots/${state.rootId}/folders`, request);
74
+
75
+ try {
76
+ const response = await api.listFolder(request);
77
+ if (!isCurrentRequestToken(state, nextPath, requestToken)) return;
78
+ applyFolderResponse(state, nextPath, response);
79
+ logResponse(log.id, response);
80
+ } catch (error) {
81
+ logError(log.id, error);
82
+ } finally {
83
+ if (isCurrentRequestToken(state, nextPath, requestToken)) {
84
+ delete state.loadingPaths[nextPath];
85
+ }
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Updates controlled selection keys.
91
+ *
92
+ * @param {Array<string>} keys Selected media row ids.
93
+ */
94
+ function setSelectedKeys(keys) {
95
+ state.selectedKeys = Array.isArray(keys) ? keys.map(String) : [];
96
+ }
97
+
98
+ /**
99
+ * Updates the browser display mode.
100
+ *
101
+ * @param {'grid'|'list'} nextMode Browser display mode.
102
+ */
103
+ function setMode(nextMode) {
104
+ state.mode = nextMode === 'list' ? 'list' : 'grid';
105
+ }
106
+
107
+ /**
108
+ * Creates a folder optimistically, then confirms it with the server.
109
+ *
110
+ * @param {{ path?: string, name?: string }} payload Create folder payload.
111
+ * @returns {Promise<Record<string, unknown>|null>} Created folder row.
112
+ */
113
+ async function createFolder(payload = {}) {
114
+ const path = normalizePath(payload.path || state.currentPath);
115
+ const parentId = currentFolderIdForPath(state, path);
116
+ const tempUlid = nextTempId('folder');
117
+ const name = uniqueVisibleName(payload.name || 'New folder', visibleItemsForPath(state, path));
118
+ const optimistic = {
119
+ ulid: tempUlid,
120
+ temporaryId: tempUlid,
121
+ type: 'dir',
122
+ name,
123
+ path: joinPath(path, name),
124
+ parent_id: parentId,
125
+ file_uuid: null,
126
+ status: 'saving',
127
+ };
128
+
129
+ upsertMediaItem(state, optimistic);
130
+ prependChild(state, path, tempUlid);
131
+ state.selectedKeys = [tempUlid];
132
+
133
+ const request = {
134
+ rootId: state.rootId,
135
+ parentId,
136
+ path,
137
+ name,
138
+ tempUlid,
139
+ };
140
+ const log = logRequest('POST', `${config.baseUrl}/folders`, request);
141
+ try {
142
+ const response = await api.createFolder(request);
143
+ const row = replaceItemInPath(state, path, tempUlid, response.data);
144
+ state.selectedKeys = [row.ulid];
145
+ logResponse(log.id, response);
146
+ return row;
147
+ } catch (error) {
148
+ removeChild(state, path, tempUlid);
149
+ delete state.rowsById[tempUlid];
150
+ logError(log.id, error);
151
+ return null;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Renames a row optimistically and lets the server return the canonical row.
157
+ *
158
+ * @param {{ item: Record<string, unknown>, name: string }} payload Rename payload.
159
+ * @returns {Promise<void>} Resolves when the rename response is applied.
160
+ */
161
+ async function renameItem(payload) {
162
+ const item = payload?.item;
163
+ const name = String(payload?.name || '').trim();
164
+ if (!item || !name) return;
165
+
166
+ const previous = clone(state.rowsById[item.ulid] || item);
167
+ applyVisiblePatch(state, item.ulid, renamePatch(item, name, 'saving'));
168
+
169
+ const request = { ulid: item.ulid, name };
170
+ const log = logRequest('PATCH', `${config.baseUrl}/${item.ulid}`, request);
171
+ try {
172
+ const response = await api.renameMedia(request);
173
+ replaceItemInPath(state, parentPathFromItem(previous), item.ulid, response.data);
174
+ logResponse(log.id, response);
175
+ } catch (error) {
176
+ upsertMediaItem(state, previous);
177
+ logError(log.id, error);
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Moves rows into a target folder with an optimistic visible removal.
183
+ *
184
+ * @param {{ items: Array<Record<string, unknown>>, target: Record<string, unknown> }} payload Move payload.
185
+ * @returns {Promise<void>} Resolves when the move response is applied.
186
+ */
187
+ async function moveItems(payload) {
188
+ const rows = Array.isArray(payload?.items) ? payload.items : [];
189
+ const target = payload?.target;
190
+ if (!rows.length || !target) return;
191
+
192
+ const sourcePath = state.currentPath;
193
+ const targetPath = normalizePath(target.path || state.currentPath);
194
+ const targetParentId = target.ulid || currentFolderIdForPath(state, targetPath);
195
+ const ulids = rows.map((item) => item.ulid);
196
+ const previousChildren = [...(state.childrenByPath[sourcePath] || [])];
197
+ const previousTargetChildren = [...(state.childrenByPath[targetPath] || [])];
198
+ state.childrenByPath[sourcePath] = previousChildren.filter((ulid) => !ulids.includes(ulid));
199
+ state.selectedKeys = [];
200
+
201
+ const request = {
202
+ ulids,
203
+ targetParentId,
204
+ };
205
+ const log = logRequest('PATCH', `${config.baseUrl}/move`, request);
206
+ try {
207
+ const response = await api.moveMedia(request);
208
+ const movedIds = (response.data || [])
209
+ .map((row) => upsertMediaItem(state, row)?.ulid)
210
+ .filter(Boolean);
211
+ if (movedIds.length && hasOwn(state.childrenByPath, targetPath)) {
212
+ state.childrenByPath[targetPath] = [
213
+ ...movedIds,
214
+ ...state.childrenByPath[targetPath].filter((ulid) => !movedIds.includes(ulid)),
215
+ ];
216
+ }
217
+ logResponse(log.id, response);
218
+ } catch (error) {
219
+ state.childrenByPath[sourcePath] = previousChildren;
220
+ if (hasOwn(state.childrenByPath, targetPath)) state.childrenByPath[targetPath] = previousTargetChildren;
221
+ logError(log.id, error);
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Creates a folder and moves selected items into it.
227
+ *
228
+ * @param {{ items: Array<Record<string, unknown>>, path?: string }} payload Folder grouping payload.
229
+ * @returns {Promise<void>} Resolves after the move completes.
230
+ */
231
+ async function makeFolderWithItems(payload = {}) {
232
+ const rows = Array.isArray(payload.items) ? payload.items : selectedItems.value;
233
+ if (!rows.length) return;
234
+ const folder = await createFolder({
235
+ path: payload.path || state.currentPath,
236
+ name: 'New folder with items',
237
+ });
238
+ if (!folder) return;
239
+ await moveItems({ items: rows, target: folder });
240
+ }
241
+
242
+ /**
243
+ * Updates joined file metadata through the file endpoint.
244
+ *
245
+ * @param {{ item: Record<string, unknown>, meta: Record<string, unknown> }} payload Metadata payload.
246
+ * @returns {Promise<void>} Resolves when the metadata response is applied.
247
+ */
248
+ async function updateMetadata(payload) {
249
+ const item = payload?.item;
250
+ const fileUuid = item?.file_uuid;
251
+ if (!item || !fileUuid) return;
252
+
253
+ const previous = clone(state.rowsById[item.ulid] || item);
254
+ applyVisiblePatch(state, item.ulid, {
255
+ file: {
256
+ ...(item.file || {}),
257
+ meta: {
258
+ ...(item.file?.meta || {}),
259
+ ...(payload.meta || {}),
260
+ },
261
+ },
262
+ status: 'saving',
263
+ });
264
+
265
+ const request = {
266
+ fileUuid,
267
+ meta: payload.meta || {},
268
+ };
269
+ const log = logRequest('PATCH', `${config.filesBaseUrl}/${fileUuid}/meta`, request);
270
+ try {
271
+ const response = await api.updateFileMeta(request);
272
+ applyVisiblePatch(state, item.ulid, {
273
+ file: response.data,
274
+ status: 'idle',
275
+ });
276
+ logResponse(log.id, response);
277
+ } catch (error) {
278
+ upsertMediaItem(state, previous);
279
+ logError(log.id, error);
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Adds optimistic upload rows, runs the chunk-upload API, and applies server rows.
285
+ *
286
+ * @param {{ files: Array<Record<string, unknown>>, target?: Record<string, unknown>|null, path?: string }} payload Upload payload.
287
+ * @returns {Promise<void>} Resolves when the upload response is applied.
288
+ */
289
+ async function uploadFiles(payload = {}) {
290
+ const uploadRecords = normalizeUploadFiles(payload.files || []);
291
+ if (!uploadRecords.length) return;
292
+
293
+ const target = payload.target || null;
294
+ const targetPath = normalizePath(payload.path || target?.path || state.currentPath);
295
+ const parentId = target?.ulid || currentFolderIdForPath(state, targetPath);
296
+ const optimisticRows = optimisticUploadRows(uploadRecords, parentId, targetPath);
297
+ mergeUploadRows(state, targetPath, optimisticRows);
298
+ state.uploadProgress = 1;
299
+
300
+ const request = {
301
+ rootId: state.rootId,
302
+ parentId,
303
+ path: targetPath,
304
+ chunkSize: 5 * 1024 * 1024,
305
+ files: uploadRecords.map(uploadRequestFile),
306
+ };
307
+ const log = logRequest('POST', `${config.baseUrl}/uploads`, request);
308
+ try {
309
+ const response = await api.uploadBatch({
310
+ ...request,
311
+ files: uploadRecords,
312
+ onProgress: (progress) => updateUploadProgress(state, targetPath, uploadRecords, progress),
313
+ });
314
+ applyVisibleRows(state, targetPath, response.visible.map(markIdleUpload));
315
+ state.uploadProgress = 0;
316
+ revokePreviewUrls(uploadRecords);
317
+ logResponse(log.id, response);
318
+ } catch (error) {
319
+ state.uploadProgress = 0;
320
+ revokePreviewUrls(uploadRecords);
321
+ logError(log.id, error);
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Logs an outgoing request and stores it as the last request preview.
327
+ *
328
+ * @param {string} method HTTP method.
329
+ * @param {string} url REST URL.
330
+ * @param {Record<string, unknown>} body Request body or query object.
331
+ * @returns {Record<string, unknown>} Created log entry.
332
+ */
333
+ function logRequest(method, url, body) {
334
+ const entry = {
335
+ id: ++logCounter,
336
+ method,
337
+ url,
338
+ body: clone(body),
339
+ status: 'pending',
340
+ requestedAt: new Date().toISOString(),
341
+ response: null,
342
+ };
343
+ state.lastRequest = entry;
344
+ state.requestLog = [entry, ...state.requestLog].slice(0, 8);
345
+ return entry;
346
+ }
347
+
348
+ /**
349
+ * Adds a response summary to an existing request log entry.
350
+ *
351
+ * @param {number} id Log entry id.
352
+ * @param {Record<string, unknown>} response Response payload.
353
+ */
354
+ function logResponse(id, response) {
355
+ state.lastResponse = response;
356
+ state.requestLog = state.requestLog.map((entry) => (
357
+ entry.id === id
358
+ ? { ...entry, status: 'success', response: clone(response) }
359
+ : entry
360
+ ));
361
+ }
362
+
363
+ /**
364
+ * Adds an error summary to an existing request log entry.
365
+ *
366
+ * @param {number} id Log entry id.
367
+ * @param {Error} error Error thrown by the API.
368
+ */
369
+ function logError(id, error) {
370
+ state.requestLog = state.requestLog.map((entry) => (
371
+ entry.id === id
372
+ ? { ...entry, status: 'error', response: { error: error.message } }
373
+ : entry
374
+ ));
375
+ state.lastResponse = { error: error.message };
376
+ }
377
+
378
+ return {
379
+ state,
380
+ rootId: computed(() => state.rootId),
381
+ currentFolder,
382
+ currentFolderId,
383
+ currentPath,
384
+ items,
385
+ breadcrumbs,
386
+ selectedKeys,
387
+ selectedItems,
388
+ mode,
389
+ loading,
390
+ emptyLoading,
391
+ uploadProgress,
392
+ requestLog,
393
+ lastRequest,
394
+ lastResponse,
395
+ loadFolder,
396
+ setSelectedKeys,
397
+ setMode,
398
+ createFolder,
399
+ renameItem,
400
+ moveItems,
401
+ makeFolderWithItems,
402
+ updateMetadata,
403
+ uploadFiles,
404
+ };
405
+ }
406
+
407
+ /**
408
+ * Creates a fetch-backed API adapter with the same method surface as the mock API.
409
+ *
410
+ * @param {Record<string, unknown>} options Adapter options.
411
+ * @returns {Record<string, Function>} REST API adapter.
412
+ */
413
+ export function createMediaBrowserRestApi(options = {}) {
414
+ const config = normalizeStoreOptions(options);
415
+
416
+ /**
417
+ * Lists a folder through the REST API.
418
+ *
419
+ * @param {Record<string, unknown>} request Folder request.
420
+ * @returns {Promise<Record<string, unknown>>} Folder response.
421
+ */
422
+ function listFolder(request) {
423
+ return requestJson('GET', `${config.baseUrl}/roots/${request.rootId}/folders?path=${encodeURIComponent(request.path)}`, null, config);
424
+ }
425
+
426
+ /**
427
+ * Creates a folder through the REST API.
428
+ *
429
+ * @param {Record<string, unknown>} request Create request.
430
+ * @returns {Promise<Record<string, unknown>>} Create response.
431
+ */
432
+ function createFolder(request) {
433
+ return requestJson('POST', `${config.baseUrl}/folders`, request, config);
434
+ }
435
+
436
+ /**
437
+ * Renames a media row through the REST API.
438
+ *
439
+ * @param {Record<string, unknown>} request Rename request.
440
+ * @returns {Promise<Record<string, unknown>>} Rename response.
441
+ */
442
+ function renameMedia(request) {
443
+ return requestJson('PATCH', `${config.baseUrl}/${request.ulid}`, request, config);
444
+ }
445
+
446
+ /**
447
+ * Moves media rows through the REST API.
448
+ *
449
+ * @param {Record<string, unknown>} request Move request.
450
+ * @returns {Promise<Record<string, unknown>>} Move response.
451
+ */
452
+ function moveMedia(request) {
453
+ return requestJson('PATCH', `${config.baseUrl}/move`, request, config);
454
+ }
455
+
456
+ /**
457
+ * Updates file metadata through the REST API.
458
+ *
459
+ * @param {Record<string, unknown>} request Metadata request.
460
+ * @returns {Promise<Record<string, unknown>>} Metadata response.
461
+ */
462
+ function updateFileMeta(request) {
463
+ return requestJson('PATCH', `${config.filesBaseUrl}/${request.fileUuid}/meta`, request, config);
464
+ }
465
+
466
+ /**
467
+ * Starts an upload batch through the REST API.
468
+ *
469
+ * @param {Record<string, unknown>} request Upload request.
470
+ * @returns {Promise<Record<string, unknown>>} Upload response.
471
+ */
472
+ function uploadBatch(request) {
473
+ return requestJson('POST', `${config.baseUrl}/uploads`, uploadRequestPayload(request), config);
474
+ }
475
+
476
+ return {
477
+ listFolder,
478
+ createFolder,
479
+ renameMedia,
480
+ moveMedia,
481
+ updateFileMeta,
482
+ uploadBatch,
483
+ };
484
+ }
485
+
486
+ /**
487
+ * Normalizes store and API adapter options.
488
+ *
489
+ * @param {Record<string, unknown>} options Raw options.
490
+ * @returns {Record<string, unknown>} Normalized options.
491
+ */
492
+ function normalizeStoreOptions(options = {}) {
493
+ return {
494
+ rootId: String(options.rootId || 'media_root'),
495
+ baseUrl: String(options.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, ''),
496
+ filesBaseUrl: String(options.filesBaseUrl || DEFAULT_FILES_BASE_URL).replace(/\/$/, ''),
497
+ api: options.api || null,
498
+ mode: options.mode === 'list' ? 'list' : 'grid',
499
+ fetchOptions: options.fetchOptions || {},
500
+ headers: options.headers || {},
501
+ };
502
+ }
503
+
504
+ /**
505
+ * Creates the initial reactive store object.
506
+ *
507
+ * @param {Record<string, unknown>} config Normalized store config.
508
+ * @returns {Record<string, unknown>} Initial state object.
509
+ */
510
+ function createInitialStoreState(config) {
511
+ return {
512
+ rootId: config.rootId,
513
+ currentPath: '/',
514
+ currentFolderId: config.rootId,
515
+ rowsById: {},
516
+ filesById: {},
517
+ folderIdByPath: {},
518
+ childrenByPath: {},
519
+ breadcrumbsByPath: {},
520
+ selectedKeys: [],
521
+ mode: config.mode,
522
+ loadingPaths: {},
523
+ uploadProgress: 0,
524
+ requestLog: [],
525
+ lastRequest: null,
526
+ lastResponse: null,
527
+ requestTokensByPath: {},
528
+ requestCounter: 0,
529
+ };
530
+ }
531
+
532
+ /**
533
+ * Seeds the root folder before the first API response arrives.
534
+ *
535
+ * @param {Record<string, unknown>} state Store state.
536
+ */
537
+ function seedRootState(state) {
538
+ const root = {
539
+ ulid: state.rootId,
540
+ type: 'root',
541
+ path: '/',
542
+ parent_id: null,
543
+ file_uuid: null,
544
+ name: 'Root',
545
+ file: null,
546
+ status: 'idle',
547
+ };
548
+ upsertMediaItem(state, root);
549
+ state.childrenByPath['/'] = state.childrenByPath['/'] || [];
550
+ state.breadcrumbsByPath['/'] = [{ label: 'Root', path: '/', ulid: state.rootId }];
551
+ state.folderIdByPath['/'] = state.rootId;
552
+ }
553
+
554
+ /**
555
+ * Starts folder navigation using local cache before the network response returns.
556
+ *
557
+ * @param {Record<string, unknown>} state Store state.
558
+ * @param {string} path Folder path.
559
+ */
560
+ function beginFolderNavigation(state, path) {
561
+ state.currentPath = path;
562
+ state.currentFolderId = state.folderIdByPath[path] || (path === '/' ? state.rootId : state.currentFolderId);
563
+ state.selectedKeys = [];
564
+ if (!hasOwn(state.childrenByPath, path)) state.childrenByPath[path] = [];
565
+ if (!state.breadcrumbsByPath[path]) state.breadcrumbsByPath[path] = deriveBreadcrumbs(path, state.rootId);
566
+ }
567
+
568
+ /**
569
+ * Applies a folder API response to the reactive cache.
570
+ *
571
+ * @param {Record<string, unknown>} state Store state.
572
+ * @param {string} path Requested folder path.
573
+ * @param {Record<string, unknown>} response API response.
574
+ */
575
+ function applyFolderResponse(state, path, response) {
576
+ const responsePath = normalizePath(response?.meta?.path || path);
577
+ const folder = upsertMediaItem(state, response.folder);
578
+ state.currentFolderId = state.currentPath === responsePath ? folder.ulid : state.currentFolderId;
579
+ state.folderIdByPath[responsePath] = folder.ulid;
580
+ applyVisibleRows(state, responsePath, response.data || []);
581
+ state.breadcrumbsByPath[responsePath] = normalizeBreadcrumbRows(response.breadcrumbs || deriveBreadcrumbs(responsePath, state.rootId));
582
+ }
583
+
584
+ /**
585
+ * Applies the canonical visible rows for one folder path.
586
+ *
587
+ * @param {Record<string, unknown>} state Store state.
588
+ * @param {string} path Folder path.
589
+ * @param {Array<Record<string, unknown>>} rows Visible rows.
590
+ */
591
+ function applyVisibleRows(state, path, rows) {
592
+ const ids = [];
593
+ for (const row of rows) {
594
+ const saved = upsertMediaItem(state, row);
595
+ ids.push(saved.ulid);
596
+ }
597
+ state.childrenByPath[normalizePath(path)] = ids;
598
+ }
599
+
600
+ /**
601
+ * Inserts or updates a media row while preserving object identity.
602
+ *
603
+ * @param {Record<string, unknown>} state Store state.
604
+ * @param {Record<string, unknown>} row Media row.
605
+ * @returns {Record<string, unknown>} Reactive media row.
606
+ */
607
+ function upsertMediaItem(state, row = {}) {
608
+ const key = String(row.ulid || row.temporaryId || '');
609
+ if (!key) return null;
610
+ const file = row.file ? upsertFileRecord(state, row.file) : null;
611
+ const next = {
612
+ ...row,
613
+ ulid: key,
614
+ name: row.name || file?.name || pathBasename(row.path),
615
+ file,
616
+ status: row.status || 'idle',
617
+ };
618
+ if (state.rowsById[key]) {
619
+ Object.assign(state.rowsById[key], next);
620
+ } else {
621
+ state.rowsById[key] = next;
622
+ }
623
+ if (['root', 'dir'].includes(next.type)) {
624
+ state.folderIdByPath[normalizePath(next.path)] = key;
625
+ }
626
+ return state.rowsById[key];
627
+ }
628
+
629
+ /**
630
+ * Inserts or updates a file row while preserving object identity.
631
+ *
632
+ * @param {Record<string, unknown>} state Store state.
633
+ * @param {Record<string, unknown>} file File row.
634
+ * @returns {Record<string, unknown>} Reactive file row.
635
+ */
636
+ function upsertFileRecord(state, file = {}) {
637
+ const key = String(file.ulid || file.uuid || '');
638
+ if (!key) return { ...file };
639
+ if (state.filesById[key]) {
640
+ Object.assign(state.filesById[key], file);
641
+ } else {
642
+ state.filesById[key] = { ...file, ulid: key };
643
+ }
644
+ return state.filesById[key];
645
+ }
646
+
647
+ /**
648
+ * Replaces one child id in a folder with a canonical media row.
649
+ *
650
+ * @param {Record<string, unknown>} state Store state.
651
+ * @param {string} path Folder path.
652
+ * @param {string} oldUlid Optimistic row id.
653
+ * @param {Record<string, unknown>} nextItem Canonical row.
654
+ * @returns {Record<string, unknown>} Saved row.
655
+ */
656
+ function replaceItemInPath(state, path, oldUlid, nextItem) {
657
+ const saved = upsertMediaItem(state, nextItem);
658
+ const normalizedPath = normalizePath(path);
659
+ const ids = state.childrenByPath[normalizedPath] || [];
660
+ state.childrenByPath[normalizedPath] = ids.map((id) => (String(id) === String(oldUlid) ? saved.ulid : id));
661
+ if (String(oldUlid) !== String(saved.ulid)) delete state.rowsById[oldUlid];
662
+ return saved;
663
+ }
664
+
665
+ /**
666
+ * Applies a partial update to a visible row.
667
+ *
668
+ * @param {Record<string, unknown>} state Store state.
669
+ * @param {string} ulid Media row id.
670
+ * @param {Record<string, unknown>} patch Partial row patch.
671
+ */
672
+ function applyVisiblePatch(state, ulid, patch) {
673
+ const row = state.rowsById[ulid];
674
+ if (!row) return;
675
+ const { file, ...rest } = patch;
676
+ Object.assign(row, rest);
677
+ if (file) row.file = upsertFileRecord(state, file);
678
+ }
679
+
680
+ /**
681
+ * Returns visible rows for a folder path.
682
+ *
683
+ * @param {Record<string, unknown>} state Store state.
684
+ * @param {string} path Folder path.
685
+ * @returns {Array<Record<string, unknown>>} Visible media rows.
686
+ */
687
+ function visibleItemsForPath(state, path) {
688
+ return (state.childrenByPath[normalizePath(path)] || [])
689
+ .map((ulid) => state.rowsById[ulid])
690
+ .filter(Boolean);
691
+ }
692
+
693
+ /**
694
+ * Prepends a child id to a folder path.
695
+ *
696
+ * @param {Record<string, unknown>} state Store state.
697
+ * @param {string} path Folder path.
698
+ * @param {string} ulid Media row id.
699
+ */
700
+ function prependChild(state, path, ulid) {
701
+ const normalizedPath = normalizePath(path);
702
+ const ids = state.childrenByPath[normalizedPath] || [];
703
+ state.childrenByPath[normalizedPath] = [ulid, ...ids.filter((id) => id !== ulid)];
704
+ }
705
+
706
+ /**
707
+ * Removes a child id from a folder path.
708
+ *
709
+ * @param {Record<string, unknown>} state Store state.
710
+ * @param {string} path Folder path.
711
+ * @param {string} ulid Media row id.
712
+ */
713
+ function removeChild(state, path, ulid) {
714
+ const normalizedPath = normalizePath(path);
715
+ state.childrenByPath[normalizedPath] = (state.childrenByPath[normalizedPath] || []).filter((id) => id !== ulid);
716
+ }
717
+
718
+ /**
719
+ * Merges optimistic upload rows into a folder cache.
720
+ *
721
+ * @param {Record<string, unknown>} state Store state.
722
+ * @param {string} path Folder path.
723
+ * @param {Array<Record<string, unknown>>} rows Upload rows.
724
+ */
725
+ function mergeUploadRows(state, path, rows) {
726
+ for (const row of rows) upsertMediaItem(state, row);
727
+ const normalizedPath = normalizePath(path);
728
+ const uploadIds = rows.map((row) => row.ulid);
729
+ const existing = state.childrenByPath[normalizedPath] || [];
730
+ state.childrenByPath[normalizedPath] = [...uploadIds, ...existing.filter((id) => !uploadIds.includes(id))];
731
+ }
732
+
733
+ /**
734
+ * Applies upload progress to optimistic rows.
735
+ *
736
+ * @param {Record<string, unknown>} state Store state.
737
+ * @param {string} path Folder path.
738
+ * @param {Array<Record<string, unknown>>} uploadRecords Upload records.
739
+ * @param {{ overall?: number, files?: Array<Record<string, unknown>> }} progress Progress payload.
740
+ */
741
+ function updateUploadProgress(state, path, uploadRecords, progress) {
742
+ state.uploadProgress = clampProgress(progress?.overall);
743
+ const fileProgress = new Map((progress?.files || []).map((file) => [String(file.tempUlid), clampProgress(file.progress)]));
744
+ const folderProgress = folderProgressByName(uploadRecords, fileProgress);
745
+ for (const item of visibleItemsForPath(state, path)) {
746
+ if (item.status !== 'uploading') continue;
747
+ if (fileProgress.has(item.ulid)) item.uploadProgress = fileProgress.get(item.ulid);
748
+ else if (folderProgress.has(String(item.name).toLowerCase())) item.uploadProgress = folderProgress.get(String(item.name).toLowerCase());
749
+ else item.uploadProgress = state.uploadProgress;
750
+ }
751
+ }
752
+
753
+ /**
754
+ * Averages nested upload progress by top-level folder name.
755
+ *
756
+ * @param {Array<Record<string, unknown>>} uploadRecords Upload records.
757
+ * @param {Map<string, number>} fileProgress Progress keyed by temp id.
758
+ * @returns {Map<string, number>} Progress keyed by folder name.
759
+ */
760
+ function folderProgressByName(uploadRecords, fileProgress) {
761
+ const grouped = new Map();
762
+ for (const record of uploadRecords) {
763
+ if (!Array.isArray(record.pathSegments) || record.pathSegments.length < 2) continue;
764
+ const folder = String(record.pathSegments[0]).toLowerCase();
765
+ const progress = fileProgress.get(record.tempUlid) || 0;
766
+ const values = grouped.get(folder) || [];
767
+ values.push(progress);
768
+ grouped.set(folder, values);
769
+ }
770
+ return new Map([...grouped.entries()].map(([folder, values]) => [
771
+ folder,
772
+ Math.round(values.reduce((sum, value) => sum + value, 0) / values.length),
773
+ ]));
774
+ }
775
+
776
+ /**
777
+ * Resolves the current folder id for a path.
778
+ *
779
+ * @param {Record<string, unknown>} state Store state.
780
+ * @param {string} path Folder path.
781
+ * @returns {string} Folder id.
782
+ */
783
+ function currentFolderIdForPath(state, path) {
784
+ return state.folderIdByPath[normalizePath(path)] || state.currentFolderId || state.rootId;
785
+ }
786
+
787
+ /**
788
+ * Creates a patch for an optimistically renamed row.
789
+ *
790
+ * @param {Record<string, unknown>} item Media row being renamed.
791
+ * @param {string} name New display name.
792
+ * @param {string} status Optimistic status.
793
+ * @returns {Record<string, unknown>} Partial row patch.
794
+ */
795
+ function renamePatch(item, name, status) {
796
+ const parentPath = parentPathFromItem(item);
797
+ const patch = {
798
+ name,
799
+ path: joinPath(parentPath, name),
800
+ status,
801
+ };
802
+ if (item.type === 'file') {
803
+ patch.file = {
804
+ ...(item.file || {}),
805
+ name,
806
+ };
807
+ }
808
+ return patch;
809
+ }
810
+
811
+ /**
812
+ * Normalizes dropped files and assigns temporary upload ids.
813
+ *
814
+ * @param {Array<Record<string, unknown>>} files Dropped files from the component.
815
+ * @returns {Array<Record<string, unknown>>} Upload records.
816
+ */
817
+ function normalizeUploadFiles(files) {
818
+ return files.map((record) => ({
819
+ ...record,
820
+ tempUlid: nextTempId('upload'),
821
+ name: record.name || record.file?.name || 'upload.bin',
822
+ previewUrl: createPreviewUrl(record.file),
823
+ pathSegments: Array.isArray(record.pathSegments) && record.pathSegments.length
824
+ ? record.pathSegments
825
+ : [record.name || record.file?.name || 'upload.bin'],
826
+ }));
827
+ }
828
+
829
+ /**
830
+ * Creates a temporary browser preview URL for image uploads.
831
+ *
832
+ * @param {File|undefined} file Browser file.
833
+ * @returns {string} Object URL or empty string.
834
+ */
835
+ function createPreviewUrl(file) {
836
+ if (!file || !String(file.type || '').startsWith('image/') || typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') return '';
837
+ return URL.createObjectURL(file);
838
+ }
839
+
840
+ /**
841
+ * Revokes temporary browser preview URLs.
842
+ *
843
+ * @param {Array<Record<string, unknown>>} records Upload records.
844
+ */
845
+ function revokePreviewUrls(records) {
846
+ if (typeof URL === 'undefined' || typeof URL.revokeObjectURL !== 'function') return;
847
+ for (const record of records) {
848
+ if (record.previewUrl) URL.revokeObjectURL(record.previewUrl);
849
+ }
850
+ }
851
+
852
+ /**
853
+ * Creates visible optimistic upload rows for files and top-level folders.
854
+ *
855
+ * @param {Array<Record<string, unknown>>} files Upload file records.
856
+ * @param {string} parentId Target parent id.
857
+ * @param {string} targetPath Target folder path.
858
+ * @returns {Array<Record<string, unknown>>} Optimistic media rows.
859
+ */
860
+ function optimisticUploadRows(files, parentId, targetPath) {
861
+ const rows = [];
862
+ const folderNames = new Set();
863
+ for (const file of files) {
864
+ if (file.pathSegments.length > 1) {
865
+ const folderName = file.pathSegments[0];
866
+ const folderKey = folderName.toLowerCase();
867
+ if (folderNames.has(folderKey)) continue;
868
+ folderNames.add(folderKey);
869
+ rows.push({
870
+ ulid: nextTempId('upload-folder'),
871
+ type: 'dir',
872
+ name: folderName,
873
+ path: joinPath(targetPath, folderName),
874
+ parent_id: parentId,
875
+ file_uuid: null,
876
+ status: 'uploading',
877
+ uploadProgress: 1,
878
+ });
879
+ continue;
880
+ }
881
+
882
+ rows.push({
883
+ ulid: file.tempUlid,
884
+ type: 'file',
885
+ name: file.name,
886
+ path: joinPath(targetPath, file.name),
887
+ parent_id: parentId,
888
+ file_uuid: null,
889
+ status: 'uploading',
890
+ uploadProgress: 1,
891
+ previewUrl: file.previewUrl,
892
+ file: {
893
+ ulid: file.tempUlid,
894
+ name: file.name,
895
+ mimeType: file.file?.type || '',
896
+ size: file.file?.size || 0,
897
+ previewUrl: file.previewUrl,
898
+ thumbnailUrl: file.previewUrl,
899
+ meta: {},
900
+ },
901
+ });
902
+ }
903
+ return rows;
904
+ }
905
+
906
+ /**
907
+ * Marks a completed upload row as idle.
908
+ *
909
+ * @param {Record<string, unknown>} item Media row.
910
+ * @returns {Record<string, unknown>} Idle row.
911
+ */
912
+ function markIdleUpload(item) {
913
+ return {
914
+ ...item,
915
+ status: 'idle',
916
+ uploadProgress: 0,
917
+ };
918
+ }
919
+
920
+ /**
921
+ * Strips browser File objects out of the visible request preview.
922
+ *
923
+ * @param {Record<string, unknown>} file Upload record.
924
+ * @returns {Record<string, unknown>} Serializable upload request file.
925
+ */
926
+ function uploadRequestFile(file) {
927
+ return {
928
+ tempUlid: file.tempUlid,
929
+ name: file.name,
930
+ relativePath: file.relativePath || file.name,
931
+ pathSegments: file.pathSegments,
932
+ size: file.file?.size || 0,
933
+ mimeType: file.file?.type || '',
934
+ };
935
+ }
936
+
937
+ /**
938
+ * Builds a serializable upload request payload for the REST adapter.
939
+ *
940
+ * @param {Record<string, unknown>} request Upload request.
941
+ * @returns {Record<string, unknown>} Serializable upload body.
942
+ */
943
+ function uploadRequestPayload(request) {
944
+ return {
945
+ rootId: request.rootId,
946
+ parentId: request.parentId,
947
+ path: request.path,
948
+ chunkSize: request.chunkSize,
949
+ files: (request.files || []).map(uploadRequestFile),
950
+ };
951
+ }
952
+
953
+ /**
954
+ * Sends a JSON REST request.
955
+ *
956
+ * @param {string} method HTTP method.
957
+ * @param {string} url Request URL.
958
+ * @param {Record<string, unknown>|null} body Request body.
959
+ * @param {Record<string, unknown>} config REST config.
960
+ * @returns {Promise<Record<string, unknown>>} Parsed JSON response.
961
+ */
962
+ async function requestJson(method, url, body, config) {
963
+ const response = await fetch(url, {
964
+ ...(config.fetchOptions || {}),
965
+ method,
966
+ headers: {
967
+ 'content-type': 'application/json',
968
+ ...(config.headers || {}),
969
+ ...(config.fetchOptions?.headers || {}),
970
+ },
971
+ body: body ? JSON.stringify(body) : undefined,
972
+ });
973
+ if (!response.ok) throw new Error(`Media API request failed with ${response.status}`);
974
+ return response.json();
975
+ }
976
+
977
+ /**
978
+ * Generates the next request token for a path.
979
+ *
980
+ * @param {Record<string, unknown>} state Store state.
981
+ * @param {string} path Folder path.
982
+ * @returns {number} Request token.
983
+ */
984
+ function nextRequestToken(state, path) {
985
+ state.requestCounter += 1;
986
+ state.requestTokensByPath[path] = state.requestCounter;
987
+ return state.requestCounter;
988
+ }
989
+
990
+ /**
991
+ * Checks whether a request token still owns a path.
992
+ *
993
+ * @param {Record<string, unknown>} state Store state.
994
+ * @param {string} path Folder path.
995
+ * @param {number} token Request token.
996
+ * @returns {boolean} True when the token is current.
997
+ */
998
+ function isCurrentRequestToken(state, path, token) {
999
+ return state.requestTokensByPath[path] === token;
1000
+ }
1001
+
1002
+ /**
1003
+ * Normalizes API breadcrumb rows.
1004
+ *
1005
+ * @param {Array<Record<string, unknown>>} breadcrumbs Breadcrumb rows.
1006
+ * @returns {Array<Record<string, unknown>>} Normalized breadcrumbs.
1007
+ */
1008
+ function normalizeBreadcrumbRows(breadcrumbs) {
1009
+ return breadcrumbs.map((crumb, index) => ({
1010
+ label: crumb.label || (index === 0 ? 'Root' : pathBasename(crumb.path)),
1011
+ path: normalizePath(crumb.path),
1012
+ ulid: crumb.ulid || null,
1013
+ }));
1014
+ }
1015
+
1016
+ /**
1017
+ * Derives breadcrumbs immediately from a path before the server responds.
1018
+ *
1019
+ * @param {string} path Folder path.
1020
+ * @param {string} rootId Root media id.
1021
+ * @returns {Array<Record<string, unknown>>} Derived breadcrumbs.
1022
+ */
1023
+ function deriveBreadcrumbs(path, rootId) {
1024
+ const segments = normalizePath(path).split('/').filter(Boolean);
1025
+ const breadcrumbs = [{ label: 'Root', path: '/', ulid: rootId }];
1026
+ let cursor = '';
1027
+ for (const segment of segments) {
1028
+ cursor = `${cursor}/${segment}`;
1029
+ breadcrumbs.push({
1030
+ label: decodeSegment(segment),
1031
+ path: cursor,
1032
+ ulid: null,
1033
+ });
1034
+ }
1035
+ return breadcrumbs;
1036
+ }
1037
+
1038
+ /**
1039
+ * Decodes a path segment for breadcrumb labels.
1040
+ *
1041
+ * @param {string} segment Path segment.
1042
+ * @returns {string} Decoded segment.
1043
+ */
1044
+ function decodeSegment(segment) {
1045
+ try {
1046
+ return decodeURIComponent(segment);
1047
+ } catch {
1048
+ return segment;
1049
+ }
1050
+ }
1051
+
1052
+ /**
1053
+ * Resolves a row's parent path.
1054
+ *
1055
+ * @param {Record<string, unknown>} item Media row.
1056
+ * @returns {string} Parent path.
1057
+ */
1058
+ function parentPathFromItem(item) {
1059
+ const parts = String(item.path || '').split('/').filter(Boolean);
1060
+ parts.pop();
1061
+ return parts.length ? `/${parts.join('/')}` : '/';
1062
+ }
1063
+
1064
+ /**
1065
+ * Joins a parent path and child name.
1066
+ *
1067
+ * @param {string} parentPath Parent media path.
1068
+ * @param {string} name Child name.
1069
+ * @returns {string} Joined media path.
1070
+ */
1071
+ function joinPath(parentPath, name) {
1072
+ const parent = normalizePath(parentPath);
1073
+ const child = String(name || '').trim().replace(/^\/+|\/+$/g, '') || 'Untitled';
1074
+ return parent === '/' ? `/${child}` : `${parent}/${child}`;
1075
+ }
1076
+
1077
+ /**
1078
+ * Normalizes a path into the media path contract.
1079
+ *
1080
+ * @param {string} path Path-like value.
1081
+ * @returns {string} Slash-prefixed path.
1082
+ */
1083
+ function normalizePath(path) {
1084
+ const normalized = String(path || '/').trim().replace(/\/+/g, '/').replace(/\/$/, '');
1085
+ return normalized.startsWith('/') ? normalized || '/' : `/${normalized}`;
1086
+ }
1087
+
1088
+ /**
1089
+ * Returns the last segment of a media path.
1090
+ *
1091
+ * @param {string} path Media path.
1092
+ * @returns {string} Path basename.
1093
+ */
1094
+ function pathBasename(path) {
1095
+ const segments = String(path || '').split('/').filter(Boolean);
1096
+ return decodeSegment(segments[segments.length - 1] || 'Root');
1097
+ }
1098
+
1099
+ /**
1100
+ * Generates a unique visible folder name.
1101
+ *
1102
+ * @param {string} requestedName Desired name.
1103
+ * @param {Array<Record<string, unknown>>} rows Currently visible media rows.
1104
+ * @returns {string} Unique visible name.
1105
+ */
1106
+ function uniqueVisibleName(requestedName, rows = []) {
1107
+ const base = String(requestedName || 'New folder').trim() || 'New folder';
1108
+ const names = new Set(rows.map((item) => String(item.name || '').toLowerCase()));
1109
+ if (!names.has(base.toLowerCase())) return base;
1110
+ let index = 2;
1111
+ while (names.has(`${base} ${index}`.toLowerCase())) index += 1;
1112
+ return `${base} ${index}`;
1113
+ }
1114
+
1115
+ /**
1116
+ * Generates a temporary client id.
1117
+ *
1118
+ * @param {string} prefix Id prefix.
1119
+ * @returns {string} Temporary id.
1120
+ */
1121
+ function nextTempId(prefix) {
1122
+ tempCounter += 1;
1123
+ return `temp_${prefix}_${tempCounter.toString(36)}`;
1124
+ }
1125
+
1126
+ /**
1127
+ * Clamps a progress value to a percentage.
1128
+ *
1129
+ * @param {number} value Raw progress value.
1130
+ * @returns {number} Percentage.
1131
+ */
1132
+ function clampProgress(value) {
1133
+ const number = Number(value);
1134
+ if (!Number.isFinite(number)) return 0;
1135
+ return Math.min(100, Math.max(0, Math.round(number)));
1136
+ }
1137
+
1138
+ /**
1139
+ * Checks whether an object owns a key.
1140
+ *
1141
+ * @param {object} value Object to inspect.
1142
+ * @param {string} key Property key.
1143
+ * @returns {boolean} True when the object owns the key.
1144
+ */
1145
+ function hasOwn(value, key) {
1146
+ return Object.prototype.hasOwnProperty.call(value, key);
1147
+ }
1148
+
1149
+ /**
1150
+ * Creates a structured clone that tolerates plain demo records.
1151
+ *
1152
+ * @param {unknown} value Value to clone.
1153
+ * @returns {unknown} Cloned value.
1154
+ */
1155
+ function clone(value) {
1156
+ return JSON.parse(JSON.stringify(value));
1157
+ }