- App.vue: mobile header with hamburger button; slide-in overlay drawer with Teleport backdrop, translate-x-0/-translate-x-full transition; drawer state owned by App.vue (D-04/D-05); route-change auto-close - AdminLayout.vue: matching responsive treatment — hamburger, backdrop, drawer, route-change close (RESP-05) - StorageBrowser.vue: responsive grid templates (mobile: grid-cols-[2rem_1fr_6rem], sm: +modified, md: all 5 cols); Size hidden below md, Modified hidden below sm; action buttons get min-w-[36px] min-h-[36px] touch targets (RESP-02, RESP-03) - Tests: drawer open/close/backdrop-close/route-change behaviour; admin drawer; responsive column and touch-target class assertions
431 lines
17 KiB
Vue
431 lines
17 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-6 py-3 flex items-center gap-3 flex-wrap">
|
|
<BreadcrumbBar
|
|
:segments="breadcrumb"
|
|
:root-label="mode === 'cloud' ? 'Cloud' : 'Home'"
|
|
@navigate="$emit('breadcrumb-navigate', $event)"
|
|
/>
|
|
<div class="ml-auto flex items-center gap-2 shrink-0">
|
|
<SearchBar v-if="showSearch" ref="searchBarRef" :model-value="searchQuery" @update:modelValue="$emit('search-change', $event)" />
|
|
<SortControls
|
|
v-if="showSearch"
|
|
:sort="sortField"
|
|
:order="sortOrder"
|
|
@change="$emit('sort-change', $event)"
|
|
/>
|
|
<button
|
|
v-if="mode === 'local'"
|
|
@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 rounded-lg transition-colors"
|
|
>
|
|
<AppIcon name="plus" class="w-4 h-4" />
|
|
New folder
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex-1 overflow-y-auto flex flex-col">
|
|
|
|
<div class="px-6 pt-5 pb-3">
|
|
<DropZone ref="dropZoneRef" @files-selected="$emit('upload', $event)" />
|
|
<UploadProgress :items="uploadQueue" />
|
|
</div>
|
|
|
|
<div class="mx-6 px-4 py-2 grid grid-cols-[2rem_1fr_6rem] sm:grid-cols-[2rem_1fr_8rem_6rem] md:grid-cols-[2rem_1fr_6rem_8rem_6rem] 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-6 mt-1 px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem] sm:grid-cols-[2rem_1fr_8rem_6rem] md:grid-cols-[2rem_1fr_6rem_8rem_6rem] 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-4">
|
|
<input
|
|
ref="newFolderInputRef"
|
|
v-model="newFolderName"
|
|
type="text"
|
|
placeholder="Folder name"
|
|
class="border border-gray-300 rounded-lg px-2 py-1 text-sm 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-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_1fr_6rem] sm:grid-cols-[2rem_1fr_8rem_6rem] md:grid-cols-[2rem_1fr_6rem_8rem_6rem] 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="mode === 'local' ? onFolderDragOver(folder.id, $event) : null"
|
|
@dragleave="dragOverFolderId = null"
|
|
@drop.prevent="mode === 'local' ? 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 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop>
|
|
<template v-if="mode === 'local'">
|
|
<button @click.stop="startRename(folder)" title="Rename"
|
|
class="p-1.5 md:p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors flex items-center justify-center">
|
|
<AppIcon name="pencil" class="w-3.5 h-3.5" />
|
|
</button>
|
|
<button @click.stop="$emit('folder-delete', folder)" title="Delete folder"
|
|
class="p-1.5 md:p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors flex items-center justify-center">
|
|
<AppIcon name="trash" class="w-3.5 h-3.5" />
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- File rows -->
|
|
<div
|
|
v-for="file in files"
|
|
:key="`d-${file.id}`"
|
|
:draggable="mode === 'local'"
|
|
class="px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem] sm:grid-cols-[2rem_1fr_8rem_6rem] md:grid-cols-[2rem_1fr_6rem_8rem_6rem] 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="mode === 'local' ? 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) }}</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 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop>
|
|
<template v-if="mode === 'local'">
|
|
<button @click.stop="$emit('file-share', file)" title="Share"
|
|
class="p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors flex items-center justify-center">
|
|
<AppIcon name="share" class="w-3.5 h-3.5" />
|
|
</button>
|
|
<div class="relative">
|
|
<button
|
|
@click.stop="openFolderPicker(file.id, $event)"
|
|
title="Move to folder"
|
|
class="p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors flex items-center justify-center"
|
|
>
|
|
<AppIcon name="folderMove" class="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
<button @click.stop="$emit('file-delete', file.id)" title="Delete"
|
|
class="p-1.5 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 rounded hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors flex items-center justify-center">
|
|
<AppIcon name="trash" class="w-3.5 h-3.5" />
|
|
</button>
|
|
</template>
|
|
</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_1fr_6rem] sm:grid-cols-[2rem_1fr_8rem_6rem] md:grid-cols-[2rem_1fr_6rem_8rem_6rem] 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 "${searchQuery}"`"
|
|
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"
|
|
@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 truncate"
|
|
@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'
|
|
|
|
const props = defineProps({
|
|
/** "local" or "cloud" — controls which actions are visible */
|
|
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' },
|
|
})
|
|
|
|
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',
|
|
])
|
|
|
|
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 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 = ''
|
|
}
|
|
|
|
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>
|