feat: collection rename
Signed-off-by: Sebastian <krupinski01@gmail.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { CollectionObject } from '@MailManager/models/collection'
|
import type { CollectionObject } from '@MailManager/models/collection'
|
||||||
import type { ServiceInterface } from '@MailManager/types/service'
|
import type { ServiceInterface } from '@MailManager/types/service'
|
||||||
|
|
||||||
@@ -18,14 +18,28 @@ const props = defineProps<Props>()
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [folder: CollectionObject]
|
select: [folder: CollectionObject]
|
||||||
createFolder: [service: ServiceInterface, parentFolder: CollectionObject | null]
|
createFolder: [service: ServiceInterface, parentFolder: CollectionObject | null]
|
||||||
|
editFolder: [service: ServiceInterface, folder: CollectionObject]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Page-based navigation state
|
// Page-based navigation state per service account
|
||||||
const currentPageLevel = ref<(string | number | null)[]>([null]) // Stack of parent IDs (null = root)
|
const pageLevels = ref<Record<string, (string | number | null)[]>>({})
|
||||||
|
|
||||||
|
const getServiceKey = (service: ServiceInterface): string => {
|
||||||
|
return `${service.provider}-${service.identifier}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCurrentPageLevel = (service: ServiceInterface): (string | number | null)[] => {
|
||||||
|
const key = getServiceKey(service)
|
||||||
|
if (!pageLevels.value[key]) {
|
||||||
|
pageLevels.value[key] = [null]
|
||||||
|
}
|
||||||
|
return pageLevels.value[key]
|
||||||
|
}
|
||||||
|
|
||||||
// Get folders for current page level
|
// Get folders for current page level
|
||||||
const getCurrentPageFolders = (folders: CollectionObject[]): CollectionObject[] => {
|
const getCurrentPageFolders = (service: ServiceInterface, folders: CollectionObject[]): CollectionObject[] => {
|
||||||
const currentParent = currentPageLevel.value[currentPageLevel.value.length - 1]
|
const level = getCurrentPageLevel(service)
|
||||||
|
const currentParent = level[level.length - 1]
|
||||||
return folders.filter(f => {
|
return folders.filter(f => {
|
||||||
if (currentParent === null) {
|
if (currentParent === null) {
|
||||||
return f.collection === null || f.collection === undefined
|
return f.collection === null || f.collection === undefined
|
||||||
@@ -95,29 +109,39 @@ const handleFolderClick = (folder: CollectionObject) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Navigate into a folder to show its children
|
// Navigate into a folder to show its children
|
||||||
const handleNavigateInto = (folderId: string | number) => {
|
const handleNavigateInto = (service: ServiceInterface, folderId: string | number) => {
|
||||||
currentPageLevel.value.push(folderId)
|
const level = getCurrentPageLevel(service)
|
||||||
|
level.push(folderId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate back in page-based view
|
// Navigate back in page-based view
|
||||||
const navigateBack = () => {
|
const navigateBack = (service: ServiceInterface) => {
|
||||||
if (currentPageLevel.value.length > 1) {
|
const level = getCurrentPageLevel(service)
|
||||||
currentPageLevel.value.pop()
|
if (level.length > 1) {
|
||||||
|
level.pop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get breadcrumb label for current page
|
// Get breadcrumb label for current page
|
||||||
const getCurrentBreadcrumb = (folders: CollectionObject[]): string => {
|
const getCurrentBreadcrumb = (service: ServiceInterface, folders: CollectionObject[]): string => {
|
||||||
const currentParent = currentPageLevel.value[currentPageLevel.value.length - 1]
|
const level = getCurrentPageLevel(service)
|
||||||
|
const currentParent = level[level.length - 1]
|
||||||
if (currentParent === null) return 'All Folders'
|
if (currentParent === null) return 'All Folders'
|
||||||
|
|
||||||
const parentFolder = folders.find(f => String(f.identifier) === String(currentParent))
|
const labels = level
|
||||||
return parentFolder?.properties.label || 'Folders'
|
.filter((id): id is string | number => id !== null)
|
||||||
|
.map(id => folders.find(f => String(f.identifier) === String(id))?.properties.label)
|
||||||
|
.filter((label): label is string => !!label)
|
||||||
|
|
||||||
|
if (labels.length === 0) return 'Folders'
|
||||||
|
if (labels.length <= 2) return labels.join('/')
|
||||||
|
return `../${labels.slice(-2).join('/')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current parent folder for dialog context
|
// Get current parent folder for dialog context
|
||||||
const getCurrentParentFolder = (folders: CollectionObject[]): CollectionObject | null => {
|
const getCurrentParentFolder = (service: ServiceInterface, folders: CollectionObject[]): CollectionObject | null => {
|
||||||
const currentParent = currentPageLevel.value[currentPageLevel.value.length - 1]
|
const level = getCurrentPageLevel(service)
|
||||||
|
const currentParent = level[level.length - 1]
|
||||||
if (currentParent === null) return null
|
if (currentParent === null) return null
|
||||||
|
|
||||||
// Search through all folders in the array
|
// Search through all folders in the array
|
||||||
@@ -125,18 +149,20 @@ const getCurrentParentFolder = (folders: CollectionObject[]): CollectionObject |
|
|||||||
return found || null
|
return found || null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Computed helper to get all folders from all service groups
|
|
||||||
const allFolders = computed(() => props.serviceGroups.flatMap(g => g.folders))
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<template v-for="group in serviceGroups" :key="`${group.service.provider}-${group.service.identifier}`">
|
<template v-for="(group, index) in serviceGroups" :key="`${group.service.provider}-${group.service.identifier}`">
|
||||||
<!-- Service account group -->
|
<!-- Service account group -->
|
||||||
<v-list-group v-if="serviceGroups.length > 1">
|
<v-list-group
|
||||||
|
v-if="serviceGroups.length > 1"
|
||||||
|
:class="['no-indent', { 'account-group-spaced': index < serviceGroups.length - 1 }]"
|
||||||
|
>
|
||||||
<template v-slot:activator="{ props: activatorProps }">
|
<template v-slot:activator="{ props: activatorProps }">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-bind="activatorProps"
|
v-bind="activatorProps"
|
||||||
|
class="account-header-item"
|
||||||
:title="group.service.label || 'Mail Account'"
|
:title="group.service.label || 'Mail Account'"
|
||||||
:subtitle="group.service.primaryAddress || undefined"
|
:subtitle="group.service.primaryAddress || undefined"
|
||||||
>
|
>
|
||||||
@@ -150,7 +176,7 @@ const allFolders = computed(() => props.serviceGroups.flatMap(g => g.folders))
|
|||||||
variant="text"
|
variant="text"
|
||||||
size="small"
|
size="small"
|
||||||
density="compact"
|
density="compact"
|
||||||
@click.stop="emit('createFolder', group.service, getCurrentParentFolder(allFolders))"
|
@click.stop="emit('createFolder', group.service, getCurrentParentFolder(group.service, group.folders))"
|
||||||
>
|
>
|
||||||
<v-icon>mdi-folder-plus</v-icon>
|
<v-icon>mdi-folder-plus</v-icon>
|
||||||
<v-tooltip activator="parent" location="bottom">New Folder</v-tooltip>
|
<v-tooltip activator="parent" location="bottom">New Folder</v-tooltip>
|
||||||
@@ -159,32 +185,34 @@ const allFolders = computed(() => props.serviceGroups.flatMap(g => g.folders))
|
|||||||
</v-list-item>
|
</v-list-item>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Back button if not at root -->
|
|
||||||
<v-list-item
|
|
||||||
v-if="currentPageLevel.length > 1"
|
|
||||||
@click="navigateBack"
|
|
||||||
prepend-icon="mdi-arrow-left"
|
|
||||||
title="Back"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Breadcrumb with New Folder button -->
|
<!-- Breadcrumb with New Folder button -->
|
||||||
<v-list-subheader v-if="currentPageLevel.length > 1" class="d-flex align-center">
|
<v-list-subheader v-if="getCurrentPageLevel(group.service).length > 1" class="d-flex align-center">
|
||||||
<span class="flex-grow-1">{{ getCurrentBreadcrumb(group.folders) }}</span>
|
<span class="flex-grow-1">{{ getCurrentBreadcrumb(group.service, group.folders) }}</span>
|
||||||
<v-btn
|
<v-btn
|
||||||
icon="mdi-folder-plus"
|
icon="mdi-folder-plus"
|
||||||
variant="text"
|
variant="text"
|
||||||
size="x-small"
|
size="x-small"
|
||||||
@click="emit('createFolder', group.service, getCurrentParentFolder(allFolders))"
|
@click="emit('createFolder', group.service, getCurrentParentFolder(group.service, group.folders))"
|
||||||
>
|
>
|
||||||
<v-icon size="small">mdi-folder-plus</v-icon>
|
<v-icon size="small">mdi-folder-plus</v-icon>
|
||||||
<v-tooltip activator="parent" location="bottom">New Subfolder</v-tooltip>
|
<v-tooltip activator="parent" location="bottom">New Subfolder</v-tooltip>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-list-subheader>
|
</v-list-subheader>
|
||||||
|
|
||||||
|
<!-- Back button if not at root -->
|
||||||
|
<v-list-item
|
||||||
|
v-if="getCurrentPageLevel(group.service).length > 1"
|
||||||
|
class="back-row-item"
|
||||||
|
@click="navigateBack(group.service)"
|
||||||
|
prepend-icon="mdi-arrow-left"
|
||||||
|
title="Back"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Current level folders -->
|
<!-- Current level folders -->
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="folder in getCurrentPageFolders(group.folders)"
|
v-for="folder in getCurrentPageFolders(group.service, group.folders)"
|
||||||
:key="`${folder.provider}-${folder.service}-${folder.identifier}`"
|
:key="`${folder.provider}-${folder.service}-${folder.identifier}`"
|
||||||
|
class="folder-page-item folder-row-item"
|
||||||
:title="folder.properties.label"
|
:title="folder.properties.label"
|
||||||
:active="isSelected(folder)"
|
:active="isSelected(folder)"
|
||||||
@click="handleFolderClick(folder)"
|
@click="handleFolderClick(folder)"
|
||||||
@@ -213,11 +241,11 @@ const allFolders = computed(() => props.serviceGroups.flatMap(g => g.folders))
|
|||||||
variant="text"
|
variant="text"
|
||||||
size="small"
|
size="small"
|
||||||
density="compact"
|
density="compact"
|
||||||
@click.stop="handleNavigateInto(folder.identifier)"
|
@click.stop="handleNavigateInto(group.service, folder.identifier)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Menu for folder actions -->
|
<!-- Menu for folder actions -->
|
||||||
<v-menu v-else>
|
<v-menu>
|
||||||
<template v-slot:activator="{ props: menuProps }">
|
<template v-slot:activator="{ props: menuProps }">
|
||||||
<v-btn
|
<v-btn
|
||||||
v-bind="menuProps"
|
v-bind="menuProps"
|
||||||
@@ -230,6 +258,12 @@ const allFolders = computed(() => props.serviceGroups.flatMap(g => g.folders))
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<v-list density="compact">
|
<v-list density="compact">
|
||||||
|
<v-list-item
|
||||||
|
prepend-icon="mdi-pencil"
|
||||||
|
@click="emit('editFolder', group.service, folder)"
|
||||||
|
>
|
||||||
|
<v-list-item-title>Edit Folder Name</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
prepend-icon="mdi-folder-plus"
|
prepend-icon="mdi-folder-plus"
|
||||||
@click="emit('createFolder', group.service, folder)"
|
@click="emit('createFolder', group.service, folder)"
|
||||||
@@ -247,33 +281,35 @@ const allFolders = computed(() => props.serviceGroups.flatMap(g => g.folders))
|
|||||||
<!-- Header with New Folder button -->
|
<!-- Header with New Folder button -->
|
||||||
<v-list-subheader class="d-flex align-center">
|
<v-list-subheader class="d-flex align-center">
|
||||||
<span class="flex-grow-1">
|
<span class="flex-grow-1">
|
||||||
{{ currentPageLevel.length > 1 ? getCurrentBreadcrumb(group.folders) : 'FOLDERS' }}
|
{{ getCurrentPageLevel(group.service).length > 1 ? getCurrentBreadcrumb(group.service, group.folders) : 'FOLDERS' }}
|
||||||
</span>
|
</span>
|
||||||
<v-btn
|
<v-btn
|
||||||
icon="mdi-folder-plus"
|
icon="mdi-folder-plus"
|
||||||
variant="text"
|
variant="text"
|
||||||
size="x-small"
|
size="x-small"
|
||||||
@click="emit('createFolder', group.service, getCurrentParentFolder(allFolders))"
|
@click="emit('createFolder', group.service, getCurrentParentFolder(group.service, group.folders))"
|
||||||
>
|
>
|
||||||
<v-icon size="small">mdi-folder-plus</v-icon>
|
<v-icon size="small">mdi-folder-plus</v-icon>
|
||||||
<v-tooltip activator="parent" location="bottom">
|
<v-tooltip activator="parent" location="bottom">
|
||||||
{{ currentPageLevel.length > 1 ? 'New Subfolder' : 'New Folder' }}
|
{{ getCurrentPageLevel(group.service).length > 1 ? 'New Subfolder' : 'New Folder' }}
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-list-subheader>
|
</v-list-subheader>
|
||||||
|
|
||||||
<!-- Back button if not at root -->
|
<!-- Back button if not at root -->
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-if="currentPageLevel.length > 1"
|
v-if="getCurrentPageLevel(group.service).length > 1"
|
||||||
@click="navigateBack"
|
class="back-row-item"
|
||||||
|
@click="navigateBack(group.service)"
|
||||||
prepend-icon="mdi-arrow-left"
|
prepend-icon="mdi-arrow-left"
|
||||||
title="Back"
|
title="Back"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Current level folders -->
|
<!-- Current level folders -->
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="folder in getCurrentPageFolders(group.folders)"
|
v-for="folder in getCurrentPageFolders(group.service, group.folders)"
|
||||||
:key="`${folder.provider}-${folder.service}-${folder.identifier}`"
|
:key="`${folder.provider}-${folder.service}-${folder.identifier}`"
|
||||||
|
class="folder-row-item"
|
||||||
:title="folder.properties.label"
|
:title="folder.properties.label"
|
||||||
:active="isSelected(folder)"
|
:active="isSelected(folder)"
|
||||||
@click="handleFolderClick(folder)"
|
@click="handleFolderClick(folder)"
|
||||||
@@ -302,10 +338,10 @@ const allFolders = computed(() => props.serviceGroups.flatMap(g => g.folders))
|
|||||||
variant="text"
|
variant="text"
|
||||||
size="small"
|
size="small"
|
||||||
density="compact"
|
density="compact"
|
||||||
@click.stop="handleNavigateInto(folder.identifier)"
|
@click.stop="handleNavigateInto(group.service, folder.identifier)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<v-menu v-else>
|
<v-menu>
|
||||||
<template v-slot:activator="{ props: menuProps }">
|
<template v-slot:activator="{ props: menuProps }">
|
||||||
<v-btn
|
<v-btn
|
||||||
v-bind="menuProps"
|
v-bind="menuProps"
|
||||||
@@ -318,6 +354,12 @@ const allFolders = computed(() => props.serviceGroups.flatMap(g => g.folders))
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<v-list density="compact">
|
<v-list density="compact">
|
||||||
|
<v-list-item
|
||||||
|
prepend-icon="mdi-pencil"
|
||||||
|
@click="emit('editFolder', group.service, folder)"
|
||||||
|
>
|
||||||
|
<v-list-item-title>Edit Folder Name</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
prepend-icon="mdi-folder-plus"
|
prepend-icon="mdi-folder-plus"
|
||||||
@click="emit('createFolder', group.service, folder)"
|
@click="emit('createFolder', group.service, folder)"
|
||||||
@@ -337,4 +379,35 @@ const allFolders = computed(() => props.serviceGroups.flatMap(g => g.folders))
|
|||||||
.v-list-item--active {
|
.v-list-item--active {
|
||||||
background-color: rgba(var(--v-theme-primary), 0.12);
|
background-color: rgba(var(--v-theme-primary), 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keep folder rows left-aligned inside multi-account groups */
|
||||||
|
.folder-page-item {
|
||||||
|
--indent-padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-indent :deep(.v-list-group__items) {
|
||||||
|
--indent-padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-group-spaced {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-header-item {
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-row-item,
|
||||||
|
.back-row-item,
|
||||||
|
.account-header-item {
|
||||||
|
--v-list-item-prepend-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-row-item :deep(.v-list-item__prepend),
|
||||||
|
.back-row-item :deep(.v-list-item__prepend),
|
||||||
|
.account-header-item :deep(.v-list-item__prepend) {
|
||||||
|
padding-inline-start: 4px;
|
||||||
|
margin-inline-end: 2px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useUser } from '@KTXC/composables/useUser'
|
|||||||
import FolderTreeView from './FolderTreeView.vue'
|
import FolderTreeView from './FolderTreeView.vue'
|
||||||
import FolderPageView from './FolderPageView.vue'
|
import FolderPageView from './FolderPageView.vue'
|
||||||
import CreateFolderDialog from './CreateFolderDialog.vue'
|
import CreateFolderDialog from './CreateFolderDialog.vue'
|
||||||
|
import RenameFolderDialog from './RenameFolderDialog.vue'
|
||||||
import type { CollectionObject } from '@MailManager/models/collection'
|
import type { CollectionObject } from '@MailManager/models/collection'
|
||||||
import type { ServiceInterface } from '@MailManager/types/service'
|
import type { ServiceInterface } from '@MailManager/types/service'
|
||||||
|
|
||||||
@@ -40,6 +41,9 @@ const folderViewMode = computed(() => {
|
|||||||
const createDialogVisible = ref(false)
|
const createDialogVisible = ref(false)
|
||||||
const createDialogService = ref<ServiceInterface | null>(null)
|
const createDialogService = ref<ServiceInterface | null>(null)
|
||||||
const createDialogParent = ref<CollectionObject | null>(null)
|
const createDialogParent = ref<CollectionObject | null>(null)
|
||||||
|
const renameDialogVisible = ref(false)
|
||||||
|
const renameDialogService = ref<ServiceInterface | null>(null)
|
||||||
|
const renameDialogFolder = ref<CollectionObject | null>(null)
|
||||||
|
|
||||||
// Handle create folder event from child components
|
// Handle create folder event from child components
|
||||||
const handleCreateFolder = (service: ServiceInterface, parentFolder: CollectionObject | null = null) => {
|
const handleCreateFolder = (service: ServiceInterface, parentFolder: CollectionObject | null = null) => {
|
||||||
@@ -54,6 +58,16 @@ const handleFolderCreated = (newFolder: CollectionObject) => {
|
|||||||
emit('select', newFolder)
|
emit('select', newFolder)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleEditFolder = (service: ServiceInterface, folder: CollectionObject) => {
|
||||||
|
renameDialogService.value = service
|
||||||
|
renameDialogFolder.value = folder
|
||||||
|
renameDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFolderRenamed = (updatedFolder: CollectionObject) => {
|
||||||
|
emit('select', updatedFolder)
|
||||||
|
}
|
||||||
|
|
||||||
// Computed: all folders for validation
|
// Computed: all folders for validation
|
||||||
const allFolders = computed(() =>
|
const allFolders = computed(() =>
|
||||||
serviceGroups.value.flatMap(g => g.folders)
|
serviceGroups.value.flatMap(g => g.folders)
|
||||||
@@ -136,6 +150,7 @@ const serviceGroups = computed(() => {
|
|||||||
:service-groups="serviceGroups"
|
:service-groups="serviceGroups"
|
||||||
@select="emit('select', $event)"
|
@select="emit('select', $event)"
|
||||||
@create-folder="handleCreateFolder"
|
@create-folder="handleCreateFolder"
|
||||||
|
@edit-folder="handleEditFolder"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Page-based View -->
|
<!-- Page-based View -->
|
||||||
@@ -145,6 +160,7 @@ const serviceGroups = computed(() => {
|
|||||||
:service-groups="serviceGroups"
|
:service-groups="serviceGroups"
|
||||||
@select="emit('select', $event)"
|
@select="emit('select', $event)"
|
||||||
@create-folder="handleCreateFolder"
|
@create-folder="handleCreateFolder"
|
||||||
|
@edit-folder="handleEditFolder"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
@@ -164,6 +180,15 @@ const serviceGroups = computed(() => {
|
|||||||
:all-folders="allFolders"
|
:all-folders="allFolders"
|
||||||
@created="handleFolderCreated"
|
@created="handleFolderCreated"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<RenameFolderDialog
|
||||||
|
v-if="renameDialogService && renameDialogFolder"
|
||||||
|
v-model="renameDialogVisible"
|
||||||
|
:service="renameDialogService"
|
||||||
|
:folder="renameDialogFolder"
|
||||||
|
:all-folders="allFolders"
|
||||||
|
@updated="handleFolderRenamed"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const props = defineProps<Props>()
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [folder: CollectionObject]
|
select: [folder: CollectionObject]
|
||||||
createSubfolder: [service: ServiceInterface, parentFolder: CollectionObject]
|
createSubfolder: [service: ServiceInterface, parentFolder: CollectionObject]
|
||||||
|
editFolder: [service: ServiceInterface, folder: CollectionObject]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Get icon for folder based on role
|
// Get icon for folder based on role
|
||||||
@@ -76,6 +77,7 @@ const isSelected = (folder: CollectionObject): boolean => {
|
|||||||
<template v-slot:activator="{ props: activatorProps }">
|
<template v-slot:activator="{ props: activatorProps }">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-bind="activatorProps"
|
v-bind="activatorProps"
|
||||||
|
class="folder-row-item"
|
||||||
:title="node.folder.properties.label"
|
:title="node.folder.properties.label"
|
||||||
:active="isSelected(node.folder)"
|
:active="isSelected(node.folder)"
|
||||||
@click.stop="emit('select', node.folder)"
|
@click.stop="emit('select', node.folder)"
|
||||||
@@ -112,6 +114,12 @@ const isSelected = (folder: CollectionObject): boolean => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<v-list density="compact">
|
<v-list density="compact">
|
||||||
|
<v-list-item
|
||||||
|
prepend-icon="mdi-pencil"
|
||||||
|
@click="emit('editFolder', service, node.folder)"
|
||||||
|
>
|
||||||
|
<v-list-item-title>Edit Folder Name</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
prepend-icon="mdi-folder-plus"
|
prepend-icon="mdi-folder-plus"
|
||||||
@click="emit('createSubfolder', service, node.folder)"
|
@click="emit('createSubfolder', service, node.folder)"
|
||||||
@@ -133,12 +141,14 @@ const isSelected = (folder: CollectionObject): boolean => {
|
|||||||
:selected-folder="selectedFolder"
|
:selected-folder="selectedFolder"
|
||||||
@select="emit('select', $event)"
|
@select="emit('select', $event)"
|
||||||
@create-subfolder="(service, parentFolder) => emit('createSubfolder', service, parentFolder)"
|
@create-subfolder="(service, parentFolder) => emit('createSubfolder', service, parentFolder)"
|
||||||
|
@edit-folder="(service, folder) => emit('editFolder', service, folder)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</v-list-group>
|
</v-list-group>
|
||||||
|
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-else
|
v-else
|
||||||
|
class="folder-tree-item folder-row-item"
|
||||||
:title="node.folder.properties.label"
|
:title="node.folder.properties.label"
|
||||||
:active="isSelected(node.folder)"
|
:active="isSelected(node.folder)"
|
||||||
@click="emit('select', node.folder)"
|
@click="emit('select', node.folder)"
|
||||||
@@ -175,6 +185,12 @@ const isSelected = (folder: CollectionObject): boolean => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<v-list density="compact">
|
<v-list density="compact">
|
||||||
|
<v-list-item
|
||||||
|
prepend-icon="mdi-pencil"
|
||||||
|
@click="emit('editFolder', service, node.folder)"
|
||||||
|
>
|
||||||
|
<v-list-item-title>Edit Folder Name</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
prepend-icon="mdi-folder-plus"
|
prepend-icon="mdi-folder-plus"
|
||||||
@click="emit('createSubfolder', service, node.folder)"
|
@click="emit('createSubfolder', service, node.folder)"
|
||||||
@@ -198,6 +214,11 @@ export default {
|
|||||||
background-color: rgba(var(--v-theme-primary), 0.12);
|
background-color: rgba(var(--v-theme-primary), 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keep top-level leaf folders aligned with expandable folders */
|
||||||
|
.folder-tree-item {
|
||||||
|
--indent-padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Remove indentation for the folder-tree-group itself */
|
/* Remove indentation for the folder-tree-group itself */
|
||||||
.folder-tree-group {
|
.folder-tree-group {
|
||||||
--indent-padding: 0 !important;
|
--indent-padding: 0 !important;
|
||||||
@@ -208,6 +229,15 @@ export default {
|
|||||||
--indent-padding: 0;
|
--indent-padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.folder-row-item {
|
||||||
|
--v-list-item-prepend-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-row-item :deep(.v-list-item__prepend) {
|
||||||
|
padding-inline-start: 4px;
|
||||||
|
margin-inline-end: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Add visual indicator for nested items */
|
/* Add visual indicator for nested items */
|
||||||
.folder-tree-children {
|
.folder-tree-children {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const props = defineProps<Props>()
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [folder: CollectionObject]
|
select: [folder: CollectionObject]
|
||||||
createFolder: [service: ServiceInterface, parentFolder: CollectionObject | null]
|
createFolder: [service: ServiceInterface, parentFolder: CollectionObject | null]
|
||||||
|
editFolder: [service: ServiceInterface, folder: CollectionObject]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ const emit = defineEmits<{
|
|||||||
<template v-slot:activator="{ props: activatorProps }">
|
<template v-slot:activator="{ props: activatorProps }">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-bind="activatorProps"
|
v-bind="activatorProps"
|
||||||
|
class="account-header-item"
|
||||||
:title="group.service.label || 'Mail Account'"
|
:title="group.service.label || 'Mail Account'"
|
||||||
:subtitle="group.service.primaryAddress || undefined"
|
:subtitle="group.service.primaryAddress || undefined"
|
||||||
>
|
>
|
||||||
@@ -66,6 +68,7 @@ const emit = defineEmits<{
|
|||||||
:selected-folder="selectedFolder"
|
:selected-folder="selectedFolder"
|
||||||
@select="emit('select', $event)"
|
@select="emit('select', $event)"
|
||||||
@create-subfolder="(service, parentFolder) => emit('createFolder', service, parentFolder)"
|
@create-subfolder="(service, parentFolder) => emit('createFolder', service, parentFolder)"
|
||||||
|
@edit-folder="(service, folder) => emit('editFolder', service, folder)"
|
||||||
/>
|
/>
|
||||||
</v-list-group>
|
</v-list-group>
|
||||||
|
|
||||||
@@ -93,6 +96,7 @@ const emit = defineEmits<{
|
|||||||
:selected-folder="selectedFolder"
|
:selected-folder="selectedFolder"
|
||||||
@select="emit('select', $event)"
|
@select="emit('select', $event)"
|
||||||
@create-subfolder="(service, parentFolder) => emit('createFolder', service, parentFolder)"
|
@create-subfolder="(service, parentFolder) => emit('createFolder', service, parentFolder)"
|
||||||
|
@edit-folder="(service, folder) => emit('editFolder', service, folder)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
@@ -103,4 +107,15 @@ const emit = defineEmits<{
|
|||||||
.v-list-item--active {
|
.v-list-item--active {
|
||||||
background-color: rgba(var(--v-theme-primary), 0.12);
|
background-color: rgba(var(--v-theme-primary), 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account-header-item {
|
||||||
|
--v-list-item-prepend-size: 22px;
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-header-item :deep(.v-list-item__prepend) {
|
||||||
|
padding-inline-start: 4px;
|
||||||
|
margin-inline-end: 2px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
247
src/components/RenameFolderDialog.vue
Normal file
247
src/components/RenameFolderDialog.vue
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
||||||
|
import { CollectionPropertiesObject } from '@MailManager/models/collection'
|
||||||
|
import type { CollectionObject } from '@MailManager/models/collection'
|
||||||
|
import type { ServiceInterface } from '@MailManager/types/service'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: boolean
|
||||||
|
service: ServiceInterface
|
||||||
|
folder: CollectionObject
|
||||||
|
allFolders?: CollectionObject[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
allFolders: () => []
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
updated: [folder: CollectionObject]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const collectionsStore = useCollectionsStore()
|
||||||
|
|
||||||
|
const folderName = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const validationErrors = ref<string[]>([])
|
||||||
|
|
||||||
|
const dialogValue = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value: boolean) => emit('update:modelValue', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isValid = computed(() => {
|
||||||
|
return folderName.value.trim().length > 0 && validationErrors.value.length === 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const parentFolderLabel = computed(() => {
|
||||||
|
const parentId = props.folder.collection
|
||||||
|
if (parentId === null || parentId === undefined) return 'Root'
|
||||||
|
|
||||||
|
const parent = props.allFolders.find(
|
||||||
|
f =>
|
||||||
|
String(f.identifier) === String(parentId) &&
|
||||||
|
f.provider === props.folder.provider &&
|
||||||
|
String(f.service) === String(props.folder.service)
|
||||||
|
)
|
||||||
|
|
||||||
|
return parent?.properties.label || 'Root'
|
||||||
|
})
|
||||||
|
|
||||||
|
const validateFolderName = (name: string): string[] => {
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
if (!name || name.trim().length === 0) {
|
||||||
|
errors.push('Folder name is required')
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.length > 255) {
|
||||||
|
errors.push('Folder name too long (max 255 characters)')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/[<>:"|?*\x00-\x1F]/.test(name)) {
|
||||||
|
errors.push('Folder name contains invalid characters')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.service.provider === 'imap' && /[\/\\]/.test(name)) {
|
||||||
|
errors.push('IMAP folder names cannot contain / or \\')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name !== name.trim()) {
|
||||||
|
errors.push('Folder name cannot have leading or trailing spaces')
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkDuplicateName = (name: string): boolean => {
|
||||||
|
const parentId = props.folder.collection ?? null
|
||||||
|
return props.allFolders.some(f => {
|
||||||
|
if (String(f.identifier) === String(props.folder.identifier)) return false
|
||||||
|
return (
|
||||||
|
f.properties.label === name &&
|
||||||
|
String(f.collection) === String(parentId) &&
|
||||||
|
f.provider === props.folder.provider &&
|
||||||
|
String(f.service) === String(props.folder.service)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(folderName, (newName) => {
|
||||||
|
errorMessage.value = ''
|
||||||
|
validationErrors.value = validateFolderName(newName)
|
||||||
|
|
||||||
|
if (validationErrors.value.length === 0 && newName.trim().length > 0 && checkDuplicateName(newName)) {
|
||||||
|
validationErrors.value.push('A folder with this name already exists in this location')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
folderName.value = props.folder.properties.label || ''
|
||||||
|
errorMessage.value = ''
|
||||||
|
validationErrors.value = []
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(dialogValue, (isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
const handleRename = async () => {
|
||||||
|
const errors = validateFolderName(folderName.value)
|
||||||
|
if (errors.length > 0) {
|
||||||
|
validationErrors.value = errors
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkDuplicateName(folderName.value)) {
|
||||||
|
validationErrors.value = ['A folder with this name already exists in this location']
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newName = folderName.value.trim()
|
||||||
|
if (newName === props.folder.properties.label) {
|
||||||
|
dialogValue.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const properties = new CollectionPropertiesObject()
|
||||||
|
properties.label = newName
|
||||||
|
properties.rank = props.folder.properties.rank ?? 0
|
||||||
|
properties.subscribed = props.folder.properties.subscribed ?? true
|
||||||
|
|
||||||
|
const updatedFolder = await collectionsStore.update(
|
||||||
|
props.folder.provider,
|
||||||
|
props.folder.service,
|
||||||
|
props.folder.identifier,
|
||||||
|
properties
|
||||||
|
)
|
||||||
|
|
||||||
|
emit('updated', updatedFolder)
|
||||||
|
dialogValue.value = false
|
||||||
|
resetForm()
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[RenameFolderDialog] Failed to rename folder:', error)
|
||||||
|
errorMessage.value = error.message || 'Failed to rename folder. Please try again.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
dialogValue.value = false
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-dialog
|
||||||
|
v-model="dialogValue"
|
||||||
|
max-width="500"
|
||||||
|
persistent
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-h5">
|
||||||
|
Rename Folder
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-card-text>
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="text-caption text-medium-emphasis">Account</div>
|
||||||
|
<div class="text-body-2">
|
||||||
|
{{ service.label || service.primaryAddress || 'Mail Account' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="text-caption text-medium-emphasis">Location</div>
|
||||||
|
<div class="text-body-2">
|
||||||
|
{{ parentFolderLabel }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="folderName"
|
||||||
|
label="Folder Name"
|
||||||
|
placeholder="Enter folder name"
|
||||||
|
variant="outlined"
|
||||||
|
autofocus
|
||||||
|
:error-messages="validationErrors"
|
||||||
|
:disabled="loading"
|
||||||
|
@keyup.enter="isValid && handleRename()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
v-if="errorMessage"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
density="compact"
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="handleCancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
variant="elevated"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="!isValid"
|
||||||
|
@click="handleRename"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.text-caption {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.0333em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user