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

@@ -12,6 +12,7 @@ defineProps<{
const emit = defineEmits<{
'open': [item: CollectionObject | EntityObject]
'edit': [item: CollectionObject | EntityObject]
'rename': [item: CollectionObject | EntityObject]
'delete': [item: CollectionObject | EntityObject]
'download': [item: CollectionObject | EntityObject]
@@ -26,6 +27,8 @@ function handleAction(action: string, item: CollectionObject | EntityObject, eve
if (action === 'open') {
emit('open', item)
} else if (action === 'edit') {
emit('edit', item)
} else if (action === 'rename') {
emit('rename', item)
} 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>
<v-list-item-title>Open</v-list-item-title>
</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)">
<template #prepend><v-icon size="small">mdi-information-outline</v-icon></template>
<v-list-item-title>Details</v-list-item-title>

View File

@@ -1,17 +1,37 @@
<script setup lang="ts">
import { computed } from 'vue'
import { VNavigationDrawer } from 'vuetify/components'
import { CollectionObject } from '@DocumentsManager/models/collection'
import { EntityObject } from '@DocumentsManager/models/entity'
import { getFileIcon, formatSize, formatDate } from '@/utils/fileHelpers'
const props = defineProps<{
const props = withDefaults(defineProps<{
selectedItems: (CollectionObject | EntityObject)[]
}>()
embedded?: boolean
}>(), {
embedded: false,
})
const emit = defineEmits<{
'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 singleSelection = computed(() => props.selectedItems.length === 1)
const selectedItem = computed(() => props.selectedItems[0] ?? null)
@@ -52,13 +72,12 @@ const totalSize = computed(() => {
</script>
<template>
<v-navigation-drawer
:model-value="hasSelection"
:key="selectedItems.map(i => String(i.identifier)).join(',')"
location="right"
width="320"
temporary
<component
:is="panelComponent"
v-if="!embedded || hasSelection"
v-bind="panelProps"
class="files-info-panel"
:class="{ 'files-info-panel--embedded': embedded }"
>
<div class="pa-4">
<!-- Header -->
@@ -172,11 +191,18 @@ const totalSize = computed(() => {
</v-list>
</template>
</div>
</v-navigation-drawer>
</component>
</template>
<style scoped>
.files-info-panel {
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>

View File

@@ -1,16 +1,22 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useDisplay } from 'vuetify'
import type { ServiceObject } from '@DocumentsManager/models/service'
const display = useDisplay()
const isMobile = computed(() => display.mdAndDown.value)
defineProps<{
modelValue: boolean
activeProviderId: string
activeServiceId: string
services: ServiceObject[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'navigate-home': []
'select-service': [service: ServiceObject]
}>()
</script>
@@ -18,9 +24,9 @@ const emit = defineEmits<{
<v-navigation-drawer
:model-value="modelValue"
@update:model-value="emit('update:modelValue', $event)"
:permanent="display.mdAndUp.value"
:temporary="display.smAndDown.value"
width="240"
:permanent="!isMobile"
:temporary="isMobile"
width="280"
class="files-sidebar"
>
<div class="pa-4">
@@ -53,11 +59,18 @@ const emit = defineEmits<{
<v-list density="compact" nav>
<v-list-subheader>Storage</v-list-subheader>
<v-list-item
v-for="service in services"
:key="`${service.provider}:${String(service.identifier ?? '')}`"
prepend-icon="mdi-harddisk"
title="Personal"
subtitle="Local storage"
:active="activeServiceId === 'personal'"
:title="service.label || String(service.identifier ?? 'Unnamed Service')"
:subtitle="service.provider"
: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>
</div>
</v-navigation-drawer>
@@ -68,4 +81,8 @@ const emit = defineEmits<{
border-right: 1px solid rgb(var(--v-border-color)) !important;
overflow-y: auto;
}
.files-sidebar-empty {
line-height: 1.4;
}
</style>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useDisplay } from 'vuetify'
import type { ViewMode } from '@/types'
const props = defineProps<{
@@ -18,6 +19,9 @@ const emit = defineEmits<{
'new-folder': []
}>()
const display = useDisplay()
const isMobile = computed(() => display.mdAndDown.value)
const searchModel = computed({
get: () => props.searchQuery,
set: (value: string) => emit('update:searchQuery', value)
@@ -30,19 +34,15 @@ const viewModeModel = computed({
</script>
<template>
<v-app-bar elevation="0" class="files-toolbar border-b">
<v-app-bar elevation="0" density="compact" class="files-toolbar">
<template #prepend>
<v-btn
icon="mdi-menu"
variant="text"
<v-app-bar-nav-icon
v-if="isMobile"
@click="emit('toggle-sidebar')"
/>
</template>
<v-app-bar-title class="d-flex align-center">
<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-app-bar-title>Files</v-app-bar-title>
<v-spacer />
@@ -55,8 +55,7 @@ const viewModeModel = computed({
prepend-inner-icon="mdi-magnify"
hide-details
single-line
class="mx-4"
style="max-width: 300px;"
class="files-toolbar-search mx-4"
/>
<!-- View toggle -->
@@ -131,7 +130,14 @@ const viewModeModel = computed({
border-bottom: 1px solid rgb(var(--v-border-color)) !important;
}
.border-b {
border-bottom: 1px solid rgb(var(--v-border-color));
@media (max-width: 960px) {
.files-toolbar-search {
max-width: 180px;
margin-inline: 8px !important;
}
}
.files-toolbar-search {
max-width: 300px;
}
</style>

View File

@@ -1,12 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { FileUploadProgress } from '@/composables/useFileUpload'
import { formatSize, getUploadStatusIcon, getUploadStatusColor } from '@/utils/fileHelpers'
defineProps<{
const props = defineProps<{
modelValue: boolean
uploads: Map<string, FileUploadProgress>
totalProgress: number
isUploading: boolean
isPreparing: boolean
preparingMessage: string
preparingProcessedCount: number
preparingTotalCount: number
pendingCount: number
completedCount: number
}>()
@@ -23,6 +28,23 @@ const emit = defineEmits<{
function handleClose() {
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>
<template>
@@ -33,6 +55,31 @@ function handleClose() {
Upload Files
</v-card-title>
<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 -->
<div v-if="totalProgress > 0 && isUploading" class="mb-4">
<v-progress-linear
@@ -46,47 +93,66 @@ function handleClose() {
</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 -->
<v-list density="compact" class="upload-file-list">
<v-list-item
v-for="[id, item] in uploads"
:key="id"
class="px-0"
>
<template #prepend>
<v-icon :color="getUploadStatusColor(item.status)" size="small">
{{ getUploadStatusIcon(item.status) }}
</v-icon>
</template>
<v-list-item-title class="text-body-2">
{{ item.relativePath || item.file.name }}
</v-list-item-title>
<v-list-item-subtitle>
{{ formatSize(item.file.size) }}
<span v-if="item.error" class="text-error"> {{ item.error }}</span>
</v-list-item-subtitle>
<template #append>
<v-btn
v-if="item.status === 'pending' || item.status === 'error'"
icon="mdi-close"
size="x-small"
variant="text"
@click="emit('remove-upload', id)"
/>
<v-btn
v-if="item.status === 'error'"
icon="mdi-refresh"
size="x-small"
variant="text"
color="primary"
@click="emit('retry-upload', id)"
/>
</template>
</v-list-item>
</v-list>
<v-virtual-scroll
v-if="queuedCount > 0"
:items="uploadEntries"
:height="listHeight"
: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>
<v-icon :color="getUploadStatusColor(entry.item.status)" size="small">
{{ getUploadStatusIcon(entry.item.status) }}
</v-icon>
</template>
<v-list-item-title class="text-body-2">
{{ entry.item.relativePath || entry.item.file.name }}
</v-list-item-title>
<v-list-item-subtitle>
{{ formatSize(entry.item.file.size) }}
<span v-if="entry.item.error" class="text-error"> {{ entry.item.error }}</span>
</v-list-item-subtitle>
<template #append>
<v-btn
v-if="entry.item.status === 'pending' || entry.item.status === 'error'"
icon="mdi-close"
size="x-small"
variant="text"
@click="emit('remove-upload', entry.id)"
/>
<v-btn
v-if="entry.item.status === 'error'"
icon="mdi-refresh"
size="x-small"
variant="text"
color="primary"
@click="emit('retry-upload', entry.id)"
/>
</template>
</v-list-item>
</template>
</v-virtual-scroll>
<!-- 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>
<div class="mt-2">No files selected</div>
<v-btn
@@ -101,7 +167,7 @@ function handleClose() {
</div>
<!-- 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
variant="text"
size="small"
@@ -117,7 +183,7 @@ function handleClose() {
<v-btn
variant="text"
@click="handleClose"
:disabled="isUploading"
:disabled="isUploading || isPreparing"
>
{{ completedCount > 0 ? 'Done' : 'Cancel' }}
</v-btn>
@@ -127,6 +193,7 @@ function handleClose() {
variant="elevated"
@click="emit('upload-all')"
:loading="isUploading"
:disabled="isPreparing"
>
Upload {{ pendingCount }} File(s)
</v-btn>
@@ -136,8 +203,25 @@ function handleClose() {
</template>
<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 {
max-height: 300px;
overflow-y: auto;
}
.upload-file-row {
border-bottom: 1px solid rgb(var(--v-border-color), 0.45);
}
</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
export * from './viewer'
// Editor
export * from './editor'

View File

@@ -19,6 +19,7 @@ const props = defineProps<{
const emit = defineEmits<{
'item-click': [item: CollectionObject | EntityObject, event: MouseEvent | KeyboardEvent]
'open': [item: CollectionObject | EntityObject]
'edit': [item: CollectionObject | EntityObject]
'rename': [item: CollectionObject | EntityObject]
'delete': [item: CollectionObject | EntityObject]
'download': [item: CollectionObject | EntityObject]
@@ -70,6 +71,7 @@ function isCollection(wrapped: ItemWithType): wrapped is { item: CollectionObjec
<FileActionsMenu
:item="wrapped.item"
@open="emit('open', $event)"
@edit="emit('edit', $event)"
@rename="emit('rename', $event)"
@delete="emit('delete', $event)"
@download="emit('download', $event)"
@@ -96,6 +98,7 @@ function isCollection(wrapped: ItemWithType): wrapped is { item: CollectionObjec
<FileActionsMenu
:item="wrapped.item"
@open="emit('open', $event)"
@edit="emit('edit', $event)"
@rename="emit('rename', $event)"
@delete="emit('delete', $event)"
@download="emit('download', $event)"

View File

@@ -13,6 +13,7 @@ defineProps<{
const emit = defineEmits<{
'item-click': [item: CollectionObject | EntityObject, event: MouseEvent | KeyboardEvent]
'open': [item: CollectionObject | EntityObject]
'edit': [item: CollectionObject | EntityObject]
'rename': [item: CollectionObject | EntityObject]
'delete': [item: CollectionObject | EntityObject]
'download': [item: CollectionObject | EntityObject]
@@ -36,6 +37,7 @@ const emit = defineEmits<{
size="x-small"
button-class="action-btn"
@open="emit('open', $event)"
@edit="emit('edit', $event)"
@rename="emit('rename', $event)"
@delete="emit('delete', $event)"
@download="emit('download', $event)"
@@ -61,6 +63,7 @@ const emit = defineEmits<{
size="x-small"
button-class="action-btn"
@open="emit('open', $event)"
@edit="emit('edit', $event)"
@rename="emit('rename', $event)"
@delete="emit('delete', $event)"
@download="emit('download', $event)"

View File

@@ -19,6 +19,7 @@ const props = defineProps<{
const emit = defineEmits<{
'item-click': [item: CollectionObject | EntityObject, event: MouseEvent | KeyboardEvent]
'open': [item: CollectionObject | EntityObject]
'edit': [item: CollectionObject | EntityObject]
'rename': [item: CollectionObject | EntityObject]
'delete': [item: CollectionObject | EntityObject]
'download': [item: CollectionObject | EntityObject]
@@ -57,6 +58,7 @@ function isCollection(wrapped: ItemWithType): wrapped is { item: CollectionObjec
<FileActionsMenu
:item="wrapped.item"
@open="emit('open', $event)"
@edit="emit('edit', $event)"
@rename="emit('rename', $event)"
@delete="emit('delete', $event)"
@download="emit('download', $event)"
@@ -81,6 +83,7 @@ function isCollection(wrapped: ItemWithType): wrapped is { item: CollectionObjec
<FileActionsMenu
:item="wrapped.item"
@open="emit('open', $event)"
@edit="emit('edit', $event)"
@rename="emit('rename', $event)"
@delete="emit('delete', $event)"
@download="emit('download', $event)"