mobilizon/js/src/views/SearchView.vue

841 lines
26 KiB
Vue
Raw Normal View History

<template>
<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 px-2 pt-2 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-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="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 eventCategories" :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">
<o-tabs type="boxed" v-if="contentType == ContentType.ALL">
<o-tab-item>
<template #header>
<Calendar />
<span>
{{ t("Events") }}
<b-tag rounded>{{ searchEvents?.total }}</b-tag>
</span>
</template>
<div v-if="searchEvents && searchEvents.total > 0">
<multi-card class="my-4" :events="searchEvents?.elements" />
<div
class="pagination"
v-if="searchEvents && searchEvents?.total > EVENT_PAGE_LIMIT"
>
<o-pagination
: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>
</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>
</o-tab-item>
<o-tab-item>
<template #header>
<AccountMultiple />
<span>
{{ t("Groups") }} <b-tag rounded>{{ searchGroups?.total }}</b-tag>
</span>
</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">
<multi-group-card class="my-4" :groups="searchGroups?.elements" />
<div class="pagination">
<o-pagination
: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>
</div>
<o-notification v-else-if="searchLoading === false" variant="danger">
{{ t("No groups found") }}
</o-notification>
</o-tab-item>
</o-tabs>
<div v-else-if="contentType === ContentType.EVENTS">
<div v-if="searchEvents && searchEvents.total > 0">
<multi-card class="my-4" :events="searchEvents?.elements" />
<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>
</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>
</div>
<div 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>
<div v-else-if="searchGroups && searchGroups?.total > 0">
<multi-group-card class="my-4" :groups="searchGroups?.elements" />
<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>
</div>
<o-notification v-else-if="searchLoading === false" variant="danger">
{{ t("No groups found") }}
</o-notification>
</div>
</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 } from "@/types/enums";
import MultiCard from "../components/Event/MultiCard.vue";
import { IEvent } from "../types/event.model";
import FullAddressAutoComplete from "../components/Event/FullAddressAutoComplete.vue";
import { SEARCH_EVENTS_AND_GROUPS } from "../graphql/search";
import { Paginate } from "../types/paginate";
import { IGroup } from "../types/actor";
import MultiGroupCard from "../components/Group/MultiGroupCard.vue";
import { CURRENT_USER_CLIENT } from "@/graphql/user";
import { ICurrentUser } from "@/types/current-user.model";
import { useQuery } from "@vue/apollo-composable";
import { computed, inject, ref } 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 } from "@/composition/apollo/config";
import geohash from "ngeohash";
import { coordsToGeoHash } from "@/utils/location";
const search = useRouteQuery("search", "");
interface ISearchTimeOption {
label: string;
start?: string | null;
end?: string | null;
}
const arrayTransformer: RouteQueryTransformer<string[]> = {
fromQuery(query: string) {
return query.split(",");
},
toQuery(value: string[]) {
return value.join(",");
},
};
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",
ContentType.ALL,
enumTransformer(ContentType)
);
const isOnline = useRouteQuery("isOnline", false, booleanTransformer);
const categoryOneOf = useRouteQuery("categoryOneOf", [], arrayTransformer);
const statusOneOf = useRouteQuery(
"statusOneOf",
[EventStatus.CONFIRMED],
arrayTransformer
);
const languageOneOf = useRouteQuery("languageOneOf", [], arrayTransformer);
const EVENT_PAGE_LIMIT = 16;
const GROUP_PAGE_LIMIT = 16;
const props = defineProps<{
tag?: string;
}>();
const { features } = useFeatures();
const { eventCategories } = useEventCategories();
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: undefined,
end: undefined,
},
};
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 (!search.value) return false;
try {
url = new URL(search.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 { result: searchElementsResult, loading: searchLoading } = useQuery<{
searchEvents: Paginate<IEvent>;
searchGroups: Paginate<IGroup>;
}>(SEARCH_EVENTS_AND_GROUPS, () => ({
term: search.value,
tags: props.tag,
location: geoHashLocation.value,
beginsOn: start.value,
endsOn: end.value,
radius: radius.value,
eventPage: eventPage.value,
groupPage: groupPage.value,
limit: EVENT_PAGE_LIMIT,
type: isOnline.value ? "ONLINE" : "IN_PERSON",
categoryOneOf: categoryOneOf.value,
statusOneOf: statusOneOf.value,
}));
</script>