Frontend basic tour

Git repo that reference the code tour : https://framagit.org/framasoft/mobilizon. On branch or commit "09cc5d1bd1ecd24e1e879b0392cf5830b2581c74".

Html entry point : public/index.html

This public/index.html is the first file that is loaded by the browser. It loads the bundled VueJs application as a script. The rendering of the application is then delegated to javascript.

The VueJs app is "injected" inside the <div is="app"></div> element.

The bundled javascript script will be inserted in the index.html file in place of the comment : <!-- built files will be auto injected -->

Browse code : js/public/index.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" href="<%= BASE_URL %>favicon.ico" />
<meta name="server-injected-data" />
</head>

<body>
<noscript>
<strong
>
We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work
properly without JavaScript enabled. Please enable it to
continue.</strong
>

</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

The main.ts VueJs entry point

This main.ts is the root Typescript file of the VueJs application.

It initializes the root component. It is automatically injected at the end of the "public/index.html" file after being bundled.

It will load the various required dependencies such as :

Browse code : js/src/main.ts

import Vue from "vue";
import Buefy from "buefy";
import Component from "vue-class-component";
import VueScrollTo from "vue-scrollto";
import VueMeta from "vue-meta";
import VTooltip from "v-tooltip";
import App from "./App.vue";
import router from "./router";
import { NotifierPlugin } from "./plugins/notifier";
import filters from "./filters";
import { i18n } from "./utils/i18n";
import apolloProvider from "./vue-apollo";
import "./registerServiceWorker";

Vue.config.productionTip = false;

Vue.use(Buefy);
Vue.use(NotifierPlugin);
Vue.use(filters);
Vue.use(VueMeta);
Vue.use(VueScrollTo);
Vue.use(VTooltip);

// Register the router hooks with their names
Component.registerHooks([
"beforeRouteEnter",
"beforeRouteLeave",
"beforeRouteUpdate", // for vue-router 2.2+
]);

/* eslint-disable no-new */
new Vue({
router,
apolloProvider,
el: "#app",
template: "<App/>",
components: { App },
render: (h) => h(App),
i18n,
});

App.vue entry point and <main>

The App.vue is the entry point single file component (root component) of the Single Page Application (SPA).

The <main> component that is resolved by the router. The component <router-view/> is replaced depending on the view component resolved by the router.

The component to replace <router-view/> depends on the rules found in the router/index.ts file.

Go further

Browse code : js/src/App.vue

<template>
<div id="mobilizon">
<NavBar />
<div v-if="config && config.demoMode">
<b-message
class="container"
type="is-danger"
:title="$t('Warning').toLocaleUpperCase()"
closable
aria-close-label="Close"
>

<p>
{{ $t("This is a demonstration site to test Mobilizon.") }}
<b>{{ $t("Please do not use it in any real way.") }}</b>
{{
$t(
"This website isn't moderated and the data that you enter will be automatically destroyed every day at 00:01 (Paris timezone)."
)
}}
</p>
</b-message>
</div>
<error v-if="error" :error="error" />

<main v-else>
<transition name="fade" mode="out-in">
<router-view />
</transition>
</main>
<mobilizon-footer />
</div>
</template>

<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import NavBar from "./components/NavBar.vue";
import {
AUTH_ACCESS_TOKEN,
AUTH_USER_EMAIL,
AUTH_USER_ID,
AUTH_USER_ROLE,
} from "./constants";
import {
CURRENT_USER_CLIENT,
UPDATE_CURRENT_USER_CLIENT,
} from "./graphql/user";
import Footer from "./components/Footer.vue";
import Logo from "./components/Logo.vue";
import { initializeCurrentActor } from "./utils/auth";
import { CONFIG } from "./graphql/config";
import { IConfig } from "./types/config.model";
import { ICurrentUser } from "./types/current-user.model";
import jwt_decode, { JwtPayload } from "jwt-decode";
import { refreshAccessToken } from "./apollo/utils";

@Component({
apollo: {
currentUser: CURRENT_USER_CLIENT,
config: CONFIG,
},
components: {
Logo,
NavBar,
error: () =>
import(/* webpackChunkName: "editor" */ "./components/Error.vue"),
"mobilizon-footer": Footer,
},
metaInfo() {
return {
titleTemplate: "%s | Mobilizon",
};
},
})
export default class App extends Vue {
config!: IConfig;

currentUser!: ICurrentUser;

error: Error | null = null;

online = true;

interval: number | undefined = undefined;

async created(): Promise<void> {
if (await this.initializeCurrentUser()) {
await initializeCurrentActor(this.$apollo.provider.defaultClient);
}
}

errorCaptured(error: Error): void {
this.error = error;
}

private async initializeCurrentUser() {
const userId = localStorage.getItem(AUTH_USER_ID);
const userEmail = localStorage.getItem(AUTH_USER_EMAIL);
const accessToken = localStorage.getItem(AUTH_ACCESS_TOKEN);
const role = localStorage.getItem(AUTH_USER_ROLE);

if (userId && userEmail && accessToken && role) {
return this.$apollo.mutate({
mutation: UPDATE_CURRENT_USER_CLIENT,
variables: {
id: userId,
email: userEmail,
isLoggedIn: true,
role,
},
});
}
return false;
}

mounted(): void {
this.online = window.navigator.onLine;
window.addEventListener("offline", () => {
this.online = false;
this.showOfflineNetworkWarning();
console.debug("offline");
});
window.addEventListener("online", () => {
this.online = true;
console.debug("online");
});
document.addEventListener("refreshApp", (event: Event) => {
this.$buefy.snackbar.open({
queue: false,
indefinite: true,
type: "is-secondary",
actionText: this.$t("Update app") as string,
cancelText: this.$t("Ignore") as string,
message: this.$t("A new version is available.") as string,
onAction: async () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const detail = event.detail;
const registration = detail as ServiceWorkerRegistration;
try {
await this.refreshApp(registration);
window.location.reload();
} catch (err) {
console.error(err);
this.$notifier.error(
this.$t(
"An error has occured while refreshing the page."
) as string
);
}
},
});
});

this.interval = setInterval(async () => {
const accessToken = localStorage.getItem(AUTH_ACCESS_TOKEN);
if (accessToken) {
const token = jwt_decode<JwtPayload>(accessToken);
if (
token?.exp !== undefined &&
new Date(token.exp * 1000 - 60000) < new Date()
) {
refreshAccessToken(this.$apollo.getClient());
}
}
}, 60000);
}

private async refreshApp(
registration: ServiceWorkerRegistration
): Promise<any> {
const worker = registration.waiting;
if (!worker) {
return Promise.resolve();
}
console.debug("Doing worker.skipWaiting().");
return new Promise((resolve, reject) => {
const channel = new MessageChannel();

channel.port1.onmessage = (event) => {
console.debug("Done worker.skipWaiting().");
if (event.data.error) {
reject(event.data);
} else {
resolve(event.data);
}
};
console.debug("calling skip waiting");
worker?.postMessage({ type: "skip-waiting" }, [channel.port2]);
});
}

showOfflineNetworkWarning(): void {
this.$notifier.error(this.$t("You are offline") as string);
}

unmounted(): void {
clearInterval(this.interval);
this.interval = undefined;
}
}
</script>

<style lang="scss">
@import "variables";

/* Icons */
$mdi-font-path: "~@mdi/font/fonts";
@import "~@mdi/font/scss/materialdesignicons";

@import "common";

#mobilizon {
min-height: 100vh;
display: flex;
flex-direction: column;

main {
flex-grow: 1;
}
}
</style>

Basic URL routing

If the URL typed in the browser is / (ex. https://mobilizon.fr/), the router resolves to the Home component. It means that the <main></main> element of the App.vue entry point will be replaced with the views/Home.vue component. Since it does not require authentication, even if no user is authenticated, the Home view will be rendered.

Go further

Browse code : js/src/router/index.ts

import Vue from "vue";
import Router, { Route } from "vue-router";
import VueScrollTo from "vue-scrollto";
import { PositionResult } from "vue-router/types/router.d";
import { ImportedComponent } from "vue/types/options";
import Home from "../views/Home.vue";
import { eventRoutes } from "./event";
import { actorRoutes } from "./actor";
import { errorRoutes } from "./error";
import { authGuardIfNeeded } from "./guards/auth-guard";
import { settingsRoutes } from "./settings";
import { groupsRoutes } from "./groups";
import { discussionRoutes } from "./discussion";
import { userRoutes } from "./user";
import RouteName from "./name";

Vue.use(Router);

function scrollBehavior(
to: Route,
from: Route,
savedPosition: any
): PositionResult | undefined | null {
if (to.hash) {
VueScrollTo.scrollTo(to.hash, 700);
return {
selector: to.hash,
offset: { x: 0, y: 10 },
};
}
if (savedPosition) {
return savedPosition;
}

return { x: 0, y: 0 };
}

export const routes = [
...userRoutes,
...eventRoutes,
...settingsRoutes,
...actorRoutes,
...groupsRoutes,
...discussionRoutes,
...errorRoutes,
{
path: "/search",
name: RouteName.SEARCH,
component: (): Promise<ImportedComponent> =>
import(/* webpackChunkName: "Search" */ "@/views/Search.vue"),
props: true,
meta: { requiredAuth: false },
},
{
path: "/",
name: RouteName.HOME,
component: Home,
meta: { requiredAuth: false },
},
{
path: "/about",
name: RouteName.ABOUT,
component: (): Promise<ImportedComponent> =>
import(/* webpackChunkName: "about" */ "@/views/About.vue"),
meta: { requiredAuth: false },
redirect: { name: RouteName.ABOUT_INSTANCE },
children: [
{
path: "instance",
name: RouteName.ABOUT_INSTANCE,
component: (): Promise<ImportedComponent> =>
import(
/* webpackChunkName: "about" */ "@/views/About/AboutInstance.vue"
),
},
{
path: "/terms",
name: RouteName.TERMS,
component: (): Promise<ImportedComponent> =>
import(/* webpackChunkName: "cookies" */ "@/views/About/Terms.vue"),
meta: { requiredAuth: false },
},
{
path: "/privacy",
name: RouteName.PRIVACY,
component: (): Promise<ImportedComponent> =>
import(/* webpackChunkName: "cookies" */ "@/views/About/Privacy.vue"),
meta: { requiredAuth: false },
},
{
path: "/rules",
name: RouteName.RULES,
component: (): Promise<ImportedComponent> =>
import(/* webpackChunkName: "cookies" */ "@/views/About/Rules.vue"),
meta: { requiredAuth: false },
},
{
path: "/glossary",
name: RouteName.GLOSSARY,
component: (): Promise<ImportedComponent> =>
import(
/* webpackChunkName: "cookies" */ "@/views/About/Glossary.vue"
),
meta: { requiredAuth: false },
},
],
},
{
path: "/interact",
name: RouteName.INTERACT,
component: (): Promise<ImportedComponent> =>
import(/* webpackChunkName: "interact" */ "@/views/Interact.vue"),
meta: { requiredAuth: false },
},
{
path: "/auth/:provider/callback",
name: "auth-callback",
component: (): Promise<ImportedComponent> =>
import(
/* webpackChunkName: "ProviderValidation" */ "@/views/User/ProviderValidation.vue"
),
},
{
path: "/welcome/:step?",
name: RouteName.WELCOME_SCREEN,
component: (): Promise<ImportedComponent> =>
import(
/* webpackChunkName: "WelcomeScreen" */ "@/views/User/SettingsOnboard.vue"
),
meta: { requiredAuth: true },
props: (route: Route): Record<string, unknown> => {
const step = Number.parseInt(route.params.step, 10);
if (Number.isNaN(step)) {
return { step: 1 };
}
return { step };
},
},
{
path: "/404",
name: RouteName.PAGE_NOT_FOUND,
component: (): Promise<ImportedComponent> =>
import(
/* webpackChunkName: "PageNotFound" */ "../views/PageNotFound.vue"
),
meta: { requiredAuth: false },
},
{
path: "*",
redirect: { name: RouteName.PAGE_NOT_FOUND },
},
];

const router = new Router({
scrollBehavior,
mode: "history",
base: "/",
routes,
});

router.beforeEach(authGuardIfNeeded);
router.afterEach(() => {
try {
if (router.app.$children[0]) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
router.app.$children[0].error = null;
}
} catch (e) {
console.error(e);
}
});

export default router;

Conditional rendering

The v-if directive tells if an element is rendered or not.

For instance, if the user is not registered of logged in, this section is shown. To know if the user is registered of logged in, config and currentUser variables are looked up.

The presence of currentUser variable is the result of a GraphQL query (see CURRENT_USER_CLIENT). The presence of config variable is the result of a GraphQL query (see CONFIG).

Go further

Browse code : js/src/views/Home.vue

<template>
<div id="homepage">
<section
class="hero"
:class="{ webp: supportsWebPFormat }"
v-if="config && (!currentUser.id || !currentActor.id)"
>

<div class="hero-body">
<div class="container">
<h1 class="title">
{{ config.slogan || $t("Gather â‹… Organize â‹… Mobilize") }}
</h1>
<p
v-html="
$t('Join <b>{instance}</b>, a Mobilizon instance', {
instance: config.name,
})
"

/>

<p class="instance-description">{{ config.description }}</p>
<!-- We don't invite to find other instances yet -->
<!-- <p v-if="!config.registrationsOpen">
{{ $t("This instance isn't opened to registrations, but you can register on other instances.") }}
</p>-->

<b-message type="is-danger" v-if="!config.registrationsOpen">{{
$t("Unfortunately, this instance isn't opened to registrations")
}}</b-message>
<div class="buttons">
<b-button
type="is-primary"
tag="router-link"
:to="{ name: RouteName.REGISTER }"
v-if="config.registrationsOpen"
>{{ $t("Create an account") }}</b-button
>

<!-- We don't invite to find other instances yet -->
<!-- <b-button v-else type="is-link" tag="a" href="https://joinmastodon.org">{{ $t('Find an instance') }}</b-button> -->
<b-button
type="is-text"
tag="router-link"
:to="{ name: RouteName.ABOUT }"
>

{{ $t("Learn more about {instance}", { instance: config.name }) }}
</b-button>
</div>
</div>
</div>
</section>
<div
id="recent_events"
class="container section"
v-if="config && (!currentUser.id || !currentActor.id)"
>

<section class="events-recent">
<h2 class="title">
{{ $t("Last published events") }}
</h2>
<p>
<i18n tag="span" path="On {instance} and other federated instances">
<b slot="instance">{{ config.name }}</b>
</i18n>
<b-loading :active.sync="$apollo.loading" />
</p>
<b-loading :active.sync="$apollo.loading" />
<div v-if="this.events.total > 0">
<div class="columns is-multiline">
<div
class="column is-one-third-desktop"
v-for="event in this.events.elements.slice(0, 6)"
:key="event.uuid"
>

<EventCard :event="event" />
</div>
</div>
<span class="view-all">
<router-link :to="{ name: RouteName.SEARCH }"
>{{ $t("View everything") }} >></router-link
>

</span>
</div>
<b-message v-else type="is-danger">{{
$t("No events found")
}}</b-message>
</section>
</div>
<div id="picture" v-if="config && (!currentUser.id || !currentActor.id)">
<div class="picture-container">
<picture>
<source
media="(max-width: 799px)"
srcset="/img/pics/homepage-480w.webp"
type="image/webp"
/>

<source
media="(max-width: 799px)"
srcset="/img/pics/homepage-480w.jpg"
type="image/jpeg"
/>


<source
media="(max-width: 1024px)"
srcset="/img/pics/homepage-1024w.webp"
type="image/webp"
/>

<source
media="(max-width: 1024px)"
srcset="/img/pics/homepage-1024w.jpg"
type="image/jpeg"
/>


<source
media="(max-width: 1920px)"
srcset="/img/pics/homepage-1920w.webp"
type="image/webp"
/>

<source
media="(max-width: 1920px)"
srcset="/img/pics/homepage-1920w.jpg"
type="image/jpeg"
/>


<source
media="(min-width: 1921px)"
srcset="/img/pics/homepage.webp"
type="image/webp"
/>

<source
media="(min-width: 1921px)"
srcset="/img/pics/homepage.jpg"
type="image/jpeg"
/>


<img
src="/img/pics/homepage-1024w.jpg"
width="3840"
height="2719"
alt=""
loading="lazy"
/>

</picture>
</div>
<div class="container section">
<div class="columns">
<div class="column">
<h3 class="title">{{ $t("A practical tool") }}</h3>
<p
v-html="
$t(
'Mobilizon is a tool that helps you <b>find, create and organise events</b>.'
)
"

/>

</div>
<div class="column">
<h3 class="title">{{ $t("An ethical alternative") }}</h3>
<p
v-html="
$t(
'Ethical alternative to Facebook events, groups and pages, Mobilizon is a <b>tool designed to serve you</b>. Period.'
)
"

/>

</div>
<div class="column">
<h3 class="title">{{ $t("A federated software") }}</h3>
<p
v-html="
$t(
'Mobilizon is not a giant platform, but a <b>multitude of interconnected Mobilizon websites</b>.'
)
"

/>

</div>
</div>
<div class="buttons">
<a
class="button is-primary is-large"
href="https://joinmobilizon.org"
>{{ $t("Learn more about Mobilizon") }}</a
>

</div>
</div>
</div>
<div
class="container section"
v-if="config && loggedUser && loggedUser.settings"
>

<section v-if="currentActor.id && (welcomeBack || newRegisteredUser)">
<b-message type="is-info" v-if="welcomeBack">{{
$t("Welcome back {username}!", {
username: currentActor.displayName(),
})
}}</b-message>
<b-message type="is-info" v-if="newRegisteredUser">{{
$t("Welcome to Mobilizon, {username}!", {
username: currentActor.displayName(),
})
}}</b-message>
</section>
<!-- Your upcoming events -->
<section v-if="canShowMyUpcomingEvents">
<h2 class="title">{{ $t("Your upcoming events") }}</h2>
<b-loading :active.sync="$apollo.loading" />
<div v-for="row of goingToEvents" class="upcoming-events" :key="row[0]">
<p
class="date-component-container"
v-if="isInLessThanSevenDays(row[0])"
>

<span v-if="isToday(row[0])">{{
$tc("You have one event today.", row[1].length, {
count: row[1].length,
})
}}</span>
<span v-else-if="isTomorrow(row[0])">{{
$tc("You have one event tomorrow.", row[1].length, {
count: row[1].length,
})
}}</span>
<span v-else-if="isInLessThanSevenDays(row[0])">
{{
$tc("You have one event in {days} days.", row[1].length, {
count: row[1].length,
days: calculateDiffDays(row[0]),
})
}}
</span>
</p>
<div>
<EventListCard
v-for="participation in thisWeek(row)"
@event-deleted="eventDeleted"
:key="participation[1].id"
:participation="participation[1]"
/>

</div>
</div>
<span class="view-all">
<router-link :to="{ name: RouteName.MY_EVENTS }"
>{{ $t("View everything") }} >></router-link
>

</span>
</section>
<hr
class="home-separator"
v-if="canShowMyUpcomingEvents && canShowLastWeekEvents"
/>

<!-- Last week events -->
<section v-if="canShowLastWeekEvents">
<h2 class="title">{{ $t("Last week") }}</h2>
<b-loading :active.sync="$apollo.loading" />
<div>
<EventListCard
v-for="participation in lastWeekEvents"
:key="participation.id"
:participation="participation"
@event-deleted="eventDeleted"
:options="{ hideDate: false }"
/>

</div>
</section>
<hr
class="home-separator"
v-if="canShowLastWeekEvents && canShowCloseEvents"
/>

<!-- Events close to you -->
<section class="events-close" v-if="canShowCloseEvents">
<h2 class="title">
{{ $t("Events nearby") }}
</h2>
<p>
{{
$tc(
"Within {number} kilometers of {place}",
loggedUser.settings.location.range,
{
number: loggedUser.settings.location.range,
place: loggedUser.settings.location.name,
}
)
}}
<router-link
:to="{ name: RouteName.PREFERENCES }"
:title="$t('Change')"
>

<b-icon class="clickable" icon="pencil" size="is-small" />
</router-link>
<b-loading :active.sync="$apollo.loading" />
</p>
<div class="columns is-multiline">
<div
class="column is-one-third-desktop"
v-for="event in closeEvents.elements.slice(0, 3)"
:key="event.uuid"
>

<event-card :event="event" />
</div>
</div>
</section>
<hr
class="home-separator"
v-if="
canShowMyUpcomingEvents || canShowLastWeekEvents || canShowCloseEvents
"

/>

<section class="events-recent">
<h2 class="title">
{{ $t("Last published events") }}
</h2>
<p>
<i18n tag="span" path="On {instance} and other federated instances">
<b slot="instance">{{ config.name }}</b>
</i18n>
<b-loading :active.sync="$apollo.loading" />
</p>

<div v-if="this.events.total > 0">
<div class="columns is-multiline">
<div
class="column is-one-third-desktop"
v-for="event in this.events.elements.slice(0, 6)"
:key="event.uuid"
>

<recent-event-card-wrapper :event="event" />
</div>
</div>
<span class="view-all">
<router-link :to="{ name: RouteName.SEARCH }"
>{{ $t("View everything") }} >></router-link
>

</span>
</div>
<b-message v-else type="is-danger"
>
{{ $t("No events found") }}<br />
<div v-if="goingToEvents.size > 0 || lastWeekEvents.length > 0">
<b-icon size="is-small" icon="information-outline" />
<small>{{
$t("The events you created are not shown here.")
}}</small>
</div>
</b-message>
</section>
</div>
</div>
</template>

<script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator";
import { EventSortField, ParticipantRole, SortDirection } from "@/types/enums";
import { Paginate } from "@/types/paginate";
import { supportsWebPFormat } from "@/utils/support";
import { IParticipant, Participant } from "../types/participant.model";
import { CLOSE_EVENTS, FETCH_EVENTS } from "../graphql/event";
import EventListCard from "../components/Event/EventListCard.vue";
import EventCard from "../components/Event/EventCard.vue";
import RecentEventCardWrapper from "../components/Event/RecentEventCardWrapper.vue";
import {
CURRENT_ACTOR_CLIENT,
LOGGED_USER_PARTICIPATIONS,
} from "../graphql/actor";
import { IPerson, Person } from "../types/actor";
import { ICurrentUser, IUser } from "../types/current-user.model";
import { CURRENT_USER_CLIENT, USER_SETTINGS } from "../graphql/user";
import RouteName from "../router/name";
import { IEvent } from "../types/event.model";
import DateComponent from "../components/Event/DateCalendarIcon.vue";
import { CONFIG } from "../graphql/config";
import { IConfig } from "../types/config.model";
import Subtitle from "../components/Utils/Subtitle.vue";

