Improve search

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2022-08-22 12:12:09 +02:00
parent 444e0d6a0c
commit baac00f678
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
36 changed files with 1343 additions and 1013 deletions

View File

@ -134,8 +134,6 @@ export default function buildCurrentUserResolver(
}, },
}; };
console.debug("updating current user location", data);
localCache.writeQuery({ data, query: CURRENT_USER_LOCATION_CLIENT }); localCache.writeQuery({ data, query: CURRENT_USER_LOCATION_CLIENT });
}, },
}, },

View File

@ -204,3 +204,35 @@ button.menubar__button {
@apply text-white; @apply text-white;
background: #363636; background: #363636;
} }
/** Pagination */
.pagination {
@apply flex items-center text-center justify-between;
}
.pagination-link {
@apply inline-flex items-center relative justify-center cursor-pointer rounded h-10 m-1 p-2 bg-white text-lg;
}
.pagination-list {
@apply flex items-center text-center list-none flex-wrap grow shrink justify-start;
}
.pagination-next,
.pagination-previous {
@apply px-3;
}
.pagination-link-current {
@apply bg-primary cursor-not-allowed pointer-events-none border-primary text-white;
}
.pagination-ellipsis {
@apply text-center m-1 text-gray-300;
}
/** Tabs */
.tabs-nav {
@apply flex items-center justify-start pb-0.5;
}
.tabs-nav-item-boxed {
@apply flex items-center justify-center px-2 py-2 rounded-t border-transparent;
}
.tabs-nav-item-active-boxed {
@apply bg-white border-gray-300 text-primary;
}

View File

