- Modified docker-compose.dev.yml to conditionally run npm install based on the presence of package-lock.json. - Upgraded Nuxt version in package-lock.json from 3.21.1 to 4.3.1 for enhanced features. - Enhanced ConfirmModal component with aria-modal attribute for better accessibility. - Updated MapErrorBoundary component's error message for clarity. - Added role and aria-label attributes to MapView and MapSearch components for improved screen reader support. - Refactored various components to manage focus behavior on modal close, enhancing user experience. - Improved ToastContainer styling for better responsiveness and visibility. - Updated layout components to include skip navigation links for improved accessibility.
345 lines
13 KiB
Vue
345 lines
13 KiB
Vue
<template>
|
|
<div class="drawer h-screen flex flex-col bg-base-100 overflow-hidden">
|
|
<a
|
|
href="#main-content"
|
|
class="absolute left-0 -top-20 z-[1200] bg-primary text-primary-content px-4 py-2 rounded-b transition-[top] duration-200 focus:top-0 focus:outline-none focus:ring-2 focus:ring-primary-content"
|
|
>
|
|
Skip to main content
|
|
</a>
|
|
<input
|
|
id="nav-drawer"
|
|
ref="drawerCheckboxRef"
|
|
type="checkbox"
|
|
class="drawer-toggle"
|
|
@change="onDrawerChange"
|
|
/>
|
|
<div class="drawer-content flex flex-col h-screen overflow-hidden">
|
|
<header class="navbar relative z-[1100] bg-base-100/80 backdrop-blur-xl border-b border-base-300/50 px-4 gap-2 shrink-0">
|
|
<NuxtLink to="/" class="flex items-center gap-2 text-lg font-semibold hover:opacity-80 transition-all duration-200">
|
|
<icons-icon-map class="size-6 text-primary shrink-0" aria-hidden="true" />
|
|
<span>{{ title }}</span>
|
|
</NuxtLink>
|
|
<div class="flex-1" />
|
|
<!-- Desktop (md+): Login / connection + user dropdown -->
|
|
<template v-if="!isLogin">
|
|
<NuxtLink
|
|
v-if="!me"
|
|
to="/login"
|
|
class="btn btn-ghost btn-sm btn-primary hidden md:inline-flex"
|
|
>
|
|
Login
|
|
</NuxtLink>
|
|
<div
|
|
v-if="me"
|
|
class="hidden md:flex items-center gap-2 shrink-0 min-h-9"
|
|
>
|
|
<div class="flex items-center shrink-0 min-h-9">
|
|
<span
|
|
class="inline-flex items-center gap-1.5 text-xs text-base-content/70 leading-none"
|
|
:class="live ? 'text-success' : 'text-base-content/50'"
|
|
>
|
|
<span
|
|
class="size-2 rounded-full shrink-0"
|
|
:class="live ? 'bg-success animate-pulse' : 'bg-base-content/40'"
|
|
aria-hidden="true"
|
|
/>
|
|
{{ live ? 'Live' : 'Offline' }}
|
|
</span>
|
|
</div>
|
|
<div class="dropdown dropdown-end flex items-center">
|
|
<details ref="userDropdownRef" class="dropdown group">
|
|
<summary class="btn btn-ghost btn-sm gap-2 flex items-center min-h-9 h-9 cursor-pointer list-none [&::-webkit-details-marker]:hidden">
|
|
<UserAvatar :username="me.username" :email="me.email" :size="32" />
|
|
<span class="max-w-[8rem] truncate font-medium">{{ me.username }}</span>
|
|
<svg class="size-4 opacity-70 shrink-0 transition-transform group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</summary>
|
|
<ul
|
|
class="dropdown-content menu bg-base-200 rounded-xl z-[1100] mt-2 w-52 border border-base-300/50 shadow-xl p-2"
|
|
>
|
|
<li>
|
|
<NuxtLink to="/" :class="route.path === '/' ? 'active' : ''" @click="closeDropdown">
|
|
<icons-icon-map />
|
|
Map
|
|
</NuxtLink>
|
|
</li>
|
|
<li>
|
|
<NuxtLink to="/profile" :class="route.path === '/profile' ? 'active' : ''" @click="closeDropdown">
|
|
<icons-icon-user />
|
|
Profile
|
|
</NuxtLink>
|
|
</li>
|
|
<li v-if="isAdmin">
|
|
<NuxtLink to="/admin" :class="route.path.startsWith('/admin') ? 'active' : ''" @click="closeDropdown">
|
|
<icons-icon-shield />
|
|
Admin
|
|
</NuxtLink>
|
|
</li>
|
|
<li class="menu-divider my-1" />
|
|
<li class="[&_.menu-item]:flex [&_.menu-item]:items-center [&_.menu-item]:gap-3">
|
|
<label class="flex cursor-pointer items-center gap-3 py-2 px-2 rounded-lg hover:bg-base-300/50 w-full min-h-0">
|
|
<icons-icon-sun v-if="!dark" class="size-4 shrink-0 opacity-80" />
|
|
<icons-icon-moon v-else class="size-4 shrink-0 opacity-80" />
|
|
<span class="flex-1 text-sm">Dark theme</span>
|
|
<input
|
|
type="checkbox"
|
|
class="toggle toggle-sm toggle-primary shrink-0"
|
|
:checked="dark"
|
|
@change="onThemeToggle"
|
|
/>
|
|
</label>
|
|
</li>
|
|
<li>
|
|
<button type="button" class="text-error hover:bg-error/10 rounded-lg" @click="doLogout">
|
|
<icons-icon-logout />
|
|
Logout
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</details>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<!-- Mobile: hamburger to open drawer -->
|
|
<label
|
|
ref="hamburgerRef"
|
|
for="nav-drawer"
|
|
class="btn btn-ghost btn-square md:hidden flex items-center justify-center min-h-[2.75rem] min-w-[2.75rem]"
|
|
aria-label="Open menu"
|
|
>
|
|
<icons-icon-menu class="size-6" />
|
|
</label>
|
|
</header>
|
|
<main id="main-content" class="flex-1 min-h-0 overflow-y-auto relative flex flex-col" tabindex="-1">
|
|
<AdminBreadcrumbs v-if="route.path.startsWith('/admin') && route.path !== '/admin'" />
|
|
<div class="flex-1 min-h-0">
|
|
<slot />
|
|
</div>
|
|
</main>
|
|
<ToastContainer />
|
|
</div>
|
|
<!-- Drawer sidebar (mobile menu) -->
|
|
<div ref="drawerSideRef" class="drawer-side z-[1100]">
|
|
<label
|
|
for="nav-drawer"
|
|
aria-label="Close menu"
|
|
class="drawer-overlay"
|
|
tabindex="0"
|
|
@keydown.enter.prevent="closeDrawer"
|
|
@keydown.space.prevent="closeDrawer"
|
|
/>
|
|
<aside class="bg-base-200/95 backdrop-blur-xl min-h-full w-72 p-4 flex flex-col">
|
|
<!-- Mobile: user + live when logged in -->
|
|
<div v-if="!isLogin && me" class="flex items-center gap-3 pb-4 mb-2 border-b border-base-300/50">
|
|
<UserAvatar :username="me.username" :email="me.email" :size="40" />
|
|
<div class="flex-1 min-w-0">
|
|
<p class="font-medium truncate">{{ me.username }}</p>
|
|
<p class="text-xs flex items-center gap-1.5" :class="live ? 'text-success' : 'text-base-content/60'">
|
|
<span class="size-1.5 rounded-full" :class="live ? 'bg-success animate-pulse' : 'bg-base-content/40'" />
|
|
{{ live ? 'Live' : 'Offline' }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<ul class="menu p-0 flex-1 text-base">
|
|
<li v-if="!me && !isLogin">
|
|
<NuxtLink to="/login" class="min-h-12 touch-manipulation" @click="closeDrawer">
|
|
Login
|
|
</NuxtLink>
|
|
</li>
|
|
<template v-else>
|
|
<li>
|
|
<NuxtLink to="/" :class="route.path === '/' ? 'active' : ''" class="min-h-12 touch-manipulation" @click="closeDrawer">
|
|
<icons-icon-map />
|
|
Map
|
|
</NuxtLink>
|
|
</li>
|
|
<li>
|
|
<NuxtLink to="/profile" :class="route.path === '/profile' ? 'active' : ''" class="min-h-12 touch-manipulation" @click="closeDrawer">
|
|
<icons-icon-user />
|
|
Profile
|
|
</NuxtLink>
|
|
</li>
|
|
<li v-if="isAdmin">
|
|
<NuxtLink to="/admin" :class="route.path.startsWith('/admin') ? 'active' : ''" class="min-h-12 touch-manipulation" @click="closeDrawer">
|
|
<icons-icon-shield />
|
|
Admin
|
|
</NuxtLink>
|
|
</li>
|
|
<li class="menu-divider my-1" />
|
|
<li class="[&_.menu-item]:flex [&_.menu-item]:items-center [&_.menu-item]:gap-3 min-h-12">
|
|
<label class="flex cursor-pointer items-center gap-3 py-3 px-2 rounded-lg hover:bg-base-300/50 w-full min-h-0 touch-manipulation">
|
|
<icons-icon-sun v-if="!dark" class="size-4 shrink-0 opacity-80" />
|
|
<icons-icon-moon v-else class="size-4 shrink-0 opacity-80" />
|
|
<span class="flex-1 text-sm">Dark theme</span>
|
|
<input
|
|
type="checkbox"
|
|
class="toggle toggle-sm toggle-primary shrink-0"
|
|
:checked="dark"
|
|
@change="onThemeToggle"
|
|
/>
|
|
</label>
|
|
</li>
|
|
<li>
|
|
<button type="button" class="text-error hover:bg-error/10 rounded-lg min-h-12 touch-manipulation w-full justify-start" @click="doLogout">
|
|
<icons-icon-logout />
|
|
Logout
|
|
</button>
|
|
</li>
|
|
</template>
|
|
</ul>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { MeResponse } from '~/types/api'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const THEME_KEY = 'hnh-map-theme'
|
|
|
|
function getInitialDark(): boolean {
|
|
if (import.meta.client) {
|
|
const stored = localStorage.getItem(THEME_KEY)
|
|
if (stored === 'dark') return true
|
|
if (stored === 'light') return false
|
|
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
}
|
|
return false
|
|
}
|
|
|
|
const title = ref('HnH Map')
|
|
const dark = ref(false)
|
|
/** Live when at least one of current user's characters is on the map (set by MapView). */
|
|
const live = useState<boolean>('mapLive', () => false)
|
|
const me = useState<MeResponse | null>('me', () => null)
|
|
const userDropdownRef = ref<HTMLDetailsElement | null>(null)
|
|
const drawerCheckboxRef = ref<HTMLInputElement | null>(null)
|
|
const drawerSideRef = ref<HTMLDivElement | null>(null)
|
|
const hamburgerRef = ref<HTMLLabelElement | null>(null)
|
|
let drawerTrapCleanup: (() => void) | null = null
|
|
|
|
function closeDropdown() {
|
|
userDropdownRef.value?.removeAttribute('open')
|
|
}
|
|
|
|
function closeDrawer() {
|
|
if (drawerCheckboxRef.value) drawerCheckboxRef.value.checked = false
|
|
}
|
|
|
|
function getDrawerFocusables(): HTMLElement[] {
|
|
const side = drawerSideRef.value
|
|
if (!side) return []
|
|
const selector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
return Array.from(side.querySelectorAll<HTMLElement>(selector)).filter(
|
|
(el) => el.offsetParent !== null && !el.hasAttribute('aria-hidden')
|
|
)
|
|
}
|
|
|
|
function onDrawerChange() {
|
|
const open = drawerCheckboxRef.value?.checked ?? false
|
|
if (drawerTrapCleanup) {
|
|
drawerTrapCleanup()
|
|
drawerTrapCleanup = null
|
|
}
|
|
if (open) {
|
|
nextTick(() => {
|
|
const focusables = getDrawerFocusables()
|
|
const first = focusables.find((el) => el.closest('aside')) ?? focusables[0]
|
|
if (first) first.focus()
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
if (e.key !== 'Tab') return
|
|
const focusables = getDrawerFocusables()
|
|
if (focusables.length === 0) return
|
|
const i = focusables.indexOf(document.activeElement as HTMLElement)
|
|
if (e.shiftKey) {
|
|
if (i <= 0) {
|
|
e.preventDefault()
|
|
focusables[focusables.length - 1]?.focus()
|
|
}
|
|
} else {
|
|
if (i === focusables.length - 1 || i < 0) {
|
|
e.preventDefault()
|
|
focusables[0]?.focus()
|
|
}
|
|
}
|
|
}
|
|
document.addEventListener('keydown', handleKeydown, true)
|
|
drawerTrapCleanup = () => document.removeEventListener('keydown', handleKeydown, true)
|
|
})
|
|
} else if (import.meta.client && hamburgerRef.value && typeof hamburgerRef.value.focus === 'function') {
|
|
nextTick(() => hamburgerRef.value?.focus())
|
|
}
|
|
}
|
|
|
|
const { isLoginPath } = useAppPaths()
|
|
const isLogin = computed(() => isLoginPath(route.path))
|
|
const isAdmin = computed(() => !!me.value?.auths?.includes('admin'))
|
|
|
|
let loadId = 0
|
|
|
|
async function loadMe() {
|
|
if (isLogin.value) return
|
|
try {
|
|
me.value = await useMapApi().me()
|
|
} catch {
|
|
me.value = null
|
|
}
|
|
}
|
|
|
|
async function loadConfig(loadToken: number) {
|
|
if (isLogin.value) return
|
|
if (loadToken !== loadId) return
|
|
try {
|
|
const config = await useMapApi().getConfig()
|
|
if (loadToken !== loadId) return
|
|
if (config?.title) title.value = config.title
|
|
} catch (_) {}
|
|
}
|
|
|
|
onMounted(() => {
|
|
dark.value = getInitialDark()
|
|
applyTheme()
|
|
})
|
|
|
|
watch(
|
|
() => route.path,
|
|
(path) => {
|
|
if (!isLoginPath(path)) {
|
|
const currentLoadId = ++loadId
|
|
loadMe().then(() => loadConfig(currentLoadId))
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
function onThemeToggle() {
|
|
dark.value = !dark.value
|
|
applyTheme()
|
|
}
|
|
|
|
function applyTheme() {
|
|
if (!import.meta.client) return
|
|
const html = document.documentElement
|
|
const theme = dark.value ? 'dark' : 'light'
|
|
html.setAttribute('data-theme', theme)
|
|
localStorage.setItem(THEME_KEY, theme)
|
|
}
|
|
|
|
async function doLogout() {
|
|
await useMapApi().logout()
|
|
closeDrawer()
|
|
await router.push('/login')
|
|
me.value = null
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
if (drawerTrapCleanup) {
|
|
drawerTrapCleanup()
|
|
drawerTrapCleanup = null
|
|
}
|
|
})
|
|
</script>
|