Initial commit
This commit is contained in:
78
src/components/FileActionsMenu.vue
Normal file
78
src/components/FileActionsMenu.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { FileCollectionObject } from '@FileManager/models/collection'
|
||||
import { FileEntityObject } from '@FileManager/models/entity'
|
||||
|
||||
defineProps<{
|
||||
item: FileCollectionObject | FileEntityObject
|
||||
size?: 'x-small' | 'small' | 'default'
|
||||
variant?: 'text' | 'flat' | 'elevated' | 'tonal' | 'outlined' | 'plain'
|
||||
buttonClass?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'rename': [item: FileCollectionObject | FileEntityObject]
|
||||
'delete': [item: FileCollectionObject | FileEntityObject]
|
||||
'download': [item: FileCollectionObject | FileEntityObject]
|
||||
'show-details': [item: FileCollectionObject | FileEntityObject]
|
||||
}>()
|
||||
|
||||
const menuOpen = ref(false)
|
||||
|
||||
function handleAction(action: string, item: FileCollectionObject | FileEntityObject, event: Event) {
|
||||
event.stopPropagation()
|
||||
menuOpen.value = false
|
||||
|
||||
if (action === 'rename') {
|
||||
emit('rename', item)
|
||||
} else if (action === 'delete') {
|
||||
emit('delete', item)
|
||||
} else if (action === 'download') {
|
||||
emit('download', item)
|
||||
} else if (action === 'details') {
|
||||
emit('show-details', item)
|
||||
}
|
||||
}
|
||||
|
||||
function isEntity(item: FileCollectionObject | FileEntityObject): item is FileEntityObject {
|
||||
return item['@type'] === 'files.entity'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-menu v-model="menuOpen" location="bottom end">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
icon="mdi-dots-vertical"
|
||||
:size="size ?? 'small'"
|
||||
:variant="variant ?? 'text'"
|
||||
:class="buttonClass"
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item @click="(e: Event) => handleAction('details', item, e)">
|
||||
<template #prepend><v-icon size="small">mdi-information-outline</v-icon></template>
|
||||
<v-list-item-title>Details</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="isEntity(item)" @click="(e: Event) => handleAction('download', item, e)">
|
||||
<template #prepend><v-icon size="small">mdi-download</v-icon></template>
|
||||
<v-list-item-title>Download</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item v-else @click="(e: Event) => handleAction('download', item, e)">
|
||||
<template #prepend><v-icon size="small">mdi-folder-download</v-icon></template>
|
||||
<v-list-item-title>Download as ZIP</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="(e: Event) => handleAction('rename', item, e)">
|
||||
<template #prepend><v-icon size="small">mdi-pencil</v-icon></template>
|
||||
<v-list-item-title>Rename</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-divider />
|
||||
<v-list-item @click="(e: Event) => handleAction('delete', item, e)" class="text-error">
|
||||
<template #prepend><v-icon size="small" color="error">mdi-delete</v-icon></template>
|
||||
<v-list-item-title>Delete</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
32
src/components/FilesBreadcrumbs.vue
Normal file
32
src/components/FilesBreadcrumbs.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import type { BreadcrumbItem } from '@/types'
|
||||
|
||||
defineProps<{
|
||||
items: BreadcrumbItem[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'navigate': [item: BreadcrumbItem]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-breadcrumbs :items="items as any" class="px-4 py-2">
|
||||
<template #item="{ item }">
|
||||
<v-breadcrumbs-item
|
||||
:disabled="false"
|
||||
class="breadcrumb-clickable"
|
||||
@click="emit('navigate', (item as unknown) as BreadcrumbItem)"
|
||||
>
|
||||
<v-icon v-if="((item as unknown) as BreadcrumbItem).isRoot" size="small" class="mr-1">mdi-home</v-icon>
|
||||
{{ ((item as unknown) as BreadcrumbItem).label }}
|
||||
</v-breadcrumbs-item>
|
||||
</template>
|
||||
</v-breadcrumbs>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.breadcrumb-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
30
src/components/FilesDragOverlay.vue
Normal file
30
src/components/FilesDragOverlay.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="files-drag-overlay">
|
||||
<v-icon size="64" color="primary">mdi-cloud-upload</v-icon>
|
||||
<div class="text-h6 mt-4">Drop files here to upload</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.files-drag-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(var(--v-theme-primary), 0.1);
|
||||
border: 3px dashed rgb(var(--v-theme-primary));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
27
src/components/FilesEmptyState.vue
Normal file
27
src/components/FilesEmptyState.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
message?: string
|
||||
submessage?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="files-empty">
|
||||
<v-icon size="64" color="grey-lighten-1">mdi-folder-open-outline</v-icon>
|
||||
<div class="text-h6 mt-4 text-grey">{{ message || 'This folder is empty' }}</div>
|
||||
<div class="text-body-2 text-grey-darken-1">
|
||||
{{ submessage || 'Drop files here or click "New Folder" to create one' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.files-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
}
|
||||
</style>
|
||||
182
src/components/FilesInfoPanel.vue
Normal file
182
src/components/FilesInfoPanel.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { FileCollectionObject } from '@FileManager/models/collection'
|
||||
import { FileEntityObject } from '@FileManager/models/entity'
|
||||
import { getFileIcon, formatSize, formatDate } from '@/utils/fileHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
selectedItems: (FileCollectionObject | FileEntityObject)[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'close': []
|
||||
}>()
|
||||
|
||||
const hasSelection = computed(() => props.selectedItems.length > 0)
|
||||
const singleSelection = computed(() => props.selectedItems.length === 1)
|
||||
const selectedItem = computed(() => props.selectedItems[0] ?? null)
|
||||
|
||||
const isCollection = computed(() =>
|
||||
selectedItem.value?.['@type'] === 'files.collection'
|
||||
)
|
||||
|
||||
const isEntity = computed(() =>
|
||||
selectedItem.value?.['@type'] === 'files.entity'
|
||||
)
|
||||
|
||||
const entity = computed(() =>
|
||||
isEntity.value ? selectedItem.value as FileEntityObject : null
|
||||
)
|
||||
|
||||
// Computed display values
|
||||
const itemIcon = computed(() => {
|
||||
if (!selectedItem.value) return 'mdi-file'
|
||||
if (isCollection.value) return 'mdi-folder'
|
||||
return getFileIcon(entity.value!)
|
||||
})
|
||||
|
||||
const itemIconColor = computed(() => {
|
||||
if (isCollection.value) return 'amber-darken-2'
|
||||
return 'grey'
|
||||
})
|
||||
|
||||
const totalSize = computed(() => {
|
||||
if (props.selectedItems.length === 0) return 0
|
||||
return props.selectedItems.reduce((sum, item) => {
|
||||
if (item['@type'] === 'files.entity') {
|
||||
return sum + ((item as FileEntityObject).size || 0)
|
||||
}
|
||||
return sum
|
||||
}, 0)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-navigation-drawer
|
||||
:model-value="hasSelection"
|
||||
:key="selectedItems.map(i => i.id).join(',')"
|
||||
location="right"
|
||||
width="320"
|
||||
temporary
|
||||
class="files-info-panel"
|
||||
>
|
||||
<div class="pa-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex align-center mb-4">
|
||||
<span class="text-h6">Info</span>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Single item selected -->
|
||||
<template v-if="singleSelection && selectedItem">
|
||||
<!-- Icon and name -->
|
||||
<div class="text-center mb-4">
|
||||
<v-icon :color="itemIconColor" size="64">{{ itemIcon }}</v-icon>
|
||||
<div class="text-h6 mt-2 text-truncate">{{ selectedItem.label }}</div>
|
||||
<div class="text-caption text-grey">
|
||||
{{ isCollection ? 'Folder' : entity?.mime || 'File' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-divider class="mb-4" />
|
||||
|
||||
<!-- Details list -->
|
||||
<v-list density="compact" class="bg-transparent">
|
||||
<!-- Type -->
|
||||
<v-list-item>
|
||||
<template #prepend>
|
||||
<v-icon size="small" class="mr-3">mdi-tag-outline</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-caption text-grey">Type</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
{{ isCollection ? 'Folder' : (entity?.mime || 'Unknown') }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Size (only for files) -->
|
||||
<v-list-item v-if="isEntity && entity">
|
||||
<template #prepend>
|
||||
<v-icon size="small" class="mr-3">mdi-harddisk</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-caption text-grey">Size</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ formatSize(entity.size) }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Created -->
|
||||
<v-list-item v-if="selectedItem.createdOn">
|
||||
<template #prepend>
|
||||
<v-icon size="small" class="mr-3">mdi-calendar-plus</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-caption text-grey">Created</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ formatDate(selectedItem.createdOn) }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Modified -->
|
||||
<v-list-item v-if="selectedItem.modifiedOn">
|
||||
<template #prepend>
|
||||
<v-icon size="small" class="mr-3">mdi-calendar-edit</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-caption text-grey">Modified</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ formatDate(selectedItem.modifiedOn) }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
|
||||
<!-- ID -->
|
||||
<v-list-item>
|
||||
<template #prepend>
|
||||
<v-icon size="small" class="mr-3">mdi-identifier</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-caption text-grey">ID</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-truncate" style="font-family: monospace; font-size: 11px;">
|
||||
{{ selectedItem.id }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<!-- Multiple items selected -->
|
||||
<template v-else-if="selectedItems.length > 1">
|
||||
<div class="text-center mb-4">
|
||||
<v-icon color="primary" size="64">mdi-checkbox-multiple-marked</v-icon>
|
||||
<div class="text-h6 mt-2">{{ selectedItems.length }} items selected</div>
|
||||
</div>
|
||||
|
||||
<v-divider class="mb-4" />
|
||||
|
||||
<v-list density="compact" class="bg-transparent">
|
||||
<!-- Total size -->
|
||||
<v-list-item>
|
||||
<template #prepend>
|
||||
<v-icon size="small" class="mr-3">mdi-harddisk</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-caption text-grey">Total Size</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ formatSize(totalSize) }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Item breakdown -->
|
||||
<v-list-item>
|
||||
<template #prepend>
|
||||
<v-icon size="small" class="mr-3">mdi-file-multiple</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-caption text-grey">Contents</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
{{ selectedItems.filter(i => i['@type'] === 'files.collection').length }} folders,
|
||||
{{ selectedItems.filter(i => i['@type'] === 'files.entity').length }} files
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
</div>
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.files-info-panel {
|
||||
border-left: 1px solid rgb(var(--v-border-color)) !important;
|
||||
}
|
||||
</style>
|
||||
31
src/components/FilesSelectionToolbar.vue
Normal file
31
src/components/FilesSelectionToolbar.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
selectedCount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'rename': []
|
||||
'copy': []
|
||||
'cut': []
|
||||
'delete': []
|
||||
'clear': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-toolbar
|
||||
density="compact"
|
||||
color="primary"
|
||||
class="mx-4 mb-2 rounded"
|
||||
>
|
||||
<v-toolbar-title class="text-body-2">
|
||||
{{ selectedCount }} selected
|
||||
</v-toolbar-title>
|
||||
<v-spacer />
|
||||
<v-btn icon="mdi-pencil" size="small" variant="text" @click="emit('rename')" />
|
||||
<v-btn icon="mdi-content-copy" size="small" variant="text" disabled @click="emit('copy')" />
|
||||
<v-btn icon="mdi-content-cut" size="small" variant="text" disabled @click="emit('cut')" />
|
||||
<v-btn icon="mdi-delete" size="small" variant="text" @click="emit('delete')" />
|
||||
<v-btn icon="mdi-close" size="small" variant="text" @click="emit('clear')" />
|
||||
</v-toolbar>
|
||||
</template>
|
||||
71
src/components/FilesSidebar.vue
Normal file
71
src/components/FilesSidebar.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
const display = useDisplay()
|
||||
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
activeServiceId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'navigate-home': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-navigation-drawer
|
||||
:model-value="modelValue"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
:permanent="display.mdAndUp.value"
|
||||
:temporary="display.smAndDown.value"
|
||||
width="240"
|
||||
class="files-sidebar"
|
||||
>
|
||||
<div class="pa-4">
|
||||
<v-list density="compact" nav>
|
||||
<v-list-subheader>Quick Access</v-list-subheader>
|
||||
<v-list-item
|
||||
prepend-icon="mdi-home"
|
||||
title="Home"
|
||||
@click="emit('navigate-home')"
|
||||
/>
|
||||
<v-list-item
|
||||
prepend-icon="mdi-clock-outline"
|
||||
title="Recent"
|
||||
disabled
|
||||
/>
|
||||
<v-list-item
|
||||
prepend-icon="mdi-star-outline"
|
||||
title="Starred"
|
||||
disabled
|
||||
/>
|
||||
<v-list-item
|
||||
prepend-icon="mdi-trash-can-outline"
|
||||
title="Trash"
|
||||
disabled
|
||||
/>
|
||||
</v-list>
|
||||
|
||||
<v-divider class="my-4" />
|
||||
|
||||
<v-list density="compact" nav>
|
||||
<v-list-subheader>Storage</v-list-subheader>
|
||||
<v-list-item
|
||||
prepend-icon="mdi-harddisk"
|
||||
title="Personal"
|
||||
subtitle="Local storage"
|
||||
:active="activeServiceId === 'personal'"
|
||||
/>
|
||||
</v-list>
|
||||
</div>
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.files-sidebar {
|
||||
border-right: 1px solid rgb(var(--v-border-color)) !important;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
137
src/components/FilesToolbar.vue
Normal file
137
src/components/FilesToolbar.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ViewMode } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
searchQuery: string
|
||||
viewMode: ViewMode
|
||||
isLoading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:searchQuery': [value: string]
|
||||
'update:viewMode': [value: ViewMode]
|
||||
'toggle-sidebar': []
|
||||
'refresh': []
|
||||
'open-file-picker': []
|
||||
'open-folder-picker': []
|
||||
'new-folder': []
|
||||
}>()
|
||||
|
||||
const searchModel = computed({
|
||||
get: () => props.searchQuery,
|
||||
set: (value: string) => emit('update:searchQuery', value)
|
||||
})
|
||||
|
||||
const viewModeModel = computed({
|
||||
get: () => props.viewMode,
|
||||
set: (value: ViewMode) => emit('update:viewMode', value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-app-bar elevation="0" class="files-toolbar border-b">
|
||||
<template #prepend>
|
||||
<v-btn
|
||||
icon="mdi-menu"
|
||||
variant="text"
|
||||
@click="emit('toggle-sidebar')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<v-app-bar-title class="d-flex align-center">
|
||||
<v-icon size="28" color="primary" class="mr-2">mdi-folder-outline</v-icon>
|
||||
<span class="text-h6 font-weight-bold">Files</span>
|
||||
</v-app-bar-title>
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<!-- Search -->
|
||||
<v-text-field
|
||||
v-model="searchModel"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
placeholder="Search files..."
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
hide-details
|
||||
single-line
|
||||
class="mx-4"
|
||||
style="max-width: 300px;"
|
||||
/>
|
||||
|
||||
<!-- View toggle -->
|
||||
<v-btn-toggle
|
||||
v-model="viewModeModel"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
mandatory
|
||||
class="mr-2"
|
||||
>
|
||||
<v-btn value="grid" size="small">
|
||||
<v-icon>mdi-view-grid</v-icon>
|
||||
</v-btn>
|
||||
<v-btn value="list" size="small">
|
||||
<v-icon>mdi-view-list</v-icon>
|
||||
</v-btn>
|
||||
<v-btn value="details" size="small">
|
||||
<v-icon>mdi-view-headline</v-icon>
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
|
||||
<template #append>
|
||||
<v-btn
|
||||
icon="mdi-refresh"
|
||||
variant="text"
|
||||
@click="emit('refresh')"
|
||||
:loading="isLoading"
|
||||
/>
|
||||
<v-menu>
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
color="secondary"
|
||||
variant="tonal"
|
||||
class="mr-2"
|
||||
v-bind="menuProps"
|
||||
>
|
||||
<v-icon start>mdi-upload</v-icon>
|
||||
<span class="d-none d-sm-inline">Upload</span>
|
||||
<v-icon end size="small">mdi-chevron-down</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item @click="emit('open-file-picker')">
|
||||
<template #prepend>
|
||||
<v-icon>mdi-file-upload</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>Upload Files</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="emit('open-folder-picker')">
|
||||
<template #prepend>
|
||||
<v-icon>mdi-folder-upload</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>Upload Folder</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="elevated"
|
||||
@click="emit('new-folder')"
|
||||
>
|
||||
<v-icon start>mdi-folder-plus</v-icon>
|
||||
<span class="d-none d-sm-inline">New Folder</span>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-app-bar>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.files-toolbar {
|
||||
border-bottom: 1px solid rgb(var(--v-border-color)) !important;
|
||||
}
|
||||
|
||||
.border-b {
|
||||
border-bottom: 1px solid rgb(var(--v-border-color));
|
||||
}
|
||||
</style>
|
||||
32
src/components/dialogs/DeleteConfirmDialog.vue
Normal file
32
src/components/dialogs/DeleteConfirmDialog.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
itemCount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'confirm': []
|
||||
}>()
|
||||
|
||||
function handleClose() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog :model-value="modelValue" @update:model-value="emit('update:modelValue', $event)" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title>Delete</v-card-title>
|
||||
<v-card-text>
|
||||
Are you sure you want to delete {{ itemCount }} item(s)?
|
||||
This action cannot be undone.
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="handleClose">Cancel</v-btn>
|
||||
<v-btn color="error" variant="elevated" @click="emit('confirm')">Delete</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
50
src/components/dialogs/NewFolderDialog.vue
Normal file
50
src/components/dialogs/NewFolderDialog.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'create': [name: string]
|
||||
}>()
|
||||
|
||||
const folderName = ref('')
|
||||
|
||||
watch(() => props.modelValue, (isOpen) => {
|
||||
if (isOpen) {
|
||||
folderName.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
function handleCreate() {
|
||||
if (!folderName.value.trim()) return
|
||||
emit('create', folderName.value.trim())
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog :model-value="modelValue" @update:model-value="emit('update:modelValue', $event)" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title>New Folder</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="folderName"
|
||||
label="Folder name"
|
||||
autofocus
|
||||
@keyup.enter="handleCreate"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="handleClose">Cancel</v-btn>
|
||||
<v-btn color="primary" variant="elevated" @click="handleCreate">Create</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
51
src/components/dialogs/RenameDialog.vue
Normal file
51
src/components/dialogs/RenameDialog.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
currentName: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'rename': [newName: string]
|
||||
}>()
|
||||
|
||||
const newName = ref('')
|
||||
|
||||
watch(() => props.modelValue, (isOpen) => {
|
||||
if (isOpen) {
|
||||
newName.value = props.currentName
|
||||
}
|
||||
})
|
||||
|
||||
function handleRename() {
|
||||
if (!newName.value.trim()) return
|
||||
emit('rename', newName.value.trim())
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog :model-value="modelValue" @update:model-value="emit('update:modelValue', $event)" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title>Rename</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="newName"
|
||||
label="New name"
|
||||
autofocus
|
||||
@keyup.enter="handleRename"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="handleClose">Cancel</v-btn>
|
||||
<v-btn color="primary" variant="elevated" @click="handleRename">Rename</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
143
src/components/dialogs/UploadDialog.vue
Normal file
143
src/components/dialogs/UploadDialog.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import type { FileUploadProgress } from '@/composables/useFileUpload'
|
||||
import { formatSize, getUploadStatusIcon, getUploadStatusColor } from '@/utils/fileHelpers'
|
||||
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
uploads: Map<string, FileUploadProgress>
|
||||
totalProgress: number
|
||||
isUploading: boolean
|
||||
pendingCount: number
|
||||
completedCount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'upload-all': []
|
||||
'remove-upload': [id: string]
|
||||
'retry-upload': [id: string]
|
||||
'add-files': []
|
||||
'close': []
|
||||
}>()
|
||||
|
||||
function handleClose() {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog :model-value="modelValue" @update:model-value="emit('update:modelValue', $event)" max-width="500" persistent>
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon class="mr-2">mdi-upload</v-icon>
|
||||
Upload Files
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<!-- Upload progress -->
|
||||
<div v-if="totalProgress > 0 && isUploading" class="mb-4">
|
||||
<v-progress-linear
|
||||
:model-value="totalProgress"
|
||||
color="primary"
|
||||
height="8"
|
||||
rounded
|
||||
/>
|
||||
<div class="text-caption text-center mt-1">
|
||||
{{ totalProgress }}% complete
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File list -->
|
||||
<v-list density="compact" class="upload-file-list">
|
||||
<v-list-item
|
||||
v-for="[id, item] in uploads"
|
||||
:key="id"
|
||||
class="px-0"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon :color="getUploadStatusColor(item.status)" size="small">
|
||||
{{ getUploadStatusIcon(item.status) }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-body-2">
|
||||
{{ item.relativePath || item.file.name }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
{{ formatSize(item.file.size) }}
|
||||
<span v-if="item.error" class="text-error"> — {{ item.error }}</span>
|
||||
</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<v-btn
|
||||
v-if="item.status === 'pending' || item.status === 'error'"
|
||||
icon="mdi-close"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
@click="emit('remove-upload', id)"
|
||||
/>
|
||||
<v-btn
|
||||
v-if="item.status === 'error'"
|
||||
icon="mdi-refresh"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
@click="emit('retry-upload', id)"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="uploads.size === 0" class="text-center py-8 text-grey">
|
||||
<v-icon size="48" color="grey-lighten-1">mdi-file-upload-outline</v-icon>
|
||||
<div class="mt-2">No files selected</div>
|
||||
<v-btn
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
class="mt-4"
|
||||
@click="emit('add-files')"
|
||||
>
|
||||
<v-icon start>mdi-plus</v-icon>
|
||||
Add Files
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Add more files button -->
|
||||
<div v-else class="text-center mt-4">
|
||||
<v-btn
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="emit('add-files')"
|
||||
>
|
||||
<v-icon start>mdi-plus</v-icon>
|
||||
Add More Files
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
variant="text"
|
||||
@click="handleClose"
|
||||
:disabled="isUploading"
|
||||
>
|
||||
{{ completedCount > 0 ? 'Done' : 'Cancel' }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="pendingCount > 0"
|
||||
color="primary"
|
||||
variant="elevated"
|
||||
@click="emit('upload-all')"
|
||||
:loading="isUploading"
|
||||
>
|
||||
Upload {{ pendingCount }} File(s)
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.upload-file-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
4
src/components/dialogs/index.ts
Normal file
4
src/components/dialogs/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as NewFolderDialog } from './NewFolderDialog.vue'
|
||||
export { default as RenameDialog } from './RenameDialog.vue'
|
||||
export { default as DeleteConfirmDialog } from './DeleteConfirmDialog.vue'
|
||||
export { default as UploadDialog } from './UploadDialog.vue'
|
||||
14
src/components/index.ts
Normal file
14
src/components/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Layout components
|
||||
export { default as FilesToolbar } from './FilesToolbar.vue'
|
||||
export { default as FilesSidebar } from './FilesSidebar.vue'
|
||||
export { default as FilesBreadcrumbs } from './FilesBreadcrumbs.vue'
|
||||
export { default as FilesEmptyState } from './FilesEmptyState.vue'
|
||||
export { default as FilesDragOverlay } from './FilesDragOverlay.vue'
|
||||
export { default as FilesInfoPanel } from './FilesInfoPanel.vue'
|
||||
export { default as FileActionsMenu } from './FileActionsMenu.vue'
|
||||
|
||||
// View components
|
||||
export * from './views'
|
||||
|
||||
// Dialog components
|
||||
export * from './dialogs'
|
||||
176
src/components/views/FilesDetailsView.vue
Normal file
176
src/components/views/FilesDetailsView.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { FileCollectionObject } from '@FileManager/models/collection'
|
||||
import { FileEntityObject } from '@FileManager/models/entity'
|
||||
import { getFileIcon, formatSize, formatDate } from '@/utils/fileHelpers'
|
||||
import { FileActionsMenu } from '@/components'
|
||||
|
||||
type ItemWithType = {
|
||||
item: FileCollectionObject | FileEntityObject
|
||||
type: 'collection' | 'entity'
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
collections: FileCollectionObject[]
|
||||
entities: FileEntityObject[]
|
||||
selectedIds: Set<string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'item-click': [item: FileCollectionObject | FileEntityObject, event: MouseEvent | KeyboardEvent]
|
||||
'rename': [item: FileCollectionObject | FileEntityObject]
|
||||
'delete': [item: FileCollectionObject | FileEntityObject]
|
||||
'download': [item: FileCollectionObject | FileEntityObject]
|
||||
'show-details': [item: FileCollectionObject | FileEntityObject]
|
||||
}>()
|
||||
|
||||
// Combine collections and entities into a single list for virtual scrolling
|
||||
const allItems = computed<ItemWithType[]>(() => [
|
||||
...props.collections.map(c => ({ item: c, type: 'collection' as const })),
|
||||
...props.entities.map(e => ({ item: e, type: 'entity' as const }))
|
||||
])
|
||||
|
||||
function isCollection(wrapped: ItemWithType): wrapped is { item: FileCollectionObject; type: 'collection' } {
|
||||
return wrapped.type === 'collection'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="files-details-view mx-4">
|
||||
<!-- Header -->
|
||||
<div class="files-details-header">
|
||||
<div class="files-details-cell files-details-name">Name</div>
|
||||
<div class="files-details-cell files-details-size">Size</div>
|
||||
<div class="files-details-cell files-details-modified">Modified</div>
|
||||
<div class="files-details-cell files-details-actions"></div>
|
||||
</div>
|
||||
|
||||
<!-- Virtual scrolling rows -->
|
||||
<v-virtual-scroll
|
||||
:items="allItems"
|
||||
:item-height="48"
|
||||
class="files-details-body"
|
||||
>
|
||||
<template #default="{ item: wrapped }">
|
||||
<!-- Folder row -->
|
||||
<div
|
||||
v-if="isCollection(wrapped)"
|
||||
class="files-details-row"
|
||||
:class="{ 'files-details-row--selected': selectedIds.has(wrapped.item.id) }"
|
||||
@click="emit('item-click', wrapped.item, $event)"
|
||||
>
|
||||
<div class="files-details-cell files-details-name">
|
||||
<v-icon color="amber-darken-2" size="small" class="mr-2">mdi-folder</v-icon>
|
||||
{{ wrapped.item.label }}
|
||||
</div>
|
||||
<div class="files-details-cell files-details-size">—</div>
|
||||
<div class="files-details-cell files-details-modified">{{ formatDate(wrapped.item.modifiedOn) }}</div>
|
||||
<div class="files-details-cell files-details-actions">
|
||||
<FileActionsMenu
|
||||
:item="wrapped.item"
|
||||
@rename="emit('rename', $event)"
|
||||
@delete="emit('delete', $event)"
|
||||
@download="emit('download', $event)"
|
||||
@show-details="emit('show-details', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File row -->
|
||||
<div
|
||||
v-else
|
||||
class="files-details-row"
|
||||
:class="{ 'files-details-row--selected': selectedIds.has(wrapped.item.id) }"
|
||||
@click="emit('item-click', wrapped.item, $event)"
|
||||
>
|
||||
<div class="files-details-cell files-details-name">
|
||||
<v-icon color="grey" size="small" class="mr-2">{{ getFileIcon(wrapped.item as FileEntityObject) }}</v-icon>
|
||||
{{ wrapped.item.label }}
|
||||
</div>
|
||||
<div class="files-details-cell files-details-size">{{ formatSize((wrapped.item as FileEntityObject).size) }}</div>
|
||||
<div class="files-details-cell files-details-modified">{{ formatDate(wrapped.item.modifiedOn) }}</div>
|
||||
<div class="files-details-cell files-details-actions">
|
||||
<FileActionsMenu
|
||||
:item="wrapped.item"
|
||||
@rename="emit('rename', $event)"
|
||||
@delete="emit('delete', $event)"
|
||||
@download="emit('download', $event)"
|
||||
@show-details="emit('show-details', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-virtual-scroll>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.files-details-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.files-details-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
border-bottom: 1px solid rgb(var(--v-border-color));
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.files-details-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.files-details-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid rgb(var(--v-border-color), 0.5);
|
||||
}
|
||||
|
||||
.files-details-row:hover {
|
||||
background-color: rgb(var(--v-theme-surface-variant), 0.3);
|
||||
}
|
||||
|
||||
.files-details-row--selected {
|
||||
background-color: rgb(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
.files-details-row--selected:hover {
|
||||
background-color: rgb(var(--v-theme-primary), 0.12);
|
||||
}
|
||||
|
||||
.files-details-cell {
|
||||
padding: 0 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.files-details-name {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.files-details-size {
|
||||
width: 100px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.files-details-modified {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.files-details-actions {
|
||||
width: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
147
src/components/views/FilesGridView.vue
Normal file
147
src/components/views/FilesGridView.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import { FileCollectionObject } from '@FileManager/models/collection'
|
||||
import { FileEntityObject } from '@FileManager/models/entity'
|
||||
import { getFileIcon, formatSize } from '@/utils/fileHelpers'
|
||||
import { FileActionsMenu } from '@/components'
|
||||
|
||||
defineProps<{
|
||||
collections: FileCollectionObject[]
|
||||
entities: FileEntityObject[]
|
||||
selectedIds: Set<string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'item-click': [item: FileCollectionObject | FileEntityObject, event: MouseEvent | KeyboardEvent]
|
||||
'rename': [item: FileCollectionObject | FileEntityObject]
|
||||
'delete': [item: FileCollectionObject | FileEntityObject]
|
||||
'download': [item: FileCollectionObject | FileEntityObject]
|
||||
'show-details': [item: FileCollectionObject | FileEntityObject]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="files-grid pa-4">
|
||||
<!-- Folders -->
|
||||
<div
|
||||
v-for="folder in collections"
|
||||
:key="folder.id"
|
||||
class="files-grid-item"
|
||||
:class="{ selected: selectedIds.has(folder.id) }"
|
||||
@click="emit('item-click', folder, $event)"
|
||||
>
|
||||
<div class="files-grid-item-actions">
|
||||
<FileActionsMenu
|
||||
:item="folder"
|
||||
size="x-small"
|
||||
button-class="action-btn"
|
||||
@rename="emit('rename', $event)"
|
||||
@delete="emit('delete', $event)"
|
||||
@download="emit('download', $event)"
|
||||
@show-details="emit('show-details', $event)"
|
||||
/>
|
||||
</div>
|
||||
<v-icon size="48" color="amber-darken-2">mdi-folder</v-icon>
|
||||
<div class="files-grid-label">{{ folder.label }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Files -->
|
||||
<div
|
||||
v-for="entity in entities"
|
||||
:key="entity.id"
|
||||
class="files-grid-item"
|
||||
:class="{ selected: selectedIds.has(entity.id) }"
|
||||
@click="emit('item-click', entity, $event)"
|
||||
>
|
||||
<div class="files-grid-item-actions">
|
||||
<FileActionsMenu
|
||||
:item="entity"
|
||||
size="x-small"
|
||||
button-class="action-btn"
|
||||
@rename="emit('rename', $event)"
|
||||
@delete="emit('delete', $event)"
|
||||
@download="emit('download', $event)"
|
||||
@show-details="emit('show-details', $event)"
|
||||
/>
|
||||
</div>
|
||||
<v-icon size="48" color="grey">{{ getFileIcon(entity) }}</v-icon>
|
||||
<div class="files-grid-label">{{ entity.label }}</div>
|
||||
<div class="files-grid-size">{{ formatSize(entity.size) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.files-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 16px;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 500px;
|
||||
}
|
||||
|
||||
.files-grid-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16px 8px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: 120px 120px;
|
||||
}
|
||||
|
||||
.files-grid-item:hover {
|
||||
background-color: rgb(var(--v-theme-surface-variant), 0.5);
|
||||
}
|
||||
|
||||
.files-grid-item.selected {
|
||||
background-color: rgb(var(--v-theme-primary), 0.12);
|
||||
}
|
||||
|
||||
.files-grid-item-actions {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.files-grid-item:hover .files-grid-item-actions,
|
||||
.files-grid-item.selected .files-grid-item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.files-grid-label {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
word-break: break-word;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.files-grid-size {
|
||||
font-size: 11px;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.files-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
95
src/components/views/FilesListView.vue
Normal file
95
src/components/views/FilesListView.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { FileCollectionObject } from '@FileManager/models/collection'
|
||||
import { FileEntityObject } from '@FileManager/models/entity'
|
||||
import { getFileIcon, formatSize } from '@/utils/fileHelpers'
|
||||
import { FileActionsMenu } from '@/components'
|
||||
|
||||
type ItemWithType = {
|
||||
item: FileCollectionObject | FileEntityObject
|
||||
type: 'collection' | 'entity'
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
collections: FileCollectionObject[]
|
||||
entities: FileEntityObject[]
|
||||
selectedIds: Set<string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'item-click': [item: FileCollectionObject | FileEntityObject, event: MouseEvent | KeyboardEvent]
|
||||
'rename': [item: FileCollectionObject | FileEntityObject]
|
||||
'delete': [item: FileCollectionObject | FileEntityObject]
|
||||
'download': [item: FileCollectionObject | FileEntityObject]
|
||||
'show-details': [item: FileCollectionObject | FileEntityObject]
|
||||
}>()
|
||||
|
||||
// Combine collections and entities into a single list for virtual scrolling
|
||||
const allItems = computed<ItemWithType[]>(() => [
|
||||
...props.collections.map(c => ({ item: c, type: 'collection' as const })),
|
||||
...props.entities.map(e => ({ item: e, type: 'entity' as const }))
|
||||
])
|
||||
|
||||
function isCollection(wrapped: ItemWithType): wrapped is { item: FileCollectionObject; type: 'collection' } {
|
||||
return wrapped.type === 'collection'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-virtual-scroll
|
||||
:items="allItems"
|
||||
:item-height="56"
|
||||
class="files-list-virtual pa-2"
|
||||
>
|
||||
<template #default="{ item: wrapped }">
|
||||
<!-- Folder -->
|
||||
<v-list-item
|
||||
v-if="isCollection(wrapped)"
|
||||
:active="selectedIds.has(wrapped.item.id)"
|
||||
@click="($event: MouseEvent | KeyboardEvent) => emit('item-click', wrapped.item, $event)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon color="amber-darken-2">mdi-folder</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ wrapped.item.label }}</v-list-item-title>
|
||||
<template #append>
|
||||
<FileActionsMenu
|
||||
:item="wrapped.item"
|
||||
@rename="emit('rename', $event)"
|
||||
@delete="emit('delete', $event)"
|
||||
@download="emit('download', $event)"
|
||||
@show-details="emit('show-details', $event)"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<!-- File -->
|
||||
<v-list-item
|
||||
v-else
|
||||
:active="selectedIds.has(wrapped.item.id)"
|
||||
@click="($event: MouseEvent | KeyboardEvent) => emit('item-click', wrapped.item, $event)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon color="grey">{{ getFileIcon(wrapped.item as FileEntityObject) }}</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ wrapped.item.label }}</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ formatSize((wrapped.item as FileEntityObject).size) }}</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<FileActionsMenu
|
||||
:item="wrapped.item"
|
||||
@rename="emit('rename', $event)"
|
||||
@delete="emit('delete', $event)"
|
||||
@download="emit('download', $event)"
|
||||
@show-details="emit('show-details', $event)"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-virtual-scroll>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.files-list-virtual {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
3
src/components/views/index.ts
Normal file
3
src/components/views/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as FilesGridView } from './FilesGridView.vue'
|
||||
export { default as FilesListView } from './FilesListView.vue'
|
||||
export { default as FilesDetailsView } from './FilesDetailsView.vue'
|
||||
Reference in New Issue
Block a user