@Component({
apollo: {
events: {
query: FETCH_EVENTS,
fetchPolicy: "no-cache", // Debug me: https://github.com/apollographql/apollo-client/issues/3030
variables: {
orderBy: EventSortField.INSERTED_AT,
direction: SortDirection.DESC,
},
},
currentActor: {
query: CURRENT_ACTOR_CLIENT,
update: (data) => new Person(data.currentActor),
},
currentUser: CURRENT_USER_CLIENT,
loggedUser: {
query: USER_SETTINGS,
fetchPolicy: "network-only",
skip() {
return !this.currentUser || this.currentUser.isLoggedIn === false;
},
error() {
return null;
},
},
config: CONFIG,
currentUserParticipations: {
query: LOGGED_USER_PARTICIPATIONS,
fetchPolicy: "cache-and-network",
variables() {
const lastWeek = new Date();
lastWeek.setDate(new Date().getDate() - 7);
return {
afterDateTime: lastWeek.toISOString(),
};
},
update: (data) =>
data.loggedUser.participations.elements.map(
(participation: IParticipant) => new Participant(participation)
),
skip() {
return this.currentUser?.isLoggedIn === false;
},
},
closeEvents: {
query: CLOSE_EVENTS,
variables() {
return {
location: this.loggedUser?.settings?.location?.geohash,
radius: this.loggedUser?.settings?.location?.range,
};
},
update: (data) => data.searchEvents,
skip() {
return (
!this.currentUser?.isLoggedIn ||
!this.loggedUser?.settings?.location?.geohash ||
!this.loggedUser?.settings?.location?.range
);
},
},
},
components: {
Subtitle,
DateComponent,
EventListCard,
EventCard,
RecentEventCardWrapper,
"settings-onboard": () => import("./User/SettingsOnboard.vue"),
},
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
title: this.instanceName,
titleTemplate: "%s | Mobilizon",
};
},
})
export default class Home extends Vue {
events: Paginate<IEvent> = {
elements: [],
total: 0,
};

locations = [];

city = { name: null };

country = { name: null };

currentUser!: IUser;

loggedUser!: ICurrentUser;

currentActor!: IPerson;

config!: IConfig;

RouteName = RouteName;

currentUserParticipations: IParticipant[] = [];

supportsWebPFormat = supportsWebPFormat;

closeEvents: Paginate<IEvent> = { elements: [], total: 0 };

// get displayed_name() {
// return this.loggedPerson && this.loggedPerson.name === null
// ? this.loggedPerson.preferredUsername
// : this.loggedPerson.name;
// }

get instanceName(): string | undefined {
if (!this.config) return undefined;
return this.config.name;
}

// eslint-disable-next-line class-methods-use-this
get welcomeBack(): boolean {
return window.localStorage.getItem("welcome-back") === "yes";
}

// eslint-disable-next-line class-methods-use-this
get newRegisteredUser(): boolean {
return window.localStorage.getItem("new-registered-user") === "yes";
}

thisWeek(
row: [string, Map<string, IParticipant>]
): Map<string, IParticipant> {
if (this.isInLessThanSevenDays(row[0])) {
return row[1];
}
return new Map();
}

// eslint-disable-next-line class-methods-use-this
mounted(): void {
if (window.localStorage.getItem("welcome-back")) {
window.localStorage.removeItem("welcome-back");
}
if (window.localStorage.getItem("new-registered-user")) {
window.localStorage.removeItem("new-registered-user");
}
}

// eslint-disable-next-line class-methods-use-this
isToday(date: Date): boolean {
return new Date(date).toDateString() === new Date().toDateString();
}

isTomorrow(date: string): boolean {
return this.isInDays(date, 1);
}

isInDays(date: string, nbDays: number): boolean {
return this.calculateDiffDays(date) === nbDays;
}

isBefore(date: string, nbDays: number): boolean {
return this.calculateDiffDays(date) < nbDays;
}

isAfter(date: string, nbDays: number): boolean {
return this.calculateDiffDays(date) >= nbDays;
}

isInLessThanSevenDays(date: string): boolean {
return this.isBefore(date, 7);
}

// eslint-disable-next-line class-methods-use-this
calculateDiffDays(date: string): number {
return Math.ceil(
(new Date(date).getTime() - new Date().getTime()) / 1000 / 60 / 60 / 24
);
}

get thisWeekGoingToEvents(): IParticipant[] {
const res = this.currentUserParticipations.filter(
({ event, role }) =>
event.beginsOn != null &&
this.isAfter(event.beginsOn.toDateString(), 0) &&
this.isBefore(event.beginsOn.toDateString(), 7) &&
role !== ParticipantRole.REJECTED
);
res.sort(
(a: IParticipant, b: IParticipant) =>
a.event.beginsOn.getTime() - b.event.beginsOn.getTime()
);
return res;
}

get goingToEvents(): Map<string, Map<string, IParticipant>> {
return this.thisWeekGoingToEvents.reduce(
(
acc: Map<string, Map<string, IParticipant>>,
participation: IParticipant

) => {
const day = new Date(participation.event.beginsOn).toDateString();
const participations: Map<string, IParticipant> =
acc.get(day) || new Map();
participations.set(
`${participation.event.uuid}${participation.actor.id}`,
participation
);
acc.set(day, participations);
return acc;
},
new Map()
);
}

get lastWeekEvents(): IParticipant[] {
const res = this.currentUserParticipations.filter(
({ event, role }) =>
event.beginsOn != null &&
this.isBefore(event.beginsOn.toDateString(), 0) &&
role !== ParticipantRole.REJECTED
);
res.sort(
(a: IParticipant, b: IParticipant) =>
a.event.beginsOn.getTime() - b.event.beginsOn.getTime()
);
return res;
}

eventDeleted(eventid: string): void {
this.currentUserParticipations = this.currentUserParticipations.filter(
(participation) => participation.event.id !== eventid
);
}

viewEvent(event: IEvent): void {
this.$router.push({ name: RouteName.EVENT, params: { uuid: event.uuid } });
}

@Watch("loggedUser")
detectEmptyUserSettings(loggedUser: IUser): void {
if (loggedUser && loggedUser.id && loggedUser.settings === null) {
this.$router.push({
name: RouteName.WELCOME_SCREEN,
params: { step: "1" },
});
}
}

get canShowMyUpcomingEvents(): boolean {
return this.currentActor.id != undefined && this.goingToEvents.size > 0;
}

get canShowLastWeekEvents(): boolean {
return this.currentActor && this.lastWeekEvents.length > 0;
}

get canShowCloseEvents(): boolean {
return this.closeEvents.total > 0;
}
}
</script>

<style lang="scss" scoped>
@import "~bulma/sass/utilities/mixins.sass";

main > div > .container {
background: $white;
padding: 1rem 0.5rem 3rem;
}

.search-autocomplete {
border: 1px solid #dbdbdb;
color: rgba(0, 0, 0, 0.87);
}

.events-recent {
& > h3 {
padding-left: 0.75rem;
}

.columns {
margin: 1rem auto 0;
}
}

.date-component-container {
display: flex;
align-items: center;
margin: 0.5rem auto 1rem;

h3.subtitle {
margin-left: 7px;
}
}

span.view-all {
display: block;
margin-top: 1rem;
text-align: right;

a {
text-decoration: underline;
}
}

section.hero {
position: relative;
z-index: 1;

&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.3;
z-index: -1;
background: url("../../public/img/pics/homepage_background-1024w.png");
background-size: cover;
}
&.webp::before {
background-image: url("../../public/img/pics/homepage_background-1024w.webp");
}

& > .hero-body {
padding: 1rem 1.5rem 3rem;
}

.title {
color: $background-color;
}

.column figure.image img {
max-width: 400px;
}

.instance-description {
margin-bottom: 1rem;
}
}

#recent_events {
padding: 0;
min-height: 20vh;
z-index: 10;

.title {
margin: 20px auto 0;
}

.columns {
margin: 0 auto;
}
}

#picture {
.picture-container {
position: relative;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}

& > img {
object-fit: cover;
max-height: 80vh;
display: block;
margin: auto;
width: 100%;
}
}

.container.section {
background: $white;

@include tablet {
margin-top: -4rem;
}
z-index: 10;

.title {
margin: 0 0 10px;
font-size: 30px;
}

.buttons {
justify-content: center;
margin-top: 2rem;
}
}
}

#homepage {
background: $white;
}

.home-separator {
background-color: $orange-2;
}

.clickable {
cursor: pointer;
}

.title {
font-size: 27px;
&:not(:last-child) {
margin-bottom: 0.5rem;
}
}
</style>

GraphQL data fetching using apollo

This @Component annotation and the apollo attribute describes the GraphQL queries upon which the component relies on to be rendered.

Data such as the following may be fetched from GraphQL:

Browse code : js/src/views/Home.vue

<template>
<div id="homepage">
<section
class="hero"
:class="{ webp: supportsWebPFormat }"
v-if="config && (!currentUser.id || !currentActor.id)"
>

<div class="hero-body">
<div class="container">
<h1 class="title">
{{ config.slogan || $t("Gather â‹… Organize â‹… Mobilize") }}
</h1>
<p
v-html="
$t('Join <b>{instance}</b>, a Mobilizon instance', {
instance: config.name,
})
"

/>

<p class="instance-description">{{ config.description }}</p>
<!-- We don't invite to find other instances yet -->
<!-- <p v-if="!config.registrationsOpen">
{{ $t("This instance isn't opened to registrations, but you can register on other instances.") }}
</p>-->

<b-message type="is-danger" v-if="!config.registrationsOpen">{{
$t("Unfortunately, this instance isn't opened to registrations")
}}</b-message>
<div class="buttons">
<b-button
type="is-primary"
tag="router-link"
:to="{ name: RouteName.REGISTER }"
v-if="config.registrationsOpen"
>{{ $t("Create an account") }}</b-button
>

<!-- We don't invite to find other instances yet -->
<!-- <b-button v-else type="is-link" tag="a" href="https://joinmastodon.org">{{ $t('Find an instance') }}</b-button> -->
<b-button
type="is-text"
tag="router-link"
:to="{ name: RouteName.ABOUT }"
>

{{ $t("Learn more about {instance}", { instance: config.name }) }}
</b-button>
</div>
</div>
</div>
</section>
<div
id="recent_events"
class="container section"
v-if="config && (!currentUser.id || !currentActor.id)"
>

<section class="events-recent">
<h2 class="title">
{{ $t("Last published events") }}
</h2>
<p>
<i18n tag="span" path="On {instance} and other federated instances">
<b slot="instance">{{ config.name }}</b>
</i18n>
<b-loading :active.sync="$apollo.loading" />
</p>
<b-loading :active.sync="$apollo.loading" />
<div v-if="this.events.total > 0">
<div class="columns is-multiline">
<div
class="column is-one-third-desktop"
v-for="event in this.events.elements.slice(0, 6)"
:key="event.uuid"
>

<EventCard :event="event" />
</div>
</div>
<span class="view-all">
<router-link :to="{ name: RouteName.SEARCH }"
>{{ $t("View everything") }} >></router-link
>

</span>
</div>
<b-message v-else type="is-danger">{{
$t("No events found")
}}</b-message>
</section>
</div>
<div id="picture" v-if="config && (!currentUser.id || !currentActor.id)">
<div class="picture-container">
<picture>
<source
media="(max-width: 799px)"
srcset="/img/pics/homepage-480w.webp"
type="image/webp"
/>

<source
media="(max-width: 799px)"
srcset="/img/pics/homepage-480w.jpg"
type="image/jpeg"
/>


<source
media="(max-width: 1024px)"
srcset="/img/pics/homepage-1024w.webp"
type="image/webp"
/>

<source
media="(max-width: 1024px)"
srcset="/img/pics/homepage-1024w.jpg"
type="image/jpeg"
/>


<source
media="(max-width: 1920px)"
srcset="/img/pics/homepage-1920w.webp"
type="image/webp"
/>

<source
media="(max-width: 1920px)"
srcset="/img/pics/homepage-1920w.jpg"
type="image/jpeg"
/>


<source
media="(min-width: 1921px)"
srcset="/img/pics/homepage.webp"
type="image/webp"
/>

<source
media="(min-width: 1921px)"
srcset="/img/pics/homepage.jpg"
type="image/jpeg"
/>


<img
src="/img/pics/homepage-1024w.jpg"
width="3840"
height="2719"
alt=""
loading="lazy"
/>

</picture>
</div>
<div class="container section">
<div class="columns">
<div class="column">
<h3 class="title">{{ $t("A practical tool") }}</h3>
<p
v-html="
$t(
'Mobilizon is a tool that helps you <b>find, create and organise events</b>.'
)
"

/>

</div>
<div class="column">
<h3 class="title">{{ $t("An ethical alternative") }}</h3>
<p
v-html="
$t(
'Ethical alternative to Facebook events, groups and pages, Mobilizon is a <b>tool designed to serve you</b>. Period.'
)
"

/>

</div>
<div class="column">
<h3 class="title">{{ $t("A federated software") }}</h3>
<p
v-html="
$t(
'Mobilizon is not a giant platform, but a <b>multitude of interconnected Mobilizon websites</b>.'
)
"

/>

</div>
</div>
<div class="buttons">
<a
class="button is-primary is-large"
href="https://joinmobilizon.org"
>{{ $t("Learn more about Mobilizon") }}</a
>

</div>
</div>
</div>
<div
class="container section"
v-if="config && loggedUser && loggedUser.settings"
>

<section v-if="currentActor.id && (welcomeBack || newRegisteredUser)">
<b-message type="is-info" v-if="welcomeBack">{{
$t("Welcome back {username}!", {
username: currentActor.displayName(),
})
}}</b-message>
<b-message type="is-info" v-if="newRegisteredUser">{{
$t("Welcome to Mobilizon, {username}!", {
username: currentActor.displayName(),
})
}}</b-message>
</section>
<!-- Your upcoming events -->
<section v-if="canShowMyUpcomingEvents">
<h2 class="title">{{ $t("Your upcoming events") }}</h2>
<b-loading :active.sync="$apollo.loading" />
<div v-for="row of goingToEvents" class="upcoming-events" :key="row[0]">
<p
class="date-component-container"
v-if="isInLessThanSevenDays(row[0])"
>

<span v-if="isToday(row[0])">{{
$tc("You have one event today.", row[1].length, {
count: row[1].length,
})
}}</span>
<span v-else-if="isTomorrow(row[0])">{{
$tc("You have one event tomorrow.", row[1].length, {
count: row[1].length,
})
}}</span>
<span v-else-if="isInLessThanSevenDays(row[0])">
{{
$tc("You have one event in {days} days.", row[1].length, {
count: row[1].length,
days: calculateDiffDays(row[0]),
})
}}
</span>
</p>
<div>
<EventListCard
v-for="participation in thisWeek(row)"
@event-deleted="eventDeleted"
:key="participation[1].id"
:participation="participation[1]"
/>

</div>
</div>
<span class="view-all">
<router-link :to="{ name: RouteName.MY_EVENTS }"
>{{ $t("View everything") }} >></router-link
>

</span>
</section>
<hr
class="home-separator"
v-if="canShowMyUpcomingEvents && canShowLastWeekEvents"
/>

<!-- Last week events -->
<section v-if="canShowLastWeekEvents">
<h2 class="title">{{ $t("Last week") }}</h2>
<b-loading :active.sync="$apollo.loading" />
<div>
<EventListCard
v-for="participation in lastWeekEvents"
:key="participation.id"
:participation="participation"
@event-deleted="eventDeleted"
:options="{ hideDate: false }"
/>

</div>
</section>
<hr
class="home-separator"
v-if="canShowLastWeekEvents && canShowCloseEvents"
/>

<!-- Events close to you -->
<section class="events-close" v-if="canShowCloseEvents">
<h2 class="title">
{{ $t("Events nearby") }}
</h2>
<p>
{{
$tc(
"Within {number} kilometers of {place}",
loggedUser.settings.location.range,
{
number: loggedUser.settings.location.range,
place: loggedUser.settings.location.name,
}
)
}}
<router-link
:to="{ name: RouteName.PREFERENCES }"
:title="$t('Change')"
>

<b-icon class="clickable" icon="pencil" size="is-small" />
</router-link>
<b-loading :active.sync="$apollo.loading" />
</p>
<div class="columns is-multiline">
<div
class="column is-one-third-desktop"
v-for="event in closeEvents.elements.slice(0, 3)"
:key="event.uuid"
>

<event-card :event="event" />
</div>
</div>
</section>
<hr
class="home-separator"
v-if="
canShowMyUpcomingEvents || canShowLastWeekEvents || canShowCloseEvents
"

/>

<section class="events-recent">
<h2 class="title">
{{ $t("Last published events") }}
</h2>
<p>
<i18n tag="span" path="On {instance} and other federated instances">
<b slot="instance">{{ config.name }}</b>
</i18n>
<b-loading :active.sync="$apollo.loading" />
</p>

<div v-if="this.events.total > 0">
<div class="columns is-multiline">
<div
class="column is-one-third-desktop"
v-for="event in this.events.elements.slice(0, 6)"
:key="event.uuid"
>

<recent-event-card-wrapper :event="event" />
</div>
</div>
<span class="view-all">
<router-link :to="{ name: RouteName.SEARCH }"
>{{ $t("View everything") }} >></router-link
>

</span>
</div>
<b-message v-else type="is-danger"
>
{{ $t("No events found") }}<br />
<div v-if="goingToEvents.size > 0 || lastWeekEvents.length > 0">
<b-icon size="is-small" icon="information-outline" />
<small>{{
$t("The events you created are not shown here.")
}}</small>
</div>
</b-message>
</section>
</div>
</div>
</template>

<script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator";
import { EventSortField, ParticipantRole, SortDirection } from "@/types/enums";
import { Paginate } from "@/types/paginate";
import { supportsWebPFormat } from "@/utils/support";
import { IParticipant, Participant } from "../types/participant.model";
import { CLOSE_EVENTS, FETCH_EVENTS } from "../graphql/event";
import EventListCard from "../components/Event/EventListCard.vue";
import EventCard from "../components/Event/EventCard.vue";
import RecentEventCardWrapper from "../components/Event/RecentEventCardWrapper.vue";
import {
CURRENT_ACTOR_CLIENT,
LOGGED_USER_PARTICIPATIONS,
} from "../graphql/actor";
import { IPerson, Person } from "../types/actor";
import { ICurrentUser, IUser } from "../types/current-user.model";
import { CURRENT_USER_CLIENT, USER_SETTINGS } from "../graphql/user";
import RouteName from "../router/name";
import { IEvent } from "../types/event.model";
import DateComponent from "../components/Event/DateCalendarIcon.vue";
import { CONFIG } from "../graphql/config";
import { IConfig } from "../types/config.model";
import Subtitle from "../components/Utils/Subtitle.vue";

@Component({
apollo: {
events: {
query: FETCH_EVENTS,
fetchPolicy: "no-cache", // Debug me: https://github.com/apollographql/apollo-client/issues/3030
variables: {
orderBy: EventSortField.INSERTED_AT,
direction: SortDirection.DESC,
},
},
currentActor: {
query: CURRENT_ACTOR_CLIENT,
update: (data) => new Person(data.currentActor),
},
currentUser: CURRENT_USER_CLIENT,
loggedUser: {
query: USER_SETTINGS,
fetchPolicy: "network-only",
skip() {
return !this.currentUser || this.currentUser.isLoggedIn === false;
},
error() {
return null;
},
},
config: CONFIG,
currentUserParticipations: {
query: LOGGED_USER_PARTICIPATIONS,
fetchPolicy: "cache-and-network",
variables() {
const lastWeek = new Date();
lastWeek.setDate(new Date().getDate() - 7);
return {
afterDateTime: lastWeek.toISOString(),
};
},
update: (data) =>
data.loggedUser.participations.elements.map(
(participation: IParticipant) => new Participant(participation)
),
skip() {
return this.currentUser?.isLoggedIn === false;
},
},
closeEvents: {
query: CLOSE_EVENTS,
variables() {
return {
location: this.loggedUser?.settings?.location?.geohash,
radius: this.loggedUser?.settings?.location?.range,
};
},
update: (data) => data.searchEvents,
skip() {
return (
!this.currentUser?.isLoggedIn ||
!this.loggedUser?.settings?.location?.geohash ||
!this.loggedUser?.settings?.location?.range
);
},
},
},
components: {
Subtitle,
DateComponent,
EventListCard,
EventCard,
RecentEventCardWrapper,
"settings-onboard": () => import("./User/SettingsOnboard.vue"),
},
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
title: this.instanceName,
titleTemplate: "%s | Mobilizon",
};
},
})
export default class Home extends Vue {
events: Paginate<IEvent> = {
elements: [],
total: 0,
};

locations = [];

city = { name: null };

country = { name: null };

currentUser!: IUser;

loggedUser!: ICurrentUser;

currentActor!: IPerson;

config!: IConfig;

RouteName = RouteName;

currentUserParticipations: IParticipant[] = [];

supportsWebPFormat = supportsWebPFormat;

closeEvents: Paginate<IEvent> = { elements: [], total: 0 };

// get displayed_name() {
// return this.loggedPerson && this.loggedPerson.name === null
// ? this.loggedPerson.preferredUsername
// : this.loggedPerson.name;
// }

get instanceName(): string | undefined {
if (!this.config) return undefined;
return this.config.name;
}

// eslint-disable-next-line class-methods-use-this
get welcomeBack(): boolean {
return window.localStorage.getItem("welcome-back") === "yes";
}

// eslint-disable-next-line class-methods-use-this
get newRegisteredUser(): boolean {
return window.localStorage.getItem("new-registered-user") === "yes";
}

thisWeek(
row: [string, Map<string, IParticipant>]
): Map<string, IParticipant> {
if (this.isInLessThanSevenDays(row[0])) {
return row[1];
}
return new Map();
}

// eslint-disable-next-line class-methods-use-this
mounted(): void {
if (window.localStorage.getItem("welcome-back")) {
window.localStorage.removeItem("welcome-back");
}
if (window.localStorage.getItem("new-registered-user")) {
window.localStorage.removeItem("new-registered-user");
}
}

// eslint-disable-next-line class-methods-use-this
isToday(date: Date): boolean {
return new Date(date).toDateString() === new Date().toDateString();
}

isTomorrow(date: string): boolean {
return this.isInDays(date, 1);
}

isInDays(date: string, nbDays: number): boolean {
return this.calculateDiffDays(date) === nbDays;
}

isBefore(date: string, nbDays: number): boolean {
return this.calculateDiffDays(date) < nbDays;
}

isAfter(date: string, nbDays: number): boolean {
return this.calculateDiffDays(date) >= nbDays;
}

isInLessThanSevenDays(date: string): boolean {
return this.isBefore(date, 7);
}

// eslint-disable-next-line class-methods-use-this
calculateDiffDays(date: string): number {
return Math.ceil(
(new Date(date).getTime() - new Date().getTime()) / 1000 / 60 / 60 / 24
);
}

get thisWeekGoingToEvents(): IParticipant[] {
const res = this.currentUserParticipations.filter(
({ event, role }) =>
event.beginsOn != null &&
this.isAfter(event.beginsOn.toDateString(), 0) &&
this.isBefore(event.beginsOn.toDateString(), 7) &&
role !== ParticipantRole.REJECTED
);
res.sort(
(a: IParticipant, b: IParticipant) =>
a.event.beginsOn.getTime() - b.event.beginsOn.getTime()
);
return res;
}

get goingToEvents(): Map<string, Map<string, IParticipant>> {
return this.thisWeekGoingToEvents.reduce(
(
acc: Map<string, Map<string, IParticipant>>,
participation: IParticipant

) => {
const day = new Date(participation.event.beginsOn).toDateString();
const participations: Map<string, IParticipant> =
acc.get(day) || new Map();
participations.set(
`${participation.event.uuid}${participation.actor.id}`,
participation
);
acc.set(day, participations);
return acc;
},
new Map()
);
}

get lastWeekEvents(): IParticipant[] {
const res = this.currentUserParticipations.filter(
({ event, role }) =>
event.beginsOn != null &&
this.isBefore(event.beginsOn.toDateString(), 0) &&
role !== ParticipantRole.REJECTED
);
res.sort(
(a: IParticipant, b: IParticipant) =>
a.event.beginsOn.getTime() - b.event.beginsOn.getTime()
);
return res;
}

eventDeleted(eventid: string): void {
this.currentUserParticipations = this.currentUserParticipations.filter(
(participation) => participation.event.id !== eventid
);
}

viewEvent(event: IEvent): void {
this.$router.push({ name: RouteName.EVENT, params: { uuid: event.uuid } });
}

@Watch("loggedUser")
detectEmptyUserSettings(loggedUser: IUser): void {
if (loggedUser && loggedUser.id && loggedUser.settings === null) {
this.$router.push({
name: RouteName.WELCOME_SCREEN,
params: { step: "1" },
});
}
}

get canShowMyUpcomingEvents(): boolean {
return this.currentActor.id != undefined && this.goingToEvents.size > 0;
}

get canShowLastWeekEvents(): boolean {
return this.currentActor && this.lastWeekEvents.length > 0;
}

get canShowCloseEvents(): boolean {
return this.closeEvents.total > 0;
}
}
</script>

<style lang="scss" scoped>
@import "~bulma/sass/utilities/mixins.sass";

main > div > .container {
background: $white;
padding: 1rem 0.5rem 3rem;
}

.search-autocomplete {
border: 1px solid #dbdbdb;
color: rgba(0, 0, 0, 0.87);
}

.events-recent {
& > h3 {
padding-left: 0.75rem;
}

.columns {
margin: 1rem auto 0;
}
}

.date-component-container {
display: flex;
align-items: center;
margin: 0.5rem auto 1rem;

h3.subtitle {
margin-left: 7px;
}
}

span.view-all {
display: block;
margin-top: 1rem;
text-align: right;

a {
text-decoration: underline;
}
}

section.hero {
position: relative;
z-index: 1;

&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.3;
z-index: -1;
background: url("../../public/img/pics/homepage_background-1024w.png");
background-size: cover;
}
&.webp::before {
background-image: url("../../public/img/pics/homepage_background-1024w.webp");
}

& > .hero-body {
padding: 1rem 1.5rem 3rem;
}

.title {
color: $background-color;
}

.column figure.image img {
max-width: 400px;
}

.instance-description {
margin-bottom: 1rem;
}
}

#recent_events {
padding: 0;
min-height: 20vh;
z-index: 10;

.title {
margin: 20px auto 0;
}

.columns {
margin: 0 auto;
}
}

#picture {
.picture-container {
position: relative;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}

& > img {
object-fit: cover;
max-height: 80vh;
display: block;
margin: auto;
width: 100%;
}
}

