refactor: split stores and use events

Signed-off-by: Sebastian <krupinski01@gmail.com>
This commit is contained in:
2026-05-14 22:26:45 -04:00
parent 46632d2454
commit 232f588225
19 changed files with 1808 additions and 1210 deletions

View File

@@ -1,7 +1,5 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { CollectionPropertiesObject } from '@MailManager/models/collection'
import type { CollectionObject } from '@MailManager/models/collection'
import type { ServiceObject } from '@MailManager/models'
@@ -9,28 +7,27 @@ import type { ServiceObject } from '@MailManager/models'
interface Props {
modelValue: boolean
service: ServiceObject
parentFolder?: CollectionObject | null
allFolders?: CollectionObject[]
parentFolderLabel?: string
validateName?: (name: string) => string[]
loading?: boolean
errorMessage?: string
}
const props = withDefaults(defineProps<Props>(), {
parentFolder: null,
allFolders: () => []
parentFolderLabel: 'Root',
validateName: () => [],
loading: false,
errorMessage: '',
})
// Emits
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'created': [folder: CollectionObject]
confirm: [folderName: string]
}>()
// Store
const collectionsStore = useCollectionsStore()
// Form state
const folderName = ref('')
const loading = ref(false)
const errorMessage = ref('')
const validationErrors = ref<string[]>([])
// Computed
@@ -39,67 +36,13 @@ const dialogValue = computed({
set: (value: boolean) => emit('update:modelValue', value)
})
const parentFolderLabel = computed(() => {
if (!props.parentFolder) return 'Root'
return props.parentFolder.properties.label
})
const isValid = computed(() => {
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(folderName, (newName) => {
errorMessage.value = ''
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')
}
}
validationErrors.value = props.validateName(newName)
})
// Reset form when dialog opens/closes
@@ -111,59 +54,25 @@ watch(dialogValue, (isOpen) => {
const resetForm = () => {
folderName.value = ''
errorMessage.value = ''
validationErrors.value = []
loading.value = false
}
const handleCreate = async () => {
// Final validation
const errors = validateFolderName(folderName.value)
const errors = props.validateName(folderName.value)
if (errors.length > 0) {
validationErrors.value = errors
return
}
if (checkDuplicateName(folderName.value)) {
validationErrors.value = ['A folder with this name already exists in this location']
return
}
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
}
emit('confirm', folderName.value.trim())
}
const handleCancel = () => {
dialogValue.value = false
resetForm()
}
</script>
<template>

View File

@@ -8,20 +8,22 @@ interface Props {
modelValue: boolean
service: ServiceObject
folder: CollectionObject
loading?: boolean
errorMessage?: string
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
loading: false,
errorMessage: '',
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
deleted: [folder: CollectionObject]
confirm: []
}>()
const collectionsStore = useCollectionsStore()
const loading = ref(false)
const errorMessage = ref('')
const dialogValue = computed({
get: () => props.modelValue,
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)
})
const resetState = () => {
loading.value = false
errorMessage.value = ''
}
watch(dialogValue, isOpen => {
if (isOpen) {
resetState()
}
})
const handleDelete = async () => {
loading.value = true
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 handleDelete = () => {
emit('confirm')
}
const handleCancel = () => {
dialogValue.value = false
resetState()
}
</script>

View File

@@ -22,9 +22,9 @@ const collectionsStore = useCollectionsStore()
const emit = defineEmits<{
select: [folder: CollectionObject]
createFolder: [service: ServiceObject, parentFolder: CollectionObject | null]
editFolder: [service: ServiceObject, folder: CollectionObject]
moveFolder: [service: ServiceObject, folder: CollectionObject]
deleteFolder: [service: ServiceObject, folder: CollectionObject]
editFolder: [folder: CollectionObject]
moveFolder: [folder: CollectionObject]
deleteFolder: [folder: CollectionObject]
}>()
// Page-based navigation state per service account
@@ -283,7 +283,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
<v-list density="compact">
<v-list-item
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>
@@ -295,7 +295,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
</v-list-item>
<v-list-item
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>
@@ -303,7 +303,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
v-if="canDeleteFolder(folder)"
prepend-icon="mdi-delete"
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>
@@ -446,7 +446,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
<v-list density="compact">
<v-list-item
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>
@@ -458,7 +458,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
</v-list-item>
<v-list-item
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>
@@ -466,7 +466,7 @@ const getCurrentParentFolder = (service: ServiceObject): CollectionObject | null
v-if="canDeleteFolder(folder)"
prepend-icon="mdi-delete"
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>

View File

@@ -1,8 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { useServicesStore } from '@MailManager/stores/servicesStore'
import { useMailStore } from '@/stores/mailStore'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import type { ServiceObject, CollectionObject } from '@MailManager/models'
import FolderSelectionTreeNode from './FolderSelectionTreeNode.vue'
@@ -31,9 +30,8 @@ const emit = defineEmits<{
cancel: []
}>()
const collectionsStore = useCollectionsStore()
const servicesStore = useServicesStore()
const mailStore = useMailStore()
const collectionsStore = useCollectionsStore()
const selectedFolderKey = ref<string | null>(null)
@@ -42,10 +40,6 @@ const dialogValue = computed({
set: (value: boolean) => emit('update:modelValue', value),
})
const folderKeyFor = (folder: CollectionObject): string => {
return `${folder.provider}:${String(folder.service)}:${String(folder.identifier)}`
}
interface ServiceGroup {
service: ServiceObject
loading: boolean
@@ -54,9 +48,7 @@ interface ServiceGroup {
}
const serviceGroups = computed<ServiceGroup[]>(() => {
const service = props.service ??
(mailStore.moveDialogService ? servicesStore.serviceByIdentifier(mailStore.moveDialogService) : null)
const service = props.service
if (!service) {
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[] => {
if (service.identifier === null) {
return []
@@ -89,36 +109,8 @@ const getServiceFolders = (service: ServiceObject): CollectionObject[] => {
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) => {
selectedFolderKey.value = folderKeyFor(folder)
selectedFolderKey.value = folder.identifier
}
const handleCancel = () => {
@@ -168,7 +160,7 @@ const handleConfirm = () => {
<FolderSelectionTreeNode
v-for="folder in getRootFolders(group.service)"
:key="folderKeyFor(folder)"
:key="folder.identifier"
:folder="folder"
:service="group.service"
:selected-folder-key="selectedFolderKey"

View File

@@ -3,6 +3,7 @@ import { computed, ref } from 'vue'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import type { CollectionObject } from '@MailManager/models/collection'
import type { ServiceObject } from '@MailManager/models'
import type { CollectionIdentifier } from '@MailManager/types/common'
interface Props {
folder: CollectionObject
@@ -20,9 +21,7 @@ const emit = defineEmits<{
const expanded = ref(false)
const folderKeyFor = (folder: CollectionObject): string => {
return `${folder.provider}:${String(folder.service)}:${String(folder.identifier)}`
}
const folderKeyFor = (folder: CollectionObject): string => String(folder.identifier)
const folderLabelFor = (folder: CollectionObject): string => {
return folder.properties.label || String(folder.identifier)
@@ -76,7 +75,11 @@ const childFolders = computed(() => {
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(() => {
@@ -86,7 +89,7 @@ const hasChildren = computed(() => {
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)

View File

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

View File

@@ -17,17 +17,17 @@ const expanded = ref(false)
const emit = defineEmits<{
select: [folder: CollectionObject]
createSubfolder: [service: ServiceObject, parentFolder: CollectionObject]
editFolder: [service: ServiceObject, folder: CollectionObject]
moveFolder: [service: ServiceObject, folder: CollectionObject]
deleteFolder: [service: ServiceObject, folder: CollectionObject]
editFolder: [folder: CollectionObject]
moveFolder: [folder: CollectionObject]
deleteFolder: [folder: CollectionObject]
}>()
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(() => {
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)
@@ -131,7 +131,7 @@ const isSelected = (folder: CollectionObject): boolean => {
<v-list density="compact">
<v-list-item
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>
@@ -143,7 +143,7 @@ const isSelected = (folder: CollectionObject): boolean => {
</v-list-item>
<v-list-item
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>
@@ -151,7 +151,7 @@ const isSelected = (folder: CollectionObject): boolean => {
v-if="canDeleteFolder"
prepend-icon="mdi-delete"
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>
@@ -170,9 +170,9 @@ const isSelected = (folder: CollectionObject): boolean => {
:selected-folder="selectedFolder"
@select="emit('select', $event)"
@create-subfolder="(service, parentFolder) => emit('createSubfolder', service, parentFolder)"
@edit-folder="(service, folder) => emit('editFolder', service, folder)"
@move-folder="(service, folder) => emit('moveFolder', service, folder)"
@delete-folder="(service, folder) => emit('deleteFolder', service, folder)"
@edit-folder="(folder) => emit('editFolder', folder)"
@move-folder="(folder) => emit('moveFolder', folder)"
@delete-folder="(folder) => emit('deleteFolder', folder)"
/>
</div>
</v-list-group>
@@ -217,7 +217,7 @@ const isSelected = (folder: CollectionObject): boolean => {
<v-list density="compact">
<v-list-item
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>
@@ -229,7 +229,7 @@ const isSelected = (folder: CollectionObject): boolean => {
</v-list-item>
<v-list-item
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>
@@ -237,7 +237,7 @@ const isSelected = (folder: CollectionObject): boolean => {
v-if="canDeleteFolder"
prepend-icon="mdi-delete"
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>

View File

@@ -22,9 +22,9 @@ const collectionsStore = useCollectionsStore()
const emit = defineEmits<{
select: [folder: CollectionObject]
createFolder: [service: ServiceObject, parentFolder: CollectionObject | null]
editFolder: [service: ServiceObject, folder: CollectionObject]
moveFolder: [service: ServiceObject, folder: CollectionObject]
deleteFolder: [service: ServiceObject, folder: CollectionObject]
editFolder: [folder: CollectionObject]
moveFolder: [folder: CollectionObject]
deleteFolder: [folder: CollectionObject]
}>()
const getRootFolders = (service: ServiceObject): CollectionObject[] => {
@@ -75,9 +75,9 @@ const getServiceFolders = (service: ServiceObject): CollectionObject[] => {
:selected-folder="selectedFolder"
@select="emit('select', $event)"
@create-subfolder="(service, parentFolder) => emit('createFolder', service, parentFolder)"
@edit-folder="(service, folder) => emit('editFolder', service, folder)"
@move-folder="(service, folder) => emit('moveFolder', service, folder)"
@delete-folder="(service, folder) => emit('deleteFolder', service, folder)"
@edit-folder="(folder) => emit('editFolder', folder)"
@move-folder="(folder) => emit('moveFolder', folder)"
@delete-folder="(folder) => emit('deleteFolder', folder)"
/>
<v-list-item v-if="group.loading && getServiceFolders(group.service).length === 0" disabled class="folder-status-item">

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

View File

@@ -1,22 +1,24 @@
<script setup lang="ts">
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 Link from '@tiptap/extension-link'
import Underline from '@tiptap/extension-underline'
import TextAlign from '@tiptap/extension-text-align'
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 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
interface Props {
replyTo?: EntityInterface<MessageInterface> | null
folder?: CollectionInterface | null
mode: 'new' | 'reply' | 'forward'
source?: EntityObject | null
folder?: CollectionObject | null
}
const props = defineProps<Props>()
@@ -27,6 +29,13 @@ const emit = defineEmits<{
sent: []
}>()
const mailStore = useMailStore()
const {
composerSending: sending,
composerSaving: saving,
composerLastSaved: lastSaved,
} = storeToRefs(mailStore)
// State
const to = ref<string[]>([])
const cc = ref<string[]>([])
@@ -34,10 +43,6 @@ const bcc = ref<string[]>([])
const subject = ref('')
const showCc = 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
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null
@@ -65,25 +70,65 @@ const editor = useEditor({
},
})
// Initialize from reply-to message
if (props.replyTo) {
const replyMessage = new MessageObject(props.replyTo.properties)
const fromEmail = replyMessage.from?.address
to.value = fromEmail ? [fromEmail] : []
const originalSubject = replyMessage.subject || ''
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 resetComposerFields() {
to.value = []
cc.value = []
bcc.value = []
subject.value = ''
showCc.value = false
showBcc.value = false
editor.value?.commands.setContent('')
}
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
const canSend = computed(() => {
return to.value.length > 0 && subject.value.trim().length > 0
@@ -110,10 +155,8 @@ const saveDraft = async () => {
return
}
saving.value = true
try {
const draftData = {
await mailStore.saveComposerDraft(props.folder, {
to: to.value,
cc: cc.value,
bcc: bcc.value,
@@ -122,27 +165,9 @@ const saveDraft = async () => {
html: editor.value?.getHTML() || '',
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) {
console.error('[MessageComposer] Failed to save draft:', error)
} finally {
saving.value = false
console.error('[Mail][Composer] Failed to save draft:', error)
}
}
@@ -173,53 +198,33 @@ onBeforeUnmount(() => {
if (autoSaveTimer) {
clearTimeout(autoSaveTimer)
}
mailStore.resetComposerState()
editor.value?.destroy()
})
// Handlers
const handleClose = () => {
mailStore.resetComposerState()
emit('close')
}
const handleSend = async () => {
if (!canSend.value || sending.value) return
sending.value = true
try {
await entityService.transmit({
message: {
to: to.value,
cc: cc.value.length > 0 ? cc.value : undefined,
bcc: bcc.value.length > 0 ? bcc.value : undefined,
subject: subject.value,
body: {
html: editor.value?.getHTML() || '',
text: editor.value?.getText() || '',
},
await mailStore.sendComposerMessage({
to: to.value,
cc: cc.value,
bcc: bcc.value,
subject: subject.value,
body: {
html: editor.value?.getHTML() || '',
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')
} catch (error) {
console.error('[MessageComposer] Failed to send message:', error)
alert('Failed to send message. Please try again.')
} finally {
sending.value = false
console.error('[Mail][Composer] Failed to send message:', error)
}
}
@@ -248,192 +253,61 @@ const removeLink = () => editor.value?.chain().focus().unsetLink().run()
const isActive = (name: string, attrs?: any) => {
return editor.value?.isActive(name, attrs) || false
}
const toggleLink = () => {
if (isActive('link')) {
removeLink()
return
}
setLink()
}
</script>
<template>
<div class="message-composer">
<!-- Toolbar -->
<v-toolbar density="compact" elevation="0" class="composer-toolbar">
<v-btn
variant="text"
@click="handleClose"
icon="mdi-close"
>
<v-icon>mdi-close</v-icon>
<v-tooltip activator="parent" location="bottom">Close</v-tooltip>
</v-btn>
<ComposerToolbar
:mode="mode"
:save-status="saveStatus"
:can-send="canSend"
:sending="sending"
@close="handleClose"
@send="handleSend"
/>
<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">
<!-- Recipients -->
<div class="composer-fields pa-4">
<v-combobox
v-model="to"
label="To"
chips
multiple
closable-chips
variant="outlined"
density="compact"
class="mb-2"
>
<template v-slot:append-inner>
<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>
<ComposerRecipients
:to="to"
:cc="cc"
:bcc="bcc"
:subject="subject"
:show-cc="showCc"
:show-bcc="showBcc"
@update:to="to = $event"
@update:cc="cc = $event"
@update:bcc="bcc = $event"
@update:subject="subject = $event"
@toggle:cc="toggleCc"
@toggle:bcc="toggleBcc"
/>
<v-divider />
<!-- Editor toolbar -->
<v-toolbar density="compact" elevation="0" class="editor-toolbar">
<v-btn
icon
size="small"
:class="{ 'v-btn--active': isActive('bold') }"
@click="toggleBold"
>
<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': 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>
<ComposerEditor
:editor="editor"
:is-bold-active="isActive('bold')"
:is-italic-active="isActive('italic')"
:is-underline-active="isActive('underline')"
:is-bullet-list-active="isActive('bulletList')"
:is-ordered-list-active="isActive('orderedList')"
:is-link-active="isActive('link')"
@bold="toggleBold"
@italic="toggleItalic"
@underline="toggleUnderline"
@bullet-list="toggleBulletList"
@ordered-list="toggleOrderedList"
@link="toggleLink"
/>
</div>
</div>
</template>
@@ -457,65 +331,4 @@ const isActive = (name: string, attrs?: any) => {
flex-direction: column;
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>

View File

@@ -38,34 +38,26 @@ const LONG_PRESS_MS = 450
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 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 hasSelection = computed(() => selectionCount.value > 0)
@@ -74,11 +66,45 @@ const allCurrentMessagesSelected = computed(() => {
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
const formatDate = (date: Date | string | null | undefined): string => {
if (!date) return ''
const messageDate = new Date(date)
if (Number.isNaN(messageDate.getTime())) return ''
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
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
}
const isSelectionControlClick = (event: MouseEvent | KeyboardEvent): boolean => {
return event.target instanceof Element && event.target.closest('.message-selection-checkbox') !== null
}
const handleSelectionToggle = (message: EntityObject) => {
emit('toggleSelection', message)
}
const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: EntityObject) => {
if (isSelectionControlClick(event)) {
return
}
if (suppressNextClick.value) {
suppressNextClick.value = false
return
}
if (event.shiftKey && !props.selectionMode) {
event.preventDefault()
event.stopPropagation()
@@ -138,11 +177,6 @@ const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: Ent
return
}
if (suppressNextClick.value) {
suppressNextClick.value = false
return
}
if (props.selectionMode) {
emit('toggleSelection', message)
return
@@ -152,6 +186,10 @@ const handleMessageMouseClick = (event: MouseEvent | KeyboardEvent, message: Ent
}
const handleMessageMouseDown = (event: MouseEvent, message: EntityObject) => {
if (isSelectionControlClick(event)) {
return
}
if (!event.shiftKey || props.selectionMode) {
return
}
@@ -200,29 +238,6 @@ onBeforeUnmount(() => {
const handleSelectAllToggle = (value: boolean | null) => {
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>
<template>
@@ -282,7 +297,6 @@ const hasCountData = computed(() => {
size="small"
icon="mdi-close"
variant="text"
:disabled="!hasSelection"
@click="emit('clearSelection')"
>
<v-icon>mdi-close</v-icon>
@@ -320,13 +334,13 @@ const hasCountData = computed(() => {
>
<template v-slot:default="{ item: message }">
<v-list-item
:key="`${message.provider}:${message.service}:${message.collection}:${message.identifier}`"
:key="message.identifier"
class="message-item"
:class="{
'opened': isOpened(message),
'selected': isSelected(message),
'selection-mode': selectionMode,
'unread': isUnread(message)
'unread': !message.properties.isRead
}"
@mousedown="handleMessageMouseDown($event, message)"
@click="handleMessageMouseClick($event, message)"
@@ -360,7 +374,7 @@ const hasCountData = computed(() => {
{{ message.properties.from?.label || message.properties.from?.address || 'Unknown Sender' }}
</span>
<span class="text-caption text-medium-emphasis ml-2">
{{ formatDate(message.properties.date) }}
{{ formatDate(getMessageTimestamp(message)) }}
</span>
</v-list-item-title>
@@ -369,13 +383,13 @@ const hasCountData = computed(() => {
</v-list-item-subtitle>
<v-list-item-subtitle class="text-caption text-truncate">
{{ truncate(message.properties.snippet, 80) }}
{{ '' }}
</v-list-item-subtitle>
<template v-slot:append>
<div class="d-flex flex-column align-center">
<v-icon
v-if="isFlagged(message)"
v-if="message.properties.isFlagged"
size="small"
color="warning"
class="mb-1"
@@ -383,7 +397,7 @@ const hasCountData = computed(() => {
mdi-star
</v-icon>
<v-icon
v-if="message.properties.attachments && message.properties.attachments.length > 0"
v-if="message.properties.hasAttachments"
size="small"
color="grey"
>

View File

@@ -1,7 +1,5 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useCollectionsStore } from '@MailManager/stores/collectionsStore'
import { CollectionPropertiesObject } from '@MailManager/models/collection'
import type { CollectionObject } from '@MailManager/models/collection'
import type { ServiceObject } from '@MailManager/models'
@@ -9,23 +7,25 @@ interface Props {
modelValue: boolean
service: ServiceObject
folder: CollectionObject
allFolders?: CollectionObject[]
parentFolderLabel?: string
validateName?: (name: string) => string[]
loading?: boolean
errorMessage?: string
}
const props = withDefaults(defineProps<Props>(), {
allFolders: () => []
parentFolderLabel: 'Root',
validateName: () => [],
loading: false,
errorMessage: '',
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
updated: [folder: CollectionObject]
confirm: [folderName: string]
}>()
const collectionsStore = useCollectionsStore()
const folderName = ref('')
const loading = ref(false)
const errorMessage = ref('')
const validationErrors = ref<string[]>([])
const dialogValue = computed({
@@ -37,74 +37,13 @@ const isValid = computed(() => {
return folderName.value.trim().length > 0 && validationErrors.value.length === 0
})
const parentFolderLabel = computed(() => {
const parentId = props.folder.collection
if (parentId === null || parentId === undefined) return 'Root'
const parent = props.allFolders.find(
f =>
String(f.identifier) === String(parentId) &&
f.provider === props.folder.provider &&
String(f.service) === String(props.folder.service)
)
return parent?.properties.label || 'Root'
})
const validateFolderName = (name: string): string[] => {
const errors: string[] = []
if (!name || name.trim().length === 0) {
errors.push('Folder name is required')
return errors
}
if (name.length > 255) {
errors.push('Folder name too long (max 255 characters)')
}
if (/[<>:"|?*\x00-\x1F]/.test(name)) {
errors.push('Folder name contains invalid characters')
}
if (props.service.provider === 'imap' && /[\/\\]/.test(name)) {
errors.push('IMAP folder names cannot contain / or \\')
}
if (name !== name.trim()) {
errors.push('Folder name cannot have leading or trailing spaces')
}
return errors
}
const checkDuplicateName = (name: string): boolean => {
const parentId = props.folder.collection ?? null
return props.allFolders.some(f => {
if (String(f.identifier) === String(props.folder.identifier)) return false
return (
f.properties.label === name &&
String(f.collection) === String(parentId) &&
f.provider === props.folder.provider &&
String(f.service) === String(props.folder.service)
)
})
}
watch(folderName, (newName) => {
errorMessage.value = ''
validationErrors.value = validateFolderName(newName)
if (validationErrors.value.length === 0 && newName.trim().length > 0 && checkDuplicateName(newName)) {
validationErrors.value.push('A folder with this name already exists in this location')
}
validationErrors.value = props.validateName(newName)
})
function resetForm() {
folderName.value = props.folder.properties.label || ''
errorMessage.value = ''
validationErrors.value = []
loading.value = false
}
watch(dialogValue, (isOpen) => {
@@ -114,54 +53,27 @@ watch(dialogValue, (isOpen) => {
}, { immediate: true })
const handleRename = async () => {
const errors = validateFolderName(folderName.value)
const errors = props.validateName(folderName.value)
if (errors.length > 0) {
validationErrors.value = errors
return
}
if (checkDuplicateName(folderName.value)) {
validationErrors.value = ['A folder with this name already exists in this location']
return
}
const newName = folderName.value.trim()
if (newName === props.folder.properties.label) {
dialogValue.value = false
return
}
loading.value = true
errorMessage.value = ''
try {
const properties = new CollectionPropertiesObject()
properties.label = newName
properties.rank = props.folder.properties.rank ?? 0
properties.subscribed = props.folder.properties.subscribed ?? true
const updatedFolder = await collectionsStore.update(
props.folder.provider,
props.folder.service,
props.folder.identifier,
properties
)
emit('updated', updatedFolder)
dialogValue.value = false
resetForm()
} catch (error: any) {
console.error('[RenameFolderDialog] Failed to rename folder:', error)
errorMessage.value = error.message || 'Failed to rename folder. Please try again.'
} finally {
loading.value = false
}
emit('confirm', newName)
}
const handleCancel = () => {
dialogValue.value = false
resetForm()
}
</script>
<template>

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

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

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