Files
kite/frontend/src/components/storage/StorageBrowser.vue
T
curo1305 44244335a1 feat(12-03): capability-aware action rendering in StorageBrowser
- Add capabilities, connectionRoot, folderFreshness, lastRefreshedAt, byteAvailability props
- CapabilityButton inline component: aria-disabled, click/Enter/Space/touch suppression
- Gray for unsupported, amber for temporarily_unavailable — no native disabled
- Local defaults preserve all pre-Phase-12 drag/share/move/delete behavior
- Cached-byte marker (clock icon) on size column when byteAvailability='cached'
- No marker for cloud_only
- AppIcon: add info, clock icons
- 50 StorageBrowser tests pass
2026-06-18 23:29:10 +02:00

694 lines
27 KiB
Vue

<template>
<div class="flex flex-col h-full">
<div class="sticky top-0 z-10 bg-white border-b border-gray-100">
<div class="px-4 sm:px-6 py-3 space-y-2">
<div class="flex items-center gap-3 min-w-0">
<div class="min-w-0 flex-1">
<BreadcrumbBar
:segments="breadcrumb"
:root-label="connectionRoot ? connectionRoot.name : (mode === 'cloud' ? 'Cloud' : 'Home')"
:show-root="true"
:folder-freshness="folderFreshness"
:last-refreshed-at="lastRefreshedAt"
@navigate="$emit('breadcrumb-navigate', $event)"
/>
</div>
<div class="hidden sm:flex items-center gap-2 shrink-0" data-test="desktop-toolbar">
<SearchBar v-if="showSearch" ref="searchBarRef" :model-value="searchQuery" @update:modelValue="$emit('search-change', $event)" />
<SortControls
v-if="showSearch"
:sort="sortField"
:order="sortOrder"
@change="handleSortChange"
/>
<button
v-if="effectiveCaps.create_folder"
@click="$emit('new-folder')"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-indigo-600 border border-indigo-200 hover:bg-indigo-50 active:bg-indigo-100 rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
>
<AppIcon name="plus" class="w-4 h-4" />
New folder
</button>
</div>
<div class="sm:hidden flex items-center gap-1 shrink-0" data-test="mobile-toolbar">
<button
v-if="showSearch"
type="button"
title="Search"
aria-label="Search"
:aria-expanded="mobileSearchOpen"
class="w-9 h-9 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 active:bg-gray-100 flex items-center justify-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
@click="mobileSearchOpen = !mobileSearchOpen"
>
<AppIcon name="search" class="w-4 h-4" />
</button>
<button
v-if="showSearch"
type="button"
title="Sort"
aria-label="Sort"
:aria-expanded="mobileSortOpen"
class="w-9 h-9 rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 active:bg-gray-100 flex items-center justify-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
@click="mobileSortOpen = !mobileSortOpen"
>
<AppIcon name="chartBar" class="w-4 h-4" />
</button>
<button
v-if="effectiveCaps.create_folder"
type="button"
title="New folder"
aria-label="New folder"
class="w-9 h-9 rounded-lg border border-indigo-200 text-indigo-600 hover:bg-indigo-50 active:bg-indigo-100 flex items-center justify-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
@click="$emit('new-folder')"
>
<AppIcon name="plus" class="w-4 h-4" />
</button>
</div>
</div>
<div v-if="showSearch && mobileSearchOpen" class="sm:hidden w-full" data-test="mobile-search-row">
<SearchBar ref="searchBarRef" :model-value="searchQuery" @update:modelValue="$emit('search-change', $event)" />
</div>
<div
v-if="showSearch && mobileSortOpen"
class="sm:hidden w-full rounded-lg border border-gray-200 bg-gray-50 px-2 py-2"
data-test="mobile-sort-panel"
>
<SortControls
:sort="sortField"
:order="sortOrder"
@change="handleSortChange"
/>
</div>
</div>
</div>
<!-- Capability explanation banner (touch tap or keyboard on disabled action) -->
<transition name="fade">
<div
v-if="capabilityNotice"
class="mx-4 sm:mx-6 mt-2 px-4 py-2.5 rounded-lg border flex items-start gap-2"
:class="capabilityNotice.type === 'temporary' ? 'bg-amber-50 border-amber-200 text-amber-800' : 'bg-gray-50 border-gray-200 text-gray-600'"
role="alert"
data-test="capability-notice"
>
<AppIcon name="info" class="w-4 h-4 shrink-0 mt-0.5" :class="capabilityNotice.type === 'temporary' ? 'text-amber-500' : 'text-gray-400'" />
<p class="text-sm">{{ capabilityNotice.message }}</p>
<button class="ml-auto text-xs opacity-60 hover:opacity-100" @click="capabilityNotice = null" aria-label="Dismiss">
<AppIcon name="x" class="w-3 h-3" />
</button>
</div>
</transition>
<div class="flex-1 overflow-y-auto flex flex-col">
<div class="px-4 sm:px-6 pt-5 pb-3">
<DropZone ref="dropZoneRef" @files-selected="$emit('upload', $event)" />
<UploadProgress :items="uploadQueue" />
</div>
<div class="mx-4 sm:mx-6 px-4 py-2 grid grid-cols-[2rem_minmax(0,1fr)_7rem] sm:grid-cols-[2rem_minmax(0,1fr)_8rem_7rem] md:grid-cols-[2rem_minmax(0,1fr)_6rem_8rem_7rem] gap-3 items-center rounded-lg bg-gray-50 text-xs font-semibold text-gray-400 uppercase tracking-wider select-none" data-test="list-header">
<span></span>
<span>Name</span>
<span class="text-right hidden md:block">Size</span>
<span class="text-right hidden sm:block">Modified</span>
<span></span>
</div>
<div v-if="showNewFolderInput" class="mx-4 sm:mx-6 mt-1 px-4 py-2.5 grid grid-cols-[2rem_minmax(0,1fr)_7rem] sm:grid-cols-[2rem_minmax(0,1fr)_8rem_7rem] md:grid-cols-[2rem_minmax(0,1fr)_6rem_8rem_7rem] gap-3 items-center rounded-lg border border-amber-200 bg-amber-50/40">
<div class="w-7 h-7 bg-amber-50 rounded-lg flex items-center justify-center">
<AppIcon name="folder" class="w-4 h-4 text-amber-400" />
</div>
<div class="flex items-center gap-2 col-span-2 sm:col-span-3 md:col-span-4 min-w-0 flex-wrap">
<input
ref="newFolderInputRef"
v-model="newFolderName"
type="text"
placeholder="Folder name"
class="border border-gray-300 rounded-lg px-2 py-1 text-sm min-w-0 flex-1 focus:outline-none focus:ring-2 focus:ring-indigo-500"
@keydown.enter="submitNewFolder"
@keydown.escape="cancelNewFolder"
/>
<button @click="submitNewFolder" class="text-sm text-indigo-600 hover:underline font-medium">Save</button>
<button @click="cancelNewFolder" class="text-sm text-gray-500 hover:text-gray-700">Cancel</button>
<p v-if="newFolderError" class="text-xs text-red-500">{{ newFolderError }}</p>
</div>
</div>
<div class="mx-4 sm:mx-6 mt-1 mb-6 flex flex-col divide-y divide-gray-100 border border-gray-100 rounded-xl overflow-hidden">
<div
v-for="folder in folders"
:key="`f-${folder.id}`"
class="px-4 py-2.5 grid grid-cols-[2rem_minmax(0,1fr)_7rem] sm:grid-cols-[2rem_minmax(0,1fr)_8rem_7rem] md:grid-cols-[2rem_minmax(0,1fr)_6rem_8rem_7rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors"
:class="{
'bg-amber-50 ring-2 ring-inset ring-amber-300': dragOverFolderId === folder.id,
'bg-gray-50': renamingId === folder.id,
}"
@click="renamingId === folder.id ? null : $emit('folder-navigate', folder)"
@dragover.prevent="effectiveCaps.drag_move ? onFolderDragOver(folder.id, $event) : null"
@dragleave="dragOverFolderId = null"
@drop.prevent="effectiveCaps.drag_move ? onDropDocOnFolder(folder.id) : null"
>
<div class="w-7 h-7 bg-amber-50 rounded-lg flex items-center justify-center shrink-0">
<AppIcon name="folder" class="w-4 h-4 text-amber-500" />
</div>
<div>
<input
v-if="renamingId === folder.id"
v-model="renameValue"
class="border border-indigo-300 rounded-lg px-2 py-0.5 text-sm w-full max-w-xs focus:outline-none focus:ring-2 focus:ring-indigo-500"
@keydown.enter="$emit('folder-rename', { id: folder.id, name: renameValue.trim() }); renamingId = null"
@keydown.escape="renamingId = null"
@vue:mounted="e => e.el?.focus()"
/>
<span v-else class="text-sm font-medium text-gray-900 truncate block">{{ folder.name }}</span>
</div>
<span class="text-right text-xs text-gray-400 hidden md:block"></span>
<span class="text-right text-xs text-gray-400 hidden sm:block">{{ formatDate(folder.created_at) }}</span>
<div class="flex justify-end gap-0.5" data-test="folder-row-actions" @click.stop>
<!-- Rename folder supported only when rename cap is active -->
<CapabilityButton
:cap="effectiveCaps.rename_folder ? 'supported' : 'unsupported'"
title="Rename"
:message="capMessage('rename_folder')"
@action="startRename(folder)"
@explain="showCapabilityNotice"
>
<AppIcon name="pencil" class="w-3.5 h-3.5" />
</CapabilityButton>
<!-- Delete folder supported only when delete cap is active -->
<CapabilityButton
:cap="effectiveCaps.delete_folder ? 'supported' : 'unsupported'"
title="Delete folder"
:message="capMessage('delete_folder')"
variant="danger"
@action="$emit('folder-delete', folder)"
@explain="showCapabilityNotice"
>
<AppIcon name="trash" class="w-3.5 h-3.5" />
</CapabilityButton>
</div>
</div>
<!-- File rows -->
<div
v-for="file in files"
:key="`d-${file.id}`"
:draggable="effectiveCaps.drag_move"
class="px-4 py-2.5 grid grid-cols-[2rem_minmax(0,1fr)_7rem] sm:grid-cols-[2rem_minmax(0,1fr)_8rem_7rem] md:grid-cols-[2rem_minmax(0,1fr)_6rem_8rem_7rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors select-none"
:class="{ 'opacity-50': draggingFile?.id === file.id }"
@click="draggingFile ? null : $emit('file-open', file)"
@dragstart="effectiveCaps.drag_move ? onFileDragStart(file, $event) : null"
@dragend="onFileDragEnd"
>
<div class="w-7 h-7 bg-indigo-50 rounded-lg flex items-center justify-center shrink-0">
<AppIcon name="document" class="w-4 h-4 text-indigo-400" />
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<p class="text-sm font-medium text-gray-900 truncate">{{ file.original_name ?? file.name }}</p>
<span v-if="file.is_shared" class="shrink-0 bg-indigo-50 text-indigo-600 text-xs font-medium px-2 py-0.5 rounded-full">Shared</span>
</div>
<div v-if="file.topics?.length" class="flex items-center gap-1 mt-0.5 flex-wrap">
<TopicBadge
v-for="t in file.topics.slice(0, 3)"
:key="t"
:name="t"
:color="topicColor(t)"
/>
</div>
</div>
<span class="text-right text-xs text-gray-400 hidden md:block">
{{ formatSize(file.size_bytes ?? file.size) }}
<!-- Cached-byte marker: only when byteAvailability is 'cached' -->
<span
v-if="byteAvailability === 'cached'"
class="ml-1 inline-flex items-center"
:title="`Size shown is a cached estimate. Last synced: ${lastRefreshedAt ? formatDate(lastRefreshedAt) : 'unknown'}`"
data-test="cached-byte-marker"
>
<AppIcon name="clock" class="w-3 h-3 text-amber-400" />
</span>
</span>
<span class="text-right text-xs text-gray-400 hidden sm:block">{{ formatDate(file.created_at) }}</span>
<div class="flex justify-end gap-0.5" data-test="file-row-actions" @click.stop>
<!-- Share -->
<CapabilityButton
:cap="fileCapState('share')"
title="Share"
:message="capMessage('share')"
@action="$emit('file-share', file)"
@explain="showCapabilityNotice"
>
<AppIcon name="share" class="w-3.5 h-3.5" />
</CapabilityButton>
<!-- Move to folder -->
<div class="relative">
<CapabilityButton
:cap="fileCapState('move')"
title="Move to folder"
:message="capMessage('move')"
@action="openFolderPicker(file.id, $event)"
@explain="showCapabilityNotice"
>
<AppIcon name="folderMove" class="w-3.5 h-3.5" />
</CapabilityButton>
</div>
<!-- Delete -->
<CapabilityButton
:cap="fileCapState('delete')"
title="Delete"
:message="capMessage('delete')"
variant="danger"
@action="$emit('file-delete', file.id)"
@explain="showCapabilityNotice"
>
<AppIcon name="trash" class="w-3.5 h-3.5" />
</CapabilityButton>
</div>
</div>
<template v-if="loading">
<div
v-for="n in 5"
:key="`sk-${n}`"
class="px-4 py-2.5 grid grid-cols-[2rem_minmax(0,1fr)_7rem] sm:grid-cols-[2rem_minmax(0,1fr)_8rem_7rem] md:grid-cols-[2rem_minmax(0,1fr)_6rem_8rem_7rem] gap-3 items-center border-b border-gray-100"
>
<div class="w-7 h-7 bg-gray-100 rounded-lg animate-pulse"></div>
<div class="h-4 bg-gray-100 rounded animate-pulse w-2/3"></div>
<div class="h-3 bg-gray-100 rounded animate-pulse hidden md:block"></div>
<div class="h-3 bg-gray-100 rounded animate-pulse hidden sm:block"></div>
<div class="w-14 h-3 bg-gray-100 rounded animate-pulse"></div>
</div>
</template>
<EmptyState
v-else-if="searchQuery && folders.length === 0 && files.length === 0"
icon="search"
:headline="`No results for &quot;${searchQuery}&quot;`"
subtext="Try a different search term or clear the filter."
>
<template #cta>
<button @click="$emit('search-change', '')" class="mt-3 text-sm text-indigo-600 hover:underline">
Clear search
</button>
</template>
</EmptyState>
<EmptyState
v-else-if="breadcrumb.length > 0 && folders.length === 0 && files.length === 0 && !showNewFolderInput"
icon="document"
:headline="emptyMessage"
:subtext="emptyHint"
/>
<EmptyState
v-else-if="folders.length === 0 && files.length === 0 && !showNewFolderInput"
icon="folder"
:headline="emptyMessage"
:subtext="emptyHint"
/>
</div>
</div>
</div>
<Teleport to="body">
<div
v-if="folderPickerFileId"
:style="pickerStyle"
data-test="folder-picker"
class="fixed z-[9999] bg-white border border-gray-200 rounded-xl shadow-lg overflow-y-auto py-1"
style="max-height: 240px;"
@click.stop
>
<button
class="w-full text-left px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 active:bg-gray-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500"
@click.stop="$emit('file-move', { fileId: folderPickerFileId, folderId: null }); folderPickerFileId = null"
>
Root (no folder)
</button>
<button
v-for="f in rootFolders"
:key="f.id"
class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-indigo-50 hover:text-indigo-700 active:bg-indigo-100 truncate focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500"
@click.stop="$emit('file-move', { fileId: folderPickerFileId, folderId: f.id }); folderPickerFileId = null"
>{{ f.name }}</button>
<p v-if="!rootFolders.length" class="px-3 py-2 text-xs text-gray-400">No folders yet</p>
</div>
</Teleport>
</template>
<script setup>
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
import AppIcon from '../ui/AppIcon.vue'
import BreadcrumbBar from '../ui/BreadcrumbBar.vue'
import EmptyState from '../ui/EmptyState.vue'
import SearchBar from '../documents/SearchBar.vue'
import SortControls from '../documents/SortControls.vue'
import DropZone from '../upload/DropZone.vue'
import UploadProgress from '../upload/UploadProgress.vue'
import TopicBadge from '../topics/TopicBadge.vue'
import { formatDate, formatSize } from '../../utils/formatters.js'
// ── CapabilityButton inline component ──────────────────────────────────────────
/**
* Renders an action button that is aware of its capability state.
* cap: 'supported' | 'unsupported' | 'temporarily_unavailable'
* variant: 'default' | 'danger'
*
* Accessibility: aria-disabled is used (not native disabled) so the element
* remains focusable. Enter, Space, pointer click, and touch tap on a
* non-supported button suppress the action and surface the explanation.
*/
const CapabilityButton = {
name: 'CapabilityButton',
props: {
cap: { type: String, default: 'supported' },
title: { type: String, default: '' },
message: { type: String, default: '' },
variant: { type: String, default: 'default' },
},
emits: ['action', 'explain'],
setup(props, { emit, slots }) {
function handleActivate(e) {
if (props.cap !== 'supported') {
e.stopPropagation()
emit('explain', { message: props.message, type: props.cap === 'temporarily_unavailable' ? 'temporary' : 'unsupported' })
return
}
emit('action', e)
}
function handleKeydown(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleActivate(e)
}
}
return () => {
const isDisabled = props.cap !== 'supported'
const colorClass = isDisabled
? (props.cap === 'temporarily_unavailable'
? 'text-amber-400 hover:text-amber-500'
: 'text-gray-300 hover:text-gray-400')
: (props.variant === 'danger'
? 'text-gray-400 hover:text-red-500 hover:bg-red-50 active:bg-red-100 focus-visible:ring-red-500'
: 'text-gray-400 hover:text-gray-700 hover:bg-gray-200 active:bg-gray-300 focus-visible:ring-indigo-500')
const h = Vue.h
return h('button', {
type: 'button',
title: isDisabled ? (props.message || props.title) : props.title,
'aria-label': props.title,
'aria-disabled': isDisabled ? 'true' : undefined,
'data-cap': props.cap,
class: `p-1.5 md:p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded transition-colors flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 ${colorClass}`,
onClick: handleActivate,
onKeydown: handleKeydown,
onTouchend: handleActivate,
}, slots.default?.())
}
},
}
// Vue's h function reference for CapabilityButton
import { h as vueH } from 'vue'
const Vue = { h: vueH }
const props = defineProps({
/** "local" or "cloud" — used for search/sort visibility fallback only */
mode: { type: String, default: 'local' },
folders: { type: Array, default: () => [] },
files: { type: Array, default: () => [] },
breadcrumb: { type: Array, default: () => [] },
uploadQueue: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
searchQuery: { type: String, default: '' },
sortField: { type: String, default: 'created_at' },
sortOrder: { type: String, default: 'desc' },
/** Root-level folders — used by the move-to-folder picker (local mode) */
rootFolders: { type: Array, default: () => [] },
/** Lookup function: topic name → color hex (local mode) */
topicColorFn: { type: Function, default: () => '#6366f1' },
emptyMessage: { type: String, default: 'This folder is empty' },
emptyHint: { type: String, default: 'Upload files above or create a sub-folder' },
/**
* Capability map for cloud connections.
* Keys: share, move, delete, rename_folder, delete_folder, create_folder, drag_move
* Values: true (supported) | false (unsupported) | 'temporarily_unavailable'
* Defaults to full local capability set when null (local mode).
*/
capabilities: { type: Object, default: null },
/** Connection root metadata { id, name, provider } for breadcrumb */
connectionRoot: { type: Object, default: null },
/** 'refreshing' | 'fresh' | 'stale' | null */
folderFreshness: { type: String, default: null },
/** ISO timestamp of last successful refresh */
lastRefreshedAt: { type: String, default: null },
/** 'cached' | 'cloud_only' | null — affects size column markers */
byteAvailability: { type: String, default: null },
})
const emit = defineEmits([
'breadcrumb-navigate',
'new-folder',
'sort-change',
'search-change',
'upload',
'folder-navigate',
'folder-create',
'folder-rename',
'folder-delete',
'file-open',
'file-share',
'file-move',
'file-delete',
'capability-explain',
])
/**
* Default capability set for local mode — full access to all actions.
* When capabilities prop is provided (cloud mode), uses that instead.
*/
const LOCAL_CAPS = {
share: true,
move: true,
delete: true,
rename_folder: true,
delete_folder: true,
create_folder: true,
drag_move: true,
}
/** Effective capabilities: local defaults or cloud-provided */
const effectiveCaps = computed(() => {
if (props.capabilities) return { ...LOCAL_CAPS, ...props.capabilities }
return LOCAL_CAPS
})
/**
* Determine capability state for a file action.
* Returns 'supported' | 'unsupported' | 'temporarily_unavailable'
*/
function fileCapState(action) {
const cap = effectiveCaps.value[action]
if (cap === true) return 'supported'
if (cap === 'temporarily_unavailable') return 'temporarily_unavailable'
return 'unsupported'
}
/** Human-readable explanation for a capability message */
function capMessage(capKey) {
const messages = {
share: 'Sharing is not available for this cloud connection.',
move: 'Moving files is not available for this cloud connection.',
delete: 'Deleting files is not available for this cloud connection.',
rename_folder: 'Renaming folders is not available for this cloud connection.',
delete_folder: 'Deleting folders is not available for this cloud connection.',
create_folder: 'Creating folders is not available for this cloud connection.',
drag_move: 'Drag-to-move is not available for this cloud connection.',
}
const cap = effectiveCaps.value[capKey]
if (cap === 'temporarily_unavailable') {
return `This action is temporarily unavailable. Please try again in a moment.`
}
return messages[capKey] ?? 'This action is not available.'
}
/** Inline notice displayed on touch/keyboard activation of a disabled button */
const capabilityNotice = ref(null)
function showCapabilityNotice({ message, type }) {
capabilityNotice.value = { message, type }
emit('capability-explain', { message, type })
}
const showSearch = computed(() => props.mode === 'local' || props.mode === 'cloud')
function topicColor(name) {
return props.topicColorFn(name)
}
const dropZoneRef = ref(null)
const searchBarRef = ref(null)
const mobileSearchOpen = ref(false)
const mobileSortOpen = ref(false)
const showNewFolderInput = ref(false)
const newFolderName = ref('')
const newFolderError = ref('')
const newFolderInputRef = ref(null)
function startNewFolder() {
newFolderName.value = ''
newFolderError.value = ''
showNewFolderInput.value = true
nextTick(() => newFolderInputRef.value?.focus())
}
function cancelNewFolder() {
showNewFolderInput.value = false
newFolderError.value = ''
}
function handleSortChange(payload) {
emit('sort-change', payload)
mobileSortOpen.value = false
}
async function submitNewFolder() {
const name = newFolderName.value.trim()
if (!name) {
newFolderError.value = 'Folder name cannot be empty.'
return
}
emit('folder-create', {
name,
onError: (msg) => {
newFolderError.value = msg
},
onSuccess: cancelNewFolder,
})
}
defineExpose({
startNewFolder,
triggerUpload: () => dropZoneRef.value?.triggerInput?.(),
focusSearch: () => searchBarRef.value?.focus?.(),
clearSearch: () => emit('search-change', ''),
})
const renamingId = ref(null)
const renameValue = ref('')
function startRename(folder) {
renamingId.value = folder.id
renameValue.value = folder.name
}
const draggingFile = ref(null)
const dragOverFolderId = ref(null)
function onFileDragStart(file, e) {
draggingFile.value = file
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', file.id)
}
function onFolderDragOver(folderId, e) {
if (!draggingFile.value) return
e.preventDefault()
dragOverFolderId.value = folderId
}
function onFileDragEnd() {
dragOverFolderId.value = null
nextTick(() => { draggingFile.value = null })
}
async function onDropDocOnFolder(folderId) {
if (!draggingFile.value) return
const fileId = draggingFile.value.id
dragOverFolderId.value = null
emit('file-move', { fileId, folderId })
await nextTick()
draggingFile.value = null
}
const folderPickerFileId = ref(null)
const pickerStyle = ref({})
const pickerTriggerMap = new Map()
function updatePickerPosition(rect) {
const spaceBelow = window.innerHeight - rect.bottom
const dropH = 240
if (spaceBelow >= dropH || spaceBelow > 120) {
pickerStyle.value = {
top: `${rect.bottom + 4}px`,
left: `${rect.left}px`,
width: '192px',
}
} else {
pickerStyle.value = {
bottom: `${window.innerHeight - rect.top + 4}px`,
left: `${rect.left}px`,
width: '192px',
}
}
}
function openFolderPicker(fileId, ev) {
if (folderPickerFileId.value === fileId) {
folderPickerFileId.value = null
return
}
folderPickerFileId.value = fileId
const trigger = ev.currentTarget
pickerTriggerMap.set(fileId, trigger)
updatePickerPosition(trigger.getBoundingClientRect())
}
function onWindowScroll() {
if (!folderPickerFileId.value) return
const trig = pickerTriggerMap.get(folderPickerFileId.value)
if (trig) updatePickerPosition(trig.getBoundingClientRect())
}
function onOutsideClick(e) {
if (
!e.target.closest('[data-test="folder-picker"]') &&
!e.target.closest('.relative')
) {
folderPickerFileId.value = null
}
}
onMounted(() => {
document.addEventListener('click', onOutsideClick)
window.addEventListener('scroll', onWindowScroll, true)
window.addEventListener('resize', onWindowScroll)
})
onUnmounted(() => {
document.removeEventListener('click', onOutsideClick)
window.removeEventListener('scroll', onWindowScroll, true)
window.removeEventListener('resize', onWindowScroll)
})
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>