Enhance frontend components and introduce new features

- Added a custom light theme in app.css to match the dark theme's palette.
- Introduced AdminBreadcrumbs component for improved navigation in admin pages.
- Implemented Skeleton component for loading states in various views.
- Added ToastContainer for displaying notifications and alerts.
- Enhanced MapView with loading indicators and improved marker handling.
- Updated MapCoordsDisplay to allow copying of shareable links.
- Refactored MapControls and MapContextMenu for better usability.
- Improved user experience in profile and admin pages with loading states and search functionality.
This commit is contained in:
2026-03-01 15:19:55 +03:00
parent 6529d7370e
commit 2bd2c8dbca
15 changed files with 817 additions and 212 deletions

View File

@@ -0,0 +1,55 @@
<template>
<nav aria-label="Breadcrumb" class="bg-base-200/50 border-b border-base-300/50 px-4 py-2 shrink-0">
<ol class="flex flex-wrap items-center gap-1.5 text-sm">
<li v-for="(item, i) in items" :key="item.path" class="flex items-center gap-1.5">
<template v-if="i > 0">
<span class="text-base-content/40" aria-hidden="true">/</span>
</template>
<NuxtLink
v-if="i < items.length - 1"
:to="item.path"
class="link link-hover text-base-content/80 hover:text-primary"
>
{{ item.label }}
</NuxtLink>
<span v-else class="font-medium text-base-content" aria-current="page">{{ item.label }}</span>
</li>
</ol>
</nav>
</template>
<script setup lang="ts">
const route = useRoute()
const adminMapName = useState<string | null>('admin-breadcrumb-map-name', () => null)
interface BreadcrumbItem {
path: string
label: string
}
const items = computed<BreadcrumbItem[]>(() => {
const path = route.path
if (!path.startsWith('/admin')) return []
const segments = path.replace(/^\/admin\/?/, '').split('/').filter(Boolean)
const result: BreadcrumbItem[] = [{ path: '/admin', label: 'Admin' }]
if (segments[0] === 'users') {
result.push({ path: '/admin', label: 'Users' })
if (segments[1]) {
result.push({
path: `/admin/users/${segments[1]}`,
label: segments[1] === 'new' ? 'New user' : segments[1],
})
}
} else if (segments[0] === 'maps') {
result.push({ path: '/admin', label: 'Maps' })
if (segments[1]) {
const id = segments[1]
result.push({
path: `/admin/maps/${id}`,
label: adminMapName.value ?? `Map ${id}`,
})
}
}
return result
})
</script>

View File

@@ -1,5 +1,19 @@
<template>
<div class="relative h-full w-full" @click="(e: MouseEvent) => e.button === 0 && mapLogic.closeContextMenus()">
<div
v-if="!mapReady"
class="absolute inset-0 z-[400] flex flex-col items-center justify-center gap-6 bg-base-200/95 p-8"
aria-busy="true"
aria-label="Loading map"
>
<Skeleton class="h-12 w-48" />
<div class="flex gap-3">
<Skeleton class="h-10 w-24" />
<Skeleton class="h-10 w-32" />
<Skeleton class="h-10 w-28" />
</div>
<Skeleton class="h-64 w-72 max-w-full" />
</div>
<div
v-if="mapsLoaded && maps.length === 0"
class="absolute inset-0 z-[500] flex flex-col items-center justify-center gap-4 bg-base-200/90 p-6"
@@ -14,9 +28,9 @@
</div>
</div>
<div ref="mapRef" class="map h-full w-full" />
<MapMapCoordsDisplay
:mapid="mapLogic.state.mapid"
:display-coords="mapLogic.state.displayCoords"
<MapCoordsDisplay
:mapid="mapLogic.state.mapid.value"
:display-coords="mapLogic.state.displayCoords.value"
/>
<MapControls
:show-grid-coordinates="mapLogic.state.showGridCoordinates.value"
@@ -38,16 +52,16 @@
@zoom-out="mapLogic.zoomOutControl(leafletMap)"
@reset-view="mapLogic.resetView(leafletMap)"
/>
<MapMapContextMenu
<MapContextMenu
:context-menu="mapLogic.contextMenu"
@wipe-tile="onWipeTile"
@rewrite-coords="onRewriteCoords"
@hide-marker="onHideMarker"
/>
<MapMapCoordSetModal
:coord-set-from="mapLogic.coordSetFrom"
:coord-set="mapLogic.coordSet"
:open="mapLogic.coordSetModalOpen"
<MapCoordSetModal
:coord-set-from="mapLogic.coordSetFrom.value"
:coord-set="mapLogic.coordSet.value"
:open="mapLogic.coordSetModalOpen.value"
@close="mapLogic.closeCoordSetModal()"
@submit="onSubmitCoordSet"
/>
@@ -56,6 +70,9 @@
<script setup lang="ts">
import MapControls from '~/components/map/MapControls.vue'
import MapCoordsDisplay from '~/components/map/MapCoordsDisplay.vue'
import MapContextMenu from '~/components/map/MapContextMenu.vue'
import MapCoordSetModal from '~/components/map/MapCoordSetModal.vue'
import { HnHDefaultZoom, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes'
import { initLeafletMap, type MapInitResult } from '~/composables/useMapInit'
import { startMapUpdates, type UseMapUpdatesReturn } from '~/composables/useMapUpdates'
@@ -77,7 +94,16 @@ const props = withDefaults(
const mapRef = ref<HTMLElement | null>(null)
const api = useMapApi()
const mapLogic = useMapLogic()
const { resolvePath } = useAppPaths()
/** Fallback marker icon (simple pin) when the real icon image fails to load. */
const FALLBACK_MARKER_ICON =
'data:image/svg+xml,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32"><path fill="%236366f1" stroke="%234f46e5" stroke-width="1.5" d="M16 4c-4 0-7 3-7 7 0 5 7 13 7 13s7-8 7-13c0-4-3-7-7-7z"/><circle cx="16" cy="11" r="3" fill="white"/></svg>'
)
const mapReady = ref(false)
const maps = ref<MapInfo[]>([])
const mapsLoaded = ref(false)
const questGivers = ref<Array<{ id: number; name: string }>>([])
@@ -203,6 +229,8 @@ onMounted(async () => {
getTrackingCharacterId: () => mapLogic.state.trackingCharacterId.value,
setTrackingCharacterId: (id: number) => { mapLogic.state.trackingCharacterId.value = id },
onMarkerContextMenu: mapLogic.openMarkerContextMenu,
resolveIconUrl: (path) => resolvePath(path),
fallbackIconUrl: FALLBACK_MARKER_ICON,
})
updatesHandle = startMapUpdates({
@@ -245,6 +273,7 @@ onMounted(async () => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (leafletMap) leafletMap.invalidateSize()
mapReady.value = true
})
})
})

