feat(05): cloud folder browser views, routing, and sidebar nav
Add CloudStorageView (/cloud) and CloudFolderView (/cloud/:provider/:folderId).
Tree items filter to directories only (is_dir) to hide files in the nav tree.
CloudProviderTreeItem root click navigates to /cloud/{provider}/root instead
of /settings. AppSidebar Cloud Storage link upgraded to router-link with
active-class highlighting. Router registers both cloud routes with requiresAuth.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -102,7 +102,7 @@ async function loadChildren() {
|
|||||||
loadError.value = false
|
loadError.value = false
|
||||||
try {
|
try {
|
||||||
const data = await api.getCloudFolders(props.provider, props.folder.id)
|
const data = await api.getCloudFolders(props.provider, props.folder.id)
|
||||||
children.value = data.items ?? []
|
children.value = (data.items ?? []).filter(i => i.is_dir)
|
||||||
childrenLoaded.value = true
|
childrenLoaded.value = true
|
||||||
} catch {
|
} catch {
|
||||||
loadError.value = true
|
loadError.value = true
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ async function loadChildren() {
|
|||||||
loadError.value = false
|
loadError.value = false
|
||||||
try {
|
try {
|
||||||
const data = await api.getCloudFolders(props.connection.provider, 'root')
|
const data = await api.getCloudFolders(props.connection.provider, 'root')
|
||||||
children.value = data.items ?? []
|
children.value = (data.items ?? []).filter(i => i.is_dir)
|
||||||
childrenLoaded.value = true
|
childrenLoaded.value = true
|
||||||
} catch {
|
} catch {
|
||||||
loadError.value = true
|
loadError.value = true
|
||||||
@@ -113,6 +113,6 @@ async function retry() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function navigateToRoot() {
|
function navigateToRoot() {
|
||||||
router.push('/settings')
|
router.push(`/cloud/${props.connection.provider}/root`)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -128,17 +128,18 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- "Cloud Storage" navigates to /settings -->
|
<!-- "Cloud Storage" navigates to the cloud overview -->
|
||||||
<a
|
<router-link
|
||||||
href="/settings"
|
to="/cloud"
|
||||||
class="nav-link flex-1 min-w-0"
|
class="nav-link flex-1 min-w-0"
|
||||||
|
:class="{ 'nav-link-active': $route.path.startsWith('/cloud') }"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4 mr-2 shrink-0 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 mr-2 shrink-0 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<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" />
|
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>
|
</svg>
|
||||||
Cloud Storage
|
Cloud Storage
|
||||||
</a>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Collapsible content -->
|
<!-- Collapsible content -->
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import FileManagerView from '../views/FileManagerView.vue'
|
|||||||
import TopicsView from '../views/TopicsView.vue'
|
import TopicsView from '../views/TopicsView.vue'
|
||||||
import DocumentView from '../views/DocumentView.vue'
|
import DocumentView from '../views/DocumentView.vue'
|
||||||
import SettingsView from '../views/SettingsView.vue'
|
import SettingsView from '../views/SettingsView.vue'
|
||||||
|
import CloudFolderView from '../views/CloudFolderView.vue'
|
||||||
|
import CloudStorageView from '../views/CloudStorageView.vue'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
// File manager is the home — handles both root and folder views
|
// File manager is the home — handles both root and folder views
|
||||||
@@ -39,6 +41,20 @@ const routes = [
|
|||||||
{ path: '/account', component: () => import('../views/AccountView.vue') },
|
{ path: '/account', component: () => import('../views/AccountView.vue') },
|
||||||
{ path: '/admin', component: () => import('../views/AdminView.vue') },
|
{ path: '/admin', component: () => import('../views/AdminView.vue') },
|
||||||
|
|
||||||
|
// Cloud storage overview and folder browser
|
||||||
|
{
|
||||||
|
path: '/cloud',
|
||||||
|
name: 'cloud',
|
||||||
|
component: CloudStorageView,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/cloud/:provider/:folderId(.*)',
|
||||||
|
name: 'cloud-folder',
|
||||||
|
component: CloudFolderView,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
|
||||||
// Phase 4 — folder and sharing routes
|
// Phase 4 — folder and sharing routes
|
||||||
{
|
{
|
||||||
path: '/folders/:folderId',
|
path: '/folders/:folderId',
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<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">
|
||||||
|
{{ error }}
|
||||||
|
<button @click="load" class="ml-2 text-indigo-600 hover:underline">Retry</button>
|
||||||
|
</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>
|
||||||
|
</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'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const items = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const provider = computed(() => route.params.provider)
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const data = await api.getCloudFolders(provider.value, folderId.value ?? 'root')
|
||||||
|
items.value = data.items ?? []
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message || 'Failed to load folder contents'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Upload ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const uploadQueue = ref([])
|
||||||
|
|
||||||
|
async function onFilesSelected({ files }) {
|
||||||
|
const promises = files.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)
|
||||||
|
.then(() => { item.done = true; item.status = null })
|
||||||
|
.catch(e => { item.error = e.message || 'Upload failed' })
|
||||||
|
})
|
||||||
|
await Promise.allSettled(promises)
|
||||||
|
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>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<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">
|
||||||
|
<span class="text-sm font-medium text-gray-700">Cloud Storage</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-1 overflow-y-auto px-6 py-5">
|
||||||
|
|
||||||
|
<!-- Column headers -->
|
||||||
|
<div class="px-4 py-2 grid grid-cols-[2rem_1fr_8rem] 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>Status</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="text-sm text-gray-400 py-8 text-center">Loading…</div>
|
||||||
|
|
||||||
|
<div v-else-if="connections.length === 0" class="text-center py-12 text-gray-400">
|
||||||
|
<p class="text-sm">No cloud storage connected.</p>
|
||||||
|
<router-link to="/settings" class="text-sm text-indigo-600 hover:underline mt-1 inline-block">
|
||||||
|
Add a connection in Settings
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex flex-col divide-y divide-gray-100 border border-gray-100 rounded-xl overflow-hidden">
|
||||||
|
<div
|
||||||
|
v-for="conn in connections"
|
||||||
|
:key="conn.id"
|
||||||
|
class="px-4 py-2.5 grid grid-cols-[2rem_1fr_8rem] gap-3 items-center hover:bg-gray-50 group cursor-pointer transition-colors"
|
||||||
|
@click="openProvider(conn)"
|
||||||
|
>
|
||||||
|
<!-- Provider icon -->
|
||||||
|
<div class="w-7 h-7 rounded-lg flex items-center justify-center shrink-0" :class="providerBg(conn.provider)">
|
||||||
|
<svg class="w-4 h-4" :class="providerColor(conn.provider)" 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="text-sm font-medium text-gray-900 truncate">{{ conn.display_name }}</span>
|
||||||
|
|
||||||
|
<span class="text-xs" :class="conn.status === 'ACTIVE' ? 'text-green-600' : 'text-amber-500'">
|
||||||
|
{{ conn.status === 'ACTIVE' ? 'Connected' : 'Needs reauth' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useCloudConnectionsStore } from '../stores/cloudConnections.js'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const cloudStore = useCloudConnectionsStore()
|
||||||
|
|
||||||
|
const loading = computed(() => cloudStore.loading)
|
||||||
|
const connections = computed(() => cloudStore.connections)
|
||||||
|
|
||||||
|
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>
|
||||||
Reference in New Issue
Block a user