Files
Lesstime/frontend/modules/mail/components/MailFolderTree.vue
T
Matthieu bb7d7e7953 feat(mail) : extract Mail front into Nuxt module layer
LST-67 (2.5) front. Completes the Mail module.

- New frontend/modules/mail/ layer (auto-detected): /mail page (3 columns),
  7 components, mail service + DTO, mail store (folders/messages/unread polling).
- sanitizeMailHtml util and useSystemFolderLabel composable stay global;
  AdminMailTab stays in /admin (service import repointed).
- Consumers repointed: AdminMailTab and PM TaskModal -> ~/modules/mail/...;
  the store is auto-imported (Pinia storesDirs) so the layout badge/polling is
  unchanged.
- /mail gated by the mail module: sidebar.php item with module=mail (so
  SidebarFilter disables /mail when the module is off); the layout filters /mail
  from the API sections to avoid a visual duplicate. ROLE_CLIENT exclusion kept.
- i18n key sidebar.general.mail added.

nuxt build passes; /mail and all other routes preserved.
2026-06-20 19:52:13 +02:00

124 lines
4.3 KiB
Vue

<script setup lang="ts">
import type { MailFolderDto } from '~/modules/mail/services/dto/mail'
const props = defineProps<{
/** Arbre de dossiers (getter folderTree du store) */
folders: readonly MailFolderDto[]
/** Chemin du dossier actuellement sélectionné */
selectedPath: string | null
/** Niveau de profondeur pour l'indentation (usage récursif interne) */
depth?: number
}>()
const emit = defineEmits<{
select: [path: string]
}>()
const { getFolderLabel, getFolderIcon } = useSystemFolderLabel()
const { t } = useI18n()
const currentDepth = computed(() => props.depth ?? 0)
// Dossiers dépliés (repliés par défaut → seuls les dossiers racine sont visibles).
const expanded = ref<Set<string>>(new Set())
function isExpanded(path: string): boolean {
return expanded.value.has(path)
}
function toggleExpanded(path: string): void {
const next = new Set(expanded.value)
if (next.has(path)) {
next.delete(path)
} else {
next.add(path)
}
expanded.value = next
}
function hasChildren(folder: MailFolderDto): boolean {
return !!folder.children && folder.children.length > 0
}
function handleSelect(path: string): void {
emit('select', path)
}
function paddingStyle(): Record<string, string> {
const depth = currentDepth.value
return { paddingLeft: `${0.5 + depth * 0.75}rem` }
}
</script>
<template>
<div>
<div
v-if="folders.length === 0 && currentDepth === 0"
class="px-3 py-4 text-sm text-neutral-400 italic"
>
{{ t('mail.empty.folder') }}
</div>
<template v-else>
<div v-for="folder in folders" :key="folder.path">
<div
class="flex items-center gap-1 rounded-md pr-2 py-1.5 text-sm transition-colors"
:class="
selectedPath === folder.path
? 'bg-primary-100 text-primary-700 font-medium'
: 'text-neutral-700 hover:bg-neutral-100'
"
:style="paddingStyle()"
>
<button
v-if="hasChildren(folder)"
type="button"
class="flex-shrink-0 rounded p-0.5 hover:bg-neutral-200"
:aria-label="isExpanded(folder.path) ? t('mail.folderTree.collapse') : t('mail.folderTree.expand')"
@click.stop="toggleExpanded(folder.path)"
>
<Icon
:name="isExpanded(folder.path) ? 'material-symbols:keyboard-arrow-down' : 'material-symbols:chevron-right'"
size="16"
class="text-neutral-400"
/>
</button>
<span v-else class="inline-block w-[22px] flex-shrink-0" />
<button
type="button"
class="flex flex-1 items-center gap-2 text-left min-w-0"
@click="handleSelect(folder.path)"
>
<Icon
:name="getFolderIcon(folder.path)"
size="16"
class="flex-shrink-0"
:class="selectedPath === folder.path ? 'text-primary-600' : 'text-neutral-400'"
/>
<span class="flex-1 truncate">
{{ getFolderLabel(folder.path, folder.displayName) }}
</span>
<span
v-if="folder.unreadCount > 0"
class="ml-auto flex-shrink-0 rounded-full bg-primary-500 px-1.5 py-0.5 text-xs font-bold text-white"
>
{{ folder.unreadCount > 99 ? '99+' : folder.unreadCount }}
</span>
</button>
</div>
<MailFolderTree
v-if="hasChildren(folder) && isExpanded(folder.path)"
:folders="folder.children"
:selected-path="selectedPath"
:depth="currentDepth + 1"
@select="handleSelect"
/>
</div>
</template>
</div>
</template>