531 lines
19 KiB
Vue
531 lines
19 KiB
Vue
<script setup lang="ts">
|
|
import type { MapMarker, MapMarkerGroup, PlayerMarker } from '@/types/Leaflet';
|
|
import type { SearchMode } from '@/types/Search';
|
|
import { onClickOutside, useFocus, useFocusWithin, useLocalStorage, useMagicKeys, whenever } from '@vueuse/core';
|
|
import { computed, onMounted, ref, onUpdated, watch } from 'vue';
|
|
import SearchMarkersTags from './SearchMarkersTags.vue';
|
|
|
|
const props = defineProps<{
|
|
markers: MapMarker[],
|
|
players: PlayerMarker,
|
|
}>()
|
|
|
|
const fixedMarkers = props.markers
|
|
const customMarkersData = useLocalStorage('custom-markers', [])
|
|
|
|
const allMarkers = computed(() => {
|
|
return [...fixedMarkers, ...customMarkersData.value]
|
|
})
|
|
|
|
// Search functions
|
|
const searchBar = ref<HTMLDivElement>()
|
|
const searchBarWrapper = ref<HTMLDivElement>()
|
|
const qInput = ref()
|
|
const { focused: isComponentFocused } = useFocusWithin(searchBarWrapper)
|
|
const { focused: isSearchFocused } = useFocus(qInput)
|
|
|
|
const currentSearchMode = ref<SearchMode>('query')
|
|
|
|
onMounted(() => {
|
|
isSearchFocused.value = true
|
|
})
|
|
|
|
const hasQuery = computed(() => q.value.length > 0)
|
|
const shouldBeActive = computed(() =>
|
|
(hasQuery.value && currentSearchMode.value === "query" && isComponentFocused.value)
|
|
|| currentSearchMode.value !== "query"
|
|
)
|
|
|
|
const q = ref<string>("")
|
|
|
|
const unifier = new RegExp(/[^a-zA-Z0-9\-\'']/g)
|
|
|
|
/**
|
|
* Dropdown's markers list
|
|
* If the search doesn't have a query set, defaults to marker grouping
|
|
*/
|
|
const filteredMarkers = computed(() => {
|
|
if (currentSearchMode.value === "query") {
|
|
return allMarkers.value?.filter(m => {
|
|
const queryString = new String(q.value).replace(unifier, "").toLocaleLowerCase()
|
|
const hitTitle = m.title.replace(unifier, "").toLocaleLowerCase().includes(queryString)
|
|
const hitDesc = m.description?.replace(unifier, "").toLocaleLowerCase().includes(queryString)
|
|
|
|
return hitTitle || hitDesc
|
|
})
|
|
}
|
|
|
|
return allMarkers.value?.filter(m => {
|
|
return m.group === currentSearchMode.value
|
|
})
|
|
})
|
|
|
|
// Limit of menu list dropdown
|
|
const currentLimit = computed(() => {
|
|
if (currentSearchMode.value === "query") return 50
|
|
return 500
|
|
})
|
|
|
|
/**
|
|
* ACCESSIBILITY
|
|
*/
|
|
// Registered key combos
|
|
const keys = useMagicKeys()
|
|
const shortcutKeyCombo = keys['Shift+Period']
|
|
const eraseKeyCombo = keys['Escape']
|
|
|
|
whenever(shortcutKeyCombo, () => {
|
|
isSearchFocused.value = true
|
|
})
|
|
whenever(eraseKeyCombo, () => {
|
|
resetAllFields('focusAfter')
|
|
})
|
|
|
|
// If query changes and has new value...
|
|
watch(q, (n, o) => {
|
|
if (n) setSearchMode('query')
|
|
})
|
|
|
|
// Toggle menu if clicked outside
|
|
onClickOutside(searchBarWrapper, () => setSearchMode('query'))
|
|
|
|
/**
|
|
* Player geolocation
|
|
* Uses native JS to avoid changing leaflet library
|
|
*/
|
|
onMounted(() => {
|
|
const playerBtnRef = document.querySelector('[data-to-players]')
|
|
const flyToPlayers = new CustomEvent(`fly-to-players`, { bubbles: true })
|
|
|
|
playerBtnRef?.addEventListener('click', () => {
|
|
playerBtnRef.dispatchEvent(flyToPlayers)
|
|
})
|
|
})
|
|
|
|
const hasPlayers = computed(() => Object.keys(props.players).length > 0)
|
|
|
|
/**
|
|
* Marker geolocation
|
|
* We need to use onUpdated hook because the list is not always rendered, and it often rebuilt.
|
|
*/
|
|
onUpdated(() => {
|
|
const markerBtnRefs = document.querySelectorAll('[data-to-marker]')
|
|
|
|
markerBtnRefs.forEach((btn) => {
|
|
const { toMarker } = (btn as HTMLButtonElement).dataset
|
|
|
|
if (!toMarker) return
|
|
|
|
const flyToEvent = new CustomEvent(`fly-to-${toMarker}`, { bubbles: true })
|
|
|
|
btn.addEventListener('click', () => btn.dispatchEvent(flyToEvent))
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Advanced Search Mode
|
|
* Uses filters to show specific kinds of markers in the search results
|
|
*/
|
|
const hasGroupFilter = computed(() => {
|
|
return currentSearchMode.value !== 'query'
|
|
})
|
|
|
|
function handleCategorySwitch(g: MapMarkerGroup) {
|
|
resetQueryValue()
|
|
|
|
if (currentSearchMode.value !== g) {
|
|
currentSearchMode.value = g
|
|
} else {
|
|
currentSearchMode.value = "query"
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Custom Markers handling
|
|
*/
|
|
onMounted(() => {
|
|
customMarkersData.value = useLocalStorage('custom-markers', []).value
|
|
|
|
document.addEventListener('refresh-custom-markers', () => {
|
|
customMarkersData.value = useLocalStorage('custom-markers', []).value
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Emit event to delete custom marker
|
|
*/
|
|
function emitDeleteCustomMarker(markerTitle: string) {
|
|
const deleteCMarkerEvent = new CustomEvent(`delete-${markerTitle}`, { bubbles: true })
|
|
|
|
searchBar.value?.dispatchEvent(deleteCMarkerEvent)
|
|
}
|
|
|
|
/**
|
|
* EVENTS
|
|
*/
|
|
// Close Button event
|
|
function onCloseQuery() {
|
|
resetAllFields("focusAfter")
|
|
}
|
|
|
|
/**
|
|
* UTILITIES
|
|
*/
|
|
/**
|
|
* Switches current search mode
|
|
*/
|
|
function setSearchMode(m: SearchMode) {
|
|
currentSearchMode.value = m
|
|
}
|
|
|
|
/**
|
|
* Resets active query in field
|
|
*/
|
|
function resetQueryValue() {
|
|
q.value = ""
|
|
}
|
|
|
|
/**
|
|
* Resets category and query states, default state
|
|
*/
|
|
function resetAllFields(actionAfter?: "focusAfter") {
|
|
setSearchMode('query')
|
|
|
|
resetQueryValue()
|
|
|
|
if (actionAfter === "focusAfter") isSearchFocused.value = true
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<nav ref="searchBarWrapper" class="toolbar">
|
|
<div ref="searchBar" class="search-w" :data-focused="shouldBeActive">
|
|
<div class="input-w">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="search-icon"><rect width="256" height="256" fill="none"/><path d="M168,112a56,56,0,1,1-56-56A56,56,0,0,1,168,112Zm61.66,117.66a8,8,0,0,1-11.32,0l-50.06-50.07a88,88,0,1,1,11.32-11.31l50.06,50.06A8,8,0,0,1,229.66,229.66ZM112,184a72,72,0,1,0-72-72A72.08,72.08,0,0,0,112,184Z"/></svg>
|
|
|
|
<input ref="qInput" class="" name="recherche" type="text" v-model="q" title="Rechercher le monde" placeholder="Ville, point d'intérêt…">
|
|
|
|
<button v-if="hasGroupFilter || hasQuery" @click="onCloseQuery" class="close-btn" title="Enlever le filtre">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="icon"><rect width="256" height="256" fill="none"/><line x1="200" y1="56" x2="56" y2="200" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="12"/><line x1="200" y1="200" x2="56" y2="56" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="12"/></svg>
|
|
</button>
|
|
|
|
<button v-if="hasPlayers" data-to-players class="player-btn" :tabindex="shouldBeActive ? 1 : 0" title="Aller à la position actuelle des joueurs">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="icon"><rect width="256" height="256" fill="none"/><path d="M128,16a88.1,88.1,0,0,0-88,88c0,75.3,80,132.17,83.41,134.55a8,8,0,0,0,9.18,0C136,236.17,216,179.3,216,104A88.1,88.1,0,0,0,128,16Zm0,56a32,32,0,1,1-32,32A32,32,0,0,1,128,72Z"/></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<ul class="search-results" v-if="shouldBeActive">
|
|
<li v-for="m in filteredMarkers?.slice(0, currentLimit)" :key="m.title">
|
|
<button
|
|
class="search-item"
|
|
:class="`group-${m.group}`"
|
|
:data-to-marker="m.title"
|
|
tabindex="0"
|
|
:title="m.group === 'quests' ? 'Voir la quête' : 'Visiter ce lieu'"
|
|
>
|
|
<span class="title">{{ m.title }}</span>
|
|
|
|
<div class="desc" v-html="m.description"></div>
|
|
|
|
<svg v-if="m.group === 'quests'" class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M231.22,59.44l-80,168a8,8,0,1,1-14.44-6.88L165.62,160H32a8,8,0,0,1-5.88-13.43l42.56-46.1L26.59,61.9A8,8,0,0,1,32,48H224a8,8,0,0,1,7.22,11.44Z"/></svg>
|
|
<svg v-else-if="m.group === 'capitals'" class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M200,24h-8a16,16,0,0,0-16,16V56H148V40a16,16,0,0,0-16-16h-8a16,16,0,0,0-16,16V56H80V40A16,16,0,0,0,64,24H56A16,16,0,0,0,40,40V84.69A15.86,15.86,0,0,0,44.69,96L56,107.31V216a16,16,0,0,0,16,16H184a16,16,0,0,0,16-16V107.31L211.31,96A15.86,15.86,0,0,0,216,84.69V40A16,16,0,0,0,200,24ZM152,216H104V152a24,24,0,0,1,48,0Z"/></svg>
|
|
<svg v-else-if="m.group === 'landmarks'" class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><path d="M208,80a8,8,0,0,0-8,8v16H188.85L184,55.2A8,8,0,0,0,181.31,50h0L138.44,11.88l-.2-.17a16,16,0,0,0-20.48,0l-.2.17L74.68,50v0A7.93,7.93,0,0,0,72,55.2L67.15,104H56V88a8,8,0,0,0-16,0v24a8,8,0,0,0,8,8H65.54l-9.47,94.48A16,16,0,0,0,72,232H184a16,16,0,0,0,15.92-17.56L190.46,120H208a8,8,0,0,0,8-8V88A8,8,0,0,0,208,80ZM87.24,64h81.52l4,40H136V88a8,8,0,0,0-16,0v16H83.23ZM72,216l4.81-48H179.19L184,216Z"/></svg>
|
|
</button>
|
|
|
|
<!-- Custom marker actions -->
|
|
<div class="search-item-actions" v-if="m.group === 'custom'">
|
|
<!-- <button class="btn btn-info btn-icon btn-sm">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="icon"><rect width="256" height="256" fill="none"/><path d="M92.69,216H48a8,8,0,0,1-8-8V163.31a8,8,0,0,1,2.34-5.65L165.66,34.34a8,8,0,0,1,11.31,0L221.66,79a8,8,0,0,1,0,11.31L98.34,213.66A8,8,0,0,1,92.69,216Z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="136" y1="64" x2="192" y2="120" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
|
|
</button> -->
|
|
|
|
<button class="btn btn-danger btn-icon btn-sm" @click="emitDeleteCustomMarker(m.title)">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="icon"><rect width="256" height="256" fill="none"/><line x1="216" y1="56" x2="40" y2="56" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="104" y1="104" x2="104" y2="168" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="152" y1="104" x2="152" y2="168" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M200,56V208a8,8,0,0,1-8,8H64a8,8,0,0,1-8-8V56" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path d="M168,56V40a16,16,0,0,0-16-16H104A16,16,0,0,0,88,40V56" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
|
|
</button>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<SearchMarkersTags
|
|
:current-search-mode="currentSearchMode"
|
|
:custom-markers="customMarkersData"
|
|
@on-category-switch="handleCategorySwitch"
|
|
/>
|
|
</nav>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
.toolbar {
|
|
@media screen and (width >= 900px) {
|
|
display: flex;
|
|
gap: 1.5rem;
|
|
align-items: start;
|
|
}
|
|
|
|
.search-w {
|
|
padding: .5rem 1.2rem;
|
|
width: calc(100% - 3rem);
|
|
background: var(--white);
|
|
border: 1px solid var(--slate-400);
|
|
border-radius: 25px;
|
|
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 12px;
|
|
pointer-events: all;
|
|
|
|
@media screen and (width >= 900px) {
|
|
width: 25%;
|
|
}
|
|
|
|
.input-w {
|
|
$search-items-gap: .5rem;
|
|
$search-icon-size: 1.2em;
|
|
|
|
position: relative;
|
|
padding-block: .2rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: $search-items-gap;
|
|
|
|
input {
|
|
display: block;
|
|
min-width: 10rem;
|
|
padding-inline-start: calc($search-icon-size + $search-items-gap);
|
|
flex-grow: 1;
|
|
|
|
&::placeholder {
|
|
color: var(--slate-400);
|
|
}
|
|
}
|
|
|
|
.search-icon {
|
|
position: absolute;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
left: 0;
|
|
width: $search-icon-size;
|
|
fill: var(--slate-400);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.player-btn, .close-btn {
|
|
position: relative;
|
|
isolation: isolate;
|
|
aspect-ratio: 1 / 1;
|
|
line-height: 1;
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
|
|
&::before {
|
|
display: block;
|
|
content: '';
|
|
position: absolute;
|
|
inset: -.4rem;
|
|
background-color: transparent;
|
|
border-radius: 50%;
|
|
z-index: -1;
|
|
outline-offset: -.2rem;
|
|
transition-property: background-color;
|
|
transition-duration: .2s;
|
|
transition-timing-function: cubic-bezier(0.445, 0.05, 0.55, 0.95);
|
|
}
|
|
|
|
&:hover {
|
|
&::before {
|
|
background-color: var(--slate-300);
|
|
}
|
|
}
|
|
|
|
&:focus-visible {
|
|
&::before {
|
|
background-color: var(--slate-200);
|
|
outline: 1px dotted var(--slate-500);
|
|
outline: 4px auto var(--slate-900);
|
|
}
|
|
}
|
|
}
|
|
|
|
.player-btn {
|
|
margin-left: .25rem;
|
|
|
|
.icon {
|
|
font-size: 1.5em;
|
|
fill: var(--red-500);
|
|
}
|
|
|
|
&:hover {
|
|
&::before {
|
|
background-color: var(--slate-300);
|
|
}
|
|
}
|
|
|
|
&:focus-visible {
|
|
&::before {
|
|
background-color: var(--slate-200);
|
|
outline: 1px dotted var(--slate-500);
|
|
outline: 4px auto var(--slate-900);
|
|
}
|
|
}
|
|
}
|
|
|
|
.close-btn {
|
|
font-size: 1.1em;
|
|
color: var(--slate-500);
|
|
}
|
|
}
|
|
|
|
.search-results {
|
|
max-height: 20vh;
|
|
overflow-y: auto;
|
|
scrollbar-gutter: stable;
|
|
|
|
@media screen and (width >= 900px) {
|
|
max-height: 50vh;
|
|
}
|
|
|
|
&::-webkit-scrollbar,
|
|
&::-webkit-scrollbar-thumb {
|
|
width: 26px;
|
|
border-radius: 13px;
|
|
background-clip: padding-box;
|
|
border: 10px solid transparent;
|
|
}
|
|
|
|
&::-webkit-scrollbar-thumb {
|
|
background-color: var(--slate-300);
|
|
}
|
|
|
|
> * + * {
|
|
margin-bottom: .33rem;
|
|
}
|
|
|
|
li {
|
|
position: relative;
|
|
|
|
&:hover:not(:has(a:hover)) {
|
|
background-color: var(--slate-100);
|
|
|
|
.title {
|
|
text-decoration: underline;
|
|
}
|
|
}
|
|
|
|
.search-item {
|
|
position: relative;
|
|
cursor: pointer;
|
|
padding: .4rem .25em;
|
|
padding-right: 2.75rem;
|
|
width: 100%;
|
|
outline-offset: -1px;
|
|
|
|
.title,
|
|
.desc {
|
|
display: block;
|
|
font-size: .85em;
|
|
}
|
|
|
|
.title {
|
|
font-weight: 500;
|
|
text-underline-offset: 2px;
|
|
}
|
|
.desc {
|
|
color: var(--slate-500);
|
|
|
|
:deep(a) {
|
|
text-underline-offset: 2px;
|
|
text-decoration: underline;
|
|
|
|
&:hover {
|
|
color: var(--green-500);
|
|
}
|
|
}
|
|
}
|
|
|
|
.icon {
|
|
position: absolute;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
right: .75rem;
|
|
fill: var(--slate-400);
|
|
}
|
|
|
|
&:focus-visible {
|
|
outline: 1px dotted var(--slate-500);
|
|
outline: 4px auto var(--slate-900);
|
|
|
|
.title {
|
|
text-decoration: underline;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rules for specific groups (capitals, cities, etc...)
|
|
*/
|
|
&.group-capitals {
|
|
.title {
|
|
font-weight: 600;
|
|
}
|
|
}
|
|
|
|
&.group-quests {
|
|
&:hover:not(:has(a:hover)) {
|
|
.icon {
|
|
color: var(--red-500);
|
|
}
|
|
}
|
|
}
|
|
|
|
&.group-custom {
|
|
.title {
|
|
font-style: italic;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Custom markers actions
|
|
*/
|
|
.search-item-actions {
|
|
position: absolute;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
right: .25em;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: .3rem;
|
|
transition-property: visibility, opacity;
|
|
transition-duration: .2s;
|
|
transition-timing-function: cubic-bezier(0.785, 0.135, 0.15, 0.86);
|
|
}
|
|
|
|
&:hover {
|
|
.search-item-actions {
|
|
visibility: visible;
|
|
opacity: 1;
|
|
pointer-events: all;
|
|
}
|
|
}
|
|
|
|
&:not(:hover) {
|
|
.search-item-actions {
|
|
visibility: hidden;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
&[data-focused="true"] {
|
|
margin-bottom: 1em;
|
|
.input-w {
|
|
border-bottom: 1px solid var(--slate-200);
|
|
margin-bottom: .5rem;
|
|
padding-bottom: .5rem;
|
|
|
|
input {
|
|
padding-block-start: .2rem;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</style> |