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:
2026-02-24 22:27:05 +03:00
commit 605a31567e
97 changed files with 18350 additions and 0 deletions

View 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>