.container.section {
background: $white;

@include tablet {
margin-top: -4rem;
}
z-index: 10;

.title {
margin: 0 0 10px;
font-size: 30px;
}

.buttons {
justify-content: center;
margin-top: 2rem;
}
}
}

#homepage {
background: $white;
}

.home-separator {
background-color: $orange-2;
}

.clickable {
cursor: pointer;
}

.title {
font-size: 27px;
&:not(:last-child) {
margin-bottom: 0.5rem;
}
}
</style>

get functions in view templates

Most of the get prefixed function are referred in the <template></template> section so most of the computation is done in the <script></script> part of the file.

In the <template></template part only view rendering computations are done (conditional rendering, collection pagination, internationalization...).

Browse code : js/src/views/Home.vue

<template>
<div id="homepage">
<section
class="hero"
:class="{ webp: supportsWebPFormat }"
v-if="config && (!currentUser.id || !currentActor.id)"
>

<div class="hero-body">
<div class="container">
<h1 class="title">
{{ config.slogan || $t("Gather â‹… Organize â‹… Mobilize") }}
</h1>
<p
v-html="
$t('Join <b>{instance}</b>, a Mobilizon instance', {
instance: config.name,
})
"

/>

<p class="instance-description">{{ config.description }}</p>
<!-- We don't invite to find other instances yet -->
<!-- <p v-if="!config.registrationsOpen">
{{ $t("This instance isn't opened to registrations, but you can register on other instances.") }}
</p>-->

<b-message type="is-danger" v-if="!config.registrationsOpen">{{
$t("Unfortunately, this instance isn't opened to registrations")
}}</b-message>
<div class="buttons">
<b-button
type="is-primary"
tag="router-link"
:to="{ name: RouteName.REGISTER }"
v-if="config.registrationsOpen"
>{{ $t("Create an account") }}</b-button
>

<!-- We don't invite to find other instances yet -->
<!-- <b-button v-else type="is-link" tag="a" href="https://joinmastodon.org">{{ $t('Find an instance') }}</b-button> -->
<b-button
type="is-text"
tag="router-link"
:to="{ name: RouteName.ABOUT }"
>

{{ $t("Learn more about {instance}", { instance: config.name }) }}
</b-button>
</div>
</div>
</div>
</section>
<div
id="recent_events"
class="container section"
v-if="config && (!currentUser.id || !currentActor.id)"
>

<section class="events-recent">
<h2 class="title">
{{ $t("Last published events") }}
</h2>
<p>
<i18n tag="span" path="On {instance} and other federated instances">
<b slot="instance">{{ config.name }}</b>
</i18n>
<b-loading :active.sync="$apollo.loading" />
</p>
<b-loading :active.sync="$apollo.loading" />
<div v-if="this.events.total > 0">
<div class="columns is-multiline">
<div
class="column is-one-third-desktop"
v-for="event in this.events.elements.slice(0, 6)"
:key="event.uuid"
>

<EventCard :event="event" />
</div>
</div>
<span class="view-all">
<router-link :to="{ name: RouteName.SEARCH }"
>{{ $t("View everything") }} >></router-link
>

</span>
</div>
<b-message v-else type="is-danger">{{
$t("No events found")
}}</b-message>
</section>
</div>
<div id="picture" v-if="config && (!currentUser.id || !currentActor.id)">
<div class="picture-container">
<picture>
<source
media="(max-width: 799px)"
srcset="/img/pics/homepage-480w.webp"
type="image/webp"
/>

<source
media="(max-width: 799px)"
srcset="/img/pics/homepage-480w.jpg"
type="image/jpeg"
/>


<source
media="(max-width: 1024px)"
srcset="/img/pics/homepage-1024w.webp"
type="image/webp"
/>

<source
media="(max-width: 1024px)"
srcset="/img/pics/homepage-1024w.jpg"
type="image/jpeg"
/>


<source
media="(max-width: 1920px)"
srcset="/img/pics/homepage-1920w.webp"
type="image/webp"
/>

<source
media="(max-width: 1920px)"
srcset="/img/pics/homepage-1920w.jpg"
type="image/jpeg"
/>


<source
media="(min-width: 1921px)"
srcset="/img/pics/homepage.webp"
type="image/webp"
/>

<source
media="(min-width: 1921px)"
srcset="/img/pics/homepage.jpg"
type="image/jpeg"
/>


<img
src="/img/pics/homepage-1024w.jpg"
width="3840"
height="2719"
alt=""
loading="lazy"
/>

</picture>
</div>
<div class="container section">
<div class="columns">
<div class="column">
<h3 class="title">{{ $t("A practical tool") }}</h3>
<p
v-html="
$t(
'Mobilizon is a tool that helps you <b>find, create and organise events</b>.'
)
"

/>

</div>
<div class="column">
<h3 class="title">{{ $t("An ethical alternative") }}</h3>
<p
v-html="
$t(
'Ethical alternative to Facebook events, groups and pages, Mobilizon is a <b>tool designed to serve you</b>. Period.'
)
"

/>

</div>
<div class="column">
<h3 class="title">{{ $t("A federated software") }}</h3>
<p
v-html="
$t(
'Mobilizon is not a giant platform, but a <b>multitude of interconnected Mobilizon websites</b>.'
)
"

/>

</div>
</div>
<div class="buttons">
<a
class="button is-primary is-large"
href="https://joinmobilizon.org"
>{{ $t("Learn more about Mobilizon") }}</a
>

</div>
</div>
</div>
<div
class="container section"
v-if="config && loggedUser && loggedUser.settings"
>

<section v-if="currentActor.id && (welcomeBack || newRegisteredUser)">
<b-message type="is-info" v-if="welcomeBack">{{
$t("Welcome back {username}!", {
username: currentActor.displayName(),
})
}}</b-message>
<b-message type="is-info" v-if="newRegisteredUser">{{
$t("Welcome to Mobilizon, {username}!", {
username: currentActor.displayName(),
})
}}</b-message>
</section>
<!-- Your upcoming events -->
<section v-if="canShowMyUpcomingEvents">
<h2 class="title">{{ $t("Your upcoming events") }}</h2>
<b-loading :active.sync="$apollo.loading" />
<div v-for="row of goingToEvents" class="upcoming-events" :key="row[0]">
<p
class="date-component-container"
v-if="isInLessThanSevenDays(row[0])"
>

<span v-if="isToday(row[0])">{{
$tc("You have one event today.", row[1].length, {
count: row[1].length,
})
}}</span>
<span v-else-if="isTomorrow(row[0])">{{
$tc("You have one event tomorrow.", row[1].length, {
count: row[1].length,
})
}}</span>
<span v-else-if="isInLessThanSevenDays(row[0])">
{{
$tc("You have one event in {days} days.", row[1].length, {
count: row[1].length,
days: calculateDiffDays(row[0]),
})
}}
</span>
</p>
<div>
<EventListCard
v-for="participation in thisWeek(row)"
@event-deleted="eventDeleted"
:key="participation[1].id"
:participation="participation[1]"
/>

</div>
</div>
<span class="view-all">
<router-link :to="{ name: RouteName.MY_EVENTS }"
>{{ $t("View everything") }} >></router-link
>

</span>
</section>
<hr
class="home-separator"
v-if="canShowMyUpcomingEvents && canShowLastWeekEvents"
/>

<!-- Last week events -->
<section v-if="canShowLastWeekEvents">
<h2 class="title">{{ $t("Last week") }}</h2>
<b-loading :active.sync="$apollo.loading" />
<div>
<EventListCard
v-for="participation in lastWeekEvents"
:key="participation.id"
:participation="participation"
@event-deleted="eventDeleted"
:options="{ hideDate: false }"
/>

</div>
</section>
<hr
class="home-separator"
v-if="canShowLastWeekEvents && canShowCloseEvents"
/>

<!-- Events close to you -->
<section class="events-close" v-if="canShowCloseEvents">
<h2 class="title">
{{ $t("Events nearby") }}
</h2>
<p>
{{
$tc(
"Within {number} kilometers of {place}",
loggedUser.settings.location.range,
{
number: loggedUser.settings.location.range,
place: loggedUser.settings.location.name,
}
)
}}
<router-link
:to="{ name: RouteName.PREFERENCES }"
:title="$t('Change')"
>

<b-icon class="clickable" icon="pencil" size="is-small" />
</router-link>
<b-loading :active.sync="$apollo.loading" />
</p>
<div class="columns is-multiline">
<div
class="column is-one-third-desktop"
v-for="event in closeEvents.elements.slice(0, 3)"
:key="event.uuid"
>

<event-card :event="event" />
</div>
</div>
</section>
<hr
class="home-separator"
v-if="
canShowMyUpcomingEvents || canShowLastWeekEvents || canShowCloseEvents
"

/>

<section class="events-recent">
<h2 class="title">
{{ $t("Last published events") }}
</h2>
<p>
<i18n tag="span" path="On {instance} and other federated instances">
<b slot="instance">{{ config.name }}</b>
</i18n>
<b-loading :active.sync="$apollo.loading" />
</p>

<div v-if="this.events.total > 0">
<div class="columns is-multiline">
<div
class="column is-one-third-desktop"
v-for="event in this.events.elements.slice(0, 6)"
:key="event.uuid"
>

<recent-event-card-wrapper :event="event" />
</div>
</div>
<span class="view-all">
<router-link :to="{ name: RouteName.SEARCH }"
>{{ $t("View everything") }} >></router-link
>

</span>
</div>
<b-message v-else type="is-danger"
>
{{ $t("No events found") }}<br />
<div v-if="goingToEvents.size > 0 || lastWeekEvents.length > 0">
<b-icon size="is-small" icon="information-outline" />
<small>{{
$t("The events you created are not shown here.")
}}</small>
</div>
</b-message>
</section>
</div>
</div>
</template>

<script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator";
import { EventSortField, ParticipantRole, SortDirection } from "@/types/enums";
import { Paginate } from "@/types/paginate";
import { supportsWebPFormat } from "@/utils/support";
import { IParticipant, Participant } from "../types/participant.model";
import { CLOSE_EVENTS, FETCH_EVENTS } from "../graphql/event";
import EventListCard from "../components/Event/EventListCard.vue";
import EventCard from "../components/Event/EventCard.vue";
import RecentEventCardWrapper from "../components/Event/RecentEventCardWrapper.vue";
import {
CURRENT_ACTOR_CLIENT,
LOGGED_USER_PARTICIPATIONS,
} from "../graphql/actor";
import { IPerson, Person } from "../types/actor";
import { ICurrentUser, IUser } from "../types/current-user.model";
import { CURRENT_USER_CLIENT, USER_SETTINGS } from "../graphql/user";
import RouteName from "../router/name";
import { IEvent } from "../types/event.model";
import DateComponent from "../components/Event/DateCalendarIcon.vue";
import { CONFIG } from "../graphql/config";
import { IConfig } from "../types/config.model";
import Subtitle from "../components/Utils/Subtitle.vue";

@Component({
apollo: {
events: {
query: FETCH_EVENTS,
fetchPolicy: "no-cache", // Debug me: https://github.com/apollographql/apollo-client/issues/3030
variables: {
orderBy: EventSortField.INSERTED_AT,
direction: SortDirection.DESC,
},
},
currentActor: {
query: CURRENT_ACTOR_CLIENT,
update: (data) => new Person(data.currentActor),
},
currentUser: CURRENT_USER_CLIENT,
loggedUser: {
query: USER_SETTINGS,
fetchPolicy: "network-only",
skip() {
return !this.currentUser || this.currentUser.isLoggedIn === false;
},
error() {
return null;
},
},
config: CONFIG,
currentUserParticipations: {
query: LOGGED_USER_PARTICIPATIONS,
fetchPolicy: "cache-and-network",
variables() {
const lastWeek = new Date();
lastWeek.setDate(new Date().getDate() - 7);
return {
afterDateTime: lastWeek.toISOString(),
};
},
update: (data) =>
data.loggedUser.participations.elements.map(
(participation: IParticipant) => new Participant(participation)
),
skip() {
return this.currentUser?.isLoggedIn === false;
},
},
closeEvents: {
query: CLOSE_EVENTS,
variables() {
return {
location: this.loggedUser?.settings?.location?.geohash,
radius: this.loggedUser?.settings?.location?.range,
};
},
update: (data) => data.searchEvents,
skip() {
return (
!this.currentUser?.isLoggedIn ||
!this.loggedUser?.settings?.location?.geohash ||
!this.loggedUser?.settings?.location?.range
);
},
},
},
components: {
Subtitle,
DateComponent,
EventListCard,
EventCard,
RecentEventCardWrapper,
"settings-onboard": () => import("./User/SettingsOnboard.vue"),
},
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
title: this.instanceName,
titleTemplate: "%s | Mobilizon",
};
},
})
export default class Home extends Vue {
events: Paginate<IEvent> = {
elements: [],
total: 0,
};

locations = [];

city = { name: null };

country = { name: null };

currentUser!: IUser;

loggedUser!: ICurrentUser;

currentActor!: IPerson;

config!: IConfig;

RouteName = RouteName;

currentUserParticipations: IParticipant[] = [];

supportsWebPFormat = supportsWebPFormat;

closeEvents: Paginate<IEvent> = { elements: [], total: 0 };

// get displayed_name() {
// return this.loggedPerson && this.loggedPerson.name === null
// ? this.loggedPerson.preferredUsername
// : this.loggedPerson.name;
// }

get instanceName(): string | undefined {
if (!this.config) return undefined;
return this.config.name;
}

// eslint-disable-next-line class-methods-use-this
get welcomeBack(): boolean {
return window.localStorage.getItem("welcome-back") === "yes";
}

// eslint-disable-next-line class-methods-use-this
get newRegisteredUser(): boolean {
return window.localStorage.getItem("new-registered-user") === "yes";
}

thisWeek(
row: [string, Map<string, IParticipant>]
): Map<string, IParticipant> {
if (this.isInLessThanSevenDays(row[0])) {
return row[1];
}
return new Map();
}

// eslint-disable-next-line class-methods-use-this
mounted(): void {
if (window.localStorage.getItem("welcome-back")) {
window.localStorage.removeItem("welcome-back");
}
if (window.localStorage.getItem("new-registered-user")) {
window.localStorage.removeItem("new-registered-user");
}
}

// eslint-disable-next-line class-methods-use-this
isToday(date: Date): boolean {
return new Date(date).toDateString() === new Date().toDateString();
}

isTomorrow(date: string): boolean {
return this.isInDays(date, 1);
}

isInDays(date: string, nbDays: number): boolean {
return this.calculateDiffDays(date) === nbDays;
}

isBefore(date: string, nbDays: number): boolean {
return this.calculateDiffDays(date) < nbDays;
}

isAfter(date: string, nbDays: number): boolean {
return this.calculateDiffDays(date) >= nbDays;
}

isInLessThanSevenDays(date: string): boolean {
return this.isBefore(date, 7);
}

// eslint-disable-next-line class-methods-use-this
calculateDiffDays(date: string): number {
return Math.ceil(
(new Date(date).getTime() - new Date().getTime()) / 1000 / 60 / 60 / 24
);
}

get thisWeekGoingToEvents(): IParticipant[] {
const res = this.currentUserParticipations.filter(
({ event, role }) =>
event.beginsOn != null &&
this.isAfter(event.beginsOn.toDateString(), 0) &&
this.isBefore(event.beginsOn.toDateString(), 7) &&
role !== ParticipantRole.REJECTED
);
res.sort(
(a: IParticipant, b: IParticipant) =>
a.event.beginsOn.getTime() - b.event.beginsOn.getTime()
);
return res;
}

get goingToEvents(): Map<string, Map<string, IParticipant>> {
return this.thisWeekGoingToEvents.reduce(
(
acc: Map<string, Map<string, IParticipant>>,
participation: IParticipant

) => {
const day = new Date(participation.event.beginsOn).toDateString();
const participations: Map<string, IParticipant> =
acc.get(day) || new Map();
participations.set(
`${participation.event.uuid}${participation.actor.id}`,
participation
);
acc.set(day, participations);
return acc;
},
new Map()
);
}

get lastWeekEvents(): IParticipant[] {
const res = this.currentUserParticipations.filter(
({ event, role }) =>
event.beginsOn != null &&
this.isBefore(event.beginsOn.toDateString(), 0) &&
role !== ParticipantRole.REJECTED
);
res.sort(
(a: IParticipant, b: IParticipant) =>
a.event.beginsOn.getTime() - b.event.beginsOn.getTime()
);
return res;
}

eventDeleted(eventid: string): void {
this.currentUserParticipations = this.currentUserParticipations.filter(
(participation) => participation.event.id !== eventid
);
}

viewEvent(event: IEvent): void {
this.$router.push({ name: RouteName.EVENT, params: { uuid: event.uuid } });
}

@Watch("loggedUser")
detectEmptyUserSettings(loggedUser: IUser): void {
if (loggedUser && loggedUser.id && loggedUser.settings === null) {
this.$router.push({
name: RouteName.WELCOME_SCREEN,
params: { step: "1" },
});
}
}

get canShowMyUpcomingEvents(): boolean {
return this.currentActor.id != undefined && this.goingToEvents.size > 0;
}

get canShowLastWeekEvents(): boolean {
return this.currentActor && this.lastWeekEvents.length > 0;
}

get canShowCloseEvents(): boolean {
return this.closeEvents.total > 0;
}
}
</script>

<style lang="scss" scoped>
@import "~bulma/sass/utilities/mixins.sass";

main > div > .container {
background: $white;
padding: 1rem 0.5rem 3rem;
}

.search-autocomplete {
border: 1px solid #dbdbdb;
color: rgba(0, 0, 0, 0.87);
}

.events-recent {
& > h3 {
padding-left: 0.75rem;
}

.columns {
margin: 1rem auto 0;
}
}

.date-component-container {
display: flex;
align-items: center;
margin: 0.5rem auto 1rem;

h3.subtitle {
margin-left: 7px;
}
}

span.view-all {
display: block;
margin-top: 1rem;
text-align: right;

a {
text-decoration: underline;
}
}

section.hero {
position: relative;
z-index: 1;

&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.3;
z-index: -1;
background: url("../../public/img/pics/homepage_background-1024w.png");
background-size: cover;
}
&.webp::before {
background-image: url("../../public/img/pics/homepage_background-1024w.webp");
}

& > .hero-body {
padding: 1rem 1.5rem 3rem;
}

.title {
color: $background-color;
}

.column figure.image img {
max-width: 400px;
}

.instance-description {
margin-bottom: 1rem;
}
}

#recent_events {
padding: 0;
min-height: 20vh;
z-index: 10;

.title {
margin: 20px auto 0;
}

.columns {
margin: 0 auto;
}
}

#picture {
.picture-container {
position: relative;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}

& > img {
object-fit: cover;
max-height: 80vh;
display: block;
margin: auto;
width: 100%;
}
}

.container.section {
background: $white;

@include tablet {
margin-top: -4rem;
}
z-index: 10;

.title {
margin: 0 0 10px;
font-size: 30px;
}

.buttons {
justify-content: center;
margin-top: 2rem;
}
}
}

#homepage {
background: $white;
}

.home-separator {
background-color: $orange-2;
}

.clickable {
cursor: pointer;
}

.title {
font-size: 27px;
&:not(:last-child) {
margin-bottom: 0.5rem;
}
}
</style>

I18n internationalized string 1/2

Depending on the current language, "Last published events" will be replaced with another text.

Ex. If language is french, the string will be looked up in the i18n/fr_FR.json file.

   {{ $t("Last published events") }}

Abstract of fr_FR.json.

{

"Last published events": "Derniers événements publiés",

}

In this case the french translation will be : "Derniers événements publiés".

Browse code : js/src/views/Home.vue

<template>
<div id="homepage">
<section
class="hero"
:class="{ webp: supportsWebPFormat }"
v-if="config && (!currentUser.id || !currentActor.id)"
>

<div class="hero-body">
<div class="container">
<h1 class="title">
{{ config.slogan || $t("Gather â‹… Organize â‹… Mobilize") }}
</h1>
<p
v-html="
$t('Join <b>{instance}</b>, a Mobilizon instance', {
instance: config.name,
})
"

/>

<p class="instance-description">{{ config.description }}</p>
<!-- We don't invite to find other instances yet -->
<!-- <p v-if="!config.registrationsOpen">
{{ $t("This instance isn't opened to registrations, but you can register on other instances.") }}
</p>-->

<b-message type="is-danger" v-if="!config.registrationsOpen">{{
$t("Unfortunately, this instance isn't opened to registrations")
}}</b-message>
<div class="buttons">
<b-button
type="is-primary"
tag="router-link"
:to="{ name: RouteName.REGISTER }"
v-if="config.registrationsOpen"
>{{ $t("Create an account") }}</b-button
>

<!-- We don't invite to find other instances yet -->
<!-- <b-button v-else type="is-link" tag="a" href="https://joinmastodon.org">{{ $t('Find an instance') }}</b-button> -->
<b-button
type="is-text"
tag="router-link"
:to="{ name: RouteName.ABOUT }"
>

{{ $t("Learn more about {instance}", { instance: config.name }) }}
</b-button>
</div>
</div>
</div>
</section>
<div
id="recent_events"
class="container section"
v-if="config && (!currentUser.id || !currentActor.id)"
>

<section class="events-recent">
<h2 class="title">
{{ $t("Last published events") }}
</h2>
<p>
<i18n tag="span" path="On {instance} and other federated instances">
<b slot="instance">{{ config.name }}</b>
</i18n>
<b-loading :active.sync="$apollo.loading" />
</p>
<b-loading :active.sync="$apollo.loading" />
<div v-if="this.events.total > 0">
<div class="columns is-multiline">
<div
class="column is-one-third-desktop"
v-for="event in this.events.elements.slice(0, 6)"
:key="event.uuid"
>

<EventCard :event="event" />
</div>
</div>
<span class="view-all">
<router-link :to="{ name: RouteName.SEARCH }"
>{{ $t("View everything") }} >></router-link
>

</span>
</div>
<b-message v-else type="is-danger">{{
$t("No events found")
}}</b-message>
</section>
</div>
<div id="picture" v-if="config && (!currentUser.id || !currentActor.id)">
<div class="picture-container">
<picture>
<source
media="(max-width: 799px)"
srcset="/img/pics/homepage-480w.webp"
type="image/webp"
/>

<source
media="(max-width: 799px)"
srcset="/img/pics/homepage-480w.jpg"
type="image/jpeg"
/>


<source
media="(max-width: 1024px)"
srcset="/img/pics/homepage-1024w.webp"
type="image/webp"
/>

<source
media="(max-width: 1024px)"
srcset="/img/pics/homepage-1024w.jpg"
type="image/jpeg"
/>


<source
media="(max-width: 1920px)"
srcset="/img/pics/homepage-1920w.webp"
type="image/webp"
/>

<source
media="(max-width: 1920px)"
srcset="/img/pics/homepage-1920w.jpg"
type="image/jpeg"
/>


<source
media="(min-width: 1921px)"
srcset="/img/pics/homepage.webp"
type="image/webp"
/>

<source
media="(min-width: 1921px)"
srcset="/img/pics/homepage.jpg"
type="image/jpeg"
/>


<img
src="/img/pics/homepage-1024w.jpg"
width="3840"
height="2719"
alt=""
loading="lazy"
/>

</picture>
</div>
<div class="container section">
<div class="columns">
<div class="column">
<h3 class="title">{{ $t("A practical tool") }}</h3>
<p
v-html="
$t(
'Mobilizon is a tool that helps you <b>find, create and organise events</b>.'
)
"

/>

</div>
<div class="column">
<h3 class="title">{{ $t("An ethical alternative") }}</h3>
<p
v-html="
$t(
'Ethical alternative to Facebook events, groups and pages, Mobilizon is a <b>tool designed to serve you</b>. Period.'
)
"

/>

</div>
<div class="column">
<h3 class="title">{{ $t("A federated software") }}</h3>
<p
v-html="
$t(
'Mobilizon is not a giant platform, but a <b>multitude of interconnected Mobilizon websites</b>.'
)
"

/>

</div>
</div>
<div class="buttons">
<a
class="button is-primary is-large"
href="https://joinmobilizon.org"
>{{ $t("Learn more about Mobilizon") }}</a
>

</div>
</div>
</div>
<div
class="container section"
v-if="config && loggedUser && loggedUser.settings"
>

<section v-if="currentActor.id && (welcomeBack || newRegisteredUser)">
<b-message type="is-info" v-if="welcomeBack">{{
$t("Welcome back {username}!", {
username: currentActor.displayName(),
})
}}</b-message>
<b-message type="is-info" v-if="newRegisteredUser">{{
$t("Welcome to Mobilizon, {username}!", {
username: currentActor.displayName(),
})
}}</b-message>
</section>
<!-- Your upcoming events -->
<section v-if="canShowMyUpcomingEvents">
<h2 class="title">{{ $t("Your upcoming events") }}</h2>
<b-loading :active.sync="$apollo.loading" />
<div v-for="row of goingToEvents" class="upcoming-events" :key="row[0]">
<p
class="date-component-container"
v-if="isInLessThanSevenDays(row[0])"
>

