mobilizon.chapril.org-mobil.../js/src/views/SearchView.vue

1286 lines
40 KiB
Vue

<template>
<div class="max-w-4xl mx-auto">
<SearchFields
class="md:ml-10 mr-2"
v-model:search="search"
v-model:location="location"
:locationDefaultText="locationName"
/>
</div>
<div
class="container mx-auto md:py-3 md:px-4 flex flex-col lg:flex-row gap-x-5 gap-y-1"
>
<aside
class="flex-none lg:block lg:sticky top-8 rounded-md w-full lg:w-80 flex-col justify-between mt-2 lg:pb-10 lg:px-8 overflow-y-auto dark:text-slate-100 bg-white dark:bg-mbz-purple"
>
<o-button
@click="toggleFilters"
icon-left="filter"
class="w-full inline-flex lg:!hidden text-white px-4 py-2 justify-center"
>
<span v-if="!filtersPanelOpened">{{ t("Hide filters") }}</span>
<span v-else>{{ t("Show filters") }}</span>
</o-button>
<form
@submit.prevent="doNewSearch"
:class="{ hidden: filtersPanelOpened }"
class="lg:block mt-4 px-2"
>
<p class="sr-only">{{ t("Type") }}</p>
<ul
class="font-medium text-gray-900 dark:text-slate-100 space-y-4 pb-4 border-b border-gray-200 dark:border-gray-500"
>
<li
v-for="content in contentTypeMapping"
:key="content.contentType"
class="flex gap-1"
>
<Magnify
v-if="content.contentType === ContentType.ALL"
:size="24"
/>
<Calendar
v-if="content.contentType === ContentType.EVENTS"
:size="24"
/>
<AccountMultiple
v-if="content.contentType === ContentType.GROUPS"
:size="24"
/>
<router-link
:to="{
...$route,
query: { ...$route.query, contentType: content.contentType },
}"
>
{{ content.label }}
</router-link>
</li>
</ul>
<div
class="py-4 border-b border-gray-200 dark:border-gray-500"
v-show="globalSearchEnabled"
>
<fieldset class="flex flex-col">
<legend class="sr-only">{{ t("Search target") }}</legend>
<div>
<input
id="internalTarget"
v-model="searchTarget"
type="radio"
name="searchTarget"
:value="SearchTargets.INTERNAL"
class="w-4 h-4 border-gray-300 focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-600 dark:focus:bg-blue-600 dark:bg-gray-700 dark:border-gray-600"
/>
<label
for="internalTarget"
class="ml-3 font-medium text-gray-900 dark:text-gray-300"
>{{ t("In this instance's network") }}</label
>
</div>
<div>
<input
id="globalTarget"
v-model="searchTarget"
type="radio"
name="searchTarget"
:value="SearchTargets.GLOBAL"
class="w-4 h-4 border-gray-300 focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-600 dark:focus:bg-blue-600 dark:bg-gray-700 dark:border-gray-600"
/>
<label
for="globalTarget"
class="ml-3 font-medium text-gray-900 dark:text-gray-300"
>{{ t("On the Fediverse") }}</label
>
</div>
</fieldset>
</div>
<div
class="py-4 border-b border-gray-200 dark:border-gray-500"
v-show="contentType !== 'GROUPS'"
>
<o-switch v-model="isOnline">{{ t("Online events") }}</o-switch>
</div>
<filter-section
v-show="contentType !== 'GROUPS'"
v-model:opened="searchFilterSectionsOpenStatus.eventDate"
:title="t('Event date')"
>
<template #options>
<fieldset class="flex flex-col">
<legend class="sr-only">{{ t("Event date") }}</legend>
<div
v-for="(eventStartDateRangeOption, key) in dateOptions"
:key="key"
>
<input
:id="key"
v-model="when"
type="radio"
name="eventStartDateRange"
:value="key"
class="w-4 h-4 border-gray-300 focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-600 dark:focus:bg-blue-600 dark:bg-gray-700 dark:border-gray-600"
/>
<label
:for="key"
class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"
>{{ eventStartDateRangeOption.label }}</label
>
</div>
</fieldset>
</template>
<template #preview>
<span
class="bg-blue-100 text-blue-800 text-sm font-semibold p-0.5 rounded dark:bg-blue-200 dark:text-blue-800 grow-0"
>
{{
Object.entries(dateOptions).find(([key]) => key === when)?.[1]
?.label
}}
</span>
</template>
</filter-section>
<filter-section
v-show="!isOnline"
v-model:opened="searchFilterSectionsOpenStatus.eventDistance"
:title="t('Distance')"
>
<template #options>
<fieldset class="flex flex-col">
<legend class="sr-only">{{ t("Distance") }}</legend>
<div
v-for="distanceOption in eventDistance"
:key="distanceOption.id"
>
<input
:id="distanceOption.id"
v-model="distance"
type="radio"
name="eventDistance"
:value="distanceOption.id"
class="w-4 h-4 border-gray-300 focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-600 dark:focus:bg-blue-600 dark:bg-gray-700 dark:border-gray-600"
/>
<label
:for="distanceOption.id"
class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"
>{{ distanceOption.label }}</label
>
</div>
</fieldset>
</template>
<template #preview>
<span
class="bg-blue-100 text-blue-800 text-sm font-semibold p-0.5 rounded dark:bg-blue-200 dark:text-blue-800 grow-0"
>
{{ eventDistance.find(({ id }) => id === distance)?.label }}
</span>
</template>
</filter-section>
<filter-section
v-show="contentType !== 'GROUPS'"
v-model:opened="searchFilterSectionsOpenStatus.eventCategory"
:title="t('Categories')"
>
<template #options>
<fieldset class="flex flex-col">
<legend class="sr-only">{{ t("Categories") }}</legend>
<div v-for="category in orderedCategories" :key="category.id">
<input
:id="category.id"
v-model="categoryOneOf"
type="checkbox"
name="category"
:value="category.id"
class="w-4 h-4 border-gray-300 focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-600 dark:focus:bg-blue-600 dark:bg-gray-700 dark:border-gray-600"
/>
<label
:for="category.id"
class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"
>{{ category.label }}</label
>
</div>
</fieldset>
</template>
<template #preview>
<span
class="bg-blue-100 text-blue-800 text-sm font-semibold p-0.5 rounded dark:bg-blue-200 dark:text-blue-800 grow-0"
v-if="categoryOneOf.length > 2"
>
{{
t("{numberOfCategories} selected", {
numberOfCategories: categoryOneOf.length,
})
}}
</span>
<span
class="bg-blue-100 text-blue-800 text-sm font-semibold p-0.5 rounded dark:bg-blue-200 dark:text-blue-800 grow-0"
v-else-if="categoryOneOf.length > 0"
>
{{
listShortDisjunctionFormatter(
categoryOneOf.map(
(category) =>
(eventCategories ?? []).find(({ id }) => id === category)
?.label ?? ""
)
)
}}
</span>
<span
class="bg-blue-100 text-blue-800 text-sm font-semibold p-0.5 rounded dark:bg-blue-200 dark:text-blue-800 grow-0"
v-else-if="categoryOneOf.length === 0"
>
{{ t("Categories", "All") }}
</span>
</template>
</filter-section>
<filter-section
v-show="contentType !== 'GROUPS'"
v-model:opened="searchFilterSectionsOpenStatus.eventStatus"
:title="t('Event status')"
>
<template #options>
<fieldset class="flex flex-col">
<legend class="sr-only">{{ t("Event status") }}</legend>
<div
v-for="eventStatusOption in eventStatuses"
:key="eventStatusOption.id"
>
<input
:id="eventStatusOption.id"
v-model="statusOneOf"
type="checkbox"
name="eventStatus"
:value="eventStatusOption.id"
class="w-4 h-4 border-gray-300 focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-600 dark:focus:bg-blue-600 dark:bg-gray-700 dark:border-gray-600"
/>
<label
:for="eventStatusOption.id"
class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"
>{{ eventStatusOption.label }}</label
>
</div>
</fieldset>
</template>
<template #preview>
<span
class="bg-blue-100 text-blue-800 text-sm font-semibold p-0.5 rounded dark:bg-blue-200 dark:text-blue-800 grow-0"
v-if="statusOneOf.length === Object.values(EventStatus).length"
>
{{ t("Statuses", "All") }}
</span>
<span
class="bg-blue-100 text-blue-800 text-sm font-semibold p-0.5 rounded dark:bg-blue-200 dark:text-blue-800 grow-0"
v-else-if="statusOneOf.length > 0"
>
{{
listShortDisjunctionFormatter(
statusOneOf.map(
(status) =>
eventStatuses.find(({ id }) => id === status)?.label ?? ""
)
)
}}
</span>
</template>
</filter-section>
<filter-section
v-model:opened="searchFilterSectionsOpenStatus.eventLanguage"
:title="t('Languages')"
>
<template #options>
<fieldset class="flex flex-col">
<legend class="sr-only">{{ t("Languages") }}</legend>
<div v-for="(language, key) in langs" :key="key">
<input
:id="key"
type="checkbox"
name="eventStartDateRange"
:value="key"
v-model="languageOneOf"
class="w-4 h-4 border-gray-300 focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-600 dark:focus:bg-blue-600 dark:bg-gray-700 dark:border-gray-600"
/>
<label
:for="key"
class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"
>{{ language }}</label
>
</div>
</fieldset>
</template>
<template #preview>
<span
class="bg-blue-100 text-blue-800 text-sm font-semibold p-0.5 rounded dark:bg-blue-200 dark:text-blue-800 grow-0"
v-if="languageOneOf.length > 2"
>
{{
t("{numberOfLanguages} selected", {
numberOfLanguages: languageOneOf.length,
})
}}
</span>
<span
class="bg-blue-100 text-blue-800 text-sm font-semibold p-0.5 rounded dark:bg-blue-200 dark:text-blue-800 grow-0"
v-else-if="languageOneOf.length > 0"
>
{{
listShortDisjunctionFormatter(
languageOneOf.map((lang) => langs[lang])
)
}}
</span>
<span
class="bg-blue-100 text-blue-800 text-sm font-semibold p-0.5 rounded dark:bg-blue-200 dark:text-blue-800 grow-0"
v-else-if="languageOneOf.length === 0"
>
{{ t("Languages", "All") }}
</span>
</template>
</filter-section>
<!--
<div class="">
<label v-translate class="font-bold" for="host">Mobilizon instance</label>
<input
id="host"
v-model="formHost"
type="text"
name="host"
placeholder="mobilizon.fr"
class="dark:text-black md:max-w-fit w-full"
/>
</div>
<div class="">
<label v-translate class="inline font-bold" for="tagsAllOf">All of these tags</label>
<button
v-if="formTagsAllOf.length !== 0"
v-translate
class="text-sm ml-2"
@click="resetField('tagsAllOf')"
>
Reset
</button>
<vue-tags-input
v-model="formTagAllOf"
:placeholder="tagsPlaceholder"
:tags="formTagsAllOf"
@tags-changed="(newTags) => (formTagsAllOf = newTags)"
/>
</div>
<div>
<div>
<label v-translate class="inline font-bold" for="tagsOneOf">One of these tags</label>
<button
v-if="formTagsOneOf.length !== 0"
v-translate
class="text-sm ml-2"
@click="resetField('tagsOneOf')"
>
Reset
</button>
</div>
<vue-tags-input
v-model="formTagOneOf"
:placeholder="tagsPlaceholder"
:tags="formTagsOneOf"
@tags-changed="(newTags) => (formTagsOneOf = newTags)"
/>
</div>-->
<div class="sr-only">
<button
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center mr-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
type="submit"
>
{{ t("Apply filters") }}
</button>
</div>
<o-button
@click="toggleFilters"
icon-left="filter"
class="w-full inline-flex lg:!hidden text-white px-4 py-2 justify-center"
>
{{ t("Hide filters") }}
</o-button>
</form>
</aside>
<div class="flex-1 px-2">
<div
id="results-anchor"
class="hidden sm:flex items-center justify-between dark:text-slate-100 mb-2"
>
<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(
"{eventsCount} events found",
{ eventsCount: searchEvents?.total },
searchEvents?.total ?? 0
)
}}
</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">
<label class="sr-only" for="sortOptionSelect">{{
t("Sort by")
}}</label>
<o-select
:placeholder="t('Sort by')"
v-model="sortBy"
id="sortOptionSelect"
>
<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'"
>
<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">
<template v-if="searchLoading">
<SkeletonGroupResultList v-for="i in 2" :key="i" />
<SkeletonEventResultList v-for="i in 4" :key="i" />
</template>
<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
class="my-2"
v-for="group in searchGroups?.elements"
:group="group"
:key="group.id"
:isRemoteGroup="group.__typename === 'GroupResult'"
:isLoggedIn="currentUser?.isLoggedIn"
mode="row"
/>
</div>
<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"
/>
</div>
<EmptyContent v-else-if="searchLoading === false" icon="magnify">
<span v-if="searchIsUrl">
{{ t("No event found at this address") }}
</span>
<span v-else-if="!search">
{{ t("No results found") }}
</span>
<i18n-t keypath="No results found for {search}" tag="span" v-else>
<template #search>
<b class="">{{ search }}</b>
</template>
</i18n-t>
<template #desc v-if="searchIsUrl && !currentUser?.id">
{{
t(
"Only registered users may fetch remote events from their URL."
)
}}
</template>
<template #desc v-else>
<p class="my-2 text-start">
{{ t("Suggestions:") }}
</p>
<ul class="list-disc list-inside text-start">
<li>
{{ t("Make sure that all words are spelled correctly.") }}
</li>
<li>{{ t("Try different keywords.") }}</li>
<li>{{ t("Try more general keywords.") }}</li>
<li>{{ t("Try fewer keywords.") }}</li>
<li>{{ t("Change the filters.") }}</li>
</ul>
</template>
</EmptyContent>
<o-pagination
v-if="
(searchEvents && searchEvents?.total > EVENT_PAGE_LIMIT) ||
(searchGroups && searchGroups?.total > GROUP_PAGE_LIMIT)
"
:total="
Math.max(searchEvents?.total ?? 0, searchGroups?.total ?? 0)
"
v-model:current="page"
: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')"
/>
</template>
<template v-else-if="contentType === ContentType.EVENTS">
<template v-if="searchLoading">
<SkeletonEventResultList v-for="i in 8" :key="i" />
</template>
<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>
<EmptyContent v-else-if="searchLoading === false" icon="calendar">
<span v-if="searchIsUrl">
{{ t("No event found at this address") }}
</span>
<span v-else-if="!search">
{{ t("No events found") }}
</span>
<i18n-t keypath="No events found for {search}" tag="span" v-else>
<template #search>
<b>{{ search }}</b>
</template>
</i18n-t>
<template #desc v-if="searchIsUrl && !currentUser?.id">
{{
t(
"Only registered users may fetch remote events from their URL."
)
}}
</template>
<template #desc v-else>
<p class="my-2 text-start">
{{ t("Suggestions:") }}
</p>
<ul class="list-disc list-inside text-start">
<li>
{{ t("Make sure that all words are spelled correctly.") }}
</li>
<li>{{ t("Try different keywords.") }}</li>
<li>{{ t("Try more general keywords.") }}</li>
<li>{{ t("Try fewer keywords.") }}</li>
<li>{{ t("Change the filters.") }}</li>
</ul>
</template>
</EmptyContent>
</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="searchLoading">
<SkeletonGroupResultList v-for="i in 6" :key="i" />
</template>
<template v-else-if="searchGroups && searchGroups?.total > 0">
<GroupCard
class="my-2"
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>
<EmptyContent
v-else-if="searchLoading === false"
icon="account-multiple"
>
<span v-if="!search">
{{ t("No events found") }}
</span>
<i18n-t keypath="No groups found for {search}" tag="span" v-else>
<template #search>
<b>{{ search }}</b>
</template>
</i18n-t>
<template #desc>
<p class="my-2 text-start">
{{ t("Suggestions:") }}
</p>
<ul class="list-disc list-inside text-start">
<li>
{{ t("Make sure that all words are spelled correctly.") }}
</li>
<li>{{ t("Try different keywords.") }}</li>
<li>{{ t("Try more general keywords.") }}</li>
<li>{{ t("Try fewer keywords.") }}</li>
<li>{{ t("Change the filters.") }}</li>
</ul>
</template>
</EmptyContent>
</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>
<script lang="ts" setup>
import {
endOfToday,
addDays,
startOfDay,
endOfDay,
endOfWeek,
addWeeks,
startOfWeek,
endOfMonth,
addMonths,
startOfMonth,
eachWeekendOfInterval,
} from "date-fns";
import { ContentType, EventStatus, SearchTargets } from "@/types/enums";
import EventCard from "@/components/Event/EventCard.vue";
import { IEvent } from "@/types/event.model";
import { SEARCH_EVENTS_AND_GROUPS } from "@/graphql/search";
import { Paginate } from "@/types/paginate";
import { IGroup } from "@/types/actor";
import GroupCard from "@/components/Group/GroupCard.vue";
import { CURRENT_USER_CLIENT } from "@/graphql/user";
import { ICurrentUser } from "@/types/current-user.model";
import { useQuery } from "@vue/apollo-composable";
import { computed, defineAsyncComponent, inject, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import {
floatTransformer,
integerTransformer,
useRouteQuery,
enumTransformer,
booleanTransformer,
RouteQueryTransformer,
} from "vue-use-route-query";
import Calendar from "vue-material-design-icons/Calendar.vue";
import AccountMultiple from "vue-material-design-icons/AccountMultiple.vue";
import Magnify from "vue-material-design-icons/Magnify.vue";
import { useHead } from "@vueuse/head";
import type { Locale } from "date-fns";
import FilterSection from "@/components/Search/filters/FilterSection.vue";
import { listShortDisjunctionFormatter } from "@/utils/listFormat";
import langs from "@/i18n/langs.json";
import {
useEventCategories,
useFeatures,
useSearchConfig,
} from "@/composition/apollo/config";
import { coordsToGeoHash } from "@/utils/location";
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 { LatLngBounds } from "leaflet";
import lodashSortBy from "lodash/sortBy";
import EmptyContent from "@/components/Utils/EmptyContent.vue";
import SkeletonGroupResultList from "@/components/Group/SkeletonGroupResultList.vue";
import SkeletonEventResultList from "@/components/Event/SkeletonEventResultList.vue";
const EventMarkerMap = defineAsyncComponent(
() => import("@/components/Search/EventMarkerMap.vue")
);
const search = useRouteQuery("search", "");
const searchDebounced = refDebounced(search, 1000);
const locationName = useRouteQuery("locationName", null);
const location = ref<IAddress | null>(null);
watch(location, (newLocation) => {
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;
longitude.value = undefined;
locationName.value = null;
}
});
interface ISearchTimeOption {
label: string;
start?: string | null;
end?: string | null;
}
enum ViewMode {
LIST = "list",
MAP = "map",
}
enum EventSortValues {
MATCH_DESC = "MATCH_DESC",
START_TIME_DESC = "START_TIME_DESC",
CREATED_AT_DESC = "CREATED_AT_DESC",
CREATED_AT_ASC = "CREATED_AT_ASC",
PARTICIPANT_COUNT_DESC = "PARTICIPANT_COUNT_DESC",
}
enum GroupSortValues {
MATCH_DESC = "MATCH_DESC",
MEMBER_COUNT_DESC = "MEMBER_COUNT_DESC",
}
enum SortValues {
MATCH_DESC = "MATCH_DESC",
START_TIME_DESC = "START_TIME_DESC",
CREATED_AT_DESC = "CREATED_AT_DESC",
CREATED_AT_ASC = "CREATED_AT_ASC",
PARTICIPANT_COUNT_DESC = "PARTICIPANT_COUNT_DESC",
MEMBER_COUNT_DESC = "MEMBER_COUNT_DESC",
}
const arrayTransformer: RouteQueryTransformer<string[]> = {
fromQuery(query: string) {
return query.split(",");
},
toQuery(value: string[]) {
return value.join(",");
},
};
const props = defineProps<{
tag?: string;
}>();
const page = useRouteQuery("page", 1, integerTransformer);
const eventPage = useRouteQuery("eventPage", 1, integerTransformer);
const groupPage = useRouteQuery("groupPage", 1, integerTransformer);
const latitude = useRouteQuery("lat", undefined, floatTransformer);
const longitude = useRouteQuery("lon", undefined, floatTransformer);
const distance = useRouteQuery("distance", "10_km");
const when = useRouteQuery("when", "any");
const contentType = useRouteQuery(
"contentType",
props.tag ? ContentType.EVENTS : ContentType.ALL,
enumTransformer(ContentType)
);
watch(contentType, (newContentType: ContentType) => {
switch (newContentType) {
case ContentType.ALL:
page.value = 1;
break;
case ContentType.EVENTS:
eventPage.value = 1;
break;
case ContentType.GROUPS:
groupPage.value = 1;
break;
}
});
const isOnline = useRouteQuery("isOnline", false, booleanTransformer);
const categoryOneOf = useRouteQuery("categoryOneOf", [], arrayTransformer);
const statusOneOf = useRouteQuery(
"statusOneOf",
[EventStatus.CONFIRMED],
arrayTransformer
);
const languageOneOf = useRouteQuery("languageOneOf", [], arrayTransformer);
const searchTarget = useRouteQuery(
"target",
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;
const GROUP_PAGE_LIMIT = 16;
const { features } = useFeatures();
const { eventCategories } = useEventCategories();
const orderedCategories = computed(() => {
if (!eventCategories.value) return [];
return lodashSortBy(eventCategories.value, ["label"]);
});
const searchEvents = computed(() => searchElementsResult.value?.searchEvents);
const searchGroups = computed(() => searchElementsResult.value?.searchGroups);
const { result: currentUserResult } = useQuery<{ currentUser: ICurrentUser }>(
CURRENT_USER_CLIENT
);
const currentUser = computed(() => currentUserResult.value?.currentUser);
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Explore events")),
});
const dateFnsLocale = inject<Locale>("dateFnsLocale");
const weekend = computed((): { start: Date; end: Date } => {
const now = new Date();
const endOfWeekDate = endOfWeek(now, { locale: dateFnsLocale });
const startOfWeekDate = startOfWeek(now, { locale: dateFnsLocale });
const [start, end] = eachWeekendOfInterval({
start: startOfWeekDate,
end: endOfWeekDate,
});
return { start: startOfDay(start), end: endOfDay(end) };
});
const dateOptions: Record<string, ISearchTimeOption> = {
past: {
label: t("In the past") as string,
start: null,
end: new Date().toISOString(),
},
today: {
label: t("Today") as string,
start: new Date().toISOString(),
end: endOfToday().toISOString(),
},
tomorrow: {
label: t("Tomorrow") as string,
start: startOfDay(addDays(new Date(), 1)).toISOString(),
end: endOfDay(addDays(new Date(), 1)).toISOString(),
},
weekend: {
label: t("This weekend") as string,
start: weekend.value.start.toISOString(),
end: weekend.value.end.toISOString(),
},
week: {
label: t("This week") as string,
start: new Date().toISOString(),
end: endOfWeek(new Date(), { locale: dateFnsLocale }).toISOString(),
},
next_week: {
label: t("Next week") as string,
start: startOfWeek(addWeeks(new Date(), 1), {
locale: dateFnsLocale,
}).toISOString(),
end: endOfWeek(addWeeks(new Date(), 1), {
locale: dateFnsLocale,
}).toISOString(),
},
month: {
label: t("This month") as string,
start: new Date().toISOString(),
end: endOfMonth(new Date()).toISOString(),
},
next_month: {
label: t("Next month") as string,
start: startOfMonth(addMonths(new Date(), 1)).toISOString(),
end: endOfMonth(addMonths(new Date(), 1)).toISOString(),
},
any: {
label: t("Any day") as string,
start: new Date().toISOString(),
end: null,
},
};
const start = computed((): string | undefined | null => {
if (dateOptions[when.value]) {
return dateOptions[when.value].start;
}
return undefined;
});
const end = computed((): string | undefined | null => {
if (dateOptions[when.value]) {
return dateOptions[when.value].end;
}
return undefined;
});
const searchIsUrl = computed((): boolean => {
let url;
if (!searchDebounced.value) return false;
try {
url = new URL(searchDebounced.value);
} catch (_) {
return false;
}
return url.protocol === "http:" || url.protocol === "https:";
});
const contentTypeMapping = computed(() => {
return [
{
contentType: "ALL",
label: t("Everything"),
},
{
contentType: "EVENTS",
label: t("Events"),
},
{
contentType: "GROUPS",
label: t("Groups"),
},
];
});
const eventDistance = computed(() => {
return [
{
id: "anywhere",
label: t("Any distance"),
},
{
id: "5_km",
label: t(
"{number} kilometers",
{
number: 5,
},
5
),
},
{
id: "10_km",
label: t(
"{number} kilometers",
{
number: 10,
},
10
),
},
{
id: "25_km",
label: t(
"{number} kilometers",
{
number: 25,
},
25
),
},
{
id: "50_km",
label: t(
"{number} kilometers",
{
number: 50,
},
50
),
},
{
id: "100_km",
label: t(
"{number} kilometers",
{
number: 100,
},
100
),
},
{
id: "150_km",
label: t(
"{number} kilometers",
{
number: 150,
},
150
),
},
];
});
const eventStatuses = computed(() => {
return [
{
id: EventStatus.CONFIRMED,
label: t("Confirmed"),
},
{
id: EventStatus.TENTATIVE,
label: t("Tentative"),
},
{
id: EventStatus.CANCELLED,
label: t("Cancelled"),
},
];
});
const searchFilterSectionsOpenStatus = ref({
eventDate: true,
eventLanguage: false,
eventCategory: false,
eventStatus: false,
eventDistance: false,
});
const filtersPanelOpened = ref(true);
const toggleFilters = () =>
(filtersPanelOpened.value = !filtersPanelOpened.value);
const geoHashLocation = computed(() =>
coordsToGeoHash(latitude.value, longitude.value)
);
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 }) =>
handleSearchConfigChanged(data?.config?.search)
);
const handleSearchConfigChanged = (
searchConfigChanged: IConfig["search"] | undefined
) => {
if (
searchConfigChanged?.global?.isEnabled &&
searchConfigChanged?.global?.isDefault
) {
searchTarget.value = SearchTargets.GLOBAL;
}
};
watch(searchConfig, (newSearchConfig) =>
handleSearchConfigChanged(newSearchConfig)
);
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 sortByForType = (
value: SortValues,
allowed: typeof EventSortValues | typeof GroupSortValues
): SortValues | undefined => {
return Object.values(allowed).includes(value) ? value : undefined;
};
const boostLanguagesQuery = computed((): string[] => {
const languages = new Set<string>();
for (const completeLanguage of navigator.languages) {
const language = completeLanguage.split("-")[0];
if (Object.keys(langs).find((langKey) => langKey === language)) {
languages.add(language);
}
}
return Array.from(languages);
});
const { result: searchElementsResult, loading: searchLoading } = useQuery<{
searchEvents: Paginate<TypeNamed<IEvent>>;
searchGroups: Paginate<TypeNamed<IGroup>>;
}>(SEARCH_EVENTS_AND_GROUPS, () => ({
term: searchDebounced.value,
tags: props.tag,
location: geoHashLocation.value,
beginsOn: start.value,
endsOn: end.value,
radius: geoHashLocation.value ? radius.value : undefined,
eventPage:
contentType.value === ContentType.ALL ? page.value : eventPage.value,
groupPage:
contentType.value === ContentType.ALL ? page.value : groupPage.value,
limit: EVENT_PAGE_LIMIT,
type: isOnline.value ? "ONLINE" : undefined,
categoryOneOf: categoryOneOf.value,
statusOneOf: statusOneOf.value,
languageOneOf: languageOneOf.value,
searchTarget: searchTarget.value,
bbox: mode.value === ViewMode.MAP ? bbox.value : undefined,
zoom: zoom.value,
sortByEvents: sortByForType(sortBy.value, EventSortValues),
sortByGroups: sortByForType(sortBy.value, GroupSortValues),
boostLanguages: boostLanguagesQuery.value,
}));
</script>