Update frontend components for accessibility and functionality improvements

- 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.
This commit is contained in:
2026-03-04 01:00:56 +03:00
parent 47b81c8f22
commit adfdfd01c4
22 changed files with 2703 additions and 1140 deletions

View File

@@ -17,7 +17,7 @@ services:
frontend: frontend:
image: node:20-alpine image: node:20-alpine
working_dir: /app working_dir: /app
command: sh -c "npm ci && npm run dev" command: sh -c "if [ -f package-lock.json ]; then npm ci; else npm install; fi && npm run dev"
ports: ports:
- "3000:3000" - "3000:3000"
volumes: volumes:

View File

@@ -1,5 +1,5 @@
<template> <template>
<dialog ref="dialogRef" class="modal" :aria-labelledby="titleId" @close="onClose"> <dialog ref="dialogRef" class="modal" :aria-labelledby="titleId" aria-modal="true" @close="onClose">
<div class="modal-box"> <div class="modal-box">
<h2 :id="titleId" class="font-bold text-lg mb-2">{{ title }}</h2> <h2 :id="titleId" class="font-bold text-lg mb-2">{{ title }}</h2>
<p>{{ message }}</p> <p>{{ message }}</p>
@@ -44,11 +44,13 @@ const emit = defineEmits<{
const titleId = computed(() => `confirm-modal-title-${Math.random().toString(36).slice(2)}`) const titleId = computed(() => `confirm-modal-title-${Math.random().toString(36).slice(2)}`)
const dialogRef = ref<HTMLDialogElement | null>(null) const dialogRef = ref<HTMLDialogElement | null>(null)
let previousActiveElement: HTMLElement | null = null
watch( watch(
() => props.modelValue, () => props.modelValue,
(open) => { (open) => {
if (open) { if (open) {
previousActiveElement = (import.meta.client ? document.activeElement : null) as HTMLElement | null
dialogRef.value?.showModal() dialogRef.value?.showModal()
} else { } else {
dialogRef.value?.close() dialogRef.value?.close()
@@ -63,6 +65,9 @@ function cancel() {
function onClose() { function onClose() {
emit('update:modelValue', false) emit('update:modelValue', false)
if (import.meta.client && previousActiveElement && typeof previousActiveElement.focus === 'function' && document.contains(previousActiveElement)) {
nextTick(() => previousActiveElement?.focus())
}
} }
function confirm() { function confirm() {

View File

@@ -8,7 +8,7 @@
> >
<p class="text-center text-lg font-medium">Map failed to load</p> <p class="text-center text-lg font-medium">Map failed to load</p>
<p class="text-center text-sm text-base-content/80"> <p class="text-center text-sm text-base-content/80">
Something went wrong while loading the map. You can try reloading the page. Something went wrong while loading the map. This can be due to a network or script error. You can try reloading the page.
</p> </p>
<button type="button" class="btn btn-primary" @click="reload"> <button type="button" class="btn btn-primary" @click="reload">
Reload page Reload page

View File

@@ -16,8 +16,11 @@
</div> </div>
<div <div
v-if="mapsLoaded && maps.length === 0" v-if="mapsLoaded && maps.length === 0"
role="region"
aria-label="No maps available"
class="absolute inset-0 z-[500] flex flex-col items-center justify-center gap-4 bg-base-200/90 p-6" class="absolute inset-0 z-[500] flex flex-col items-center justify-center gap-4 bg-base-200/90 p-6"
> >
<icons-icon-map-pin class="size-12 text-base-content/40 shrink-0" aria-hidden="true" />
<p class="text-center text-lg">Map list is empty.</p> <p class="text-center text-lg">Map list is empty.</p>
<p class="text-center text-sm opacity-80"> <p class="text-center text-sm opacity-80">
Make sure you are logged in and at least one map exists in Admin (uncheck «Hidden» if needed). Make sure you are logged in and at least one map exists in Admin (uncheck «Hidden» if needed).

View File

@@ -13,6 +13,7 @@
:required="required" :required="required"
:autocomplete="autocomplete" :autocomplete="autocomplete"
:readonly="readonly" :readonly="readonly"
:aria-describedby="ariaDescribedby"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)" @input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/> />
<button <button
@@ -38,8 +39,9 @@ const props = withDefaults(
autocomplete?: string autocomplete?: string
readonly?: boolean readonly?: boolean
inputId?: string inputId?: string
ariaDescribedby?: string
}>(), }>(),
{ required: false, autocomplete: 'off', inputId: undefined } { required: false, autocomplete: 'off', inputId: undefined, ariaDescribedby: undefined }
) )
const emit = defineEmits<{ 'update:modelValue': [value: string] }>() const emit = defineEmits<{ 'update:modelValue': [value: string] }>()

View File

@@ -1,7 +1,7 @@
<template> <template>
<Teleport to="body"> <Teleport to="body">
<div <div
class="fixed bottom-4 right-4 z-[9998] flex flex-col gap-2 max-w-sm w-full pointer-events-none" class="fixed bottom-4 right-4 z-[9998] flex flex-col gap-2 w-full max-w-[calc(100vw-2rem)] sm:max-w-sm pointer-events-none"
aria-live="polite" aria-live="polite"
aria-label="Notifications" aria-label="Notifications"
> >

View File

@@ -1,7 +1,14 @@
<template> <template>
<dialog ref="modalRef" class="modal" @cancel="$emit('close')"> <dialog
ref="modalRef"
class="modal"
aria-modal="true"
:aria-labelledby="titleId"
@cancel="$emit('close')"
@close="onDialogClose"
>
<div class="modal-box transition-all duration-200" @click.stop> <div class="modal-box transition-all duration-200" @click.stop>
<h3 class="font-bold text-lg">{{ title }}</h3> <h3 :id="titleId" class="font-bold text-lg">{{ title }}</h3>
<div class="py-2"> <div class="py-2">
<label class="label py-0"><span>Name</span></label> <label class="label py-0"><span>Name</span></label>
<input <input
@@ -39,14 +46,17 @@ const emit = defineEmits<{
submit: [name: string] submit: [name: string]
}>() }>()
const titleId = computed(() => `bookmark-modal-title-${Math.random().toString(36).slice(2)}`)
const modalRef = ref<HTMLDialogElement | null>(null) const modalRef = ref<HTMLDialogElement | null>(null)
const inputRef = ref<HTMLInputElement | null>(null) const inputRef = ref<HTMLInputElement | null>(null)
const localName = ref(props.defaultName) const localName = ref(props.defaultName)
let previousActiveElement: HTMLElement | null = null
watch( watch(
() => props.open, () => props.open,
(open) => { (open) => {
if (open) { if (open) {
previousActiveElement = (import.meta.client ? document.activeElement : null) as HTMLElement | null
localName.value = props.defaultName || '' localName.value = props.defaultName || ''
nextTick(() => { nextTick(() => {
modalRef.value?.showModal() modalRef.value?.showModal()
@@ -59,6 +69,12 @@ watch(
{ immediate: true } { immediate: true }
) )
function onDialogClose() {
if (import.meta.client && previousActiveElement && typeof previousActiveElement.focus === 'function' && document.contains(previousActiveElement)) {
nextTick(() => previousActiveElement?.focus())
}
}
watch( watch(
() => props.defaultName, () => props.defaultName,
(name) => { (name) => {

View File

@@ -7,6 +7,7 @@
<div class="flex flex-col gap-1 max-h-40 overflow-y-auto"> <div class="flex flex-col gap-1 max-h-40 overflow-y-auto">
<template v-if="bookmarks.length === 0"> <template v-if="bookmarks.length === 0">
<p class="text-xs text-base-content/60 py-1">No saved locations.</p> <p class="text-xs text-base-content/60 py-1">No saved locations.</p>
<p class="text-xs text-base-content/50 py-0">Add current location or a selected quest giver below.</p>
</template> </template>
<template v-else> <template v-else>
<div <div

View File

@@ -150,7 +150,10 @@
@jump-to-marker="$emit('jumpToMarker', $event)" @jump-to-marker="$emit('jumpToMarker', $event)"
/> />
</div> </div>
<div class="p-3 border-t border-base-300 shrink-0 safe-area-pb"> <div class="p-3 border-t border-base-300 shrink-0 safe-area-pb flex flex-col gap-2">
<p class="text-xs text-base-content/50 text-center">
<kbd class="kbd kbd-xs">?</kbd> for shortcuts
</p>
<button <button
type="button" type="button"
class="btn btn-ghost btn-block touch-manipulation min-h-12" class="btn btn-ghost btn-block touch-manipulation min-h-12"

View File

@@ -119,6 +119,9 @@
:selected-marker-for-bookmark="selectedMarkerForBookmark ?? null" :selected-marker-for-bookmark="selectedMarkerForBookmark ?? null"
:touch-friendly="touchFriendly" :touch-friendly="touchFriendly"
/> />
<p class="text-xs text-base-content/50 mt-2">
<kbd class="kbd kbd-xs">?</kbd> for shortcuts
</p>
</div> </div>
</template> </template>

View File

@@ -1,10 +1,17 @@
<template> <template>
<dialog ref="modalRef" class="modal" @cancel="$emit('close')"> <dialog
ref="modalRef"
class="modal"
aria-modal="true"
aria-labelledby="coord-set-modal-title"
@cancel="$emit('close')"
@close="onDialogClose"
>
<div class="modal-box transition-all duration-200" @click.stop> <div class="modal-box transition-all duration-200" @click.stop>
<h3 class="font-bold text-lg">Rewrite tile coords</h3> <h3 id="coord-set-modal-title" class="font-bold text-lg">Rewrite tile coords</h3>
<p class="py-2">From ({{ coordSetFrom.x }}, {{ coordSetFrom.y }}) to:</p> <p class="py-2">From ({{ coordSetFrom.x }}, {{ coordSetFrom.y }}) to:</p>
<div class="flex gap-2"> <div class="flex gap-2">
<input v-model.number="localTo.x" type="number" class="input flex-1" placeholder="X" /> <input ref="firstInputRef" v-model.number="localTo.x" type="number" class="input flex-1" placeholder="X" />
<input v-model.number="localTo.y" type="number" class="input flex-1" placeholder="Y" /> <input v-model.number="localTo.y" type="number" class="input flex-1" placeholder="Y" />
</div> </div>
<div class="modal-action"> <div class="modal-action">
@@ -31,14 +38,20 @@ const emit = defineEmits<{
}>() }>()
const modalRef = ref<HTMLDialogElement | null>(null) const modalRef = ref<HTMLDialogElement | null>(null)
const firstInputRef = ref<HTMLInputElement | null>(null)
const localTo = ref({ x: props.coordSet.x, y: props.coordSet.y }) const localTo = ref({ x: props.coordSet.x, y: props.coordSet.y })
let previousActiveElement: HTMLElement | null = null
watch( watch(
() => props.open, () => props.open,
(open) => { (open) => {
if (open) { if (open) {
previousActiveElement = (import.meta.client ? document.activeElement : null) as HTMLElement | null
localTo.value = { x: props.coordSet.x, y: props.coordSet.y } localTo.value = { x: props.coordSet.x, y: props.coordSet.y }
nextTick(() => modalRef.value?.showModal()) nextTick(() => {
modalRef.value?.showModal()
firstInputRef.value?.focus()
})
} else { } else {
modalRef.value?.close() modalRef.value?.close()
} }
@@ -46,6 +59,12 @@ watch(
{ immediate: true } { immediate: true }
) )
function onDialogClose() {
if (import.meta.client && previousActiveElement && typeof previousActiveElement.focus === 'function' && document.contains(previousActiveElement)) {
nextTick(() => previousActiveElement?.focus())
}
}
watch( watch(
() => props.coordSet, () => props.coordSet,
(c) => { (c) => {

View File

@@ -39,6 +39,7 @@ function copyShareUrl() {
if (import.meta.client && navigator.clipboard?.writeText) { if (import.meta.client && navigator.clipboard?.writeText) {
navigator.clipboard.writeText(url).then(() => { navigator.clipboard.writeText(url).then(() => {
copied.value = true copied.value = true
useToast().success('Link copied')
if (copyTimeout) clearTimeout(copyTimeout) if (copyTimeout) clearTimeout(copyTimeout)
copyTimeout = setTimeout(() => { copyTimeout = setTimeout(() => {
copied.value = false copied.value = false

View File

@@ -12,6 +12,10 @@
class="input input-sm w-full pr-8 focus:ring-2 focus:ring-primary" class="input input-sm w-full pr-8 focus:ring-2 focus:ring-primary"
:class="touchFriendly ? 'min-h-11 text-base' : ''" :class="touchFriendly ? 'min-h-11 text-base' : ''"
placeholder="Coords (x, y) or marker name" placeholder="Coords (x, y) or marker name"
aria-label="Search by coordinates or marker name"
aria-expanded="showDropdown && (suggestions.length > 0 || recent.recent.length > 0)"
aria-haspopup="listbox"
aria-controls="map-search-listbox"
autocomplete="off" autocomplete="off"
@focus="showDropdown = true" @focus="showDropdown = true"
@blur="scheduleCloseDropdown" @blur="scheduleCloseDropdown"
@@ -30,15 +34,21 @@
</button> </button>
<div <div
v-if="showDropdown && (suggestions.length > 0 || recent.recent.length > 0)" v-if="showDropdown && (suggestions.length > 0 || recent.recent.length > 0)"
id="map-search-listbox"
role="listbox"
class="absolute left-0 right-0 top-full z-[600] mt-1 max-h-56 overflow-y-auto rounded-lg border border-base-300 bg-base-100 shadow-xl py-1" class="absolute left-0 right-0 top-full z-[600] mt-1 max-h-56 overflow-y-auto rounded-lg border border-base-300 bg-base-100 shadow-xl py-1"
:aria-activedescendant="suggestions.length > 0 ? `map-search-opt-${highlightIndex}` : recent.recent.length > 0 ? `map-search-recent-${highlightIndex}` : undefined"
> >
<template v-if="suggestions.length > 0"> <template v-if="suggestions.length > 0">
<button <button
v-for="(s, i) in suggestions" v-for="(s, i) in suggestions"
:id="`map-search-opt-${i}`"
:key="s.key" :key="s.key"
type="button" type="button"
role="option"
class="w-full text-left px-3 py-2 text-sm hover:bg-base-200 flex items-center gap-2" class="w-full text-left px-3 py-2 text-sm hover:bg-base-200 flex items-center gap-2"
:class="{ 'bg-primary/10': i === highlightIndex }" :class="{ 'bg-primary/10': i === highlightIndex }"
:aria-selected="i === highlightIndex"
@mousedown.prevent="goToSuggestion(s)" @mousedown.prevent="goToSuggestion(s)"
> >
<icons-icon-map-pin class="size-4 shrink-0 opacity-70" /> <icons-icon-map-pin class="size-4 shrink-0 opacity-70" />
@@ -49,10 +59,13 @@
<p class="px-3 py-1 text-xs text-base-content/60 uppercase tracking-wider">Recent</p> <p class="px-3 py-1 text-xs text-base-content/60 uppercase tracking-wider">Recent</p>
<button <button
v-for="(r, i) in recent.recent" v-for="(r, i) in recent.recent"
:id="`map-search-recent-${i}`"
:key="r.at" :key="r.at"
type="button" type="button"
role="option"
class="w-full text-left px-3 py-2 text-sm hover:bg-base-200 flex items-center gap-2" class="w-full text-left px-3 py-2 text-sm hover:bg-base-200 flex items-center gap-2"
:class="{ 'bg-primary/10': i === highlightIndex }" :class="{ 'bg-primary/10': i === highlightIndex }"
:aria-selected="i === highlightIndex"
@mousedown.prevent="goToRecent(r)" @mousedown.prevent="goToRecent(r)"
> >
<icons-icon-map-pin class="size-4 shrink-0 opacity-70" /> <icons-icon-map-pin class="size-4 shrink-0 opacity-70" />

View File

@@ -1,6 +1,18 @@
<template> <template>
<div class="drawer 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" /> <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"> <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"> <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"> <NuxtLink to="/" class="flex items-center gap-2 text-lg font-semibold hover:opacity-80 transition-all duration-200">
@@ -91,6 +103,7 @@
</template> </template>
<!-- Mobile: hamburger to open drawer --> <!-- Mobile: hamburger to open drawer -->
<label <label
ref="hamburgerRef"
for="nav-drawer" for="nav-drawer"
class="btn btn-ghost btn-square md:hidden flex items-center justify-center min-h-[2.75rem] min-w-[2.75rem]" 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" aria-label="Open menu"
@@ -98,7 +111,7 @@
<icons-icon-menu class="size-6" /> <icons-icon-menu class="size-6" />
</label> </label>
</header> </header>
<main class="flex-1 min-h-0 overflow-y-auto relative flex flex-col"> <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'" /> <AdminBreadcrumbs v-if="route.path.startsWith('/admin') && route.path !== '/admin'" />
<div class="flex-1 min-h-0"> <div class="flex-1 min-h-0">
<slot /> <slot />
@@ -107,8 +120,15 @@
<ToastContainer /> <ToastContainer />
</div> </div>
<!-- Drawer sidebar (mobile menu) --> <!-- Drawer sidebar (mobile menu) -->
<div class="drawer-side z-[1100]"> <div ref="drawerSideRef" class="drawer-side z-[1100]">
<label for="nav-drawer" aria-label="Close menu" class="drawer-overlay" /> <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"> <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 --> <!-- 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 v-if="!isLogin && me" class="flex items-center gap-3 pb-4 mb-2 border-b border-base-300/50">
@@ -197,6 +217,9 @@ const live = useState<boolean>('mapLive', () => false)
const me = useState<MeResponse | null>('me', () => null) const me = useState<MeResponse | null>('me', () => null)
const userDropdownRef = ref<HTMLDetailsElement | null>(null) const userDropdownRef = ref<HTMLDetailsElement | null>(null)
const drawerCheckboxRef = ref<HTMLInputElement | 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() { function closeDropdown() {
userDropdownRef.value?.removeAttribute('open') userDropdownRef.value?.removeAttribute('open')
@@ -206,6 +229,51 @@ function closeDrawer() {
if (drawerCheckboxRef.value) drawerCheckboxRef.value.checked = false 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 { isLoginPath } = useAppPaths()
const isLogin = computed(() => isLoginPath(route.path)) const isLogin = computed(() => isLoginPath(route.path))
const isAdmin = computed(() => !!me.value?.auths?.includes('admin')) const isAdmin = computed(() => !!me.value?.auths?.includes('admin'))
@@ -266,4 +334,11 @@ async function doLogout() {
await router.push('/login') await router.push('/login')
me.value = null me.value = null
} }
onBeforeUnmount(() => {
if (drawerTrapCleanup) {
drawerTrapCleanup()
drawerTrapCleanup = null
}
})
</script> </script>

View File

@@ -106,6 +106,8 @@ export function createMarker(
const position = mapview.map.unproject([marker.position.x, marker.position.y], HnHMaxZoom) const position = mapview.map.unproject([marker.position.x, marker.position.y], HnHMaxZoom)
leafletMarker = L.marker(position, { icon, title: marker.name }) leafletMarker = L.marker(position, { icon, title: marker.name })
leafletMarker.addTo(mapview.markerLayer) leafletMarker.addTo(mapview.markerLayer)
const markerEl = (leafletMarker as unknown as { getElement?: () => HTMLElement }).getElement?.()
if (markerEl) markerEl.setAttribute('aria-label', marker.name)
leafletMarker.on('click', (e: L.LeafletMouseEvent) => { leafletMarker.on('click', (e: L.LeafletMouseEvent) => {
if (onClick) onClick(e) if (onClick) onClick(e)
}) })

File diff suppressed because it is too large Load Diff

View File

@@ -107,21 +107,21 @@
<table class="table table-sm table-zebra min-w-[32rem]"> <table class="table table-sm table-zebra min-w-[32rem]">
<thead> <thead>
<tr> <tr>
<th> <th scope="col">
<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')"> <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 ID
<span v-if="mapsSortBy === 'ID'" class="opacity-70">{{ mapsSortDir === 'asc' ? '' : '' }}</span> <span v-if="mapsSortBy === 'ID'" class="opacity-70">{{ mapsSortDir === 'asc' ? '' : '' }}</span>
</button> </button>
</th> </th>
<th> <th scope="col">
<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')"> <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 Name
<span v-if="mapsSortBy === 'Name'" class="opacity-70">{{ mapsSortDir === 'asc' ? '' : '' }}</span> <span v-if="mapsSortBy === 'Name'" class="opacity-70">{{ mapsSortDir === 'asc' ? '' : '' }}</span>
</button> </button>
</th> </th>
<th>Hidden</th> <th scope="col">Hidden</th>
<th>Priority</th> <th scope="col">Priority</th>
<th class="text-right"></th> <th scope="col" class="text-right"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -241,8 +241,10 @@
<div <div
v-if="rebuilding || merging || wiping" v-if="rebuilding || merging || wiping"
class="fixed inset-0 z-50 flex items-center justify-center bg-base-100/80 backdrop-blur-sm" class="fixed inset-0 z-50 flex items-center justify-center bg-base-100/80 backdrop-blur-sm"
role="status"
aria-live="polite" aria-live="polite"
aria-busy="true" aria-busy="true"
:aria-label="rebuilding ? 'Rebuilding zooms in progress' : merging ? 'Merging in progress' : 'Wiping data in progress'"
> >
<div class="flex flex-col items-center gap-4 p-6 rounded-xl bg-base-200 border border-base-300 shadow-2xl"> <div class="flex flex-col items-center gap-4 p-6 rounded-xl bg-base-200 border border-base-300 shadow-2xl">
<span class="loading loading-spinner loading-lg text-primary" /> <span class="loading loading-spinner loading-lg text-primary" />
@@ -279,6 +281,7 @@
import type { MapInfoAdmin } from '~/types/api' import type { MapInfoAdmin } from '~/types/api'
definePageMeta({ middleware: 'admin' }) definePageMeta({ middleware: 'admin' })
useHead({ title: 'Admin HnH Map' })
const api = useMapApi() const api = useMapApi()
const toast = useToast() const toast = useToast()

View File

@@ -40,6 +40,7 @@
import type { MapInfoAdmin } from '~/types/api' import type { MapInfoAdmin } from '~/types/api'
definePageMeta({ middleware: 'admin' }) definePageMeta({ middleware: 'admin' })
useHead({ title: 'Edit map HnH Map' })
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()

View File

@@ -14,10 +14,12 @@
:readonly="!isNew" :readonly="!isNew"
/> />
</fieldset> </fieldset>
<p id="admin-user-password-hint" class="text-sm text-base-content/60 mb-1">Leave blank to keep current password.</p>
<PasswordInput <PasswordInput
v-model="form.pass" v-model="form.pass"
label="Password (leave blank to keep)" label="Password"
autocomplete="new-password" autocomplete="new-password"
aria-describedby="admin-user-password-hint"
/> />
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label">Auths</label> <label class="label">Auths</label>
@@ -61,6 +63,7 @@
<script setup lang="ts"> <script setup lang="ts">
definePageMeta({ middleware: 'admin' }) definePageMeta({ middleware: 'admin' })
useHead({ title: 'User HnH Map' })
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()

View File

@@ -18,6 +18,7 @@
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label" for="user">User</label> <label class="label" for="user">User</label>
<input <input
ref="userInputRef"
id="user" id="user"
v-model="user" v-model="user"
type="text" type="text"
@@ -44,6 +45,8 @@
<script setup lang="ts"> <script setup lang="ts">
// No auth required; auth.global skips this path // No auth required; auth.global skips this path
useHead({ title: 'Login HnH Map' })
const user = ref('') const user = ref('')
const pass = ref('') const pass = ref('')
const oauthProviders = ref<string[]>([]) const oauthProviders = ref<string[]>([])
@@ -54,8 +57,13 @@ const { loading, error, run } = useFormSubmit('Login failed')
const redirect = computed(() => (route.query.redirect as string) || '') const redirect = computed(() => (route.query.redirect as string) || '')
const userInputRef = ref<HTMLInputElement | null>(null)
onMounted(async () => { onMounted(async () => {
oauthProviders.value = await api.oauthProviders() oauthProviders.value = await api.oauthProviders()
if (import.meta.client && window.matchMedia('(pointer: fine)').matches) {
nextTick(() => userInputRef.value?.focus())
}
}) })
async function submit() { async function submit() {

View File

@@ -48,6 +48,8 @@
placeholder="email@example.com" placeholder="email@example.com"
class="input input-bordered input-sm w-full max-w-xs" class="input input-bordered input-sm w-full max-w-xs"
autocomplete="email" autocomplete="email"
:aria-describedby="emailError ? 'profile-email-error' : undefined"
:aria-invalid="!!emailError"
> >
<button type="submit" class="btn btn-primary btn-sm" :disabled="loadingEmail"> <button type="submit" class="btn btn-primary btn-sm" :disabled="loadingEmail">
{{ loadingEmail ? '…' : 'Save' }} {{ loadingEmail ? '…' : 'Save' }}
@@ -55,7 +57,7 @@
<button type="button" class="btn btn-ghost btn-sm" :disabled="loadingEmail" @click="cancelEditEmail"> <button type="button" class="btn btn-ghost btn-sm" :disabled="loadingEmail" @click="cancelEditEmail">
Cancel Cancel
</button> </button>
<p v-if="emailError" class="text-error text-sm w-full">{{ emailError }}</p> <p v-if="emailError" id="profile-email-error" class="text-error text-sm w-full" role="alert">{{ emailError }}</p>
</form> </form>
</div> </div>
</div> </div>
@@ -130,6 +132,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { MeResponse } from '~/types/api' import type { MeResponse } from '~/types/api'
useHead({ title: 'Profile HnH Map' })
const api = useMapApi() const api = useMapApi()
const toast = useToast() const toast = useToast()
const initialLoad = ref(true) const initialLoad = ref(true)

View File

@@ -20,13 +20,15 @@
</button> </button>
</form> </form>
</AuthCard> </AuthCard>
<NuxtLink to="/" class="link link-hover underline underline-offset-2 mt-4 text-primary">Map</NuxtLink> <NuxtLink to="/" class="btn btn-ghost btn-sm mt-4">Map</NuxtLink>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// No auth required; auth.global skips this path // No auth required; auth.global skips this path
useHead({ title: 'Setup HnH Map' })
const pass = ref('') const pass = ref('')
const router = useRouter() const router = useRouter()
const api = useMapApi() const api = useMapApi()