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:
@@ -1,446 +1,446 @@
|
||||
<template>
|
||||
<div class="container mx-auto p-4 max-w-2xl">
|
||||
<h1 class="text-2xl font-bold mb-6">Admin</h1>
|
||||
|
||||
<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">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title gap-2">
|
||||
<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 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">{{ u }}</span>
|
||||
</div>
|
||||
<NuxtLink :to="`/admin/users/${u}`" class="btn btn-outline btn-sm gap-1 shrink-0">
|
||||
<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">
|
||||
<icons-icon-plus /> Add user
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-xl 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 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>
|
||||
<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 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">
|
||||
<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 bg-base-200 shadow-xl 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"
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label gap-2 cursor-pointer justify-start" 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" :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 bg-base-200 shadow-xl 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">
|
||||
Export zip
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</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" @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" :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-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">
|
||||
<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"
|
||||
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>
|
||||
<p>Wipe all grids, markers, tiles and maps? This cannot be undone.</p>
|
||||
<div class="modal-action">
|
||||
<form method="dialog">
|
||||
<button class="btn">Cancel</button>
|
||||
</form>
|
||||
<button class="btn btn-error" :disabled="wiping" @click="doWipe">Wipe</button>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<script setup lang="ts">
|
||||
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: '' })
|
||||
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 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 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() {
|
||||
rebuildModalRef.value?.showModal()
|
||||
}
|
||||
|
||||
async function doRebuildZooms() {
|
||||
rebuildModalRef.value?.close()
|
||||
rebuilding.value = true
|
||||
try {
|
||||
await api.adminRebuildZooms()
|
||||
toast.success('Zooms rebuilt.')
|
||||
} catch (e) {
|
||||
toast.error((e as Error)?.message ?? 'Failed to rebuild zooms.')
|
||||
} finally {
|
||||
rebuilding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmWipe() {
|
||||
wipeModalRef.value?.showModal()
|
||||
}
|
||||
|
||||
async function doWipe() {
|
||||
wiping.value = true
|
||||
try {
|
||||
await api.adminWipe()
|
||||
wipeModalRef.value?.close()
|
||||
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>
|
||||
|
||||
<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 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">
|
||||
<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 bg-base-200 shadow-xl 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>
|
||||
<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 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>
|
||||
</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 bg-base-200 shadow-xl 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 bg-base-200 shadow-xl 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-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 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"
|
||||
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>
|
||||
<p>Wipe all grids, markers, tiles and maps? This cannot be undone.</p>
|
||||
<div class="modal-action">
|
||||
<form method="dialog">
|
||||
<button class="btn">Cancel</button>
|
||||
</form>
|
||||
<button class="btn btn-error" :disabled="wiping" @click="doWipe">Wipe</button>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<script setup lang="ts">
|
||||
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: '' })
|
||||
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 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 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() {
|
||||
rebuildModalRef.value?.showModal()
|
||||
}
|
||||
|
||||
async function doRebuildZooms() {
|
||||
rebuildModalRef.value?.close()
|
||||
rebuilding.value = true
|
||||
try {
|
||||
await api.adminRebuildZooms()
|
||||
toast.success('Zooms rebuilt.')
|
||||
} catch (e) {
|
||||
toast.error((e as Error)?.message ?? 'Failed to rebuild zooms.')
|
||||
} finally {
|
||||
rebuilding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmWipe() {
|
||||
wipeModalRef.value?.showModal()
|
||||
}
|
||||
|
||||
async function doWipe() {
|
||||
wiping.value = true
|
||||
try {
|
||||
await api.adminWipe()
|
||||
wipeModalRef.value?.close()
|
||||
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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user