Files
hnh-map/frontend-nuxt/layouts/default.vue
Nikolay Tatarinov d27eb2651e Enhance frontend styling and accessibility features
- Updated .dockerignore to exclude backup directory with root-only permissions from build context.
- Added new CSS variables for card radius and transition duration in app.css.
- Implemented consistent focus ring styles for interactive elements to improve accessibility.
- Refactored card components across various pages to utilize a unified card style, enhancing visual consistency.
- Improved button styles with touch manipulation support for better user interaction on mobile devices.
2026-03-01 22:19:51 +03:00

312 lines
12 KiB
Vue

<template>
<div class="drawer h-screen flex flex-col bg-base-100 overflow-hidden">
<input id="nav-drawer" ref="drawerCheckboxRef" type="checkbox" class="drawer-toggle" />
<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">
<div class="avatar">
<div class="rounded-full w-8 h-8 overflow-hidden flex items-center justify-center">
<img
v-if="me.email && !gravatarErrorDesktop"
:src="useGravatarUrl(me.email, 32)"
alt=""
class="w-full h-full object-cover"
@error="gravatarErrorDesktop = true"
>
<div
v-else
class="bg-primary text-primary-content rounded-full w-8 h-8 flex items-center justify-center"
>
<span class="text-sm font-medium">{{ (me.username || '?')[0].toUpperCase() }}</span>
</div>
</div>
</div>
<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
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 class="flex-1 min-h-0 overflow-y-auto relative flex flex-col">
<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 class="drawer-side z-[1100]">
<label for="nav-drawer" aria-label="Close menu" class="drawer-overlay" />
<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">
<div class="avatar">
<div class="rounded-full w-10 h-10 overflow-hidden flex items-center justify-center">
<img
v-if="me.email && !gravatarErrorDrawer"
:src="useGravatarUrl(me.email, 40)"
alt=""
class="w-full h-full object-cover"
@error="gravatarErrorDrawer = true"
>
<div
v-else
class="bg-primary text-primary-content rounded-full w-10 h-10 flex items-center justify-center"
>
<span class="text-sm font-medium">{{ (me.username || '?')[0].toUpperCase() }}</span>
</div>
</div>
</div>
<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 gravatarErrorDesktop = ref(false)
const gravatarErrorDrawer = ref(false)
const userDropdownRef = ref<HTMLDetailsElement | null>(null)
const drawerCheckboxRef = ref<HTMLInputElement | null>(null)
function closeDropdown() {
userDropdownRef.value?.removeAttribute('open')
}
function closeDrawer() {
if (drawerCheckboxRef.value) drawerCheckboxRef.value.checked = false
}
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 }
)
watch(
() => me.value?.email,
() => {
gravatarErrorDesktop.value = false
gravatarErrorDrawer.value = false
}
)
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
}
</script>