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:
55
frontend-nuxt/components/AdminBreadcrumbs.vue
Normal file
55
frontend-nuxt/components/AdminBreadcrumbs.vue
Normal 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>
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
10
frontend-nuxt/components/Skeleton.vue
Normal file
10
frontend-nuxt/components/Skeleton.vue
Normal 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>
|
||||
54
frontend-nuxt/components/ToastContainer.vue
Normal file
54
frontend-nuxt/components/ToastContainer.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user