feat(05-08): add cloud tree components and getCloudFolders API function
- Add getCloudFolders(provider, folderId) to api/client.js (GET /api/cloud/folders/{provider}/{folderId})
- Create CloudProviderTreeItem.vue: lazy-load folder tree per connection, providerIconColor computed, expand/collapse arrow, loading/error states
- Create CloudFolderTreeItem.vue: recursive folder tree node with is_dir expand arrow, lazy-load children, depth padding
This commit is contained in:
@@ -390,3 +390,7 @@ export function updateDefaultStorage(backend) {
|
|||||||
body: JSON.stringify({ backend }),
|
body: JSON.stringify({ backend }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCloudFolders(provider, folderId) {
|
||||||
|
return request(`/api/cloud/folders/${provider}/${folderId}`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Row -->
|
||||||
|
<div
|
||||||
|
class="flex items-center group"
|
||||||
|
:style="{ paddingLeft: `${depth * 12}px` }"
|
||||||
|
>
|
||||||
|
<!-- 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 -->
|
||||||
|
<svg
|
||||||
|
v-if="folder.is_dir"
|
||||||
|
class="w-4 h-4 shrink-0 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
v-else
|
||||||
|
class="w-4 h-4 shrink-0 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span class="truncate">{{ folder.name }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Children: nested sub-folders (lazy loaded) -->
|
||||||
|
<template v-if="expanded">
|
||||||
|
<div v-if="loading" class="text-xs text-gray-400 py-1" :style="{ paddingLeft: `${(depth + 1) * 12 + 8}px` }">Loading…</div>
|
||||||
|
<div
|
||||||
|
v-else-if="loadError"
|
||||||
|
class="text-xs text-red-500 cursor-pointer py-1"
|
||||||
|
:style="{ paddingLeft: `${(depth + 1) * 12 + 8}px` }"
|
||||||
|
@click="retry"
|
||||||
|
>
|
||||||
|
Failed to load — tap to retry
|
||||||
|
</div>
|
||||||
|
<div v-else-if="children.length === 0" class="text-xs text-gray-400 py-1" :style="{ paddingLeft: `${(depth + 1) * 12 + 8}px` }">Empty</div>
|
||||||
|
<CloudFolderTreeItem
|
||||||
|
v-for="child in children"
|
||||||
|
:key="child.id"
|
||||||
|
:folder="child"
|
||||||
|
:provider="provider"
|
||||||
|
:depth="depth + 1"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import * as api from '../../api/client.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
folder: { type: Object, required: true },
|
||||||
|
provider: { type: String, required: true },
|
||||||
|
depth: { type: Number, default: 2 },
|
||||||
|
})
|
||||||
|
|
||||||
|
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 ?? []
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateTo() {
|
||||||
|
router.push(`/cloud/${props.provider}/${props.folder.id}`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Row -->
|
||||||
|
<div
|
||||||
|
class="flex items-center group"
|
||||||
|
:style="{ paddingLeft: `${depth * 12}px` }"
|
||||||
|
>
|
||||||
|
<!-- Expand/collapse arrow -->
|
||||||
|
<button
|
||||||
|
@click.prevent.stop="toggleExpand"
|
||||||
|
class="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-gray-600 shrink-0 transition-colors"
|
||||||
|
:aria-label="expanded ? 'Collapse ' + connection.display_name : 'Expand ' + connection.display_name"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-3 h-3 transition-transform duration-150"
|
||||||
|
:class="{ 'rotate-90': expanded }"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Provider name (click navigates to /settings) -->
|
||||||
|
<button
|
||||||
|
@click="navigateToRoot"
|
||||||
|
class="flex items-center gap-2 flex-1 min-w-0 px-2 py-1.5 rounded-lg text-sm font-medium transition-colors text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
<!-- Provider cloud icon (w-4 h-4, provider color) -->
|
||||||
|
<svg class="w-4 h-4 shrink-0" :class="providerIconColor" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
|
||||||
|
</svg>
|
||||||
|
<span class="truncate">{{ connection.display_name }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Children: first-level cloud folders (lazy loaded) -->
|
||||||
|
<template v-if="expanded">
|
||||||
|
<div v-if="loading" class="pl-12 py-1 text-xs text-gray-400">Loading…</div>
|
||||||
|
<div
|
||||||
|
v-else-if="loadError"
|
||||||
|
class="pl-12 py-1 text-xs text-red-500 cursor-pointer"
|
||||||
|
@click="retry"
|
||||||
|
>
|
||||||
|
Failed to load — tap to retry
|
||||||
|
</div>
|
||||||
|
<div v-else-if="children.length === 0" class="pl-12 py-1 text-xs text-gray-400">Empty</div>
|
||||||
|
<CloudFolderTreeItem
|
||||||
|
v-for="folder in children"
|
||||||
|
:key="folder.id"
|
||||||
|
:folder="folder"
|
||||||
|
:provider="connection.provider"
|
||||||
|
:depth="depth + 1"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import * as api from '../../api/client.js'
|
||||||
|
import CloudFolderTreeItem from './CloudFolderTreeItem.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
connection: { type: Object, required: true },
|
||||||
|
depth: { type: Number, default: 1 },
|
||||||
|
})
|
||||||
|
|
||||||
|
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'
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadChildren() {
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = false
|
||||||
|
try {
|
||||||
|
const data = await api.getCloudFolders(props.connection.provider, 'root')
|
||||||
|
children.value = data.items ?? []
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToRoot() {
|
||||||
|
router.push('/settings')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user