From f80e3679b63c63e64c748229f0e3ad5f14736207 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Sat, 15 Aug 2026 11:20:37 -0400 Subject: [PATCH] feat: add documents selector Signed-off-by: Sebastian Krupinski --- package-lock.json | 141 ++++++++++- package.json | 5 +- src/components/DocumentSelectorDialog.vue | 228 +++++++++++++++++ src/composables/index.ts | 9 + src/composables/useDocumentManager.ts | 295 ++++++++++++++++++++++ src/composables/useDocumentSelection.ts | 135 ++++++++++ src/integrations.ts | 14 + src/main.ts | 10 +- src/utils/fileHelpers.ts | 71 ++++++ tsconfig.app.json | 2 +- 10 files changed, 904 insertions(+), 6 deletions(-) create mode 100644 src/components/DocumentSelectorDialog.vue create mode 100644 src/composables/index.ts create mode 100644 src/composables/useDocumentManager.ts create mode 100644 src/composables/useDocumentSelection.ts create mode 100644 src/integrations.ts create mode 100644 src/utils/fileHelpers.ts diff --git a/package-lock.json b/package-lock.json index ff889b6..a79e019 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,17 +9,20 @@ "version": "1.0.0", "dependencies": { "pinia": "^4.0.0", - "vue": "^3.5.13" + "vue": "^3.5.13", + "vuetify": "^4.0.0" }, "devDependencies": { "@tsconfig/node24": "^24.0.0", "@types/node": "^24.0.0", + "@vitejs/plugin-vue": "^6.0.1", "@vitest/coverage-v8": "^4.1.6", "@vue/tsconfig": "^0.9.0", "jsdom": "^29.1.1", "typescript": "~6.0.0", "vite": "^8.0.0", - "vitest": "^4.1.6" + "vitest": "^4.1.6", + "vue-tsc": "^3.0.5" } }, "node_modules/@asamuzakjp/css-color": { @@ -714,6 +717,23 @@ "undici-types": "~7.18.0" } }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", + "integrity": "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, "node_modules/@vitest/coverage-v8": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", @@ -868,6 +888,35 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, "node_modules/@vue/compiler-core": { "version": "3.5.40", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz", @@ -948,6 +997,22 @@ "license": "MIT", "peer": true }, + "node_modules/@vue/language-core": { + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.9.tgz", + "integrity": "sha512-in/68oAa4BCtVY6n/nkuhLIkV8DHYd2UivedJ6cMZ6UYtlq9jaoaSNUBHYCVO44z3nKg7MdE5OBoHKt5SxeBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.2.1", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, "node_modules/@vue/reactivity": { "version": "3.5.40", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.40.tgz", @@ -1015,6 +1080,13 @@ } } }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1649,6 +1721,13 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -1713,6 +1792,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2226,6 +2312,13 @@ } } }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, "node_modules/vue": { "version": "3.5.40", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.40.tgz", @@ -2247,6 +2340,50 @@ } } }, + "node_modules/vue-tsc": { + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.9.tgz", + "integrity": "sha512-TS3Y1ux/IRoE8OCP2PpACAeOseuIs0UvWrcr7u+w3PmfY+SlCfEf8zjrBgnQksHUgLpthi5vHlffcQTQTdPBZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.3.9" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/vuetify": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.1.9.tgz", + "integrity": "sha512-2NlpUiSwkUAGxKmkKwoRCst3IJF6EPP7msNJd+VxFjcAN9xCG+RFODzsfXfO2ypl9aSj3T/PJ65BBjQeTGgFKA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/johnleider" + }, + "peerDependencies": { + "typescript": ">=4.7", + "vite-plugin-vuetify": ">=2.1.0", + "vue": "^3.5.0 || ^3.6.0-0", + "webpack-plugin-vuetify": ">=3.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vite-plugin-vuetify": { + "optional": true + }, + "webpack-plugin-vuetify": { + "optional": true + } + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/package.json b/package.json index d32ce16..b60d085 100644 --- a/package.json +++ b/package.json @@ -15,14 +15,17 @@ }, "dependencies": { "pinia": "^4.0.0", - "vue": "^3.5.13" + "vue": "^3.5.13", + "vuetify": "^4.0.0" }, "devDependencies": { "@tsconfig/node24": "^24.0.0", "@types/node": "^24.0.0", + "@vitejs/plugin-vue": "^6.0.1", "@vue/tsconfig": "^0.9.0", "typescript": "~6.0.0", "vite": "^8.0.0", + "vue-tsc": "^3.0.5", "@vitest/coverage-v8": "^4.1.6", "jsdom": "^29.1.1", "vitest": "^4.1.6" diff --git a/src/components/DocumentSelectorDialog.vue b/src/components/DocumentSelectorDialog.vue new file mode 100644 index 0000000..0c8549a --- /dev/null +++ b/src/components/DocumentSelectorDialog.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/src/composables/index.ts b/src/composables/index.ts new file mode 100644 index 0000000..8532645 --- /dev/null +++ b/src/composables/index.ts @@ -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' diff --git a/src/composables/useDocumentManager.ts b/src/composables/useDocumentManager.ts new file mode 100644 index 0000000..7add667 --- /dev/null +++ b/src/composables/useDocumentManager.ts @@ -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 | ComputedRef + serviceId: string | Ref | ComputedRef + 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 = 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 = computed(() => + nodesStore.getChildCollections(currentProviderId(), currentServiceId(), currentLocation.value) + ) + + const currentEntities: ComputedRef = 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 diff --git a/src/composables/useDocumentSelection.ts b/src/composables/useDocumentSelection.ts new file mode 100644 index 0000000..4100a77 --- /dev/null +++ b/src/composables/useDocumentSelection.ts @@ -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> = ref(new Set()) + const selectedNodes: Ref> = ref(new Map()) + + const count: ComputedRef = computed(() => selectedIds.value.size) + const hasSelection: ComputedRef = computed(() => selectedIds.value.size > 0) + + const selectedIdArray: ComputedRef = computed(() => Array.from(selectedIds.value)) + const selectedNodeArray: ComputedRef = computed(() => Array.from(selectedNodes.value.values())) + + const selectedCollections: ComputedRef = computed(() => + selectedNodeArray.value.filter((node): node is CollectionObject => node instanceof CollectionObject), + ) + + const selectedEntities: ComputedRef = 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 diff --git a/src/integrations.ts b/src/integrations.ts new file mode 100644 index 0000000..b256343 --- /dev/null +++ b/src/integrations.ts @@ -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; diff --git a/src/main.ts b/src/main.ts index a1b43ac..c7ebac2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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' diff --git a/src/utils/fileHelpers.ts b/src/utils/fileHelpers.ts new file mode 100644 index 0000000..80c8e1f --- /dev/null +++ b/src/utils/fileHelpers.ts @@ -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' + } +} diff --git a/tsconfig.app.json b/tsconfig.app.json index 88d256c..89e9e17 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -15,5 +15,5 @@ "@KTXC/*": ["../../core/src/*"] } }, - "include": ["src/**/*.ts", "src/**/*.tsx"] + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] }