Update project structure and enhance frontend functionality

- Added a new AGENTS.md file to document the project structure and conventions.
- Updated .gitignore to include node_modules and refined cursor rules.
- Introduced new backend and frontend components for improved map interactions, including context menus and controls.
- Enhanced API composables for better admin and authentication functionalities.
- Refactored existing components for cleaner code and improved user experience.
- Updated README.md to clarify production asset serving and user setup instructions.
This commit is contained in:
2026-02-25 16:32:55 +03:00
parent 104fde7640
commit 5ffa10f8b7
48 changed files with 2699 additions and 465 deletions

View File

@@ -0,0 +1,63 @@
<template>
<!-- Context menu (tile) -->
<div
v-show="contextMenu.tile.show"
class="fixed z-[1000] bg-base-100/95 backdrop-blur-xl shadow-xl rounded-lg border border-base-300 py-1 min-w-[180px] transition-opacity duration-150"
:style="{ left: contextMenu.tile.x + 'px', top: contextMenu.tile.y + 'px' }"
>
<button
type="button"
class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors"
@click="onWipeTile(contextMenu.tile.data?.coords)"
>
Wipe tile {{ contextMenu.tile.data?.coords?.x }}, {{ contextMenu.tile.data?.coords?.y }}
</button>
<button
type="button"
class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors"
@click="onRewriteCoords(contextMenu.tile.data?.coords)"
>
Rewrite tile coords
</button>
</div>
<!-- Context menu (marker) -->
<div
v-show="contextMenu.marker.show"
class="fixed z-[1000] bg-base-100/95 backdrop-blur-xl shadow-xl rounded-lg border border-base-300 py-1 min-w-[180px] transition-opacity duration-150"
:style="{ left: contextMenu.marker.x + 'px', top: contextMenu.marker.y + 'px' }"
>
<button
type="button"
class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors"
@click="onHideMarker(contextMenu.marker.data?.id)"
>
Hide marker {{ contextMenu.marker.data?.name }}
</button>
</div>
</template>
<script setup lang="ts">
import type { ContextMenuState } from '~/composables/useMapLogic'
defineProps<{
contextMenu: ContextMenuState
}>()
const emit = defineEmits<{
wipeTile: [coords: { x: number; y: number } | undefined]
rewriteCoords: [coords: { x: number; y: number } | undefined]
hideMarker: [id: number | undefined]
}>()
function onWipeTile(coords: { x: number; y: number } | undefined) {
if (coords) emit('wipeTile', coords)
}
function onRewriteCoords(coords: { x: number; y: number } | undefined) {
if (coords) emit('rewriteCoords', coords)
}
function onHideMarker(id: number | undefined) {
if (id != null) emit('hideMarker', id)
}
</script>

View File

