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:
@@ -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,36 +1,12 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Row -->
|
||||
<div
|
||||
class="flex items-center group"
|
||||
:style="{ paddingLeft: `${depth * 12}px` }"
|
||||
<TreeItem
|
||||
:label="folder.name"
|
||||
:expandable="folder.is_dir"
|
||||
:load-children="loadChildren"
|
||||
:depth="depth"
|
||||
@select="navigate"
|
||||
>
|
||||
<!-- Expand/collapse arrow (only for directories) -->
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<!-- Folder icon for directories, document icon for files -->
|
||||
<template #icon>
|
||||
<svg
|
||||
v-if="folder.is_dir"
|
||||
class="w-4 h-4 shrink-0 text-gray-400"
|
||||
@@ -51,22 +27,8 @@
|
||||
<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>
|
||||
</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
|
||||
}
|
||||
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` }"
|
||||
<TreeItem
|
||||
:label="connection.display_name"
|
||||
:load-children="loadChildren"
|
||||
:depth="depth"
|
||||
@select="navigateToRoot"
|
||||
>
|
||||
<!-- 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) -->
|
||||
<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>
|
||||
<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>
|
||||
</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()
|
||||
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,38 +1,14 @@
|
||||
<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'"
|
||||
>
|
||||
<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
|
||||
<TreeItem
|
||||
:label="folder.name"
|
||||
: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'"
|
||||
: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'"
|
||||
@@ -43,20 +19,16 @@
|
||||
<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">
|
||||
</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 = []
|
||||
}
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Shared formatting utilities — import from here, never redefine inline.
|
||||
*/
|
||||
|
||||
export function formatDate(iso) {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export function formatSize(bytes) {
|
||||
if (!bytes) return '—'
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / 1048576).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
/** Maps a cloud provider slug to a Tailwind text-color class. */
|
||||
export function providerColor(provider) {
|
||||
const map = {
|
||||
google_drive: 'text-blue-500',
|
||||
onedrive: 'text-sky-500',
|
||||
nextcloud: 'text-orange-500',
|
||||
webdav: 'text-gray-500',
|
||||
}
|
||||
return map[provider] ?? 'text-gray-400'
|
||||
}
|
||||
|
||||
/** Maps a cloud provider slug to a Tailwind background-color class. */
|
||||
export function providerBg(provider) {
|
||||
const map = {
|
||||
google_drive: 'bg-blue-50',
|
||||
onedrive: 'bg-sky-50',
|
||||
nextcloud: 'bg-orange-50',
|
||||
webdav: 'bg-gray-50',
|
||||
}
|
||||
return map[provider] ?? 'bg-gray-50'
|
||||
}
|
||||
|
||||
/** Human-readable label for a cloud provider slug. */
|
||||
export function providerLabel(provider) {
|
||||
const map = {
|
||||
google_drive: 'Google Drive',
|
||||
onedrive: 'OneDrive',
|
||||
nextcloud: 'Nextcloud',
|
||||
webdav: 'WebDAV',
|
||||
}
|
||||
return map[provider] ?? provider
|
||||
}
|
||||
@@ -1,113 +1,26 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
|
||||
<!-- 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">
|
||||
<button
|
||||
@click="goUp"
|
||||
class="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-800 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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back
|
||||
</button>
|
||||
<span class="text-gray-300">|</span>
|
||||
<span class="text-sm text-gray-400 capitalize">{{ providerLabel }}</span>
|
||||
<svg class="w-3 h-3 text-gray-300" 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>
|
||||
<span class="text-sm font-medium text-gray-700 truncate">{{ folderName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto px-6 py-5">
|
||||
|
||||
<!-- Upload zone -->
|
||||
<div class="mb-5">
|
||||
<DropZone @files-selected="onFilesSelected" />
|
||||
<UploadProgress :items="uploadQueue" />
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-sm text-gray-400 py-8 text-center">Loading…</div>
|
||||
|
||||
<div v-else-if="error" class="text-sm text-red-500 py-8 text-center">
|
||||
<p>{{ error }}</p>
|
||||
<div class="flex items-center justify-center gap-3 mt-2">
|
||||
<router-link
|
||||
to="/settings"
|
||||
class="text-indigo-600 hover:underline text-sm"
|
||||
>
|
||||
Go to Settings
|
||||
</router-link>
|
||||
<button @click="load" class="text-indigo-600 hover:underline text-sm">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<!-- Column headers -->
|
||||
<div class="px-4 py-2 grid grid-cols-[2rem_1fr_6rem] gap-3 items-center rounded-lg bg-gray-50 text-xs font-semibold text-gray-400 uppercase tracking-wider select-none mb-1">
|
||||
<span></span>
|
||||
<span>Name</span>
|
||||
<span class="text-right hidden md:block">Size</span>
|
||||
</div>
|
||||
|
||||
<div v-if="items.length === 0" class="text-sm text-gray-400 py-10 text-center">
|
||||
This folder is empty.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col divide-y divide-gray-100 border border-gray-100 rounded-xl overflow-hidden">
|
||||
|
||||
<!-- Folder rows -->
|
||||
<div
|
||||
v-for="item in folders"
|
||||
:key="item.id"
|
||||
class="px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors"
|
||||
@click="navigateTo(item)"
|
||||
>
|
||||
<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>
|
||||
<span class="text-sm font-medium text-gray-900 truncate">{{ item.name }}</span>
|
||||
<span class="text-right text-xs text-gray-400 hidden md:block">—</span>
|
||||
</div>
|
||||
|
||||
<!-- File rows -->
|
||||
<div
|
||||
v-for="item in files"
|
||||
:key="item.id"
|
||||
class="px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem] gap-3 items-center hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<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>
|
||||
<span class="text-sm text-gray-700 truncate">{{ item.name }}</span>
|
||||
<span class="text-right text-xs text-gray-400 hidden md:block">{{ formatSize(item.size) }}</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<StorageBrowser
|
||||
mode="cloud"
|
||||
:folders="folders"
|
||||
:files="files"
|
||||
:breadcrumb="breadcrumb"
|
||||
:upload-queue="uploadQueue"
|
||||
:loading="loading"
|
||||
:empty-message="error || 'This folder is empty'"
|
||||
:empty-hint="error ? '' : 'Files stored here are managed by ' + providerLabel(provider)"
|
||||
@breadcrumb-navigate="handleBreadcrumbNavigate"
|
||||
@upload="onFilesSelected"
|
||||
@folder-navigate="item => navigateTo(item)"
|
||||
@file-open="file => {}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, reactive } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import * as api from '../api/client.js'
|
||||
import DropZone from '../components/upload/DropZone.vue'
|
||||
import UploadProgress from '../components/upload/UploadProgress.vue'
|
||||
import StorageBrowser from '../components/storage/StorageBrowser.vue'
|
||||
import { providerLabel } from '../utils/formatters.js'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -122,15 +35,14 @@ const folderId = computed(() => route.params.folderId)
|
||||
const folders = computed(() => items.value.filter(i => i.is_dir))
|
||||
const files = computed(() => items.value.filter(i => !i.is_dir))
|
||||
|
||||
const providerLabel = computed(() => {
|
||||
const map = { google_drive: 'Google Drive', onedrive: 'OneDrive', nextcloud: 'Nextcloud', webdav: 'WebDAV' }
|
||||
return map[provider.value] ?? provider.value
|
||||
})
|
||||
|
||||
const folderName = computed(() => {
|
||||
const id = folderId.value ?? ''
|
||||
const parts = id.replace(/\/$/, '').split('/')
|
||||
return parts[parts.length - 1] || providerLabel.value
|
||||
/** Breadcrumb built from the folder path segments. */
|
||||
const breadcrumb = computed(() => {
|
||||
if (!folderId.value || folderId.value === 'root') return []
|
||||
const parts = folderId.value.replace(/\/$/, '').split('/')
|
||||
return parts.map((seg, idx) => ({
|
||||
id: parts.slice(0, idx + 1).join('/'),
|
||||
name: seg,
|
||||
}))
|
||||
})
|
||||
|
||||
async function load() {
|
||||
@@ -155,22 +67,17 @@ function navigateTo(item) {
|
||||
router.push(`/cloud/${provider.value}/${item.id}`)
|
||||
}
|
||||
|
||||
function goUp() {
|
||||
const id = (folderId.value ?? '').replace(/\/$/, '')
|
||||
const lastSlash = id.lastIndexOf('/')
|
||||
if (lastSlash > 0) {
|
||||
router.push(`/cloud/${provider.value}/${id.slice(0, lastSlash + 1)}`)
|
||||
} else {
|
||||
router.back()
|
||||
}
|
||||
function handleBreadcrumbNavigate(id) {
|
||||
if (id == null) router.push(`/cloud/${provider.value}/root`)
|
||||
else router.push(`/cloud/${provider.value}/${id}`)
|
||||
}
|
||||
|
||||
// ── Upload ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const uploadQueue = ref([])
|
||||
|
||||
async function onFilesSelected({ files }) {
|
||||
const promises = files.map(file => {
|
||||
async function onFilesSelected({ files: selectedFiles }) {
|
||||
const promises = selectedFiles.map(file => {
|
||||
const item = reactive({ name: file.name, done: false, error: null, status: 'Uploading…' })
|
||||
uploadQueue.value.unshift(item)
|
||||
return api.uploadToCloud(file, provider.value, folderId.value || null)
|
||||
@@ -181,13 +88,6 @@ async function onFilesSelected({ files }) {
|
||||
await load()
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch([provider, folderId], load)
|
||||
</script>
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useCloudConnectionsStore } from '../stores/cloudConnections.js'
|
||||
import { providerColor, providerBg } from '../utils/formatters.js'
|
||||
|
||||
const router = useRouter()
|
||||
const cloudStore = useCloudConnectionsStore()
|
||||
@@ -73,21 +74,4 @@ function openProvider(conn) {
|
||||
router.push(`/cloud/${conn.provider}/root`)
|
||||
}
|
||||
|
||||
function providerColor(provider) {
|
||||
return {
|
||||
google_drive: 'text-blue-500',
|
||||
onedrive: 'text-sky-500',
|
||||
nextcloud: 'text-orange-500',
|
||||
webdav: 'text-gray-500',
|
||||
}[provider] ?? 'text-gray-400'
|
||||
}
|
||||
|
||||
function providerBg(provider) {
|
||||
return {
|
||||
google_drive: 'bg-blue-50',
|
||||
onedrive: 'bg-sky-50',
|
||||
nextcloud: 'bg-orange-50',
|
||||
webdav: 'bg-gray-100',
|
||||
}[provider] ?? 'bg-gray-50'
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -148,6 +148,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { formatDate, formatSize } from '../utils/formatters.js'
|
||||
import TopicBadge from '../components/topics/TopicBadge.vue'
|
||||
import DocumentPreviewModal from '../components/documents/DocumentPreviewModal.vue'
|
||||
import { useDocumentsStore } from '../stores/documents.js'
|
||||
@@ -292,15 +293,4 @@ function cancelCloudDeleteWarning() {
|
||||
showCloudDeleteWarning.value = false
|
||||
}
|
||||
|
||||
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,243 +1,34 @@
|
||||
<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="foldersStore.breadcrumb"
|
||||
@navigate="handleBreadcrumbNavigate"
|
||||
<StorageBrowser
|
||||
mode="local"
|
||||
:folders="foldersStore.folders"
|
||||
:files="docsStore.documents"
|
||||
:breadcrumb="foldersStore.breadcrumb"
|
||||
:upload-queue="uploadQueue"
|
||||
:loading="docsStore.loading || foldersStore.loading"
|
||||
:search-query="docsStore.searchQuery"
|
||||
:sort-field="docsStore.sortField"
|
||||
:sort-order="docsStore.sortOrder"
|
||||
:root-folders="foldersStore.rootFolders"
|
||||
:topic-color-fn="topicColor"
|
||||
:empty-message="currentFolderId ? 'This folder is empty' : 'No folders yet'"
|
||||
:empty-hint="currentFolderId ? 'Upload files above or create a sub-folder' : 'Create a folder to get started'"
|
||||
ref="browserRef"
|
||||
@breadcrumb-navigate="handleBreadcrumbNavigate"
|
||||
@new-folder="browserRef?.startNewFolder()"
|
||||
@search-change="val => (docsStore.searchQuery = val)"
|
||||
@sort-change="handleSortChange"
|
||||
@upload="onFilesSelected"
|
||||
@folder-navigate="folder => navigateToFolder(folder.id)"
|
||||
@folder-create="handleFolderCreate"
|
||||
@folder-rename="handleFolderRename"
|
||||
@folder-delete="folder => (folderToDelete = folder)"
|
||||
@file-open="file => $router.push(`/document/${file.id}`)"
|
||||
@file-share="file => (shareDoc = file)"
|
||||
@file-move="({ fileId, folderId }) => doMove(fileId, folderId)"
|
||||
@file-delete="doDeleteDoc"
|
||||
/>
|
||||
<div class="ml-auto flex items-center gap-2 shrink-0">
|
||||
<SearchBar v-if="currentFolderId" v-model="docsStore.searchQuery" />
|
||||
<SortControls
|
||||
v-if="currentFolderId"
|
||||
:sort="docsStore.sortField"
|
||||
:order="docsStore.sortOrder"
|
||||
@change="handleSortChange"
|
||||
/>
|
||||
<button
|
||||
@click="startNewFolder"
|
||||
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>
|
||||
|
||||
<!-- ── Folder view ─────────────────────────────────────────────────── -->
|
||||
<div class="flex-1 overflow-y-auto flex flex-col">
|
||||
|
||||
<!-- Upload zone (always at top of folder view) -->
|
||||
<div class="px-6 pt-5 pb-3">
|
||||
<DropZone @files-selected="onFilesSelected" />
|
||||
<UploadProgress :items="uploadQueue" />
|
||||
</div>
|
||||
|
||||
<!-- Column headers -->
|
||||
<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>
|
||||
|
||||
<!-- New-folder inline row -->
|
||||
<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 foldersStore.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': docDragOverFolderId === folder.id,
|
||||
'bg-gray-50': renaming === folder.id,
|
||||
}"
|
||||
@click="renaming === folder.id ? null : navigateToFolder(folder.id)"
|
||||
@dragover.prevent="onFolderDragOver(folder.id, $event)"
|
||||
@dragleave="onFolderDragLeave"
|
||||
@drop.prevent="onDropDocOnFolder(folder.id)"
|
||||
>
|
||||
<!-- Folder icon -->
|
||||
<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="renaming === 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="submitRename(folder.id)"
|
||||
@keydown.escape="cancelRename"
|
||||
@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>
|
||||
|
||||
<!-- Hover actions -->
|
||||
<div class="flex justify-end gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop>
|
||||
<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="folderToDelete = 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Document rows ------------------------------------------------ -->
|
||||
<div
|
||||
v-for="doc in docsStore.documents"
|
||||
:key="`d-${doc.id}`"
|
||||
draggable="true"
|
||||
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': docDragging?.id === doc.id }"
|
||||
@click="$router.push(`/document/${doc.id}`)"
|
||||
@dragstart="onDocDragStart(doc, $event)"
|
||||
@dragend="docDragging = null; docDragOverFolderId = null"
|
||||
>
|
||||
<!-- File icon -->
|
||||
<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 -->
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{ doc.original_name }}</p>
|
||||
<span v-if="doc.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="doc.topics?.length" class="flex items-center gap-1 mt-0.5 flex-wrap">
|
||||
<TopicBadge v-for="t in doc.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(doc.size_bytes) }}</span>
|
||||
<span class="text-right text-xs text-gray-400 hidden sm:block">{{ formatDate(doc.created_at) }}</span>
|
||||
|
||||
<!-- Hover actions -->
|
||||
<div class="flex justify-end gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop>
|
||||
<!-- Share -->
|
||||
<button @click.stop="shareDoc = doc" 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="folderPickerDocId = folderPickerDocId === doc.id ? null : doc.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="folderPickerDocId === doc.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="doMove(doc.id, null)">Root (no folder)</button>
|
||||
<button
|
||||
v-for="f in foldersStore.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="doMove(doc.id, f.id)"
|
||||
>{{ f.name }}</button>
|
||||
<p v-if="!foldersStore.rootFolders.length" class="px-3 py-2 text-xs text-gray-400">No folders yet</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Delete -->
|
||||
<button @click.stop="doDeleteDoc(doc.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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state (no search active) -->
|
||||
<div
|
||||
v-if="!docsStore.loading && !foldersStore.loading && foldersStore.folders.length === 0 && docsStore.documents.length === 0 && !showNewFolderInput"
|
||||
class="px-4 py-10 text-center text-gray-300"
|
||||
>
|
||||
<p class="text-gray-400 text-sm">{{ currentFolderId ? 'This folder is empty' : 'No folders yet' }}</p>
|
||||
<p class="text-xs mt-1">{{ currentFolderId ? 'Upload files above or create a sub-folder' : 'Create a folder to get started' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- No results for search -->
|
||||
<div
|
||||
v-else-if="docsStore.searchQuery && docsStore.documents.length === 0 && foldersStore.folders.length === 0"
|
||||
class="px-4 py-10 text-center text-sm text-gray-400"
|
||||
>
|
||||
No items match "{{ docsStore.searchQuery }}".
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="docsStore.loading || foldersStore.loading" class="py-6 text-center text-sm text-gray-400">
|
||||
Loading…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Modals ──────────────────────────────────────────────────────── -->
|
||||
<FolderDeleteModal
|
||||
v-if="folderToDelete"
|
||||
:folder="folderToDelete"
|
||||
@@ -250,22 +41,16 @@
|
||||
@close="shareDoc = null"
|
||||
@unshared="(id) => { const d = docsStore.documents.find(x => x.id === id); if (d) d.is_shared = false }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, reactive, computed, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useFoldersStore } from '../stores/folders.js'
|
||||
import { useDocumentsStore } from '../stores/documents.js'
|
||||
import { useTopicsStore } from '../stores/topics.js'
|
||||
import FolderBreadcrumb from '../components/folders/FolderBreadcrumb.vue'
|
||||
import StorageBrowser from '../components/storage/StorageBrowser.vue'
|
||||
import FolderDeleteModal from '../components/folders/FolderDeleteModal.vue'
|
||||
import SearchBar from '../components/documents/SearchBar.vue'
|
||||
import SortControls from '../components/documents/SortControls.vue'
|
||||
import TopicBadge from '../components/topics/TopicBadge.vue'
|
||||
import UploadProgress from '../components/upload/UploadProgress.vue'
|
||||
import DropZone from '../components/upload/DropZone.vue'
|
||||
import ShareModal from '../components/sharing/ShareModal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -273,6 +58,7 @@ const router = useRouter()
|
||||
const foldersStore = useFoldersStore()
|
||||
const docsStore = useDocumentsStore()
|
||||
const topicsStore = useTopicsStore()
|
||||
const browserRef = ref(null)
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -328,84 +114,22 @@ async function onFilesSelected({ files, autoClassify }) {
|
||||
await topicsStore.fetchTopics()
|
||||
}
|
||||
|
||||
// ── Drag-and-drop: move documents onto folders ────────────────────────────────
|
||||
// ── Folder CRUD ───────────────────────────────────────────────────────────────
|
||||
|
||||
const docDragging = ref(null) // doc object being dragged
|
||||
const docDragOverFolderId = ref(null) // folder.id the cursor is over
|
||||
|
||||
function onDocDragStart(doc, e) {
|
||||
docDragging.value = doc
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', doc.id)
|
||||
}
|
||||
|
||||
function onFolderDragOver(folderId, e) {
|
||||
if (!docDragging.value) return
|
||||
e.preventDefault()
|
||||
docDragOverFolderId.value = folderId
|
||||
}
|
||||
|
||||
function onFolderDragLeave() {
|
||||
docDragOverFolderId.value = null
|
||||
}
|
||||
|
||||
async function onDropDocOnFolder(folderId) {
|
||||
if (!docDragging.value) return
|
||||
const docId = docDragging.value.id
|
||||
docDragging.value = null
|
||||
docDragOverFolderId.value = null
|
||||
try { await docsStore.moveToFolder(docId, folderId) } catch (e) { console.error(e.message) }
|
||||
}
|
||||
|
||||
// ── New folder ────────────────────────────────────────────────────────────────
|
||||
|
||||
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 }
|
||||
async function handleFolderCreate({ name, onError, onSuccess }) {
|
||||
try {
|
||||
await foldersStore.createFolder(name, currentFolderId.value)
|
||||
showNewFolderInput.value = false
|
||||
newFolderError.value = ''
|
||||
onSuccess()
|
||||
} catch (e) {
|
||||
newFolderError.value = e.message || 'Failed to create folder.'
|
||||
onError(e.message || 'Failed to create folder.')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rename folder ─────────────────────────────────────────────────────────────
|
||||
|
||||
const renaming = ref(null)
|
||||
const renameValue = ref('')
|
||||
|
||||
function startRename(folder) {
|
||||
renaming.value = folder.id
|
||||
renameValue.value = folder.name
|
||||
async function handleFolderRename({ id, name }) {
|
||||
if (!name) return
|
||||
try { await foldersStore.renameFolder(id, name) } catch {}
|
||||
}
|
||||
|
||||
function cancelRename() { renaming.value = null }
|
||||
|
||||
async function submitRename(folderId) {
|
||||
const name = renameValue.value.trim()
|
||||
if (!name) { cancelRename(); return }
|
||||
try { await foldersStore.renameFolder(folderId, name) }
|
||||
finally { renaming.value = null }
|
||||
}
|
||||
|
||||
// ── Delete folder ─────────────────────────────────────────────────────────────
|
||||
|
||||
const folderToDelete = ref(null)
|
||||
|
||||
async function confirmDeleteFolder() {
|
||||
@@ -417,10 +141,8 @@ async function confirmDeleteFolder() {
|
||||
// ── Document actions ──────────────────────────────────────────────────────────
|
||||
|
||||
const shareDoc = ref(null)
|
||||
const folderPickerDocId = ref(null)
|
||||
|
||||
async function doMove(docId, folderId) {
|
||||
folderPickerDocId.value = null
|
||||
try { await docsStore.moveToFolder(docId, folderId) } catch (e) { console.error(e.message) }
|
||||
}
|
||||
|
||||
@@ -428,27 +150,9 @@ async function doDeleteDoc(docId) {
|
||||
try { await docsStore.remove(docId) } catch (e) { console.error(e.message) }
|
||||
}
|
||||
|
||||
function onOutsideClick(e) {
|
||||
if (!e.target.closest('.relative')) folderPickerDocId.value = null
|
||||
}
|
||||
onMounted(() => document.addEventListener('click', onOutsideClick))
|
||||
onUnmounted(() => document.removeEventListener('click', onOutsideClick))
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
// ── Topic color lookup ────────────────────────────────────────────────────────
|
||||
|
||||
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 < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / 1048576).toFixed(1) + ' MB'
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
<template>
|
||||
<div class="p-8 max-w-4xl mx-auto">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="mb-6">
|
||||
<FolderBreadcrumb
|
||||
:segments="foldersStore.breadcrumb"
|
||||
@navigate="handleBreadcrumbNavigate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Folder heading -->
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-1">
|
||||
{{ currentFolderName || 'Folder' }}
|
||||
</h2>
|
||||
<p class="text-gray-500 text-sm mb-6">Documents and sub-folders in this folder.</p>
|
||||
|
||||
<!-- Sub-folders section -->
|
||||
<div v-if="foldersStore.folders.length > 0" class="mb-6">
|
||||
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-3">Folders</h3>
|
||||
<div class="grid gap-2">
|
||||
<FolderRow
|
||||
v-for="folder in foldersStore.folders"
|
||||
:key="folder.id"
|
||||
:folder="folder"
|
||||
:on-navigate="navigateToFolder"
|
||||
:on-rename="handleRenameFolder"
|
||||
:on-delete="handleDeleteFolder"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New subfolder button / inline input -->
|
||||
<div class="mb-6">
|
||||
<div v-if="showNewFolderInput" class="flex items-center gap-2">
|
||||
<input
|
||||
ref="newFolderInputRef"
|
||||
v-model="newFolderName"
|
||||
type="text"
|
||||
placeholder="Folder name"
|
||||
class="border border-gray-300 rounded-lg px-3 py-2 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"
|
||||
>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 ml-2">{{ newFolderError }}</p>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
@click="startNewFolder"
|
||||
class="text-sm text-indigo-600 hover:underline flex items-center gap-1"
|
||||
>
|
||||
<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 subfolder
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Documents section -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-800">Documents</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<SortControls
|
||||
:sort="docsStore.sortField"
|
||||
:order="docsStore.sortOrder"
|
||||
@change="handleSortChange"
|
||||
/>
|
||||
<SearchBar v-model="docsStore.searchQuery" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="docsStore.loading" class="text-sm text-gray-400">Loading…</div>
|
||||
<div v-else-if="docsStore.documents.length === 0" class="text-center py-12 text-gray-400">
|
||||
<p class="text-sm" v-if="docsStore.searchQuery">
|
||||
No documents match "{{ docsStore.searchQuery }}". Try a different search term.
|
||||
</p>
|
||||
<div v-else>
|
||||
<p class="text-sm">This folder is empty.</p>
|
||||
<p class="text-xs mt-1">Upload documents or create sub-folders to organize your files.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="grid gap-3">
|
||||
<DocumentCard v-for="doc in docsStore.documents" :key="doc.id" :doc="doc" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete confirmation modal -->
|
||||
<FolderDeleteModal
|
||||
v-if="folderToDelete"
|
||||
:folder="folderToDelete"
|
||||
@confirm="confirmDeleteFolder"
|
||||
@cancel="folderToDelete = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useFoldersStore } from '../stores/folders.js'
|
||||
import { useDocumentsStore } from '../stores/documents.js'
|
||||
import FolderBreadcrumb from '../components/folders/FolderBreadcrumb.vue'
|
||||
import FolderRow from '../components/folders/FolderRow.vue'
|
||||
import FolderDeleteModal from '../components/folders/FolderDeleteModal.vue'
|
||||
import DocumentCard from '../components/documents/DocumentCard.vue'
|
||||
import SearchBar from '../components/documents/SearchBar.vue'
|
||||
import SortControls from '../components/documents/SortControls.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const foldersStore = useFoldersStore()
|
||||
const docsStore = useDocumentsStore()
|
||||
|
||||
const showNewFolderInput = ref(false)
|
||||
const newFolderName = ref('')
|
||||
const newFolderError = ref('')
|
||||
const newFolderInputRef = ref(null)
|
||||
const folderToDelete = ref(null)
|
||||
|
||||
const currentFolderName = computed(() => {
|
||||
const breadcrumb = foldersStore.breadcrumb
|
||||
return breadcrumb.length > 0 ? breadcrumb[breadcrumb.length - 1].name : ''
|
||||
})
|
||||
|
||||
async function loadFolder(folderId) {
|
||||
await foldersStore.navigateTo(folderId)
|
||||
await foldersStore.fetchFolders(folderId)
|
||||
docsStore.currentFolderId = folderId
|
||||
await docsStore.fetchDocuments({ folderId, sort: docsStore.sortField, order: docsStore.sortOrder })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const folderId = route.params.folderId
|
||||
if (folderId) loadFolder(folderId)
|
||||
})
|
||||
|
||||
watch(() => route.params.folderId, (newId) => {
|
||||
if (newId) loadFolder(newId)
|
||||
})
|
||||
|
||||
function navigateToFolder(folderId) {
|
||||
router.push(`/folders/${folderId}`)
|
||||
}
|
||||
|
||||
function handleBreadcrumbNavigate(folderId) {
|
||||
if (folderId == null) {
|
||||
router.push('/')
|
||||
} else {
|
||||
router.push(`/folders/${folderId}`)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSortChange({ sort, order }) {
|
||||
docsStore.sortField = sort
|
||||
docsStore.sortOrder = order
|
||||
docsStore.fetchDocuments({ folderId: route.params.folderId, sort, order })
|
||||
}
|
||||
|
||||
function startNewFolder() {
|
||||
newFolderName.value = ''
|
||||
newFolderError.value = ''
|
||||
showNewFolderInput.value = true
|
||||
nextTick(() => newFolderInputRef.value?.focus())
|
||||
}
|
||||
|
||||
function cancelNewFolder() {
|
||||
showNewFolderInput.value = false
|
||||
newFolderError.value = ''
|
||||
}
|
||||
|
||||
async function submitNewFolder() {
|
||||
const trimmed = newFolderName.value.trim()
|
||||
if (!trimmed) {
|
||||
newFolderError.value = 'Folder name cannot be empty.'
|
||||
return
|
||||
}
|
||||
try {
|
||||
await foldersStore.createFolder(trimmed, route.params.folderId)
|
||||
showNewFolderInput.value = false
|
||||
newFolderError.value = ''
|
||||
} catch (e) {
|
||||
newFolderError.value = e.message || 'Failed to create folder.'
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRenameFolder(folderId, name) {
|
||||
await foldersStore.renameFolder(folderId, name)
|
||||
}
|
||||
|
||||
function handleDeleteFolder(folder) {
|
||||
folderToDelete.value = folder
|
||||
}
|
||||
|
||||
async function confirmDeleteFolder() {
|
||||
if (!folderToDelete.value) return
|
||||
try {
|
||||
await foldersStore.deleteFolder(folderToDelete.value.id)
|
||||
folderToDelete.value = null
|
||||
} catch (e) {
|
||||
folderToDelete.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,87 +0,0 @@
|
||||
<template>
|
||||
<div class="p-8 max-w-4xl mx-auto">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-1">Upload Documents</h2>
|
||||
<p class="text-gray-500 text-sm mb-6">Drop files to extract text and classify them with AI.</p>
|
||||
|
||||
<DropZone @files-selected="onFilesSelected" />
|
||||
<UploadProgress :items="uploadQueue" />
|
||||
|
||||
<!-- Recent documents -->
|
||||
<div class="mt-10">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-800">Recent Documents</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<SortControls
|
||||
:sort="docsStore.sortField"
|
||||
:order="docsStore.sortOrder"
|
||||
@change="handleSortChange"
|
||||
/>
|
||||
<SearchBar v-model="docsStore.searchQuery" />
|
||||
<span class="text-sm text-gray-400">{{ docsStore.total }} total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="docsStore.loading" class="text-sm text-gray-400">Loading…</div>
|
||||
<div v-else-if="docsStore.documents.length === 0" class="text-center py-12 text-gray-400">
|
||||
<p class="text-sm" v-if="docsStore.searchQuery">
|
||||
No documents match "{{ docsStore.searchQuery }}". Try a different search term.
|
||||
</p>
|
||||
<p class="text-sm" v-else>No documents yet. Upload one above.</p>
|
||||
</div>
|
||||
<div v-else class="grid gap-3">
|
||||
<DocumentCard v-for="doc in docsStore.documents" :key="doc.id" :doc="doc" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import DropZone from '../components/upload/DropZone.vue'
|
||||
import UploadProgress from '../components/upload/UploadProgress.vue'
|
||||
import DocumentCard from '../components/documents/DocumentCard.vue'
|
||||
import SearchBar from '../components/documents/SearchBar.vue'
|
||||
import SortControls from '../components/documents/SortControls.vue'
|
||||
import { useDocumentsStore } from '../stores/documents.js'
|
||||
import { useTopicsStore } from '../stores/topics.js'
|
||||
import { useFoldersStore } from '../stores/folders.js'
|
||||
|
||||
const docsStore = useDocumentsStore()
|
||||
const topicsStore = useTopicsStore()
|
||||
const foldersStore = useFoldersStore()
|
||||
const uploadQueue = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
docsStore.fetchDocuments()
|
||||
foldersStore.fetchFolders(null)
|
||||
})
|
||||
|
||||
function handleSortChange({ sort, order }) {
|
||||
docsStore.sortField = sort
|
||||
docsStore.sortOrder = order
|
||||
docsStore.fetchDocuments({ sort, order })
|
||||
}
|
||||
|
||||
async function onFilesSelected({ files, autoClassify }) {
|
||||
// Build queue items
|
||||
const items = files.map(f => ({ name: f.name, done: false, error: null, topics: null }))
|
||||
uploadQueue.value = [...items, ...uploadQueue.value]
|
||||
|
||||
for (const [i, file] of files.entries()) {
|
||||
try {
|
||||
const doc = await docsStore.upload(file, autoClassify)
|
||||
const item = uploadQueue.value.find(q => q.name === file.name && !q.done && !q.error)
|
||||
if (item) {
|
||||
item.done = true
|
||||
item.topics = doc.topics
|
||||
}
|
||||
} catch (e) {
|
||||
const item = uploadQueue.value.find(q => q.name === file.name && !q.done && !q.error)
|
||||
if (item) item.error = e.message
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh topics (new ones may have been created)
|
||||
await topicsStore.fetchTopics()
|
||||
}
|
||||
</script>
|
||||
@@ -50,6 +50,7 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import * as api from '../api/client.js'
|
||||
import { formatDate, formatSize } from '../utils/formatters.js'
|
||||
|
||||
const sharedDocs = ref([])
|
||||
const loading = ref(false)
|
||||
@@ -68,15 +69,4 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user