refactor: improvemets

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-03-24 19:10:52 -04:00
parent b6d6bed2ee
commit da6a407445
16 changed files with 1063 additions and 254 deletions

View File

@@ -1,10 +1,13 @@
<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 { useFileManager, useFileSelection, useFileUpload } from '@/composables'
import { useFileManager, useFileSelection, useFileUpload, useFileEditor } from '@/composables'
import type { ViewMode, SortField, SortOrder, BreadcrumbItem } from '@/types'
import { CollectionObject } from '@DocumentsManager/models/collection'
import { EntityObject } from '@DocumentsManager/models/entity'
import { ServiceObject } from '@DocumentsManager/models/service'
import { useServicesStore } from '@DocumentsManager/stores/servicesStore'
// Components
import {
@@ -18,6 +21,7 @@ import {
FilesListView,
FilesDetailsView,
FileViewerDialog,
FileEditorDialog,
NewFolderDialog,
RenameDialog,
DeleteConfirmDialog,
@@ -25,19 +29,24 @@ import {
} from '@/components'
// Check if file manager is available
const display = useDisplay()
const isMobile = computed(() => display.mdAndDown.value)
const moduleStore = useModuleStore()
const isFileManagerAvailable = computed(() => {
return moduleStore.has('documents_manager') || moduleStore.has('DocumentsManager')
})
const servicesStore = useServicesStore()
// Active provider/service (will be selectable later)
const activeProviderId = ref('default')
const activeServiceId = ref('personal')
// File manager composable
const fileManager = useFileManager({
providerId: activeProviderId.value,
serviceId: activeServiceId.value,
providerId: activeProviderId,
serviceId: activeServiceId,
})
// Selection composable
@@ -45,8 +54,8 @@ const selection = useFileSelection({ multiple: true })
// Upload composable
const upload = useFileUpload({
providerId: activeProviderId.value,
serviceId: activeServiceId.value,
providerId: activeProviderId,
serviceId: activeServiceId,
})
// Keep upload collection in sync with current location
@@ -54,6 +63,12 @@ watch(() => fileManager.currentLocation.value, (newLocation) => {
upload.setCollection(newLocation)
})
watch(() => fileManager.error.value, (message) => {
if (message) {
notify(message, 'error')
}
})
// View state
const viewMode = ref<ViewMode>('grid')
const sortField = ref<SortField>('label')
@@ -72,10 +87,80 @@ const nodesToDelete = ref<(CollectionObject | EntityObject)[]>([])
// Drag and drop state
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
const viewerEntity = ref<EntityObject | null>(null)
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) {
if (item instanceof EntityObject) {
viewerEntity.value = item
@@ -91,6 +176,13 @@ function handleViewerNavigate(entity: EntityObject) {
viewerEntity.value = entity
}
function handleEditItem(item: CollectionObject | EntityObject) {
if (item instanceof EntityObject && fileEditorComposable.canEdit(item)) {
editorEntity.value = item
showEditor.value = true
}
}
// Hidden file inputs
const fileInputRef = 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
)
const hasSelection = computed(() => selection.hasSelection.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
async function handleBreadcrumbNavigate(item: BreadcrumbItem) {
selection.clear()
@@ -251,8 +410,10 @@ async function handleCreateFolder(name: string) {
try {
await fileManager.createFolder(name)
showNewFolderDialog.value = false
notify(`Folder "${name}" created`, 'success')
} catch (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) {
if (!nodeToRename.value) return
const currentName = renameCurrentName(nodeToRename.value)
try {
await fileManager.renameNode(nodeId(nodeToRename.value), newName)
showRenameDialog.value = false
nodeToRename.value = null
notify(`Renamed "${currentName}" to "${newName}"`, 'success')
} catch (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() {
const deletedCount = nodesToDelete.value.length
try {
for (const node of nodesToDelete.value) {
await fileManager.deleteNode(nodeId(node))
@@ -288,8 +454,13 @@ async function handleDelete() {
selection.clear()
showDeleteDialog.value = false
nodesToDelete.value = []
notify(
deletedCount === 1 ? 'Item deleted' : `${deletedCount} items deleted`,
'success'
)
} catch (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) {
// Download single file
fileManager.downloadEntity(nodeId(item), String(item.collection ?? fileManager.ROOT_ID))
notify(`Download started for "${nodeLabel(item)}"`, 'info')
} else if (item instanceof CollectionObject) {
// Download folder as ZIP
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) {
upload.addFiles(input.files)
showUploadDialog.value = true
notify(
input.files.length === 1 ? '1 file added to uploads' : `${input.files.length} files added to uploads`,
'info'
)
input.value = ''
}
}
function handleFolderSelect(event: Event) {
async function handleFolderSelect(event: Event) {
const input = event.target as HTMLInputElement
if (input.files && input.files.length > 0) {
upload.addFilesWithPaths(input.files)
showUploadDialog.value = true
await queueFolderUploads(
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 = ''
}
}
@@ -352,18 +534,38 @@ async function handleDrop(event: DragEvent) {
if (items && items.length > 0) {
const entries: FileSystemEntry[] = []
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) {
entries.push(entry)
}
}
if (entries.length > 0) {
const filesWithPaths = await readEntriesRecursively(entries)
if (filesWithPaths.length > 0) {
upload.addFilesWithPaths(filesWithPaths)
showUploadDialog.value = true
showUploadDialog.value = true
beginUploadPreparation('Scanning dropped folder...')
await nextTick()
try {
const filesWithPaths = await readEntriesRecursively(entries)
if (filesWithPaths.length > 0) {
await queueFolderUploads(
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
}
}
@@ -371,6 +573,10 @@ async function handleDrop(event: DragEvent) {
if (event.dataTransfer.files && event.dataTransfer.files.length > 0) {
upload.addFiles(event.dataTransfer.files)
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
async function handleUploadAll() {
await upload.uploadAll()
await fileManager.refresh()
try {
await upload.uploadAll()
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() {
showUploadDialog.value = false
finishUploadPreparation()
upload.clearAll()
}
// Refresh
async function handleRefresh() {
await fileManager.refresh()
try {
await fileManager.refresh()
} catch (error) {
console.error('Failed to refresh files:', error)
notify(getErrorMessage(error, 'Failed to refresh files'), 'error')
}
}
// Initialize
onMounted(async () => {
try {
await fileManager.initialize()
await servicesStore.list()
syncActiveService(availableServices.value)
await fileManager.refresh()
console.log('[Files] - Initialized with:', {
provider: activeProviderId.value,
@@ -441,12 +661,32 @@ onMounted(async () => {
})
} catch (error) {
console.error('[Files] - Failed to initialize:', error)
notify(getErrorMessage(error, 'Failed to initialize file manager'), 'error')
}
})
</script>
<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 -->
<FilesToolbar
v-model:search-query="searchQuery"
@@ -472,91 +712,96 @@ onMounted(async () => {
<!-- Sidebar -->
<FilesSidebar
v-model="sidebarVisible"
:active-provider-id="activeProviderId"
:active-service-id="activeServiceId"
:services="availableServices"
@navigate-home="fileManager.navigateToRoot()"
@select-service="handleServiceSelect"
/>
<!-- Main Area -->
<div class="files-main">
<!-- Breadcrumbs -->
<FilesBreadcrumbs
:items="breadcrumbs"
@navigate="handleBreadcrumbNavigate"
/>
<div class="files-workspace">
<div class="files-browser-panel">
<!-- Breadcrumbs -->
<FilesBreadcrumbs
:items="breadcrumbs"
@navigate="handleBreadcrumbNavigate"
/>
<!-- Loading -->
<v-progress-linear
v-if="fileManager.isLoading.value"
indeterminate
color="primary"
class="mx-4"
/>
<!-- Loading -->
<v-progress-linear
v-if="fileManager.isLoading.value"
indeterminate
color="primary"
class="mx-4"
/>
<!-- Error -->
<v-alert
v-if="fileManager.error.value"
type="error"
variant="tonal"
class="mx-4 mb-4"
>
{{ fileManager.error.value }}
</v-alert>
<div class="files-view-panel">
<!-- Empty state -->
<FilesEmptyState v-if="!hasItems && !fileManager.isLoading.value" />
<!-- 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>
<!-- Grid View -->
<FilesGridView
v-else-if="viewMode === 'grid'"
:collections="sortedItems.collections"
:entities="sortedItems.entities"
:selected-ids="selectedIds"
@item-click="handleItemClick"
@open="handleOpenItem"
@edit="handleEditItem"
@rename="handleRenameItem"
@delete="handleDeleteItem"
@download="handleDownloadItem"
@show-details="handleShowDetails"
/>
<!-- Empty state -->
<FilesEmptyState v-else-if="!hasItems && !fileManager.isLoading.value" />
<!-- List View -->
<FilesListView
v-else-if="viewMode === 'list'"
:collections="sortedItems.collections"
:entities="sortedItems.entities"
:selected-ids="selectedIds"
@item-click="handleItemClick"
@open="handleOpenItem"
@edit="handleEditItem"
@rename="handleRenameItem"
@delete="handleDeleteItem"
@download="handleDownloadItem"
@show-details="handleShowDetails"
/>
<!-- Grid View -->
<FilesGridView
v-else-if="viewMode === 'grid'"
:collections="sortedItems.collections"
:entities="sortedItems.entities"
:selected-ids="selectedIds"
@item-click="handleItemClick"
@open="handleOpenItem"
@rename="handleRenameItem"
@delete="handleDeleteItem"
@download="handleDownloadItem"
@show-details="handleShowDetails"
/>
<!-- Details View -->
<FilesDetailsView
v-else-if="viewMode === 'details'"
:collections="sortedItems.collections"
:entities="sortedItems.entities"
:selected-ids="selectedIds"
@item-click="handleItemClick"
@open="handleOpenItem"
@edit="handleEditItem"
@rename="handleRenameItem"
@delete="handleDeleteItem"
@download="handleDownloadItem"
@show-details="handleShowDetails"
/>
</div>
</div>
<!-- List View -->
<FilesListView
v-else-if="viewMode === 'list'"
:collections="sortedItems.collections"
:entities="sortedItems.entities"
:selected-ids="selectedIds"
@item-click="handleItemClick"
@open="handleOpenItem"
@rename="handleRenameItem"
@delete="handleDeleteItem"
@download="handleDownloadItem"
@show-details="handleShowDetails"
/>
<!-- Details View -->
<FilesDetailsView
v-else-if="viewMode === 'details'"
:collections="sortedItems.collections"
:entities="sortedItems.entities"
:selected-ids="selectedIds"
@item-click="handleItemClick"
@open="handleOpenItem"
@rename="handleRenameItem"
@delete="handleDeleteItem"
@download="handleDownloadItem"
@show-details="handleShowDetails"
/>
<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 -->
@@ -598,6 +843,10 @@ onMounted(async () => {
:uploads="upload.uploads.value"
:total-progress="upload.totalProgress.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"
:completed-count="upload.completedUploads.value.length"
@upload-all="handleUploadAll"
@@ -607,12 +856,6 @@ onMounted(async () => {
@close="handleUploadDialogClose"
/>
<!-- Info Panel (right side) -->
<FilesInfoPanel
:selected-items="selection.selectedNodeArray.value"
@close="selection.clear()"
/>
<!-- File Viewer -->
<FileViewerDialog
v-model="showViewer"
@@ -622,10 +865,46 @@ onMounted(async () => {
:download-entity="fileManager.downloadEntity"
@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>
</template>
<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 {
display: flex;
flex-direction: column;
@@ -642,8 +921,35 @@ onMounted(async () => {
.files-main {
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;
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>