Enhance frontend components and introduce new features

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

View File

@@ -2,13 +2,47 @@
<div class="container mx-auto p-4 max-w-2xl">
<h1 class="text-2xl font-bold mb-6">Admin</h1>
<div
v-if="message.text"
class="mb-4 rounded-lg px-4 py-2 transition-all duration-300"
:class="message.type === 'error' ? 'bg-error/20 text-error' : 'bg-success/20 text-success'"
role="alert"
>
{{ message.text }}
<template v-if="loading">
<div class="card bg-base-200 shadow-xl 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 bg-base-200 shadow-xl 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 bg-base-200 shadow-xl 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 bg-base-200 shadow-xl mb-6 transition-all duration-200">
@@ -17,9 +51,18 @@
<icons-icon-users />
Users
</h2>
<div class="form-control w-full max-w-xs mb-3">
<input
v-model="userSearch"
type="search"
placeholder="Search users…"
class="input input-sm input-bordered w-full"
aria-label="Search users"
/>
</div>
<div class="flex flex-col gap-2">
<div
v-for="u in users"
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"
>
@@ -35,8 +78,8 @@
<icons-icon-pencil /> Edit
</NuxtLink>
</div>
<div v-if="!users.length" class="py-6 text-center text-base-content/60 rounded-lg bg-base-300/30">
No users yet.
<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">
<icons-icon-plus /> Add user
@@ -51,13 +94,38 @@
<icons-icon-map-pin />
Maps
</h2>
<div class="form-control w-full max-w-xs mb-3">
<input
v-model="mapSearch"
type="search"
placeholder="Search maps…"
class="input input-sm input-bordered w-full"
aria-label="Search maps"
/>
</div>
<div class="overflow-x-auto">
<table class="table table-sm table-zebra">
<thead>
<tr><th>ID</th><th>Name</th><th>Hidden</th><th>Priority</th><th class="text-right"></th></tr>
<tr>
<th>
<button type="button" class="btn btn-ghost btn-xs gap-0.5" :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')">
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>
</tr>
</thead>
<tbody>
<tr v-for="map in maps" :key="map.ID" class="hover:bg-base-300">
<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>
@@ -68,8 +136,8 @@
</NuxtLink>
</td>
</tr>
<tr v-if="!maps.length">
<td colspan="5" class="text-base-content/60">No maps.</td>
<tr v-if="!sortedFilteredMaps.length">
<td colspan="5" class="text-base-content/60">{{ mapSearch ? 'No maps match.' : 'No maps.' }}</td>
</tr>
</tbody>
</table>
@@ -136,7 +204,7 @@
</a>
</div>
<div>
<button class="btn btn-sm" :disabled="rebuilding" @click="rebuildZooms">
<button class="btn btn-sm" :disabled="rebuilding" @click="confirmRebuildZooms">
<span v-if="rebuilding" class="loading loading-spinner loading-sm" />
<span v-else>Rebuild zooms</span>
</button>
@@ -169,6 +237,22 @@
</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"
aria-live="polite"
aria-busy="true"
>
<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>
<dialog ref="wipeModalRef" class="modal" aria-labelledby="wipe-modal-title">
<div class="modal-box">
<h2 id="wipe-modal-title" class="font-bold text-lg mb-2">Confirm wipe</h2>
@@ -182,6 +266,21 @@
</div>
<form method="dialog" class="modal-backdrop"><button aria-label="Close">Close</button></form>
</dialog>
<dialog ref="rebuildModalRef" class="modal" aria-labelledby="rebuild-modal-title">
<div class="modal-box">
<h2 id="rebuild-modal-title" class="font-bold text-lg mb-2">Rebuild zooms</h2>
<p>Rebuild tile zoom levels for all maps? This may take a while.</p>
<div class="modal-action">
<form method="dialog">
<button class="btn">Cancel</button>
</form>
<button class="btn btn-primary" :disabled="rebuilding" @click="doRebuildZooms">Rebuild</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button aria-label="Close">Close</button></form>
</dialog>
</template>
</div>
</template>
@@ -189,6 +288,7 @@
definePageMeta({ middleware: 'admin' })
const api = useMapApi()
const toast = useToast()
const users = ref<string[]>([])
const maps = ref<Array<{ ID: number; Name: string; Hidden: boolean; Priority: boolean }>>([])
const settings = ref({ prefix: '', defaultHide: false, title: '' })
@@ -199,20 +299,53 @@ const merging = ref(false)
const mergeFile = ref<File | null>(null)
const mergeFileRef = ref<HTMLInputElement | null>(null)
const wipeModalRef = ref<HTMLDialogElement | null>(null)
const rebuildModalRef = ref<HTMLDialogElement | null>(null)
const loading = ref(true)
const userSearch = ref('')
const mapSearch = ref('')
const mapsSortBy = ref<'ID' | 'Name'>('ID')
const mapsSortDir = ref<'asc' | 'desc'>('asc')
const message = ref<{ type: 'success' | 'error'; text: string }>({ type: 'success', text: '' })
let messageTimeout: ReturnType<typeof setTimeout> | null = null
function setMessage(type: 'success' | 'error', text: string) {
message.value = { type, text }
if (messageTimeout) clearTimeout(messageTimeout)
messageTimeout = setTimeout(() => {
message.value = { type: 'success', text: '' }
messageTimeout = null
}, 4000)
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() {
@@ -244,21 +377,26 @@ async function saveSettings() {
savingSettings.value = true
try {
await api.adminSettingsPost(settings.value)
setMessage('success', 'Settings saved.')
toast.success('Settings saved.')
} catch (e) {
setMessage('error', (e as Error)?.message ?? 'Failed to save settings.')
toast.error((e as Error)?.message ?? 'Failed to save settings.')
} finally {
savingSettings.value = false
}
}
async function rebuildZooms() {
function confirmRebuildZooms() {
rebuildModalRef.value?.showModal()
}
async function doRebuildZooms() {
rebuildModalRef.value?.close()
rebuilding.value = true
try {
await api.adminRebuildZooms()
setMessage('success', 'Zooms rebuilt.')
toast.success('Zooms rebuilt.')
} catch (e) {
setMessage('error', (e as Error)?.message ?? 'Failed to rebuild zooms.')
toast.error((e as Error)?.message ?? 'Failed to rebuild zooms.')
} finally {
rebuilding.value = false
}
@@ -274,9 +412,9 @@ async function doWipe() {
await api.adminWipe()
wipeModalRef.value?.close()
await loadMaps()
setMessage('success', 'All data wiped.')
toast.success('All data wiped.')
} catch (e) {
setMessage('error', (e as Error)?.message ?? 'Failed to wipe.')
toast.error((e as Error)?.message ?? 'Failed to wipe.')
} finally {
wiping.value = false
}
@@ -297,11 +435,12 @@ async function doMerge() {
mergeFile.value = null
if (mergeFileRef.value) mergeFileRef.value.value = ''
await loadMaps()
setMessage('success', 'Merge completed.')
toast.success('Merge completed.')
} catch (e) {
setMessage('error', (e as Error)?.message ?? 'Merge failed.')
toast.error((e as Error)?.message ?? 'Merge failed.')
} finally {
merging.value = false
}
}
</script>