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:
@@ -3,6 +3,28 @@
|
|||||||
themes: light --default;
|
themes: light --default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Custom light theme — OKLCH matching the dark theme's violet palette */
|
||||||
|
@plugin "daisyui/theme" {
|
||||||
|
name: "light";
|
||||||
|
color-scheme: light;
|
||||||
|
--color-primary: oklch(50% 0.22 277);
|
||||||
|
--color-primary-content: oklch(100% 0 0);
|
||||||
|
--color-secondary: oklch(52% 0.22 293);
|
||||||
|
--color-secondary-content: oklch(100% 0 0);
|
||||||
|
--color-accent: oklch(55% 0.14 203);
|
||||||
|
--color-accent-content: oklch(100% 0 0);
|
||||||
|
--color-neutral: oklch(25% 0.02 249);
|
||||||
|
--color-neutral-content: oklch(98% 0.005 249);
|
||||||
|
--color-base-100: oklch(99% 0.005 250);
|
||||||
|
--color-base-200: oklch(96% 0.008 251);
|
||||||
|
--color-base-300: oklch(92% 0.01 250);
|
||||||
|
--color-base-content: oklch(22% 0.02 249);
|
||||||
|
--color-info: oklch(55% 0.15 250);
|
||||||
|
--color-success: oklch(55% 0.16 155);
|
||||||
|
--color-warning: oklch(75% 0.15 85);
|
||||||
|
--color-error: oklch(55% 0.22 25);
|
||||||
|
}
|
||||||
|
|
||||||
@plugin "daisyui/theme" {
|
@plugin "daisyui/theme" {
|
||||||
name: "dark";
|
name: "dark";
|
||||||
prefersdark: true;
|
prefersdark: true;
|
||||||
|
|||||||
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>
|
<template>
|
||||||
<div class="relative h-full w-full" @click="(e: MouseEvent) => e.button === 0 && mapLogic.closeContextMenus()">
|
<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
|
<div
|
||||||
v-if="mapsLoaded && maps.length === 0"
|
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"
|
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>
|
</div>
|
||||||
<div ref="mapRef" class="map h-full w-full" />
|
<div ref="mapRef" class="map h-full w-full" />
|
||||||
<MapMapCoordsDisplay
|
<MapCoordsDisplay
|
||||||
:mapid="mapLogic.state.mapid"
|
:mapid="mapLogic.state.mapid.value"
|
||||||
:display-coords="mapLogic.state.displayCoords"
|
:display-coords="mapLogic.state.displayCoords.value"
|
||||||
/>
|
/>
|
||||||
<MapControls
|
<MapControls
|
||||||
:show-grid-coordinates="mapLogic.state.showGridCoordinates.value"
|
:show-grid-coordinates="mapLogic.state.showGridCoordinates.value"
|
||||||
@@ -38,16 +52,16 @@
|
|||||||
@zoom-out="mapLogic.zoomOutControl(leafletMap)"
|
@zoom-out="mapLogic.zoomOutControl(leafletMap)"
|
||||||
@reset-view="mapLogic.resetView(leafletMap)"
|
@reset-view="mapLogic.resetView(leafletMap)"
|
||||||
/>
|
/>
|
||||||
<MapMapContextMenu
|
<MapContextMenu
|
||||||
:context-menu="mapLogic.contextMenu"
|
:context-menu="mapLogic.contextMenu"
|
||||||
@wipe-tile="onWipeTile"
|
@wipe-tile="onWipeTile"
|
||||||
@rewrite-coords="onRewriteCoords"
|
@rewrite-coords="onRewriteCoords"
|
||||||
@hide-marker="onHideMarker"
|
@hide-marker="onHideMarker"
|
||||||
/>
|
/>
|
||||||
<MapMapCoordSetModal
|
<MapCoordSetModal
|
||||||
:coord-set-from="mapLogic.coordSetFrom"
|
:coord-set-from="mapLogic.coordSetFrom.value"
|
||||||
:coord-set="mapLogic.coordSet"
|
:coord-set="mapLogic.coordSet.value"
|
||||||
:open="mapLogic.coordSetModalOpen"
|
:open="mapLogic.coordSetModalOpen.value"
|
||||||
@close="mapLogic.closeCoordSetModal()"
|
@close="mapLogic.closeCoordSetModal()"
|
||||||
@submit="onSubmitCoordSet"
|
@submit="onSubmitCoordSet"
|
||||||
/>
|
/>
|
||||||
@@ -56,6 +70,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import MapControls from '~/components/map/MapControls.vue'
|
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 { HnHDefaultZoom, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes'
|
||||||
import { initLeafletMap, type MapInitResult } from '~/composables/useMapInit'
|
import { initLeafletMap, type MapInitResult } from '~/composables/useMapInit'
|
||||||
import { startMapUpdates, type UseMapUpdatesReturn } from '~/composables/useMapUpdates'
|
import { startMapUpdates, type UseMapUpdatesReturn } from '~/composables/useMapUpdates'
|
||||||
@@ -77,7 +94,16 @@ const props = withDefaults(
|
|||||||
const mapRef = ref<HTMLElement | null>(null)
|
const mapRef = ref<HTMLElement | null>(null)
|
||||||
const api = useMapApi()
|
const api = useMapApi()
|
||||||
const mapLogic = useMapLogic()
|
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 maps = ref<MapInfo[]>([])
|
||||||
const mapsLoaded = ref(false)
|
const mapsLoaded = ref(false)
|
||||||
const questGivers = ref<Array<{ id: number; name: string }>>([])
|
const questGivers = ref<Array<{ id: number; name: string }>>([])
|
||||||
@@ -203,6 +229,8 @@ onMounted(async () => {
|
|||||||
getTrackingCharacterId: () => mapLogic.state.trackingCharacterId.value,
|
getTrackingCharacterId: () => mapLogic.state.trackingCharacterId.value,
|
||||||
setTrackingCharacterId: (id: number) => { mapLogic.state.trackingCharacterId.value = id },
|
setTrackingCharacterId: (id: number) => { mapLogic.state.trackingCharacterId.value = id },
|
||||||
onMarkerContextMenu: mapLogic.openMarkerContextMenu,
|
onMarkerContextMenu: mapLogic.openMarkerContextMenu,
|
||||||
|
resolveIconUrl: (path) => resolvePath(path),
|
||||||
|
fallbackIconUrl: FALLBACK_MARKER_ICON,
|
||||||
})
|
})
|
||||||
|
|
||||||
updatesHandle = startMapUpdates({
|
updatesHandle = startMapUpdates({
|
||||||
@@ -245,6 +273,7 @@ onMounted(async () => {
|
|||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (leafletMap) leafletMap.invalidateSize()
|
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,13 +4,45 @@
|
|||||||
:class="panelCollapsed ? 'w-12' : 'w-64'"
|
:class="panelCollapsed ? 'w-12' : 'w-64'"
|
||||||
>
|
>
|
||||||
<div
|
<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="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' : 'w-56'"
|
: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"
|
||||||
>
|
>
|
||||||
<div v-show="!panelCollapsed" class="flex flex-col p-4 gap-4 flex-1 min-w-0">
|
|
||||||
<!-- Zoom -->
|
<!-- Zoom -->
|
||||||
<section class="flex flex-col gap-2">
|
<section class="flex flex-col gap-2">
|
||||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Zoom</h3>
|
<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">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -43,7 +75,10 @@
|
|||||||
</section>
|
</section>
|
||||||
<!-- Display -->
|
<!-- Display -->
|
||||||
<section class="flex flex-col gap-2">
|
<section class="flex flex-col gap-2">
|
||||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Display</h3>
|
<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">
|
<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" />
|
<input v-model="showGridCoordinates" type="checkbox" class="checkbox checkbox-sm" />
|
||||||
<span>Show grid coordinates</span>
|
<span>Show grid coordinates</span>
|
||||||
@@ -55,7 +90,10 @@
|
|||||||
</section>
|
</section>
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<section class="flex flex-col gap-3">
|
<section class="flex flex-col gap-3">
|
||||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Navigation</h3>
|
<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">
|
<fieldset class="fieldset">
|
||||||
<label class="label py-0"><span>Jump to Map</span></label>
|
<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">
|
<select v-model="selectedMapIdSelect" class="select select-sm w-full focus:ring-2 focus:ring-primary">
|
||||||
@@ -86,14 +124,15 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
<button
|
<button
|
||||||
type="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'"
|
:title="panelCollapsed ? 'Expand panel' : 'Collapse panel'"
|
||||||
:aria-label="panelCollapsed ? 'Expand panel' : 'Collapse panel'"
|
:aria-label="panelCollapsed ? 'Expand panel' : 'Collapse panel'"
|
||||||
@click.stop="panelCollapsed = !panelCollapsed"
|
@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 />
|
<icons-icon-panel-left v-else />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,3 +204,15 @@ const selectedPlayerIdSelect = computed({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
</script>
|
</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>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="displayCoords"
|
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"
|
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"
|
aria-label="Current grid position and zoom — click to copy share link"
|
||||||
title="mapId · x, y · zoom"
|
:title="copied ? 'Copied!' : 'Click to copy share link'"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="copyShareUrl"
|
||||||
|
@keydown.enter="copyShareUrl"
|
||||||
|
@keydown.space.prevent="copyShareUrl"
|
||||||
>
|
>
|
||||||
|
<span class="relative">
|
||||||
{{ mapid }} · {{ displayCoords.x }}, {{ displayCoords.y }} · z{{ displayCoords.z }}
|
{{ 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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
mapid: number
|
mapid: number
|
||||||
displayCoords: { x: number; y: number; z: number } | null
|
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>
|
</script>
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export interface MapLayersOptions {
|
|||||||
getTrackingCharacterId: () => number
|
getTrackingCharacterId: () => number
|
||||||
setTrackingCharacterId: (id: number) => void
|
setTrackingCharacterId: (id: number) => void
|
||||||
onMarkerContextMenu: (clientX: number, clientY: number, id: number, name: string) => void
|
onMarkerContextMenu: (clientX: number, clientY: number, id: number, name: string) => void
|
||||||
|
/** Resolves relative marker icon path to absolute URL. If omitted, relative paths are used. */
|
||||||
|
resolveIconUrl?: (path: string) => string
|
||||||
|
/** Fallback icon URL when a marker image fails to load. */
|
||||||
|
fallbackIconUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MapLayersManager {
|
export interface MapLayersManager {
|
||||||
@@ -55,6 +59,8 @@ export function createMapLayers(options: MapLayersOptions): MapLayersManager {
|
|||||||
getTrackingCharacterId,
|
getTrackingCharacterId,
|
||||||
setTrackingCharacterId,
|
setTrackingCharacterId,
|
||||||
onMarkerContextMenu,
|
onMarkerContextMenu,
|
||||||
|
resolveIconUrl,
|
||||||
|
fallbackIconUrl,
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const markers = createUniqueList<MapMarker>()
|
const markers = createUniqueList<MapMarker>()
|
||||||
@@ -93,9 +99,13 @@ export function createMapLayers(options: MapLayersOptions): MapLayersManager {
|
|||||||
function updateMarkers(markersData: ApiMarker[]) {
|
function updateMarkers(markersData: ApiMarker[]) {
|
||||||
const list = Array.isArray(markersData) ? markersData : []
|
const list = Array.isArray(markersData) ? markersData : []
|
||||||
const ctx = markerCtx()
|
const ctx = markerCtx()
|
||||||
|
const iconOptions =
|
||||||
|
resolveIconUrl != null
|
||||||
|
? { resolveIconUrl, fallbackIconUrl }
|
||||||
|
: undefined
|
||||||
uniqueListUpdate(
|
uniqueListUpdate(
|
||||||
markers,
|
markers,
|
||||||
list.map((it) => createMarker(it as MarkerData)),
|
list.map((it) => createMarker(it as MarkerData, iconOptions)),
|
||||||
(marker: MapMarker) => {
|
(marker: MapMarker) => {
|
||||||
if (marker.map === getCurrentMapId() || marker.map === overlayLayer.map) marker.add(ctx)
|
if (marker.map === getCurrentMapId() || marker.map === overlayLayer.map) marker.add(ctx)
|
||||||
marker.setClickCallback(() => {
|
marker.setClickCallback(() => {
|
||||||
|
|||||||
36
frontend-nuxt/composables/useToast.ts
Normal file
36
frontend-nuxt/composables/useToast.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export type ToastType = 'success' | 'error' | 'info'
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: number
|
||||||
|
type: ToastType
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOAST_STATE_KEY = 'hnh-map-toasts'
|
||||||
|
const DEFAULT_DURATION_MS = 4000
|
||||||
|
|
||||||
|
let nextId = 0
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const toasts = useState<Toast[]>(TOAST_STATE_KEY, () => [])
|
||||||
|
|
||||||
|
function dismiss(id: number) {
|
||||||
|
toasts.value = toasts.value.filter((t) => t.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(type: ToastType, text: string, durationMs = DEFAULT_DURATION_MS) {
|
||||||
|
const id = ++nextId
|
||||||
|
toasts.value = [...toasts.value, { id, type, text }]
|
||||||
|
if (durationMs > 0 && import.meta.client) {
|
||||||
|
setTimeout(() => dismiss(id), durationMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
toasts: readonly(toasts),
|
||||||
|
success: (text: string, durationMs?: number) => show('success', text, durationMs),
|
||||||
|
error: (text: string, durationMs?: number) => show('error', text, durationMs),
|
||||||
|
info: (text: string, durationMs?: number) => show('info', text, durationMs),
|
||||||
|
dismiss,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,54 +1,103 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-screen flex flex-col bg-base-100 overflow-hidden">
|
<div class="h-screen flex flex-col bg-base-100 overflow-hidden">
|
||||||
<header class="navbar bg-base-100/80 backdrop-blur-xl border-b border-base-300/50 px-4 gap-2 shrink-0">
|
<header class="navbar relative z-[1100] bg-base-100/80 backdrop-blur-xl border-b border-base-300/50 px-4 gap-2 shrink-0">
|
||||||
<NuxtLink to="/" class="text-lg font-semibold hover:opacity-80 transition-all duration-200">{{ title }}</NuxtLink>
|
<NuxtLink to="/" class="flex items-center gap-2 text-lg font-semibold hover:opacity-80 transition-all duration-200">
|
||||||
|
<icons-icon-map class="size-6 text-primary shrink-0" aria-hidden="true" />
|
||||||
|
<span>{{ title }}</span>
|
||||||
|
</NuxtLink>
|
||||||
<div class="flex-1" />
|
<div class="flex-1" />
|
||||||
|
<!-- When not logged in: show Login link (except on login page) -->
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="!isLogin"
|
v-if="!me && !isLogin"
|
||||||
to="/"
|
to="/login"
|
||||||
class="btn btn-ghost btn-sm gap-1.5 transition-all duration-200 hover:scale-105"
|
class="btn btn-ghost btn-sm btn-primary"
|
||||||
:class="route.path === '/' ? 'btn-primary' : ''"
|
|
||||||
>
|
>
|
||||||
|
Login
|
||||||
|
</NuxtLink>
|
||||||
|
<!-- Connection status: pulsing dot + tooltip when logged in -->
|
||||||
|
<div
|
||||||
|
v-if="!isLogin && me"
|
||||||
|
class="tooltip tooltip-bottom shrink-0"
|
||||||
|
:data-tip="live ? 'Connected to live updates' : 'Disconnected'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 text-xs text-base-content/70"
|
||||||
|
:class="live ? 'text-success' : 'text-base-content/50'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="size-2 rounded-full"
|
||||||
|
:class="live ? 'bg-success animate-pulse' : 'bg-base-content/40'"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{{ live ? 'Live' : 'Offline' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- User dropdown when logged in: details/summary for reliable click-to-open -->
|
||||||
|
<div v-if="!isLogin && me" class="dropdown dropdown-end">
|
||||||
|
<details ref="userDropdownRef" class="dropdown group">
|
||||||
|
<summary class="btn btn-ghost btn-sm gap-2 cursor-pointer list-none [&::-webkit-details-marker]:hidden">
|
||||||
|
<div class="avatar placeholder">
|
||||||
|
<div class="bg-primary text-primary-content rounded-full w-8">
|
||||||
|
<span class="text-sm font-medium">{{ (me.username || '?')[0].toUpperCase() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="max-w-[8rem] truncate font-medium hidden sm:inline">{{ me.username }}</span>
|
||||||
|
<svg class="size-4 opacity-70 shrink-0 transition-transform group-open:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</summary>
|
||||||
|
<ul
|
||||||
|
class="dropdown-content menu bg-base-200 rounded-box z-[1100] mt-2 w-52 border border-base-300/50 shadow-xl p-2"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/" :class="route.path === '/' ? 'active' : ''" @click="closeDropdown">
|
||||||
<icons-icon-map />
|
<icons-icon-map />
|
||||||
Map
|
Map
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
</li>
|
||||||
v-if="!isLogin"
|
<li>
|
||||||
to="/profile"
|
<NuxtLink to="/profile" :class="route.path === '/profile' ? 'active' : ''" @click="closeDropdown">
|
||||||
class="btn btn-ghost btn-sm gap-1.5 transition-all duration-200 hover:scale-105"
|
|
||||||
:class="route.path === '/profile' ? 'btn-primary' : ''"
|
|
||||||
>
|
|
||||||
<icons-icon-user />
|
<icons-icon-user />
|
||||||
Profile
|
Profile
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
</li>
|
||||||
v-if="!isLogin && isAdmin"
|
<li v-if="isAdmin">
|
||||||
to="/admin"
|
<NuxtLink to="/admin" :class="route.path.startsWith('/admin') ? 'active' : ''" @click="closeDropdown">
|
||||||
class="btn btn-ghost btn-sm gap-1.5 transition-all duration-200 hover:scale-105"
|
|
||||||
:class="route.path.startsWith('/admin') ? 'btn-primary' : ''"
|
|
||||||
>
|
|
||||||
<icons-icon-shield />
|
<icons-icon-shield />
|
||||||
Admin
|
Admin
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<button
|
</li>
|
||||||
v-if="!isLogin && me"
|
<li class="menu-divider my-1" />
|
||||||
type="button"
|
<li class="[&_.menu-item]:flex [&_.menu-item]:items-center [&_.menu-item]:gap-3">
|
||||||
class="btn btn-ghost btn-sm btn-error btn-outline gap-1.5 transition-all duration-200 hover:scale-105"
|
<label class="flex cursor-pointer items-center gap-3 py-2 px-2 rounded-lg hover:bg-base-300/50 w-full min-h-0">
|
||||||
@click="doLogout"
|
<icons-icon-sun v-if="!dark" class="size-4 shrink-0 opacity-80" />
|
||||||
>
|
<icons-icon-moon v-else class="size-4 shrink-0 opacity-80" />
|
||||||
|
<span class="flex-1 text-sm">Тёмная тема</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="toggle toggle-sm toggle-primary shrink-0"
|
||||||
|
:checked="dark"
|
||||||
|
@change="onThemeToggle"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button type="button" class="text-error hover:bg-error/10 rounded-lg" @click="doLogout">
|
||||||
<icons-icon-logout />
|
<icons-icon-logout />
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
<label class="swap swap-rotate btn btn-ghost btn-sm transition-all duration-200 hover:scale-105">
|
</li>
|
||||||
<input type="checkbox" v-model="dark" @change="toggleTheme" />
|
</ul>
|
||||||
<span class="swap-off"><icons-icon-sun /></span>
|
</details>
|
||||||
<span class="swap-on"><icons-icon-moon /></span>
|
</div>
|
||||||
</label>
|
|
||||||
<span v-if="live" class="badge badge-success badge-sm">Live</span>
|
|
||||||
</header>
|
</header>
|
||||||
<main class="flex-1 min-h-0 overflow-y-auto relative">
|
<main class="flex-1 min-h-0 overflow-y-auto relative flex flex-col">
|
||||||
|
<AdminBreadcrumbs v-if="route.path.startsWith('/admin')" />
|
||||||
|
<div class="flex-1 min-h-0">
|
||||||
<slot />
|
<slot />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -71,6 +120,11 @@ const title = ref('HnH Map')
|
|||||||
const dark = ref(false)
|
const dark = ref(false)
|
||||||
const live = ref(false)
|
const live = ref(false)
|
||||||
const me = ref<{ username?: string; auths?: string[] } | null>(null)
|
const me = ref<{ username?: string; auths?: string[] } | null>(null)
|
||||||
|
const userDropdownRef = ref<HTMLDetailsElement | null>(null)
|
||||||
|
|
||||||
|
function closeDropdown() {
|
||||||
|
userDropdownRef.value?.removeAttribute('open')
|
||||||
|
}
|
||||||
|
|
||||||
const { isLoginPath } = useAppPaths()
|
const { isLoginPath } = useAppPaths()
|
||||||
const isLogin = computed(() => isLoginPath(route.path))
|
const isLogin = computed(() => isLoginPath(route.path))
|
||||||
@@ -95,8 +149,7 @@ async function loadConfig() {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
dark.value = getInitialDark()
|
dark.value = getInitialDark()
|
||||||
const html = document.documentElement
|
applyTheme()
|
||||||
html.setAttribute('data-theme', dark.value ? 'dark' : 'light')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -107,15 +160,17 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
function toggleTheme() {
|
function onThemeToggle() {
|
||||||
const html = document.documentElement
|
dark.value = !dark.value
|
||||||
if (dark.value) {
|
applyTheme()
|
||||||
html.setAttribute('data-theme', 'dark')
|
|
||||||
localStorage.setItem(THEME_KEY, 'dark')
|
|
||||||
} else {
|
|
||||||
html.setAttribute('data-theme', 'light')
|
|
||||||
localStorage.setItem(THEME_KEY, 'light')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyTheme() {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
const html = document.documentElement
|
||||||
|
const theme = dark.value ? 'dark' : 'light'
|
||||||
|
html.setAttribute('data-theme', theme)
|
||||||
|
localStorage.setItem(THEME_KEY, theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doLogout() {
|
async function doLogout() {
|
||||||
|
|||||||
@@ -70,12 +70,29 @@ export const GridCoordLayer = L.GridLayer.extend({
|
|||||||
},
|
},
|
||||||
}) as unknown as new (options?: GridCoordLayerOptions) => L.GridLayer
|
}) as unknown as new (options?: GridCoordLayerOptions) => L.GridLayer
|
||||||
|
|
||||||
|
export interface ImageIconOptions extends L.IconOptions {
|
||||||
|
/** When the main icon image fails to load, use this URL (e.g. data URL or default marker). */
|
||||||
|
fallbackIconUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
export const ImageIcon = L.Icon.extend({
|
export const ImageIcon = L.Icon.extend({
|
||||||
options: {
|
options: {
|
||||||
iconSize: [32, 32],
|
iconSize: [32, 32],
|
||||||
iconAnchor: [16, 16],
|
iconAnchor: [16, 16],
|
||||||
|
} as ImageIconOptions,
|
||||||
|
|
||||||
|
createIcon(oldIcon?: HTMLElement): HTMLElement {
|
||||||
|
const img = L.Icon.prototype.createIcon.call(this, oldIcon) as HTMLImageElement
|
||||||
|
const fallback = (this.options as ImageIconOptions).fallbackIconUrl
|
||||||
|
if (fallback && img && img.tagName === 'IMG') {
|
||||||
|
img.onerror = () => {
|
||||||
|
img.onerror = null
|
||||||
|
img.src = fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return img
|
||||||
},
|
},
|
||||||
}) as unknown as new (options?: L.IconOptions) => L.Icon
|
}) as unknown as new (options?: ImageIconOptions) => L.Icon
|
||||||
|
|
||||||
const latNormalization = (90.0 * TileSize) / 2500000.0
|
const latNormalization = (90.0 * TileSize) / 2500000.0
|
||||||
const lngNormalization = (180.0 * TileSize) / 2500000.0
|
const lngNormalization = (180.0 * TileSize) / 2500000.0
|
||||||
|
|||||||
@@ -41,7 +41,14 @@ function detectType(name: string): string {
|
|||||||
return name.substring('gfx/terobjs/mm/'.length)
|
return name.substring('gfx/terobjs/mm/'.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMarker(data: MarkerData): MapMarker {
|
export interface MarkerIconOptions {
|
||||||
|
/** Resolves relative icon path to absolute URL (e.g. with app base path). */
|
||||||
|
resolveIconUrl: (path: string) => string
|
||||||
|
/** Optional fallback URL when the icon image fails to load. */
|
||||||
|
fallbackIconUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMarker(data: MarkerData, iconOptions?: MarkerIconOptions): MapMarker {
|
||||||
let leafletMarker: L.Marker | null = null
|
let leafletMarker: L.Marker | null = null
|
||||||
let onClick: ((e: L.LeafletMouseEvent) => void) | null = null
|
let onClick: ((e: L.LeafletMouseEvent) => void) | null = null
|
||||||
let onContext: ((e: L.LeafletMouseEvent) => void) | null = null
|
let onContext: ((e: L.LeafletMouseEvent) => void) | null = null
|
||||||
@@ -70,17 +77,24 @@ export function createMarker(data: MarkerData): MapMarker {
|
|||||||
|
|
||||||
add(mapview: MapViewRef): void {
|
add(mapview: MapViewRef): void {
|
||||||
if (!marker.hidden) {
|
if (!marker.hidden) {
|
||||||
|
const resolve = iconOptions?.resolveIconUrl ?? ((path: string) => path)
|
||||||
|
const fallback = iconOptions?.fallbackIconUrl
|
||||||
let icon: L.Icon
|
let icon: L.Icon
|
||||||
if (marker.image === 'gfx/terobjs/mm/custom') {
|
if (marker.image === 'gfx/terobjs/mm/custom') {
|
||||||
icon = new ImageIcon({
|
icon = new ImageIcon({
|
||||||
iconUrl: 'gfx/terobjs/mm/custom.png',
|
iconUrl: resolve('gfx/terobjs/mm/custom.png'),
|
||||||
iconSize: [21, 23],
|
iconSize: [21, 23],
|
||||||
iconAnchor: [11, 21],
|
iconAnchor: [11, 21],
|
||||||
popupAnchor: [1, 3],
|
popupAnchor: [1, 3],
|
||||||
tooltipAnchor: [1, 3],
|
tooltipAnchor: [1, 3],
|
||||||
|
fallbackIconUrl: fallback,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
icon = new ImageIcon({ iconUrl: `${marker.image}.png`, iconSize: [32, 32] })
|
icon = new ImageIcon({
|
||||||
|
iconUrl: resolve(`${marker.image}.png`),
|
||||||
|
iconSize: [32, 32],
|
||||||
|
fallbackIconUrl: fallback,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const position = mapview.map.unproject([marker.position.x, marker.position.y], HnHMaxZoom)
|
const position = mapview.map.unproject([marker.position.x, marker.position.y], HnHMaxZoom)
|
||||||
|
|||||||
@@ -2,13 +2,47 @@
|
|||||||
<div class="container mx-auto p-4 max-w-2xl">
|
<div class="container mx-auto p-4 max-w-2xl">
|
||||||
<h1 class="text-2xl font-bold mb-6">Admin</h1>
|
<h1 class="text-2xl font-bold mb-6">Admin</h1>
|
||||||
|
|
||||||
<div
|
<template v-if="loading">
|
||||||
v-if="message.text"
|
<div class="card bg-base-200 shadow-xl mb-6">
|
||||||
class="mb-4 rounded-lg px-4 py-2 transition-all duration-300"
|
<div class="card-body">
|
||||||
:class="message.type === 'error' ? 'bg-error/20 text-error' : 'bg-success/20 text-success'"
|
<Skeleton class="h-6 w-24 mb-4" />
|
||||||
role="alert"
|
<div class="flex flex-col gap-2">
|
||||||
>
|
<Skeleton v-for="i in 3" :key="i" class="h-12 w-full" />
|
||||||
{{ message.text }}
|
<Skeleton class="h-9 w-28 mt-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-base-200 shadow-xl mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<Skeleton class="h-6 w-20 mb-4" />
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<Skeleton class="h-32 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card bg-base-200 shadow-xl mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<Skeleton class="h-6 w-16 mb-4" />
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<Skeleton class="h-10 w-48" />
|
||||||
|
<Skeleton class="h-10 w-48" />
|
||||||
|
<Skeleton class="h-8 w-36" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
|
||||||
|
<div class="stat bg-base-200 rounded-xl shadow border border-base-300/50 py-4 px-4">
|
||||||
|
<div class="stat-title text-xs">Users</div>
|
||||||
|
<div class="stat-value text-2xl">{{ users.length }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-base-200 rounded-xl shadow border border-base-300/50 py-4 px-4">
|
||||||
|
<div class="stat-title text-xs">Maps</div>
|
||||||
|
<div class="stat-value text-2xl">{{ maps.length }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card bg-base-200 shadow-xl mb-6 transition-all duration-200">
|
<div class="card bg-base-200 shadow-xl mb-6 transition-all duration-200">
|
||||||
@@ -17,9 +51,18 @@
|
|||||||
<icons-icon-users />
|
<icons-icon-users />
|
||||||
Users
|
Users
|
||||||
</h2>
|
</h2>
|
||||||
|
<div class="form-control w-full max-w-xs mb-3">
|
||||||
|
<input
|
||||||
|
v-model="userSearch"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search users…"
|
||||||
|
class="input input-sm input-bordered w-full"
|
||||||
|
aria-label="Search users"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div
|
<div
|
||||||
v-for="u in users"
|
v-for="u in filteredUsers"
|
||||||
:key="u"
|
:key="u"
|
||||||
class="flex items-center justify-between gap-3 w-full p-3 rounded-lg bg-base-300/50 hover:bg-base-300/70 transition-colors"
|
class="flex items-center justify-between gap-3 w-full p-3 rounded-lg bg-base-300/50 hover:bg-base-300/70 transition-colors"
|
||||||
>
|
>
|
||||||
@@ -35,8 +78,8 @@
|
|||||||
<icons-icon-pencil /> Edit
|
<icons-icon-pencil /> Edit
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!users.length" class="py-6 text-center text-base-content/60 rounded-lg bg-base-300/30">
|
<div v-if="!filteredUsers.length" class="py-6 text-center text-base-content/60 rounded-lg bg-base-300/30">
|
||||||
No users yet.
|
{{ userSearch ? 'No users match.' : 'No users yet.' }}
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/admin/users/new" class="btn btn-primary btn-sm mt-2 gap-1">
|
<NuxtLink to="/admin/users/new" class="btn btn-primary btn-sm mt-2 gap-1">
|
||||||
<icons-icon-plus /> Add user
|
<icons-icon-plus /> Add user
|
||||||
@@ -51,13 +94,38 @@
|
|||||||
<icons-icon-map-pin />
|
<icons-icon-map-pin />
|
||||||
Maps
|
Maps
|
||||||
</h2>
|
</h2>
|
||||||
|
<div class="form-control w-full max-w-xs mb-3">
|
||||||
|
<input
|
||||||
|
v-model="mapSearch"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search maps…"
|
||||||
|
class="input input-sm input-bordered w-full"
|
||||||
|
aria-label="Search maps"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table table-sm table-zebra">
|
<table class="table table-sm table-zebra">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>ID</th><th>Name</th><th>Hidden</th><th>Priority</th><th class="text-right"></th></tr>
|
<tr>
|
||||||
|
<th>
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs gap-0.5" :class="mapsSortBy === 'ID' ? 'btn-active' : ''" @click="setMapsSort('ID')">
|
||||||
|
ID
|
||||||
|
<span v-if="mapsSortBy === 'ID'" class="opacity-70">{{ mapsSortDir === 'asc' ? '↑' : '↓' }}</span>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs gap-0.5" :class="mapsSortBy === 'Name' ? 'btn-active' : ''" @click="setMapsSort('Name')">
|
||||||
|
Name
|
||||||
|
<span v-if="mapsSortBy === 'Name'" class="opacity-70">{{ mapsSortDir === 'asc' ? '↑' : '↓' }}</span>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th>Hidden</th>
|
||||||
|
<th>Priority</th>
|
||||||
|
<th class="text-right"></th>
|
||||||
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="map in maps" :key="map.ID" class="hover:bg-base-300">
|
<tr v-for="map in sortedFilteredMaps" :key="map.ID" class="hover:bg-base-300">
|
||||||
<td>{{ map.ID }}</td>
|
<td>{{ map.ID }}</td>
|
||||||
<td>{{ map.Name }}</td>
|
<td>{{ map.Name }}</td>
|
||||||
<td>{{ map.Hidden ? 'Yes' : 'No' }}</td>
|
<td>{{ map.Hidden ? 'Yes' : 'No' }}</td>
|
||||||
@@ -68,8 +136,8 @@
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="!maps.length">
|
<tr v-if="!sortedFilteredMaps.length">
|
||||||
<td colspan="5" class="text-base-content/60">No maps.</td>
|
<td colspan="5" class="text-base-content/60">{{ mapSearch ? 'No maps match.' : 'No maps.' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -136,7 +204,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button class="btn btn-sm" :disabled="rebuilding" @click="rebuildZooms">
|
<button class="btn btn-sm" :disabled="rebuilding" @click="confirmRebuildZooms">
|
||||||
<span v-if="rebuilding" class="loading loading-spinner loading-sm" />
|
<span v-if="rebuilding" class="loading loading-spinner loading-sm" />
|
||||||
<span v-else>Rebuild zooms</span>
|
<span v-else>Rebuild zooms</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -169,6 +237,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress indicator for long operations -->
|
||||||
|
<div
|
||||||
|
v-if="rebuilding || merging || wiping"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-base-100/80 backdrop-blur-sm"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-busy="true"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center gap-4 p-6 rounded-xl bg-base-200 border border-base-300 shadow-2xl">
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary" />
|
||||||
|
<p class="font-medium">
|
||||||
|
{{ rebuilding ? 'Rebuilding zooms…' : merging ? 'Merging…' : 'Wiping data…' }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-base-content/70">This may take a moment.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<dialog ref="wipeModalRef" class="modal" aria-labelledby="wipe-modal-title">
|
<dialog ref="wipeModalRef" class="modal" aria-labelledby="wipe-modal-title">
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h2 id="wipe-modal-title" class="font-bold text-lg mb-2">Confirm wipe</h2>
|
<h2 id="wipe-modal-title" class="font-bold text-lg mb-2">Confirm wipe</h2>
|
||||||
@@ -182,6 +266,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<form method="dialog" class="modal-backdrop"><button aria-label="Close">Close</button></form>
|
<form method="dialog" class="modal-backdrop"><button aria-label="Close">Close</button></form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<dialog ref="rebuildModalRef" class="modal" aria-labelledby="rebuild-modal-title">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h2 id="rebuild-modal-title" class="font-bold text-lg mb-2">Rebuild zooms</h2>
|
||||||
|
<p>Rebuild tile zoom levels for all maps? This may take a while.</p>
|
||||||
|
<div class="modal-action">
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn">Cancel</button>
|
||||||
|
</form>
|
||||||
|
<button class="btn btn-primary" :disabled="rebuilding" @click="doRebuildZooms">Rebuild</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop"><button aria-label="Close">Close</button></form>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -189,6 +288,7 @@
|
|||||||
definePageMeta({ middleware: 'admin' })
|
definePageMeta({ middleware: 'admin' })
|
||||||
|
|
||||||
const api = useMapApi()
|
const api = useMapApi()
|
||||||
|
const toast = useToast()
|
||||||
const users = ref<string[]>([])
|
const users = ref<string[]>([])
|
||||||
const maps = ref<Array<{ ID: number; Name: string; Hidden: boolean; Priority: boolean }>>([])
|
const maps = ref<Array<{ ID: number; Name: string; Hidden: boolean; Priority: boolean }>>([])
|
||||||
const settings = ref({ prefix: '', defaultHide: false, title: '' })
|
const settings = ref({ prefix: '', defaultHide: false, title: '' })
|
||||||
@@ -199,20 +299,53 @@ const merging = ref(false)
|
|||||||
const mergeFile = ref<File | null>(null)
|
const mergeFile = ref<File | null>(null)
|
||||||
const mergeFileRef = ref<HTMLInputElement | null>(null)
|
const mergeFileRef = ref<HTMLInputElement | null>(null)
|
||||||
const wipeModalRef = ref<HTMLDialogElement | null>(null)
|
const wipeModalRef = ref<HTMLDialogElement | null>(null)
|
||||||
|
const rebuildModalRef = ref<HTMLDialogElement | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const userSearch = ref('')
|
||||||
|
const mapSearch = ref('')
|
||||||
|
const mapsSortBy = ref<'ID' | 'Name'>('ID')
|
||||||
|
const mapsSortDir = ref<'asc' | 'desc'>('asc')
|
||||||
|
|
||||||
const message = ref<{ type: 'success' | 'error'; text: string }>({ type: 'success', text: '' })
|
const filteredUsers = computed(() => {
|
||||||
let messageTimeout: ReturnType<typeof setTimeout> | null = null
|
const q = userSearch.value.trim().toLowerCase()
|
||||||
function setMessage(type: 'success' | 'error', text: string) {
|
if (!q) return users.value
|
||||||
message.value = { type, text }
|
return users.value.filter((u) => u.toLowerCase().includes(q))
|
||||||
if (messageTimeout) clearTimeout(messageTimeout)
|
})
|
||||||
messageTimeout = setTimeout(() => {
|
|
||||||
message.value = { type: 'success', text: '' }
|
const filteredMaps = computed(() => {
|
||||||
messageTimeout = null
|
const q = mapSearch.value.trim().toLowerCase()
|
||||||
}, 4000)
|
if (!q) return maps.value
|
||||||
|
return maps.value.filter(
|
||||||
|
(m) =>
|
||||||
|
m.Name.toLowerCase().includes(q) ||
|
||||||
|
String(m.ID).includes(q)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortedFilteredMaps = computed(() => {
|
||||||
|
const list = [...filteredMaps.value]
|
||||||
|
const by = mapsSortBy.value
|
||||||
|
const dir = mapsSortDir.value
|
||||||
|
list.sort((a, b) => {
|
||||||
|
let cmp = 0
|
||||||
|
if (by === 'ID') cmp = a.ID - b.ID
|
||||||
|
else cmp = a.Name.localeCompare(b.Name)
|
||||||
|
return dir === 'asc' ? cmp : -cmp
|
||||||
|
})
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
function setMapsSort(by: 'ID' | 'Name') {
|
||||||
|
if (mapsSortBy.value === by) mapsSortDir.value = mapsSortDir.value === 'asc' ? 'desc' : 'asc'
|
||||||
|
else {
|
||||||
|
mapsSortBy.value = by
|
||||||
|
mapsSortDir.value = 'asc'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadUsers(), loadMaps(), loadSettings()])
|
await Promise.all([loadUsers(), loadMaps(), loadSettings()])
|
||||||
|
loading.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
@@ -244,21 +377,26 @@ async function saveSettings() {
|
|||||||
savingSettings.value = true
|
savingSettings.value = true
|
||||||
try {
|
try {
|
||||||
await api.adminSettingsPost(settings.value)
|
await api.adminSettingsPost(settings.value)
|
||||||
setMessage('success', 'Settings saved.')
|
toast.success('Settings saved.')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMessage('error', (e as Error)?.message ?? 'Failed to save settings.')
|
toast.error((e as Error)?.message ?? 'Failed to save settings.')
|
||||||
} finally {
|
} finally {
|
||||||
savingSettings.value = false
|
savingSettings.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rebuildZooms() {
|
function confirmRebuildZooms() {
|
||||||
|
rebuildModalRef.value?.showModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRebuildZooms() {
|
||||||
|
rebuildModalRef.value?.close()
|
||||||
rebuilding.value = true
|
rebuilding.value = true
|
||||||
try {
|
try {
|
||||||
await api.adminRebuildZooms()
|
await api.adminRebuildZooms()
|
||||||
setMessage('success', 'Zooms rebuilt.')
|
toast.success('Zooms rebuilt.')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMessage('error', (e as Error)?.message ?? 'Failed to rebuild zooms.')
|
toast.error((e as Error)?.message ?? 'Failed to rebuild zooms.')
|
||||||
} finally {
|
} finally {
|
||||||
rebuilding.value = false
|
rebuilding.value = false
|
||||||
}
|
}
|
||||||
@@ -274,9 +412,9 @@ async function doWipe() {
|
|||||||
await api.adminWipe()
|
await api.adminWipe()
|
||||||
wipeModalRef.value?.close()
|
wipeModalRef.value?.close()
|
||||||
await loadMaps()
|
await loadMaps()
|
||||||
setMessage('success', 'All data wiped.')
|
toast.success('All data wiped.')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMessage('error', (e as Error)?.message ?? 'Failed to wipe.')
|
toast.error((e as Error)?.message ?? 'Failed to wipe.')
|
||||||
} finally {
|
} finally {
|
||||||
wiping.value = false
|
wiping.value = false
|
||||||
}
|
}
|
||||||
@@ -297,11 +435,12 @@ async function doMerge() {
|
|||||||
mergeFile.value = null
|
mergeFile.value = null
|
||||||
if (mergeFileRef.value) mergeFileRef.value.value = ''
|
if (mergeFileRef.value) mergeFileRef.value.value = ''
|
||||||
await loadMaps()
|
await loadMaps()
|
||||||
setMessage('success', 'Merge completed.')
|
toast.success('Merge completed.')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMessage('error', (e as Error)?.message ?? 'Merge failed.')
|
toast.error((e as Error)?.message ?? 'Merge failed.')
|
||||||
} finally {
|
} finally {
|
||||||
merging.value = false
|
merging.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -48,8 +48,10 @@ const mapsLoaded = ref(false)
|
|||||||
const form = ref({ name: '', hidden: false, priority: false })
|
const form = ref({ name: '', hidden: false, priority: false })
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const adminMapName = useState<string | null>('admin-breadcrumb-map-name', () => null)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
adminMapName.value = null
|
||||||
try {
|
try {
|
||||||
const maps = await api.adminMaps()
|
const maps = await api.adminMaps()
|
||||||
mapsLoaded.value = true
|
mapsLoaded.value = true
|
||||||
@@ -57,6 +59,7 @@ onMounted(async () => {
|
|||||||
if (found) {
|
if (found) {
|
||||||
map.value = found
|
map.value = found
|
||||||
form.value = { name: found.Name, hidden: found.Hidden, priority: found.Priority }
|
form.value = { name: found.Name, hidden: found.Hidden, priority: found.Priority }
|
||||||
|
adminMapName.value = found.Name
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
mapsLoaded.value = true
|
mapsLoaded.value = true
|
||||||
@@ -64,6 +67,10 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
adminMapName.value = null
|
||||||
|
})
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!map.value) return
|
if (!map.value) return
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
|||||||
@@ -2,23 +2,75 @@
|
|||||||
<div class="container mx-auto p-4 max-w-2xl">
|
<div class="container mx-auto p-4 max-w-2xl">
|
||||||
<h1 class="text-2xl font-bold mb-6">Profile</h1>
|
<h1 class="text-2xl font-bold mb-6">Profile</h1>
|
||||||
|
|
||||||
|
<!-- User info card -->
|
||||||
<div class="card bg-base-200 shadow-xl mb-6 transition-all duration-200">
|
<div class="card bg-base-200 shadow-xl mb-6 transition-all duration-200">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
<template v-if="initialLoad">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<Skeleton class="size-14 rounded-full shrink-0" />
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Skeleton class="h-6 w-32" />
|
||||||
|
<Skeleton class="h-4 w-48" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="me">
|
||||||
|
<div class="flex flex-wrap items-center gap-4">
|
||||||
|
<div class="avatar placeholder">
|
||||||
|
<div class="bg-primary text-primary-content rounded-full w-14">
|
||||||
|
<span class="text-2xl font-semibold">{{ (me.username || '?')[0].toUpperCase() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h2 class="text-lg font-semibold">{{ me.username }}</h2>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<span
|
||||||
|
v-for="auth in (me.auths ?? [])"
|
||||||
|
:key="auth"
|
||||||
|
class="badge badge-sm badge-outline"
|
||||||
|
>
|
||||||
|
{{ auth }}
|
||||||
|
</span>
|
||||||
|
<span v-if="!me.auths?.length" class="text-sm text-base-content/60">No roles</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload tokens -->
|
||||||
|
<div class="card bg-base-200 shadow-xl mb-6 transition-all duration-200">
|
||||||
|
<div class="card-body">
|
||||||
|
<template v-if="initialLoad">
|
||||||
|
<Skeleton class="h-6 w-32 mb-2" />
|
||||||
|
<Skeleton class="h-4 w-full mb-4" />
|
||||||
|
<Skeleton class="h-8 w-full mb-2" />
|
||||||
|
<Skeleton class="h-9 w-36 mt-2" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
<h2 class="card-title gap-2">
|
<h2 class="card-title gap-2">
|
||||||
<icons-icon-key />
|
<icons-icon-key />
|
||||||
Upload tokens
|
Upload tokens
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm opacity-80">Tokens for upload API. Generate and copy as needed.</p>
|
<p class="text-sm opacity-80">Tokens for upload API. Generate and copy as needed.</p>
|
||||||
<ul v-if="tokens?.length" class="list-disc list-inside mt-2 space-y-1">
|
<ul v-if="tokens?.length" class="list-none mt-2 space-y-2">
|
||||||
<li v-for="t in tokens" :key="t" class="font-mono text-sm flex items-center gap-2">
|
<li
|
||||||
<span class="break-all">{{ uploadTokenDisplay(t) }}</span>
|
v-for="(t, idx) in tokens"
|
||||||
|
:key="t"
|
||||||
|
class="font-mono text-sm flex flex-wrap items-center gap-2 p-2 rounded-lg bg-base-300/50"
|
||||||
|
>
|
||||||
|
<span class="break-all flex-1 min-w-0">{{ uploadTokenDisplay(t) }}</span>
|
||||||
|
<span class="text-xs text-base-content/60 shrink-0">Token {{ idx + 1 }}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost btn-xs shrink-0"
|
class="btn btn-ghost btn-xs shrink-0 gap-1"
|
||||||
aria-label="Copy token"
|
aria-label="Copy token"
|
||||||
|
:class="copiedToken === t ? 'btn-success' : ''"
|
||||||
@click="copyToken(t)"
|
@click="copyToken(t)"
|
||||||
>
|
>
|
||||||
Copy
|
<template v-if="copiedToken === t">Copied!</template>
|
||||||
|
<template v-else>Copy</template>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -27,6 +79,7 @@
|
|||||||
<button class="btn btn-primary btn-sm mt-2 transition-all duration-200 hover:scale-[1.02]" :disabled="loadingTokens" @click="generateToken">
|
<button class="btn btn-primary btn-sm mt-2 transition-all duration-200 hover:scale-[1.02]" :disabled="loadingTokens" @click="generateToken">
|
||||||
{{ loadingTokens ? '…' : 'Generate token' }}
|
{{ loadingTokens ? '…' : 'Generate token' }}
|
||||||
</button>
|
</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -52,6 +105,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const api = useMapApi()
|
const api = useMapApi()
|
||||||
|
const toast = useToast()
|
||||||
|
const initialLoad = ref(true)
|
||||||
|
const me = ref<{ username?: string; auths?: string[] } | null>(null)
|
||||||
const tokens = ref<string[]>([])
|
const tokens = ref<string[]>([])
|
||||||
const uploadPrefix = ref('')
|
const uploadPrefix = ref('')
|
||||||
const newPass = ref('')
|
const newPass = ref('')
|
||||||
@@ -60,24 +116,41 @@ const loadingPass = ref(false)
|
|||||||
const passMsg = ref('')
|
const passMsg = ref('')
|
||||||
const passOk = ref(false)
|
const passOk = ref(false)
|
||||||
const tokenError = ref('')
|
const tokenError = ref('')
|
||||||
|
const copiedToken = ref<string | null>(null)
|
||||||
|
let copiedTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
function uploadTokenDisplay(token: string): string {
|
function uploadTokenDisplay(token: string): string {
|
||||||
const base = (uploadPrefix.value ?? '').replace(/\/+$/, '')
|
const base = (uploadPrefix.value ?? '').replace(/\/+$/, '')
|
||||||
return base ? `${base}/client/${token}` : `client/${token}`
|
return base ? `${base}/client/${token}` : `client/${token}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyToken(token: string) {
|
async function copyToken(token: string) {
|
||||||
navigator.clipboard.writeText(uploadTokenDisplay(token)).catch(() => {})
|
const text = uploadTokenDisplay(token)
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
copiedToken.value = token
|
||||||
|
toast.success('Copied to clipboard', 2000)
|
||||||
|
if (copiedTimeout) clearTimeout(copiedTimeout)
|
||||||
|
copiedTimeout = setTimeout(() => {
|
||||||
|
copiedToken.value = null
|
||||||
|
}, 2000)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to copy')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const me = await api.me()
|
const data = await api.me()
|
||||||
tokens.value = me.tokens ?? []
|
me.value = { username: data.username, auths: data.auths }
|
||||||
uploadPrefix.value = me.prefix ?? ''
|
tokens.value = data.tokens ?? []
|
||||||
|
uploadPrefix.value = data.prefix ?? ''
|
||||||
} catch {
|
} catch {
|
||||||
|
me.value = null
|
||||||
tokens.value = []
|
tokens.value = []
|
||||||
uploadPrefix.value = ''
|
uploadPrefix.value = ''
|
||||||
|
} finally {
|
||||||
|
initialLoad.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user