refactor: split stores and use events #36
@@ -1,7 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
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 { CollectionObject } from '@MailManager/models/collection'
|
||||||
import type { ServiceObject } from '@MailManager/models'
|
import type { ServiceObject } from '@MailManager/models'
|
||||||
|
|
||||||
@@ -9,28 +7,27 @@ import type { ServiceObject } from '@MailManager/models'
|
|||||||
interface Props {
|
interface Props {
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
service: ServiceObject
|
service: ServiceObject
|
||||||
parentFolder?: CollectionObject | null
|
parentFolderLabel?: string
|
||||||
allFolders?: CollectionObject[]
|
validateName?: (name: string) => string[]
|
||||||
|
loading?: boolean
|
||||||
|
errorMessage?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
parentFolder: null,
|
parentFolderLabel: 'Root',
|
||||||
allFolders: () => []
|
validateName: () => [],
|
||||||
|
loading: false,
|
||||||
|
errorMessage: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Emits
|
// Emits
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: boolean]
|
'update:modelValue': [value: boolean]
|
||||||
'created': [folder: CollectionObject]
|
confirm: [folderName: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Store
|
|
||||||
const collectionsStore = useCollectionsStore()
|
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const folderName = ref('')
|
const folderName = ref('')
|
||||||
const loading = ref(false)
|
|
||||||
const errorMessage = ref('')
|
|
||||||
const validationErrors = ref<string[]>([])
|
const validationErrors = ref<string[]>([])
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
@@ -39,67 +36,13 @@ const dialogValue = computed({
|
|||||||
set: (value: boolean) => emit('update:modelValue', value)
|
set: (value: boolean) => emit('update:modelValue', value)
|
||||||
})
|
})
|
||||||
|
|
||||||
const parentFolderLabel = computed(() => {
|
|
||||||
if (!props.parentFolder) return 'Root'
|
|
||||||
return props.parentFolder.properties.label
|
|
||||||
})
|
|
||||||
|
|
||||||
const isValid = computed(() => {
|
const isValid = computed(() => {
|
||||||
return folderName.value.trim().length > 0 && validationErrors.value.length === 0
|
return folderName.value.trim().length > 0 && validationErrors.value.length === 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// Validation functions
|
|
||||||
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)')
|
|
||||||
}
|
|
||||||
|
|
||||||
// No special characters that might cause issues
|
|
||||||
if (/[<>:"|?*\x00-\x1F]/.test(name)) {
|
|
||||||
errors.push('Folder name contains invalid characters')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provider-specific rules
|
|
||||||
if (props.service.provider === 'imap' && /[\/\\]/.test(name)) {
|
|
||||||
errors.push('IMAP folder names cannot contain / or \\')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Leading/trailing spaces
|
|
||||||
if (name !== name.trim()) {
|
|
||||||
errors.push('Folder name cannot have leading or trailing spaces')
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkDuplicateName = (name: string): boolean => {
|
|
||||||
const parentId = props.parentFolder?.identifier ?? null
|
|
||||||
|
|
||||||
return props.allFolders.some(f =>
|
|
||||||
f.properties.label === name &&
|
|
||||||
String(f.collection) === String(parentId) &&
|
|
||||||
String(f.service) === String(props.service.identifier)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watch folder name for validation
|
// Watch folder name for validation
|
||||||
watch(folderName, (newName) => {
|
watch(folderName, (newName) => {
|
||||||
errorMessage.value = ''
|
validationErrors.value = props.validateName(newName)
|
||||||
validationErrors.value = validateFolderName(newName)
|
|
||||||
|
|
||||||
// Check for duplicates only if no other validation errors
|
|
||||||
if (validationErrors.value.length === 0 && newName.trim().length > 0) {
|
|
||||||
if (checkDuplicateName(newName)) {
|
|
||||||
validationErrors.value.push('A folder with this name already exists in this location')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Reset form when dialog opens/closes
|
// Reset form when dialog opens/closes
|
||||||
@@ -111,59 +54,25 @@ watch(dialogValue, (isOpen) => {
|
|||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
folderName.value = ''
|
folderName.value = ''
|
||||||
errorMessage.value = ''
|
|
||||||
validationErrors.value = []
|
validationErrors.value = []
|
||||||
loading.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
// Final validation
|
const errors = props.validateName(folderName.value)
|
||||||
const errors = validateFolderName(folderName.value)
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
validationErrors.value = errors
|
validationErrors.value = errors
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checkDuplicateName(folderName.value)) {
|
emit('confirm', folderName.value.trim())
|
||||||
validationErrors.value = ['A folder with this name already exists in this location']
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
errorMessage.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create properties object
|
|
||||||
const properties = new CollectionPropertiesObject()
|
|
||||||
properties.label = folderName.value.trim()
|
|
||||||
properties.rank = 0
|
|
||||||
properties.subscribed = true
|
|
||||||
|
|
||||||
// Create the collection
|
|
||||||
const newFolder = await collectionsStore.create(
|
|
||||||
props.service.provider,
|
|
||||||
props.service.identifier as string | number,
|
|
||||||
props.parentFolder?.identifier ?? null,
|
|
||||||
properties
|
|
||||||
)
|
|
||||||
|
|
||||||
// Success!
|
|
||||||
emit('created', newFolder)
|
|
||||||
dialogValue.value = false
|
|
||||||
resetForm()
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('[CreateFolderDialog] Failed to create folder:', error)
|
|
||||||
errorMessage.value = error.message || 'Failed to create folder. Please try again.'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
dialogValue.value = false
|
dialogValue.value = false
|
||||||
resetForm()
|
resetForm()
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -8,20 +8,22 @@ interface Props {
|
|||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
service: ServiceObject
|
service: ServiceObject
|
||||||
folder: CollectionObject
|
folder: CollectionObject
|
||||||
|
loading?: boolean
|
||||||
|
errorMessage?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
loading: false,
|
||||||
|
errorMessage: '',
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: boolean]
|
'update:modelValue': [value: boolean]
|
||||||
deleted: [folder: CollectionObject]
|
confirm: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const collectionsStore = useCollectionsStore()
|
const collectionsStore = useCollectionsStore()
|
||||||
|
|
||||||
const loading = ref(false)
|
|
||||||
const errorMessage = ref('')
|
|
||||||
|
|
||||||
const dialogValue = computed({
|
const dialogValue = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
set: (value: boolean) => emit('update:modelValue', value),
|
set: (value: boolean) => emit('update:modelValue', value),
|
||||||
@@ -33,37 +35,17 @@ const hasChildren = computed(() => {
|
|||||||
return collectionsStore.hasChildrenInCollection(props.folder.provider, props.folder.service, props.folder.identifier)
|
return collectionsStore.hasChildrenInCollection(props.folder.provider, props.folder.service, props.folder.identifier)
|
||||||
})
|
})
|
||||||
|
|
||||||
const resetState = () => {
|
|
||||||
loading.value = false
|
|
||||||
errorMessage.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(dialogValue, isOpen => {
|
watch(dialogValue, isOpen => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
resetState()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = () => {
|
||||||
loading.value = true
|
emit('confirm')
|
||||||
errorMessage.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
await collectionsStore.delete(props.folder.provider, props.folder.service, props.folder.identifier)
|
|
||||||
emit('deleted', props.folder)
|
|
||||||
dialogValue.value = false
|
|
||||||
resetState()
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('[DeleteFolderDialog] Failed to delete folder:', error)
|
|
||||||
errorMessage.value = error.message || 'Failed to delete folder. Please try again.'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
dialogValue.value = false
|
dialogValue.value = false
|
||||||
resetState()
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ const collectionsStore = useCollectionsStore()
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [folder: CollectionObject]
|
select: [folder: CollectionObject]
|
||||||
createFolder: [service: ServiceObject, parentFolder: CollectionObject | null]
|
createFolder: [service: ServiceObject, parentFolder: CollectionObject | null]
|
||||||
editFolder: [service: ServiceObject, folder: CollectionObject]
|
editFolder: [folder: CollectionObject]
|
||||||
moveFolder: [service: ServiceObject, folder: CollectionObject]
|
moveFolder: [folder: CollectionObject]
|
||||||
deleteFolder: [service: ServiceObject, folder: CollectionObject]
|
deleteFolder: [folder: CollectionObject]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Page-based navigation state per service account
|
// Page-based navigation state per service account
|
||||||
@@ -283,7 +283,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
|
|||||||
<v-list density="compact">
|
<v-list density="compact">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
prepend-icon="mdi-pencil"
|
prepend-icon="mdi-pencil"
|
||||||
@click="emit('editFolder', group.service, folder)"
|
@click="emit('editFolder', folder)"
|
||||||
>
|
>
|
||||||
<v-list-item-title>Edit Folder Name</v-list-item-title>
|
<v-list-item-title>Edit Folder Name</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
@@ -295,7 +295,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
|
|||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
prepend-icon="mdi-folder-move"
|
prepend-icon="mdi-folder-move"
|
||||||
@click="emit('moveFolder', group.service, folder)"
|
@click="emit('moveFolder', folder)"
|
||||||
>
|
>
|
||||||
<v-list-item-title>Move Folder</v-list-item-title>
|
<v-list-item-title>Move Folder</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
@@ -303,7 +303,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
|
|||||||
v-if="canDeleteFolder(folder)"
|
v-if="canDeleteFolder(folder)"
|
||||||
prepend-icon="mdi-delete"
|
prepend-icon="mdi-delete"
|
||||||
base-color="error"
|
base-color="error"
|
||||||
@click="emit('deleteFolder', group.service, folder)"
|
@click="emit('deleteFolder', folder)"
|
||||||
>
|
>
|
||||||
<v-list-item-title>Delete Folder</v-list-item-title>
|
<v-list-item-title>Delete Folder</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
@@ -446,7 +446,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
|
|||||||
<v-list density="compact">
|
<v-list density="compact">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
prepend-icon="mdi-pencil"
|
prepend-icon="mdi-pencil"
|
||||||
@click="emit('editFolder', group.service, folder)"
|
@click="emit('editFolder', folder)"
|
||||||
>
|
>
|
||||||
<v-list-item-title>Edit Folder Name</v-list-item-title>
|
<v-list-item-title>Edit Folder Name</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
@@ -458,7 +458,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
|
|||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
prepend-icon="mdi-folder-move"
|
prepend-icon="mdi-folder-move"
|
||||||
@click="emit('moveFolder', group.service, folder)"
|
@click="emit('moveFolder', folder)"
|
||||||
>
|
>
|
||||||
<v-list-item-title>Move Folder</v-list-item-title>
|
<v-list-item-title>Move Folder</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
@@ -466,7 +466,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
|
|||||||
v-if="canDeleteFolder(folder)"
|
v-if="canDeleteFolder(folder)"
|
||||||
prepend-icon="mdi-delete"
|
prepend-icon="mdi-delete"
|
||||||
base-color="error"
|
base-color="error"
|
||||||
@click="emit('deleteFolder', group.service, folder)"
|
@click="emit('deleteFolder', folder)"
|
||||||
>
|
>
|
||||||
<v-list-item-title>Delete Folder</v-list-item-title>
|
<v-list-item-title>Delete Folder</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
|
||||||
import { useServicesStore } from '@MailManager/stores/servicesStore'
|
|
||||||
import { useMailStore } from '@/stores/mailStore'
|
import { useMailStore } from '@/stores/mailStore'
|
||||||
|
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
||||||
import type { ServiceObject, CollectionObject } from '@MailManager/models'
|
import type { ServiceObject, CollectionObject } from '@MailManager/models'
|
||||||
import FolderSelectionTreeNode from './FolderSelectionTreeNode.vue'
|
import FolderSelectionTreeNode from './FolderSelectionTreeNode.vue'
|
||||||
|
|
||||||
@@ -31,9 +30,8 @@ const emit = defineEmits<{
|
|||||||
cancel: []
|
cancel: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const collectionsStore = useCollectionsStore()
|
|
||||||
const servicesStore = useServicesStore()
|
|
||||||
const mailStore = useMailStore()
|
const mailStore = useMailStore()
|
||||||
|
const collectionsStore = useCollectionsStore()
|
||||||
|
|
||||||
const selectedFolderKey = ref<string | null>(null)
|
const selectedFolderKey = ref<string | null>(null)
|
||||||
|
|
||||||
@@ -42,10 +40,6 @@ const dialogValue = computed({
|
|||||||
set: (value: boolean) => emit('update:modelValue', value),
|
set: (value: boolean) => emit('update:modelValue', value),
|
||||||
})
|
})
|
||||||
|
|
||||||
const folderKeyFor = (folder: CollectionObject): string => {
|
|
||||||
return `${folder.provider}:${String(folder.service)}:${String(folder.identifier)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServiceGroup {
|
interface ServiceGroup {
|
||||||
service: ServiceObject
|
service: ServiceObject
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@@ -54,9 +48,7 @@ interface ServiceGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const serviceGroups = computed<ServiceGroup[]>(() => {
|
const serviceGroups = computed<ServiceGroup[]>(() => {
|
||||||
const service = props.service ??
|
const service = props.service
|
||||||
(mailStore.moveDialogService ? servicesStore.serviceByIdentifier(mailStore.moveDialogService) : null)
|
|
||||||
|
|
||||||
if (!service) {
|
if (!service) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -73,6 +65,34 @@ const serviceGroups = computed<ServiceGroup[]>(() => {
|
|||||||
}]
|
}]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const selectedFolder = computed(() => {
|
||||||
|
if (!selectedFolderKey.value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = serviceGroups.value[0]
|
||||||
|
if (!group) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return getServiceFolders(group.service).find(folder => folder.identifier === selectedFolderKey.value) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const canConfirm = computed(() => {
|
||||||
|
return selectedFolder.value !== null && !props.loading
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedFolderKey.value = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const getRootFolders = (service: ServiceObject): CollectionObject[] => {
|
const getRootFolders = (service: ServiceObject): CollectionObject[] => {
|
||||||
if (service.identifier === null) {
|
if (service.identifier === null) {
|
||||||
return []
|
return []
|
||||||
@@ -89,36 +109,8 @@ const getServiceFolders = (service: ServiceObject): CollectionObject[] => {
|
|||||||
return collectionsStore.collectionsForService(service.provider, service.identifier)
|
return collectionsStore.collectionsForService(service.provider, service.identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedFolder = computed(() => {
|
|
||||||
if (!selectedFolderKey.value) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const group = serviceGroups.value[0]
|
|
||||||
if (!group) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return getServiceFolders(group.service).find(folder => folderKeyFor(folder) === selectedFolderKey.value) ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
const canConfirm = computed(() => {
|
|
||||||
return selectedFolder.value !== null && !props.loading
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => [props.modelValue, mailStore.moveDialogService],
|
|
||||||
([isOpen]) => {
|
|
||||||
if (!isOpen) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedFolderKey.value = null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleSelect = (folder: CollectionObject) => {
|
const handleSelect = (folder: CollectionObject) => {
|
||||||
selectedFolderKey.value = folderKeyFor(folder)
|
selectedFolderKey.value = folder.identifier
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
@@ -168,7 +160,7 @@ const handleConfirm = () => {
|
|||||||
|
|
||||||
<FolderSelectionTreeNode
|
<FolderSelectionTreeNode
|
||||||
v-for="folder in getRootFolders(group.service)"
|
v-for="folder in getRootFolders(group.service)"
|
||||||
:key="folderKeyFor(folder)"
|
:key="folder.identifier"
|
||||||
:folder="folder"
|
:folder="folder"
|
||||||
:service="group.service"
|
:service="group.service"
|
||||||
:selected-folder-key="selectedFolderKey"
|
:selected-folder-key="selectedFolderKey"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue'
|
|||||||
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
||||||
import type { CollectionObject } from '@MailManager/models/collection'
|
import type { CollectionObject } from '@MailManager/models/collection'
|
||||||
import type { ServiceObject } from '@MailManager/models'
|
import type { ServiceObject } from '@MailManager/models'
|
||||||
|
import type { CollectionIdentifier } from '@MailManager/types/common'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
folder: CollectionObject
|
folder: CollectionObject
|
||||||
@@ -20,9 +21,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const expanded = ref(false)
|
const expanded = ref(false)
|
||||||
|
|
||||||
const folderKeyFor = (folder: CollectionObject): string => {
|
const folderKeyFor = (folder: CollectionObject): string => String(folder.identifier)
|
||||||
return `${folder.provider}:${String(folder.service)}:${String(folder.identifier)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const folderLabelFor = (folder: CollectionObject): string => {
|
const folderLabelFor = (folder: CollectionObject): string => {
|
||||||
return folder.properties.label || String(folder.identifier)
|
return folder.properties.label || String(folder.identifier)
|
||||||
@@ -76,7 +75,11 @@ const childFolders = computed(() => {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
return collectionsStore.collectionsInCollection(props.service.provider, serviceIdentifier, props.folder.identifier)
|
return collectionsStore.collectionsInCollection(
|
||||||
|
props.service.provider,
|
||||||
|
serviceIdentifier,
|
||||||
|
props.folder.identifier as CollectionIdentifier,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasChildren = computed(() => {
|
const hasChildren = computed(() => {
|
||||||
@@ -86,7 +89,7 @@ const hasChildren = computed(() => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return collectionsStore.hasChildrenInCollection(props.service.provider, serviceIdentifier, props.folder.identifier)
|
return collectionsStore.hasChildrenInCollection(props.service.provider, serviceIdentifier, props.folder.identifier as CollectionIdentifier)
|
||||||
})
|
})
|
||||||
const isSelected = computed(() => props.selectedFolderKey === key.value)
|
const isSelected = computed(() => props.selectedFolderKey === key.value)
|
||||||
|
|
||||||
|
|||||||
@@ -1,278 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
|
||||||
import { useServicesStore } from '@MailManager/stores/servicesStore'
|
|
||||||
import { useMailStore } from '@/stores/mailStore'
|
|
||||||
import { useUser } from '@KTXC'
|
|
||||||
import FolderTreeView from './FolderTreeView.vue'
|
|
||||||
import FolderPageView from './FolderPageView.vue'
|
|
||||||
import CreateFolderDialog from './CreateFolderDialog.vue'
|
|
||||||
import DeleteFolderDialog from './DeleteFolderDialog.vue'
|
|
||||||
import FolderSelectionDialog from './FolderSelectionDialog.vue'
|
|
||||||
import RenameFolderDialog from './RenameFolderDialog.vue'
|
|
||||||
import type { CollectionObject } from '@MailManager/models/collection'
|
|
||||||
import type { ServiceObject } from '@MailManager/models'
|
|
||||||
|
|
||||||
type FolderViewMode = 'tree' | 'page'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
selectedFolder?: CollectionObject | null
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// Emits
|
|
||||||
const emit = defineEmits<{
|
|
||||||
select: [folder: CollectionObject]
|
|
||||||
folderCreated: [folder: CollectionObject]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// Stores
|
|
||||||
const collectionsStore = useCollectionsStore()
|
|
||||||
const servicesStore = useServicesStore()
|
|
||||||
const mailStore = useMailStore()
|
|
||||||
|
|
||||||
// User settings
|
|
||||||
const { settings } = useUser()
|
|
||||||
|
|
||||||
// Folder view mode from user settings
|
|
||||||
const folderViewMode = computed(() => {
|
|
||||||
return (settings.value.get('mail.folderViewMode') as FolderViewMode) || 'tree'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create folder dialog state
|
|
||||||
const createDialogVisible = ref(false)
|
|
||||||
const createDialogService = ref<ServiceObject | null>(null)
|
|
||||||
const createDialogParent = ref<CollectionObject | null>(null)
|
|
||||||
const renameDialogVisible = ref(false)
|
|
||||||
const renameDialogService = ref<ServiceObject | null>(null)
|
|
||||||
const renameDialogFolder = ref<CollectionObject | null>(null)
|
|
||||||
const moveDialogVisible = ref(false)
|
|
||||||
const moveDialogService = ref<ServiceObject | null>(null)
|
|
||||||
const moveDialogFolder = ref<CollectionObject | null>(null)
|
|
||||||
const deleteDialogVisible = ref(false)
|
|
||||||
const deleteDialogService = ref<ServiceObject | null>(null)
|
|
||||||
const deleteDialogFolder = ref<CollectionObject | null>(null)
|
|
||||||
|
|
||||||
// Handle create folder event from child components
|
|
||||||
const handleCreateFolder = (service: ServiceObject, parentFolder: CollectionObject | null = null) => {
|
|
||||||
createDialogService.value = service
|
|
||||||
createDialogParent.value = parentFolder
|
|
||||||
createDialogVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle folder created
|
|
||||||
const handleFolderCreated = (newFolder: CollectionObject) => {
|
|
||||||
emit('folderCreated', newFolder)
|
|
||||||
emit('select', newFolder)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEditFolder = (service: ServiceObject, folder: CollectionObject) => {
|
|
||||||
renameDialogService.value = service
|
|
||||||
renameDialogFolder.value = folder
|
|
||||||
renameDialogVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMoveFolder = (service: ServiceObject, folder: CollectionObject) => {
|
|
||||||
moveDialogService.value = service
|
|
||||||
moveDialogFolder.value = folder
|
|
||||||
moveDialogVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteFolder = (service: ServiceObject, folder: CollectionObject) => {
|
|
||||||
deleteDialogService.value = service
|
|
||||||
deleteDialogFolder.value = folder
|
|
||||||
deleteDialogVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFolderRenamed = (updatedFolder: CollectionObject) => {
|
|
||||||
emit('select', updatedFolder)
|
|
||||||
}
|
|
||||||
|
|
||||||
const folderKeyFor = (folder: CollectionObject): string => {
|
|
||||||
return `${folder.provider}:${String(folder.service)}:${String(folder.identifier)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const moveDialogInvalidFolderKeys = computed(() => {
|
|
||||||
const sourceFolder = moveDialogFolder.value
|
|
||||||
|
|
||||||
if (!sourceFolder) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const invalidKeys = new Set<string>()
|
|
||||||
const queue: CollectionObject[] = [sourceFolder]
|
|
||||||
|
|
||||||
while (queue.length > 0) {
|
|
||||||
const currentFolder = queue.shift()
|
|
||||||
|
|
||||||
if (!currentFolder) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
invalidKeys.add(folderKeyFor(currentFolder))
|
|
||||||
|
|
||||||
collectionsStore
|
|
||||||
.collectionsInCollection(currentFolder.provider, currentFolder.service, currentFolder.identifier)
|
|
||||||
.forEach(childFolder => {
|
|
||||||
queue.push(childFolder)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(invalidKeys)
|
|
||||||
})
|
|
||||||
|
|
||||||
const isSameFolder = (left: CollectionObject | null | undefined, right: CollectionObject | null | undefined) => {
|
|
||||||
if (!left || !right) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return left.provider === right.provider &&
|
|
||||||
String(left.service) === String(right.service) &&
|
|
||||||
String(left.identifier) === String(right.identifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFolderDeleted = (deletedFolder: CollectionObject) => {
|
|
||||||
if (isSameFolder(props.selectedFolder, deletedFolder)) {
|
|
||||||
mailStore.clearSelectedFolder()
|
|
||||||
}
|
|
||||||
|
|
||||||
mailStore.notify(`Folder "${deletedFolder.properties.label || String(deletedFolder.identifier)}" deleted`, 'success')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMoveDialogCancel = () => {
|
|
||||||
moveDialogVisible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFolderMove = async (targetFolder: CollectionObject) => {
|
|
||||||
const sourceFolder = moveDialogFolder.value
|
|
||||||
|
|
||||||
if (!sourceFolder) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const movedFolder = await collectionsStore.move(folderKeyFor(targetFolder), folderKeyFor(sourceFolder))
|
|
||||||
moveDialogVisible.value = false
|
|
||||||
|
|
||||||
if (isSameFolder(props.selectedFolder, sourceFolder)) {
|
|
||||||
emit('select', movedFolder)
|
|
||||||
}
|
|
||||||
|
|
||||||
mailStore.notify(
|
|
||||||
`Folder "${sourceFolder.properties.label || String(sourceFolder.identifier)}" moved to "${targetFolder.properties.label || String(targetFolder.identifier)}"`,
|
|
||||||
'success',
|
|
||||||
)
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error('[FolderTree] Failed to move folder:', error)
|
|
||||||
mailStore.notify(error instanceof Error ? error.message : 'Failed to move folder', 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Computed: all folders for validation
|
|
||||||
const allFolders = computed(() =>
|
|
||||||
servicesStore.servicesEnabled.flatMap(service =>
|
|
||||||
collectionsStore.collectionsForService(service.provider, service.identifier),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
interface ServiceGroup {
|
|
||||||
service: ServiceObject
|
|
||||||
loading: boolean
|
|
||||||
loaded: boolean
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
const serviceGroups = computed(() => {
|
|
||||||
const groups: ServiceGroup[] = []
|
|
||||||
|
|
||||||
servicesStore.servicesEnabled.forEach(service => {
|
|
||||||
groups.push({
|
|
||||||
service,
|
|
||||||
loading: mailStore.isServiceFolderLoading(service.provider, service.identifier),
|
|
||||||
loaded: mailStore.hasServiceFoldersLoaded(service.provider, service.identifier),
|
|
||||||
error: mailStore.getServiceFolderError(service.provider, service.identifier),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return groups
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<v-list density="compact" nav>
|
|
||||||
<!-- Tree View -->
|
|
||||||
<FolderTreeView
|
|
||||||
v-if="folderViewMode === 'tree'"
|
|
||||||
:selected-folder="selectedFolder"
|
|
||||||
:service-groups="serviceGroups"
|
|
||||||
@select="emit('select', $event)"
|
|
||||||
@create-folder="handleCreateFolder"
|
|
||||||
@edit-folder="handleEditFolder"
|
|
||||||
@move-folder="handleMoveFolder"
|
|
||||||
@delete-folder="handleDeleteFolder"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Page-based View -->
|
|
||||||
<FolderPageView
|
|
||||||
v-else
|
|
||||||
:selected-folder="selectedFolder"
|
|
||||||
:service-groups="serviceGroups"
|
|
||||||
@select="emit('select', $event)"
|
|
||||||
@create-folder="handleCreateFolder"
|
|
||||||
@edit-folder="handleEditFolder"
|
|
||||||
@move-folder="handleMoveFolder"
|
|
||||||
@delete-folder="handleDeleteFolder"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Empty state -->
|
|
||||||
<v-list-item v-if="servicesStore.servicesEnabled.length === 0">
|
|
||||||
<v-list-item-title class="text-center text-medium-emphasis">
|
|
||||||
No mail accounts configured
|
|
||||||
</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
|
|
||||||
<!-- Create Folder Dialog -->
|
|
||||||
<CreateFolderDialog
|
|
||||||
v-if="createDialogService"
|
|
||||||
v-model="createDialogVisible"
|
|
||||||
:service="createDialogService"
|
|
||||||
:parent-folder="createDialogParent"
|
|
||||||
:all-folders="allFolders"
|
|
||||||
@created="handleFolderCreated"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RenameFolderDialog
|
|
||||||
v-if="renameDialogService && renameDialogFolder"
|
|
||||||
v-model="renameDialogVisible"
|
|
||||||
:service="renameDialogService"
|
|
||||||
:folder="renameDialogFolder"
|
|
||||||
:all-folders="allFolders"
|
|
||||||
@updated="handleFolderRenamed"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FolderSelectionDialog
|
|
||||||
v-if="moveDialogService && moveDialogFolder"
|
|
||||||
v-model="moveDialogVisible"
|
|
||||||
:service="moveDialogService"
|
|
||||||
:loading="collectionsStore.transceiving"
|
|
||||||
title="Move Folder"
|
|
||||||
confirm-text="Move Folder"
|
|
||||||
empty-text="No valid target folders are available."
|
|
||||||
:disabled-folder-keys="moveDialogInvalidFolderKeys"
|
|
||||||
@cancel="handleMoveDialogCancel"
|
|
||||||
@select="handleFolderMove"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DeleteFolderDialog
|
|
||||||
v-if="deleteDialogService && deleteDialogFolder"
|
|
||||||
v-model="deleteDialogVisible"
|
|
||||||
:service="deleteDialogService"
|
|
||||||
:folder="deleteDialogFolder"
|
|
||||||
@deleted="handleFolderDeleted"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.v-list-item--active {
|
|
||||||
background-color: rgba(var(--v-theme-primary), 0.12);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -17,17 +17,17 @@ const expanded = ref(false)
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [folder: CollectionObject]
|
select: [folder: CollectionObject]
|
||||||
createSubfolder: [service: ServiceObject, parentFolder: CollectionObject]
|
createSubfolder: [service: ServiceObject, parentFolder: CollectionObject]
|
||||||
editFolder: [service: ServiceObject, folder: CollectionObject]
|
editFolder: [folder: CollectionObject]
|
||||||
moveFolder: [service: ServiceObject, folder: CollectionObject]
|
moveFolder: [folder: CollectionObject]
|
||||||
deleteFolder: [service: ServiceObject, folder: CollectionObject]
|
deleteFolder: [folder: CollectionObject]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const childFolders = computed(() => {
|
const childFolders = computed(() => {
|
||||||
return collectionsStore.collectionsInCollection(props.service.provider, props.service.identifier, props.folder.identifier)
|
return collectionsStore.collectionsInCollection(props.service.provider, props.service.identifier ?? '', props.folder.identifier)
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasChildren = computed(() => {
|
const hasChildren = computed(() => {
|
||||||
return collectionsStore.hasChildrenInCollection(props.service.provider, props.service.identifier, props.folder.identifier)
|
return collectionsStore.hasChildrenInCollection(props.service.provider, props.service.identifier ?? '', props.folder.identifier)
|
||||||
})
|
})
|
||||||
|
|
||||||
const canDeleteFolder = computed(() => !props.folder.properties.role)
|
const canDeleteFolder = computed(() => !props.folder.properties.role)
|
||||||
@@ -131,7 +131,7 @@ const isSelected = (folder: CollectionObject): boolean => {
|
|||||||
<v-list density="compact">
|
<v-list density="compact">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
prepend-icon="mdi-pencil"
|
prepend-icon="mdi-pencil"
|
||||||
@click="emit('editFolder', service, folder)"
|
@click="emit('editFolder', folder)"
|
||||||
>
|
>
|
||||||
<v-list-item-title>Edit Folder Name</v-list-item-title>
|
<v-list-item-title>Edit Folder Name</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
@@ -143,7 +143,7 @@ const isSelected = (folder: CollectionObject): boolean => {
|
|||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
prepend-icon="mdi-folder-move"
|
prepend-icon="mdi-folder-move"
|
||||||
@click="emit('moveFolder', service, folder)"
|
@click="emit('moveFolder', folder)"
|
||||||
>
|
>
|
||||||
<v-list-item-title>Move Folder</v-list-item-title>
|
<v-list-item-title>Move Folder</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
@@ -151,7 +151,7 @@ const isSelected = (folder: CollectionObject): boolean => {
|
|||||||
v-if="canDeleteFolder"
|
v-if="canDeleteFolder"
|
||||||
prepend-icon="mdi-delete"
|
prepend-icon="mdi-delete"
|
||||||
base-color="error"
|
base-color="error"
|
||||||
@click="emit('deleteFolder', service, folder)"
|
@click="emit('deleteFolder', folder)"
|
||||||
>
|
>
|
||||||
<v-list-item-title>Delete Folder</v-list-item-title>
|
<v-list-item-title>Delete Folder</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
@@ -170,9 +170,9 @@ 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)"
|
@edit-folder="(folder) => emit('editFolder', folder)"
|
||||||
@move-folder="(service, folder) => emit('moveFolder', service, folder)"
|
@move-folder="(folder) => emit('moveFolder', folder)"
|
||||||
@delete-folder="(service, folder) => emit('deleteFolder', service, folder)"
|
@delete-folder="(folder) => emit('deleteFolder', folder)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</v-list-group>
|
</v-list-group>
|
||||||
@@ -217,7 +217,7 @@ const isSelected = (folder: CollectionObject): boolean => {
|
|||||||
<v-list density="compact">
|
<v-list density="compact">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
prepend-icon="mdi-pencil"
|
prepend-icon="mdi-pencil"
|
||||||
@click="emit('editFolder', service, folder)"
|
@click="emit('editFolder', folder)"
|
||||||
>
|
>
|
||||||
<v-list-item-title>Edit Folder Name</v-list-item-title>
|
<v-list-item-title>Edit Folder Name</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
@@ -229,7 +229,7 @@ const isSelected = (folder: CollectionObject): boolean => {
|
|||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-list-item
|
<v-list-item
|
||||||
prepend-icon="mdi-folder-move"
|
prepend-icon="mdi-folder-move"
|
||||||
@click="emit('moveFolder', service, folder)"
|
@click="emit('moveFolder', folder)"
|
||||||
>
|
>
|
||||||
<v-list-item-title>Move Folder</v-list-item-title>
|
<v-list-item-title>Move Folder</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
@@ -237,7 +237,7 @@ const isSelected = (folder: CollectionObject): boolean => {
|
|||||||
v-if="canDeleteFolder"
|
v-if="canDeleteFolder"
|
||||||
prepend-icon="mdi-delete"
|
prepend-icon="mdi-delete"
|
||||||
base-color="error"
|
base-color="error"
|
||||||
@click="emit('deleteFolder', service, folder)"
|
@click="emit('deleteFolder', folder)"
|
||||||
>
|
>
|
||||||
<v-list-item-title>Delete Folder</v-list-item-title>
|
<v-list-item-title>Delete Folder</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ const collectionsStore = useCollectionsStore()
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [folder: CollectionObject]
|
select: [folder: CollectionObject]
|
||||||
createFolder: [service: ServiceObject, parentFolder: CollectionObject | null]
|
createFolder: [service: ServiceObject, parentFolder: CollectionObject | null]
|
||||||
editFolder: [service: ServiceObject, folder: CollectionObject]
|
editFolder: [folder: CollectionObject]
|
||||||
moveFolder: [service: ServiceObject, folder: CollectionObject]
|
moveFolder: [folder: CollectionObject]
|
||||||
deleteFolder: [service: ServiceObject, folder: CollectionObject]
|
deleteFolder: [folder: CollectionObject]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const getRootFolders = (service: ServiceObject): CollectionObject[] => {
|
const getRootFolders = (service: ServiceObject): CollectionObject[] => {
|
||||||
@@ -75,9 +75,9 @@ const getServiceFolders = (service: ServiceObject): CollectionObject[] => {
|
|||||||
: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)"
|
@edit-folder="(folder) => emit('editFolder', folder)"
|
||||||
@move-folder="(service, folder) => emit('moveFolder', service, folder)"
|
@move-folder="(folder) => emit('moveFolder', folder)"
|
||||||
@delete-folder="(service, folder) => emit('deleteFolder', service, folder)"
|
@delete-folder="(folder) => emit('deleteFolder', folder)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<v-list-item v-if="group.loading && getServiceFolders(group.service).length === 0" disabled class="folder-status-item">
|
<v-list-item v-if="group.loading && getServiceFolders(group.service).length === 0" disabled class="folder-status-item">
|
||||||
|
|||||||
118
src/components/FolderView.vue
Normal file
118
src/components/FolderView.vue
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useServicesStore } from '@MailManager/stores/servicesStore'
|
||||||
|
import { useMailStore } from '@/stores/mailStore'
|
||||||
|
import { useMailUiStore } from '@/stores/mailUiStore'
|
||||||
|
import { useUser } from '@KTXC'
|
||||||
|
import FolderTreeView from './FolderTreeView.vue'
|
||||||
|
import FolderPageView from './FolderPageView.vue'
|
||||||
|
import type { CollectionObject } from '@MailManager/models/collection'
|
||||||
|
import type { ServiceObject } from '@MailManager/models'
|
||||||
|
|
||||||
|
type FolderViewMode = 'tree' | 'page'
|
||||||
|
|
||||||
|
interface ServiceGroup {
|
||||||
|
service: ServiceObject
|
||||||
|
loading: boolean
|
||||||
|
loaded: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
selectedFolder?: CollectionObject | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Emits
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [folder: CollectionObject]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Stores
|
||||||
|
const servicesStore = useServicesStore()
|
||||||
|
const mailStore = useMailStore()
|
||||||
|
const mailUiStore = useMailUiStore()
|
||||||
|
const { settings } = useUser()
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const folderViewMode = computed(() => {
|
||||||
|
return (settings.value.get('mail.folderViewMode') as FolderViewMode) || 'tree'
|
||||||
|
})
|
||||||
|
|
||||||
|
const serviceGroups = computed(() => {
|
||||||
|
const groups: ServiceGroup[] = []
|
||||||
|
|
||||||
|
servicesStore.servicesEnabled.forEach(service => {
|
||||||
|
if (service.identifier === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
service,
|
||||||
|
loading: mailStore.isServiceFolderLoading(service.provider, service.identifier),
|
||||||
|
loaded: mailStore.hasServiceFoldersLoaded(service.provider, service.identifier),
|
||||||
|
error: mailStore.getServiceFolderError(service.provider, service.identifier),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleFolderCreate = (service: ServiceObject, parentFolder: CollectionObject | null = null) => {
|
||||||
|
mailUiStore.openCreateFolderDialog(service, parentFolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFolderEdit = (folder: CollectionObject) => {
|
||||||
|
mailUiStore.openRenameFolderDialog(folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFolderDelete = (folder: CollectionObject) => {
|
||||||
|
mailUiStore.openDeleteFolderDialog(folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFolderMove = (folder: CollectionObject) => {
|
||||||
|
mailUiStore.openMoveFolderDialog(folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-list density="compact" nav>
|
||||||
|
<!-- Tree View -->
|
||||||
|
<FolderTreeView
|
||||||
|
v-if="folderViewMode === 'tree'"
|
||||||
|
:selected-folder="selectedFolder"
|
||||||
|
:service-groups="serviceGroups"
|
||||||
|
@select="emit('select', $event)"
|
||||||
|
@create-folder="handleFolderCreate"
|
||||||
|
@edit-folder="handleFolderEdit"
|
||||||
|
@move-folder="handleFolderMove"
|
||||||
|
@delete-folder="handleFolderDelete"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Page-based View -->
|
||||||
|
<FolderPageView
|
||||||
|
v-else
|
||||||
|
:selected-folder="selectedFolder"
|
||||||
|
:service-groups="serviceGroups"
|
||||||
|
@select="emit('select', $event)"
|
||||||
|
@create-folder="handleFolderCreate"
|
||||||
|
@edit-folder="handleFolderEdit"
|
||||||
|
@move-folder="handleFolderMove"
|
||||||
|
@delete-folder="handleFolderDelete"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<v-list-item v-if="servicesStore.servicesEnabled.length === 0">
|
||||||
|
<v-list-item-title class="text-center text-medium-emphasis">
|
||||||
|
No mail accounts configured
|
||||||
|
</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.v-list-item--active {
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.12);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,22 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||||
import { useEditor, EditorContent } from '@tiptap/vue-3'
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useEditor } from '@tiptap/vue-3'
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
import Link from '@tiptap/extension-link'
|
import Link from '@tiptap/extension-link'
|
||||||
import Underline from '@tiptap/extension-underline'
|
import Underline from '@tiptap/extension-underline'
|
||||||
import TextAlign from '@tiptap/extension-text-align'
|
import TextAlign from '@tiptap/extension-text-align'
|
||||||
import Placeholder from '@tiptap/extension-placeholder'
|
import Placeholder from '@tiptap/extension-placeholder'
|
||||||
import { entityService } from '@MailManager/services'
|
|
||||||
import type { EntityInterface } from '@MailManager/types/entity'
|
|
||||||
import type { MessageInterface } from '@MailManager/types/message'
|
|
||||||
import type { CollectionInterface } from '@MailManager/types/collection'
|
|
||||||
import { MessageObject } from '@MailManager/models/message'
|
|
||||||
import { EntityObject } from '@MailManager/models/entity'
|
import { EntityObject } from '@MailManager/models/entity'
|
||||||
|
import type { CollectionObject } from '@MailManager/models'
|
||||||
|
import { useMailStore } from '@/stores/mailStore'
|
||||||
|
import ComposerToolbar from '@/components/composer/ComposerToolbar.vue'
|
||||||
|
import ComposerRecipients from '@/components/composer/ComposerRecipients.vue'
|
||||||
|
import ComposerEditor from '@/components/composer/ComposerEditor.vue'
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
interface Props {
|
interface Props {
|
||||||
replyTo?: EntityInterface<MessageInterface> | null
|
mode: 'new' | 'reply' | 'forward'
|
||||||
folder?: CollectionInterface | null
|
source?: EntityObject | null
|
||||||
|
folder?: CollectionObject | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@@ -27,6 +29,13 @@ const emit = defineEmits<{
|
|||||||
sent: []
|
sent: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const mailStore = useMailStore()
|
||||||
|
const {
|
||||||
|
composerSending: sending,
|
||||||
|
composerSaving: saving,
|
||||||
|
composerLastSaved: lastSaved,
|
||||||
|
} = storeToRefs(mailStore)
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const to = ref<string[]>([])
|
const to = ref<string[]>([])
|
||||||
const cc = ref<string[]>([])
|
const cc = ref<string[]>([])
|
||||||
@@ -34,10 +43,6 @@ const bcc = ref<string[]>([])
|
|||||||
const subject = ref('')
|
const subject = ref('')
|
||||||
const showCc = ref(false)
|
const showCc = ref(false)
|
||||||
const showBcc = ref(false)
|
const showBcc = ref(false)
|
||||||
const sending = ref(false)
|
|
||||||
const saving = ref(false)
|
|
||||||
const lastSaved = ref<Date | null>(null)
|
|
||||||
const draftId = ref<string | null>(null)
|
|
||||||
|
|
||||||
// Auto-save timer
|
// Auto-save timer
|
||||||
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null
|
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
@@ -65,25 +70,65 @@ const editor = useEditor({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Initialize from reply-to message
|
function resetComposerFields() {
|
||||||
if (props.replyTo) {
|
to.value = []
|
||||||
const replyMessage = new MessageObject(props.replyTo.properties)
|
cc.value = []
|
||||||
|
bcc.value = []
|
||||||
const fromEmail = replyMessage.from?.address
|
subject.value = ''
|
||||||
to.value = fromEmail ? [fromEmail] : []
|
showCc.value = false
|
||||||
|
showBcc.value = false
|
||||||
const originalSubject = replyMessage.subject || ''
|
editor.value?.commands.setContent('')
|
||||||
subject.value = originalSubject.startsWith('Re:')
|
|
||||||
? originalSubject
|
|
||||||
: `Re: ${originalSubject}`
|
|
||||||
|
|
||||||
// Add quoted reply - prefer HTML content, fallback to text
|
|
||||||
const originalBody = replyMessage.getHtmlContent() || replyMessage.getTextContent() || ''
|
|
||||||
const senderName = replyMessage.from?.label || replyMessage.from?.address || 'Unknown'
|
|
||||||
const quotedReply = `<p><br></p><p>On ${new Date(replyMessage.date || '').toLocaleString()}, ${senderName} wrote:</p><blockquote>${originalBody}</blockquote>`
|
|
||||||
editor.value?.commands.setContent(quotedReply)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initializeComposerFromProps() {
|
||||||
|
mailStore.resetComposerState()
|
||||||
|
resetComposerFields()
|
||||||
|
|
||||||
|
if (!props.source) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceMessage = props.source.properties
|
||||||
|
const originalSubject = sourceMessage.subject || ''
|
||||||
|
const originalBody = sourceMessage.getHtmlContent() || sourceMessage.getTextContent() || ''
|
||||||
|
const senderName = sourceMessage.from?.label || sourceMessage.from?.address || 'Unknown'
|
||||||
|
const sentAt = sourceMessage.sent || props.source.created || ''
|
||||||
|
const sentLabel = sentAt ? new Date(sentAt).toLocaleString() : 'an unknown time'
|
||||||
|
|
||||||
|
if (props.mode === 'reply') {
|
||||||
|
const fromEmail = sourceMessage.replyTo?.[0]?.address || sourceMessage.from?.address
|
||||||
|
to.value = fromEmail ? [fromEmail] : []
|
||||||
|
subject.value = /^Re:/i.test(originalSubject)
|
||||||
|
? originalSubject
|
||||||
|
: `Re: ${originalSubject}`
|
||||||
|
editor.value?.commands.setContent(
|
||||||
|
`<p><br></p><p>On ${sentLabel}, ${senderName} wrote:</p><blockquote>${originalBody}</blockquote>`,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.mode === 'forward') {
|
||||||
|
subject.value = /^Fwd:/i.test(originalSubject)
|
||||||
|
? originalSubject
|
||||||
|
: `Fwd: ${originalSubject}`
|
||||||
|
editor.value?.commands.setContent(
|
||||||
|
`<p><br></p><p>---------- Forwarded message ---------</p><p>From: ${senderName}</p><p>Date: ${sentLabel}</p><p>Subject: ${originalSubject}</p><blockquote>${originalBody}</blockquote>`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => props.mode, () => props.source, () => editor.value],
|
||||||
|
([, , currentEditor]) => {
|
||||||
|
if (!currentEditor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeComposerFromProps()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const canSend = computed(() => {
|
const canSend = computed(() => {
|
||||||
return to.value.length > 0 && subject.value.trim().length > 0
|
return to.value.length > 0 && subject.value.trim().length > 0
|
||||||
@@ -110,10 +155,8 @@ const saveDraft = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
saving.value = true
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const draftData = {
|
await mailStore.saveComposerDraft(props.folder, {
|
||||||
to: to.value,
|
to: to.value,
|
||||||
cc: cc.value,
|
cc: cc.value,
|
||||||
bcc: bcc.value,
|
bcc: bcc.value,
|
||||||
@@ -122,27 +165,9 @@ const saveDraft = async () => {
|
|||||||
html: editor.value?.getHTML() || '',
|
html: editor.value?.getHTML() || '',
|
||||||
text: editor.value?.getText() || '',
|
text: editor.value?.getText() || '',
|
||||||
},
|
},
|
||||||
}
|
|
||||||
|
|
||||||
// Find drafts folder for this service
|
|
||||||
// For now, we'll use the current folder's service
|
|
||||||
// In a real implementation, you'd find the actual Drafts folder
|
|
||||||
const response = await entityService.create({
|
|
||||||
provider: props.folder.provider,
|
|
||||||
service: props.folder.service,
|
|
||||||
collection: props.folder.identifier, // Should be drafts folder ID
|
|
||||||
properties: draftData,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response) {
|
|
||||||
draftId.value = String(response.identifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
lastSaved.value = new Date()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[MessageComposer] Failed to save draft:', error)
|
console.error('[Mail][Composer] Failed to save draft:', error)
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,53 +198,33 @@ onBeforeUnmount(() => {
|
|||||||
if (autoSaveTimer) {
|
if (autoSaveTimer) {
|
||||||
clearTimeout(autoSaveTimer)
|
clearTimeout(autoSaveTimer)
|
||||||
}
|
}
|
||||||
|
mailStore.resetComposerState()
|
||||||
editor.value?.destroy()
|
editor.value?.destroy()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
mailStore.resetComposerState()
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
if (!canSend.value || sending.value) return
|
if (!canSend.value || sending.value) return
|
||||||
|
|
||||||
sending.value = true
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await entityService.transmit({
|
await mailStore.sendComposerMessage({
|
||||||
message: {
|
to: to.value,
|
||||||
to: to.value,
|
cc: cc.value,
|
||||||
cc: cc.value.length > 0 ? cc.value : undefined,
|
bcc: bcc.value,
|
||||||
bcc: bcc.value.length > 0 ? bcc.value : undefined,
|
subject: subject.value,
|
||||||
subject: subject.value,
|
body: {
|
||||||
body: {
|
html: editor.value?.getHTML() || '',
|
||||||
html: editor.value?.getHTML() || '',
|
text: editor.value?.getText() || '',
|
||||||
text: editor.value?.getText() || '',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Delete draft if it was saved
|
|
||||||
if (draftId.value && props.folder) {
|
|
||||||
try {
|
|
||||||
await entityService.delete({
|
|
||||||
provider: props.folder.provider,
|
|
||||||
service: props.folder.service,
|
|
||||||
collection: props.folder.identifier,
|
|
||||||
identifier: draftId.value,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[MessageComposer] Failed to delete draft:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('sent')
|
emit('sent')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[MessageComposer] Failed to send message:', error)
|
console.error('[Mail][Composer] Failed to send message:', error)
|
||||||
alert('Failed to send message. Please try again.')
|
|
||||||
} finally {
|
|
||||||
sending.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,192 +253,61 @@ const removeLink = () => editor.value?.chain().focus().unsetLink().run()
|
|||||||
const isActive = (name: string, attrs?: any) => {
|
const isActive = (name: string, attrs?: any) => {
|
||||||
return editor.value?.isActive(name, attrs) || false
|
return editor.value?.isActive(name, attrs) || false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleLink = () => {
|
||||||
|
if (isActive('link')) {
|
||||||
|
removeLink()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLink()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="message-composer">
|
<div class="message-composer">
|
||||||
<!-- Toolbar -->
|
<ComposerToolbar
|
||||||
<v-toolbar density="compact" elevation="0" class="composer-toolbar">
|
:mode="mode"
|
||||||
<v-btn
|
:save-status="saveStatus"
|
||||||
variant="text"
|
:can-send="canSend"
|
||||||
@click="handleClose"
|
:sending="sending"
|
||||||
icon="mdi-close"
|
@close="handleClose"
|
||||||
>
|
@send="handleSend"
|
||||||
<v-icon>mdi-close</v-icon>
|
/>
|
||||||
<v-tooltip activator="parent" location="bottom">Close</v-tooltip>
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<v-toolbar-title>
|
|
||||||
{{ replyTo ? 'Reply' : 'New Message' }}
|
|
||||||
</v-toolbar-title>
|
|
||||||
|
|
||||||
<v-spacer />
|
|
||||||
|
|
||||||
<span v-if="saveStatus" class="text-caption text-medium-emphasis mr-4">
|
|
||||||
{{ saveStatus }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
color="primary"
|
|
||||||
:disabled="!canSend"
|
|
||||||
:loading="sending"
|
|
||||||
@click="handleSend"
|
|
||||||
prepend-icon="mdi-send"
|
|
||||||
>
|
|
||||||
Send
|
|
||||||
</v-btn>
|
|
||||||
</v-toolbar>
|
|
||||||
|
|
||||||
<!-- Composer content -->
|
|
||||||
<div class="composer-content">
|
<div class="composer-content">
|
||||||
<!-- Recipients -->
|
<ComposerRecipients
|
||||||
<div class="composer-fields pa-4">
|
:to="to"
|
||||||
<v-combobox
|
:cc="cc"
|
||||||
v-model="to"
|
:bcc="bcc"
|
||||||
label="To"
|
:subject="subject"
|
||||||
chips
|
:show-cc="showCc"
|
||||||
multiple
|
:show-bcc="showBcc"
|
||||||
closable-chips
|
@update:to="to = $event"
|
||||||
variant="outlined"
|
@update:cc="cc = $event"
|
||||||
density="compact"
|
@update:bcc="bcc = $event"
|
||||||
class="mb-2"
|
@update:subject="subject = $event"
|
||||||
>
|
@toggle:cc="toggleCc"
|
||||||
<template v-slot:append-inner>
|
@toggle:bcc="toggleBcc"
|
||||||
<v-btn
|
/>
|
||||||
size="x-small"
|
|
||||||
variant="text"
|
|
||||||
@click="toggleCc"
|
|
||||||
class="mr-1"
|
|
||||||
>
|
|
||||||
Cc
|
|
||||||
</v-btn>
|
|
||||||
<v-btn
|
|
||||||
size="x-small"
|
|
||||||
variant="text"
|
|
||||||
@click="toggleBcc"
|
|
||||||
>
|
|
||||||
Bcc
|
|
||||||
</v-btn>
|
|
||||||
</template>
|
|
||||||
</v-combobox>
|
|
||||||
|
|
||||||
<v-combobox
|
|
||||||
v-if="showCc"
|
|
||||||
v-model="cc"
|
|
||||||
label="Cc"
|
|
||||||
chips
|
|
||||||
multiple
|
|
||||||
closable-chips
|
|
||||||
variant="outlined"
|
|
||||||
density="compact"
|
|
||||||
class="mb-2"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-combobox
|
|
||||||
v-if="showBcc"
|
|
||||||
v-model="bcc"
|
|
||||||
label="Bcc"
|
|
||||||
chips
|
|
||||||
multiple
|
|
||||||
closable-chips
|
|
||||||
variant="outlined"
|
|
||||||
density="compact"
|
|
||||||
class="mb-2"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-text-field
|
|
||||||
v-model="subject"
|
|
||||||
label="Subject"
|
|
||||||
variant="outlined"
|
|
||||||
density="compact"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<v-divider />
|
<v-divider />
|
||||||
|
|
||||||
<!-- Editor toolbar -->
|
<ComposerEditor
|
||||||
<v-toolbar density="compact" elevation="0" class="editor-toolbar">
|
:editor="editor"
|
||||||
<v-btn
|
:is-bold-active="isActive('bold')"
|
||||||
icon
|
:is-italic-active="isActive('italic')"
|
||||||
size="small"
|
:is-underline-active="isActive('underline')"
|
||||||
:class="{ 'v-btn--active': isActive('bold') }"
|
:is-bullet-list-active="isActive('bulletList')"
|
||||||
@click="toggleBold"
|
:is-ordered-list-active="isActive('orderedList')"
|
||||||
>
|
:is-link-active="isActive('link')"
|
||||||
<v-icon>mdi-format-bold</v-icon>
|
@bold="toggleBold"
|
||||||
<v-tooltip activator="parent" location="bottom">Bold</v-tooltip>
|
@italic="toggleItalic"
|
||||||
</v-btn>
|
@underline="toggleUnderline"
|
||||||
|
@bullet-list="toggleBulletList"
|
||||||
<v-btn
|
@ordered-list="toggleOrderedList"
|
||||||
icon
|
@link="toggleLink"
|
||||||
size="small"
|
/>
|
||||||
:class="{ 'v-btn--active': isActive('italic') }"
|
|
||||||
@click="toggleItalic"
|
|
||||||
>
|
|
||||||
<v-icon>mdi-format-italic</v-icon>
|
|
||||||
<v-tooltip activator="parent" location="bottom">Italic</v-tooltip>
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
icon
|
|
||||||
size="small"
|
|
||||||
:class="{ 'v-btn--active': isActive('underline') }"
|
|
||||||
@click="toggleUnderline"
|
|
||||||
>
|
|
||||||
<v-icon>mdi-format-underline</v-icon>
|
|
||||||
<v-tooltip activator="parent" location="bottom">Underline</v-tooltip>
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<v-divider vertical class="mx-2" />
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
icon
|
|
||||||
size="small"
|
|
||||||
:class="{ 'v-btn--active': isActive('bulletList') }"
|
|
||||||
@click="toggleBulletList"
|
|
||||||
>
|
|
||||||
<v-icon>mdi-format-list-bulleted</v-icon>
|
|
||||||
<v-tooltip activator="parent" location="bottom">Bullet List</v-tooltip>
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
icon
|
|
||||||
size="small"
|
|
||||||
:class="{ 'v-btn--active': isActive('orderedList') }"
|
|
||||||
@click="toggleOrderedList"
|
|
||||||
>
|
|
||||||
<v-icon>mdi-format-list-numbered</v-icon>
|
|
||||||
<v-tooltip activator="parent" location="bottom">Numbered List</v-tooltip>
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<v-divider vertical class="mx-2" />
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
icon
|
|
||||||
size="small"
|
|
||||||
:class="{ 'v-btn--active': isActive('link') }"
|
|
||||||
@click="isActive('link') ? removeLink() : setLink()"
|
|
||||||
>
|
|
||||||
<v-icon>mdi-link</v-icon>
|
|
||||||
<v-tooltip activator="parent" location="bottom">Link</v-tooltip>
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<v-spacer />
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
icon
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<v-icon>mdi-paperclip</v-icon>
|
|
||||||
<v-tooltip activator="parent" location="bottom">Attach Files</v-tooltip>
|
|
||||||
</v-btn>
|
|
||||||
</v-toolbar>
|
|
||||||
|
|
||||||
<v-divider />
|
|
||||||
|
|
||||||
<!-- Editor -->
|
|
||||||
<div class="editor-container">
|
|
||||||
<editor-content :editor="editor" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -457,65 +331,4 @@ const isActive = (name: string, attrs?: any) => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer-fields {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-toolbar {
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-bottom: 1px solid rgb(var(--v-border-color));
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-container {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
background-color: rgb(var(--v-theme-background));
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-btn--active {
|
|
||||||
background-color: rgba(var(--v-theme-primary), 0.12);
|
|
||||||
color: rgb(var(--v-theme-primary));
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.tiptap-editor) {
|
|
||||||
outline: none;
|
|
||||||
min-height: 300px;
|
|
||||||
|
|
||||||
p.is-editor-empty:first-child::before {
|
|
||||||
color: rgb(var(--v-theme-on-surface-variant));
|
|
||||||
content: attr(data-placeholder);
|
|
||||||
float: left;
|
|
||||||
height: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
line-height: 1.3;
|
|
||||||
margin-top: 1em;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul, ol {
|
|
||||||
padding-left: 1.5em;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
border-left: 3px solid rgb(var(--v-border-color));
|
|
||||||
padding-left: 1em;
|
|
||||||
margin-left: 0;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
color: rgb(var(--v-theme-on-surface-variant));
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: rgb(var(--v-theme-primary));
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -38,34 +38,26 @@ const LONG_PRESS_MS = 450
|
|||||||
|
|
||||||
const selectedIdSet = computed(() => new Set(props.selectionList))
|
const selectedIdSet = computed(() => new Set(props.selectionList))
|
||||||
|
|
||||||
const isOpened = (message: EntityObject): boolean => {
|
|
||||||
if (!props.selectedMessage) return false
|
|
||||||
return (
|
|
||||||
message.provider === props.selectedMessage.provider &&
|
|
||||||
message.service === props.selectedMessage.service &&
|
|
||||||
message.collection === props.selectedMessage.collection &&
|
|
||||||
message.identifier === props.selectedMessage.identifier
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSelected = (message: EntityObject): boolean => {
|
|
||||||
return selectedIdSet.value.has(
|
|
||||||
`${message.provider}:${String(message.service)}:${String(message.collection)}:${String(message.identifier)}` as EntityIdentifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if message is unread
|
|
||||||
const isUnread = (message: EntityObject): boolean => {
|
|
||||||
return !message.properties.flags?.read
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if message is flagged
|
|
||||||
const isFlagged = (message: EntityObject): boolean => {
|
|
||||||
return message.properties.flags?.flagged || false
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentMessages = computed(() => props.messages ?? [])
|
const currentMessages = computed(() => props.messages ?? [])
|
||||||
|
|
||||||
|
const getMessageTimestamp = (message: EntityObject): string | null => {
|
||||||
|
return message.properties.received
|
||||||
|
|| message.properties.sent
|
||||||
|
|| message.modified
|
||||||
|
|| message.created
|
||||||
|
|| null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMessageTimeValue = (message: EntityObject): number => {
|
||||||
|
const timestamp = getMessageTimestamp(message)
|
||||||
|
if (!timestamp) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeValue = new Date(timestamp).getTime()
|
||||||
|
return Number.isNaN(timeValue) ? 0 : timeValue
|
||||||
|
}
|
||||||
|
|
||||||
const selectionCount = computed(() => props.selectionList.length)
|
const selectionCount = computed(() => props.selectionList.length)
|
||||||
|
|
||||||
const hasSelection = computed(() => selectionCount.value > 0)
|
const hasSelection = computed(() => selectionCount.value > 0)
|
||||||
@@ -74,11 +66,45 @@ const allCurrentMessagesSelected = computed(() => {
|
|||||||
return currentMessages.value.length > 0 && currentMessages.value.every(message => isSelected(message))
|
return currentMessages.value.length > 0 && currentMessages.value.every(message => isSelected(message))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Sorted messages (newest first)
|
||||||
|
const sortedMessages = computed(() => {
|
||||||
|
return [...currentMessages.value].sort((a, b) => {
|
||||||
|
const dateA = getMessageTimeValue(a)
|
||||||
|
const dateB = getMessageTimeValue(b)
|
||||||
|
return dateB - dateA
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Read/Unread counts from collection properties
|
||||||
|
const unreadCount = computed(() => {
|
||||||
|
return props.selectedCollection?.properties.unread ?? 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalCount = computed(() => {
|
||||||
|
return props.selectedCollection?.properties.total ?? 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// True only when the collection explicitly provides total/unread counts
|
||||||
|
const hasCountData = computed(() => {
|
||||||
|
return props.selectedCollection?.properties.total != null
|
||||||
|
})
|
||||||
|
|
||||||
|
const isOpened = (message: EntityObject): boolean => {
|
||||||
|
if (!props.selectedMessage) return false
|
||||||
|
return (message.identifier === props.selectedMessage.identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSelected = (message: EntityObject): boolean => {
|
||||||
|
return selectedIdSet.value.has(message.identifier)
|
||||||
|
}
|
||||||
|
|
||||||
// Format date for display
|
// Format date for display
|
||||||
const formatDate = (date: Date | string | null | undefined): string => {
|
const formatDate = (date: Date | string | null | undefined): string => {
|
||||||
if (!date) return ''
|
if (!date) return ''
|
||||||
|
|
||||||
const messageDate = new Date(date)
|
const messageDate = new Date(date)
|
||||||
|
if (Number.isNaN(messageDate.getTime())) return ''
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
const yesterday = new Date(today)
|
const yesterday = new Date(today)
|
||||||
@@ -121,11 +147,24 @@ const truncate = (text: string | null | undefined, length: number = 100): string
|
|||||||
return text.length > length ? text.substring(0, length) + '...' : text
|
return text.length > length ? text.substring(0, length) + '...' : text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSelectionControlClick = (event: MouseEvent | KeyboardEvent): boolean => {
|
||||||
|
return event.target instanceof Element && event.target.closest('.message-selection-checkbox') !== null
|
||||||
|
}
|
||||||
|
|
||||||
const handleSelectionToggle = (message: EntityObject) => {
|
const handleSelectionToggle = (message: EntityObject) => {
|
||||||
emit('toggleSelection', message)
|
emit('toggleSelection', message)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: EntityObject) => {
|
const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: EntityObject) => {
|
||||||
|
if (isSelectionControlClick(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suppressNextClick.value) {
|
||||||
|
suppressNextClick.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (event.shiftKey && !props.selectionMode) {
|
if (event.shiftKey && !props.selectionMode) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
@@ -138,11 +177,6 @@ const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: Ent
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (suppressNextClick.value) {
|
|
||||||
suppressNextClick.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.selectionMode) {
|
if (props.selectionMode) {
|
||||||
emit('toggleSelection', message)
|
emit('toggleSelection', message)
|
||||||
return
|
return
|
||||||
@@ -152,6 +186,10 @@ const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: Ent
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleMessageMouseDown = (event: MouseEvent, message: EntityObject) => {
|
const handleMessageMouseDown = (event: MouseEvent, message: EntityObject) => {
|
||||||
|
if (isSelectionControlClick(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!event.shiftKey || props.selectionMode) {
|
if (!event.shiftKey || props.selectionMode) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -200,29 +238,6 @@ onBeforeUnmount(() => {
|
|||||||
const handleSelectAllToggle = (value: boolean | null) => {
|
const handleSelectAllToggle = (value: boolean | null) => {
|
||||||
emit('toggleSelectAll', value === true)
|
emit('toggleSelectAll', value === true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sorted messages (newest first)
|
|
||||||
const sortedMessages = computed(() => {
|
|
||||||
return [...currentMessages.value].sort((a, b) => {
|
|
||||||
const dateA = a.properties.date ? new Date(a.properties.date).getTime() : 0
|
|
||||||
const dateB = b.properties.date ? new Date(b.properties.date).getTime() : 0
|
|
||||||
return dateB - dateA
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Read/Unread counts from collection properties
|
|
||||||
const unreadCount = computed(() => {
|
|
||||||
return props.selectedCollection?.properties.unread ?? 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const totalCount = computed(() => {
|
|
||||||
return props.selectedCollection?.properties.total ?? 0
|
|
||||||
})
|
|
||||||
|
|
||||||
// True only when the collection explicitly provides total/unread counts
|
|
||||||
const hasCountData = computed(() => {
|
|
||||||
return props.selectedCollection?.properties.total != null
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -282,7 +297,6 @@ const hasCountData = computed(() => {
|
|||||||
size="small"
|
size="small"
|
||||||
icon="mdi-close"
|
icon="mdi-close"
|
||||||
variant="text"
|
variant="text"
|
||||||
:disabled="!hasSelection"
|
|
||||||
@click="emit('clearSelection')"
|
@click="emit('clearSelection')"
|
||||||
>
|
>
|
||||||
<v-icon>mdi-close</v-icon>
|
<v-icon>mdi-close</v-icon>
|
||||||
@@ -320,13 +334,13 @@ const hasCountData = computed(() => {
|
|||||||
>
|
>
|
||||||
<template v-slot:default="{ item: message }">
|
<template v-slot:default="{ item: message }">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
:key="`${message.provider}:${message.service}:${message.collection}:${message.identifier}`"
|
:key="message.identifier"
|
||||||
class="message-item"
|
class="message-item"
|
||||||
:class="{
|
:class="{
|
||||||
'opened': isOpened(message),
|
'opened': isOpened(message),
|
||||||
'selected': isSelected(message),
|
'selected': isSelected(message),
|
||||||
'selection-mode': selectionMode,
|
'selection-mode': selectionMode,
|
||||||
'unread': isUnread(message)
|
'unread': !message.properties.isRead
|
||||||
}"
|
}"
|
||||||
@mousedown="handleMessageMouseDown($event, message)"
|
@mousedown="handleMessageMouseDown($event, message)"
|
||||||
@click="handleMessageMouseClick($event, message)"
|
@click="handleMessageMouseClick($event, message)"
|
||||||
@@ -360,7 +374,7 @@ const hasCountData = computed(() => {
|
|||||||
{{ message.properties.from?.label || message.properties.from?.address || 'Unknown Sender' }}
|
{{ message.properties.from?.label || message.properties.from?.address || 'Unknown Sender' }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-caption text-medium-emphasis ml-2">
|
<span class="text-caption text-medium-emphasis ml-2">
|
||||||
{{ formatDate(message.properties.date) }}
|
{{ formatDate(getMessageTimestamp(message)) }}
|
||||||
</span>
|
</span>
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
|
|
||||||
@@ -369,13 +383,13 @@ const hasCountData = computed(() => {
|
|||||||
</v-list-item-subtitle>
|
</v-list-item-subtitle>
|
||||||
|
|
||||||
<v-list-item-subtitle class="text-caption text-truncate">
|
<v-list-item-subtitle class="text-caption text-truncate">
|
||||||
{{ truncate(message.properties.snippet, 80) }}
|
{{ '' }}
|
||||||
</v-list-item-subtitle>
|
</v-list-item-subtitle>
|
||||||
|
|
||||||
<template v-slot:append>
|
<template v-slot:append>
|
||||||
<div class="d-flex flex-column align-center">
|
<div class="d-flex flex-column align-center">
|
||||||
<v-icon
|
<v-icon
|
||||||
v-if="isFlagged(message)"
|
v-if="message.properties.isFlagged"
|
||||||
size="small"
|
size="small"
|
||||||
color="warning"
|
color="warning"
|
||||||
class="mb-1"
|
class="mb-1"
|
||||||
@@ -383,7 +397,7 @@ const hasCountData = computed(() => {
|
|||||||
mdi-star
|
mdi-star
|
||||||
</v-icon>
|
</v-icon>
|
||||||
<v-icon
|
<v-icon
|
||||||
v-if="message.properties.attachments && message.properties.attachments.length > 0"
|
v-if="message.properties.hasAttachments"
|
||||||
size="small"
|
size="small"
|
||||||
color="grey"
|
color="grey"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
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 { CollectionObject } from '@MailManager/models/collection'
|
||||||
import type { ServiceObject } from '@MailManager/models'
|
import type { ServiceObject } from '@MailManager/models'
|
||||||
|
|
||||||
@@ -9,23 +7,25 @@ interface Props {
|
|||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
service: ServiceObject
|
service: ServiceObject
|
||||||
folder: CollectionObject
|
folder: CollectionObject
|
||||||
allFolders?: CollectionObject[]
|
parentFolderLabel?: string
|
||||||
|
validateName?: (name: string) => string[]
|
||||||
|
loading?: boolean
|
||||||
|
errorMessage?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
allFolders: () => []
|
parentFolderLabel: 'Root',
|
||||||
|
validateName: () => [],
|
||||||
|
loading: false,
|
||||||
|
errorMessage: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: boolean]
|
'update:modelValue': [value: boolean]
|
||||||
updated: [folder: CollectionObject]
|
confirm: [folderName: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const collectionsStore = useCollectionsStore()
|
|
||||||
|
|
||||||
const folderName = ref('')
|
const folderName = ref('')
|
||||||
const loading = ref(false)
|
|
||||||
const errorMessage = ref('')
|
|
||||||
const validationErrors = ref<string[]>([])
|
const validationErrors = ref<string[]>([])
|
||||||
|
|
||||||
const dialogValue = computed({
|
const dialogValue = computed({
|
||||||
@@ -37,74 +37,13 @@ const isValid = computed(() => {
|
|||||||
return folderName.value.trim().length > 0 && validationErrors.value.length === 0
|
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) => {
|
watch(folderName, (newName) => {
|
||||||
errorMessage.value = ''
|
validationErrors.value = props.validateName(newName)
|
||||||
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() {
|
function resetForm() {
|
||||||
folderName.value = props.folder.properties.label || ''
|
folderName.value = props.folder.properties.label || ''
|
||||||
errorMessage.value = ''
|
|
||||||
validationErrors.value = []
|
validationErrors.value = []
|
||||||
loading.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(dialogValue, (isOpen) => {
|
watch(dialogValue, (isOpen) => {
|
||||||
@@ -114,54 +53,27 @@ watch(dialogValue, (isOpen) => {
|
|||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
const handleRename = async () => {
|
const handleRename = async () => {
|
||||||
const errors = validateFolderName(folderName.value)
|
const errors = props.validateName(folderName.value)
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
validationErrors.value = errors
|
validationErrors.value = errors
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checkDuplicateName(folderName.value)) {
|
|
||||||
validationErrors.value = ['A folder with this name already exists in this location']
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const newName = folderName.value.trim()
|
const newName = folderName.value.trim()
|
||||||
if (newName === props.folder.properties.label) {
|
if (newName === props.folder.properties.label) {
|
||||||
dialogValue.value = false
|
dialogValue.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = true
|
emit('confirm', newName)
|
||||||
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 = () => {
|
const handleCancel = () => {
|
||||||
dialogValue.value = false
|
dialogValue.value = false
|
||||||
resetForm()
|
resetForm()
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
184
src/components/composer/ComposerEditor.vue
Normal file
184
src/components/composer/ComposerEditor.vue
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PropType } from 'vue'
|
||||||
|
import { EditorContent, type Editor } from '@tiptap/vue-3'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
editor: {
|
||||||
|
type: Object as PropType<Editor | null>,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
isBoldActive: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isItalicActive: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isUnderlineActive: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isBulletListActive: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isOrderedListActive: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isLinkActive: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
bold: []
|
||||||
|
italic: []
|
||||||
|
underline: []
|
||||||
|
bulletList: []
|
||||||
|
orderedList: []
|
||||||
|
link: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-toolbar density="compact" elevation="0" class="editor-toolbar">
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
:class="{ 'v-btn--active': isBoldActive }"
|
||||||
|
@click="$emit('bold')"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-format-bold</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Bold</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
:class="{ 'v-btn--active': isItalicActive }"
|
||||||
|
@click="$emit('italic')"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-format-italic</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Italic</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
:class="{ 'v-btn--active': isUnderlineActive }"
|
||||||
|
@click="$emit('underline')"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-format-underline</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Underline</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-divider vertical class="mx-2" />
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
:class="{ 'v-btn--active': isBulletListActive }"
|
||||||
|
@click="$emit('bulletList')"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-format-list-bulleted</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Bullet List</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
:class="{ 'v-btn--active': isOrderedListActive }"
|
||||||
|
@click="$emit('orderedList')"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-format-list-numbered</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Numbered List</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-divider vertical class="mx-2" />
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
:class="{ 'v-btn--active': isLinkActive }"
|
||||||
|
@click="$emit('link')"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-link</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Link</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-spacer />
|
||||||
|
|
||||||
|
<v-btn icon size="small">
|
||||||
|
<v-icon>mdi-paperclip</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Attach Files</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
</v-toolbar>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<div class="editor-container">
|
||||||
|
<EditorContent :editor="editor" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.editor-toolbar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid rgb(var(--v-border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: rgb(var(--v-theme-background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-btn--active {
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.12);
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.tiptap-editor) {
|
||||||
|
outline: none;
|
||||||
|
min-height: 300px;
|
||||||
|
|
||||||
|
p.is-editor-empty:first-child::before {
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
padding-left: 1.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-left: 3px solid rgb(var(--v-border-color));
|
||||||
|
padding-left: 1em;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
95
src/components/composer/ComposerRecipients.vue
Normal file
95
src/components/composer/ComposerRecipients.vue
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
to: string[]
|
||||||
|
cc: string[]
|
||||||
|
bcc: string[]
|
||||||
|
subject: string
|
||||||
|
showCc: boolean
|
||||||
|
showBcc: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'update:to': [value: string[]]
|
||||||
|
'update:cc': [value: string[]]
|
||||||
|
'update:bcc': [value: string[]]
|
||||||
|
'update:subject': [value: string]
|
||||||
|
'toggle:cc': []
|
||||||
|
'toggle:bcc': []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="composer-fields pa-4">
|
||||||
|
<v-combobox
|
||||||
|
:model-value="to"
|
||||||
|
label="To"
|
||||||
|
chips
|
||||||
|
multiple
|
||||||
|
closable-chips
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
class="mb-2"
|
||||||
|
@update:model-value="$emit('update:to', $event)"
|
||||||
|
>
|
||||||
|
<template #append-inner>
|
||||||
|
<v-btn
|
||||||
|
size="x-small"
|
||||||
|
variant="text"
|
||||||
|
class="mr-1"
|
||||||
|
@click="$emit('toggle:cc')"
|
||||||
|
>
|
||||||
|
Cc
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
size="x-small"
|
||||||
|
variant="text"
|
||||||
|
@click="$emit('toggle:bcc')"
|
||||||
|
>
|
||||||
|
Bcc
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-combobox>
|
||||||
|
|
||||||
|
<v-combobox
|
||||||
|
v-if="showCc"
|
||||||
|
:model-value="cc"
|
||||||
|
label="Cc"
|
||||||
|
chips
|
||||||
|
multiple
|
||||||
|
closable-chips
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
class="mb-2"
|
||||||
|
@update:model-value="$emit('update:cc', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-combobox
|
||||||
|
v-if="showBcc"
|
||||||
|
:model-value="bcc"
|
||||||
|
label="Bcc"
|
||||||
|
chips
|
||||||
|
multiple
|
||||||
|
closable-chips
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
class="mb-2"
|
||||||
|
@update:model-value="$emit('update:bcc', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
:model-value="subject"
|
||||||
|
label="Subject"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
@update:model-value="$emit('update:subject', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.composer-fields {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
55
src/components/composer/ComposerToolbar.vue
Normal file
55
src/components/composer/ComposerToolbar.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
mode: 'new' | 'reply' | 'forward'
|
||||||
|
saveStatus: string
|
||||||
|
canSend: boolean
|
||||||
|
sending: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
close: []
|
||||||
|
send: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-toolbar density="compact" elevation="0" class="composer-toolbar">
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
icon="mdi-close"
|
||||||
|
@click="$emit('close')"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-close</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Close</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-toolbar-title>
|
||||||
|
{{ mode === 'reply' ? 'Reply' : mode === 'forward' ? 'Forward' : 'New Message' }}
|
||||||
|
</v-toolbar-title>
|
||||||
|
|
||||||
|
<v-spacer />
|
||||||
|
|
||||||
|
<span v-if="saveStatus" class="text-caption text-medium-emphasis mr-4">
|
||||||
|
{{ saveStatus }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
:disabled="!canSend"
|
||||||
|
:loading="sending"
|
||||||
|
prepend-icon="mdi-send"
|
||||||
|
@click="$emit('send')"
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</v-btn>
|
||||||
|
</v-toolbar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.composer-toolbar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid rgb(var(--v-border-color));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted, unref } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { useModuleStore } from '@KTXC'
|
import { useModuleStore } from '@KTXC'
|
||||||
|
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
||||||
import { useMailStore } from '@/stores/mailStore'
|
import { useMailStore } from '@/stores/mailStore'
|
||||||
|
import { useMailUiStore } from '@/stores/mailUiStore'
|
||||||
import type { CollectionObject, EntityObject } from '@MailManager/models'
|
import type { CollectionObject, EntityObject } from '@MailManager/models'
|
||||||
import type { EntityIdentifier } from '@MailManager/types/common'
|
|
||||||
import FolderTree from '@/components/FolderTree.vue'
|
|
||||||
import MessageList from '@/components/MessageList.vue'
|
import MessageList from '@/components/MessageList.vue'
|
||||||
import MessageReader from '@/components/MessageReader.vue'
|
import MessageReader from '@/components/MessageReader.vue'
|
||||||
import MessageComposer from '@/components/MessageComposer.vue'
|
import MessageComposer from '@/components/MessageComposer.vue'
|
||||||
|
import CreateFolderDialog from '@/components/CreateFolderDialog.vue'
|
||||||
|
import DeleteFolderDialog from '@/components/DeleteFolderDialog.vue'
|
||||||
import FolderSelectionDialog from '@/components/FolderSelectionDialog.vue'
|
import FolderSelectionDialog from '@/components/FolderSelectionDialog.vue'
|
||||||
|
import RenameFolderDialog from '@/components/RenameFolderDialog.vue'
|
||||||
import SettingsDialog from '@/components/settings/SettingsDialog.vue'
|
import SettingsDialog from '@/components/settings/SettingsDialog.vue'
|
||||||
|
import FolderView from '@/components/FolderView.vue'
|
||||||
|
|
||||||
// Vuetify display for responsive behavior
|
// Vuetify display for responsive behavior
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
@@ -19,94 +23,175 @@ const isMobile = computed(() => display.mdAndDown.value)
|
|||||||
|
|
||||||
// Check if mail manager is available
|
// Check if mail manager is available
|
||||||
const moduleStore = useModuleStore()
|
const moduleStore = useModuleStore()
|
||||||
const isMailManagerAvailable = computed(() => {
|
const isManagerAvailable = computed(() => {
|
||||||
return moduleStore.has('mail_manager') || moduleStore.has('MailManager')
|
return moduleStore.has('mail_manager') || moduleStore.has('MailManager')
|
||||||
})
|
})
|
||||||
|
const collectionsStore = useCollectionsStore()
|
||||||
|
|
||||||
// Mail module store
|
// Mail module store
|
||||||
const mailStore = useMailStore()
|
const mailStore = useMailStore()
|
||||||
|
const mailUiStore = useMailUiStore()
|
||||||
|
|
||||||
// storeToRefs preserves reactivity for state and computed properties
|
// storeToRefs preserves reactivity for state and computed properties
|
||||||
const {
|
const {
|
||||||
sidebarVisible,
|
|
||||||
settingsDialogVisible,
|
|
||||||
loading,
|
loading,
|
||||||
selectedFolder,
|
selectedFolder,
|
||||||
selectedMessage,
|
selectedMessage,
|
||||||
|
currentMessages,
|
||||||
|
} = storeToRefs(mailStore)
|
||||||
|
|
||||||
|
const {
|
||||||
|
sidebarVisible,
|
||||||
|
settingsDialogVisible,
|
||||||
|
composeMode,
|
||||||
|
composeSource,
|
||||||
|
composeVisible,
|
||||||
selectionList,
|
selectionList,
|
||||||
selectionMode,
|
selectionMode,
|
||||||
composeMode,
|
moveMessagesDialogVisible,
|
||||||
composeReplyTo,
|
moveMessagesDialogService,
|
||||||
currentMessages,
|
createFolderDialogVisible,
|
||||||
moveDialogVisible,
|
createFolderDialogService,
|
||||||
moveDialogCandidates,
|
createFolderDialogParent,
|
||||||
} = storeToRefs(mailStore)
|
createFolderDialogLoading,
|
||||||
|
createFolderDialogError,
|
||||||
|
renameFolderDialogVisible,
|
||||||
|
renameFolderDialogService,
|
||||||
|
renameFolderDialogFolder,
|
||||||
|
renameFolderDialogLoading,
|
||||||
|
renameFolderDialogError,
|
||||||
|
moveFolderDialogVisible,
|
||||||
|
moveFolderDialogService,
|
||||||
|
moveFolderDialogSource,
|
||||||
|
deleteFolderDialogVisible,
|
||||||
|
deleteFolderDialogService,
|
||||||
|
deleteFolderDialogFolder,
|
||||||
|
deleteFolderDialogLoading,
|
||||||
|
deleteFolderDialogError,
|
||||||
|
} = storeToRefs(mailUiStore)
|
||||||
|
|
||||||
// Complex store/composable objects accessed directly (not simple refs)
|
// Complex store/composable objects accessed directly (not simple refs)
|
||||||
const { mailSync, entitiesStore } = mailStore
|
const { mailSync, entitiesStore } = mailStore
|
||||||
|
|
||||||
const lastSyncLabel = computed(() => {
|
const lastSyncLabel = computed(() => {
|
||||||
if (!mailSync.lastSync) return ''
|
const lastSync = unref(unref(mailSync.lastSync))
|
||||||
return `(Last: ${new Date(mailSync.lastSync).toLocaleTimeString()})`
|
|
||||||
|
if (!(lastSync instanceof Date)) return ''
|
||||||
|
return `(Last: ${lastSync.toLocaleTimeString()})`
|
||||||
})
|
})
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!isMailManagerAvailable.value) return
|
if (!isManagerAvailable.value) return
|
||||||
await mailStore.initialize()
|
await mailStore.initialize()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handlers — thin wrappers that delegate to the store
|
// Handlers — thin wrappers that delegate to the store
|
||||||
|
const {
|
||||||
|
validateCreateFolderName,
|
||||||
|
validateRenameFolderName,
|
||||||
|
} = mailUiStore
|
||||||
|
|
||||||
const handleFolderSelect = (folder: CollectionObject) => mailStore.selectFolder(folder)
|
const handleFolderSelect = (folder: CollectionObject) => mailStore.selectFolder(folder)
|
||||||
|
|
||||||
const handleMessageOpen = (message: EntityObject) => mailStore.selectMessage(message, isMobile.value)
|
const handleMessageOpen = (message: EntityObject) => {
|
||||||
|
mailStore.selectMessage(message)
|
||||||
|
|
||||||
const handleMessageSelectionToggle = (message: EntityObject) => mailStore.toggleMessageSelection(message)
|
if (isMobile.value) {
|
||||||
|
mailUiStore.closeSidebar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSelectionModeActivate = (message: EntityObject) => mailStore.activateSelectionMode(message)
|
const handleMessageSelectionToggle = (message: EntityObject) => mailUiStore.toggleMessageSelection(message)
|
||||||
|
|
||||||
|
const handleSelectionModeActivate = (message: EntityObject) => mailUiStore.activateSelectionMode(message)
|
||||||
|
|
||||||
const handleSelectAllToggle = (value: boolean) => {
|
const handleSelectAllToggle = (value: boolean) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
mailStore.selectAllCurrentMessages()
|
mailUiStore.selectAllCurrentMessages()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
mailStore.clearSelection()
|
mailUiStore.clearSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectionClear = () => mailStore.deactivateSelectionMode()
|
const handleSelectionClear = () => mailUiStore.deactivateSelectionMode()
|
||||||
|
|
||||||
const handleSelectionMove = () => mailStore.openMoveDialog()
|
const handleSelectionMove = () => mailUiStore.openMoveMessagesDialog()
|
||||||
|
|
||||||
const handleSelectionDelete = () => mailStore.deleteMessages([...selectionList.value])
|
const handleSelectionDelete = () => mailUiStore.deleteSelectedMessages()
|
||||||
|
|
||||||
const handleCompose = (message?: EntityObject) => mailStore.openCompose(message)
|
const handleCompose = () => mailUiStore.openCompose()
|
||||||
|
|
||||||
const handleComposeClose = () => mailStore.closeCompose()
|
const handleComposeReply = (message: EntityObject) => mailUiStore.openCompose(message, 'reply')
|
||||||
|
|
||||||
const handleComposeSent = () => mailStore.afterSent()
|
const handleComposeForward = (message: EntityObject) => mailUiStore.openCompose(message, 'forward')
|
||||||
|
|
||||||
|
const handleComposeClose = () => mailUiStore.closeCompose()
|
||||||
|
|
||||||
|
const handleComposeSent = () => mailUiStore.afterSent()
|
||||||
|
|
||||||
const handleDelete = (message: EntityObject) => {
|
const handleDelete = (message: EntityObject) => {
|
||||||
const id = `${message.provider}:${String(message.service)}:${String(message.collection)}:${String(message.identifier)}` as EntityIdentifier
|
mailStore.deleteMessages([message.identifier])
|
||||||
mailStore.deleteMessages([id])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMove = (message: EntityObject) => mailStore.openMoveDialog(message)
|
const handleMove = (message: EntityObject) => mailUiStore.openMoveMessagesDialog(message)
|
||||||
|
|
||||||
const handleMoveConfirm = async (target: CollectionObject) => { await mailStore.moveMessages(target, moveDialogCandidates.value ?? []) }
|
const handleMoveConfirm = async (target: CollectionObject) => { await mailUiStore.confirmMoveMessages(target) }
|
||||||
|
|
||||||
const handleMoveCancel = () => mailStore.closeMoveDialog()
|
const handleMoveCancel = () => mailUiStore.closeMoveMessagesDialog()
|
||||||
|
|
||||||
const toggleSidebar = () => mailStore.toggleSidebar()
|
const handleFolderCreateConfirm = async (folderName: string) => {
|
||||||
|
try {
|
||||||
|
const mutatedFolder = await mailUiStore.confirmCreateFolder(folderName)
|
||||||
|
|
||||||
const handleSettingsOpen = () => mailStore.openSettings()
|
if (mutatedFolder) {
|
||||||
|
handleFolderSelect(mutatedFolder)
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('[MailPage] Failed to create folder:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFolderRenameConfirm = async (folderName: string) => {
|
||||||
|
try {
|
||||||
|
const mutatedFolder = await mailUiStore.confirmRenameFolder(folderName)
|
||||||
|
|
||||||
|
if (mutatedFolder) {
|
||||||
|
handleFolderSelect(mutatedFolder)
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('[MailPage] Failed to rename folder:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFolderMoveConfirm = async (targetFolder: CollectionObject) => {
|
||||||
|
try {
|
||||||
|
await mailUiStore.confirmMoveFolder(targetFolder)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('[MailPage] Failed to move folder:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFolderMoveCancel = () => mailUiStore.closeMoveFolderDialog()
|
||||||
|
|
||||||
|
const handleFolderDeleteConfirm = async () => {
|
||||||
|
try {
|
||||||
|
await mailUiStore.confirmDeleteFolder()
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('[MailPage] Failed to delete folder:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSidebar = () => mailUiStore.toggleSidebar()
|
||||||
|
|
||||||
|
const handleSettingsOpen = () => mailUiStore.openSettings()
|
||||||
|
|
||||||
const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Folder "${folder.properties.label}" created`, 'success')
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- Manager Unavailable -->
|
<!-- Manager Unavailable -->
|
||||||
<div v-if="!isMailManagerAvailable" class="mail-unavailable">
|
<div v-if="!isManagerAvailable" class="mail-unavailable">
|
||||||
<v-alert
|
<v-alert
|
||||||
type="warning"
|
type="warning"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -180,10 +265,10 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
|
|||||||
width="280"
|
width="280"
|
||||||
class="mail-sidebar"
|
class="mail-sidebar"
|
||||||
>
|
>
|
||||||
<FolderTree
|
|
||||||
|
<FolderView
|
||||||
:selected-folder="selectedFolder"
|
:selected-folder="selectedFolder"
|
||||||
@select="handleFolderSelect"
|
@select="handleFolderSelect"
|
||||||
@folder-created="handleFolderCreated"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<template #append>
|
<template #append>
|
||||||
@@ -225,8 +310,9 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
|
|||||||
<!-- Reader/Composer panel -->
|
<!-- Reader/Composer panel -->
|
||||||
<div class="mail-reader-panel">
|
<div class="mail-reader-panel">
|
||||||
<MessageComposer
|
<MessageComposer
|
||||||
v-if="composeMode"
|
v-if="composeVisible"
|
||||||
:reply-to="composeReplyTo"
|
:mode="composeMode"
|
||||||
|
:source="composeSource"
|
||||||
:folder="selectedFolder"
|
:folder="selectedFolder"
|
||||||
@close="handleComposeClose"
|
@close="handleComposeClose"
|
||||||
@sent="handleComposeSent"
|
@sent="handleComposeSent"
|
||||||
@@ -236,7 +322,8 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
|
|||||||
v-else
|
v-else
|
||||||
:entity="selectedMessage"
|
:entity="selectedMessage"
|
||||||
@compose="handleCompose"
|
@compose="handleCompose"
|
||||||
@reply="handleCompose"
|
@reply="handleComposeReply"
|
||||||
|
@forward="handleComposeForward"
|
||||||
@move="handleMove"
|
@move="handleMove"
|
||||||
@delete="handleDelete"
|
@delete="handleDelete"
|
||||||
/>
|
/>
|
||||||
@@ -249,14 +336,62 @@ const handleFolderCreated = (folder: CollectionObject) => mailStore.notify(`Fold
|
|||||||
<SettingsDialog v-model="settingsDialogVisible" />
|
<SettingsDialog v-model="settingsDialogVisible" />
|
||||||
|
|
||||||
<FolderSelectionDialog
|
<FolderSelectionDialog
|
||||||
v-model="moveDialogVisible"
|
v-if="moveMessagesDialogService && moveFolderDialogSource"
|
||||||
|
v-model="moveMessagesDialogVisible"
|
||||||
|
:service="moveMessagesDialogService"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
title="Move To"
|
title="Move Messages To"
|
||||||
confirm-text="Move"
|
confirm-text="Move"
|
||||||
empty-text="No other folders are available in this account."
|
empty-text="No other folders are available in this account."
|
||||||
@select="handleMoveConfirm"
|
@select="handleMoveConfirm"
|
||||||
@cancel="handleMoveCancel"
|
@cancel="handleMoveCancel"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FolderSelectionDialog
|
||||||
|
v-if="moveFolderDialogService && moveFolderDialogSource"
|
||||||
|
v-model="moveFolderDialogVisible"
|
||||||
|
:service="moveFolderDialogService"
|
||||||
|
:loading="collectionsStore.transceiving"
|
||||||
|
title="Move Folder To"
|
||||||
|
confirm-text="Move"
|
||||||
|
empty-text="No other folders are available in this account."
|
||||||
|
:disabled-folder-keys="mailUiStore.moveFolderDialogInvalidFolderKeys"
|
||||||
|
@select="handleFolderMoveConfirm"
|
||||||
|
@cancel="handleFolderMoveCancel"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CreateFolderDialog
|
||||||
|
v-if="createFolderDialogService"
|
||||||
|
v-model="createFolderDialogVisible"
|
||||||
|
:service="createFolderDialogService"
|
||||||
|
:parent-folder-label="mailUiStore.createFolderDialogParentLabel"
|
||||||
|
:validate-name="validateCreateFolderName"
|
||||||
|
:loading="createFolderDialogLoading"
|
||||||
|
:error-message="createFolderDialogError"
|
||||||
|
@confirm="handleFolderCreateConfirm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RenameFolderDialog
|
||||||
|
v-if="renameFolderDialogService && renameFolderDialogFolder"
|
||||||
|
v-model="renameFolderDialogVisible"
|
||||||
|
:service="renameFolderDialogService"
|
||||||
|
:folder="renameFolderDialogFolder"
|
||||||
|
:parent-folder-label="mailUiStore.renameFolderDialogParentLabel"
|
||||||
|
:validate-name="validateRenameFolderName"
|
||||||
|
:loading="renameFolderDialogLoading"
|
||||||
|
:error-message="renameFolderDialogError"
|
||||||
|
@confirm="handleFolderRenameConfirm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteFolderDialog
|
||||||
|
v-if="deleteFolderDialogService && deleteFolderDialogFolder"
|
||||||
|
v-model="deleteFolderDialogVisible"
|
||||||
|
:service="deleteFolderDialogService"
|
||||||
|
:folder="deleteFolderDialogFolder"
|
||||||
|
:loading="deleteFolderDialogLoading"
|
||||||
|
:error-message="deleteFolderDialogError"
|
||||||
|
@confirm="handleFolderDeleteConfirm"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ref, computed, shallowRef, watch } from 'vue'
|
import { ref, computed, shallowRef } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
||||||
import { useEntitiesStore } from '@MailManager/stores/entitiesStore'
|
import { useEntitiesStore } from '@MailManager/stores/entitiesStore'
|
||||||
@@ -6,7 +6,21 @@ import { useServicesStore } from '@MailManager/stores/servicesStore'
|
|||||||
import { useMailSync } from '@MailManager/composables/useMailSync'
|
import { useMailSync } from '@MailManager/composables/useMailSync'
|
||||||
import { useSnackbar } from '@KTXC'
|
import { useSnackbar } from '@KTXC'
|
||||||
import type { ServiceIdentifier, CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common'
|
import type { ServiceIdentifier, CollectionIdentifier, EntityIdentifier } from '@MailManager/types/common'
|
||||||
import type { ServiceObject, CollectionObject, EntityObject } from '@MailManager/models'
|
import type { EntityTransmitRequest } from '@MailManager/types/entity'
|
||||||
|
import type { MessageAddressInterface, MessageInterface, MessagePartInterface } from '@MailManager/types/message'
|
||||||
|
import { ServiceObject, type CollectionObject, type EntityObject } from '@MailManager/models'
|
||||||
|
import { CollectionPropertiesObject } from '@MailManager/models/collection'
|
||||||
|
|
||||||
|
interface ComposerMessageInput {
|
||||||
|
to: string[]
|
||||||
|
cc: string[]
|
||||||
|
bcc: string[]
|
||||||
|
subject: string
|
||||||
|
body: {
|
||||||
|
html: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const useMailStore = defineStore('mailStore', () => {
|
export const useMailStore = defineStore('mailStore', () => {
|
||||||
const servicesStore = useServicesStore()
|
const servicesStore = useServicesStore()
|
||||||
@@ -31,27 +45,17 @@ export const useMailStore = defineStore('mailStore', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── General State ─────────────────-───────────────────────────────────────
|
// ── General State ─────────────────-───────────────────────────────────────
|
||||||
const sidebarVisible = ref(true)
|
|
||||||
const settingsDialogVisible = ref(false)
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const serviceFolderLoadingState = ref<Record<string, boolean>>({})
|
const serviceFolderLoadingState = ref<Record<string, boolean>>({})
|
||||||
const serviceFolderLoadedState = ref<Record<string, boolean>>({})
|
const serviceFolderLoadedState = ref<Record<string, boolean>>({})
|
||||||
const serviceFolderErrorState = ref<Record<string, string | null>>({})
|
const serviceFolderErrorState = ref<Record<string, string | null>>({})
|
||||||
|
|
||||||
// ── Selection State ───────────────────────────────────────────────────────
|
|
||||||
const selectedFolder = shallowRef<CollectionObject | null>(null)
|
const selectedFolder = shallowRef<CollectionObject | null>(null)
|
||||||
const selectedMessage = shallowRef<EntityObject | null>(null)
|
const selectedMessage = shallowRef<EntityObject | null>(null)
|
||||||
const selectionMode = ref(false)
|
const composerSaving = ref(false)
|
||||||
const selectionList = ref<EntityIdentifier[]>([])
|
const composerSending = ref(false)
|
||||||
|
const composerLastSaved = ref<Date | null>(null)
|
||||||
// ── Compose State ─────────────────────────────────────────────────────────
|
const composerDraftIdentifier = ref<EntityIdentifier | null>(null)
|
||||||
const composeMode = ref(false)
|
|
||||||
const composeReplyTo = shallowRef<EntityObject | null>(null)
|
|
||||||
|
|
||||||
// ── Move State ────────────────────────────────────────────────────────────
|
|
||||||
const moveDialogVisible = ref(false)
|
|
||||||
const moveDialogService = ref<ServiceIdentifier | null>(null)
|
|
||||||
const moveDialogCandidates = ref<EntityIdentifier[] | null>(null)
|
|
||||||
|
|
||||||
// ── Computed ──────────────────────────────────────────────────────────────
|
// ── Computed ──────────────────────────────────────────────────────────────
|
||||||
const currentMessages = computed(() => {
|
const currentMessages = computed(() => {
|
||||||
@@ -98,11 +102,7 @@ export const useMailStore = defineStore('mailStore', () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// retrieve folders for service
|
// retrieve folders for service
|
||||||
const collections = await collectionsStore.list({
|
const collections = await collectionsStore.collectionsForService(service.provider, service.identifier, true)
|
||||||
[service.provider]: {
|
|
||||||
[String(service.identifier)]: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
_setServiceFolderLoaded(service.provider, service.identifier, true)
|
_setServiceFolderLoaded(service.provider, service.identifier, true)
|
||||||
|
|
||||||
@@ -121,7 +121,6 @@ export const useMailStore = defineStore('mailStore', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_updateSyncSources()
|
_updateSyncSources()
|
||||||
return collections
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Failed to load folders'
|
const message = error instanceof Error ? error.message : 'Failed to load folders'
|
||||||
_setServiceFolderError(service.provider, service.identifier, message)
|
_setServiceFolderError(service.provider, service.identifier, message)
|
||||||
@@ -139,15 +138,20 @@ export const useMailStore = defineStore('mailStore', () => {
|
|||||||
// ── Helpers ──────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function _serviceIdentifier(item: ServiceObject | CollectionObject | EntityObject | { provider: string, service: string | number }): ServiceIdentifier {
|
function _serviceIdentifier(item: ServiceObject | CollectionObject | EntityObject | { provider: string, service: string | number }): ServiceIdentifier {
|
||||||
|
if (item instanceof ServiceObject) {
|
||||||
|
return `${item.provider}:${String(item.identifier)}` as ServiceIdentifier
|
||||||
|
}
|
||||||
return `${item.provider}:${String(item.service)}` as ServiceIdentifier
|
return `${item.provider}:${String(item.service)}` as ServiceIdentifier
|
||||||
}
|
}
|
||||||
|
|
||||||
function _collectionIdentifier(item: CollectionObject | EntityObject): CollectionIdentifier {
|
function _sameCollection(left: CollectionObject | null | undefined, right: CollectionObject | null | undefined): boolean {
|
||||||
return `${item.provider}:${String(item.service)}:${String(item.identifier)}` as CollectionIdentifier
|
if (!left || !right) {
|
||||||
}
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
function _entityIdentifier(item: EntityObject): EntityIdentifier {
|
return left.provider === right.provider &&
|
||||||
return `${item.provider}:${String(item.service)}:${String(item.collection)}:${String(item.identifier)}` as EntityIdentifier
|
String(left.service) === String(right.service) &&
|
||||||
|
String(left.identifier) === String(right.identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
function _setServiceFolderLoading(provider: string, service: string | number, loadingState: boolean) {
|
function _setServiceFolderLoading(provider: string, service: string | number, loadingState: boolean) {
|
||||||
@@ -176,15 +180,18 @@ export const useMailStore = defineStore('mailStore', () => {
|
|||||||
|
|
||||||
// Track the currently selected folder
|
// Track the currently selected folder
|
||||||
if (selectedFolder.value) {
|
if (selectedFolder.value) {
|
||||||
mailSyncController.addSource({
|
//mailSyncController.addSource({
|
||||||
provider: selectedFolder.value.provider,
|
// provider: selectedFolder.value.provider,
|
||||||
service: selectedFolder.value.service,
|
// service: selectedFolder.value.service,
|
||||||
collections: [selectedFolder.value.identifier],
|
// collections: [selectedFolder.value.identifier],
|
||||||
})
|
//})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always track inboxes for each account (for new-mail notifications)
|
// Always track inboxes for each account (for new-mail notifications)
|
||||||
servicesStore.servicesEnabled.forEach(service => {
|
servicesStore.servicesEnabled.forEach(service => {
|
||||||
|
if (service.identifier === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const inboxes = collectionsStore.collectionsForService(service.provider, service.identifier).filter(
|
const inboxes = collectionsStore.collectionsForService(service.provider, service.identifier).filter(
|
||||||
c =>
|
c =>
|
||||||
String(c.service) === String(service.identifier) &&
|
String(c.service) === String(service.identifier) &&
|
||||||
@@ -193,11 +200,11 @@ export const useMailStore = defineStore('mailStore', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (inboxes.length > 0) {
|
if (inboxes.length > 0) {
|
||||||
mailSyncController.addSource({
|
//mailSyncController.addSource({
|
||||||
provider: service.provider,
|
// provider: service.provider,
|
||||||
service: service.identifier as string | number,
|
// service: service.identifier as string | number,
|
||||||
collections: inboxes.map(inbox => inbox.identifier),
|
// collections: inboxes.map(inbox => inbox.identifier),
|
||||||
})
|
//})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -218,58 +225,113 @@ export const useMailStore = defineStore('mailStore', () => {
|
|||||||
return serviceFolderErrorState.value[_serviceIdentifier({ provider, service })] ?? null
|
return serviceFolderErrorState.value[_serviceIdentifier({ provider, service })] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
function _reloadFolderMessages(folder: CollectionObject) {
|
function _findDraftFolder(folder: CollectionObject): CollectionObject {
|
||||||
return entitiesStore.list({
|
return collectionsStore.collectionsForService(folder.provider, folder.service).find(
|
||||||
[folder.provider]: {
|
candidate =>
|
||||||
[String(folder.service)]: {
|
candidate.provider === folder.provider &&
|
||||||
[String(folder.identifier)]: true,
|
String(candidate.service) === String(folder.service) &&
|
||||||
},
|
(candidate.properties.role === 'drafts' ||
|
||||||
|
String(candidate.identifier).toLowerCase() === 'drafts' ||
|
||||||
|
candidate.properties.label.toLowerCase() === 'drafts'),
|
||||||
|
) ?? folder
|
||||||
|
}
|
||||||
|
|
||||||
|
function _toMessageAddresses(addresses: string[]): MessageAddressInterface[] | undefined {
|
||||||
|
const normalized = addresses
|
||||||
|
.map(address => address.trim())
|
||||||
|
.filter(address => address.length > 0)
|
||||||
|
|
||||||
|
if (normalized.length === 0) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.map(address => ({ address }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function _toDraftBody(body: ComposerMessageInput['body']): MessagePartInterface | null {
|
||||||
|
const parts: MessagePartInterface[] = []
|
||||||
|
const text = body.text.trim()
|
||||||
|
const html = body.html.trim()
|
||||||
|
|
||||||
|
if (text.length > 0) {
|
||||||
|
parts.push({
|
||||||
|
type: 'text/plain',
|
||||||
|
content: text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (html.length > 0) {
|
||||||
|
parts.push({
|
||||||
|
type: 'text/html',
|
||||||
|
content: html,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 1) {
|
||||||
|
return parts[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'multipart/alternative',
|
||||||
|
subParts: parts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _toDraftProperties(message: ComposerMessageInput): MessageInterface {
|
||||||
|
return {
|
||||||
|
'@type': 'mail:message',
|
||||||
|
to: _toMessageAddresses(message.to),
|
||||||
|
cc: _toMessageAddresses(message.cc),
|
||||||
|
bcc: _toMessageAddresses(message.bcc),
|
||||||
|
subject: message.subject.trim() || null,
|
||||||
|
body: _toDraftBody(message.body),
|
||||||
|
flags: {
|
||||||
|
draft: true,
|
||||||
},
|
},
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function _setSelectionList(nextIds: EntityIdentifier[]) {
|
|
||||||
selectionList.value = Array.from(new Set(nextIds))
|
|
||||||
|
|
||||||
if (selectionList.value.length === 0) {
|
|
||||||
selectionMode.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _reconcileSelection() {
|
function resetComposerState() {
|
||||||
if (!selectedFolder.value) {
|
composerSaving.value = false
|
||||||
clearSelection()
|
composerSending.value = false
|
||||||
selectedMessage.value = null
|
composerLastSaved.value = null
|
||||||
return
|
composerDraftIdentifier.value = null
|
||||||
}
|
|
||||||
|
|
||||||
const currentMessageIdentifiers = new Set(currentMessages.value.map(message => _entityIdentifier(message)))
|
|
||||||
const nextSelectedIds = selectionList.value.filter(identifier => currentMessageIdentifiers.has(identifier))
|
|
||||||
|
|
||||||
if (nextSelectedIds.length !== selectionList.value.length) {
|
|
||||||
_setSelectionList(nextSelectedIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedMessage.value && !currentMessageIdentifiers.has(_entityIdentifier(selectedMessage.value))) {
|
|
||||||
selectedMessage.value = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(currentMessages, () => {
|
|
||||||
_reconcileSelection()
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Actions ───────────────────────────────────────────────────────────────
|
// ── Actions ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function retrieveService(identifier: ServiceIdentifier, force: boolean = false): Promise<ServiceObject | null> {
|
||||||
|
let service = servicesStore.serviceByIdentifier(identifier)
|
||||||
|
if (service && !force) {
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
service = await servicesStore.serviceByIdentifier(identifier, true)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Mail] Failed to retrieve service ${identifier}:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!service) {
|
||||||
|
const message = `Service ${identifier} not found`
|
||||||
|
console.error(`[Mail] ${message}`)
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
async function selectFolder(folder: CollectionObject) {
|
async function selectFolder(folder: CollectionObject) {
|
||||||
selectedFolder.value = folder
|
selectedFolder.value = folder
|
||||||
selectedMessage.value = null
|
selectedMessage.value = null
|
||||||
clearSelection()
|
|
||||||
selectionMode.value = false
|
|
||||||
composeMode.value = false
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _reloadFolderMessages(folder)
|
await entitiesStore.list([folder.identifier])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Mail] Failed to load messages:', error)
|
console.error('[Mail] Failed to load messages:', error)
|
||||||
}
|
}
|
||||||
@@ -280,140 +342,216 @@ export const useMailStore = defineStore('mailStore', () => {
|
|||||||
function clearSelectedFolder() {
|
function clearSelectedFolder() {
|
||||||
selectedFolder.value = null
|
selectedFolder.value = null
|
||||||
selectedMessage.value = null
|
selectedMessage.value = null
|
||||||
clearSelection()
|
|
||||||
selectionMode.value = false
|
|
||||||
composeMode.value = false
|
|
||||||
composeReplyTo.value = null
|
|
||||||
|
|
||||||
_updateSyncSources()
|
_updateSyncSources()
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectMessage(entity: EntityObject, closeSidebar = false) {
|
function selectMessage(entity: EntityObject) {
|
||||||
selectedMessage.value = entity
|
selectedMessage.value = entity
|
||||||
composeMode.value = false
|
|
||||||
|
|
||||||
if (closeSidebar) {
|
|
||||||
sidebarVisible.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCompose(replyTo?: EntityObject) {
|
function clearSelectedMessage() {
|
||||||
composeMode.value = true
|
|
||||||
composeReplyTo.value = replyTo ?? null
|
|
||||||
selectedMessage.value = null
|
selectedMessage.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeCompose() {
|
|
||||||
composeMode.value = false
|
|
||||||
composeReplyTo.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function afterSent() {
|
|
||||||
composeMode.value = false
|
|
||||||
composeReplyTo.value = null
|
|
||||||
|
|
||||||
|
async function reloadSelectedFolder() {
|
||||||
// Reload the current folder so the sent message appears in Sent
|
// Reload the current folder so the sent message appears in Sent
|
||||||
if (selectedFolder.value) {
|
if (selectedFolder.value) {
|
||||||
await selectFolder(selectedFolder.value)
|
await selectFolder(selectedFolder.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMessageSelected(message: EntityObject) {
|
async function saveComposerDraft(folder: CollectionObject, message: ComposerMessageInput) {
|
||||||
return selectionList.value.includes(_entityIdentifier(message))
|
composerSaving.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targetFolder = _findDraftFolder(folder)
|
||||||
|
const properties = _toDraftProperties(message)
|
||||||
|
const draft = composerDraftIdentifier.value
|
||||||
|
? await entitiesStore.update(composerDraftIdentifier.value, properties)
|
||||||
|
: await entitiesStore.create(targetFolder.identifier, properties)
|
||||||
|
|
||||||
|
composerDraftIdentifier.value = draft.identifier
|
||||||
|
composerLastSaved.value = new Date()
|
||||||
|
|
||||||
|
return draft
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Mail] Failed to save draft:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
composerSaving.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleMessageSelection(message: EntityObject) {
|
async function sendComposerMessage(message: ComposerMessageInput) {
|
||||||
const identifier = _entityIdentifier(message)
|
composerSending.value = true
|
||||||
|
|
||||||
selectionMode.value = true
|
const transmitRequest: EntityTransmitRequest = {
|
||||||
|
message: {
|
||||||
if (selectionList.value.includes(identifier)) {
|
to: message.to.map(address => address.trim()).filter(address => address.length > 0),
|
||||||
_setSelectionList(selectionList.value.filter(selectedId => selectedId !== identifier))
|
cc: message.cc.map(address => address.trim()).filter(address => address.length > 0),
|
||||||
|
bcc: message.bcc.map(address => address.trim()).filter(address => address.length > 0),
|
||||||
return
|
subject: message.subject,
|
||||||
|
body: {
|
||||||
|
html: message.body.html,
|
||||||
|
text: message.body.text,
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
_setSelectionList([...selectionList.value, identifier])
|
if (transmitRequest.message.cc?.length === 0) {
|
||||||
}
|
delete transmitRequest.message.cc
|
||||||
|
}
|
||||||
|
|
||||||
function selectAllCurrentMessages() {
|
if (transmitRequest.message.bcc?.length === 0) {
|
||||||
selectionMode.value = true
|
delete transmitRequest.message.bcc
|
||||||
_setSelectionList(currentMessages.value.map(message => _entityIdentifier(message)))
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function activateSelectionMode(message?: EntityObject) {
|
try {
|
||||||
selectionMode.value = true
|
const response = await entitiesStore.transmit(transmitRequest)
|
||||||
|
|
||||||
if (message) {
|
if (composerDraftIdentifier.value) {
|
||||||
const identifier = _entityIdentifier(message)
|
try {
|
||||||
|
await entitiesStore.delete([composerDraftIdentifier.value])
|
||||||
if (!selectionList.value.includes(identifier)) {
|
} catch (error) {
|
||||||
_setSelectionList([...selectionList.value, identifier])
|
console.error('[Mail] Failed to delete draft after send:', error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notify('Message sent', 'success')
|
||||||
|
resetComposerState()
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
const messageText = error instanceof Error ? error.message : 'Failed to send message'
|
||||||
|
console.error('[Mail] Failed to send message:', error)
|
||||||
|
notify(messageText, 'error')
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
composerSending.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function deactivateSelectionMode() {
|
async function createFolder(
|
||||||
selectionMode.value = false
|
service: ServiceObject,
|
||||||
clearSelection()
|
label: string,
|
||||||
}
|
parentFolder: CollectionObject | null = null,
|
||||||
|
): Promise<CollectionObject> {
|
||||||
function clearSelection() {
|
if (service.identifier === null) {
|
||||||
_setSelectionList([])
|
throw new Error('Cannot create folder for a service without an identifier')
|
||||||
}
|
|
||||||
|
|
||||||
function openMoveDialog(entities?: EntityObject | EntityObject[]) {
|
|
||||||
|
|
||||||
moveDialogCandidates.value = []
|
|
||||||
|
|
||||||
if (entities) {
|
|
||||||
if (Array.isArray(entities)) {
|
|
||||||
moveDialogCandidates.value = entities.map(entity => _entityIdentifier(entity))
|
|
||||||
moveDialogService.value = _serviceIdentifier(entities[0])
|
|
||||||
} else {
|
|
||||||
moveDialogCandidates.value = [_entityIdentifier(entities)]
|
|
||||||
moveDialogService.value = _serviceIdentifier(entities)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
moveDialogCandidates.value = selectionList.value
|
|
||||||
moveDialogService.value = _serviceIdentifier(selectedFolder.value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
moveDialogVisible.value = true
|
const properties = new CollectionPropertiesObject()
|
||||||
|
properties.label = label.trim()
|
||||||
|
properties.rank = 0
|
||||||
|
properties.subscribed = true
|
||||||
|
|
||||||
|
const newFolder = await collectionsStore.create(
|
||||||
|
service.provider,
|
||||||
|
service.identifier,
|
||||||
|
properties,
|
||||||
|
parentFolder?.identifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
notify(
|
||||||
|
`Folder "${newFolder.properties.label || properties.label}" created`,
|
||||||
|
'success',
|
||||||
|
)
|
||||||
|
|
||||||
|
return newFolder
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeMoveDialog() {
|
async function renameFolder(folder: CollectionObject, label: string): Promise<CollectionObject> {
|
||||||
moveDialogVisible.value = false
|
const properties = new CollectionPropertiesObject()
|
||||||
moveDialogService.value = null
|
properties.label = label.trim()
|
||||||
moveDialogCandidates.value = null
|
properties.rank = folder.properties.rank ?? 0
|
||||||
|
properties.subscribed = folder.properties.subscribed ?? true
|
||||||
|
|
||||||
|
const updatedFolder = await collectionsStore.update(folder.identifier, properties)
|
||||||
|
|
||||||
|
if (_sameCollection(selectedFolder.value, folder)) {
|
||||||
|
selectedFolder.value = updatedFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
notify(
|
||||||
|
`Folder "${folder.properties.label || String(folder.identifier)}" renamed to "${updatedFolder.properties.label || properties.label}"`,
|
||||||
|
'success',
|
||||||
|
)
|
||||||
|
|
||||||
|
return updatedFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveFolder(source: CollectionObject, target: CollectionObject): Promise<CollectionObject> {
|
||||||
|
const movedFolder = await collectionsStore.move(target.identifier, source.identifier)
|
||||||
|
|
||||||
|
if (_sameCollection(selectedFolder.value, source)) {
|
||||||
|
selectedFolder.value = movedFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
notify(
|
||||||
|
`Folder "${source.properties.label || String(source.identifier)}" moved to "${target.properties.label || String(target.identifier)}"`,
|
||||||
|
'success',
|
||||||
|
)
|
||||||
|
|
||||||
|
return movedFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFolder(folder: CollectionObject): Promise<CollectionObject | boolean> {
|
||||||
|
const deletedFolder = await collectionsStore.delete(folder.identifier)
|
||||||
|
|
||||||
|
if (_sameCollection(selectedFolder.value, folder)) {
|
||||||
|
clearSelectedFolder()
|
||||||
|
}
|
||||||
|
|
||||||
|
notify(
|
||||||
|
`Folder "${folder.properties.label || String(folder.identifier)}" deleted`,
|
||||||
|
'success',
|
||||||
|
)
|
||||||
|
|
||||||
|
return deletedFolder
|
||||||
}
|
}
|
||||||
|
|
||||||
async function moveMessages(target: CollectionObject, entityIdentifiers: EntityIdentifier[]) {
|
async function moveMessages(target: CollectionObject, entityIdentifiers: EntityIdentifier[]) {
|
||||||
const movableIdentifiers = entityIdentifiers.filter(identifier => {
|
const { movableIdentifiers, sourceCollections } = entityIdentifiers.reduce(
|
||||||
const entity = entitiesStore.entityByIdentifier(identifier)
|
(accumulator, identifier) => {
|
||||||
|
const entity = entitiesStore.entity(identifier)
|
||||||
|
|
||||||
if (!entity) {
|
if (!entity) {
|
||||||
return false
|
return accumulator
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allow moving messages within the same service and disallow moving into the same folder
|
// Only allow moving messages within the same service and disallow moving into the same folder
|
||||||
return entity.provider === target.provider &&
|
const canMove = entity.provider === target.provider &&
|
||||||
String(entity.service) === String(target.service) &&
|
String(entity.service) === String(target.service) &&
|
||||||
String(entity.collection) !== String(target.identifier)
|
String(entity.collection) !== String(target.identifier)
|
||||||
})
|
|
||||||
|
if (!canMove) {
|
||||||
|
return accumulator
|
||||||
|
}
|
||||||
|
|
||||||
|
accumulator.movableIdentifiers.push(identifier)
|
||||||
|
|
||||||
|
if (!accumulator.sourceCollections.some(
|
||||||
|
collection => String(collection) === String(entity.collection),
|
||||||
|
)) {
|
||||||
|
accumulator.sourceCollections.push(entity.collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
return accumulator
|
||||||
|
},
|
||||||
|
{
|
||||||
|
movableIdentifiers: [] as EntityIdentifier[],
|
||||||
|
sourceCollections: [] as CollectionIdentifier[],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if (movableIdentifiers.length === 0) {
|
if (movableIdentifiers.length === 0) {
|
||||||
closeMoveDialog()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [successes, failures] = await entitiesStore.move(_collectionIdentifier(target), movableIdentifiers)
|
const { successes, failures } = await entitiesStore.move(target.identifier, movableIdentifiers)
|
||||||
|
|
||||||
clearSelection()
|
|
||||||
closeMoveDialog()
|
|
||||||
|
|
||||||
if (failures.length === 0) {
|
if (failures.length === 0) {
|
||||||
notify(
|
notify(
|
||||||
@@ -430,6 +568,10 @@ export const useMailStore = defineStore('mailStore', () => {
|
|||||||
successes.length === 0 ? 'error' : 'warning',
|
successes.length === 0 ? 'error' : 'warning',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// update source collections to reflect moved messages
|
||||||
|
sourceCollections.push(target.identifier)
|
||||||
|
await collectionsStore.fetch(sourceCollections)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const messageText = error instanceof Error ? error.message : 'Failed to move messages'
|
const messageText = error instanceof Error ? error.message : 'Failed to move messages'
|
||||||
console.error('[Mail] Failed to move messages:', error)
|
console.error('[Mail] Failed to move messages:', error)
|
||||||
@@ -448,9 +590,7 @@ export const useMailStore = defineStore('mailStore', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [successes, failures] = await entitiesStore.delete(entityIdentifiers)
|
const { successes, failures } = await entitiesStore.delete(entityIdentifiers)
|
||||||
|
|
||||||
clearSelection()
|
|
||||||
|
|
||||||
if (failures.length === 0) {
|
if (failures.length === 0) {
|
||||||
notify(
|
notify(
|
||||||
@@ -477,14 +617,6 @@ export const useMailStore = defineStore('mailStore', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSidebar() {
|
|
||||||
sidebarVisible.value = !sidebarVisible.value
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSettings() {
|
|
||||||
settingsDialogVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function notify(message: string, color: 'success' | 'error' | 'info' | 'warning' = 'success') {
|
function notify(message: string, color: 'success' | 'error' | 'info' | 'warning' = 'success') {
|
||||||
showSnackbar({ message, color })
|
showSnackbar({ message, color })
|
||||||
}
|
}
|
||||||
@@ -499,18 +631,13 @@ export const useMailStore = defineStore('mailStore', () => {
|
|||||||
mailSync,
|
mailSync,
|
||||||
|
|
||||||
// State
|
// State
|
||||||
sidebarVisible,
|
|
||||||
settingsDialogVisible,
|
|
||||||
loading,
|
loading,
|
||||||
selectedFolder,
|
selectedFolder,
|
||||||
selectedMessage,
|
selectedMessage,
|
||||||
selectionList,
|
composerSaving,
|
||||||
selectionMode,
|
composerSending,
|
||||||
composeMode,
|
composerLastSaved,
|
||||||
composeReplyTo,
|
composerDraftIdentifier,
|
||||||
moveDialogVisible,
|
|
||||||
moveDialogService,
|
|
||||||
moveDialogCandidates,
|
|
||||||
serviceFolderLoadingState,
|
serviceFolderLoadingState,
|
||||||
serviceFolderLoadedState,
|
serviceFolderLoadedState,
|
||||||
serviceFolderErrorState,
|
serviceFolderErrorState,
|
||||||
@@ -519,24 +646,21 @@ export const useMailStore = defineStore('mailStore', () => {
|
|||||||
currentMessages,
|
currentMessages,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
retrieveService,
|
||||||
selectFolder,
|
selectFolder,
|
||||||
clearSelectedFolder,
|
clearSelectedFolder,
|
||||||
selectMessage,
|
selectMessage,
|
||||||
isMessageSelected,
|
clearSelectedMessage,
|
||||||
activateSelectionMode,
|
createFolder,
|
||||||
deactivateSelectionMode,
|
reloadSelectedFolder,
|
||||||
toggleMessageSelection,
|
saveComposerDraft,
|
||||||
selectAllCurrentMessages,
|
sendComposerMessage,
|
||||||
clearSelection,
|
resetComposerState,
|
||||||
openCompose,
|
|
||||||
openMoveDialog,
|
|
||||||
closeMoveDialog,
|
|
||||||
closeCompose,
|
|
||||||
afterSent,
|
|
||||||
deleteMessages,
|
deleteMessages,
|
||||||
|
deleteFolder,
|
||||||
moveMessages,
|
moveMessages,
|
||||||
toggleSidebar,
|
moveFolder,
|
||||||
openSettings,
|
renameFolder,
|
||||||
notify,
|
notify,
|
||||||
isServiceFolderLoading,
|
isServiceFolderLoading,
|
||||||
hasServiceFoldersLoaded,
|
hasServiceFoldersLoaded,
|
||||||
|
|||||||
534
src/stores/mailUiStore.ts
Normal file
534
src/stores/mailUiStore.ts
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
import { computed, ref, shallowRef, watch } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
|
||||||
|
import { useMailStore } from '@/stores/mailStore'
|
||||||
|
import type { ServiceIdentifier, EntityIdentifier } from '@MailManager/types/common'
|
||||||
|
import type { EntityObject, ServiceObject } from '@MailManager/models'
|
||||||
|
import type { CollectionObject } from '@MailManager/models/collection'
|
||||||
|
|
||||||
|
export const useMailUiStore = defineStore('mailUiStore', () => {
|
||||||
|
const collectionsStore = useCollectionsStore()
|
||||||
|
const mailStore = useMailStore()
|
||||||
|
|
||||||
|
const sidebarVisible = ref(true)
|
||||||
|
const settingsDialogVisible = ref(false)
|
||||||
|
const composeMode = ref<'new' | 'reply' | 'forward'>('new')
|
||||||
|
const composeSource = shallowRef<EntityObject | null>(null)
|
||||||
|
const composeVisible = ref(false)
|
||||||
|
const selectionMode = ref(false)
|
||||||
|
const selectionList = ref<EntityIdentifier[]>([])
|
||||||
|
const moveMessagesDialogVisible = ref(false)
|
||||||
|
const moveMessagesDialogService = shallowRef<ServiceObject | null>(null)
|
||||||
|
const moveMessagesDialogCandidates = ref<EntityIdentifier[] | null>(null)
|
||||||
|
const createFolderDialogVisible = ref(false)
|
||||||
|
const createFolderDialogService = shallowRef<ServiceObject | null>(null)
|
||||||
|
const createFolderDialogParent = shallowRef<CollectionObject | null>(null)
|
||||||
|
const createFolderDialogLoading = ref(false)
|
||||||
|
const createFolderDialogError = ref('')
|
||||||
|
const renameFolderDialogVisible = ref(false)
|
||||||
|
const renameFolderDialogService = shallowRef<ServiceObject | null>(null)
|
||||||
|
const renameFolderDialogFolder = shallowRef<CollectionObject | null>(null)
|
||||||
|
const renameFolderDialogLoading = ref(false)
|
||||||
|
const renameFolderDialogError = ref('')
|
||||||
|
const moveFolderDialogVisible = ref(false)
|
||||||
|
const moveFolderDialogService = shallowRef<ServiceObject | null>(null)
|
||||||
|
const moveFolderDialogSource = shallowRef<CollectionObject | null>(null)
|
||||||
|
const deleteFolderDialogVisible = ref(false)
|
||||||
|
const deleteFolderDialogService = shallowRef<ServiceObject | null>(null)
|
||||||
|
const deleteFolderDialogFolder = shallowRef<CollectionObject | null>(null)
|
||||||
|
const deleteFolderDialogLoading = ref(false)
|
||||||
|
const deleteFolderDialogError = ref('')
|
||||||
|
|
||||||
|
const createFolderDialogParentLabel = computed(() => {
|
||||||
|
return createFolderDialogParent.value?.properties.label || 'Root'
|
||||||
|
})
|
||||||
|
|
||||||
|
const renameFolderDialogParentLabel = computed(() => {
|
||||||
|
const folder = renameFolderDialogFolder.value
|
||||||
|
|
||||||
|
if (!folder || folder.collection === null || folder.collection === undefined) {
|
||||||
|
return 'Root'
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = collectionsStore.collectionsInCollection(folder.provider, folder.service, null)
|
||||||
|
.flatMap(rootFolder => [rootFolder, ...collectionsStore.collectionsForService(folder.provider, folder.service)])
|
||||||
|
.find(candidate => String(candidate.identifier) === String(folder.collection))
|
||||||
|
|
||||||
|
return parent?.properties.label || 'Root'
|
||||||
|
})
|
||||||
|
|
||||||
|
const moveFolderDialogInvalidFolderKeys = computed(() => {
|
||||||
|
const sourceFolder = moveFolderDialogSource.value
|
||||||
|
|
||||||
|
if (!sourceFolder) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidKeys = new Set<string>()
|
||||||
|
const queue = [sourceFolder]
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const currentFolder = queue.shift()
|
||||||
|
|
||||||
|
if (!currentFolder) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidKeys.add(String(currentFolder.identifier))
|
||||||
|
|
||||||
|
collectionsStore
|
||||||
|
.collectionsInCollection(currentFolder.provider, currentFolder.service, currentFolder.identifier)
|
||||||
|
.forEach(childFolder => {
|
||||||
|
queue.push(childFolder)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(invalidKeys)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => mailStore.selectedFolder,
|
||||||
|
() => {
|
||||||
|
closeCompose()
|
||||||
|
deactivateSelectionMode()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => mailStore.selectedMessage,
|
||||||
|
selectedMessage => {
|
||||||
|
if (selectedMessage) {
|
||||||
|
closeCompose()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => mailStore.currentMessages,
|
||||||
|
() => {
|
||||||
|
reconcileSelection()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function validateFolderNameBase(service: ServiceObject, 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 (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
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCreateFolderName(name: string): string[] {
|
||||||
|
const service = createFolderDialogService.value
|
||||||
|
|
||||||
|
if (!service || service.identifier === null) {
|
||||||
|
return ['Folder service is unavailable']
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = validateFolderNameBase(service, name)
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentIdentifier = createFolderDialogParent.value?.identifier ?? null
|
||||||
|
const duplicate = collectionsStore
|
||||||
|
.collectionsInCollection(service.provider, service.identifier, parentIdentifier)
|
||||||
|
.some(folder => folder.properties.label === name)
|
||||||
|
|
||||||
|
if (duplicate) {
|
||||||
|
errors.push('A folder with this name already exists in this location')
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRenameFolderName(name: string): string[] {
|
||||||
|
const service = renameFolderDialogService.value
|
||||||
|
const folder = renameFolderDialogFolder.value
|
||||||
|
|
||||||
|
if (!service || !folder || service.identifier === null) {
|
||||||
|
return ['Folder service is unavailable']
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = validateFolderNameBase(service, name)
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentIdentifier = folder.collection ?? null
|
||||||
|
const duplicate = collectionsStore
|
||||||
|
.collectionsInCollection(service.provider, service.identifier, parentIdentifier)
|
||||||
|
.some(candidate =>
|
||||||
|
String(candidate.identifier) !== String(folder.identifier) &&
|
||||||
|
candidate.properties.label === name,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (duplicate) {
|
||||||
|
errors.push('A folder with this name already exists in this location')
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelectionList(nextIds: EntityIdentifier[]) {
|
||||||
|
selectionList.value = Array.from(new Set(nextIds))
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCompose(source?: EntityObject, mode: 'reply' | 'forward' = 'reply') {
|
||||||
|
mailStore.clearSelectedMessage()
|
||||||
|
composeSource.value = source ?? null
|
||||||
|
composeMode.value = mode
|
||||||
|
composeVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCompose() {
|
||||||
|
composeMode.value = 'new'
|
||||||
|
composeSource.value = null
|
||||||
|
composeVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function afterSent() {
|
||||||
|
closeCompose()
|
||||||
|
await mailStore.reloadSelectedFolder()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
setSelectionList([])
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateSelectionMode(message?: EntityObject) {
|
||||||
|
selectionMode.value = true
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifier = message.identifier
|
||||||
|
|
||||||
|
if (!selectionList.value.includes(identifier)) {
|
||||||
|
setSelectionList([...selectionList.value, identifier])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deactivateSelectionMode() {
|
||||||
|
selectionMode.value = false
|
||||||
|
clearSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMessageSelection(message: EntityObject) {
|
||||||
|
const identifier = message.identifier
|
||||||
|
|
||||||
|
selectionMode.value = true
|
||||||
|
|
||||||
|
if (selectionList.value.includes(identifier)) {
|
||||||
|
setSelectionList(selectionList.value.filter(selectedId => selectedId !== identifier))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectionList([...selectionList.value, identifier])
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllCurrentMessages() {
|
||||||
|
selectionMode.value = true
|
||||||
|
setSelectionList(mailStore.currentMessages.map(message => message.identifier))
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcileSelection() {
|
||||||
|
if (!mailStore.selectedFolder) {
|
||||||
|
clearSelection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentMessageIdentifiers = new Set(mailStore.currentMessages.map(message => message.identifier))
|
||||||
|
const nextSelectedIds = selectionList.value.filter(identifier => currentMessageIdentifiers.has(identifier))
|
||||||
|
|
||||||
|
if (nextSelectedIds.length !== selectionList.value.length) {
|
||||||
|
setSelectionList(nextSelectedIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextSelectedIds.length === 0) {
|
||||||
|
selectionMode.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
sidebarVisible.value = !sidebarVisible.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSidebar() {
|
||||||
|
sidebarVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSettings() {
|
||||||
|
settingsDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSettings() {
|
||||||
|
settingsDialogVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMoveMessagesDialog(entities?: EntityObject | EntityObject[]) {
|
||||||
|
|
||||||
|
let moveMessagesServiceIdentifier = null as ServiceIdentifier | null
|
||||||
|
moveMessagesDialogCandidates.value = []
|
||||||
|
|
||||||
|
if (entities) {
|
||||||
|
if (Array.isArray(entities)) {
|
||||||
|
moveMessagesDialogCandidates.value = entities.map(entity => entity.identifier)
|
||||||
|
moveMessagesServiceIdentifier = entities[0]?.service as ServiceIdentifier || null
|
||||||
|
} else {
|
||||||
|
moveMessagesDialogCandidates.value = [entities.identifier]
|
||||||
|
moveMessagesServiceIdentifier = entities.service as ServiceIdentifier || null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
moveMessagesDialogCandidates.value = [...selectionList.value]
|
||||||
|
moveMessagesServiceIdentifier = mailStore.selectedFolder?.service as ServiceIdentifier || null
|
||||||
|
}
|
||||||
|
|
||||||
|
moveMessagesDialogService.value = await mailStore.retrieveService(moveMessagesServiceIdentifier);
|
||||||
|
moveMessagesDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMoveMessagesDialog() {
|
||||||
|
moveMessagesDialogVisible.value = false
|
||||||
|
moveMessagesDialogService.value = null
|
||||||
|
moveMessagesDialogCandidates.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmMoveMessages(targetIdentifier: Parameters<typeof mailStore.moveMessages>[0]) {
|
||||||
|
await mailStore.moveMessages(targetIdentifier, moveMessagesDialogCandidates.value ?? [])
|
||||||
|
deactivateSelectionMode()
|
||||||
|
closeMoveMessagesDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSelectedMessages() {
|
||||||
|
await mailStore.deleteMessages([...selectionList.value])
|
||||||
|
deactivateSelectionMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateFolderDialog(service: ServiceObject, parentFolder: CollectionObject | null = null) {
|
||||||
|
createFolderDialogService.value = service
|
||||||
|
createFolderDialogParent.value = parentFolder
|
||||||
|
createFolderDialogError.value = ''
|
||||||
|
createFolderDialogLoading.value = false
|
||||||
|
createFolderDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreateFolderDialog() {
|
||||||
|
createFolderDialogVisible.value = false
|
||||||
|
createFolderDialogService.value = null
|
||||||
|
createFolderDialogParent.value = null
|
||||||
|
createFolderDialogError.value = ''
|
||||||
|
createFolderDialogLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmCreateFolder(label: string) {
|
||||||
|
const service = createFolderDialogService.value
|
||||||
|
|
||||||
|
if (!service) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
createFolderDialogLoading.value = true
|
||||||
|
createFolderDialogError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const folder = await mailStore.createFolder(service, label, createFolderDialogParent.value)
|
||||||
|
closeCreateFolderDialog()
|
||||||
|
return folder
|
||||||
|
} catch (error) {
|
||||||
|
createFolderDialogError.value = error instanceof Error ? error.message : 'Failed to create folder. Please try again.'
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
createFolderDialogLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openRenameFolderDialog(target: CollectionObject) {
|
||||||
|
const service = await mailStore.retrieveService(target.service)
|
||||||
|
renameFolderDialogService.value = service
|
||||||
|
renameFolderDialogFolder.value = target
|
||||||
|
renameFolderDialogError.value = ''
|
||||||
|
renameFolderDialogLoading.value = false
|
||||||
|
renameFolderDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRenameFolderDialog() {
|
||||||
|
renameFolderDialogVisible.value = false
|
||||||
|
renameFolderDialogService.value = null
|
||||||
|
renameFolderDialogFolder.value = null
|
||||||
|
renameFolderDialogError.value = ''
|
||||||
|
renameFolderDialogLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRenameFolder(label: string) {
|
||||||
|
const folder = renameFolderDialogFolder.value
|
||||||
|
|
||||||
|
if (!folder) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
renameFolderDialogLoading.value = true
|
||||||
|
renameFolderDialogError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedFolder = await mailStore.renameFolder(folder, label)
|
||||||
|
closeRenameFolderDialog()
|
||||||
|
return updatedFolder
|
||||||
|
} catch (error) {
|
||||||
|
renameFolderDialogError.value = error instanceof Error ? error.message : 'Failed to rename folder. Please try again.'
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
renameFolderDialogLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMoveFolderDialog(source: CollectionObject) {
|
||||||
|
const service = await mailStore.retrieveService(source.service)
|
||||||
|
moveFolderDialogService.value = service
|
||||||
|
moveFolderDialogSource.value = source
|
||||||
|
moveFolderDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMoveFolderDialog() {
|
||||||
|
moveFolderDialogVisible.value = false
|
||||||
|
moveFolderDialogService.value = null
|
||||||
|
moveFolderDialogSource.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmMoveFolder(target: CollectionObject) {
|
||||||
|
const source = moveFolderDialogSource.value
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const movedFolder = await mailStore.moveFolder(source, target)
|
||||||
|
closeMoveFolderDialog()
|
||||||
|
return movedFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDeleteFolderDialog(target: CollectionObject) {
|
||||||
|
const service = await mailStore.retrieveService(target.service)
|
||||||
|
deleteFolderDialogService.value = service
|
||||||
|
deleteFolderDialogFolder.value = target
|
||||||
|
deleteFolderDialogError.value = ''
|
||||||
|
deleteFolderDialogLoading.value = false
|
||||||
|
deleteFolderDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteFolderDialog() {
|
||||||
|
deleteFolderDialogVisible.value = false
|
||||||
|
deleteFolderDialogService.value = null
|
||||||
|
deleteFolderDialogFolder.value = null
|
||||||
|
deleteFolderDialogError.value = ''
|
||||||
|
deleteFolderDialogLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteFolder() {
|
||||||
|
const folder = deleteFolderDialogFolder.value
|
||||||
|
|
||||||
|
if (!folder) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteFolderDialogLoading.value = true
|
||||||
|
deleteFolderDialogError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deleted = await mailStore.deleteFolder(folder)
|
||||||
|
closeDeleteFolderDialog()
|
||||||
|
return deleted
|
||||||
|
} catch (error) {
|
||||||
|
deleteFolderDialogError.value = error instanceof Error ? error.message : 'Failed to delete folder. Please try again.'
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
deleteFolderDialogLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sidebarVisible,
|
||||||
|
settingsDialogVisible,
|
||||||
|
composeMode,
|
||||||
|
composeSource,
|
||||||
|
composeVisible,
|
||||||
|
selectionMode,
|
||||||
|
selectionList,
|
||||||
|
moveMessagesDialogVisible,
|
||||||
|
moveMessagesDialogService,
|
||||||
|
moveMessagesDialogCandidates,
|
||||||
|
createFolderDialogParentLabel,
|
||||||
|
createFolderDialogVisible,
|
||||||
|
createFolderDialogService,
|
||||||
|
createFolderDialogParent,
|
||||||
|
createFolderDialogLoading,
|
||||||
|
createFolderDialogError,
|
||||||
|
renameFolderDialogVisible,
|
||||||
|
renameFolderDialogService,
|
||||||
|
renameFolderDialogFolder,
|
||||||
|
renameFolderDialogParentLabel,
|
||||||
|
renameFolderDialogLoading,
|
||||||
|
renameFolderDialogError,
|
||||||
|
moveFolderDialogVisible,
|
||||||
|
moveFolderDialogService,
|
||||||
|
moveFolderDialogSource,
|
||||||
|
moveFolderDialogInvalidFolderKeys,
|
||||||
|
deleteFolderDialogVisible,
|
||||||
|
deleteFolderDialogService,
|
||||||
|
deleteFolderDialogFolder,
|
||||||
|
deleteFolderDialogLoading,
|
||||||
|
deleteFolderDialogError,
|
||||||
|
toggleSidebar,
|
||||||
|
closeSidebar,
|
||||||
|
openSettings,
|
||||||
|
closeSettings,
|
||||||
|
openCompose,
|
||||||
|
closeCompose,
|
||||||
|
afterSent,
|
||||||
|
activateSelectionMode,
|
||||||
|
deactivateSelectionMode,
|
||||||
|
toggleMessageSelection,
|
||||||
|
selectAllCurrentMessages,
|
||||||
|
clearSelection,
|
||||||
|
validateCreateFolderName,
|
||||||
|
validateRenameFolderName,
|
||||||
|
openMoveMessagesDialog,
|
||||||
|
closeMoveMessagesDialog,
|
||||||
|
confirmMoveMessages,
|
||||||
|
deleteSelectedMessages,
|
||||||
|
openCreateFolderDialog,
|
||||||
|
closeCreateFolderDialog,
|
||||||
|
confirmCreateFolder,
|
||||||
|
openRenameFolderDialog,
|
||||||
|
closeRenameFolderDialog,
|
||||||
|
confirmRenameFolder,
|
||||||
|
openMoveFolderDialog,
|
||||||
|
closeMoveFolderDialog,
|
||||||
|
confirmMoveFolder,
|
||||||
|
openDeleteFolderDialog,
|
||||||
|
closeDeleteFolderDialog,
|
||||||
|
confirmDeleteFolder,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
{
|
{
|
||||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
"include": ["src/**/*", "src/**/*.vue", "../../core/src/**/*.ts"],
|
"include": [
|
||||||
|
"src/**/*",
|
||||||
|
"src/**/*.vue",
|
||||||
|
"../../core/src/**/*.ts",
|
||||||
|
"../mail_manager/src/**/*.ts",
|
||||||
|
"../mail_manager/src/**/*.vue"
|
||||||
|
],
|
||||||
"exclude": ["src/**/__tests__/*"],
|
"exclude": ["src/**/__tests__/*"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user