Initial commit

This commit is contained in:
root
2025-12-21 09:57:09 -05:00
committed by Sebastian Krupinski
commit 8ac20d8b45
38 changed files with 4677 additions and 0 deletions

562
src/pages/FilesPage.vue Normal file
View 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>