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:
2026-03-01 15:45:49 +03:00
parent 2bd2c8dbca
commit 945b803dba
8 changed files with 1293 additions and 969 deletions

View File

@@ -1,218 +1,255 @@
<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 flex flex-col transition-all duration-300 ease-out"
:class="panelCollapsed ? 'w-12 items-center py-2 gap-1' : 'w-56'"
>
<!-- Collapsed: zoom in/out + expand -->
<template v-if="panelCollapsed">
<div class="flex flex-col gap-1">
<button
type="button"
class="btn btn-ghost 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 class="size-4" />
</button>
<button
type="button"
class="btn btn-ghost 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 class="size-4" />
</button>
</div>
</template>
<!-- Expanded: full content with transition -->
<Transition name="panel-slide" mode="out-in">
<div
v-if="!panelCollapsed"
key="expanded"
class="flex flex-col p-4 gap-4 flex-1 min-w-0 overflow-hidden"
>
<!-- Zoom -->
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70 flex items-center gap-1.5">
<icons-icon-zoom-in class="size-3.5 opacity-80" aria-hidden="true" />
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 flex items-center gap-1.5">
<icons-icon-eye class="size-3.5 opacity-80" aria-hidden="true" />
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>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>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 flex items-center gap-1.5">
<icons-icon-map-pin class="size-3.5 opacity-80" aria-hidden="true" />
Navigation
</h3>
<fieldset class="fieldset">
<label class="label py-0"><span>Jump to Map</span></label>
<select v-model="selectedMapIdSelect" class="select 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>
</fieldset>
<fieldset class="fieldset">
<label class="label py-0"><span>Overlay Map</span></label>
<select v-model="overlayMapId" class="select 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>
</fieldset>
<fieldset class="fieldset">
<label class="label py-0"><span>Jump to Quest Giver</span></label>
<select v-model="selectedMarkerIdSelect" class="select 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>
</fieldset>
<fieldset class="fieldset">
<label class="label py-0"><span>Jump to Player</span></label>
<select v-model="selectedPlayerIdSelect" class="select 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>
</fieldset>
</section>
</div>
</Transition>
<button
type="button"
class="btn btn-ghost btn-sm btn-square shrink-0 m-1 transition-all duration-200 hover:scale-105 mt-auto"
:title="panelCollapsed ? 'Expand panel' : 'Collapse panel'"
:aria-label="panelCollapsed ? 'Expand panel' : 'Collapse panel'"
@click.stop="panelCollapsed = !panelCollapsed"
>
<icons-icon-chevron-right v-if="panelCollapsed" class="rotate-0" />
<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>
<style scoped>
.panel-slide-enter-active,
.panel-slide-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.panel-slide-enter-from,
.panel-slide-leave-to {
opacity: 0;
transform: translateX(-0.5rem);
}
</style>
<template>
<!-- Desktop: left panel (md and up) -->
<div
class="absolute left-3 top-[10%] z-[502] hidden md: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 flex flex-col transition-all duration-300 ease-out"
:class="panelCollapsed ? 'w-12 items-center py-2 gap-1' : 'w-56'"
>
<template v-if="panelCollapsed">
<div class="flex flex-col gap-1">
<button
type="button"
class="btn btn-ghost 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 class="size-4" />
</button>
<button
type="button"
class="btn btn-ghost 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 class="size-4" />
</button>
</div>
</template>
<Transition name="panel-slide" mode="out-in">
<div
v-if="!panelCollapsed"
key="expanded"
class="flex flex-col p-4 gap-4 flex-1 min-w-0 overflow-hidden"
>
<MapControlsContent
v-model:show-grid-coordinates="showGridCoordinates"
v-model:hide-markers="hideMarkers"
:selected-map-id-select="selectedMapIdSelect"
@update:selected-map-id-select="(v) => (selectedMapIdSelect = v)"
:overlay-map-id="overlayMapId"
@update:overlay-map-id="(v) => (overlayMapId = v)"
:selected-marker-id-select="selectedMarkerIdSelect"
@update:selected-marker-id-select="(v) => (selectedMarkerIdSelect = v)"
:selected-player-id-select="selectedPlayerIdSelect"
@update:selected-player-id-select="(v) => (selectedPlayerIdSelect = v)"
:maps="maps"
:quest-givers="questGivers"
:players="players"
@zoom-in="$emit('zoomIn')"
@zoom-out="$emit('zoomOut')"
@reset-view="$emit('resetView')"
/>
</div>
</Transition>
<button
type="button"
class="btn btn-ghost btn-sm btn-square shrink-0 m-1 transition-all duration-200 hover:scale-105 mt-auto"
:title="panelCollapsed ? 'Expand panel' : 'Collapse panel'"
:aria-label="panelCollapsed ? 'Expand panel' : 'Collapse panel'"
@click.stop="panelCollapsed = !panelCollapsed"
>
<icons-icon-chevron-right v-if="panelCollapsed" class="rotate-0" />
<icons-icon-panel-left v-else />
</button>
</div>
</div>
<!-- Mobile: FAB + bottom sheet -->
<div class="fixed left-3 bottom-6 z-[502] flex flex-col gap-2 md:hidden">
<button
type="button"
class="btn btn-primary btn-circle shadow-lg min-h-12 min-w-12 touch-manipulation"
aria-label="Open map controls"
@click="sheetOpen = true"
>
<icons-icon-panel-left class="size-6" />
</button>
<div class="flex flex-col gap-1">
<button
type="button"
class="btn btn-ghost btn-sm btn-circle min-h-10 min-w-10 touch-manipulation"
title="Zoom in"
aria-label="Zoom in"
@click="$emit('zoomIn')"
>
<icons-icon-zoom-in class="size-4" />
</button>
<button
type="button"
class="btn btn-ghost btn-sm btn-circle min-h-10 min-w-10 touch-manipulation"
title="Zoom out"
aria-label="Zoom out"
@click="$emit('zoomOut')"
>
<icons-icon-zoom-out class="size-4" />
</button>
</div>
</div>
<!-- Mobile bottom sheet overlay + panel -->
<Teleport to="body">
<Transition name="sheet">
<div
v-if="sheetOpen"
class="fixed inset-0 z-[1100] md:hidden"
role="dialog"
aria-modal="true"
aria-label="Map controls"
>
<div
class="absolute inset-0 bg-black/50"
aria-hidden="true"
@click="sheetOpen = false"
/>
<div
class="sheet-panel absolute left-0 right-0 bottom-0 max-h-[85vh] rounded-t-2xl bg-base-100 shadow-2xl flex flex-col overflow-hidden"
@click.stop
>
<div class="flex justify-center py-2 shrink-0">
<span class="w-12 h-1 rounded-full bg-base-300" aria-hidden="true" />
</div>
<div class="overflow-y-auto overscroll-contain flex-1 p-4 pb-8">
<MapControlsContent
v-model:show-grid-coordinates="showGridCoordinates"
v-model:hide-markers="hideMarkers"
:selected-map-id-select="selectedMapIdSelect"
@update:selected-map-id-select="(v) => (selectedMapIdSelect = v)"
:overlay-map-id="overlayMapId"
@update:overlay-map-id="(v) => (overlayMapId = v)"
:selected-marker-id-select="selectedMarkerIdSelect"
@update:selected-marker-id-select="(v) => (selectedMarkerIdSelect = v)"
:selected-player-id-select="selectedPlayerIdSelect"
@update:selected-player-id-select="(v) => (selectedPlayerIdSelect = v)"
:maps="maps"
:quest-givers="questGivers"
:players="players"
:touch-friendly="true"
@zoom-in="$emit('zoomIn')"
@zoom-out="$emit('zoomOut')"
@reset-view="$emit('resetView')"
/>
</div>
<div class="p-3 border-t border-base-300 shrink-0 safe-area-pb">
<button
type="button"
class="btn btn-ghost btn-block touch-manipulation min-h-12"
@click="sheetOpen = false"
>
Close
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { MapInfo } from '~/types/api'
import MapControlsContent from '~/components/map/MapControlsContent.vue'
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: () => [] }
)
defineEmits<{
zoomIn: []
zoomOut: []
resetView: []
}>()
const showGridCoordinates = defineModel<boolean>('showGridCoordinates', { default: false })
const hideMarkers = defineModel<boolean>('hideMarkers', { default: false })
const panelCollapsed = ref(false)
const sheetOpen = 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 })
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>
<style scoped>
.panel-slide-enter-active,
.panel-slide-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.panel-slide-enter-from,
.panel-slide-leave-to {
opacity: 0;
transform: translateX(-0.5rem);
}
.sheet-enter-active,
.sheet-leave-active {
transition: opacity 0.2s ease;
}
.sheet-enter-from,
.sheet-leave-to {
opacity: 0;
}
.sheet-enter-active .sheet-panel,
.sheet-leave-active .sheet-panel {
transition: transform 0.25s ease-out;
}
.sheet-enter-from .sheet-panel,
.sheet-leave-to .sheet-panel {
transform: translateY(100%);
}
</style>

