feat: add documents selector

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-08-15 11:20:37 -04:00
parent 26ee6e9c33
commit f80e3679b6
10 changed files with 904 additions and 6 deletions
+228
View File
@@ -0,0 +1,228 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useDocumentManager, useDocumentSelection } from '@/composables'
import { CollectionObject } from '@/models/collection'
import { EntityObject } from '@/models/entity'
import { formatSize, getFileIcon } from '@/utils/fileHelpers'
export interface DocumentSelection {
kind: 'entity' | 'collection'
identifier: string
name: string
mimeType?: string
size?: number
}
const PROVIDER_ID = 'default'
const SERVICE_ID = 'personal'
const LARGE_FILE_WARNING_BYTES = 25 * 1024 * 1024
const props = withDefaults(defineProps<{
modelValue: boolean
selectable?: ('entity' | 'collection')[]
}>(), {
selectable: () => ['entity'],
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
select: [items: DocumentSelection[]]
}>()
const allowEntities = computed(() => props.selectable.includes('entity'))
const allowCollections = computed(() => props.selectable.includes('collection'))
const documentManager = useDocumentManager({
providerId: PROVIDER_ID,
serviceId: SERVICE_ID,
})
const selection = useDocumentSelection({
multiple: true,
allowFolders: allowCollections.value,
allowFiles: allowEntities.value,
})
const initialized = ref(false)
watch(() => props.modelValue, async (isOpen) => {
if (!isOpen) {
return
}
selection.clear()
if (!initialized.value) {
await documentManager.initialize()
initialized.value = true
}
await documentManager.navigateToRoot()
})
function nodeId(node: CollectionObject | EntityObject): string {
return String(node.identifier ?? '')
}
function nodeLabel(node: CollectionObject | EntityObject): string {
return node.properties.label || nodeId(node)
}
const breadcrumbs = computed(() => [
{ id: documentManager.ROOT_ID, label: 'Home' },
...documentManager.breadcrumbs.value.map(node => ({ id: nodeId(node), label: nodeLabel(node) })),
])
async function navigateToBreadcrumb(id: string) {
selection.clear()
await documentManager.navigateTo(id === documentManager.ROOT_ID ? null : id)
}
async function openFolder(folder: CollectionObject) {
selection.clear()
await documentManager.navigateTo(nodeId(folder))
}
function toggleFolderSelection(folder: CollectionObject, event: Event) {
event.stopPropagation()
selection.toggle(folder)
}
function toggleEntity(entity: EntityObject) {
selection.toggle(entity)
}
const hasLargeSelection = computed(() =>
selection.selectedEntities.value.some(entity => entity.properties.size > LARGE_FILE_WARNING_BYTES),
)
function entityIdentifier(entity: EntityObject): string {
return `${entity.provider}:${entity.service}:${entity.collection}:${entity.identifier}`
}
function collectionIdentifier(collection: CollectionObject): string {
return `${collection.provider}:${collection.service}:${collection.identifier}`
}
function toSelection(node: CollectionObject | EntityObject): DocumentSelection {
if (node instanceof EntityObject) {
return {
kind: 'entity',
identifier: entityIdentifier(node),
name: nodeLabel(node),
mimeType: node.properties.mime || 'application/octet-stream',
size: node.properties.size,
}
}
return {
kind: 'collection',
identifier: collectionIdentifier(node),
name: nodeLabel(node),
}
}
function handleClose() {
emit('update:modelValue', false)
}
function handleConfirm() {
const items = selection.selectedNodeArray.value.map(toSelection)
if (items.length === 0) {
return
}
emit('select', items)
selection.clear()
emit('update:modelValue', false)
}
</script>
<template>
<v-dialog :model-value="modelValue" @update:model-value="emit('update:modelValue', $event)" max-width="560">
<v-card>
<v-card-title class="d-flex align-center">
<v-icon class="mr-2">mdi-folder-search-outline</v-icon>
Select Documents
</v-card-title>
<v-card-text>
<v-breadcrumbs :items="breadcrumbs" density="compact" class="px-0 mb-2">
<template #item="{ item }">
<v-breadcrumbs-item :disabled="item.id === breadcrumbs[breadcrumbs.length - 1]?.id" @click="navigateToBreadcrumb(item.id)">
{{ item.label }}
</v-breadcrumbs-item>
</template>
</v-breadcrumbs>
<v-progress-linear v-if="documentManager.isLoading.value" indeterminate color="primary" class="mb-2" />
<div class="selector-list">
<v-list v-if="documentManager.currentCollections.value.length > 0 || documentManager.currentEntities.value.length > 0" density="compact">
<v-list-item
v-for="folder in documentManager.currentCollections.value"
:key="nodeId(folder)"
:title="nodeLabel(folder)"
@click="openFolder(folder)"
>
<template #prepend>
<v-checkbox-btn
v-if="allowCollections"
:model-value="selection.isSelected(nodeId(folder))"
class="mr-2"
@click="toggleFolderSelection(folder, $event)"
/>
<v-icon>mdi-folder</v-icon>
</template>
<template #append>
<v-icon size="small">mdi-chevron-right</v-icon>
</template>
</v-list-item>
<v-list-item
v-for="entity in documentManager.currentEntities.value"
:key="nodeId(entity)"
:title="nodeLabel(entity)"
:subtitle="formatSize(entity.properties.size)"
:disabled="!allowEntities"
@click="allowEntities && toggleEntity(entity)"
>
<template #prepend>
<v-checkbox-btn v-if="allowEntities" :model-value="selection.isSelected(nodeId(entity))" class="mr-2" />
<v-icon>{{ getFileIcon(entity) }}</v-icon>
</template>
</v-list-item>
</v-list>
<div v-else-if="!documentManager.isLoading.value" class="text-center py-8 text-medium-emphasis">
<v-icon size="40" class="mb-2">mdi-folder-open-outline</v-icon>
<div>This folder is empty</div>
</div>
</div>
<v-alert v-if="hasLargeSelection" type="warning" variant="tonal" density="compact" class="mt-3">
One or more selected files are larger than 25 MB and may take a moment to process.
</v-alert>
</v-card-text>
<v-card-actions>
<span class="text-caption text-medium-emphasis ml-2">
{{ selection.count.value }} selected
</span>
<v-spacer />
<v-btn variant="text" @click="handleClose">Cancel</v-btn>
<v-btn color="primary" variant="elevated" :disabled="!selection.hasSelection.value" @click="handleConfirm">
Select
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.selector-list {
max-height: 360px;
overflow-y: auto;
border: 1px solid rgb(var(--v-border-color));
border-radius: 8px;
}
</style>
+9
View File
@@ -0,0 +1,9 @@
/**
* Central export point for Documents Manager composables
*/
export { useDocumentManager } from './useDocumentManager'
export { useDocumentSelection } from './useDocumentSelection'
export type { UseDocumentManagerOptions } from './useDocumentManager'
export type { UseDocumentSelectionOptions } from './useDocumentSelection'
+295
View File
@@ -0,0 +1,295 @@
/**
* File Manager composable for convenient file/folder operations
* Provides reactive access to file manager state and actions
*/
import { computed, ref, unref } from 'vue'
import type { Ref, ComputedRef } from 'vue'
import { useProvidersStore } from '@/stores/providersStore'
import { useServicesStore } from '@/stores/servicesStore'
import { useNodesStore, ROOT_ID } from '@/stores/nodesStore'
import type { ListFilter, ListSort, ListRange } from '@/types/common'
import type { DocumentInterface } from '@/types/document'
import { CollectionObject } from '@/models/collection'
import { EntityObject } from '@/models/entity'
// Base URL for file manager transfer endpoints
const TRANSFER_BASE_URL = '/m/documents_manager'
export interface UseDocumentManagerOptions {
providerId: string | Ref<string> | ComputedRef<string>
serviceId: string | Ref<string> | ComputedRef<string>
autoFetch?: boolean
}
export function useDocumentManager(options: UseDocumentManagerOptions) {
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 useDocumentManager
+135
View File
@@ -0,0 +1,135 @@
/**
* File selection composable
* Provides reactive selection management for file manager
*/
import { ref, computed } from 'vue'
import type { Ref, ComputedRef } from 'vue'
import { CollectionObject } from '@/models/collection'
import { EntityObject } from '@/models/entity'
type NodeRecord = CollectionObject | EntityObject
export interface UseDocumentSelectionOptions {
multiple?: boolean
allowFolders?: boolean
allowFiles?: boolean
}
export function useDocumentSelection(options: UseDocumentSelectionOptions = {}) {
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 useDocumentSelection
+14
View File
@@ -0,0 +1,14 @@
import type { ModuleIntegrations } from "@KTXC/types/moduleTypes";
const integrations: ModuleIntegrations = {
documents_selector: [
{
id: 'dialog',
label: 'Document Selector Dialog',
priority: 10,
component: () => import('./components/DocumentSelectorDialog.vue'),
},
],
};
export default integrations;
+8 -2
View File
@@ -2,14 +2,20 @@
* Documents Manager Module Boot
*/
import integrations from '@/integrations'
console.log('[Documents Manager] Booting module...')
console.log('[Documents Manager] Module booted successfully...')
// CSS will be injected by build process
//export const css = ['__CSS_FILENAME_PLACEHOLDER__']
// CSS filename is injected by the vite plugin at build time
export const css = ['__CSS_FILENAME_PLACEHOLDER__']
export { integrations }
// Export services, stores and models for external use
export * from '@/services'
export * from '@/stores'
export * from '@/models'
export * from '@/composables'
export * from '@/utils/fileHelpers'
+71
View File
@@ -0,0 +1,71 @@
import { EntityObject } from '@/models/entity'
/**
* Get the appropriate icon for a file based on its MIME type
*/
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'
}
}