@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.
- package/LICENSE.md +109 -0
- package/README.md +44 -0
- package/SKILL.md +83 -0
- package/package.json +73 -0
- package/public/llms.txt +57 -0
- package/public/skills.sh +11 -0
- package/src/pages/_layout/componentCatalog.generated.js +4514 -0
- package/src/pages/_layout/componentInspector.js +60 -0
- package/src/pages/_layout/componentManager.js +192 -0
- package/src/pages/_layout/componentRuntimeRecords.js +49 -0
- package/src/pages/_layout/inspector/componentRegistry.js +639 -0
- package/src/pages/_layout/inspector/serialize.js +134 -0
- package/src/pages/_layout/inspector/useInspector.js +293 -0
- package/src/pages/components/accordion/DomAccordion.vue +39 -0
- package/src/pages/components/breadcrumbs/DomBreadcrumbs.vue +347 -0
- package/src/pages/components/button/DomButton.vue +132 -0
- package/src/pages/components/command-palette/DomCommandPalette.vue +482 -0
- package/src/pages/components/context-menu/DomContextMenu.vue +178 -0
- package/src/pages/components/dialog/DomDialog.vue +271 -0
- package/src/pages/components/dialog/DomDialogSchemaForm.vue +72 -0
- package/src/pages/components/dialog/DomDialogStack.vue +289 -0
- package/src/pages/components/dialog/useDialogs.js +253 -0
- package/src/pages/components/drawer/DomDrawer.vue +159 -0
- package/src/pages/components/dropdown/DomDropdown.vue +261 -0
- package/src/pages/components/icon-button/DomIconButton.vue +155 -0
- package/src/pages/components/lightbox/DomLightbox.vue +1561 -0
- package/src/pages/components/menu/DomMenu.vue +359 -0
- package/src/pages/components/menu/DomMenuItem.vue +233 -0
- package/src/pages/components/menu/DomMenuLabel.vue +19 -0
- package/src/pages/components/menu/DomMenuSeparator.vue +14 -0
- package/src/pages/components/menu/menuContext.js +11 -0
- package/src/pages/components/menu/menuPlacement.js +26 -0
- package/src/pages/components/menu/submenu.js +58 -0
- package/src/pages/components/pagination/DomPagination.vue +265 -0
- package/src/pages/components/playground/DomRenderer.vue +48 -0
- package/src/pages/components/popover/DomPopover.vue +244 -0
- package/src/pages/components/profile-menu/DomProfileMenu.vue +408 -0
- package/src/pages/components/profile-menu/DomProfileMenuItemContent.vue +45 -0
- package/src/pages/components/scroll-menu/DomScrollMenu.vue +430 -0
- package/src/pages/components/scroll-menu/DomScrollMenuItem.vue +212 -0
- package/src/pages/components/scroll-menu/scrollMenuContext.js +22 -0
- package/src/pages/components/splitter-panel/DomSplitterPanel.vue +283 -0
- package/src/pages/components/stepper/DomStepper.vue +386 -0
- package/src/pages/components/tabs/DomTabs.vue +149 -0
- package/src/pages/components/theme-switcher/DomThemeSwitcher.vue +226 -0
- package/src/pages/components/toast/DomToastItem.vue +52 -0
- package/src/pages/components/toast/DomToastStack.vue +768 -0
- package/src/pages/components/toast/useToasts.js +237 -0
- package/src/pages/components/toolbar/DomToolbar.vue +158 -0
- package/src/pages/components/tooltip/DomTooltip.vue +69 -0
- package/src/pages/components/tooltip/vDomTooltip.js +106 -0
- package/src/pages/components/tree-view/DomTreeView.vue +961 -0
- package/src/pages/components/tree-view/treeSchemas.js +129 -0
- package/src/pages/components/tree-view/treeUtils.js +228 -0
- package/src/pages/data/_shared/calculateVirtualWindow.js +101 -0
- package/src/pages/data/_shared/resourceRecord.js +29 -0
- package/src/pages/data/activity-timeline/DomActivityTimeline.vue +417 -0
- package/src/pages/data/agent-chat/DomAgentChatShell.vue +565 -0
- package/src/pages/data/agent-chat/DomAgentConversation.vue +737 -0
- package/src/pages/data/agent-chat/DomAgentConversationList.vue +319 -0
- package/src/pages/data/agent-chat/examples/agentChatMockApi.js +620 -0
- package/src/pages/data/agent-chat/examples/useAgentChatDemoStore.js +730 -0
- package/src/pages/data/data-grid/DataGridVirtualRows.vue +220 -0
- package/src/pages/data/data-grid/DomDataGrid.vue +1643 -0
- package/src/pages/data/document-editor/DomDocumentEditor.vue +3030 -0
- package/src/pages/data/document-editor/documentEditorCodeBlock.js +282 -0
- package/src/pages/data/document-editor/examples/documentEditorMockApi.js +272 -0
- package/src/pages/data/document-editor/examples/useDocumentEditorDemoStore.js +284 -0
- package/src/pages/data/filter-bar/DomFilterBar.vue +533 -0
- package/src/pages/data/kanban/DomKanban.vue +1703 -0
- package/src/pages/data/kanban/kanbanApi.js +80 -0
- package/src/pages/data/media-browser/DomMediaBrowser.vue +2344 -0
- package/src/pages/data/media-browser/examples/mediaBrowserMockApi.js +653 -0
- package/src/pages/data/media-browser/examples/useMediaBrowserDemoStore.js +1157 -0
- package/src/pages/data/month-calendar/DomMonthCalendar.vue +1718 -0
- package/src/pages/data/week-planner/DomWeekPlanner.vue +2308 -0
- package/src/pages/dev/code-block/DomCodeBlock.vue +263 -0
- package/src/pages/dev/json-viewer/DomJsonViewer.vue +962 -0
- package/src/pages/forms/_shared/DomFieldLoadingSpinner.vue +12 -0
- package/src/pages/forms/_shared/options.js +61 -0
- package/src/pages/forms/_shared/useOptionMenu.js +59 -0
- package/src/pages/forms/autocomplete/DomAutocomplete.vue +180 -0
- package/src/pages/forms/calendar/DomCalendar.vue +442 -0
- package/src/pages/forms/checkbox/DomCheckbox.vue +69 -0
- package/src/pages/forms/class-toggle-input/DomClassToggleInput.vue +316 -0
- package/src/pages/forms/code-input/DomCodeInput.vue +202 -0
- package/src/pages/forms/color-input/DomColorInput.vue +118 -0
- package/src/pages/forms/combobox/DomCombobox.vue +237 -0
- package/src/pages/forms/confirmation-code-input/DomConfirmationCodeInput.vue +345 -0
- package/src/pages/forms/date-picker/DomDatePicker.vue +299 -0
- package/src/pages/forms/date-time-picker/DomDateTimePicker.vue +630 -0
- package/src/pages/forms/email-input/DomEmailInput.vue +55 -0
- package/src/pages/forms/field/DomField.vue +267 -0
- package/src/pages/forms/field/fieldLayout.js +1 -0
- package/src/pages/forms/field/fieldProps.js +114 -0
- package/src/pages/forms/field/layouts/DomGridFieldLayout.vue +38 -0
- package/src/pages/forms/field/useField.js +343 -0
- package/src/pages/forms/field/validators.js +364 -0
- package/src/pages/forms/file-upload/DomFileUpload.vue +361 -0
- package/src/pages/forms/form/DomForm.vue +985 -0
- package/src/pages/forms/form/adapters/fieldBuilder.js +45 -0
- package/src/pages/forms/form/adapters/jsonSchema.js +264 -0
- package/src/pages/forms/form/adapters/shared.js +53 -0
- package/src/pages/forms/form/adapters/zod.js +235 -0
- package/src/pages/forms/form/formApi.js +38 -0
- package/src/pages/forms/form/formDefinition.js +392 -0
- package/src/pages/forms/form/schemaAdapter.js +54 -0
- package/src/pages/forms/icon-selector/DomIconSelector.vue +460 -0
- package/src/pages/forms/icon-selector/iconSelectorItems.js +490 -0
- package/src/pages/forms/input-group/DomInputGroup.vue +232 -0
- package/src/pages/forms/json-input/DomJsonInput.vue +227 -0
- package/src/pages/forms/json-list-input/DomJsonListInput.vue +206 -0
- package/src/pages/forms/listbox/DomListbox.vue +105 -0
- package/src/pages/forms/money-input/DomMoneyInput.vue +147 -0
- package/src/pages/forms/native-select/DomNativeSelect.vue +67 -0
- package/src/pages/forms/number-input/DomNumberInput.vue +47 -0
- package/src/pages/forms/password-input/DomPasswordInput.vue +218 -0
- package/src/pages/forms/password-input/pwnedPasswords.js +47 -0
- package/src/pages/forms/password-strength/DomPasswordStrength.vue +128 -0
- package/src/pages/forms/password-strength/passwordStrength.js +92 -0
- package/src/pages/forms/position-input/DomPositionInput.vue +83 -0
- package/src/pages/forms/radio-group/DomRadioGroup.vue +102 -0
- package/src/pages/forms/range-input/DomRangeInput.vue +296 -0
- package/src/pages/forms/select/DomSelect.vue +479 -0
- package/src/pages/forms/select-input/DomSelectInput.vue +155 -0
- package/src/pages/forms/tag-combobox/DomTagCombobox.vue +358 -0
- package/src/pages/forms/text-input/DomTextInput.vue +48 -0
- package/src/pages/forms/textarea-composer/DomTextareaComposer.vue +234 -0
- package/src/pages/forms/textarea-input/DomTextareaInput.vue +86 -0
- package/src/pages/forms/textarea-input/useTextareaAutosize.js +147 -0
- package/src/pages/forms/time-selector/DomTimeSelector.vue +389 -0
- package/src/pages/forms/time-selector/timeUtils.js +194 -0
- package/src/pages/forms/toggle/DomToggle.vue +158 -0
- package/src/pages/forms/toggle-button/DomToggleButton.vue +88 -0
- package/src/pages/forms/toggle-button-group/DomToggleButtonGroup.vue +340 -0
- package/src/pages/forms/url-input/DomUrlInput.vue +55 -0
- package/src/pages/forms/wysiwyg/DomWysiwyg.vue +204 -0
- package/src/pages/lib/css/color.js +245 -0
- package/src/pages/lib/headless/accordion.d.ts +7 -0
- package/src/pages/lib/headless/accordion.js +246 -0
- package/src/pages/lib/headless/autocomplete.d.ts +4 -0
- package/src/pages/lib/headless/autocomplete.js +33 -0
- package/src/pages/lib/headless/base.d.ts +25 -0
- package/src/pages/lib/headless/base.js +127 -0
- package/src/pages/lib/headless/checkbox.d.ts +19 -0
- package/src/pages/lib/headless/checkbox.js +99 -0
- package/src/pages/lib/headless/combobox.d.ts +7 -0
- package/src/pages/lib/headless/combobox.js +488 -0
- package/src/pages/lib/headless/dialog.d.ts +23 -0
- package/src/pages/lib/headless/dialog.js +384 -0
- package/src/pages/lib/headless/drawer.d.ts +21 -0
- package/src/pages/lib/headless/drawer.js +682 -0
- package/src/pages/lib/headless/dropdown.d.ts +19 -0
- package/src/pages/lib/headless/dropdown.js +412 -0
- package/src/pages/lib/headless/floating-effect.js +118 -0
- package/src/pages/lib/headless/floating.d.ts +34 -0
- package/src/pages/lib/headless/floating.js +382 -0
- package/src/pages/lib/headless/index.d.ts +19 -0
- package/src/pages/lib/headless/index.js +19 -0
- package/src/pages/lib/headless/listbox.d.ts +19 -0
- package/src/pages/lib/headless/listbox.js +126 -0
- package/src/pages/lib/headless/menu.d.ts +18 -0
- package/src/pages/lib/headless/menu.js +117 -0
- package/src/pages/lib/headless/popover-panel.d.ts +18 -0
- package/src/pages/lib/headless/popover-panel.js +309 -0
- package/src/pages/lib/headless/popover.d.ts +19 -0
- package/src/pages/lib/headless/popover.js +391 -0
- package/src/pages/lib/headless/radio-group.d.ts +19 -0
- package/src/pages/lib/headless/radio-group.js +100 -0
- package/src/pages/lib/headless/tabs.d.ts +18 -0
- package/src/pages/lib/headless/tabs.js +119 -0
- package/src/pages/lib/headless/toast.d.ts +13 -0
- package/src/pages/lib/headless/toast.js +65 -0
- package/src/pages/lib/headless/toggle.d.ts +19 -0
- package/src/pages/lib/headless/toggle.js +162 -0
- package/src/pages/lib/headless/tooltip-controller.d.ts +22 -0
- package/src/pages/lib/headless/tooltip-controller.js +549 -0
- package/src/pages/lib/headless/tooltip.d.ts +19 -0
- package/src/pages/lib/headless/tooltip.js +153 -0
- package/src/pages/lib/headless/transitions.d.ts +5 -0
- package/src/pages/lib/headless/transitions.js +89 -0
- package/src/pages/lib/vue/_internal/DomLiquidGlassSurface.vue +310 -0
- package/src/pages/lib/vue/_internal/liquidGlass.js +112 -0
- package/src/pages/lib/vue/_internal/liquidGlassConfig.js +10 -0
- package/src/pages/lib/vue/codeHighlighter.js +132 -0
- package/src/pages/lib/vue/floatingTeleportTarget.js +23 -0
- package/src/pages/lib/vue/index.d.ts +6806 -0
- package/src/pages/lib/vue/index.js +218 -0
- package/src/pages/lib/vue/useTheme.js +116 -0
- package/src/pages/mobile/action-sheet/DomActionSheet.vue +186 -0
- package/src/pages/mobile/bottom-nav/DomAppBottomNav.vue +94 -0
- package/src/pages/mobile/list-item/DomAppListItem.vue +90 -0
- package/src/pages/mobile/safe-area/DomSafeArea.vue +39 -0
- package/src/pages/mobile/shell/DomAppShell.vue +51 -0
- package/src/pages/mobile/stack/AppStackScreen.vue +63 -0
- package/src/pages/mobile/stack/DomAppStack.vue +711 -0
- package/src/pages/mobile/top-bar/DomAppTopBar.vue +67 -0
- package/src/pages/visual/alert/DomAlert.vue +363 -0
- package/src/pages/visual/avatar/DomAvatar.vue +110 -0
- package/src/pages/visual/avatar-stack/DomAvatarStack.vue +89 -0
- package/src/pages/visual/badge/DomBadge.vue +105 -0
- package/src/pages/visual/card/DomCard.vue +65 -0
- package/src/pages/visual/card-rail/DomCardRail.vue +99 -0
- package/src/pages/visual/empty-state/DomEmptyState.vue +88 -0
- package/src/pages/visual/progress/DomProgress.vue +212 -0
- package/src/pages/visual/progress/DomRadialProgress.vue +186 -0
- package/src/pages/visual/progress/DomSegmentedProgress.vue +350 -0
- package/src/pages/visual/skeleton/DomSkeleton.vue +174 -0
- package/src/pages/visual/spinner/DomSpinner.vue +122 -0
- package/src/pages/visual/status-pill/DomStatusPill.vue +75 -0
- package/src/pages/visual/text-diff/DomTextDiff.vue +577 -0
- package/src/pages/visual/text-diff/textDiff.js +300 -0
- package/src/style.css +446 -0
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
let idCounter = 1000;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates an isolated in-memory API that behaves like a server-backed media library.
|
|
5
|
+
*
|
|
6
|
+
* @returns {Record<string, Function>} API methods used by the Pinia demo store.
|
|
7
|
+
*/
|
|
8
|
+
export function createMediaBrowserMockApi() {
|
|
9
|
+
const state = createInitialState();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Lists the immediate children for a folder path.
|
|
13
|
+
*
|
|
14
|
+
* @param {{ rootId: string, path: string }} request Folder read request.
|
|
15
|
+
* @returns {Promise<Record<string, unknown>>} Folder response.
|
|
16
|
+
*/
|
|
17
|
+
async function listFolder(request) {
|
|
18
|
+
await wait(260);
|
|
19
|
+
const root = state.media.find((item) => item.ulid === request.rootId && item.type === 'root') || state.media[0];
|
|
20
|
+
const path = normalizePath(request.path || '/');
|
|
21
|
+
const folder = findFolderByPath(state, root.ulid, path) || root;
|
|
22
|
+
const data = state.media
|
|
23
|
+
.filter((item) => item.parent_id === folder.ulid)
|
|
24
|
+
.map((item) => hydrateMediaItem(state, item))
|
|
25
|
+
.sort(compareMediaRows);
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
folder: hydrateMediaItem(state, folder),
|
|
29
|
+
data,
|
|
30
|
+
breadcrumbs: breadcrumbsForPath(state, root.ulid, path),
|
|
31
|
+
meta: {
|
|
32
|
+
rootId: root.ulid,
|
|
33
|
+
path,
|
|
34
|
+
total: data.length,
|
|
35
|
+
requestedAt: new Date().toISOString(),
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Creates a new folder under a parent folder.
|
|
42
|
+
*
|
|
43
|
+
* @param {{ rootId: string, parentId: string, path: string, name: string, tempUlid?: string }} request Create request.
|
|
44
|
+
* @returns {Promise<Record<string, unknown>>} Created folder response.
|
|
45
|
+
*/
|
|
46
|
+
async function createFolder(request) {
|
|
47
|
+
await wait(360);
|
|
48
|
+
const parent = state.media.find((item) => item.ulid === request.parentId) || findFolderByPath(state, request.rootId, request.path);
|
|
49
|
+
const name = uniqueChildName(state, parent.ulid, request.name || 'New folder');
|
|
50
|
+
const row = {
|
|
51
|
+
ulid: request.tempUlid || nextId('media'),
|
|
52
|
+
type: 'dir',
|
|
53
|
+
path: joinPath(parent.path, name),
|
|
54
|
+
parent_id: parent.ulid,
|
|
55
|
+
root_id: request.rootId,
|
|
56
|
+
file_uuid: null,
|
|
57
|
+
createdAt: new Date().toISOString(),
|
|
58
|
+
updatedAt: new Date().toISOString(),
|
|
59
|
+
};
|
|
60
|
+
state.media.push(row);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
data: hydrateMediaItem(state, row),
|
|
64
|
+
meta: {
|
|
65
|
+
requestedAt: new Date().toISOString(),
|
|
66
|
+
action: 'create-folder',
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Renames a media row and rewrites descendant paths for folders.
|
|
73
|
+
*
|
|
74
|
+
* @param {{ ulid: string, name: string }} request Rename request.
|
|
75
|
+
* @returns {Promise<Record<string, unknown>>} Updated media response.
|
|
76
|
+
*/
|
|
77
|
+
async function renameMedia(request) {
|
|
78
|
+
await wait(320);
|
|
79
|
+
const row = state.media.find((item) => item.ulid === request.ulid);
|
|
80
|
+
if (!row) throw new Error('Media row not found.');
|
|
81
|
+
|
|
82
|
+
const previousPath = row.path;
|
|
83
|
+
const parent = state.media.find((item) => item.ulid === row.parent_id);
|
|
84
|
+
const nextName = uniqueChildName(state, row.parent_id, request.name || pathName(row.path), row.ulid);
|
|
85
|
+
row.path = joinPath(parent?.path || '/', nextName);
|
|
86
|
+
row.updatedAt = new Date().toISOString();
|
|
87
|
+
|
|
88
|
+
if (row.type === 'file' && row.file_uuid) {
|
|
89
|
+
const file = state.files.find((record) => record.ulid === row.file_uuid);
|
|
90
|
+
if (file) file.name = nextName;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (row.type === 'dir') updateDescendantPaths(state, row, previousPath);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
data: hydrateMediaItem(state, row),
|
|
97
|
+
descendants: state.media
|
|
98
|
+
.filter((item) => item.path.startsWith(`${row.path}/`))
|
|
99
|
+
.map((item) => hydrateMediaItem(state, item)),
|
|
100
|
+
meta: {
|
|
101
|
+
requestedAt: new Date().toISOString(),
|
|
102
|
+
action: 'rename',
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Moves media rows into another folder.
|
|
109
|
+
*
|
|
110
|
+
* @param {{ ulids: Array<string>, targetParentId: string }} request Move request.
|
|
111
|
+
* @returns {Promise<Record<string, unknown>>} Updated rows response.
|
|
112
|
+
*/
|
|
113
|
+
async function moveMedia(request) {
|
|
114
|
+
await wait(380);
|
|
115
|
+
const target = state.media.find((item) => item.ulid === request.targetParentId && ['root', 'dir'].includes(item.type));
|
|
116
|
+
if (!target) throw new Error('Target folder not found.');
|
|
117
|
+
|
|
118
|
+
const moved = [];
|
|
119
|
+
for (const ulid of request.ulids || []) {
|
|
120
|
+
const row = state.media.find((item) => item.ulid === ulid);
|
|
121
|
+
if (!row || row.ulid === target.ulid || target.path.startsWith(`${row.path}/`)) continue;
|
|
122
|
+
const previousPath = row.path;
|
|
123
|
+
const name = uniqueChildName(state, target.ulid, pathName(row.path), row.ulid);
|
|
124
|
+
row.parent_id = target.ulid;
|
|
125
|
+
row.path = joinPath(target.path, name);
|
|
126
|
+
row.updatedAt = new Date().toISOString();
|
|
127
|
+
if (row.type === 'dir') updateDescendantPaths(state, row, previousPath);
|
|
128
|
+
moved.push(hydrateMediaItem(state, row));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
data: moved,
|
|
133
|
+
meta: {
|
|
134
|
+
requestedAt: new Date().toISOString(),
|
|
135
|
+
action: 'move',
|
|
136
|
+
targetParentId: target.ulid,
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Updates metadata on a joined file row.
|
|
143
|
+
*
|
|
144
|
+
* @param {{ fileUuid: string, meta: Record<string, unknown> }} request Metadata request.
|
|
145
|
+
* @returns {Promise<Record<string, unknown>>} Updated file response.
|
|
146
|
+
*/
|
|
147
|
+
async function updateFileMeta(request) {
|
|
148
|
+
await wait(280);
|
|
149
|
+
const file = state.files.find((record) => record.ulid === request.fileUuid);
|
|
150
|
+
if (!file) throw new Error('File row not found.');
|
|
151
|
+
file.meta = {
|
|
152
|
+
...(file.meta || {}),
|
|
153
|
+
...(request.meta || {}),
|
|
154
|
+
};
|
|
155
|
+
file.updatedAt = new Date().toISOString();
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
data: { ...file },
|
|
159
|
+
meta: {
|
|
160
|
+
requestedAt: new Date().toISOString(),
|
|
161
|
+
action: 'update-file-meta',
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Emulates a modern chunk upload batch and creates media rows on completion.
|
|
168
|
+
*
|
|
169
|
+
* @param {{ rootId: string, parentId: string, path: string, files: Array<Record<string, unknown>>, onProgress?: Function }} request Upload request.
|
|
170
|
+
* @returns {Promise<Record<string, unknown>>} Upload completion response.
|
|
171
|
+
*/
|
|
172
|
+
async function uploadBatch(request) {
|
|
173
|
+
const files = Array.isArray(request.files) ? request.files : [];
|
|
174
|
+
for (let step = 1; step <= 10; step += 1) {
|
|
175
|
+
await wait(110);
|
|
176
|
+
request.onProgress?.({
|
|
177
|
+
overall: step * 10,
|
|
178
|
+
files: files.map((file, index) => ({
|
|
179
|
+
name: file.name,
|
|
180
|
+
tempUlid: file.tempUlid,
|
|
181
|
+
progress: Math.min(100, step * 10 + ((index % 3) * 3)),
|
|
182
|
+
})),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const parent = state.media.find((item) => item.ulid === request.parentId) || findFolderByPath(state, request.rootId, request.path);
|
|
187
|
+
const created = [];
|
|
188
|
+
for (const record of files) {
|
|
189
|
+
const createdRow = createUploadedFile(state, request.rootId, parent, record);
|
|
190
|
+
created.push(hydrateMediaItem(state, createdRow));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const visible = state.media
|
|
194
|
+
.filter((item) => item.parent_id === parent.ulid)
|
|
195
|
+
.map((item) => hydrateMediaItem(state, item))
|
|
196
|
+
.sort(compareMediaRows);
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
data: created,
|
|
200
|
+
visible,
|
|
201
|
+
meta: {
|
|
202
|
+
requestedAt: new Date().toISOString(),
|
|
203
|
+
action: 'upload-batch',
|
|
204
|
+
total: files.length,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
listFolder,
|
|
211
|
+
createFolder,
|
|
212
|
+
renameMedia,
|
|
213
|
+
moveMedia,
|
|
214
|
+
updateFileMeta,
|
|
215
|
+
uploadBatch,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Creates the initial media and file rows for the demo.
|
|
221
|
+
*
|
|
222
|
+
* @returns {{ media: Array<Record<string, unknown>>, files: Array<Record<string, unknown>> }} Mock database state.
|
|
223
|
+
*/
|
|
224
|
+
function createInitialState() {
|
|
225
|
+
const root = {
|
|
226
|
+
ulid: 'media_root_brand',
|
|
227
|
+
type: 'root',
|
|
228
|
+
path: '/',
|
|
229
|
+
parent_id: null,
|
|
230
|
+
root_id: 'media_root_brand',
|
|
231
|
+
file_uuid: null,
|
|
232
|
+
createdAt: '2026-06-01T10:00:00.000Z',
|
|
233
|
+
updatedAt: '2026-06-01T10:00:00.000Z',
|
|
234
|
+
};
|
|
235
|
+
const folders = [
|
|
236
|
+
createFolderRow('media_images', 'Images', root.ulid, root.ulid),
|
|
237
|
+
createFolderRow('media_documents', 'Documents', root.ulid, root.ulid),
|
|
238
|
+
createFolderRow('media_campaign', 'Campaign', root.ulid, 'media_images', '/Images'),
|
|
239
|
+
createFolderRow('media_brand', 'Brand assets', root.ulid, 'media_images', '/Images'),
|
|
240
|
+
createFolderRow('media_people', 'People', root.ulid, 'media_images', '/Images'),
|
|
241
|
+
createFolderRow('media_spaces', 'Office spaces', root.ulid, 'media_images', '/Images'),
|
|
242
|
+
createFolderRow('media_events', 'Event photography', root.ulid, 'media_images', '/Images'),
|
|
243
|
+
];
|
|
244
|
+
const heroImage = unsplashImage('photo-1497366754035-f200968a6e72', 1600, 900);
|
|
245
|
+
const productImage = unsplashImage('photo-1518005020951-eccb494ad742', 1400, 900);
|
|
246
|
+
const campaignImage = unsplashImage('photo-1500530855697-b586d89ba3ee', 1400, 900);
|
|
247
|
+
const galleryImages = createSeedImageRecords([
|
|
248
|
+
{ id: 'file_workspace_planning', name: 'workspace-planning.jpg', photo: 'photo-1556761175-b413da4baf72', size: 734210, alt: 'Team planning campaign work around a table' },
|
|
249
|
+
{ id: 'file_editorial_portrait', name: 'editorial-portrait.jpg', photo: 'photo-1500648767791-00dcc994a43e', size: 428940, alt: 'Editorial portrait with soft studio light' },
|
|
250
|
+
{ id: 'file_studio_detail', name: 'studio-detail-shot.jpg', photo: 'photo-1518005020951-eccb494ad742', size: 512340, alt: 'Detailed product surface in a studio setting' },
|
|
251
|
+
{ id: 'file_office_lounge', name: 'office-lounge.jpg', photo: 'photo-1497366811353-6870744d04b2', size: 912440, alt: 'Modern office lounge with warm seating' },
|
|
252
|
+
{ id: 'file_retail_display', name: 'retail-display-long-name-example.jpg', photo: 'photo-1441986300917-64674bd600d8', size: 835120, alt: 'Retail display with clothing rails and natural light' },
|
|
253
|
+
{ id: 'file_architecture_corner', name: 'architecture-corner.jpg', photo: 'photo-1494526585095-c41746248156', size: 688450, alt: 'Bright architectural interior corner' },
|
|
254
|
+
{ id: 'file_workshop_notes', name: 'workshop-notes.jpg', photo: 'photo-1516321318423-f06f85e504b3', size: 492670, alt: 'Workshop notebook beside a laptop' },
|
|
255
|
+
{ id: 'file_installation_view', name: 'installation-view.jpg', photo: 'photo-1511818966892-d7d671e672a2', size: 743180, alt: 'Interior installation view with soft lighting' },
|
|
256
|
+
], 1400, 900);
|
|
257
|
+
const files = [
|
|
258
|
+
createFileRow('file_hero', 'hero-image.jpg', 'image/jpeg', 842120, heroImage, { alt: 'Sunlit workspace with product planning materials', width: 1600, height: 900 }, heroImage),
|
|
259
|
+
createFileRow('file_product', 'product-shot.png', 'image/png', 412880, productImage, { alt: 'Architectural product composition with warm light', width: 1400, height: 900 }, productImage),
|
|
260
|
+
createFileRow('file_guidelines', 'brand-guidelines.pdf', 'application/pdf', 2462022, '', { pages: 28 }, 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf'),
|
|
261
|
+
createFileRow('file_notes', 'release-notes.txt', 'text/plain', 18320, '', { encoding: 'utf-8' }),
|
|
262
|
+
createFileRow('file_campaign', 'summer-campaign.png', 'image/png', 665920, campaignImage, { alt: 'Landscape campaign image with water and greenery', width: 1400, height: 900 }, campaignImage),
|
|
263
|
+
...galleryImages.files,
|
|
264
|
+
];
|
|
265
|
+
const media = [
|
|
266
|
+
root,
|
|
267
|
+
...folders,
|
|
268
|
+
createFileMediaRow('media_hero', '/Images/hero-image.jpg', 'media_images', root.ulid, 'file_hero'),
|
|
269
|
+
createFileMediaRow('media_product', '/Images/product-shot.png', 'media_images', root.ulid, 'file_product'),
|
|
270
|
+
createFileMediaRow('media_guidelines', '/Documents/brand-guidelines.pdf', 'media_documents', root.ulid, 'file_guidelines'),
|
|
271
|
+
createFileMediaRow('media_notes', '/release-notes.txt', root.ulid, root.ulid, 'file_notes'),
|
|
272
|
+
createFileMediaRow('media_campaign_file', '/Images/Campaign/summer-campaign.png', 'media_campaign', root.ulid, 'file_campaign'),
|
|
273
|
+
...galleryImages.media,
|
|
274
|
+
];
|
|
275
|
+
|
|
276
|
+
return { media, files };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Creates paired file and media records for image demo seed data.
|
|
281
|
+
*
|
|
282
|
+
* @param {Array<{ id: string, name: string, photo: string, size: number, alt: string }>} records Seed image descriptors.
|
|
283
|
+
* @param {number} width Requested Unsplash image width.
|
|
284
|
+
* @param {number} height Requested Unsplash image height.
|
|
285
|
+
* @returns {{ files: Array<Record<string, unknown>>, media: Array<Record<string, unknown>> }} Seed file and media records.
|
|
286
|
+
*/
|
|
287
|
+
function createSeedImageRecords(records, width, height) {
|
|
288
|
+
const files = records.map((record) => {
|
|
289
|
+
const imageUrl = unsplashImage(record.photo, width, height);
|
|
290
|
+
return createFileRow(record.id, record.name, 'image/jpeg', record.size, imageUrl, {
|
|
291
|
+
alt: record.alt,
|
|
292
|
+
width,
|
|
293
|
+
height,
|
|
294
|
+
}, imageUrl);
|
|
295
|
+
});
|
|
296
|
+
const media = records.map((record) => createFileMediaRow(
|
|
297
|
+
`media_${record.id.replace(/^file_/, '')}`,
|
|
298
|
+
`/Images/${record.name}`,
|
|
299
|
+
'media_images',
|
|
300
|
+
'media_root_brand',
|
|
301
|
+
record.id,
|
|
302
|
+
));
|
|
303
|
+
|
|
304
|
+
return { files, media };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Creates a folder row for seed data.
|
|
309
|
+
*
|
|
310
|
+
* @param {string} ulid Stable folder id.
|
|
311
|
+
* @param {string} name Folder name.
|
|
312
|
+
* @param {string} rootId Root media id.
|
|
313
|
+
* @param {string} parentId Parent media id.
|
|
314
|
+
* @param {string} parentPath Parent path.
|
|
315
|
+
* @returns {Record<string, unknown>} Media folder row.
|
|
316
|
+
*/
|
|
317
|
+
function createFolderRow(ulid, name, rootId, parentId, parentPath = '/') {
|
|
318
|
+
return {
|
|
319
|
+
ulid,
|
|
320
|
+
type: 'dir',
|
|
321
|
+
path: joinPath(parentPath, name),
|
|
322
|
+
parent_id: parentId,
|
|
323
|
+
root_id: rootId,
|
|
324
|
+
file_uuid: null,
|
|
325
|
+
createdAt: '2026-06-02T10:00:00.000Z',
|
|
326
|
+
updatedAt: '2026-06-02T10:00:00.000Z',
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Creates a file database row for seed data.
|
|
332
|
+
*
|
|
333
|
+
* @param {string} ulid Stable file id.
|
|
334
|
+
* @param {string} name File name.
|
|
335
|
+
* @param {string} mimeType MIME type.
|
|
336
|
+
* @param {number} size File size.
|
|
337
|
+
* @param {string} thumbnailUrl Thumbnail URL.
|
|
338
|
+
* @param {Record<string, unknown>} meta Extra metadata.
|
|
339
|
+
* @param {string} publicUrl Public URL.
|
|
340
|
+
* @returns {Record<string, unknown>} File row.
|
|
341
|
+
*/
|
|
342
|
+
function createFileRow(ulid, name, mimeType, size, thumbnailUrl, meta = {}, publicUrl = '') {
|
|
343
|
+
return {
|
|
344
|
+
ulid,
|
|
345
|
+
name,
|
|
346
|
+
mimeType,
|
|
347
|
+
diskPath: `brand/${ulid}/${name}`,
|
|
348
|
+
size,
|
|
349
|
+
publicUrl: publicUrl || `/media/${ulid}/${name}`,
|
|
350
|
+
thumbnailUrl,
|
|
351
|
+
meta,
|
|
352
|
+
createdAt: '2026-06-03T10:00:00.000Z',
|
|
353
|
+
updatedAt: '2026-06-03T10:00:00.000Z',
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Creates a media row that points to a file database row.
|
|
359
|
+
*
|
|
360
|
+
* @param {string} ulid Stable media id.
|
|
361
|
+
* @param {string} path Media path.
|
|
362
|
+
* @param {string} parentId Parent media id.
|
|
363
|
+
* @param {string} rootId Root media id.
|
|
364
|
+
* @param {string} fileUuid Joined file id.
|
|
365
|
+
* @returns {Record<string, unknown>} File media row.
|
|
366
|
+
*/
|
|
367
|
+
function createFileMediaRow(ulid, path, parentId, rootId, fileUuid) {
|
|
368
|
+
return {
|
|
369
|
+
ulid,
|
|
370
|
+
type: 'file',
|
|
371
|
+
path,
|
|
372
|
+
parent_id: parentId,
|
|
373
|
+
root_id: rootId,
|
|
374
|
+
file_uuid: fileUuid,
|
|
375
|
+
createdAt: '2026-06-03T10:00:00.000Z',
|
|
376
|
+
updatedAt: '2026-06-03T10:00:00.000Z',
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Creates a media and file row from an uploaded browser file record.
|
|
382
|
+
*
|
|
383
|
+
* @param {{ media: Array<Record<string, unknown>>, files: Array<Record<string, unknown>> }} state Mock database state.
|
|
384
|
+
* @param {string} rootId Root media id.
|
|
385
|
+
* @param {Record<string, unknown>} parent Parent media row.
|
|
386
|
+
* @param {Record<string, unknown>} record Upload file record.
|
|
387
|
+
* @returns {Record<string, unknown>} Created media row.
|
|
388
|
+
*/
|
|
389
|
+
function createUploadedFile(state, rootId, parent, record) {
|
|
390
|
+
const pathSegments = Array.isArray(record.pathSegments) && record.pathSegments.length
|
|
391
|
+
? record.pathSegments
|
|
392
|
+
: [record.name];
|
|
393
|
+
const folderSegments = pathSegments.slice(0, -1);
|
|
394
|
+
const filename = pathSegments[pathSegments.length - 1] || record.name || 'upload.bin';
|
|
395
|
+
const targetParent = ensureFolderPath(state, rootId, parent, folderSegments);
|
|
396
|
+
const fileUlid = nextId('file');
|
|
397
|
+
const mediaUlid = record.tempUlid || nextId('media');
|
|
398
|
+
const mimeType = record.file?.type || record.mimeType || inferMimeType(filename);
|
|
399
|
+
const file = {
|
|
400
|
+
ulid: fileUlid,
|
|
401
|
+
name: filename,
|
|
402
|
+
mimeType,
|
|
403
|
+
diskPath: `uploads/${fileUlid}/${filename}`,
|
|
404
|
+
size: record.file?.size || record.size || 0,
|
|
405
|
+
publicUrl: record.previewUrl || `/media/${fileUlid}/${filename}`,
|
|
406
|
+
thumbnailUrl: mimeType.startsWith('image/') ? (record.previewUrl || unsplashImage('photo-1498050108023-c5249f4df0852', 1200, 800)) : '',
|
|
407
|
+
previewUrl: record.previewUrl || '',
|
|
408
|
+
meta: {},
|
|
409
|
+
createdAt: new Date().toISOString(),
|
|
410
|
+
updatedAt: new Date().toISOString(),
|
|
411
|
+
};
|
|
412
|
+
const media = {
|
|
413
|
+
ulid: mediaUlid,
|
|
414
|
+
type: 'file',
|
|
415
|
+
path: joinPath(targetParent.path, filename),
|
|
416
|
+
parent_id: targetParent.ulid,
|
|
417
|
+
root_id: rootId,
|
|
418
|
+
file_uuid: fileUlid,
|
|
419
|
+
createdAt: new Date().toISOString(),
|
|
420
|
+
updatedAt: new Date().toISOString(),
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
state.files.push(file);
|
|
424
|
+
state.media.push(media);
|
|
425
|
+
return media;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Ensures every folder in a relative upload path exists.
|
|
430
|
+
*
|
|
431
|
+
* @param {{ media: Array<Record<string, unknown>> }} state Mock database state.
|
|
432
|
+
* @param {string} rootId Root media id.
|
|
433
|
+
* @param {Record<string, unknown>} parent Starting parent row.
|
|
434
|
+
* @param {Array<string>} segments Folder segments.
|
|
435
|
+
* @returns {Record<string, unknown>} Deepest folder row.
|
|
436
|
+
*/
|
|
437
|
+
function ensureFolderPath(state, rootId, parent, segments) {
|
|
438
|
+
let current = parent;
|
|
439
|
+
for (const segment of segments) {
|
|
440
|
+
const path = joinPath(current.path, segment);
|
|
441
|
+
let folder = state.media.find((item) => item.root_id === rootId && item.path === path && item.type === 'dir');
|
|
442
|
+
if (!folder) {
|
|
443
|
+
folder = {
|
|
444
|
+
ulid: nextId('media'),
|
|
445
|
+
type: 'dir',
|
|
446
|
+
path,
|
|
447
|
+
parent_id: current.ulid,
|
|
448
|
+
root_id: rootId,
|
|
449
|
+
file_uuid: null,
|
|
450
|
+
createdAt: new Date().toISOString(),
|
|
451
|
+
updatedAt: new Date().toISOString(),
|
|
452
|
+
};
|
|
453
|
+
state.media.push(folder);
|
|
454
|
+
}
|
|
455
|
+
current = folder;
|
|
456
|
+
}
|
|
457
|
+
return current;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Finds a folder by root id and path.
|
|
462
|
+
*
|
|
463
|
+
* @param {{ media: Array<Record<string, unknown>> }} state Mock database state.
|
|
464
|
+
* @param {string} rootId Root media id.
|
|
465
|
+
* @param {string} path Folder path.
|
|
466
|
+
* @returns {Record<string, unknown>|null} Folder row or null.
|
|
467
|
+
*/
|
|
468
|
+
function findFolderByPath(state, rootId, path) {
|
|
469
|
+
const normalizedPath = normalizePath(path);
|
|
470
|
+
return state.media.find((item) => (
|
|
471
|
+
item.root_id === rootId
|
|
472
|
+
&& item.path === normalizedPath
|
|
473
|
+
&& ['root', 'dir'].includes(item.type)
|
|
474
|
+
)) || null;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Joins a parent path and display name into the media path contract.
|
|
479
|
+
*
|
|
480
|
+
* @param {string} parentPath Parent path.
|
|
481
|
+
* @param {string} name Child name.
|
|
482
|
+
* @returns {string} Joined path.
|
|
483
|
+
*/
|
|
484
|
+
function joinPath(parentPath, name) {
|
|
485
|
+
const parent = normalizePath(parentPath);
|
|
486
|
+
const child = String(name || '').trim().replace(/^\/+|\/+$/g, '') || 'Untitled';
|
|
487
|
+
return parent === '/' ? `/${child}` : `${parent}/${child}`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Normalizes a path into a slash-prefixed media path.
|
|
492
|
+
*
|
|
493
|
+
* @param {string} value Path-like value.
|
|
494
|
+
* @returns {string} Normalized path.
|
|
495
|
+
*/
|
|
496
|
+
function normalizePath(value) {
|
|
497
|
+
const path = String(value || '/').trim();
|
|
498
|
+
const normalized = (path.startsWith('/') ? path : `/${path}`).replace(/\/+/g, '/').replace(/\/$/, '');
|
|
499
|
+
return normalized || '/';
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Returns a human name from the end of a path.
|
|
504
|
+
*
|
|
505
|
+
* @param {string} path Media path.
|
|
506
|
+
* @returns {string} Path basename.
|
|
507
|
+
*/
|
|
508
|
+
function pathName(path) {
|
|
509
|
+
const segments = String(path || '').split('/').filter(Boolean);
|
|
510
|
+
return segments[segments.length - 1] || 'Root';
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Hydrates a media row with its joined file row and display name.
|
|
515
|
+
*
|
|
516
|
+
* @param {{ files: Array<Record<string, unknown>> }} state Mock database state.
|
|
517
|
+
* @param {Record<string, unknown>} row Media row.
|
|
518
|
+
* @returns {Record<string, unknown>} Hydrated media row.
|
|
519
|
+
*/
|
|
520
|
+
function hydrateMediaItem(state, row) {
|
|
521
|
+
const file = row.file_uuid ? state.files.find((record) => record.ulid === row.file_uuid) : null;
|
|
522
|
+
return {
|
|
523
|
+
...row,
|
|
524
|
+
name: file?.name || pathName(row.path),
|
|
525
|
+
file: file ? { ...file } : null,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Builds breadcrumbs for a folder path.
|
|
531
|
+
*
|
|
532
|
+
* @param {{ media: Array<Record<string, unknown>> }} state Mock database state.
|
|
533
|
+
* @param {string} rootId Root media id.
|
|
534
|
+
* @param {string} path Current folder path.
|
|
535
|
+
* @returns {Array<Record<string, unknown>>} Breadcrumb records.
|
|
536
|
+
*/
|
|
537
|
+
function breadcrumbsForPath(state, rootId, path) {
|
|
538
|
+
const segments = normalizePath(path).split('/').filter(Boolean);
|
|
539
|
+
const breadcrumbs = [{ label: 'Root', path: '/', ulid: rootId }];
|
|
540
|
+
let cursor = '';
|
|
541
|
+
for (const segment of segments) {
|
|
542
|
+
cursor = `${cursor}/${segment}`;
|
|
543
|
+
const folder = findFolderByPath(state, rootId, cursor);
|
|
544
|
+
breadcrumbs.push({
|
|
545
|
+
label: segment,
|
|
546
|
+
path: cursor,
|
|
547
|
+
ulid: folder?.ulid || null,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
return breadcrumbs;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Creates a unique child name under one parent.
|
|
555
|
+
*
|
|
556
|
+
* @param {{ media: Array<Record<string, unknown>>, files: Array<Record<string, unknown>> }} state Mock database state.
|
|
557
|
+
* @param {string} parentId Parent media row id.
|
|
558
|
+
* @param {string} requestedName Desired name.
|
|
559
|
+
* @param {string} ignoredUlid Existing row id to ignore.
|
|
560
|
+
* @returns {string} Unique child name.
|
|
561
|
+
*/
|
|
562
|
+
function uniqueChildName(state, parentId, requestedName, ignoredUlid = '') {
|
|
563
|
+
const base = String(requestedName || 'Untitled').trim() || 'Untitled';
|
|
564
|
+
const names = new Set(state.media
|
|
565
|
+
.filter((item) => item.parent_id === parentId && item.ulid !== ignoredUlid)
|
|
566
|
+
.map((item) => hydrateMediaItem(state, item).name.toLowerCase()));
|
|
567
|
+
if (!names.has(base.toLowerCase())) return base;
|
|
568
|
+
|
|
569
|
+
let index = 2;
|
|
570
|
+
while (names.has(`${base} ${index}`.toLowerCase())) index += 1;
|
|
571
|
+
return `${base} ${index}`;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Rewrites descendant paths after a folder path changes.
|
|
576
|
+
*
|
|
577
|
+
* @param {{ media: Array<Record<string, unknown>> }} state Mock database state.
|
|
578
|
+
* @param {Record<string, unknown>} folder Renamed or moved folder.
|
|
579
|
+
* @param {string} previousPath Folder path before the update.
|
|
580
|
+
*/
|
|
581
|
+
function updateDescendantPaths(state, folder, previousPath) {
|
|
582
|
+
const oldPrefix = `${previousPath}/`;
|
|
583
|
+
const newPrefix = `${folder.path}/`;
|
|
584
|
+
for (const item of state.media) {
|
|
585
|
+
if (!String(item.path).startsWith(oldPrefix)) continue;
|
|
586
|
+
item.path = `${newPrefix}${String(item.path).slice(oldPrefix.length)}`;
|
|
587
|
+
item.updatedAt = new Date().toISOString();
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Sorts folders before files, then by name.
|
|
593
|
+
*
|
|
594
|
+
* @param {Record<string, unknown>} a Left media row.
|
|
595
|
+
* @param {Record<string, unknown>} b Right media row.
|
|
596
|
+
* @returns {number} Sort comparison.
|
|
597
|
+
*/
|
|
598
|
+
function compareMediaRows(a, b) {
|
|
599
|
+
if (a.type !== b.type) {
|
|
600
|
+
if (a.type === 'dir' || a.type === 'root') return -1;
|
|
601
|
+
if (b.type === 'dir' || b.type === 'root') return 1;
|
|
602
|
+
}
|
|
603
|
+
return String(a.name || '').localeCompare(String(b.name || ''), undefined, { sensitivity: 'base' });
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Infers a MIME type for a demo upload when the browser does not provide one.
|
|
608
|
+
*
|
|
609
|
+
* @param {string} filename Uploaded filename.
|
|
610
|
+
* @returns {string} MIME type.
|
|
611
|
+
*/
|
|
612
|
+
function inferMimeType(filename) {
|
|
613
|
+
const lower = String(filename || '').toLowerCase();
|
|
614
|
+
if (/\.(png|jpg|jpeg|gif|webp|svg)$/.test(lower)) return 'image/png';
|
|
615
|
+
if (lower.endsWith('.pdf')) return 'application/pdf';
|
|
616
|
+
if (lower.endsWith('.txt')) return 'text/plain';
|
|
617
|
+
return 'application/octet-stream';
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Generates a stable local-ish id for demo rows.
|
|
622
|
+
*
|
|
623
|
+
* @param {string} prefix Id prefix.
|
|
624
|
+
* @returns {string} Generated id.
|
|
625
|
+
*/
|
|
626
|
+
function nextId(prefix) {
|
|
627
|
+
idCounter += 1;
|
|
628
|
+
return `${prefix}_${idCounter.toString(36)}`;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Builds a stable Unsplash image URL for demo media rows.
|
|
633
|
+
*
|
|
634
|
+
* @param {string} id Unsplash photo id.
|
|
635
|
+
* @param {number} width Requested image width.
|
|
636
|
+
* @param {number} height Requested image height.
|
|
637
|
+
* @returns {string} Unsplash image URL.
|
|
638
|
+
*/
|
|
639
|
+
function unsplashImage(id, width, height) {
|
|
640
|
+
return `https://images.unsplash.com/${id}?auto=format&fit=crop&w=${width}&h=${height}&q=80`;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Resolves after a small timeout to emulate network latency.
|
|
645
|
+
*
|
|
646
|
+
* @param {number} ms Delay in milliseconds.
|
|
647
|
+
* @returns {Promise<void>} Delay promise.
|
|
648
|
+
*/
|
|
649
|
+
function wait(ms) {
|
|
650
|
+
return new Promise((resolve) => {
|
|
651
|
+
window.setTimeout(resolve, ms);
|
|
652
|
+
});
|
|
653
|
+
}
|