<span v-if="isToday(row[0])">{{
$tc("You have one event today.", row[1].length, {
count: row[1].length,
})
}}</span>
<span v-else-if="isTomorrow(row[0])">{{
$tc("You have one event tomorrow.", row[1].length, {
count: row[1].length,
})
}}</span>
<span v-else-if="isInLessThanSevenDays(row[0])">
{{
$tc("You have one event in {days} days.", row[1].length, {
count: row[1].length,
days: calculateDiffDays(row[0]),
})
}}
</span>
</p>
<div>
<EventListCard
v-for="participation in thisWeek(row)"
@event-deleted="eventDeleted"
:key="participation[1].id"
:participation="participation[1]"
/>

</div>
</div>
<span class="view-all">
<router-link :to="{ name: RouteName.MY_EVENTS }"
>{{ $t("View everything") }} >></router-link
>

</span>
</section>
<hr
class="home-separator"
v-if="canShowMyUpcomingEvents && canShowLastWeekEvents"
/>

<!-- Last week events -->
<section v-if="canShowLastWeekEvents">
<h2 class="title">{{ $t("Last week") }}</h2>
<b-loading :active.sync="$apollo.loading" />
<div>
<EventListCard
v-for="participation in lastWeekEvents"
:key="participation.id"
:participation="participation"
@event-deleted="eventDeleted"
:options="{ hideDate: false }"
/>

</div>
</section>
<hr
class="home-separator"
v-if="canShowLastWeekEvents && canShowCloseEvents"
/>

<!-- Events close to you -->
<section class="events-close" v-if="canShowCloseEvents">
<h2 class="title">
{{ $t("Events nearby") }}
</h2>
<p>
{{
$tc(
"Within {number} kilometers of {place}",
loggedUser.settings.location.range,
{
number: loggedUser.settings.location.range,
place: loggedUser.settings.location.name,
}
)
}}
<router-link
:to="{ name: RouteName.PREFERENCES }"
:title="$t('Change')"
>

<b-icon class="clickable" icon="pencil" size="is-small" />
</router-link>
<b-loading :active.sync="$apollo.loading" />
</p>
<div class="columns is-multiline">
<div
class="column is-one-third-desktop"
v-for="event in closeEvents.elements.slice(0, 3)"
:key="event.uuid"
>

<event-card :event="event" />
</div>
</div>
</section>
<hr
class="home-separator"
v-if="
canShowMyUpcomingEvents || canShowLastWeekEvents || canShowCloseEvents
"

/>

<section class="events-recent">
<h2 class="title">
{{ $t("Last published events") }}
</h2>
<p>
<i18n tag="span" path="On {instance} and other federated instances">
<b slot="instance">{{ config.name }}</b>
</i18n>
<b-loading :active.sync="$apollo.loading" />
</p>

<div v-if="this.events.total > 0">
<div class="columns is-multiline">
<div
class="column is-one-third-desktop"
v-for="event in this.events.elements.slice(0, 6)"
:key="event.uuid"
>

<recent-event-card-wrapper :event="event" />
</div>
</div>
<span class="view-all">
<router-link :to="{ name: RouteName.SEARCH }"
>{{ $t("View everything") }} >></router-link
>

</span>
</div>
<b-message v-else type="is-danger"
>
{{ $t("No events found") }}<br />
<div v-if="goingToEvents.size > 0 || lastWeekEvents.length > 0">
<b-icon size="is-small" icon="information-outline" />
<small>{{
$t("The events you created are not shown here.")
}}</small>
</div>
</b-message>
</section>
</div>
</div>
</template>

<script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator";
import { EventSortField, ParticipantRole, SortDirection } from "@/types/enums";
import { Paginate } from "@/types/paginate";
import { supportsWebPFormat } from "@/utils/support";
import { IParticipant, Participant } from "../types/participant.model";
import { CLOSE_EVENTS, FETCH_EVENTS } from "../graphql/event";
import EventListCard from "../components/Event/EventListCard.vue";
import EventCard from "../components/Event/EventCard.vue";
import RecentEventCardWrapper from "../components/Event/RecentEventCardWrapper.vue";
import {
CURRENT_ACTOR_CLIENT,
LOGGED_USER_PARTICIPATIONS,
} from "../graphql/actor";
import { IPerson, Person } from "../types/actor";
import { ICurrentUser, IUser } from "../types/current-user.model";
import { CURRENT_USER_CLIENT, USER_SETTINGS } from "../graphql/user";
import RouteName from "../router/name";
import { IEvent } from "../types/event.model";
import DateComponent from "../components/Event/DateCalendarIcon.vue";
import { CONFIG } from "../graphql/config";
import { IConfig } from "../types/config.model";
import Subtitle from "../components/Utils/Subtitle.vue";

@Component({
apollo: {
events: {
query: FETCH_EVENTS,
fetchPolicy: "no-cache", // Debug me: https://github.com/apollographql/apollo-client/issues/3030
variables: {
orderBy: EventSortField.INSERTED_AT,
direction: SortDirection.DESC,
},
},
currentActor: {
query: CURRENT_ACTOR_CLIENT,
update: (data) => new Person(data.currentActor),
},
currentUser: CURRENT_USER_CLIENT,
loggedUser: {
query: USER_SETTINGS,
fetchPolicy: "network-only",
skip() {
return !this.currentUser || this.currentUser.isLoggedIn === false;
},
error() {
return null;
},
},
config: CONFIG,
currentUserParticipations: {
query: LOGGED_USER_PARTICIPATIONS,
fetchPolicy: "cache-and-network",
variables() {
const lastWeek = new Date();
lastWeek.setDate(new Date().getDate() - 7);
return {
afterDateTime: lastWeek.toISOString(),
};
},
update: (data) =>
data.loggedUser.participations.elements.map(
(participation: IParticipant) => new Participant(participation)
),
skip() {
return this.currentUser?.isLoggedIn === false;
},
},
closeEvents: {
query: CLOSE_EVENTS,
variables() {
return {
location: this.loggedUser?.settings?.location?.geohash,
radius: this.loggedUser?.settings?.location?.range,
};
},
update: (data) => data.searchEvents,
skip() {
return (
!this.currentUser?.isLoggedIn ||
!this.loggedUser?.settings?.location?.geohash ||
!this.loggedUser?.settings?.location?.range
);
},
},
},
components: {
Subtitle,
DateComponent,
EventListCard,
EventCard,
RecentEventCardWrapper,
"settings-onboard": () => import("./User/SettingsOnboard.vue"),
},
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
title: this.instanceName,
titleTemplate: "%s | Mobilizon",
};
},
})
export default class Home extends Vue {
events: Paginate<IEvent> = {
elements: [],
total: 0,
};

locations = [];

city = { name: null };

country = { name: null };

currentUser!: IUser;

loggedUser!: ICurrentUser;

currentActor!: IPerson;

config!: IConfig;

RouteName = RouteName;

currentUserParticipations: IParticipant[] = [];

supportsWebPFormat = supportsWebPFormat;

closeEvents: Paginate<IEvent> = { elements: [], total: 0 };

// get displayed_name() {
// return this.loggedPerson && this.loggedPerson.name === null
// ? this.loggedPerson.preferredUsername
// : this.loggedPerson.name;
// }

get instanceName(): string | undefined {
if (!this.config) return undefined;
return this.config.name;
}

// eslint-disable-next-line class-methods-use-this
get welcomeBack(): boolean {
return window.localStorage.getItem("welcome-back") === "yes";
}

// eslint-disable-next-line class-methods-use-this
get newRegisteredUser(): boolean {
return window.localStorage.getItem("new-registered-user") === "yes";
}

thisWeek(
row: [string, Map<string, IParticipant>]
): Map<string, IParticipant> {
if (this.isInLessThanSevenDays(row[0])) {
return row[1];
}
return new Map();
}

// eslint-disable-next-line class-methods-use-this
mounted(): void {
if (window.localStorage.getItem("welcome-back")) {
window.localStorage.removeItem("welcome-back");
}
if (window.localStorage.getItem("new-registered-user")) {
window.localStorage.removeItem("new-registered-user");
}
}

// eslint-disable-next-line class-methods-use-this
isToday(date: Date): boolean {
return new Date(date).toDateString() === new Date().toDateString();
}

isTomorrow(date: string): boolean {
return this.isInDays(date, 1);
}

isInDays(date: string, nbDays: number): boolean {
return this.calculateDiffDays(date) === nbDays;
}

isBefore(date: string, nbDays: number): boolean {
return this.calculateDiffDays(date) < nbDays;
}

isAfter(date: string, nbDays: number): boolean {
return this.calculateDiffDays(date) >= nbDays;
}

isInLessThanSevenDays(date: string): boolean {
return this.isBefore(date, 7);
}

// eslint-disable-next-line class-methods-use-this
calculateDiffDays(date: string): number {
return Math.ceil(
(new Date(date).getTime() - new Date().getTime()) / 1000 / 60 / 60 / 24
);
}

get thisWeekGoingToEvents(): IParticipant[] {
const res = this.currentUserParticipations.filter(
({ event, role }) =>
event.beginsOn != null &&
this.isAfter(event.beginsOn.toDateString(), 0) &&
this.isBefore(event.beginsOn.toDateString(), 7) &&
role !== ParticipantRole.REJECTED
);
res.sort(
(a: IParticipant, b: IParticipant) =>
a.event.beginsOn.getTime() - b.event.beginsOn.getTime()
);
return res;
}

get goingToEvents(): Map<string, Map<string, IParticipant>> {
return this.thisWeekGoingToEvents.reduce(
(
acc: Map<string, Map<string, IParticipant>>,
participation: IParticipant

) => {
const day = new Date(participation.event.beginsOn).toDateString();
const participations: Map<string, IParticipant> =
acc.get(day) || new Map();
participations.set(
`${participation.event.uuid}${participation.actor.id}`,
participation
);
acc.set(day, participations);
return acc;
},
new Map()
);
}

get lastWeekEvents(): IParticipant[] {
const res = this.currentUserParticipations.filter(
({ event, role }) =>
event.beginsOn != null &&
this.isBefore(event.beginsOn.toDateString(), 0) &&
role !== ParticipantRole.REJECTED
);
res.sort(
(a: IParticipant, b: IParticipant) =>
a.event.beginsOn.getTime() - b.event.beginsOn.getTime()
);
return res;
}

eventDeleted(eventid: string): void {
this.currentUserParticipations = this.currentUserParticipations.filter(
(participation) => participation.event.id !== eventid
);
}

viewEvent(event: IEvent): void {
this.$router.push({ name: RouteName.EVENT, params: { uuid: event.uuid } });
}

@Watch("loggedUser")
detectEmptyUserSettings(loggedUser: IUser): void {
if (loggedUser && loggedUser.id && loggedUser.settings === null) {
this.$router.push({
name: RouteName.WELCOME_SCREEN,
params: { step: "1" },
});
}
}

get canShowMyUpcomingEvents(): boolean {
return this.currentActor.id != undefined && this.goingToEvents.size > 0;
}

get canShowLastWeekEvents(): boolean {
return this.currentActor && this.lastWeekEvents.length > 0;
}

get canShowCloseEvents(): boolean {
return this.closeEvents.total > 0;
}
}
</script>

<style lang="scss" scoped>
@import "~bulma/sass/utilities/mixins.sass";

main > div > .container {
background: $white;
padding: 1rem 0.5rem 3rem;
}

.search-autocomplete {
border: 1px solid #dbdbdb;
color: rgba(0, 0, 0, 0.87);
}

.events-recent {
& > h3 {
padding-left: 0.75rem;
}

.columns {
margin: 1rem auto 0;
}
}

.date-component-container {
display: flex;
align-items: center;
margin: 0.5rem auto 1rem;

h3.subtitle {
margin-left: 7px;
}
}

span.view-all {
display: block;
margin-top: 1rem;
text-align: right;

a {
text-decoration: underline;
}
}

section.hero {
position: relative;
z-index: 1;

&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.3;
z-index: -1;
background: url("../../public/img/pics/homepage_background-1024w.png");
background-size: cover;
}
&.webp::before {
background-image: url("../../public/img/pics/homepage_background-1024w.webp");
}

& > .hero-body {
padding: 1rem 1.5rem 3rem;
}

.title {
color: $background-color;
}

.column figure.image img {
max-width: 400px;
}

.instance-description {
margin-bottom: 1rem;
}
}

#recent_events {
padding: 0;
min-height: 20vh;
z-index: 10;

.title {
margin: 20px auto 0;
}

.columns {
margin: 0 auto;
}
}

#picture {
.picture-container {
position: relative;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}

& > img {
object-fit: cover;
max-height: 80vh;
display: block;
margin: auto;
width: 100%;
}
}

.container.section {
background: $white;

@include tablet {
margin-top: -4rem;
}
z-index: 10;

.title {
margin: 0 0 10px;
font-size: 30px;
}

.buttons {
justify-content: center;
margin-top: 2rem;
}
}
}

#homepage {
background: $white;
}

.home-separator {
background-color: $orange-2;
}

.clickable {
cursor: pointer;
}

.title {
font-size: 27px;
&:not(:last-child) {
margin-bottom: 0.5rem;
}
}
</style>

I18n internationalized string 2/2

Another way to translate text is by using <i18n></i18n> element. I allows more complex text substitutions like parametrized text.

Browse code : js/src/views/Home.vue

<template>
<div id="homepage">
<section
class="hero"
:class="{ webp: supportsWebPFormat }"
v-if="config && (!currentUser.id || !currentActor.id)"
>

<div class="hero-body">
<div class="container">
<h1 class="title">
{{ config.slogan || $t("Gather â‹… Organize â‹… Mobilize") }}
</h1>
<p
v-html="
$t('Join <b>{instance}</b>, a Mobilizon instance', {
instance: config.name,
})
"

/>

<p class="instance-description">{{ config.description }}</p>
<!-- We don't invite to find other instances yet -->
<!-- <p v-if="!config.registrationsOpen">
{{ $t("This instance isn't opened to registrations, but you can register on other instances.") }}
</p>-->

<b-message type="is-danger" v-if="!config.registrationsOpen">{{
$t("Unfortunately, this instance isn't opened to registrations")
}}</b-message>
<div class="buttons">
<b-button
type="is-primary"
tag="router-link"
:to="{ name: RouteName.REGISTER }"
v-if="config.registrationsOpen"
>{{ $t("Create an account") }}</b-button
>

<!-- We don't invite to find other instances yet -->
<!-- <b-button v-else type="is-link" tag="a" href="https://joinmastodon.org">{{ $t('Find an instance') }}</b-button> -->
<b-button
type="is-text"
tag="router-link"
:to="{ name: RouteName.ABOUT }"
>

{{ $t("Learn more about {instance}", { instance: config.name }) }}
</b-button>
</div>
</div>
</div>
</section>
<div
id="recent_events"
class="container section"
v-if="config && (!currentUser.id || !currentActor.id)"
>

<section class="events-recent">
<h2 class="title">
{{ $t("Last published events") }}
</h2>
<p>
<i18n tag="span" path="On {instance} and other federated instances">
<b slot="instance">{{ config.name }}</b>
</i18n>
<b-loading :active.sync="$apollo.loading" />
</p>
<b-loading :active.sync="$apollo.loading" />
<div v-if="this.events.total > 0">
<div class="columns is-multiline">
<div
class="column is-one-third-desktop"
v-for="event in this.events.elements.slice(0, 6)"
:key="event.uuid"
>

<EventCard :event="event" />
</div>
</div>
<span class="view-all">
<router-link :to="{ name: RouteName.SEARCH }"
>{{ $t("View everything") }} >></router-link
>

</span>
</div>
<b-message v-else type="is-danger">{{
$t("No events found")
}}</b-message>
</section>
</div>
<div id="picture" v-if="config && (!currentUser.id || !currentActor.id)">
<div class="picture-container">
<picture>
<source
media="(max-width: 799px)"
srcset="/img/pics/homepage-480w.webp"
type="image/webp"
/>

<source
media="(max-width: 799px)"
srcset="/img/pics/homepage-480w.jpg"
type="image/jpeg"
/>


<source
media="(max-width: 1024px)"
srcset="/img/pics/homepage-1024w.webp"
type="image/webp"
/>

<source
media="(max-width: 1024px)"
srcset="/img/pics/homepage-1024w.jpg"
type="image/jpeg"
/>


<source
media="(max-width: 1920px)"
srcset="/img/pics/homepage-1920w.webp"
type="image/webp"
/>

<source
media="(max-width: 1920px)"
srcset="/img/pics/homepage-1920w.jpg"
type="image/jpeg"
/>


<source
media="(min-width: 1921px)"
srcset="/img/pics/homepage.webp"
type="image/webp"
/>

<source
media="(min-width: 1921px)"
srcset="/img/pics/homepage.jpg"
type="image/jpeg"
/>


<img
src="/img/pics/homepage-1024w.jpg"
width="3840"
height="2719"
alt=""
loading="lazy"
/>

</picture>
</div>
<div class="container section">
<div class="columns">
<div class="column">
<h3 class="title">{{ $t("A practical tool") }}</h3>
<p
v-html="
$t(
'Mobilizon is a tool that helps you <b>find, create and organise events</b>.'
)
"

/>

</div>
<div class="column">
<h3 class="title">{{ $t("An ethical alternative") }}</h3>
<p
v-html="
$t(
'Ethical alternative to Facebook events, groups and pages, Mobilizon is a <b>tool designed to serve you</b>. Period.'
)
"

/>

</div>
<div class="column">
<h3 class="title">{{ $t("A federated software") }}</h3>
<p
v-html="
$t(
'Mobilizon is not a giant platform, but a <b>multitude of interconnected Mobilizon websites</b>.'
)
"

/>

</div>
</div>
<div class="buttons">
<a
class="button is-primary is-large"
href="https://joinmobilizon.org"
>{{ $t("Learn more about Mobilizon") }}</a
>

</div>
</div>
</div>
<div
class="container section"
v-if="config && loggedUser && loggedUser.settings"
>

<section v-if="currentActor.id && (welcomeBack || newRegisteredUser)">
<b-message type="is-info" v-if="welcomeBack">{{
$t("Welcome back {username}!", {
username: currentActor.displayName(),
})
}}</b-message>
<b-message type="is-info" v-if="newRegisteredUser">{{
$t("Welcome to Mobilizon, {username}!", {
username: currentActor.displayName(),
})
}}</b-message>
</section>
<!-- Your upcoming events -->
<section v-if="canShowMyUpcomingEvents">
<h2 class="title">{{ $t("Your upcoming events") }}</h2>
<b-loading :active.sync="$apollo.loading" />
<div v-for="row of goingToEvents" class="upcoming-events" :key="row[0]">
<p
class="date-component-container"
v-if="isInLessThanSevenDays(row[0])"
>

<span v-if="isToday(row[0])">{{
$tc("You have one event today.", row[1].length, {
count: row[1].length,
})
}}</span>
<span v-else-if="isTomorrow(row[0])">{{
$tc("You have one event tomorrow.", row[1].length, {
count: row[1].length,
})
}}</span>
<span v-else-if="isInLessThanSevenDays(row[0])">
{{
$tc("You have one event in {days} days.", row[1].length, {
count: row[1].length,
days: calculateDiffDays(row[0]),
})
}}
</span>
</p>
<div>
<EventListCard
v-for="participation in thisWeek(row)"
@event-deleted="eventDeleted"
:key="participation[1].id"
:participation="participation[1]"
/>

</div>
</div>
<span class="view-all">
<router-link :to="{ name: RouteName.MY_EVENTS }"
>{{ $t("View everything") }} >></router-link
>

</span>
</section>
<hr
class="home-separator"
v-if="canShowMyUpcomingEvents && canShowLastWeekEvents"
/>

<!-- Last week events -->
<section v-if="canShowLastWeekEvents">
<h2 class="title">{{ $t("Last week") }}</h2>
<b-loading :active.sync="$apollo.loading" />
<div>
<EventListCard
v-for="participation in lastWeekEvents"
:key="participation.id"
:participation="participation"
@event-deleted="eventDeleted"
:options="{ hideDate: false }"
/>

</div>
</section>
<hr
class="home-separator"
v-if="canShowLastWeekEvents && canShowCloseEvents"
/>

<!-- Events close to you -->
<section class="events-close" v-if="canShowCloseEvents">
<h2 class="title">
{{ $t("Events nearby") }}
</h2>
<p>
{{
$tc(
"Within {number} kilometers of {place}",
loggedUser.settings.location.range,
{
number: loggedUser.settings.location.range,
place: loggedUser.settings.location.name,
}
)
}}
<router-link
:to="{ name: RouteName.PREFERENCES }"
:title="$t('Change')"
>

<b-icon class="clickable" icon="pencil" size="is-small" />
</router-link>
<b-loading :active.sync="$apollo.loading" />
</p>
<div class="columns is-multiline">
<div
class="column is-one-third-desktop"
v-for="event in closeEvents.elements.slice(0, 3)"
:key="event.uuid"
>

<event-card :event="event" />
</div>
</div>
</section>
<hr
class="home-separator"
v-if="
canShowMyUpcomingEvents || canShowLastWeekEvents || canShowCloseEvents
"

/>

<section class="events-recent">
<h2 class="title">
{{ $t("Last published events") }}
</h2>
<p>
<i18n tag="span" path="On {instance} and other federated instances">
<b slot="instance">{{ config.name }}</b>
</i18n>
<b-loading :active.sync="$apollo.loading" />
</p>

<div v-if="this.events.total > 0">
<div class="columns is-multiline">
<div
class="column is-one-third-desktop"
v-for="event in this.events.elements.slice(0, 6)"
:key="event.uuid"
>

<recent-event-card-wrapper :event="event" />
</div>
</div>
<span class="view-all">
<router-link :to="{ name: RouteName.SEARCH }"
>{{ $t("View everything") }} >></router-link
>

</span>
</div>
<b-message v-else type="is-danger"
>
{{ $t("No events found") }}<br />
<div v-if="goingToEvents.size > 0 || lastWeekEvents.length > 0">
<b-icon size="is-small" icon="information-outline" />
<small>{{
$t("The events you created are not shown here.")
}}</small>
</div>
</b-message>
</section>
</div>
</div>
</template>

<script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator";
import { EventSortField, ParticipantRole, SortDirection } from "@/types/enums";
import { Paginate } from "@/types/paginate";
import { supportsWebPFormat } from "@/utils/support";
import { IParticipant, Participant } from "../types/participant.model";
import { CLOSE_EVENTS, FETCH_EVENTS } from "../graphql/event";
import EventListCard from "../components/Event/EventListCard.vue";
import EventCard from "../components/Event/EventCard.vue";
import RecentEventCardWrapper from "../components/Event/RecentEventCardWrapper.vue";
import {
CURRENT_ACTOR_CLIENT,
LOGGED_USER_PARTICIPATIONS,
} from "../graphql/actor";
import { IPerson, Person } from "../types/actor";
import { ICurrentUser, IUser } from "../types/current-user.model";
import { CURRENT_USER_CLIENT, USER_SETTINGS } from "../graphql/user";
import RouteName from "../router/name";
import { IEvent } from "../types/event.model";
import DateComponent from "../components/Event/DateCalendarIcon.vue";
import { CONFIG } from "../graphql/config";
import { IConfig } from "../types/config.model";
import Subtitle from "../components/Utils/Subtitle.vue";

