Add initial project structure with backend and frontend setup
- Created backend structure with Go, including main application logic and API endpoints. - Added Docker support for both development and production environments. - Introduced frontend using Nuxt 3 with Tailwind CSS for styling. - Included configuration files for Docker and environment variables. - Established basic documentation for contributing, development, and deployment processes. - Set up .gitignore and .dockerignore files to manage ignored files in the repository.
This commit is contained in:
278
frontend-nuxt/pages/admin/index.vue
Normal file
278
frontend-nuxt/pages/admin/index.vue
Normal file
@@ -0,0 +1,278 @@
|
||||
<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"
|
||||
: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">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Users</h2>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="u in users"
|
||||
:key="u"
|
||||
class="flex justify-between items-center gap-3 py-1 border-b border-base-300 last:border-0"
|
||||
>
|
||||
<span>{{ u }}</span>
|
||||
<NuxtLink :to="`/admin/users/${u}`" class="btn btn-ghost btn-xs">Edit</NuxtLink>
|
||||
</li>
|
||||
<li v-if="!users.length" class="py-1 text-base-content/60">
|
||||
No users yet.
|
||||
</li>
|
||||
</ul>
|
||||
<NuxtLink to="/admin/users/new" class="btn btn-primary btn-sm mt-2">Add user</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Maps</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Name</th><th>Hidden</th><th>Priority</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="map in maps" :key="map.ID">
|
||||
<td>{{ map.ID }}</td>
|
||||
<td>{{ map.Name }}</td>
|
||||
<td>{{ map.Hidden ? 'Yes' : 'No' }}</td>
|
||||
<td>{{ map.Priority ? 'Yes' : 'No' }}</td>
|
||||
<td>
|
||||
<NuxtLink :to="`/admin/maps/${map.ID}`" class="btn btn-ghost btn-xs">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">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Settings</h2>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="form-control 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-bordered input-sm w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control 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-bordered input-sm w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-2">
|
||||
<button class="btn btn-sm" :disabled="savingSettings" @click="saveSettings">
|
||||
{{ savingSettings ? '…' : 'Save settings' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">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">
|
||||
{{ rebuilding ? '…' : 'Rebuild zooms' }}
|
||||
</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">
|
||||
{{ merging ? '…' : 'Merge' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="border-t border-base-300 pt-4 mt-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">
|
||||
{{ wiping ? '…' : 'Wipe all data' }}
|
||||
</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>
|
||||
77
frontend-nuxt/pages/admin/maps/[id].vue
Normal file
77
frontend-nuxt/pages/admin/maps/[id].vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div class="container mx-auto p-4 max-w-lg">
|
||||
<h1 class="text-2xl font-bold mb-6">Edit map {{ id }}</h1>
|
||||
|
||||
<form v-if="map" @submit.prevent="submit" class="flex flex-col gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="name">Name</label>
|
||||
<input id="name" v-model="form.name" type="text" class="input input-bordered" required />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer gap-2">
|
||||
<input v-model="form.hidden" type="checkbox" class="checkbox" />
|
||||
<span class="label-text">Hidden</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer gap-2">
|
||||
<input v-model="form.priority" type="checkbox" class="checkbox" />
|
||||
<span class="label-text">Priority</span>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="error" class="text-error text-sm">{{ error }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading">{{ loading ? '…' : 'Save' }}</button>
|
||||
<NuxtLink to="/admin" class="btn btn-ghost">Back</NuxtLink>
|
||||
</div>
|
||||
</form>
|
||||
<template v-else-if="mapsLoaded">
|
||||
<p class="text-base-content/70">Map not found.</p>
|
||||
<NuxtLink to="/admin" class="btn btn-ghost mt-2">Back to Admin</NuxtLink>
|
||||
</template>
|
||||
<p v-else class="text-base-content/70">Loading…</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ middleware: 'admin' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const api = useMapApi()
|
||||
const id = computed(() => parseInt(route.params.id as string, 10))
|
||||
const map = ref<{ ID: number; Name: string; Hidden: boolean; Priority: boolean } | null>(null)
|
||||
const mapsLoaded = ref(false)
|
||||
const form = ref({ name: '', hidden: false, priority: false })
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const maps = await api.adminMaps()
|
||||
mapsLoaded.value = true
|
||||
const found = maps.find((m) => m.ID === id.value)
|
||||
if (found) {
|
||||
map.value = found
|
||||
form.value = { name: found.Name, hidden: found.Hidden, priority: found.Priority }
|
||||
}
|
||||
} catch {
|
||||
mapsLoaded.value = true
|
||||
error.value = 'Failed to load map'
|
||||
}
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (!map.value) return
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
await api.adminMapPost(map.value.ID, form.value)
|
||||
await router.push('/admin')
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
123
frontend-nuxt/pages/admin/users/[username].vue
Normal file
123
frontend-nuxt/pages/admin/users/[username].vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="container mx-auto p-4 max-w-lg">
|
||||
<h1 class="text-2xl font-bold mb-6">{{ isNew ? 'New user' : `Edit ${username}` }}</h1>
|
||||
|
||||
<form @submit.prevent="submit" class="flex flex-col gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="user">Username</label>
|
||||
<input
|
||||
id="user"
|
||||
v-model="form.user"
|
||||
type="text"
|
||||
class="input input-bordered"
|
||||
required
|
||||
:readonly="!isNew"
|
||||
/>
|
||||
</div>
|
||||
<PasswordInput
|
||||
v-model="form.pass"
|
||||
label="Password (leave blank to keep)"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<div class="form-control">
|
||||
<label class="label">Auths</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label v-for="a of authOptions" :key="a" class="label cursor-pointer gap-2" :for="`auth-${a}`">
|
||||
<input :id="`auth-${a}`" v-model="form.auths" type="checkbox" :value="a" class="checkbox checkbox-sm" />
|
||||
<span class="label-text">{{ a }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="error" class="text-error text-sm">{{ error }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading">{{ loading ? '…' : 'Save' }}</button>
|
||||
<NuxtLink to="/admin" class="btn btn-ghost">Back</NuxtLink>
|
||||
<button
|
||||
v-if="!isNew"
|
||||
type="button"
|
||||
class="btn btn-error btn-outline ml-auto"
|
||||
:disabled="loading"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<dialog ref="deleteModalRef" class="modal">
|
||||
<div class="modal-box">
|
||||
<p>Delete user {{ form.user }}?</p>
|
||||
<div class="modal-action">
|
||||
<form method="dialog">
|
||||
<button class="btn">Cancel</button>
|
||||
</form>
|
||||
<button class="btn btn-error" :disabled="deleting" @click="doDelete">Delete</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 route = useRoute()
|
||||
const router = useRouter()
|
||||
const api = useMapApi()
|
||||
const username = computed(() => (route.params.username as string) ?? '')
|
||||
const isNew = computed(() => username.value === 'new')
|
||||
|
||||
const form = ref({ user: '', pass: '', auths: [] as string[] })
|
||||
const authOptions = ['admin', 'map', 'markers', 'upload']
|
||||
const loading = ref(false)
|
||||
const deleting = ref(false)
|
||||
const error = ref('')
|
||||
const deleteModalRef = ref<HTMLDialogElement | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isNew.value) {
|
||||
form.value.user = username.value
|
||||
try {
|
||||
const u = await api.adminUserByName(username.value)
|
||||
form.value.auths = u.auths ?? []
|
||||
} catch {
|
||||
error.value = 'Failed to load user'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
await api.adminUserPost({
|
||||
user: form.value.user,
|
||||
pass: form.value.pass || undefined,
|
||||
auths: form.value.auths,
|
||||
})
|
||||
await router.push('/admin')
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
deleteModalRef.value?.showModal()
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
deleting.value = true
|
||||
try {
|
||||
await api.adminUserDelete(form.value.user)
|
||||
deleteModalRef.value?.close()
|
||||
await router.push('/admin')
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : 'Delete failed'
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
12
frontend-nuxt/pages/character/[characterId].vue
Normal file
12
frontend-nuxt/pages/character/[characterId].vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="h-full min-h-0">
|
||||
<MapPageWrapper>
|
||||
<MapView :character-id="characterId" />
|
||||
</MapPageWrapper>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const characterId = computed(() => Number(route.params.characterId) || -1)
|
||||
</script>
|
||||
18
frontend-nuxt/pages/grid/[map]/[gridX]/[gridY]/[zoom].vue
Normal file
18
frontend-nuxt/pages/grid/[map]/[gridX]/[gridY]/[zoom].vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<MapPageWrapper>
|
||||
<MapView
|
||||
:map-id="mapId"
|
||||
:grid-x="gridX"
|
||||
:grid-y="gridY"
|
||||
:zoom="zoom"
|
||||
/>
|
||||
</MapPageWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const mapId = computed(() => Number(route.params.map) || 0)
|
||||
const gridX = computed(() => Number(route.params.gridX) || 0)
|
||||
const gridY = computed(() => Number(route.params.gridY) || 0)
|
||||
const zoom = computed(() => Number(route.params.zoom) || 1)
|
||||
</script>
|
||||
8
frontend-nuxt/pages/index.vue
Normal file
8
frontend-nuxt/pages/index.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<MapPageWrapper>
|
||||
<MapView />
|
||||
</MapPageWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
56
frontend-nuxt/pages/login.vue
Normal file
56
frontend-nuxt/pages/login.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col items-center justify-center bg-base-200 p-4 overflow-hidden">
|
||||
<div class="card w-full max-w-sm bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title justify-center">Log in</h1>
|
||||
<form @submit.prevent="submit" class="flex flex-col gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="user">User</label>
|
||||
<input
|
||||
id="user"
|
||||
v-model="user"
|
||||
type="text"
|
||||
class="input input-bordered"
|
||||
required
|
||||
autocomplete="username"
|
||||
/>
|
||||
</div>
|
||||
<PasswordInput
|
||||
v-model="pass"
|
||||
label="Password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<p v-if="error" class="text-error text-sm">{{ error }}</p>
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||
{{ loading ? '…' : 'Log in' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// No auth required; auth.global skips this path
|
||||
|
||||
const user = ref('')
|
||||
const pass = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const router = useRouter()
|
||||
const api = useMapApi()
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
await api.login(user.value, pass.value)
|
||||
await router.push('/profile')
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : 'Login failed'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
111
frontend-nuxt/pages/profile.vue
Normal file
111
frontend-nuxt/pages/profile.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="container mx-auto p-4 max-w-2xl">
|
||||
<h1 class="text-2xl font-bold mb-6">Profile</h1>
|
||||
|
||||
<div class="card bg-base-200 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Upload tokens</h2>
|
||||
<p class="text-sm opacity-80">Tokens for upload API. Generate and copy as needed.</p>
|
||||
<ul v-if="tokens?.length" class="list-disc list-inside mt-2 space-y-1">
|
||||
<li v-for="t in tokens" :key="t" class="font-mono text-sm flex items-center gap-2">
|
||||
<span class="break-all">{{ uploadTokenDisplay(t) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs shrink-0"
|
||||
aria-label="Copy token"
|
||||
@click="copyToken(t)"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="text-sm mt-2">No tokens yet.</p>
|
||||
<p v-if="tokenError" class="text-error text-sm mt-2">{{ tokenError }}</p>
|
||||
<button class="btn btn-primary btn-sm mt-2" :disabled="loadingTokens" @click="generateToken">
|
||||
{{ loadingTokens ? '…' : 'Generate token' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-200 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Change password</h2>
|
||||
<form @submit.prevent="changePass" class="flex flex-col gap-2">
|
||||
<PasswordInput
|
||||
v-model="newPass"
|
||||
placeholder="New password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<p v-if="passMsg" class="text-sm" :class="passOk ? 'text-success' : 'text-error'">{{ passMsg }}</p>
|
||||
<button type="submit" class="btn btn-sm" :disabled="loadingPass">Save password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const api = useMapApi()
|
||||
const tokens = ref<string[]>([])
|
||||
const uploadPrefix = ref('')
|
||||
const newPass = ref('')
|
||||
const loadingTokens = ref(false)
|
||||
const loadingPass = ref(false)
|
||||
const passMsg = ref('')
|
||||
const passOk = ref(false)
|
||||
const tokenError = ref('')
|
||||
|
||||
function uploadTokenDisplay(token: string): string {
|
||||
const base = (uploadPrefix.value ?? '').replace(/\/+$/, '')
|
||||
return base ? `${base}/client/${token}` : `client/${token}`
|
||||
}
|
||||
|
||||
function copyToken(token: string) {
|
||||
navigator.clipboard.writeText(uploadTokenDisplay(token)).catch(() => {})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const me = await api.me()
|
||||
tokens.value = me.tokens ?? []
|
||||
uploadPrefix.value = me.prefix ?? ''
|
||||
} catch {
|
||||
tokens.value = []
|
||||
uploadPrefix.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
async function generateToken() {
|
||||
tokenError.value = ''
|
||||
loadingTokens.value = true
|
||||
try {
|
||||
await api.meTokens()
|
||||
const me = await api.me()
|
||||
tokens.value = me.tokens ?? []
|
||||
uploadPrefix.value = me.prefix ?? ''
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : ''
|
||||
tokenError.value = msg === 'Forbidden'
|
||||
? 'You need "upload" permission to generate tokens. Ask an admin to add it to your account.'
|
||||
: (msg || 'Failed to generate token')
|
||||
} finally {
|
||||
loadingTokens.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function changePass() {
|
||||
passMsg.value = ''
|
||||
loadingPass.value = true
|
||||
try {
|
||||
await api.mePassword(newPass.value)
|
||||
passMsg.value = 'Password updated.'
|
||||
passOk.value = true
|
||||
newPass.value = ''
|
||||
} catch (e: unknown) {
|
||||
passMsg.value = e instanceof Error ? e.message : 'Failed'
|
||||
passOk.value = false
|
||||
} finally {
|
||||
loadingPass.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
60
frontend-nuxt/pages/setup.vue
Normal file
60
frontend-nuxt/pages/setup.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col items-center justify-center bg-base-200 p-4">
|
||||
<div class="card w-full max-w-sm bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title justify-center">First-time setup</h1>
|
||||
<p class="text-sm text-base-content/80">
|
||||
This is the first run. Create the administrator account using the bootstrap password
|
||||
from the server configuration (e.g. <code class="text-xs">HNHMAP_BOOTSTRAP_PASSWORD</code>).
|
||||
</p>
|
||||
<form @submit.prevent="submit" class="flex flex-col gap-4">
|
||||
<PasswordInput
|
||||
v-model="pass"
|
||||
label="Bootstrap password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<p v-if="error" class="text-error text-sm">{{ error }}</p>
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||
{{ loading ? '…' : 'Create and log in' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<NuxtLink to="/" class="link link-hover underline underline-offset-2 mt-4 text-primary">Map</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// No auth required; auth.global skips this path
|
||||
|
||||
const pass = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const router = useRouter()
|
||||
const api = useMapApi()
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { setupRequired: required } = await api.setupRequired()
|
||||
if (!required) await navigateTo('/login')
|
||||
} catch {
|
||||
// If API fails, stay on page
|
||||
}
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
await api.login('admin', pass.value)
|
||||
await router.push('/profile')
|
||||
} catch (e: unknown) {
|
||||
error.value = (e as Error)?.message === 'Unauthorized'
|
||||
? 'Invalid bootstrap password.'
|
||||
: (e as Error)?.message || 'Setup failed'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user