Files
hnh-map/frontend-nuxt/pages/admin/index.vue
Nikolay Tatarinov adfdfd01c4 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.
2026-03-04 01:00:56 +03:00

446 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<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">
<div class="card card-app card-bg-base-200 mb-6">
<div class="card-body">
<Skeleton class="h-6 w-24 mb-4" />
<div class="flex flex-col gap-2">
<Skeleton v-for="i in 3" :key="i" class="h-12 w-full" />
<Skeleton class="h-9 w-28 mt-2" />
</div>
</div>
</div>
<div class="card card-app card-bg-base-200 mb-6">
<div class="card-body">
<Skeleton class="h-6 w-20 mb-4" />
<div class="overflow-x-auto">
<Skeleton class="h-32 w-full" />
</div>
</div>
</div>
<div class="card card-app card-bg-base-200 mb-6">
<div class="card-body">
<Skeleton class="h-6 w-16 mb-4" />
<div class="flex flex-col gap-3">
<Skeleton class="h-10 w-48" />
<Skeleton class="h-10 w-48" />
<Skeleton class="h-8 w-36" />
</div>
</div>
</div>
</template>
<template v-else>
<!-- Stats -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<div class="stat bg-base-200 rounded-xl shadow border border-base-300/50 py-4 px-4">
<div class="stat-title text-xs">Users</div>
<div class="stat-value text-2xl">{{ users.length }}</div>
</div>
<div class="stat bg-base-200 rounded-xl shadow border border-base-300/50 py-4 px-4">
<div class="stat-title text-xs">Maps</div>
<div class="stat-value text-2xl">{{ maps.length }}</div>
</div>
</div>
<div class="card card-app card-bg-base-200 mb-6 transition-all duration-200">
<div class="card-body">
<h2 class="card-title gap-2">
<icons-icon-users />
Users
</h2>
<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 min-h-11 touch-manipulation"
aria-label="Search users"
/>
</div>
<div class="flex flex-col gap-2">
<div
v-for="u in filteredUsers"
:key="u"
class="flex items-center justify-between gap-3 w-full p-3 rounded-lg bg-base-300/50 hover:bg-base-300/70 transition-colors"
>
<div class="flex items-center gap-2 min-w-0">
<div class="avatar avatar-placeholder">
<div class="bg-neutral text-neutral-content rounded-full w-8">
<span class="text-xs">{{ u[0]?.toUpperCase() }}</span>
</div>
</div>
<span class="font-medium truncate">{{ u }}</span>
</div>
<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 min-h-11 touch-manipulation">
<icons-icon-plus /> Add user
</NuxtLink>
</div>
</div>
</div>
<div class="card card-app card-bg-base-200 mb-6 transition-all duration-200">
<div class="card-body">
<h2 class="card-title gap-2">
<icons-icon-map-pin />
Maps
</h2>
<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 min-h-11 touch-manipulation"
aria-label="Search maps"
/>
</div>
<div class="overflow-x-auto">
<table class="table table-sm table-zebra min-w-[32rem]">
<thead>
<tr>
<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 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 scope="col">Hidden</th>
<th scope="col">Priority</th>
<th scope="col" class="text-right"></th>
</tr>
</thead>
<tbody>
<tr v-for="map in sortedFilteredMaps" :key="map.ID" class="hover:bg-base-300">
<td>{{ map.ID }}</td>
<td>{{ map.Name }}</td>
<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 min-h-9 touch-manipulation">
<icons-icon-pencil /> Edit
</NuxtLink>
</td>
</tr>
<tr v-if="!sortedFilteredMaps.length">
<td colspan="5" class="text-base-content/60">{{ mapSearch ? 'No maps match.' : 'No maps.' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="card card-app card-bg-base-200 mb-6 transition-all duration-200">
<div class="card-body">
<h2 class="card-title gap-2">
<icons-icon-settings />
Settings
</h2>
<div class="flex flex-col gap-4">
<fieldset class="fieldset w-full max-w-xs">
<label class="label" for="admin-settings-prefix">Prefix</label>
<input
id="admin-settings-prefix"
v-model="settings.prefix"
type="text"
class="input input-sm w-full min-h-11 touch-manipulation"
/>
</fieldset>
<fieldset class="fieldset w-full max-w-xs">
<label class="label" for="admin-settings-title">Title</label>
<input
id="admin-settings-title"
v-model="settings.title"
type="text"
class="input input-sm w-full min-h-11 touch-manipulation"
/>
</fieldset>
<fieldset class="fieldset">
<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"
type="checkbox"
class="checkbox checkbox-sm"
/>
Default hide new maps
</label>
</fieldset>
</div>
<div class="flex justify-end mt-2">
<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>
</div>
</div>
</div>
<div class="card card-app card-bg-base-200 mb-6 transition-all duration-200">
<div class="card-body">
<h2 class="card-title gap-2">
<icons-icon-alert-triangle />
Actions
</h2>
<div class="flex flex-col gap-3">
<div>
<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 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>
</div>
<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 min-h-11 touch-manipulation" @click="mergeFileRef?.click()">
Choose merge file
</button>
<span class="text-sm text-base-content/70">
{{ mergeFile ? mergeFile.name : 'No file chosen' }}
</span>
</div>
<form @submit.prevent="doMerge">
<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>
</form>
</div>
<div class="border-t border-error/30 pt-4 mt-1 bg-error/5 rounded-lg p-3 -mx-1">
<p class="text-sm font-medium text-error mb-2">Danger zone</p>
<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>
</div>
</div>
</div>
</div>
<!-- Progress indicator for long operations -->
<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" />
<p class="font-medium">
{{ rebuilding ? 'Rebuilding zooms…' : merging ? 'Merging…' : 'Wiping data…' }}
</p>
<p class="text-sm text-base-content/70">This may take a moment.</p>
</div>
</div>
<ConfirmModal
v-model="showWipeModal"
title="Confirm wipe"
message="Wipe all grids, markers, tiles and maps? This cannot be undone."
confirm-label="Wipe"
:danger="true"
:loading="wiping"
@confirm="doWipe"
/>
<ConfirmModal
v-model="showRebuildModal"
title="Rebuild zooms"
message="Rebuild tile zoom levels for all maps? This may take a while."
confirm-label="Rebuild"
:loading="rebuilding"
@confirm="doRebuildZooms"
/>
</template>
</div>
</template>
<script setup lang="ts">
import type { MapInfoAdmin } from '~/types/api'
definePageMeta({ middleware: 'admin' })
useHead({ title: 'Admin HnH Map' })
const api = useMapApi()
const toast = useToast()
const users = ref<string[]>([])
const maps = ref<MapInfoAdmin[]>([])
const settings = ref({ prefix: '', defaultHide: false, title: '' })
const savingSettings = ref(false)
const rebuilding = ref(false)
const wiping = ref(false)
const merging = ref(false)
const mergeFile = ref<File | null>(null)
const mergeFileRef = ref<HTMLInputElement | null>(null)
const showWipeModal = ref(false)
const showRebuildModal = ref(false)
const loading = ref(true)
const userSearch = ref('')
const mapSearch = ref('')
const mapsSortBy = ref<'ID' | 'Name'>('ID')
const mapsSortDir = ref<'asc' | 'desc'>('asc')
const filteredUsers = computed(() => {
const q = userSearch.value.trim().toLowerCase()
if (!q) return users.value
return users.value.filter((u) => u.toLowerCase().includes(q))
})
const filteredMaps = computed(() => {
const q = mapSearch.value.trim().toLowerCase()
if (!q) return maps.value
return maps.value.filter(
(m) =>
m.Name.toLowerCase().includes(q) ||
String(m.ID).includes(q)
)
})
const sortedFilteredMaps = computed(() => {
const list = [...filteredMaps.value]
const by = mapsSortBy.value
const dir = mapsSortDir.value
list.sort((a, b) => {
let cmp = 0
if (by === 'ID') cmp = a.ID - b.ID
else cmp = a.Name.localeCompare(b.Name)
return dir === 'asc' ? cmp : -cmp
})
return list
})
function setMapsSort(by: 'ID' | 'Name') {
if (mapsSortBy.value === by) mapsSortDir.value = mapsSortDir.value === 'asc' ? 'desc' : 'asc'
else {
mapsSortBy.value = by
mapsSortDir.value = 'asc'
}
}
onMounted(async () => {
await Promise.all([loadUsers(), loadMaps(), loadSettings()])
loading.value = false
})
async function loadUsers() {
try {
users.value = await api.adminUsers()
} catch {
users.value = []
}
}
async function loadMaps() {
try {
maps.value = await api.adminMaps()
} catch {
maps.value = []
}
}
async function loadSettings() {
try {
const s = await api.adminSettings()
settings.value = { prefix: s.prefix ?? '', defaultHide: s.defaultHide ?? false, title: s.title ?? '' }
} catch {
//
}
}
async function saveSettings() {
savingSettings.value = true
try {
await api.adminSettingsPost(settings.value)
toast.success('Settings saved.')
} catch (e) {
toast.error((e as Error)?.message ?? 'Failed to save settings.')
} finally {
savingSettings.value = false
}
}
function confirmRebuildZooms() {
showRebuildModal.value = true
}
const { markRebuildDone } = useRebuildZoomsInvalidation()
async function doRebuildZooms() {
rebuilding.value = true
try {
await api.adminRebuildZooms()
markRebuildDone()
showRebuildModal.value = false
toast.success('Zooms rebuilt.')
} catch (e) {
toast.error((e as Error)?.message ?? 'Failed to rebuild zooms.')
} finally {
rebuilding.value = false
}
}
function confirmWipe() {
showWipeModal.value = true
}
async function doWipe() {
wiping.value = true
try {
await api.adminWipe()
showWipeModal.value = false
await loadMaps()
toast.success('All data wiped.')
} catch (e) {
toast.error((e as Error)?.message ?? 'Failed to wipe.')
} finally {
wiping.value = false
}
}
function onMergeFile(e: Event) {
const input = e.target as HTMLInputElement
mergeFile.value = input.files?.[0] ?? null
}
async function doMerge() {
if (!mergeFile.value) return
merging.value = true
try {
const fd = new FormData()
fd.append('merge', mergeFile.value)
await api.adminMerge(fd)
mergeFile.value = null
if (mergeFileRef.value) mergeFileRef.value.value = ''
await loadMaps()
toast.success('Merge completed.')
} catch (e) {
toast.error((e as Error)?.message ?? 'Merge failed.')
} finally {
merging.value = false
}
}
</script>