@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,2344 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
/**
|
|
3
|
+
* @typedef {object} MediaBrowserFileRecord
|
|
4
|
+
* @property {string} ulid Stable file row id.
|
|
5
|
+
* @property {string} name Display filename.
|
|
6
|
+
* @property {string} mimeType Browser or server MIME type.
|
|
7
|
+
* @property {string} [diskPath] Storage adapter path.
|
|
8
|
+
* @property {number} [size] File size in bytes.
|
|
9
|
+
* @property {string} [publicUrl] Public URL when the file is externally readable.
|
|
10
|
+
* @property {string} [thumbnailUrl] Thumbnail URL for previewable media.
|
|
11
|
+
* @property {string} [previewUrl] Temporary browser preview URL while a file is uploading.
|
|
12
|
+
* @property {Record<string, unknown>} [meta] Extra file metadata such as dimensions, alt text, or duration.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} MediaBrowserItem
|
|
17
|
+
* @property {string} ulid Stable media row id.
|
|
18
|
+
* @property {'root'|'dir'|'file'} type Media row type.
|
|
19
|
+
* @property {string} path Slash-prefixed path within the active root.
|
|
20
|
+
* @property {string|null} [parent_id] Parent media row id.
|
|
21
|
+
* @property {string|null} [file_uuid] File row id for file media rows.
|
|
22
|
+
* @property {string} [name] Optional display name override.
|
|
23
|
+
* @property {MediaBrowserFileRecord} [file] Joined file row when type is file.
|
|
24
|
+
* @property {'idle'|'saving'|'uploading'|'complete'|'error'} [status] Optimistic sync or upload status.
|
|
25
|
+
* @property {number} [uploadProgress] Upload percentage from 0 to 100.
|
|
26
|
+
* @property {string} [temporaryId] Client id used before the server confirms a permanent row.
|
|
27
|
+
* @property {Record<string, unknown>} [meta] Media-row metadata.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {object} MediaBrowserBreadcrumb
|
|
32
|
+
* @property {string} label Text rendered in the breadcrumb button.
|
|
33
|
+
* @property {string} path Slash-prefixed folder path.
|
|
34
|
+
* @property {string|null} [ulid] Folder media row id when known.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @typedef {object} MediaBrowserDroppedFile
|
|
39
|
+
* @property {File} file Browser File object.
|
|
40
|
+
* @property {string} name File name.
|
|
41
|
+
* @property {string} relativePath Path relative to the dropped folder, when available.
|
|
42
|
+
* @property {Array<string>} pathSegments Relative path split into folder and file segments.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @typedef {object} MediaBrowserItemPayload
|
|
47
|
+
* @property {MediaBrowserItem} item Item associated with the event.
|
|
48
|
+
* @property {MouseEvent|KeyboardEvent|DragEvent} event Native event.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @typedef {object} MediaBrowserSelectionPayload
|
|
53
|
+
* @property {Array<string>} selectedKeys Selected media row ids.
|
|
54
|
+
* @property {Array<MediaBrowserItem>} selectedItems Selected media rows.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @typedef {object} MediaBrowserMovePayload
|
|
59
|
+
* @property {Array<MediaBrowserItem>} items Media rows being moved.
|
|
60
|
+
* @property {MediaBrowserItem} target Folder receiving the rows.
|
|
61
|
+
* @property {DragEvent} event Native drop event.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @typedef {object} MediaBrowserUploadPayload
|
|
66
|
+
* @property {Array<MediaBrowserDroppedFile>} files Browser files selected through the upload button or dropped onto the browser.
|
|
67
|
+
* @property {MediaBrowserItem|null} target Folder that received the drop, or null for the current folder.
|
|
68
|
+
* @property {string} path Current folder path.
|
|
69
|
+
* @property {Event} event Native file input or drop event.
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @typedef {object} MediaBrowserDetailPayload
|
|
74
|
+
* @property {MediaBrowserItem} item Media row opened in detail mode.
|
|
75
|
+
* @property {'folder'|'image'|'pdf'|'default'} kind Resolved renderer kind.
|
|
76
|
+
* @property {'dialog'|'inline'} mode Detail placement.
|
|
77
|
+
* @property {MouseEvent|KeyboardEvent|null} event Native activation event when available.
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
export const mediaBrowserProps = {
|
|
81
|
+
items: {
|
|
82
|
+
type: Array,
|
|
83
|
+
default: () => [],
|
|
84
|
+
_edit: { description: 'Media rows for the current folder. Rows usually come from the Pinia store after a folder API request.' },
|
|
85
|
+
},
|
|
86
|
+
breadcrumbs: {
|
|
87
|
+
type: Array,
|
|
88
|
+
default: () => [],
|
|
89
|
+
_edit: { description: 'Clickable folder path segments. When omitted, the component derives labels from currentPath.' },
|
|
90
|
+
},
|
|
91
|
+
currentPath: {
|
|
92
|
+
type: String,
|
|
93
|
+
default: '/',
|
|
94
|
+
_edit: { description: 'Slash-prefixed folder path currently being rendered.' },
|
|
95
|
+
},
|
|
96
|
+
selectedKeys: {
|
|
97
|
+
type: Array,
|
|
98
|
+
default: () => [],
|
|
99
|
+
_edit: { description: 'Controlled media row ids selected in the browser.' },
|
|
100
|
+
},
|
|
101
|
+
mode: {
|
|
102
|
+
type: String,
|
|
103
|
+
default: 'grid',
|
|
104
|
+
validator: (value) => ['grid', 'list'].includes(value),
|
|
105
|
+
_edit: { options: ['grid', 'list'], description: 'Visual display mode.' },
|
|
106
|
+
},
|
|
107
|
+
loading: {
|
|
108
|
+
type: Boolean,
|
|
109
|
+
default: false,
|
|
110
|
+
_edit: { description: 'Show the current folder as loading while the store waits for the API.' },
|
|
111
|
+
},
|
|
112
|
+
toolbar: {
|
|
113
|
+
type: Boolean,
|
|
114
|
+
default: true,
|
|
115
|
+
_edit: { description: 'Render the built-in breadcrumb and action toolbar.' },
|
|
116
|
+
},
|
|
117
|
+
title: {
|
|
118
|
+
type: String,
|
|
119
|
+
default: 'Media browser',
|
|
120
|
+
_edit: { description: 'Accessible title for the browser surface.' },
|
|
121
|
+
},
|
|
122
|
+
emptyText: {
|
|
123
|
+
type: String,
|
|
124
|
+
default: 'Drop files here or create a folder to start this library.',
|
|
125
|
+
_edit: { description: 'Message shown when the current folder has no media rows.' },
|
|
126
|
+
},
|
|
127
|
+
inspector: {
|
|
128
|
+
type: Boolean,
|
|
129
|
+
default: true,
|
|
130
|
+
_edit: { description: 'Show an inspector panel for the active selection.' },
|
|
131
|
+
},
|
|
132
|
+
canUpload: {
|
|
133
|
+
type: Boolean,
|
|
134
|
+
default: true,
|
|
135
|
+
_edit: { description: 'Show the upload affordance, allow files to be selected, and accept external file or folder drops.' },
|
|
136
|
+
},
|
|
137
|
+
canCreateFolder: {
|
|
138
|
+
type: Boolean,
|
|
139
|
+
default: true,
|
|
140
|
+
_edit: { description: 'Show create-folder actions and emit create-folder events.' },
|
|
141
|
+
},
|
|
142
|
+
canRename: {
|
|
143
|
+
type: Boolean,
|
|
144
|
+
default: true,
|
|
145
|
+
_edit: { description: 'Allow selected rows to enter inline rename mode.' },
|
|
146
|
+
},
|
|
147
|
+
canMove: {
|
|
148
|
+
type: Boolean,
|
|
149
|
+
default: true,
|
|
150
|
+
_edit: { description: 'Allow internal rows to be dragged onto folders.' },
|
|
151
|
+
},
|
|
152
|
+
rubberBand: {
|
|
153
|
+
type: Boolean,
|
|
154
|
+
default: true,
|
|
155
|
+
_edit: { description: 'Allow click-drag rectangle selection across visible rows.' },
|
|
156
|
+
},
|
|
157
|
+
selectionMode: {
|
|
158
|
+
type: String,
|
|
159
|
+
default: 'multiple',
|
|
160
|
+
validator: (value) => ['single', 'multiple'].includes(value),
|
|
161
|
+
_edit: { options: ['single', 'multiple'], description: 'Selection behavior for picker and library workflows.' },
|
|
162
|
+
},
|
|
163
|
+
selectableTypes: {
|
|
164
|
+
type: Array,
|
|
165
|
+
default: () => ['dir', 'file'],
|
|
166
|
+
_edit: { description: 'Media row types that can be selected. Use ["file"] with accept="image/*" for an image picker.' },
|
|
167
|
+
},
|
|
168
|
+
accept: {
|
|
169
|
+
type: [String, Array],
|
|
170
|
+
default: '',
|
|
171
|
+
_edit: { description: 'Accepted file MIME patterns or extensions for file selection, such as image/* or .pdf.' },
|
|
172
|
+
},
|
|
173
|
+
itemComponents: {
|
|
174
|
+
type: Object,
|
|
175
|
+
default: () => ({}),
|
|
176
|
+
_edit: { description: 'Component map keyed by folder, image, pdf, or default for custom item renderers.' },
|
|
177
|
+
},
|
|
178
|
+
inspectorComponents: {
|
|
179
|
+
type: Object,
|
|
180
|
+
default: () => ({}),
|
|
181
|
+
_edit: { description: 'Component map keyed by folder, image, pdf, or default for custom inspector renderers.' },
|
|
182
|
+
},
|
|
183
|
+
detailComponents: {
|
|
184
|
+
type: Object,
|
|
185
|
+
default: () => ({}),
|
|
186
|
+
_edit: { description: 'Component map keyed by folder, image, pdf, or default. Values may be a component or { component, mode }.' },
|
|
187
|
+
},
|
|
188
|
+
detailMode: {
|
|
189
|
+
type: String,
|
|
190
|
+
default: 'dialog',
|
|
191
|
+
validator: (value) => ['dialog', 'inline'].includes(value),
|
|
192
|
+
_edit: { options: ['dialog', 'inline'], description: 'Default placement when a detail component does not define its own mode.' },
|
|
193
|
+
},
|
|
194
|
+
renameDelay: {
|
|
195
|
+
type: Number,
|
|
196
|
+
default: 260,
|
|
197
|
+
_edit: { description: 'Delay in milliseconds before a selected filename click enters rename mode, leaving double-click open intact.' },
|
|
198
|
+
},
|
|
199
|
+
uploadProgress: {
|
|
200
|
+
type: Number,
|
|
201
|
+
default: null,
|
|
202
|
+
_edit: { description: 'Overall upload progress. When omitted, progress is inferred from uploading rows.' },
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
</script>
|
|
206
|
+
|
|
207
|
+
<script setup>
|
|
208
|
+
import { computed, markRaw, nextTick, onBeforeUnmount, ref, useId, watch } from 'vue';
|
|
209
|
+
import DomButton from '../../components/button/DomButton.vue';
|
|
210
|
+
import DomContextMenu from '../../components/context-menu/DomContextMenu.vue';
|
|
211
|
+
import DomDialog from '../../components/dialog/DomDialog.vue';
|
|
212
|
+
import DomEmptyState from '../../visual/empty-state/DomEmptyState.vue';
|
|
213
|
+
import DomProgress from '../../visual/progress/DomProgress.vue';
|
|
214
|
+
import DomRadialProgress from '../../visual/progress/DomRadialProgress.vue';
|
|
215
|
+
import DomSkeleton from '../../visual/skeleton/DomSkeleton.vue';
|
|
216
|
+
import DomToggleButtonGroup from '../../forms/toggle-button-group/DomToggleButtonGroup.vue';
|
|
217
|
+
|
|
218
|
+
defineOptions({
|
|
219
|
+
__doc: {
|
|
220
|
+
name: 'Media browser',
|
|
221
|
+
tag: '<DomMediaBrowser>',
|
|
222
|
+
description: 'A folder-aware media library surface for server-backed files, optimistic Pinia stores, custom file renderers, drag and drop, upload progress, and picker workflows.',
|
|
223
|
+
icon: 'M4 7h6l2 2h8v9H4V7Zm3 6h10M8 16h4',
|
|
224
|
+
events: [
|
|
225
|
+
{ name: 'update:selectedKeys', payload: 'Array<string>', description: 'Fired when the user changes the selected media rows.' },
|
|
226
|
+
{ name: 'update:mode', payload: '\'grid\' | \'list\'', description: 'Fired when the user switches between grid and list layout.' },
|
|
227
|
+
{ name: 'selection-change', payload: 'MediaBrowserSelectionPayload', typeRef: 'MediaBrowserSelectionPayload', description: 'Fired with selected keys and resolved rows.' },
|
|
228
|
+
{ name: 'navigate', payload: '({ path, item, breadcrumb, event })', description: 'Fired when the user opens a folder or breadcrumb.' },
|
|
229
|
+
{ name: 'create-folder', payload: '({ path })', description: 'Fired when the built-in new-folder action is activated.' },
|
|
230
|
+
{ name: 'rename', payload: '({ item, name })', description: 'Fired after inline rename is committed.' },
|
|
231
|
+
{ name: 'move', payload: 'MediaBrowserMovePayload', typeRef: 'MediaBrowserMovePayload', description: 'Fired when one or more selected rows are dropped onto a folder.' },
|
|
232
|
+
{ name: 'upload', payload: 'MediaBrowserUploadPayload', typeRef: 'MediaBrowserUploadPayload', description: 'Fired when files are selected through the upload button or files and folders are dropped onto the current browser or a folder.' },
|
|
233
|
+
{ name: 'metadata-update', payload: '({ item, meta })', description: 'Fired when the default inspector commits editable metadata such as image alt text.' },
|
|
234
|
+
{ name: 'make-folder-with-items', payload: '({ items, path })', description: 'Fired from the context menu when the selection should be grouped into a new folder.' },
|
|
235
|
+
{ name: 'context-action', payload: '({ action, items, path })', description: 'Fired for context menu actions after built-in handling.' },
|
|
236
|
+
{ name: 'detail-open', payload: 'MediaBrowserDetailPayload', typeRef: 'MediaBrowserDetailPayload', description: 'Fired when a row with a detail component opens in dialog or inline mode.' },
|
|
237
|
+
{ name: 'detail-close', payload: 'MediaBrowserDetailPayload', typeRef: 'MediaBrowserDetailPayload', description: 'Fired when the current detail component closes.' },
|
|
238
|
+
{ name: 'open', payload: 'MediaBrowserItemPayload', typeRef: 'MediaBrowserItemPayload', description: 'Fired when a non-folder row is opened.' },
|
|
239
|
+
{ name: 'item-click', payload: 'MediaBrowserItemPayload', typeRef: 'MediaBrowserItemPayload', description: 'Fired before selection handling when a visible row is clicked.' },
|
|
240
|
+
],
|
|
241
|
+
slots: [
|
|
242
|
+
{ name: 'toolbar', payload: '{ selectedItems, selectedKeys, mode, currentPath, setMode, viewModeOptions }', description: 'Replace or extend the toolbar for detached picker controls.' },
|
|
243
|
+
{ name: 'item', payload: '{ item, kind, selected, disabled, mode, rename }', description: 'Replace the full item body.' },
|
|
244
|
+
{ name: 'inspector', payload: '{ item, kind, file, selectedItems }', description: 'Replace the inspector panel body.' },
|
|
245
|
+
{ name: 'detail', payload: '{ item, kind, file, mode, close }', description: 'Replace a detail component body for dialog or inline rendering.' },
|
|
246
|
+
{ name: 'empty', description: 'Custom empty-folder state.' },
|
|
247
|
+
],
|
|
248
|
+
keyboard: [
|
|
249
|
+
{ keys: 'Enter on folder', action: 'Navigate into the folder.' },
|
|
250
|
+
{ keys: 'Enter / Space on file', action: 'Select the file.' },
|
|
251
|
+
{ keys: 'F2 on selected row', action: 'Start inline rename.' },
|
|
252
|
+
{ keys: 'Enter in rename input', action: 'Commit the rename.' },
|
|
253
|
+
{ keys: 'Esc in rename input', action: 'Cancel the rename.' },
|
|
254
|
+
],
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const props = defineProps(mediaBrowserProps);
|
|
259
|
+
const emit = defineEmits([
|
|
260
|
+
'update:selectedKeys',
|
|
261
|
+
'update:mode',
|
|
262
|
+
'selection-change',
|
|
263
|
+
'navigate',
|
|
264
|
+
'create-folder',
|
|
265
|
+
'rename',
|
|
266
|
+
'move',
|
|
267
|
+
'upload',
|
|
268
|
+
'metadata-update',
|
|
269
|
+
'make-folder-with-items',
|
|
270
|
+
'context-action',
|
|
271
|
+
'detail-open',
|
|
272
|
+
'detail-close',
|
|
273
|
+
'open',
|
|
274
|
+
'item-click',
|
|
275
|
+
]);
|
|
276
|
+
|
|
277
|
+
const titleId = `dom-media-browser-${useId()}`;
|
|
278
|
+
const viewport = ref(null);
|
|
279
|
+
const uploadInput = ref(null);
|
|
280
|
+
const renamingKey = ref('');
|
|
281
|
+
const renameDraft = ref('');
|
|
282
|
+
const dragTargetKey = ref('');
|
|
283
|
+
const breadcrumbDropPath = ref('');
|
|
284
|
+
const dropTargetMode = ref('');
|
|
285
|
+
const draggingKeys = ref([]);
|
|
286
|
+
const externalDropActive = ref(false);
|
|
287
|
+
const bandSelection = ref(null);
|
|
288
|
+
const inspectorDraft = ref({});
|
|
289
|
+
const inlineDetailKey = ref('');
|
|
290
|
+
const dialogDetailKey = ref('');
|
|
291
|
+
const detailDialogOpen = ref(false);
|
|
292
|
+
const dragDataType = 'application/x-dom-media-browser-items';
|
|
293
|
+
const viewModeOptions = [
|
|
294
|
+
{
|
|
295
|
+
label: 'List',
|
|
296
|
+
value: 'list',
|
|
297
|
+
tooltip: 'List view',
|
|
298
|
+
ariaLabel: 'List view',
|
|
299
|
+
icon: 'M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01',
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
label: 'Grid',
|
|
303
|
+
value: 'grid',
|
|
304
|
+
tooltip: 'Grid view',
|
|
305
|
+
ariaLabel: 'Grid view',
|
|
306
|
+
icon: 'M7 7h4v4H7V7Zm6 0h4v4h-4V7ZM7 13h4v4H7v-4Zm6 0h4v4h-4v-4Z',
|
|
307
|
+
},
|
|
308
|
+
];
|
|
309
|
+
const folderIconPaths = {
|
|
310
|
+
back: 'M8 22c0-4.4 3.6-8 8-8h21.6c3.3 0 5.2 1.1 7.6 3.6l5 5.2H80c4.4 0 8 3.6 8 8v4H8V22Z',
|
|
311
|
+
front: 'M7.3 33.2c-.7-5 3.2-9.2 8.2-9.2H37c3.2 0 4.9 1.1 7.1 3.3l3.4 3.4c1.8 1.8 3.5 2.7 6.1 2.7h27c5.2 0 9.1 4.7 8.1 9.8l-5 25.8c-.8 3.9-4.2 6.8-8.2 6.8H20.7c-4.1 0-7.6-3-8.2-7.1L7.3 33.2Z',
|
|
312
|
+
shadow: 'M22 75c13.2 4 40.7 4 54 0',
|
|
313
|
+
};
|
|
314
|
+
let renameClickTimer = null;
|
|
315
|
+
let previousUserSelect = '';
|
|
316
|
+
|
|
317
|
+
const sourceItems = computed(() => Array.isArray(props.items) ? props.items : []);
|
|
318
|
+
const selectedKeySet = computed(() => new Set(props.selectedKeys.map(String)));
|
|
319
|
+
const selectedItems = computed(() => sourceItems.value.filter((item) => selectedKeySet.value.has(itemKey(item))));
|
|
320
|
+
const draggingKeySet = computed(() => new Set(draggingKeys.value.map(String)));
|
|
321
|
+
const draggingCount = computed(() => draggingKeys.value.length);
|
|
322
|
+
const activeItem = computed(() => selectedItems.value[0] || null);
|
|
323
|
+
const currentPath = computed(() => normalizeFolderPath(props.currentPath));
|
|
324
|
+
const resolvedBreadcrumbs = computed(getResolvedBreadcrumbs);
|
|
325
|
+
const renderMode = computed(() => props.mode === 'list' ? 'list' : 'grid');
|
|
326
|
+
const uploadingItems = computed(() => sourceItems.value.filter((item) => isUploading(item)));
|
|
327
|
+
const inferredUploadProgress = computed(() => averageProgress(uploadingItems.value));
|
|
328
|
+
const overallUploadProgress = computed(() => {
|
|
329
|
+
const value = Number(props.uploadProgress);
|
|
330
|
+
return Number.isFinite(value) ? clampProgress(value) : inferredUploadProgress.value;
|
|
331
|
+
});
|
|
332
|
+
const hasUploadProgress = computed(() => uploadingItems.value.length > 0 || overallUploadProgress.value > 0);
|
|
333
|
+
const showEmptyLoading = computed(() => props.loading && sourceItems.value.length === 0);
|
|
334
|
+
const loadingLabel = computed(() => (sourceItems.value.length ? 'Refreshing folder' : 'Loading folder'));
|
|
335
|
+
const inlineDetailItem = computed(() => findItemByKey(inlineDetailKey.value));
|
|
336
|
+
const inlineDetailConfig = computed(() => detailConfigFor(inlineDetailItem.value));
|
|
337
|
+
const dialogDetailItem = computed(() => findItemByKey(dialogDetailKey.value));
|
|
338
|
+
const dialogDetailConfig = computed(() => detailConfigFor(dialogDetailItem.value));
|
|
339
|
+
const dialogDetailTitle = computed(() => detailTitle(dialogDetailItem.value, dialogDetailConfig.value));
|
|
340
|
+
const uploadAccept = computed(() => Array.isArray(props.accept) ? props.accept.join(',') : props.accept);
|
|
341
|
+
const hasContextMenuActions = computed(() => props.canCreateFolder || props.canRename);
|
|
342
|
+
const selectionSummary = computed(() => {
|
|
343
|
+
const count = selectedItems.value.length;
|
|
344
|
+
if (!count) return 'No items selected';
|
|
345
|
+
if (count === 1) return `${itemName(selectedItems.value[0])} selected`;
|
|
346
|
+
return `${count} items selected`;
|
|
347
|
+
});
|
|
348
|
+
const contextMenuItems = computed(() => {
|
|
349
|
+
const items = [];
|
|
350
|
+
|
|
351
|
+
if (props.canCreateFolder) {
|
|
352
|
+
items.push({ label: 'New folder', value: 'new-folder' });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (props.canRename) {
|
|
356
|
+
items.push({ label: 'Rename', value: 'rename', disabled: selectedItems.value.length !== 1 });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (props.canCreateFolder) {
|
|
360
|
+
items.push({ label: 'Make new folder with items...', value: 'folder-from-selection', disabled: selectedItems.value.length === 0 });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (items.length) items.push({ separator: true });
|
|
364
|
+
|
|
365
|
+
items.push(
|
|
366
|
+
{ label: 'Grid view', value: 'mode-grid', type: 'radio', checked: renderMode.value === 'grid' },
|
|
367
|
+
{ label: 'List view', value: 'mode-list', type: 'radio', checked: renderMode.value === 'list' },
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
return items;
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
watch(activeItem, (item) => {
|
|
374
|
+
inspectorDraft.value = {
|
|
375
|
+
alt: imageAltText(item),
|
|
376
|
+
};
|
|
377
|
+
}, { immediate: true });
|
|
378
|
+
|
|
379
|
+
watch(currentPath, clearInlineDetail);
|
|
380
|
+
|
|
381
|
+
onBeforeUnmount(() => {
|
|
382
|
+
clearRenameTimer();
|
|
383
|
+
removeBandListeners();
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Resolves breadcrumbs and appends the active inline detail item when present.
|
|
388
|
+
*
|
|
389
|
+
* @returns {Array<MediaBrowserBreadcrumb>} Breadcrumbs rendered in the toolbar.
|
|
390
|
+
*/
|
|
391
|
+
function getResolvedBreadcrumbs() {
|
|
392
|
+
const breadcrumbs = normalizeBreadcrumbs(props.breadcrumbs, currentPath.value);
|
|
393
|
+
if (!inlineDetailItem.value) return breadcrumbs;
|
|
394
|
+
return [
|
|
395
|
+
...breadcrumbs,
|
|
396
|
+
{
|
|
397
|
+
label: itemName(inlineDetailItem.value),
|
|
398
|
+
path: inlineDetailPath(inlineDetailItem.value),
|
|
399
|
+
ulid: itemKey(inlineDetailItem.value),
|
|
400
|
+
detail: true,
|
|
401
|
+
},
|
|
402
|
+
];
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Finds a visible item by its stable key.
|
|
407
|
+
*
|
|
408
|
+
* @param {string} key Media row key.
|
|
409
|
+
* @returns {MediaBrowserItem|null} Matching visible item.
|
|
410
|
+
*/
|
|
411
|
+
function findItemByKey(key) {
|
|
412
|
+
if (!key) return null;
|
|
413
|
+
return sourceItems.value.find((item) => itemKey(item) === String(key)) || null;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Returns a stable string key for a media row.
|
|
418
|
+
*
|
|
419
|
+
* @param {MediaBrowserItem} item Media row to identify.
|
|
420
|
+
* @returns {string} Stable key used for selection, dragging, and DOM attributes.
|
|
421
|
+
*/
|
|
422
|
+
function itemKey(item) {
|
|
423
|
+
return String(item?.ulid ?? item?.temporaryId ?? '');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Resolves the display name for a media row.
|
|
428
|
+
*
|
|
429
|
+
* @param {MediaBrowserItem} item Media row to label.
|
|
430
|
+
* @returns {string} Human-readable name.
|
|
431
|
+
*/
|
|
432
|
+
function itemName(item) {
|
|
433
|
+
if (!item) return '';
|
|
434
|
+
return item.name || item.file?.name || pathBasename(item.path) || (item.type === 'root' ? 'Root' : 'Untitled');
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Returns the file record associated with a media row.
|
|
439
|
+
*
|
|
440
|
+
* @param {MediaBrowserItem} item Media row that may join a file record.
|
|
441
|
+
* @returns {MediaBrowserFileRecord|null} Joined file row or null.
|
|
442
|
+
*/
|
|
443
|
+
function itemFile(item) {
|
|
444
|
+
return item?.file && typeof item.file === 'object' ? item.file : null;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Resolves the MIME type for a media row.
|
|
449
|
+
*
|
|
450
|
+
* @param {MediaBrowserItem} item Media row that may be backed by a file.
|
|
451
|
+
* @returns {string} MIME type or an empty string.
|
|
452
|
+
*/
|
|
453
|
+
function itemMime(item) {
|
|
454
|
+
return String(itemFile(item)?.mimeType || item?.mimeType || item?.mime_type || '').toLowerCase();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Resolves the item renderer bucket for built-in and custom components.
|
|
459
|
+
*
|
|
460
|
+
* @param {MediaBrowserItem} item Media row to classify.
|
|
461
|
+
* @returns {'folder'|'image'|'pdf'|'default'} Renderer bucket.
|
|
462
|
+
*/
|
|
463
|
+
function itemKind(item) {
|
|
464
|
+
if (item?.type === 'root' || item?.type === 'dir') return 'folder';
|
|
465
|
+
const mime = itemMime(item);
|
|
466
|
+
if (mime.startsWith('image/')) return 'image';
|
|
467
|
+
if (mime === 'application/pdf' || mime.endsWith('/pdf')) return 'pdf';
|
|
468
|
+
return 'default';
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Resolves a custom item component for a media row.
|
|
473
|
+
*
|
|
474
|
+
* @param {MediaBrowserItem} item Media row to render.
|
|
475
|
+
* @returns {unknown} Vue component or null.
|
|
476
|
+
*/
|
|
477
|
+
function itemComponentFor(item) {
|
|
478
|
+
const components = props.itemComponents || {};
|
|
479
|
+
const component = components[itemKind(item)] || components.default || null;
|
|
480
|
+
return component ? markRaw(component) : null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Resolves a custom inspector component for a media row.
|
|
485
|
+
*
|
|
486
|
+
* @param {MediaBrowserItem} item Media row to inspect.
|
|
487
|
+
* @returns {unknown} Vue component or null.
|
|
488
|
+
*/
|
|
489
|
+
function inspectorComponentFor(item) {
|
|
490
|
+
const components = props.inspectorComponents || {};
|
|
491
|
+
const component = components[itemKind(item)] || components.default || null;
|
|
492
|
+
return component ? markRaw(component) : null;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Resolves a detail component config for a media row.
|
|
497
|
+
*
|
|
498
|
+
* @param {MediaBrowserItem} item Media row to open.
|
|
499
|
+
* @returns {Record<string, unknown>|null} Detail config or null.
|
|
500
|
+
*/
|
|
501
|
+
function detailConfigFor(item) {
|
|
502
|
+
if (!item || itemKind(item) === 'folder') return null;
|
|
503
|
+
const components = props.detailComponents || {};
|
|
504
|
+
const entry = components[itemKind(item)] || components.default || null;
|
|
505
|
+
if (!entry) return null;
|
|
506
|
+
if (isDetailConfig(entry)) {
|
|
507
|
+
const component = entry.component || null;
|
|
508
|
+
return {
|
|
509
|
+
...entry,
|
|
510
|
+
component: rawComponent(component),
|
|
511
|
+
mode: normalizeDetailMode(entry.mode || props.detailMode),
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
return {
|
|
515
|
+
component: rawComponent(entry),
|
|
516
|
+
mode: normalizeDetailMode(props.detailMode),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Marks object-based Vue components as raw while leaving string component names alone.
|
|
522
|
+
*
|
|
523
|
+
* @param {unknown} component Detail component candidate.
|
|
524
|
+
* @returns {unknown} Component value suitable for Vue's dynamic component.
|
|
525
|
+
*/
|
|
526
|
+
function rawComponent(component) {
|
|
527
|
+
return component && typeof component === 'object' ? markRaw(component) : component;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Checks whether a detail map entry is a config object rather than a component.
|
|
532
|
+
*
|
|
533
|
+
* @param {unknown} value Detail map value.
|
|
534
|
+
* @returns {boolean} True when the value looks like a detail config.
|
|
535
|
+
*/
|
|
536
|
+
function isDetailConfig(value) {
|
|
537
|
+
return Boolean(
|
|
538
|
+
value
|
|
539
|
+
&& typeof value === 'object'
|
|
540
|
+
&& (
|
|
541
|
+
hasOwn(value, 'component')
|
|
542
|
+
|| hasOwn(value, 'mode')
|
|
543
|
+
|| hasOwn(value, 'title')
|
|
544
|
+
|| hasOwn(value, 'size')
|
|
545
|
+
|| hasOwn(value, 'width')
|
|
546
|
+
|| hasOwn(value, 'height')
|
|
547
|
+
),
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Checks whether an object owns a key.
|
|
553
|
+
*
|
|
554
|
+
* @param {object} value Object to inspect.
|
|
555
|
+
* @param {string} key Property key.
|
|
556
|
+
* @returns {boolean} True when the object owns the key.
|
|
557
|
+
*/
|
|
558
|
+
function hasOwn(value, key) {
|
|
559
|
+
return Object.prototype.hasOwnProperty.call(value, key);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Normalizes a detail placement value.
|
|
564
|
+
*
|
|
565
|
+
* @param {unknown} mode Configured detail mode.
|
|
566
|
+
* @returns {'dialog'|'inline'} Supported detail mode.
|
|
567
|
+
*/
|
|
568
|
+
function normalizeDetailMode(mode) {
|
|
569
|
+
return mode === 'inline' ? 'inline' : 'dialog';
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Resolves the title for the dialog detail surface.
|
|
574
|
+
*
|
|
575
|
+
* @param {MediaBrowserItem|null} item Detail item.
|
|
576
|
+
* @param {Record<string, unknown>|null} config Detail config.
|
|
577
|
+
* @returns {string} Dialog title.
|
|
578
|
+
*/
|
|
579
|
+
function detailTitle(item, config) {
|
|
580
|
+
if (!item) return '';
|
|
581
|
+
if (typeof config?.title === 'function') return String(config.title(item) || '');
|
|
582
|
+
if (typeof config?.title === 'string') return config.title;
|
|
583
|
+
return itemName(item);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Builds a breadcrumb-safe pseudo path for an inline detail item.
|
|
588
|
+
*
|
|
589
|
+
* @param {MediaBrowserItem} item Detail item.
|
|
590
|
+
* @returns {string} Detail path marker.
|
|
591
|
+
*/
|
|
592
|
+
function inlineDetailPath(item) {
|
|
593
|
+
return `${currentPath.value}#${encodeURIComponent(itemKey(item))}`;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Checks whether the row is currently uploading.
|
|
598
|
+
*
|
|
599
|
+
* @param {MediaBrowserItem} item Media row to inspect.
|
|
600
|
+
* @returns {boolean} True when the row reports upload progress.
|
|
601
|
+
*/
|
|
602
|
+
function isUploading(item) {
|
|
603
|
+
return item?.status === 'uploading' || (Number(item?.uploadProgress) > 0 && Number(item?.uploadProgress) < 100);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Clamps a progress value into the browser's accepted percentage range.
|
|
608
|
+
*
|
|
609
|
+
* @param {number} value Raw progress value.
|
|
610
|
+
* @returns {number} Percentage from 0 to 100.
|
|
611
|
+
*/
|
|
612
|
+
function clampProgress(value) {
|
|
613
|
+
const number = Number(value);
|
|
614
|
+
if (!Number.isFinite(number)) return 0;
|
|
615
|
+
return Math.min(100, Math.max(0, Math.round(number)));
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Averages progress from visible uploading rows.
|
|
620
|
+
*
|
|
621
|
+
* @param {Array<MediaBrowserItem>} items Uploading rows.
|
|
622
|
+
* @returns {number} Average progress percentage.
|
|
623
|
+
*/
|
|
624
|
+
function averageProgress(items) {
|
|
625
|
+
if (!items.length) return 0;
|
|
626
|
+
const total = items.reduce((sum, item) => sum + clampProgress(item.uploadProgress), 0);
|
|
627
|
+
return Math.round(total / items.length);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Normalizes a folder path to the slash-prefixed path contract.
|
|
632
|
+
*
|
|
633
|
+
* @param {string} value Path-like value.
|
|
634
|
+
* @returns {string} Slash-prefixed folder path.
|
|
635
|
+
*/
|
|
636
|
+
function normalizeFolderPath(value) {
|
|
637
|
+
const raw = String(value || '/').trim();
|
|
638
|
+
const prefixed = raw.startsWith('/') ? raw : `/${raw}`;
|
|
639
|
+
return prefixed.replace(/\/+/g, '/').replace(/\/$/, '') || '/';
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Normalizes supplied breadcrumbs or derives them from the current path.
|
|
644
|
+
*
|
|
645
|
+
* @param {Array<MediaBrowserBreadcrumb>} breadcrumbs User-supplied breadcrumbs.
|
|
646
|
+
* @param {string} path Current folder path.
|
|
647
|
+
* @returns {Array<MediaBrowserBreadcrumb>} Breadcrumb records.
|
|
648
|
+
*/
|
|
649
|
+
function normalizeBreadcrumbs(breadcrumbs, path) {
|
|
650
|
+
if (Array.isArray(breadcrumbs) && breadcrumbs.length) {
|
|
651
|
+
return breadcrumbs.map((crumb, index) => ({
|
|
652
|
+
label: crumb.label || (index === 0 ? 'Root' : pathBasename(crumb.path)),
|
|
653
|
+
path: normalizeFolderPath(crumb.path),
|
|
654
|
+
ulid: crumb.ulid || null,
|
|
655
|
+
}));
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const segments = normalizeFolderPath(path).split('/').filter(Boolean);
|
|
659
|
+
const crumbs = [{ label: 'Root', path: '/', ulid: null }];
|
|
660
|
+
let cursor = '';
|
|
661
|
+
for (const segment of segments) {
|
|
662
|
+
cursor = `${cursor}/${segment}`;
|
|
663
|
+
crumbs.push({
|
|
664
|
+
label: decodePathSegment(segment),
|
|
665
|
+
path: cursor,
|
|
666
|
+
ulid: null,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
return crumbs;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Returns the final path segment for a media path.
|
|
674
|
+
*
|
|
675
|
+
* @param {string} path Path-like value.
|
|
676
|
+
* @returns {string} Final path segment.
|
|
677
|
+
*/
|
|
678
|
+
function pathBasename(path) {
|
|
679
|
+
const segments = String(path || '').split('/').filter(Boolean);
|
|
680
|
+
return decodePathSegment(segments[segments.length - 1] || '');
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Decodes a route-safe path segment for display.
|
|
685
|
+
*
|
|
686
|
+
* @param {string} segment Encoded path segment.
|
|
687
|
+
* @returns {string} Display segment.
|
|
688
|
+
*/
|
|
689
|
+
function decodePathSegment(segment) {
|
|
690
|
+
try {
|
|
691
|
+
return decodeURIComponent(segment);
|
|
692
|
+
} catch {
|
|
693
|
+
return segment;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Updates the controlled selection and emits the derived payload.
|
|
699
|
+
*
|
|
700
|
+
* @param {Array<string>} keys Next selected row ids.
|
|
701
|
+
*/
|
|
702
|
+
function updateSelection(keys) {
|
|
703
|
+
const cleanKeys = uniqueStrings(keys).filter((key) => sourceItems.value.some((item) => itemKey(item) === key));
|
|
704
|
+
emit('update:selectedKeys', cleanKeys);
|
|
705
|
+
emit('selection-change', {
|
|
706
|
+
selectedKeys: cleanKeys,
|
|
707
|
+
selectedItems: sourceItems.value.filter((item) => cleanKeys.includes(itemKey(item))),
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Converts values into unique stable string ids.
|
|
713
|
+
*
|
|
714
|
+
* @param {Array<unknown>} values Candidate key values.
|
|
715
|
+
* @returns {Array<string>} Unique string ids.
|
|
716
|
+
*/
|
|
717
|
+
function uniqueStrings(values) {
|
|
718
|
+
return [...new Set(values.map((value) => String(value || '')).filter(Boolean))];
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Checks whether the item can be selected for the current picker contract.
|
|
723
|
+
*
|
|
724
|
+
* @param {MediaBrowserItem} item Media row to test.
|
|
725
|
+
* @returns {boolean} True when the item can be selected.
|
|
726
|
+
*/
|
|
727
|
+
function isSelectable(item) {
|
|
728
|
+
if (!item || !props.selectableTypes.map(String).includes(String(item.type))) return false;
|
|
729
|
+
if (item.type !== 'file') return true;
|
|
730
|
+
return acceptsFile(item);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Tests a file row against the accept prop.
|
|
735
|
+
*
|
|
736
|
+
* @param {MediaBrowserItem} item Media row to test.
|
|
737
|
+
* @returns {boolean} True when the row matches the accepted file filter.
|
|
738
|
+
*/
|
|
739
|
+
function acceptsFile(item) {
|
|
740
|
+
const acceptRules = normalizeAcceptRules(props.accept);
|
|
741
|
+
if (!acceptRules.length) return true;
|
|
742
|
+
const name = itemName(item).toLowerCase();
|
|
743
|
+
const mime = itemMime(item);
|
|
744
|
+
return acceptRules.some((rule) => {
|
|
745
|
+
if (rule.endsWith('/*')) return mime.startsWith(rule.slice(0, -1));
|
|
746
|
+
if (rule.startsWith('.')) return name.endsWith(rule);
|
|
747
|
+
return mime === rule;
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Normalizes MIME and extension accept rules.
|
|
753
|
+
*
|
|
754
|
+
* @param {string|Array<string>} accept Accepted rules.
|
|
755
|
+
* @returns {Array<string>} Lowercase accept rules.
|
|
756
|
+
*/
|
|
757
|
+
function normalizeAcceptRules(accept) {
|
|
758
|
+
const values = Array.isArray(accept) ? accept : String(accept || '').split(',');
|
|
759
|
+
return values.map((value) => String(value).trim().toLowerCase()).filter(Boolean);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Handles click selection on a visible row.
|
|
764
|
+
*
|
|
765
|
+
* @param {MediaBrowserItem} item Clicked media row.
|
|
766
|
+
* @param {MouseEvent} event Native click event.
|
|
767
|
+
*/
|
|
768
|
+
function selectItem(item, event) {
|
|
769
|
+
clearInlineDetail();
|
|
770
|
+
emit('item-click', { item, event });
|
|
771
|
+
if (!isSelectable(item)) return;
|
|
772
|
+
|
|
773
|
+
const key = itemKey(item);
|
|
774
|
+
if (props.selectionMode === 'single') {
|
|
775
|
+
updateSelection([key]);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (event.metaKey || event.ctrlKey) {
|
|
780
|
+
const next = selectedKeySet.value.has(key)
|
|
781
|
+
? props.selectedKeys.filter((value) => String(value) !== key)
|
|
782
|
+
: [...props.selectedKeys, key];
|
|
783
|
+
updateSelection(next);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
if (event.shiftKey && selectedItems.value.length) {
|
|
788
|
+
updateSelection(rangeSelection(key));
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
updateSelection([key]);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Selects an item before a context menu opens when it is not already selected.
|
|
797
|
+
*
|
|
798
|
+
* @param {MediaBrowserItem} item Media row under the context pointer.
|
|
799
|
+
*/
|
|
800
|
+
function selectForContext(item) {
|
|
801
|
+
if (!hasContextMenuActions.value) return;
|
|
802
|
+
if (selectedKeySet.value.has(itemKey(item)) || !isSelectable(item)) return;
|
|
803
|
+
updateSelection([itemKey(item)]);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Resolves a contiguous range between the first selected row and the target row.
|
|
808
|
+
*
|
|
809
|
+
* @param {string} targetKey Target row id.
|
|
810
|
+
* @returns {Array<string>} Range-selected row ids.
|
|
811
|
+
*/
|
|
812
|
+
function rangeSelection(targetKey) {
|
|
813
|
+
const keys = sourceItems.value.filter(isSelectable).map(itemKey);
|
|
814
|
+
const anchor = keys.indexOf(itemKey(selectedItems.value[0]));
|
|
815
|
+
const target = keys.indexOf(targetKey);
|
|
816
|
+
if (anchor < 0 || target < 0) return [targetKey];
|
|
817
|
+
const [start, end] = anchor < target ? [anchor, target] : [target, anchor];
|
|
818
|
+
return keys.slice(start, end + 1);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Handles keyboard activation for media rows.
|
|
823
|
+
*
|
|
824
|
+
* @param {MediaBrowserItem} item Focused media row.
|
|
825
|
+
* @param {KeyboardEvent} event Native keyboard event.
|
|
826
|
+
*/
|
|
827
|
+
function onItemKeydown(item, event) {
|
|
828
|
+
if (event.key === 'F2' && selectedKeySet.value.has(itemKey(item))) {
|
|
829
|
+
event.preventDefault();
|
|
830
|
+
beginRename(item);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
835
|
+
event.preventDefault();
|
|
836
|
+
if (event.key === 'Enter' && itemKind(item) === 'folder') {
|
|
837
|
+
navigateToItem(item, event);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
if (event.key === 'Enter' && detailConfigFor(item)) {
|
|
841
|
+
openItem(item, event);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
selectItem(item, event);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Handles row double-click behavior.
|
|
849
|
+
*
|
|
850
|
+
* @param {MediaBrowserItem} item Activated media row.
|
|
851
|
+
* @param {MouseEvent} event Native double-click event.
|
|
852
|
+
*/
|
|
853
|
+
function openItem(item, event) {
|
|
854
|
+
clearRenameTimer();
|
|
855
|
+
if (itemKind(item) === 'folder') {
|
|
856
|
+
navigateToItem(item, event);
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
emit('open', { item, event });
|
|
861
|
+
const config = detailConfigFor(item);
|
|
862
|
+
if (!config?.component) return;
|
|
863
|
+
openDetail(item, config, event);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Opens a media row in the configured detail placement.
|
|
868
|
+
*
|
|
869
|
+
* @param {MediaBrowserItem} item Media row to open.
|
|
870
|
+
* @param {Record<string, unknown>} config Detail component config.
|
|
871
|
+
* @param {MouseEvent|KeyboardEvent|null} event Native activation event.
|
|
872
|
+
*/
|
|
873
|
+
function openDetail(item, config, event = null) {
|
|
874
|
+
const mode = normalizeDetailMode(config.mode);
|
|
875
|
+
if (mode === 'inline') {
|
|
876
|
+
dialogDetailKey.value = '';
|
|
877
|
+
detailDialogOpen.value = false;
|
|
878
|
+
inlineDetailKey.value = itemKey(item);
|
|
879
|
+
} else {
|
|
880
|
+
inlineDetailKey.value = '';
|
|
881
|
+
dialogDetailKey.value = itemKey(item);
|
|
882
|
+
detailDialogOpen.value = true;
|
|
883
|
+
}
|
|
884
|
+
emit('detail-open', {
|
|
885
|
+
item,
|
|
886
|
+
kind: itemKind(item),
|
|
887
|
+
mode,
|
|
888
|
+
event,
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Closes the active inline detail view.
|
|
894
|
+
*/
|
|
895
|
+
function clearInlineDetail() {
|
|
896
|
+
if (!inlineDetailItem.value) {
|
|
897
|
+
inlineDetailKey.value = '';
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
emit('detail-close', {
|
|
901
|
+
item: inlineDetailItem.value,
|
|
902
|
+
kind: itemKind(inlineDetailItem.value),
|
|
903
|
+
mode: 'inline',
|
|
904
|
+
event: null,
|
|
905
|
+
});
|
|
906
|
+
inlineDetailKey.value = '';
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Closes the active dialog detail view.
|
|
911
|
+
*/
|
|
912
|
+
function clearDialogDetail() {
|
|
913
|
+
if (dialogDetailItem.value) {
|
|
914
|
+
emit('detail-close', {
|
|
915
|
+
item: dialogDetailItem.value,
|
|
916
|
+
kind: itemKind(dialogDetailItem.value),
|
|
917
|
+
mode: 'dialog',
|
|
918
|
+
event: null,
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
dialogDetailKey.value = '';
|
|
922
|
+
detailDialogOpen.value = false;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Emits a folder navigation event for a media row.
|
|
927
|
+
*
|
|
928
|
+
* @param {MediaBrowserItem} item Folder row.
|
|
929
|
+
* @param {MouseEvent|KeyboardEvent} event Native activation event.
|
|
930
|
+
*/
|
|
931
|
+
function navigateToItem(item, event) {
|
|
932
|
+
clearInlineDetail();
|
|
933
|
+
emit('navigate', {
|
|
934
|
+
path: normalizeFolderPath(item.path),
|
|
935
|
+
item,
|
|
936
|
+
breadcrumb: null,
|
|
937
|
+
event,
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Emits a breadcrumb navigation event.
|
|
943
|
+
*
|
|
944
|
+
* @param {MediaBrowserBreadcrumb} breadcrumb Breadcrumb row.
|
|
945
|
+
* @param {MouseEvent} event Native click event.
|
|
946
|
+
*/
|
|
947
|
+
function navigateToBreadcrumb(breadcrumb, event) {
|
|
948
|
+
if (breadcrumb?.detail) return;
|
|
949
|
+
clearInlineDetail();
|
|
950
|
+
emit('navigate', {
|
|
951
|
+
path: normalizeFolderPath(breadcrumb.path),
|
|
952
|
+
item: null,
|
|
953
|
+
breadcrumb,
|
|
954
|
+
event,
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Marks a breadcrumb as an internal drop target for parent-folder moves.
|
|
960
|
+
*
|
|
961
|
+
* @param {MediaBrowserBreadcrumb} breadcrumb Candidate breadcrumb target.
|
|
962
|
+
* @param {DragEvent} event Native drag event.
|
|
963
|
+
*/
|
|
964
|
+
function onBreadcrumbDragOver(breadcrumb, event) {
|
|
965
|
+
if (!canDropOnBreadcrumb(breadcrumb, event)) return;
|
|
966
|
+
event.preventDefault();
|
|
967
|
+
event.stopPropagation();
|
|
968
|
+
breadcrumbDropPath.value = breadcrumbDropKey(breadcrumb);
|
|
969
|
+
dropTargetMode.value = 'move';
|
|
970
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Clears a breadcrumb drop target when the pointer leaves the crumb.
|
|
975
|
+
*
|
|
976
|
+
* @param {MediaBrowserBreadcrumb} breadcrumb Breadcrumb being left.
|
|
977
|
+
* @param {DragEvent} event Native drag leave event.
|
|
978
|
+
*/
|
|
979
|
+
function onBreadcrumbDragLeave(breadcrumb, event) {
|
|
980
|
+
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
|
|
981
|
+
if (breadcrumbDropPath.value === breadcrumbDropKey(breadcrumb)) clearDropTargets();
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Handles internal row drops onto a breadcrumb target.
|
|
986
|
+
*
|
|
987
|
+
* @param {MediaBrowserBreadcrumb} breadcrumb Breadcrumb receiving the drop.
|
|
988
|
+
* @param {DragEvent} event Native drop event.
|
|
989
|
+
*/
|
|
990
|
+
function onBreadcrumbDrop(breadcrumb, event) {
|
|
991
|
+
if (!canDropOnBreadcrumb(breadcrumb, event)) return;
|
|
992
|
+
event.preventDefault();
|
|
993
|
+
event.stopPropagation();
|
|
994
|
+
const target = breadcrumbDropTarget(breadcrumb);
|
|
995
|
+
clearDropTargets();
|
|
996
|
+
emitMoveFromDrop(target, event);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Checks whether a breadcrumb can receive the current internal drag.
|
|
1001
|
+
*
|
|
1002
|
+
* @param {MediaBrowserBreadcrumb} breadcrumb Breadcrumb to test.
|
|
1003
|
+
* @param {DragEvent} event Native drag event.
|
|
1004
|
+
* @returns {boolean} True when rows can be moved to the breadcrumb folder.
|
|
1005
|
+
*/
|
|
1006
|
+
function canDropOnBreadcrumb(breadcrumb, event) {
|
|
1007
|
+
if (!props.canMove || breadcrumb?.detail) return false;
|
|
1008
|
+
if (!dataTransferHasType(event, dragDataType)) return false;
|
|
1009
|
+
const targetPath = breadcrumbDropKey(breadcrumb);
|
|
1010
|
+
if (!targetPath || targetPath === currentPath.value) return false;
|
|
1011
|
+
const movedItems = draggedItemsFromEvent(event);
|
|
1012
|
+
return movedItems.length > 0 && !movedItems.some((item) => itemKey(item) === String(breadcrumb?.ulid || ''));
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Builds a folder-shaped target row from a breadcrumb.
|
|
1017
|
+
*
|
|
1018
|
+
* @param {MediaBrowserBreadcrumb} breadcrumb Breadcrumb target.
|
|
1019
|
+
* @returns {MediaBrowserItem} Folder-shaped target row.
|
|
1020
|
+
*/
|
|
1021
|
+
function breadcrumbDropTarget(breadcrumb) {
|
|
1022
|
+
const path = breadcrumbDropKey(breadcrumb);
|
|
1023
|
+
return {
|
|
1024
|
+
ulid: breadcrumb?.ulid || '',
|
|
1025
|
+
type: path === '/' ? 'root' : 'dir',
|
|
1026
|
+
path,
|
|
1027
|
+
parent_id: null,
|
|
1028
|
+
file_uuid: null,
|
|
1029
|
+
name: breadcrumb?.label || pathBasename(path) || 'Root',
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* Resolves the normalized folder path key for a breadcrumb.
|
|
1035
|
+
*
|
|
1036
|
+
* @param {MediaBrowserBreadcrumb} breadcrumb Breadcrumb target.
|
|
1037
|
+
* @returns {string} Normalized breadcrumb path.
|
|
1038
|
+
*/
|
|
1039
|
+
function breadcrumbDropKey(breadcrumb) {
|
|
1040
|
+
return normalizeFolderPath(breadcrumb?.path);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Emits the create folder command for the current folder path.
|
|
1045
|
+
*/
|
|
1046
|
+
function emitCreateFolder() {
|
|
1047
|
+
if (!props.canCreateFolder) return;
|
|
1048
|
+
emit('create-folder', { path: currentPath.value });
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* Starts inline rename mode for a media row.
|
|
1053
|
+
*
|
|
1054
|
+
* @param {MediaBrowserItem} item Media row to rename.
|
|
1055
|
+
*/
|
|
1056
|
+
function beginRename(item) {
|
|
1057
|
+
if (!props.canRename || !item) return;
|
|
1058
|
+
clearInlineDetail();
|
|
1059
|
+
renamingKey.value = itemKey(item);
|
|
1060
|
+
renameDraft.value = itemName(item);
|
|
1061
|
+
nextTick(focusRenameInput);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Focuses the active rename input after it is rendered.
|
|
1066
|
+
*/
|
|
1067
|
+
function focusRenameInput() {
|
|
1068
|
+
viewport.value?.querySelector?.('[data-media-browser-rename]')?.focus();
|
|
1069
|
+
viewport.value?.querySelector?.('[data-media-browser-rename]')?.select();
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* Selects an unselected name or starts rename when it is already selected.
|
|
1074
|
+
*
|
|
1075
|
+
* @param {MediaBrowserItem} item Media row represented by the clicked name.
|
|
1076
|
+
* @param {MouseEvent} event Native click event.
|
|
1077
|
+
*/
|
|
1078
|
+
function onNameClick(item, event) {
|
|
1079
|
+
if (!selectedKeySet.value.has(itemKey(item))) {
|
|
1080
|
+
selectItem(item, event);
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
scheduleRename(item);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* Starts rename after a small delay so double-click can open the item.
|
|
1088
|
+
*
|
|
1089
|
+
* @param {MediaBrowserItem} item Media row represented by the clicked name.
|
|
1090
|
+
*/
|
|
1091
|
+
function scheduleRename(item) {
|
|
1092
|
+
clearRenameTimer();
|
|
1093
|
+
const delay = Math.max(0, Number(props.renameDelay) || 0);
|
|
1094
|
+
renameClickTimer = globalThis.setTimeout(() => {
|
|
1095
|
+
beginRename(item);
|
|
1096
|
+
}, delay);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* Clears a pending delayed rename timer.
|
|
1101
|
+
*/
|
|
1102
|
+
function clearRenameTimer() {
|
|
1103
|
+
if (!renameClickTimer) return;
|
|
1104
|
+
globalThis.clearTimeout(renameClickTimer);
|
|
1105
|
+
renameClickTimer = null;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Commits the current inline rename draft.
|
|
1110
|
+
*
|
|
1111
|
+
* @param {MediaBrowserItem} item Media row being renamed.
|
|
1112
|
+
*/
|
|
1113
|
+
function commitRename(item) {
|
|
1114
|
+
const name = renameDraft.value.trim();
|
|
1115
|
+
cancelRename();
|
|
1116
|
+
if (!name || name === itemName(item)) return;
|
|
1117
|
+
emit('rename', { item, name });
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Cancels inline rename mode.
|
|
1122
|
+
*/
|
|
1123
|
+
function cancelRename() {
|
|
1124
|
+
renamingKey.value = '';
|
|
1125
|
+
renameDraft.value = '';
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Switches display mode and emits the controlled update.
|
|
1130
|
+
*
|
|
1131
|
+
* @param {'grid'|'list'} mode Target display mode.
|
|
1132
|
+
*/
|
|
1133
|
+
function setMode(mode) {
|
|
1134
|
+
if (mode === renderMode.value) return;
|
|
1135
|
+
emit('update:mode', mode);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Handles built-in context menu actions.
|
|
1140
|
+
*
|
|
1141
|
+
* @param {{ value: string }} payload DomContextMenu select payload.
|
|
1142
|
+
*/
|
|
1143
|
+
function onContextSelect(payload) {
|
|
1144
|
+
const action = payload?.value || '';
|
|
1145
|
+
if (action === 'new-folder') emitCreateFolder();
|
|
1146
|
+
if (action === 'rename' && selectedItems.value.length === 1) beginRename(selectedItems.value[0]);
|
|
1147
|
+
if (action === 'folder-from-selection') {
|
|
1148
|
+
emit('make-folder-with-items', {
|
|
1149
|
+
items: selectedItems.value,
|
|
1150
|
+
path: currentPath.value,
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
if (action === 'mode-grid') setMode('grid');
|
|
1154
|
+
if (action === 'mode-list') setMode('list');
|
|
1155
|
+
|
|
1156
|
+
emit('context-action', {
|
|
1157
|
+
action,
|
|
1158
|
+
items: selectedItems.value,
|
|
1159
|
+
path: currentPath.value,
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* Checks whether a row can be dragged internally.
|
|
1165
|
+
*
|
|
1166
|
+
* @param {MediaBrowserItem} item Media row to test.
|
|
1167
|
+
* @returns {boolean} True when internal dragging can start.
|
|
1168
|
+
*/
|
|
1169
|
+
function canDragItem(item) {
|
|
1170
|
+
return props.canMove && isSelectable(item) && item?.status !== 'uploading';
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* Starts an internal media-row drag operation.
|
|
1175
|
+
*
|
|
1176
|
+
* @param {MediaBrowserItem} item Dragged media row.
|
|
1177
|
+
* @param {DragEvent} event Native drag start event.
|
|
1178
|
+
*/
|
|
1179
|
+
function onItemDragStart(item, event) {
|
|
1180
|
+
if (!canDragItem(item)) {
|
|
1181
|
+
event.preventDefault();
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
const key = itemKey(item);
|
|
1186
|
+
const isAlreadySelected = selectedKeySet.value.has(key);
|
|
1187
|
+
const items = isAlreadySelected ? selectedItems.value : [item];
|
|
1188
|
+
if (!isAlreadySelected) updateSelection([key]);
|
|
1189
|
+
draggingKeys.value = items.map(itemKey);
|
|
1190
|
+
|
|
1191
|
+
const payload = {
|
|
1192
|
+
keys: items.map(itemKey),
|
|
1193
|
+
items: items.map(serializableItem),
|
|
1194
|
+
};
|
|
1195
|
+
|
|
1196
|
+
if (event.dataTransfer) {
|
|
1197
|
+
event.dataTransfer.effectAllowed = 'move';
|
|
1198
|
+
event.dataTransfer.setData(dragDataType, JSON.stringify(payload));
|
|
1199
|
+
event.dataTransfer.setData('text/plain', items.map(itemName).join(', '));
|
|
1200
|
+
}
|
|
1201
|
+
setGroupedDragImage(event, items);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* Clears internal drag preview state.
|
|
1206
|
+
*/
|
|
1207
|
+
function onItemDragEnd() {
|
|
1208
|
+
clearDropTargets();
|
|
1209
|
+
draggingKeys.value = [];
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* Marks a folder row as the current internal drop target.
|
|
1214
|
+
*
|
|
1215
|
+
* @param {MediaBrowserItem} item Candidate folder row.
|
|
1216
|
+
* @param {DragEvent} event Native drag event.
|
|
1217
|
+
*/
|
|
1218
|
+
function onFolderDragOver(item, event) {
|
|
1219
|
+
const canMoveHere = canDropOnFolder(item, event);
|
|
1220
|
+
const canUploadHere = canDropExternalOnFolder(item, event);
|
|
1221
|
+
if (!canMoveHere && !canUploadHere) return;
|
|
1222
|
+
event.preventDefault();
|
|
1223
|
+
event.stopPropagation();
|
|
1224
|
+
dragTargetKey.value = itemKey(item);
|
|
1225
|
+
dropTargetMode.value = canUploadHere ? 'upload' : 'move';
|
|
1226
|
+
externalDropActive.value = false;
|
|
1227
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = canUploadHere ? 'copy' : 'move';
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* Clears a folder drop target when the pointer has left the row.
|
|
1232
|
+
*
|
|
1233
|
+
* @param {MediaBrowserItem} item Folder row that may be active.
|
|
1234
|
+
* @param {DragEvent} event Native drag leave event.
|
|
1235
|
+
*/
|
|
1236
|
+
function onFolderDragLeave(item, event) {
|
|
1237
|
+
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
|
|
1238
|
+
if (dragTargetKey.value === itemKey(item)) clearDropTargets();
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* Handles internal or external drops onto a folder row.
|
|
1243
|
+
*
|
|
1244
|
+
* @param {MediaBrowserItem} item Folder row receiving the drop.
|
|
1245
|
+
* @param {DragEvent} event Native drop event.
|
|
1246
|
+
*/
|
|
1247
|
+
async function onFolderDrop(item, event) {
|
|
1248
|
+
if (!canDropOnFolder(item, event) && !canDropExternalOnFolder(item, event)) return;
|
|
1249
|
+
event.preventDefault();
|
|
1250
|
+
event.stopPropagation();
|
|
1251
|
+
clearDropTargets();
|
|
1252
|
+
externalDropActive.value = false;
|
|
1253
|
+
|
|
1254
|
+
if (hasExternalFiles(event)) {
|
|
1255
|
+
await emitUploadFromDrop(event, item);
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
emitMoveFromDrop(item, event);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/**
|
|
1263
|
+
* Checks whether a folder row can receive the current internal drag.
|
|
1264
|
+
*
|
|
1265
|
+
* @param {MediaBrowserItem} item Folder row to test.
|
|
1266
|
+
* @param {DragEvent} event Native drag event.
|
|
1267
|
+
* @returns {boolean} True when the drop should move selected rows.
|
|
1268
|
+
*/
|
|
1269
|
+
function canDropOnFolder(item, event) {
|
|
1270
|
+
if (!props.canMove || itemKind(item) !== 'folder') return false;
|
|
1271
|
+
if (!dataTransferHasType(event, dragDataType)) return false;
|
|
1272
|
+
const movedItems = draggedItemsFromEvent(event);
|
|
1273
|
+
return movedItems.length > 0 && !movedItems.some((movedItem) => itemKey(movedItem) === itemKey(item));
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/**
|
|
1277
|
+
* Checks whether external files can be uploaded directly into a folder row.
|
|
1278
|
+
*
|
|
1279
|
+
* @param {MediaBrowserItem} item Folder row to test.
|
|
1280
|
+
* @param {DragEvent} event Native drag event.
|
|
1281
|
+
* @returns {boolean} True when the folder can receive browser files.
|
|
1282
|
+
*/
|
|
1283
|
+
function canDropExternalOnFolder(item, event) {
|
|
1284
|
+
return props.canUpload && itemKind(item) === 'folder' && hasExternalFiles(event);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/**
|
|
1288
|
+
* Emits a move event from an internal drag payload.
|
|
1289
|
+
*
|
|
1290
|
+
* @param {MediaBrowserItem} target Folder receiving selected rows.
|
|
1291
|
+
* @param {DragEvent} event Native drop event.
|
|
1292
|
+
*/
|
|
1293
|
+
function emitMoveFromDrop(target, event) {
|
|
1294
|
+
const movedItems = draggedItemsFromEvent(event);
|
|
1295
|
+
if (!movedItems.length) return;
|
|
1296
|
+
emit('move', {
|
|
1297
|
+
items: movedItems,
|
|
1298
|
+
target,
|
|
1299
|
+
event,
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/**
|
|
1304
|
+
* Resolves the current internal drag rows from payload first, then selection.
|
|
1305
|
+
*
|
|
1306
|
+
* @param {DragEvent} event Native drag event.
|
|
1307
|
+
* @returns {Array<MediaBrowserItem>} Moved media rows.
|
|
1308
|
+
*/
|
|
1309
|
+
function draggedItemsFromEvent(event) {
|
|
1310
|
+
const payloadItems = itemsFromDragEvent(event);
|
|
1311
|
+
if (payloadItems.length) return payloadItems;
|
|
1312
|
+
const activeDragItems = sourceItems.value.filter((item) => draggingKeySet.value.has(itemKey(item)));
|
|
1313
|
+
return activeDragItems.length ? activeDragItems : selectedItems.value;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/**
|
|
1317
|
+
* Resolves media rows represented by a native data transfer payload.
|
|
1318
|
+
*
|
|
1319
|
+
* @param {DragEvent} event Native drag event.
|
|
1320
|
+
* @returns {Array<MediaBrowserItem>} Moved media rows.
|
|
1321
|
+
*/
|
|
1322
|
+
function itemsFromDragEvent(event) {
|
|
1323
|
+
const payload = parseDragPayload(event);
|
|
1324
|
+
const keys = Array.isArray(payload.keys) ? payload.keys.map(String) : [];
|
|
1325
|
+
const serializedItems = Array.isArray(payload.items) ? payload.items : [];
|
|
1326
|
+
return keys
|
|
1327
|
+
.map((key) => (
|
|
1328
|
+
sourceItems.value.find((item) => itemKey(item) === key)
|
|
1329
|
+
|| serializedItems.find((item) => itemKey(item) === key)
|
|
1330
|
+
))
|
|
1331
|
+
.filter(Boolean);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* Serializes the row fields needed by non-Vue drag consumers.
|
|
1336
|
+
*
|
|
1337
|
+
* @param {MediaBrowserItem} item Media row to serialize.
|
|
1338
|
+
* @returns {Record<string, unknown>} Serializable row summary.
|
|
1339
|
+
*/
|
|
1340
|
+
function serializableItem(item) {
|
|
1341
|
+
return {
|
|
1342
|
+
ulid: item.ulid,
|
|
1343
|
+
type: item.type,
|
|
1344
|
+
path: item.path,
|
|
1345
|
+
parent_id: item.parent_id || null,
|
|
1346
|
+
file_uuid: item.file_uuid || null,
|
|
1347
|
+
name: itemName(item),
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/**
|
|
1352
|
+
* Parses the media-row drag payload from a native drop event.
|
|
1353
|
+
*
|
|
1354
|
+
* @param {DragEvent} event Native drag event.
|
|
1355
|
+
* @returns {Record<string, unknown>} Parsed payload.
|
|
1356
|
+
*/
|
|
1357
|
+
function parseDragPayload(event) {
|
|
1358
|
+
try {
|
|
1359
|
+
const payload = event.dataTransfer?.getData(dragDataType);
|
|
1360
|
+
return payload ? JSON.parse(payload) : {};
|
|
1361
|
+
} catch {
|
|
1362
|
+
return {};
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/**
|
|
1367
|
+
* Clears folder and breadcrumb drop-target preview state.
|
|
1368
|
+
*/
|
|
1369
|
+
function clearDropTargets() {
|
|
1370
|
+
dragTargetKey.value = '';
|
|
1371
|
+
breadcrumbDropPath.value = '';
|
|
1372
|
+
dropTargetMode.value = '';
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* Renders a compact native drag image that communicates grouped drags.
|
|
1377
|
+
*
|
|
1378
|
+
* @param {DragEvent} event Native drag start event.
|
|
1379
|
+
* @param {Array<MediaBrowserItem>} items Dragged rows.
|
|
1380
|
+
*/
|
|
1381
|
+
function setGroupedDragImage(event, items) {
|
|
1382
|
+
if (!event.dataTransfer || typeof document === 'undefined' || items.length <= 1) return;
|
|
1383
|
+
const preview = document.createElement('div');
|
|
1384
|
+
preview.textContent = `${items.length} items`;
|
|
1385
|
+
preview.style.position = 'fixed';
|
|
1386
|
+
preview.style.top = '-999px';
|
|
1387
|
+
preview.style.left = '-999px';
|
|
1388
|
+
preview.style.zIndex = '9999';
|
|
1389
|
+
preview.style.padding = '0.5rem 0.7rem';
|
|
1390
|
+
preview.style.borderRadius = '0.5rem';
|
|
1391
|
+
preview.style.border = '1px solid var(--primary)';
|
|
1392
|
+
preview.style.background = 'var(--canvas)';
|
|
1393
|
+
preview.style.color = 'var(--canvas-fg)';
|
|
1394
|
+
preview.style.boxShadow = 'var(--shadow-lg)';
|
|
1395
|
+
preview.style.font = '600 12px system-ui, sans-serif';
|
|
1396
|
+
preview.style.pointerEvents = 'none';
|
|
1397
|
+
document.body.appendChild(preview);
|
|
1398
|
+
event.dataTransfer.setDragImage(preview, 18, 18);
|
|
1399
|
+
globalThis.setTimeout(() => preview.remove(), 0);
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
/**
|
|
1403
|
+
* Resolves the visible drop label for an active folder target.
|
|
1404
|
+
*
|
|
1405
|
+
* @returns {string} Short target label.
|
|
1406
|
+
*/
|
|
1407
|
+
function dropTargetLabel() {
|
|
1408
|
+
if (dropTargetMode.value === 'upload') return 'Upload here';
|
|
1409
|
+
const count = draggingCount.value || selectedItems.value.length || 1;
|
|
1410
|
+
return count === 1 ? 'Move here' : `Move ${count} items here`;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* Checks whether a row is part of the current native drag source.
|
|
1415
|
+
*
|
|
1416
|
+
* @param {MediaBrowserItem} item Media row to test.
|
|
1417
|
+
* @returns {boolean} True when the row is being dragged.
|
|
1418
|
+
*/
|
|
1419
|
+
function isDraggedItem(item) {
|
|
1420
|
+
return draggingKeySet.value.has(itemKey(item));
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/**
|
|
1424
|
+
* Handles drag-over state for external browser uploads.
|
|
1425
|
+
*
|
|
1426
|
+
* @param {DragEvent} event Native drag event.
|
|
1427
|
+
*/
|
|
1428
|
+
function onBrowserDragOver(event) {
|
|
1429
|
+
if (!props.canUpload || !hasExternalFiles(event)) return;
|
|
1430
|
+
event.preventDefault();
|
|
1431
|
+
externalDropActive.value = true;
|
|
1432
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
/**
|
|
1436
|
+
* Clears the external drop preview when the pointer leaves the browser.
|
|
1437
|
+
*
|
|
1438
|
+
* @param {DragEvent} event Native drag leave event.
|
|
1439
|
+
*/
|
|
1440
|
+
function onBrowserDragLeave(event) {
|
|
1441
|
+
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
|
|
1442
|
+
externalDropActive.value = false;
|
|
1443
|
+
clearDropTargets();
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/**
|
|
1447
|
+
* Handles external files dropped onto the current folder surface.
|
|
1448
|
+
*
|
|
1449
|
+
* @param {DragEvent} event Native drop event.
|
|
1450
|
+
*/
|
|
1451
|
+
async function onBrowserDrop(event) {
|
|
1452
|
+
if (!props.canUpload || !hasExternalFiles(event)) return;
|
|
1453
|
+
event.preventDefault();
|
|
1454
|
+
externalDropActive.value = false;
|
|
1455
|
+
clearDropTargets();
|
|
1456
|
+
await emitUploadFromDrop(event, null);
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/**
|
|
1460
|
+
* Emits an upload event after expanding files and folder drops.
|
|
1461
|
+
*
|
|
1462
|
+
* @param {DragEvent} event Native drop event.
|
|
1463
|
+
* @param {MediaBrowserItem|null} target Target folder row, or null for current folder.
|
|
1464
|
+
*/
|
|
1465
|
+
async function emitUploadFromDrop(event, target) {
|
|
1466
|
+
const files = await collectDroppedFiles(event.dataTransfer);
|
|
1467
|
+
if (!files.length) return;
|
|
1468
|
+
emit('upload', {
|
|
1469
|
+
files,
|
|
1470
|
+
target,
|
|
1471
|
+
path: target ? normalizeFolderPath(target.path) : currentPath.value,
|
|
1472
|
+
event,
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/**
|
|
1477
|
+
* Opens the native file chooser for the current media folder.
|
|
1478
|
+
*/
|
|
1479
|
+
function openUploadPicker() {
|
|
1480
|
+
if (!props.canUpload || !uploadInput.value) return;
|
|
1481
|
+
uploadInput.value.value = '';
|
|
1482
|
+
uploadInput.value.click();
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
/**
|
|
1486
|
+
* Emits files selected through the native chooser using the shared upload payload.
|
|
1487
|
+
*
|
|
1488
|
+
* @param {Event} event Native file input change event.
|
|
1489
|
+
*/
|
|
1490
|
+
function onUploadInputChange(event) {
|
|
1491
|
+
const input = event.target;
|
|
1492
|
+
const files = Array.from(input?.files || []).map((file) => normalizeDroppedFile({
|
|
1493
|
+
file,
|
|
1494
|
+
relativePath: file.webkitRelativePath || file.name,
|
|
1495
|
+
}));
|
|
1496
|
+
|
|
1497
|
+
if (files.length) {
|
|
1498
|
+
emit('upload', {
|
|
1499
|
+
files,
|
|
1500
|
+
target: null,
|
|
1501
|
+
path: currentPath.value,
|
|
1502
|
+
event,
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
if (input) input.value = '';
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/**
|
|
1510
|
+
* Checks whether a drag event carries external files.
|
|
1511
|
+
*
|
|
1512
|
+
* @param {DragEvent} event Native drag event.
|
|
1513
|
+
* @returns {boolean} True when file data is present.
|
|
1514
|
+
*/
|
|
1515
|
+
function hasExternalFiles(event) {
|
|
1516
|
+
return dataTransferHasType(event, 'Files') || Boolean(event.dataTransfer?.files?.length);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
/**
|
|
1520
|
+
* Checks whether a data transfer includes a type.
|
|
1521
|
+
*
|
|
1522
|
+
* @param {DragEvent} event Native drag event.
|
|
1523
|
+
* @param {string} type DataTransfer type.
|
|
1524
|
+
* @returns {boolean} True when the type exists.
|
|
1525
|
+
*/
|
|
1526
|
+
function dataTransferHasType(event, type) {
|
|
1527
|
+
return Array.from(event.dataTransfer?.types || []).includes(type);
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/**
|
|
1531
|
+
* Collects browser files from a drop, including folders where supported.
|
|
1532
|
+
*
|
|
1533
|
+
* @param {DataTransfer|null} dataTransfer Native data transfer object.
|
|
1534
|
+
* @returns {Promise<Array<MediaBrowserDroppedFile>>} Dropped file records.
|
|
1535
|
+
*/
|
|
1536
|
+
async function collectDroppedFiles(dataTransfer) {
|
|
1537
|
+
if (!dataTransfer) return [];
|
|
1538
|
+
const itemEntries = Array.from(dataTransfer.items || [])
|
|
1539
|
+
.map((item) => item.webkitGetAsEntry?.())
|
|
1540
|
+
.filter(Boolean);
|
|
1541
|
+
|
|
1542
|
+
if (itemEntries.length) {
|
|
1543
|
+
const nestedFiles = await Promise.all(itemEntries.map((entry) => collectEntryFiles(entry, '')));
|
|
1544
|
+
return nestedFiles.flat().map(normalizeDroppedFile);
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
return Array.from(dataTransfer.files || []).map((file) => normalizeDroppedFile({
|
|
1548
|
+
file,
|
|
1549
|
+
relativePath: file.webkitRelativePath || file.name,
|
|
1550
|
+
}));
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/**
|
|
1554
|
+
* Recursively collects files from a dropped file-system entry.
|
|
1555
|
+
*
|
|
1556
|
+
* @param {FileSystemEntry} entry Browser file-system entry.
|
|
1557
|
+
* @param {string} prefix Relative path prefix.
|
|
1558
|
+
* @returns {Promise<Array<{ file: File, relativePath: string }>>} Dropped file records.
|
|
1559
|
+
*/
|
|
1560
|
+
function collectEntryFiles(entry, prefix) {
|
|
1561
|
+
if (entry.isFile) {
|
|
1562
|
+
return new Promise((resolve) => {
|
|
1563
|
+
entry.file(
|
|
1564
|
+
(file) => resolve([{ file, relativePath: `${prefix}${file.name}` }]),
|
|
1565
|
+
() => resolve([]),
|
|
1566
|
+
);
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
if (!entry.isDirectory) return Promise.resolve([]);
|
|
1571
|
+
const directoryPrefix = `${prefix}${entry.name}/`;
|
|
1572
|
+
const reader = entry.createReader();
|
|
1573
|
+
return readAllDirectoryEntries(reader).then(async (entries) => {
|
|
1574
|
+
const nested = await Promise.all(entries.map((child) => collectEntryFiles(child, directoryPrefix)));
|
|
1575
|
+
return nested.flat();
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/**
|
|
1580
|
+
* Reads every entry from a browser directory reader.
|
|
1581
|
+
*
|
|
1582
|
+
* @param {FileSystemDirectoryReader} reader Browser directory reader.
|
|
1583
|
+
* @returns {Promise<Array<FileSystemEntry>>} Directory entries.
|
|
1584
|
+
*/
|
|
1585
|
+
function readAllDirectoryEntries(reader) {
|
|
1586
|
+
return new Promise((resolve) => {
|
|
1587
|
+
const entries = [];
|
|
1588
|
+
/**
|
|
1589
|
+
* Reads one browser directory batch and continues until exhausted.
|
|
1590
|
+
*/
|
|
1591
|
+
const readBatch = () => {
|
|
1592
|
+
reader.readEntries((batch) => {
|
|
1593
|
+
if (!batch.length) {
|
|
1594
|
+
resolve(entries);
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
entries.push(...batch);
|
|
1598
|
+
readBatch();
|
|
1599
|
+
}, () => resolve(entries));
|
|
1600
|
+
};
|
|
1601
|
+
readBatch();
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
/**
|
|
1606
|
+
* Normalizes a dropped browser file into the upload payload contract.
|
|
1607
|
+
*
|
|
1608
|
+
* @param {{ file: File, relativePath: string }} record Dropped file record.
|
|
1609
|
+
* @returns {MediaBrowserDroppedFile} Upload payload record.
|
|
1610
|
+
*/
|
|
1611
|
+
function normalizeDroppedFile(record) {
|
|
1612
|
+
const relativePath = String(record.relativePath || record.file.name || '').replace(/^\/+/, '');
|
|
1613
|
+
return {
|
|
1614
|
+
file: record.file,
|
|
1615
|
+
name: record.file.name,
|
|
1616
|
+
relativePath,
|
|
1617
|
+
pathSegments: relativePath.split('/').filter(Boolean),
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/**
|
|
1622
|
+
* Starts a rubber-band selection gesture when the pointer begins on empty space.
|
|
1623
|
+
*
|
|
1624
|
+
* @param {PointerEvent} event Native pointer event.
|
|
1625
|
+
*/
|
|
1626
|
+
function onViewportPointerDown(event) {
|
|
1627
|
+
if (!props.rubberBand || props.selectionMode !== 'multiple' || event.button !== 0) return;
|
|
1628
|
+
if (event.target instanceof Element && event.target.closest('[data-media-browser-item], button, input, textarea, a')) return;
|
|
1629
|
+
if (!viewport.value) return;
|
|
1630
|
+
|
|
1631
|
+
const rect = viewport.value.getBoundingClientRect();
|
|
1632
|
+
bandSelection.value = {
|
|
1633
|
+
startX: event.clientX,
|
|
1634
|
+
startY: event.clientY,
|
|
1635
|
+
currentX: event.clientX,
|
|
1636
|
+
currentY: event.clientY,
|
|
1637
|
+
originLeft: rect.left,
|
|
1638
|
+
originTop: rect.top,
|
|
1639
|
+
additive: event.metaKey || event.ctrlKey,
|
|
1640
|
+
initialKeys: props.selectedKeys.map(String),
|
|
1641
|
+
};
|
|
1642
|
+
previousUserSelect = document.body.style.userSelect;
|
|
1643
|
+
document.body.style.userSelect = 'none';
|
|
1644
|
+
document.addEventListener('pointermove', updateBandSelection);
|
|
1645
|
+
document.addEventListener('pointerup', finishBandSelection);
|
|
1646
|
+
document.addEventListener('pointercancel', finishBandSelection);
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/**
|
|
1650
|
+
* Updates the rubber-band rectangle and preview selection.
|
|
1651
|
+
*
|
|
1652
|
+
* @param {PointerEvent} event Native pointer event.
|
|
1653
|
+
*/
|
|
1654
|
+
function updateBandSelection(event) {
|
|
1655
|
+
if (!bandSelection.value) return;
|
|
1656
|
+
bandSelection.value = {
|
|
1657
|
+
...bandSelection.value,
|
|
1658
|
+
currentX: event.clientX,
|
|
1659
|
+
currentY: event.clientY,
|
|
1660
|
+
};
|
|
1661
|
+
updateSelection(keysInsideBand());
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/**
|
|
1665
|
+
* Finishes the current rubber-band gesture.
|
|
1666
|
+
*/
|
|
1667
|
+
function finishBandSelection() {
|
|
1668
|
+
removeBandListeners();
|
|
1669
|
+
bandSelection.value = null;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
/**
|
|
1673
|
+
* Removes document listeners used by rubber-band selection.
|
|
1674
|
+
*/
|
|
1675
|
+
function removeBandListeners() {
|
|
1676
|
+
if (typeof document === 'undefined') return;
|
|
1677
|
+
document.removeEventListener('pointermove', updateBandSelection);
|
|
1678
|
+
document.removeEventListener('pointerup', finishBandSelection);
|
|
1679
|
+
document.removeEventListener('pointercancel', finishBandSelection);
|
|
1680
|
+
document.body.style.userSelect = previousUserSelect;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
/**
|
|
1684
|
+
* Resolves selectable item keys intersecting the rubber-band rectangle.
|
|
1685
|
+
*
|
|
1686
|
+
* @returns {Array<string>} Selected row ids.
|
|
1687
|
+
*/
|
|
1688
|
+
function keysInsideBand() {
|
|
1689
|
+
const band = bandSelection.value;
|
|
1690
|
+
if (!band || !viewport.value) return props.selectedKeys.map(String);
|
|
1691
|
+
const bandRect = normalizedRect(band.startX, band.startY, band.currentX, band.currentY);
|
|
1692
|
+
const keys = Array.from(viewport.value.querySelectorAll('[data-media-browser-item]'))
|
|
1693
|
+
.filter((element) => rectanglesIntersect(bandRect, element.getBoundingClientRect()))
|
|
1694
|
+
.map((element) => element.getAttribute('data-media-key'))
|
|
1695
|
+
.filter(Boolean);
|
|
1696
|
+
|
|
1697
|
+
return band.additive ? uniqueStrings([...band.initialKeys, ...keys]) : uniqueStrings(keys);
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
/**
|
|
1701
|
+
* Builds a normalized DOMRect-like object from two pointer coordinates.
|
|
1702
|
+
*
|
|
1703
|
+
* @param {number} x1 Start x coordinate.
|
|
1704
|
+
* @param {number} y1 Start y coordinate.
|
|
1705
|
+
* @param {number} x2 Current x coordinate.
|
|
1706
|
+
* @param {number} y2 Current y coordinate.
|
|
1707
|
+
* @returns {{ left: number, right: number, top: number, bottom: number }} Normalized rectangle.
|
|
1708
|
+
*/
|
|
1709
|
+
function normalizedRect(x1, y1, x2, y2) {
|
|
1710
|
+
return {
|
|
1711
|
+
left: Math.min(x1, x2),
|
|
1712
|
+
right: Math.max(x1, x2),
|
|
1713
|
+
top: Math.min(y1, y2),
|
|
1714
|
+
bottom: Math.max(y1, y2),
|
|
1715
|
+
};
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
/**
|
|
1719
|
+
* Checks whether two rectangles overlap.
|
|
1720
|
+
*
|
|
1721
|
+
* @param {{ left: number, right: number, top: number, bottom: number }} a First rectangle.
|
|
1722
|
+
* @param {DOMRect} b Second rectangle.
|
|
1723
|
+
* @returns {boolean} True when rectangles overlap.
|
|
1724
|
+
*/
|
|
1725
|
+
function rectanglesIntersect(a, b) {
|
|
1726
|
+
return a.left <= b.right && a.right >= b.left && a.top <= b.bottom && a.bottom >= b.top;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
/**
|
|
1730
|
+
* Resolves inline style for the active rubber-band rectangle.
|
|
1731
|
+
*
|
|
1732
|
+
* @returns {Record<string, string>|null} Vue style object.
|
|
1733
|
+
*/
|
|
1734
|
+
function bandStyle() {
|
|
1735
|
+
const band = bandSelection.value;
|
|
1736
|
+
if (!band) return null;
|
|
1737
|
+
const rect = normalizedRect(band.startX, band.startY, band.currentX, band.currentY);
|
|
1738
|
+
return {
|
|
1739
|
+
left: `${rect.left - band.originLeft}px`,
|
|
1740
|
+
top: `${rect.top - band.originTop}px`,
|
|
1741
|
+
width: `${rect.right - rect.left}px`,
|
|
1742
|
+
height: `${rect.bottom - rect.top}px`,
|
|
1743
|
+
};
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
/**
|
|
1747
|
+
* Returns the default SVG path for an item kind.
|
|
1748
|
+
*
|
|
1749
|
+
* @param {MediaBrowserItem} item Media row to render.
|
|
1750
|
+
* @returns {string} SVG path data.
|
|
1751
|
+
*/
|
|
1752
|
+
function iconPath(item) {
|
|
1753
|
+
if (itemKind(item) === 'folder') return 'M3.75 7.5h6l1.5 2h9v7.75a2 2 0 0 1-2 2H5.75a2 2 0 0 1-2-2V7.5Z';
|
|
1754
|
+
if (itemKind(item) === 'pdf') return 'M6 3.75h8l4 4v12.5H6V3.75Zm8 0v4h4M8.5 15.5h1.25a1.25 1.25 0 0 0 0-2.5H8.5v5M12.5 13v5h1.1a2.5 2.5 0 0 0 0-5h-1.1M16.75 13h2.5M16.75 15.5h2';
|
|
1755
|
+
return 'M7 3.75h7l3 3v13.5H7V3.75Zm7 0v3h3M9.5 12.5h5M9.5 15.5h3.5';
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
/**
|
|
1759
|
+
* Resolves a thumbnail URL for an image row.
|
|
1760
|
+
*
|
|
1761
|
+
* @param {MediaBrowserItem} item Media row to preview.
|
|
1762
|
+
* @returns {string} Thumbnail URL or empty string.
|
|
1763
|
+
*/
|
|
1764
|
+
function thumbnailUrl(item) {
|
|
1765
|
+
const file = itemFile(item);
|
|
1766
|
+
return file?.previewUrl || item?.previewUrl || file?.thumbnailUrl || file?.publicUrl || item?.thumbnailUrl || item?.publicUrl || '';
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
/**
|
|
1770
|
+
* Formats a file size for display.
|
|
1771
|
+
*
|
|
1772
|
+
* @param {number} size Raw byte count.
|
|
1773
|
+
* @returns {string} Human-readable size.
|
|
1774
|
+
*/
|
|
1775
|
+
function formatBytes(size) {
|
|
1776
|
+
const value = Number(size);
|
|
1777
|
+
if (!Number.isFinite(value) || value <= 0) return '—';
|
|
1778
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
1779
|
+
let amount = value;
|
|
1780
|
+
let unitIndex = 0;
|
|
1781
|
+
while (amount >= 1024 && unitIndex < units.length - 1) {
|
|
1782
|
+
amount /= 1024;
|
|
1783
|
+
unitIndex += 1;
|
|
1784
|
+
}
|
|
1785
|
+
return `${amount >= 10 || unitIndex === 0 ? Math.round(amount) : amount.toFixed(1)} ${units[unitIndex]}`;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
/**
|
|
1789
|
+
* Resolves image alt text from joined file metadata.
|
|
1790
|
+
*
|
|
1791
|
+
* @param {MediaBrowserItem|null} item Media row to inspect.
|
|
1792
|
+
* @returns {string} Alt text value.
|
|
1793
|
+
*/
|
|
1794
|
+
function imageAltText(item) {
|
|
1795
|
+
return String(itemFile(item)?.meta?.alt || item?.meta?.alt || '');
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
/**
|
|
1799
|
+
* Commits a metadata edit from the default inspector.
|
|
1800
|
+
*
|
|
1801
|
+
* @param {string} key Metadata key.
|
|
1802
|
+
* @param {unknown} value Metadata value.
|
|
1803
|
+
*/
|
|
1804
|
+
function commitMetadata(key, value) {
|
|
1805
|
+
if (!activeItem.value) return;
|
|
1806
|
+
emit('metadata-update', {
|
|
1807
|
+
item: activeItem.value,
|
|
1808
|
+
meta: {
|
|
1809
|
+
[key]: value,
|
|
1810
|
+
},
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
</script>
|
|
1814
|
+
|
|
1815
|
+
<template>
|
|
1816
|
+
<section
|
|
1817
|
+
class="overflow-hidden rounded-lg border border-border bg-canvas shadow-sm"
|
|
1818
|
+
:aria-labelledby="titleId"
|
|
1819
|
+
@dragover="onBrowserDragOver"
|
|
1820
|
+
@dragleave="onBrowserDragLeave"
|
|
1821
|
+
@drop="onBrowserDrop"
|
|
1822
|
+
>
|
|
1823
|
+
<header v-if="toolbar" class="border-b border-border bg-secondary/25 px-4 py-3">
|
|
1824
|
+
<div class="flex flex-wrap items-start justify-between gap-3">
|
|
1825
|
+
<div class="min-w-0">
|
|
1826
|
+
<h3 :id="titleId" class="text-sm font-semibold text-canvas-fg">{{ title }}</h3>
|
|
1827
|
+
<nav class="mt-2 flex min-w-0 flex-wrap items-center gap-1 text-sm" aria-label="Folder path">
|
|
1828
|
+
<template v-for="(crumb, index) in resolvedBreadcrumbs" :key="`${crumb.path}-${index}`">
|
|
1829
|
+
<button
|
|
1830
|
+
type="button"
|
|
1831
|
+
class="inline-flex items-center gap-1 rounded-md px-2 py-1 font-medium text-muted-fg transition hover:bg-secondary hover:text-canvas-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
|
|
1832
|
+
:class="[
|
|
1833
|
+
index === resolvedBreadcrumbs.length - 1 && 'bg-canvas text-canvas-fg ring-1 ring-border',
|
|
1834
|
+
breadcrumbDropPath === breadcrumbDropKey(crumb) && 'bg-primary/10 text-primary ring-2 ring-primary/45',
|
|
1835
|
+
]"
|
|
1836
|
+
@click="navigateToBreadcrumb(crumb, $event)"
|
|
1837
|
+
@dragover="onBreadcrumbDragOver(crumb, $event)"
|
|
1838
|
+
@dragleave="onBreadcrumbDragLeave(crumb, $event)"
|
|
1839
|
+
@drop="onBreadcrumbDrop(crumb, $event)"
|
|
1840
|
+
>
|
|
1841
|
+
<span>{{ crumb.label }}</span>
|
|
1842
|
+
<span
|
|
1843
|
+
v-if="breadcrumbDropPath === breadcrumbDropKey(crumb)"
|
|
1844
|
+
class="rounded-full bg-primary px-1.5 py-0.5 text-[0.625rem] font-semibold leading-none text-primary-fg"
|
|
1845
|
+
>
|
|
1846
|
+
Move
|
|
1847
|
+
</span>
|
|
1848
|
+
</button>
|
|
1849
|
+
<span v-if="index < resolvedBreadcrumbs.length - 1" class="text-muted-fg/60">/</span>
|
|
1850
|
+
</template>
|
|
1851
|
+
</nav>
|
|
1852
|
+
</div>
|
|
1853
|
+
|
|
1854
|
+
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
|
1855
|
+
<slot
|
|
1856
|
+
name="toolbar"
|
|
1857
|
+
:selected-items="selectedItems"
|
|
1858
|
+
:selected-keys="selectedKeys"
|
|
1859
|
+
:mode="renderMode"
|
|
1860
|
+
:current-path="currentPath"
|
|
1861
|
+
:set-mode="setMode"
|
|
1862
|
+
:view-mode-options="viewModeOptions"
|
|
1863
|
+
>
|
|
1864
|
+
<DomButton
|
|
1865
|
+
v-if="canCreateFolder"
|
|
1866
|
+
size="sm"
|
|
1867
|
+
variant="secondary"
|
|
1868
|
+
@click="emitCreateFolder"
|
|
1869
|
+
>
|
|
1870
|
+
New folder
|
|
1871
|
+
</DomButton>
|
|
1872
|
+
<DomToggleButtonGroup
|
|
1873
|
+
:model-value="renderMode"
|
|
1874
|
+
:options="viewModeOptions"
|
|
1875
|
+
label="Media layout"
|
|
1876
|
+
chrome="none"
|
|
1877
|
+
size="icon-sm"
|
|
1878
|
+
variant="switch"
|
|
1879
|
+
@update:model-value="setMode"
|
|
1880
|
+
>
|
|
1881
|
+
<template #option="{ option }">
|
|
1882
|
+
<svg viewBox="0 0 24 24" class="size-4" fill="none" aria-hidden="true">
|
|
1883
|
+
<path :d="option.icon" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
|
|
1884
|
+
</svg>
|
|
1885
|
+
<span class="sr-only">{{ option.label }}</span>
|
|
1886
|
+
</template>
|
|
1887
|
+
</DomToggleButtonGroup>
|
|
1888
|
+
</slot>
|
|
1889
|
+
</div>
|
|
1890
|
+
</div>
|
|
1891
|
+
|
|
1892
|
+
<div class="mt-3 flex flex-wrap items-center gap-3 text-xs text-muted-fg">
|
|
1893
|
+
<span>{{ selectionSummary }}</span>
|
|
1894
|
+
<span v-if="loading" class="inline-flex items-center gap-2">
|
|
1895
|
+
<span class="size-1.5 rounded-full bg-primary"></span>
|
|
1896
|
+
{{ loadingLabel }}
|
|
1897
|
+
</span>
|
|
1898
|
+
<span v-if="hasUploadProgress" class="inline-flex items-center gap-2">
|
|
1899
|
+
<span class="size-1.5 rounded-full bg-success"></span>
|
|
1900
|
+
{{ overallUploadProgress }}% uploading
|
|
1901
|
+
</span>
|
|
1902
|
+
</div>
|
|
1903
|
+
|
|
1904
|
+
<DomProgress
|
|
1905
|
+
v-if="hasUploadProgress"
|
|
1906
|
+
class="mt-3"
|
|
1907
|
+
size="sm"
|
|
1908
|
+
label=""
|
|
1909
|
+
:value="overallUploadProgress"
|
|
1910
|
+
tone="success"
|
|
1911
|
+
/>
|
|
1912
|
+
</header>
|
|
1913
|
+
|
|
1914
|
+
<div
|
|
1915
|
+
class="grid min-h-[32rem]"
|
|
1916
|
+
:class="inspector ? 'lg:grid-cols-[minmax(0,1fr)_18rem]' : 'lg:grid-cols-1'"
|
|
1917
|
+
>
|
|
1918
|
+
<DomContextMenu
|
|
1919
|
+
:items="contextMenuItems"
|
|
1920
|
+
:open-on-context-menu="hasContextMenuActions"
|
|
1921
|
+
width="min-w-60"
|
|
1922
|
+
@select="onContextSelect"
|
|
1923
|
+
>
|
|
1924
|
+
<div
|
|
1925
|
+
ref="viewport"
|
|
1926
|
+
class="relative min-h-[32rem] overflow-auto p-4 outline-none"
|
|
1927
|
+
:class="inspector ? 'lg:border-r lg:border-border' : ''"
|
|
1928
|
+
:aria-busy="loading ? 'true' : 'false'"
|
|
1929
|
+
@pointerdown="onViewportPointerDown"
|
|
1930
|
+
>
|
|
1931
|
+
<div
|
|
1932
|
+
v-if="externalDropActive && !dragTargetKey"
|
|
1933
|
+
class="pointer-events-none absolute inset-3 z-20 grid place-items-center rounded-lg border-2 border-dashed border-primary bg-primary/10 text-sm font-semibold text-primary"
|
|
1934
|
+
>
|
|
1935
|
+
Drop to upload into {{ currentPath }}
|
|
1936
|
+
</div>
|
|
1937
|
+
|
|
1938
|
+
<div
|
|
1939
|
+
v-if="canUpload && !inlineDetailItem"
|
|
1940
|
+
class="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-dashed border-border bg-secondary/25 px-3 py-2.5"
|
|
1941
|
+
data-media-browser-upload
|
|
1942
|
+
>
|
|
1943
|
+
<div class="flex min-w-0 items-center gap-3">
|
|
1944
|
+
<div class="grid size-9 shrink-0 place-items-center rounded-lg bg-primary/10 text-primary">
|
|
1945
|
+
<svg viewBox="0 0 24 24" class="size-5" fill="none" aria-hidden="true">
|
|
1946
|
+
<path d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5M5 14v4a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" />
|
|
1947
|
+
</svg>
|
|
1948
|
+
</div>
|
|
1949
|
+
<div class="min-w-0">
|
|
1950
|
+
<p class="text-sm font-semibold text-canvas-fg">Drag and drop files here</p>
|
|
1951
|
+
<p class="truncate text-xs text-muted-fg">Uploads will be added to {{ currentPath }}</p>
|
|
1952
|
+
</div>
|
|
1953
|
+
</div>
|
|
1954
|
+
<DomButton type="button" size="sm" variant="secondary" @click.stop="openUploadPicker">
|
|
1955
|
+
Upload files
|
|
1956
|
+
</DomButton>
|
|
1957
|
+
<input
|
|
1958
|
+
ref="uploadInput"
|
|
1959
|
+
type="file"
|
|
1960
|
+
class="hidden"
|
|
1961
|
+
:accept="uploadAccept"
|
|
1962
|
+
multiple
|
|
1963
|
+
@change="onUploadInputChange"
|
|
1964
|
+
/>
|
|
1965
|
+
</div>
|
|
1966
|
+
|
|
1967
|
+
<slot v-if="!sourceItems.length && !loading && !inlineDetailItem" name="empty">
|
|
1968
|
+
<DomEmptyState
|
|
1969
|
+
class="mt-10"
|
|
1970
|
+
:title="currentPath === '/' ? 'No media yet' : 'This folder is empty'"
|
|
1971
|
+
:description="emptyText"
|
|
1972
|
+
icon="M4 7h6l2 2h8v9H4V7Zm3 6h10M8 16h4"
|
|
1973
|
+
/>
|
|
1974
|
+
</slot>
|
|
1975
|
+
|
|
1976
|
+
<div
|
|
1977
|
+
v-if="showEmptyLoading && renderMode === 'grid'"
|
|
1978
|
+
class="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-4"
|
|
1979
|
+
aria-label="Loading media thumbnails"
|
|
1980
|
+
>
|
|
1981
|
+
<div v-for="index in 8" :key="index" class="flex min-h-[14.5rem] flex-col rounded-lg bg-secondary/35 p-3">
|
|
1982
|
+
<DomSkeleton class="min-h-32 flex-1" label="Loading media thumbnail" />
|
|
1983
|
+
<DomSkeleton class="mt-3" variant="text" :lines="2" label="Loading media details" />
|
|
1984
|
+
</div>
|
|
1985
|
+
</div>
|
|
1986
|
+
|
|
1987
|
+
<div
|
|
1988
|
+
v-else-if="showEmptyLoading"
|
|
1989
|
+
class="overflow-hidden rounded-lg border border-border"
|
|
1990
|
+
aria-label="Loading media rows"
|
|
1991
|
+
>
|
|
1992
|
+
<div class="grid grid-cols-[2.5rem_minmax(12rem,1fr)_9rem_7rem] bg-secondary/45 px-3 py-2 text-xs font-semibold uppercase text-muted-fg">
|
|
1993
|
+
<div></div>
|
|
1994
|
+
<div>Name</div>
|
|
1995
|
+
<div>Type</div>
|
|
1996
|
+
<div class="text-right">Size</div>
|
|
1997
|
+
</div>
|
|
1998
|
+
<div
|
|
1999
|
+
v-for="index in 8"
|
|
2000
|
+
:key="index"
|
|
2001
|
+
class="grid min-h-14 grid-cols-[2.5rem_minmax(12rem,1fr)_9rem_7rem] items-center gap-3 border-t border-border px-3 py-2"
|
|
2002
|
+
>
|
|
2003
|
+
<DomSkeleton variant="circle" width="2rem" height="2rem" label="Loading media icon" />
|
|
2004
|
+
<DomSkeleton variant="text" :lines="2" label="Loading media row name" />
|
|
2005
|
+
<DomSkeleton height="0.75rem" label="Loading media type" />
|
|
2006
|
+
<DomSkeleton height="0.75rem" label="Loading media size" />
|
|
2007
|
+
</div>
|
|
2008
|
+
</div>
|
|
2009
|
+
|
|
2010
|
+
<div v-else-if="inlineDetailItem && inlineDetailConfig?.component" class="min-h-[28rem]">
|
|
2011
|
+
<slot
|
|
2012
|
+
name="detail"
|
|
2013
|
+
:item="inlineDetailItem"
|
|
2014
|
+
:kind="itemKind(inlineDetailItem)"
|
|
2015
|
+
:file="itemFile(inlineDetailItem)"
|
|
2016
|
+
mode="inline"
|
|
2017
|
+
:close="clearInlineDetail"
|
|
2018
|
+
>
|
|
2019
|
+
<component
|
|
2020
|
+
:is="inlineDetailConfig.component"
|
|
2021
|
+
:item="inlineDetailItem"
|
|
2022
|
+
:kind="itemKind(inlineDetailItem)"
|
|
2023
|
+
:file="itemFile(inlineDetailItem)"
|
|
2024
|
+
mode="inline"
|
|
2025
|
+
@close="clearInlineDetail"
|
|
2026
|
+
/>
|
|
2027
|
+
</slot>
|
|
2028
|
+
</div>
|
|
2029
|
+
|
|
2030
|
+
<div
|
|
2031
|
+
v-else-if="renderMode === 'grid'"
|
|
2032
|
+
class="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-4"
|
|
2033
|
+
role="listbox"
|
|
2034
|
+
:aria-label="title"
|
|
2035
|
+
:aria-multiselectable="selectionMode === 'multiple' ? 'true' : 'false'"
|
|
2036
|
+
>
|
|
2037
|
+
<article
|
|
2038
|
+
v-for="item in sourceItems"
|
|
2039
|
+
:key="itemKey(item)"
|
|
2040
|
+
class="group relative flex min-h-[14.5rem] flex-col overflow-hidden rounded-lg border border-transparent bg-secondary/35 p-3 text-center outline-none transition hover:bg-secondary/45 hover:shadow-sm focus-visible:ring-2 focus-visible:ring-ring/60"
|
|
2041
|
+
:class="[
|
|
2042
|
+
selectedKeySet.has(itemKey(item)) && 'border-primary bg-primary/10 shadow-md ring-2 ring-primary/25',
|
|
2043
|
+
dragTargetKey === itemKey(item) && 'scale-[1.01] border-primary bg-primary/15 shadow-lg ring-2 ring-primary/45',
|
|
2044
|
+
isDraggedItem(item) && 'opacity-60 ring-2 ring-primary/15',
|
|
2045
|
+
!isSelectable(item) && item.type === 'file' && 'opacity-55',
|
|
2046
|
+
]"
|
|
2047
|
+
role="option"
|
|
2048
|
+
tabindex="0"
|
|
2049
|
+
data-media-browser-item
|
|
2050
|
+
:data-media-key="itemKey(item)"
|
|
2051
|
+
:aria-selected="selectedKeySet.has(itemKey(item)) ? 'true' : 'false'"
|
|
2052
|
+
:draggable="canDragItem(item)"
|
|
2053
|
+
@click="selectItem(item, $event)"
|
|
2054
|
+
@dblclick="openItem(item, $event)"
|
|
2055
|
+
@contextmenu="selectForContext(item)"
|
|
2056
|
+
@keydown="onItemKeydown(item, $event)"
|
|
2057
|
+
@dragstart="onItemDragStart(item, $event)"
|
|
2058
|
+
@dragend="onItemDragEnd"
|
|
2059
|
+
@dragover="onFolderDragOver(item, $event)"
|
|
2060
|
+
@dragleave="onFolderDragLeave(item, $event)"
|
|
2061
|
+
@drop="onFolderDrop(item, $event)"
|
|
2062
|
+
>
|
|
2063
|
+
<div
|
|
2064
|
+
v-if="dragTargetKey === itemKey(item) && itemKind(item) === 'folder'"
|
|
2065
|
+
class="pointer-events-none absolute inset-2 z-20 grid place-items-center rounded-lg border border-primary/60 bg-primary/15 text-xs font-semibold text-primary backdrop-blur-[1px]"
|
|
2066
|
+
>
|
|
2067
|
+
{{ dropTargetLabel() }}
|
|
2068
|
+
</div>
|
|
2069
|
+
|
|
2070
|
+
<slot
|
|
2071
|
+
name="item"
|
|
2072
|
+
:item="item"
|
|
2073
|
+
:kind="itemKind(item)"
|
|
2074
|
+
:selected="selectedKeySet.has(itemKey(item))"
|
|
2075
|
+
:disabled="!isSelectable(item)"
|
|
2076
|
+
:mode="renderMode"
|
|
2077
|
+
:rename="() => beginRename(item)"
|
|
2078
|
+
>
|
|
2079
|
+
<component
|
|
2080
|
+
v-if="itemComponentFor(item)"
|
|
2081
|
+
:is="itemComponentFor(item)"
|
|
2082
|
+
:item="item"
|
|
2083
|
+
:kind="itemKind(item)"
|
|
2084
|
+
:selected="selectedKeySet.has(itemKey(item))"
|
|
2085
|
+
:mode="renderMode"
|
|
2086
|
+
/>
|
|
2087
|
+
<div v-else class="flex min-h-0 flex-1 flex-col">
|
|
2088
|
+
<div class="relative grid min-h-32 flex-1 place-items-center overflow-hidden rounded-lg bg-canvas/45">
|
|
2089
|
+
<img
|
|
2090
|
+
v-if="itemKind(item) === 'image' && thumbnailUrl(item)"
|
|
2091
|
+
:src="thumbnailUrl(item)"
|
|
2092
|
+
:alt="imageAltText(item) || itemName(item)"
|
|
2093
|
+
class="max-h-full w-full object-contain p-2"
|
|
2094
|
+
:class="isUploading(item) && 'opacity-65'"
|
|
2095
|
+
/>
|
|
2096
|
+
<svg
|
|
2097
|
+
v-else-if="itemKind(item) === 'folder'"
|
|
2098
|
+
viewBox="0 0 96 84"
|
|
2099
|
+
class="size-24 text-primary"
|
|
2100
|
+
fill="none"
|
|
2101
|
+
aria-hidden="true"
|
|
2102
|
+
>
|
|
2103
|
+
<path :d="folderIconPaths.back" fill="currentColor" opacity="0.18" stroke="currentColor" stroke-width="2.2" stroke-linejoin="round" />
|
|
2104
|
+
<path :d="folderIconPaths.front" fill="var(--canvas)" stroke="currentColor" stroke-width="2.4" stroke-linejoin="round" />
|
|
2105
|
+
<path :d="folderIconPaths.shadow" stroke="currentColor" stroke-width="5" stroke-linecap="round" opacity="0.08" />
|
|
2106
|
+
</svg>
|
|
2107
|
+
<div v-else class="grid size-16 place-items-center rounded-2xl bg-canvas/85 text-muted-fg ring-1 ring-border/70">
|
|
2108
|
+
<svg viewBox="0 0 24 24" class="size-9" fill="none" aria-hidden="true">
|
|
2109
|
+
<path :d="iconPath(item)" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
2110
|
+
</svg>
|
|
2111
|
+
</div>
|
|
2112
|
+
<div
|
|
2113
|
+
v-if="isUploading(item)"
|
|
2114
|
+
class="absolute inset-0 grid place-items-center bg-canvas/45 backdrop-blur-[1px]"
|
|
2115
|
+
>
|
|
2116
|
+
<DomRadialProgress
|
|
2117
|
+
:value="clampProgress(item.uploadProgress)"
|
|
2118
|
+
tone="success"
|
|
2119
|
+
size="md"
|
|
2120
|
+
:label="`${itemName(item)} upload progress`"
|
|
2121
|
+
/>
|
|
2122
|
+
</div>
|
|
2123
|
+
</div>
|
|
2124
|
+
</div>
|
|
2125
|
+
</slot>
|
|
2126
|
+
|
|
2127
|
+
<div class="mt-3 min-h-[3.25rem] min-w-0">
|
|
2128
|
+
<input
|
|
2129
|
+
v-if="renamingKey === itemKey(item)"
|
|
2130
|
+
v-model="renameDraft"
|
|
2131
|
+
data-media-browser-rename
|
|
2132
|
+
class="skin-input !h-7 !rounded-md !px-2 !py-0 text-center text-sm font-semibold leading-5"
|
|
2133
|
+
@keydown.enter.prevent="commitRename(item)"
|
|
2134
|
+
@keydown.esc.prevent="cancelRename"
|
|
2135
|
+
@blur="commitRename(item)"
|
|
2136
|
+
@click.stop
|
|
2137
|
+
/>
|
|
2138
|
+
<button
|
|
2139
|
+
v-else
|
|
2140
|
+
type="button"
|
|
2141
|
+
class="mx-auto block w-full truncate rounded-md px-1 py-0.5 text-center text-sm font-semibold text-canvas-fg outline-none hover:bg-canvas/60 focus-visible:ring-2 focus-visible:ring-ring/60"
|
|
2142
|
+
@click.stop="onNameClick(item, $event)"
|
|
2143
|
+
@dblclick.stop="openItem(item, $event)"
|
|
2144
|
+
>
|
|
2145
|
+
{{ itemName(item) }}
|
|
2146
|
+
</button>
|
|
2147
|
+
<div class="mt-1 flex min-w-0 items-center justify-center gap-2 text-xs text-muted-fg">
|
|
2148
|
+
<span class="truncate">{{ itemKind(item) === 'folder' ? 'Folder' : itemMime(item) || 'File' }}</span>
|
|
2149
|
+
<span v-if="itemFile(item)?.size" class="shrink-0">{{ formatBytes(itemFile(item).size) }}</span>
|
|
2150
|
+
<span v-else-if="item.status === 'saving'" class="shrink-0">Saving</span>
|
|
2151
|
+
</div>
|
|
2152
|
+
</div>
|
|
2153
|
+
</article>
|
|
2154
|
+
</div>
|
|
2155
|
+
|
|
2156
|
+
<div v-else role="listbox" :aria-label="title" class="overflow-hidden rounded-lg border border-border">
|
|
2157
|
+
<div class="grid grid-cols-[2.5rem_minmax(12rem,1fr)_9rem_7rem] bg-secondary/45 px-3 py-2 text-xs font-semibold uppercase text-muted-fg">
|
|
2158
|
+
<div></div>
|
|
2159
|
+
<div>Name</div>
|
|
2160
|
+
<div>Type</div>
|
|
2161
|
+
<div class="text-right">Size</div>
|
|
2162
|
+
</div>
|
|
2163
|
+
<div
|
|
2164
|
+
v-for="item in sourceItems"
|
|
2165
|
+
:key="itemKey(item)"
|
|
2166
|
+
class="relative grid min-h-14 cursor-default grid-cols-[2.5rem_minmax(12rem,1fr)_9rem_7rem] items-center gap-0 border-t border-border px-3 py-2 outline-none transition hover:bg-secondary/35 focus-visible:ring-2 focus-visible:ring-ring/60"
|
|
2167
|
+
:class="[
|
|
2168
|
+
selectedKeySet.has(itemKey(item)) && 'bg-primary/5 text-primary',
|
|
2169
|
+
dragTargetKey === itemKey(item) && 'bg-primary/15 ring-2 ring-inset ring-primary/45',
|
|
2170
|
+
isDraggedItem(item) && 'opacity-60',
|
|
2171
|
+
!isSelectable(item) && item.type === 'file' && 'opacity-55',
|
|
2172
|
+
]"
|
|
2173
|
+
role="option"
|
|
2174
|
+
tabindex="0"
|
|
2175
|
+
data-media-browser-item
|
|
2176
|
+
:data-media-key="itemKey(item)"
|
|
2177
|
+
:aria-selected="selectedKeySet.has(itemKey(item)) ? 'true' : 'false'"
|
|
2178
|
+
:draggable="canDragItem(item)"
|
|
2179
|
+
@click="selectItem(item, $event)"
|
|
2180
|
+
@dblclick="openItem(item, $event)"
|
|
2181
|
+
@contextmenu="selectForContext(item)"
|
|
2182
|
+
@keydown="onItemKeydown(item, $event)"
|
|
2183
|
+
@dragstart="onItemDragStart(item, $event)"
|
|
2184
|
+
@dragend="onItemDragEnd"
|
|
2185
|
+
@dragover="onFolderDragOver(item, $event)"
|
|
2186
|
+
@dragleave="onFolderDragLeave(item, $event)"
|
|
2187
|
+
@drop="onFolderDrop(item, $event)"
|
|
2188
|
+
>
|
|
2189
|
+
<div
|
|
2190
|
+
v-if="dragTargetKey === itemKey(item) && itemKind(item) === 'folder'"
|
|
2191
|
+
class="pointer-events-none absolute right-3 top-1/2 z-20 -translate-y-1/2 rounded-full bg-primary px-2 py-1 text-xs font-semibold text-primary-fg shadow-sm"
|
|
2192
|
+
>
|
|
2193
|
+
{{ dropTargetLabel() }}
|
|
2194
|
+
</div>
|
|
2195
|
+
|
|
2196
|
+
<div class="relative grid size-8 place-items-center overflow-hidden rounded-md bg-secondary text-muted-fg">
|
|
2197
|
+
<img
|
|
2198
|
+
v-if="itemKind(item) === 'image' && thumbnailUrl(item)"
|
|
2199
|
+
:src="thumbnailUrl(item)"
|
|
2200
|
+
:alt="imageAltText(item) || itemName(item)"
|
|
2201
|
+
class="size-full object-cover"
|
|
2202
|
+
:class="isUploading(item) && 'opacity-65'"
|
|
2203
|
+
/>
|
|
2204
|
+
<svg v-else viewBox="0 0 24 24" class="size-5" fill="none" aria-hidden="true">
|
|
2205
|
+
<path :d="iconPath(item)" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
2206
|
+
</svg>
|
|
2207
|
+
<div
|
|
2208
|
+
v-if="isUploading(item)"
|
|
2209
|
+
class="absolute inset-0 grid place-items-center bg-canvas/55 backdrop-blur-[1px]"
|
|
2210
|
+
>
|
|
2211
|
+
<DomRadialProgress
|
|
2212
|
+
:value="clampProgress(item.uploadProgress)"
|
|
2213
|
+
tone="success"
|
|
2214
|
+
:size="30"
|
|
2215
|
+
:show-value="false"
|
|
2216
|
+
:label="`${itemName(item)} upload progress`"
|
|
2217
|
+
/>
|
|
2218
|
+
</div>
|
|
2219
|
+
</div>
|
|
2220
|
+
<div class="min-w-0">
|
|
2221
|
+
<input
|
|
2222
|
+
v-if="renamingKey === itemKey(item)"
|
|
2223
|
+
v-model="renameDraft"
|
|
2224
|
+
data-media-browser-rename
|
|
2225
|
+
class="skin-input !h-7 !rounded-md !px-2 !py-0 text-sm font-semibold leading-5"
|
|
2226
|
+
@keydown.enter.prevent="commitRename(item)"
|
|
2227
|
+
@keydown.esc.prevent="cancelRename"
|
|
2228
|
+
@blur="commitRename(item)"
|
|
2229
|
+
@click.stop
|
|
2230
|
+
/>
|
|
2231
|
+
<button
|
|
2232
|
+
v-else
|
|
2233
|
+
type="button"
|
|
2234
|
+
class="block w-full truncate rounded-md px-1 py-0.5 text-left text-sm font-semibold text-canvas-fg outline-none hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring/60"
|
|
2235
|
+
@click.stop="onNameClick(item, $event)"
|
|
2236
|
+
@dblclick.stop="openItem(item, $event)"
|
|
2237
|
+
>
|
|
2238
|
+
{{ itemName(item) }}
|
|
2239
|
+
</button>
|
|
2240
|
+
<p v-if="isUploading(item)" class="mt-1 text-xs text-muted-fg">{{ clampProgress(item.uploadProgress) }}% uploaded</p>
|
|
2241
|
+
</div>
|
|
2242
|
+
<div class="truncate text-sm text-muted-fg">{{ itemKind(item) === 'folder' ? 'Folder' : itemMime(item) || 'File' }}</div>
|
|
2243
|
+
<div class="text-right text-sm text-muted-fg">{{ itemFile(item)?.size ? formatBytes(itemFile(item).size) : '—' }}</div>
|
|
2244
|
+
</div>
|
|
2245
|
+
</div>
|
|
2246
|
+
|
|
2247
|
+
<div
|
|
2248
|
+
v-if="bandSelection"
|
|
2249
|
+
class="pointer-events-none absolute z-30 rounded-md border border-primary bg-primary/10"
|
|
2250
|
+
:style="bandStyle()"
|
|
2251
|
+
></div>
|
|
2252
|
+
</div>
|
|
2253
|
+
</DomContextMenu>
|
|
2254
|
+
|
|
2255
|
+
<aside
|
|
2256
|
+
v-if="inspector"
|
|
2257
|
+
class="min-h-[32rem] bg-secondary/20 p-4"
|
|
2258
|
+
:class="activeItem ? '' : 'hidden lg:block'"
|
|
2259
|
+
>
|
|
2260
|
+
<slot
|
|
2261
|
+
v-if="activeItem"
|
|
2262
|
+
name="inspector"
|
|
2263
|
+
:item="activeItem"
|
|
2264
|
+
:kind="itemKind(activeItem)"
|
|
2265
|
+
:file="itemFile(activeItem)"
|
|
2266
|
+
:selected-items="selectedItems"
|
|
2267
|
+
>
|
|
2268
|
+
<component
|
|
2269
|
+
v-if="inspectorComponentFor(activeItem)"
|
|
2270
|
+
:is="inspectorComponentFor(activeItem)"
|
|
2271
|
+
:item="activeItem"
|
|
2272
|
+
:kind="itemKind(activeItem)"
|
|
2273
|
+
:file="itemFile(activeItem)"
|
|
2274
|
+
:selected-items="selectedItems"
|
|
2275
|
+
/>
|
|
2276
|
+
<div v-else class="space-y-4">
|
|
2277
|
+
<div>
|
|
2278
|
+
<p class="text-xs font-semibold uppercase text-muted-fg">Inspector</p>
|
|
2279
|
+
<h4 class="mt-1 truncate text-base font-semibold text-canvas-fg">{{ itemName(activeItem) }}</h4>
|
|
2280
|
+
</div>
|
|
2281
|
+
<dl class="space-y-3 text-sm">
|
|
2282
|
+
<div>
|
|
2283
|
+
<dt class="text-xs font-medium uppercase text-muted-fg">Media row</dt>
|
|
2284
|
+
<dd class="mt-1 break-all font-mono text-xs text-canvas-fg">{{ activeItem.ulid }}</dd>
|
|
2285
|
+
</div>
|
|
2286
|
+
<div>
|
|
2287
|
+
<dt class="text-xs font-medium uppercase text-muted-fg">Path</dt>
|
|
2288
|
+
<dd class="mt-1 break-all text-canvas-fg">{{ activeItem.path }}</dd>
|
|
2289
|
+
</div>
|
|
2290
|
+
<div v-if="itemFile(activeItem)">
|
|
2291
|
+
<dt class="text-xs font-medium uppercase text-muted-fg">File</dt>
|
|
2292
|
+
<dd class="mt-1 text-canvas-fg">{{ itemMime(activeItem) || 'Unknown type' }} · {{ formatBytes(itemFile(activeItem).size) }}</dd>
|
|
2293
|
+
</div>
|
|
2294
|
+
<div v-if="itemFile(activeItem)?.publicUrl">
|
|
2295
|
+
<dt class="text-xs font-medium uppercase text-muted-fg">Public URL</dt>
|
|
2296
|
+
<dd class="mt-1 break-all text-primary">{{ itemFile(activeItem).publicUrl }}</dd>
|
|
2297
|
+
</div>
|
|
2298
|
+
</dl>
|
|
2299
|
+
<label v-if="itemKind(activeItem) === 'image'" class="block text-sm">
|
|
2300
|
+
<span class="font-medium text-canvas-fg">Alt text</span>
|
|
2301
|
+
<textarea
|
|
2302
|
+
v-model="inspectorDraft.alt"
|
|
2303
|
+
class="skin-input mt-2 min-h-24 w-full resize-y py-2"
|
|
2304
|
+
@change="commitMetadata('alt', inspectorDraft.alt)"
|
|
2305
|
+
></textarea>
|
|
2306
|
+
</label>
|
|
2307
|
+
</div>
|
|
2308
|
+
</slot>
|
|
2309
|
+
<div v-else class="flex h-full min-h-[28rem] items-center justify-center text-center text-sm text-muted-fg">
|
|
2310
|
+
Select an item to inspect its details.
|
|
2311
|
+
</div>
|
|
2312
|
+
</aside>
|
|
2313
|
+
</div>
|
|
2314
|
+
|
|
2315
|
+
<DomDialog
|
|
2316
|
+
v-if="dialogDetailConfig?.component"
|
|
2317
|
+
v-model="detailDialogOpen"
|
|
2318
|
+
:title="dialogDetailTitle"
|
|
2319
|
+
:size="dialogDetailConfig.size || 'xl'"
|
|
2320
|
+
:width="dialogDetailConfig.width || ''"
|
|
2321
|
+
:height="dialogDetailConfig.height || ''"
|
|
2322
|
+
:footer="false"
|
|
2323
|
+
@close-complete="clearDialogDetail"
|
|
2324
|
+
>
|
|
2325
|
+
<slot
|
|
2326
|
+
name="detail"
|
|
2327
|
+
:item="dialogDetailItem"
|
|
2328
|
+
:kind="itemKind(dialogDetailItem)"
|
|
2329
|
+
:file="itemFile(dialogDetailItem)"
|
|
2330
|
+
mode="dialog"
|
|
2331
|
+
:close="clearDialogDetail"
|
|
2332
|
+
>
|
|
2333
|
+
<component
|
|
2334
|
+
:is="dialogDetailConfig.component"
|
|
2335
|
+
:item="dialogDetailItem"
|
|
2336
|
+
:kind="itemKind(dialogDetailItem)"
|
|
2337
|
+
:file="itemFile(dialogDetailItem)"
|
|
2338
|
+
mode="dialog"
|
|
2339
|
+
@close="clearDialogDetail"
|
|
2340
|
+
/>
|
|
2341
|
+
</slot>
|
|
2342
|
+
</DomDialog>
|
|
2343
|
+
</section>
|
|
2344
|
+
</template>
|