@Component({
apollo: {
events: {
query: FETCH_EVENTS,
fetchPolicy: "no-cache", // Debug me: https://github.com/apollographql/apollo-client/issues/3030
variables: {
orderBy: EventSortField.INSERTED_AT,
direction: SortDirection.DESC,
},
},
currentActor: {
query: CURRENT_ACTOR_CLIENT,
update: (data) => new Person(data.currentActor),
},
currentUser: CURRENT_USER_CLIENT,
loggedUser: {
query: USER_SETTINGS,
fetchPolicy: "network-only",
skip() {
return !this.currentUser || this.currentUser.isLoggedIn === false;
},
error() {
return null;
},
},
config: CONFIG,
currentUserParticipations: {
query: LOGGED_USER_PARTICIPATIONS,
fetchPolicy: "cache-and-network",
variables() {
const lastWeek = new Date();
lastWeek.setDate(new Date().getDate() - 7);
return {
afterDateTime: lastWeek.toISOString(),
};
},
update: (data) =>
data.loggedUser.participations.elements.map(
(participation: IParticipant) => new Participant(participation)
),
skip() {
return this.currentUser?.isLoggedIn === false;
},
},
closeEvents: {
query: CLOSE_EVENTS,
variables() {
return {
location: this.loggedUser?.settings?.location?.geohash,
radius: this.loggedUser?.settings?.location?.range,
};
},
update: (data) => data.searchEvents,
skip() {
return (
!this.currentUser?.isLoggedIn ||
!this.loggedUser?.settings?.location?.geohash ||
!this.loggedUser?.settings?.location?.range
);
},
},
},
components: {
Subtitle,
DateComponent,
EventListCard,
EventCard,
RecentEventCardWrapper,
"settings-onboard": () => import("./User/SettingsOnboard.vue"),
},
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
title: this.instanceName,
titleTemplate: "%s | Mobilizon",
};
},
})
export default class Home extends Vue {
events: Paginate<IEvent> = {
elements: [],
total: 0,
};

locations = [];

city = { name: null };

country = { name: null };

currentUser!: IUser;

loggedUser!: ICurrentUser;

currentActor!: IPerson;

config!: IConfig;

RouteName = RouteName;

currentUserParticipations: IParticipant[] = [];

supportsWebPFormat = supportsWebPFormat;

closeEvents: Paginate<IEvent> = { elements: [], total: 0 };

// get displayed_name() {
// return this.loggedPerson && this.loggedPerson.name === null
// ? this.loggedPerson.preferredUsername
// : this.loggedPerson.name;
// }

get instanceName(): string | undefined {
if (!this.config) return undefined;
return this.config.name;
}

// eslint-disable-next-line class-methods-use-this
get welcomeBack(): boolean {
return window.localStorage.getItem("welcome-back") === "yes";
}

// eslint-disable-next-line class-methods-use-this
get newRegisteredUser(): boolean {
return window.localStorage.getItem("new-registered-user") === "yes";
}

thisWeek(
row: [string, Map<string, IParticipant>]
): Map<string, IParticipant> {
if (this.isInLessThanSevenDays(row[0])) {
return row[1];
}
return new Map();
}

// eslint-disable-next-line class-methods-use-this
mounted(): void {
if (window.localStorage.getItem("welcome-back")) {
window.localStorage.removeItem("welcome-back");
}
if (window.localStorage.getItem("new-registered-user")) {
window.localStorage.removeItem("new-registered-user");
}
}

// eslint-disable-next-line class-methods-use-this
isToday(date: Date): boolean {
return new Date(date).toDateString() === new Date().toDateString();
}

isTomorrow(date: string): boolean {
return this.isInDays(date, 1);
}

isInDays(date: string, nbDays: number): boolean {
return this.calculateDiffDays(date) === nbDays;
}

isBefore(date: string, nbDays: number): boolean {
return this.calculateDiffDays(date) < nbDays;
}

isAfter(date: string, nbDays: number): boolean {
return this.calculateDiffDays(date) >= nbDays;
}

isInLessThanSevenDays(date: string): boolean {
return this.isBefore(date, 7);
}

// eslint-disable-next-line class-methods-use-this
calculateDiffDays(date: string): number {
return Math.ceil(
(new Date(date).getTime() - new Date().getTime()) / 1000 / 60 / 60 / 24
);
}

get thisWeekGoingToEvents(): IParticipant[] {
const res = this.currentUserParticipations.filter(
({ event, role }) =>
event.beginsOn != null &&
this.isAfter(event.beginsOn.toDateString(), 0) &&
this.isBefore(event.beginsOn.toDateString(), 7) &&
role !== ParticipantRole.REJECTED
);
res.sort(
(a: IParticipant, b: IParticipant) =>
a.event.beginsOn.getTime() - b.event.beginsOn.getTime()
);
return res;
}

get goingToEvents(): Map<string, Map<string, IParticipant>> {
return this.thisWeekGoingToEvents.reduce(
(
acc: Map<string, Map<string, IParticipant>>,
participation: IParticipant

) => {
const day = new Date(participation.event.beginsOn).toDateString();
const participations: Map<string, IParticipant> =
acc.get(day) || new Map();
participations.set(
`${participation.event.uuid}${participation.actor.id}`,
participation
);
acc.set(day, participations);
return acc;
},
new Map()
);
}

get lastWeekEvents(): IParticipant[] {
const res = this.currentUserParticipations.filter(
({ event, role }) =>
event.beginsOn != null &&
this.isBefore(event.beginsOn.toDateString(), 0) &&
role !== ParticipantRole.REJECTED
);
res.sort(
(a: IParticipant, b: IParticipant) =>
a.event.beginsOn.getTime() - b.event.beginsOn.getTime()
);
return res;
}

eventDeleted(eventid: string): void {
this.currentUserParticipations = this.currentUserParticipations.filter(
(participation) => participation.event.id !== eventid
);
}

viewEvent(event: IEvent): void {
this.$router.push({ name: RouteName.EVENT, params: { uuid: event.uuid } });
}

@Watch("loggedUser")
detectEmptyUserSettings(loggedUser: IUser): void {
if (loggedUser && loggedUser.id && loggedUser.settings === null) {
this.$router.push({
name: RouteName.WELCOME_SCREEN,
params: { step: "1" },
});
}
}

get canShowMyUpcomingEvents(): boolean {
return this.currentActor.id != undefined && this.goingToEvents.size > 0;
}

get canShowLastWeekEvents(): boolean {
return this.currentActor && this.lastWeekEvents.length > 0;
}

get canShowCloseEvents(): boolean {
return this.closeEvents.total > 0;
}
}
</script>

<style lang="scss" scoped>
@import "~bulma/sass/utilities/mixins.sass";

main > div > .container {
background: $white;
padding: 1rem 0.5rem 3rem;
}

.search-autocomplete {
border: 1px solid #dbdbdb;
color: rgba(0, 0, 0, 0.87);
}

.events-recent {
& > h3 {
padding-left: 0.75rem;
}

.columns {
margin: 1rem auto 0;
}
}

.date-component-container {
display: flex;
align-items: center;
margin: 0.5rem auto 1rem;

h3.subtitle {
margin-left: 7px;
}
}

span.view-all {
display: block;
margin-top: 1rem;
text-align: right;

a {
text-decoration: underline;
}
}

section.hero {
position: relative;
z-index: 1;

&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.3;
z-index: -1;
background: url("../../public/img/pics/homepage_background-1024w.png");
background-size: cover;
}
&.webp::before {
background-image: url("../../public/img/pics/homepage_background-1024w.webp");
}

& > .hero-body {
padding: 1rem 1.5rem 3rem;
}

.title {
color: $background-color;
}

.column figure.image img {
max-width: 400px;
}

.instance-description {
margin-bottom: 1rem;
}
}

#recent_events {
padding: 0;
min-height: 20vh;
z-index: 10;

.title {
margin: 20px auto 0;
}

.columns {
margin: 0 auto;
}
}

#picture {
.picture-container {
position: relative;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}

& > img {
object-fit: cover;
max-height: 80vh;
display: block;
margin: auto;
width: 100%;
}
}

.container.section {
background: $white;

@include tablet {
margin-top: -4rem;
}
z-index: 10;

.title {
margin: 0 0 10px;
font-size: 30px;
}

.buttons {
justify-content: center;
margin-top: 2rem;
}
}
}

#homepage {
background: $white;
}

.home-separator {
background-color: $orange-2;
}

.clickable {
cursor: pointer;
}

.title {
font-size: 27px;
&:not(:last-child) {
margin-bottom: 0.5rem;
}
}
</style>

Hyperlink with router 1/5 - $router.push

An hyperlink can be translated as a change of route with the router.

  viewEvent(event: IEvent): void {
this.$router.push({ name: RouteName.EVENT, params: { uuid: event.uuid } });
}

In this case, the uuid of the clicked Mobilizon Event is passed to the router to resolve the EVENT route which is the Event's detail page.

Browse code : js/src/views/Home.vue

<template>
<div id="homepage">
<section
class="hero"
:class="{ webp: supportsWebPFormat }"
v-if="config && (!currentUser.id || !currentActor.id)"
>

<div class="hero-body">
<div class="container">
<h1 class="title">
{{ config.slogan || $t("Gather â‹… Organize â‹… Mobilize") }}
</h1>
<p
v-html="
$t('Join <b>{instance}</b>, a Mobilizon instance', {
instance: config.name,
})
"

/>

<p class="instance-description">{{ config.description }}</p>
<!-- We don't invite to find other instances yet -->
<!-- <p v-if="!config.registrationsOpen">
{{ $t("This instance isn't opened to registrations, but you can register on other instances.") }}
</p>-->

<b-message type="is-danger" v-if="!config.registrationsOpen">{{
$t("Unfortunately, this instance isn't opened to registrations")
}}</b-message>
<div class="buttons">
<b-button
type="is-primary"
tag="router-link"
:to="{ name: RouteName.REGISTER }"
v-if="config.registrationsOpen"
>{{ $t("Create an account") }}</b-button
>

<!-- We don't invite to find other instances yet -->
<!-- <b-button v-else type="is-link" tag="a" href="https://joinmastodon.org">{{ $t('Find an instance') }}</b-button> -->
<b-button
type="is-text"
tag="router-link"
:to="{ name: RouteName.ABOUT }"
>

{{ $t("Learn more about {instance}", { instance: config.name }) }}
</b-button>
</div>
</div>
</div>
</section>
<div
id="recent_events"
class="container section"
v-if="config && (!currentUser.id || !currentActor.id)"
>

<section class="events-recent">
<h2 class="title">
{{ $t("Last published events") }}
</h2>
<p>
<i18n tag="span" path="On {instance} and other federated instances">
<b slot="instance">{{ config.name }}</b>
</i18n>
<b-loading :active.sync="$apollo.loading" />
</p>
<b-loading :active.sync="$apollo.loading" />
<div v-if="this.events.total > 0">
<div class="columns is-multiline">
<div
class="column is-one-third-desktop"
v-for="event in this.events.elements.slice(0, 6)"
:key="event.uuid"
>

<EventCard :event="event" />
</div>
</div>
<span class="view-all">
<router-link :to="{ name: RouteName.SEARCH }"
>{{ $t("View everything") }} >></router-link
>

</span>
</div>
<b-message v-else type="is-danger">{{
$t("No events found")
}}</b-message>
</section>
</div>
<div id="picture" v-if="config && (!currentUser.id || !currentActor.id)">
<div class="picture-container">
<picture>
<source
media="(max-width: 799px)"
srcset="/img/pics/homepage-480w.webp"
type="image/webp"
/>

<source
media="(max-width: 799px)"
srcset="/img/pics/homepage-480w.jpg"
type="image/jpeg"
/>


<source
media="(max-width: 1024px)"
srcset="/img/pics/homepage-1024w.webp"
type="image/webp"
/>

<source
media="(max-width: 1024px)"
srcset="/img/pics/homepage-1024w.jpg"
type="image/jpeg"
/>


<source
media="(max-width: 1920px)"
srcset="/img/pics/homepage-1920w.webp"
type="image/webp"
/>

<source
media="(max-width: 1920px)"
srcset="/img/pics/homepage-1920w.jpg"
type="image/jpeg"
/>


<source
media="(min-width: 1921px)"
srcset="/img/pics/homepage.webp"
type="image/webp"
/>

<source
media="(min-width: 1921px)"
srcset="/img/pics/homepage.jpg"
type="image/jpeg"
/>


<img
src="/img/pics/homepage-1024w.jpg"
width="3840"
height="2719"
alt=""
loading="lazy"
/>

</picture>
</div>
<div class="container section">
<div class="columns">
<div class="column">
<h3 class="title">{{ $t("A practical tool") }}</h3>
<p
v-html="
$t(
'Mobilizon is a tool that helps you <b>find, create and organise events</b>.'
)
"

/>

</div>
<div class="column">
<h3 class="title">{{ $t("An ethical alternative") }}</h3>
<p
v-html="
$t(
'Ethical alternative to Facebook events, groups and pages, Mobilizon is a <b>tool designed to serve you</b>. Period.'
)
"

/>

</div>
<div class="column">
<h3 class="title">{{ $t("A federated software") }}</h3>
<p
v-html="
$t(
'Mobilizon is not a giant platform, but a <b>multitude of interconnected Mobilizon websites</b>.'
)
"

/>

</div>
</div>
<div class="buttons">
<a
class="button is-primary is-large"
href="https://joinmobilizon.org"
>{{ $t("Learn more about Mobilizon") }}</a
>

</div>
</div>
</div>
<div
class="container section"
v-if="config && loggedUser && loggedUser.settings"
>

<section v-if="currentActor.id && (welcomeBack || newRegisteredUser)">
<b-message type="is-info" v-if="welcomeBack">{{
$t("Welcome back {username}!", {
username: currentActor.displayName(),
})
}}</b-message>
<b-message type="is-info" v-if="newRegisteredUser">{{
$t("Welcome to Mobilizon, {username}!", {
username: currentActor.displayName(),
})
}}</b-message>
</section>
<!-- Your upcoming events -->
<section v-if="canShowMyUpcomingEvents">
<h2 class="title">{{ $t("Your upcoming events") }}</h2>
<b-loading :active.sync="$apollo.loading" />
<div v-for="row of goingToEvents" class="upcoming-events" :key="row[0]">
<p
class="date-component-container"
v-if="isInLessThanSevenDays(row[0])"
>

<span v-if="isToday(row[0])">{{
$tc("You have one event today.", row[1].length, {
count: row[1].length,
})
}}</span>
<span v-else-if="isTomorrow(row[0])">{{
$tc("You have one event tomorrow.", row[1].length, {
count: row[1].length,
})
}}</span>
<span v-else-if="isInLessThanSevenDays(row[0])">
{{
$tc("You have one event in {days} days.", row[1].length, {
count: row[1].length,
days: calculateDiffDays(row[0]),
})
}}
</span>
</p>
<div>
<EventListCard
v-for="participation in thisWeek(row)"
@event-deleted="eventDeleted"
:key="participation[1].id"
:participation="participation[1]"
/>

</div>
</div>
<span class="view-all">
<router-link :to="{ name: RouteName.MY_EVENTS }"
>{{ $t("View everything") }} >></router-link
>

</span>
</section>
<hr
class="home-separator"
v-if="canShowMyUpcomingEvents && canShowLastWeekEvents"
/>

<!-- Last week events -->
<section v-if="canShowLastWeekEvents">
<h2 class="title">{{ $t("Last week") }}</h2>
<b-loading :active.sync="$apollo.loading" />
<div>
<EventListCard
v-for="participation in lastWeekEvents"
:key="participation.id"
:participation="participation"
@event-deleted="eventDeleted"
:options="{ hideDate: false }"
/>

</div>
</section>
<hr
class="home-separator"
v-if="canShowLastWeekEvents && canShowCloseEvents"
/>

<!-- Events close to you -->
<section class="events-close" v-if="canShowCloseEvents">
<h2 class="title">
{{ $t("Events nearby") }}
</h2>
<p>
{{
$tc(
"Within {number} kilometers of {place}",
loggedUser.settings.location.range,
{
number: loggedUser.settings.location.range,
place: loggedUser.settings.location.name,
}
)
}}
<router-link
:to="{ name: RouteName.PREFERENCES }"
:title="$t('Change')"
>

<b-icon class="clickable" icon="pencil" size="is-small" />
</router-link>
<b-loading :active.sync="$apollo.loading" />
</p>
<div class="columns is-multiline">
<div
class="column is-one-third-desktop"
v-for="event in closeEvents.elements.slice(0, 3)"
:key="event.uuid"
>

<event-card :event="event" />
</div>
</div>
</section>
<hr
class="home-separator"
v-if="
canShowMyUpcomingEvents || canShowLastWeekEvents || canShowCloseEvents
"

/>

<section class="events-recent">
<h2 class="title">
{{ $t("Last published events") }}
</h2>
<p>
<i18n tag="span" path="On {instance} and other federated instances">
<b slot="instance">{{ config.name }}</b>
</i18n>
<b-loading :active.sync="$apollo.loading" />
</p>

<div v-if="this.events.total > 0">
<div class="columns is-multiline">
<div
class="column is-one-third-desktop"
v-for="event in this.events.elements.slice(0, 6)"
:key="event.uuid"
>

<recent-event-card-wrapper :event="event" />
</div>
</div>
<span class="view-all">
<router-link :to="{ name: RouteName.SEARCH }"
>{{ $t("View everything") }} >></router-link
>

</span>
</div>
<b-message v-else type="is-danger"
>
{{ $t("No events found") }}<br />
<div v-if="goingToEvents.size > 0 || lastWeekEvents.length > 0">
<b-icon size="is-small" icon="information-outline" />
<small>{{
$t("The events you created are not shown here.")
}}</small>
</div>
</b-message>
</section>
</div>
</div>
</template>

<script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator";
import { EventSortField, ParticipantRole, SortDirection } from "@/types/enums";
import { Paginate } from "@/types/paginate";
import { supportsWebPFormat } from "@/utils/support";
import { IParticipant, Participant } from "../types/participant.model";
import { CLOSE_EVENTS, FETCH_EVENTS } from "../graphql/event";
import EventListCard from "../components/Event/EventListCard.vue";
import EventCard from "../components/Event/EventCard.vue";
import RecentEventCardWrapper from "../components/Event/RecentEventCardWrapper.vue";
import {
CURRENT_ACTOR_CLIENT,
LOGGED_USER_PARTICIPATIONS,
} from "../graphql/actor";
import { IPerson, Person } from "../types/actor";
import { ICurrentUser, IUser } from "../types/current-user.model";
import { CURRENT_USER_CLIENT, USER_SETTINGS } from "../graphql/user";
import RouteName from "../router/name";
import { IEvent } from "../types/event.model";
import DateComponent from "../components/Event/DateCalendarIcon.vue";
import { CONFIG } from "../graphql/config";
import { IConfig } from "../types/config.model";
import Subtitle from "../components/Utils/Subtitle.vue";

@Component({
apollo: {
events: {
query: FETCH_EVENTS,
fetchPolicy: "no-cache", // Debug me: https://github.com/apollographql/apollo-client/issues/3030
variables: {
orderBy: EventSortField.INSERTED_AT,
direction: SortDirection.DESC,
},
},
currentActor: {
query: CURRENT_ACTOR_CLIENT,
update: (data) => new Person(data.currentActor),
},
currentUser: CURRENT_USER_CLIENT,
loggedUser: {
query: USER_SETTINGS,
fetchPolicy: "network-only",
skip() {
return !this.currentUser || this.currentUser.isLoggedIn === false;
},
error() {
return null;
},
},
config: CONFIG,
currentUserParticipations: {
query: LOGGED_USER_PARTICIPATIONS,
fetchPolicy: "cache-and-network",
variables() {
const lastWeek = new Date();
lastWeek.setDate(new Date().getDate() - 7);
return {
afterDateTime: lastWeek.toISOString(),
};
},
update: (data) =>
data.loggedUser.participations.elements.map(
(participation: IParticipant) => new Participant(participation)
),
skip() {
return this.currentUser?.isLoggedIn === false;
},
},
closeEvents: {
query: CLOSE_EVENTS,
variables() {
return {
location: this.loggedUser?.settings?.location?.geohash,
radius: this.loggedUser?.settings?.location?.range,
};
},
update: (data) => data.searchEvents,
skip() {
return (
!this.currentUser?.isLoggedIn ||
!this.loggedUser?.settings?.location?.geohash ||
!this.loggedUser?.settings?.location?.range
);
},
},
},
components: {
Subtitle,
DateComponent,
EventListCard,
EventCard,
RecentEventCardWrapper,
"settings-onboard": () => import("./User/SettingsOnboard.vue"),
},
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
title: this.instanceName,
titleTemplate: "%s | Mobilizon",
};
},
})
export default class Home extends Vue {
events: Paginate<IEvent> = {
elements: [],
total: 0,
};

locations = [];

city = { name: null };

country = { name: null };

currentUser!: IUser;

loggedUser!: ICurrentUser;

currentActor!: IPerson;

config!: IConfig;

RouteName = RouteName;

currentUserParticipations: IParticipant[] = [];

supportsWebPFormat = supportsWebPFormat;

closeEvents: Paginate<IEvent> = { elements: [], total: 0 };

// get displayed_name() {
// return this.loggedPerson && this.loggedPerson.name === null
// ? this.loggedPerson.preferredUsername
// : this.loggedPerson.name;
// }

get instanceName(): string | undefined {
if (!this.config) return undefined;
return this.config.name;
}

// eslint-disable-next-line class-methods-use-this
get welcomeBack(): boolean {
return window.localStorage.getItem("welcome-back") === "yes";
}

// eslint-disable-next-line class-methods-use-this
get newRegisteredUser(): boolean {
return window.localStorage.getItem("new-registered-user") === "yes";
}

thisWeek(
row: [string, Map<string, IParticipant>]
): Map<string, IParticipant> {
if (this.isInLessThanSevenDays(row[0])) {
return row[1];
}
return new Map();
}

// eslint-disable-next-line class-methods-use-this
mounted(): void {
if (window.localStorage.getItem("welcome-back")) {
window.localStorage.removeItem("welcome-back");
}
if (window.localStorage.getItem("new-registered-user")) {
window.localStorage.removeItem("new-registered-user");
}
}

// eslint-disable-next-line class-methods-use-this
isToday(date: Date): boolean {
return new Date(date).toDateString() === new Date().toDateString();
}

isTomorrow(date: string): boolean {
return this.isInDays(date, 1);
}

isInDays(date: string, nbDays: number): boolean {
return this.calculateDiffDays(date) === nbDays;
}

isBefore(date: string, nbDays: number): boolean {
return this.calculateDiffDays(date) < nbDays;
}

isAfter(date: string, nbDays: number): boolean {
return this.calculateDiffDays(date) >= nbDays;
}

isInLessThanSevenDays(date: string): boolean {
return this.isBefore(date, 7);
}

// eslint-disable-next-line class-methods-use-this
calculateDiffDays(date: string): number {
return Math.ceil(
(new Date(date).getTime() - new Date().getTime()) / 1000 / 60 / 60 / 24
);
}

get thisWeekGoingToEvents(): IParticipant[] {
const res = this.currentUserParticipations.filter(
({ event, role }) =>
event.beginsOn != null &&
this.isAfter(event.beginsOn.toDateString(), 0) &&
this.isBefore(event.beginsOn.toDateString(), 7) &&
role !== ParticipantRole.REJECTED
);
res.sort(
(a: IParticipant, b: IParticipant) =>
a.event.beginsOn.getTime() - b.event.beginsOn.getTime()
);
return res;
}

get goingToEvents(): Map<string, Map<string, IParticipant>> {
return this.thisWeekGoingToEvents.reduce(
(
acc: Map<string, Map<string, IParticipant>>,
participation: IParticipant

) => {
const day = new Date(participation.event.beginsOn).toDateString();
const participations: Map<string, IParticipant> =
acc.get(day) || new Map();
participations.set(
`${participation.event.uuid}${participation.actor.id}`,
participation
);
acc.set(day, participations);
return acc;
},
new Map()
);
}

get lastWeekEvents(): IParticipant[] {
const res = this.currentUserParticipations.filter(
({ event, role }) =>
event.beginsOn != null &&
this.isBefore(event.beginsOn.toDateString(), 0) &&
role !== ParticipantRole.REJECTED
);
res.sort(
(a: IParticipant, b: IParticipant) =>
a.event.beginsOn.getTime() - b.event.beginsOn.getTime()
);
return res;
}

eventDeleted(eventid: string): void {
this.currentUserParticipations = this.currentUserParticipations.filter(
(participation) => participation.event.id !== eventid
);
}

viewEvent(event: IEvent): void {
this.$router.push({ name: RouteName.EVENT, params: { uuid: event.uuid } });
}

@Watch("loggedUser")
detectEmptyUserSettings(loggedUser: IUser): void {
if (loggedUser && loggedUser.id && loggedUser.settings === null) {
this.$router.push({
name: RouteName.WELCOME_SCREEN,
params: { step: "1" },
});
}
}

get canShowMyUpcomingEvents(): boolean {
return this.currentActor.id != undefined && this.goingToEvents.size > 0;
}

get canShowLastWeekEvents(): boolean {
return this.currentActor && this.lastWeekEvents.length > 0;
}

get canShowCloseEvents(): boolean {
return this.closeEvents.total > 0;
}
}
</script>

<style lang="scss" scoped>
@import "~bulma/sass/utilities/mixins.sass";

main > div > .container {
background: $white;
padding: 1rem 0.5rem 3rem;
}

.search-autocomplete {
border: 1px solid #dbdbdb;
color: rgba(0, 0, 0, 0.87);
}

.events-recent {
& > h3 {
padding-left: 0.75rem;
}

.columns {
margin: 1rem auto 0;
}
}

.date-component-container {
display: flex;
align-items: center;
margin: 0.5rem auto 1rem;

h3.subtitle {
margin-left: 7px;
}
}

