Add the map in search view
Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
class="mbz-card snap-center dark:bg-mbz-purple"
|
||||
:class="{
|
||||
'sm:flex sm:items-start': mode === 'row',
|
||||
'max-w-xs w-[18rem] shrink-0 flex flex-col': mode === 'column',
|
||||
'sm:max-w-xs sm:w-[18rem] shrink-0 flex flex-col': mode === 'column',
|
||||
}"
|
||||
:to="to"
|
||||
:isInternal="isInternal"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-2xl">{{ title }}</h2>
|
||||
<h2>{{ title }}</h2>
|
||||
<div class="flex items-center mb-3 gap-1 eventMetadataBlock">
|
||||
<slot name="icon"></slot>
|
||||
<!-- Custom icons -->
|
||||
@@ -15,7 +15,7 @@
|
||||
/>
|
||||
</span>
|
||||
<o-icon v-else-if="icon" :icon="icon" size="is-medium" /> -->
|
||||
<div class="content-wrapper">
|
||||
<div class="content-wrapper overflow-hidden w-full">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
@@ -28,13 +28,7 @@ defineProps<{
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
div.eventMetadataBlock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1.75rem;
|
||||
|
||||
.content-wrapper {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
max-width: calc(100vw - 32px - 20px);
|
||||
|
||||
&.padding-left {
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<div>
|
||||
<event-metadata-block
|
||||
v-if="!event.options.isOnline"
|
||||
:title="$t('Location')"
|
||||
:icon="physicalAddress ? physicalAddress.poiInfos.poiIcon.icon : 'earth'"
|
||||
:title="t('Location')"
|
||||
:icon="addressPOIInfos?.poiIcon?.icon ?? 'earth'"
|
||||
>
|
||||
<div class="address-wrapper">
|
||||
<span v-if="!physicalAddress">{{ $t("No address defined") }}</span>
|
||||
<span v-if="!physicalAddress">{{ t("No address defined") }}</span>
|
||||
<div class="address" v-if="physicalAddress">
|
||||
<address-info :address="physicalAddress" />
|
||||
<o-button
|
||||
@@ -15,12 +15,23 @@
|
||||
@click="$emit('showMapModal', true)"
|
||||
v-if="physicalAddress.geom"
|
||||
>
|
||||
{{ $t("Show map") }}
|
||||
{{ t("Show map") }}
|
||||
</o-button>
|
||||
</div>
|
||||
</div>
|
||||
<template #icon>
|
||||
<o-icon
|
||||
v-if="addressPOIInfos?.poiIcon?.icon"
|
||||
:icon="addressPOIInfos?.poiIcon?.icon"
|
||||
customSize="36"
|
||||
/>
|
||||
<Earth v-else :size="36" />
|
||||
</template>
|
||||
</event-metadata-block>
|
||||
<event-metadata-block :title="$t('Date and time')" icon="calendar">
|
||||
<event-metadata-block :title="t('Date and time')">
|
||||
<template #icon>
|
||||
<Calendar :size="36" />
|
||||
</template>
|
||||
<event-full-date
|
||||
:beginsOn="event.beginsOn.toString()"
|
||||
:show-start-time="event.options.showStartTime"
|
||||
@@ -32,7 +43,7 @@
|
||||
</event-metadata-block>
|
||||
<event-metadata-block
|
||||
class="metadata-organized-by"
|
||||
:title="$t('Organized by')"
|
||||
:title="t('Organized by')"
|
||||
>
|
||||
<router-link
|
||||
v-if="event.attributedTo"
|
||||
@@ -66,16 +77,18 @@
|
||||
</event-metadata-block>
|
||||
<event-metadata-block
|
||||
v-if="event.onlineAddress && urlToHostname(event.onlineAddress)"
|
||||
icon="link"
|
||||
:title="$t('Website')"
|
||||
:title="t('Website')"
|
||||
>
|
||||
<template #icon>
|
||||
<Link :size="36" />
|
||||
</template>
|
||||
<a
|
||||
target="_blank"
|
||||
class="hover:underline"
|
||||
rel="noopener noreferrer ugc"
|
||||
:href="event.onlineAddress"
|
||||
:title="
|
||||
$t('View page on {hostname} (in a new window)', {
|
||||
t('View page on {hostname} (in a new window)', {
|
||||
hostname: urlToHostname(event.onlineAddress),
|
||||
})
|
||||
"
|
||||
@@ -85,9 +98,9 @@
|
||||
<event-metadata-block
|
||||
v-for="extra in extraMetadata"
|
||||
:title="extra.title || extra.label"
|
||||
:icon="extra.icon"
|
||||
:key="extra.key"
|
||||
>
|
||||
<template #icon> <o-icon :icon="extra.icon" customSize="36" /> </template>
|
||||
<span
|
||||
v-if="
|
||||
((extra.type == EventMetadataType.STRING &&
|
||||
@@ -108,7 +121,7 @@
|
||||
rel="noopener noreferrer ugc"
|
||||
:href="extra.value"
|
||||
:title="
|
||||
$t('View page on {hostname} (in a new window)', {
|
||||
t('View page on {hostname} (in a new window)', {
|
||||
hostname: urlToHostname(extra.value),
|
||||
})
|
||||
"
|
||||
@@ -123,7 +136,7 @@
|
||||
rel="noopener noreferrer ugc"
|
||||
:href="accountURL(extra)"
|
||||
:title="
|
||||
$t('View account on {hostname} (in a new window)', {
|
||||
t('View account on {hostname} (in a new window)', {
|
||||
hostname: urlToHostname(accountURL(extra)),
|
||||
})
|
||||
"
|
||||
@@ -134,7 +147,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { Address } from "@/types/address.model";
|
||||
import { Address, addressToPoiInfos } from "@/types/address.model";
|
||||
import { EventMetadataKeyType, EventMetadataType } from "@/types/enums";
|
||||
import { IEvent } from "@/types/event.model";
|
||||
import { computed } from "vue";
|
||||
@@ -147,6 +160,10 @@ import AddressInfo from "../../components/Address/AddressInfo.vue";
|
||||
import { IEventMetadataDescription } from "@/types/event-metadata";
|
||||
import { eventMetaDataList } from "../../services/EventMetadata";
|
||||
import { IUser } from "@/types/current-user.model";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import Earth from "vue-material-design-icons/Earth.vue";
|
||||
import Calendar from "vue-material-design-icons/Calendar.vue";
|
||||
import Link from "vue-material-design-icons/Link.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -157,12 +174,19 @@ const props = withDefaults(
|
||||
{ showMap: false }
|
||||
);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const physicalAddress = computed((): Address | null => {
|
||||
if (!props.event.physicalAddress) return null;
|
||||
|
||||
return new Address(props.event.physicalAddress);
|
||||
});
|
||||
|
||||
const addressPOIInfos = computed(() => {
|
||||
if (!props.event.physicalAddress) return null;
|
||||
return addressToPoiInfos(props.event.physicalAddress);
|
||||
});
|
||||
|
||||
const extraMetadata = computed((): IEventMetadataDescription[] => {
|
||||
return props.event.metadata.map((val) => {
|
||||
const def = eventMetaDataList.find((dat) => dat.key === val.key);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
class="mbz-card shrink-0 dark:bg-mbz-purple dark:text-white rounded-lg shadow-lg my-4 flex items-center flex-col"
|
||||
:class="{
|
||||
'sm:flex-row': mode === 'row',
|
||||
'max-w-xs w-[18rem] shrink-0 flex flex-col': mode === 'column',
|
||||
'sm:max-w-xs sm:w-[18rem] shrink-0 flex flex-col': mode === 'column',
|
||||
}"
|
||||
>
|
||||
<div class="flex-none p-2 md:p-4">
|
||||
@@ -25,7 +25,7 @@
|
||||
:class="{ 'sm:flex-1': mode === 'row' }"
|
||||
>
|
||||
<div class="flex gap-1 mb-2">
|
||||
<div class="px-1 overflow-hidden flex-auto">
|
||||
<div class="overflow-hidden flex-auto">
|
||||
<h3
|
||||
class="text-2xl leading-5 line-clamp-3 font-bold text-violet-3 dark:text-white"
|
||||
dir="auto"
|
||||
|
||||
370
js/src/components/Map/VueBottomSheet.vue
Normal file
370
js/src/components/Map/VueBottomSheet.vue
Normal file
@@ -0,0 +1,370 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'bottom-sheet',
|
||||
{
|
||||
opened: opened,
|
||||
closed: opened === false,
|
||||
moving: moving,
|
||||
},
|
||||
]"
|
||||
v-on="handlers"
|
||||
ref="bottomSheet"
|
||||
:style="{
|
||||
'pointer-events':
|
||||
backgroundClickable && clickToClose === false ? 'none' : 'all',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="overlay"
|
||||
class="bottom-sheet__backdrop"
|
||||
:style="{ background: overlayColor }"
|
||||
/>
|
||||
<div
|
||||
:style="[
|
||||
{ bottom: cardP + 'px', maxWidth: maxWidth, maxHeight: maxHeight },
|
||||
{ height: isFullScreen ? '100%' : 'auto' },
|
||||
{ 'pointer-events': 'all' },
|
||||
]"
|
||||
:class="[
|
||||
'bottom-sheet__card bg-white dark:bg-gray-800',
|
||||
{ stripe: stripe, square: !rounded },
|
||||
effect,
|
||||
]"
|
||||
ref="bottomSheetCard"
|
||||
>
|
||||
<div class="bottom-sheet__pan" ref="pan">
|
||||
<div class="bottom-sheet__bar bg-gray-700 dark:bg-gray-400" />
|
||||
</div>
|
||||
<div
|
||||
:style="{ height: contentH }"
|
||||
ref="bottomSheetCardContent"
|
||||
class="bottom-sheet__content"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Hammer from "hammerjs";
|
||||
import { onBeforeUnmount, reactive, ref } from "vue";
|
||||
|
||||
const inited = ref(false);
|
||||
const opened = ref(false);
|
||||
const contentH = ref("auto");
|
||||
const hammer = reactive<{
|
||||
pan: any;
|
||||
content: any;
|
||||
}>({
|
||||
pan: null,
|
||||
content: null,
|
||||
});
|
||||
const contentScroll = ref(0);
|
||||
const cardP = ref<number>(0);
|
||||
const cardH = ref<number>(0);
|
||||
const moving = ref(false);
|
||||
const stripe = ref(0);
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
overlay?: boolean;
|
||||
maxWidth?: string;
|
||||
maxHeight?: string;
|
||||
clickToClose?: boolean;
|
||||
effect?: string;
|
||||
rounded?: boolean;
|
||||
swipeAble?: boolean;
|
||||
isFullScreen?: boolean;
|
||||
overlayColor?: string;
|
||||
backgroundScrollable?: boolean;
|
||||
backgroundClickable?: boolean;
|
||||
}>(),
|
||||
{
|
||||
overlay: true,
|
||||
maxWidth: "640px",
|
||||
maxHeight: "95%",
|
||||
clickToClose: true,
|
||||
effect: "fx-default",
|
||||
rounded: true,
|
||||
swipeAble: true,
|
||||
isFullScreen: false,
|
||||
overlayColor: "#0000004D",
|
||||
backgroundScrollable: false,
|
||||
backgroundClickable: false,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits(["closed", "opened"]);
|
||||
|
||||
const bottomSheetCardContent = ref();
|
||||
const bottomSheetCard = ref();
|
||||
const pan = ref();
|
||||
|
||||
const isIphone = () => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const iPhone = /iPhone/.test(navigator.userAgent) && !window.MSStream;
|
||||
const aspect = window.screen.width / window.screen.height;
|
||||
return iPhone && aspect.toFixed(3) === "0.462";
|
||||
};
|
||||
const move = (event: any, type: any) => {
|
||||
if (props.swipeAble) {
|
||||
const delta = -event.deltaY;
|
||||
if (
|
||||
(type === "content" && event.type === "panup") ||
|
||||
(type === "content" &&
|
||||
event.type === "pandown" &&
|
||||
contentScroll.value > 0)
|
||||
) {
|
||||
bottomSheetCardContent.value.scrollTop = contentScroll.value + delta;
|
||||
} else if (event.type === "panup" || event.type === "pandown") {
|
||||
moving.value = true;
|
||||
if (event.deltaY > 0) {
|
||||
cardP.value = delta;
|
||||
}
|
||||
}
|
||||
if (event.isFinal) {
|
||||
contentScroll.value = bottomSheetCardContent.value.scrollTop;
|
||||
moving.value = false;
|
||||
if (cardP.value < -30) {
|
||||
opened.value = false;
|
||||
cardP.value = (-cardH.value ?? 0) - stripe.value;
|
||||
document.body.style.overflow = "";
|
||||
emit("closed");
|
||||
} else {
|
||||
cardP.value = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const init = () => {
|
||||
return new Promise((resolve) => {
|
||||
contentH.value = "auto";
|
||||
stripe.value = isIphone() ? 20 : 0;
|
||||
cardH.value = bottomSheetCard.value.clientHeight;
|
||||
contentH.value = `${cardH.value - pan.value.clientHeight}px`;
|
||||
bottomSheetCard.value.style.maxHeight = props.maxHeight;
|
||||
cardP.value =
|
||||
props.effect === "fx-slide-from-right" ||
|
||||
props.effect === "fx-slide-from-left"
|
||||
? 0
|
||||
: -cardH.value - stripe.value;
|
||||
if (!inited.value) {
|
||||
inited.value = true;
|
||||
const options = {
|
||||
recognizers: [[Hammer.Pan, { direction: Hammer.DIRECTION_VERTICAL }]],
|
||||
};
|
||||
hammer.pan = new Hammer(pan.value, options as any);
|
||||
hammer.pan?.on("panstart panup pandown panend", (e: any) => {
|
||||
move(e, "pan");
|
||||
});
|
||||
hammer.content = new Hammer(bottomSheetCardContent.value, options as any);
|
||||
hammer.content?.on("panstart panup pandown panend", (e: any) => {
|
||||
move(e, "content");
|
||||
});
|
||||
}
|
||||
setTimeout(() => {
|
||||
resolve(undefined);
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
const open = async () => {
|
||||
console.debug("open vue bottom sheet");
|
||||
await init();
|
||||
opened.value = true;
|
||||
cardP.value = 0;
|
||||
|
||||
if (!props.backgroundScrollable) {
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
emit("opened");
|
||||
};
|
||||
const close = () => {
|
||||
opened.value = false;
|
||||
cardP.value =
|
||||
props.effect === "fx-slide-from-right" ||
|
||||
props.effect === "fx-slide-from-left"
|
||||
? 0
|
||||
: -cardH.value - stripe.value;
|
||||
document.body.style.overflow = "";
|
||||
emit("closed");
|
||||
};
|
||||
const clickOnBottomSheet = (event: any) => {
|
||||
if (props.clickToClose) {
|
||||
if (
|
||||
event.target.classList.contains("bottom-sheet__backdrop") ||
|
||||
event.target.classList.contains("bottom-sheet")
|
||||
) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
hammer?.pan?.destroy();
|
||||
hammer?.content?.destroy();
|
||||
});
|
||||
|
||||
const handlers = {
|
||||
mousedown: clickOnBottomSheet,
|
||||
touchstart: clickOnBottomSheet,
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bottom-sheet {
|
||||
z-index: 99999;
|
||||
transition: all 0.4s ease;
|
||||
position: relative;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&__content {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
&__backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&__card {
|
||||
width: 100%;
|
||||
|
||||
position: fixed;
|
||||
border-radius: 14px 14px 0 0;
|
||||
left: 50%;
|
||||
z-index: 9999;
|
||||
margin: 0 auto;
|
||||
|
||||
&.square {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
&.stripe {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
&.fx-default {
|
||||
transform: translate(-50%, 0);
|
||||
transition: bottom 0.3s ease;
|
||||
}
|
||||
|
||||
&.fx-fadein-scale {
|
||||
transform: translate(-50%, 0) scale(0.7);
|
||||
opacity: 0;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
&.fx-slide-from-right {
|
||||
transform: translate(100%, 0);
|
||||
opacity: 0;
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.5, 0.5, 0.9);
|
||||
}
|
||||
|
||||
&.fx-slide-from-left {
|
||||
transform: translate(-100%, 0);
|
||||
opacity: 0;
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.5, 0.5, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
&__pan {
|
||||
padding-bottom: 20px;
|
||||
padding-top: 15px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
&__bar {
|
||||
display: block;
|
||||
width: 50px;
|
||||
height: 3px;
|
||||
border-radius: 14px;
|
||||
margin: 0 auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.closed {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
|
||||
.bottom-sheet__backdrop {
|
||||
animation: hide 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&.moving {
|
||||
.bottom-sheet__card {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.opened {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.bottom-sheet__backdrop {
|
||||
animation: show 0.3s ease;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.bottom-sheet__card {
|
||||
&.fx-fadein-scale {
|
||||
transform: translate(-50%, 0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.fx-slide-from-right {
|
||||
transform: translate(-50%, 0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.fx-slide-from-left {
|
||||
transform: translate(-50%, 0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes show {
|
||||
0% {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hide {
|
||||
0% {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
400
js/src/components/Search/EventMarkerMap.vue
Normal file
400
js/src/components/Search/EventMarkerMap.vue
Normal file
@@ -0,0 +1,400 @@
|
||||
<template>
|
||||
<div class="relative my-2">
|
||||
<div style="height: 70vh" id="mapMountPoint" />
|
||||
<vue-bottom-sheet
|
||||
v-if="activeElement"
|
||||
ref="myBottomSheet"
|
||||
class="md:hidden"
|
||||
max-height="70%"
|
||||
:background-scrollable="false"
|
||||
>
|
||||
<event-card
|
||||
v-if="instanceOfIEvent(activeElement)"
|
||||
:event="(activeElement as IEvent)"
|
||||
:has-border="false"
|
||||
view-mode="column"
|
||||
:options="{
|
||||
isRemoteEvent: activeElement.__typename === 'EventResult',
|
||||
isLoggedIn,
|
||||
}"
|
||||
/>
|
||||
<group-card
|
||||
v-else
|
||||
:group="(activeElement as IGroup)"
|
||||
:has-border="false"
|
||||
view-mode="column"
|
||||
:isRemoteGroup="activeElement.__typename === 'GroupResult'"
|
||||
:isLoggedIn="isLoggedIn"
|
||||
/>
|
||||
</vue-bottom-sheet>
|
||||
<div
|
||||
class="absolute hidden md:block bottom-0 md:top-8 right-0 h-48 w-full md:w-80 overflow-y-visible text-white [box-shadow:0 6px 9px 2px rgba(119,119,119,.75)] -my-4 px-2 z-[1100]"
|
||||
v-if="activeElement"
|
||||
>
|
||||
<event-card
|
||||
v-if="instanceOfIEvent(activeElement)"
|
||||
:event="(activeElement as IEvent)"
|
||||
view-mode="column"
|
||||
:has-border="false"
|
||||
:options="{
|
||||
isRemoteEvent: activeElement.__typename === 'EventResult',
|
||||
isLoggedIn,
|
||||
}"
|
||||
/>
|
||||
<group-card
|
||||
v-else
|
||||
:group="(activeElement as IGroup)"
|
||||
:has-border="false"
|
||||
view-mode="column"
|
||||
:isRemoteGroup="activeElement.__typename === 'GroupResult'"
|
||||
:isLoggedIn="isLoggedIn"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import {
|
||||
computed,
|
||||
createApp,
|
||||
DefineComponent,
|
||||
h,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from "vue";
|
||||
import VueBottomSheet from "@/components/Map/VueBottomSheet.vue";
|
||||
import {
|
||||
map,
|
||||
LatLngBounds,
|
||||
tileLayer,
|
||||
marker,
|
||||
divIcon,
|
||||
Map,
|
||||
Marker,
|
||||
} from "leaflet";
|
||||
import { MarkerClusterGroup } from "leaflet.markercluster/src";
|
||||
import { IGroup } from "@/types/actor";
|
||||
import { IEvent, instanceOfIEvent } from "@/types/event.model";
|
||||
import { ContentType } from "@/types/enums";
|
||||
import Calendar from "vue-material-design-icons/Calendar.vue";
|
||||
import AccountMultiple from "vue-material-design-icons/AccountMultiple.vue";
|
||||
import GroupCard from "@/components/Group/GroupCard.vue";
|
||||
import EventCard from "@/components/Event/EventCard.vue";
|
||||
import debounce from "lodash/debounce";
|
||||
import { Paginate } from "@/types/paginate";
|
||||
import { TypeNamed } from "@/types/apollo";
|
||||
|
||||
const mapElement = ref<Map>();
|
||||
const markers = ref<MarkerClusterGroup>();
|
||||
const myBottomSheet = ref<typeof VueBottomSheet>();
|
||||
|
||||
const props = defineProps<{
|
||||
contentType: ContentType;
|
||||
events: Paginate<TypeNamed<IEvent>>;
|
||||
groups: Paginate<TypeNamed<IGroup>>;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
isLoggedIn: boolean | undefined;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: "map-updated",
|
||||
{ bounds, zoom }: { bounds: LatLngBounds; zoom: number }
|
||||
): void;
|
||||
}>();
|
||||
|
||||
const activeElement = ref<TypeNamed<IEvent> | TypeNamed<IGroup> | null>(null);
|
||||
|
||||
const events = computed(() => props.events?.elements ?? []);
|
||||
const groups = computed(() => props.groups?.elements ?? []);
|
||||
|
||||
watch([events, groups], update);
|
||||
|
||||
function update() {
|
||||
if (!mapElement.value || !mapElement.value.getBounds) return;
|
||||
const rawBounds: LatLngBounds = mapElement.value.getBounds();
|
||||
const bounds: LatLngBounds = mapElement.value.wrapLatLngBounds(rawBounds);
|
||||
if (
|
||||
bounds.getNorthWest().lat === 0 ||
|
||||
bounds.getNorthWest().lat === bounds.getSouthEast().lat
|
||||
)
|
||||
return;
|
||||
const zoom = mapElement.value.getZoom();
|
||||
emit("map-updated", { bounds, zoom });
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (mapElement.value) {
|
||||
mapElement.value.remove();
|
||||
}
|
||||
});
|
||||
|
||||
const initialView = computed<[[number, number], number]>(() => {
|
||||
if (props.latitude && props.longitude) {
|
||||
return [[props.latitude, props.longitude], 12];
|
||||
}
|
||||
return [[0, 0], 3];
|
||||
});
|
||||
|
||||
watch(initialView, ([latlng, zoom]) => {
|
||||
setLatLng(latlng, zoom);
|
||||
});
|
||||
|
||||
const setLatLng = (latlng: [number, number], zoom: number) => {
|
||||
console.debug("setting view to ", latlng, zoom);
|
||||
mapElement.value?.setView(latlng, zoom);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
mapElement.value = map("mapMountPoint");
|
||||
setLatLng(...initialView.value);
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
mapElement.value._onResize();
|
||||
mapElement.value.on("click", () => {
|
||||
activeElement.value = null;
|
||||
if (myBottomSheet.value) {
|
||||
myBottomSheet.value.close();
|
||||
}
|
||||
});
|
||||
// mapElement.value.on('load', function () {
|
||||
// console.log('load event')
|
||||
// setTimeout(() => {
|
||||
// console.log('invalidate size')
|
||||
// mapElement.value.invalidateSize()
|
||||
// }, 1000)
|
||||
// })
|
||||
markers.value = new MarkerClusterGroup({ chunkedLoading: true });
|
||||
|
||||
mapElement.value.on("zoom", debounce(update, 1000));
|
||||
mapElement.value.on("moveend", debounce(update, 1000));
|
||||
|
||||
tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
attribution:
|
||||
'© <a href="https://osm.org/copyright">OpenStreetMap</a> contributors',
|
||||
className: "map-tiles",
|
||||
}).addTo(mapElement.value);
|
||||
});
|
||||
|
||||
const categoryToColorClass = (element: IEvent | IGroup): string => {
|
||||
if (instanceOfIEvent(element)) {
|
||||
return "marker-event";
|
||||
}
|
||||
return "marker-group";
|
||||
};
|
||||
|
||||
const pointToLayer = (
|
||||
element: TypeNamed<IEvent> | TypeNamed<IGroup>,
|
||||
latlng: { lat: number; lng: number }
|
||||
): Marker => {
|
||||
const icon = divIcon({
|
||||
html: `<div class="marker-container ${categoryToColorClass(element)}">
|
||||
<div class="pin-icon-container">
|
||||
<svg width="24" height="24" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 10C20 14.4183 12 22 12 22C12 22 4 14.4183 4 10C4 5.58172 7.58172 2 12 2C16.4183 2 20 5.58172 20 10Z" stroke="currentColor" stroke-width="1.5"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="element-icon-container text-black">
|
||||
${instanceOfIEvent(element) ? calendarHTML : AccountMultipleHTML}
|
||||
</div>
|
||||
</div>`,
|
||||
iconSize: [50, 50],
|
||||
iconAnchor: [25, 50],
|
||||
// iconSize: [
|
||||
// MARKER_TOUCH_TARGET_SIZE * 0.5,
|
||||
// MARKER_TOUCH_TARGET_SIZE * 0.5,
|
||||
// ],
|
||||
});
|
||||
|
||||
return marker(latlng, { icon }).on("click", () => {
|
||||
activeElement.value = element;
|
||||
if (myBottomSheet.value) {
|
||||
myBottomSheet.value.open();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// https://stackoverflow.com/a/68319134/10204399
|
||||
const vueComponentToHTML = (
|
||||
component: DefineComponent,
|
||||
localProps: Record<string, any> = {}
|
||||
) => {
|
||||
const tempApp = createApp({
|
||||
render() {
|
||||
return h(component, localProps);
|
||||
},
|
||||
});
|
||||
|
||||
// in Vue 3 we need real element to mount to unlike in Vue 2 where mount() could be called without argument...
|
||||
const el = document.createElement("div");
|
||||
const mountedApp = tempApp.mount(el);
|
||||
|
||||
const html = mountedApp.$el.outerHTML as string;
|
||||
// tempApp.unmount();
|
||||
return html;
|
||||
};
|
||||
|
||||
const calendarHTML = vueComponentToHTML(Calendar);
|
||||
const AccountMultipleHTML = vueComponentToHTML(AccountMultiple);
|
||||
|
||||
update();
|
||||
|
||||
const eventMarkers = computed(() => {
|
||||
return events.value?.reduce((acc, event) => {
|
||||
if (event.physicalAddress?.geom) {
|
||||
const [lng, lat] = event.physicalAddress.geom.split(";");
|
||||
return [
|
||||
...acc,
|
||||
pointToLayer(event, {
|
||||
lng: Number.parseFloat(lng),
|
||||
lat: Number.parseFloat(lat),
|
||||
}),
|
||||
];
|
||||
}
|
||||
return acc;
|
||||
}, [] as Marker[]);
|
||||
});
|
||||
|
||||
const groupMarkers = computed(() => {
|
||||
return groups.value?.reduce((acc: Marker[], group: TypeNamed<IGroup>) => {
|
||||
if (group.physicalAddress?.geom) {
|
||||
const [lng, lat] = group.physicalAddress.geom.split(";");
|
||||
return [
|
||||
...acc,
|
||||
pointToLayer(group, {
|
||||
lng: Number.parseFloat(lng),
|
||||
lat: Number.parseFloat(lat),
|
||||
}),
|
||||
];
|
||||
}
|
||||
return acc;
|
||||
}, [] as Marker[]);
|
||||
});
|
||||
|
||||
watch([markers, eventMarkers, groupMarkers], () => {
|
||||
if (!markers.value) return;
|
||||
console.debug(
|
||||
"something changed in the search map",
|
||||
markers.value,
|
||||
eventMarkers.value,
|
||||
groupMarkers.value
|
||||
);
|
||||
markers.value?.clearLayers();
|
||||
if (props.contentType !== ContentType.GROUPS) {
|
||||
eventMarkers.value?.forEach((markerToAdd) => {
|
||||
console.debug("adding event marker layer to markers");
|
||||
markers.value.addLayer(markerToAdd);
|
||||
});
|
||||
}
|
||||
if (props.contentType !== ContentType.EVENTS) {
|
||||
groupMarkers.value?.forEach((markerToAdd) => {
|
||||
console.debug("adding group marker layer to markers");
|
||||
markers.value.addLayer(markerToAdd);
|
||||
});
|
||||
}
|
||||
mapElement.value?.addLayer(markers.value);
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
/*
|
||||
* https://github.com/mapbox/supercluster/blob/f073fade1caae0b2b1beffd013b74ff024ff413b/demo/cluster.css
|
||||
*/
|
||||
.marker-cluster-small {
|
||||
background-color: rgba(181, 226, 140, 0.6);
|
||||
}
|
||||
|
||||
.marker-cluster-small div {
|
||||
background-color: rgba(110, 204, 57, 0.6);
|
||||
}
|
||||
|
||||
.marker-cluster-medium {
|
||||
background-color: rgba(241, 211, 87, 0.6);
|
||||
}
|
||||
|
||||
.marker-cluster-medium div {
|
||||
background-color: rgba(240, 194, 12, 0.6);
|
||||
}
|
||||
|
||||
.marker-cluster-large {
|
||||
background-color: rgba(253, 156, 115, 0.6);
|
||||
}
|
||||
|
||||
.marker-cluster-large div {
|
||||
background-color: rgba(241, 128, 23, 0.6);
|
||||
}
|
||||
|
||||
.marker-cluster {
|
||||
background-clip: padding-box;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.marker-cluster div {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-left: 5px;
|
||||
margin-top: 5px;
|
||||
|
||||
text-align: center;
|
||||
border-radius: 15px;
|
||||
font: 12px "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
.marker-cluster span {
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
:root {
|
||||
--map-tiles-filter: brightness(0.6) invert(1) contrast(3) hue-rotate(200deg)
|
||||
saturate(0.3) brightness(0.7);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.map-tiles {
|
||||
filter: var(--map-tiles-filter, none);
|
||||
}
|
||||
}
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.marker-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.marker-container.marker-event .pin-icon-container svg {
|
||||
fill: yellow;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.marker-container.marker-group .pin-icon-container svg {
|
||||
fill: lightblue;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pin-icon-container {
|
||||
position: absolute;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.pin-icon-container svg path {
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.pin-icon-container svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.element-icon-container {
|
||||
position: absolute;
|
||||
transform: translate(12px, 8px);
|
||||
}
|
||||
</style>
|
||||
@@ -104,6 +104,7 @@ const icons: Record<string, () => Promise<any>> = {
|
||||
),
|
||||
Earth: () =>
|
||||
import(`../../../node_modules/vue-material-design-icons/Earth.vue`),
|
||||
Map: () => import(`../../../node_modules/vue-material-design-icons/Map.vue`),
|
||||
MapMarker: () =>
|
||||
import(`../../../node_modules/vue-material-design-icons/MapMarker.vue`),
|
||||
Close: () =>
|
||||
@@ -231,6 +232,8 @@ const icons: Record<string, () => Promise<any>> = {
|
||||
import(`../../../node_modules/vue-material-design-icons/Filter.vue`),
|
||||
CheckCircle: () =>
|
||||
import(`../../../node_modules/vue-material-design-icons/CheckCircle.vue`),
|
||||
ViewList: () =>
|
||||
import(`../../../node_modules/vue-material-design-icons/ViewList.vue`),
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
|
||||
@@ -33,6 +33,8 @@ export const SEARCH_EVENTS_AND_GROUPS = gql`
|
||||
$searchTarget: SearchTarget
|
||||
$beginsOn: DateTime
|
||||
$endsOn: DateTime
|
||||
$bbox: String
|
||||
$zoom: Int
|
||||
$eventPage: Int
|
||||
$groupPage: Int
|
||||
$limit: Int
|
||||
@@ -49,6 +51,8 @@ export const SEARCH_EVENTS_AND_GROUPS = gql`
|
||||
searchTarget: $searchTarget
|
||||
beginsOn: $beginsOn
|
||||
endsOn: $endsOn
|
||||
bbox: $bbox
|
||||
zoom: $zoom
|
||||
page: $eventPage
|
||||
limit: $limit
|
||||
) {
|
||||
@@ -88,6 +92,8 @@ export const SEARCH_EVENTS_AND_GROUPS = gql`
|
||||
radius: $radius
|
||||
languageOneOf: $languageOneOf
|
||||
searchTarget: $searchTarget
|
||||
bbox: $bbox
|
||||
zoom: $zoom
|
||||
page: $groupPage
|
||||
limit: $limit
|
||||
) {
|
||||
|
||||
@@ -1390,5 +1390,20 @@
|
||||
"The videoconference will be created on {service}": "The videoconference will be created on {service}",
|
||||
"Search target": "Search target",
|
||||
"In this instance's network": "In this instance's network",
|
||||
"On the Fediverse": "On the Fediverse"
|
||||
"On the Fediverse": "On the Fediverse",
|
||||
"Report reason": "Report reason",
|
||||
"Reported content": "Reported content",
|
||||
"No results found": "No results found",
|
||||
"{eventsCount} events found": "No events found|One event found|{eventsCount} events found",
|
||||
"{groupsCount} groups found": "No groups found|One group found|{groupsCount} groups found",
|
||||
"{resultsCount} results found": "No results found|On result found|{resultsCount} results found",
|
||||
"Loading map": "Loading map",
|
||||
"Sort by": "Sort by",
|
||||
"Map": "Map",
|
||||
"List": "List",
|
||||
"Best match": "Best match",
|
||||
"Most recently published": "Most recently published",
|
||||
"Least recently published": "Least recently published",
|
||||
"With the most participants": "With the most participants",
|
||||
"Number of members": "Number of members"
|
||||
}
|
||||
@@ -1374,5 +1374,20 @@
|
||||
"The videoconference will be created on {service}": "La visio-conférence sera créée sur {service}",
|
||||
"Search target": "Cible de la recherche",
|
||||
"In this instance's network": "Dans le réseau de cette instance",
|
||||
"On the Fediverse": "Dans le fediverse"
|
||||
"On the Fediverse": "Dans le fediverse",
|
||||
"Report reason": "Raison du signalement",
|
||||
"Reported content": "Contenu signalé",
|
||||
"No results found": "Aucun résultat trouvé",
|
||||
"{eventsCount} events found": "Aucun événement trouvé|Un événement trouvé|{eventsCount} événements trouvés",
|
||||
"{groupsCount} groups found": "Aucun groupe trouvé|Un groupe trouvé|{groupsCount} groupes trouvés",
|
||||
"{resultsCount} results found": "Aucun résultat trouvé|Un résultat trouvé|{resultsCount} résultats trouvés",
|
||||
"Loading map": "Chargement de la carte",
|
||||
"Sort by": "Trier par",
|
||||
"Map": "Carte",
|
||||
"List": "Liste",
|
||||
"Best match": "Pertinence",
|
||||
"Most recently published": "Publié récemment",
|
||||
"Least recently published": "Le moins récemment publié",
|
||||
"With the most participants": "Avec le plus de participants",
|
||||
"Number of members": "Nombre de membres"
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export const sentry = (environment: any, sentryConfiguration: any) => {
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
// of transactions for performance monitoring.
|
||||
// We recommend adjusting this value in production
|
||||
tracesSampleRate: sentryConfiguration.tracesSampleRate,
|
||||
tracesSampleRate: Number.parseFloat(sentryConfiguration.tracesSampleRate),
|
||||
release: environment.version,
|
||||
logErrors: true,
|
||||
});
|
||||
|
||||
2
js/src/shims-vue.d.ts
vendored
2
js/src/shims-vue.d.ts
vendored
@@ -2,7 +2,7 @@ declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
const component: DefineComponent<{}, {}, {}>;
|
||||
export default component;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,3 +3,7 @@ import { GraphQLError } from "graphql/error/GraphQLError";
|
||||
export class AbsintheGraphQLError extends GraphQLError {
|
||||
readonly field: string | undefined;
|
||||
}
|
||||
|
||||
export type TypeNamed<T extends Record<string, any>> = T & {
|
||||
__typename: string;
|
||||
};
|
||||
|
||||
@@ -228,7 +228,6 @@ export class EventModel implements IEvent {
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
export function removeTypeName(entity: any): any {
|
||||
if (entity?.__typename) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
@@ -292,3 +291,7 @@ export function organizerDisplayName(event: IEvent): string | null {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function instanceOfIEvent(object: any): object is IEvent {
|
||||
return "organizerActor" in object;
|
||||
}
|
||||
|
||||
@@ -322,7 +322,7 @@
|
||||
<aside class="event-metadata rounded dark:bg-gray-600 shadow-md">
|
||||
<div class="sticky">
|
||||
<event-metadata-sidebar
|
||||
v-if="event && loggedUser"
|
||||
v-if="event"
|
||||
:event="event"
|
||||
:user="loggedUser"
|
||||
@showMapModal="showMap = true"
|
||||
|
||||
@@ -461,10 +461,15 @@
|
||||
<event-metadata-block
|
||||
v-if="physicalAddress && physicalAddress.url"
|
||||
:title="t('Location')"
|
||||
:icon="
|
||||
physicalAddress ? physicalAddress.poiInfos.poiIcon.icon : 'earth'
|
||||
"
|
||||
>
|
||||
<template #icon>
|
||||
<o-icon
|
||||
v-if="physicalAddress.poiInfos.poiIcon.icon"
|
||||
:icon="physicalAddress.poiInfos.poiIcon.icon"
|
||||
customSize="48"
|
||||
/>
|
||||
<Earth v-else :size="48" />
|
||||
</template>
|
||||
<div class="address-wrapper">
|
||||
<span
|
||||
v-if="!physicalAddress || !addressFullName(physicalAddress)"
|
||||
@@ -670,6 +675,7 @@ import CalendarSync from "vue-material-design-icons/CalendarSync.vue";
|
||||
import Flag from "vue-material-design-icons/Flag.vue";
|
||||
import ExitToApp from "vue-material-design-icons/ExitToApp.vue";
|
||||
import AccountMultiplePlus from "vue-material-design-icons/AccountMultiplePlus.vue";
|
||||
import Earth from "vue-material-design-icons/Earth.vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useCreateReport } from "@/composition/apollo/report";
|
||||
import { useHead } from "@vueuse/head";
|
||||
|
||||
@@ -30,7 +30,10 @@
|
||||
<!-- Categories preview -->
|
||||
<categories-preview />
|
||||
<!-- Welcome back -->
|
||||
<section v-if="currentActor?.id && (welcomeBack || newRegisteredUser)">
|
||||
<section
|
||||
class="container mx-auto"
|
||||
v-if="currentActor?.id && (welcomeBack || newRegisteredUser)"
|
||||
>
|
||||
<o-notification variant="info" v-if="welcomeBack">{{
|
||||
$t("Welcome back {username}!", {
|
||||
username: displayName(currentActor),
|
||||
|
||||
@@ -422,131 +422,217 @@
|
||||
</form>
|
||||
</aside>
|
||||
<div class="flex-1 px-2">
|
||||
<template v-if="contentType === ContentType.ALL">
|
||||
<o-notification v-if="features && !features.groups" variant="danger">
|
||||
{{ t("Groups are not enabled on this instance.") }}
|
||||
</o-notification>
|
||||
<div v-else-if="searchGroups && searchGroups?.total > 0">
|
||||
<GroupCard
|
||||
v-for="group in searchGroups?.elements"
|
||||
:group="group"
|
||||
:key="group.id"
|
||||
:isRemoteGroup="group.__typename === 'GroupResult'"
|
||||
:isLoggedIn="currentUser?.isLoggedIn"
|
||||
mode="row"
|
||||
/>
|
||||
<o-pagination
|
||||
v-if="searchGroups && searchGroups?.total > GROUP_PAGE_LIMIT"
|
||||
:total="searchGroups?.total"
|
||||
v-model:current="groupPage"
|
||||
:per-page="GROUP_PAGE_LIMIT"
|
||||
:aria-next-label="t('Next page')"
|
||||
:aria-previous-label="t('Previous page')"
|
||||
:aria-page-label="t('Page')"
|
||||
:aria-current-label="t('Current page')"
|
||||
>
|
||||
</o-pagination>
|
||||
</div>
|
||||
<o-notification v-else-if="searchLoading === false" variant="danger">
|
||||
{{ t("No groups found") }}
|
||||
</o-notification>
|
||||
<div v-if="searchEvents && searchEvents.total > 0">
|
||||
<event-card
|
||||
mode="row"
|
||||
v-for="event in searchEvents?.elements"
|
||||
:event="event"
|
||||
:key="event.uuid"
|
||||
:options="{
|
||||
isRemoteEvent: event.__typename === 'EventResult',
|
||||
isLoggedIn: currentUser?.isLoggedIn,
|
||||
}"
|
||||
class="my-4"
|
||||
/>
|
||||
<o-pagination
|
||||
v-if="searchEvents && searchEvents?.total > EVENT_PAGE_LIMIT"
|
||||
:total="searchEvents.total"
|
||||
v-model:current="eventPage"
|
||||
:per-page="EVENT_PAGE_LIMIT"
|
||||
:aria-next-label="t('Next page')"
|
||||
:aria-previous-label="t('Previous page')"
|
||||
:aria-page-label="t('Page')"
|
||||
:aria-current-label="t('Current page')"
|
||||
>
|
||||
</o-pagination>
|
||||
</div>
|
||||
<o-notification v-else-if="searchLoading === false" variant="info">
|
||||
<p>{{ t("No events found") }}</p>
|
||||
<p v-if="searchIsUrl && !currentUser?.id">
|
||||
<div
|
||||
id="results-anchor"
|
||||
class="hidden sm:flex items-center justify-between dark:text-slate-100"
|
||||
>
|
||||
<p v-if="totalCount === 0">
|
||||
<span v-if="contentType === ContentType.EVENTS">{{
|
||||
t("No events found")
|
||||
}}</span>
|
||||
<span v-else-if="contentType === ContentType.GROUPS">{{
|
||||
t("No groups found")
|
||||
}}</span>
|
||||
<span v-else>{{ t("No results found") }}</span>
|
||||
</p>
|
||||
<p v-else>
|
||||
<span v-if="contentType === 'EVENTS'">
|
||||
{{
|
||||
t("Only registered users may fetch remote events from their URL.")
|
||||
t(
|
||||
"{eventsCount} events found",
|
||||
{ eventsCount: searchEvents?.total },
|
||||
searchEvents?.total ?? 0
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</o-notification>
|
||||
</template>
|
||||
<template v-else-if="contentType === ContentType.EVENTS">
|
||||
<template v-if="searchEvents && searchEvents.total > 0">
|
||||
<event-card
|
||||
mode="row"
|
||||
v-for="event in searchEvents?.elements"
|
||||
:event="event"
|
||||
:key="event.uuid"
|
||||
:options="{
|
||||
isRemoteEvent: event.__typename === 'EventResult',
|
||||
isLoggedIn: currentUser?.isLoggedIn,
|
||||
}"
|
||||
class="my-4"
|
||||
/>
|
||||
<o-pagination
|
||||
v-show="searchEvents && searchEvents?.total > EVENT_PAGE_LIMIT"
|
||||
:total="searchEvents.total"
|
||||
v-model:current="eventPage"
|
||||
:per-page="EVENT_PAGE_LIMIT"
|
||||
:aria-next-label="t('Next page')"
|
||||
:aria-previous-label="t('Previous page')"
|
||||
:aria-page-label="t('Page')"
|
||||
:aria-current-label="t('Current page')"
|
||||
</span>
|
||||
<span v-else-if="contentType === 'GROUPS'">
|
||||
{{
|
||||
t(
|
||||
"{groupsCount} groups found",
|
||||
{ groupsCount: searchGroups?.total },
|
||||
searchGroups?.total ?? 0
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{
|
||||
t(
|
||||
"{resultsCount} results found",
|
||||
{ resultsCount: totalCount },
|
||||
totalCount
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<o-select :placeholder="t('Sort by')" v-model="sortBy">
|
||||
<option
|
||||
v-for="sortOption in sortOptions"
|
||||
:key="sortOption.key"
|
||||
:value="sortOption.key"
|
||||
>
|
||||
{{ sortOption.label }}
|
||||
</option>
|
||||
</o-select>
|
||||
<o-button
|
||||
v-show="!isOnline"
|
||||
@click="
|
||||
() =>
|
||||
(mode = mode === ViewMode.MAP ? ViewMode.LIST : ViewMode.MAP)
|
||||
"
|
||||
:icon-left="mode === ViewMode.MAP ? 'view-list' : 'map'"
|
||||
>
|
||||
</o-pagination>
|
||||
<span v-if="mode === ViewMode.LIST">
|
||||
{{ t("Map") }}
|
||||
</span>
|
||||
<span v-else-if="mode === ViewMode.MAP">
|
||||
{{ t("List") }}
|
||||
</span>
|
||||
</o-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="mode === ViewMode.LIST">
|
||||
<template v-if="contentType === ContentType.ALL">
|
||||
<o-notification v-if="features && !features.groups" variant="danger">
|
||||
{{ t("Groups are not enabled on this instance.") }}
|
||||
</o-notification>
|
||||
<div v-else-if="searchGroups && searchGroups?.total > 0">
|
||||
<GroupCard
|
||||
v-for="group in searchGroups?.elements"
|
||||
:group="group"
|
||||
:key="group.id"
|
||||
:isRemoteGroup="group.__typename === 'GroupResult'"
|
||||
:isLoggedIn="currentUser?.isLoggedIn"
|
||||
mode="row"
|
||||
/>
|
||||
<o-pagination
|
||||
v-if="searchGroups && searchGroups?.total > GROUP_PAGE_LIMIT"
|
||||
:total="searchGroups?.total"
|
||||
v-model:current="groupPage"
|
||||
:per-page="GROUP_PAGE_LIMIT"
|
||||
:aria-next-label="t('Next page')"
|
||||
:aria-previous-label="t('Previous page')"
|
||||
:aria-page-label="t('Page')"
|
||||
:aria-current-label="t('Current page')"
|
||||
>
|
||||
</o-pagination>
|
||||
</div>
|
||||
<o-notification v-else-if="searchLoading === false" variant="danger">
|
||||
{{ t("No groups found") }}
|
||||
</o-notification>
|
||||
<div v-if="searchEvents && searchEvents.total > 0">
|
||||
<event-card
|
||||
mode="row"
|
||||
v-for="event in searchEvents?.elements"
|
||||
:event="event"
|
||||
:key="event.uuid"
|
||||
:options="{
|
||||
isRemoteEvent: event.__typename === 'EventResult',
|
||||
isLoggedIn: currentUser?.isLoggedIn,
|
||||
}"
|
||||
class="my-4"
|
||||
/>
|
||||
<o-pagination
|
||||
v-if="searchEvents && searchEvents?.total > EVENT_PAGE_LIMIT"
|
||||
:total="searchEvents.total"
|
||||
v-model:current="eventPage"
|
||||
:per-page="EVENT_PAGE_LIMIT"
|
||||
:aria-next-label="t('Next page')"
|
||||
:aria-previous-label="t('Previous page')"
|
||||
:aria-page-label="t('Page')"
|
||||
:aria-current-label="t('Current page')"
|
||||
>
|
||||
</o-pagination>
|
||||
</div>
|
||||
<o-notification v-else-if="searchLoading === false" variant="info">
|
||||
<p>{{ t("No events found") }}</p>
|
||||
<p v-if="searchIsUrl && !currentUser?.id">
|
||||
{{
|
||||
t(
|
||||
"Only registered users may fetch remote events from their URL."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</o-notification>
|
||||
</template>
|
||||
<o-notification v-else-if="searchLoading === false" variant="info">
|
||||
<p>{{ t("No events found") }}</p>
|
||||
<p v-if="searchIsUrl && !currentUser?.id">
|
||||
{{
|
||||
t("Only registered users may fetch remote events from their URL.")
|
||||
}}
|
||||
</p>
|
||||
</o-notification>
|
||||
</template>
|
||||
<template v-else-if="contentType === ContentType.GROUPS">
|
||||
<o-notification v-if="features && !features.groups" variant="danger">
|
||||
{{ t("Groups are not enabled on this instance.") }}
|
||||
</o-notification>
|
||||
<template v-else-if="contentType === ContentType.EVENTS">
|
||||
<template v-if="searchEvents && searchEvents.total > 0">
|
||||
<event-card
|
||||
mode="row"
|
||||
v-for="event in searchEvents?.elements"
|
||||
:event="event"
|
||||
:key="event.uuid"
|
||||
:options="{
|
||||
isRemoteEvent: event.__typename === 'EventResult',
|
||||
isLoggedIn: currentUser?.isLoggedIn,
|
||||
}"
|
||||
class="my-4"
|
||||
/>
|
||||
<o-pagination
|
||||
v-show="searchEvents && searchEvents?.total > EVENT_PAGE_LIMIT"
|
||||
:total="searchEvents.total"
|
||||
v-model:current="eventPage"
|
||||
:per-page="EVENT_PAGE_LIMIT"
|
||||
:aria-next-label="t('Next page')"
|
||||
:aria-previous-label="t('Previous page')"
|
||||
:aria-page-label="t('Page')"
|
||||
:aria-current-label="t('Current page')"
|
||||
>
|
||||
</o-pagination>
|
||||
</template>
|
||||
<o-notification v-else-if="searchLoading === false" variant="info">
|
||||
<p>{{ t("No events found") }}</p>
|
||||
<p v-if="searchIsUrl && !currentUser?.id">
|
||||
{{
|
||||
t(
|
||||
"Only registered users may fetch remote events from their URL."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</o-notification>
|
||||
</template>
|
||||
<template v-else-if="contentType === ContentType.GROUPS">
|
||||
<o-notification v-if="features && !features.groups" variant="danger">
|
||||
{{ t("Groups are not enabled on this instance.") }}
|
||||
</o-notification>
|
||||
|
||||
<template v-else-if="searchGroups && searchGroups?.total > 0">
|
||||
<GroupCard
|
||||
v-for="group in searchGroups?.elements"
|
||||
:group="group"
|
||||
:key="group.id"
|
||||
:isRemoteGroup="group.__typename === 'GroupResult'"
|
||||
:isLoggedIn="currentUser?.isLoggedIn"
|
||||
mode="row"
|
||||
/>
|
||||
<o-pagination
|
||||
v-show="searchGroups && searchGroups?.total > GROUP_PAGE_LIMIT"
|
||||
:total="searchGroups?.total"
|
||||
v-model:current="groupPage"
|
||||
:per-page="GROUP_PAGE_LIMIT"
|
||||
:aria-next-label="t('Next page')"
|
||||
:aria-previous-label="t('Previous page')"
|
||||
:aria-page-label="t('Page')"
|
||||
:aria-current-label="t('Current page')"
|
||||
>
|
||||
</o-pagination>
|
||||
<template v-else-if="searchGroups && searchGroups?.total > 0">
|
||||
<GroupCard
|
||||
v-for="group in searchGroups?.elements"
|
||||
:group="group"
|
||||
:key="group.id"
|
||||
:isRemoteGroup="group.__typename === 'GroupResult'"
|
||||
:isLoggedIn="currentUser?.isLoggedIn"
|
||||
mode="row"
|
||||
/>
|
||||
<o-pagination
|
||||
v-show="searchGroups && searchGroups?.total > GROUP_PAGE_LIMIT"
|
||||
:total="searchGroups?.total"
|
||||
v-model:current="groupPage"
|
||||
:per-page="GROUP_PAGE_LIMIT"
|
||||
:aria-next-label="t('Next page')"
|
||||
:aria-previous-label="t('Previous page')"
|
||||
:aria-page-label="t('Page')"
|
||||
:aria-current-label="t('Current page')"
|
||||
>
|
||||
</o-pagination>
|
||||
</template>
|
||||
<o-notification v-else-if="searchLoading === false" variant="danger">
|
||||
{{ t("No groups found") }}
|
||||
</o-notification>
|
||||
</template>
|
||||
<o-notification v-else-if="searchLoading === false" variant="danger">
|
||||
{{ t("No groups found") }}
|
||||
</o-notification>
|
||||
</template>
|
||||
</div>
|
||||
<event-marker-map
|
||||
v-if="mode === ViewMode.MAP"
|
||||
:contentType="contentType"
|
||||
:latitude="latitude"
|
||||
:longitude="longitude"
|
||||
:locationName="locationName"
|
||||
@map-updated="setBounds"
|
||||
:events="searchEvents"
|
||||
:groups="searchGroups"
|
||||
:isLoggedIn="currentUser?.isLoggedIn"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -604,6 +690,9 @@ import SearchFields from "@/components/Home/SearchFields.vue";
|
||||
import { refDebounced } from "@vueuse/core";
|
||||
import { IAddress } from "@/types/address.model";
|
||||
import { IConfig } from "@/types/config.model";
|
||||
import { TypeNamed } from "@/types/apollo";
|
||||
import EventMarkerMap from "@/components/Search/EventMarkerMap.vue";
|
||||
import { LatLngBounds } from "leaflet";
|
||||
|
||||
const search = useRouteQuery("search", "");
|
||||
const searchDebounced = refDebounced(search, 1000);
|
||||
@@ -611,11 +700,16 @@ const locationName = useRouteQuery("locationName", null);
|
||||
const location = ref<IAddress | null>(null);
|
||||
|
||||
watch(location, (newLocation) => {
|
||||
console.debug("location change");
|
||||
console.debug("location change", newLocation);
|
||||
if (newLocation?.geom) {
|
||||
latitude.value = parseFloat(newLocation?.geom.split(";")[1]);
|
||||
longitude.value = parseFloat(newLocation?.geom.split(";")[0]);
|
||||
locationName.value = newLocation?.description;
|
||||
console.debug("set location", [
|
||||
latitude.value,
|
||||
longitude.value,
|
||||
locationName.value,
|
||||
]);
|
||||
} else {
|
||||
console.debug("location emptied");
|
||||
latitude.value = undefined;
|
||||
@@ -630,6 +724,20 @@ interface ISearchTimeOption {
|
||||
end?: string | null;
|
||||
}
|
||||
|
||||
enum ViewMode {
|
||||
LIST = "list",
|
||||
MAP = "map",
|
||||
}
|
||||
|
||||
enum SortValues {
|
||||
MATCH_DESC = "-match",
|
||||
START_TIME_DESC = "-startTime",
|
||||
CREATED_AT_DESC = "-createdAt",
|
||||
CREATED_AT_ASC = "createdAt",
|
||||
PARTICIPANT_COUNT_DESC = "-participantCount",
|
||||
MEMBER_COUNT_DESC = "-memberCount",
|
||||
}
|
||||
|
||||
const arrayTransformer: RouteQueryTransformer<string[]> = {
|
||||
fromQuery(query: string) {
|
||||
return query.split(",");
|
||||
@@ -665,6 +773,14 @@ const searchTarget = useRouteQuery(
|
||||
SearchTargets.INTERNAL,
|
||||
enumTransformer(SearchTargets)
|
||||
);
|
||||
const mode = useRouteQuery("mode", ViewMode.LIST, enumTransformer(ViewMode));
|
||||
const sortBy = useRouteQuery(
|
||||
"sortBy",
|
||||
SortValues.MATCH_DESC,
|
||||
enumTransformer(SortValues)
|
||||
);
|
||||
const bbox = useRouteQuery("bbox", undefined);
|
||||
const zoom = useRouteQuery("zoom", undefined, integerTransformer);
|
||||
|
||||
const EVENT_PAGE_LIMIT = 16;
|
||||
|
||||
@@ -905,6 +1021,49 @@ const geoHashLocation = computed(() =>
|
||||
|
||||
const radius = computed(() => Number.parseInt(distance.value.slice(0, -3)));
|
||||
|
||||
const totalCount = computed(() => {
|
||||
return (searchEvents.value?.total ?? 0) + (searchGroups.value?.total ?? 0);
|
||||
});
|
||||
|
||||
const sortOptions = computed(() => {
|
||||
const options = [
|
||||
{
|
||||
key: SortValues.MATCH_DESC,
|
||||
label: t("Best match"),
|
||||
},
|
||||
];
|
||||
|
||||
if (contentType.value == ContentType.EVENTS) {
|
||||
options.push(
|
||||
{
|
||||
key: SortValues.START_TIME_DESC,
|
||||
label: t("Event date"),
|
||||
},
|
||||
{
|
||||
key: SortValues.CREATED_AT_DESC,
|
||||
label: t("Most recently published"),
|
||||
},
|
||||
{
|
||||
key: SortValues.CREATED_AT_ASC,
|
||||
label: t("Least recently published"),
|
||||
},
|
||||
{
|
||||
key: SortValues.PARTICIPANT_COUNT_DESC,
|
||||
label: t("With the most participants"),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (contentType.value == ContentType.GROUPS) {
|
||||
options.push({
|
||||
key: SortValues.MEMBER_COUNT_DESC,
|
||||
label: t("Number of members"),
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
});
|
||||
|
||||
const { searchConfig, onResult: onSearchConfigResult } = useSearchConfig();
|
||||
|
||||
onSearchConfigResult(({ data }) =>
|
||||
@@ -930,9 +1089,34 @@ const globalSearchEnabled = computed(
|
||||
() => searchConfig.value?.global?.isEnabled
|
||||
);
|
||||
|
||||
const setBounds = ({
|
||||
bounds,
|
||||
zoom: boundsZoom,
|
||||
}: {
|
||||
bounds: LatLngBounds;
|
||||
zoom: number;
|
||||
}) => {
|
||||
bbox.value = `${bounds.getNorthWest().lat}, ${bounds.getNorthWest().lng}:${
|
||||
bounds.getSouthEast().lat
|
||||
}, ${bounds.getSouthEast().lng}`;
|
||||
zoom.value = boundsZoom;
|
||||
};
|
||||
|
||||
watch(mode, (newMode) => {
|
||||
if (newMode === ViewMode.MAP) {
|
||||
isOnline.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
watch(isOnline, (newIsOnline) => {
|
||||
if (newIsOnline) {
|
||||
mode.value = ViewMode.LIST;
|
||||
}
|
||||
});
|
||||
|
||||
const { result: searchElementsResult, loading: searchLoading } = useQuery<{
|
||||
searchEvents: Paginate<IEvent>;
|
||||
searchGroups: Paginate<IGroup>;
|
||||
searchEvents: Paginate<TypeNamed<IEvent>>;
|
||||
searchGroups: Paginate<TypeNamed<IGroup>>;
|
||||
}>(SEARCH_EVENTS_AND_GROUPS, () => ({
|
||||
term: searchDebounced.value,
|
||||
tags: props.tag,
|
||||
@@ -948,5 +1132,7 @@ const { result: searchElementsResult, loading: searchLoading } = useQuery<{
|
||||
statusOneOf: statusOneOf.value,
|
||||
languageOneOf: languageOneOf.value,
|
||||
searchTarget: searchTarget.value,
|
||||
bbox: bbox.value,
|
||||
zoom: zoom.value,
|
||||
}));
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user