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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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'

View 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>

View 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>

View 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>

View 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'