Enhance frontend components and introduce new features

- Added a custom light theme in app.css to match the dark theme's palette.
- Introduced AdminBreadcrumbs component for improved navigation in admin pages.
- Implemented Skeleton component for loading states in various views.
- Added ToastContainer for displaying notifications and alerts.
- Enhanced MapView with loading indicators and improved marker handling.
- Updated MapCoordsDisplay to allow copying of shareable links.
- Refactored MapControls and MapContextMenu for better usability.
- Improved user experience in profile and admin pages with loading states and search functionality.
This commit is contained in:
2026-03-01 15:19:55 +03:00
parent 6529d7370e
commit 2bd2c8dbca
15 changed files with 817 additions and 212 deletions

View File

@@ -1,54 +1,103 @@
<template>
<div class="h-screen flex flex-col bg-base-100 overflow-hidden">
<header class="navbar bg-base-100/80 backdrop-blur-xl border-b border-base-300/50 px-4 gap-2 shrink-0">
<NuxtLink to="/" class="text-lg font-semibold hover:opacity-80 transition-all duration-200">{{ title }}</NuxtLink>
<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" />
<!-- When not logged in: show Login link (except on login page) -->
<NuxtLink
v-if="!isLogin"
to="/"
class="btn btn-ghost btn-sm gap-1.5 transition-all duration-200 hover:scale-105"
:class="route.path === '/' ? 'btn-primary' : ''"
v-if="!me && !isLogin"
to="/login"
class="btn btn-ghost btn-sm btn-primary"
>
<icons-icon-map />
Map
Login
</NuxtLink>
<NuxtLink
v-if="!isLogin"
to="/profile"
class="btn btn-ghost btn-sm gap-1.5 transition-all duration-200 hover:scale-105"
:class="route.path === '/profile' ? 'btn-primary' : ''"
>
<icons-icon-user />
Profile
</NuxtLink>
<NuxtLink
v-if="!isLogin && isAdmin"
to="/admin"
class="btn btn-ghost btn-sm gap-1.5 transition-all duration-200 hover:scale-105"
:class="route.path.startsWith('/admin') ? 'btn-primary' : ''"
>
<icons-icon-shield />
Admin
</NuxtLink>
<button
<!-- Connection status: pulsing dot + tooltip when logged in -->
<div
v-if="!isLogin && me"
type="button"
class="btn btn-ghost btn-sm btn-error btn-outline gap-1.5 transition-all duration-200 hover:scale-105"
@click="doLogout"
class="tooltip tooltip-bottom shrink-0"
:data-tip="live ? 'Connected to live updates' : 'Disconnected'"
>
<icons-icon-logout />
Logout
</button>
<label class="swap swap-rotate btn btn-ghost btn-sm transition-all duration-200 hover:scale-105">
<input type="checkbox" v-model="dark" @change="toggleTheme" />
<span class="swap-off"><icons-icon-sun /></span>
<span class="swap-on"><icons-icon-moon /></span>
</label>
<span v-if="live" class="badge badge-success badge-sm">Live</span>
<span
class="inline-flex items-center gap-1.5 text-xs text-base-content/70"
:class="live ? 'text-success' : 'text-base-content/50'"
>
<span
class="size-2 rounded-full"
:class="live ? 'bg-success animate-pulse' : 'bg-base-content/40'"
aria-hidden="true"
/>
{{ live ? 'Live' : 'Offline' }}
</span>
</div>
<!-- User dropdown when logged in: details/summary for reliable click-to-open -->
<div v-if="!isLogin && me" class="dropdown dropdown-end">
<details ref="userDropdownRef" class="dropdown group">
<summary class="btn btn-ghost btn-sm gap-2 cursor-pointer list-none [&::-webkit-details-marker]:hidden">
<div class="avatar placeholder">
<div class="bg-primary text-primary-content rounded-full w-8">
<span class="text-sm font-medium">{{ (me.username || '?')[0].toUpperCase() }}</span>
</div>
</div>
<span class="max-w-[8rem] truncate font-medium hidden sm:inline">{{ 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-box 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">Тёмная тема</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>
</header>
<main class="flex-1 min-h-0 overflow-y-auto relative">
<slot />
<main class="flex-1 min-h-0 overflow-y-auto relative flex flex-col">
<AdminBreadcrumbs v-if="route.path.startsWith('/admin')" />
<div class="flex-1 min-h-0">
<slot />
</div>
</main>
<ToastContainer />
</div>
</template>
@@ -71,6 +120,11 @@ const title = ref('HnH Map')
const dark = ref(false)
const live = ref(false)
const me = ref<{ username?: string; auths?: string[] } | null>(null)
const userDropdownRef = ref<HTMLDetailsElement | null>(null)
function closeDropdown() {
userDropdownRef.value?.removeAttribute('open')
}
const { isLoginPath } = useAppPaths()
const isLogin = computed(() => isLoginPath(route.path))
@@ -95,8 +149,7 @@ async function loadConfig() {
onMounted(() => {
dark.value = getInitialDark()
const html = document.documentElement
html.setAttribute('data-theme', dark.value ? 'dark' : 'light')
applyTheme()
})
watch(
@@ -107,15 +160,17 @@ watch(
{ immediate: true }
)
function toggleTheme() {
function onThemeToggle() {
dark.value = !dark.value
applyTheme()
}
function applyTheme() {
if (!import.meta.client) return
const html = document.documentElement
if (dark.value) {
html.setAttribute('data-theme', 'dark')
localStorage.setItem(THEME_KEY, 'dark')
} else {
html.setAttribute('data-theme', 'light')
localStorage.setItem(THEME_KEY, 'light')
}
const theme = dark.value ? 'dark' : 'light'
html.setAttribute('data-theme', theme)
localStorage.setItem(THEME_KEY, theme)
}
async function doLogout() {