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:
curo1305
2026-06-02 16:10:47 +02:00
co-authored by Claude Sonnet 4.6
parent a548266461
commit cce70b2ef6
15 changed files with 728 additions and 1087 deletions
@@ -268,6 +268,7 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { formatDate } from '../../utils/formatters.js'
import * as api from '../../api/client.js'
const users = ref([])
@@ -464,14 +465,6 @@ async function resetPassword(id) {
}
}
function formatDate(iso) {
if (!iso) return '—'
try {
return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
} catch {
return iso
}
}
onMounted(async () => {
loading.value = true
@@ -1,72 +1,34 @@
<template>
<div>
<!-- Row -->
<div
class="flex items-center group"
:style="{ paddingLeft: `${depth * 12}px` }"
>
<!-- Expand/collapse arrow (only for directories) -->
<button
<TreeItem
:label="folder.name"
:expandable="folder.is_dir"
:load-children="loadChildren"
:depth="depth"
@select="navigate"
>
<template #icon>
<svg
v-if="folder.is_dir"
@click.prevent.stop="toggleExpand"
class="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-gray-600 shrink-0 transition-colors"
:aria-label="expanded ? 'Collapse ' + folder.name : 'Expand ' + folder.name"
class="w-4 h-4 shrink-0 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg
class="w-3 h-3 transition-transform duration-150"
:class="{ 'rotate-90': expanded }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7" />
</svg>
</button>
<!-- Spacer for non-directory items -->
<span v-else class="w-5 h-5 shrink-0"></span>
<!-- Folder/file name button -->
<button
@click="navigateTo"
class="flex items-center gap-2 flex-1 min-w-0 px-2 py-1.5 rounded-lg text-sm font-medium transition-colors text-gray-600 hover:bg-gray-100 hover:text-gray-900"
<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>
<svg
v-else
class="w-4 h-4 shrink-0 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<!-- Folder icon for directories, document icon for files -->
<svg
v-if="folder.is_dir"
class="w-4 h-4 shrink-0 text-gray-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>
<svg
v-else
class="w-4 h-4 shrink-0 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span class="truncate">{{ folder.name }}</span>
</button>
</div>
<!-- Children: nested sub-folders (lazy loaded) -->
<template v-if="expanded">
<div v-if="loading" class="text-xs text-gray-400 py-1" :style="{ paddingLeft: `${(depth + 1) * 12 + 8}px` }">Loading</div>
<div
v-else-if="loadError"
class="text-xs text-red-500 cursor-pointer py-1"
:style="{ paddingLeft: `${(depth + 1) * 12 + 8}px` }"
@click="retry"
>
Failed to load tap to retry
</div>
<div v-else-if="children.length === 0" class="text-xs text-gray-400 py-1" :style="{ paddingLeft: `${(depth + 1) * 12 + 8}px` }">Empty</div>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</template>
<template #children="{ children }">
<CloudFolderTreeItem
v-for="child in children"
:key="child.id"
@@ -75,13 +37,13 @@
:depth="depth + 1"
/>
</template>
</div>
</TreeItem>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import * as api from '../../api/client.js'
import TreeItem from '../ui/TreeItem.vue'
const props = defineProps({
folder: { type: Object, required: true },
@@ -91,38 +53,12 @@ const props = defineProps({
const router = useRouter()
const expanded = ref(false)
const children = ref([])
const loading = ref(false)
const loadError = ref(false)
const childrenLoaded = ref(false)
async function loadChildren() {
loading.value = true
loadError.value = false
try {
const data = await api.getCloudFolders(props.provider, props.folder.id)
children.value = (data.items ?? []).filter(i => i.is_dir)
childrenLoaded.value = true
} catch {
loadError.value = true
} finally {
loading.value = false
}
const data = await api.getCloudFolders(props.provider, props.folder.id)
return (data.items ?? []).filter(i => i.is_dir)
}
async function toggleExpand() {
if (!expanded.value && !childrenLoaded.value) {
await loadChildren()
}
expanded.value = !expanded.value
}
async function retry() {
await loadChildren()
}
function navigateTo() {
function navigate() {
router.push(`/cloud/${props.provider}/${props.folder.id}`)
}
</script>
@@ -1,52 +1,17 @@
<template>
<div>
<!-- Row -->
<div
class="flex items-center group"
:style="{ paddingLeft: `${depth * 12}px` }"
>
<!-- Expand/collapse arrow -->
<button
@click.prevent.stop="toggleExpand"
class="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-gray-600 shrink-0 transition-colors"
:aria-label="expanded ? 'Collapse ' + connection.display_name : 'Expand ' + connection.display_name"
>
<svg
class="w-3 h-3 transition-transform duration-150"
:class="{ 'rotate-90': expanded }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7" />
</svg>
</button>
<!-- Provider name (click navigates to /settings) -->
<button
@click="navigateToRoot"
class="flex items-center gap-2 flex-1 min-w-0 px-2 py-1.5 rounded-lg text-sm font-medium transition-colors text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
<!-- Provider cloud icon (w-4 h-4, provider color) -->
<svg class="w-4 h-4 shrink-0" :class="providerIconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
<span class="truncate">{{ connection.display_name }}</span>
</button>
</div>
<!-- Children: first-level cloud folders (lazy loaded) -->
<template v-if="expanded">
<div v-if="loading" class="pl-12 py-1 text-xs text-gray-400">Loading</div>
<div
v-else-if="loadError"
class="pl-12 py-1 text-xs text-red-500 cursor-pointer"
@click="retry"
>
Failed to load tap to retry
</div>
<div v-else-if="children.length === 0" class="pl-12 py-1 text-xs text-gray-400">Empty</div>
<TreeItem
:label="connection.display_name"
:load-children="loadChildren"
:depth="depth"
@select="navigateToRoot"
>
<template #icon>
<svg class="w-4 h-4 shrink-0" :class="providerIconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
</template>
<template #children="{ children }">
<CloudFolderTreeItem
v-for="folder in children"
:key="folder.id"
@@ -55,14 +20,16 @@
:depth="depth + 1"
/>
</template>
</div>
</TreeItem>
</template>
<script setup>
import { ref, computed } from 'vue'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import * as api from '../../api/client.js'
import TreeItem from '../ui/TreeItem.vue'
import CloudFolderTreeItem from './CloudFolderTreeItem.vue'
import { providerColor } from '../../utils/formatters.js'
const props = defineProps({
connection: { type: Object, required: true },
@@ -71,45 +38,11 @@ const props = defineProps({
const router = useRouter()
const expanded = ref(false)
const children = ref([])
const loading = ref(false)
const loadError = ref(false)
const childrenLoaded = ref(false)
const providerIconColor = computed(() => {
const map = {
google_drive: 'text-blue-500',
onedrive: 'text-sky-500',
nextcloud: 'text-orange-500',
webdav: 'text-gray-500',
}
return map[props.connection.provider] ?? 'text-gray-400'
})
const providerIconColor = computed(() => providerColor(props.connection.provider))
async function loadChildren() {
loading.value = true
loadError.value = false
try {
const data = await api.getCloudFolders(props.connection.provider, 'root')
children.value = (data.items ?? []).filter(i => i.is_dir)
childrenLoaded.value = true
} catch {
loadError.value = true
} finally {
loading.value = false
}
}
async function toggleExpand() {
if (!expanded.value && !childrenLoaded.value) {
await loadChildren()
}
expanded.value = !expanded.value
}
async function retry() {
await loadChildren()
const data = await api.getCloudFolders(props.connection.provider, 'root')
return (data.items ?? []).filter(i => i.is_dir)
}
function navigateToRoot() {
@@ -97,6 +97,7 @@ import { useFoldersStore } from '../../stores/folders.js'
import { moveDocument } from '../../api/client.js'
import TopicBadge from '../topics/TopicBadge.vue'
import ShareModal from '../sharing/ShareModal.vue'
import { formatDate, formatSize } from '../../utils/formatters.js'
const props = defineProps({
doc: Object,
@@ -137,15 +138,4 @@ function topicColor(name) {
return topicsStore.topics.find(t => t.name === name)?.color ?? '#6366f1'
}
function formatDate(iso) {
if (!iso) return ''
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
function formatSize(bytes) {
if (!bytes) return ''
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
</script>
@@ -1,62 +1,34 @@
<template>
<div>
<!-- Row -->
<div
class="flex items-center group"
:style="{ paddingLeft: `${depth * 12}px` }"
>
<!-- Expand/collapse arrow hidden when folder is a known leaf (has_children === false) -->
<button
v-if="folder.has_children !== false && (!childrenLoaded || (children && children.length > 0))"
@click.prevent.stop="toggleExpand"
class="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-gray-600 shrink-0 transition-colors"
:aria-label="expanded ? 'Collapse' : 'Expand'"
<TreeItem
:label="folder.name"
:to="`/folders/${folder.id}`"
:expandable="folder.has_children !== false && (!childrenLoaded || subfolders.length > 0)"
:load-children="loadChildren"
:depth="depth"
:is-active="isActive"
ref="treeRef"
>
<template #icon>
<svg
class="w-4 h-4 shrink-0"
:class="isActive ? 'text-indigo-500' : 'text-gray-400'"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<svg
class="w-3 h-3 transition-transform duration-150"
:class="{ 'rotate-90': expanded }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7" />
</svg>
</button>
<!-- Spacer for leaf nodes -->
<span v-else class="w-5 h-5 shrink-0"></span>
<!-- Folder name (router-link) -->
<router-link
:to="`/folders/${folder.id}`"
class="flex items-center gap-2 flex-1 min-w-0 px-2 py-1.5 rounded-lg text-sm font-medium transition-colors"
:class="isActive
? 'bg-indigo-50 text-indigo-700'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'"
>
<svg
class="w-4 h-4 shrink-0"
:class="isActive ? 'text-indigo-500' : 'text-gray-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>
<span class="truncate">{{ folder.name }}</span>
</router-link>
</div>
<!-- Children (recursively rendered) -->
<div v-if="expanded && children && children.length > 0">
<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>
</template>
<template #children="{ children }">
<FolderTreeItem
v-for="child in children"
:key="child.id"
:folder="child"
:depth="depth + 1"
/>
</div>
</div>
</template>
</TreeItem>
</template>
<script setup>
@@ -64,6 +36,7 @@ import { ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useFoldersStore } from '../../stores/folders.js'
import * as api from '../../api/client.js'
import TreeItem from '../ui/TreeItem.vue'
const props = defineProps({
folder: { type: Object, required: true },
@@ -72,9 +45,8 @@ const props = defineProps({
const route = useRoute()
const foldersStore = useFoldersStore()
const expanded = ref(false)
const children = ref(null) // null = not yet loaded
const treeRef = ref(null)
const subfolders = ref([])
const childrenLoaded = ref(false)
const isActive = computed(() =>
@@ -83,26 +55,13 @@ const isActive = computed(() =>
)
async function loadChildren() {
try {
const data = await api.listFolders(props.folder.id)
children.value = data.items ?? data
} catch {
children.value = []
}
const data = await api.listFolders(props.folder.id)
subfolders.value = data.items ?? data
childrenLoaded.value = true
return subfolders.value
}
async function toggleExpand() {
if (!childrenLoaded.value) {
await loadChildren()
}
expanded.value = !expanded.value
}
// Re-fetch children when any folder mutation occurs (create/rename/delete)
watch(() => foldersStore.treeVersion, () => {
if (expanded.value && childrenLoaded.value) {
loadChildren()
}
treeRef.value?.refresh()
})
</script>
@@ -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>
+134
View File
@@ -0,0 +1,134 @@
<template>
<div>
<!-- Row -->
<div
class="flex items-center group"
:style="{ paddingLeft: `${depth * 12}px` }"
>
<!-- Expand/collapse arrow hidden when item is a known leaf -->
<button
v-if="expandable"
@click.prevent.stop="toggleExpand"
class="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-gray-600 shrink-0 transition-colors"
:aria-label="expanded ? 'Collapse' : 'Expand'"
>
<svg
class="w-3 h-3 transition-transform duration-150"
:class="{ 'rotate-90': expanded }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7" />
</svg>
</button>
<span v-else class="w-5 h-5 shrink-0"></span>
<!-- Label row router-link when `to` is provided, button otherwise -->
<router-link
v-if="to"
:to="to"
class="flex items-center gap-2 flex-1 min-w-0 px-2 py-1.5 rounded-lg text-sm font-medium transition-colors"
:class="isActive
? 'bg-indigo-50 text-indigo-700'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'"
>
<slot name="icon" />
<span class="truncate">{{ label }}</span>
</router-link>
<button
v-else
@click="$emit('select')"
class="flex items-center gap-2 flex-1 min-w-0 px-2 py-1.5 rounded-lg text-sm font-medium transition-colors text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
<slot name="icon" />
<span class="truncate">{{ label }}</span>
</button>
</div>
<!-- Children -->
<template v-if="expanded">
<div
v-if="loading"
class="text-xs text-gray-400 py-1"
:style="{ paddingLeft: `${(depth + 1) * 12 + 8}px` }"
>
Loading
</div>
<div
v-else-if="loadError"
class="text-xs text-red-500 cursor-pointer py-1"
:style="{ paddingLeft: `${(depth + 1) * 12 + 8}px` }"
@click="retry"
>
Failed to load tap to retry
</div>
<div
v-else-if="children.length === 0"
class="text-xs text-gray-400 py-1"
:style="{ paddingLeft: `${(depth + 1) * 12 + 8}px` }"
>
Empty
</div>
<slot name="children" :children="children" />
</template>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
label: { type: String, required: true },
/** Router-link destination. When omitted, the label row renders as a button that emits 'select'. */
to: { type: [String, Object], default: null },
/** Whether this item can be expanded to reveal children. */
expandable: { type: Boolean, default: true },
/** Async function that returns an array of child items when called. */
loadChildren: { type: Function, required: true },
depth: { type: Number, default: 0 },
/** Highlight the label row as the active route item. */
isActive: { type: Boolean, default: false },
})
const emit = defineEmits(['select'])
const expanded = ref(false)
const children = ref([])
const loading = ref(false)
const loadError = ref(false)
const childrenLoaded = ref(false)
async function load() {
loading.value = true
loadError.value = false
try {
children.value = await props.loadChildren()
childrenLoaded.value = true
} catch {
loadError.value = true
} finally {
loading.value = false
}
}
async function retry() {
await load()
}
async function toggleExpand() {
if (!expanded.value && !childrenLoaded.value) {
await load()
}
expanded.value = !expanded.value
}
/** Reload children if already expanded — called by parent when data changes. */
function refresh() {
if (expanded.value && childrenLoaded.value) {
load()
}
}
defineExpose({ refresh })
</script>