fix: create component StorageFileBrowser

This commit is contained in:
2026-04-09 16:00:36 +02:00
parent 0d2bf3a27f
commit 1fb2a33cfd
5 changed files with 192 additions and 10 deletions

2
bo/components.d.ts vendored
View File

@@ -40,6 +40,7 @@ declare module 'vue' {
Profile: typeof import('./src/components/customer-management/Profile.vue')['default'] Profile: typeof import('./src/components/customer-management/Profile.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
StorageFileBrowser: typeof import('./src/components/customer/StorageFileBrowser.vue')['default']
ThemeSwitch: typeof import('./src/components/inner/ThemeSwitch.vue')['default'] ThemeSwitch: typeof import('./src/components/inner/ThemeSwitch.vue')['default']
TopBar: typeof import('./src/components/TopBar.vue')['default'] TopBar: typeof import('./src/components/TopBar.vue')['default']
TopBarLogin: typeof import('./src/components/TopBarLogin.vue')['default'] TopBarLogin: typeof import('./src/components/TopBarLogin.vue')['default']
@@ -68,5 +69,6 @@ declare module 'vue' {
UTable: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Table.vue')['default'] UTable: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Table.vue')['default']
UTabs: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Tabs.vue')['default'] UTabs: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Tabs.vue')['default']
UTextarea: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Textarea.vue')['default'] UTextarea: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Textarea.vue')['default']
UTree: typeof import('./node_modules/@nuxt/ui/dist/runtime/components/Tree.vue')['default']
} }
} }

View File

