Initial commit
This commit is contained in:
562
src/pages/FilesPage.vue
Normal file
562
src/pages/FilesPage.vue
Normal file
@@ -0,0 +1,562 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useModuleStore } from '@KTXC/stores/moduleStore'
|
||||
import { useFileManager, useFileSelection, useFileUpload } from '@/composables'
|
||||
import type { ViewMode, SortField, SortOrder, BreadcrumbItem } from '@/types'
|
||||
import { FileCollectionObject } from '@FileManager/models/collection'
|
||||
import { FileEntityObject } from '@FileManager/models/entity'
|
||||
|
||||
// Components
|
||||
import {
|
||||
FilesToolbar,
|
||||
FilesSidebar,
|
||||
FilesBreadcrumbs,
|
||||
FilesEmptyState,
|
||||
FilesDragOverlay,
|
||||
FilesInfoPanel,
|
||||
FilesGridView,
|
||||
FilesListView,
|
||||
FilesDetailsView,
|
||||
NewFolderDialog,
|
||||
RenameDialog,
|
||||
DeleteConfirmDialog,
|
||||
UploadDialog,
|
||||
} from '@/components'
|
||||
|
||||
// Check if file manager is available
|
||||
const moduleStore = useModuleStore()
|
||||
const isFileManagerAvailable = computed(() => {
|
||||
return moduleStore.has('file_manager') || moduleStore.has('FileManager')
|
||||
})
|
||||
|
||||
// Active provider/service (will be selectable later)
|
||||
const activeProviderId = ref('default')
|
||||
const activeServiceId = ref('personal')
|
||||
|
||||
// File manager composable
|
||||
const fileManager = useFileManager({
|
||||
providerId: activeProviderId.value,
|
||||
serviceId: activeServiceId.value,
|
||||
})
|
||||
|
||||
// Selection composable
|
||||
const selection = useFileSelection({ multiple: true })
|
||||
|
||||
// Upload composable
|
||||
const upload = useFileUpload({
|
||||
providerId: activeProviderId.value,
|
||||
serviceId: activeServiceId.value,
|
||||
})
|
||||
|
||||
// Keep upload collection in sync with current location
|
||||
watch(() => fileManager.currentLocation.value, (newLocation) => {
|
||||
upload.setCollection(newLocation)
|
||||
})
|
||||
|
||||
// View state
|
||||
const viewMode = ref<ViewMode>('grid')
|
||||
const sortField = ref<SortField>('label')
|
||||
const sortOrder = ref<SortOrder>('asc')
|
||||
const sidebarVisible = ref(true)
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Dialogs
|
||||
const showNewFolderDialog = ref(false)
|
||||
const showRenameDialog = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const showUploadDialog = ref(false)
|
||||
const nodeToRename = ref<FileCollectionObject | FileEntityObject | null>(null)
|
||||
const nodesToDelete = ref<(FileCollectionObject | FileEntityObject)[]>([])
|
||||
|
||||
// Drag and drop state
|
||||
const isDragOver = ref(false)
|
||||
|
||||
// Hidden file inputs
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const folderInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// Computed
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => {
|
||||
const items: BreadcrumbItem[] = [
|
||||
{ id: fileManager.ROOT_ID, label: 'Home', isRoot: true }
|
||||
]
|
||||
|
||||
for (const node of fileManager.breadcrumbs.value) {
|
||||
items.push({
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
isRoot: false,
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const sortedItems = computed(() => {
|
||||
const collections = [...fileManager.currentCollections.value]
|
||||
const entities = [...fileManager.currentEntities.value]
|
||||
|
||||
// Sort collections
|
||||
collections.sort((a, b) => {
|
||||
const aVal = a[sortField.value as keyof FileCollectionObject] ?? ''
|
||||
const bVal = b[sortField.value as keyof FileCollectionObject] ?? ''
|
||||
const cmp = String(aVal).localeCompare(String(bVal))
|
||||
return sortOrder.value === 'asc' ? cmp : -cmp
|
||||
})
|
||||
|
||||
// Sort entities
|
||||
entities.sort((a, b) => {
|
||||
const aVal = a[sortField.value as keyof FileEntityObject] ?? ''
|
||||
const bVal = b[sortField.value as keyof FileEntityObject] ?? ''
|
||||
const cmp = String(aVal).localeCompare(String(bVal))
|
||||
return sortOrder.value === 'asc' ? cmp : -cmp
|
||||
})
|
||||
|
||||
// Filter by search
|
||||
const filterFn = (item: FileCollectionObject | FileEntityObject) => {
|
||||
if (!searchQuery.value) return true
|
||||
return item.label.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
}
|
||||
|
||||
return {
|
||||
collections: collections.filter(filterFn),
|
||||
entities: entities.filter(filterFn),
|
||||
}
|
||||
})
|
||||
|
||||
const hasItems = computed(() =>
|
||||
sortedItems.value.collections.length > 0 || sortedItems.value.entities.length > 0
|
||||
)
|
||||
|
||||
const selectedIds = computed(() => selection.selectedIds.value)
|
||||
|
||||
// Navigation methods
|
||||
async function handleBreadcrumbNavigate(item: BreadcrumbItem) {
|
||||
selection.clear()
|
||||
await fileManager.navigateTo(item.isRoot ? null : item.id)
|
||||
}
|
||||
|
||||
async function handleFolderOpen(folder: FileCollectionObject) {
|
||||
selection.clear()
|
||||
await fileManager.navigateTo(folder.id)
|
||||
}
|
||||
|
||||
// Item interaction methods
|
||||
function handleItemClick(item: FileCollectionObject | FileEntityObject, event: MouseEvent | KeyboardEvent) {
|
||||
const hasCtrl = event.ctrlKey || event.metaKey
|
||||
const hasShift = event.shiftKey
|
||||
|
||||
if (hasCtrl) {
|
||||
// Ctrl+click: toggle selection
|
||||
selection.toggle(item)
|
||||
} else if (hasShift && selection.hasSelection.value) {
|
||||
// Shift+click: extend selection
|
||||
selection.select(item)
|
||||
} else {
|
||||
// Single click behavior depends on item type
|
||||
if (item['@type'] === 'files.collection') {
|
||||
// Folders: navigate into them
|
||||
selection.clear()
|
||||
handleFolderOpen(item as FileCollectionObject)
|
||||
} else {
|
||||
// Files: show info panel
|
||||
selection.clear()
|
||||
selection.select(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show details panel for an item
|
||||
function handleShowDetails(item: FileCollectionObject | FileEntityObject) {
|
||||
selection.clear()
|
||||
selection.select(item)
|
||||
}
|
||||
|
||||
// Folder operations
|
||||
async function handleCreateFolder(name: string) {
|
||||
try {
|
||||
await fileManager.createFolder(name)
|
||||
showNewFolderDialog.value = false
|
||||
} catch (error) {
|
||||
console.error('Failed to create folder:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Rename operations - for specific item
|
||||
function handleRenameItem(item: FileCollectionObject | FileEntityObject) {
|
||||
nodeToRename.value = item
|
||||
showRenameDialog.value = true
|
||||
}
|
||||
|
||||
async function handleRename(newName: string) {
|
||||
if (!nodeToRename.value) return
|
||||
|
||||
try {
|
||||
await fileManager.renameNode(nodeToRename.value.id, newName)
|
||||
showRenameDialog.value = false
|
||||
nodeToRename.value = null
|
||||
} catch (error) {
|
||||
console.error('Failed to rename:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete operations - for specific item
|
||||
function handleDeleteItem(item: FileCollectionObject | FileEntityObject) {
|
||||
nodesToDelete.value = [item]
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
for (const node of nodesToDelete.value) {
|
||||
await fileManager.deleteNode(node.id)
|
||||
}
|
||||
selection.clear()
|
||||
showDeleteDialog.value = false
|
||||
nodesToDelete.value = []
|
||||
} catch (error) {
|
||||
console.error('Failed to delete:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Download operation
|
||||
function handleDownloadItem(item: FileCollectionObject | FileEntityObject) {
|
||||
if (item['@type'] === 'files.entity') {
|
||||
// Download single file
|
||||
fileManager.downloadEntity(item.id, item.in)
|
||||
} else if (item['@type'] === 'files.collection') {
|
||||
// Download folder as ZIP
|
||||
fileManager.downloadCollection(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
// File picker methods
|
||||
function openFilePicker() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function openFolderPicker() {
|
||||
folderInputRef.value?.click()
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (input.files && input.files.length > 0) {
|
||||
upload.addFiles(input.files)
|
||||
showUploadDialog.value = true
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function handleFolderSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (input.files && input.files.length > 0) {
|
||||
upload.addFilesWithPaths(input.files)
|
||||
showUploadDialog.value = true
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Drag and drop methods
|
||||
function handleDragOver(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
isDragOver.value = true
|
||||
}
|
||||
|
||||
function handleDragLeave(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
async function handleDrop(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
isDragOver.value = false
|
||||
|
||||
if (!event.dataTransfer) return
|
||||
|
||||
const items = event.dataTransfer.items
|
||||
if (items && items.length > 0) {
|
||||
const entries: FileSystemEntry[] = []
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const entry = items[i].webkitGetAsEntry?.()
|
||||
if (entry) {
|
||||
entries.push(entry)
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length > 0) {
|
||||
const filesWithPaths = await readEntriesRecursively(entries)
|
||||
if (filesWithPaths.length > 0) {
|
||||
upload.addFilesWithPaths(filesWithPaths)
|
||||
showUploadDialog.value = true
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (event.dataTransfer.files && event.dataTransfer.files.length > 0) {
|
||||
upload.addFiles(event.dataTransfer.files)
|
||||
showUploadDialog.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function readEntriesRecursively(
|
||||
entries: FileSystemEntry[],
|
||||
basePath: string = ''
|
||||
): Promise<{ file: File; relativePath: string }[]> {
|
||||
const results: { file: File; relativePath: string }[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile) {
|
||||
const fileEntry = entry as FileSystemFileEntry
|
||||
const file = await new Promise<File>((resolve, reject) => {
|
||||
fileEntry.file(resolve, reject)
|
||||
})
|
||||
const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name
|
||||
results.push({ file, relativePath })
|
||||
} else if (entry.isDirectory) {
|
||||
const dirEntry = entry as FileSystemDirectoryEntry
|
||||
const dirReader = dirEntry.createReader()
|
||||
const subEntries = await new Promise<FileSystemEntry[]>((resolve, reject) => {
|
||||
const allEntries: FileSystemEntry[] = []
|
||||
const readBatch = () => {
|
||||
dirReader.readEntries((batch) => {
|
||||
if (batch.length === 0) {
|
||||
resolve(allEntries)
|
||||
} else {
|
||||
allEntries.push(...batch)
|
||||
readBatch()
|
||||
}
|
||||
}, reject)
|
||||
}
|
||||
readBatch()
|
||||
})
|
||||
const subPath = basePath ? `${basePath}/${entry.name}` : entry.name
|
||||
const subResults = await readEntriesRecursively(subEntries, subPath)
|
||||
results.push(...subResults)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// Upload methods
|
||||
async function handleUploadAll() {
|
||||
await upload.uploadAll()
|
||||
await fileManager.refresh()
|
||||
}
|
||||
|
||||
function handleUploadDialogClose() {
|
||||
showUploadDialog.value = false
|
||||
upload.clearAll()
|
||||
}
|
||||
|
||||
// Refresh
|
||||
async function handleRefresh() {
|
||||
await fileManager.refresh()
|
||||
}
|
||||
|
||||
// Initialize
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await fileManager.initialize()
|
||||
await fileManager.refresh()
|
||||
console.log('[Files] - Initialized with:', {
|
||||
provider: activeProviderId.value,
|
||||
service: activeServiceId.value,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Files] - Failed to initialize:', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="files-container">
|
||||
<!-- Top Toolbar -->
|
||||
<FilesToolbar
|
||||
v-model:search-query="searchQuery"
|
||||
v-model:view-mode="viewMode"
|
||||
:is-loading="fileManager.isLoading.value"
|
||||
@toggle-sidebar="sidebarVisible = !sidebarVisible"
|
||||
@refresh="handleRefresh"
|
||||
@open-file-picker="openFilePicker"
|
||||
@open-folder-picker="openFolderPicker"
|
||||
@new-folder="showNewFolderDialog = true"
|
||||
/>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div
|
||||
class="files-content"
|
||||
@dragover="handleDragOver"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop"
|
||||
>
|
||||
<!-- Drag overlay -->
|
||||
<FilesDragOverlay :visible="isDragOver" />
|
||||
|
||||
<!-- Sidebar -->
|
||||
<FilesSidebar
|
||||
v-model="sidebarVisible"
|
||||
:active-service-id="activeServiceId"
|
||||
@navigate-home="fileManager.navigateToRoot()"
|
||||
/>
|
||||
|
||||
<!-- Main Area -->
|
||||
<div class="files-main">
|
||||
<!-- Breadcrumbs -->
|
||||
<FilesBreadcrumbs
|
||||
:items="breadcrumbs"
|
||||
@navigate="handleBreadcrumbNavigate"
|
||||
/>
|
||||
|
||||
<!-- Loading -->
|
||||
<v-progress-linear
|
||||
v-if="fileManager.isLoading.value"
|
||||
indeterminate
|
||||
color="primary"
|
||||
class="mx-4"
|
||||
/>
|
||||
|
||||
<!-- Error -->
|
||||
<v-alert
|
||||
v-if="fileManager.error.value"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
class="mx-4 mb-4"
|
||||
>
|
||||
{{ fileManager.error.value }}
|
||||
</v-alert>
|
||||
|
||||
<!-- File Manager not available -->
|
||||
<v-alert
|
||||
v-if="!isFileManagerAvailable"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
class="mx-4 mb-4"
|
||||
>
|
||||
File Manager module is not available. Please ensure it is installed and enabled.
|
||||
</v-alert>
|
||||
|
||||
<!-- Empty state -->
|
||||
<FilesEmptyState v-else-if="!hasItems && !fileManager.isLoading.value" />
|
||||
|
||||
<!-- Grid View -->
|
||||
<FilesGridView
|
||||
v-else-if="viewMode === 'grid'"
|
||||
:collections="sortedItems.collections"
|
||||
:entities="sortedItems.entities"
|
||||
:selected-ids="selectedIds"
|
||||
@item-click="handleItemClick"
|
||||
@rename="handleRenameItem"
|
||||
@delete="handleDeleteItem"
|
||||
@download="handleDownloadItem"
|
||||
@show-details="handleShowDetails"
|
||||
/>
|
||||
|
||||
<!-- List View -->
|
||||
<FilesListView
|
||||
v-else-if="viewMode === 'list'"
|
||||
:collections="sortedItems.collections"
|
||||
:entities="sortedItems.entities"
|
||||
:selected-ids="selectedIds"
|
||||
@item-click="handleItemClick"
|
||||
@rename="handleRenameItem"
|
||||
@delete="handleDeleteItem"
|
||||
@download="handleDownloadItem"
|
||||
@show-details="handleShowDetails"
|
||||
/>
|
||||
|
||||
<!-- Details View -->
|
||||
<FilesDetailsView
|
||||
v-else-if="viewMode === 'details'"
|
||||
:collections="sortedItems.collections"
|
||||
:entities="sortedItems.entities"
|
||||
:selected-ids="selectedIds"
|
||||
@item-click="handleItemClick"
|
||||
@rename="handleRenameItem"
|
||||
@delete="handleDeleteItem"
|
||||
@download="handleDownloadItem"
|
||||
@show-details="handleShowDetails"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden file inputs -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
multiple
|
||||
style="display: none;"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
<input
|
||||
ref="folderInputRef"
|
||||
type="file"
|
||||
webkitdirectory
|
||||
style="display: none;"
|
||||
@change="handleFolderSelect"
|
||||
/>
|
||||
|
||||
<!-- Dialogs -->
|
||||
<NewFolderDialog
|
||||
v-model="showNewFolderDialog"
|
||||
@create="handleCreateFolder"
|
||||
/>
|
||||
|
||||
<RenameDialog
|
||||
v-model="showRenameDialog"
|
||||
:current-name="nodeToRename?.label ?? ''"
|
||||
@rename="handleRename"
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
v-model="showDeleteDialog"
|
||||
:item-count="nodesToDelete.length"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
|
||||
<UploadDialog
|
||||
v-model="showUploadDialog"
|
||||
:uploads="upload.uploads.value"
|
||||
:total-progress="upload.totalProgress.value"
|
||||
:is-uploading="upload.isUploading.value"
|
||||
:pending-count="upload.pendingUploads.value.length"
|
||||
:completed-count="upload.completedUploads.value.length"
|
||||
@upload-all="handleUploadAll"
|
||||
@remove-upload="upload.removeUpload"
|
||||
@retry-upload="upload.retryUpload"
|
||||
@add-files="openFilePicker"
|
||||
@close="handleUploadDialogClose"
|
||||
/>
|
||||
|
||||
<!-- Info Panel (right side) -->
|
||||
<FilesInfoPanel
|
||||
:selected-items="selection.selectedNodeArray.value"
|
||||
@close="selection.clear()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.files-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.files-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.files-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user