feat(SPA): profile page, display name and the new account menu

This commit is contained in:
fsylvain committed 2026-09-29 10:13:09 +02:00
1 parent 55525c06d6
commit b0f7b7892b
29 files changed
+3166 -469

No files matched your search

+150
View File
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { ColorModePreference } from '@/composables/useColorMode'
import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu'
const ITEM_SELECTOR = '[role="menuitem"], [role="menuitemcheckbox"]'
const ITEM_CLASS = 'h-12 justify-start gap-3 px-4 text-[0.9375rem] text-highlighted'
const props = defineProps<{
username: string
displayName?: string
email?: string
}>()
const emit = defineEmits<{
logout: []
}>()
const open = defineModel<boolean>('open', { default: false })
const {
colorMode,
name,
initials,
onProfile,
goToProfile: navigateToProfile,
} = useAccountMenu(() => props)
const menu = ref<HTMLElement | null>(null)
function goToProfile() {
open.value = false
navigateToProfile()
}
function chooseColorMode(value: ColorModePreference) {
colorMode.preference = value
open.value = false
}
function logOut() {
open.value = false
emit('logout')
}
function moveFocus(event: KeyboardEvent) {
const items = Array.from(menu.value?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? [])
if (items.length === 0) {
return
}
const current = items.indexOf(document.activeElement as HTMLElement)
const lastIndex = items.length - 1
const targets: Record<string, number> = {
ArrowDown: current >= lastIndex ? 0 : current + 1,
ArrowUp: current <= 0 ? lastIndex : current - 1,
Home: 0,
End: lastIndex,
}
const target = targets[event.key]
if (target === undefined) {
return
}
event.preventDefault()
items[target]?.focus()
}
</script>
<template>
<USlideover
v-model:open="open"
side="bottom"
title="Account"
description="Your account menu"
:close="false"
:ui="{
content: 'max-h-none',
body: 'p-0 sm:p-0',
header: 'sr-only',
}"
>
<template #body>
<div ref="menu" role="menu" aria-label="Account" class="pt-2 pb-6" @keydown="moveFocus">
<div class="mx-auto mb-2 h-1 w-9 bg-accented" aria-hidden="true" />
<div class="mb-1 flex items-center gap-3 border-b border-default px-4 pt-2 pb-3.5">
<UAvatar
aria-hidden="true"
:text="initials"
:ui="{
root: 'size-9 bg-secondary-300',
fallback: 'text-[0.8125rem] font-semibold text-primary-900',
}"
/>
<div class="flex min-w-0 flex-col">
<span class="truncate text-[0.9375rem] font-semibold text-highlighted">{{
name
}}</span>
<span class="truncate text-muted">{{ email }}</span>
</div>
</div>
<UButton
role="menuitem"
color="neutral"
variant="ghost"
block
icon="i-lucide-circle-user"
:class="[ITEM_CLASS, onProfile ? 'bg-elevated' : '']"
:ui="{ leadingIcon: 'size-4.5' }"
@click="goToProfile"
>
Profile
</UButton>
<USeparator class="my-1" />
<UButton
v-for="option in colorModeOptions"
:key="option.value"
role="menuitemcheckbox"
color="neutral"
variant="ghost"
block
:aria-checked="colorMode.preference === option.value"
:trailing-icon="
colorMode.preference === option.value ? 'i-lucide-check' : undefined
"
:class="ITEM_CLASS"
:ui="{ trailingIcon: 'ms-auto size-4.5' }"
@click="chooseColorMode(option.value)"
>
{{ option.label }}
</UButton>
<USeparator class="my-1" />
<UButton
role="menuitem"
color="neutral"
variant="ghost"
block
icon="i-lucide-log-out"
:class="ITEM_CLASS"
:ui="{ leadingIcon: 'size-4.5' }"
@click="logOut"
>
Log out
</UButton>
</div>
</template>
</USlideover>
</template>