Enhance frontend components and introduce new features

- Updated PasswordInput component with improved styling and touch manipulation support.
- Added new IconMenu component for consistent icon representation in the UI.
- Refactored MapControls and introduced MapControlsContent for better organization and usability.
- Implemented suppress-leaflet-deprecation plugin to handle known warnings in Firefox.
- Enhanced default layout with a responsive drawer for mobile navigation and improved user experience.
This commit is contained in:
2026-03-01 15:45:49 +03:00
parent 2bd2c8dbca
commit 945b803dba
8 changed files with 1293 additions and 969 deletions

View File

@@ -8,7 +8,7 @@
:id="inputId"
:value="modelValue"
:type="showPass ? 'text' : 'password'"
class="input flex-1 pr-10"
class="input flex-1 pr-10 min-h-11 touch-manipulation"
:placeholder="placeholder"
:required="required"
:autocomplete="autocomplete"
@@ -17,7 +17,7 @@
/>
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-sm btn-square min-h-0 h-8 w-8"
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-sm btn-square min-h-9 min-w-9 touch-manipulation"
:aria-label="showPass ? 'Hide password' : 'Show password'"
@click="showPass = !showPass"
>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</template>

View File

@@ -1,13 +1,13 @@
<template>
<!-- Desktop: left panel (md and up) -->
<div
class="absolute left-3 top-[10%] z-[502] flex min-w-[3rem] min-h-[3rem] transition-all duration-300 ease-out"
class="absolute left-3 top-[10%] z-[502] hidden md:flex min-w-[3rem] min-h-[3rem] transition-all duration-300 ease-out"
:class="panelCollapsed ? 'w-12' : 'w-64'"
>
<div
class="rounded-xl bg-base-100/80 backdrop-blur-xl border border-base-300/50 shadow-xl overflow-hidden flex flex-col transition-all duration-300 ease-out"
:class="panelCollapsed ? 'w-12 items-center py-2 gap-1' : 'w-56'"
>
<!-- Collapsed: zoom in/out + expand -->
<template v-if="panelCollapsed">
<div class="flex flex-col gap-1">
<button
@@ -30,99 +30,30 @@
</button>
</div>
</template>
<!-- Expanded: full content with transition -->
<Transition name="panel-slide" mode="out-in">
<div
v-if="!panelCollapsed"
key="expanded"
class="flex flex-col p-4 gap-4 flex-1 min-w-0 overflow-hidden"
>
<!-- Zoom -->
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70 flex items-center gap-1.5">
<icons-icon-zoom-in class="size-3.5 opacity-80" aria-hidden="true" />
Zoom
</h3>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105"
title="Zoom in"
aria-label="Zoom in"
@click="$emit('zoomIn')"
>
<icons-icon-zoom-in />
</button>
<button
type="button"
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105"
title="Zoom out"
aria-label="Zoom out"
@click="$emit('zoomOut')"
>
<icons-icon-zoom-out />
</button>
<button
type="button"
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105"
title="Reset view — center map and minimum zoom"
aria-label="Reset view"
@click="$emit('resetView')"
>
<icons-icon-home />
</button>
</div>
</section>
<!-- Display -->
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70 flex items-center gap-1.5">
<icons-icon-eye class="size-3.5 opacity-80" aria-hidden="true" />
Display
</h3>
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2">
<input v-model="showGridCoordinates" type="checkbox" class="checkbox checkbox-sm" />
<span>Show grid coordinates</span>
</label>
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2">
<input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" />
<span>Hide markers</span>
</label>
</section>
<!-- Navigation -->
<section class="flex flex-col gap-3">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70 flex items-center gap-1.5">
<icons-icon-map-pin class="size-3.5 opacity-80" aria-hidden="true" />
Navigation
</h3>
<fieldset class="fieldset">
<label class="label py-0"><span>Jump to Map</span></label>
<select v-model="selectedMapIdSelect" class="select select-sm w-full focus:ring-2 focus:ring-primary">
<option value="">Select map</option>
<option v-for="m in maps" :key="m.ID" :value="m.ID">{{ m.Name }}</option>
</select>
</fieldset>
<fieldset class="fieldset">
<label class="label py-0"><span>Overlay Map</span></label>
<select v-model="overlayMapId" class="select select-sm w-full focus:ring-2 focus:ring-primary">
<option :value="-1">None</option>
<option v-for="m in maps" :key="'overlay-' + m.ID" :value="m.ID">{{ m.Name }}</option>
</select>
</fieldset>
<fieldset class="fieldset">
<label class="label py-0"><span>Jump to Quest Giver</span></label>
<select v-model="selectedMarkerIdSelect" class="select select-sm w-full focus:ring-2 focus:ring-primary">
<option value="">Select quest giver</option>
<option v-for="q in questGivers" :key="q.id" :value="q.id">{{ q.name }}</option>
</select>
</fieldset>
<fieldset class="fieldset">
<label class="label py-0"><span>Jump to Player</span></label>
<select v-model="selectedPlayerIdSelect" class="select select-sm w-full focus:ring-2 focus:ring-primary">
<option value="">Select player</option>
<option v-for="p in players" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</fieldset>
</section>
<MapControlsContent
v-model:show-grid-coordinates="showGridCoordinates"
v-model:hide-markers="hideMarkers"
:selected-map-id-select="selectedMapIdSelect"
@update:selected-map-id-select="(v) => (selectedMapIdSelect = v)"
:overlay-map-id="overlayMapId"
@update:overlay-map-id="(v) => (overlayMapId = v)"
:selected-marker-id-select="selectedMarkerIdSelect"
@update:selected-marker-id-select="(v) => (selectedMarkerIdSelect = v)"
:selected-player-id-select="selectedPlayerIdSelect"
@update:selected-player-id-select="(v) => (selectedPlayerIdSelect = v)"
:maps="maps"
:quest-givers="questGivers"
:players="players"
@zoom-in="$emit('zoomIn')"
@zoom-out="$emit('zoomOut')"
@reset-view="$emit('resetView')"
/>
</div>
</Transition>
<button
@@ -137,10 +68,100 @@
</button>
</div>
</div>
<!-- Mobile: FAB + bottom sheet -->
<div class="fixed left-3 bottom-6 z-[502] flex flex-col gap-2 md:hidden">
<button
type="button"
class="btn btn-primary btn-circle shadow-lg min-h-12 min-w-12 touch-manipulation"
aria-label="Open map controls"
@click="sheetOpen = true"
>
<icons-icon-panel-left class="size-6" />
</button>
<div class="flex flex-col gap-1">
<button
type="button"
class="btn btn-ghost btn-sm btn-circle min-h-10 min-w-10 touch-manipulation"
title="Zoom in"
aria-label="Zoom in"
@click="$emit('zoomIn')"
>
<icons-icon-zoom-in class="size-4" />
</button>
<button
type="button"
class="btn btn-ghost btn-sm btn-circle min-h-10 min-w-10 touch-manipulation"
title="Zoom out"
aria-label="Zoom out"
@click="$emit('zoomOut')"
>
<icons-icon-zoom-out class="size-4" />
</button>
</div>
</div>
<!-- Mobile bottom sheet overlay + panel -->
<Teleport to="body">
<Transition name="sheet">
<div
v-if="sheetOpen"
class="fixed inset-0 z-[1100] md:hidden"
role="dialog"
aria-modal="true"
aria-label="Map controls"
>
<div
class="absolute inset-0 bg-black/50"
aria-hidden="true"
@click="sheetOpen = false"
/>
<div
class="sheet-panel absolute left-0 right-0 bottom-0 max-h-[85vh] rounded-t-2xl bg-base-100 shadow-2xl flex flex-col overflow-hidden"
@click.stop
>
<div class="flex justify-center py-2 shrink-0">
<span class="w-12 h-1 rounded-full bg-base-300" aria-hidden="true" />
</div>
<div class="overflow-y-auto overscroll-contain flex-1 p-4 pb-8">
<MapControlsContent
v-model:show-grid-coordinates="showGridCoordinates"
v-model:hide-markers="hideMarkers"
:selected-map-id-select="selectedMapIdSelect"
@update:selected-map-id-select="(v) => (selectedMapIdSelect = v)"
:overlay-map-id="overlayMapId"
@update:overlay-map-id="(v) => (overlayMapId = v)"
:selected-marker-id-select="selectedMarkerIdSelect"
@update:selected-marker-id-select="(v) => (selectedMarkerIdSelect = v)"
:selected-player-id-select="selectedPlayerIdSelect"
@update:selected-player-id-select="(v) => (selectedPlayerIdSelect = v)"
:maps="maps"
:quest-givers="questGivers"
:players="players"
:touch-friendly="true"
@zoom-in="$emit('zoomIn')"
@zoom-out="$emit('zoomOut')"
@reset-view="$emit('resetView')"
/>
</div>
<div class="p-3 border-t border-base-300 shrink-0 safe-area-pb">
<button
type="button"
class="btn btn-ghost btn-block touch-manipulation min-h-12"
@click="sheetOpen = false"
>
Close
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { MapInfo } from '~/types/api'
import MapControlsContent from '~/components/map/MapControlsContent.vue'
interface QuestGiver {
id: number
@@ -161,7 +182,7 @@ const props = withDefaults(
{ maps: () => [], questGivers: () => [], players: () => [] }
)
const emit = defineEmits<{
defineEmits<{
zoomIn: []
zoomOut: []
resetView: []
@@ -170,12 +191,12 @@ const emit = defineEmits<{
const showGridCoordinates = defineModel<boolean>('showGridCoordinates', { default: false })
const hideMarkers = defineModel<boolean>('hideMarkers', { default: false })
const panelCollapsed = ref(false)
const sheetOpen = ref(false)
const selectedMapId = defineModel<number | null>('selectedMapId', { default: null })
const overlayMapId = defineModel<number>('overlayMapId', { default: -1 })
const selectedMarkerId = defineModel<number | null>('selectedMarkerId', { default: null })
const selectedPlayerId = defineModel<number | null>('selectedPlayerId', { default: null })
// String bindings for selects with placeholder ('' ↔ null)
const selectedMapIdSelect = computed({
get() {
const v = selectedMapId.value
@@ -215,4 +236,20 @@ const selectedPlayerIdSelect = computed({
opacity: 0;
transform: translateX(-0.5rem);
}
.sheet-enter-active,
.sheet-leave-active {
transition: opacity 0.2s ease;
}
.sheet-enter-from,
.sheet-leave-to {
opacity: 0;
}
.sheet-enter-active .sheet-panel,
.sheet-leave-active .sheet-panel {
transition: transform 0.25s ease-out;
}
.sheet-enter-from .sheet-panel,
.sheet-leave-to .sheet-panel {
transform: translateY(100%);
}
</style>

View File

@@ -0,0 +1,169 @@
<template>
<div class="flex flex-col gap-4">
<!-- Zoom -->
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70 flex items-center gap-1.5">
<icons-icon-zoom-in class="size-3.5 opacity-80" aria-hidden="true" />
Zoom
</h3>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105 touch-manipulation"
:class="touchFriendly ? 'min-h-10 min-w-10' : ''"
title="Zoom in"
aria-label="Zoom in"
@click="$emit('zoomIn')"
>
<icons-icon-zoom-in />
</button>
<button
type="button"
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105 touch-manipulation"
:class="touchFriendly ? 'min-h-10 min-w-10' : ''"
title="Zoom out"
aria-label="Zoom out"
@click="$emit('zoomOut')"
>
<icons-icon-zoom-out />
</button>
<button
type="button"
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105 touch-manipulation"
:class="touchFriendly ? 'min-h-10 min-w-10' : ''"
title="Reset view — center map and minimum zoom"
aria-label="Reset view"
@click="$emit('resetView')"
>
<icons-icon-home />
</button>
</div>
</section>
<!-- Display -->
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70 flex items-center gap-1.5">
<icons-icon-eye class="size-3.5 opacity-80" aria-hidden="true" />
Display
</h3>
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2 touch-manipulation" :class="touchFriendly ? 'min-h-11' : ''">
<input v-model="showGridCoordinates" type="checkbox" class="checkbox checkbox-sm" />
<span>Show grid coordinates</span>
</label>
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2 touch-manipulation" :class="touchFriendly ? 'min-h-11' : ''">
<input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" />
<span>Hide markers</span>
</label>
</section>
<!-- Navigation -->
<section class="flex flex-col gap-3">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70 flex items-center gap-1.5">
<icons-icon-map-pin class="size-3.5 opacity-80" aria-hidden="true" />
Navigation
</h3>
<fieldset class="fieldset">
<label class="label py-0"><span>Jump to Map</span></label>
<select
v-model="selectedMapIdSelect"
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation"
:class="touchFriendly ? 'min-h-11 text-base' : ''"
>
<option value="">Select map</option>
<option v-for="m in maps" :key="m.ID" :value="String(m.ID)">{{ m.Name }}</option>
</select>
</fieldset>
<fieldset class="fieldset">
<label class="label py-0"><span>Overlay Map</span></label>
<select
v-model="overlayMapId"
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation"
:class="touchFriendly ? 'min-h-11 text-base' : ''"
>
<option value="-1">None</option>
<option v-for="m in maps" :key="'overlay-' + m.ID" :value="String(m.ID)">{{ m.Name }}</option>
</select>
</fieldset>
<fieldset class="fieldset">
<label class="label py-0"><span>Jump to Quest Giver</span></label>
<select
v-model="selectedMarkerIdSelect"
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation"
:class="touchFriendly ? 'min-h-11 text-base' : ''"
>
<option value="">Select quest giver</option>
<option v-for="q in questGivers" :key="q.id" :value="String(q.id)">{{ q.name }}</option>
</select>
</fieldset>
<fieldset class="fieldset">
<label class="label py-0"><span>Jump to Player</span></label>
<select
v-model="selectedPlayerIdSelect"
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation"
:class="touchFriendly ? 'min-h-11 text-base' : ''"
>
<option value="">Select player</option>
<option v-for="p in players" :key="p.id" :value="String(p.id)">{{ p.name }}</option>
</select>
</fieldset>
</section>
</div>
</template>
<script setup lang="ts">
import type { MapInfo } from '~/types/api'
interface QuestGiver {
id: number
name: string
}
interface Player {
id: number
name: string
}
const props = withDefaults(
defineProps<{
maps: MapInfo[]
questGivers: QuestGiver[]
players: Player[]
touchFriendly?: boolean
selectedMapIdSelect: string
overlayMapId: number
selectedMarkerIdSelect: string
selectedPlayerIdSelect: string
}>(),
{ touchFriendly: false }
)
const emit = defineEmits<{
zoomIn: []
zoomOut: []
resetView: []
'update:showGridCoordinates': [v: boolean]
'update:hideMarkers': [v: boolean]
'update:selectedMapIdSelect': [v: string]
'update:overlayMapId': [v: number]
'update:selectedMarkerIdSelect': [v: string]
'update:selectedPlayerIdSelect': [v: string]
}>()
const showGridCoordinates = defineModel<boolean>('showGridCoordinates', { required: true })
const hideMarkers = defineModel<boolean>('hideMarkers', { required: true })
const selectedMapIdSelect = computed({
get: () => props.selectedMapIdSelect,
set: (v) => emit('update:selectedMapIdSelect', v),
})
const overlayMapId = computed({
get: () => String(props.overlayMapId),
set: (v) => emit('update:overlayMapId', v === '' ? -1 : Number(v)),
})
const selectedMarkerIdSelect = computed({
get: () => props.selectedMarkerIdSelect,
set: (v) => emit('update:selectedMarkerIdSelect', v),
})
const selectedPlayerIdSelect = computed({
get: () => props.selectedPlayerIdSelect,
set: (v) => emit('update:selectedPlayerIdSelect', v),
})
</script>

View File

@@ -1,22 +1,27 @@
<template>
<div class="h-screen flex flex-col bg-base-100 overflow-hidden">
<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" />
<!-- When not logged in: show Login link (except on login page) -->
<!-- Desktop (md+): Login / connection + user dropdown -->
<template v-if="!isLogin">
<NuxtLink
v-if="!me && !isLogin"
v-if="!me"
to="/login"
class="btn btn-ghost btn-sm btn-primary"
class="btn btn-ghost btn-sm btn-primary hidden md:inline-flex"
>
Login
</NuxtLink>
<!-- Connection status: pulsing dot + tooltip when logged in -->
<div
v-if="!isLogin && me"
v-if="me"
class="hidden md:flex items-center gap-2 shrink-0"
>
<div
class="tooltip tooltip-bottom shrink-0"
:data-tip="live ? 'Connected to live updates' : 'Disconnected'"
>
@@ -32,8 +37,7 @@
{{ 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">
<div 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">
@@ -41,7 +45,7 @@
<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>
<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>
@@ -90,15 +94,94 @@
</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')" />
<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 placeholder">
<div class="bg-primary text-primary-content rounded-full w-10">
<span class="text-sm font-medium">{{ (me.username || '?')[0].toUpperCase() }}</span>
</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">Тёмная тема</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">
@@ -121,11 +204,16 @@ const dark = ref(false)
const live = ref(false)
const me = ref<{ username?: string; auths?: string[] } | null>(null)
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'))
@@ -175,6 +263,7 @@ function applyTheme() {
async function doLogout() {
await useMapApi().logout()
closeDrawer()
await router.push('/login')
me.value = null
}

View File

@@ -1,5 +1,5 @@
<template>
<div class="container mx-auto p-4 max-w-2xl">
<div class="container mx-auto p-4 max-w-2xl min-w-0">
<h1 class="text-2xl font-bold mb-6">Admin</h1>
<template v-if="loading">
@@ -51,12 +51,12 @@
<icons-icon-users />
Users
</h2>
<div class="form-control w-full max-w-xs mb-3">
<div class="form-control w-full mb-3 sm:max-w-xs">
<input
v-model="userSearch"
type="search"
placeholder="Search users…"
class="input input-sm input-bordered w-full"
class="input input-sm input-bordered w-full min-h-11 touch-manipulation"
aria-label="Search users"
/>
</div>
@@ -72,16 +72,16 @@
<span class="text-xs">{{ u[0]?.toUpperCase() }}</span>
</div>
</div>
<span class="font-medium">{{ u }}</span>
<span class="font-medium truncate">{{ u }}</span>
</div>
<NuxtLink :to="`/admin/users/${u}`" class="btn btn-outline btn-sm gap-1 shrink-0">
<NuxtLink :to="`/admin/users/${u}`" class="btn btn-outline btn-sm gap-1 shrink-0 min-h-11 touch-manipulation">
<icons-icon-pencil /> Edit
</NuxtLink>
</div>
<div v-if="!filteredUsers.length" class="py-6 text-center text-base-content/60 rounded-lg bg-base-300/30">
{{ userSearch ? 'No users match.' : 'No users yet.' }}
</div>
<NuxtLink to="/admin/users/new" class="btn btn-primary btn-sm mt-2 gap-1">
<NuxtLink to="/admin/users/new" class="btn btn-primary btn-sm mt-2 gap-1 min-h-11 touch-manipulation">
<icons-icon-plus /> Add user
</NuxtLink>
</div>
@@ -94,27 +94,27 @@
<icons-icon-map-pin />
Maps
</h2>
<div class="form-control w-full max-w-xs mb-3">
<div class="form-control w-full mb-3 sm:max-w-xs">
<input
v-model="mapSearch"
type="search"
placeholder="Search maps…"
class="input input-sm input-bordered w-full"
class="input input-sm input-bordered w-full min-h-11 touch-manipulation"
aria-label="Search maps"
/>
</div>
<div class="overflow-x-auto">
<table class="table table-sm table-zebra">
<table class="table table-sm table-zebra min-w-[32rem]">
<thead>
<tr>
<th>
<button type="button" class="btn btn-ghost btn-xs gap-0.5" :class="mapsSortBy === 'ID' ? 'btn-active' : ''" @click="setMapsSort('ID')">
<button type="button" class="btn btn-ghost btn-xs gap-0.5 min-h-9 touch-manipulation" :class="mapsSortBy === 'ID' ? 'btn-active' : ''" @click="setMapsSort('ID')">
ID
<span v-if="mapsSortBy === 'ID'" class="opacity-70">{{ mapsSortDir === 'asc' ? '' : '' }}</span>
</button>
</th>
<th>
<button type="button" class="btn btn-ghost btn-xs gap-0.5" :class="mapsSortBy === 'Name' ? 'btn-active' : ''" @click="setMapsSort('Name')">
<button type="button" class="btn btn-ghost btn-xs gap-0.5 min-h-9 touch-manipulation" :class="mapsSortBy === 'Name' ? 'btn-active' : ''" @click="setMapsSort('Name')">
Name
<span v-if="mapsSortBy === 'Name'" class="opacity-70">{{ mapsSortDir === 'asc' ? '' : '' }}</span>
</button>
@@ -131,7 +131,7 @@
<td>{{ map.Hidden ? 'Yes' : 'No' }}</td>
<td>{{ map.Priority ? 'Yes' : 'No' }}</td>
<td class="text-right">
<NuxtLink :to="`/admin/maps/${map.ID}`" class="btn btn-outline btn-sm gap-1">
<NuxtLink :to="`/admin/maps/${map.ID}`" class="btn btn-outline btn-sm gap-1 min-h-9 touch-manipulation">
<icons-icon-pencil /> Edit
</NuxtLink>
</td>
@@ -158,7 +158,7 @@
id="admin-settings-prefix"
v-model="settings.prefix"
type="text"
class="input input-sm w-full"
class="input input-sm w-full min-h-11 touch-manipulation"
/>
</fieldset>
<fieldset class="fieldset w-full max-w-xs">
@@ -167,11 +167,11 @@
id="admin-settings-title"
v-model="settings.title"
type="text"
class="input input-sm w-full"
class="input input-sm w-full min-h-11 touch-manipulation"
/>
</fieldset>
<fieldset class="fieldset">
<label class="label gap-2 cursor-pointer justify-start" for="admin-settings-default-hide">
<label class="label gap-2 cursor-pointer justify-start min-h-11 touch-manipulation" for="admin-settings-default-hide">
<input
id="admin-settings-default-hide"
v-model="settings.defaultHide"
@@ -183,7 +183,7 @@
</fieldset>
</div>
<div class="flex justify-end mt-2">
<button class="btn btn-primary btn-sm" :disabled="savingSettings" @click="saveSettings">
<button class="btn btn-primary btn-sm min-h-11 touch-manipulation" :disabled="savingSettings" @click="saveSettings">
<span v-if="savingSettings" class="loading loading-spinner loading-sm" />
<span v-else>Save settings</span>
</button>
@@ -199,12 +199,12 @@
</h2>
<div class="flex flex-col gap-3">
<div>
<a :href="api.adminExportUrl()" target="_blank" rel="noopener" class="btn btn-sm">
<a :href="api.adminExportUrl()" target="_blank" rel="noopener" class="btn btn-sm min-h-11 touch-manipulation">
Export zip
</a>
</div>
<div>
<button class="btn btn-sm" :disabled="rebuilding" @click="confirmRebuildZooms">
<button class="btn btn-sm min-h-11 touch-manipulation" :disabled="rebuilding" @click="confirmRebuildZooms">
<span v-if="rebuilding" class="loading loading-spinner loading-sm" />
<span v-else>Rebuild zooms</span>
</button>
@@ -212,7 +212,7 @@
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2">
<input ref="mergeFileRef" type="file" accept=".zip" class="hidden" @change="onMergeFile" />
<button type="button" class="btn btn-sm" @click="mergeFileRef?.click()">
<button type="button" class="btn btn-sm min-h-11 touch-manipulation" @click="mergeFileRef?.click()">
Choose merge file
</button>
<span class="text-sm text-base-content/70">
@@ -220,7 +220,7 @@
</span>
</div>
<form @submit.prevent="doMerge">
<button type="submit" class="btn btn-sm btn-primary" :disabled="!mergeFile || merging">
<button type="submit" class="btn btn-sm btn-primary min-h-11 touch-manipulation" :disabled="!mergeFile || merging">
<span v-if="merging" class="loading loading-spinner loading-sm" />
<span v-else>Merge</span>
</button>
@@ -228,7 +228,7 @@
</div>
<div class="border-t border-red-500/30 pt-4 mt-1 bg-error/5 rounded-lg p-3 -mx-1">
<p class="text-sm font-medium text-error/90 mb-2">Danger zone</p>
<button class="btn btn-sm btn-error" :disabled="wiping" @click="confirmWipe">
<button class="btn btn-sm btn-error min-h-11 touch-manipulation" :disabled="wiping" @click="confirmWipe">
<span v-if="wiping" class="loading loading-spinner loading-sm" />
<span v-else>Wipe all data</span>
</button>

View File

@@ -1,5 +1,5 @@
<template>
<div class="container mx-auto p-4 max-w-2xl">
<div class="container mx-auto p-4 max-w-2xl min-w-0">
<h1 class="text-2xl font-bold mb-6">Profile</h1>
<!-- User info card -->
@@ -64,7 +64,7 @@
<span class="text-xs text-base-content/60 shrink-0">Token {{ idx + 1 }}</span>
<button
type="button"
class="btn btn-ghost btn-xs shrink-0 gap-1"
class="btn btn-ghost btn-xs shrink-0 gap-1 min-h-9 min-w-[4rem] touch-manipulation"
aria-label="Copy token"
:class="copiedToken === t ? 'btn-success' : ''"
@click="copyToken(t)"
@@ -76,7 +76,7 @@
</ul>
<p v-else class="text-sm mt-2">No tokens yet.</p>
<p v-if="tokenError" class="text-error text-sm mt-2">{{ tokenError }}</p>
<button class="btn btn-primary btn-sm mt-2 transition-all duration-200 hover:scale-[1.02]" :disabled="loadingTokens" @click="generateToken">
<button class="btn btn-primary btn-sm mt-2 min-h-11 touch-manipulation transition-all duration-200 hover:scale-[1.02]" :disabled="loadingTokens" @click="generateToken">
{{ loadingTokens ? '' : 'Generate token' }}
</button>
</template>
@@ -96,7 +96,7 @@
autocomplete="new-password"
/>
<p v-if="passMsg" class="text-sm" :class="passOk ? 'text-success' : 'text-error'">{{ passMsg }}</p>
<button type="submit" class="btn btn-sm transition-all duration-200 hover:scale-[1.02]" :disabled="loadingPass">Save password</button>
<button type="submit" class="btn btn-sm min-h-11 touch-manipulation transition-all duration-200 hover:scale-[1.02]" :disabled="loadingPass">Save password</button>
</form>
</div>
</div>

View File

@@ -0,0 +1,22 @@
/**
* Suppress known Firefox deprecation warnings from Leaflet when panning/dragging the map.
* See: https://github.com/Leaflet/Leaflet/issues/7078, https://github.com/Leaflet/Leaflet/issues/9386
* No ill effect; Leaflet has not fixed this upstream.
*/
export default defineNuxtPlugin(() => {
if (import.meta.dev && typeof console !== 'undefined' && console.warn) {
const originalWarn = console.warn
console.warn = function (...args: unknown[]) {
const msg = typeof args[0] === 'string' ? args[0] : String(args[0])
if (
msg.includes('mozPressure') ||
msg.includes('mozInputSource') ||
msg.includes('PointerEvent.pressure') ||
msg.includes('PointerEvent.pointerType')
) {
return
}
originalWarn.apply(console, args)
}
}
})