span.view-all {
display: block;
margin-top: 1rem;
text-align: right;

a {
text-decoration: underline;
}
}

section.hero {
position: relative;
z-index: 1;

&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.3;
z-index: -1;
background: url("../../public/img/pics/homepage_background-1024w.png");
background-size: cover;
}
&.webp::before {
background-image: url("../../public/img/pics/homepage_background-1024w.webp");
}

& > .hero-body {
padding: 1rem 1.5rem 3rem;
}

.title {
color: $background-color;
}

.column figure.image img {
max-width: 400px;
}

.instance-description {
margin-bottom: 1rem;
}
}

#recent_events {
padding: 0;
min-height: 20vh;
z-index: 10;

.title {
margin: 20px auto 0;
}

.columns {
margin: 0 auto;
}
}

#picture {
.picture-container {
position: relative;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}

& > img {
object-fit: cover;
max-height: 80vh;
display: block;
margin: auto;
width: 100%;
}
}

.container.section {
background: $white;

@include tablet {
margin-top: -4rem;
}
z-index: 10;

.title {
margin: 0 0 10px;
font-size: 30px;
}

.buttons {
justify-content: center;
margin-top: 2rem;
}
}
}

#homepage {
background: $white;
}

.home-separator {
background-color: $orange-2;
}

.clickable {
cursor: pointer;
}

.title {
font-size: 27px;
&:not(:last-child) {
margin-bottom: 0.5rem;
}
}
</style>

Hyperlink with router 2/5 - route definition

The uuid provided previously is passed to render the component views/Event/Event.vue

Browse code : js/src/router/event.ts

import { RouteConfig, Route } from "vue-router";
import { ImportedComponent } from "vue/types/options";

const participations = (): Promise<ImportedComponent> =>
import(
/* webpackChunkName: "participations" */ "@/views/Event/Participants.vue"
);
const editEvent = (): Promise<ImportedComponent> =>
import(/* webpackChunkName: "edit-event" */ "@/views/Event/Edit.vue");
const event = (): Promise<ImportedComponent> =>
import(/* webpackChunkName: "event" */ "@/views/Event/Event.vue");
const myEvents = (): Promise<ImportedComponent> =>
import(/* webpackChunkName: "my-events" */ "@/views/Event/MyEvents.vue");

export enum EventRouteName {
EVENT_LIST = "EventList",
CREATE_EVENT = "CreateEvent",
MY_EVENTS = "MyEvents",
EDIT_EVENT = "EditEvent",
DUPLICATE_EVENT = "DuplicateEvent",
PARTICIPATIONS = "Participations",
EVENT = "Event",
EVENT_PARTICIPATE_WITH_ACCOUNT = "EVENT_PARTICIPATE_WITH_ACCOUNT",
EVENT_PARTICIPATE_WITHOUT_ACCOUNT = "EVENT_PARTICIPATE_WITHOUT_ACCOUNT",
EVENT_PARTICIPATE_LOGGED_OUT = "EVENT_PARTICIPATE_LOGGED_OUT",
EVENT_PARTICIPATE_CONFIRM = "EVENT_PARTICIPATE_CONFIRM",
TAG = "Tag",
}

export const eventRoutes: RouteConfig[] = [
{
path: "/events/list/:location?",
name: EventRouteName.EVENT_LIST,
component: (): Promise<ImportedComponent> =>
import(/* webpackChunkName: "EventList" */ "@/views/Event/EventList.vue"),
meta: { requiredAuth: false },
},
{
path: "/events/create",
name: EventRouteName.CREATE_EVENT,
component: editEvent,
meta: { requiredAuth: true },
},
{
path: "/events/me",
name: EventRouteName.MY_EVENTS,
component: myEvents,
meta: { requiredAuth: true },
},
{
path: "/events/edit/:eventId",
name: EventRouteName.EDIT_EVENT,
component: editEvent,
meta: { requiredAuth: true },
props: (route: Route): Record<string, unknown> => {
return { ...route.params, ...{ isUpdate: true } };
},
},
{
path: "/events/duplicate/:eventId",
name: EventRouteName.DUPLICATE_EVENT,
component: editEvent,
meta: { requiredAuth: true },
props: (route: Route): Record<string, unknown> => ({
...route.params,
...{ isDuplicate: true },
}),
},
{
path: "/events/:eventId/participations",
name: EventRouteName.PARTICIPATIONS,
component: participations,
meta: { requiredAuth: true },
props: true,
},
{
path: "/events/:uuid",
name: EventRouteName.EVENT,
component: event,
props: true,
meta: { requiredAuth: false },
},
{
path: "/events/:uuid/participate",
name: EventRouteName.EVENT_PARTICIPATE_LOGGED_OUT,
component: (): Promise<ImportedComponent> =>
import("../components/Participation/UnloggedParticipation.vue"),
props: true,
},
{
path: "/events/:uuid/participate/with-account",
name: EventRouteName.EVENT_PARTICIPATE_WITH_ACCOUNT,
component: (): Promise<ImportedComponent> =>
import("../components/Participation/ParticipationWithAccount.vue"),
props: true,
},
{
path: "/events/:uuid/participate/without-account",
name: EventRouteName.EVENT_PARTICIPATE_WITHOUT_ACCOUNT,
component: (): Promise<ImportedComponent> =>
import("../components/Participation/ParticipationWithoutAccount.vue"),
props: true,
},
{
path: "/participation/email/confirm/:token",
name: EventRouteName.EVENT_PARTICIPATE_CONFIRM,
component: (): Promise<ImportedComponent> =>
import("../components/Participation/ConfirmParticipation.vue"),
props: true,
},
{
path: "/tag/:tag",
name: EventRouteName.TAG,
component: (): Promise<ImportedComponent> =>
import(/* webpackChunkName: "Search" */ "@/views/Search.vue"),
props: true,
meta: { requiredAuth: false },
},
];

Hyperlink with router 3/5 - component prop from router

Here, we can see that the event's uuid from the router is present as a @Prop for the component.

Browse code : js/src/views/Event/Event.vue

<template>
<div class="container">
<b-loading :active.sync="$apollo.queries.event.loading" />
<div class="wrapper">
<event-banner :picture="event.picture" />
<div class="intro-wrapper">
<div class="date-calendar-icon-wrapper">
<date-calendar-icon :date="event.beginsOn" />
</div>
<section class="intro">
<div class="columns">
<div class="column">
<h1 class="title" style="margin: 0">{{ event.title }}</h1>
<div class="organizer">
<span v-if="event.organizerActor && !event.attributedTo">
<popover-actor-card
:actor="event.organizerActor"
:inline="true"
>

<span>
{{
$t("By @{username}", {
username: usernameWithDomain(event.organizerActor),
})
}}
</span>
</popover-actor-card>
</span>
<span
v-else-if="
event.attributedTo &&
event.options.hideOrganizerWhenGroupEvent
"

>

<popover-actor-card
:actor="event.attributedTo"
:inline="true"
>

{{
$t("By @{group}", {
group: usernameWithDomain(event.attributedTo),
})
}}
</popover-actor-card>
</span>
<span v-else-if="event.organizerActor && event.attributedTo">
<i18n path="By {group}">
<popover-actor-card
:actor="event.attributedTo"
slot="group"
:inline="true"
>

<router-link
:to="{
name: RouteName.GROUP,
params: {
preferredUsername: usernameWithDomain(
event.attributedTo
),
},
}"

>

{{
$t("@{group}", {
group: usernameWithDomain(event.attributedTo),
})
}}
</router-link>
</popover-actor-card>
</i18n>
</span>
</div>
<p class="tags" v-if="event.tags && event.tags.length > 0">
<router-link
v-for="tag in event.tags"
:key="tag.title"
:to="{ name: RouteName.TAG, params: { tag: tag.title } }"
>

<tag>{{ tag.title }}</tag>
</router-link>
</p>
<b-tag type="is-warning" size="is-medium" v-if="event.draft"
>
{{ $t("Draft") }}
</b-tag>
<span
class="event-status"
v-if="event.status !== EventStatus.CONFIRMED"
>

<b-tag
type="is-warning"
v-if="event.status === EventStatus.TENTATIVE"
>{{ $t("Event to be confirmed") }}</b-tag
>

<b-tag
type="is-danger"
v-if="event.status === EventStatus.CANCELLED"
>{{ $t("Event cancelled") }}</b-tag
>

</span>
</div>
<div class="column is-3-tablet">
<participation-section
:participation="participations[0]"
:event="event"
:anonymousParticipation="anonymousParticipation"
@join-event="joinEvent"
@join-modal="isJoinModalActive = true"
@join-event-with-confirmation="joinEventWithConfirmation"
@confirm-leave="confirmLeave"
@cancel-anonymous-participation="cancelAnonymousParticipation"
/>

<div class="has-text-right">
<template class="visibility" v-if="!event.draft">
<p v-if="event.visibility === EventVisibility.PUBLIC">
{{ $t("Public event") }}
<b-icon icon="earth" />
</p>
<p v-if="event.visibility === EventVisibility.UNLISTED">
{{ $t("Private event") }}
<b-icon icon="link" />
</p>
</template>
<template v-if="!event.local && organizer.domain">
<a :href="event.url">
<tag>{{ organizer.domain }}</tag>
</a>
</template>
<p>
<router-link
class="participations-link"
v-if="canManageEvent && event.draft === false"
:to="{
name: RouteName.PARTICIPATIONS,
params: { eventId: event.uuid },
}"

>

<!-- We retire one because of the event creator who is a participant -->
<span v-if="event.options.maximumAttendeeCapacity">
{{
$tc(
"{available}/{capacity} available places",
event.options.maximumAttendeeCapacity -
event.participantStats.participant,
{
available:
event.options.maximumAttendeeCapacity -
event.participantStats.participant,
capacity: event.options.maximumAttendeeCapacity,
}
)
}}
</span>
<span v-else>
{{
$tc(
"No one is participating|One person participating|{going} people participating",
event.participantStats.participant,
{
going: event.participantStats.participant,
}
)
}}
</span>
</router-link>
<span v-else>
<span v-if="event.options.maximumAttendeeCapacity">
{{
$tc(
"{available}/{capacity} available places",
event.options.maximumAttendeeCapacity -
event.participantStats.participant,
{
available:
event.options.maximumAttendeeCapacity -
event.participantStats.participant,
capacity: event.options.maximumAttendeeCapacity,
}
)
}}
</span>
<span v-else>
{{
$tc(
"No one is participating|One person participating|{going} people participating",
event.participantStats.participant,
{
going: event.participantStats.participant,
}
)
}}
</span>
</span>
<b-tooltip
type="is-dark"
v-if="!event.local"
:label="
$t(
'The actual number of participants may differ, as this event is hosted on another instance.'
)
"

>

<b-icon size="is-small" icon="help-circle-outline" />
</b-tooltip>
<b-icon icon="ticket-confirmation-outline" />
</p>
<b-dropdown position="is-bottom-left" aria-role="list">
<b-button
slot="trigger"
role="button"
icon-right="dots-horizontal"
>

{{ $t("Actions") }}
</b-button>
<b-dropdown-item
aria-role="listitem"
has-link
v-if="canManageEvent || event.draft"
>

<router-link
:to="{
name: RouteName.EDIT_EVENT,
params: { eventId: event.uuid },
}"

>

{{ $t("Edit") }}
<b-icon icon="pencil" />
</router-link>
</b-dropdown-item>
<b-dropdown-item
aria-role="listitem"
has-link
v-if="canManageEvent || event.draft"
>

<router-link
:to="{
name: RouteName.DUPLICATE_EVENT,
params: { eventId: event.uuid },
}"

>

{{ $t("Duplicate") }}
<b-icon icon="content-duplicate" />
</router-link>
</b-dropdown-item>
<b-dropdown-item
aria-role="listitem"
v-if="canManageEvent || event.draft"
@click="openDeleteEventModalWrapper"
>

{{ $t("Delete") }}
<b-icon icon="delete" />
</b-dropdown-item>

<hr
class="dropdown-divider"
aria-role="menuitem"
v-if="canManageEvent || event.draft"
/>

<b-dropdown-item
aria-role="listitem"
v-if="!event.draft"
@click="triggerShare()"
>

<span>
{{ $t("Share this event") }}
<b-icon icon="share" />
</span>
</b-dropdown-item>
<b-dropdown-item
aria-role="listitem"
@click="downloadIcsEvent()"
v-if="!event.draft"
>

<span>
{{ $t("Add to my calendar") }}
<b-icon icon="calendar-plus" />
</span>
</b-dropdown-item>
<b-dropdown-item
aria-role="listitem"
v-if="ableToReport"
@click="isReportModalActive = true"
>

<span>
{{ $t("Report") }}
<b-icon icon="flag" />
</span>
</b-dropdown-item>
</b-dropdown>
</div>
</div>
</div>
</section>
</div>
<div class="event-description-wrapper">
<aside class="event-metadata">
<div class="sticky">
<event-metadata-sidebar
v-if="event && config"
:event="event"
:config="config"
/>

</div>
</aside>
<div class="event-description-comments">
<section class="event-description">
<subtitle>{{ $t("About this event") }}</subtitle>
<p v-if="!event.description">
{{ $t("The event organizer didn't add any description.") }}
</p>
<div v-else>
<div
class="description-content"
ref="eventDescriptionElement"
v-html="event.description"
/>

</div>
</section>
<section class="integration-wrappers">
<component
v-for="(metadata, integration) in integrations"
:is="integration"
:key="integration"
:metadata="metadata"
/>

</section>
<section class="comments" ref="commentsObserver">
<a href="#comments">
<subtitle id="comments">{{ $t("Comments") }}</subtitle>
</a>
<comment-tree v-if="loadComments" :event="event" />
</section>
</div>
</div>
<section
class="more-events section"
v-if="event.relatedEvents.length > 0"
>

<h3 class="title has-text-centered">
{{ $t("These events may interest you") }}
</h3>
<div class="columns">
<div
class="column is-one-third-desktop"
v-for="relatedEvent in event.relatedEvents"
:key="relatedEvent.uuid"
>

<EventCard :event="relatedEvent" />
</div>
</div>
</section>
<b-modal
:active.sync="isReportModalActive"
has-modal-card
ref="reportModal"
>

<report-modal
:on-confirm="reportEvent"
:title="$t('Report this event')"
:outside-domain="organizerDomain"
@close="$refs.reportModal.close()"
/>

</b-modal>
<b-modal
:active.sync="isShareModalActive"
has-modal-card
ref="shareModal"
>

<share-event-modal :event="event" :eventCapacityOK="eventCapacityOK" />
</b-modal>
<b-modal
:active.sync="isJoinModalActive"
has-modal-card
ref="participationModal"
>

<identity-picker v-model="identity">
<template v-slot:footer>
<footer class="modal-card-foot">
<button
class="button"
ref="cancelButton"
@click="isJoinModalActive = false"
>

{{ $t("Cancel") }}
</button>
<button
class="button is-primary"
ref="confirmButton"
@click="
event.joinOptions === EventJoinOptions.RESTRICTED
? joinEventWithConfirmation(identity)
: joinEvent(identity)
"

>

{{ $t("Confirm my particpation") }}
</button>
</footer>
</template>
</identity-picker>
</b-modal>
<b-modal
:active.sync="isJoinConfirmationModalActive"
has-modal-card
ref="joinConfirmationModal"
>

<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">
{{ $t("Participation confirmation") }}
</p>
</header>

<section class="modal-card-body">
<p>
{{
$t(
"The event organiser has chosen to validate manually participations. Do you want to add a little note to explain why you want to participate to this event?"
)
}}
</p>
<form
@submit.prevent="
joinEvent(actorForConfirmation, messageForConfirmation)
"

>

<b-field :label="$t('Message')">
<b-input
type="textarea"
size="is-medium"
v-model="messageForConfirmation"
minlength="10"
>
</b-input>
</b-field>
<div class="buttons">
<b-button
native-type="button"
class="button"
ref="cancelButton"
@click="isJoinConfirmationModalActive = false"
>
{{ $t("Cancel") }}
</b-button>
<b-button type="is-primary" native-type="submit">
{{ $t("Confirm my participation") }}
</b-button>
</div>
</form>
</section>
</div>
</b-modal>
</div>
</div>
</template>

<script lang="ts">
import { Component, Prop, Watch } from "vue-property-decorator";
import BIcon from "buefy/src/components/icon/Icon.vue";
import {
EventJoinOptions,
EventStatus,
EventVisibility,
MemberRole,
ParticipantRole,
} from "@/types/enums";
import {
EVENT_PERSON_PARTICIPATION,
EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED,
FETCH_EVENT,
JOIN_EVENT,
} from "../../graphql/event";
import {
CURRENT_ACTOR_CLIENT,
PERSON_MEMBERSHIP_GROUP,
} from "../../graphql/actor";
import { EventModel, IEvent } from "../../types/event.model";
import { IActor, IPerson, Person, usernameWithDomain } from "../../types/actor";
import { GRAPHQL_API_ENDPOINT } from "../../api/_entrypoint";
import DateCalendarIcon from "../../components/Event/DateCalendarIcon.vue";
import EventCard from "../../components/Event/EventCard.vue";
import ReportModal from "../../components/Report/ReportModal.vue";
import { IReport } from "../../types/report.model";
import { CREATE_REPORT } from "../../graphql/report";
import EventMixin from "../../mixins/event";
import IdentityPicker from "../Account/IdentityPicker.vue";
import ParticipationSection from "../../components/Participation/ParticipationSection.vue";
import RouteName from "../../router/name";
import CommentTree from "../../components/Comment/CommentTree.vue";
import "intersection-observer";
import { CONFIG } from "../../graphql/config";
import {
AnonymousParticipationNotFoundError,
getLeaveTokenForParticipation,
isParticipatingInThisEvent,
removeAnonymousParticipation,
} from "../../services/AnonymousParticipationStorage";
import { IConfig } from "../../types/config.model";
import Subtitle from "../../components/Utils/Subtitle.vue";
import Tag from "../../components/Tag.vue";
import EventMetadataSidebar from "../../components/Event/EventMetadataSidebar.vue";
import EventBanner from "../../components/Event/EventBanner.vue";
import PopoverActorCard from "../../components/Account/PopoverActorCard.vue";
import { IParticipant } from "../../types/participant.model";
import { ApolloCache, FetchResult } from "@apollo/client/core";
import { IEventMetadataDescription } from "@/types/event-metadata";
import { eventMetaDataList } from "../../services/EventMetadata";

