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 @@
+
+
+
+
+
+
+ mdi-folder-search-outline
+ Select Documents
+
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
+
+
+
+
+
+ mdi-folder
+
+
+ mdi-chevron-right
+
+
+
+
+
+
+ {{ getFileIcon(entity) }}
+
+
+
+
+
+
mdi-folder-open-outline
+
This folder is empty
+
+
+
+
+ One or more selected files are larger than 25 MB and may take a moment to process.
+
+
+
+
+
+ {{ selection.count.value }} selected
+
+
+ Cancel
+
+ Select
+
+
+
+
+
+
+
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