@@ -0,0 +1,159 @@
<template>
<component :is="Default || 'div'">
<div class="p-4">
<div v-if="loading" class="flex justify-center py-8">
<ULoader />
</div>
<div v-else-if="error" class="text-red-500">
{{ error }}
</div>
<UTree v-else :items="treeItems" :expanded="expandedFolders">
<template #item-wrapper="{ item }">
<div class="flex items-start cursor-pointer" @click="onItemClick(item)">
<div class="flex items-center gap-1">
<UIcon :name="item.icon" :size="30" />
<div class="flex gap-1 items-center">
<span class="text-[15px] font-medium">{{ item.label }}</span>
<UButton v-if="!item.isFolder && item.fileName" size="xxs" color="neutral"
variant="outline" icon="i-lucide-download"
@click.stop="downloadFile(item.path, item.fileName)" :ui="{ base: 'ring-0!' }" />
</div>
</div>
</div>
</template>
</UTree>
</div>
</component>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useFetchJson } from '@/composable/useFetchJson'
import Default from '@/layouts/default.vue'
interface FileItemRaw {
Name: string
IsFolder: boolean
}
interface FileItem {
name: string
type: 'file' | 'folder'
}
interface TreeItem {
label: string
icon: string
children?: TreeItem[]
isFolder: boolean
path: string
value: string
fileName?: string
}
const props = defineProps<{ initialPath?: string }>()
const currentPath = ref(props.initialPath || '')
const allData = ref<Map<string, FileItem[]>>(new Map())
const expandedFolders = ref<string[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchFolderContents(path: string): Promise<FileItem[]> {
const url = `/api/v1/restricted/storage/list-content/${path}`
const data = await useFetchJson<FileItemRaw[]>(url)
return (data.items || []).map(i => ({
name: i.Name,
type: i.IsFolder ? 'folder' : 'file'
}))
}
async function loadFolder(path: string) {
if (allData.value.has(path)) return
loading.value = true
error.value = null
try {
const items = await fetchFolderContents(path)
allData.value.set(path, items)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load folder contents'
} finally {
loading.value = false
}
}
async function toggleFolder(item: TreeItem) {
if (!item.isFolder) return
if (!expandedFolders.value.includes(item.value)) {
expandedFolders.value.push(item.value)
await loadFolder(item.value)
} else {
expandedFolders.value = expandedFolders.value.filter(v => v !== item.value)
}
}
function onItemClick(item: TreeItem) {
if (item.isFolder) {
toggleFolder(item)
}
}
function buildTreeItems(items: FileItem[], path: string): TreeItem[] {
return items.map(item => {
const itemPath = path ? `${path}/${item.name}` : item.name
const isFolder = item.type === 'folder'
const children = isFolder
? buildTreeItems(allData.value.get(itemPath) || [], itemPath)
: undefined
return {
label: item.name,
icon: isFolder ? 'fxemoji:folder' : 'flat-color-icons:file',
isFolder,
path: isFolder ? itemPath : path,
value: itemPath,
fileName: isFolder ? undefined : item.name,
children
}
})
}
async function downloadFile(path: string, fileName: string) {
try {
const response = await fetch(`/api/v1/restricted/storage/download-file/${path}/${fileName}`)
if (!response.ok) throw new Error('Failed to download file')
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(url)
} catch (e) {
console.error('Download error:', e)
alert('Failed to download file')
}
}
const treeItems = computed<TreeItem[]>(() => {
const items = allData.value.get(currentPath.value) || []
return buildTreeItems(items, currentPath.value)
})
loadFolder(currentPath.value).then(() => {
const rootItems = treeItems.value
if (rootItems.length > 0 && rootItems[0].isFolder) {
toggleFolder(rootItems[0])
}
})
</script>

View File

@@ -23,8 +23,8 @@
<template #footer> <template #footer>
<UDropdownMenu :items="userItems" :content="{ align: 'center', collisionPadding: 12 }" <UDropdownMenu :items="userItems" :content="{ align: 'center', collisionPadding: 12 }"
:ui="{ content: 'w-(--reka-dropdown-menu-trigger-width) min-w-48' }"> :ui="{ content: 'w-(--reka-dropdown-menu-trigger-width) min-w-48' }">
<UButton v-bind="userStore.user" :label="userStore.user?.email" trailing-icon="i-lucide-chevrons-up-down" color="neutral" <UButton v-bind="userStore.user" :label="userStore.user?.email" trailing-icon="i-lucide-chevrons-up-down"
variant="ghost" square class="w-full data-[state=open]:bg-elevated overflow-hidden" :ui="{ color="neutral" variant="ghost" square class="w-full data-[state=open]:bg-elevated overflow-hidden" :ui="{
trailingIcon: 'text-dimmed ms-auto' trailingIcon: 'text-dimmed ms-auto'
}" /> }" />
</UDropdownMenu> </UDropdownMenu>
@@ -34,8 +34,12 @@
<div class="flex-1 flex flex-col"> <div class="flex-1 flex flex-col">
<div class="flex h-(--ui-header-height) shrink-0 items-center justify-between px-4 border-b border-default"> <div class="flex h-(--ui-header-height) shrink-0 items-center justify-between px-4 border-b border-default">
<UButton icon="i-lucide-panel-left" color="neutral" variant="ghost" aria-label="Toggle sidebar" <div class="flex items-center gap-2">
@click="open = !open" /> <UButton icon="i-lucide-panel-left" color="neutral" variant="ghost" aria-label="Toggle sidebar"
@click="open = !open" />
<span class="text-[20px] font-medium">{{ pageTitle }}</span>
</div>
<div class="hidden md:flex items-center gap-12"> <div class="hidden md:flex items-center gap-12">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<CountryCurrencySwitch /> <CountryCurrencySwitch />
@@ -44,8 +48,9 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<ThemeSwitch /> <ThemeSwitch />
<button v-if="authStore.isAuthenticated" @click="authStore.logout()" <button v-if="authStore.isAuthenticated" @click="authStore.logout()"
class="px-3 py-1.5 text-sm font-medium text-black dark:text-white hover:text-black dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg transition-colors border border-(--border-light) dark:border-(--border-dark) whitespace-nowrap"> class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium text-black dark:text-white hover:text-black dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg transition-colors border border-(--border-light) dark:border-(--border-dark) whitespace-nowrap">
{{ $t('general.logout') }} {{ $t('general.logout') }}
<UIcon name="ic:baseline-logout" class="text-red-700 dark:text-red-500"/>
</button> </button>
</div> </div>
</div> </div>
@@ -68,6 +73,8 @@ import { useAuthStore } from '../stores/customer/auth'
const authStore = useAuthStore() const authStore = useAuthStore()
const userStore = useUserStore() const userStore = useUserStore()
const route = useRoute()
const pageTitle = computed(() => route.meta.name ?? 'Default Page')
await userStore.getUser() await userStore.getUser()
const open = ref(true) const open = ref(true)
@@ -157,7 +164,7 @@ function getItems(state: 'collapsed' | 'expanded') {
} }
// //
import { useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { currentLang } from '@/router/langs' import { currentLang } from '@/router/langs'
import { useFetchJson } from '@/composable/useFetchJson' import { useFetchJson } from '@/composable/useFetchJson'
import CountryCurrencySwitch from '@/components/inner/CountryCurrencySwitch.vue' import CountryCurrencySwitch from '@/components/inner/CountryCurrencySwitch.vue'
@@ -174,7 +181,7 @@ const menu = ref<TopMenuItem[] | null>(null)
async function getTopMenu() { async function getTopMenu() {
try { try {
const { items } = await useFetchJson<TopMenuItem[]>('/api/v1/restricted/menu/get-top-menu') const { items } = await useFetchJson<TopMenuItem[]>('/api/v1/restricted/menu/get-top-menu')
menu.value = items menu.value = items
} catch (err) { } catch (err) {
console.log(err) console.log(err)

View File

@@ -38,7 +38,8 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<UButton icon="i-lucide-panel-left" color="neutral" variant="ghost" aria-label="Toggle sidebar" <UButton icon="i-lucide-panel-left" color="neutral" variant="ghost" aria-label="Toggle sidebar"
@click="open = !open" /> @click="open = !open" />
<p class="font-bold text-xl">Customer-Management: <span class="text-[20px] font-medium">{{ pageTitle }}</span></p> <p class="font-bold text-xl">Customer-Management: <span class="text-[20px] font-medium">{{ pageTitle
}}</span></p>
</div> </div>
<div class="hidden md:flex items-center gap-12"> <div class="hidden md:flex items-center gap-12">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -48,12 +49,13 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<ThemeSwitch /> <ThemeSwitch />
<button v-if="authStore.isAuthenticated" @click="authStore.logout()" <button v-if="authStore.isAuthenticated" @click="authStore.logout()"
class="px-3 py-1.5 text-sm font-medium text-black dark:text-white hover:text-black dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg transition-colors border border-(--border-light) dark:border-(--border-dark) whitespace-nowrap"> class="flex gap-2 items-center px-3 py-1.5 text-sm font-medium text-black dark:text-white hover:text-black dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg transition-colors border border-(--border-light) dark:border-(--border-dark) whitespace-nowrap">
{{ $t('general.logout') }} {{ $t('general.logout') }}
<UIcon name="ic:baseline-logout" class="text-red-700 dark:text-red-500" />
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<div class="flex-1 p-4 bg-slate-50 dark:bg-(--main-dark)"> <div class="flex-1 p-4 bg-slate-50 dark:bg-(--main-dark)">
<slot /> <slot />

View File

@@ -0,0 +1,12 @@
<template>
<Default>
<div class="h-full">
<StorageFileBrowser initial-path="dest/src" />
</div>
</Default>
</template>
<script setup lang="ts">
import Default from '@/layouts/default.vue'
import StorageFileBrowser from '@/components/customer/StorageFileBrowser.vue'
</script>