feat: add documents selector
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -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'
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user