Compare commits
1 Commits
main
..
5d27eecf09
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d27eecf09 |
@@ -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
|
|
||||||
@@ -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
@@ -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
@@ -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',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+493
-1497
File diff suppressed because it is too large
Load Diff
+3
-11
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
import type { ServiceObject } from '@DocumentsManager/models/service'
|
||||||
|
|
||||||
|
const display = useDisplay()
|
||||||
|
const isMobile = computed(() => display.mdAndDown.value)
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
activeProviderId: string
|
||||||
|
activeServiceId: string
|
||||||
|
services: ServiceObject[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
'navigate-home': []
|
||||||
|
'select-service': [service: ServiceObject]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-navigation-drawer
|
||||||
|
:model-value="modelValue"
|
||||||
|
contained
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
:permanent="modelValue && !isMobile"
|
||||||
|
:temporary="isMobile"
|
||||||
|
width="280"
|
||||||
|
class="files-sidebar"
|
||||||
|
>
|
||||||
|
<div class="pa-4">
|
||||||
|
<v-list density="compact" nav>
|
||||||
|
<v-list-subheader>Quick Access</v-list-subheader>
|
||||||
|
<v-list-item
|
||||||
|
prepend-icon="mdi-home"
|
||||||
|
title="Home"
|
||||||
|
@click="emit('navigate-home')"
|
||||||
|
/>
|
||||||
|
<v-list-item
|
||||||
|
prepend-icon="mdi-clock-outline"
|
||||||
|
title="Recent"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<v-list-item
|
||||||
|
prepend-icon="mdi-star-outline"
|
||||||
|
title="Starred"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<v-list-item
|
||||||
|
prepend-icon="mdi-trash-can-outline"
|
||||||
|
title="Trash"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</v-list>
|
||||||
|
|
||||||
|
<v-divider class="my-4" />
|
||||||
|
|
||||||
|
<v-list density="compact" nav>
|
||||||
|
<v-list-subheader>Storage</v-list-subheader>
|
||||||
|
<v-list-item
|
||||||
|
v-for="service in services"
|
||||||
|
:key="`${service.provider}:${String(service.identifier ?? '')}`"
|
||||||
|
prepend-icon="mdi-harddisk"
|
||||||
|
:title="service.label || String(service.identifier ?? 'Unnamed Service')"
|
||||||
|
:subtitle="service.provider"
|
||||||
|
:active="activeProviderId === service.provider && activeServiceId === String(service.identifier ?? '')"
|
||||||
|
@click="emit('select-service', service)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="services.length === 0" class="files-sidebar-empty text-caption text-medium-emphasis px-4 py-2">
|
||||||
|
No storage services available.
|
||||||
|
</div>
|
||||||
|
</v-list>
|
||||||
|
</div>
|
||||||
|
</v-navigation-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.files-sidebar {
|
||||||
|
border-right: 1px solid rgb(var(--v-border-color)) !important;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-sidebar-empty {
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import { useDisplay } from 'vuetify'
|
|
||||||
import type { ServiceObject } from '@DocumentsManager/models/service'
|
|
||||||
|
|
||||||
const display = useDisplay()
|
|
||||||
const isMobile = computed(() => display.mdAndDown.value)
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
modelValue: boolean
|
|
||||||
activeProviderId: string
|
|
||||||
activeServiceId: string
|
|
||||||
services: ServiceObject[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:modelValue': [value: boolean]
|
|
||||||
'navigate-home': []
|
|
||||||
'select-service': [service: ServiceObject]
|
|
||||||
'new-folder': []
|
|
||||||
'open-file-picker': []
|
|
||||||
'open-folder-picker': []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// Settings placeholder (no settings dialog exists for this module yet)
|
|
||||||
const showSettingsDialog = ref(false)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<v-navigation-drawer
|
|
||||||
:model-value="modelValue"
|
|
||||||
contained
|
|
||||||
@update:model-value="emit('update:modelValue', $event)"
|
|
||||||
:permanent="modelValue && !isMobile"
|
|
||||||
:temporary="isMobile"
|
|
||||||
width="280"
|
|
||||||
class="files-sidebar"
|
|
||||||
>
|
|
||||||
<div class="pa-3">
|
|
||||||
<!-- Primary action -->
|
|
||||||
<v-menu>
|
|
||||||
<template #activator="{ props: menuProps }">
|
|
||||||
<v-btn
|
|
||||||
block
|
|
||||||
color="primary"
|
|
||||||
variant="tonal"
|
|
||||||
prepend-icon="mdi-plus"
|
|
||||||
append-icon="mdi-chevron-down"
|
|
||||||
class="mb-4 justify-start"
|
|
||||||
v-bind="menuProps"
|
|
||||||
>
|
|
||||||
New
|
|
||||||
</v-btn>
|
|
||||||
</template>
|
|
||||||
<v-list density="compact">
|
|
||||||
<v-list-item @click="emit('new-folder')">
|
|
||||||
<template #prepend>
|
|
||||||
<v-icon>mdi-folder-plus</v-icon>
|
|
||||||
</template>
|
|
||||||
<v-list-item-title>New Folder</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
<v-list-item @click="emit('open-file-picker')">
|
|
||||||
<template #prepend>
|
|
||||||
<v-icon>mdi-file-upload</v-icon>
|
|
||||||
</template>
|
|
||||||
<v-list-item-title>Upload Files</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
<v-list-item @click="emit('open-folder-picker')">
|
|
||||||
<template #prepend>
|
|
||||||
<v-icon>mdi-folder-upload</v-icon>
|
|
||||||
</template>
|
|
||||||
<v-list-item-title>Upload Folder</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
</v-menu>
|
|
||||||
|
|
||||||
<!-- Quick Access -->
|
|
||||||
<div class="d-flex align-center mb-1 px-1">
|
|
||||||
<v-icon icon="mdi-star" size="14" class="mr-2 text-medium-emphasis" />
|
|
||||||
<span class="sidebar-section-label">Quick Access</span>
|
|
||||||
</div>
|
|
||||||
<v-divider class="mb-2" />
|
|
||||||
<v-list density="compact" nav class="pa-0">
|
|
||||||
<v-list-item
|
|
||||||
prepend-icon="mdi-home"
|
|
||||||
title="Home"
|
|
||||||
rounded="lg"
|
|
||||||
@click="emit('navigate-home')"
|
|
||||||
/>
|
|
||||||
<v-list-item
|
|
||||||
prepend-icon="mdi-clock-outline"
|
|
||||||
title="Recent"
|
|
||||||
rounded="lg"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
<v-list-item
|
|
||||||
prepend-icon="mdi-star-outline"
|
|
||||||
title="Starred"
|
|
||||||
rounded="lg"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
<v-list-item
|
|
||||||
prepend-icon="mdi-trash-can-outline"
|
|
||||||
title="Trash"
|
|
||||||
rounded="lg"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</v-list>
|
|
||||||
|
|
||||||
<div class="my-3"></div>
|
|
||||||
|
|
||||||
<!-- Storage -->
|
|
||||||
<div class="d-flex align-center mb-1 px-1">
|
|
||||||
<v-icon icon="mdi-harddisk" size="14" class="mr-2 text-medium-emphasis" />
|
|
||||||
<span class="sidebar-section-label">Storage</span>
|
|
||||||
</div>
|
|
||||||
<v-divider class="mb-2" />
|
|
||||||
<v-list density="compact" nav class="pa-0">
|
|
||||||
<v-list-item
|
|
||||||
v-for="service in services"
|
|
||||||
:key="`${service.provider}:${String(service.identifier ?? '')}`"
|
|
||||||
prepend-icon="mdi-harddisk"
|
|
||||||
:title="service.label || String(service.identifier ?? 'Unnamed Service')"
|
|
||||||
:subtitle="service.provider"
|
|
||||||
:active="activeProviderId === service.provider && activeServiceId === String(service.identifier ?? '')"
|
|
||||||
rounded="lg"
|
|
||||||
@click="emit('select-service', service)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-if="services.length === 0" class="files-sidebar-empty text-caption text-medium-emphasis px-4 py-2">
|
|
||||||
No storage services available.
|
|
||||||
</div>
|
|
||||||
</v-list>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pinned utility footer -->
|
|
||||||
<template #append>
|
|
||||||
<v-divider />
|
|
||||||
<div class="pa-2">
|
|
||||||
<v-btn
|
|
||||||
block
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
prepend-icon="mdi-cog"
|
|
||||||
class="justify-start text-medium-emphasis"
|
|
||||||
@click="showSettingsDialog = true"
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</v-navigation-drawer>
|
|
||||||
|
|
||||||
<!-- Settings placeholder dialog -->
|
|
||||||
<v-dialog v-model="showSettingsDialog" max-width="480">
|
|
||||||
<v-card>
|
|
||||||
<v-card-title class="d-flex align-center">
|
|
||||||
<v-icon icon="mdi-cog" size="small" class="mr-2" />
|
|
||||||
Files Settings
|
|
||||||
</v-card-title>
|
|
||||||
<v-card-text class="text-medium-emphasis">
|
|
||||||
No settings are available for this module yet.
|
|
||||||
</v-card-text>
|
|
||||||
<v-card-actions>
|
|
||||||
<v-spacer />
|
|
||||||
<v-btn variant="text" @click="showSettingsDialog = false">Close</v-btn>
|
|
||||||
</v-card-actions>
|
|
||||||
</v-card>
|
|
||||||
</v-dialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.files-sidebar {
|
|
||||||
border-right: 1px solid rgb(var(--v-border-color)) !important;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-section-label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.09em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-sidebar-empty {
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { default as NewFolderDialog } from './NewFolderDialog.vue'
|
||||||
|
export { default as RenameDialog } from './RenameDialog.vue'
|
||||||
|
export { default as DeleteConfirmDialog } from './DeleteConfirmDialog.vue'
|
||||||
|
export { default as UploadDialog } from './UploadDialog.vue'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as FileEditorDialog } from './FileEditorDialog.vue'
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Layout components
|
||||||
|
export { default as FilesToolbar } from './FilesToolbar.vue'
|
||||||
|
export { default as FilesSidebar } from './FilesSidebar.vue'
|
||||||
|
export { default as FilesBreadcrumbs } from './FilesBreadcrumbs.vue'
|
||||||
|
export { default as FilesEmptyState } from './FilesEmptyState.vue'
|
||||||
|
export { default as FilesDragOverlay } from './FilesDragOverlay.vue'
|
||||||
|
export { default as FilesInfoPanel } from './FilesInfoPanel.vue'
|
||||||
|
export { default as FileActionsMenu } from './FileActionsMenu.vue'
|
||||||
|
|
||||||
|
// View components
|
||||||
|
export * from './views'
|
||||||
|
|
||||||
|
// Viewer
|
||||||
|
export * from './viewer'
|
||||||
|
|
||||||
|
// Dialog components
|
||||||
|
export * from './dialogs'
|
||||||
|
|
||||||
|
// Editor
|
||||||
|
export * from './editor'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as FilePreviewPopover } from './FilePreviewPopover.vue'
|
||||||
@@ -2,15 +2,13 @@
|
|||||||
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, FilePreviewPopover } from '@/components'
|
||||||
import FilePreview from '@/components/FilePreview.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 +16,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 +27,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 +57,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 +92,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>
|
||||||
|
|||||||
@@ -1,22 +1,16 @@
|
|||||||
<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, FilePreviewPopover } from '@/components'
|
||||||
import FilePreview from '@/components/FilePreview.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 +21,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 +73,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 +85,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 +135,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;
|
||||||
|
|||||||
@@ -2,15 +2,13 @@
|
|||||||
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, FilePreviewPopover } from '@/components'
|
||||||
import FilePreview from '@/components/FilePreview.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 +16,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 +27,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 +47,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 +78,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>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as FilesGridView } from './FilesGridView.vue'
|
||||||
|
export { default as FilesListView } from './FilesListView.vue'
|
||||||
|
export { default as FilesDetailsView } from './FilesDetailsView.vue'
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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('/*')) {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,31 @@
|
|||||||
<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'
|
||||||
import { useServicesStore } from '@DocumentsManager/stores/servicesStore'
|
import { useServicesStore } from '@DocumentsManager/stores/servicesStore'
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import FilesToolbar from '@/components/FilesToolbar.vue'
|
import {
|
||||||
import Sidebar from '@/components/Sidebar.vue'
|
FilesToolbar,
|
||||||
import FilesBreadcrumbs from '@/components/FilesBreadcrumbs.vue'
|
FilesSidebar,
|
||||||
import FilesEmptyState from '@/components/FilesEmptyState.vue'
|
FilesBreadcrumbs,
|
||||||
import FilesDragOverlay from '@/components/FilesDragOverlay.vue'
|
FilesEmptyState,
|
||||||
import FilesInfoPanel from '@/components/FilesInfoPanel.vue'
|
FilesDragOverlay,
|
||||||
import FilesGridView from '@/components/views/FilesGridView.vue'
|
FilesInfoPanel,
|
||||||
import FilesListView from '@/components/views/FilesListView.vue'
|
FilesGridView,
|
||||||
import FilesDetailsView from '@/components/views/FilesDetailsView.vue'
|
FilesListView,
|
||||||
import FileEditorDialog from '@/components/editor/FileEditorDialog.vue'
|
FilesDetailsView,
|
||||||
import NewFolderDialog from '@/components/dialogs/NewFolderDialog.vue'
|
FileEditorDialog,
|
||||||
import RenameDialog from '@/components/dialogs/RenameDialog.vue'
|
NewFolderDialog,
|
||||||
import DeleteConfirmDialog from '@/components/dialogs/DeleteConfirmDialog.vue'
|
RenameDialog,
|
||||||
import UploadDialog from '@/components/dialogs/UploadDialog.vue'
|
DeleteConfirmDialog,
|
||||||
|
UploadDialog,
|
||||||
|
} from '@/components'
|
||||||
|
|
||||||
// Check if file manager is available
|
// Check if file manager is available
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
@@ -32,8 +33,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 +44,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 +59,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 +118,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 +243,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 +257,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 +377,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 +400,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 +442,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 +462,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 +483,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 +502,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 +658,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 +675,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 +685,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 +725,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"
|
||||||
@@ -751,16 +744,13 @@ onMounted(async () => {
|
|||||||
<FilesDragOverlay :visible="isDragOver" />
|
<FilesDragOverlay :visible="isDragOver" />
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<Sidebar
|
<FilesSidebar
|
||||||
v-model="sidebarVisible"
|
v-model="sidebarVisible"
|
||||||
: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"
|
|
||||||
@open-file-picker="openFilePicker"
|
|
||||||
@open-folder-picker="openFolderPicker"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Main Area -->
|
<!-- Main Area -->
|
||||||
@@ -775,7 +765,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 +773,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 +789,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 +805,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 +821,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 +829,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 +837,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 +910,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
|
||||||
+1
-1
@@ -4,7 +4,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
name: 'documents',
|
name: 'documents',
|
||||||
path: '/',
|
path: '/',
|
||||||
component: () => import('@/pages/Main.vue'),
|
component: () => import('@/pages/FilesPage.vue'),
|
||||||
meta: { layout: 'fixed' },
|
meta: { layout: 'fixed' },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
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'
|
export type SortField = 'label' | 'size' | 'modifiedOn' | 'createdOn' | 'mime'
|
||||||
|
|
||||||
export type SortOrder = 'asc' | 'desc'
|
export type SortOrder = 'asc' | 'desc'
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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/**',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user