Compare commits
35 Commits
226dd34153
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 72cb2b8d02 | |||
| a0c50bded9 | |||
| 09ada28f09 | |||
| ea28911822 | |||
| 0a88c7f14a | |||
| 1d7e363076 | |||
| 7f94b97a8d | |||
| fe5d4fae37 | |||
| 01610727fd | |||
| 6942469259 | |||
| f73e53bc65 | |||
| 66635a73ff | |||
| ea4c052981 | |||
| 47d97a8d1a | |||
| 5b12979c39 | |||
| 48f922e7ad | |||
| fe5a2c876a | |||
| eef3a76916 | |||
| 41d0f5a0f9 | |||
| ce35df5f31 | |||
| 00af47f453 | |||
| fba880e1bd | |||
| 52d865f5b0 | |||
| c8b29539cf | |||
| d88b6d45e5 | |||
| 42f5cfb084 | |||
| 4a34cb5d9d | |||
| 71fefcb056 | |||
| dd2267089b | |||
| 59546ae163 | |||
| f14de07061 | |||
| cf0d90ffa7 | |||
| c5a6461f5c | |||
| 0ceb6808c4 | |||
| 20df40cd34 |
+7
-12
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
namespace KTXM\Documents;
|
namespace KTXM\Documents;
|
||||||
|
|
||||||
use KTXF\Module\ModuleBrowserInterface;
|
use KTXF\Module\Configuration\BrowserModuleContextInterface;
|
||||||
|
use KTXF\Module\Configuration\ModuleContextInterface;
|
||||||
use KTXF\Module\ModuleInstanceAbstract;
|
use KTXF\Module\ModuleInstanceAbstract;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Documents Module - Document Browser UI
|
* Documents Module - Document Browser UI
|
||||||
*/
|
*/
|
||||||
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
class Module extends ModuleInstanceAbstract
|
||||||
{
|
{
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
@@ -50,16 +51,10 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function registerBI(): array
|
public function configure(ModuleContextInterface $context): void
|
||||||
{
|
{
|
||||||
return [
|
if ($context instanceof BrowserModuleContextInterface) {
|
||||||
'handle' => $this->handle(),
|
$context->registerModule($this, 'Documents', 'static/module.mjs');
|
||||||
'namespace' => 'Documents',
|
}
|
||||||
'version' => $this->version(),
|
|
||||||
'label' => $this->label(),
|
|
||||||
'author' => $this->author(),
|
|
||||||
'description' => $this->description(),
|
|
||||||
'boot' => 'static/module.mjs',
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+452
-1055
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -28,9 +28,9 @@
|
|||||||
"typescript": "~6.0.0",
|
"typescript": "~6.0.0",
|
||||||
"vite": "^8.0.0",
|
"vite": "^8.0.0",
|
||||||
"vue-tsc": "^3.0.5",
|
"vue-tsc": "^3.0.5",
|
||||||
"@vitest/coverage-v8": "^4.1.6",
|
"@vitest/coverage-v8": "^5.0.0",
|
||||||
"@vue/test-utils": "^2.4.10",
|
"@vue/test-utils": "^2.4.10",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^30.0.0",
|
||||||
"vitest": "^4.1.6"
|
"vitest": "^5.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { EntityObject } from '@DocumentsManager/models/entity'
|
||||||
|
import { getFileIcon } from '@/utils/fileHelpers'
|
||||||
|
|
||||||
|
type PreviewSource = (item: { id: string; meta?: Record<string, unknown> }) => string | Blob | Promise<string | Blob>
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
entity: EntityObject
|
||||||
|
source?: PreviewSource
|
||||||
|
size?: number
|
||||||
|
}>(), {
|
||||||
|
source: undefined,
|
||||||
|
size: 32,
|
||||||
|
})
|
||||||
|
|
||||||
|
const failed = ref(false)
|
||||||
|
const url = computed(() => {
|
||||||
|
if (!props.source) return null
|
||||||
|
const result = props.source({
|
||||||
|
id: String(props.entity.identifier ?? ''),
|
||||||
|
meta: { collection: props.entity.collection ? String(props.entity.collection) : null },
|
||||||
|
})
|
||||||
|
return typeof result === 'string' ? result : null
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(url, () => {
|
||||||
|
failed.value = false
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="file-preview"
|
||||||
|
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||||
|
>
|
||||||
|
<v-img
|
||||||
|
v-if="url && !failed"
|
||||||
|
:src="url"
|
||||||
|
:alt="entity.properties.label || 'File preview'"
|
||||||
|
contain
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
@error="failed = true"
|
||||||
|
/>
|
||||||
|
<v-icon v-else :size="size * 0.75" color="grey">{{ getFileIcon(entity) }}</v-icon>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.file-preview {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: rgb(var(--v-theme-surface-variant), 0.4);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { VNavigationDrawer } from 'vuetify/components'
|
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'
|
||||||
@@ -8,8 +8,10 @@ import { getFileIcon, formatSize, formatDate } from '@/utils/fileHelpers'
|
|||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
selectedItems: (CollectionObject | EntityObject)[]
|
selectedItems: (CollectionObject | EntityObject)[]
|
||||||
embedded?: boolean
|
embedded?: boolean
|
||||||
|
previewUrl?: string | null
|
||||||
}>(), {
|
}>(), {
|
||||||
embedded: false,
|
embedded: false,
|
||||||
|
previewUrl: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -69,6 +71,13 @@ const totalSize = computed(() => {
|
|||||||
return sum
|
return sum
|
||||||
}, 0)
|
}, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const previewFailed = ref(false)
|
||||||
|
const showPreview = computed(() => isEntity.value && !!props.previewUrl && !previewFailed.value)
|
||||||
|
|
||||||
|
watch(() => props.previewUrl, () => {
|
||||||
|
previewFailed.value = false
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -94,9 +103,24 @@ const totalSize = computed(() => {
|
|||||||
|
|
||||||
<!-- Single item selected -->
|
<!-- Single item selected -->
|
||||||
<template v-if="singleSelection && selectedItem">
|
<template v-if="singleSelection && selectedItem">
|
||||||
<!-- Icon and name -->
|
<!-- Preview/icon and name -->
|
||||||
<div class="text-center mb-4">
|
<div class="text-center mb-4">
|
||||||
<v-icon :color="itemIconColor" size="64">{{ itemIcon }}</v-icon>
|
<div v-if="showPreview" class="files-info-preview mb-3">
|
||||||
|
<v-img
|
||||||
|
:src="previewUrl!"
|
||||||
|
:alt="selectedItem.properties.label || 'File preview'"
|
||||||
|
aspect-ratio="1.45"
|
||||||
|
contain
|
||||||
|
@error="previewFailed = true"
|
||||||
|
>
|
||||||
|
<template #placeholder>
|
||||||
|
<div class="d-flex fill-height align-center justify-center">
|
||||||
|
<v-progress-circular indeterminate color="primary" size="28" width="2" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-img>
|
||||||
|
</div>
|
||||||
|
<v-icon v-else :color="itemIconColor" size="64">{{ itemIcon }}</v-icon>
|
||||||
<div class="text-h6 mt-2 text-truncate">{{ selectedItem.properties.label || String(selectedItem.identifier ?? '') }}</div>
|
<div class="text-h6 mt-2 text-truncate">{{ selectedItem.properties.label || String(selectedItem.identifier ?? '') }}</div>
|
||||||
<div class="text-caption text-grey">
|
<div class="text-caption text-grey">
|
||||||
{{ isCollection ? 'Folder' : entity?.properties.mime || 'File' }}
|
{{ isCollection ? 'Folder' : entity?.properties.mime || 'File' }}
|
||||||
@@ -205,4 +229,11 @@ const totalSize = computed(() => {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background-color: rgb(var(--v-theme-surface));
|
background-color: rgb(var(--v-theme-surface));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.files-info-preview {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgb(var(--v-border-color));
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: rgb(var(--v-theme-surface-variant));
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, nextTick, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
hasMore: boolean
|
||||||
|
loading: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
intersect: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const sentinelEl = ref<HTMLElement | null>(null)
|
||||||
|
let observer: IntersectionObserver | null = null
|
||||||
|
let observerGeneration = 0
|
||||||
|
|
||||||
|
function destroyObserver() {
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect()
|
||||||
|
observer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createObserver() {
|
||||||
|
const generation = ++observerGeneration
|
||||||
|
destroyObserver()
|
||||||
|
|
||||||
|
// The sentinel is conditionally rendered from `hasMore`. Wait until Vue has
|
||||||
|
// committed that DOM update before reading the template ref.
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
if (generation !== observerGeneration || !sentinelEl.value || !props.hasMore) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// root: null (viewport) still resolves correctly through clipping/scrolling
|
||||||
|
// ancestors (including Vuetify's internal v-virtual-scroll container), per the
|
||||||
|
// IntersectionObserver spec — no need to locate that container explicitly.
|
||||||
|
observer = new IntersectionObserver((entries) => {
|
||||||
|
if (entries.some((entry) => entry.isIntersecting) && props.hasMore && !props.loading) {
|
||||||
|
emit('intersect')
|
||||||
|
}
|
||||||
|
}, { root: null, rootMargin: '200px' })
|
||||||
|
|
||||||
|
observer.observe(sentinelEl.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void createObserver()
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
observerGeneration++
|
||||||
|
destroyObserver()
|
||||||
|
})
|
||||||
|
watch(
|
||||||
|
[() => props.hasMore, () => props.loading],
|
||||||
|
() => {
|
||||||
|
void createObserver()
|
||||||
|
},
|
||||||
|
{ flush: 'post' },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="hasMore" ref="sentinelEl" class="files-load-more-sentinel">
|
||||||
|
<v-progress-circular v-if="loading" indeterminate size="20" width="2" color="primary" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.files-load-more-sentinel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,14 +2,15 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
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 { formatSize, formatDate } from '@/utils/fileHelpers'
|
||||||
import FileActionsMenu from '@/components/FileActionsMenu.vue'
|
import FileActionsMenu from '@/components/FileActionsMenu.vue'
|
||||||
|
import FilePreview from '@/components/FilePreview.vue'
|
||||||
import FilePreviewPopover from '@/components/viewer/FilePreviewPopover.vue'
|
import FilePreviewPopover from '@/components/viewer/FilePreviewPopover.vue'
|
||||||
|
import FilesLoadMoreSentinel from '@/components/FilesLoadMoreSentinel.vue'
|
||||||
|
|
||||||
type ItemWithType = {
|
type ItemWithType =
|
||||||
item: CollectionObject | EntityObject
|
| { item: CollectionObject | EntityObject; type: 'collection' | 'entity' }
|
||||||
type: 'collection' | 'entity'
|
| { item: null; type: 'sentinel' }
|
||||||
}
|
|
||||||
|
|
||||||
type PreviewSource = (item: { id: string; meta?: Record<string, unknown> }) => string | Blob | Promise<string | Blob>
|
type PreviewSource = (item: { id: string; meta?: Record<string, unknown> }) => string | Blob | Promise<string | Blob>
|
||||||
|
|
||||||
@@ -17,7 +18,10 @@ const props = defineProps<{
|
|||||||
collections: CollectionObject[]
|
collections: CollectionObject[]
|
||||||
entities: EntityObject[]
|
entities: EntityObject[]
|
||||||
selectedIds: Set<string>
|
selectedIds: Set<string>
|
||||||
|
hasMore?: boolean
|
||||||
|
isLoadingMore?: boolean
|
||||||
previewSource?: PreviewSource
|
previewSource?: PreviewSource
|
||||||
|
thumbnailSource?: PreviewSource
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -28,17 +32,25 @@ const emit = defineEmits<{
|
|||||||
'delete': [item: CollectionObject | EntityObject]
|
'delete': [item: CollectionObject | EntityObject]
|
||||||
'download': [item: CollectionObject | EntityObject]
|
'download': [item: CollectionObject | EntityObject]
|
||||||
'show-details': [item: CollectionObject | EntityObject]
|
'show-details': [item: CollectionObject | EntityObject]
|
||||||
|
'load-more': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Combine collections and entities into a single list for virtual scrolling
|
// Combine collections and entities into a single list for virtual scrolling; a
|
||||||
|
// trailing sentinel row (rendered only once virtual-scroll actually mounts it)
|
||||||
|
// drives the infinite-scroll "load more" trigger.
|
||||||
const allItems = computed<ItemWithType[]>(() => [
|
const allItems = computed<ItemWithType[]>(() => [
|
||||||
...props.collections.map(c => ({ item: c, type: 'collection' as const })),
|
...props.collections.map(c => ({ item: c, type: 'collection' as const })),
|
||||||
...props.entities.map(e => ({ item: e, type: 'entity' as const }))
|
...props.entities.map(e => ({ item: e, type: 'entity' as const })),
|
||||||
|
...(props.hasMore ? [{ item: null, type: 'sentinel' as const }] : []),
|
||||||
])
|
])
|
||||||
|
|
||||||
function isCollection(wrapped: ItemWithType): wrapped is { item: CollectionObject; type: 'collection' } {
|
function isCollection(wrapped: ItemWithType): wrapped is { item: CollectionObject; type: 'collection' } {
|
||||||
return wrapped.type === 'collection'
|
return wrapped.type === 'collection'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSentinel(wrapped: ItemWithType): wrapped is { item: null; type: 'sentinel' } {
|
||||||
|
return wrapped.type === 'sentinel'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -58,9 +70,17 @@ function isCollection(wrapped: ItemWithType): wrapped is { item: CollectionObjec
|
|||||||
class="files-details-body"
|
class="files-details-body"
|
||||||
>
|
>
|
||||||
<template #default="{ item: wrapped }">
|
<template #default="{ item: wrapped }">
|
||||||
|
<!-- Load-more sentinel -->
|
||||||
|
<FilesLoadMoreSentinel
|
||||||
|
v-if="isSentinel(wrapped)"
|
||||||
|
:has-more="!!hasMore"
|
||||||
|
:loading="!!isLoadingMore"
|
||||||
|
@intersect="emit('load-more')"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Folder row -->
|
<!-- Folder row -->
|
||||||
<div
|
<div
|
||||||
v-if="isCollection(wrapped)"
|
v-else-if="isCollection(wrapped)"
|
||||||
class="files-details-row"
|
class="files-details-row"
|
||||||
:class="{ 'files-details-row--selected': selectedIds.has(String(wrapped.item.identifier)) }"
|
:class="{ 'files-details-row--selected': selectedIds.has(String(wrapped.item.identifier)) }"
|
||||||
@click="emit('item-click', wrapped.item, $event)"
|
@click="emit('item-click', wrapped.item, $event)"
|
||||||
@@ -93,7 +113,12 @@ function isCollection(wrapped: ItemWithType): wrapped is { item: CollectionObjec
|
|||||||
@dblclick.stop="emit('open', wrapped.item)"
|
@dblclick.stop="emit('open', wrapped.item)"
|
||||||
>
|
>
|
||||||
<div class="files-details-cell files-details-name">
|
<div class="files-details-cell files-details-name">
|
||||||
<v-icon color="grey" size="small" class="mr-2">{{ getFileIcon(wrapped.item as EntityObject) }}</v-icon>
|
<FilePreview
|
||||||
|
:entity="(wrapped.item as EntityObject)"
|
||||||
|
:source="thumbnailSource"
|
||||||
|
:size="24"
|
||||||
|
class="mr-2"
|
||||||
|
/>
|
||||||
{{ wrapped.item.properties.label || String(wrapped.item.identifier ?? '') }}
|
{{ wrapped.item.properties.label || String(wrapped.item.identifier ?? '') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="files-details-cell files-details-size">{{ formatSize(wrapped.item.properties.size) }}</div>
|
<div class="files-details-cell files-details-size">{{ formatSize(wrapped.item.properties.size) }}</div>
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
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 } from '@/utils/fileHelpers'
|
import { formatSize } from '@/utils/fileHelpers'
|
||||||
import FileActionsMenu from '@/components/FileActionsMenu.vue'
|
import FileActionsMenu from '@/components/FileActionsMenu.vue'
|
||||||
|
import FilePreview from '@/components/FilePreview.vue'
|
||||||
import FilePreviewPopover from '@/components/viewer/FilePreviewPopover.vue'
|
import FilePreviewPopover from '@/components/viewer/FilePreviewPopover.vue'
|
||||||
|
import FilesLoadMoreSentinel from '@/components/FilesLoadMoreSentinel.vue'
|
||||||
|
|
||||||
type PreviewSource = (item: { id: string; meta?: Record<string, unknown> }) => string | Blob | Promise<string | Blob>
|
type PreviewSource = (item: { id: string; meta?: Record<string, unknown> }) => string | Blob | Promise<string | Blob>
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
collections: CollectionObject[]
|
collections: CollectionObject[]
|
||||||
entities: EntityObject[]
|
entities: EntityObject[]
|
||||||
selectedIds: Set<string>
|
selectedIds: Set<string>
|
||||||
|
hasMore?: boolean
|
||||||
|
isLoadingMore?: boolean
|
||||||
previewSource?: PreviewSource
|
previewSource?: PreviewSource
|
||||||
|
thumbnailSource?: PreviewSource
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -22,6 +27,7 @@ const emit = defineEmits<{
|
|||||||
'delete': [item: CollectionObject | EntityObject]
|
'delete': [item: CollectionObject | EntityObject]
|
||||||
'download': [item: CollectionObject | EntityObject]
|
'download': [item: CollectionObject | EntityObject]
|
||||||
'show-details': [item: CollectionObject | EntityObject]
|
'show-details': [item: CollectionObject | EntityObject]
|
||||||
|
'load-more': []
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -74,7 +80,9 @@ const emit = defineEmits<{
|
|||||||
@show-details="emit('show-details', $event)"
|
@show-details="emit('show-details', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<v-icon size="48" color="grey">{{ getFileIcon(entity) }}</v-icon>
|
<div class="files-grid-thumbnail">
|
||||||
|
<FilePreview :entity="entity" :source="thumbnailSource" :size="64" />
|
||||||
|
</div>
|
||||||
<div class="files-grid-label">{{ entity.properties.label || String(entity.identifier ?? '') }}</div>
|
<div class="files-grid-label">{{ entity.properties.label || String(entity.identifier ?? '') }}</div>
|
||||||
<div class="files-grid-size">{{ formatSize(entity.properties.size) }}</div>
|
<div class="files-grid-size">{{ formatSize(entity.properties.size) }}</div>
|
||||||
|
|
||||||
@@ -86,6 +94,12 @@ const emit = defineEmits<{
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FilesLoadMoreSentinel
|
||||||
|
:has-more="!!hasMore"
|
||||||
|
:loading="!!isLoadingMore"
|
||||||
|
@intersect="emit('load-more')"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -136,6 +150,17 @@ const emit = defineEmits<{
|
|||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.files-grid-thumbnail {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: rgb(var(--v-theme-surface-variant), 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
.files-grid-label {
|
.files-grid-label {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
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 } from '@/utils/fileHelpers'
|
import { formatSize } from '@/utils/fileHelpers'
|
||||||
import FileActionsMenu from '@/components/FileActionsMenu.vue'
|
import FileActionsMenu from '@/components/FileActionsMenu.vue'
|
||||||
|
import FilePreview from '@/components/FilePreview.vue'
|
||||||
import FilePreviewPopover from '@/components/viewer/FilePreviewPopover.vue'
|
import FilePreviewPopover from '@/components/viewer/FilePreviewPopover.vue'
|
||||||
|
import FilesLoadMoreSentinel from '@/components/FilesLoadMoreSentinel.vue'
|
||||||
|
|
||||||
type ItemWithType = {
|
type ItemWithType =
|
||||||
item: CollectionObject | EntityObject
|
| { item: CollectionObject | EntityObject; type: 'collection' | 'entity' }
|
||||||
type: 'collection' | 'entity'
|
| { item: null; type: 'sentinel' }
|
||||||
}
|
|
||||||
|
|
||||||
type PreviewSource = (item: { id: string; meta?: Record<string, unknown> }) => string | Blob | Promise<string | Blob>
|
type PreviewSource = (item: { id: string; meta?: Record<string, unknown> }) => string | Blob | Promise<string | Blob>
|
||||||
|
|
||||||
@@ -17,7 +18,10 @@ const props = defineProps<{
|
|||||||
collections: CollectionObject[]
|
collections: CollectionObject[]
|
||||||
entities: EntityObject[]
|
entities: EntityObject[]
|
||||||
selectedIds: Set<string>
|
selectedIds: Set<string>
|
||||||
|
hasMore?: boolean
|
||||||
|
isLoadingMore?: boolean
|
||||||
previewSource?: PreviewSource
|
previewSource?: PreviewSource
|
||||||
|
thumbnailSource?: PreviewSource
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -28,17 +32,25 @@ const emit = defineEmits<{
|
|||||||
'delete': [item: CollectionObject | EntityObject]
|
'delete': [item: CollectionObject | EntityObject]
|
||||||
'download': [item: CollectionObject | EntityObject]
|
'download': [item: CollectionObject | EntityObject]
|
||||||
'show-details': [item: CollectionObject | EntityObject]
|
'show-details': [item: CollectionObject | EntityObject]
|
||||||
|
'load-more': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Combine collections and entities into a single list for virtual scrolling
|
// Combine collections and entities into a single list for virtual scrolling; a
|
||||||
|
// trailing sentinel row (rendered only once virtual-scroll actually mounts it)
|
||||||
|
// drives the infinite-scroll "load more" trigger.
|
||||||
const allItems = computed<ItemWithType[]>(() => [
|
const allItems = computed<ItemWithType[]>(() => [
|
||||||
...props.collections.map(c => ({ item: c, type: 'collection' as const })),
|
...props.collections.map(c => ({ item: c, type: 'collection' as const })),
|
||||||
...props.entities.map(e => ({ item: e, type: 'entity' as const }))
|
...props.entities.map(e => ({ item: e, type: 'entity' as const })),
|
||||||
|
...(props.hasMore ? [{ item: null, type: 'sentinel' as const }] : []),
|
||||||
])
|
])
|
||||||
|
|
||||||
function isCollection(wrapped: ItemWithType): wrapped is { item: CollectionObject; type: 'collection' } {
|
function isCollection(wrapped: ItemWithType): wrapped is { item: CollectionObject; type: 'collection' } {
|
||||||
return wrapped.type === 'collection'
|
return wrapped.type === 'collection'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSentinel(wrapped: ItemWithType): wrapped is { item: null; type: 'sentinel' } {
|
||||||
|
return wrapped.type === 'sentinel'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -48,9 +60,17 @@ function isCollection(wrapped: ItemWithType): wrapped is { item: CollectionObjec
|
|||||||
class="files-list-virtual pa-2"
|
class="files-list-virtual pa-2"
|
||||||
>
|
>
|
||||||
<template #default="{ item: wrapped }">
|
<template #default="{ item: wrapped }">
|
||||||
|
<!-- Load-more sentinel -->
|
||||||
|
<FilesLoadMoreSentinel
|
||||||
|
v-if="isSentinel(wrapped)"
|
||||||
|
:has-more="!!hasMore"
|
||||||
|
:loading="!!isLoadingMore"
|
||||||
|
@intersect="emit('load-more')"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Folder -->
|
<!-- Folder -->
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-if="isCollection(wrapped)"
|
v-else-if="isCollection(wrapped)"
|
||||||
:active="selectedIds.has(String(wrapped.item.identifier))"
|
:active="selectedIds.has(String(wrapped.item.identifier))"
|
||||||
@click="($event: MouseEvent | KeyboardEvent) => emit('item-click', wrapped.item, $event)"
|
@click="($event: MouseEvent | KeyboardEvent) => emit('item-click', wrapped.item, $event)"
|
||||||
>
|
>
|
||||||
@@ -79,7 +99,11 @@ function isCollection(wrapped: ItemWithType): wrapped is { item: CollectionObjec
|
|||||||
@dblclick.stop="emit('open', wrapped.item)"
|
@dblclick.stop="emit('open', wrapped.item)"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-icon color="grey">{{ getFileIcon(wrapped.item as EntityObject) }}</v-icon>
|
<FilePreview
|
||||||
|
:entity="(wrapped.item as EntityObject)"
|
||||||
|
:source="thumbnailSource"
|
||||||
|
:size="32"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<v-list-item-title>{{ wrapped.item.properties.label || String(wrapped.item.identifier ?? '') }}</v-list-item-title>
|
<v-list-item-title>{{ wrapped.item.properties.label || String(wrapped.item.identifier ?? '') }}</v-list-item-title>
|
||||||
<v-list-item-subtitle>{{ formatSize(wrapped.item.properties.size) }}</v-list-item-subtitle>
|
<v-list-item-subtitle>{{ formatSize(wrapped.item.properties.size) }}</v-list-item-subtitle>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* useFileEditor — resolves which registered editor can handle a given MIME type.
|
* useFileEditor — resolves which registered editor can handle a given MIME type.
|
||||||
*
|
*
|
||||||
* Other modules register editors at the `documents_file_editor` integration
|
* Other modules register editors at the `documents_editor` integration
|
||||||
* point by including an entry in their `integrations.ts`:
|
* point by including an entry in their `integrations.ts`:
|
||||||
*
|
*
|
||||||
* ```ts
|
* ```ts
|
||||||
* documents_file_editor: [{
|
* documents_editor: [{
|
||||||
* id: 'my_editor',
|
* id: 'my_editor',
|
||||||
* meta: { mimeTypes: ['text/plain'] },
|
* meta: { mimeTypes: ['text/plain'] },
|
||||||
* component: () => import('./components/MyEditor.vue'),
|
* component: () => import('./components/MyEditor.vue'),
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
import { useIntegrationStore } from '@KTXC'
|
import { useIntegrationStore } from '@KTXC'
|
||||||
import type { EntityObject } from '@DocumentsManager/models/entity'
|
import type { EntityObject } from '@DocumentsManager/models/entity'
|
||||||
|
|
||||||
const INTEGRATION_POINT = 'documents_file_editor'
|
const INTEGRATION_POINT = 'documents_editor'
|
||||||
|
|
||||||
function mimeMatchesPattern(mime: string, pattern: string): boolean {
|
function mimeMatchesPattern(mime: string, pattern: string): boolean {
|
||||||
if (pattern.endsWith('/*')) {
|
if (pattern.endsWith('/*')) {
|
||||||
|
|||||||
@@ -293,9 +293,7 @@ export function useFileUpload(options: UseFileUploadOptions) {
|
|||||||
try {
|
try {
|
||||||
// Create the folder
|
// Create the folder
|
||||||
const collection = await nodesStore.createCollection(
|
const collection = await nodesStore.createCollection(
|
||||||
currentProviderId(),
|
`${currentProviderId()}:${currentServiceId()}:${parentId || ROOT_ID}`,
|
||||||
currentServiceId(),
|
|
||||||
parentId,
|
|
||||||
{ label: folderName, owner: '' }
|
{ label: folderName, owner: '' }
|
||||||
)
|
)
|
||||||
folderIdMap.set(folderPath, String(collection.identifier))
|
folderIdMap.set(folderPath, String(collection.identifier))
|
||||||
@@ -342,9 +340,7 @@ export function useFileUpload(options: UseFileUploadOptions) {
|
|||||||
|
|
||||||
// Create the entity
|
// Create the entity
|
||||||
const entity = await nodesStore.createEntity(
|
const entity = await nodesStore.createEntity(
|
||||||
currentProviderId(),
|
`${currentProviderId()}:${currentServiceId()}:${targetCollection || ROOT_ID}`,
|
||||||
currentServiceId(),
|
|
||||||
targetCollection || ROOT_ID,
|
|
||||||
{
|
{
|
||||||
'@type': 'documents.properties',
|
'@type': 'documents.properties',
|
||||||
urid: null,
|
urid: null,
|
||||||
@@ -360,10 +356,7 @@ export function useFileUpload(options: UseFileUploadOptions) {
|
|||||||
|
|
||||||
// Write the content
|
// Write the content
|
||||||
await nodesStore.writeEntity(
|
await nodesStore.writeEntity(
|
||||||
currentProviderId(),
|
`${entity.provider}:${entity.service}:${entity.collection || ROOT_ID}:${entity.identifier}`,
|
||||||
currentServiceId(),
|
|
||||||
targetCollection || ROOT_ID,
|
|
||||||
String(entity.identifier),
|
|
||||||
content
|
content
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+105
-82
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch, nextTick, defineAsyncComponent } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick, defineAsyncComponent } from 'vue'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { useModuleStore, useIntegrationStore } from '@KTXC'
|
import { useModuleStore, useIntegrationStore, usePreviewStore } from '@KTXC'
|
||||||
import { useDocumentManager, useDocumentSelection, useFileUpload, useFileEditor } from '@/composables'
|
import { useDocumentManager, useDocumentSelection, useFileUpload, useFileEditor } from '@/composables'
|
||||||
import type { ViewMode, SortField, SortOrder, BreadcrumbItem } from '@/types'
|
import type { ViewMode, SortField, SortOrder, BreadcrumbItem } from '@/types'
|
||||||
|
import type { ListFilter, ListSort } from '@DocumentsManager/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 { ServiceObject } from '@DocumentsManager/models/service'
|
||||||
@@ -31,6 +32,8 @@ const isMobile = computed(() => display.mdAndDown.value)
|
|||||||
|
|
||||||
const moduleStore = useModuleStore()
|
const moduleStore = useModuleStore()
|
||||||
const integrationStore = useIntegrationStore()
|
const integrationStore = useIntegrationStore()
|
||||||
|
const previewStore = usePreviewStore()
|
||||||
|
const previewsAvailable = computed(() => previewStore.availability.storage)
|
||||||
const isFileManagerAvailable = computed(() => {
|
const isFileManagerAvailable = computed(() => {
|
||||||
return moduleStore.has('documents_manager') || moduleStore.has('DocumentsManager')
|
return moduleStore.has('documents_manager') || moduleStore.has('DocumentsManager')
|
||||||
})
|
})
|
||||||
@@ -116,22 +119,59 @@ const mediaViewerDialog = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const viewerItems = computed<ViewerItem[]>(() =>
|
const viewerItems = computed<ViewerItem[]>(() =>
|
||||||
sortedItems.value.entities.map(entityToViewerItem),
|
documentManager.currentEntities.value.map(entityToViewerItem),
|
||||||
)
|
)
|
||||||
|
|
||||||
function entityToViewerItem(entity: EntityObject): ViewerItem {
|
function entityToViewerItem(entity: EntityObject): ViewerItem {
|
||||||
return {
|
return {
|
||||||
id: String(entity.identifier ?? ''),
|
id: String(entity.identifier ?? ''),
|
||||||
title: entity.properties.label ?? String(entity.identifier ?? ''),
|
title: entity.properties.label ?? String(entity.identifier ?? ''),
|
||||||
mime: entity.properties.mime ?? '',
|
mime: 'image/webp',
|
||||||
meta: { collection: entity.collection ? String(entity.collection) : null },
|
meta: { collection: entity.collection ? String(entity.collection) : null },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveViewerSource(item: ViewerItem): string {
|
function resolveViewerSource(item: ViewerItem): string | Promise<string> {
|
||||||
return documentManager.getEntityUrl(item.id, (item.meta?.collection as string | null) ?? null)
|
if (!previewsAvailable.value) {
|
||||||
|
return Promise.reject(new Error('Preview storage is unavailable'))
|
||||||
|
}
|
||||||
|
return documentManager.getEntityPreviewUrl(
|
||||||
|
item.id,
|
||||||
|
(item.meta?.collection as string | null) ?? null,
|
||||||
|
'fullscreen',
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvePreviewSource(item: ViewerItem): string {
|
||||||
|
return documentManager.getEntityPreviewUrl(
|
||||||
|
item.id,
|
||||||
|
(item.meta?.collection as string | null) ?? null,
|
||||||
|
'inline',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveThumbnailSource(item: ViewerItem): string {
|
||||||
|
return documentManager.getEntityPreviewUrl(
|
||||||
|
item.id,
|
||||||
|
(item.meta?.collection as string | null) ?? null,
|
||||||
|
'thumbnail',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview shown in the info panel sidebar for the current single selection
|
||||||
|
const infoPanelPreviewUrl = computed<string | null>(() => {
|
||||||
|
if (!previewsAvailable.value) return null
|
||||||
|
const items = selection.selectedNodeArray.value
|
||||||
|
if (items.length !== 1) return null
|
||||||
|
const item = items[0]
|
||||||
|
if (!(item instanceof EntityObject)) return null
|
||||||
|
return documentManager.getEntityPreviewUrl(
|
||||||
|
nodeId(item),
|
||||||
|
item.collection ? String(item.collection) : null,
|
||||||
|
'inline',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
function downloadViewerItem(item: ViewerItem): void {
|
function downloadViewerItem(item: ViewerItem): void {
|
||||||
documentManager.downloadEntity(item.id, (item.meta?.collection as string | null) ?? null)
|
documentManager.downloadEntity(item.id, (item.meta?.collection as string | null) ?? null)
|
||||||
}
|
}
|
||||||
@@ -255,76 +295,45 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => {
|
|||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
const sortedItems = computed(() => {
|
const hasItems = computed(() =>
|
||||||
const collections = [...documentManager.currentCollections.value]
|
documentManager.currentCollections.value.length > 0 || documentManager.currentEntities.value.length > 0
|
||||||
const entities = [...documentManager.currentEntities.value]
|
)
|
||||||
|
|
||||||
// Sort collections
|
// Search/sort are server-driven: build the current filter/sort and reset-load
|
||||||
collections.sort((a, b) => {
|
// the current location whenever either changes.
|
||||||
const aVal = sortField.value === 'label'
|
function buildFilter(): ListFilter | undefined {
|
||||||
? (a.properties.label || nodeId(a))
|
return searchQuery.value ? { label: searchQuery.value } : undefined
|
||||||
: sortField.value === 'modifiedOn'
|
}
|
||||||
? (a.modified?.getTime() ?? 0)
|
|
||||||
: sortField.value === 'createdOn'
|
|
||||||
? (a.created?.getTime() ?? 0)
|
|
||||||
: ''
|
|
||||||
const bVal = sortField.value === 'label'
|
|
||||||
? (b.properties.label || nodeId(b))
|
|
||||||
: sortField.value === 'modifiedOn'
|
|
||||||
? (b.modified?.getTime() ?? 0)
|
|
||||||
: sortField.value === 'createdOn'
|
|
||||||
? (b.created?.getTime() ?? 0)
|
|
||||||
: ''
|
|
||||||
const cmp = String(aVal).localeCompare(String(bVal))
|
|
||||||
return sortOrder.value === 'asc' ? cmp : -cmp
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sort entities
|
function buildSort(): ListSort {
|
||||||
entities.sort((a, b) => {
|
return { [sortField.value]: sortOrder.value === 'asc' }
|
||||||
const aVal = sortField.value === 'label'
|
}
|
||||||
? (a.properties.label || nodeId(a))
|
|
||||||
: sortField.value === 'mime'
|
|
||||||
? (a.properties.mime || '')
|
|
||||||
: sortField.value === 'size'
|
|
||||||
? a.properties.size
|
|
||||||
: sortField.value === 'modifiedOn'
|
|
||||||
? (a.modified?.getTime() ?? 0)
|
|
||||||
: sortField.value === 'createdOn'
|
|
||||||
? (a.created?.getTime() ?? 0)
|
|
||||||
: ''
|
|
||||||
const bVal = sortField.value === 'label'
|
|
||||||
? (b.properties.label || nodeId(b))
|
|
||||||
: sortField.value === 'mime'
|
|
||||||
? (b.properties.mime || '')
|
|
||||||
: sortField.value === 'size'
|
|
||||||
? b.properties.size
|
|
||||||
: sortField.value === 'modifiedOn'
|
|
||||||
? (b.modified?.getTime() ?? 0)
|
|
||||||
: sortField.value === 'createdOn'
|
|
||||||
? (b.created?.getTime() ?? 0)
|
|
||||||
: ''
|
|
||||||
const cmp = String(aVal).localeCompare(String(bVal))
|
|
||||||
return sortOrder.value === 'asc' ? cmp : -cmp
|
|
||||||
})
|
|
||||||
|
|
||||||
// Filter by search
|
async function reload() {
|
||||||
const filterFn = (item: CollectionObject | EntityObject) => {
|
await documentManager.resetAndLoad(buildFilter(), buildSort())
|
||||||
if (!searchQuery.value) return true
|
}
|
||||||
const label = item instanceof EntityObject
|
|
||||||
? (item.properties.label || nodeId(item))
|
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
: (item.properties.label || nodeId(item))
|
|
||||||
return label.toLowerCase().includes(searchQuery.value.toLowerCase())
|
watch(searchQuery, () => {
|
||||||
}
|
if (searchDebounceTimer !== null) {
|
||||||
|
clearTimeout(searchDebounceTimer)
|
||||||
return {
|
|
||||||
collections: collections.filter(filterFn),
|
|
||||||
entities: entities.filter(filterFn),
|
|
||||||
}
|
}
|
||||||
|
searchDebounceTimer = setTimeout(() => {
|
||||||
|
searchDebounceTimer = null
|
||||||
|
reload()
|
||||||
|
}, 300)
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasItems = computed(() =>
|
watch([sortField, sortOrder], () => {
|
||||||
sortedItems.value.collections.length > 0 || sortedItems.value.entities.length > 0
|
reload()
|
||||||
)
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (searchDebounceTimer !== null) {
|
||||||
|
clearTimeout(searchDebounceTimer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const hasSelection = computed(() => selection.hasSelection.value)
|
const hasSelection = computed(() => selection.hasSelection.value)
|
||||||
|
|
||||||
@@ -656,7 +665,7 @@ async function readEntriesRecursively(
|
|||||||
async function handleUploadAll() {
|
async function handleUploadAll() {
|
||||||
try {
|
try {
|
||||||
await upload.uploadAll()
|
await upload.uploadAll()
|
||||||
await documentManager.refresh()
|
await reload()
|
||||||
notify('Uploads completed', 'success')
|
notify('Uploads completed', 'success')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to upload files:', error)
|
console.error('Failed to upload files:', error)
|
||||||
@@ -673,7 +682,7 @@ function handleUploadDialogClose() {
|
|||||||
// Refresh
|
// Refresh
|
||||||
async function handleRefresh() {
|
async function handleRefresh() {
|
||||||
try {
|
try {
|
||||||
await documentManager.refresh()
|
await reload()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to refresh files:', error)
|
console.error('Failed to refresh files:', error)
|
||||||
notify(getErrorMessage(error, 'Failed to refresh files'), 'error')
|
notify(getErrorMessage(error, 'Failed to refresh files'), 'error')
|
||||||
@@ -686,7 +695,7 @@ onMounted(async () => {
|
|||||||
await documentManager.initialize()
|
await documentManager.initialize()
|
||||||
await servicesStore.list()
|
await servicesStore.list()
|
||||||
syncActiveService(availableServices.value)
|
syncActiveService(availableServices.value)
|
||||||
await documentManager.refresh()
|
await reload()
|
||||||
console.log('[Files] - Initialized with:', {
|
console.log('[Files] - Initialized with:', {
|
||||||
provider: activeProviderId.value,
|
provider: activeProviderId.value,
|
||||||
service: activeServiceId.value,
|
service: activeServiceId.value,
|
||||||
@@ -779,10 +788,13 @@ onMounted(async () => {
|
|||||||
<!-- Grid View -->
|
<!-- Grid View -->
|
||||||
<FilesGridView
|
<FilesGridView
|
||||||
v-else-if="viewMode === 'grid'"
|
v-else-if="viewMode === 'grid'"
|
||||||
:collections="sortedItems.collections"
|
:collections="documentManager.currentCollections.value"
|
||||||
:entities="sortedItems.entities"
|
:entities="documentManager.currentEntities.value"
|
||||||
:selected-ids="selectedIds"
|
:selected-ids="selectedIds"
|
||||||
:preview-source="resolveViewerSource"
|
:has-more="documentManager.hasMore.value"
|
||||||
|
:is-loading-more="documentManager.isLoadingMore.value"
|
||||||
|
:preview-source="previewsAvailable ? resolvePreviewSource : undefined"
|
||||||
|
:thumbnail-source="previewsAvailable ? resolveThumbnailSource : undefined"
|
||||||
@item-click="handleItemClick"
|
@item-click="handleItemClick"
|
||||||
@open="handleOpenItem"
|
@open="handleOpenItem"
|
||||||
@edit="handleEditItem"
|
@edit="handleEditItem"
|
||||||
@@ -790,15 +802,19 @@ onMounted(async () => {
|
|||||||
@delete="handleDeleteItem"
|
@delete="handleDeleteItem"
|
||||||
@download="handleDownloadItem"
|
@download="handleDownloadItem"
|
||||||
@show-details="handleShowDetails"
|
@show-details="handleShowDetails"
|
||||||
|
@load-more="documentManager.loadMore"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- List View -->
|
<!-- List View -->
|
||||||
<FilesListView
|
<FilesListView
|
||||||
v-else-if="viewMode === 'list'"
|
v-else-if="viewMode === 'list'"
|
||||||
:collections="sortedItems.collections"
|
:collections="documentManager.currentCollections.value"
|
||||||
:entities="sortedItems.entities"
|
:entities="documentManager.currentEntities.value"
|
||||||
:selected-ids="selectedIds"
|
:selected-ids="selectedIds"
|
||||||
:preview-source="resolveViewerSource"
|
:has-more="documentManager.hasMore.value"
|
||||||
|
:is-loading-more="documentManager.isLoadingMore.value"
|
||||||
|
:preview-source="previewsAvailable ? resolvePreviewSource : undefined"
|
||||||
|
:thumbnail-source="previewsAvailable ? resolveThumbnailSource : undefined"
|
||||||
@item-click="handleItemClick"
|
@item-click="handleItemClick"
|
||||||
@open="handleOpenItem"
|
@open="handleOpenItem"
|
||||||
@edit="handleEditItem"
|
@edit="handleEditItem"
|
||||||
@@ -806,15 +822,19 @@ onMounted(async () => {
|
|||||||
@delete="handleDeleteItem"
|
@delete="handleDeleteItem"
|
||||||
@download="handleDownloadItem"
|
@download="handleDownloadItem"
|
||||||
@show-details="handleShowDetails"
|
@show-details="handleShowDetails"
|
||||||
|
@load-more="documentManager.loadMore"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Details View -->
|
<!-- Details View -->
|
||||||
<FilesDetailsView
|
<FilesDetailsView
|
||||||
v-else-if="viewMode === 'details'"
|
v-else-if="viewMode === 'details'"
|
||||||
:collections="sortedItems.collections"
|
:collections="documentManager.currentCollections.value"
|
||||||
:entities="sortedItems.entities"
|
:entities="documentManager.currentEntities.value"
|
||||||
:selected-ids="selectedIds"
|
:selected-ids="selectedIds"
|
||||||
:preview-source="resolveViewerSource"
|
:has-more="documentManager.hasMore.value"
|
||||||
|
:is-loading-more="documentManager.isLoadingMore.value"
|
||||||
|
:preview-source="previewsAvailable ? resolvePreviewSource : undefined"
|
||||||
|
:thumbnail-source="previewsAvailable ? resolveThumbnailSource : undefined"
|
||||||
@item-click="handleItemClick"
|
@item-click="handleItemClick"
|
||||||
@open="handleOpenItem"
|
@open="handleOpenItem"
|
||||||
@edit="handleEditItem"
|
@edit="handleEditItem"
|
||||||
@@ -822,6 +842,7 @@ onMounted(async () => {
|
|||||||
@delete="handleDeleteItem"
|
@delete="handleDeleteItem"
|
||||||
@download="handleDownloadItem"
|
@download="handleDownloadItem"
|
||||||
@show-details="handleShowDetails"
|
@show-details="handleShowDetails"
|
||||||
|
@load-more="documentManager.loadMore"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -830,6 +851,7 @@ onMounted(async () => {
|
|||||||
v-if="hasSelection && !isMobile"
|
v-if="hasSelection && !isMobile"
|
||||||
embedded
|
embedded
|
||||||
:selected-items="selection.selectedNodeArray.value"
|
:selected-items="selection.selectedNodeArray.value"
|
||||||
|
:preview-url="infoPanelPreviewUrl"
|
||||||
@close="selection.clear()"
|
@close="selection.clear()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -838,6 +860,7 @@ onMounted(async () => {
|
|||||||
<FilesInfoPanel
|
<FilesInfoPanel
|
||||||
v-if="isMobile"
|
v-if="isMobile"
|
||||||
:selected-items="selection.selectedNodeArray.value"
|
:selected-items="selection.selectedNodeArray.value"
|
||||||
|
:preview-url="infoPanelPreviewUrl"
|
||||||
@close="selection.clear()"
|
@close="selection.clear()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,222 +0,0 @@
|
|||||||
import { computed, ref } from 'vue'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { useProvidersStore, useServicesStore, useNodesStore, ROOT_ID } from '@DocumentsManager/stores'
|
|
||||||
import { CollectionObject } from '@DocumentsManager/models/collection'
|
|
||||||
import { EntityObject } from '@DocumentsManager/models/entity'
|
|
||||||
import type { SortField, SortOrder, ViewMode, BreadcrumbItem } from '@/types'
|
|
||||||
|
|
||||||
type NodeRecord = CollectionObject | EntityObject
|
|
||||||
|
|
||||||
export const useDocumentsStore = defineStore('documentsStore', () => {
|
|
||||||
const providersStore = useProvidersStore()
|
|
||||||
const servicesStore = useServicesStore()
|
|
||||||
const nodesStore = useNodesStore()
|
|
||||||
|
|
||||||
const activeProviderId = ref('default')
|
|
||||||
const activeServiceId = ref<string | number>('personal')
|
|
||||||
const currentLocation = ref<string>(ROOT_ID)
|
|
||||||
|
|
||||||
const viewMode = ref<ViewMode>('grid')
|
|
||||||
const sortField = ref<SortField>('label')
|
|
||||||
const sortOrder = ref<SortOrder>('asc')
|
|
||||||
const searchQuery = ref('')
|
|
||||||
const sidebarVisible = ref(true)
|
|
||||||
|
|
||||||
const transceiving = computed(() =>
|
|
||||||
providersStore.transceiving || servicesStore.transceiving || nodesStore.transceiving,
|
|
||||||
)
|
|
||||||
|
|
||||||
const provider = computed(() => providersStore.provider(activeProviderId.value))
|
|
||||||
const service = computed(() => servicesStore.service(activeProviderId.value, activeServiceId.value))
|
|
||||||
|
|
||||||
const currentCollections = computed(() =>
|
|
||||||
nodesStore.getChildCollections(activeProviderId.value, activeServiceId.value, currentLocation.value),
|
|
||||||
)
|
|
||||||
|
|
||||||
const currentEntities = computed(() =>
|
|
||||||
nodesStore.getChildEntities(activeProviderId.value, activeServiceId.value, currentLocation.value),
|
|
||||||
)
|
|
||||||
|
|
||||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => {
|
|
||||||
const nodes = nodesStore.getPath(activeProviderId.value, activeServiceId.value, currentLocation.value)
|
|
||||||
return [
|
|
||||||
{ id: ROOT_ID, label: 'Home', isRoot: true },
|
|
||||||
...nodes.map((node) => ({
|
|
||||||
id: String(node.identifier),
|
|
||||||
label: node.properties.label,
|
|
||||||
isRoot: false,
|
|
||||||
})),
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
function sortCollections(items: CollectionObject[]): CollectionObject[] {
|
|
||||||
return [...items].sort((a, b) => {
|
|
||||||
const aVal = sortField.value === 'label'
|
|
||||||
? a.properties.label
|
|
||||||
: sortField.value === 'modifiedOn'
|
|
||||||
? (a.modified?.getTime() ?? 0)
|
|
||||||
: sortField.value === 'createdOn'
|
|
||||||
? (a.created?.getTime() ?? 0)
|
|
||||||
: ''
|
|
||||||
const bVal = sortField.value === 'label'
|
|
||||||
? b.properties.label
|
|
||||||
: sortField.value === 'modifiedOn'
|
|
||||||
? (b.modified?.getTime() ?? 0)
|
|
||||||
: sortField.value === 'createdOn'
|
|
||||||
? (b.created?.getTime() ?? 0)
|
|
||||||
: ''
|
|
||||||
|
|
||||||
const cmp = String(aVal).localeCompare(String(bVal))
|
|
||||||
return sortOrder.value === 'asc' ? cmp : -cmp
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortEntities(items: EntityObject[]): EntityObject[] {
|
|
||||||
return [...items].sort((a, b) => {
|
|
||||||
const aVal = sortField.value === 'label'
|
|
||||||
? a.properties.label
|
|
||||||
: sortField.value === 'mime'
|
|
||||||
? (a.properties.mime ?? '')
|
|
||||||
: sortField.value === 'size'
|
|
||||||
? a.properties.size
|
|
||||||
: sortField.value === 'modifiedOn'
|
|
||||||
? (a.modified?.getTime() ?? 0)
|
|
||||||
: sortField.value === 'createdOn'
|
|
||||||
? (a.created?.getTime() ?? 0)
|
|
||||||
: ''
|
|
||||||
const bVal = sortField.value === 'label'
|
|
||||||
? b.properties.label
|
|
||||||
: sortField.value === 'mime'
|
|
||||||
? (b.properties.mime ?? '')
|
|
||||||
: sortField.value === 'size'
|
|
||||||
? b.properties.size
|
|
||||||
: sortField.value === 'modifiedOn'
|
|
||||||
? (b.modified?.getTime() ?? 0)
|
|
||||||
: sortField.value === 'createdOn'
|
|
||||||
? (b.created?.getTime() ?? 0)
|
|
||||||
: ''
|
|
||||||
|
|
||||||
const cmp = String(aVal).localeCompare(String(bVal))
|
|
||||||
return sortOrder.value === 'asc' ? cmp : -cmp
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterNode(node: NodeRecord): boolean {
|
|
||||||
if (!searchQuery.value) return true
|
|
||||||
return node.properties.label.toLowerCase().includes(searchQuery.value.toLowerCase())
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedItems = computed(() => ({
|
|
||||||
collections: sortCollections(currentCollections.value).filter(filterNode),
|
|
||||||
entities: sortEntities(currentEntities.value).filter(filterNode),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const hasItems = computed(() =>
|
|
||||||
sortedItems.value.collections.length > 0 || sortedItems.value.entities.length > 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
const isAtRoot = computed(() => currentLocation.value === ROOT_ID)
|
|
||||||
|
|
||||||
async function initialize() {
|
|
||||||
await providersStore.list([activeProviderId.value])
|
|
||||||
await servicesStore.list()
|
|
||||||
await refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
await nodesStore.fetchNodes(activeProviderId.value, activeServiceId.value, currentLocation.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function navigateTo(collectionId: string | null) {
|
|
||||||
currentLocation.value = collectionId || ROOT_ID
|
|
||||||
await refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function navigateToRoot() {
|
|
||||||
await navigateTo(ROOT_ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function navigateUp() {
|
|
||||||
if (currentLocation.value === ROOT_ID) return
|
|
||||||
const currentNode = nodesStore.getNode(activeProviderId.value, activeServiceId.value, currentLocation.value)
|
|
||||||
if (!currentNode) return
|
|
||||||
const parentId = currentNode.collection ? String(currentNode.collection) : ROOT_ID
|
|
||||||
await navigateTo(parentId)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createFolder(label: string) {
|
|
||||||
await nodesStore.createCollection(
|
|
||||||
activeProviderId.value,
|
|
||||||
activeServiceId.value,
|
|
||||||
currentLocation.value,
|
|
||||||
{ label, owner: '' },
|
|
||||||
)
|
|
||||||
await refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renameNode(node: NodeRecord, label: string) {
|
|
||||||
if (node instanceof CollectionObject) {
|
|
||||||
await nodesStore.updateCollection(
|
|
||||||
activeProviderId.value,
|
|
||||||
activeServiceId.value,
|
|
||||||
node.identifier,
|
|
||||||
{ label, owner: node.properties.owner },
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
await nodesStore.updateEntity(
|
|
||||||
activeProviderId.value,
|
|
||||||
activeServiceId.value,
|
|
||||||
node.collection,
|
|
||||||
node.identifier,
|
|
||||||
{
|
|
||||||
'@type': 'documents.properties',
|
|
||||||
urid: node.properties.urid,
|
|
||||||
size: node.properties.size,
|
|
||||||
label,
|
|
||||||
mime: node.properties.mime,
|
|
||||||
format: node.properties.format,
|
|
||||||
encoding: node.properties.encoding,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
await refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteNode(node: NodeRecord) {
|
|
||||||
if (node instanceof CollectionObject) {
|
|
||||||
await nodesStore.deleteCollection(activeProviderId.value, activeServiceId.value, node.identifier)
|
|
||||||
} else {
|
|
||||||
await nodesStore.deleteEntity(activeProviderId.value, activeServiceId.value, node.collection, node.identifier)
|
|
||||||
}
|
|
||||||
await refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
activeProviderId,
|
|
||||||
activeServiceId,
|
|
||||||
currentLocation,
|
|
||||||
viewMode,
|
|
||||||
sortField,
|
|
||||||
sortOrder,
|
|
||||||
searchQuery,
|
|
||||||
sidebarVisible,
|
|
||||||
transceiving,
|
|
||||||
provider,
|
|
||||||
service,
|
|
||||||
currentCollections,
|
|
||||||
currentEntities,
|
|
||||||
sortedItems,
|
|
||||||
hasItems,
|
|
||||||
breadcrumbs,
|
|
||||||
isAtRoot,
|
|
||||||
initialize,
|
|
||||||
refresh,
|
|
||||||
navigateTo,
|
|
||||||
navigateToRoot,
|
|
||||||
navigateUp,
|
|
||||||
createFolder,
|
|
||||||
renameNode,
|
|
||||||
deleteNode,
|
|
||||||
ROOT_ID,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { useDocumentsStore } from './documentsStore'
|
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
export type ViewMode = 'grid' | 'list' | 'details'
|
export type ViewMode = 'grid' | 'list' | 'details'
|
||||||
|
|
||||||
export type SortField = 'label' | 'size' | 'modifiedOn' | 'createdOn' | 'mime'
|
export type SortField = 'label' | 'size' | 'modifiedOn' | 'createdOn'
|
||||||
|
|
||||||
export type SortOrder = 'asc' | 'desc'
|
export type SortOrder = 'asc' | 'desc'
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user