View File

@@ -0,0 +1,169 @@
<template>
<div class="flex flex-col gap-4">
<!-- Zoom -->
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70 flex items-center gap-1.5">
<icons-icon-zoom-in class="size-3.5 opacity-80" aria-hidden="true" />
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 touch-manipulation"
:class="touchFriendly ? 'min-h-10 min-w-10' : ''"
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 touch-manipulation"
:class="touchFriendly ? 'min-h-10 min-w-10' : ''"
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 touch-manipulation"
:class="touchFriendly ? 'min-h-10 min-w-10' : ''"
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 flex items-center gap-1.5">
<icons-icon-eye class="size-3.5 opacity-80" aria-hidden="true" />
Display
</h3>
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2 touch-manipulation" :class="touchFriendly ? 'min-h-11' : ''">
<input v-model="showGridCoordinates" type="checkbox" class="checkbox checkbox-sm" />
<span>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 touch-manipulation" :class="touchFriendly ? 'min-h-11' : ''">
<input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" />
<span>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 flex items-center gap-1.5">
<icons-icon-map-pin class="size-3.5 opacity-80" aria-hidden="true" />
Navigation
</h3>
<fieldset class="fieldset">
<label class="label py-0"><span>Jump to Map</span></label>
<select
v-model="selectedMapIdSelect"
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation"
:class="touchFriendly ? 'min-h-11 text-base' : ''"
>
<option value="">Select map</option>
<option v-for="m in maps" :key="m.ID" :value="String(m.ID)">{{ m.Name }}</option>
</select>
</fieldset>
<fieldset class="fieldset">
<label class="label py-0"><span>Overlay Map</span></label>
<select
v-model="overlayMapId"
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation"
:class="touchFriendly ? 'min-h-11 text-base' : ''"
>
<option value="-1">None</option>
<option v-for="m in maps" :key="'overlay-' + m.ID" :value="String(m.ID)">{{ m.Name }}</option>
</select>
</fieldset>
<fieldset class="fieldset">
<label class="label py-0"><span>Jump to Quest Giver</span></label>
<select
v-model="selectedMarkerIdSelect"
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation"
:class="touchFriendly ? 'min-h-11 text-base' : ''"
>
<option value="">Select quest giver</option>
<option v-for="q in questGivers" :key="q.id" :value="String(q.id)">{{ q.name }}</option>
</select>
</fieldset>
<fieldset class="fieldset">
<label class="label py-0"><span>Jump to Player</span></label>
<select
v-model="selectedPlayerIdSelect"
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation"
:class="touchFriendly ? 'min-h-11 text-base' : ''"
>
<option value="">Select player</option>
<option v-for="p in players" :key="p.id" :value="String(p.id)">{{ p.name }}</option>
</select>
</fieldset>
</section>
</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[]
touchFriendly?: boolean
selectedMapIdSelect: string
overlayMapId: number
selectedMarkerIdSelect: string
selectedPlayerIdSelect: string
}>(),
{ touchFriendly: false }
)
const emit = defineEmits<{
zoomIn: []
zoomOut: []
resetView: []
'update:showGridCoordinates': [v: boolean]
'update:hideMarkers': [v: boolean]
'update:selectedMapIdSelect': [v: string]
'update:overlayMapId': [v: number]
'update:selectedMarkerIdSelect': [v: string]
'update:selectedPlayerIdSelect': [v: string]
}>()
const showGridCoordinates = defineModel<boolean>('showGridCoordinates', { required: true })
const hideMarkers = defineModel<boolean>('hideMarkers', { required: true })
const selectedMapIdSelect = computed({
get: () => props.selectedMapIdSelect,
set: (v) => emit('update:selectedMapIdSelect', v),
})
const overlayMapId = computed({
get: () => String(props.overlayMapId),
set: (v) => emit('update:overlayMapId', v === '' ? -1 : Number(v)),
})
const selectedMarkerIdSelect = computed({
get: () => props.selectedMarkerIdSelect,
set: (v) => emit('update:selectedMarkerIdSelect', v),
})
const selectedPlayerIdSelect = computed({
get: () => props.selectedPlayerIdSelect,
set: (v) => emit('update:selectedPlayerIdSelect', v),
})
</script>