Merge pull request 'refactor: improvemets' (#4) from chore/improvements into main

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-03-24 23:13:51 +00:00
16 changed files with 1063 additions and 254 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -0,0 +1 @@
export { default as FileEditorDialog } from './FileEditorDialog.vue'

View File

@@ -15,3 +15,6 @@ export * from './dialogs'
// Viewer // Viewer
export * from './viewer' export * from './viewer'
// Editor
export * from './editor'

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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'

View 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>

View File

@@ -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()
} }

View File

@@ -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,

View File

@@ -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>