refactor: improvemets #4
@@ -12,6 +12,7 @@ defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'open': [item: CollectionObject | EntityObject]
|
'open': [item: CollectionObject | EntityObject]
|
||||||
|
'edit': [item: CollectionObject | EntityObject]
|
||||||
'rename': [item: CollectionObject | EntityObject]
|
'rename': [item: CollectionObject | EntityObject]
|
||||||
'delete': [item: CollectionObject | EntityObject]
|
'delete': [item: CollectionObject | EntityObject]
|
||||||
'download': [item: CollectionObject | EntityObject]
|
'download': [item: CollectionObject | EntityObject]
|
||||||
@@ -26,6 +27,8 @@ function handleAction(action: string, item: CollectionObject | EntityObject, eve
|
|||||||
|
|
||||||
if (action === 'open') {
|
if (action === 'open') {
|
||||||
emit('open', item)
|
emit('open', item)
|
||||||
|
} else if (action === 'edit') {
|
||||||
|
emit('edit', item)
|
||||||
} else if (action === 'rename') {
|
} else if (action === 'rename') {
|
||||||
emit('rename', item)
|
emit('rename', item)
|
||||||
} else if (action === 'delete') {
|
} else if (action === 'delete') {
|
||||||
@@ -59,6 +62,10 @@ function isEntity(item: CollectionObject | EntityObject): item is EntityObject {
|
|||||||
<template #prepend><v-icon size="small">mdi-open-in-app</v-icon></template>
|
<template #prepend><v-icon size="small">mdi-open-in-app</v-icon></template>
|
||||||
<v-list-item-title>Open</v-list-item-title>
|
<v-list-item-title>Open</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
<v-list-item v-if="isEntity(item)" @click="(e: Event) => handleAction('edit', item, e)">
|
||||||
|
<template #prepend><v-icon size="small">mdi-pencil</v-icon></template>
|
||||||
|
<v-list-item-title>Edit</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
<v-list-item @click="(e: Event) => handleAction('details', item, e)">
|
<v-list-item @click="(e: Event) => handleAction('details', item, e)">
|
||||||
<template #prepend><v-icon size="small">mdi-information-outline</v-icon></template>
|
<template #prepend><v-icon size="small">mdi-information-outline</v-icon></template>
|
||||||
<v-list-item-title>Details</v-list-item-title>
|
<v-list-item-title>Details</v-list-item-title>
|
||||||
|
|||||||
@@ -1,17 +1,37 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { VNavigationDrawer } from 'vuetify/components'
|
||||||
import { CollectionObject } from '@DocumentsManager/models/collection'
|
import { CollectionObject } from '@DocumentsManager/models/collection'
|
||||||
import { EntityObject } from '@DocumentsManager/models/entity'
|
import { EntityObject } from '@DocumentsManager/models/entity'
|
||||||
import { getFileIcon, formatSize, formatDate } from '@/utils/fileHelpers'
|
import { getFileIcon, formatSize, formatDate } from '@/utils/fileHelpers'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
selectedItems: (CollectionObject | EntityObject)[]
|
selectedItems: (CollectionObject | EntityObject)[]
|
||||||
}>()
|
embedded?: boolean
|
||||||
|
}>(), {
|
||||||
|
embedded: false,
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'close': []
|
'close': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const panelComponent = computed(() => props.embedded ? 'div' : VNavigationDrawer)
|
||||||
|
|
||||||
|
const panelProps = computed(() => {
|
||||||
|
if (props.embedded) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
modelValue: hasSelection.value,
|
||||||
|
key: props.selectedItems.map(item => String(item.identifier)).join(','),
|
||||||
|
location: 'right',
|
||||||
|
width: 320,
|
||||||
|
temporary: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const hasSelection = computed(() => props.selectedItems.length > 0)
|
const hasSelection = computed(() => props.selectedItems.length > 0)
|
||||||
const singleSelection = computed(() => props.selectedItems.length === 1)
|
const singleSelection = computed(() => props.selectedItems.length === 1)
|
||||||
const selectedItem = computed(() => props.selectedItems[0] ?? null)
|
const selectedItem = computed(() => props.selectedItems[0] ?? null)
|
||||||
@@ -52,13 +72,12 @@ const totalSize = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-navigation-drawer
|
<component
|
||||||
:model-value="hasSelection"
|
:is="panelComponent"
|
||||||
:key="selectedItems.map(i => String(i.identifier)).join(',')"
|
v-if="!embedded || hasSelection"
|
||||||
location="right"
|
v-bind="panelProps"
|
||||||
width="320"
|
|
||||||
temporary
|
|
||||||
class="files-info-panel"
|
class="files-info-panel"
|
||||||
|
:class="{ 'files-info-panel--embedded': embedded }"
|
||||||
>
|
>
|
||||||
<div class="pa-4">
|
<div class="pa-4">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
@@ -172,11 +191,18 @@ const totalSize = computed(() => {
|
|||||||
</v-list>
|
</v-list>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</v-navigation-drawer>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.files-info-panel {
|
.files-info-panel {
|
||||||
border-left: 1px solid rgb(var(--v-border-color)) !important;
|
border-left: 1px solid rgb(var(--v-border-color)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.files-info-panel--embedded {
|
||||||
|
width: 320px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: rgb(var(--v-theme-surface));
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
|
import type { ServiceObject } from '@DocumentsManager/models/service'
|
||||||
|
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
|
const isMobile = computed(() => display.mdAndDown.value)
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
|
activeProviderId: string
|
||||||
activeServiceId: string
|
activeServiceId: string
|
||||||
|
services: ServiceObject[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: boolean]
|
'update:modelValue': [value: boolean]
|
||||||
'navigate-home': []
|
'navigate-home': []
|
||||||
|
'select-service': [service: ServiceObject]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -18,9 +24,9 @@ const emit = defineEmits<{
|
|||||||
<v-navigation-drawer
|
<v-navigation-drawer
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
@update:model-value="emit('update:modelValue', $event)"
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
:permanent="display.mdAndUp.value"
|
:permanent="!isMobile"
|
||||||
:temporary="display.smAndDown.value"
|
:temporary="isMobile"
|
||||||
width="240"
|
width="280"
|
||||||
class="files-sidebar"
|
class="files-sidebar"
|
||||||
>
|
>
|
||||||
<div class="pa-4">
|
<div class="pa-4">
|
||||||
@@ -53,11 +59,18 @@ const emit = defineEmits<{
|
|||||||
<v-list density="compact" nav>
|
<v-list density="compact" nav>
|
||||||
<v-list-subheader>Storage</v-list-subheader>
|
<v-list-subheader>Storage</v-list-subheader>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
|
v-for="service in services"
|
||||||
|
:key="`${service.provider}:${String(service.identifier ?? '')}`"
|
||||||
prepend-icon="mdi-harddisk"
|
prepend-icon="mdi-harddisk"
|
||||||
title="Personal"
|
:title="service.label || String(service.identifier ?? 'Unnamed Service')"
|
||||||
subtitle="Local storage"
|
:subtitle="service.provider"
|
||||||
:active="activeServiceId === 'personal'"
|
:active="activeProviderId === service.provider && activeServiceId === String(service.identifier ?? '')"
|
||||||
|
@click="emit('select-service', service)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div v-if="services.length === 0" class="files-sidebar-empty text-caption text-medium-emphasis px-4 py-2">
|
||||||
|
No storage services available.
|
||||||
|
</div>
|
||||||
</v-list>
|
</v-list>
|
||||||
</div>
|
</div>
|
||||||
</v-navigation-drawer>
|
</v-navigation-drawer>
|
||||||
@@ -68,4 +81,8 @@ const emit = defineEmits<{
|
|||||||
border-right: 1px solid rgb(var(--v-border-color)) !important;
|
border-right: 1px solid rgb(var(--v-border-color)) !important;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.files-sidebar-empty {
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
import type { ViewMode } from '@/types'
|
import type { ViewMode } from '@/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -18,6 +19,9 @@ const emit = defineEmits<{
|
|||||||
'new-folder': []
|
'new-folder': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const display = useDisplay()
|
||||||
|
const isMobile = computed(() => display.mdAndDown.value)
|
||||||
|
|
||||||
const searchModel = computed({
|
const searchModel = computed({
|
||||||
get: () => props.searchQuery,
|
get: () => props.searchQuery,
|
||||||
set: (value: string) => emit('update:searchQuery', value)
|
set: (value: string) => emit('update:searchQuery', value)
|
||||||
@@ -30,19 +34,15 @@ const viewModeModel = computed({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-app-bar elevation="0" class="files-toolbar border-b">
|
<v-app-bar elevation="0" density="compact" class="files-toolbar">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-btn
|
<v-app-bar-nav-icon
|
||||||
icon="mdi-menu"
|
v-if="isMobile"
|
||||||
variant="text"
|
|
||||||
@click="emit('toggle-sidebar')"
|
@click="emit('toggle-sidebar')"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<v-app-bar-title class="d-flex align-center">
|
<v-app-bar-title>Files</v-app-bar-title>
|
||||||
<v-icon size="28" color="primary" class="mr-2">mdi-folder-outline</v-icon>
|
|
||||||
<span class="text-h6 font-weight-bold">Files</span>
|
|
||||||
</v-app-bar-title>
|
|
||||||
|
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
|
|
||||||
@@ -55,8 +55,7 @@ const viewModeModel = computed({
|
|||||||
prepend-inner-icon="mdi-magnify"
|
prepend-inner-icon="mdi-magnify"
|
||||||
hide-details
|
hide-details
|
||||||
single-line
|
single-line
|
||||||
class="mx-4"
|
class="files-toolbar-search mx-4"
|
||||||
style="max-width: 300px;"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- View toggle -->
|
<!-- View toggle -->
|
||||||
@@ -131,7 +130,14 @@ const viewModeModel = computed({
|
|||||||
border-bottom: 1px solid rgb(var(--v-border-color)) !important;
|
border-bottom: 1px solid rgb(var(--v-border-color)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.border-b {
|
@media (max-width: 960px) {
|
||||||
border-bottom: 1px solid rgb(var(--v-border-color));
|
.files-toolbar-search {
|
||||||
|
max-width: 180px;
|
||||||
|
margin-inline: 8px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-toolbar-search {
|
||||||
|
max-width: 300px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
import type { FileUploadProgress } from '@/composables/useFileUpload'
|
import type { FileUploadProgress } from '@/composables/useFileUpload'
|
||||||
import { formatSize, getUploadStatusIcon, getUploadStatusColor } from '@/utils/fileHelpers'
|
import { formatSize, getUploadStatusIcon, getUploadStatusColor } from '@/utils/fileHelpers'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
uploads: Map<string, FileUploadProgress>
|
uploads: Map<string, FileUploadProgress>
|
||||||
totalProgress: number
|
totalProgress: number
|
||||||
isUploading: boolean
|
isUploading: boolean
|
||||||
|
isPreparing: boolean
|
||||||
|
preparingMessage: string
|
||||||
|
preparingProcessedCount: number
|
||||||
|
preparingTotalCount: number
|
||||||
pendingCount: number
|
pendingCount: number
|
||||||
completedCount: number
|
completedCount: number
|
||||||
}>()
|
}>()
|
||||||
@@ -23,6 +28,23 @@ const emit = defineEmits<{
|
|||||||
function handleClose() {
|
function handleClose() {
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uploadEntries = computed(() =>
|
||||||
|
Array.from(props.uploads.entries(), ([id, item]) => ({ id, item }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const failedCount = computed(() =>
|
||||||
|
uploadEntries.value.filter(entry => entry.item.status === 'error').length
|
||||||
|
)
|
||||||
|
|
||||||
|
const queuedCount = computed(() => uploadEntries.value.length)
|
||||||
|
|
||||||
|
const preparingProgress = computed(() => {
|
||||||
|
if (props.preparingTotalCount <= 0) return undefined
|
||||||
|
return Math.round((props.preparingProcessedCount / props.preparingTotalCount) * 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
const listHeight = computed(() => Math.min(Math.max(uploadEntries.value.length, 1), 6) * 64)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -33,6 +55,31 @@ function handleClose() {
|
|||||||
Upload Files
|
Upload Files
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
|
<div v-if="isPreparing" class="upload-preparing mb-4">
|
||||||
|
<div class="d-flex align-center ga-3 mb-2">
|
||||||
|
<v-progress-circular indeterminate size="18" width="2" color="primary" />
|
||||||
|
<div>
|
||||||
|
<div class="text-body-2 font-weight-medium">{{ preparingMessage }}</div>
|
||||||
|
<div class="text-caption text-medium-emphasis">
|
||||||
|
<template v-if="preparingTotalCount > 0">
|
||||||
|
{{ preparingProcessedCount }} / {{ preparingTotalCount }} files queued
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Preparing uploads...
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-progress-linear
|
||||||
|
v-if="preparingProgress !== undefined"
|
||||||
|
:model-value="preparingProgress"
|
||||||
|
color="primary"
|
||||||
|
height="6"
|
||||||
|
rounded
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Upload progress -->
|
<!-- Upload progress -->
|
||||||
<div v-if="totalProgress > 0 && isUploading" class="mb-4">
|
<div v-if="totalProgress > 0 && isUploading" class="mb-4">
|
||||||
<v-progress-linear
|
<v-progress-linear
|
||||||
@@ -46,47 +93,66 @@ function handleClose() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="queuedCount > 0" class="upload-summary mb-4">
|
||||||
|
<v-chip size="small" variant="tonal" color="primary">
|
||||||
|
{{ queuedCount }} queued
|
||||||
|
</v-chip>
|
||||||
|
<v-chip v-if="pendingCount > 0" size="small" variant="tonal">
|
||||||
|
{{ pendingCount }} pending
|
||||||
|
</v-chip>
|
||||||
|
<v-chip v-if="completedCount > 0" size="small" variant="tonal" color="success">
|
||||||
|
{{ completedCount }} completed
|
||||||
|
</v-chip>
|
||||||
|
<v-chip v-if="failedCount > 0" size="small" variant="tonal" color="error">
|
||||||
|
{{ failedCount }} failed
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- File list -->
|
<!-- File list -->
|
||||||
<v-list density="compact" class="upload-file-list">
|
<v-virtual-scroll
|
||||||
<v-list-item
|
v-if="queuedCount > 0"
|
||||||
v-for="[id, item] in uploads"
|
:items="uploadEntries"
|
||||||
:key="id"
|
:height="listHeight"
|
||||||
class="px-0"
|
:item-height="64"
|
||||||
|
class="upload-file-list"
|
||||||
>
|
>
|
||||||
|
<template #default="{ item: entry }">
|
||||||
|
<v-list-item :key="entry.id" class="px-0 upload-file-row">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-icon :color="getUploadStatusColor(item.status)" size="small">
|
<v-icon :color="getUploadStatusColor(entry.item.status)" size="small">
|
||||||
{{ getUploadStatusIcon(item.status) }}
|
{{ getUploadStatusIcon(entry.item.status) }}
|
||||||
</v-icon>
|
</v-icon>
|
||||||
</template>
|
</template>
|
||||||
<v-list-item-title class="text-body-2">
|
<v-list-item-title class="text-body-2">
|
||||||
{{ item.relativePath || item.file.name }}
|
{{ entry.item.relativePath || entry.item.file.name }}
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
<v-list-item-subtitle>
|
<v-list-item-subtitle>
|
||||||
{{ formatSize(item.file.size) }}
|
{{ formatSize(entry.item.file.size) }}
|
||||||
<span v-if="item.error" class="text-error"> — {{ item.error }}</span>
|
<span v-if="entry.item.error" class="text-error"> — {{ entry.item.error }}</span>
|
||||||
</v-list-item-subtitle>
|
</v-list-item-subtitle>
|
||||||
<template #append>
|
<template #append>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="item.status === 'pending' || item.status === 'error'"
|
v-if="entry.item.status === 'pending' || entry.item.status === 'error'"
|
||||||
icon="mdi-close"
|
icon="mdi-close"
|
||||||
size="x-small"
|
size="x-small"
|
||||||
variant="text"
|
variant="text"
|
||||||
@click="emit('remove-upload', id)"
|
@click="emit('remove-upload', entry.id)"
|
||||||
/>
|
/>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="item.status === 'error'"
|
v-if="entry.item.status === 'error'"
|
||||||
icon="mdi-refresh"
|
icon="mdi-refresh"
|
||||||
size="x-small"
|
size="x-small"
|
||||||
variant="text"
|
variant="text"
|
||||||
color="primary"
|
color="primary"
|
||||||
@click="emit('retry-upload', id)"
|
@click="emit('retry-upload', entry.id)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</template>
|
||||||
|
</v-virtual-scroll>
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<div v-if="uploads.size === 0" class="text-center py-8 text-grey">
|
<div v-if="queuedCount === 0 && !isPreparing" class="text-center py-8 text-grey">
|
||||||
<v-icon size="48" color="grey-lighten-1">mdi-file-upload-outline</v-icon>
|
<v-icon size="48" color="grey-lighten-1">mdi-file-upload-outline</v-icon>
|
||||||
<div class="mt-2">No files selected</div>
|
<div class="mt-2">No files selected</div>
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -101,7 +167,7 @@ function handleClose() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add more files button -->
|
<!-- Add more files button -->
|
||||||
<div v-else class="text-center mt-4">
|
<div v-else-if="queuedCount > 0" class="text-center mt-4">
|
||||||
<v-btn
|
<v-btn
|
||||||
variant="text"
|
variant="text"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -117,7 +183,7 @@ function handleClose() {
|
|||||||
<v-btn
|
<v-btn
|
||||||
variant="text"
|
variant="text"
|
||||||
@click="handleClose"
|
@click="handleClose"
|
||||||
:disabled="isUploading"
|
:disabled="isUploading || isPreparing"
|
||||||
>
|
>
|
||||||
{{ completedCount > 0 ? 'Done' : 'Cancel' }}
|
{{ completedCount > 0 ? 'Done' : 'Cancel' }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
@@ -127,6 +193,7 @@ function handleClose() {
|
|||||||
variant="elevated"
|
variant="elevated"
|
||||||
@click="emit('upload-all')"
|
@click="emit('upload-all')"
|
||||||
:loading="isUploading"
|
:loading="isUploading"
|
||||||
|
:disabled="isPreparing"
|
||||||
>
|
>
|
||||||
Upload {{ pendingCount }} File(s)
|
Upload {{ pendingCount }} File(s)
|
||||||
</v-btn>
|
</v-btn>
|
||||||
@@ -136,8 +203,25 @@ function handleClose() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.upload-preparing {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid rgb(var(--v-border-color));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgb(var(--v-theme-surface-variant), 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.upload-file-list {
|
.upload-file-list {
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-file-row {
|
||||||
|
border-bottom: 1px solid rgb(var(--v-border-color), 0.45);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
197
src/components/editor/FileEditorDialog.vue
Normal file
197
src/components/editor/FileEditorDialog.vue
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, defineAsyncComponent, ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { EntityObject } from '@DocumentsManager/models/entity'
|
||||||
|
import { useFileEditor } from '@/composables'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
entity: EntityObject | null
|
||||||
|
readFile: (entityId: string) => Promise<string | null>
|
||||||
|
writeFile: (entityId: string, content: string) => Promise<number>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const fileEditor = useFileEditor()
|
||||||
|
|
||||||
|
// Track whether the editor component reports unsaved changes
|
||||||
|
const isDirty = ref(false)
|
||||||
|
const showDiscardDialog = ref(false)
|
||||||
|
|
||||||
|
const mime = computed(() => props.entity?.properties.mime ?? '')
|
||||||
|
|
||||||
|
const editor = computed(() => {
|
||||||
|
if (!mime.value) return null
|
||||||
|
return fileEditor.findEditor(mime.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const editorComponent = computed(() => {
|
||||||
|
if (!editor.value?.component) return null
|
||||||
|
return defineAsyncComponent(editor.value.component as () => Promise<unknown>)
|
||||||
|
})
|
||||||
|
|
||||||
|
const filename = computed(
|
||||||
|
() => props.entity?.properties.label ?? String(props.entity?.identifier ?? ''),
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleDirtyChange(dirty: boolean) {
|
||||||
|
isDirty.value = dirty
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestClose() {
|
||||||
|
if (isDirty.value) {
|
||||||
|
showDiscardDialog.value = true
|
||||||
|
} else {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
isDirty.value = false
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDiscardConfirm() {
|
||||||
|
showDiscardDialog.value = false
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard: let Escape trigger close with guard
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (!props.modelValue) return
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
requestClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => window.addEventListener('keydown', handleKeydown))
|
||||||
|
onUnmounted(() => window.removeEventListener('keydown', handleKeydown))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-dialog
|
||||||
|
:model-value="modelValue"
|
||||||
|
fullscreen
|
||||||
|
transition="dialog-bottom-transition"
|
||||||
|
persistent
|
||||||
|
@update:model-value="requestClose"
|
||||||
|
>
|
||||||
|
<div class="editor-shell">
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="editor-toolbar">
|
||||||
|
<v-btn icon="mdi-close" variant="text" @click="requestClose" />
|
||||||
|
<v-icon class="ml-1 mr-2" size="small" color="medium-emphasis">mdi-pencil</v-icon>
|
||||||
|
<span class="editor-filename text-truncate">{{ filename }}</span>
|
||||||
|
<v-spacer />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content area -->
|
||||||
|
<div class="editor-content">
|
||||||
|
<template v-if="entity">
|
||||||
|
<!-- Registered editor component -->
|
||||||
|
<Suspense v-if="editor">
|
||||||
|
<template #default>
|
||||||
|
<component
|
||||||
|
:is="editorComponent"
|
||||||
|
:entity="entity"
|
||||||
|
:mime="mime"
|
||||||
|
:read-file="readFile"
|
||||||
|
:write-file="writeFile"
|
||||||
|
class="editor-component"
|
||||||
|
@dirty="handleDirtyChange"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #fallback>
|
||||||
|
<div class="editor-loading">
|
||||||
|
<v-progress-circular indeterminate color="primary" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<!-- No editor registered for this type -->
|
||||||
|
<div v-else class="editor-no-support">
|
||||||
|
<v-icon size="64" color="grey-lighten-1">mdi-file-edit-outline</v-icon>
|
||||||
|
<p class="text-h6 mt-4">No editor available</p>
|
||||||
|
<p class="text-body-2 text-medium-emphasis mb-6">
|
||||||
|
{{ mime || 'Unknown file type' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- Discard changes confirmation -->
|
||||||
|
<v-dialog v-model="showDiscardDialog" max-width="400" persistent>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Unsaved Changes</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
You have unsaved changes. Are you sure you want to close without saving?
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="showDiscardDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="error" variant="tonal" @click="handleDiscardConfirm">Discard</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.editor-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: rgb(var(--v-theme-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
min-height: 56px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-filename {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-component {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-no-support {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1
src/components/editor/index.ts
Normal file
1
src/components/editor/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as FileEditorDialog } from './FileEditorDialog.vue'
|
||||||
@@ -15,3 +15,6 @@ export * from './dialogs'
|
|||||||
|
|
||||||
// Viewer
|
// Viewer
|
||||||
export * from './viewer'
|
export * from './viewer'
|
||||||
|
|
||||||
|
// Editor
|
||||||
|
export * from './editor'
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'item-click': [item: CollectionObject | EntityObject, event: MouseEvent | KeyboardEvent]
|
'item-click': [item: CollectionObject | EntityObject, event: MouseEvent | KeyboardEvent]
|
||||||
'open': [item: CollectionObject | EntityObject]
|
'open': [item: CollectionObject | EntityObject]
|
||||||
|
'edit': [item: CollectionObject | EntityObject]
|
||||||
'rename': [item: CollectionObject | EntityObject]
|
'rename': [item: CollectionObject | EntityObject]
|
||||||
'delete': [item: CollectionObject | EntityObject]
|
'delete': [item: CollectionObject | EntityObject]
|
||||||
'download': [item: CollectionObject | EntityObject]
|
'download': [item: CollectionObject | EntityObject]
|
||||||
@@ -70,6 +71,7 @@ function isCollection(wrapped: ItemWithType): wrapped is { item: CollectionObjec
|
|||||||
<FileActionsMenu
|
<FileActionsMenu
|
||||||
:item="wrapped.item"
|
:item="wrapped.item"
|
||||||
@open="emit('open', $event)"
|
@open="emit('open', $event)"
|
||||||
|
@edit="emit('edit', $event)"
|
||||||
@rename="emit('rename', $event)"
|
@rename="emit('rename', $event)"
|
||||||
@delete="emit('delete', $event)"
|
@delete="emit('delete', $event)"
|
||||||
@download="emit('download', $event)"
|
@download="emit('download', $event)"
|
||||||
@@ -96,6 +98,7 @@ function isCollection(wrapped: ItemWithType): wrapped is { item: CollectionObjec
|
|||||||
<FileActionsMenu
|
<FileActionsMenu
|
||||||
:item="wrapped.item"
|
:item="wrapped.item"
|
||||||
@open="emit('open', $event)"
|
@open="emit('open', $event)"
|
||||||
|
@edit="emit('edit', $event)"
|
||||||
@rename="emit('rename', $event)"
|
@rename="emit('rename', $event)"
|
||||||
@delete="emit('delete', $event)"
|
@delete="emit('delete', $event)"
|
||||||
@download="emit('download', $event)"
|
@download="emit('download', $event)"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'item-click': [item: CollectionObject | EntityObject, event: MouseEvent | KeyboardEvent]
|
'item-click': [item: CollectionObject | EntityObject, event: MouseEvent | KeyboardEvent]
|
||||||
'open': [item: CollectionObject | EntityObject]
|
'open': [item: CollectionObject | EntityObject]
|
||||||
|
'edit': [item: CollectionObject | EntityObject]
|
||||||
'rename': [item: CollectionObject | EntityObject]
|
'rename': [item: CollectionObject | EntityObject]
|
||||||
'delete': [item: CollectionObject | EntityObject]
|
'delete': [item: CollectionObject | EntityObject]
|
||||||
'download': [item: CollectionObject | EntityObject]
|
'download': [item: CollectionObject | EntityObject]
|
||||||
@@ -36,6 +37,7 @@ const emit = defineEmits<{
|
|||||||
size="x-small"
|
size="x-small"
|
||||||
button-class="action-btn"
|
button-class="action-btn"
|
||||||
@open="emit('open', $event)"
|
@open="emit('open', $event)"
|
||||||
|
@edit="emit('edit', $event)"
|
||||||
@rename="emit('rename', $event)"
|
@rename="emit('rename', $event)"
|
||||||
@delete="emit('delete', $event)"
|
@delete="emit('delete', $event)"
|
||||||
@download="emit('download', $event)"
|
@download="emit('download', $event)"
|
||||||
@@ -61,6 +63,7 @@ const emit = defineEmits<{
|
|||||||
size="x-small"
|
size="x-small"
|
||||||
button-class="action-btn"
|
button-class="action-btn"
|
||||||
@open="emit('open', $event)"
|
@open="emit('open', $event)"
|
||||||
|
@edit="emit('edit', $event)"
|
||||||
@rename="emit('rename', $event)"
|
@rename="emit('rename', $event)"
|
||||||
@delete="emit('delete', $event)"
|
@delete="emit('delete', $event)"
|
||||||
@download="emit('download', $event)"
|
@download="emit('download', $event)"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'item-click': [item: CollectionObject | EntityObject, event: MouseEvent | KeyboardEvent]
|
'item-click': [item: CollectionObject | EntityObject, event: MouseEvent | KeyboardEvent]
|
||||||
'open': [item: CollectionObject | EntityObject]
|
'open': [item: CollectionObject | EntityObject]
|
||||||
|
'edit': [item: CollectionObject | EntityObject]
|
||||||
'rename': [item: CollectionObject | EntityObject]
|
'rename': [item: CollectionObject | EntityObject]
|
||||||
'delete': [item: CollectionObject | EntityObject]
|
'delete': [item: CollectionObject | EntityObject]
|
||||||
'download': [item: CollectionObject | EntityObject]
|
'download': [item: CollectionObject | EntityObject]
|
||||||
@@ -57,6 +58,7 @@ function isCollection(wrapped: ItemWithType): wrapped is { item: CollectionObjec
|
|||||||
<FileActionsMenu
|
<FileActionsMenu
|
||||||
:item="wrapped.item"
|
:item="wrapped.item"
|
||||||
@open="emit('open', $event)"
|
@open="emit('open', $event)"
|
||||||
|
@edit="emit('edit', $event)"
|
||||||
@rename="emit('rename', $event)"
|
@rename="emit('rename', $event)"
|
||||||
@delete="emit('delete', $event)"
|
@delete="emit('delete', $event)"
|
||||||
@download="emit('download', $event)"
|
@download="emit('download', $event)"
|
||||||
@@ -81,6 +83,7 @@ function isCollection(wrapped: ItemWithType): wrapped is { item: CollectionObjec
|
|||||||
<FileActionsMenu
|
<FileActionsMenu
|
||||||
:item="wrapped.item"
|
:item="wrapped.item"
|
||||||
@open="emit('open', $event)"
|
@open="emit('open', $event)"
|
||||||
|
@edit="emit('edit', $event)"
|
||||||
@rename="emit('rename', $event)"
|
@rename="emit('rename', $event)"
|
||||||
@delete="emit('delete', $event)"
|
@delete="emit('delete', $event)"
|
||||||
@download="emit('download', $event)"
|
@download="emit('download', $event)"
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ export { useFileManager } from './useFileManager'
|
|||||||
export { useFileSelection } from './useFileSelection'
|
export { useFileSelection } from './useFileSelection'
|
||||||
export { useFileUpload } from './useFileUpload'
|
export { useFileUpload } from './useFileUpload'
|
||||||
export { useFileViewer } from './useFileViewer'
|
export { useFileViewer } from './useFileViewer'
|
||||||
|
export { useFileEditor } from './useFileEditor'
|
||||||
|
|
||||||
export type { UseFileManagerOptions } from './useFileManager'
|
export type { UseFileManagerOptions } from './useFileManager'
|
||||||
export type { UseFileSelectionOptions } from './useFileSelection'
|
export type { UseFileSelectionOptions } from './useFileSelection'
|
||||||
export type { UseFileUploadOptions, FileUploadProgress } from './useFileUpload'
|
export type { UseFileUploadOptions, FileUploadProgress } from './useFileUpload'
|
||||||
export type { UseFileViewerReturn } from './useFileViewer'
|
export type { UseFileViewerReturn } from './useFileViewer'
|
||||||
|
export type { UseFileEditorReturn } from './useFileEditor'
|
||||||
|
|||||||
82
src/composables/useFileEditor.ts
Normal file
82
src/composables/useFileEditor.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* useFileEditor — resolves which registered editor can handle a given MIME type.
|
||||||
|
*
|
||||||
|
* Other modules register editors at the `documents_file_editor` integration
|
||||||
|
* point by including an entry in their `integrations.ts`:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* documents_file_editor: [{
|
||||||
|
* id: 'my_editor',
|
||||||
|
* meta: { mimeTypes: ['text/plain'] },
|
||||||
|
* component: () => import('./components/MyEditor.vue'),
|
||||||
|
* }]
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Editor components receive the props:
|
||||||
|
* `entity: EntityObject`, `mime: string`,
|
||||||
|
* `readFile: (entityId: string) => Promise<string | null>`,
|
||||||
|
* `writeFile: (entityId: string, content: string) => Promise<number>`
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useIntegrationStore } from '@KTXC'
|
||||||
|
import type { EntityObject } from '@DocumentsManager/models/entity'
|
||||||
|
|
||||||
|
const INTEGRATION_POINT = 'documents_file_editor'
|
||||||
|
|
||||||
|
function mimeMatchesPattern(mime: string, pattern: string): boolean {
|
||||||
|
if (pattern.endsWith('/*')) {
|
||||||
|
return mime.startsWith(pattern.slice(0, -1))
|
||||||
|
}
|
||||||
|
return mime === pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
function editorMatchesMime(
|
||||||
|
mime: string,
|
||||||
|
mimeTypes?: string[],
|
||||||
|
mimePatterns?: string[],
|
||||||
|
): boolean {
|
||||||
|
if (mimeTypes?.includes(mime)) return true
|
||||||
|
if (mimePatterns) {
|
||||||
|
for (const pattern of mimePatterns) {
|
||||||
|
if (mimeMatchesPattern(mime, pattern)) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFileEditor() {
|
||||||
|
const integrationStore = useIntegrationStore()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the highest-priority registered editor that can handle `mime`,
|
||||||
|
* or `null` if none is found.
|
||||||
|
*/
|
||||||
|
function findEditor(mime: string) {
|
||||||
|
const editors = integrationStore.getItems(INTEGRATION_POINT)
|
||||||
|
for (const editor of editors) {
|
||||||
|
if (
|
||||||
|
editorMatchesMime(
|
||||||
|
mime,
|
||||||
|
editor.meta?.mimeTypes as string[] | undefined,
|
||||||
|
editor.meta?.mimePatterns as string[] | undefined,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return editor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: returns true if any registered editor can handle this entity.
|
||||||
|
*/
|
||||||
|
function canEdit(entity: EntityObject): boolean {
|
||||||
|
const mime = entity.properties.mime
|
||||||
|
if (!mime) return false
|
||||||
|
return findEditor(mime) !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
return { findEditor, canEdit }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseFileEditorReturn = ReturnType<typeof useFileEditor>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* Provides reactive access to file manager state and actions
|
* Provides reactive access to file manager state and actions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, unref } from 'vue'
|
||||||
import type { Ref, ComputedRef } from 'vue'
|
import type { Ref, ComputedRef } from 'vue'
|
||||||
import { useProvidersStore } from '@DocumentsManager/stores/providersStore'
|
import { useProvidersStore } from '@DocumentsManager/stores/providersStore'
|
||||||
import { useServicesStore } from '@DocumentsManager/stores/servicesStore'
|
import { useServicesStore } from '@DocumentsManager/stores/servicesStore'
|
||||||
@@ -17,8 +17,8 @@ import { EntityObject } from '@DocumentsManager/models/entity'
|
|||||||
const TRANSFER_BASE_URL = '/m/documents_manager'
|
const TRANSFER_BASE_URL = '/m/documents_manager'
|
||||||
|
|
||||||
export interface UseFileManagerOptions {
|
export interface UseFileManagerOptions {
|
||||||
providerId: string
|
providerId: string | Ref<string> | ComputedRef<string>
|
||||||
serviceId: string
|
serviceId: string | Ref<string> | ComputedRef<string>
|
||||||
autoFetch?: boolean
|
autoFetch?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +29,9 @@ export function useFileManager(options: UseFileManagerOptions) {
|
|||||||
|
|
||||||
const { providerId, serviceId, autoFetch = false } = options
|
const { providerId, serviceId, autoFetch = false } = options
|
||||||
|
|
||||||
|
const currentProviderId = () => unref(providerId)
|
||||||
|
const currentServiceId = () => unref(serviceId)
|
||||||
|
|
||||||
// Current location (folder being viewed)
|
// Current location (folder being viewed)
|
||||||
const currentLocation: Ref<string> = ref(ROOT_ID)
|
const currentLocation: Ref<string> = ref(ROOT_ID)
|
||||||
|
|
||||||
@@ -37,21 +40,21 @@ export function useFileManager(options: UseFileManagerOptions) {
|
|||||||
const error = computed(() => nodesStore.error)
|
const error = computed(() => nodesStore.error)
|
||||||
|
|
||||||
// Provider and service
|
// Provider and service
|
||||||
const provider = computed(() => providersStore.provider(providerId))
|
const provider = computed(() => providersStore.provider(currentProviderId()))
|
||||||
const service = computed(() => servicesStore.service(providerId, serviceId))
|
const service = computed(() => servicesStore.service(currentProviderId(), currentServiceId()))
|
||||||
const rootId = computed(() => ROOT_ID)
|
const rootId = computed(() => ROOT_ID)
|
||||||
|
|
||||||
// Current children
|
// Current children
|
||||||
const currentChildren = computed(() =>
|
const currentChildren = computed(() =>
|
||||||
nodesStore.getChildren(providerId, serviceId, currentLocation.value)
|
nodesStore.getChildren(currentProviderId(), currentServiceId(), currentLocation.value)
|
||||||
)
|
)
|
||||||
|
|
||||||
const currentCollections: ComputedRef<CollectionObject[]> = computed(() =>
|
const currentCollections: ComputedRef<CollectionObject[]> = computed(() =>
|
||||||
nodesStore.getChildCollections(providerId, serviceId, currentLocation.value)
|
nodesStore.getChildCollections(currentProviderId(), currentServiceId(), currentLocation.value)
|
||||||
)
|
)
|
||||||
|
|
||||||
const currentEntities: ComputedRef<EntityObject[]> = computed(() =>
|
const currentEntities: ComputedRef<EntityObject[]> = computed(() =>
|
||||||
nodesStore.getChildEntities(providerId, serviceId, currentLocation.value)
|
nodesStore.getChildEntities(currentProviderId(), currentServiceId(), currentLocation.value)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Breadcrumb path
|
// Breadcrumb path
|
||||||
@@ -59,7 +62,7 @@ export function useFileManager(options: UseFileManagerOptions) {
|
|||||||
if (currentLocation.value === ROOT_ID) {
|
if (currentLocation.value === ROOT_ID) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
return nodesStore.getPath(providerId, serviceId, currentLocation.value)
|
return nodesStore.getPath(currentProviderId(), currentServiceId(), currentLocation.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Is at root?
|
// Is at root?
|
||||||
@@ -76,7 +79,7 @@ export function useFileManager(options: UseFileManagerOptions) {
|
|||||||
if (currentLocation.value === ROOT_ID) {
|
if (currentLocation.value === ROOT_ID) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const currentNode = nodesStore.getNode(providerId, serviceId, currentLocation.value)
|
const currentNode = nodesStore.getNode(currentProviderId(), currentServiceId(), currentLocation.value)
|
||||||
if (currentNode) {
|
if (currentNode) {
|
||||||
await navigateTo(currentNode.collection ? String(currentNode.collection) : ROOT_ID)
|
await navigateTo(currentNode.collection ? String(currentNode.collection) : ROOT_ID)
|
||||||
}
|
}
|
||||||
@@ -94,8 +97,8 @@ export function useFileManager(options: UseFileManagerOptions) {
|
|||||||
range?: ListRange
|
range?: ListRange
|
||||||
) => {
|
) => {
|
||||||
await nodesStore.fetchNodes(
|
await nodesStore.fetchNodes(
|
||||||
providerId,
|
currentProviderId(),
|
||||||
serviceId,
|
currentServiceId(),
|
||||||
currentLocation.value === ROOT_ID ? ROOT_ID : currentLocation.value,
|
currentLocation.value === ROOT_ID ? ROOT_ID : currentLocation.value,
|
||||||
filter,
|
filter,
|
||||||
sort,
|
sort,
|
||||||
@@ -106,8 +109,8 @@ export function useFileManager(options: UseFileManagerOptions) {
|
|||||||
// Create a new folder
|
// Create a new folder
|
||||||
const createFolder = async (label: string): Promise<CollectionObject> => {
|
const createFolder = async (label: string): Promise<CollectionObject> => {
|
||||||
return await nodesStore.createCollection(
|
return await nodesStore.createCollection(
|
||||||
providerId,
|
currentProviderId(),
|
||||||
serviceId,
|
currentServiceId(),
|
||||||
currentLocation.value === ROOT_ID ? ROOT_ID : currentLocation.value,
|
currentLocation.value === ROOT_ID ? ROOT_ID : currentLocation.value,
|
||||||
{ label, owner: '' }
|
{ label, owner: '' }
|
||||||
)
|
)
|
||||||
@@ -129,8 +132,8 @@ export function useFileManager(options: UseFileManagerOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await nodesStore.createEntity(
|
return await nodesStore.createEntity(
|
||||||
providerId,
|
currentProviderId(),
|
||||||
serviceId,
|
currentServiceId(),
|
||||||
currentLocation.value === ROOT_ID ? ROOT_ID : currentLocation.value,
|
currentLocation.value === ROOT_ID ? ROOT_ID : currentLocation.value,
|
||||||
properties
|
properties
|
||||||
)
|
)
|
||||||
@@ -138,13 +141,13 @@ export function useFileManager(options: UseFileManagerOptions) {
|
|||||||
|
|
||||||
// Rename a node
|
// Rename a node
|
||||||
const renameNode = async (nodeId: string, newLabel: string) => {
|
const renameNode = async (nodeId: string, newLabel: string) => {
|
||||||
const node = nodesStore.getNode(providerId, serviceId, nodeId)
|
const node = nodesStore.getNode(currentProviderId(), currentServiceId(), nodeId)
|
||||||
if (!node) {
|
if (!node) {
|
||||||
throw new Error('Node not found')
|
throw new Error('Node not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node instanceof CollectionObject) {
|
if (node instanceof CollectionObject) {
|
||||||
return await nodesStore.updateCollection(providerId, serviceId, nodeId, {
|
return await nodesStore.updateCollection(currentProviderId(), currentServiceId(), nodeId, {
|
||||||
label: newLabel,
|
label: newLabel,
|
||||||
owner: node.properties.owner,
|
owner: node.properties.owner,
|
||||||
})
|
})
|
||||||
@@ -158,53 +161,53 @@ export function useFileManager(options: UseFileManagerOptions) {
|
|||||||
format: node.properties.format,
|
format: node.properties.format,
|
||||||
encoding: node.properties.encoding,
|
encoding: node.properties.encoding,
|
||||||
}
|
}
|
||||||
return await nodesStore.updateEntity(providerId, serviceId, node.collection, nodeId, properties)
|
return await nodesStore.updateEntity(currentProviderId(), currentServiceId(), node.collection, nodeId, properties)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete a node
|
// Delete a node
|
||||||
const deleteNode = async (nodeId: string): Promise<boolean> => {
|
const deleteNode = async (nodeId: string): Promise<boolean> => {
|
||||||
const node = nodesStore.getNode(providerId, serviceId, nodeId)
|
const node = nodesStore.getNode(currentProviderId(), currentServiceId(), nodeId)
|
||||||
if (!node) {
|
if (!node) {
|
||||||
throw new Error('Node not found')
|
throw new Error('Node not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node instanceof CollectionObject) {
|
if (node instanceof CollectionObject) {
|
||||||
return await nodesStore.deleteCollection(providerId, serviceId, nodeId)
|
return await nodesStore.deleteCollection(currentProviderId(), currentServiceId(), nodeId)
|
||||||
} else {
|
} else {
|
||||||
return await nodesStore.deleteEntity(providerId, serviceId, node.collection, nodeId)
|
return await nodesStore.deleteEntity(currentProviderId(), currentServiceId(), node.collection, nodeId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read file content
|
// Read file content
|
||||||
const readFile = async (entityId: string): Promise<string | null> => {
|
const readFile = async (entityId: string): Promise<string | null> => {
|
||||||
const node = nodesStore.getNode(providerId, serviceId, entityId)
|
const node = nodesStore.getNode(currentProviderId(), currentServiceId(), entityId)
|
||||||
if (!node || !(node instanceof EntityObject)) {
|
if (!node || !(node instanceof EntityObject)) {
|
||||||
throw new Error('Entity not found')
|
throw new Error('Entity not found')
|
||||||
}
|
}
|
||||||
return await nodesStore.readEntity(providerId, serviceId, node.collection || ROOT_ID, entityId)
|
return await nodesStore.readEntity(currentProviderId(), currentServiceId(), node.collection || ROOT_ID, entityId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write file content
|
// Write file content
|
||||||
const writeFile = async (entityId: string, content: string): Promise<number> => {
|
const writeFile = async (entityId: string, content: string): Promise<number> => {
|
||||||
const node = nodesStore.getNode(providerId, serviceId, entityId)
|
const node = nodesStore.getNode(currentProviderId(), currentServiceId(), entityId)
|
||||||
if (!node || !(node instanceof EntityObject)) {
|
if (!node || !(node instanceof EntityObject)) {
|
||||||
throw new Error('Entity not found')
|
throw new Error('Entity not found')
|
||||||
}
|
}
|
||||||
return await nodesStore.writeEntity(providerId, serviceId, node.collection, entityId, content)
|
return await nodesStore.writeEntity(currentProviderId(), currentServiceId(), node.collection, entityId, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get a URL suitable for inline viewing (img src / video src)
|
// Get a URL suitable for inline viewing (img src / video src)
|
||||||
const getEntityUrl = (entityId: string, collectionId?: string | null): string => {
|
const getEntityUrl = (entityId: string, collectionId?: string | null): string => {
|
||||||
const collection = collectionId ?? currentLocation.value
|
const collection = collectionId ?? currentLocation.value
|
||||||
return `${TRANSFER_BASE_URL}/download/entity/${encodeURIComponent(providerId)}/${encodeURIComponent(serviceId)}/${encodeURIComponent(collection)}/${encodeURIComponent(entityId)}`
|
return `${TRANSFER_BASE_URL}/download/entity/${encodeURIComponent(currentProviderId())}/${encodeURIComponent(currentServiceId())}/${encodeURIComponent(collection)}/${encodeURIComponent(entityId)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download a single file
|
// Download a single file
|
||||||
const downloadEntity = (entityId: string, collectionId?: string | null): void => {
|
const downloadEntity = (entityId: string, collectionId?: string | null): void => {
|
||||||
const collection = collectionId ?? currentLocation.value
|
const collection = collectionId ?? currentLocation.value
|
||||||
// Use path parameters: /download/entity/{provider}/{service}/{collection}/{identifier}
|
// Use path parameters: /download/entity/{provider}/{service}/{collection}/{identifier}
|
||||||
const url = `${TRANSFER_BASE_URL}/download/entity/${encodeURIComponent(providerId)}/${encodeURIComponent(serviceId)}/${encodeURIComponent(collection)}/${encodeURIComponent(entityId)}`
|
const url = `${TRANSFER_BASE_URL}/download/entity/${encodeURIComponent(currentProviderId())}/${encodeURIComponent(currentServiceId())}/${encodeURIComponent(collection)}/${encodeURIComponent(entityId)}`
|
||||||
|
|
||||||
// Trigger download by opening URL (browser handles it)
|
// Trigger download by opening URL (browser handles it)
|
||||||
window.open(url, '_blank')
|
window.open(url, '_blank')
|
||||||
@@ -212,7 +215,7 @@ export function useFileManager(options: UseFileManagerOptions) {
|
|||||||
|
|
||||||
const downloadCollection = (collectionId: string): void => {
|
const downloadCollection = (collectionId: string): void => {
|
||||||
// Use path parameters: /download/collection/{provider}/{service}/{identifier}
|
// Use path parameters: /download/collection/{provider}/{service}/{identifier}
|
||||||
const url = `${TRANSFER_BASE_URL}/download/collection/${encodeURIComponent(providerId)}/${encodeURIComponent(serviceId)}/${encodeURIComponent(collectionId)}`
|
const url = `${TRANSFER_BASE_URL}/download/collection/${encodeURIComponent(currentProviderId())}/${encodeURIComponent(currentServiceId())}/${encodeURIComponent(collectionId)}`
|
||||||
|
|
||||||
window.open(url, '_blank')
|
window.open(url, '_blank')
|
||||||
}
|
}
|
||||||
@@ -220,8 +223,8 @@ export function useFileManager(options: UseFileManagerOptions) {
|
|||||||
const downloadArchive = (ids: string[], name: string = 'download', collectionId?: string | null): void => {
|
const downloadArchive = (ids: string[], name: string = 'download', collectionId?: string | null): void => {
|
||||||
const collection = collectionId ?? currentLocation.value
|
const collection = collectionId ?? currentLocation.value
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
provider: providerId,
|
provider: currentProviderId(),
|
||||||
service: serviceId,
|
service: currentServiceId(),
|
||||||
})
|
})
|
||||||
ids.forEach(id => params.append('ids[]', id))
|
ids.forEach(id => params.append('ids[]', id))
|
||||||
if (name) {
|
if (name) {
|
||||||
@@ -239,7 +242,7 @@ export function useFileManager(options: UseFileManagerOptions) {
|
|||||||
// Initialize - fetch providers, services, and initial nodes if autoFetch
|
// Initialize - fetch providers, services, and initial nodes if autoFetch
|
||||||
const initialize = async () => {
|
const initialize = async () => {
|
||||||
await providersStore.list()
|
await providersStore.list()
|
||||||
await servicesStore.list({ [providerId]: true })
|
await servicesStore.list({ [currentProviderId()]: true })
|
||||||
if (autoFetch) {
|
if (autoFetch) {
|
||||||
await refresh()
|
await refresh()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Supports individual files and entire folder uploads with path preservation
|
* Supports individual files and entire folder uploads with path preservation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, unref } from 'vue'
|
||||||
import type { Ref, ComputedRef } from 'vue'
|
import type { Ref, ComputedRef } from 'vue'
|
||||||
import { useNodesStore, ROOT_ID } from '@DocumentsManager/stores/nodesStore'
|
import { useNodesStore, ROOT_ID } from '@DocumentsManager/stores/nodesStore'
|
||||||
import { EntityObject } from '@DocumentsManager/models/entity'
|
import { EntityObject } from '@DocumentsManager/models/entity'
|
||||||
@@ -25,9 +25,19 @@ export interface FileWithPath {
|
|||||||
relativePath: string
|
relativePath: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NormalizedUploadItem {
|
||||||
|
file: File
|
||||||
|
relativePath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddUploadsWithPathsOptions {
|
||||||
|
batchSize?: number
|
||||||
|
onProgress?: (processed: number, total: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
export interface UseFileUploadOptions {
|
export interface UseFileUploadOptions {
|
||||||
providerId: string
|
providerId: string | Ref<string> | ComputedRef<string>
|
||||||
serviceId: string
|
serviceId: string | Ref<string> | ComputedRef<string>
|
||||||
collectionId?: string | null
|
collectionId?: string | null
|
||||||
maxFileSize?: number
|
maxFileSize?: number
|
||||||
allowedTypes?: string[]
|
allowedTypes?: string[]
|
||||||
@@ -44,6 +54,9 @@ export function useFileUpload(options: UseFileUploadOptions) {
|
|||||||
allowedTypes
|
allowedTypes
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
|
const currentProviderId = () => unref(providerId)
|
||||||
|
const currentServiceId = () => unref(serviceId)
|
||||||
|
|
||||||
const uploads: Ref<Map<string, FileUploadProgress>> = ref(new Map())
|
const uploads: Ref<Map<string, FileUploadProgress>> = ref(new Map())
|
||||||
const isUploading = ref(false)
|
const isUploading = ref(false)
|
||||||
|
|
||||||
@@ -113,25 +126,69 @@ export function useFileUpload(options: UseFileUploadOptions) {
|
|||||||
return `${pathPart}-${file.size}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
return `${pathPart}-${file.size}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createUploadProgress = (file: File, relativePath?: string): FileUploadProgress => {
|
||||||
|
const error = validateFile(file)
|
||||||
|
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
progress: 0,
|
||||||
|
status: error ? 'error' : 'pending',
|
||||||
|
error: error || undefined,
|
||||||
|
relativePath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const yieldToBrowser = async (): Promise<void> => {
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||||
|
window.requestAnimationFrame(() => resolve())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(resolve, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeUploadsWithPaths = (
|
||||||
|
filesOrList: FileList | File[] | FileWithPath[]
|
||||||
|
): NormalizedUploadItem[] => {
|
||||||
|
if (filesOrList instanceof FileList) {
|
||||||
|
return Array.from(filesOrList, file => ({
|
||||||
|
file,
|
||||||
|
relativePath: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filesOrList.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filesOrList[0] instanceof File) {
|
||||||
|
return (filesOrList as File[]).map(file => ({
|
||||||
|
file,
|
||||||
|
relativePath: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (filesOrList as FileWithPath[]).map(({ file, relativePath }) => ({ file, relativePath }))
|
||||||
|
}
|
||||||
|
|
||||||
// Add files to upload queue
|
// Add files to upload queue
|
||||||
const addFiles = (files: FileList | File[]): FileUploadProgress[] => {
|
const addFiles = (files: FileList | File[]): FileUploadProgress[] => {
|
||||||
const added: FileUploadProgress[] = []
|
const added: FileUploadProgress[] = []
|
||||||
|
|
||||||
|
const nextUploads = new Map(uploads.value)
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const error = validateFile(file)
|
const progress = createUploadProgress(file)
|
||||||
const uploadId = generateUploadId(file)
|
const uploadId = generateUploadId(file)
|
||||||
|
|
||||||
const progress: FileUploadProgress = {
|
nextUploads.set(uploadId, progress)
|
||||||
file,
|
|
||||||
progress: 0,
|
|
||||||
status: error ? 'error' : 'pending',
|
|
||||||
error: error || undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
uploads.value.set(uploadId, progress)
|
|
||||||
added.push(progress)
|
added.push(progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uploads.value = nextUploads
|
||||||
|
|
||||||
return added
|
return added
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,43 +198,51 @@ export function useFileUpload(options: UseFileUploadOptions) {
|
|||||||
): FileUploadProgress[] => {
|
): FileUploadProgress[] => {
|
||||||
const added: FileUploadProgress[] = []
|
const added: FileUploadProgress[] = []
|
||||||
|
|
||||||
// Handle FileList from webkitdirectory input
|
const nextUploads = new Map(uploads.value)
|
||||||
if (filesOrList instanceof FileList || (Array.isArray(filesOrList) && filesOrList[0] instanceof File && !('relativePath' in filesOrList[0]))) {
|
|
||||||
const fileList = filesOrList as FileList | File[]
|
for (const { file, relativePath } of normalizeUploadsWithPaths(filesOrList)) {
|
||||||
for (const file of fileList) {
|
const progress = createUploadProgress(file, relativePath)
|
||||||
// webkitRelativePath is set on files from folder input
|
|
||||||
const relativePath = (file as any).webkitRelativePath || file.name
|
|
||||||
const error = validateFile(file)
|
|
||||||
const uploadId = generateUploadId(file, relativePath)
|
const uploadId = generateUploadId(file, relativePath)
|
||||||
|
|
||||||
const progress: FileUploadProgress = {
|
nextUploads.set(uploadId, progress)
|
||||||
file,
|
|
||||||
progress: 0,
|
|
||||||
status: error ? 'error' : 'pending',
|
|
||||||
error: error || undefined,
|
|
||||||
relativePath
|
|
||||||
}
|
|
||||||
|
|
||||||
uploads.value.set(uploadId, progress)
|
|
||||||
added.push(progress)
|
added.push(progress)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Handle FileWithPath array (from drag & drop folder processing)
|
uploads.value = nextUploads
|
||||||
const filesWithPaths = filesOrList as FileWithPath[]
|
|
||||||
for (const { file, relativePath } of filesWithPaths) {
|
return added
|
||||||
const error = validateFile(file)
|
}
|
||||||
|
|
||||||
|
const addFilesWithPathsBatched = async (
|
||||||
|
filesOrList: FileList | File[] | FileWithPath[],
|
||||||
|
options: AddUploadsWithPathsOptions = {}
|
||||||
|
): Promise<FileUploadProgress[]> => {
|
||||||
|
const items = normalizeUploadsWithPaths(filesOrList)
|
||||||
|
const total = items.length
|
||||||
|
const batchSize = Math.max(1, options.batchSize ?? 250)
|
||||||
|
const added: FileUploadProgress[] = []
|
||||||
|
|
||||||
|
options.onProgress?.(0, total)
|
||||||
|
|
||||||
|
for (let start = 0; start < total; start += batchSize) {
|
||||||
|
const batch = items.slice(start, start + batchSize)
|
||||||
|
const nextUploads = new Map(uploads.value)
|
||||||
|
|
||||||
|
for (const { file, relativePath } of batch) {
|
||||||
|
const progress = createUploadProgress(file, relativePath)
|
||||||
const uploadId = generateUploadId(file, relativePath)
|
const uploadId = generateUploadId(file, relativePath)
|
||||||
|
|
||||||
const progress: FileUploadProgress = {
|
nextUploads.set(uploadId, progress)
|
||||||
file,
|
added.push(progress)
|
||||||
progress: 0,
|
|
||||||
status: error ? 'error' : 'pending',
|
|
||||||
error: error || undefined,
|
|
||||||
relativePath
|
|
||||||
}
|
}
|
||||||
|
|
||||||
uploads.value.set(uploadId, progress)
|
uploads.value = nextUploads
|
||||||
added.push(progress)
|
|
||||||
|
const processed = Math.min(start + batch.length, total)
|
||||||
|
options.onProgress?.(processed, total)
|
||||||
|
|
||||||
|
if (processed < total) {
|
||||||
|
await yieldToBrowser()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,8 +293,8 @@ export function useFileUpload(options: UseFileUploadOptions) {
|
|||||||
try {
|
try {
|
||||||
// Create the folder
|
// Create the folder
|
||||||
const collection = await nodesStore.createCollection(
|
const collection = await nodesStore.createCollection(
|
||||||
providerId,
|
currentProviderId(),
|
||||||
serviceId,
|
currentServiceId(),
|
||||||
parentId,
|
parentId,
|
||||||
{ label: folderName, owner: '' }
|
{ label: folderName, owner: '' }
|
||||||
)
|
)
|
||||||
@@ -277,8 +342,8 @@ export function useFileUpload(options: UseFileUploadOptions) {
|
|||||||
|
|
||||||
// Create the entity
|
// Create the entity
|
||||||
const entity = await nodesStore.createEntity(
|
const entity = await nodesStore.createEntity(
|
||||||
providerId,
|
currentProviderId(),
|
||||||
serviceId,
|
currentServiceId(),
|
||||||
targetCollection || ROOT_ID,
|
targetCollection || ROOT_ID,
|
||||||
{
|
{
|
||||||
'@type': 'documents.properties',
|
'@type': 'documents.properties',
|
||||||
@@ -295,8 +360,8 @@ export function useFileUpload(options: UseFileUploadOptions) {
|
|||||||
|
|
||||||
// Write the content
|
// Write the content
|
||||||
await nodesStore.writeEntity(
|
await nodesStore.writeEntity(
|
||||||
providerId,
|
currentProviderId(),
|
||||||
serviceId,
|
currentServiceId(),
|
||||||
targetCollection || ROOT_ID,
|
targetCollection || ROOT_ID,
|
||||||
String(entity.identifier),
|
String(entity.identifier),
|
||||||
content
|
content
|
||||||
@@ -417,6 +482,7 @@ export function useFileUpload(options: UseFileUploadOptions) {
|
|||||||
validateFile,
|
validateFile,
|
||||||
addFiles,
|
addFiles,
|
||||||
addFilesWithPaths,
|
addFilesWithPaths,
|
||||||
|
addFilesWithPathsBatched,
|
||||||
uploadFile,
|
uploadFile,
|
||||||
uploadAll,
|
uploadAll,
|
||||||
removeUpload,
|
removeUpload,
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
import { useModuleStore } from '@KTXC'
|
import { useModuleStore } from '@KTXC'
|
||||||
import { useFileManager, useFileSelection, useFileUpload } from '@/composables'
|
import { useFileManager, useFileSelection, useFileUpload, useFileEditor } from '@/composables'
|
||||||
import type { ViewMode, SortField, SortOrder, BreadcrumbItem } from '@/types'
|
import type { ViewMode, SortField, SortOrder, BreadcrumbItem } from '@/types'
|
||||||
import { CollectionObject } from '@DocumentsManager/models/collection'
|
import { CollectionObject } from '@DocumentsManager/models/collection'
|
||||||
import { EntityObject } from '@DocumentsManager/models/entity'
|
import { EntityObject } from '@DocumentsManager/models/entity'
|
||||||
|
import { ServiceObject } from '@DocumentsManager/models/service'
|
||||||
|
import { useServicesStore } from '@DocumentsManager/stores/servicesStore'
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +21,7 @@ import {
|
|||||||
FilesListView,
|
FilesListView,
|
||||||
FilesDetailsView,
|
FilesDetailsView,
|
||||||
FileViewerDialog,
|
FileViewerDialog,
|
||||||
|
FileEditorDialog,
|
||||||
NewFolderDialog,
|
NewFolderDialog,
|
||||||
RenameDialog,
|
RenameDialog,
|
||||||
DeleteConfirmDialog,
|
DeleteConfirmDialog,
|
||||||
@@ -25,19 +29,24 @@ import {
|
|||||||
} from '@/components'
|
} from '@/components'
|
||||||
|
|
||||||
// Check if file manager is available
|
// Check if file manager is available
|
||||||
|
const display = useDisplay()
|
||||||
|
const isMobile = computed(() => display.mdAndDown.value)
|
||||||
|
|
||||||
const moduleStore = useModuleStore()
|
const moduleStore = useModuleStore()
|
||||||
const isFileManagerAvailable = computed(() => {
|
const isFileManagerAvailable = computed(() => {
|
||||||
return moduleStore.has('documents_manager') || moduleStore.has('DocumentsManager')
|
return moduleStore.has('documents_manager') || moduleStore.has('DocumentsManager')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const servicesStore = useServicesStore()
|
||||||
|
|
||||||
// Active provider/service (will be selectable later)
|
// Active provider/service (will be selectable later)
|
||||||
const activeProviderId = ref('default')
|
const activeProviderId = ref('default')
|
||||||
const activeServiceId = ref('personal')
|
const activeServiceId = ref('personal')
|
||||||
|
|
||||||
// File manager composable
|
// File manager composable
|
||||||
const fileManager = useFileManager({
|
const fileManager = useFileManager({
|
||||||
providerId: activeProviderId.value,
|
providerId: activeProviderId,
|
||||||
serviceId: activeServiceId.value,
|
serviceId: activeServiceId,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Selection composable
|
// Selection composable
|
||||||
@@ -45,8 +54,8 @@ const selection = useFileSelection({ multiple: true })
|
|||||||
|
|
||||||
// Upload composable
|
// Upload composable
|
||||||
const upload = useFileUpload({
|
const upload = useFileUpload({
|
||||||
providerId: activeProviderId.value,
|
providerId: activeProviderId,
|
||||||
serviceId: activeServiceId.value,
|
serviceId: activeServiceId,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Keep upload collection in sync with current location
|
// Keep upload collection in sync with current location
|
||||||
@@ -54,6 +63,12 @@ watch(() => fileManager.currentLocation.value, (newLocation) => {
|
|||||||
upload.setCollection(newLocation)
|
upload.setCollection(newLocation)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(() => fileManager.error.value, (message) => {
|
||||||
|
if (message) {
|
||||||
|
notify(message, 'error')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// View state
|
// View state
|
||||||
const viewMode = ref<ViewMode>('grid')
|
const viewMode = ref<ViewMode>('grid')
|
||||||
const sortField = ref<SortField>('label')
|
const sortField = ref<SortField>('label')
|
||||||
@@ -72,10 +87,80 @@ const nodesToDelete = ref<(CollectionObject | EntityObject)[]>([])
|
|||||||
// Drag and drop state
|
// Drag and drop state
|
||||||
const isDragOver = ref(false)
|
const isDragOver = ref(false)
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
const snackbarVisible = ref(false)
|
||||||
|
const snackbarMessage = ref('')
|
||||||
|
const snackbarColor = ref<'success' | 'info' | 'warning' | 'error'>('success')
|
||||||
|
|
||||||
|
// Upload preparation state
|
||||||
|
const isPreparingUploads = ref(false)
|
||||||
|
const uploadPreparationMessage = ref('Preparing uploads...')
|
||||||
|
const uploadPreparationProcessedCount = ref(0)
|
||||||
|
const uploadPreparationTotalCount = ref(0)
|
||||||
|
|
||||||
// Viewer state
|
// Viewer state
|
||||||
const viewerEntity = ref<EntityObject | null>(null)
|
const viewerEntity = ref<EntityObject | null>(null)
|
||||||
const showViewer = ref(false)
|
const showViewer = ref(false)
|
||||||
|
|
||||||
|
// Editor state
|
||||||
|
const fileEditorComposable = useFileEditor()
|
||||||
|
const editorEntity = ref<EntityObject | null>(null)
|
||||||
|
const showEditor = ref(false)
|
||||||
|
|
||||||
|
function notify(message: string, color: 'success' | 'info' | 'warning' | 'error' = 'success') {
|
||||||
|
snackbarMessage.value = message
|
||||||
|
snackbarColor.value = color
|
||||||
|
snackbarVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown, fallback: string) {
|
||||||
|
return error instanceof Error ? error.message : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginUploadPreparation(message: string, totalCount: number = 0) {
|
||||||
|
isPreparingUploads.value = true
|
||||||
|
uploadPreparationMessage.value = message
|
||||||
|
uploadPreparationProcessedCount.value = 0
|
||||||
|
uploadPreparationTotalCount.value = totalCount
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUploadPreparation(processedCount: number, totalCount: number) {
|
||||||
|
uploadPreparationProcessedCount.value = processedCount
|
||||||
|
uploadPreparationTotalCount.value = totalCount
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishUploadPreparation() {
|
||||||
|
isPreparingUploads.value = false
|
||||||
|
uploadPreparationProcessedCount.value = 0
|
||||||
|
uploadPreparationTotalCount.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queueFolderUploads(
|
||||||
|
filesOrList: FileList | { file: File; relativePath: string }[],
|
||||||
|
message: string,
|
||||||
|
successMessage: string
|
||||||
|
) {
|
||||||
|
const totalCount = filesOrList.length
|
||||||
|
|
||||||
|
showUploadDialog.value = true
|
||||||
|
beginUploadPreparation(message, totalCount)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await upload.addFilesWithPathsBatched(filesOrList, {
|
||||||
|
batchSize: 250,
|
||||||
|
onProgress: updateUploadPreparation,
|
||||||
|
})
|
||||||
|
|
||||||
|
notify(successMessage, 'info')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to prepare uploads:', error)
|
||||||
|
notify(getErrorMessage(error, 'Failed to prepare uploads'), 'error')
|
||||||
|
} finally {
|
||||||
|
finishUploadPreparation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleOpenItem(item: CollectionObject | EntityObject) {
|
function handleOpenItem(item: CollectionObject | EntityObject) {
|
||||||
if (item instanceof EntityObject) {
|
if (item instanceof EntityObject) {
|
||||||
viewerEntity.value = item
|
viewerEntity.value = item
|
||||||
@@ -91,6 +176,13 @@ function handleViewerNavigate(entity: EntityObject) {
|
|||||||
viewerEntity.value = entity
|
viewerEntity.value = entity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleEditItem(item: CollectionObject | EntityObject) {
|
||||||
|
if (item instanceof EntityObject && fileEditorComposable.canEdit(item)) {
|
||||||
|
editorEntity.value = item
|
||||||
|
showEditor.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Hidden file inputs
|
// Hidden file inputs
|
||||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
const folderInputRef = ref<HTMLInputElement | null>(null)
|
const folderInputRef = ref<HTMLInputElement | null>(null)
|
||||||
@@ -202,8 +294,75 @@ const hasItems = computed(() =>
|
|||||||
sortedItems.value.collections.length > 0 || sortedItems.value.entities.length > 0
|
sortedItems.value.collections.length > 0 || sortedItems.value.entities.length > 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const hasSelection = computed(() => selection.hasSelection.value)
|
||||||
|
|
||||||
const selectedIds = computed(() => selection.selectedIds.value)
|
const selectedIds = computed(() => selection.selectedIds.value)
|
||||||
|
|
||||||
|
const availableServices = computed<ServiceObject[]>(() => {
|
||||||
|
const services = servicesStore.services
|
||||||
|
const enabledServices = services.filter(service => service.enabled)
|
||||||
|
const source = enabledServices.length > 0 ? enabledServices : services
|
||||||
|
|
||||||
|
return [...source].sort((left, right) => {
|
||||||
|
const leftLabel = (left.label || String(left.identifier || '')).toLowerCase()
|
||||||
|
const rightLabel = (right.label || String(right.identifier || '')).toLowerCase()
|
||||||
|
return leftLabel.localeCompare(rightLabel)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function syncActiveService(services: ServiceObject[]) {
|
||||||
|
if (services.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentService = services.find(service =>
|
||||||
|
service.provider === activeProviderId.value &&
|
||||||
|
String(service.identifier ?? '') === activeServiceId.value
|
||||||
|
)
|
||||||
|
|
||||||
|
if (currentService) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [nextService] = services
|
||||||
|
activeProviderId.value = nextService.provider
|
||||||
|
activeServiceId.value = String(nextService.identifier ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(availableServices, (services) => {
|
||||||
|
syncActiveService(services)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
watch([activeProviderId, activeServiceId], async ([providerId, serviceId], [previousProviderId, previousServiceId]) => {
|
||||||
|
if (!providerId || !serviceId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providerId === previousProviderId && serviceId === previousServiceId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selection.clear()
|
||||||
|
upload.setCollection(fileManager.rootId.value)
|
||||||
|
await fileManager.navigateToRoot()
|
||||||
|
}, { flush: 'post' })
|
||||||
|
|
||||||
|
async function handleServiceSelect(service: ServiceObject) {
|
||||||
|
const providerId = service.provider
|
||||||
|
const serviceId = String(service.identifier ?? '')
|
||||||
|
|
||||||
|
if (providerId === activeProviderId.value && serviceId === activeServiceId.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activeProviderId.value = providerId
|
||||||
|
activeServiceId.value = serviceId
|
||||||
|
if (isMobile.value) {
|
||||||
|
sidebarVisible.value = false
|
||||||
|
}
|
||||||
|
notify(`Switched to ${service.label || serviceId}`, 'info')
|
||||||
|
}
|
||||||
|
|
||||||
// Navigation methods
|
// Navigation methods
|
||||||
async function handleBreadcrumbNavigate(item: BreadcrumbItem) {
|
async function handleBreadcrumbNavigate(item: BreadcrumbItem) {
|
||||||
selection.clear()
|
selection.clear()
|
||||||
@@ -251,8 +410,10 @@ async function handleCreateFolder(name: string) {
|
|||||||
try {
|
try {
|
||||||
await fileManager.createFolder(name)
|
await fileManager.createFolder(name)
|
||||||
showNewFolderDialog.value = false
|
showNewFolderDialog.value = false
|
||||||
|
notify(`Folder "${name}" created`, 'success')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create folder:', error)
|
console.error('Failed to create folder:', error)
|
||||||
|
notify(getErrorMessage(error, 'Failed to create folder'), 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,13 +425,16 @@ function handleRenameItem(item: CollectionObject | EntityObject) {
|
|||||||
|
|
||||||
async function handleRename(newName: string) {
|
async function handleRename(newName: string) {
|
||||||
if (!nodeToRename.value) return
|
if (!nodeToRename.value) return
|
||||||
|
const currentName = renameCurrentName(nodeToRename.value)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fileManager.renameNode(nodeId(nodeToRename.value), newName)
|
await fileManager.renameNode(nodeId(nodeToRename.value), newName)
|
||||||
showRenameDialog.value = false
|
showRenameDialog.value = false
|
||||||
nodeToRename.value = null
|
nodeToRename.value = null
|
||||||
|
notify(`Renamed "${currentName}" to "${newName}"`, 'success')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to rename:', error)
|
console.error('Failed to rename:', error)
|
||||||
|
notify(getErrorMessage(error, 'Failed to rename item'), 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +445,8 @@ function handleDeleteItem(item: CollectionObject | EntityObject) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
|
const deletedCount = nodesToDelete.value.length
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const node of nodesToDelete.value) {
|
for (const node of nodesToDelete.value) {
|
||||||
await fileManager.deleteNode(nodeId(node))
|
await fileManager.deleteNode(nodeId(node))
|
||||||
@@ -288,8 +454,13 @@ async function handleDelete() {
|
|||||||
selection.clear()
|
selection.clear()
|
||||||
showDeleteDialog.value = false
|
showDeleteDialog.value = false
|
||||||
nodesToDelete.value = []
|
nodesToDelete.value = []
|
||||||
|
notify(
|
||||||
|
deletedCount === 1 ? 'Item deleted' : `${deletedCount} items deleted`,
|
||||||
|
'success'
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete:', error)
|
console.error('Failed to delete:', error)
|
||||||
|
notify(getErrorMessage(error, 'Failed to delete selected items'), 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,9 +469,11 @@ function handleDownloadItem(item: CollectionObject | EntityObject) {
|
|||||||
if (item instanceof EntityObject) {
|
if (item instanceof EntityObject) {
|
||||||
// Download single file
|
// Download single file
|
||||||
fileManager.downloadEntity(nodeId(item), String(item.collection ?? fileManager.ROOT_ID))
|
fileManager.downloadEntity(nodeId(item), String(item.collection ?? fileManager.ROOT_ID))
|
||||||
|
notify(`Download started for "${nodeLabel(item)}"`, 'info')
|
||||||
} else if (item instanceof CollectionObject) {
|
} else if (item instanceof CollectionObject) {
|
||||||
// Download folder as ZIP
|
// Download folder as ZIP
|
||||||
fileManager.downloadCollection(nodeId(item))
|
fileManager.downloadCollection(nodeId(item))
|
||||||
|
notify(`Archive download started for "${nodeLabel(item)}"`, 'info')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,15 +491,24 @@ function handleFileSelect(event: Event) {
|
|||||||
if (input.files && input.files.length > 0) {
|
if (input.files && input.files.length > 0) {
|
||||||
upload.addFiles(input.files)
|
upload.addFiles(input.files)
|
||||||
showUploadDialog.value = true
|
showUploadDialog.value = true
|
||||||
|
notify(
|
||||||
|
input.files.length === 1 ? '1 file added to uploads' : `${input.files.length} files added to uploads`,
|
||||||
|
'info'
|
||||||
|
)
|
||||||
input.value = ''
|
input.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFolderSelect(event: Event) {
|
async function handleFolderSelect(event: Event) {
|
||||||
const input = event.target as HTMLInputElement
|
const input = event.target as HTMLInputElement
|
||||||
if (input.files && input.files.length > 0) {
|
if (input.files && input.files.length > 0) {
|
||||||
upload.addFilesWithPaths(input.files)
|
await queueFolderUploads(
|
||||||
showUploadDialog.value = true
|
input.files,
|
||||||
|
'Preparing folder upload...',
|
||||||
|
input.files.length === 1
|
||||||
|
? '1 file added from folder upload'
|
||||||
|
: `${input.files.length} files added from folder upload`
|
||||||
|
)
|
||||||
input.value = ''
|
input.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,18 +534,38 @@ async function handleDrop(event: DragEvent) {
|
|||||||
if (items && items.length > 0) {
|
if (items && items.length > 0) {
|
||||||
const entries: FileSystemEntry[] = []
|
const entries: FileSystemEntry[] = []
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
const entry = items[i].webkitGetAsEntry?.()
|
const item = items[i]
|
||||||
|
if (!item) continue
|
||||||
|
|
||||||
|
const entry = item.webkitGetAsEntry?.()
|
||||||
if (entry) {
|
if (entry) {
|
||||||
entries.push(entry)
|
entries.push(entry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entries.length > 0) {
|
if (entries.length > 0) {
|
||||||
|
showUploadDialog.value = true
|
||||||
|
beginUploadPreparation('Scanning dropped folder...')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
try {
|
||||||
const filesWithPaths = await readEntriesRecursively(entries)
|
const filesWithPaths = await readEntriesRecursively(entries)
|
||||||
if (filesWithPaths.length > 0) {
|
if (filesWithPaths.length > 0) {
|
||||||
upload.addFilesWithPaths(filesWithPaths)
|
await queueFolderUploads(
|
||||||
showUploadDialog.value = true
|
filesWithPaths,
|
||||||
|
'Preparing dropped folder upload...',
|
||||||
|
filesWithPaths.length === 1 ? '1 file added to uploads' : `${filesWithPaths.length} files added to uploads`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
notify('No files found in dropped folder', 'warning')
|
||||||
|
finishUploadPreparation()
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
finishUploadPreparation()
|
||||||
|
console.error('Failed to read dropped folder:', error)
|
||||||
|
notify(getErrorMessage(error, 'Failed to read dropped folder'), 'error')
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -371,6 +573,10 @@ async function handleDrop(event: DragEvent) {
|
|||||||
if (event.dataTransfer.files && event.dataTransfer.files.length > 0) {
|
if (event.dataTransfer.files && event.dataTransfer.files.length > 0) {
|
||||||
upload.addFiles(event.dataTransfer.files)
|
upload.addFiles(event.dataTransfer.files)
|
||||||
showUploadDialog.value = true
|
showUploadDialog.value = true
|
||||||
|
notify(
|
||||||
|
event.dataTransfer.files.length === 1 ? '1 file added to uploads' : `${event.dataTransfer.files.length} files added to uploads`,
|
||||||
|
'info'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,24 +622,38 @@ async function readEntriesRecursively(
|
|||||||
|
|
||||||
// Upload methods
|
// Upload methods
|
||||||
async function handleUploadAll() {
|
async function handleUploadAll() {
|
||||||
|
try {
|
||||||
await upload.uploadAll()
|
await upload.uploadAll()
|
||||||
await fileManager.refresh()
|
await fileManager.refresh()
|
||||||
|
notify('Uploads completed', 'success')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to upload files:', error)
|
||||||
|
notify(getErrorMessage(error, 'Failed to upload files'), 'error')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleUploadDialogClose() {
|
function handleUploadDialogClose() {
|
||||||
showUploadDialog.value = false
|
showUploadDialog.value = false
|
||||||
|
finishUploadPreparation()
|
||||||
upload.clearAll()
|
upload.clearAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh
|
// Refresh
|
||||||
async function handleRefresh() {
|
async function handleRefresh() {
|
||||||
|
try {
|
||||||
await fileManager.refresh()
|
await fileManager.refresh()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh files:', error)
|
||||||
|
notify(getErrorMessage(error, 'Failed to refresh files'), 'error')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
await fileManager.initialize()
|
await fileManager.initialize()
|
||||||
|
await servicesStore.list()
|
||||||
|
syncActiveService(availableServices.value)
|
||||||
await fileManager.refresh()
|
await fileManager.refresh()
|
||||||
console.log('[Files] - Initialized with:', {
|
console.log('[Files] - Initialized with:', {
|
||||||
provider: activeProviderId.value,
|
provider: activeProviderId.value,
|
||||||
@@ -441,12 +661,32 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Files] - Failed to initialize:', error)
|
console.error('[Files] - Failed to initialize:', error)
|
||||||
|
notify(getErrorMessage(error, 'Failed to initialize file manager'), 'error')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="files-container">
|
<div v-if="!isFileManagerAvailable" class="files-unavailable">
|
||||||
|
<v-alert
|
||||||
|
type="warning"
|
||||||
|
variant="outlined"
|
||||||
|
class="files-unavailable-alert"
|
||||||
|
>
|
||||||
|
<v-icon size="64" color="warning" class="mb-6">mdi-folder-off-outline</v-icon>
|
||||||
|
<h2 class="text-h5 font-weight-bold mb-4">File Manager Not Available</h2>
|
||||||
|
<p>
|
||||||
|
The File Manager module is not installed or enabled.
|
||||||
|
This page requires the <strong>documents_manager</strong> module to function properly.
|
||||||
|
</p>
|
||||||
|
<p class="mb-0 mt-2">
|
||||||
|
Please contact your system administrator to install and enable the
|
||||||
|
<code>documents_manager</code> module.
|
||||||
|
</p>
|
||||||
|
</v-alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="files-container">
|
||||||
<!-- Top Toolbar -->
|
<!-- Top Toolbar -->
|
||||||
<FilesToolbar
|
<FilesToolbar
|
||||||
v-model:search-query="searchQuery"
|
v-model:search-query="searchQuery"
|
||||||
@@ -472,12 +712,17 @@ onMounted(async () => {
|
|||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<FilesSidebar
|
<FilesSidebar
|
||||||
v-model="sidebarVisible"
|
v-model="sidebarVisible"
|
||||||
|
:active-provider-id="activeProviderId"
|
||||||
:active-service-id="activeServiceId"
|
:active-service-id="activeServiceId"
|
||||||
|
:services="availableServices"
|
||||||
@navigate-home="fileManager.navigateToRoot()"
|
@navigate-home="fileManager.navigateToRoot()"
|
||||||
|
@select-service="handleServiceSelect"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Main Area -->
|
<!-- Main Area -->
|
||||||
<div class="files-main">
|
<div class="files-main">
|
||||||
|
<div class="files-workspace">
|
||||||
|
<div class="files-browser-panel">
|
||||||
<!-- Breadcrumbs -->
|
<!-- Breadcrumbs -->
|
||||||
<FilesBreadcrumbs
|
<FilesBreadcrumbs
|
||||||
:items="breadcrumbs"
|
:items="breadcrumbs"
|
||||||
@@ -492,28 +737,9 @@ onMounted(async () => {
|
|||||||
class="mx-4"
|
class="mx-4"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Error -->
|
<div class="files-view-panel">
|
||||||
<v-alert
|
|
||||||
v-if="fileManager.error.value"
|
|
||||||
type="error"
|
|
||||||
variant="tonal"
|
|
||||||
class="mx-4 mb-4"
|
|
||||||
>
|
|
||||||
{{ fileManager.error.value }}
|
|
||||||
</v-alert>
|
|
||||||
|
|
||||||
<!-- File Manager not available -->
|
|
||||||
<v-alert
|
|
||||||
v-if="!isFileManagerAvailable"
|
|
||||||
type="warning"
|
|
||||||
variant="tonal"
|
|
||||||
class="mx-4 mb-4"
|
|
||||||
>
|
|
||||||
File Manager module is not available. Please ensure it is installed and enabled.
|
|
||||||
</v-alert>
|
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<FilesEmptyState v-else-if="!hasItems && !fileManager.isLoading.value" />
|
<FilesEmptyState v-if="!hasItems && !fileManager.isLoading.value" />
|
||||||
|
|
||||||
<!-- Grid View -->
|
<!-- Grid View -->
|
||||||
<FilesGridView
|
<FilesGridView
|
||||||
@@ -523,6 +749,7 @@ onMounted(async () => {
|
|||||||
:selected-ids="selectedIds"
|
:selected-ids="selectedIds"
|
||||||
@item-click="handleItemClick"
|
@item-click="handleItemClick"
|
||||||
@open="handleOpenItem"
|
@open="handleOpenItem"
|
||||||
|
@edit="handleEditItem"
|
||||||
@rename="handleRenameItem"
|
@rename="handleRenameItem"
|
||||||
@delete="handleDeleteItem"
|
@delete="handleDeleteItem"
|
||||||
@download="handleDownloadItem"
|
@download="handleDownloadItem"
|
||||||
@@ -537,6 +764,7 @@ onMounted(async () => {
|
|||||||
:selected-ids="selectedIds"
|
:selected-ids="selectedIds"
|
||||||
@item-click="handleItemClick"
|
@item-click="handleItemClick"
|
||||||
@open="handleOpenItem"
|
@open="handleOpenItem"
|
||||||
|
@edit="handleEditItem"
|
||||||
@rename="handleRenameItem"
|
@rename="handleRenameItem"
|
||||||
@delete="handleDeleteItem"
|
@delete="handleDeleteItem"
|
||||||
@download="handleDownloadItem"
|
@download="handleDownloadItem"
|
||||||
@@ -551,6 +779,7 @@ onMounted(async () => {
|
|||||||
:selected-ids="selectedIds"
|
:selected-ids="selectedIds"
|
||||||
@item-click="handleItemClick"
|
@item-click="handleItemClick"
|
||||||
@open="handleOpenItem"
|
@open="handleOpenItem"
|
||||||
|
@edit="handleEditItem"
|
||||||
@rename="handleRenameItem"
|
@rename="handleRenameItem"
|
||||||
@delete="handleDeleteItem"
|
@delete="handleDeleteItem"
|
||||||
@download="handleDownloadItem"
|
@download="handleDownloadItem"
|
||||||
@@ -559,6 +788,22 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FilesInfoPanel
|
||||||
|
v-if="hasSelection && !isMobile"
|
||||||
|
embedded
|
||||||
|
:selected-items="selection.selectedNodeArray.value"
|
||||||
|
@close="selection.clear()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FilesInfoPanel
|
||||||
|
v-if="isMobile"
|
||||||
|
:selected-items="selection.selectedNodeArray.value"
|
||||||
|
@close="selection.clear()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Hidden file inputs -->
|
<!-- Hidden file inputs -->
|
||||||
<input
|
<input
|
||||||
ref="fileInputRef"
|
ref="fileInputRef"
|
||||||
@@ -598,6 +843,10 @@ onMounted(async () => {
|
|||||||
:uploads="upload.uploads.value"
|
:uploads="upload.uploads.value"
|
||||||
:total-progress="upload.totalProgress.value"
|
:total-progress="upload.totalProgress.value"
|
||||||
:is-uploading="upload.isUploading.value"
|
:is-uploading="upload.isUploading.value"
|
||||||
|
:is-preparing="isPreparingUploads"
|
||||||
|
:preparing-message="uploadPreparationMessage"
|
||||||
|
:preparing-processed-count="uploadPreparationProcessedCount"
|
||||||
|
:preparing-total-count="uploadPreparationTotalCount"
|
||||||
:pending-count="upload.pendingUploads.value.length"
|
:pending-count="upload.pendingUploads.value.length"
|
||||||
:completed-count="upload.completedUploads.value.length"
|
:completed-count="upload.completedUploads.value.length"
|
||||||
@upload-all="handleUploadAll"
|
@upload-all="handleUploadAll"
|
||||||
@@ -607,12 +856,6 @@ onMounted(async () => {
|
|||||||
@close="handleUploadDialogClose"
|
@close="handleUploadDialogClose"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Info Panel (right side) -->
|
|
||||||
<FilesInfoPanel
|
|
||||||
:selected-items="selection.selectedNodeArray.value"
|
|
||||||
@close="selection.clear()"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- File Viewer -->
|
<!-- File Viewer -->
|
||||||
<FileViewerDialog
|
<FileViewerDialog
|
||||||
v-model="showViewer"
|
v-model="showViewer"
|
||||||
@@ -622,10 +865,46 @@ onMounted(async () => {
|
|||||||
:download-entity="fileManager.downloadEntity"
|
:download-entity="fileManager.downloadEntity"
|
||||||
@navigate="handleViewerNavigate"
|
@navigate="handleViewerNavigate"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- File Editor -->
|
||||||
|
<FileEditorDialog
|
||||||
|
v-model="showEditor"
|
||||||
|
:entity="editorEntity"
|
||||||
|
:read-file="fileManager.readFile"
|
||||||
|
:write-file="fileManager.writeFile"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-snackbar
|
||||||
|
v-model="snackbarVisible"
|
||||||
|
:color="snackbarColor"
|
||||||
|
:timeout="3000"
|
||||||
|
location="bottom right"
|
||||||
|
>
|
||||||
|
{{ snackbarMessage }}
|
||||||
|
<template #actions>
|
||||||
|
<v-btn variant="text" @click="snackbarVisible = false">Close</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-snackbar>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.files-unavailable {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
padding: 48px;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-unavailable-alert {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.files-container {
|
.files-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -642,8 +921,35 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.files-main {
|
.files-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-workspace {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-browser-panel {
|
||||||
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-view-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.files-workspace {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user