refactor(frontend): extract shared modules, thin views, delete dead code
Shared utilities: - Add src/utils/formatters.js — formatDate, formatSize, providerColor, providerBg, providerLabel; all components import from here, no inline duplicates - Add src/components/ui/TreeItem.vue — generic expand/collapse tree node; FolderTreeItem, CloudFolderTreeItem, CloudProviderTreeItem now wrap it - Add src/components/storage/StorageBrowser.vue — unified file browser grid used by both FileManagerView and CloudFolderView View refactor (thin data-providers): - FileManagerView.vue: stripped to props + event wiring; all layout moved to StorageBrowser - CloudFolderView.vue: same treatment — feeds props into StorageBrowser - All tree sidebar components delegate expand/collapse to TreeItem.vue Dead code removed: - Delete HomeView.vue — no active route, replaced by FileManagerView - Delete FolderView.vue — no active route, logic merged into FileManagerView Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
a548266461
commit
cce70b2ef6
@@ -0,0 +1,374 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
|
||||
<!-- ── Sticky toolbar ──────────────────────────────────────────────── -->
|
||||
<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">
|
||||
<FolderBreadcrumb
|
||||
:segments="breadcrumb"
|
||||
@navigate="$emit('breadcrumb-navigate', $event)"
|
||||
/>
|
||||
<div class="ml-auto flex items-center gap-2 shrink-0">
|
||||
<SearchBar v-if="showSearch" :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"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
New folder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Content area ───────────────────────────────────────────────── -->
|
||||
<div class="flex-1 overflow-y-auto flex flex-col">
|
||||
|
||||
<!-- Upload zone -->
|
||||
<div class="px-6 pt-5 pb-3">
|
||||
<DropZone @files-selected="$emit('upload', $event)" />
|
||||
<UploadProgress :items="uploadQueue" />
|
||||
</div>
|
||||
|
||||
<!-- Column headers — 5-column grid, consistent for local and cloud -->
|
||||
<div class="mx-6 px-4 py-2 grid 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">
|
||||
<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>
|
||||
|
||||
<!-- Inline new-folder row (local only) -->
|
||||
<div v-if="showNewFolderInput" class="mx-6 mt-1 px-4 py-2.5 grid 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">
|
||||
<svg class="w-4 h-4 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
|
||||
</svg>
|
||||
</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>
|
||||
|
||||
<!-- ── Item list ─────────────────────────────────────────────────── -->
|
||||
<div class="mx-6 mt-1 mb-6 flex flex-col divide-y divide-gray-100 border border-gray-100 rounded-xl overflow-hidden">
|
||||
|
||||
<!-- Folder rows -->
|
||||
<div
|
||||
v-for="folder in folders"
|
||||
:key="`f-${folder.id}`"
|
||||
class="px-4 py-2.5 grid 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">
|
||||
<svg class="w-4 h-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Name / rename input -->
|
||||
<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>
|
||||
|
||||
<!-- Folder actions (local only) -->
|
||||
<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 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button @click.stop="$emit('folder-delete', folder)" title="Delete folder"
|
||||
class="p-1.5 rounded hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</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_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="$emit('file-open', file)"
|
||||
@dragstart="mode === 'local' ? onFileDragStart(file, $event) : null"
|
||||
@dragend="draggingFile = null; dragOverFolderId = null"
|
||||
>
|
||||
<div class="w-7 h-7 bg-indigo-50 rounded-lg flex items-center justify-center shrink-0">
|
||||
<svg class="w-4 h-4 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Name + topics + shared badge -->
|
||||
<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>
|
||||
|
||||
<!-- File actions (local only) -->
|
||||
<div class="flex justify-end gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop>
|
||||
<template v-if="mode === 'local'">
|
||||
<!-- Share -->
|
||||
<button @click.stop="$emit('file-share', file)" title="Share"
|
||||
class="p-1.5 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Move -->
|
||||
<div class="relative">
|
||||
<button
|
||||
@click.stop="folderPickerFileId = folderPickerFileId === file.id ? null : file.id"
|
||||
title="Move to folder"
|
||||
class="p-1.5 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
v-if="folderPickerFileId === file.id"
|
||||
class="absolute right-0 top-full mt-1 w-48 bg-white border border-gray-200 rounded-xl shadow-lg z-20 py-1"
|
||||
@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: file.id, 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: file.id, 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>
|
||||
</div>
|
||||
<!-- Delete -->
|
||||
<button @click.stop="$emit('file-delete', file.id)" title="Delete"
|
||||
class="p-1.5 rounded hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-if="!loading && folders.length === 0 && files.length === 0 && !showNewFolderInput"
|
||||
class="px-4 py-10 text-center text-gray-300"
|
||||
>
|
||||
<p class="text-gray-400 text-sm">{{ emptyMessage }}</p>
|
||||
<p class="text-xs mt-1">{{ emptyHint }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Search no-results -->
|
||||
<div
|
||||
v-else-if="searchQuery && files.length === 0 && folders.length === 0"
|
||||
class="px-4 py-10 text-center text-sm text-gray-400"
|
||||
>
|
||||
No items match "{{ searchQuery }}".
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="py-6 text-center text-sm text-gray-400">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import FolderBreadcrumb from '../folders/FolderBreadcrumb.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.breadcrumb.length > 0)
|
||||
|
||||
function topicColor(name) {
|
||||
return props.topicColorFn(name)
|
||||
}
|
||||
|
||||
// ── New folder inline input ───────────────────────────────────────────────────
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
/** Called by the parent view when the toolbar "New folder" button is clicked. */
|
||||
defineExpose({ startNewFolder })
|
||||
|
||||
// ── Rename ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const renamingId = ref(null)
|
||||
const renameValue = ref('')
|
||||
|
||||
function startRename(folder) {
|
||||
renamingId.value = folder.id
|
||||
renameValue.value = folder.name
|
||||
}
|
||||
|
||||
// ── Drag and drop (local only) ────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
async function onDropDocOnFolder(folderId) {
|
||||
if (!draggingFile.value) return
|
||||
const fileId = draggingFile.value.id
|
||||
draggingFile.value = null
|
||||
dragOverFolderId.value = null
|
||||
emit('file-move', { fileId, folderId })
|
||||
}
|
||||
|
||||
// ── Move folder picker ────────────────────────────────────────────────────────
|
||||
|
||||
const folderPickerFileId = ref(null)
|
||||
|
||||
function onOutsideClick(e) {
|
||||
if (!e.target.closest('.relative')) folderPickerFileId.value = null
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', onOutsideClick))
|
||||
onUnmounted(() => document.removeEventListener('click', onOutsideClick))
|
||||
</script>
|
||||
Reference in New Issue
Block a user