- Introduced .editorconfig for consistent coding styles across the project. - Added .golangci.yml for Go linting configuration. - Updated AGENTS.md to clarify project structure and components. - Enhanced CONTRIBUTING.md with Makefile usage for common tasks. - Updated Dockerfiles to use Go 1.24 and improved build instructions. - Refined README.md and deployment documentation for clarity. - Added testing documentation in testing.md for backend and frontend tests. - Introduced Makefile for streamlined development commands and tasks.
308 lines
11 KiB
Vue
308 lines
11 KiB
Vue
<template>
|
|
<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 }}
|
|
</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="flex flex-col gap-2">
|
|
<div
|
|
v-for="u in users"
|
|
: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="!users.length" class="py-6 text-center text-base-content/60 rounded-lg bg-base-300/30">
|
|
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="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>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="map in maps" :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="!maps.length">
|
|
<td colspan="5" class="text-base-content/60">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="rebuildZooms">
|
|
<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>
|
|
|
|
<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>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
definePageMeta({ middleware: 'admin' })
|
|
|
|
const api = useMapApi()
|
|
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 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)
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await Promise.all([loadUsers(), loadMaps(), loadSettings()])
|
|
})
|
|
|
|
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)
|
|
setMessage('success', 'Settings saved.')
|
|
} catch (e) {
|
|
setMessage('error', (e as Error)?.message ?? 'Failed to save settings.')
|
|
} finally {
|
|
savingSettings.value = false
|
|
}
|
|
}
|
|
|
|
async function rebuildZooms() {
|
|
rebuilding.value = true
|
|
try {
|
|
await api.adminRebuildZooms()
|
|
setMessage('success', 'Zooms rebuilt.')
|
|
} catch (e) {
|
|
setMessage('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()
|
|
setMessage('success', 'All data wiped.')
|
|
} catch (e) {
|
|
setMessage('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()
|
|
setMessage('success', 'Merge completed.')
|
|
} catch (e) {
|
|
setMessage('error', (e as Error)?.message ?? 'Merge failed.')
|
|
} finally {
|
|
merging.value = false
|
|
}
|
|
}
|
|
</script>
|