Compare commits

..

1 Commits

Author SHA1 Message Date
Sebastian 2d5384952e chore(deps): update dependency vite to v8.1.5 2026-07-18 03:05:52 +00:00
24 changed files with 1388 additions and 2103 deletions
-50
View File
@@ -1,50 +0,0 @@
name: Build Test
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Retrieve Server Install Action
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/action-server-install
ref: main
path: action-server-install
github-server-url: https://git.ktrix.dev
- name: Install Server
uses: ./action-server-install
with:
install-php: 'false'
install-node: 'true'
php-version: '8.5'
node-version: '24'
server-path: './server'
- name: Install Documents Manager
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/documents_manager
ref: main
path: server/modules/documents_manager
github-server-url: https://git.ktrix.dev
- name: Checkout Pull Request
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.repository }}
ref: ${{ github.event.pull_request.head.sha }}
path: server/modules/documents
github-server-url: https://git.ktrix.dev
- name: Install dependencies
run: npm ci
working-directory: server/modules/documents
- name: Build
run: npm run build
working-directory: server/modules/documents
-50
View File
@@ -1,50 +0,0 @@
name: JS Unit Tests
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Retrieve Server Install Action
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/action-server-install
ref: main
path: action-server-install
github-server-url: https://git.ktrix.dev
- name: Install Server
uses: ./action-server-install
with:
install-php: 'false'
install-node: 'true'
php-version: '8.5'
node-version: '24'
server-path: './server'
- name: Install Documents Manager
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/documents_manager
ref: main
path: server/modules/documents_manager
github-server-url: https://git.ktrix.dev
- name: Checkout Pull Request
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.repository }}
ref: ${{ github.event.pull_request.head.sha }}
path: server/modules/documents
github-server-url: https://git.ktrix.dev
- name: Install dependencies
run: npm ci
working-directory: server/modules/documents
- name: Run tests
run: npm run test:unit
working-directory: server/modules/documents
+4 -1
View File
@@ -15,7 +15,10 @@ node_modules/
/vendor/ /vendor/
/lib/vendor/ /lib/vendor/
coverage/ coverage/
*.cache phpunit.xml.cache
.phpunit.result.cache
.php-cs-fixer.cache
.phpstan.cache
.phpactor/ .phpactor/
# Editors # Editors
+12 -7
View File
@@ -2,14 +2,13 @@
namespace KTXM\Documents; namespace KTXM\Documents;
use KTXF\Module\Configuration\BrowserModuleContextInterface; use KTXF\Module\ModuleBrowserInterface;
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 class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
{ {
public function __construct() public function __construct()
@@ -51,10 +50,16 @@ class Module extends ModuleInstanceAbstract
]; ];
} }
public function configure(ModuleContextInterface $context): void public function registerBI(): array
{ {
if ($context instanceof BrowserModuleContextInterface) { return [
$context->registerModule($this, 'Documents', 'static/module.mjs'); 'handle' => $this->handle(),
} 'namespace' => 'Documents',
'version' => $this->version(),
'label' => $this->label(),
'author' => $this->author(),
'description' => $this->description(),
'boot' => 'static/module.mjs',
];
} }
} }
+494 -1498
View File
File diff suppressed because it is too large Load Diff
+3 -11
View File
@@ -10,14 +10,10 @@
"dev": "vite build --mode development --config vite.config.ts", "dev": "vite build --mode development --config vite.config.ts",
"watch": "vite build --mode development --watch --config vite.config.ts", "watch": "vite build --mode development --watch --config vite.config.ts",
"typecheck": "vue-tsc --noEmit", "typecheck": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
"test": "vitest run --config tests/js/vitest.config.ts",
"test:unit": "vitest run --config tests/js/vitest.config.ts",
"test:watch": "vitest watch --config tests/js/vitest.config.ts",
"test:coverage": "vitest run --coverage --config tests/js/vitest.config.ts"
}, },
"dependencies": { "dependencies": {
"pinia": "^4.0.0", "pinia": "^3.0.0",
"vue": "^3.5.18", "vue": "^3.5.18",
"vue-router": "^5.0.0", "vue-router": "^5.0.0",
"vuetify": "^4.0.0" "vuetify": "^4.0.0"
@@ -27,10 +23,6 @@
"@vue/tsconfig": "^0.9.0", "@vue/tsconfig": "^0.9.0",
"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": "^5.0.0",
"@vue/test-utils": "^2.4.10",
"jsdom": "^30.0.0",
"vitest": "^5.0.0"
} }
} }
-60
View File
@@ -1,60 +0,0 @@
<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>
+3 -34
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed } 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,10 +8,8 @@ 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<{
@@ -71,13 +69,6 @@ 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>
@@ -103,24 +94,9 @@ watch(() => props.previewUrl, () => {
<!-- Single item selected --> <!-- Single item selected -->
<template v-if="singleSelection && selectedItem"> <template v-if="singleSelection && selectedItem">
<!-- Preview/icon and name --> <!-- Icon and name -->
<div class="text-center mb-4"> <div class="text-center mb-4">
<div v-if="showPreview" class="files-info-preview mb-3"> <v-icon :color="itemIconColor" size="64">{{ itemIcon }}</v-icon>
<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' }}
@@ -229,11 +205,4 @@ watch(() => props.previewUrl, () => {
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>
-79
View File
@@ -1,79 +0,0 @@
<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>
+9 -34
View File
@@ -2,15 +2,14 @@
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 { formatSize, formatDate } from '@/utils/fileHelpers' import { getFileIcon, 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; type: 'collection' | 'entity' } item: CollectionObject | EntityObject
| { item: null; type: 'sentinel' } type: 'collection' | 'entity'
}
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>
@@ -18,10 +17,7 @@ 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<{
@@ -32,25 +28,17 @@ 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; a // Combine collections and entities into a single list for virtual scrolling
// 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>
@@ -70,17 +58,9 @@ function isSentinel(wrapped: ItemWithType): wrapped is { item: null; type: 'sent
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-else-if="isCollection(wrapped)" v-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)"
@@ -113,12 +93,7 @@ function isSentinel(wrapped: ItemWithType): wrapped is { item: null; type: 'sent
@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">
<FilePreview <v-icon color="grey" size="small" class="mr-2">{{ getFileIcon(wrapped.item as EntityObject) }}</v-icon>
: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>
+3 -28
View File
@@ -1,22 +1,17 @@
<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 { formatSize } from '@/utils/fileHelpers' import { getFileIcon, 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>
const props = defineProps<{ 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<{
@@ -27,7 +22,6 @@ 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>
@@ -80,9 +74,7 @@ const emit = defineEmits<{
@show-details="emit('show-details', $event)" @show-details="emit('show-details', $event)"
/> />
</div> </div>
<div class="files-grid-thumbnail"> <v-icon size="48" color="grey">{{ getFileIcon(entity) }}</v-icon>
<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>
@@ -94,12 +86,6 @@ const emit = defineEmits<{
/> />
</div> </div>
</div> </div>
<FilesLoadMoreSentinel
:has-more="!!hasMore"
:loading="!!isLoadingMore"
@intersect="emit('load-more')"
/>
</template> </template>
<style scoped> <style scoped>
@@ -150,17 +136,6 @@ 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;
+9 -33
View File
@@ -2,15 +2,14 @@
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 { formatSize } from '@/utils/fileHelpers' import { getFileIcon, 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; type: 'collection' | 'entity' } item: CollectionObject | EntityObject
| { item: null; type: 'sentinel' } type: 'collection' | 'entity'
}
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>
@@ -18,10 +17,7 @@ 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<{
@@ -32,25 +28,17 @@ 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; a // Combine collections and entities into a single list for virtual scrolling
// 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>
@@ -60,17 +48,9 @@ function isSentinel(wrapped: ItemWithType): wrapped is { item: null; type: 'sent
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-else-if="isCollection(wrapped)" v-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)"
> >
@@ -99,11 +79,7 @@ function isSentinel(wrapped: ItemWithType): wrapped is { item: null; type: 'sent
@dblclick.stop="emit('open', wrapped.item)" @dblclick.stop="emit('open', wrapped.item)"
> >
<template #prepend> <template #prepend>
<FilePreview <v-icon color="grey">{{ getFileIcon(wrapped.item as EntityObject) }}</v-icon>
: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>
+4 -9
View File
@@ -1,18 +1,13 @@
/** /**
* Central export point for all Files module composables * Central export point for all Files module composables
*
* useDocumentManager/useDocumentSelection live in documents_manager (they only
* ever depended on its SDK, never on this module's own code) so other modules
* can reuse them too; re-exported here so existing imports from
* '@/composables' keep working unchanged.
*/ */
export { useDocumentManager } from '@DocumentsManager/composables/useDocumentManager' export { useFileManager } from './useFileManager'
export { useDocumentSelection } from '@DocumentsManager/composables/useDocumentSelection' export { useFileSelection } from './useFileSelection'
export { useFileUpload } from './useFileUpload' export { useFileUpload } from './useFileUpload'
export { useFileEditor } from './useFileEditor' export { useFileEditor } from './useFileEditor'
export type { UseDocumentManagerOptions } from '@DocumentsManager/composables/useDocumentManager' export type { UseFileManagerOptions } from './useFileManager'
export type { UseDocumentSelectionOptions } from '@DocumentsManager/composables/useDocumentSelection' export type { UseFileSelectionOptions } from './useFileSelection'
export type { UseFileUploadOptions, FileUploadProgress } from './useFileUpload' export type { UseFileUploadOptions, FileUploadProgress } from './useFileUpload'
export type { UseFileEditorReturn } from './useFileEditor' export type { UseFileEditorReturn } from './useFileEditor'
+3 -3
View File
@@ -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_editor` integration * Other modules register editors at the `documents_file_editor` integration
* point by including an entry in their `integrations.ts`: * point by including an entry in their `integrations.ts`:
* *
* ```ts * ```ts
* documents_editor: [{ * documents_file_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_editor' const INTEGRATION_POINT = 'documents_file_editor'
function mimeMatchesPattern(mime: string, pattern: string): boolean { function mimeMatchesPattern(mime: string, pattern: string): boolean {
if (pattern.endsWith('/*')) { if (pattern.endsWith('/*')) {
+295
View File
@@ -0,0 +1,295 @@
/**
* File Manager composable for convenient file/folder operations
* Provides reactive access to file manager state and actions
*/
import { computed, ref, unref } from 'vue'
import type { Ref, ComputedRef } from 'vue'
import { useProvidersStore } from '@DocumentsManager/stores/providersStore'
import { useServicesStore } from '@DocumentsManager/stores/servicesStore'
import { useNodesStore, ROOT_ID } from '@DocumentsManager/stores/nodesStore'
import type { ListFilter, ListSort, ListRange } from '@DocumentsManager/types/common'
import type { DocumentInterface } from '@DocumentsManager/types/document'
import { CollectionObject } from '@DocumentsManager/models/collection'
import { EntityObject } from '@DocumentsManager/models/entity'
// Base URL for file manager transfer endpoints
const TRANSFER_BASE_URL = '/m/documents_manager'
export interface UseFileManagerOptions {
providerId: string | Ref<string> | ComputedRef<string>
serviceId: string | Ref<string> | ComputedRef<string>
autoFetch?: boolean
}
export function useFileManager(options: UseFileManagerOptions) {
const providersStore = useProvidersStore()
const servicesStore = useServicesStore()
const nodesStore = useNodesStore()
const { providerId, serviceId, autoFetch = false } = options
const currentProviderId = () => unref(providerId)
const currentServiceId = () => unref(serviceId)
// Current location (folder being viewed)
const currentLocation: Ref<string> = ref(ROOT_ID)
// Loading/error state
const isLoading = computed(() => nodesStore.transceiving)
const error = computed(() => nodesStore.error)
// Provider and service
const provider = computed(() => providersStore.provider(currentProviderId()))
const service = computed(() => servicesStore.service(currentProviderId(), currentServiceId()))
const rootId = computed(() => ROOT_ID)
// Current children
const currentChildren = computed(() =>
nodesStore.getChildren(currentProviderId(), currentServiceId(), currentLocation.value)
)
const currentCollections: ComputedRef<CollectionObject[]> = computed(() =>
nodesStore.getChildCollections(currentProviderId(), currentServiceId(), currentLocation.value)
)
const currentEntities: ComputedRef<EntityObject[]> = computed(() =>
nodesStore.getChildEntities(currentProviderId(), currentServiceId(), currentLocation.value)
)
// Breadcrumb path
const breadcrumbs = computed(() => {
if (currentLocation.value === ROOT_ID) {
return []
}
return nodesStore.getPath(currentProviderId(), currentServiceId(), currentLocation.value)
})
// Is at root?
const isAtRoot = computed(() => currentLocation.value === ROOT_ID)
// Navigate to a folder
const navigateTo = async (collectionId: string | null) => {
currentLocation.value = collectionId || ROOT_ID
await refresh()
}
// Navigate up one level
const navigateUp = async () => {
if (currentLocation.value === ROOT_ID) {
return
}
const currentNode = nodesStore.getNode(currentProviderId(), currentServiceId(), currentLocation.value)
if (currentNode) {
await navigateTo(currentNode.collection ? String(currentNode.collection) : ROOT_ID)
}
}
// Navigate to root
const navigateToRoot = async () => {
await navigateTo(ROOT_ID)
}
// Refresh current location
const refresh = async (
filter?: ListFilter,
sort?: ListSort,
range?: ListRange
) => {
await nodesStore.fetchNodes(
currentProviderId(),
currentServiceId(),
currentLocation.value === ROOT_ID ? ROOT_ID : currentLocation.value,
filter,
sort,
range
)
}
// Create a new folder
const createFolder = async (label: string): Promise<CollectionObject> => {
return await nodesStore.createCollection(
currentProviderId(),
currentServiceId(),
currentLocation.value === ROOT_ID ? ROOT_ID : currentLocation.value,
{ label, owner: '' }
)
}
// Create a new file
const createFile = async (
label: string,
mime: string = 'application/octet-stream'
): Promise<EntityObject> => {
const properties: DocumentInterface = {
'@type': 'documents.properties',
urid: null,
size: 0,
label,
mime,
format: null,
encoding: null,
}
return await nodesStore.createEntity(
currentProviderId(),
currentServiceId(),
currentLocation.value === ROOT_ID ? ROOT_ID : currentLocation.value,
properties
)
}
// Rename a node
const renameNode = async (nodeId: string, newLabel: string) => {
const node = nodesStore.getNode(currentProviderId(), currentServiceId(), nodeId)
if (!node) {
throw new Error('Node not found')
}
if (node instanceof CollectionObject) {
return await nodesStore.updateCollection(currentProviderId(), currentServiceId(), nodeId, {
label: newLabel,
owner: node.properties.owner,
})
} else {
const properties: DocumentInterface = {
'@type': 'documents.properties',
urid: node.properties.urid,
size: node.properties.size,
label: newLabel,
mime: node.properties.mime,
format: node.properties.format,
encoding: node.properties.encoding,
}
return await nodesStore.updateEntity(currentProviderId(), currentServiceId(), node.collection, nodeId, properties)
}
}
// Delete a node
const deleteNode = async (nodeId: string): Promise<boolean> => {
const node = nodesStore.getNode(currentProviderId(), currentServiceId(), nodeId)
if (!node) {
throw new Error('Node not found')
}
if (node instanceof CollectionObject) {
return await nodesStore.deleteCollection(currentProviderId(), currentServiceId(), nodeId)
} else {
return await nodesStore.deleteEntity(currentProviderId(), currentServiceId(), node.collection, nodeId)
}
}
// Read file content
const readFile = async (entityId: string): Promise<string | null> => {
const node = nodesStore.getNode(currentProviderId(), currentServiceId(), entityId)
if (!node || !(node instanceof EntityObject)) {
throw new Error('Entity not found')
}
return await nodesStore.readEntity(currentProviderId(), currentServiceId(), node.collection || ROOT_ID, entityId)
}
// Write file content
const writeFile = async (entityId: string, content: string): Promise<number> => {
const node = nodesStore.getNode(currentProviderId(), currentServiceId(), entityId)
if (!node || !(node instanceof EntityObject)) {
throw new Error('Entity not found')
}
return await nodesStore.writeEntity(currentProviderId(), currentServiceId(), node.collection, entityId, content)
}
// Get a URL suitable for inline viewing (img src / video src)
const getEntityUrl = (entityId: string, collectionId?: string | null): string => {
const collection = collectionId ?? currentLocation.value
return `${TRANSFER_BASE_URL}/download/entity/${encodeURIComponent(currentProviderId())}/${encodeURIComponent(currentServiceId())}/${encodeURIComponent(collection)}/${encodeURIComponent(entityId)}`
}
// Download a single file
const downloadEntity = (entityId: string, collectionId?: string | null): void => {
const collection = collectionId ?? currentLocation.value
// Use path parameters: /download/entity/{provider}/{service}/{collection}/{identifier}
const url = `${TRANSFER_BASE_URL}/download/entity/${encodeURIComponent(currentProviderId())}/${encodeURIComponent(currentServiceId())}/${encodeURIComponent(collection)}/${encodeURIComponent(entityId)}`
// Trigger download by opening URL (browser handles it)
window.open(url, '_blank')
}
const downloadCollection = (collectionId: string): void => {
// Use path parameters: /download/collection/{provider}/{service}/{identifier}
const url = `${TRANSFER_BASE_URL}/download/collection/${encodeURIComponent(currentProviderId())}/${encodeURIComponent(currentServiceId())}/${encodeURIComponent(collectionId)}`
window.open(url, '_blank')
}
const downloadArchive = (ids: string[], name: string = 'download', collectionId?: string | null): void => {
const collection = collectionId ?? currentLocation.value
const params = new URLSearchParams({
provider: currentProviderId(),
service: currentServiceId(),
})
ids.forEach(id => params.append('ids[]', id))
if (name) {
params.append('name', name)
}
if (collection && collection !== ROOT_ID) {
params.append('collection', collection)
}
const url = `${TRANSFER_BASE_URL}/download/archive?${params.toString()}`
window.open(url, '_blank')
}
// Initialize - fetch providers, services, and initial nodes if autoFetch
const initialize = async () => {
await providersStore.list([currentProviderId()])
await servicesStore.list()
if (autoFetch) {
await refresh()
}
}
return {
// State
currentLocation,
isLoading,
error,
// Provider/Service
provider,
service,
rootId,
// Current view
currentChildren,
currentCollections,
currentEntities,
breadcrumbs,
isAtRoot,
// Navigation
navigateTo,
navigateUp,
navigateToRoot,
refresh,
// Operations
createFolder,
createFile,
renameNode,
deleteNode,
readFile,
writeFile,
getEntityUrl,
downloadEntity,
downloadCollection,
downloadArchive,
// Initialize
initialize,
// Constants
ROOT_ID,
}
}
export default useFileManager
+135
View File
@@ -0,0 +1,135 @@
/**
* File selection composable
* Provides reactive selection management for file manager
*/
import { ref, computed } from 'vue'
import type { Ref, ComputedRef } from 'vue'
import { CollectionObject } from '@DocumentsManager/models/collection'
import { EntityObject } from '@DocumentsManager/models/entity'
type NodeRecord = CollectionObject | EntityObject
export interface UseFileSelectionOptions {
multiple?: boolean
allowFolders?: boolean
allowFiles?: boolean
}
export function useFileSelection(options: UseFileSelectionOptions = {}) {
const {
multiple = true,
allowFolders = true,
allowFiles = true,
} = options
const selectedIds: Ref<Set<string>> = ref(new Set())
const selectedNodes: Ref<Map<string, NodeRecord>> = ref(new Map())
const count: ComputedRef<number> = computed(() => selectedIds.value.size)
const hasSelection: ComputedRef<boolean> = computed(() => selectedIds.value.size > 0)
const selectedIdArray: ComputedRef<string[]> = computed(() => Array.from(selectedIds.value))
const selectedNodeArray: ComputedRef<NodeRecord[]> = computed(() => Array.from(selectedNodes.value.values()))
const selectedCollections: ComputedRef<CollectionObject[]> = computed(() =>
selectedNodeArray.value.filter((node): node is CollectionObject => node instanceof CollectionObject),
)
const selectedEntities: ComputedRef<EntityObject[]> = computed(() =>
selectedNodeArray.value.filter((node): node is EntityObject => node instanceof EntityObject),
)
const isSelected = (nodeId: string): boolean => selectedIds.value.has(nodeId)
const isTypeAllowed = (node: NodeRecord): boolean => {
if (node instanceof CollectionObject && !allowFolders) return false
if (node instanceof EntityObject && !allowFiles) return false
return true
}
const select = (node: NodeRecord) => {
if (!isTypeAllowed(node)) return
if (!multiple) {
selectedIds.value.clear()
selectedNodes.value.clear()
}
const nodeId = String(node.identifier)
selectedIds.value.add(nodeId)
selectedNodes.value.set(nodeId, node)
}
const deselect = (nodeId: string) => {
selectedIds.value.delete(nodeId)
selectedNodes.value.delete(nodeId)
}
const toggle = (node: NodeRecord) => {
const nodeId = String(node.identifier)
if (isSelected(nodeId)) {
deselect(nodeId)
return
}
select(node)
}
const selectMultiple = (nodes: NodeRecord[]) => {
if (!multiple) {
const lastNode = nodes[nodes.length - 1]
if (lastNode && isTypeAllowed(lastNode)) {
selectedIds.value.clear()
selectedNodes.value.clear()
const nodeId = String(lastNode.identifier)
selectedIds.value.add(nodeId)
selectedNodes.value.set(nodeId, lastNode)
}
return
}
for (const node of nodes) {
if (isTypeAllowed(node)) {
const nodeId = String(node.identifier)
selectedIds.value.add(nodeId)
selectedNodes.value.set(nodeId, node)
}
}
}
const selectAll = (nodes: NodeRecord[]) => {
if (!multiple) return
selectMultiple(nodes)
}
const clear = () => {
selectedIds.value.clear()
selectedNodes.value.clear()
}
const setSelection = (nodes: NodeRecord[]) => {
clear()
selectMultiple(nodes)
}
return {
selectedIds,
selectedNodes,
count,
hasSelection,
selectedIdArray,
selectedNodeArray,
selectedCollections,
selectedEntities,
isSelected,
select,
deselect,
toggle,
selectMultiple,
selectAll,
clear,
setSelection,
}
}
export default useFileSelection
+10 -3
View File
@@ -293,7 +293,9 @@ export function useFileUpload(options: UseFileUploadOptions) {
try { try {
// Create the folder // Create the folder
const collection = await nodesStore.createCollection( const collection = await nodesStore.createCollection(
`${currentProviderId()}:${currentServiceId()}:${parentId || ROOT_ID}`, currentProviderId(),
currentServiceId(),
parentId,
{ label: folderName, owner: '' } { label: folderName, owner: '' }
) )
folderIdMap.set(folderPath, String(collection.identifier)) folderIdMap.set(folderPath, String(collection.identifier))
@@ -340,7 +342,9 @@ export function useFileUpload(options: UseFileUploadOptions) {
// Create the entity // Create the entity
const entity = await nodesStore.createEntity( const entity = await nodesStore.createEntity(
`${currentProviderId()}:${currentServiceId()}:${targetCollection || ROOT_ID}`, currentProviderId(),
currentServiceId(),
targetCollection || ROOT_ID,
{ {
'@type': 'documents.properties', '@type': 'documents.properties',
urid: null, urid: null,
@@ -356,7 +360,10 @@ export function useFileUpload(options: UseFileUploadOptions) {
// Write the content // Write the content
await nodesStore.writeEntity( await nodesStore.writeEntity(
`${entity.provider}:${entity.service}:${entity.collection || ROOT_ID}:${entity.identifier}`, currentProviderId(),
currentServiceId(),
targetCollection || ROOT_ID,
String(entity.identifier),
content content
) )
+111 -134
View File
@@ -1,10 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick, defineAsyncComponent } from 'vue' import { ref, computed, onMounted, watch, nextTick, defineAsyncComponent } from 'vue'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import { useModuleStore, useIntegrationStore, usePreviewStore } from '@KTXC' import { useModuleStore, useIntegrationStore } from '@KTXC'
import { useDocumentManager, useDocumentSelection, useFileUpload, useFileEditor } from '@/composables' import { useFileManager, useFileSelection, useFileUpload, useFileEditor } from '@/composables'
import type { ViewMode, SortField, SortOrder, BreadcrumbItem } from '@/types' import type { ViewMode, SortField, SortOrder, BreadcrumbItem } from '@/types'
import 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'
@@ -32,8 +31,6 @@ 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')
}) })
@@ -45,13 +42,13 @@ const activeProviderId = ref('default')
const activeServiceId = ref('personal') const activeServiceId = ref('personal')
// File manager composable // File manager composable
const documentManager = useDocumentManager({ const fileManager = useFileManager({
providerId: activeProviderId, providerId: activeProviderId,
serviceId: activeServiceId, serviceId: activeServiceId,
}) })
// Selection composable // Selection composable
const selection = useDocumentSelection({ multiple: true }) const selection = useFileSelection({ multiple: true })
// Upload composable // Upload composable
const upload = useFileUpload({ const upload = useFileUpload({
@@ -60,11 +57,11 @@ const upload = useFileUpload({
}) })
// Keep upload collection in sync with current location // Keep upload collection in sync with current location
watch(() => documentManager.currentLocation.value, (newLocation) => { watch(() => fileManager.currentLocation.value, (newLocation) => {
upload.setCollection(newLocation) upload.setCollection(newLocation)
}) })
watch(() => documentManager.error.value, (message) => { watch(() => fileManager.error.value, (message) => {
if (message) { if (message) {
notify(message, 'error') notify(message, 'error')
} }
@@ -119,61 +116,24 @@ const mediaViewerDialog = computed(() => {
}) })
const viewerItems = computed<ViewerItem[]>(() => const viewerItems = computed<ViewerItem[]>(() =>
documentManager.currentEntities.value.map(entityToViewerItem), sortedItems.value.entities.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: 'image/webp', mime: entity.properties.mime ?? '',
meta: { collection: entity.collection ? String(entity.collection) : null }, meta: { collection: entity.collection ? String(entity.collection) : null },
} }
} }
function resolveViewerSource(item: ViewerItem): string | Promise<string> { function resolveViewerSource(item: ViewerItem): string {
if (!previewsAvailable.value) { return fileManager.getEntityUrl(item.id, (item.meta?.collection as string | null) ?? null)
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) fileManager.downloadEntity(item.id, (item.meta?.collection as string | null) ?? null)
} }
// Editor state // Editor state
@@ -281,10 +241,10 @@ function renameCurrentName(node: CollectionObject | EntityObject | null): string
// Computed // Computed
const breadcrumbs = computed<BreadcrumbItem[]>(() => { const breadcrumbs = computed<BreadcrumbItem[]>(() => {
const items: BreadcrumbItem[] = [ const items: BreadcrumbItem[] = [
{ id: documentManager.ROOT_ID, label: 'Home', isRoot: true } { id: fileManager.ROOT_ID, label: 'Home', isRoot: true }
] ]
for (const node of documentManager.breadcrumbs.value) { for (const node of fileManager.breadcrumbs.value) {
items.push({ items.push({
id: nodeId(node), id: nodeId(node),
label: nodeLabel(node), label: nodeLabel(node),
@@ -295,46 +255,77 @@ const breadcrumbs = computed<BreadcrumbItem[]>(() => {
return items return items
}) })
const hasItems = computed(() => const sortedItems = computed(() => {
documentManager.currentCollections.value.length > 0 || documentManager.currentEntities.value.length > 0 const collections = [...fileManager.currentCollections.value]
const entities = [...fileManager.currentEntities.value]
// Sort collections
collections.sort((a, b) => {
const aVal = sortField.value === 'label'
? (a.properties.label || nodeId(a))
: 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
entities.sort((a, b) => {
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
const filterFn = (item: CollectionObject | EntityObject) => {
if (!searchQuery.value) return true
const label = item instanceof EntityObject
? (item.properties.label || nodeId(item))
: (item.properties.label || nodeId(item))
return label.toLowerCase().includes(searchQuery.value.toLowerCase())
}
return {
collections: collections.filter(filterFn),
entities: entities.filter(filterFn),
}
})
const hasItems = computed(() =>
sortedItems.value.collections.length > 0 || sortedItems.value.entities.length > 0
) )
// Search/sort are server-driven: build the current filter/sort and reset-load
// the current location whenever either changes.
function buildFilter(): ListFilter | undefined {
return searchQuery.value ? { label: searchQuery.value } : undefined
}
function buildSort(): ListSort {
return { [sortField.value]: sortOrder.value === 'asc' }
}
async function reload() {
await documentManager.resetAndLoad(buildFilter(), buildSort())
}
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
watch(searchQuery, () => {
if (searchDebounceTimer !== null) {
clearTimeout(searchDebounceTimer)
}
searchDebounceTimer = setTimeout(() => {
searchDebounceTimer = null
reload()
}, 300)
})
watch([sortField, sortOrder], () => {
reload()
})
onUnmounted(() => {
if (searchDebounceTimer !== null) {
clearTimeout(searchDebounceTimer)
}
})
const hasSelection = computed(() => selection.hasSelection.value) const hasSelection = computed(() => selection.hasSelection.value)
const selectedIds = computed(() => selection.selectedIds.value) const selectedIds = computed(() => selection.selectedIds.value)
@@ -384,8 +375,8 @@ watch([activeProviderId, activeServiceId], async ([providerId, serviceId], [prev
} }
selection.clear() selection.clear()
upload.setCollection(documentManager.rootId.value) upload.setCollection(fileManager.rootId.value)
await documentManager.navigateToRoot() await fileManager.navigateToRoot()
}, { flush: 'post' }) }, { flush: 'post' })
async function handleServiceSelect(service: ServiceObject) { async function handleServiceSelect(service: ServiceObject) {
@@ -407,12 +398,12 @@ async function handleServiceSelect(service: ServiceObject) {
// Navigation methods // Navigation methods
async function handleBreadcrumbNavigate(item: BreadcrumbItem) { async function handleBreadcrumbNavigate(item: BreadcrumbItem) {
selection.clear() selection.clear()
await documentManager.navigateTo(item.isRoot ? null : item.id) await fileManager.navigateTo(item.isRoot ? null : item.id)
} }
async function handleFolderOpen(folder: CollectionObject) { async function handleFolderOpen(folder: CollectionObject) {
selection.clear() selection.clear()
await documentManager.navigateTo(nodeId(folder)) await fileManager.navigateTo(nodeId(folder))
} }
// Item interaction methods // Item interaction methods
@@ -449,7 +440,7 @@ function handleShowDetails(item: CollectionObject | EntityObject) {
// Folder operations // Folder operations
async function handleCreateFolder(name: string) { async function handleCreateFolder(name: string) {
try { try {
await documentManager.createFolder(name) await fileManager.createFolder(name)
showNewFolderDialog.value = false showNewFolderDialog.value = false
notify(`Folder "${name}" created`, 'success') notify(`Folder "${name}" created`, 'success')
} catch (error) { } catch (error) {
@@ -469,7 +460,7 @@ async function handleRename(newName: string) {
const currentName = renameCurrentName(nodeToRename.value) const currentName = renameCurrentName(nodeToRename.value)
try { try {
await documentManager.renameNode(nodeId(nodeToRename.value), newName) await fileManager.renameNode(nodeId(nodeToRename.value), newName)
showRenameDialog.value = false showRenameDialog.value = false
nodeToRename.value = null nodeToRename.value = null
notify(`Renamed "${currentName}" to "${newName}"`, 'success') notify(`Renamed "${currentName}" to "${newName}"`, 'success')
@@ -490,7 +481,7 @@ async function handleDelete() {
try { try {
for (const node of nodesToDelete.value) { for (const node of nodesToDelete.value) {
await documentManager.deleteNode(nodeId(node)) await fileManager.deleteNode(nodeId(node))
} }
selection.clear() selection.clear()
showDeleteDialog.value = false showDeleteDialog.value = false
@@ -509,11 +500,11 @@ async function handleDelete() {
function handleDownloadItem(item: CollectionObject | EntityObject) { function handleDownloadItem(item: CollectionObject | EntityObject) {
if (item instanceof EntityObject) { if (item instanceof EntityObject) {
// Download single file // Download single file
documentManager.downloadEntity(nodeId(item), String(item.collection ?? documentManager.ROOT_ID)) fileManager.downloadEntity(nodeId(item), String(item.collection ?? fileManager.ROOT_ID))
notify(`Download started for "${nodeLabel(item)}"`, 'info') notify(`Download started for "${nodeLabel(item)}"`, 'info')
} else if (item instanceof CollectionObject) { } else if (item instanceof CollectionObject) {
// Download folder as ZIP // Download folder as ZIP
documentManager.downloadCollection(nodeId(item)) fileManager.downloadCollection(nodeId(item))
notify(`Archive download started for "${nodeLabel(item)}"`, 'info') notify(`Archive download started for "${nodeLabel(item)}"`, 'info')
} }
} }
@@ -665,7 +656,7 @@ async function readEntriesRecursively(
async function handleUploadAll() { async function handleUploadAll() {
try { try {
await upload.uploadAll() await upload.uploadAll()
await reload() await fileManager.refresh()
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)
@@ -682,7 +673,7 @@ function handleUploadDialogClose() {
// Refresh // Refresh
async function handleRefresh() { async function handleRefresh() {
try { try {
await reload() await fileManager.refresh()
} 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')
@@ -692,10 +683,10 @@ async function handleRefresh() {
// Initialize // Initialize
onMounted(async () => { onMounted(async () => {
try { try {
await documentManager.initialize() await fileManager.initialize()
await servicesStore.list() await servicesStore.list()
syncActiveService(availableServices.value) syncActiveService(availableServices.value)
await reload() await fileManager.refresh()
console.log('[Files] - Initialized with:', { console.log('[Files] - Initialized with:', {
provider: activeProviderId.value, provider: activeProviderId.value,
service: activeServiceId.value, service: activeServiceId.value,
@@ -732,7 +723,7 @@ onMounted(async () => {
<FilesToolbar <FilesToolbar
v-model:search-query="searchQuery" v-model:search-query="searchQuery"
v-model:view-mode="viewMode" v-model:view-mode="viewMode"
:is-loading="documentManager.isLoading.value" :is-loading="fileManager.isLoading.value"
@toggle-sidebar="sidebarVisible = !sidebarVisible" @toggle-sidebar="sidebarVisible = !sidebarVisible"
@refresh="handleRefresh" @refresh="handleRefresh"
@open-file-picker="openFilePicker" @open-file-picker="openFilePicker"
@@ -756,7 +747,7 @@ onMounted(async () => {
:active-provider-id="activeProviderId" :active-provider-id="activeProviderId"
:active-service-id="activeServiceId" :active-service-id="activeServiceId"
:services="availableServices" :services="availableServices"
@navigate-home="documentManager.navigateToRoot()" @navigate-home="fileManager.navigateToRoot()"
@select-service="handleServiceSelect" @select-service="handleServiceSelect"
@new-folder="showNewFolderDialog = true" @new-folder="showNewFolderDialog = true"
@open-file-picker="openFilePicker" @open-file-picker="openFilePicker"
@@ -775,7 +766,7 @@ onMounted(async () => {
<!-- Loading --> <!-- Loading -->
<v-progress-linear <v-progress-linear
v-if="documentManager.isLoading.value" v-if="fileManager.isLoading.value"
indeterminate indeterminate
color="primary" color="primary"
class="mx-4" class="mx-4"
@@ -783,18 +774,15 @@ onMounted(async () => {
<div class="files-view-panel"> <div class="files-view-panel">
<!-- Empty state --> <!-- Empty state -->
<FilesEmptyState v-if="!hasItems && !documentManager.isLoading.value" /> <FilesEmptyState v-if="!hasItems && !fileManager.isLoading.value" />
<!-- Grid View --> <!-- Grid View -->
<FilesGridView <FilesGridView
v-else-if="viewMode === 'grid'" v-else-if="viewMode === 'grid'"
:collections="documentManager.currentCollections.value" :collections="sortedItems.collections"
:entities="documentManager.currentEntities.value" :entities="sortedItems.entities"
:selected-ids="selectedIds" :selected-ids="selectedIds"
:has-more="documentManager.hasMore.value" :preview-source="resolveViewerSource"
: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"
@@ -802,19 +790,15 @@ 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="documentManager.currentCollections.value" :collections="sortedItems.collections"
:entities="documentManager.currentEntities.value" :entities="sortedItems.entities"
:selected-ids="selectedIds" :selected-ids="selectedIds"
:has-more="documentManager.hasMore.value" :preview-source="resolveViewerSource"
: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,19 +806,15 @@ 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="documentManager.currentCollections.value" :collections="sortedItems.collections"
:entities="documentManager.currentEntities.value" :entities="sortedItems.entities"
:selected-ids="selectedIds" :selected-ids="selectedIds"
:has-more="documentManager.hasMore.value" :preview-source="resolveViewerSource"
: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"
@@ -842,7 +822,6 @@ onMounted(async () => {
@delete="handleDeleteItem" @delete="handleDeleteItem"
@download="handleDownloadItem" @download="handleDownloadItem"
@show-details="handleShowDetails" @show-details="handleShowDetails"
@load-more="documentManager.loadMore"
/> />
</div> </div>
</div> </div>
@@ -851,7 +830,6 @@ 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>
@@ -860,7 +838,6 @@ 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>
@@ -934,8 +911,8 @@ onMounted(async () => {
<FileEditorDialog <FileEditorDialog
v-model="showEditor" v-model="showEditor"
:entity="editorEntity" :entity="editorEntity"
:read-file="documentManager.readFile" :read-file="fileManager.readFile"
:write-file="documentManager.writeFile" :write-file="fileManager.writeFile"
/> />
<v-snackbar <v-snackbar
+222
View File
@@ -0,0 +1,222 @@
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
View File
@@ -0,0 +1 @@
export { useDocumentsStore } from './documentsStore'
+1 -1
View File
@@ -4,7 +4,7 @@
export type ViewMode = 'grid' | 'list' | 'details' export type ViewMode = 'grid' | 'list' | 'details'
export type SortField = 'label' | 'size' | 'modifiedOn' | 'createdOn' export type SortField = 'label' | 'size' | 'modifiedOn' | 'createdOn' | 'mime'
export type SortOrder = 'asc' | 'desc' export type SortOrder = 'asc' | 'desc'
+69 -5
View File
@@ -1,7 +1,71 @@
import { EntityObject } from '@DocumentsManager/models/entity'
/** /**
* These helpers only ever depended on documents_manager's EntityObject model, * Get the appropriate icon for a file based on its MIME type
* never on this module's own code, so they live in documents_manager (where
* other modules can reuse them too); re-exported here so existing imports
* from '@/utils/fileHelpers' keep working unchanged.
*/ */
export { getFileIcon, formatSize, formatDate, getUploadStatusIcon, getUploadStatusColor } from '@DocumentsManager/utils/fileHelpers' export function getFileIcon(entity: EntityObject): string {
const mime = entity.properties.mime || ''
if (!mime) return 'mdi-file'
if (mime.startsWith('image/')) return 'mdi-file-image'
if (mime.startsWith('video/')) return 'mdi-file-video'
if (mime.startsWith('audio/')) return 'mdi-file-music'
if (mime.startsWith('text/')) return 'mdi-file-document'
if (mime === 'application/pdf') return 'mdi-file-pdf-box'
if (mime.includes('zip') || mime.includes('tar') || mime.includes('compressed')) return 'mdi-folder-zip'
if (mime.includes('word') || mime.includes('document')) return 'mdi-file-word'
if (mime.includes('excel') || mime.includes('spreadsheet')) return 'mdi-file-excel'
if (mime.includes('powerpoint') || mime.includes('presentation')) return 'mdi-file-powerpoint'
return 'mdi-file'
}
/**
* Format file size in human readable format
*/
export function formatSize(bytes: number): string {
const safeBytes = Number.isFinite(bytes) ? bytes : 0
if (safeBytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(safeBytes) / Math.log(k))
return parseFloat((safeBytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
/**
* Format date string to localized format
*/
export function formatDate(value: Date | string | null | undefined): string {
if (!value) return '—'
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
/**
* Get upload status icon
*/
export function getUploadStatusIcon(status: string): string {
switch (status) {
case 'pending': return 'mdi-clock-outline'
case 'uploading': return 'mdi-loading mdi-spin'
case 'completed': return 'mdi-check-circle'
case 'error': return 'mdi-alert-circle'
default: return 'mdi-file'
}
}
/**
* Get upload status color
*/
export function getUploadStatusColor(status: string): string {
switch (status) {
case 'pending': return 'grey'
case 'uploading': return 'primary'
case 'completed': return 'success'
case 'error': return 'error'
default: return 'grey'
}
}
-30
View File
@@ -1,30 +0,0 @@
import { describe, it, expect } from 'vitest'
describe('Basic Tests', () => {
it('should perform basic assertion', () => {
expect(true).toBe(true)
})
it('should test array operations', () => {
const array = ['foo', 'bar', 'baz']
expect(array).toHaveLength(3)
expect(array).toContain('bar')
expect(array[0]).toBe('foo')
})
it('should test string operations', () => {
const string = 'Hello, World!'
expect(string).toContain('World')
expect(string.length).toBe(13)
})
it('should test object operations', () => {
const obj = { foo: 'bar', count: 42 }
expect(obj).toHaveProperty('foo')
expect(obj.foo).toBe('bar')
expect(obj.count).toBeGreaterThan(40)
})
})
-33
View File
@@ -1,33 +0,0 @@
import { fileURLToPath } from 'node:url'
import { defineConfig, configDefaults } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import path from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, '../../src'),
'@KTXC': path.resolve(__dirname, '../../../../core/src'),
},
},
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('../../', import.meta.url)),
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'tests/',
'**/*.d.ts',
'**/*.config.*',
'**/dist/**',
],
},
},
})