@@ -0,0 +1,167 @@
<template>
<div
class="absolute left-3 top-[10%] z-[502] flex min-w-[3rem] min-h-[3rem] transition-all duration-300 ease-out"
:class="panelCollapsed ? 'w-12' : 'w-64'"
>
<div
class="rounded-xl bg-base-100/80 backdrop-blur-xl border border-base-300/50 shadow-xl overflow-hidden transition-all duration-300 flex flex-col"
:class="panelCollapsed ? 'w-12 items-center py-2' : 'w-56'"
>
<div v-show="!panelCollapsed" class="flex flex-col p-4 gap-4 flex-1 min-w-0">
<!-- Zoom -->
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Zoom</h3>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105"
title="Zoom in"
aria-label="Zoom in"
@click="$emit('zoomIn')"
>
<icons-icon-zoom-in />
</button>
<button
type="button"
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105"
title="Zoom out"
aria-label="Zoom out"
@click="$emit('zoomOut')"
>
<icons-icon-zoom-out />
</button>
<button
type="button"
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105"
title="Reset view — center map and minimum zoom"
aria-label="Reset view"
@click="$emit('resetView')"
>
<icons-icon-home />
</button>
</div>
</section>
<!-- Display -->
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Display</h3>
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2">
<input v-model="showGridCoordinates" type="checkbox" class="checkbox checkbox-sm" />
<span class="label-text">Show grid coordinates</span>
</label>
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2">
<input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" />
<span class="label-text">Hide markers</span>
</label>
</section>
<!-- Navigation -->
<section class="flex flex-col gap-3">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Navigation</h3>
<div class="form-control">
<label class="label py-0"><span class="label-text">Jump to Map</span></label>
<select v-model="selectedMapIdSelect" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
<option value="">Select map</option>
<option v-for="m in maps" :key="m.ID" :value="m.ID">{{ m.Name }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-0"><span class="label-text">Overlay Map</span></label>
<select v-model="overlayMapId" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
<option :value="-1">None</option>
<option v-for="m in maps" :key="'overlay-' + m.ID" :value="m.ID">{{ m.Name }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-0"><span class="label-text">Jump to Quest Giver</span></label>
<select v-model="selectedMarkerIdSelect" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
<option value="">Select quest giver</option>
<option v-for="q in questGivers" :key="q.id" :value="q.id">{{ q.name }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-0"><span class="label-text">Jump to Player</span></label>
<select v-model="selectedPlayerIdSelect" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
<option value="">Select player</option>
<option v-for="p in players" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
</section>
</div>
<button
type="button"
class="btn btn-ghost btn-sm btn-square shrink-0 m-1 transition-all duration-200 hover:scale-105"
:title="panelCollapsed ? 'Expand panel' : 'Collapse panel'"
:aria-label="panelCollapsed ? 'Expand panel' : 'Collapse panel'"
@click.stop="panelCollapsed = !panelCollapsed"
>
<icons-icon-chevron-right v-if="panelCollapsed" />
<icons-icon-panel-left v-else />
</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { MapInfo } from '~/types/api'
interface QuestGiver {
id: number
name: string
}
interface Player {
id: number
name: string
}
const props = withDefaults(
defineProps<{
maps: MapInfo[]
questGivers: QuestGiver[]
players: Player[]
}>(),
{ maps: () => [], questGivers: () => [], players: () => [] }
)
const emit = defineEmits<{
zoomIn: []
zoomOut: []
resetView: []
}>()
const showGridCoordinates = defineModel<boolean>('showGridCoordinates', { default: false })
const hideMarkers = defineModel<boolean>('hideMarkers', { default: false })
const panelCollapsed = ref(false)
const selectedMapId = defineModel<number | null>('selectedMapId', { default: null })
const overlayMapId = defineModel<number>('overlayMapId', { default: -1 })
const selectedMarkerId = defineModel<number | null>('selectedMarkerId', { default: null })
const selectedPlayerId = defineModel<number | null>('selectedPlayerId', { default: null })
// String bindings for selects with placeholder ('' ↔ null)
const selectedMapIdSelect = computed({
get() {
const v = selectedMapId.value
return v == null ? '' : String(v)
},
set(v: string) {
selectedMapId.value = v === '' ? null : Number(v)
},
})
const selectedMarkerIdSelect = computed({
get() {
const v = selectedMarkerId.value
return v == null ? '' : String(v)
},
set(v: string) {
selectedMarkerId.value = v === '' ? null : Number(v)
},
})
const selectedPlayerIdSelect = computed({
get() {
const v = selectedPlayerId.value
return v == null ? '' : String(v)
},
set(v: string) {
selectedPlayerId.value = v === '' ? null : Number(v)
},
})
</script>

View File

@@ -0,0 +1,61 @@
<template>
<dialog ref="modalRef" class="modal" @cancel="$emit('close')">
<div class="modal-box transition-all duration-200" @click.stop>
<h3 class="font-bold text-lg">Rewrite tile coords</h3>
<p class="py-2">From ({{ coordSetFrom.x }}, {{ coordSetFrom.y }}) to:</p>
<div class="flex gap-2">
<input v-model.number="localTo.x" type="number" class="input input-bordered flex-1" placeholder="X" />
<input v-model.number="localTo.y" type="number" class="input input-bordered flex-1" placeholder="Y" />
</div>
<div class="modal-action">
<form method="dialog" @submit.prevent="onSubmit">
<button type="submit" class="btn btn-primary">Submit</button>
<button type="button" class="btn" @click="$emit('close')">Cancel</button>
</form>
</div>
</div>
<div class="modal-backdrop cursor-pointer" aria-label="Close" @click="$emit('close')" />
</dialog>
</template>
<script setup lang="ts">
const props = defineProps<{
coordSetFrom: { x: number; y: number }
coordSet: { x: number; y: number }
open: boolean
}>()
const emit = defineEmits<{
close: []
submit: [from: { x: number; y: number }; to: { x: number; y: number }]
}>()
const modalRef = ref<HTMLDialogElement | null>(null)
const localTo = ref({ x: props.coordSet.x, y: props.coordSet.y })
watch(
() => props.open,
(open) => {
if (open) {
localTo.value = { x: props.coordSet.x, y: props.coordSet.y }
nextTick(() => modalRef.value?.showModal())
} else {
modalRef.value?.close()
}
},
{ immediate: true }
)
watch(
() => props.coordSet,
(c) => {
localTo.value = { x: c.x, y: c.y }
},
{ deep: true }
)
function onSubmit() {
emit('submit', props.coordSetFrom, localTo.value)
emit('close')
}
</script>

View File

@@ -0,0 +1,17 @@
<template>
<div
v-if="displayCoords"
class="absolute bottom-2 right-2 z-[501] rounded-lg px-3 py-2 font-mono text-sm bg-base-100/95 backdrop-blur-sm border border-base-300/50 shadow"
aria-label="Current grid position and zoom"
title="mapId · x, y · zoom"
>
{{ mapid }} · {{ displayCoords.x }}, {{ displayCoords.y }} · z{{ displayCoords.z }}
</div>
</template>
<script setup lang="ts">
defineProps<{
mapid: number
displayCoords: { x: number; y: number; z: number } | null
}>()
</script>