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:
@@ -17,7 +17,7 @@ services:
|
||||
frontend:
|
||||
image: node:20-alpine
|
||||
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:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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">
|
||||
<h2 :id="titleId" class="font-bold text-lg mb-2">{{ title }}</h2>
|
||||
<p>{{ message }}</p>
|
||||
@@ -44,11 +44,13 @@ const emit = defineEmits<{
|
||||
|
||||
const titleId = computed(() => `confirm-modal-title-${Math.random().toString(36).slice(2)}`)
|
||||
const dialogRef = ref<HTMLDialogElement | null>(null)
|
||||
let previousActiveElement: HTMLElement | null = null
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) {
|
||||
previousActiveElement = (import.meta.client ? document.activeElement : null) as HTMLElement | null
|
||||
dialogRef.value?.showModal()
|
||||
} else {
|
||||
dialogRef.value?.close()
|
||||
@@ -63,6 +65,9 @@ function cancel() {
|
||||
|
||||
function onClose() {
|
||||
emit('update:modelValue', false)
|
||||
if (import.meta.client && previousActiveElement && typeof previousActiveElement.focus === 'function' && document.contains(previousActiveElement)) {
|
||||
nextTick(() => previousActiveElement?.focus())
|
||||
}
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
>
|
||||
<p class="text-center text-lg font-medium">Map failed to load</p>
|
||||
<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>
|
||||
<button type="button" class="btn btn-primary" @click="reload">
|
||||
Reload page
|
||||
|
||||
@@ -16,8 +16,11 @@
|
||||
</div>
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<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-sm opacity-80">
|
||||
Make sure you are logged in and at least one map exists in Admin (uncheck «Hidden» if needed).
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
:required="required"
|
||||
:autocomplete="autocomplete"
|
||||
:readonly="readonly"
|
||||
:aria-describedby="ariaDescribedby"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<button
|
||||
@@ -38,8 +39,9 @@ const props = withDefaults(
|
||||
autocomplete?: string
|
||||
readonly?: boolean
|
||||
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] }>()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<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-label="Notifications"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<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>
|
||||
<h3 class="font-bold text-lg">{{ title }}</h3>
|
||||
<h3 :id="titleId" class="font-bold text-lg">{{ title }}</h3>
|
||||
<div class="py-2">
|
||||
<label class="label py-0"><span>Name</span></label>
|
||||
<input
|
||||
@@ -39,14 +46,17 @@ const emit = defineEmits<{
|
||||
submit: [name: string]
|
||||
}>()
|
||||
|
||||
const titleId = computed(() => `bookmark-modal-title-${Math.random().toString(36).slice(2)}`)
|
||||
const modalRef = ref<HTMLDialogElement | null>(null)
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const localName = ref(props.defaultName)
|
||||
let previousActiveElement: HTMLElement | null = null
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
previousActiveElement = (import.meta.client ? document.activeElement : null) as HTMLElement | null
|
||||
localName.value = props.defaultName || ''
|
||||
nextTick(() => {
|
||||
modalRef.value?.showModal()
|
||||
@@ -59,6 +69,12 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function onDialogClose() {
|
||||
if (import.meta.client && previousActiveElement && typeof previousActiveElement.focus === 'function' && document.contains(previousActiveElement)) {
|
||||
nextTick(() => previousActiveElement?.focus())
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.defaultName,
|
||||
(name) => {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<div class="flex flex-col gap-1 max-h-40 overflow-y-auto">
|
||||
<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/50 py-0">Add current location or a selected quest giver below.</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
|
||||
@@ -150,7 +150,10 @@
|
||||
@jump-to-marker="$emit('jumpToMarker', $event)"
|
||||
/>
|
||||
</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
|
||||
type="button"
|
||||
class="btn btn-ghost btn-block touch-manipulation min-h-12"
|
||||
|
||||
@@ -119,6 +119,9 @@
|
||||
:selected-marker-for-bookmark="selectedMarkerForBookmark ?? null"
|
||||
:touch-friendly="touchFriendly"
|
||||
/>
|
||||
<p class="text-xs text-base-content/50 mt-2">
|
||||
<kbd class="kbd kbd-xs">?</kbd> for shortcuts
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
<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>
|
||||
<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>
|
||||
<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" />
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
@@ -31,14 +38,20 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const modalRef = ref<HTMLDialogElement | null>(null)
|
||||
const firstInputRef = ref<HTMLInputElement | null>(null)
|
||||
const localTo = ref({ x: props.coordSet.x, y: props.coordSet.y })
|
||||
let previousActiveElement: HTMLElement | null = null
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) {
|
||||
previousActiveElement = (import.meta.client ? document.activeElement : null) as HTMLElement | null
|
||||
localTo.value = { x: props.coordSet.x, y: props.coordSet.y }
|
||||
nextTick(() => modalRef.value?.showModal())
|
||||
nextTick(() => {
|
||||
modalRef.value?.showModal()
|
||||
firstInputRef.value?.focus()
|
||||
})
|
||||
} else {
|
||||
modalRef.value?.close()
|
||||
}
|
||||
@@ -46,6 +59,12 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function onDialogClose() {
|
||||
if (import.meta.client && previousActiveElement && typeof previousActiveElement.focus === 'function' && document.contains(previousActiveElement)) {
|
||||
nextTick(() => previousActiveElement?.focus())
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.coordSet,
|
||||
(c) => {
|
||||
|
||||
@@ -39,6 +39,7 @@ function copyShareUrl() {
|
||||
if (import.meta.client && navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
copied.value = true
|
||||
useToast().success('Link copied')
|
||||
if (copyTimeout) clearTimeout(copyTimeout)
|
||||
copyTimeout = setTimeout(() => {
|
||||
copied.value = false
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
class="input input-sm w-full pr-8 focus:ring-2 focus:ring-primary"
|
||||
:class="touchFriendly ? 'min-h-11 text-base' : ''"
|
||||
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"
|
||||
@focus="showDropdown = true"
|
||||
@blur="scheduleCloseDropdown"
|
||||
@@ -30,15 +34,21 @@
|
||||
</button>
|
||||
<div
|
||||
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"
|
||||
:aria-activedescendant="suggestions.length > 0 ? `map-search-opt-${highlightIndex}` : recent.recent.length > 0 ? `map-search-recent-${highlightIndex}` : undefined"
|
||||
>
|
||||
<template v-if="suggestions.length > 0">
|
||||
<button
|
||||
v-for="(s, i) in suggestions"
|
||||
:id="`map-search-opt-${i}`"
|
||||
:key="s.key"
|
||||
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="{ 'bg-primary/10': i === highlightIndex }"
|
||||
:aria-selected="i === highlightIndex"
|
||||
@mousedown.prevent="goToSuggestion(s)"
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
v-for="(r, i) in recent.recent"
|
||||
:id="`map-search-recent-${i}`"
|
||||
:key="r.at"
|
||||
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="{ 'bg-primary/10': i === highlightIndex }"
|
||||
:aria-selected="i === highlightIndex"
|
||||
@mousedown.prevent="goToRecent(r)"
|
||||
>
|
||||
<icons-icon-map-pin class="size-4 shrink-0 opacity-70" />
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<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" />
|
||||
<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">
|
||||
@@ -91,6 +103,7 @@
|
||||
</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"
|
||||
@@ -98,7 +111,7 @@
|
||||
<icons-icon-menu class="size-6" />
|
||||
</label>
|
||||
</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'" />
|
||||
<div class="flex-1 min-h-0">
|
||||
<slot />
|
||||
@@ -107,8 +120,15 @@
|
||||
<ToastContainer />
|
||||
</div>
|
||||
<!-- Drawer sidebar (mobile menu) -->
|
||||
<div class="drawer-side z-[1100]">
|
||||
<label for="nav-drawer" aria-label="Close menu" class="drawer-overlay" />
|
||||
<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">
|
||||
@@ -197,6 +217,9 @@ 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')
|
||||
@@ -206,6 +229,51 @@ 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'))
|
||||
@@ -266,4 +334,11 @@ async function doLogout() {
|
||||
await router.push('/login')
|
||||
me.value = null
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (drawerTrapCleanup) {
|
||||
drawerTrapCleanup()
|
||||
drawerTrapCleanup = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -106,6 +106,8 @@ export function createMarker(
|
||||
const position = mapview.map.unproject([marker.position.x, marker.position.y], HnHMaxZoom)
|
||||
leafletMarker = L.marker(position, { icon, title: marker.name })
|
||||
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) => {
|
||||
if (onClick) onClick(e)
|
||||
})
|
||||
|
||||
3631
frontend-nuxt/package-lock.json
generated
3631
frontend-nuxt/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -107,21 +107,21 @@
|
||||
<table class="table table-sm table-zebra min-w-[32rem]">
|
||||
<thead>
|
||||
<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')">
|
||||
ID
|
||||
<span v-if="mapsSortBy === 'ID'" class="opacity-70">{{ mapsSortDir === 'asc' ? '↑' : '↓' }}</span>
|
||||
</button>
|
||||
</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')">
|
||||
Name
|
||||
<span v-if="mapsSortBy === 'Name'" class="opacity-70">{{ mapsSortDir === 'asc' ? '↑' : '↓' }}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th>Hidden</th>
|
||||
<th>Priority</th>
|
||||
<th class="text-right"></th>
|
||||
<th scope="col">Hidden</th>
|
||||
<th scope="col">Priority</th>
|
||||
<th scope="col" class="text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -241,8 +241,10 @@
|
||||
<div
|
||||
v-if="rebuilding || merging || wiping"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-base-100/80 backdrop-blur-sm"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
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">
|
||||
<span class="loading loading-spinner loading-lg text-primary" />
|
||||
@@ -279,6 +281,7 @@
|
||||
import type { MapInfoAdmin } from '~/types/api'
|
||||
|
||||
definePageMeta({ middleware: 'admin' })
|
||||
useHead({ title: 'Admin – HnH Map' })
|
||||
|
||||
const api = useMapApi()
|
||||
const toast = useToast()
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
import type { MapInfoAdmin } from '~/types/api'
|
||||
|
||||
definePageMeta({ middleware: 'admin' })
|
||||
useHead({ title: 'Edit map – HnH Map' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
:readonly="!isNew"
|
||||
/>
|
||||
</fieldset>
|
||||
<p id="admin-user-password-hint" class="text-sm text-base-content/60 mb-1">Leave blank to keep current password.</p>
|
||||
<PasswordInput
|
||||
v-model="form.pass"
|
||||
label="Password (leave blank to keep)"
|
||||
label="Password"
|
||||
autocomplete="new-password"
|
||||
aria-describedby="admin-user-password-hint"
|
||||
/>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label">Auths</label>
|
||||
@@ -61,6 +63,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ middleware: 'admin' })
|
||||
useHead({ title: 'User – HnH Map' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<fieldset class="fieldset">
|
||||
<label class="label" for="user">User</label>
|
||||
<input
|
||||
ref="userInputRef"
|
||||
id="user"
|
||||
v-model="user"
|
||||
type="text"
|
||||
@@ -44,6 +45,8 @@
|
||||
<script setup lang="ts">
|
||||
// No auth required; auth.global skips this path
|
||||
|
||||
useHead({ title: 'Login – HnH Map' })
|
||||
|
||||
const user = ref('')
|
||||
const pass = ref('')
|
||||
const oauthProviders = ref<string[]>([])
|
||||
@@ -54,8 +57,13 @@ const { loading, error, run } = useFormSubmit('Login failed')
|
||||
|
||||
const redirect = computed(() => (route.query.redirect as string) || '')
|
||||
|
||||
const userInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
oauthProviders.value = await api.oauthProviders()
|
||||
if (import.meta.client && window.matchMedia('(pointer: fine)').matches) {
|
||||
nextTick(() => userInputRef.value?.focus())
|
||||
}
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
placeholder="email@example.com"
|
||||
class="input input-bordered input-sm w-full max-w-xs"
|
||||
autocomplete="email"
|
||||
:aria-describedby="emailError ? 'profile-email-error' : undefined"
|
||||
:aria-invalid="!!emailError"
|
||||
>
|
||||
<button type="submit" class="btn btn-primary btn-sm" :disabled="loadingEmail">
|
||||
{{ loadingEmail ? '…' : 'Save' }}
|
||||
@@ -55,7 +57,7 @@
|
||||
<button type="button" class="btn btn-ghost btn-sm" :disabled="loadingEmail" @click="cancelEditEmail">
|
||||
Cancel
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,6 +132,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { MeResponse } from '~/types/api'
|
||||
|
||||
useHead({ title: 'Profile – HnH Map' })
|
||||
|
||||
const api = useMapApi()
|
||||
const toast = useToast()
|
||||
const initialLoad = ref(true)
|
||||
|
||||
@@ -20,13 +20,15 @@
|
||||
</button>
|
||||
</form>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// No auth required; auth.global skips this path
|
||||
|
||||
useHead({ title: 'Setup – HnH Map' })
|
||||
|
||||
const pass = ref('')
|
||||
const router = useRouter()
|
||||
const api = useMapApi()
|
||||
|
||||
Reference in New Issue
Block a user