// noinspection TypeScriptValidateTypes
@Component({
components: {
Subtitle,
EventCard,
BIcon,
DateCalendarIcon,
ReportModal,
IdentityPicker,
ParticipationSection,
CommentTree,
Tag,
PopoverActorCard,
EventBanner,
EventMetadataSidebar,
ShareEventModal: () =>
import(
/* webpackChunkName: "shareEventModal" */ "../../components/Event/ShareEventModal.vue"
),
"integration-twitch": () =>
import(
/* webpackChunkName: "twitchIntegration" */ "../../components/Event/Integrations/Twitch.vue"
),
"integration-peertube": () =>
import(
/* webpackChunkName: "PeerTubeIntegration" */ "../../components/Event/Integrations/PeerTube.vue"
),
"integration-youtube": () =>
import(
/* webpackChunkName: "YouTubeIntegration" */ "../../components/Event/Integrations/YouTube.vue"
),
"integration-jitsi-meet": () =>
import(
/* webpackChunkName: "JitsiMeetIntegration" */ "../../components/Event/Integrations/JitsiMeet.vue"
),
"integration-etherpad": () =>
import(
/* webpackChunkName: "EtherpadIntegration" */ "../../components/Event/Integrations/Etherpad.vue"
),
},
apollo: {
event: {
query: FETCH_EVENT,
fetchPolicy: "cache-and-network",
variables() {
return {
uuid: this.uuid,
};
},
error({ graphQLErrors }) {
this.handleErrors(graphQLErrors);
},
},
currentActor: {
query: CURRENT_ACTOR_CLIENT,
},
participations: {
query: EVENT_PERSON_PARTICIPATION,
fetchPolicy: "cache-and-network",
variables() {
return {
eventId: this.event.id,
actorId: this.currentActor.id,
};
},
subscribeToMore: {
document: EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED,
variables() {
return {
eventId: this.event.id,
actorId: this.currentActor.id,
};
},
},
update: (data) => {
if (data && data.person) return data.person.participations.elements;
return [];
},
skip() {
return (
!this.currentActor ||
!this.event ||
!this.event.id ||
!this.currentActor.id
);
},
},
person: {
query: PERSON_MEMBERSHIP_GROUP,
fetchPolicy: "cache-and-network",
variables() {
return {
id: this.currentActor.id,
group: usernameWithDomain(this.event?.attributedTo),
};
},
skip() {
return (
!this.currentActor.id ||
!this.event?.attributedTo ||
!this.event?.attributedTo?.preferredUsername
);
},
},
config: CONFIG,
},
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
title: this.eventTitle,
meta: [
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
{ name: "description", content: this.eventDescription },
],
};
},
})
export default class Event extends EventMixin {
@Prop({ type: String, required: true }) uuid!: string;

event: IEvent = new EventModel();

currentActor!: IPerson;

identity: IPerson = new Person();

config!: IConfig;

person!: IPerson;

participations: IParticipant[] = [];

oldParticipationRole!: string;

isReportModalActive = false;

isShareModalActive = false;

isJoinModalActive = false;

isJoinConfirmationModalActive = false;

EventVisibility = EventVisibility;

EventStatus = EventStatus;

EventJoinOptions = EventJoinOptions;

usernameWithDomain = usernameWithDomain;

RouteName = RouteName;

observer!: IntersectionObserver;

loadComments = false;

anonymousParticipation: boolean | null = null;

actorForConfirmation!: IPerson;

messageForConfirmation = "";

get eventTitle(): undefined | string {
if (!this.event) return undefined;
return this.event.title;
}

get eventDescription(): undefined | string {
if (!this.event) return undefined;
return this.event.description;
}

async mounted(): Promise<void> {
this.identity = this.currentActor;
if (this.$route.hash.includes("#comment-")) {
this.loadComments = true;
}

try {
if (window.isSecureContext) {
this.anonymousParticipation =
await this.anonymousParticipationConfirmed();
}
} catch (e) {
if (e instanceof AnonymousParticipationNotFoundError) {
this.anonymousParticipation = null;
} else {
console.error(e);
}
}

this.observer = new IntersectionObserver(
(entries) => {
// eslint-disable-next-line no-restricted-syntax
for (const entry of entries) {
if (entry) {
this.loadComments = entry.isIntersecting || this.loadComments;
}
}
},
{
rootMargin: "-50px 0px -50px",
}
);
this.observer.observe(this.$refs.commentsObserver as Element);

this.$watch("eventDescription", (eventDescription) => {
if (!eventDescription) return;
const eventDescriptionElement = this.$refs
.eventDescriptionElement as HTMLElement;

eventDescriptionElement.addEventListener("click", ($event) => {
// TODO: Find the right type for target
let { target }: { target: any } = $event;
while (target && target.tagName !== "A") target = target.parentNode;
// handle only links that occur inside the component and do not reference external resources
if (target && target.matches(".hashtag") && target.href) {
// some sanity checks taken from vue-router:
// https://github.com/vuejs/vue-router/blob/dev/src/components/link.js#L106
const {
altKey,
ctrlKey,
metaKey,
shiftKey,
button,
defaultPrevented,
} = $event;
// don't handle with control keys
if (metaKey || altKey || ctrlKey || shiftKey) return;
// don't handle when preventDefault called
if (defaultPrevented) return;
// don't handle right clicks
if (button !== undefined && button !== 0) return;
// don't handle if `target="_blank"`
if (target && target.getAttribute) {
const linkTarget = target.getAttribute("target");
if (/\b_blank\b/i.test(linkTarget)) return;
}
// don't handle same page links/anchors
const url = new URL(target.href);
const to = url.pathname;
if (window.location.pathname !== to && $event.preventDefault) {
$event.preventDefault();
this.$router.push(to);
}
}
});
});

this.$on("event-deleted", () => {
return this.$router.push({ name: RouteName.HOME });
});
}

/**
* Delete the event, then redirect to home.
*/

async openDeleteEventModalWrapper(): Promise<void> {
await this.openDeleteEventModal(this.event);
}

async reportEvent(content: string, forward: boolean): Promise<void> {
this.isReportModalActive = false;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.$refs.reportModal.close();
if (!this.organizer) return;
const eventTitle = this.event.title;

try {
await this.$apollo.mutate<IReport>({
mutation: CREATE_REPORT,
variables: {
eventId: this.event.id,
reportedId: this.organizer ? this.organizer.id : null,
content,
forward,
},
});
this.$notifier.success(
this.$t("Event {eventTitle} reported", { eventTitle }) as string
);
} catch (error) {
console.error(error);
}
}

joinEventWithConfirmation(actor: IPerson): void {
this.isJoinConfirmationModalActive = true;
this.actorForConfirmation = actor;
}

async joinEvent(
identity: IPerson,
message: string | null = null
): Promise<void> {
this.isJoinConfirmationModalActive = false;
this.isJoinModalActive = false;
try {
const { data: mutationData } = await this.$apollo.mutate<{
joinEvent: IParticipant;
}>({
mutation: JOIN_EVENT,
variables: {
eventId: this.event.id,
actorId: identity.id,
message,
},
update: (
store: ApolloCache<{
joinEvent: IParticipant;
}>,
{ data }: FetchResult

) => {
if (data == null) return;

const participationCachedData = store.readQuery<{ person: IPerson }>({
query: EVENT_PERSON_PARTICIPATION,
variables: { eventId: this.event.id, actorId: identity.id },
});

if (participationCachedData?.person == undefined) {
console.error(
"Cannot update participation cache, because of null value."
);
return;
}
store.writeQuery({
query: EVENT_PERSON_PARTICIPATION,
variables: { eventId: this.event.id, actorId: identity.id },
data: {
person: {
...participationCachedData?.person,
participations: {
elements: [data.joinEvent],
total: 1,
},
},
},
});

const cachedData = store.readQuery<{ event: IEvent }>({
query: FETCH_EVENT,
variables: { uuid: this.event.uuid },
});
if (cachedData == null) return;
const { event } = cachedData;
if (event === null) {
console.error(
"Cannot update event participant cache, because of null value."
);
return;
}
const participantStats = { ...event.participantStats };

if (data.joinEvent.role === ParticipantRole.NOT_APPROVED) {
participantStats.notApproved += 1;
} else {
participantStats.going += 1;
participantStats.participant += 1;
}

store.writeQuery({
query: FETCH_EVENT,
variables: { uuid: this.uuid },
data: {
event: {
...event,
participantStats,
},
},
});
},
});
if (mutationData) {
if (mutationData.joinEvent.role === ParticipantRole.NOT_APPROVED) {
this.participationRequestedMessage();
} else {
this.participationConfirmedMessage();
}
}
} catch (error) {
console.error(error);
}
}

confirmLeave(): void {
this.$buefy.dialog.confirm({
title: this.$t('Leaving event "{title}"', {
title: this.event.title,
}) as string,
message: this.$t(
'Are you sure you want to cancel your participation at event "{title}"?',
{
title: this.event.title,
}
) as string,
confirmText: this.$t("Leave event") as string,
cancelText: this.$t("Cancel") as string,
type: "is-danger",
hasIcon: true,
onConfirm: () => {
if (this.currentActor.id) {
this.leaveEvent(this.event, this.currentActor.id);
}
},
});
}

@Watch("participations")
watchParticipations(): void {
if (this.participations.length > 0) {
if (
this.oldParticipationRole &&
this.participations[0].role !== ParticipantRole.NOT_APPROVED &&
this.oldParticipationRole !== this.participations[0].role
) {
switch (this.participations[0].role) {
case ParticipantRole.PARTICIPANT:
this.participationConfirmedMessage();
break;
case ParticipantRole.REJECTED:
this.participationRejectedMessage();
break;
default:
this.participationChangedMessage();
break;
}
}
this.oldParticipationRole = this.participations[0].role;
}
}

private participationConfirmedMessage() {
this.$notifier.success(
this.$t("Your participation has been confirmed") as string
);
}

private participationRequestedMessage() {
this.$notifier.success(
this.$t("Your participation has been requested") as string
);
}

private participationRejectedMessage() {
this.$notifier.error(
this.$t("Your participation has been rejected") as string
);
}

private participationChangedMessage() {
this.$notifier.info(
this.$t("Your participation status has been changed") as string
);
}

async downloadIcsEvent(): Promise<void> {
const data = await (
await fetch(`${GRAPHQL_API_ENDPOINT}/events/${this.uuid}/export/ics`)
).text();
const blob = new Blob([data], { type: "text/calendar" });
const link = document.createElement("a");
link.href = window.URL.createObjectURL(blob);
link.download = `${this.event.title}.ics`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}

triggerShare(): void {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-start
if (navigator.share) {
navigator
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
.share({
title: this.event.title,
url: this.event.url,
})
.then(() => console.log("Successful share"))
.catch((error: any) => console.log("Error sharing", error));
} else {
this.isShareModalActive = true;
// send popup
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-end
}

handleErrors(errors: any[]): void {
if (
errors.some((error) => error.status_code === 404) ||
errors.some(({ message }) => message.includes("has invalid value $uuid"))
) {
this.$router.replace({ name: RouteName.PAGE_NOT_FOUND });
}
}

get actorIsParticipant(): boolean {
if (this.actorIsOrganizer) return true;

return (
this.participations.length > 0 &&
this.participations[0].role === ParticipantRole.PARTICIPANT
);
}

get actorIsOrganizer(): boolean {
return (
this.participations.length > 0 &&
this.participations[0].role === ParticipantRole.CREATOR
);
}

get hasGroupPrivileges(): boolean {
return (
this.person?.memberships?.total > 0 &&
[MemberRole.MODERATOR, MemberRole.ADMINISTRATOR].includes(
this.person?.memberships?.elements[0].role
)
);
}

get canManageEvent(): boolean {
return this.actorIsOrganizer || this.hasGroupPrivileges;
}

get endDate(): Date {
return this.event.endsOn !== null && this.event.endsOn > this.event.beginsOn
? this.event.endsOn
: this.event.beginsOn;
}

get eventCapacityOK(): boolean {
if (this.event.draft) return true;
if (!this.event.options.maximumAttendeeCapacity) return true;
return (
this.event.options.maximumAttendeeCapacity >
this.event.participantStats.participant
);
}

get numberOfPlacesStillAvailable(): number {
if (this.event.draft) return this.event.options.maximumAttendeeCapacity;
return (
this.event.options.maximumAttendeeCapacity -
this.event.participantStats.participant
);
}

async anonymousParticipationConfirmed(): Promise<boolean> {
return isParticipatingInThisEvent(this.uuid);
}

async cancelAnonymousParticipation(): Promise<void> {
const token = (await getLeaveTokenForParticipation(this.uuid)) as string;
await this.leaveEvent(this.event, this.config.anonymous.actorId, token);
await removeAnonymousParticipation(this.uuid);
this.anonymousParticipation = null;
}

get ableToReport(): boolean {
return (
this.config &&
(this.currentActor.id != null || this.config.anonymous.reports.allowed)
);
}

get organizer(): IActor | null {
if (this.event.attributedTo && this.event.attributedTo.id) {
return this.event.attributedTo;
}
if (this.event.organizerActor) {
return this.event.organizerActor;
}
return null;
}

get organizerDomain(): string | null {
if (this.organizer) {
return this.organizer.domain;
}
return null;
}

metadataToComponent: Record<string, string> = {
"mz:live:twitch:url": "integration-twitch",
"mz:live:peertube:url": "integration-peertube",
"mz:live:youtube:url": "integration-youtube",
"mz:visio:jitsi_meet": "integration-jitsi-meet",
"mz:notes:etherpad:url": "integration-etherpad",
};

get integrations(): Record<string, IEventMetadataDescription> {
return this.event.metadata
.map((val) => {
const def = eventMetaDataList.find((dat) => dat.key === val.key);
return {
...def,
...val,
};
})
.reduce((acc: Record<string, IEventMetadataDescription>, metadata) => {
const component = this.metadataToComponent[metadata.key];
if (component !== undefined) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
acc[component] = metadata;
}
return acc;
}, {});
}
}
</script>
<style lang="scss" scoped>
.section {
padding: 1rem 2rem 4rem;
}

.fade-enter-active,
.fade-leave-active
{
transition: opacity 0.5s;
}

.fade-enter,
.fade-leave-to
{
opacity: 0;
}

div.sidebar {
display: flex;
flex-wrap: wrap;
flex-direction: column;

position: relative;

&::before {
content: "";
background: #b3b3b2;
position: absolute;
bottom: 30px;
top: 30px;
left: 0;
height: calc(100% - 60px);
width: 1px;
}

div.organizer {
display: inline-flex;
padding-top: 10px;

a {
color: #4a4a4a;

span {
line-height: 2.7rem;
padding-right: 6px;
}
}
}
}

.intro {
background: white;

.is-3-tablet {
width: initial;
}

p.tags {
a {
text-decoration: none;
}

span {
&.tag {
margin: 0 2px;
}
}
}
}

.event-description-wrapper {
display: flex;
flex-wrap: wrap;
flex-direction: column;
padding: 0;

@media all and (min-width: 672px) {
flex-direction: row-reverse;
}

& > aside,
& > div
{
@media all and (min-width: 672px) {
margin: 2rem auto;
}
}

aside.event-metadata {
min-width: 20rem;
flex: 1;
@media all and (min-width: 672px) {
padding-left: 1rem;
}

.sticky {
position: sticky;
background: white;
top: 50px;
padding: 1rem;
}
}

div.event-description-comments {
min-width: 20rem;
padding: 1rem;
flex: 2;
background: white;
}

.description-content {
::v-deep h1 {
font-size: 2rem;
}

::v-deep h2 {
font-size: 1.5rem;
}

::v-deep h3 {
font-size: 1.25rem;
}

::v-deep ul {
list-style-type: disc;
}

::v-deep li {
margin: 10px auto 10px 2rem;
}

::v-deep blockquote {
border-left: 0.2em solid #333;
display: block;
padding-left: 1em;
}

::v-deep p {
margin: 10px auto;

a {
display: inline-block;
padding: 0.3rem;
background: $secondary;
color: #111;

&:empty {
display: none;
}
}
}
}
}

.comments {
padding-top: 3rem;

a h3#comments {
margin-bottom: 10px;
}
}

.more-events {
background: white;
padding: 1rem 1rem 4rem;

& > .title {
font-size: 1.5rem;
}
}

.dropdown .dropdown-trigger span {
cursor: pointer;
}

a.dropdown-item,
.dropdown .dropdown-menu .has-link a,
button.dropdown-item
{
white-space: nowrap;
width: 100%;
padding-right: 1rem;
text-align: right;
}

a.participations-link {
text-decoration: none;
}

.event-status .tag {
font-size: 1rem;
}

.no-border {
border: 0;
cursor: auto;
}

.wrapper,
.intro-wrapper
{
display: flex;
flex-direction: column;
}

.intro-wrapper {
position: relative;
padding: 0 16px 16px;
background: #fff;

.date-calendar-icon-wrapper {
margin-top: 16px;
height: 0;
display: flex;
align-items: flex-end;
align-self: flex-start;
margin-bottom: 7px;
margin-left: 0rem;
}
}
.title {
margin: 0;
font-size: 2rem;
}
</style>

Hyperlink with router 4/5 - variable in GraphQL

The GraphQL query can then rely on the prop uuid from the component. This way GraphQL can use it as $uuid named variable.

Browse code : js/src/views/Event/Event.vue

<template>
<div class="container">
<b-loading :active.sync="$apollo.queries.event.loading" />
<div class="wrapper">
<event-banner :picture="event.picture" />
<div class="intro-wrapper">
<div class="date-calendar-icon-wrapper">
<date-calendar-icon :date="event.beginsOn" />
</div>
<section class="intro">
<div class="columns">
<div class="column">
<h1 class="title" style="margin: 0">{{ event.title }}</h1>
<div class="organizer">
<span v-if="event.organizerActor && !event.attributedTo">
<popover-actor-card
:actor="event.organizerActor"
:inline="true"
>

<span>
{{
$t("By @{username}", {
username: usernameWithDomain(event.organizerActor),
})
}}
</span>
</popover-actor-card>
</span>
<span
v-else-if="
event.attributedTo &&
event.options.hideOrganizerWhenGroupEvent
"

>

<popover-actor-card
:actor="event.attributedTo"
:inline="true"
>

{{
$t("By @{group}", {
group: usernameWithDomain(event.attributedTo),
})
}}
</popover-actor-card>
</span>
<span v-else-if="event.organizerActor && event.attributedTo">
<i18n path="By {group}">
<popover-actor-card
:actor="event.attributedTo"
slot="group"
:inline="true"
>

<router-link
:to="{
name: RouteName.GROUP,
params: {
preferredUsername: usernameWithDomain(
event.attributedTo
),
},
}"

>

{{
$t("@{group}", {
group: usernameWithDomain(event.attributedTo),
})
}}
</router-link>
</popover-actor-card>
</i18n>
</span>
</div>
<p class="tags" v-if="event.tags && event.tags.length > 0">
<router-link
v-for="tag in event.tags"
:key="tag.title"
:to="{ name: RouteName.TAG, params: { tag: tag.title } }"
>

<tag>{{ tag.title }}</tag>
</router-link>
</p>
<b-tag type="is-warning" size="is-medium" v-if="event.draft"
>
{{ $t("Draft") }}
</b-tag>
<span
class="event-status"
v-if="event.status !== EventStatus.CONFIRMED"
>

<b-tag
type="is-warning"
v-if="event.status === EventStatus.TENTATIVE"
>{{ $t("Event to be confirmed") }}</b-tag
>

<b-tag
type="is-danger"
v-if="event.status === EventStatus.CANCELLED"
>{{ $t("Event cancelled") }}</b-tag
>

</span>
</div>
<div class="column is-3-tablet">
<participation-section
:participation="participations[0]"
:event="event"
:anonymousParticipation="anonymousParticipation"
@join-event="joinEvent"
@join-modal="isJoinModalActive = true"
@join-event-with-confirmation="joinEventWithConfirmation"
@confirm-leave="confirmLeave"
@cancel-anonymous-participation="cancelAnonymousParticipation"
/>

<div class="has-text-right">
<template class="visibility" v-if="!event.draft">
<p v-if="event.visibility === EventVisibility.PUBLIC">
{{ $t("Public event") }}
<b-icon icon="earth" />
</p>
<p v-if="event.visibility === EventVisibility.UNLISTED">
{{ $t("Private event") }}
<b-icon icon="link" />
</p>
</template>
<template v-if="!event.local && organizer.domain">
<a :href="event.url">
<tag>{{ organizer.domain }}</tag>
</a>
</template>
<p>
<router-link
class="participations-link"
v-if="canManageEvent && event.draft === false"
:to="{
name: RouteName.PARTICIPATIONS,
params: { eventId: event.uuid },
}"

>

<!-- We retire one because of the event creator who is a participant -->
<span v-if="event.options.maximumAttendeeCapacity">
{{
$tc(
"{available}/{capacity} available places",
event.options.maximumAttendeeCapacity -
event.participantStats.participant,
{
available:
event.options.maximumAttendeeCapacity -
event.participantStats.participant,
capacity: event.options.maximumAttendeeCapacity,
}
)
}}
</span>
<span v-else>
{{
$tc(
"No one is participating|One person participating|{going} people participating",
event.participantStats.participant,
{
going: event.participantStats.participant,
}
)
}}
</span>
</router-link>
<span v-else>
<span v-if="event.options.maximumAttendeeCapacity">
{{
$tc(
"{available}/{capacity} available places",
event.options.maximumAttendeeCapacity -
event.participantStats.participant,
{
available:
event.options.maximumAttendeeCapacity -
event.participantStats.participant,
capacity: event.options.maximumAttendeeCapacity,
}
)
}}
</span>
<span v-else>
{{
$tc(
"No one is participating|One person participating|{going} people participating",
event.participantStats.participant,
{
going: event.participantStats.participant,
}
)
}}
</span>
</span>
<b-tooltip
type="is-dark"
v-if="!event.local"
:label="
$t(
'The actual number of participants may differ, as this event is hosted on another instance.'
)
"

>

<b-icon size="is-small" icon="help-circle-outline" />
</b-tooltip>
<b-icon icon="ticket-confirmation-outline" />
</p>
<b-dropdown position="is-bottom-left" aria-role="list">
<b-button
slot="trigger"
role="button"
icon-right="dots-horizontal"
>

{{ $t("Actions") }}
</b-button>
<b-dropdown-item
aria-role="listitem"
has-link
v-if="canManageEvent || event.draft"
>

<router-link
:to="{
name: RouteName.EDIT_EVENT,
params: { eventId: event.uuid },
}"

>

{{ $t("Edit") }}
<b-icon icon="pencil" />
</router-link>
</b-dropdown-item>
<b-dropdown-item
aria-role="listitem"
has-link
v-if="canManageEvent || event.draft"
>

<router-link
:to="{
name: RouteName.DUPLICATE_EVENT,
params: { eventId: event.uuid },
}"

>

{{ $t("Duplicate") }}
<b-icon icon="content-duplicate" />
</router-link>
</b-dropdown-item>
<b-dropdown-item
aria-role="listitem"
v-if="canManageEvent || event.draft"
@click="openDeleteEventModalWrapper"
>

{{ $t("Delete") }}
<b-icon icon="delete" />
</b-dropdown-item>

<hr
class="dropdown-divider"
aria-role="menuitem"
v-if="canManageEvent || event.draft"
/>

<b-dropdown-item
aria-role="listitem"
v-if="!event.draft"
@click="triggerShare()"
>

<span>
{{ $t("Share this event") }}
<b-icon icon="share" />
</span>
</b-dropdown-item>
<b-dropdown-item
aria-role="listitem"
@click="downloadIcsEvent()"
v-if="!event.draft"
>

<span>
{{ $t("Add to my calendar") }}
<b-icon icon="calendar-plus" />
</span>
</b-dropdown-item>
<b-dropdown-item
aria-role="listitem"
v-if="ableToReport"
@click="isReportModalActive = true"
>

<span>
{{ $t("Report") }}
<b-icon icon="flag" />
</span>
</b-dropdown-item>
</b-dropdown>
</div>
</div>
</div>
</section>
</div>
<div class="event-description-wrapper">
<aside class="event-metadata">
<div class="sticky">
<event-metadata-sidebar
v-if="event && config"
:event="event"
:config="config"
/>

</div>
</aside>
<div class="event-description-comments">
<section class="event-description">
<subtitle>{{ $t("About this event") }}</subtitle>
<p v-if="!event.description">
{{ $t("The event organizer didn't add any description.") }}
</p>
<div v-else>
<div
class="description-content"
ref="eventDescriptionElement"
v-html="event.description"
/>

</div>
</section>
<section class="integration-wrappers">
<component
v-for="(metadata, integration) in integrations"
:is="integration"
:key="integration"
:metadata="metadata"
/>

</section>
<section class="comments" ref="commentsObserver">
<a href="#comments">
<subtitle id="comments">{{ $t("Comments") }}</subtitle>
</a>
<comment-tree v-if="loadComments" :event="event" />
</section>
</div>
</div>
<section
class="more-events section"
v-if="event.relatedEvents.length > 0"
>

<h3 class="title has-text-centered">
{{ $t("These events may interest you") }}
</h3>
<div class="columns">
<div
class="column is-one-third-desktop"
v-for="relatedEvent in event.relatedEvents"
:key="relatedEvent.uuid"
>

<EventCard :event="relatedEvent" />
</div>
</div>
</section>
<b-modal
:active.sync="isReportModalActive"
has-modal-card
ref="reportModal"
>

<report-modal
:on-confirm="reportEvent"
:title="$t('Report this event')"
:outside-domain="organizerDomain"
@close="$refs.reportModal.close()"
/>

</b-modal>
<b-modal
:active.sync="isShareModalActive"
has-modal-card
ref="shareModal"
>

<share-event-modal :event="event" :eventCapacityOK="eventCapacityOK" />
</b-modal>
<b-modal
:active.sync="isJoinModalActive"
has-modal-card
ref="participationModal"
>

<identity-picker v-model="identity">
<template v-slot:footer>
<footer class="modal-card-foot">
<button
class="button"
ref="cancelButton"
@click="isJoinModalActive = false"
>

{{ $t("Cancel") }}
</button>
<button
class="button is-primary"
ref="confirmButton"
@click="
event.joinOptions === EventJoinOptions.RESTRICTED
? joinEventWithConfirmation(identity)
: joinEvent(identity)
"

>

{{ $t("Confirm my particpation") }}
</button>
</footer>
</template>
</identity-picker>
</b-modal>
<b-modal
:active.sync="isJoinConfirmationModalActive"
has-modal-card
ref="joinConfirmationModal"
>

<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">
{{ $t("Participation confirmation") }}
</p>
</header>

<section class="modal-card-body">
<p>
{{
$t(
"The event organiser has chosen to validate manually participations. Do you want to add a little note to explain why you want to participate to this event?"
)
}}
</p>
<form
@submit.prevent="
joinEvent(actorForConfirmation, messageForConfirmation)
"

>

<b-field :label="$t('Message')">
<b-input
type="textarea"
size="is-medium"
v-model="messageForConfirmation"
minlength="10"
>
</b-input>
</b-field>
<div class="buttons">
<b-button
native-type="button"
class="button"
ref="cancelButton"
@click="isJoinConfirmationModalActive = false"
>
{{ $t("Cancel") }}
</b-button>
<b-button type="is-primary" native-type="submit">
{{ $t("Confirm my participation") }}
</b-button>
</div>
</form>
</section>
</div>
</b-modal>
</div>
</div>
</template>

<script lang="ts">
import { Component, Prop, Watch } from "vue-property-decorator";
import BIcon from "buefy/src/components/icon/Icon.vue";
import {
EventJoinOptions,
EventStatus,
EventVisibility,
MemberRole,
ParticipantRole,
} from "@/types/enums";
import {
EVENT_PERSON_PARTICIPATION,
EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED,
FETCH_EVENT,
JOIN_EVENT,
} from "../../graphql/event";
import {
CURRENT_ACTOR_CLIENT,
PERSON_MEMBERSHIP_GROUP,
} from "../../graphql/actor";
import { EventModel, IEvent } from "../../types/event.model";
import { IActor, IPerson, Person, usernameWithDomain } from "../../types/actor";
import { GRAPHQL_API_ENDPOINT } from "../../api/_entrypoint";
import DateCalendarIcon from "../../components/Event/DateCalendarIcon.vue";
import EventCard from "../../components/Event/EventCard.vue";
import ReportModal from "../../components/Report/ReportModal.vue";
import { IReport } from "../../types/report.model";
import { CREATE_REPORT } from "../../graphql/report";
import EventMixin from "../../mixins/event";
import IdentityPicker from "../Account/IdentityPicker.vue";
import ParticipationSection from "../../components/Participation/ParticipationSection.vue";
import RouteName from "../../router/name";
import CommentTree from "../../components/Comment/CommentTree.vue";
import "intersection-observer";
import { CONFIG } from "../../graphql/config";
import {
AnonymousParticipationNotFoundError,
getLeaveTokenForParticipation,
isParticipatingInThisEvent,
removeAnonymousParticipation,
} from "../../services/AnonymousParticipationStorage";
import { IConfig } from "../../types/config.model";
import Subtitle from "../../components/Utils/Subtitle.vue";
import Tag from "../../components/Tag.vue";
import EventMetadataSidebar from "../../components/Event/EventMetadataSidebar.vue";
import EventBanner from "../../components/Event/EventBanner.vue";
import PopoverActorCard from "../../components/Account/PopoverActorCard.vue";
import { IParticipant } from "../../types/participant.model";
import { ApolloCache, FetchResult } from "@apollo/client/core";
import { IEventMetadataDescription } from "@/types/event-metadata";
import { eventMetaDataList } from "../../services/EventMetadata";

