Enhance frontend components and introduce new features
- Updated PasswordInput component with improved styling and touch manipulation support. - Added new IconMenu component for consistent icon representation in the UI. - Refactored MapControls and introduced MapControlsContent for better organization and usability. - Implemented suppress-leaflet-deprecation plugin to handle known warnings in Firefox. - Enhanced default layout with a responsive drawer for mobile navigation and improved user experience.
This commit is contained in:
@@ -1,49 +1,49 @@
|
|||||||
<template>
|
<template>
|
||||||
<fieldset class="fieldset">
|
<fieldset class="fieldset">
|
||||||
<label v-if="label" class="label" :for="inputId">
|
<label v-if="label" class="label" :for="inputId">
|
||||||
<span>{{ label }}</span>
|
<span>{{ label }}</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="relative flex">
|
<div class="relative flex">
|
||||||
<input
|
<input
|
||||||
:id="inputId"
|
:id="inputId"
|
||||||
:value="modelValue"
|
:value="modelValue"
|
||||||
:type="showPass ? 'text' : 'password'"
|
:type="showPass ? 'text' : 'password'"
|
||||||
class="input flex-1 pr-10"
|
class="input flex-1 pr-10 min-h-11 touch-manipulation"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:required="required"
|
:required="required"
|
||||||
:autocomplete="autocomplete"
|
:autocomplete="autocomplete"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-sm btn-square min-h-0 h-8 w-8"
|
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-sm btn-square min-h-9 min-w-9 touch-manipulation"
|
||||||
:aria-label="showPass ? 'Hide password' : 'Show password'"
|
:aria-label="showPass ? 'Hide password' : 'Show password'"
|
||||||
@click="showPass = !showPass"
|
@click="showPass = !showPass"
|
||||||
>
|
>
|
||||||
<icons-icon-eye-off v-if="showPass" />
|
<icons-icon-eye-off v-if="showPass" />
|
||||||
<icons-icon-eye v-else />
|
<icons-icon-eye v-else />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
modelValue: string
|
modelValue: string
|
||||||
label?: string
|
label?: string
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
required?: boolean
|
required?: boolean
|
||||||
autocomplete?: string
|
autocomplete?: string
|
||||||
readonly?: boolean
|
readonly?: boolean
|
||||||
inputId?: string
|
inputId?: string
|
||||||
}>(),
|
}>(),
|
||||||
{ required: false, autocomplete: 'off', inputId: undefined }
|
{ required: false, autocomplete: 'off', inputId: undefined }
|
||||||
)
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||||
|
|
||||||
const showPass = ref(false)
|
const showPass = ref(false)
|
||||||
const inputId = computed(() => props.inputId ?? `password-${Math.random().toString(36).slice(2, 9)}`)
|
const inputId = computed(() => props.inputId ?? `password-${Math.random().toString(36).slice(2, 9)}`)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
7
frontend-nuxt/components/icons/IconMenu.vue
Normal file
7
frontend-nuxt/components/icons/IconMenu.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6" />
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12" />
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@@ -1,218 +1,255 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<!-- Desktop: left panel (md and up) -->
|
||||||
class="absolute left-3 top-[10%] z-[502] flex min-w-[3rem] min-h-[3rem] transition-all duration-300 ease-out"
|
<div
|
||||||
:class="panelCollapsed ? 'w-12' : 'w-64'"
|
class="absolute left-3 top-[10%] z-[502] hidden md:flex min-w-[3rem] min-h-[3rem] transition-all duration-300 ease-out"
|
||||||
>
|
:class="panelCollapsed ? 'w-12' : 'w-64'"
|
||||||
<div
|
>
|
||||||
class="rounded-xl bg-base-100/80 backdrop-blur-xl border border-base-300/50 shadow-xl overflow-hidden flex flex-col transition-all duration-300 ease-out"
|
<div
|
||||||
:class="panelCollapsed ? 'w-12 items-center py-2 gap-1' : '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'"
|
||||||
<!-- Collapsed: zoom in/out + expand -->
|
>
|
||||||
<template v-if="panelCollapsed">
|
<template v-if="panelCollapsed">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost btn-sm btn-square transition-all duration-200 hover:scale-105"
|
class="btn btn-ghost btn-sm btn-square transition-all duration-200 hover:scale-105"
|
||||||
title="Zoom in"
|
title="Zoom in"
|
||||||
aria-label="Zoom in"
|
aria-label="Zoom in"
|
||||||
@click="$emit('zoomIn')"
|
@click="$emit('zoomIn')"
|
||||||
>
|
>
|
||||||
<icons-icon-zoom-in class="size-4" />
|
<icons-icon-zoom-in class="size-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost btn-sm btn-square transition-all duration-200 hover:scale-105"
|
class="btn btn-ghost btn-sm btn-square transition-all duration-200 hover:scale-105"
|
||||||
title="Zoom out"
|
title="Zoom out"
|
||||||
aria-label="Zoom out"
|
aria-label="Zoom out"
|
||||||
@click="$emit('zoomOut')"
|
@click="$emit('zoomOut')"
|
||||||
>
|
>
|
||||||
<icons-icon-zoom-out class="size-4" />
|
<icons-icon-zoom-out class="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<!-- Expanded: full content with transition -->
|
<Transition name="panel-slide" mode="out-in">
|
||||||
<Transition name="panel-slide" mode="out-in">
|
<div
|
||||||
<div
|
v-if="!panelCollapsed"
|
||||||
v-if="!panelCollapsed"
|
key="expanded"
|
||||||
key="expanded"
|
class="flex flex-col p-4 gap-4 flex-1 min-w-0 overflow-hidden"
|
||||||
class="flex flex-col p-4 gap-4 flex-1 min-w-0 overflow-hidden"
|
>
|
||||||
>
|
<MapControlsContent
|
||||||
<!-- Zoom -->
|
v-model:show-grid-coordinates="showGridCoordinates"
|
||||||
<section class="flex flex-col gap-2">
|
v-model:hide-markers="hideMarkers"
|
||||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70 flex items-center gap-1.5">
|
:selected-map-id-select="selectedMapIdSelect"
|
||||||
<icons-icon-zoom-in class="size-3.5 opacity-80" aria-hidden="true" />
|
@update:selected-map-id-select="(v) => (selectedMapIdSelect = v)"
|
||||||
Zoom
|
:overlay-map-id="overlayMapId"
|
||||||
</h3>
|
@update:overlay-map-id="(v) => (overlayMapId = v)"
|
||||||
<div class="flex items-center gap-2">
|
:selected-marker-id-select="selectedMarkerIdSelect"
|
||||||
<button
|
@update:selected-marker-id-select="(v) => (selectedMarkerIdSelect = v)"
|
||||||
type="button"
|
:selected-player-id-select="selectedPlayerIdSelect"
|
||||||
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105"
|
@update:selected-player-id-select="(v) => (selectedPlayerIdSelect = v)"
|
||||||
title="Zoom in"
|
:maps="maps"
|
||||||
aria-label="Zoom in"
|
:quest-givers="questGivers"
|
||||||
@click="$emit('zoomIn')"
|
:players="players"
|
||||||
>
|
@zoom-in="$emit('zoomIn')"
|
||||||
<icons-icon-zoom-in />
|
@zoom-out="$emit('zoomOut')"
|
||||||
</button>
|
@reset-view="$emit('resetView')"
|
||||||
<button
|
/>
|
||||||
type="button"
|
</div>
|
||||||
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105"
|
</Transition>
|
||||||
title="Zoom out"
|
<button
|
||||||
aria-label="Zoom out"
|
type="button"
|
||||||
@click="$emit('zoomOut')"
|
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'"
|
||||||
<icons-icon-zoom-out />
|
:aria-label="panelCollapsed ? 'Expand panel' : 'Collapse panel'"
|
||||||
</button>
|
@click.stop="panelCollapsed = !panelCollapsed"
|
||||||
<button
|
>
|
||||||
type="button"
|
<icons-icon-chevron-right v-if="panelCollapsed" class="rotate-0" />
|
||||||
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105"
|
<icons-icon-panel-left v-else />
|
||||||
title="Reset view — center map and minimum zoom"
|
</button>
|
||||||
aria-label="Reset view"
|
</div>
|
||||||
@click="$emit('resetView')"
|
</div>
|
||||||
>
|
|
||||||
<icons-icon-home />
|
<!-- Mobile: FAB + bottom sheet -->
|
||||||
</button>
|
<div class="fixed left-3 bottom-6 z-[502] flex flex-col gap-2 md:hidden">
|
||||||
</div>
|
<button
|
||||||
</section>
|
type="button"
|
||||||
<!-- Display -->
|
class="btn btn-primary btn-circle shadow-lg min-h-12 min-w-12 touch-manipulation"
|
||||||
<section class="flex flex-col gap-2">
|
aria-label="Open map controls"
|
||||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70 flex items-center gap-1.5">
|
@click="sheetOpen = true"
|
||||||
<icons-icon-eye class="size-3.5 opacity-80" aria-hidden="true" />
|
>
|
||||||
Display
|
<icons-icon-panel-left class="size-6" />
|
||||||
</h3>
|
</button>
|
||||||
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2">
|
<div class="flex flex-col gap-1">
|
||||||
<input v-model="showGridCoordinates" type="checkbox" class="checkbox checkbox-sm" />
|
<button
|
||||||
<span>Show grid coordinates</span>
|
type="button"
|
||||||
</label>
|
class="btn btn-ghost btn-sm btn-circle min-h-10 min-w-10 touch-manipulation"
|
||||||
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2">
|
title="Zoom in"
|
||||||
<input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" />
|
aria-label="Zoom in"
|
||||||
<span>Hide markers</span>
|
@click="$emit('zoomIn')"
|
||||||
</label>
|
>
|
||||||
</section>
|
<icons-icon-zoom-in class="size-4" />
|
||||||
<!-- Navigation -->
|
</button>
|
||||||
<section class="flex flex-col gap-3">
|
<button
|
||||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70 flex items-center gap-1.5">
|
type="button"
|
||||||
<icons-icon-map-pin class="size-3.5 opacity-80" aria-hidden="true" />
|
class="btn btn-ghost btn-sm btn-circle min-h-10 min-w-10 touch-manipulation"
|
||||||
Navigation
|
title="Zoom out"
|
||||||
</h3>
|
aria-label="Zoom out"
|
||||||
<fieldset class="fieldset">
|
@click="$emit('zoomOut')"
|
||||||
<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">
|
<icons-icon-zoom-out class="size-4" />
|
||||||
<option value="">Select map</option>
|
</button>
|
||||||
<option v-for="m in maps" :key="m.ID" :value="m.ID">{{ m.Name }}</option>
|
</div>
|
||||||
</select>
|
</div>
|
||||||
</fieldset>
|
|
||||||
<fieldset class="fieldset">
|
<!-- Mobile bottom sheet overlay + panel -->
|
||||||
<label class="label py-0"><span>Overlay Map</span></label>
|
<Teleport to="body">
|
||||||
<select v-model="overlayMapId" class="select select-sm w-full focus:ring-2 focus:ring-primary">
|
<Transition name="sheet">
|
||||||
<option :value="-1">None</option>
|
<div
|
||||||
<option v-for="m in maps" :key="'overlay-' + m.ID" :value="m.ID">{{ m.Name }}</option>
|
v-if="sheetOpen"
|
||||||
</select>
|
class="fixed inset-0 z-[1100] md:hidden"
|
||||||
</fieldset>
|
role="dialog"
|
||||||
<fieldset class="fieldset">
|
aria-modal="true"
|
||||||
<label class="label py-0"><span>Jump to Quest Giver</span></label>
|
aria-label="Map controls"
|
||||||
<select v-model="selectedMarkerIdSelect" class="select select-sm w-full focus:ring-2 focus:ring-primary">
|
>
|
||||||
<option value="">Select quest giver</option>
|
<div
|
||||||
<option v-for="q in questGivers" :key="q.id" :value="q.id">{{ q.name }}</option>
|
class="absolute inset-0 bg-black/50"
|
||||||
</select>
|
aria-hidden="true"
|
||||||
</fieldset>
|
@click="sheetOpen = false"
|
||||||
<fieldset class="fieldset">
|
/>
|
||||||
<label class="label py-0"><span>Jump to Player</span></label>
|
<div
|
||||||
<select v-model="selectedPlayerIdSelect" class="select select-sm w-full focus:ring-2 focus:ring-primary">
|
class="sheet-panel absolute left-0 right-0 bottom-0 max-h-[85vh] rounded-t-2xl bg-base-100 shadow-2xl flex flex-col overflow-hidden"
|
||||||
<option value="">Select player</option>
|
@click.stop
|
||||||
<option v-for="p in players" :key="p.id" :value="p.id">{{ p.name }}</option>
|
>
|
||||||
</select>
|
<div class="flex justify-center py-2 shrink-0">
|
||||||
</fieldset>
|
<span class="w-12 h-1 rounded-full bg-base-300" aria-hidden="true" />
|
||||||
</section>
|
</div>
|
||||||
</div>
|
<div class="overflow-y-auto overscroll-contain flex-1 p-4 pb-8">
|
||||||
</Transition>
|
<MapControlsContent
|
||||||
<button
|
v-model:show-grid-coordinates="showGridCoordinates"
|
||||||
type="button"
|
v-model:hide-markers="hideMarkers"
|
||||||
class="btn btn-ghost btn-sm btn-square shrink-0 m-1 transition-all duration-200 hover:scale-105 mt-auto"
|
:selected-map-id-select="selectedMapIdSelect"
|
||||||
:title="panelCollapsed ? 'Expand panel' : 'Collapse panel'"
|
@update:selected-map-id-select="(v) => (selectedMapIdSelect = v)"
|
||||||
:aria-label="panelCollapsed ? 'Expand panel' : 'Collapse panel'"
|
:overlay-map-id="overlayMapId"
|
||||||
@click.stop="panelCollapsed = !panelCollapsed"
|
@update:overlay-map-id="(v) => (overlayMapId = v)"
|
||||||
>
|
:selected-marker-id-select="selectedMarkerIdSelect"
|
||||||
<icons-icon-chevron-right v-if="panelCollapsed" class="rotate-0" />
|
@update:selected-marker-id-select="(v) => (selectedMarkerIdSelect = v)"
|
||||||
<icons-icon-panel-left v-else />
|
:selected-player-id-select="selectedPlayerIdSelect"
|
||||||
</button>
|
@update:selected-player-id-select="(v) => (selectedPlayerIdSelect = v)"
|
||||||
</div>
|
:maps="maps"
|
||||||
</div>
|
:quest-givers="questGivers"
|
||||||
</template>
|
:players="players"
|
||||||
|
:touch-friendly="true"
|
||||||
<script setup lang="ts">
|
@zoom-in="$emit('zoomIn')"
|
||||||
import type { MapInfo } from '~/types/api'
|
@zoom-out="$emit('zoomOut')"
|
||||||
|
@reset-view="$emit('resetView')"
|
||||||
interface QuestGiver {
|
/>
|
||||||
id: number
|
</div>
|
||||||
name: string
|
<div class="p-3 border-t border-base-300 shrink-0 safe-area-pb">
|
||||||
}
|
<button
|
||||||
|
type="button"
|
||||||
interface Player {
|
class="btn btn-ghost btn-block touch-manipulation min-h-12"
|
||||||
id: number
|
@click="sheetOpen = false"
|
||||||
name: string
|
>
|
||||||
}
|
Close
|
||||||
|
</button>
|
||||||
const props = withDefaults(
|
</div>
|
||||||
defineProps<{
|
</div>
|
||||||
maps: MapInfo[]
|
</div>
|
||||||
questGivers: QuestGiver[]
|
</Transition>
|
||||||
players: Player[]
|
</Teleport>
|
||||||
}>(),
|
</template>
|
||||||
{ maps: () => [], questGivers: () => [], players: () => [] }
|
|
||||||
)
|
<script setup lang="ts">
|
||||||
|
import type { MapInfo } from '~/types/api'
|
||||||
const emit = defineEmits<{
|
import MapControlsContent from '~/components/map/MapControlsContent.vue'
|
||||||
zoomIn: []
|
|
||||||
zoomOut: []
|
interface QuestGiver {
|
||||||
resetView: []
|
id: number
|
||||||
}>()
|
name: string
|
||||||
|
}
|
||||||
const showGridCoordinates = defineModel<boolean>('showGridCoordinates', { default: false })
|
|
||||||
const hideMarkers = defineModel<boolean>('hideMarkers', { default: false })
|
interface Player {
|
||||||
const panelCollapsed = ref(false)
|
id: number
|
||||||
const selectedMapId = defineModel<number | null>('selectedMapId', { default: null })
|
name: string
|
||||||
const overlayMapId = defineModel<number>('overlayMapId', { default: -1 })
|
}
|
||||||
const selectedMarkerId = defineModel<number | null>('selectedMarkerId', { default: null })
|
|
||||||
const selectedPlayerId = defineModel<number | null>('selectedPlayerId', { default: null })
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
// String bindings for selects with placeholder ('' ↔ null)
|
maps: MapInfo[]
|
||||||
const selectedMapIdSelect = computed({
|
questGivers: QuestGiver[]
|
||||||
get() {
|
players: Player[]
|
||||||
const v = selectedMapId.value
|
}>(),
|
||||||
return v == null ? '' : String(v)
|
{ maps: () => [], questGivers: () => [], players: () => [] }
|
||||||
},
|
)
|
||||||
set(v: string) {
|
|
||||||
selectedMapId.value = v === '' ? null : Number(v)
|
defineEmits<{
|
||||||
},
|
zoomIn: []
|
||||||
})
|
zoomOut: []
|
||||||
const selectedMarkerIdSelect = computed({
|
resetView: []
|
||||||
get() {
|
}>()
|
||||||
const v = selectedMarkerId.value
|
|
||||||
return v == null ? '' : String(v)
|
const showGridCoordinates = defineModel<boolean>('showGridCoordinates', { default: false })
|
||||||
},
|
const hideMarkers = defineModel<boolean>('hideMarkers', { default: false })
|
||||||
set(v: string) {
|
const panelCollapsed = ref(false)
|
||||||
selectedMarkerId.value = v === '' ? null : Number(v)
|
const sheetOpen = ref(false)
|
||||||
},
|
const selectedMapId = defineModel<number | null>('selectedMapId', { default: null })
|
||||||
})
|
const overlayMapId = defineModel<number>('overlayMapId', { default: -1 })
|
||||||
const selectedPlayerIdSelect = computed({
|
const selectedMarkerId = defineModel<number | null>('selectedMarkerId', { default: null })
|
||||||
get() {
|
const selectedPlayerId = defineModel<number | null>('selectedPlayerId', { default: null })
|
||||||
const v = selectedPlayerId.value
|
|
||||||
return v == null ? '' : String(v)
|
const selectedMapIdSelect = computed({
|
||||||
},
|
get() {
|
||||||
set(v: string) {
|
const v = selectedMapId.value
|
||||||
selectedPlayerId.value = v === '' ? null : Number(v)
|
return v == null ? '' : String(v)
|
||||||
},
|
},
|
||||||
})
|
set(v: string) {
|
||||||
</script>
|
selectedMapId.value = v === '' ? null : Number(v)
|
||||||
|
},
|
||||||
<style scoped>
|
})
|
||||||
.panel-slide-enter-active,
|
const selectedMarkerIdSelect = computed({
|
||||||
.panel-slide-leave-active {
|
get() {
|
||||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
const v = selectedMarkerId.value
|
||||||
}
|
return v == null ? '' : String(v)
|
||||||
.panel-slide-enter-from,
|
},
|
||||||
.panel-slide-leave-to {
|
set(v: string) {
|
||||||
opacity: 0;
|
selectedMarkerId.value = v === '' ? null : Number(v)
|
||||||
transform: translateX(-0.5rem);
|
},
|
||||||
}
|
})
|
||||||
</style>
|
const selectedPlayerIdSelect = computed({
|
||||||
|
get() {
|
||||||
|
const v = selectedPlayerId.value
|
||||||
|
return v == null ? '' : String(v)
|
||||||
|
},
|
||||||
|
set(v: string) {
|
||||||
|
selectedPlayerId.value = v === '' ? null : Number(v)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.panel-slide-enter-active,
|
||||||
|
.panel-slide-leave-active {
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.panel-slide-enter-from,
|
||||||
|
.panel-slide-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-0.5rem);
|
||||||
|
}
|
||||||
|
.sheet-enter-active,
|
||||||
|
.sheet-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.sheet-enter-from,
|
||||||
|
.sheet-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.sheet-enter-active .sheet-panel,
|
||||||
|
.sheet-leave-active .sheet-panel {
|
||||||
|
transition: transform 0.25s ease-out;
|
||||||
|
}
|
||||||
|
.sheet-enter-from .sheet-panel,
|
||||||
|
.sheet-leave-to .sheet-panel {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
169
frontend-nuxt/components/map/MapControlsContent.vue
Normal file
169
frontend-nuxt/components/map/MapControlsContent.vue
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<!-- Zoom -->
|
||||||
|
<section class="flex flex-col gap-2">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70 flex items-center gap-1.5">
|
||||||
|
<icons-icon-zoom-in class="size-3.5 opacity-80" aria-hidden="true" />
|
||||||
|
Zoom
|
||||||
|
</h3>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105 touch-manipulation"
|
||||||
|
:class="touchFriendly ? 'min-h-10 min-w-10' : ''"
|
||||||
|
title="Zoom in"
|
||||||
|
aria-label="Zoom in"
|
||||||
|
@click="$emit('zoomIn')"
|
||||||
|
>
|
||||||
|
<icons-icon-zoom-in />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105 touch-manipulation"
|
||||||
|
:class="touchFriendly ? 'min-h-10 min-w-10' : ''"
|
||||||
|
title="Zoom out"
|
||||||
|
aria-label="Zoom out"
|
||||||
|
@click="$emit('zoomOut')"
|
||||||
|
>
|
||||||
|
<icons-icon-zoom-out />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105 touch-manipulation"
|
||||||
|
:class="touchFriendly ? 'min-h-10 min-w-10' : ''"
|
||||||
|
title="Reset view — center map and minimum zoom"
|
||||||
|
aria-label="Reset view"
|
||||||
|
@click="$emit('resetView')"
|
||||||
|
>
|
||||||
|
<icons-icon-home />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<!-- Display -->
|
||||||
|
<section class="flex flex-col gap-2">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70 flex items-center gap-1.5">
|
||||||
|
<icons-icon-eye class="size-3.5 opacity-80" aria-hidden="true" />
|
||||||
|
Display
|
||||||
|
</h3>
|
||||||
|
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2 touch-manipulation" :class="touchFriendly ? 'min-h-11' : ''">
|
||||||
|
<input v-model="showGridCoordinates" type="checkbox" class="checkbox checkbox-sm" />
|
||||||
|
<span>Show grid coordinates</span>
|
||||||
|
</label>
|
||||||
|
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2 touch-manipulation" :class="touchFriendly ? 'min-h-11' : ''">
|
||||||
|
<input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" />
|
||||||
|
<span>Hide markers</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
<!-- Navigation -->
|
||||||
|
<section class="flex flex-col gap-3">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70 flex items-center gap-1.5">
|
||||||
|
<icons-icon-map-pin class="size-3.5 opacity-80" aria-hidden="true" />
|
||||||
|
Navigation
|
||||||
|
</h3>
|
||||||
|
<fieldset class="fieldset">
|
||||||
|
<label class="label py-0"><span>Jump to Map</span></label>
|
||||||
|
<select
|
||||||
|
v-model="selectedMapIdSelect"
|
||||||
|
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation"
|
||||||
|
:class="touchFriendly ? 'min-h-11 text-base' : ''"
|
||||||
|
>
|
||||||
|
<option value="">Select map</option>
|
||||||
|
<option v-for="m in maps" :key="m.ID" :value="String(m.ID)">{{ m.Name }}</option>
|
||||||
|
</select>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="fieldset">
|
||||||
|
<label class="label py-0"><span>Overlay Map</span></label>
|
||||||
|
<select
|
||||||
|
v-model="overlayMapId"
|
||||||
|
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation"
|
||||||
|
:class="touchFriendly ? 'min-h-11 text-base' : ''"
|
||||||
|
>
|
||||||
|
<option value="-1">None</option>
|
||||||
|
<option v-for="m in maps" :key="'overlay-' + m.ID" :value="String(m.ID)">{{ m.Name }}</option>
|
||||||
|
</select>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="fieldset">
|
||||||
|
<label class="label py-0"><span>Jump to Quest Giver</span></label>
|
||||||
|
<select
|
||||||
|
v-model="selectedMarkerIdSelect"
|
||||||
|
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation"
|
||||||
|
:class="touchFriendly ? 'min-h-11 text-base' : ''"
|
||||||
|
>
|
||||||
|
<option value="">Select quest giver</option>
|
||||||
|
<option v-for="q in questGivers" :key="q.id" :value="String(q.id)">{{ q.name }}</option>
|
||||||
|
</select>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="fieldset">
|
||||||
|
<label class="label py-0"><span>Jump to Player</span></label>
|
||||||
|
<select
|
||||||
|
v-model="selectedPlayerIdSelect"
|
||||||
|
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation"
|
||||||
|
:class="touchFriendly ? 'min-h-11 text-base' : ''"
|
||||||
|
>
|
||||||
|
<option value="">Select player</option>
|
||||||
|
<option v-for="p in players" :key="p.id" :value="String(p.id)">{{ p.name }}</option>
|
||||||
|
</select>
|
||||||
|
</fieldset>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { MapInfo } from '~/types/api'
|
||||||
|
|
||||||
|
interface QuestGiver {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Player {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
maps: MapInfo[]
|
||||||
|
questGivers: QuestGiver[]
|
||||||
|
players: Player[]
|
||||||
|
touchFriendly?: boolean
|
||||||
|
selectedMapIdSelect: string
|
||||||
|
overlayMapId: number
|
||||||
|
selectedMarkerIdSelect: string
|
||||||
|
selectedPlayerIdSelect: string
|
||||||
|
}>(),
|
||||||
|
{ touchFriendly: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
zoomIn: []
|
||||||
|
zoomOut: []
|
||||||
|
resetView: []
|
||||||
|
'update:showGridCoordinates': [v: boolean]
|
||||||
|
'update:hideMarkers': [v: boolean]
|
||||||
|
'update:selectedMapIdSelect': [v: string]
|
||||||
|
'update:overlayMapId': [v: number]
|
||||||
|
'update:selectedMarkerIdSelect': [v: string]
|
||||||
|
'update:selectedPlayerIdSelect': [v: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const showGridCoordinates = defineModel<boolean>('showGridCoordinates', { required: true })
|
||||||
|
const hideMarkers = defineModel<boolean>('hideMarkers', { required: true })
|
||||||
|
|
||||||
|
const selectedMapIdSelect = computed({
|
||||||
|
get: () => props.selectedMapIdSelect,
|
||||||
|
set: (v) => emit('update:selectedMapIdSelect', v),
|
||||||
|
})
|
||||||
|
const overlayMapId = computed({
|
||||||
|
get: () => String(props.overlayMapId),
|
||||||
|
set: (v) => emit('update:overlayMapId', v === '' ? -1 : Number(v)),
|
||||||
|
})
|
||||||
|
const selectedMarkerIdSelect = computed({
|
||||||
|
get: () => props.selectedMarkerIdSelect,
|
||||||
|
set: (v) => emit('update:selectedMarkerIdSelect', v),
|
||||||
|
})
|
||||||
|
const selectedPlayerIdSelect = computed({
|
||||||
|
get: () => props.selectedPlayerIdSelect,
|
||||||
|
set: (v) => emit('update:selectedPlayerIdSelect', v),
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,75 +1,165 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-screen flex flex-col bg-base-100 overflow-hidden">
|
<div class="drawer h-screen flex flex-col bg-base-100 overflow-hidden">
|
||||||
<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">
|
<input id="nav-drawer" ref="drawerCheckboxRef" type="checkbox" class="drawer-toggle" />
|
||||||
<NuxtLink to="/" class="flex items-center gap-2 text-lg font-semibold hover:opacity-80 transition-all duration-200">
|
<div class="drawer-content flex flex-col h-screen overflow-hidden">
|
||||||
<icons-icon-map class="size-6 text-primary shrink-0" aria-hidden="true" />
|
<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">
|
||||||
<span>{{ title }}</span>
|
<NuxtLink to="/" class="flex items-center gap-2 text-lg font-semibold hover:opacity-80 transition-all duration-200">
|
||||||
</NuxtLink>
|
<icons-icon-map class="size-6 text-primary shrink-0" aria-hidden="true" />
|
||||||
<div class="flex-1" />
|
<span>{{ title }}</span>
|
||||||
<!-- When not logged in: show Login link (except on login page) -->
|
</NuxtLink>
|
||||||
<NuxtLink
|
<div class="flex-1" />
|
||||||
v-if="!me && !isLogin"
|
<!-- Desktop (md+): Login / connection + user dropdown -->
|
||||||
to="/login"
|
<template v-if="!isLogin">
|
||||||
class="btn btn-ghost btn-sm btn-primary"
|
<NuxtLink
|
||||||
>
|
v-if="!me"
|
||||||
Login
|
to="/login"
|
||||||
</NuxtLink>
|
class="btn btn-ghost btn-sm btn-primary hidden md:inline-flex"
|
||||||
<!-- 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"
|
|
||||||
>
|
>
|
||||||
|
Login
|
||||||
|
</NuxtLink>
|
||||||
|
<div
|
||||||
|
v-if="me"
|
||||||
|
class="hidden md:flex items-center gap-2 shrink-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
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>
|
||||||
|
<div 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">{{ 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 />
|
||||||
|
Map
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/profile" :class="route.path === '/profile' ? 'active' : ''" @click="closeDropdown">
|
||||||
|
<icons-icon-user />
|
||||||
|
Profile
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li v-if="isAdmin">
|
||||||
|
<NuxtLink to="/admin" :class="route.path.startsWith('/admin') ? 'active' : ''" @click="closeDropdown">
|
||||||
|
<icons-icon-shield />
|
||||||
|
Admin
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li class="menu-divider my-1" />
|
||||||
|
<li class="[&_.menu-item]:flex [&_.menu-item]:items-center [&_.menu-item]:gap-3">
|
||||||
|
<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">
|
||||||
|
<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 />
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- Mobile: hamburger to open drawer -->
|
||||||
|
<label
|
||||||
|
for="nav-drawer"
|
||||||
|
class="btn btn-ghost btn-square md:hidden flex items-center justify-center min-h-[2.75rem] min-w-[2.75rem]"
|
||||||
|
aria-label="Open menu"
|
||||||
|
>
|
||||||
|
<icons-icon-menu class="size-6" />
|
||||||
|
</label>
|
||||||
|
</header>
|
||||||
|
<main class="flex-1 min-h-0 overflow-y-auto relative flex flex-col">
|
||||||
|
<AdminBreadcrumbs v-if="route.path.startsWith('/admin') && route.path !== '/admin'" />
|
||||||
|
<div class="flex-1 min-h-0">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<ToastContainer />
|
||||||
|
</div>
|
||||||
|
<!-- Drawer sidebar (mobile menu) -->
|
||||||
|
<div class="drawer-side z-[1100]">
|
||||||
|
<label for="nav-drawer" aria-label="Close menu" class="drawer-overlay" />
|
||||||
|
<aside class="bg-base-200/95 backdrop-blur-xl min-h-full w-72 p-4 flex flex-col">
|
||||||
|
<!-- Mobile: user + live when logged in -->
|
||||||
|
<div v-if="!isLogin && me" class="flex items-center gap-3 pb-4 mb-2 border-b border-base-300/50">
|
||||||
|
<div class="avatar placeholder">
|
||||||
|
<div class="bg-primary text-primary-content rounded-full w-10">
|
||||||
|
<span class="text-sm font-medium">{{ (me.username || '?')[0].toUpperCase() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium truncate">{{ me.username }}</p>
|
||||||
|
<p class="text-xs flex items-center gap-1.5" :class="live ? 'text-success' : 'text-base-content/60'">
|
||||||
|
<span class="size-1.5 rounded-full" :class="live ? 'bg-success animate-pulse' : 'bg-base-content/40'" />
|
||||||
|
{{ live ? 'Live' : 'Offline' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul class="menu p-0 flex-1 text-base">
|
||||||
|
<li v-if="!me && !isLogin">
|
||||||
|
<NuxtLink to="/login" class="min-h-12 touch-manipulation" @click="closeDrawer">
|
||||||
|
Login
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<template v-else>
|
||||||
<li>
|
<li>
|
||||||
<NuxtLink to="/" :class="route.path === '/' ? 'active' : ''" @click="closeDropdown">
|
<NuxtLink to="/" :class="route.path === '/' ? 'active' : ''" class="min-h-12 touch-manipulation" @click="closeDrawer">
|
||||||
<icons-icon-map />
|
<icons-icon-map />
|
||||||
Map
|
Map
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<NuxtLink to="/profile" :class="route.path === '/profile' ? 'active' : ''" @click="closeDropdown">
|
<NuxtLink to="/profile" :class="route.path === '/profile' ? 'active' : ''" class="min-h-12 touch-manipulation" @click="closeDrawer">
|
||||||
<icons-icon-user />
|
<icons-icon-user />
|
||||||
Profile
|
Profile
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="isAdmin">
|
<li v-if="isAdmin">
|
||||||
<NuxtLink to="/admin" :class="route.path.startsWith('/admin') ? 'active' : ''" @click="closeDropdown">
|
<NuxtLink to="/admin" :class="route.path.startsWith('/admin') ? 'active' : ''" class="min-h-12 touch-manipulation" @click="closeDrawer">
|
||||||
<icons-icon-shield />
|
<icons-icon-shield />
|
||||||
Admin
|
Admin
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
<li class="menu-divider my-1" />
|
<li class="menu-divider my-1" />
|
||||||
<li class="[&_.menu-item]:flex [&_.menu-item]:items-center [&_.menu-item]:gap-3">
|
<li class="[&_.menu-item]:flex [&_.menu-item]:items-center [&_.menu-item]:gap-3 min-h-12">
|
||||||
<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">
|
<label class="flex cursor-pointer items-center gap-3 py-3 px-2 rounded-lg hover:bg-base-300/50 w-full min-h-0 touch-manipulation">
|
||||||
<icons-icon-sun v-if="!dark" class="size-4 shrink-0 opacity-80" />
|
<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" />
|
<icons-icon-moon v-else class="size-4 shrink-0 opacity-80" />
|
||||||
<span class="flex-1 text-sm">Тёмная тема</span>
|
<span class="flex-1 text-sm">Тёмная тема</span>
|
||||||
@@ -82,22 +172,15 @@
|
|||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button type="button" class="text-error hover:bg-error/10 rounded-lg" @click="doLogout">
|
<button type="button" class="text-error hover:bg-error/10 rounded-lg min-h-12 touch-manipulation w-full justify-start" @click="doLogout">
|
||||||
<icons-icon-logout />
|
<icons-icon-logout />
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</template>
|
||||||
</details>
|
</ul>
|
||||||
</div>
|
</aside>
|
||||||
</header>
|
</div>
|
||||||
<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 />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<ToastContainer />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -121,11 +204,16 @@ 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)
|
const userDropdownRef = ref<HTMLDetailsElement | null>(null)
|
||||||
|
const drawerCheckboxRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
function closeDropdown() {
|
function closeDropdown() {
|
||||||
userDropdownRef.value?.removeAttribute('open')
|
userDropdownRef.value?.removeAttribute('open')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeDrawer() {
|
||||||
|
if (drawerCheckboxRef.value) drawerCheckboxRef.value.checked = false
|
||||||
|
}
|
||||||
|
|
||||||
const { isLoginPath } = useAppPaths()
|
const { isLoginPath } = useAppPaths()
|
||||||
const isLogin = computed(() => isLoginPath(route.path))
|
const isLogin = computed(() => isLoginPath(route.path))
|
||||||
const isAdmin = computed(() => !!me.value?.auths?.includes('admin'))
|
const isAdmin = computed(() => !!me.value?.auths?.includes('admin'))
|
||||||
@@ -175,6 +263,7 @@ function applyTheme() {
|
|||||||
|
|
||||||
async function doLogout() {
|
async function doLogout() {
|
||||||
await useMapApi().logout()
|
await useMapApi().logout()
|
||||||
|
closeDrawer()
|
||||||
await router.push('/login')
|
await router.push('/login')
|
||||||
me.value = null
|
me.value = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,446 +1,446 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container mx-auto p-4 max-w-2xl">
|
<div class="container mx-auto p-4 max-w-2xl min-w-0">
|
||||||
<h1 class="text-2xl font-bold mb-6">Admin</h1>
|
<h1 class="text-2xl font-bold mb-6">Admin</h1>
|
||||||
|
|
||||||
<template v-if="loading">
|
<template v-if="loading">
|
||||||
<div class="card bg-base-200 shadow-xl mb-6">
|
<div class="card bg-base-200 shadow-xl mb-6">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<Skeleton class="h-6 w-24 mb-4" />
|
<Skeleton class="h-6 w-24 mb-4" />
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<Skeleton v-for="i in 3" :key="i" class="h-12 w-full" />
|
<Skeleton v-for="i in 3" :key="i" class="h-12 w-full" />
|
||||||
<Skeleton class="h-9 w-28 mt-2" />
|
<Skeleton class="h-9 w-28 mt-2" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card bg-base-200 shadow-xl mb-6">
|
<div class="card bg-base-200 shadow-xl mb-6">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<Skeleton class="h-6 w-20 mb-4" />
|
<Skeleton class="h-6 w-20 mb-4" />
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<Skeleton class="h-32 w-full" />
|
<Skeleton class="h-32 w-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card bg-base-200 shadow-xl mb-6">
|
<div class="card bg-base-200 shadow-xl mb-6">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<Skeleton class="h-6 w-16 mb-4" />
|
<Skeleton class="h-6 w-16 mb-4" />
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<Skeleton class="h-10 w-48" />
|
<Skeleton class="h-10 w-48" />
|
||||||
<Skeleton class="h-10 w-48" />
|
<Skeleton class="h-10 w-48" />
|
||||||
<Skeleton class="h-8 w-36" />
|
<Skeleton class="h-8 w-36" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
|
<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 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-title text-xs">Users</div>
|
||||||
<div class="stat-value text-2xl">{{ users.length }}</div>
|
<div class="stat-value text-2xl">{{ users.length }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat bg-base-200 rounded-xl shadow border border-base-300/50 py-4 px-4">
|
<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-title text-xs">Maps</div>
|
||||||
<div class="stat-value text-2xl">{{ maps.length }}</div>
|
<div class="stat-value text-2xl">{{ maps.length }}</div>
|
||||||
</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">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title gap-2">
|
<h2 class="card-title gap-2">
|
||||||
<icons-icon-users />
|
<icons-icon-users />
|
||||||
Users
|
Users
|
||||||
</h2>
|
</h2>
|
||||||
<div class="form-control w-full max-w-xs mb-3">
|
<div class="form-control w-full mb-3 sm:max-w-xs">
|
||||||
<input
|
<input
|
||||||
v-model="userSearch"
|
v-model="userSearch"
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search users…"
|
placeholder="Search users…"
|
||||||
class="input input-sm input-bordered w-full"
|
class="input input-sm input-bordered w-full min-h-11 touch-manipulation"
|
||||||
aria-label="Search users"
|
aria-label="Search users"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div
|
<div
|
||||||
v-for="u in filteredUsers"
|
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"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<div class="avatar avatar-placeholder">
|
<div class="avatar avatar-placeholder">
|
||||||
<div class="bg-neutral text-neutral-content rounded-full w-8">
|
<div class="bg-neutral text-neutral-content rounded-full w-8">
|
||||||
<span class="text-xs">{{ u[0]?.toUpperCase() }}</span>
|
<span class="text-xs">{{ u[0]?.toUpperCase() }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="font-medium">{{ u }}</span>
|
<span class="font-medium truncate">{{ u }}</span>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink :to="`/admin/users/${u}`" class="btn btn-outline btn-sm gap-1 shrink-0">
|
<NuxtLink :to="`/admin/users/${u}`" class="btn btn-outline btn-sm gap-1 shrink-0 min-h-11 touch-manipulation">
|
||||||
<icons-icon-pencil /> Edit
|
<icons-icon-pencil /> Edit
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!filteredUsers.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">
|
||||||
{{ userSearch ? 'No users match.' : '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 min-h-11 touch-manipulation">
|
||||||
<icons-icon-plus /> Add user
|
<icons-icon-plus /> Add user
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title gap-2">
|
<h2 class="card-title gap-2">
|
||||||
<icons-icon-map-pin />
|
<icons-icon-map-pin />
|
||||||
Maps
|
Maps
|
||||||
</h2>
|
</h2>
|
||||||
<div class="form-control w-full max-w-xs mb-3">
|
<div class="form-control w-full mb-3 sm:max-w-xs">
|
||||||
<input
|
<input
|
||||||
v-model="mapSearch"
|
v-model="mapSearch"
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search maps…"
|
placeholder="Search maps…"
|
||||||
class="input input-sm input-bordered w-full"
|
class="input input-sm input-bordered w-full min-h-11 touch-manipulation"
|
||||||
aria-label="Search maps"
|
aria-label="Search maps"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 min-w-[32rem]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>
|
<th>
|
||||||
<button type="button" class="btn btn-ghost btn-xs gap-0.5" :class="mapsSortBy === 'ID' ? 'btn-active' : ''" @click="setMapsSort('ID')">
|
<button type="button" class="btn btn-ghost btn-xs gap-0.5 min-h-9 touch-manipulation" :class="mapsSortBy === 'ID' ? 'btn-active' : ''" @click="setMapsSort('ID')">
|
||||||
ID
|
ID
|
||||||
<span v-if="mapsSortBy === 'ID'" class="opacity-70">{{ mapsSortDir === 'asc' ? '↑' : '↓' }}</span>
|
<span v-if="mapsSortBy === 'ID'" class="opacity-70">{{ mapsSortDir === 'asc' ? '↑' : '↓' }}</span>
|
||||||
</button>
|
</button>
|
||||||
</th>
|
</th>
|
||||||
<th>
|
<th>
|
||||||
<button type="button" class="btn btn-ghost btn-xs gap-0.5" :class="mapsSortBy === 'Name' ? 'btn-active' : ''" @click="setMapsSort('Name')">
|
<button type="button" class="btn btn-ghost btn-xs gap-0.5 min-h-9 touch-manipulation" :class="mapsSortBy === 'Name' ? 'btn-active' : ''" @click="setMapsSort('Name')">
|
||||||
Name
|
Name
|
||||||
<span v-if="mapsSortBy === 'Name'" class="opacity-70">{{ mapsSortDir === 'asc' ? '↑' : '↓' }}</span>
|
<span v-if="mapsSortBy === 'Name'" class="opacity-70">{{ mapsSortDir === 'asc' ? '↑' : '↓' }}</span>
|
||||||
</button>
|
</button>
|
||||||
</th>
|
</th>
|
||||||
<th>Hidden</th>
|
<th>Hidden</th>
|
||||||
<th>Priority</th>
|
<th>Priority</th>
|
||||||
<th class="text-right"></th>
|
<th class="text-right"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="map in sortedFilteredMaps" :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>
|
||||||
<td>{{ map.Priority ? 'Yes' : 'No' }}</td>
|
<td>{{ map.Priority ? 'Yes' : 'No' }}</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<NuxtLink :to="`/admin/maps/${map.ID}`" class="btn btn-outline btn-sm gap-1">
|
<NuxtLink :to="`/admin/maps/${map.ID}`" class="btn btn-outline btn-sm gap-1 min-h-9 touch-manipulation">
|
||||||
<icons-icon-pencil /> Edit
|
<icons-icon-pencil /> Edit
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="!sortedFilteredMaps.length">
|
<tr v-if="!sortedFilteredMaps.length">
|
||||||
<td colspan="5" class="text-base-content/60">{{ mapSearch ? 'No maps match.' : 'No maps.' }}</td>
|
<td colspan="5" class="text-base-content/60">{{ mapSearch ? 'No maps match.' : 'No maps.' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title gap-2">
|
<h2 class="card-title gap-2">
|
||||||
<icons-icon-settings />
|
<icons-icon-settings />
|
||||||
Settings
|
Settings
|
||||||
</h2>
|
</h2>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<fieldset class="fieldset w-full max-w-xs">
|
<fieldset class="fieldset w-full max-w-xs">
|
||||||
<label class="label" for="admin-settings-prefix">Prefix</label>
|
<label class="label" for="admin-settings-prefix">Prefix</label>
|
||||||
<input
|
<input
|
||||||
id="admin-settings-prefix"
|
id="admin-settings-prefix"
|
||||||
v-model="settings.prefix"
|
v-model="settings.prefix"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-sm w-full"
|
class="input input-sm w-full min-h-11 touch-manipulation"
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset class="fieldset w-full max-w-xs">
|
<fieldset class="fieldset w-full max-w-xs">
|
||||||
<label class="label" for="admin-settings-title">Title</label>
|
<label class="label" for="admin-settings-title">Title</label>
|
||||||
<input
|
<input
|
||||||
id="admin-settings-title"
|
id="admin-settings-title"
|
||||||
v-model="settings.title"
|
v-model="settings.title"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-sm w-full"
|
class="input input-sm w-full min-h-11 touch-manipulation"
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset class="fieldset">
|
<fieldset class="fieldset">
|
||||||
<label class="label gap-2 cursor-pointer justify-start" for="admin-settings-default-hide">
|
<label class="label gap-2 cursor-pointer justify-start min-h-11 touch-manipulation" for="admin-settings-default-hide">
|
||||||
<input
|
<input
|
||||||
id="admin-settings-default-hide"
|
id="admin-settings-default-hide"
|
||||||
v-model="settings.defaultHide"
|
v-model="settings.defaultHide"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox checkbox-sm"
|
class="checkbox checkbox-sm"
|
||||||
/>
|
/>
|
||||||
Default hide new maps
|
Default hide new maps
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end mt-2">
|
<div class="flex justify-end mt-2">
|
||||||
<button class="btn btn-primary btn-sm" :disabled="savingSettings" @click="saveSettings">
|
<button class="btn btn-primary btn-sm min-h-11 touch-manipulation" :disabled="savingSettings" @click="saveSettings">
|
||||||
<span v-if="savingSettings" class="loading loading-spinner loading-sm" />
|
<span v-if="savingSettings" class="loading loading-spinner loading-sm" />
|
||||||
<span v-else>Save settings</span>
|
<span v-else>Save settings</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title gap-2">
|
<h2 class="card-title gap-2">
|
||||||
<icons-icon-alert-triangle />
|
<icons-icon-alert-triangle />
|
||||||
Actions
|
Actions
|
||||||
</h2>
|
</h2>
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<div>
|
<div>
|
||||||
<a :href="api.adminExportUrl()" target="_blank" rel="noopener" class="btn btn-sm">
|
<a :href="api.adminExportUrl()" target="_blank" rel="noopener" class="btn btn-sm min-h-11 touch-manipulation">
|
||||||
Export zip
|
Export zip
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button class="btn btn-sm" :disabled="rebuilding" @click="confirmRebuildZooms">
|
<button class="btn btn-sm min-h-11 touch-manipulation" :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>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<input ref="mergeFileRef" type="file" accept=".zip" class="hidden" @change="onMergeFile" />
|
<input ref="mergeFileRef" type="file" accept=".zip" class="hidden" @change="onMergeFile" />
|
||||||
<button type="button" class="btn btn-sm" @click="mergeFileRef?.click()">
|
<button type="button" class="btn btn-sm min-h-11 touch-manipulation" @click="mergeFileRef?.click()">
|
||||||
Choose merge file
|
Choose merge file
|
||||||
</button>
|
</button>
|
||||||
<span class="text-sm text-base-content/70">
|
<span class="text-sm text-base-content/70">
|
||||||
{{ mergeFile ? mergeFile.name : 'No file chosen' }}
|
{{ mergeFile ? mergeFile.name : 'No file chosen' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="doMerge">
|
<form @submit.prevent="doMerge">
|
||||||
<button type="submit" class="btn btn-sm btn-primary" :disabled="!mergeFile || merging">
|
<button type="submit" class="btn btn-sm btn-primary min-h-11 touch-manipulation" :disabled="!mergeFile || merging">
|
||||||
<span v-if="merging" class="loading loading-spinner loading-sm" />
|
<span v-if="merging" class="loading loading-spinner loading-sm" />
|
||||||
<span v-else>Merge</span>
|
<span v-else>Merge</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-t border-red-500/30 pt-4 mt-1 bg-error/5 rounded-lg p-3 -mx-1">
|
<div class="border-t border-red-500/30 pt-4 mt-1 bg-error/5 rounded-lg p-3 -mx-1">
|
||||||
<p class="text-sm font-medium text-error/90 mb-2">Danger zone</p>
|
<p class="text-sm font-medium text-error/90 mb-2">Danger zone</p>
|
||||||
<button class="btn btn-sm btn-error" :disabled="wiping" @click="confirmWipe">
|
<button class="btn btn-sm btn-error min-h-11 touch-manipulation" :disabled="wiping" @click="confirmWipe">
|
||||||
<span v-if="wiping" class="loading loading-spinner loading-sm" />
|
<span v-if="wiping" class="loading loading-spinner loading-sm" />
|
||||||
<span v-else>Wipe all data</span>
|
<span v-else>Wipe all data</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progress indicator for long operations -->
|
<!-- Progress indicator for long operations -->
|
||||||
<div
|
<div
|
||||||
v-if="rebuilding || merging || wiping"
|
v-if="rebuilding || merging || wiping"
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-base-100/80 backdrop-blur-sm"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-base-100/80 backdrop-blur-sm"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-busy="true"
|
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">
|
<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" />
|
<span class="loading loading-spinner loading-lg text-primary" />
|
||||||
<p class="font-medium">
|
<p class="font-medium">
|
||||||
{{ rebuilding ? 'Rebuilding zooms…' : merging ? 'Merging…' : 'Wiping data…' }}
|
{{ rebuilding ? 'Rebuilding zooms…' : merging ? 'Merging…' : 'Wiping data…' }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-base-content/70">This may take a moment.</p>
|
<p class="text-sm text-base-content/70">This may take a moment.</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
<p>Wipe all grids, markers, tiles and maps? This cannot be undone.</p>
|
<p>Wipe all grids, markers, tiles and maps? This cannot be undone.</p>
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<form method="dialog">
|
<form method="dialog">
|
||||||
<button class="btn">Cancel</button>
|
<button class="btn">Cancel</button>
|
||||||
</form>
|
</form>
|
||||||
<button class="btn btn-error" :disabled="wiping" @click="doWipe">Wipe</button>
|
<button class="btn btn-error" :disabled="wiping" @click="doWipe">Wipe</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<dialog ref="rebuildModalRef" class="modal" aria-labelledby="rebuild-modal-title">
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h2 id="rebuild-modal-title" class="font-bold text-lg mb-2">Rebuild zooms</h2>
|
<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>
|
<p>Rebuild tile zoom levels for all maps? This may take a while.</p>
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<form method="dialog">
|
<form method="dialog">
|
||||||
<button class="btn">Cancel</button>
|
<button class="btn">Cancel</button>
|
||||||
</form>
|
</form>
|
||||||
<button class="btn btn-primary" :disabled="rebuilding" @click="doRebuildZooms">Rebuild</button>
|
<button class="btn btn-primary" :disabled="rebuilding" @click="doRebuildZooms">Rebuild</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
definePageMeta({ middleware: 'admin' })
|
definePageMeta({ middleware: 'admin' })
|
||||||
|
|
||||||
const api = useMapApi()
|
const api = useMapApi()
|
||||||
const toast = useToast()
|
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: '' })
|
||||||
const savingSettings = ref(false)
|
const savingSettings = ref(false)
|
||||||
const rebuilding = ref(false)
|
const rebuilding = ref(false)
|
||||||
const wiping = ref(false)
|
const wiping = ref(false)
|
||||||
const merging = ref(false)
|
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 rebuildModalRef = ref<HTMLDialogElement | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const userSearch = ref('')
|
const userSearch = ref('')
|
||||||
const mapSearch = ref('')
|
const mapSearch = ref('')
|
||||||
const mapsSortBy = ref<'ID' | 'Name'>('ID')
|
const mapsSortBy = ref<'ID' | 'Name'>('ID')
|
||||||
const mapsSortDir = ref<'asc' | 'desc'>('asc')
|
const mapsSortDir = ref<'asc' | 'desc'>('asc')
|
||||||
|
|
||||||
const filteredUsers = computed(() => {
|
const filteredUsers = computed(() => {
|
||||||
const q = userSearch.value.trim().toLowerCase()
|
const q = userSearch.value.trim().toLowerCase()
|
||||||
if (!q) return users.value
|
if (!q) return users.value
|
||||||
return users.value.filter((u) => u.toLowerCase().includes(q))
|
return users.value.filter((u) => u.toLowerCase().includes(q))
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredMaps = computed(() => {
|
const filteredMaps = computed(() => {
|
||||||
const q = mapSearch.value.trim().toLowerCase()
|
const q = mapSearch.value.trim().toLowerCase()
|
||||||
if (!q) return maps.value
|
if (!q) return maps.value
|
||||||
return maps.value.filter(
|
return maps.value.filter(
|
||||||
(m) =>
|
(m) =>
|
||||||
m.Name.toLowerCase().includes(q) ||
|
m.Name.toLowerCase().includes(q) ||
|
||||||
String(m.ID).includes(q)
|
String(m.ID).includes(q)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const sortedFilteredMaps = computed(() => {
|
const sortedFilteredMaps = computed(() => {
|
||||||
const list = [...filteredMaps.value]
|
const list = [...filteredMaps.value]
|
||||||
const by = mapsSortBy.value
|
const by = mapsSortBy.value
|
||||||
const dir = mapsSortDir.value
|
const dir = mapsSortDir.value
|
||||||
list.sort((a, b) => {
|
list.sort((a, b) => {
|
||||||
let cmp = 0
|
let cmp = 0
|
||||||
if (by === 'ID') cmp = a.ID - b.ID
|
if (by === 'ID') cmp = a.ID - b.ID
|
||||||
else cmp = a.Name.localeCompare(b.Name)
|
else cmp = a.Name.localeCompare(b.Name)
|
||||||
return dir === 'asc' ? cmp : -cmp
|
return dir === 'asc' ? cmp : -cmp
|
||||||
})
|
})
|
||||||
return list
|
return list
|
||||||
})
|
})
|
||||||
|
|
||||||
function setMapsSort(by: 'ID' | 'Name') {
|
function setMapsSort(by: 'ID' | 'Name') {
|
||||||
if (mapsSortBy.value === by) mapsSortDir.value = mapsSortDir.value === 'asc' ? 'desc' : 'asc'
|
if (mapsSortBy.value === by) mapsSortDir.value = mapsSortDir.value === 'asc' ? 'desc' : 'asc'
|
||||||
else {
|
else {
|
||||||
mapsSortBy.value = by
|
mapsSortBy.value = by
|
||||||
mapsSortDir.value = 'asc'
|
mapsSortDir.value = 'asc'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadUsers(), loadMaps(), loadSettings()])
|
await Promise.all([loadUsers(), loadMaps(), loadSettings()])
|
||||||
loading.value = false
|
loading.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
try {
|
try {
|
||||||
users.value = await api.adminUsers()
|
users.value = await api.adminUsers()
|
||||||
} catch {
|
} catch {
|
||||||
users.value = []
|
users.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMaps() {
|
async function loadMaps() {
|
||||||
try {
|
try {
|
||||||
maps.value = await api.adminMaps()
|
maps.value = await api.adminMaps()
|
||||||
} catch {
|
} catch {
|
||||||
maps.value = []
|
maps.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
try {
|
try {
|
||||||
const s = await api.adminSettings()
|
const s = await api.adminSettings()
|
||||||
settings.value = { prefix: s.prefix ?? '', defaultHide: s.defaultHide ?? false, title: s.title ?? '' }
|
settings.value = { prefix: s.prefix ?? '', defaultHide: s.defaultHide ?? false, title: s.title ?? '' }
|
||||||
} catch {
|
} catch {
|
||||||
//
|
//
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
savingSettings.value = true
|
savingSettings.value = true
|
||||||
try {
|
try {
|
||||||
await api.adminSettingsPost(settings.value)
|
await api.adminSettingsPost(settings.value)
|
||||||
toast.success('Settings saved.')
|
toast.success('Settings saved.')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmRebuildZooms() {
|
function confirmRebuildZooms() {
|
||||||
rebuildModalRef.value?.showModal()
|
rebuildModalRef.value?.showModal()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doRebuildZooms() {
|
async function doRebuildZooms() {
|
||||||
rebuildModalRef.value?.close()
|
rebuildModalRef.value?.close()
|
||||||
rebuilding.value = true
|
rebuilding.value = true
|
||||||
try {
|
try {
|
||||||
await api.adminRebuildZooms()
|
await api.adminRebuildZooms()
|
||||||
toast.success('Zooms rebuilt.')
|
toast.success('Zooms rebuilt.')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmWipe() {
|
function confirmWipe() {
|
||||||
wipeModalRef.value?.showModal()
|
wipeModalRef.value?.showModal()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doWipe() {
|
async function doWipe() {
|
||||||
wiping.value = true
|
wiping.value = true
|
||||||
try {
|
try {
|
||||||
await api.adminWipe()
|
await api.adminWipe()
|
||||||
wipeModalRef.value?.close()
|
wipeModalRef.value?.close()
|
||||||
await loadMaps()
|
await loadMaps()
|
||||||
toast.success('All data wiped.')
|
toast.success('All data wiped.')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMergeFile(e: Event) {
|
function onMergeFile(e: Event) {
|
||||||
const input = e.target as HTMLInputElement
|
const input = e.target as HTMLInputElement
|
||||||
mergeFile.value = input.files?.[0] ?? null
|
mergeFile.value = input.files?.[0] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doMerge() {
|
async function doMerge() {
|
||||||
if (!mergeFile.value) return
|
if (!mergeFile.value) return
|
||||||
merging.value = true
|
merging.value = true
|
||||||
try {
|
try {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
fd.append('merge', mergeFile.value)
|
fd.append('merge', mergeFile.value)
|
||||||
await api.adminMerge(fd)
|
await api.adminMerge(fd)
|
||||||
mergeFile.value = null
|
mergeFile.value = null
|
||||||
if (mergeFileRef.value) mergeFileRef.value.value = ''
|
if (mergeFileRef.value) mergeFileRef.value.value = ''
|
||||||
await loadMaps()
|
await loadMaps()
|
||||||
toast.success('Merge completed.')
|
toast.success('Merge completed.')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,190 +1,190 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container mx-auto p-4 max-w-2xl">
|
<div class="container mx-auto p-4 max-w-2xl min-w-0">
|
||||||
<h1 class="text-2xl font-bold mb-6">Profile</h1>
|
<h1 class="text-2xl font-bold mb-6">Profile</h1>
|
||||||
|
|
||||||
<!-- User info card -->
|
<!-- 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">
|
<template v-if="initialLoad">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<Skeleton class="size-14 rounded-full shrink-0" />
|
<Skeleton class="size-14 rounded-full shrink-0" />
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<Skeleton class="h-6 w-32" />
|
<Skeleton class="h-6 w-32" />
|
||||||
<Skeleton class="h-4 w-48" />
|
<Skeleton class="h-4 w-48" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="me">
|
<template v-else-if="me">
|
||||||
<div class="flex flex-wrap items-center gap-4">
|
<div class="flex flex-wrap items-center gap-4">
|
||||||
<div class="avatar placeholder">
|
<div class="avatar placeholder">
|
||||||
<div class="bg-primary text-primary-content rounded-full w-14">
|
<div class="bg-primary text-primary-content rounded-full w-14">
|
||||||
<span class="text-2xl font-semibold">{{ (me.username || '?')[0].toUpperCase() }}</span>
|
<span class="text-2xl font-semibold">{{ (me.username || '?')[0].toUpperCase() }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<h2 class="text-lg font-semibold">{{ me.username }}</h2>
|
<h2 class="text-lg font-semibold">{{ me.username }}</h2>
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
<span
|
<span
|
||||||
v-for="auth in (me.auths ?? [])"
|
v-for="auth in (me.auths ?? [])"
|
||||||
:key="auth"
|
:key="auth"
|
||||||
class="badge badge-sm badge-outline"
|
class="badge badge-sm badge-outline"
|
||||||
>
|
>
|
||||||
{{ auth }}
|
{{ auth }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="!me.auths?.length" class="text-sm text-base-content/60">No roles</span>
|
<span v-if="!me.auths?.length" class="text-sm text-base-content/60">No roles</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Upload tokens -->
|
<!-- Upload tokens -->
|
||||||
<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">
|
<template v-if="initialLoad">
|
||||||
<Skeleton class="h-6 w-32 mb-2" />
|
<Skeleton class="h-6 w-32 mb-2" />
|
||||||
<Skeleton class="h-4 w-full mb-4" />
|
<Skeleton class="h-4 w-full mb-4" />
|
||||||
<Skeleton class="h-8 w-full mb-2" />
|
<Skeleton class="h-8 w-full mb-2" />
|
||||||
<Skeleton class="h-9 w-36 mt-2" />
|
<Skeleton class="h-9 w-36 mt-2" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<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-none mt-2 space-y-2">
|
<ul v-if="tokens?.length" class="list-none mt-2 space-y-2">
|
||||||
<li
|
<li
|
||||||
v-for="(t, idx) in tokens"
|
v-for="(t, idx) in tokens"
|
||||||
:key="t"
|
:key="t"
|
||||||
class="font-mono text-sm flex flex-wrap items-center gap-2 p-2 rounded-lg bg-base-300/50"
|
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="break-all flex-1 min-w-0">{{ uploadTokenDisplay(t) }}</span>
|
||||||
<span class="text-xs text-base-content/60 shrink-0">Token {{ idx + 1 }}</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 gap-1"
|
class="btn btn-ghost btn-xs shrink-0 gap-1 min-h-9 min-w-[4rem] touch-manipulation"
|
||||||
aria-label="Copy token"
|
aria-label="Copy token"
|
||||||
:class="copiedToken === t ? 'btn-success' : ''"
|
:class="copiedToken === t ? 'btn-success' : ''"
|
||||||
@click="copyToken(t)"
|
@click="copyToken(t)"
|
||||||
>
|
>
|
||||||
<template v-if="copiedToken === t">Copied!</template>
|
<template v-if="copiedToken === t">Copied!</template>
|
||||||
<template v-else>Copy</template>
|
<template v-else>Copy</template>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p v-else class="text-sm mt-2">No tokens yet.</p>
|
<p v-else class="text-sm mt-2">No tokens yet.</p>
|
||||||
<p v-if="tokenError" class="text-error text-sm mt-2">{{ tokenError }}</p>
|
<p v-if="tokenError" class="text-error text-sm mt-2">{{ tokenError }}</p>
|
||||||
<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 min-h-11 touch-manipulation transition-all duration-200 hover:scale-[1.02]" :disabled="loadingTokens" @click="generateToken">
|
||||||
{{ loadingTokens ? '…' : 'Generate token' }}
|
{{ loadingTokens ? '…' : 'Generate token' }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</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">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title gap-2">
|
<h2 class="card-title gap-2">
|
||||||
<icons-icon-settings />
|
<icons-icon-settings />
|
||||||
Change password
|
Change password
|
||||||
</h2>
|
</h2>
|
||||||
<form @submit.prevent="changePass" class="flex flex-col gap-2">
|
<form @submit.prevent="changePass" class="flex flex-col gap-2">
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
v-model="newPass"
|
v-model="newPass"
|
||||||
placeholder="New password"
|
placeholder="New password"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
/>
|
/>
|
||||||
<p v-if="passMsg" class="text-sm" :class="passOk ? 'text-success' : 'text-error'">{{ passMsg }}</p>
|
<p v-if="passMsg" class="text-sm" :class="passOk ? 'text-success' : 'text-error'">{{ passMsg }}</p>
|
||||||
<button type="submit" class="btn btn-sm transition-all duration-200 hover:scale-[1.02]" :disabled="loadingPass">Save password</button>
|
<button type="submit" class="btn btn-sm min-h-11 touch-manipulation transition-all duration-200 hover:scale-[1.02]" :disabled="loadingPass">Save password</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const api = useMapApi()
|
const api = useMapApi()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const initialLoad = ref(true)
|
const initialLoad = ref(true)
|
||||||
const me = ref<{ username?: string; auths?: string[] } | null>(null)
|
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('')
|
||||||
const loadingTokens = ref(false)
|
const loadingTokens = ref(false)
|
||||||
const loadingPass = ref(false)
|
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)
|
const copiedToken = ref<string | null>(null)
|
||||||
let copiedTimeout: ReturnType<typeof setTimeout> | 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}`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyToken(token: string) {
|
async function copyToken(token: string) {
|
||||||
const text = uploadTokenDisplay(token)
|
const text = uploadTokenDisplay(token)
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
copiedToken.value = token
|
copiedToken.value = token
|
||||||
toast.success('Copied to clipboard', 2000)
|
toast.success('Copied to clipboard', 2000)
|
||||||
if (copiedTimeout) clearTimeout(copiedTimeout)
|
if (copiedTimeout) clearTimeout(copiedTimeout)
|
||||||
copiedTimeout = setTimeout(() => {
|
copiedTimeout = setTimeout(() => {
|
||||||
copiedToken.value = null
|
copiedToken.value = null
|
||||||
}, 2000)
|
}, 2000)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to copy')
|
toast.error('Failed to copy')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await api.me()
|
const data = await api.me()
|
||||||
me.value = { username: data.username, auths: data.auths }
|
me.value = { username: data.username, auths: data.auths }
|
||||||
tokens.value = data.tokens ?? []
|
tokens.value = data.tokens ?? []
|
||||||
uploadPrefix.value = data.prefix ?? ''
|
uploadPrefix.value = data.prefix ?? ''
|
||||||
} catch {
|
} catch {
|
||||||
me.value = null
|
me.value = null
|
||||||
tokens.value = []
|
tokens.value = []
|
||||||
uploadPrefix.value = ''
|
uploadPrefix.value = ''
|
||||||
} finally {
|
} finally {
|
||||||
initialLoad.value = false
|
initialLoad.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function generateToken() {
|
async function generateToken() {
|
||||||
tokenError.value = ''
|
tokenError.value = ''
|
||||||
loadingTokens.value = true
|
loadingTokens.value = true
|
||||||
try {
|
try {
|
||||||
await api.meTokens()
|
await api.meTokens()
|
||||||
const me = await api.me()
|
const me = await api.me()
|
||||||
tokens.value = me.tokens ?? []
|
tokens.value = me.tokens ?? []
|
||||||
uploadPrefix.value = me.prefix ?? ''
|
uploadPrefix.value = me.prefix ?? ''
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const msg = e instanceof Error ? e.message : ''
|
const msg = e instanceof Error ? e.message : ''
|
||||||
tokenError.value = msg === 'Forbidden'
|
tokenError.value = msg === 'Forbidden'
|
||||||
? 'You need "upload" permission to generate tokens. Ask an admin to add it to your account.'
|
? 'You need "upload" permission to generate tokens. Ask an admin to add it to your account.'
|
||||||
: (msg || 'Failed to generate token')
|
: (msg || 'Failed to generate token')
|
||||||
} finally {
|
} finally {
|
||||||
loadingTokens.value = false
|
loadingTokens.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function changePass() {
|
async function changePass() {
|
||||||
passMsg.value = ''
|
passMsg.value = ''
|
||||||
loadingPass.value = true
|
loadingPass.value = true
|
||||||
try {
|
try {
|
||||||
await api.mePassword(newPass.value)
|
await api.mePassword(newPass.value)
|
||||||
passMsg.value = 'Password updated.'
|
passMsg.value = 'Password updated.'
|
||||||
passOk.value = true
|
passOk.value = true
|
||||||
newPass.value = ''
|
newPass.value = ''
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
passMsg.value = e instanceof Error ? e.message : 'Failed'
|
passMsg.value = e instanceof Error ? e.message : 'Failed'
|
||||||
passOk.value = false
|
passOk.value = false
|
||||||
} finally {
|
} finally {
|
||||||
loadingPass.value = false
|
loadingPass.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
22
frontend-nuxt/plugins/suppress-leaflet-deprecation.client.ts
Normal file
22
frontend-nuxt/plugins/suppress-leaflet-deprecation.client.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Suppress known Firefox deprecation warnings from Leaflet when panning/dragging the map.
|
||||||
|
* See: https://github.com/Leaflet/Leaflet/issues/7078, https://github.com/Leaflet/Leaflet/issues/9386
|
||||||
|
* No ill effect; Leaflet has not fixed this upstream.
|
||||||
|
*/
|
||||||
|
export default defineNuxtPlugin(() => {
|
||||||
|
if (import.meta.dev && typeof console !== 'undefined' && console.warn) {
|
||||||
|
const originalWarn = console.warn
|
||||||
|
console.warn = function (...args: unknown[]) {
|
||||||
|
const msg = typeof args[0] === 'string' ? args[0] : String(args[0])
|
||||||
|
if (
|
||||||
|
msg.includes('mozPressure') ||
|
||||||
|
msg.includes('mozInputSource') ||
|
||||||
|
msg.includes('PointerEvent.pressure') ||
|
||||||
|
msg.includes('PointerEvent.pointerType')
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
originalWarn.apply(console, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user