refactor: standardize design

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-03-03 21:55:48 -05:00
parent 31a33d8758
commit a59dbff9f1
25 changed files with 994 additions and 306 deletions

View File

@@ -5,7 +5,9 @@
export { useFileManager } from './useFileManager'
export { useFileSelection } from './useFileSelection'
export { useFileUpload } from './useFileUpload'
export { useFileViewer } from './useFileViewer'
export type { UseFileManagerOptions } from './useFileManager'
export type { UseFileSelectionOptions } from './useFileSelection'
export type { UseFileUploadOptions, FileUploadProgress } from './useFileUpload'
export type { UseFileViewerReturn } from './useFileViewer'

View File

@@ -5,15 +5,16 @@
import { computed, ref } from 'vue'
import type { Ref, ComputedRef } from 'vue'
import { useProvidersStore } from '@FilesManager/stores/providersStore'
import { useServicesStore } from '@FilesManager/stores/servicesStore'
import { useNodesStore, ROOT_ID } from '@FilesManager/stores/nodesStore'
import type { FilterCondition, SortCondition, RangeCondition } from '@FilesManager/types/common'
import { FileCollectionObject } from '@FilesManager/models/collection'
import { FileEntityObject } from '@FilesManager/models/entity'
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/file_manager'
const TRANSFER_BASE_URL = '/m/documents_manager'
export interface UseFileManagerOptions {
providerId: string
@@ -32,24 +33,24 @@ export function useFileManager(options: UseFileManagerOptions) {
const currentLocation: Ref<string> = ref(ROOT_ID)
// Loading/error state
const isLoading = computed(() => nodesStore.loading)
const isLoading = computed(() => nodesStore.transceiving)
const error = computed(() => nodesStore.error)
// Provider and service
const provider = computed(() => providersStore.getProvider(providerId))
const service = computed(() => servicesStore.getService(providerId, serviceId))
const rootId = computed(() => servicesStore.getRootId(providerId, serviceId) || ROOT_ID)
const provider = computed(() => providersStore.provider(providerId))
const service = computed(() => servicesStore.service(providerId, serviceId))
const rootId = computed(() => ROOT_ID)
// Current children
const currentChildren = computed(() =>
nodesStore.getChildren(providerId, serviceId, currentLocation.value)
)
const currentCollections: ComputedRef<FileCollectionObject[]> = computed(() =>
const currentCollections: ComputedRef<CollectionObject[]> = computed(() =>
nodesStore.getChildCollections(providerId, serviceId, currentLocation.value)
)
const currentEntities: ComputedRef<FileEntityObject[]> = computed(() =>
const currentEntities: ComputedRef<EntityObject[]> = computed(() =>
nodesStore.getChildEntities(providerId, serviceId, currentLocation.value)
)
@@ -77,7 +78,7 @@ export function useFileManager(options: UseFileManagerOptions) {
}
const currentNode = nodesStore.getNode(providerId, serviceId, currentLocation.value)
if (currentNode) {
await navigateTo(currentNode.in || ROOT_ID)
await navigateTo(currentNode.collection ? String(currentNode.collection) : ROOT_ID)
}
}
@@ -88,15 +89,14 @@ export function useFileManager(options: UseFileManagerOptions) {
// Refresh current location
const refresh = async (
filter?: FilterCondition[] | null,
sort?: SortCondition[] | null,
range?: RangeCondition | null
filter?: ListFilter,
sort?: ListSort,
range?: ListRange
) => {
await nodesStore.fetchNodes(
providerId,
serviceId,
currentLocation.value === ROOT_ID ? null : currentLocation.value,
false,
currentLocation.value === ROOT_ID ? ROOT_ID : currentLocation.value,
filter,
sort,
range
@@ -104,12 +104,12 @@ export function useFileManager(options: UseFileManagerOptions) {
}
// Create a new folder
const createFolder = async (label: string): Promise<FileCollectionObject> => {
const createFolder = async (label: string): Promise<CollectionObject> => {
return await nodesStore.createCollection(
providerId,
serviceId,
currentLocation.value === ROOT_ID ? ROOT_ID : currentLocation.value,
{ label }
{ label, owner: '' }
)
}
@@ -117,12 +117,22 @@ export function useFileManager(options: UseFileManagerOptions) {
const createFile = async (
label: string,
mime: string = 'application/octet-stream'
): Promise<FileEntityObject> => {
): Promise<EntityObject> => {
const properties: DocumentInterface = {
'@type': 'documents.properties',
urid: null,
size: 0,
label,
mime,
format: null,
encoding: null,
}
return await nodesStore.createEntity(
providerId,
serviceId,
currentLocation.value === ROOT_ID ? ROOT_ID : currentLocation.value,
{ label, mime }
properties
)
}
@@ -133,10 +143,22 @@ export function useFileManager(options: UseFileManagerOptions) {
throw new Error('Node not found')
}
if (node['@type'] === 'files.collection') {
return await nodesStore.modifyCollection(providerId, serviceId, nodeId, { label: newLabel })
if (node instanceof CollectionObject) {
return await nodesStore.updateCollection(providerId, serviceId, nodeId, {
label: newLabel,
owner: node.properties.owner,
})
} else {
return await nodesStore.modifyEntity(providerId, serviceId, node.in, nodeId, { label: newLabel })
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(providerId, serviceId, node.collection, nodeId, properties)
}
}
@@ -147,61 +169,35 @@ export function useFileManager(options: UseFileManagerOptions) {
throw new Error('Node not found')
}
if (node['@type'] === 'files.collection') {
return await nodesStore.destroyCollection(providerId, serviceId, nodeId)
if (node instanceof CollectionObject) {
return await nodesStore.deleteCollection(providerId, serviceId, nodeId)
} else {
return await nodesStore.destroyEntity(providerId, serviceId, node.in, nodeId)
}
}
// Copy a node
const copyNode = async (nodeId: string, destinationId?: string | null) => {
const node = nodesStore.getNode(providerId, serviceId, nodeId)
if (!node) {
throw new Error('Node not found')
}
const destination = destinationId ?? currentLocation.value
if (node['@type'] === 'files.collection') {
return await nodesStore.copyCollection(providerId, serviceId, nodeId, destination)
} else {
return await nodesStore.copyEntity(providerId, serviceId, node.in, nodeId, destination)
}
}
// Move a node
const moveNode = async (nodeId: string, destinationId?: string | null) => {
const node = nodesStore.getNode(providerId, serviceId, nodeId)
if (!node) {
throw new Error('Node not found')
}
const destination = destinationId ?? currentLocation.value
if (node['@type'] === 'files.collection') {
return await nodesStore.moveCollection(providerId, serviceId, nodeId, destination)
} else {
return await nodesStore.moveEntity(providerId, serviceId, node.in, nodeId, destination)
return await nodesStore.deleteEntity(providerId, serviceId, node.collection, nodeId)
}
}
// Read file content
const readFile = async (entityId: string): Promise<string | null> => {
const node = nodesStore.getNode(providerId, serviceId, entityId)
if (!node || node['@type'] !== 'files.entity') {
if (!node || !(node instanceof EntityObject)) {
throw new Error('Entity not found')
}
return await nodesStore.readEntity(providerId, serviceId, node.in || ROOT_ID, entityId)
return await nodesStore.readEntity(providerId, serviceId, node.collection || ROOT_ID, entityId)
}
// Write file content
const writeFile = async (entityId: string, content: string): Promise<number> => {
const node = nodesStore.getNode(providerId, serviceId, entityId)
if (!node || node['@type'] !== 'files.entity') {
if (!node || !(node instanceof EntityObject)) {
throw new Error('Entity not found')
}
return await nodesStore.writeEntity(providerId, serviceId, node.in, entityId, content)
return await nodesStore.writeEntity(providerId, serviceId, 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(providerId)}/${encodeURIComponent(serviceId)}/${encodeURIComponent(collection)}/${encodeURIComponent(entityId)}`
}
// Download a single file
@@ -214,7 +210,6 @@ export function useFileManager(options: UseFileManagerOptions) {
window.open(url, '_blank')
}
// Download a collection (folder) as ZIP
const downloadCollection = (collectionId: string): void => {
// Use path parameters: /download/collection/{provider}/{service}/{identifier}
const url = `${TRANSFER_BASE_URL}/download/collection/${encodeURIComponent(providerId)}/${encodeURIComponent(serviceId)}/${encodeURIComponent(collectionId)}`
@@ -222,7 +217,6 @@ export function useFileManager(options: UseFileManagerOptions) {
window.open(url, '_blank')
}
// Download multiple items as ZIP archive
const downloadArchive = (ids: string[], name: string = 'download', collectionId?: string | null): void => {
const collection = collectionId ?? currentLocation.value
const params = new URLSearchParams({
@@ -244,12 +238,8 @@ export function useFileManager(options: UseFileManagerOptions) {
// Initialize - fetch providers, services, and initial nodes if autoFetch
const initialize = async () => {
if (!providersStore.initialized) {
await providersStore.fetchProviders()
}
if (!servicesStore.initialized) {
await servicesStore.fetchServices()
}
await providersStore.list()
await servicesStore.list({ [providerId]: true })
if (autoFetch) {
await refresh()
}
@@ -284,10 +274,9 @@ export function useFileManager(options: UseFileManagerOptions) {
createFile,
renameNode,
deleteNode,
copyNode,
moveNode,
readFile,
writeFile,
getEntityUrl,
downloadEntity,
downloadCollection,
downloadArchive,

View File

@@ -5,10 +5,10 @@
import { ref, computed } from 'vue'
import type { Ref, ComputedRef } from 'vue'
import { FileCollectionObject } from '@FilesManager/models/collection'
import { FileEntityObject } from '@FilesManager/models/entity'
import { CollectionObject } from '@DocumentsManager/models/collection'
import { EntityObject } from '@DocumentsManager/models/entity'
type NodeRecord = FileCollectionObject | FileEntityObject
type NodeRecord = CollectionObject | EntityObject
export interface UseFileSelectionOptions {
multiple?: boolean
@@ -17,148 +17,110 @@ export interface UseFileSelectionOptions {
}
export function useFileSelection(options: UseFileSelectionOptions = {}) {
const {
multiple = true,
allowFolders = true,
allowFiles = true
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())
// Get selected count
const count: ComputedRef<number> = computed(() => selectedIds.value.size)
// Check if any selected
const hasSelection: ComputedRef<boolean> = computed(() => selectedIds.value.size > 0)
// Get selected IDs as array
const selectedIdArray: ComputedRef<string[]> = computed(() =>
Array.from(selectedIds.value)
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),
)
// Get selected nodes as array
const selectedNodeArray: ComputedRef<NodeRecord[]> = computed(() =>
Array.from(selectedNodes.value.values())
const selectedEntities: ComputedRef<EntityObject[]> = computed(() =>
selectedNodeArray.value.filter((node): node is EntityObject => node instanceof EntityObject),
)
// Get selected collections only
const selectedCollections: ComputedRef<FileCollectionObject[]> = computed(() =>
selectedNodeArray.value.filter(
(node): node is FileCollectionObject => node['@type'] === 'files.collection'
)
)
const isSelected = (nodeId: string): boolean => selectedIds.value.has(nodeId)
// Get selected entities only
const selectedEntities: ComputedRef<FileEntityObject[]> = computed(() =>
selectedNodeArray.value.filter(
(node): node is FileEntityObject => node['@type'] === 'files.entity'
)
)
// Check if a node is selected
const isSelected = (nodeId: string): boolean => {
return selectedIds.value.has(nodeId)
}
// Check if node type is allowed
const isTypeAllowed = (node: NodeRecord): boolean => {
if (node['@type'] === 'files.collection' && !allowFolders) {
return false
}
if (node['@type'] === 'files.entity' && !allowFiles) {
return false
}
if (node instanceof CollectionObject && !allowFolders) return false
if (node instanceof EntityObject && !allowFiles) return false
return true
}
// Select a node
const select = (node: NodeRecord) => {
if (!isTypeAllowed(node)) {
return
}
if (!isTypeAllowed(node)) return
if (!multiple) {
// Clear previous selection for single select
selectedIds.value.clear()
selectedNodes.value.clear()
}
selectedIds.value.add(node.id)
selectedNodes.value.set(node.id, node)
const nodeId = String(node.identifier)
selectedIds.value.add(nodeId)
selectedNodes.value.set(nodeId, node)
}
// Deselect a node
const deselect = (nodeId: string) => {
selectedIds.value.delete(nodeId)
selectedNodes.value.delete(nodeId)
}
// Toggle selection
const toggle = (node: NodeRecord) => {
if (isSelected(node.id)) {
deselect(node.id)
} else {
select(node)
const nodeId = String(node.identifier)
if (isSelected(nodeId)) {
deselect(nodeId)
return
}
select(node)
}
// Select multiple nodes
const selectMultiple = (nodes: NodeRecord[]) => {
if (!multiple) {
// For single select, only select the last one
const lastNode = nodes[nodes.length - 1]
if (lastNode && isTypeAllowed(lastNode)) {
selectedIds.value.clear()
selectedNodes.value.clear()
selectedIds.value.add(lastNode.id)
selectedNodes.value.set(lastNode.id, lastNode)
const nodeId = String(lastNode.identifier)
selectedIds.value.add(nodeId)
selectedNodes.value.set(nodeId, lastNode)
}
return
}
for (const node of nodes) {
if (isTypeAllowed(node)) {
selectedIds.value.add(node.id)
selectedNodes.value.set(node.id, node)
const nodeId = String(node.identifier)
selectedIds.value.add(nodeId)
selectedNodes.value.set(nodeId, node)
}
}
}
// Select all from a list
const selectAll = (nodes: NodeRecord[]) => {
if (!multiple) {
return
}
if (!multiple) return
selectMultiple(nodes)
}
// Clear selection
const clear = () => {
selectedIds.value.clear()
selectedNodes.value.clear()
}
// Set selection (replace current)
const setSelection = (nodes: NodeRecord[]) => {
clear()
selectMultiple(nodes)
}
return {
// State
selectedIds,
selectedNodes,
// Computed
count,
hasSelection,
selectedIdArray,
selectedNodeArray,
selectedCollections,
selectedEntities,
// Methods
isSelected,
select,
deselect,

View File

@@ -6,15 +6,16 @@
import { ref, computed } from 'vue'
import type { Ref, ComputedRef } from 'vue'
import { useNodesStore, ROOT_ID } from '@FilesManager/stores/nodesStore'
import { FileEntityObject } from '@FilesManager/models/entity'
import { useNodesStore, ROOT_ID } from '@DocumentsManager/stores/nodesStore'
import { EntityObject } from '@DocumentsManager/models/entity'
import type { DocumentInterface } from '@DocumentsManager/types/document'
export interface FileUploadProgress {
file: File
progress: number
status: 'pending' | 'uploading' | 'completed' | 'error'
error?: string
entity?: FileEntityObject
entity?: EntityObject
/** Relative path within folder upload (e.g., "folder/subfolder/file.txt") */
relativePath?: string
}
@@ -230,9 +231,9 @@ export function useFileUpload(options: UseFileUploadOptions) {
providerId,
serviceId,
parentId,
{ label: folderName }
{ label: folderName, owner: '' }
)
folderIdMap.set(folderPath, collection.id)
folderIdMap.set(folderPath, String(collection.identifier))
} catch (e) {
console.error(`Failed to create folder: ${folderPath}`, e)
// Try to continue with other folders
@@ -246,7 +247,7 @@ export function useFileUpload(options: UseFileUploadOptions) {
const uploadFile = async (
uploadId: string,
folderIdMap?: Map<string, string>
): Promise<FileEntityObject | null> => {
): Promise<EntityObject | null> => {
const upload = uploads.value.get(uploadId)
if (!upload || upload.status !== 'pending') {
return null
@@ -278,12 +279,16 @@ export function useFileUpload(options: UseFileUploadOptions) {
const entity = await nodesStore.createEntity(
providerId,
serviceId,
targetCollection,
targetCollection || ROOT_ID,
{
'@type': 'documents.properties',
urid: null,
format: null,
encoding: null,
label: upload.file.name,
mime: upload.file.type || 'application/octet-stream',
size: upload.file.size,
}
} as DocumentInterface
)
upload.progress = 75
@@ -292,8 +297,8 @@ export function useFileUpload(options: UseFileUploadOptions) {
await nodesStore.writeEntity(
providerId,
serviceId,
targetCollection,
entity.id,
targetCollection || ROOT_ID,
String(entity.identifier),
content
)
@@ -325,9 +330,9 @@ export function useFileUpload(options: UseFileUploadOptions) {
}
// Upload all pending files
const uploadAll = async (): Promise<FileEntityObject[]> => {
const uploadAll = async (): Promise<EntityObject[]> => {
isUploading.value = true
const entities: FileEntityObject[] = []
const entities: EntityObject[] = []
try {
// Check if any uploads have relative paths (folder upload)
@@ -377,7 +382,7 @@ export function useFileUpload(options: UseFileUploadOptions) {
}
// Retry a failed upload
const retryUpload = async (uploadId: string): Promise<FileEntityObject | null> => {
const retryUpload = async (uploadId: string): Promise<EntityObject | null> => {
const upload = uploads.value.get(uploadId)
if (!upload || upload.status !== 'error') {
return null

View File

@@ -0,0 +1,81 @@
/**
* useFileViewer — resolves which registered viewer can handle a given MIME type.
*
* Other modules register viewers at the `documents_file_viewer` integration
* point by including an entry in their `integrations.ts`:
*
* ```ts
* documents_file_viewer: [{
* id: 'my_viewer',
* meta: { mimeTypes: ['application/pdf'] },
* component: () => import('./components/MyViewer.vue'),
* }]
* ```
*
* Viewer components receive the props: `url: string`, `entity: EntityObject`, `mime: string`.
*/
import { useIntegrationStore } from '@KTXC'
import type { EntityObject } from '@DocumentsManager/models/entity'
const INTEGRATION_POINT = 'documents_file_viewer'
function mimeMatchesPattern(mime: string, pattern: string): boolean {
if (pattern.endsWith('/*')) {
// e.g. 'image/*' matches 'image/png', 'image/jpeg', …
return mime.startsWith(pattern.slice(0, -1))
}
return mime === pattern
}
function viewerMatchesMime(
mime: string,
mimeTypes?: string[],
mimePatterns?: string[],
): boolean {
if (mimeTypes?.includes(mime)) return true
if (mimePatterns) {
for (const pattern of mimePatterns) {
if (mimeMatchesPattern(mime, pattern)) return true
}
}
return false
}
export function useFileViewer() {
const integrationStore = useIntegrationStore()
/**
* Returns the highest-priority registered viewer that can handle `mime`,
* or `null` if none is found.
*/
function findViewer(mime: string) {
// getItems() already returns items sorted by priority (ascending)
const viewers = integrationStore.getItems(INTEGRATION_POINT)
for (const viewer of viewers) {
if (
viewerMatchesMime(
mime,
viewer.meta?.mimeTypes as string[] | undefined,
viewer.meta?.mimePatterns as string[] | undefined,
)
) {
return viewer
}
}
return null
}
/**
* Convenience: returns true if any registered viewer can open this entity.
*/
function canOpen(entity: EntityObject): boolean {
const mime = entity.properties.mime
if (!mime) return false
return findViewer(mime) !== null
}
return { findViewer, canOpen }
}
export type UseFileViewerReturn = ReturnType<typeof useFileViewer>