View File

@@ -0,0 +1,10 @@
<template>
<div
class="animate-pulse rounded-md bg-base-300"
:class="$attrs.class"
/>
</template>
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
</script>

View File

@@ -0,0 +1,54 @@
<template>
<Teleport to="body">
<div
class="fixed bottom-4 right-4 z-[9998] flex flex-col gap-2 max-w-sm w-full pointer-events-none"
aria-live="polite"
aria-label="Notifications"
>
<TransitionGroup name="toast">
<div
v-for="t in toasts"
:key="t.id"
class="pointer-events-auto rounded-lg px-4 py-3 shadow-lg border backdrop-blur-sm flex items-center gap-2 min-w-0"
:class="
t.type === 'error'
? 'bg-error/95 text-error-content border-error/30'
: t.type === 'info'
? 'bg-info/95 text-info-content border-info/30'
: 'bg-success/95 text-success-content border-success/30'
"
role="alert"
>
<span class="flex-1 text-sm font-medium truncate">{{ t.text }}</span>
<button
type="button"
class="btn btn-ghost btn-xs btn-circle shrink-0 opacity-70 hover:opacity-100"
aria-label="Dismiss"
@click="dismiss(t.id)"
>
<span class="text-lg leading-none font-light" aria-hidden="true">×</span>
</button>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<script setup lang="ts">
const { toasts, dismiss } = useToast()
</script>
<style scoped>
.toast-enter-active,
.toast-leave-active {
transition: all 0.2s ease;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateX(1rem);
}
.toast-move {
transition: transform 0.2s ease;
}
</style>

View File

@@ -4,96 +4,135 @@
: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'"
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'"
>
<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>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">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>
<!-- 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"
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" />
<icons-icon-chevron-right v-if="panelCollapsed" class="rotate-0" />
<icons-icon-panel-left v-else />
</button>
</div>
@@ -165,3 +204,15 @@ const selectedPlayerIdSelect = computed({
},
})
</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>

View File

@@ -1,17 +1,50 @@
<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"
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 cursor-pointer select-none transition-all hover:border-primary/50 hover:bg-base-100"
aria-label="Current grid position and zoom click to copy share link"
:title="copied ? 'Copied!' : 'Click to copy share link'"
role="button"
tabindex="0"
@click="copyShareUrl"
@keydown.enter="copyShareUrl"
@keydown.space.prevent="copyShareUrl"
>
{{ mapid }} · {{ displayCoords.x }}, {{ displayCoords.y }} · z{{ displayCoords.z }}
<span class="relative">
{{ mapid }} · {{ displayCoords.x }}, {{ displayCoords.y }} · z{{ displayCoords.z }}
<span
v-if="copied"
class="absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-primary px-2 py-1 text-xs font-sans text-primary-content shadow"
>
Copied!
</span>
</span>
</div>
</template>
<script setup lang="ts">
defineProps<{
const props = defineProps<{
mapid: number
displayCoords: { x: number; y: number; z: number } | null
}>()
const { fullUrl } = useAppPaths()
const copied = ref(false)
let copyTimeout: ReturnType<typeof setTimeout> | null = null
function copyShareUrl() {
if (!props.displayCoords) return
const path = `grid/${props.mapid}/${props.displayCoords.x}/${props.displayCoords.y}/${props.displayCoords.z}`
const url = fullUrl(path)
if (import.meta.client && navigator.clipboard?.writeText) {
navigator.clipboard.writeText(url).then(() => {
copied.value = true
if (copyTimeout) clearTimeout(copyTimeout)
copyTimeout = setTimeout(() => {
copied.value = false
copyTimeout = null
}, 2000)
})
}
}
</script>