@ -3,7 +3,7 @@
:to="{ :to="{
name: 'SEARCH', name: 'SEARCH',
query: { query: {
eventCategory: category.key, categoryOneOf: [category.key],
contentType: 'EVENTS', contentType: 'EVENTS',
radius: undefined, radius: undefined,
}, },

View File

@ -1,6 +1,6 @@
<template> <template>
<router-link <router-link
class="mbz-card max-w-xs shrink-0 w-[18rem] snap-center" class="mbz-card max-w-xs shrink-0 w-[18rem] snap-center dark:bg-mbz-purple"
:to="{ name: RouteName.EVENT, params: { uuid: event.uuid } }" :to="{ name: RouteName.EVENT, params: { uuid: event.uuid } }"
> >
<div class="bg-secondary rounded-lg"> <div class="bg-secondary rounded-lg">

View File

@ -1,9 +1,6 @@
<template> <template>
<article class="bg-white dark:bg-mbz-purple mb-5 mt-4 p-0"> <article class="bg-white dark:bg-mbz-purple mb-5 mt-4 pb-2 md:p-0">
<div <div class="bg-yellow-2 flex p-2 text-violet-title rounded-t-lg" dir="auto">
class="bg-mbz-yellow-2 flex p-2 text-violet-title rounded-t-lg"
dir="auto"
>
<figure <figure
class="image is-24x24 ltr:pr-1 rtl:pl-1" class="image is-24x24 ltr:pr-1 rtl:pl-1"
v-if="participation.actor.avatar" v-if="participation.actor.avatar"
@ -21,7 +18,7 @@
</div> </div>
<div class="list-card flex flex-col relative"> <div class="list-card flex flex-col relative">
<div <div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-x-1.5 gapt-x-3 pb-2" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-x-1.5 md:gap-y-3 gapt-x-3"
> >
<div class="mr-0 ml-0"> <div class="mr-0 ml-0">
<div class="h-36 relative w-full"> <div class="h-36 relative w-full">
@ -52,24 +49,24 @@
</router-link> </router-link>
</div> </div>
</div> </div>
<div class="list-card-content lg:col-span-2 flex-1 p-2"> <div class="list-card-content lg:col-span-4 flex-1 p-2">
<div class="flex items-center pt-2" dir="auto"> <div class="flex items-center pt-2" dir="auto">
<b-tag <Tag
variant="info" variant="info"
class="mr-1 mb-1" class="mr-1 mb-1"
size="is-medium" size="medium"
v-if="participation.event.status === EventStatus.TENTATIVE" v-if="participation.event.status === EventStatus.TENTATIVE"
> >
{{ $t("Tentative") }} {{ t("Tentative") }}
</b-tag> </Tag>
<b-tag <Tag
variant="danger" variant="danger"
class="mr-1 mb-1" class="mr-1 mb-1"
size="is-medium" size="medium"
v-if="participation.event.status === EventStatus.CANCELLED" v-if="participation.event.status === EventStatus.CANCELLED"
> >
{{ $t("Cancelled") }} {{ t("Cancelled") }}
</b-tag> </Tag>
<router-link <router-link
:to="{ :to="{
name: RouteName.EVENT, name: RouteName.EVENT,
@ -96,7 +93,7 @@
" "
> >
<Video /> <Video />
<span>{{ $t("Online") }}</span> <span>{{ t("Online") }}</span>
</div> </div>
<div class="flex gap-1"> <div class="flex gap-1">
<figure class="" v-if="actorAvatarURL"> <figure class="" v-if="actorAvatarURL">
@ -122,7 +119,7 @@
<!-- Less than 10 seats left --> <!-- Less than 10 seats left -->
<span class="has-text-danger" v-if="lastSeatsLeft"> <span class="has-text-danger" v-if="lastSeatsLeft">
{{ {{
$t("{number} seats left", { t("{number} seats left", {
number: seatsLeft, number: seatsLeft,
}) })
}} }}
@ -133,7 +130,7 @@
" "
> >
{{ {{
$t( t(
"{available}/{capacity} available places", "{available}/{capacity} available places",
{ {
available: available:
@ -149,7 +146,7 @@
</span> </span>
<span v-else> <span v-else>
{{ {{
$t( t(
"{count} participants", "{count} participants",
{ {
count: participation.event.participantStats.participant, count: participation.event.participantStats.participant,
@ -170,7 +167,7 @@
" "
> >
{{ {{
$t( t(
"{count} requests waiting", "{count} requests waiting",
{ {
count: participation.event.participantStats.notApproved, count: participation.event.participantStats.notApproved,
@ -183,10 +180,13 @@
</div> </div>
</div> </div>
<o-dropdown aria-role="list" class="text-center self-center"> <o-dropdown
aria-role="list"
class="text-center self-center md:col-span-2 lg:col-span-1"
>
<template #trigger> <template #trigger>
<o-button icon-right="dots-horizontal"> <o-button icon-right="dots-horizontal">
{{ $t("Actions") }} {{ t("Actions") }}
</o-button> </o-button>
</template> </template>
<o-dropdown-item <o-dropdown-item
@ -208,7 +208,7 @@
" "
> >
<Pencil /> <Pencil />
{{ $t("Edit") }} {{ t("Edit") }}
</div> </div>
</o-dropdown-item> </o-dropdown-item>
@ -226,7 +226,7 @@
" "
> >
<ContentDuplicate /> <ContentDuplicate />
{{ $t("Duplicate") }} {{ t("Duplicate") }}
</div> </div>
</o-dropdown-item> </o-dropdown-item>
@ -241,7 +241,7 @@
> >
<div @click="openDeleteEventModalWrapper" class="flex gap-1"> <div @click="openDeleteEventModalWrapper" class="flex gap-1">
<Delete /> <Delete />
{{ $t("Delete") }} {{ t("Delete") }}
</div> </div>
</o-dropdown-item> </o-dropdown-item>
@ -264,7 +264,7 @@
" "
> >
<AccountMultiplePlus /> <AccountMultiplePlus />
{{ $t("Manage participations") }} {{ t("Manage participations") }}
</div> </div>
</o-dropdown-item> </o-dropdown-item>
@ -277,7 +277,7 @@
}" }"
> >
<ViewCompact /> <ViewCompact />
{{ $t("View event page") }} {{ t("View event page") }}
</router-link> </router-link>
</o-dropdown-item> </o-dropdown-item>
</o-dropdown> </o-dropdown>
@ -288,12 +288,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue"; import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
import { EventStatus, EventVisibility, ParticipantRole } from "@/types/enums"; import { EventStatus, ParticipantRole } from "@/types/enums";
import { IParticipant } from "@/types/participant.model"; import { IParticipant } from "@/types/participant.model";
import { import {
IEvent, IEvent,
IEventCardOptions, IEventCardOptions,
organizer,
organizerAvatarUrl, organizerAvatarUrl,
organizerDisplayName, organizerDisplayName,
} from "@/types/event.model"; } from "@/types/event.model";
@ -319,6 +318,7 @@ import { useI18n } from "vue-i18n";
import { Dialog } from "@/plugins/dialog"; import { Dialog } from "@/plugins/dialog";
import { Snackbar } from "@/plugins/snackbar"; import { Snackbar } from "@/plugins/snackbar";
import { useDeleteEvent } from "@/composition/apollo/event"; import { useDeleteEvent } from "@/composition/apollo/event";
import Tag from "@/components/Tag.vue";
const defaultOptions: IEventCardOptions = { const defaultOptions: IEventCardOptions = {
hideDate: true, hideDate: true,

View File

@ -1,6 +1,6 @@
<template> <template>
<div <div
class="grid auto-rows-[1fr] gap-x-5 gap-y-8 grid-cols-[repeat(auto-fill,_minmax(250px,_1fr))]" class="grid auto-rows-[1fr] gap-x-2 gap-y-4 md:gap-x-6 grid-cols-[repeat(auto-fill,_minmax(250px,_1fr))] justify-items-center"
> >
<event-card <event-card
class="flex flex-col h-full" class="flex flex-col h-full"

View File

@ -4,35 +4,49 @@
name: RouteName.GROUP, name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group) }, params: { preferredUsername: usernameWithDomain(group) },
}" }"
class="card flex flex-col max-w-md bg-white dark:bg-mbz-purple dark:text-white rounded-lg shadow-lg" class="card flex flex-col shrink-0 w-[18rem] bg-white dark:bg-mbz-purple dark:text-white rounded-lg shadow-lg"
> >
<figure class="rounded-t-lg flex justify-center h-1/4"> <figure class="rounded-t-lg flex justify-center h-40">
<lazy-image-wrapper :picture="group.banner" :rounded="true" /> <lazy-image-wrapper :picture="group.banner" :rounded="true" />
</figure> </figure>
<div class="py-2 pl-2"> <div class="py-2 pl-2">
<div class="flex gap-1 mb-2"> <div class="flex gap-1 mb-2">
<div class=""> <div class="">
<figure class="" v-if="group.avatar"> <figure class="" v-if="group.avatar">
<img class="rounded-xl" :src="group.avatar.url" alt="" /> <img
class="rounded-xl"
:src="group.avatar.url"
alt=""
height="64"
width="64"
/>
</figure> </figure>
<AccountGroup v-else :size="48" /> <AccountGroup v-else :size="64" />
</div> </div>
<div class=""> <div class="px-1">
<h3 class="text-2xl" dir="auto"> <h3
class="text-2xl leading-5 line-clamp-3 font-bold text-violet-3 dark:text-white"
dir="auto"
>
{{ displayName(group) }} {{ displayName(group) }}
</h3> </h3>
<span class="is-6 has-text-grey-dark group-federated-username"> <span>
{{ `@${usernameWithDomain(group)}` }} {{ `@${usernameWithDomain(group)}` }}
</span> </span>
</div> </div>
</div> </div>
<div class="mb-2 line-clamp-3" dir="auto" v-html="group.summary" /> <div
class="mb-2 line-clamp-3"
dir="auto"
v-html="saneSummary"
v-if="showSummary"
/>
<div> <div>
<inline-address <inline-address
v-if="group.physicalAddress && addressFullName(group.physicalAddress)" v-if="group.physicalAddress && addressFullName(group.physicalAddress)"
:physicalAddress="group.physicalAddress" :physicalAddress="group.physicalAddress"
/> />
<p class="flex"> <p class="flex gap-1">
<Account /> <Account />
{{ {{
t( t(
@ -58,10 +72,18 @@ import { addressFullName } from "@/types/address.model";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import AccountGroup from "vue-material-design-icons/AccountGroup.vue"; import AccountGroup from "vue-material-design-icons/AccountGroup.vue";
import Account from "vue-material-design-icons/Account.vue"; import Account from "vue-material-design-icons/Account.vue";
import { htmlToText } from "@/utils/html";
import { computed } from "vue";
const props = withDefaults(
defineProps<{ defineProps<{
group: IGroup; group: IGroup;
}>(); showSummary: boolean;
}>(),
{ showSummary: true }
);
const { t } = useI18n({ useScope: "global" }); const { t } = useI18n({ useScope: "global" });
const saneSummary = computed(() => htmlToText(props.group.summary));
</script> </script>

View File

@ -62,7 +62,7 @@
<div <div
class="mt-3 prose dark:prose-invert lg:prose-xl line-clamp-2" class="mt-3 prose dark:prose-invert lg:prose-xl line-clamp-2"
v-if="member.parent.summary" v-if="member.parent.summary"
v-html="flattenHTMLParagraphs(member.parent.summary)" v-html="htmlToText(member.parent.summary)"
/> />
</div> </div>
<div> <div>
@ -95,7 +95,7 @@ import DotsHorizontal from "vue-material-design-icons/DotsHorizontal.vue";
import AccountGroup from "vue-material-design-icons/AccountGroup.vue"; import AccountGroup from "vue-material-design-icons/AccountGroup.vue";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue"; import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import Tag from "@/components/Tag.vue"; import Tag from "@/components/Tag.vue";
import { flattenHTMLParagraphs } from "@/utils/html"; import { htmlToText } from "@/utils/html";
defineProps<{ defineProps<{
member: IMember; member: IMember;

View File

@ -57,13 +57,11 @@ const imageOpacity = computed(() =>
); );
onMounted(() => { onMounted(() => {
console.debug("on lazy image mounted");
observer.value = new IntersectionObserver((entries) => { observer.value = new IntersectionObserver((entries) => {
isIntersecting.value = entries[0].isIntersecting; isIntersecting.value = entries[0].isIntersecting;
}); });
if (wrapper.value) { if (wrapper.value) {
console.debug("starting observing");
observer.value.observe(wrapper.value); observer.value.observe(wrapper.value);
} }
}); });

View File

@ -9,7 +9,7 @@
</h2> </h2>
<button <button
v-if="suggestGeoloc && isIPLocation" v-if="suggestGeoloc"
class="inline-flex bg-primary rounded text-white flex-initial px-4 py-2 justify-center w-full md:w-min whitespace-nowrap" class="inline-flex bg-primary rounded text-white flex-initial px-4 py-2 justify-center w-full md:w-min whitespace-nowrap"
@click="emit('doGeoLoc')" @click="emit('doGeoLoc')"
> >
@ -21,7 +21,7 @@
<div class="hidden sm:block" v-show="showScrollLeftButton"> <div class="hidden sm:block" v-show="showScrollLeftButton">
<button <button
@click="scrollLeft" @click="scrollLeft"
class="absolute inset-y-0 my-auto z-10 rounded-full bg-white w-10 h-10 border border-shadowColor -left-5" class="absolute inset-y-0 my-auto z-10 rounded-full bg-white dark:bg-transparent w-10 h-10 border border-shadowColor -left-5"
> >
<div class="">&lt;</div> <div class="">&lt;</div>
</button> </button>
@ -38,7 +38,7 @@
<div class="hidden sm:block" v-show="showScrollRightButton"> <div class="hidden sm:block" v-show="showScrollRightButton">
<button <button
@click="scrollRight" @click="scrollRight"
class="absolute inset-y-0 my-auto z-10 rounded-full bg-white w-10 h-10 border border-shadowColor -right-5" class="absolute inset-y-0 my-auto z-10 rounded-full bg-white dark:bg-transparent w-10 h-10 border border-shadowColor -right-5"
> >
<div class="">&gt;</div> <div class="">&gt;</div>
</button> </button>
@ -47,9 +47,8 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, inject, onMounted, onUnmounted, ref } from "vue"; import { onMounted, onUnmounted, ref } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { LocationType } from "../../types/user-location.model";
withDefaults( withDefaults(
defineProps<{ defineProps<{
@ -62,14 +61,6 @@ const emit = defineEmits(["doGeoLoc"]);
const { t } = useI18n({ useScope: "global" }); const { t } = useI18n({ useScope: "global" });
const userLocationInjection = inject<{
userLocation: LocationType;
}>("userLocation");
const isIPLocation = computed(
() => userLocationInjection?.userLocation.isIPLocation
);
const showScrollRightButton = ref(true); const showScrollRightButton = ref(true);
const showScrollLeftButton = ref(false); const showScrollLeftButton = ref(false);

View File

@ -1,8 +1,10 @@
<template> <template>
<close-content <close-content
class="container mx-auto px-2"
v-show="loadingEvents || (events && events.total > 0)" v-show="loadingEvents || (events && events.total > 0)"
:suggestGeoloc="suggestGeoloc" :suggestGeoloc="suggestGeoloc"
v-on="attrs" v-on="attrs"
@doGeoLoc="emit('doGeoLoc')"
> >
<template #title> <template #title>
<template v-if="userLocationName"> <template v-if="userLocationName">
@ -25,13 +27,7 @@
:key="event.uuid" :key="event.uuid"
/> />
<more-content <more-content
v-if=" v-if="userLocationName && userLocation?.lat && userLocation?.lon"
userLocation &&
userLocationName &&
userLocation.lat &&
userLocation.lon &&
userLocation.picture
"
:to="{ :to="{
name: 'SEARCH', name: 'SEARCH',
query: { query: {
@ -39,10 +35,10 @@
lat: userLocation.lat?.toString(), lat: userLocation.lat?.toString(),
lon: userLocation.lon?.toString(), lon: userLocation.lon?.toString(),
contentType: 'EVENTS', contentType: 'EVENTS',
distance: '25', distance: '25_km',
}, },
}" }"
:picture="userLocation.picture" :picture="userLocation?.picture"
> >
{{ {{
t("View more events around {position}", { t("View more events around {position}", {
@ -65,115 +61,33 @@ import { useQuery } from "@vue/apollo-composable";
import EventCard from "../Event/EventCard.vue"; import EventCard from "../Event/EventCard.vue";
import { Paginate } from "@/types/paginate"; import { Paginate } from "@/types/paginate";
import SkeletonEventResult from "../Event/SkeletonEventResult.vue"; import SkeletonEventResult from "../Event/SkeletonEventResult.vue";
import { CURRENT_USER_LOCATION_CLIENT } from "@/graphql/location";
import { ICurrentUser, IUser } from "@/types/current-user.model";
import { CURRENT_USER_CLIENT, USER_SETTINGS } from "@/graphql/user";
import { coordsToGeoHash, geoHashToCoords } from "@/utils/location";
import { REVERSE_GEOCODE } from "@/graphql/address";
import { IAddress } from "@/types/address.model";
import { CONFIG } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { coordsToGeoHash } from "@/utils/location";
const props = defineProps<{ userLocation: LocationType }>();
const emit = defineEmits(["doGeoLoc"]);
const EVENT_PAGE_LIMIT = 12; const EVENT_PAGE_LIMIT = 12;
const { result: currentUserResult } = useQuery<{
currentUser: ICurrentUser;
}>(CURRENT_USER_CLIENT);
const currentUser = computed(() => currentUserResult.value?.currentUser);
const { result: userResult } = useQuery<{ loggedUser: IUser }>(
USER_SETTINGS,
{},
() => ({
enabled: currentUser.value?.isLoggedIn,
})
);
const loggedUser = computed(() => userResult.value?.loggedUser);
const { result: configResult } = useQuery<{ config: IConfig }>(CONFIG);
const config = computed<IConfig | undefined>(() => configResult.value?.config);
const serverLocation = computed(() => config.value?.location);
const { t } = useI18n({ useScope: "global" }); const { t } = useI18n({ useScope: "global" });
const attrs = useAttrs(); const attrs = useAttrs();
const coords = computed(() => {
const userSettingsGeoHash =
loggedUser.value?.settings?.location?.geohash ?? undefined;
const userSettingsCoords = geoHashToCoords(userSettingsGeoHash);
if (userSettingsCoords) {
return { ...userSettingsCoords, isIPLocation: false };
}
return { ...serverLocation.value, isIPLocation: true };
});
const { result: reverseGeocodeResult } = useQuery<{
reverseGeocode: IAddress[];
}>(REVERSE_GEOCODE, coords, () => ({
enabled: coords.value?.longitude != undefined,
}));
const userSettingsLocation = computed(() => {
const address = reverseGeocodeResult.value?.reverseGeocode[0];
const placeName = address?.locality ?? address?.region ?? address?.country;
return {
lat: coords.value?.latitude,
lon: coords.value?.longitude,
name: placeName,
picture: address?.pictureInfo,
isIPLocation: coords.value?.isIPLocation,
};
});
const { result: currentUserLocationResult } = useQuery<{
currentUserLocation: LocationType;
}>(CURRENT_USER_LOCATION_CLIENT);
const currentUserLocation = computed(() => {
return {
...currentUserLocationResult.value?.currentUserLocation,
isIPLocation: false,
};
});
const geohash = computed(() => {
return coordsToGeoHash(userLocation.value?.lat, userLocation.value?.lon);
});
const userLocationName = computed(() => { const userLocationName = computed(() => {
return userLocation.value?.name; return props.userLocation?.name;
}); });
const suggestGeoloc = computed(() => props.userLocation?.isIPLocation);
const userLocation = computed(() => {
if (
!userSettingsLocation.value ||
(userSettingsLocation.value?.isIPLocation &&
currentUserLocation.value?.name)
) {
return currentUserLocation.value;
}
return userSettingsLocation.value;
});
const suggestGeoloc = computed(() => userLocation.value?.isIPLocation);
const { result: eventsResult, loading: loadingEvents } = useQuery<{ const { result: eventsResult, loading: loadingEvents } = useQuery<{
searchEvents: Paginate<IEvent>; searchEvents: Paginate<IEvent>;
}>(SEARCH_EVENTS, { }>(SEARCH_EVENTS, () => ({
location: geohash, location: coordsToGeoHash(props.userLocation.lat, props.userLocation.lon),
beginsOn: new Date(), beginsOn: new Date(),
endsOn: undefined, endsOn: undefined,
radius: 25, radius: 25,
eventPage: 1, eventPage: 1,
limit: EVENT_PAGE_LIMIT, limit: EVENT_PAGE_LIMIT,
type: "IN_PERSON", type: "IN_PERSON",
}); }));
const events = computed( const events = computed(
() => eventsResult.value?.searchEvents ?? { elements: [], total: 0 } () => eventsResult.value?.searchEvents ?? { elements: [], total: 0 }

View File

@ -1,12 +1,22 @@
<template> <template>
<close-content v-show="loadingGroups || selectedGroups.length > 0"> <close-content
class="container mx-auto px-2"
v-show="loadingGroups || selectedGroups.length > 0"
@do-geo-loc="emit('doGeoLoc')"
:suggestGeoloc="userLocation.isIPLocation"
>
<template #title> <template #title>
<template v-if="userLocationName">
{{ {{
$t("Popular groups nearby {position}", { t("Popular groups nearby {position}", {
position: userLocationName, position: userLocationName,
}) })
}} }}
</template> </template>
<template v-else>
{{ t("Popular groups close to you") }}
</template>
</template>
<template #content> <template #content>
<!-- <skeleton-group-result <!-- <skeleton-group-result
v-for="i in [...Array(6).keys()]" v-for="i in [...Array(6).keys()]"
@ -14,32 +24,32 @@
:key="i" :key="i"
v-show="loadingGroups" v-show="loadingGroups"
/> --> /> -->
<group-result <group-card
v-for="group in selectedGroups" v-for="group in selectedGroups"
:key="group.id" :key="group.id"
class="scroll-ml-6 snap-center shrink-0 first:pl-8 last:pr-8 w-[18rem]"
:group="group" :group="group"
:view-mode="'column'" :view-mode="'column'"
:minimal="true" :minimal="true"
:has-border="true" :has-border="true"
:showSummary="false"
/> />
<more-content <more-content
v-if="userLocation" v-if="userLocationName"
:to="{ :to="{
name: 'SEARCH', name: 'SEARCH',
query: { query: {
locationName: userLocationName, locationName: userLocationName,
lat: userLocation.lat, lat: userLocation.lat?.toString(),
lon: userLocation.lon, lon: userLocation.lon?.toString(),
contentType: 'GROUPS', contentType: 'GROUPS',
distance: currentDistance, distance: '25_km',
}, },
}" }"
:picture="userLocation.picture" :picture="userLocation.picture"
> >
{{ {{
$t("View more groups around {position}", { t("View more groups around {position}", {
position: userLocationName, position: userLocationName,
}) })
}} }}
@ -48,8 +58,7 @@
</close-content> </close-content>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent, inject, reactive, computed, ref } from "vue";
// import SkeletonGroupResult from "../../components/result/SkeletonGroupResult.vue"; // import SkeletonGroupResult from "../../components/result/SkeletonGroupResult.vue";
import sampleSize from "lodash/sampleSize"; import sampleSize from "lodash/sampleSize";
import { LocationType } from "../../types/user-location.model"; import { LocationType } from "../../types/user-location.model";
@ -57,39 +66,36 @@ import MoreContent from "./MoreContent.vue";
import CloseContent from "./CloseContent.vue"; import CloseContent from "./CloseContent.vue";
import { IGroup } from "@/types/actor"; import { IGroup } from "@/types/actor";
import { SEARCH_GROUPS } from "@/graphql/search"; import { SEARCH_GROUPS } from "@/graphql/search";
import { useQuery } from "@vue/apollo-composable";
import { Paginate } from "@/types/paginate";
import { computed } from "vue";
import GroupCard from "@/components/Group/GroupCard.vue";
import { coordsToGeoHash } from "@/utils/location";
import { useI18n } from "vue-i18n";
export default defineComponent({ const props = defineProps<{ userLocation: LocationType }>();
components: { const emit = defineEmits(["doGeoLoc"]);
MoreContent,
CloseContent,
// SkeletonGroupResult,
},
apollo: {
groups: {
query: SEARCH_GROUPS,
},
},
setup() {
const userLocationInjection = inject<{
userLocation: LocationType;
}>("userLocation");
const groups = reactive<IGroup[]>([]); const { t } = useI18n({ useScope: "global" });
const selectedGroups = computed(() => sampleSize(groups, 5)); const { result: groupsResult, loading: loadingGroups } = useQuery<{
searchGroups: Paginate<IGroup>;
const currentDistance = ref("25_km"); }>(
SEARCH_GROUPS,
const userLocationName = computed( () => ({
() => userLocationInjection?.userLocation?.name location: coordsToGeoHash(props.userLocation.lat, props.userLocation.lon),
radius: 25,
page: 1,
limit: 12,
}),
() => ({ enabled: props.userLocation?.lat !== undefined })
); );
return { const groups = computed(
selectedGroups, () => groupsResult.value?.searchGroups ?? { total: 0, elements: [] }
userLocation: userLocationInjection?.userLocation, );
userLocationName,
currentDistance, const selectedGroups = computed(() => sampleSize(groups.value?.elements, 5));
};
}, const userLocationName = computed(() => props?.userLocation?.name);
});
</script> </script>

View File

@ -1,5 +1,6 @@
<template> <template>
<close-content <close-content
class="container mx-auto px-2"
v-show="loadingEvents || (events && events.total > 0)" v-show="loadingEvents || (events && events.total > 0)"
:suggestGeoloc="false" :suggestGeoloc="false"
v-on="attrs" v-on="attrs"

View File

@ -1,7 +1,7 @@
<template> <template>
<router-link <router-link
:to="to" :to="to"
class="mbz-card flex flex-col items-center dark:border-gray-700 shadow-md md:flex-col snap-center shrink-0 first:pl-8 w-[18rem]" class="mbz-card flex flex-col items-center dark:border-gray-700 shadow-md md:flex-col snap-center shrink-0 first:pl-8 w-[18rem] dark:bg-mbz-purple"
> >
<div class="relative w-full group"> <div class="relative w-full group">
<img <img
@ -63,7 +63,7 @@ import { computed } from "vue";
import { CategoryPictureLicencing } from "../Categories/constants"; import { CategoryPictureLicencing } from "../Categories/constants";
const props = defineProps<{ const props = defineProps<{
to: { name: string; query: Record<string, string> }; to: { name: string; query: Record<string, string | undefined> };
picture?: CategoryPictureLicencing & { url: string }; picture?: CategoryPictureLicencing & { url: string };
}>(); }>();

View File

@ -0,0 +1,70 @@
<template>
<div class="border-b border-gray-200 dark:border-gray-500 py-6">
<h2 class="-my-3 flow-root">
<!-- Expand/collapse section button -->
<button
type="button"
class="py-3 w-full flex items-center justify-between text-gray-400 hover:text-gray-500 dark:text-slate-100 hover:dark:text-slate-200"
aria-controls="filter-section-mobile-0"
:aria-expanded="opened"
@click="$emit('update:opened', !opened)"
>
<div class="flex flex-wrap gap-1 flex-col items-start">
<span class="font-medium text-gray-900 dark:text-slate-100 text-left">
{{ title }}
</span>
<slot name="preview" />
</div>
<span class="ml-6 flex items-center">
<svg
v-show="!opened"
class="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
clip-rule="evenodd"
/>
</svg>
<svg
v-show="opened"
class="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M5 10a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1z"
clip-rule="evenodd"
/>
</svg>
</span>
</button>
</h2>
<!-- Filter section, show/hide based on section state. -->
<transition>
<div v-show="opened" class="pt-2">
<slot name="options" />
</div>
</transition>
</div>
</template>
<script setup lang="ts">
defineProps({
title: {
required: true,
type: String,
},
opened: {
required: true,
type: Boolean,
},
});
defineEmits(["update:opened"]);
</script>

View File

@ -1,7 +1,7 @@
<template> <template>
<span <span
class="rounded-md my-1 truncate text-sm text-violet-title capitalize px-2 py-1" class="rounded-md my-1 truncate text-sm text-violet-title px-2 py-1"
:class="typeClasses" :class="[typeClasses, capitalize]"
> >
<slot /> <slot />
</span> </span>
@ -12,8 +12,9 @@ import { computed } from "vue";
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
variant?: "info" | "danger" | "warning" | "light"; variant?: "info" | "danger" | "warning" | "light";
capitalize: boolean;
}>(), }>(),
{ variant: "light" } { variant: "light", capitalize: false }
); );
const typeClasses = computed(() => { const typeClasses = computed(() => {
@ -23,6 +24,8 @@ const typeClasses = computed(() => {
return "bg-purple-3 dark:text-violet-3"; return "bg-purple-3 dark:text-violet-3";
case "info": case "info":
return "bg-mbz-info dark:text-white"; return "bg-mbz-info dark:text-white";
case "warning":
return "bg-yellow-1";
case "danger": case "danger":
return "bg-mbz-danger dark:text-white"; return "bg-mbz-danger dark:text-white";
} }

View File

@ -1,6 +1,6 @@
<template> <template>
<div v-if="attached && closable" class="tags has-addons"> <div v-if="attached && closable" class="tags has-addons">
<span class="tag" :class="[type, size, { 'is-rounded': rounded }]"> <span class="tag" :class="[variant, size, { rounded }]">
<!-- <o-icon <!-- <o-icon
v-if="icon" v-if="icon"
:icon="icon" :icon="icon"
@ -15,7 +15,7 @@
class="tag" class="tag"
role="button" role="button"
:aria-label="ariaCloseLabel" :aria-label="ariaCloseLabel"
:tabindex="tabstop ? 0 : false" :tabindex="tabstop ? 0 : undefined"
:disabled="disabled" :disabled="disabled"
:class="[ :class="[
size, size,
@ -35,7 +35,7 @@
/> --> /> -->
</a> </a>
</div> </div>
<span v-else class="tag" :class="[type, size, { 'is-rounded': rounded }]"> <span v-else class="tag" :class="[variant, size, { 'is-rounded': rounded }]">
<!-- <o-icon <!-- <o-icon
v-if="icon" v-if="icon"
:icon="icon" :icon="icon"
@ -53,7 +53,7 @@
class="delete is-small" class="delete is-small"
:class="closeType" :class="closeType"
:disabled="disabled" :disabled="disabled"
:tabindex="tabstop ? 0 : false" :tabindex="tabstop ? 0 : undefined"
@click="close" @click="close"
@keyup.delete.prevent="close" @keyup.delete.prevent="close"
/> />
@ -64,7 +64,7 @@ const props = withDefaults(
defineProps<{ defineProps<{
attached?: boolean; attached?: boolean;
closable?: boolean; closable?: boolean;
type?: string; variant?: string;
size?: string; size?: string;
rounded?: boolean; rounded?: boolean;
disabled?: boolean; disabled?: boolean;

View File

@ -227,6 +227,8 @@ const icons: Record<string, () => Promise<any>> = {
import(`../../../node_modules/vue-material-design-icons/LanDisconnect.vue`), import(`../../../node_modules/vue-material-design-icons/LanDisconnect.vue`),
CloudQuestion: () => CloudQuestion: () =>
import(`../../../node_modules/vue-material-design-icons/CloudQuestion.vue`), import(`../../../node_modules/vue-material-design-icons/CloudQuestion.vue`),
Filter: () =>
import(`../../../node_modules/vue-material-design-icons/Filter.vue`),
}; };
const props = withDefaults( const props = withDefaults(

View File

@ -11,7 +11,8 @@ export const SEARCH_EVENTS_AND_GROUPS = gql`
$tags: String $tags: String
$term: String $term: String
$type: EventType $type: EventType
$category: String $categoryOneOf: [String]
$statusOneOf: [EventStatus]
$beginsOn: DateTime $beginsOn: DateTime
$endsOn: DateTime $endsOn: DateTime
$eventPage: Int $eventPage: Int
@ -24,7 +25,8 @@ export const SEARCH_EVENTS_AND_GROUPS = gql`
tags: $tags tags: $tags
term: $term term: $term
type: $type type: $type
category: $category categoryOneOf: $categoryOneOf
statusOneOf: $statusOneOf
beginsOn: $beginsOn beginsOn: $beginsOn
endsOn: $endsOn endsOn: $endsOn
page: $eventPage page: $eventPage
@ -156,9 +158,7 @@ export const SEARCH_GROUPS = gql`
query SearchGroups( query SearchGroups(
$location: String $location: String
$radius: Float $radius: Float
$tags: String
$term: String $term: String
$category: String
$groupPage: Int $groupPage: Int
$limit: Int $limit: Int
) { ) {
@ -188,8 +188,6 @@ export const SEARCH_GROUPS = gql`
} }
} }
} }
${EVENT_OPTIONS_FRAGMENT}
${TAG_FRAGMENT}
${ADDRESS_FRAGMENT} ${ADDRESS_FRAGMENT}
${ACTOR_FRAGMENT} ${ACTOR_FRAGMENT}
`; `;

View File

@ -1365,5 +1365,23 @@
"multitude of interconnected Mobilizon websites": "multitude of interconnected Mobilizon websites", "multitude of interconnected Mobilizon websites": "multitude of interconnected Mobilizon websites",
"Mobilizon is a tool that helps you {find_create_organize_events}.": "Mobilizon is a tool that helps you {find_create_organize_events}.", "Mobilizon is a tool that helps you {find_create_organize_events}.": "Mobilizon is a tool that helps you {find_create_organize_events}.",
"Ethical alternative to Facebook events, groups and pages, Mobilizon is a {tool_designed_to_serve_you}. Period.": "Ethical alternative to Facebook events, groups and pages, Mobilizon is a {tool_designed_to_serve_you}. Period.", "Ethical alternative to Facebook events, groups and pages, Mobilizon is a {tool_designed_to_serve_you}. Period.": "Ethical alternative to Facebook events, groups and pages, Mobilizon is a {tool_designed_to_serve_you}. Period.",
"Mobilizon is not a giant platform, but a {multitude_of_interconnected_mobilizon_websites}.": "Mobilizon is not a giant platform, but a {multitude_of_interconnected_mobilizon_websites}." "Mobilizon is not a giant platform, but a {multitude_of_interconnected_mobilizon_websites}.": "Mobilizon is not a giant platform, but a {multitude_of_interconnected_mobilizon_websites}.",
"translation": "",
"detail": "",
"Events close to you": "Events close to you",
"Popular groups close to you": "Popular groups close to you",
"View more events": "View more events",
"Hide filters": "Hide filters",
"Show filters": "Show filters",
"Online events": "Online events",
"Event date": "Event date",
"Distance": "Distance",
"{numberOfCategories} selected": "{numberOfCategories} selected",
"Event status": "Event status",
"Statuses": "Statuses",
"Languages": "Languages",
"{numberOfLanguages} selected": "{numberOfLanguages} selected",
"Apply filters": "Apply filters",
"Any distance": "Any distance",
"{number} kilometers": "{number} kilometers"
} }

View File

@ -1351,5 +1351,21 @@
"multitude of interconnected Mobilizon websites": "multitude de sites web Mobilizon interconnectés", "multitude of interconnected Mobilizon websites": "multitude de sites web Mobilizon interconnectés",
"Mobilizon is a tool that helps you {find_create_organize_events}.": "Mobilizon est un outil qui vous permet de {find_create_organize_events}.", "Mobilizon is a tool that helps you {find_create_organize_events}.": "Mobilizon est un outil qui vous permet de {find_create_organize_events}.",
"Ethical alternative to Facebook events, groups and pages, Mobilizon is a {tool_designed_to_serve_you}. Period.": "Alternative éthique aux événements, groupes et pages Facebook, Mobilizon est un {tool_designed_to_serve_you}. Point.", "Ethical alternative to Facebook events, groups and pages, Mobilizon is a {tool_designed_to_serve_you}. Period.": "Alternative éthique aux événements, groupes et pages Facebook, Mobilizon est un {tool_designed_to_serve_you}. Point.",
"Mobilizon is not a giant platform, but a {multitude_of_interconnected_mobilizon_websites}.": "Mobilizon nest pas une plateforme géante, mais une {multitude_of_interconnected_mobilizon_websites}." "Mobilizon is not a giant platform, but a {multitude_of_interconnected_mobilizon_websites}.": "Mobilizon nest pas une plateforme géante, mais une {multitude_of_interconnected_mobilizon_websites}.",
"Events close to you": "Événements proches de vous",
"Popular groups close to you": "Groupes populaires proches de vous",
"View more events": "Voir plus d'événements",
"Hide filters": "Masquer les filtres",
"Show filters": "Afficher les filtres",
"Online events": "Événements en ligne",
"Event date": "Date de l'événement",
"Distance": "Distance",
"{numberOfCategories} selected": "{numberOfCategories} sélectionnées",
"Event status": "Statut de l'événement",
"Statuses": "Statuts",
"Languages": "Langues",
"{numberOfLanguages} selected": "{numberOfLanguages} sélectionnées",
"Apply filters": "Appliquer les filtres",
"Any distance": "N'importe quelle distance",
"{number} kilometers": "{number} kilomètres"
} }

View File

@ -20,7 +20,7 @@ import { dateFnsPlugin } from "./plugins/dateFns";
import { dialogPlugin } from "./plugins/dialog"; import { dialogPlugin } from "./plugins/dialog";
import { snackbarPlugin } from "./plugins/snackbar"; import { snackbarPlugin } from "./plugins/snackbar";
import { notifierPlugin } from "./plugins/notifier"; import { notifierPlugin } from "./plugins/notifier";
import Tag from "./components/core/Tag.vue"; import Tag from "./components/core/CoreTag.vue";
import FloatingVue from "floating-vue"; import FloatingVue from "floating-vue";
import "floating-vue/dist/style.css"; import "floating-vue/dist/style.css";
import Oruga from "@oruga-ui/oruga-next"; import Oruga from "@oruga-ui/oruga-next";

View File

@ -77,4 +77,25 @@ export const orugaConfig = {
tableClass: "table", tableClass: "table",
tdClass: "table-td", tdClass: "table-td",
}, },
pagination: {
rootClass: "pagination",
simpleClass: "pagination-simple",
listClass: "pagination-list",
infoClass: "pagination-info",
linkClass: "pagination-link",
linkCurrentClass: "pagination-link-current",
linkDisabledClass: "pagination-link-disabled",
nextBtnClass: "pagination-next",
prevBtnClass: "pagination-previous",
ellipsisClass: "pagination-ellipsis",
},
tabs: {
rootClass: "tabs",
navTabsClass: "tabs-nav",
navTypeClass: "tabs-nav-",
navSizeClass: "tabs-nav-",
tabItemWrapperClass: "tabs-nav-item-wrapper",
itemHeaderTypeClass: "tabs-nav-item-",
itemHeaderActiveClass: "tabs-nav-item-active-",
},
}; };

View File

@ -1,8 +1,6 @@
/* eslint-disable no-console */
import { register } from "register-service-worker"; import { register } from "register-service-worker";
if ("serviceWorker" in navigator) { if ("serviceWorker" in navigator && import.meta.env.PROD) {
register(`${import.meta.env.BASE_URL}service-worker.js`, { register(`${import.meta.env.BASE_URL}service-worker.js`, {
ready() { ready() {
console.debug( console.debug(

View File

@ -130,6 +130,12 @@ export enum SearchTabs {
GROUPS = 1, GROUPS = 1,
} }
export enum ContentType {
ALL = "ALL",
EVENTS = "EVENTS",
GROUPS = "GROUPS",
}
export enum ActorType { export enum ActorType {
PERSON = "PERSON", PERSON = "PERSON",
APPLICATION = "APPLICATION", APPLICATION = "APPLICATION",

View File

@ -2,6 +2,11 @@ export function nl2br(text: string): string {
return text.replace(/(?:\r\n|\r|\n)/g, "<br>"); return text.replace(/(?:\r\n|\r|\n)/g, "<br>");
} }
export function flattenHTMLParagraphs(html: string): string { export function htmlToText(html: string) {
return html.replace("</p><p>", "<br />").replace(/<?\/p>/g, ""); const template = document.createElement("template");
const trimmedHTML = html.trim();
template.innerHTML = trimmedHTML;
const text = template.content.textContent;
template.remove();
return text;
} }

View File

@ -11,17 +11,10 @@ const localeInLocalStorage = getLocaleData();
export const AVAILABLE_LANGUAGES = Object.keys(langs); export const AVAILABLE_LANGUAGES = Object.keys(langs);
console.debug("localeInLocalStorage", localeInLocalStorage);
let language = let language =
localeInLocalStorage || localeInLocalStorage ||
(document.documentElement.getAttribute("lang") as string); (document.documentElement.getAttribute("lang") as string);
console.debug(
"localeInLocalStorage or fallback to lang html attribute",
language
);
language = language =
language || language ||
((window.navigator as any).userLanguage || window.navigator.language).replace( ((window.navigator as any).userLanguage || window.navigator.language).replace(
@ -29,15 +22,11 @@ language =
"_" "_"
); );
console.debug("language or fallback to window.navigator language", language);
export const locale = export const locale =
language && Object.prototype.hasOwnProperty.call(langs, language) language && Object.prototype.hasOwnProperty.call(langs, language)
? language ? language
: language.split("-")[0]; : language.split("-")[0];
console.debug("chosen locale", locale);
export const i18n = createI18n({ export const i18n = createI18n({
legacy: false, legacy: false,
locale: locale, // set locale locale: locale, // set locale
@ -52,12 +41,9 @@ export const i18n = createI18n({
globalInjection: true, globalInjection: true,
}); });
console.debug("set VueI18n with default locale", DEFAULT_LOCALE);
const loadedLanguages = [DEFAULT_LOCALE]; const loadedLanguages = [DEFAULT_LOCALE];
function setI18nLanguage(lang: string): string { function setI18nLanguage(lang: string): string {
console.debug("setting i18n locale to", lang);
i18n.global.locale = lang; i18n.global.locale = lang;
setLanguageInDOM(lang); setLanguageInDOM(lang);
return lang; return lang;
@ -74,7 +60,6 @@ function setLanguageInDOM(lang: string): void {
const direction = ["ar", "ae", "he", "fa", "ku", "ur"].includes(fixedLang) const direction = ["ar", "ae", "he", "fa", "ku", "ur"].includes(fixedLang)
? "rtl" ? "rtl"
: "ltr"; : "ltr";
console.debug("setDirection with", [fixedLang, direction]);
html.setAttribute("dir", direction); html.setAttribute("dir", direction);
} }
@ -96,17 +81,14 @@ function vueI18NfileForLanguage(lang: string) {
export async function loadLanguageAsync(lang: string): Promise<string> { export async function loadLanguageAsync(lang: string): Promise<string> {
// If the same language // If the same language
if (i18n.global.locale === lang) { if (i18n.global.locale === lang) {
console.debug("already using language", lang);
return Promise.resolve(setI18nLanguage(lang)); return Promise.resolve(setI18nLanguage(lang));
} }
// If the language was already loaded // If the language was already loaded
if (loadedLanguages.includes(lang)) { if (loadedLanguages.includes(lang)) {
console.debug("language already loaded", lang);
return Promise.resolve(setI18nLanguage(lang)); return Promise.resolve(setI18nLanguage(lang));
} }
// If the language hasn't been loaded yet // If the language hasn't been loaded yet
console.debug("loading language", lang);
const newMessages = await import( const newMessages = await import(
`../i18n/${vueI18NfileForLanguage(lang)}.json` `../i18n/${vueI18NfileForLanguage(lang)}.json`
); );
@ -115,9 +97,7 @@ export async function loadLanguageAsync(lang: string): Promise<string> {
return setI18nLanguage(lang); return setI18nLanguage(lang);
} }
console.debug("loading async locale", locale);
loadLanguageAsync(locale); loadLanguageAsync(locale);
console.debug("loaded async locale", locale);
export function formatList(list: string[]): string { export function formatList(list: string[]): string {
if (window.Intl && Intl.ListFormat) { if (window.Intl && Intl.ListFormat) {

View File

@ -0,0 +1,17 @@
const shortConjunctionFormatter = new Intl.ListFormat(undefined, {
style: "short",
type: "conjunction",
});
const shortDisjunctionFormatter = new Intl.ListFormat(undefined, {
style: "short",
type: "disjunction",
});
export const listShortConjunctionFormatter = (list: Array<string>): string => {
return shortConjunctionFormatter.format(list);
};
export const listShortDisjunctionFormatter = (list: Array<string>): string => {
return shortDisjunctionFormatter.format(list);
};

View File

@ -66,7 +66,7 @@
</span> </span>
</div> </div>
<p class="flex gap-1 items-center" dir="auto"> <p class="flex gap-1 items-center" dir="auto">
<tag v-if="eventCategory" class="category">{{ <tag v-if="eventCategory" class="category" capitalize>{{
eventCategory eventCategory
}}</tag> }}</tag>
<router-link <router-link
@ -77,22 +77,22 @@
<tag>{{ tag.title }}</tag> <tag>{{ tag.title }}</tag>
</router-link> </router-link>
</p> </p>
<b-tag variant="warning" size="is-medium" v-if="event?.draft" <tag variant="warning" size="is-medium" v-if="event?.draft"
>{{ t("Draft") }} >{{ t("Draft") }}
</b-tag> </tag>
<span <span
class="event-status" class="event-status"
v-if="event?.status !== EventStatus.CONFIRMED" v-if="event?.status !== EventStatus.CONFIRMED"
> >
<b-tag <tag
variant="warning" variant="warning"
v-if="event?.status === EventStatus.TENTATIVE" v-if="event?.status === EventStatus.TENTATIVE"
>{{ t("Event to be confirmed") }}</b-tag >{{ t("Event to be confirmed") }}</tag
> >
<b-tag <tag
variant="danger" variant="danger"
v-if="event?.status === EventStatus.CANCELLED" v-if="event?.status === EventStatus.CANCELLED"
>{{ t("Event cancelled") }}</b-tag >{{ t("Event cancelled") }}</tag
> >
</span> </span>
</div> </div>

View File

@ -79,15 +79,13 @@
<section <section
class="py-4" class="py-4"
v-if=" v-if="
showUpcoming && showUpcoming && monthlyFutureEvents && monthlyFutureEvents.size > 0
monthlyFutureEvents &&
monthlyFutureEvents.length > 0
" "
> >
<transition-group name="list" tag="p"> <transition-group name="list" tag="p">
<div <div
class="mb-5" class="mb-5"
v-for="month of monthlyFutureEvents()" v-for="month of monthlyFutureEvents"
:key="month[0]" :key="month[0]"
> >
<span class="upcoming-month">{{ month[0] }}</span> <span class="upcoming-month">{{ month[0] }}</span>
@ -336,7 +334,7 @@ const monthlyEvents = (
}, new Map()); }, new Map());
}; };
const monthlyFutureEvents = (): Map<string, Eventable[]> => { const monthlyFutureEvents = computed((): Map<string, Eventable[]> => {
let eventable = [] as Eventable[]; let eventable = [] as Eventable[];
if (showAttending.value) { if (showAttending.value) {
eventable = [...eventable, ...futureParticipations.value]; eventable = [...eventable, ...futureParticipations.value];
@ -345,7 +343,7 @@ const monthlyFutureEvents = (): Map<string, Eventable[]> => {
eventable = [...eventable, ...groupEvents.value]; eventable = [...eventable, ...groupEvents.value];
} }
return monthlyEvents(eventable); return monthlyEvents(eventable);
}; });
const monthlyPastParticipations = computed((): Map<string, Eventable[]> => { const monthlyPastParticipations = computed((): Map<string, Eventable[]> => {
return monthlyEvents(pastParticipations.value.elements, true); return monthlyEvents(pastParticipations.value.elements, true);

View File

@ -1,6 +1,6 @@
<template> <template>
<div>
<!-- <o-loading v-model:active="$apollo.loading" /> --> <!-- <o-loading v-model:active="$apollo.loading" /> -->
<!-- Nice looking SVGs -->
<section class="mt-5 sm:mt-24"> <section class="mt-5 sm:mt-24">
<div class="-z-10 overflow-hidden"> <div class="-z-10 overflow-hidden">
<img <img
@ -23,109 +23,28 @@
/> />
</div> </div>
</section> </section>
<!-- Unlogged introduction -->
<unlogged-introduction :config="config" v-if="config && !isLoggedIn" /> <unlogged-introduction :config="config" v-if="config && !isLoggedIn" />
<!-- Search fields -->
<search-fields v-model:search="search" v-model:location="location" /> <search-fields v-model:search="search" v-model:location="location" />
<!-- Categories preview -->
<categories-preview /> <categories-preview />
<div <!-- Welcome back -->
id="recent_events" <section v-if="currentActor?.id && (welcomeBack || newRegisteredUser)">
class="container mx-auto section"
v-if="config && (!currentUser || !currentActor)"
>
<section class="events-recent px-2">
<h2 class="title">
{{ $t("Last published events") }}
</h2>
<p>
<i18n-t
tag="span"
keypath="On {instance} and other federated instances"
>
<template #instance>
<b>{{ config.name }}</b>
</template>
</i18n-t>
</p>
<div v-if="events.total > 0">
<multi-card :events="events.elements.slice(0, 6)" />
<span
class="block mt-2 text-right underline text-slate-700 dark:text-slate-300"
>
<router-link
:to="{ name: RouteName.SEARCH }"
class="hover:text-slate-800 hover:dark:text-slate-400"
>{{ $t("View everything") }} >></router-link
>
</span>
</div>
<o-notification v-else variant="danger">{{
$t("No events found")
}}</o-notification>
</section>
</div>
<div
id="picture"
v-if="config && (!currentUser || !currentUser.isLoggedIn)"
>
<div class="container mx-auto">
<close-events @doGeoLoc="performGeoLocation()" />
</div>
<div class="picture-container">
<picture>
<source
media="(max-width: 799px)"
:srcset="`/img/pics/homepage-480w.webp`"
type="image/webp"
/>
<source
media="(max-width: 1024px)"
:srcset="`/img/pics/homepage-1024w.webp`"
type="image/webp"
/>
<source
media="(max-width: 1920px)"
:srcset="`/img/pics/homepage-1920w.webp`"
type="image/webp"
/>
<source
media="(min-width: 1921px)"
:srcset="`/img/pics/homepage.webp`"
type="image/webp"
/>
<img
:src="`/img/pics/homepage-1024w.webp`"
width="3840"
height="2719"
alt=""
loading="lazy"
/>
</picture>
</div>
<presentation />
</div>
<div class="container mx-auto" v-if="config && loggedUserSettings">
<section
v-if="
currentActor && currentActor.id && (welcomeBack || newRegisteredUser)
"
>
<o-notification variant="info" v-if="welcomeBack">{{ <o-notification variant="info" v-if="welcomeBack">{{
$t("Welcome back {username}!", { $t("Welcome back {username}!", {
username: currentActor.displayName(), username: displayName(currentActor),
}) })
}}</o-notification> }}</o-notification>
<o-notification variant="info" v-if="newRegisteredUser">{{ <o-notification variant="info" v-if="newRegisteredUser">{{
$t("Welcome to Mobilizon, {username}!", { $t("Welcome to Mobilizon, {username}!", {
username: currentActor.displayName(), username: displayName(currentActor),
}) })
}}</o-notification> }}</o-notification>
</section> </section>
<!-- Your upcoming events --> <!-- Your upcoming events -->
<section v-if="canShowMyUpcomingEvents"> <section v-if="canShowMyUpcomingEvents" class="container mx-auto">
<h2 class="dark:text-white text-2xl font-bold"> <h2 class="dark:text-white font-bold">
{{ $t("Your upcoming events") }} {{ $t("Your upcoming events") }}
</h2> </h2>
<div <div
@ -133,10 +52,7 @@
class="text-slate-700 dark:text-slate-300" class="text-slate-700 dark:text-slate-300"
:key="row[0]" :key="row[0]"
> >
<p <p class="date-component-container" v-if="isInLessThanSevenDays(row[0])">
class="date-component-container"
v-if="isInLessThanSevenDays(row[0])"
>
<span v-if="isToday(row[0])">{{ <span v-if="isToday(row[0])">{{
$t( $t(
"You have one event today.", "You have one event today.",
@ -186,11 +102,6 @@
> >
</span> </span>
</section> </section>
<hr
role="presentation"
class="home-separator"
v-if="canShowMyUpcomingEvents && canShowFollowedGroupEvents"
/>
<!-- Events from your followed groups --> <!-- Events from your followed groups -->
<section class="followActivity" v-if="canShowFollowedGroupEvents"> <section class="followActivity" v-if="canShowFollowedGroupEvents">
<h2 class="title"> <h2 class="title">
@ -216,11 +127,45 @@
> >
</span> </span>
</section> </section>
<!-- Recent events -->
<CloseEvents @doGeoLoc="performGeoLocation()" :userLocation="userLocation" />
<CloseGroups :userLocation="userLocation" @doGeoLoc="performGeoLocation()" />
<LastEvents v-if="config" :instanceName="config.name" />
<!-- Unlogged content section -->
<picture v-if="!currentUser?.isLoggedIn">
<source
media="(max-width: 799px)"
:srcset="`/img/pics/homepage-480w.webp`"
type="image/webp"
/>
<CloseEvents @doGeoLoc="performGeoLocation()" /> <source
<LastEvents :instanceName="config.name" class="mb-10" /> media="(max-width: 1024px)"
</div> :srcset="`/img/pics/homepage-1024w.webp`"
</div> type="image/webp"
/>
<source
media="(max-width: 1920px)"
:srcset="`/img/pics/homepage-1920w.webp`"
type="image/webp"
/>
<source
media="(min-width: 1921px)"
:srcset="`/img/pics/homepage.webp`"
type="image/webp"
/>
<img
:src="`/img/pics/homepage-1024w.webp`"
width="3840"
height="2719"
alt=""
loading="lazy"
/>
</picture>
<presentation v-if="!currentUser?.isLoggedIn" />
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -231,8 +176,7 @@ import { FETCH_EVENTS } from "../graphql/event";
import EventParticipationCard from "../components/Event/EventParticipationCard.vue"; import EventParticipationCard from "../components/Event/EventParticipationCard.vue";
import MultiCard from "../components/Event/MultiCard.vue"; import MultiCard from "../components/Event/MultiCard.vue";
import { CURRENT_ACTOR_CLIENT } from "../graphql/actor"; import { CURRENT_ACTOR_CLIENT } from "../graphql/actor";
import { IPerson, Person } from "../types/actor"; import { IPerson, displayName } from "../types/actor";
import ngeohash from "ngeohash";
import { import {
ICurrentUser, ICurrentUser,
IUser, IUser,
@ -246,6 +190,7 @@ import { CONFIG } from "../graphql/config";
import { IConfig } from "../types/config.model"; import { IConfig } from "../types/config.model";
// import { IFollowedGroupEvent } from "../types/followedGroupEvent.model"; // import { IFollowedGroupEvent } from "../types/followedGroupEvent.model";
import CloseEvents from "@/components/Local/CloseEvents.vue"; import CloseEvents from "@/components/Local/CloseEvents.vue";
import CloseGroups from "@/components/Local/CloseGroups.vue";
import LastEvents from "@/components/Local/LastEvents.vue"; import LastEvents from "@/components/Local/LastEvents.vue";
import { computed, onMounted, reactive, watch } from "vue"; import { computed, onMounted, reactive, watch } from "vue";
import { useMutation, useQuery } from "@vue/apollo-composable"; import { useMutation, useQuery } from "@vue/apollo-composable";
@ -262,7 +207,7 @@ import CategoriesPreview from "@/components/Home/CategoriesPreview.vue";
import UnloggedIntroduction from "@/components/Home/UnloggedIntroduction.vue"; import UnloggedIntroduction from "@/components/Home/UnloggedIntroduction.vue";
import SearchFields from "@/components/Home/SearchFields.vue"; import SearchFields from "@/components/Home/SearchFields.vue";
import { useHead } from "@vueuse/head"; import { useHead } from "@vueuse/head";
import InformationOutline from "vue-material-design-icons/InformationOutline.vue"; import { geoHashToCoords } from "@/utils/location";
const { result: resultEvents } = useQuery<{ events: Paginate<IEvent> }>( const { result: resultEvents } = useQuery<{ events: Paginate<IEvent> }>(
FETCH_EVENTS, FETCH_EVENTS,
@ -278,8 +223,8 @@ const events = computed(
const { result: currentActorResult } = useQuery<{ currentActor: IPerson }>( const { result: currentActorResult } = useQuery<{ currentActor: IPerson }>(
CURRENT_ACTOR_CLIENT CURRENT_ACTOR_CLIENT
); );
const currentActor = computed<Person | undefined>( const currentActor = computed<IPerson | undefined>(
() => new Person(currentActorResult.value?.currentActor) () => currentActorResult.value?.currentActor
); );
const { result: currentUserResult } = useQuery<{ const { result: currentUserResult } = useQuery<{
@ -305,112 +250,6 @@ const followedGroupEvents = computed(
() => userResult.value?.loggedUser?.followedGroupEvents () => userResult.value?.loggedUser?.followedGroupEvents
); );
const userSettingsLocation = computed(
() => loggedUser.value?.settings?.location
);
const { result: currentUserLocationResult } = useQuery<{
currentUserLocation: LocationType;
}>(CURRENT_USER_LOCATION_CLIENT);
const currentUserLocation = computed(() => {
return currentUserLocationResult.value?.currentUserLocation;
});
const { mutate: saveCurrentUserLocation } = useMutation<any, LocationType>(
UPDATE_CURRENT_USER_LOCATION_CLIENT
);
const reverseGeoCodeInformation = reactive<{
latitude: number | undefined;
longitude: number | undefined;
accuracy: number | undefined;
}>({
latitude: undefined,
longitude: undefined,
accuracy: undefined,
});
const { onResult: onReverseGeocodeResult } = useQuery<{
reverseGeocode: IAddress[];
}>(REVERSE_GEOCODE, reverseGeoCodeInformation, () => ({
enabled: reverseGeoCodeInformation.latitude !== undefined,
}));
onReverseGeocodeResult((result) => {
if (!result?.data) return;
const geoLocationInformation = result?.data?.reverseGeocode[0];
console.debug("geoLocationInformation", result.data);
const placeName =
geoLocationInformation.locality ??
geoLocationInformation.region ??
geoLocationInformation.country;
console.log("place name", placeName);
saveCurrentUserLocation({
lat: reverseGeoCodeInformation.latitude,
lon: reverseGeoCodeInformation.longitude,
accuracy: Math.round(reverseGeoCodeInformation.accuracy ?? 12) / 1000,
isIPLocation: false,
name: placeName,
picture: geoLocationInformation.pictureInfo,
});
});
const fetchAndSaveCurrentLocationName = async ({
coords: { latitude, longitude, accuracy },
}: GeolocationPosition) => {
console.debug(
"data found from navigator geocoding",
latitude,
longitude,
accuracy
);
reverseGeoCodeInformation.latitude = latitude;
reverseGeoCodeInformation.longitude = longitude;
reverseGeoCodeInformation.accuracy = accuracy;
};
const performGeoLocation = () => {
navigator.geolocation.getCurrentPosition(fetchAndSaveCurrentLocationName);
};
const GEOHASH_DEPTH = 9; // put enough accuracy, radius will be used anyway
const geohash = computed(
() => userSettingsLocation.value?.geohash ?? currentUserLocationGeoHash
);
const currentUserLocationGeoHash = computed(() => {
if (currentUserLocation.value?.lat && currentUserLocation.value?.lon) {
return ngeohash.encode(
currentUserLocation.value?.lat,
currentUserLocation.value?.lon,
GEOHASH_DEPTH
);
}
return undefined;
});
const radius = computed(
() => userSettingsLocation.value?.range ?? currentUserLocation.value?.accuracy
);
const locationName = computed(
() => userSettingsLocation.value?.name ?? currentUserLocation.value?.name
);
// const { result: closeEventsResult } = useQuery<{
// searchEvents: Paginate<IEvent>;
// }>(
// CLOSE_CONTENT,
// { location: geohash, radius },
// { enabled: geohash.value !== undefined }
// );
// const closeEvents = computed(
// () => closeEventsResult.value?.searchEvents || { total: 0, elements: [] }
// );
const currentUserParticipations = computed( const currentUserParticipations = computed(
() => loggedUser.value?.participations.elements () => loggedUser.value?.participations.elements
); );
@ -499,16 +338,6 @@ const goingToEvents = computed<Map<string, Map<string, IParticipant>>>(() => {
); );
}); });
// const eventDeleted = (eventid: string): void => {
// currentUserParticipations = currentUserParticipations?.filter(
// (participation) => participation.event.id !== eventid
// );
// }
// viewEvent(event: IEvent): void {
// this.$router.push({ name: RouteName.EVENT, params: { uuid: event.uuid } });
// }
const loggedUserSettings = computed<IUserSettings | undefined>(() => { const loggedUserSettings = computed<IUserSettings | undefined>(() => {
return loggedUser.value?.settings; return loggedUser.value?.settings;
}); });
@ -517,14 +346,6 @@ const canShowMyUpcomingEvents = computed<boolean>(() => {
return currentActor.value?.id != undefined && goingToEvents.value.size > 0; return currentActor.value?.id != undefined && goingToEvents.value.size > 0;
}); });
// const canShowCloseEvents = computed<boolean>(() => {
// return (
// loggedUser.value?.settings?.location != undefined &&
// closeEvents.value != undefined &&
// closeEvents.value?.total > 0
// );
// });
const canShowFollowedGroupEvents = computed<boolean>(() => { const canShowFollowedGroupEvents = computed<boolean>(() => {
return filteredFollowedGroupsEvents.value.length > 0; return filteredFollowedGroupsEvents.value.length > 0;
}); });
@ -562,18 +383,134 @@ watch(loggedUser, (loggedUserValue) => {
}); });
} }
}); });
// metaInfo() {
// return {
// // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// // @ts-ignore
// title: this.instanceName,
// titleTemplate: "%s | Mobilizon",
// };
// },
const isLoggedIn = computed(() => loggedUser.value?.id !== undefined); const isLoggedIn = computed(() => loggedUser.value?.id !== undefined);
/**
* Geolocation stuff
*/
// The location hash saved in the user settings (should be the default)
const userSettingsLocationGeoHash = computed(
() => loggedUser.value?.settings?.location?.geohash
);
// The location provided by the server
const serverLocation = computed(() => config.value?.location);
// The coords from the user location or the server provided location
const coords = computed(() => {
const userSettingsCoords = geoHashToCoords(
userSettingsLocationGeoHash.value ?? undefined
);
if (userSettingsCoords) {
return { ...userSettingsCoords, isIPLocation: false };
}
return { ...serverLocation.value, isIPLocation: true };
});
const { result: reverseGeocodeResult } = useQuery<{
reverseGeocode: IAddress[];
}>(REVERSE_GEOCODE, coords, () => ({
enabled: coords.value?.longitude != undefined,
}));
const userSettingsLocation = computed(() => {
const address = reverseGeocodeResult.value?.reverseGeocode[0];
const placeName = address?.locality ?? address?.region ?? address?.country;
return {
lat: coords.value?.latitude,
lon: coords.value?.longitude,
name: placeName,
picture: address?.pictureInfo,
isIPLocation: coords.value?.isIPLocation,
};
});
const { result: currentUserLocationResult } = useQuery<{
currentUserLocation: LocationType;
}>(CURRENT_USER_LOCATION_CLIENT);
// The user's location currently in the Apollo cache
const currentUserLocation = computed(() => {
return {
...(currentUserLocationResult.value?.currentUserLocation ?? {
lat: undefined,
lon: undefined,
accuracy: undefined,
isIPLocation: undefined,
name: undefined,
picture: undefined,
}),
isIPLocation: false,
};
});
const userLocation = computed(() => {
if (
!userSettingsLocation.value ||
(userSettingsLocation.value?.isIPLocation &&
currentUserLocation.value?.name)
) {
return currentUserLocation.value;
}
return userSettingsLocation.value;
});
const { mutate: saveCurrentUserLocation } = useMutation<any, LocationType>(
UPDATE_CURRENT_USER_LOCATION_CLIENT
);
const reverseGeoCodeInformation = reactive<{
latitude: number | undefined;
longitude: number | undefined;
accuracy: number | undefined;
}>({
latitude: undefined,
longitude: undefined,
accuracy: undefined,
});
const { onResult: onReverseGeocodeResult } = useQuery<{
reverseGeocode: IAddress[];
}>(REVERSE_GEOCODE, reverseGeoCodeInformation, () => ({
enabled: reverseGeoCodeInformation.latitude !== undefined,
}));
onReverseGeocodeResult((result) => {
if (!result?.data) return;
const geoLocationInformation = result?.data?.reverseGeocode[0];
const placeName =
geoLocationInformation.locality ??
geoLocationInformation.region ??
geoLocationInformation.country;
saveCurrentUserLocation({
lat: reverseGeoCodeInformation.latitude,
lon: reverseGeoCodeInformation.longitude,
accuracy: Math.round(reverseGeoCodeInformation.accuracy ?? 12) / 1000,
isIPLocation: false,
name: placeName,
picture: geoLocationInformation.pictureInfo,
});
});
const fetchAndSaveCurrentLocationName = async ({
coords: { latitude, longitude, accuracy },
}: GeolocationPosition) => {
reverseGeoCodeInformation.latitude = latitude;
reverseGeoCodeInformation.longitude = longitude;
reverseGeoCodeInformation.accuracy = accuracy;
};
const performGeoLocation = () => {
navigator.geolocation.getCurrentPosition(fetchAndSaveCurrentLocationName);
};
/**
* View Head
*/
useHead({ useHead({
title: computed(() => instanceName.value ?? ""), title: computed(() => instanceName.value ?? ""),
}); });

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,6 @@ module.exports = {
"mbz-yellow": "#ffd599", "mbz-yellow": "#ffd599",
"mbz-purple": "#424056", "mbz-purple": "#424056",
"mbz-bluegreen": "#1e7d97", "mbz-bluegreen": "#1e7d97",
"mbz-yellow-2": "rgb(var(--color-mbz-yellow-2) / <alpha-value>)",
"violet-1": "#3a384c", "violet-1": "#3a384c",
"violet-2": "#474467", "violet-2": "#474467",
"violet-3": "#3c376e", "violet-3": "#3c376e",

View File

@ -12,7 +12,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do
""" """
@spec get_config(any(), map(), Absinthe.Resolution.t()) :: {:ok, map()} @spec get_config(any(), map(), Absinthe.Resolution.t()) :: {:ok, map()}
def get_config(_parent, _params, %{context: %{ip: ip}}) do def get_config(_parent, _params, %{context: %{ip: ip}}) do
# ip = "2a01:e0a:184:2000:1112:e19d:9779:88c8" ip = "2a01:e0a:184:2000:1112:e19d:9779:88c8"
geolix = Geolix.lookup(ip) geolix = Geolix.lookup(ip)
country_code = country_code =

View File

@ -95,6 +95,14 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do
arg(:type, :event_type, description: "Whether the event is online or in person") arg(:type, :event_type, description: "Whether the event is online or in person")
arg(:category, :string, description: "The category for the event") arg(:category, :string, description: "The category for the event")
arg(:category_one_of, list_of(:string),
description: "The list of categories the event can be in"
)
arg(:status_one_of, list_of(:event_status),
description: "The list of statuses this event can have"
)
arg(:radius, :float, arg(:radius, :float,
default_value: 50, default_value: 50,
description: "Radius around the location to search in" description: "Radius around the location to search in"

View File

@ -530,6 +530,8 @@ defmodule Mobilizon.Events do
|> events_for_begins_on(args) |> events_for_begins_on(args)
|> events_for_ends_on(args) |> events_for_ends_on(args)
|> events_for_category(args) |> events_for_category(args)
|> events_for_categories(args)
|> events_for_statuses(args)
|> events_for_tags(args) |> events_for_tags(args)
|> events_for_location(args) |> events_for_location(args)
|> filter_online(args) |> filter_online(args)
@ -1320,6 +1322,19 @@ defmodule Mobilizon.Events do
defp events_for_category(query, _args), do: query defp events_for_category(query, _args), do: query
@spec events_for_categories(Ecto.Queryable.t(), map()) :: Ecto.Query.t()
defp events_for_categories(query, %{category_one_of: category_one_of}) when length(category_one_of) > 0 do
where(query, [q], q.category in ^category_one_of)
end
defp events_for_categories(query, _args), do: query
defp events_for_statuses(query, %{status_one_of: status_one_of}) when length(status_one_of) > 0 do
where(query, [q], q.status in ^status_one_of)
end
defp events_for_statuses(query, _args), do: query
@spec events_for_tags(Ecto.Queryable.t(), map()) :: Ecto.Query.t() @spec events_for_tags(Ecto.Queryable.t(), map()) :: Ecto.Query.t()
defp events_for_tags(query, %{tags: tags}) when is_valid_string(tags) do defp events_for_tags(query, %{tags: tags}) when is_valid_string(tags) do
query query