// noinspection TypeScriptValidateTypes
@Component({
components: {
Subtitle,
EventCard,
BIcon,
DateCalendarIcon,
ReportModal,
IdentityPicker,
ParticipationSection,
CommentTree,
Tag,
PopoverActorCard,
EventBanner,
EventMetadataSidebar,
ShareEventModal: () =>
import(
/* webpackChunkName: "shareEventModal" */ "../../components/Event/ShareEventModal.vue"
),
"integration-twitch": () =>
import(
/* webpackChunkName: "twitchIntegration" */ "../../components/Event/Integrations/Twitch.vue"
),
"integration-peertube": () =>
import(
/* webpackChunkName: "PeerTubeIntegration" */ "../../components/Event/Integrations/PeerTube.vue"
),
"integration-youtube": () =>
import(
/* webpackChunkName: "YouTubeIntegration" */ "../../components/Event/Integrations/YouTube.vue"
),
"integration-jitsi-meet": () =>
import(
/* webpackChunkName: "JitsiMeetIntegration" */ "../../components/Event/Integrations/JitsiMeet.vue"
),
"integration-etherpad": () =>
import(
/* webpackChunkName: "EtherpadIntegration" */ "../../components/Event/Integrations/Etherpad.vue"
),
},
apollo: {
event: {
query: FETCH_EVENT,
fetchPolicy: "cache-and-network",
variables() {
return {
uuid: this.uuid,
};
},
error({ graphQLErrors }) {
this.handleErrors(graphQLErrors);
},
},
currentActor: {
query: CURRENT_ACTOR_CLIENT,
},
participations: {
query: EVENT_PERSON_PARTICIPATION,
fetchPolicy: "cache-and-network",
variables() {
return {
eventId: this.event.id,
actorId: this.currentActor.id,
};
},
subscribeToMore: {
document: EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED,
variables() {
return {
eventId: this.event.id,
actorId: this.currentActor.id,
};
},
},
update: (data) => {
if (data && data.person) return data.person.participations.elements;
return [];
},
skip() {
return (
!this.currentActor ||
!this.event ||
!this.event.id ||
!this.currentActor.id
);
},
},
person: {
query: PERSON_MEMBERSHIP_GROUP,
fetchPolicy: "cache-and-network",
variables() {
return {
id: this.currentActor.id,
group: usernameWithDomain(this.event?.attributedTo),
};
},
skip() {
return (
!this.currentActor.id ||
!this.event?.attributedTo ||
!this.event?.attributedTo?.preferredUsername
);
},
},
config: CONFIG,
},
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
title: this.eventTitle,
meta: [
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
{ name: "description", content: this.eventDescription },
],
};
},
})
export default class Event extends EventMixin {
@Prop({ type: String, required: true }) uuid!: string;

event: IEvent = new EventModel();

currentActor!: IPerson;

identity: IPerson = new Person();

config!: IConfig;

person!: IPerson;

participations: IParticipant[] = [];

oldParticipationRole!: string;

isReportModalActive = false;

isShareModalActive = false;

isJoinModalActive = false;

isJoinConfirmationModalActive = false;

EventVisibility = EventVisibility;

EventStatus = EventStatus;

EventJoinOptions = EventJoinOptions;

usernameWithDomain = usernameWithDomain;

RouteName = RouteName;

observer!: IntersectionObserver;

loadComments = false;

anonymousParticipation: boolean | null = null;

actorForConfirmation!: IPerson;

messageForConfirmation = "";

get eventTitle(): undefined | string {
if (!this.event) return undefined;
return this.event.title;
}

get eventDescription(): undefined | string {
if (!this.event) return undefined;
return this.event.description;
}

async mounted(): Promise<void> {
this.identity = this.currentActor;
if (this.$route.hash.includes("#comment-")) {
this.loadComments = true;
}

try {
if (window.isSecureContext) {
this.anonymousParticipation =
await this.anonymousParticipationConfirmed();
}
} catch (e) {
if (e instanceof AnonymousParticipationNotFoundError) {
this.anonymousParticipation = null;
} else {
console.error(e);
}
}

this.observer = new IntersectionObserver(
(entries) => {
// eslint-disable-next-line no-restricted-syntax
for (const entry of entries) {
if (entry) {
this.loadComments = entry.isIntersecting || this.loadComments;
}
}
},
{
rootMargin: "-50px 0px -50px",
}
);
this.observer.observe(this.$refs.commentsObserver as Element);

this.$watch("eventDescription", (eventDescription) => {
if (!eventDescription) return;
const eventDescriptionElement = this.$refs
.eventDescriptionElement as HTMLElement;

eventDescriptionElement.addEventListener("click", ($event) => {
// TODO: Find the right type for target
let { target }: { target: any } = $event;
while (target && target.tagName !== "A") target = target.parentNode;
// handle only links that occur inside the component and do not reference external resources
if (target && target.matches(".hashtag") && target.href) {
// some sanity checks taken from vue-router:
// https://github.com/vuejs/vue-router/blob/dev/src/components/link.js#L106
const {
altKey,
ctrlKey,
metaKey,
shiftKey,
button,
defaultPrevented,
} = $event;
// don't handle with control keys
if (metaKey || altKey || ctrlKey || shiftKey) return;
// don't handle when preventDefault called
if (defaultPrevented) return;
// don't handle right clicks
if (button !== undefined && button !== 0) return;
// don't handle if `target="_blank"`
if (target && target.getAttribute) {
const linkTarget = target.getAttribute("target");
if (/\b_blank\b/i.test(linkTarget)) return;
}
// don't handle same page links/anchors
const url = new URL(target.href);
const to = url.pathname;
if (window.location.pathname !== to && $event.preventDefault) {
$event.preventDefault();
this.$router.push(to);
}
}
});
});

this.$on("event-deleted", () => {
return this.$router.push({ name: RouteName.HOME });
});
}

/**
* Delete the event, then redirect to home.
*/

async openDeleteEventModalWrapper(): Promise<void> {
await this.openDeleteEventModal(this.event);
}

async reportEvent(content: string, forward: boolean): Promise<void> {
this.isReportModalActive = false;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.$refs.reportModal.close();
if (!this.organizer) return;
const eventTitle = this.event.title;

try {
await this.$apollo.mutate<IReport>({
mutation: CREATE_REPORT,
variables: {
eventId: this.event.id,
reportedId: this.organizer ? this.organizer.id : null,
content,
forward,
},
});
this.$notifier.success(
this.$t("Event {eventTitle} reported", { eventTitle }) as string
);
} catch (error) {
console.error(error);
}
}

joinEventWithConfirmation(actor: IPerson): void {
this.isJoinConfirmationModalActive = true;
this.actorForConfirmation = actor;
}

async joinEvent(
identity: IPerson,
message: string | null = null
): Promise<void> {
this.isJoinConfirmationModalActive = false;
this.isJoinModalActive = false;
try {
const { data: mutationData } = await this.$apollo.mutate<{
joinEvent: IParticipant;
}>({
mutation: JOIN_EVENT,
variables: {
eventId: this.event.id,
actorId: identity.id,
message,
},
update: (
store: ApolloCache<{
joinEvent: IParticipant;
}>,
{ data }: FetchResult

) => {
if (data == null) return;

const participationCachedData = store.readQuery<{ person: IPerson }>({
query: EVENT_PERSON_PARTICIPATION,
variables: { eventId: this.event.id, actorId: identity.id },
});

if (participationCachedData?.person == undefined) {
console.error(
"Cannot update participation cache, because of null value."
);
return;
}
store.writeQuery({
query: EVENT_PERSON_PARTICIPATION,
variables: { eventId: this.event.id, actorId: identity.id },
data: {
person: {
...participationCachedData?.person,
participations: {
elements: [data.joinEvent],
total: 1,
},
},
},
});

const cachedData = store.readQuery<{ event: IEvent }>({
query: FETCH_EVENT,
variables: { uuid: this.event.uuid },
});
if (cachedData == null) return;
const { event } = cachedData;
if (event === null) {
console.error(
"Cannot update event participant cache, because of null value."
);
return;
}
const participantStats = { ...event.participantStats };

if (data.joinEvent.role === ParticipantRole.NOT_APPROVED) {
participantStats.notApproved += 1;
} else {
participantStats.going += 1;
participantStats.participant += 1;
}

store.writeQuery({
query: FETCH_EVENT,
variables: { uuid: this.uuid },
data: {
event: {
...event,
participantStats,
},
},
});
},
});
if (mutationData) {
if (mutationData.joinEvent.role === ParticipantRole.NOT_APPROVED) {
this.participationRequestedMessage();
} else {
this.participationConfirmedMessage();
}
}
} catch (error) {
console.error(error);
}
}

confirmLeave(): void {
this.$buefy.dialog.confirm({
title: this.$t('Leaving event "{title}"', {
title: this.event.title,
}) as string,
message: this.$t(
'Are you sure you want to cancel your participation at event "{title}"?',
{
title: this.event.title,
}
) as string,
confirmText: this.$t("Leave event") as string,
cancelText: this.$t("Cancel") as string,
type: "is-danger",
hasIcon: true,
onConfirm: () => {
if (this.currentActor.id) {
this.leaveEvent(this.event, this.currentActor.id);
}
},
});
}

@Watch("participations")
watchParticipations(): void {
if (this.participations.length > 0) {
if (
this.oldParticipationRole &&
this.participations[0].role !== ParticipantRole.NOT_APPROVED &&
this.oldParticipationRole !== this.participations[0].role
) {
switch (this.participations[0].role) {
case ParticipantRole.PARTICIPANT:
this.participationConfirmedMessage();
break;
case ParticipantRole.REJECTED:
this.participationRejectedMessage();
break;
default:
this.participationChangedMessage();
break;
}
}
this.oldParticipationRole = this.participations[0].role;
}
}

private participationConfirmedMessage() {
this.$notifier.success(
this.$t("Your participation has been confirmed") as string
);
}

private participationRequestedMessage() {
this.$notifier.success(
this.$t("Your participation has been requested") as string
);
}

private participationRejectedMessage() {
this.$notifier.error(
this.$t("Your participation has been rejected") as string
);
}

private participationChangedMessage() {
this.$notifier.info(
this.$t("Your participation status has been changed") as string
);
}

async downloadIcsEvent(): Promise<void> {
const data = await (
await fetch(`${GRAPHQL_API_ENDPOINT}/events/${this.uuid}/export/ics`)
).text();
const blob = new Blob([data], { type: "text/calendar" });
const link = document.createElement("a");
link.href = window.URL.createObjectURL(blob);
link.download = `${this.event.title}.ics`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}

triggerShare(): void {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-start
if (navigator.share) {
navigator
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
.share({
title: this.event.title,
url: this.event.url,
})
.then(() => console.log("Successful share"))
.catch((error: any) => console.log("Error sharing", error));
} else {
this.isShareModalActive = true;
// send popup
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-end
}

handleErrors(errors: any[]): void {
if (
errors.some((error) => error.status_code === 404) ||
errors.some(({ message }) => message.includes("has invalid value $uuid"))
) {
this.$router.replace({ name: RouteName.PAGE_NOT_FOUND });
}
}

get actorIsParticipant(): boolean {
if (this.actorIsOrganizer) return true;

return (
this.participations.length > 0 &&
this.participations[0].role === ParticipantRole.PARTICIPANT
);
}

get actorIsOrganizer(): boolean {
return (
this.participations.length > 0 &&
this.participations[0].role === ParticipantRole.CREATOR
);
}

get hasGroupPrivileges(): boolean {
return (
this.person?.memberships?.total > 0 &&
[MemberRole.MODERATOR, MemberRole.ADMINISTRATOR].includes(
this.person?.memberships?.elements[0].role
)
);
}

get canManageEvent(): boolean {
return this.actorIsOrganizer || this.hasGroupPrivileges;
}

get endDate(): Date {
return this.event.endsOn !== null && this.event.endsOn > this.event.beginsOn
? this.event.endsOn
: this.event.beginsOn;
}

get eventCapacityOK(): boolean {
if (this.event.draft) return true;
if (!this.event.options.maximumAttendeeCapacity) return true;
return (
this.event.options.maximumAttendeeCapacity >
this.event.participantStats.participant
);
}

get numberOfPlacesStillAvailable(): number {
if (this.event.draft) return this.event.options.maximumAttendeeCapacity;
return (
this.event.options.maximumAttendeeCapacity -
this.event.participantStats.participant
);
}

async anonymousParticipationConfirmed(): Promise<boolean> {
return isParticipatingInThisEvent(this.uuid);
}

async cancelAnonymousParticipation(): Promise<void> {
const token = (await getLeaveTokenForParticipation(this.uuid)) as string;
await this.leaveEvent(this.event, this.config.anonymous.actorId, token);
await removeAnonymousParticipation(this.uuid);
this.anonymousParticipation = null;
}

get ableToReport(): boolean {
return (
this.config &&
(this.currentActor.id != null || this.config.anonymous.reports.allowed)
);
}

get organizer(): IActor | null {
if (this.event.attributedTo && this.event.attributedTo.id) {
return this.event.attributedTo;
}
if (this.event.organizerActor) {
return this.event.organizerActor;
}
return null;
}

get organizerDomain(): string | null {
if (this.organizer) {
return this.organizer.domain;
}
return null;
}

metadataToComponent: Record<string, string> = {
"mz:live:twitch:url": "integration-twitch",
"mz:live:peertube:url": "integration-peertube",
"mz:live:youtube:url": "integration-youtube",
"mz:visio:jitsi_meet": "integration-jitsi-meet",
"mz:notes:etherpad:url": "integration-etherpad",
};

get integrations(): Record<string, IEventMetadataDescription> {
return this.event.metadata
.map((val) => {
const def = eventMetaDataList.find((dat) => dat.key === val.key);
return {
...def,
...val,
};
})
.reduce((acc: Record<string, IEventMetadataDescription>, metadata) => {
const component = this.metadataToComponent[metadata.key];
if (component !== undefined) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
acc[component] = metadata;
}
return acc;
}, {});
}
}
</script>
<style lang="scss" scoped>
.section {
padding: 1rem 2rem 4rem;
}

.fade-enter-active,
.fade-leave-active
{
transition: opacity 0.5s;
}

.fade-enter,
.fade-leave-to
{
opacity: 0;
}

div.sidebar {
display: flex;
flex-wrap: wrap;
flex-direction: column;

position: relative;

&::before {
content: "";
background: #b3b3b2;
position: absolute;
bottom: 30px;
top: 30px;
left: 0;
height: calc(100% - 60px);
width: 1px;
}

div.organizer {
display: inline-flex;
padding-top: 10px;

a {
color: #4a4a4a;

span {
line-height: 2.7rem;
padding-right: 6px;
}
}
}
}

.intro {
background: white;

.is-3-tablet {
width: initial;
}

p.tags {
a {
text-decoration: none;
}

span {
&.tag {
margin: 0 2px;
}
}
}
}

.event-description-wrapper {
display: flex;
flex-wrap: wrap;
flex-direction: column;
padding: 0;

@media all and (min-width: 672px) {
flex-direction: row-reverse;
}

& > aside,
& > div
{
@media all and (min-width: 672px) {
margin: 2rem auto;
}
}

aside.event-metadata {
min-width: 20rem;
flex: 1;
@media all and (min-width: 672px) {
padding-left: 1rem;
}

.sticky {
position: sticky;
background: white;
top: 50px;
padding: 1rem;
}
}

div.event-description-comments {
min-width: 20rem;
padding: 1rem;
flex: 2;
background: white;
}

.description-content {
::v-deep h1 {
font-size: 2rem;
}

::v-deep h2 {
font-size: 1.5rem;
}

::v-deep h3 {
font-size: 1.25rem;
}

::v-deep ul {
list-style-type: disc;
}

::v-deep li {
margin: 10px auto 10px 2rem;
}

::v-deep blockquote {
border-left: 0.2em solid #333;
display: block;
padding-left: 1em;
}

::v-deep p {
margin: 10px auto;

a {
display: inline-block;
padding: 0.3rem;
background: $secondary;
color: #111;

&:empty {
display: none;
}
}
}
}
}

.comments {
padding-top: 3rem;

a h3#comments {
margin-bottom: 10px;
}
}

.more-events {
background: white;
padding: 1rem 1rem 4rem;

& > .title {
font-size: 1.5rem;
}
}

.dropdown .dropdown-trigger span {
cursor: pointer;
}

a.dropdown-item,
.dropdown .dropdown-menu .has-link a,
button.dropdown-item
{
white-space: nowrap;
width: 100%;
padding-right: 1rem;
text-align: right;
}

a.participations-link {
text-decoration: none;
}

.event-status .tag {
font-size: 1rem;
}

.no-border {
border: 0;
cursor: auto;
}

.wrapper,
.intro-wrapper
{
display: flex;
flex-direction: column;
}

.intro-wrapper {
position: relative;
padding: 0 16px 16px;
background: #fff;

.date-calendar-icon-wrapper {
margin-top: 16px;
height: 0;
display: flex;
align-items: flex-end;
align-self: flex-start;
margin-bottom: 7px;
margin-left: 0rem;
}
}
.title {
margin: 0;
font-size: 2rem;
}
</style>

Hyperlink with router 5/5 - GraphQL query with variable

The named variable $uuid from the component's prop can be used for GraphQL query.

Browse code : js/src/graphql/event.ts

import gql from "graphql-tag";
import { ADDRESS_FRAGMENT } from "./address";
import { TAG_FRAGMENT } from "./tags";

const PARTICIPANT_QUERY_FRAGMENT = gql`
fragment ParticipantQuery on Participant {
role
id
actor {
preferredUsername
avatar {
id
url
}
name
id
domain
}
event {
id
uuid
}
metadata {
cancellationToken
message
}
insertedAt
}
`
;

const PARTICIPANTS_QUERY_FRAGMENT = gql`
fragment ParticipantsQuery on PaginatedParticipantList {
total
elements {
...ParticipantQuery
}
}
${PARTICIPANT_QUERY_FRAGMENT}
`
;

const EVENT_OPTIONS_FRAGMENT = gql`
fragment EventOptions on EventOptions {
maximumAttendeeCapacity
remainingAttendeeCapacity
showRemainingAttendeeCapacity
anonymousParticipation
showStartTime
showEndTime
offers {
price
priceCurrency
url
}
participationConditions {
title
content
url
}
attendees
program
commentModeration
showParticipationPrice
hideOrganizerWhenGroupEvent
}
`
;

const FULL_EVENT_FRAGMENT = gql`
fragment FullEvent on Event {
id
uuid
url
local
title
description
beginsOn
endsOn
status
visibility
joinOptions
draft
picture {
id
url
name
metadata {
width
height
blurhash
}
}
publishAt
onlineAddress
phoneAddress
physicalAddress {
...AdressFragment
}
organizerActor {
avatar {
id
url
}
preferredUsername
domain
name
url
id
summary
}
contacts {
avatar {
id
url
}
preferredUsername
name
summary
domain
url
id
}
attributedTo {
avatar {
id
url
}
preferredUsername
name
summary
domain
url
id
}
participantStats {
going
notApproved
participant
}
tags {
...TagFragment
}
relatedEvents {
id
uuid
title
beginsOn
picture {
id
url
name
metadata {
width
height
blurhash
}
}
physicalAddress {
id
description
}
organizerActor {
id
avatar {
id
url
}
preferredUsername
domain
name
}
}
options {
...EventOptions
}
metadata {
key
title
value
type
}
}
${ADDRESS_FRAGMENT}
${TAG_FRAGMENT}
${EVENT_OPTIONS_FRAGMENT}
`
;

export const FETCH_EVENT = gql`
query FetchEvent($uuid: UUID!) {
event(uuid: $uuid) {
...FullEvent
}
}
${FULL_EVENT_FRAGMENT}
`
;

export const FETCH_EVENT_BASIC = gql`
query ($uuid: UUID!) {
event(uuid: $uuid) {
id
uuid
joinOptions
participantStats {
going
notApproved
notConfirmed
participant
}
}
}
`
;

export const FETCH_EVENTS = gql`
query FetchEvents(
$orderBy: EventOrderBy
$direction: SortDirection
$page: Int
$limit: Int
) {
events(
orderBy: $orderBy
direction: $direction
page: $page
limit: $limit
) {
total
elements {
id
uuid
url
local
title
description
beginsOn
endsOn
status
visibility
insertedAt
picture {
id
url
}
publishAt
# online_address,
# phone_address,
physicalAddress {
id
description
locality
}
organizerActor {
id
avatar {
id
url
}
preferredUsername
domain
name
}
attributedTo {
avatar {
id
url
}
preferredUsername
name
}
category
tags {
...TagFragment
}
}
}
}
${TAG_FRAGMENT}
`
;

export const CREATE_EVENT = gql`
mutation createEvent(
$organizerActorId: ID!
$attributedToId: ID
$title: String!
$description: String!
$beginsOn: DateTime!
$endsOn: DateTime
$status: EventStatus
$visibility: EventVisibility
$joinOptions: EventJoinOptions
$draft: Boolean
$tags: [String]
$picture: MediaInput
$onlineAddress: String
$phoneAddress: String
$category: String
$physicalAddress: AddressInput
$options: EventOptionsInput
$contacts: [Contact]
) {
createEvent(
organizerActorId: $organizerActorId
attributedToId: $attributedToId
title: $title
description: $description
beginsOn: $beginsOn
endsOn: $endsOn
status: $status
visibility: $visibility
joinOptions: $joinOptions
draft: $draft
tags: $tags
picture: $picture
onlineAddress: $onlineAddress
phoneAddress: $phoneAddress
category: $category
physicalAddress: $physicalAddress
options: $options
contacts: $contacts
) {
...FullEvent
}
}
${FULL_EVENT_FRAGMENT}
`
;

export const EDIT_EVENT = gql`
mutation updateEvent(
$id: ID!
$title: String
$description: String
$beginsOn: DateTime
$endsOn: DateTime
$status: EventStatus
$visibility: EventVisibility
$joinOptions: EventJoinOptions
$draft: Boolean
$tags: [String]
$picture: MediaInput
$onlineAddress: String
$phoneAddress: String
$organizerActorId: ID
$attributedToId: ID
$category: String
$physicalAddress: AddressInput
$options: EventOptionsInput
$contacts: [Contact]
$metadata: EventMetadataInput
) {
updateEvent(
eventId: $id
title: $title
description: $description
beginsOn: $beginsOn
endsOn: $endsOn
status: $status
visibility: $visibility
joinOptions: $joinOptions
draft: $draft
tags: $tags
picture: $picture
onlineAddress: $onlineAddress
phoneAddress: $phoneAddress
organizerActorId: $organizerActorId
attributedToId: $attributedToId
category: $category
physicalAddress: $physicalAddress
options: $options
contacts: $contacts
metadata: $metadata
) {
...FullEvent
}
}
${FULL_EVENT_FRAGMENT}
`
;

export const JOIN_EVENT = gql`
mutation JoinEvent(
$eventId: ID!
$actorId: ID!
$email: String
$message: String
$locale: String
) {
joinEvent(
eventId: $eventId
actorId: $actorId
email: $email
message: $message
locale: $locale
) {
...ParticipantQuery
}
}
${PARTICIPANT_QUERY_FRAGMENT}
`
;

export const LEAVE_EVENT = gql`
mutation LeaveEvent($eventId: ID!, $actorId: ID!, $token: String) {
leaveEvent(eventId: $eventId, actorId: $actorId, token: $token) {
actor {
id
}
}
}
`
;

export const CONFIRM_PARTICIPATION = gql`
mutation ConfirmParticipation($token: String!) {
confirmParticipation(confirmationToken: $token) {
actor {
id
}
event {
id
uuid
joinOptions
}
role
}
}
`
;

export const UPDATE_PARTICIPANT = gql`
mutation UpdateParticipant($id: ID!, $role: ParticipantRoleEnum!) {
updateParticipation(id: $id, role: $role) {
role
id
}
}
`
;

export const DELETE_EVENT = gql`
mutation DeleteEvent($eventId: ID!) {
deleteEvent(eventId: $eventId) {
id
}
}
`
;

export const PARTICIPANTS = gql`
query Participants($uuid: UUID!, $page: Int, $limit: Int, $roles: String) {
event(uuid: $uuid) {
id
uuid
title
participants(page: $page, limit: $limit, roles: $roles) {
...ParticipantsQuery
}
participantStats {
going
notApproved
rejected
participant
}
}
}
${PARTICIPANTS_QUERY_FRAGMENT}
`
;

export const EVENT_PERSON_PARTICIPATION = gql`
query EventPersonParticipation($actorId: ID!, $eventId: ID!) {
person(id: $actorId) {
id
participations(eventId: $eventId) {
total
elements {
id
role
actor {
id
}
event {
id
}
}
}
}
}
`
;

export const EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED = gql`
subscription EventPersonParticipationSubscriptionChanged(
$actorId: ID!
$eventId: ID!
) {
eventPersonParticipationChanged(personId: $actorId) {
id
participations(eventId: $eventId) {
total
elements {
id
role
actor {
id
}
event {
id
}
}
}
}
}
`
;

export const FETCH_GROUP_EVENTS = gql`
query FetchGroupEvents(
$name: String!
$afterDateTime: DateTime
$beforeDateTime: DateTime
$organisedEventsPage: Int
$organisedEventslimit: Int
) {
group(preferredUsername: $name) {
id
preferredUsername
domain
name
organizedEvents(
afterDatetime: $afterDateTime
beforeDatetime: $beforeDateTime
page: $organisedEventsPage
limit: $organisedEventslimit
) {
elements {
id
uuid
title
beginsOn
draft
options {
maximumAttendeeCapacity
}
participantStats {
participant
notApproved
}
attributedTo {
id
preferredUsername
name
domain
}
organizerActor {
id
preferredUsername
name
domain
}
}
total
}
}
}
`
;

export const CLOSE_EVENTS = gql`
query CloseEvents($location: String, $radius: Float) {
searchEvents(location: $location, radius: $radius, page: 1, limit: 10) {
total
elements {
id
title
uuid
beginsOn
picture {
id
url
}
tags {
slug
title
}
__typename
}
}
}
`
;