Work on dashboard

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
master
Thomas Citharel 3 years ago
parent 48fd14bf9c
commit ffa4ec9209
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
  1. 2
      js/public/index.html
  2. 34
      js/src/App.vue
  3. 4
      js/src/components/Event/DateCalendarIcon.vue
  4. 17
      js/src/components/Event/DateTimePicker.vue
  5. 185
      js/src/components/Event/EventListCard.vue
  6. 14
      js/src/components/NavBar.vue
  7. 62
      js/src/graphql/actor.ts
  8. 17
      js/src/i18n/en_US.json
  9. 17
      js/src/i18n/fr_FR.json
  10. 12
      js/src/mixins/actor.ts
  11. 61
      js/src/mixins/event.ts
  12. 8
      js/src/router/event.ts
  13. 2
      js/src/types/actor/actor.model.ts
  14. 3
      js/src/types/current-user.model.ts
  15. 15
      js/src/types/event.model.ts
  16. 40
      js/src/utils/auth.ts
  17. 1
      js/src/views/Account/children/EditIdentity.vue
  18. 63
      js/src/views/Event/Event.vue
  19. 201
      js/src/views/Event/MyEvents.vue
  20. 159
      js/src/views/Home.vue
  21. 3
      js/src/views/User/Login.vue
  22. 4
      js/src/views/User/PasswordReset.vue
  23. 4
      js/src/views/User/Register.vue
  24. 14
      js/src/vue-apollo.ts
  25. 60
      lib/mobilizon/events/events.ex
  26. 10
      lib/mobilizon_web/resolvers/event.ex
  27. 20
      lib/mobilizon_web/resolvers/user.ex
  28. 10
      lib/mobilizon_web/schema/user.ex
  29. 23
      lib/mobilizon_web/views/error_view.ex
  30. 5
      schema.graphql
  31. 18
      test/mobilizon/events/events_test.exs
  32. 46
      test/mobilizon_web/resolvers/event_resolver_test.exs
  33. 3
      test/mobilizon_web/views/error_view_test.exs

@ -6,7 +6,7 @@
<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">
<link rel="stylesheet" href="//cdn.materialdesignicons.com/3.5.95/css/materialdesignicons.min.css">
<link rel="stylesheet" href="//cdn.materialdesignicons.com/4.4.95/css/materialdesignicons.min.css">
<title>mobilizon</title>
<!--server-generated-meta-->
</head>

@ -24,7 +24,7 @@ import Footer from '@/components/Footer.vue';
import Logo from '@/components/Logo.vue';
import { CURRENT_ACTOR_CLIENT, IDENTITIES, UPDATE_CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { IPerson } from '@/types/actor';
import { changeIdentity, saveActorData } from '@/utils/auth';
import { changeIdentity, initializeCurrentActor, saveActorData } from '@/utils/auth';
@Component({
apollo: {
@ -40,18 +40,19 @@ import { changeIdentity, saveActorData } from '@/utils/auth';
})
export default class App extends Vue {
async created() {
await this.initializeCurrentUser();
await this.initializeCurrentActor();
if (await this.initializeCurrentUser()) {
await initializeCurrentActor(this.$apollo.provider.defaultClient);
}
}
private initializeCurrentUser() {
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({
return await this.$apollo.mutate({
mutation: UPDATE_CURRENT_USER_CLIENT,
variables: {
id: userId,
@ -61,26 +62,7 @@ export default class App extends Vue {
},
});
}
}
/**
* We fetch from localStorage the latest actor ID used,
* then fetch the current identities to set in cache
* the current identity used
*/
private async initializeCurrentActor() {
const actorId = localStorage.getItem(AUTH_USER_ACTOR_ID);
const result = await this.$apollo.query({
query: IDENTITIES,
});
const identities = result.data.identities;
if (identities.length < 1) return;
const activeIdentity = identities.find(identity => identity.id === actorId) || identities[0] as IPerson;
if (activeIdentity) {
return await changeIdentity(this.$apollo.provider.defaultClient, activeIdentity);
}
return false;
}
}
</script>
@ -107,6 +89,7 @@ export default class App extends Vue {
@import "~bulma/sass/elements/icon.sass";
@import "~bulma/sass/elements/image.sass";
@import "~bulma/sass/elements/other.sass";
@import "~bulma/sass/elements/progress.sass";
@import "~bulma/sass/elements/tag.sass";
@import "~bulma/sass/elements/title.sass";
@import "~bulma/sass/elements/notification";
@ -122,6 +105,7 @@ export default class App extends Vue {
@import "~buefy/src/scss/components/autocomplete";
@import "~buefy/src/scss/components/form";
@import "~buefy/src/scss/components/modal";
@import "~buefy/src/scss/components/progress";
@import "~buefy/src/scss/components/tag";
@import "~buefy/src/scss/components/taginput";
@import "~buefy/src/scss/components/upload";

@ -1,5 +1,5 @@
<template>
<time class="container" :datetime="dateObj.getUTCSeconds()">
<time class="datetime-container" :datetime="dateObj.getUTCSeconds()">
<span class="month">{{ month }}</span>
<span class="day">{{ day }}</span>
</time>
@ -26,7 +26,7 @@ export default class DateCalendarIcon extends Vue {
</script>
<style lang="scss" scoped>
time.container {
time.datetime-container {
background: #f6f7f8;
border: 1px solid rgba(46,62,72,.12);
border-radius: 8px;

@ -23,11 +23,20 @@ export default class DateTimePicker extends Vue {
}
@Watch('time')
updateDateTime(time) {
updateTime(time) {
const [hours, minutes] = time.split(':', 2);
this.value.setHours(hours);
this.value.setMinutes(minutes);
this.$emit('input', this.value);
this.date.setHours(hours);
this.date.setMinutes(minutes);
this.updateDateTime();
}
@Watch('date')
updateDate() {
this.updateDateTime();
}
updateDateTime() {
this.$emit('input', this.date);
}
}
</script>

@ -0,0 +1,185 @@
<template>
<article class="box columns">
<div class="content column">
<div class="title-wrapper">
<div class="date-component" v-if="!mergedOptions.hideDate">
<date-calendar-icon :date="participation.event.beginsOn" />
</div>
<h2 class="title" ref="title">{{ participation.event.title }}</h2>
</div>
<div>
<span v-if="participation.event.physicalAddress && participation.event.physicalAddress.locality">{{ participation.event.physicalAddress.locality }} - </span>
<span v-if="participation.actor.id === participation.event.organizerActor.id">{{ $t("You're organizing this event") }}</span>
<span v-else>
<span>{{ $t('Organized by {name}', { name: participation.event.organizerActor.displayName() } ) }}</span> |
<span>{{ $t('Going as {name}', { name: participation.actor.displayName() }) }}</span>
</span>
</div>
<div class="columns">
<span class="column is-narrow">
<b-icon icon="earth" v-if=" participation.event.visibility === EventVisibility.PUBLIC" />
<b-icon icon="lock_opened" v-if=" participation.event.visibility === EventVisibility.RESTRICTED" />
<b-icon icon="lock" v-if=" participation.event.visibility === EventVisibility.PRIVATE" />
</span>
<span class="column">
<span v-if="!participation.event.options.maximumAttendeeCapacity">
{{ $tc('{count} participants', participation.event.participantStats.approved, { count: participation.event.participantStats.approved })}}
</span>
<b-progress
v-if="participation.event.options.maximumAttendeeCapacity > 0"
type="is-primary"
size="is-medium"
:value="participation.event.participantStats.approved * 100 / participation.event.options.maximumAttendeeCapacity" show-value>
{{ $t('{approved} / {total} seats', {approved: participation.event.participantStats.approved, total: participation.event.options.maximumAttendeeCapacity }) }}
</b-progress>
<span
v-if="participation.event.participantStats.unapproved > 0">
{{ $tc('{count} requests waiting', participation.event.participantStats.unapproved, { count: participation.event.participantStats.unapproved })}}
</span>
</span>
</div>
</div>
<div class="actions column is-narrow">
<ul>
<li v-if="!([ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes(participation.role))">
<router-link :to="{ name: EventRouteName.EDIT_EVENT, params: { eventId: participation.event.uuid } }">
<b-icon icon="pencil" /> {{ $t('Edit') }}
</router-link>
</li>
<li v-if="!([ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes(participation.role))">
<a @click="openDeleteEventModalWrapper"><b-icon icon="delete" /> {{ $t('Delete') }}</a>
</li>
<li v-if="!([ParticipantRole.PARTICIPANT, ParticipantRole.NOT_APPROVED].includes(participation.role))">
<a @click="">
<b-icon icon="account-multiple-plus" /> {{ $t('Manage participations') }}
</a>
</li>
<li>
<router-link :to="{ name: EventRouteName.EVENT, params: { uuid: participation.event.uuid } }"><b-icon icon="view-compact" /> {{ $t('View event page') }}</router-link>
</li>
</ul>
</div>
</article>
</template>
<script lang="ts">
import { IParticipant, ParticipantRole, EventVisibility } from '@/types/event.model';
import { Component, Prop } from 'vue-property-decorator';
import DateCalendarIcon from '@/components/Event/DateCalendarIcon.vue';
import { IActor, IPerson, Person } from '@/types/actor';
import { EventRouteName } from '@/router/event';
import { mixins } from 'vue-class-component';
import ActorMixin from '@/mixins/actor';
import { CURRENT_ACTOR_CLIENT, LOGGED_USER_PARTICIPATIONS } from '@/graphql/actor';
import EventMixin from '@/mixins/event';
import { RouteName } from '@/router';
import { ICurrentUser } from '@/types/current-user.model';
import { IEventCardOptions } from './EventCard.vue';
const lineClamp = require('line-clamp');
@Component({
components: {
DateCalendarIcon,
},
mounted() {
lineClamp(this.$refs.title, 3);
},
apollo: {
currentActor: {
query: CURRENT_ACTOR_CLIENT,
},
},
})
export default class EventListCard extends mixins(ActorMixin, EventMixin) {
@Prop({ required: true }) participation!: IParticipant;
@Prop({ required: false }) options!: IEventCardOptions;
currentActor!: IPerson;
ParticipantRole = ParticipantRole;
EventRouteName = EventRouteName;
EventVisibility = EventVisibility;
defaultOptions: IEventCardOptions = {
hideDate: true,
loggedPerson: false,
hideDetails: false,
organizerActor: null,
};
get mergedOptions(): IEventCardOptions {
return { ...this.defaultOptions, ...this.options };
}
/**
* Delete the event
*/
async openDeleteEventModalWrapper() {
await this.openDeleteEventModal(this.participation.event, this.currentActor);
}
}
</script>
<style lang="scss">
@import "../../variables";
article.box {
div.tag-container {
position: absolute;
top: 10px;
right: 0;
margin-right: -5px;
z-index: 10;
max-width: 40%;
span.tag {
margin: 5px auto;
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 1);
/*word-break: break-all;*/
text-overflow: ellipsis;
overflow: hidden;
display: block;
/*text-align: right;*/
font-size: 1em;
/*padding: 0 1px;*/
line-height: 1.75em;
}
}
div.content {
padding: 5px;
div.title-wrapper {
display: flex;
div.date-component {
flex: 0;
margin-right: 16px;
}
.title {
font-weight: 400;
line-height: 1em;
font-size: 1.6em;
padding-bottom: 5px;
}
}
progress + .progress-value {
color: $primary !important;
}
}
.actions {
ul li {
margin: 0 auto;
* {
font-size: 0.8rem;
color: $primary;
}
}
}
}
</style>

@ -108,7 +108,7 @@ import { RouteName } from '@/router';
},
identities: {
query: IDENTITIES,
update: ({ identities }) => identities.map(identity => new Person(identity)),
update: ({ identities }) => identities ? identities.map(identity => new Person(identity)) : [],
},
config: {
query: CONFIG,
@ -128,12 +128,22 @@ export default class NavBar extends Vue {
config!: IConfig;
currentUser!: ICurrentUser;
ICurrentUserRole = ICurrentUserRole;
identities!: IPerson[];
identities: IPerson[] = [];
showNavbar: boolean = false;
ActorRouteName = ActorRouteName;
AdminRouteName = AdminRouteName;
@Watch('currentActor')
async initializeListOfIdentities() {
const { data } = await this.$apollo.query<{ identities: IPerson[] }>({
query: IDENTITIES,
});
if (data) {
this.identities = data.identities.map(identity => new Person(identity));
}
}
// @Watch('currentUser')
// async onCurrentUserChanged() {
// // Refresh logged person object

@ -59,25 +59,49 @@ export const UPDATE_CURRENT_ACTOR_CLIENT = gql`
}
`;
export const LOGGED_PERSON_WITH_GOING_TO_EVENTS = gql`
query {
loggedPerson {
id,
avatar {
url
},
preferredUsername,
goingToEvents {
uuid,
title,
beginsOn,
participants {
actor {
id,
preferredUsername
}
}
},
export const LOGGED_USER_PARTICIPATIONS = gql`
query LoggedUserParticipations($afterDateTime: DateTime, $beforeDateTime: DateTime $page: Int, $limit: Int) {
loggedUser {
participations(afterDatetime: $afterDateTime, beforeDatetime: $beforeDateTime, page: $page, limit: $limit) {
event {
id,
uuid,
title,
picture {
url,
alt
},
beginsOn,
visibility,
organizerActor {
id,
preferredUsername,
name,
domain,
avatar {
url
}
},
participantStats {
approved,
unapproved
},
options {
maximumAttendeeCapacity
remainingAttendeeCapacity
}
},
role,
actor {
id,
preferredUsername,
name,
domain,
avatar {
url
}
}
}
}
}`;

@ -65,6 +65,7 @@
"Forgot your password ?": "Forgot your password ?",
"From the {startDate} at {startTime} to the {endDate} at {endTime}": "From the {startDate} at {startTime} to the {endDate} at {endTime}",
"General information": "General information",
"Going as {name}": "Going as {name}",
"Group List": "Group List",
"Group full name": "Group full name",
"Group name": "Group name",
@ -108,6 +109,7 @@
"Only accessible through link and search (private)": "Only accessible through link and search (private)",
"Opened reports": "Opened reports",
"Organized": "Organized",
"Organized by {name}": "Organized by {name}",
"Organizer": "Organizer",
"Other stuff…": "Other stuff…",
"Otherwise this identity will just be removed from the group administrators.": "Otherwise this identity will just be removed from the group administrators.",
@ -115,6 +117,7 @@
"Participation approval": "Participation approval",
"Password reset": "Password reset",
"Password": "Password",
"Password (confirmation)": "Password (confirmation)",
"Pick an identity": "Pick an identity",
"Please be nice to each other": "Please be nice to each other",
"Please check you spam folder if you didn't receive the email.": "Please check you spam folder if you didn't receive the email.",
@ -196,5 +199,17 @@
"meditate a bit": "meditate a bit",
"public event": "public event",
"{actor}'s avatar": "{actor}'s avatar",
"© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks"
"{count} participants": "{count} participants",
"{count} requests waiting": "{count} requests waiting",
"© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks",
"You're organizing this event": "You're organizing this event",
"View event page": "View event page",
"Manage participations": "Manage participations",
"Upcoming": "Upcoming",
"{approved} / {total} seats": "{approved} / {total} seats",
"My events": "My events",
"Load more": "Load more",
"Past events": "Passed events",
"View everything": "View everything",
"Last week": "Last week"
}

@ -65,6 +65,7 @@
"Forgot your password ?": "Mot de passe oublié ?",
"From the {startDate} at {startTime} to the {endDate} at {endTime}": "Du {startDate} à {startTime} au {endDate} à {endTime}",
"General information": "Information générales",
"Going as {name}": "En tant que {name}",
"Group List": "Liste de groupes",
"Group full name": "Nom complet du groupe",
"Group name": "Nom du groupe",
@ -108,6 +109,7 @@
"Only accessible through link and search (private)": "Uniquement accessibles par lien et la recherche (privé)",
"Opened reports": "Signalements ouverts",
"Organized": "Organisés",
"Organized by {name}": "Organisé par {name}",
"Organizer": "Organisateur",
"Other stuff…": "Autres trucs…",
"Otherwise this identity will just be removed from the group administrators.": "Sinon cette identité sera juste supprimée des administrateurs du groupe.",
@ -115,6 +117,7 @@
"Participation approval": "Validation des participations",
"Password reset": "Réinitialisation du mot de passe",
"Password": "Mot de passe",
"Password (confirmation)": "Mot de passe (confirmation)",
"Pick an identity": "Choisissez une identité",
"Please be nice to each other": "Soyez sympas entre vous",
"Please check you spam folder if you didn't receive the email.": "Merci de vérifier votre dossier des indésirables si vous n'avez pas reçu l'email.",
@ -196,5 +199,17 @@
"meditate a bit": "méditez un peu",
"public event": "événement public",
"{actor}'s avatar": "Avatar de {actor}",
"© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© Les contributeurs de Mobilizon {date} - Fait avec Elixir, Phoenix, VueJS & et de l'amour et des semaines"
"{count} participants": "Un⋅e participant⋅e|{count} participant⋅e⋅s",
"{count} requests waiting": "Un⋅e demande en attente|{count} demandes en attente",
"© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© Les contributeurs de Mobilizon {date} - Fait avec Elixir, Phoenix, VueJS & et de l'amour et des semaines",
"You're organizing this event": "Vous organisez cet événement",
"View event page": "Voir la page de l'événement",
"Manage participations": "Gérer les participations",
"Upcoming": "À venir",
"{approved} / {total} seats": "{approved} / {total} places",
"My events": "Mes événements",
"Load more": "Voir plus",
"Past events": "Événements passés",
"View everything": "Voir tout",
"Last week": "La semaine dernière"
}

@ -0,0 +1,12 @@
import { IActor } from '@/types/actor';
import { IEvent } from '@/types/event.model';
import { Component, Vue } from 'vue-property-decorator';
@Component
export default class ActorMixin extends Vue {
actorIsOrganizer(actor: IActor, event: IEvent) {
console.log('actorIsOrganizer actor', actor.id);
console.log('actorIsOrganizer event', event);
return event.organizerActor && actor.id === event.organizerActor.id;
}
}

@ -0,0 +1,61 @@
import { mixins } from 'vue-class-component';
import { Component, Vue } from 'vue-property-decorator';
import { IEvent, IParticipant } from '@/types/event.model';
import { DELETE_EVENT } from '@/graphql/event';
import { RouteName } from '@/router';
import { IPerson } from '@/types/actor';
@Component
export default class EventMixin extends mixins(Vue) {
async openDeleteEventModal (event: IEvent, currentActor: IPerson) {
const participantsLength = event.participantStats.approved;
const prefix = participantsLength
? this.$tc('There are {participants} participants.', event.participantStats.approved, {
participants: event.participantStats.approved,
})
: '';
this.$buefy.dialog.prompt({
type: 'is-danger',
title: this.$t('Delete event') as string,
message: `${prefix}
${this.$t('Are you sure you want to delete this event? This action cannot be reverted.')}
<br><br>
${this.$t('To confirm, type your event title "{eventTitle}"', { eventTitle: event.title })}`,
confirmText: this.$t(
'Delete {eventTitle}',
{ eventTitle: event.title },
) as string,
inputAttrs: {
placeholder: event.title,
pattern: event.title,
},
onConfirm: () => this.deleteEvent(event, currentActor),
});
}
private async deleteEvent(event: IEvent, currentActor: IPerson) {
const router = this.$router;
const eventTitle = event.title;
try {
await this.$apollo.mutate<IParticipant>({
mutation: DELETE_EVENT,
variables: {
eventId: event.id,
actorId: currentActor.id,
},
});
this.$emit('eventDeleted', event.id);
this.$buefy.notification.open({
message: this.$t('Event {eventTitle} deleted', { eventTitle }) as string,
type: 'is-success',
position: 'is-bottom-right',
duration: 5000,
});
} catch (error) {
console.error(error);
}
}
}

@ -5,11 +5,13 @@ import { RouteConfig } from 'vue-router';
// tslint:disable:space-in-parens
const editEvent = () => import(/* webpackChunkName: "create-event" */ '@/views/Event/Edit.vue');
const event = () => import(/* webpackChunkName: "event" */ '@/views/Event/Event.vue');
const myEvents = () => import(/* webpackChunkName: "event" */ '@/views/Event/MyEvents.vue');
// tslint:enable
export enum EventRouteName {
EVENT_LIST = 'EventList',
CREATE_EVENT = 'CreateEvent',
MY_EVENTS = 'MyEvents',
EDIT_EVENT = 'EditEvent',
EVENT = 'Event',
LOCATION = 'Location',
@ -28,6 +30,12 @@ export const eventRoutes: RouteConfig[] = [
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,

@ -10,6 +10,8 @@ export interface IActor {
suspended: boolean;
avatar: IPicture | null;
banner: IPicture | null;
displayName();
}
export class Actor implements IActor {

@ -1,3 +1,5 @@
import { IParticipant } from '@/types/event.model';
export enum ICurrentUserRole {
USER = 'USER',
MODERATOR = 'MODERATOR',
@ -9,4 +11,5 @@ export interface ICurrentUser {
email: string;
isLoggedIn: boolean;
role: ICurrentUserRole;
participations: IParticipant[];
}

@ -50,6 +50,20 @@ export interface IParticipant {
event: IEvent;
}
export class Participant implements IParticipant {
event!: IEvent;
actor!: IActor;
role: ParticipantRole = ParticipantRole.NOT_APPROVED;
constructor(hash?: IParticipant) {
if (!hash) return;
this.event = new EventModel(hash.event);
this.actor = new Actor(hash.actor);
this.role = hash.role;
}
}
export interface IOffer {
price: number;
priceCurrency: string;
@ -203,6 +217,7 @@ export class EventModel implements IEvent {
this.onlineAddress = hash.onlineAddress;
this.phoneAddress = hash.phoneAddress;
this.physicalAddress = hash.physicalAddress;
this.participantStats = hash.participantStats;
this.tags = hash.tags;
if (hash.options) this.options = hash.options;

@ -12,7 +12,7 @@ import { onLogout } from '@/vue-apollo';
import ApolloClient from 'apollo-client';
import { ICurrentUserRole } from '@/types/current-user.model';
import { IPerson } from '@/types/actor';
import { UPDATE_CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { IDENTITIES, UPDATE_CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
export function saveUserData(obj: ILogin) {
localStorage.setItem(AUTH_USER_ID, `${obj.user.id}`);
@ -32,11 +32,31 @@ export function saveTokenData(obj: IToken) {
}
export function deleteUserData() {
for (const key of [AUTH_USER_ID, AUTH_USER_EMAIL, AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN, AUTH_USER_ROLE, AUTH_USER_ACTOR_ID]) {
for (const key of [AUTH_USER_ID, AUTH_USER_EMAIL, AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN, AUTH_USER_ROLE]) {
localStorage.removeItem(key);
}
}
/**
* We fetch from localStorage the latest actor ID used,
* then fetch the current identities to set in cache
* the current identity used
*/
export async function initializeCurrentActor(apollo: ApolloClient<any>) {
const actorId = localStorage.getItem(AUTH_USER_ACTOR_ID);
const result = await apollo.query({
query: IDENTITIES,
});
const identities = result.data.identities;
if (identities.length < 1) return;
const activeIdentity = identities.find(identity => identity.id === actorId) || identities[0] as IPerson;
if (activeIdentity) {
return await changeIdentity(apollo, activeIdentity);
}
}
export async function changeIdentity(apollo: ApolloClient<any>, identity: IPerson) {
await apollo.mutate({
mutation: UPDATE_CURRENT_ACTOR_CLIENT,
@ -45,8 +65,8 @@ export async function changeIdentity(apollo: ApolloClient<any>, identity: IPerso
saveActorData(identity);
}
export function logout(apollo: ApolloClient<any>) {
apollo.mutate({
export async function logout(apollo: ApolloClient<any>) {
await apollo.mutate({
mutation: UPDATE_CURRENT_USER_CLIENT,
variables: {
id: null,
@ -56,7 +76,17 @@ export function logout(apollo: ApolloClient<any>) {
},
});
await apollo.mutate({
mutation: UPDATE_CURRENT_ACTOR_CLIENT,
variables: {
id: null,
avatar: null,
preferredUsername: null,
name: null,
},
});
deleteUserData();
onLogout();
await onLogout();
}

@ -30,6 +30,7 @@
has-icon
aria-close-label="Close notification"
role="alert"
:key="error"
v-for="error in errors"
>
{{ error }}

@ -69,7 +69,7 @@
</router-link>
</p>
<p class="control" v-if="actorIsOrganizer()">
<a class="button is-danger" @click="openDeleteEventModal()">
<a class="button is-danger" @click="openDeleteEventModalWrapper">
{{ $t('Delete') }}
</a>
</p>
@ -111,7 +111,7 @@
<img
class="is-rounded"
:src="event.organizerActor.avatar.url"
:alt="$t("{actor}'s avatar", {actor: event.organizerActor.preferredUsername})" />
:alt="event.organizerActor.avatar.alt" />
</figure>
</actor-link>
</div>
@ -262,6 +262,7 @@ import ReportModal from '@/components/Report/ReportModal.vue';
import ParticipationModal from '@/components/Event/ParticipationModal.vue';
import { IReport } from '@/types/report.model';
import { CREATE_REPORT } from '@/graphql/report';
import EventMixin from '@/mixins/event';
@Component({
components: {
@ -290,7 +291,7 @@ import { CREATE_REPORT } from '@/graphql/report';
},
},
})
export default class Event extends Vue {
export default class Event extends EventMixin {
@Prop({ type: String, required: true }) uuid!: string;
event!: IEvent;
@ -302,31 +303,12 @@ export default class Event extends Vue {
EventVisibility = EventVisibility;
async openDeleteEventModal () {
const participantsLength = this.event.participants.length;
const prefix = participantsLength
? this.$tc('There are {participants} participants.', this.event.participants.length, {
participants: this.event.participants.length,
})
: '';
this.$buefy.dialog.prompt({
type: 'is-danger',
title: this.$t('Delete event') as string,
message: `${prefix}
${this.$t('Are you sure you want to delete this event? This action cannot be reverted.')}
<br><br>
${this.$t('To confirm, type your event title "{eventTitle}"', { eventTitle: this.event.title })}`,
confirmText: this.$t(
'Delete {eventTitle}',
{ eventTitle: this.event.title },
) as string,
inputAttrs: {
placeholder: this.event.title,
pattern: this.event.title,
},
onConfirm: () => this.deleteEvent(),
});
/**
* Delete the event, then redirect to home.
*/
async openDeleteEventModalWrapper() {
await this.openDeleteEventModal(this.event, this.currentActor);
await this.$router.push({ name: RouteName.HOME });
}
async reportEvent(content: string, forward: boolean) {
@ -464,31 +446,6 @@ export default class Event extends Vue {
return `mailto:?to=&body=${this.event.url}${encodeURIComponent('\n\n')}${this.event.description}&subject=${this.event.title}`;
}
private async deleteEvent() {
const router = this.$router;
const eventTitle = this.event.title;
try {
await this.$apollo.mutate<IParticipant>({
mutation: DELETE_EVENT,
variables: {
eventId: this.event.id,
actorId: this.currentActor.id,
},
});
await router.push({ name: RouteName.HOME });
this.$buefy.notification.open({
message: this.$t('Event {eventTitle} deleted', { eventTitle }) as string,
type: 'is-success',
position: 'is-bottom-right',
duration: 5000,
});
} catch (error) {
console.error(error);
}
}
}
</script>
<style lang="scss" scoped>

@ -0,0 +1,201 @@
<template>
<main class="container">
<h1 class="title">
{{ $t('My events') }}
</h1>
<b-loading :active.sync="$apollo.loading"></b-loading>
<section v-if="futureParticipations.length > 0">
<h2 class="subtitle">
{{ $t('Upcoming') }}
</h2>
<transition-group name="list" tag="p">
<div v-for="month in monthlyFutureParticipations" :key="month[0]">
<h3>{{ month[0] }}</h3>
<EventListCard
v-for="participation in month[1]"
:key="`${participation.event.uuid}${participation.actor.id}`"
:participation="participation"
:options="{ hideDate: false }"
@eventDeleted="eventDeleted"
class="participation"
/>
</div>
</transition-group>
<div class="columns is-centered">
<b-button class="column is-narrow"
v-if="hasMoreFutureParticipations && (futureParticipations.length === limit)" @click="loadMoreFutureParticipations" size="is-large" type="is-primary">{{ $t('Load more') }}</b-button>
</div>
</section>
<section v-if="pastParticipations.length > 0">
<h2 class="subtitle">
{{ $t('Past events') }}
</h2>
<transition-group name="list" tag="p">
<div v-for="month in monthlyPastParticipations" :key="month[0]">
<h3>{{ month[0] }}</h3>
<EventListCard
v-for="participation in month[1]"
:key="`${participation.event.uuid}${participation.actor.id}`"
:participation="participation"
:options="{ hideDate: false }"
@eventDeleted="eventDeleted"
class="participation"
/>
</div>
</transition-group>
<div class="columns is-centered">
<b-button class="column is-narrow"
v-if="hasMorePastParticipations && (pastParticipations.length === limit)" @click="loadMorePastParticipations" size="is-large" type="is-primary">{{ $t('Load more') }}</b-button>
</div>
</section>
<b-message v-if="futureParticipations.length === 0 && pastParticipations.length === 0 && $apollo.loading === false" type="is-danger">
{{ $t('No events found') }}
</b-message>
</main>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { LOGGED_USER_PARTICIPATIONS } from '@/graphql/actor';
import { IParticipant, Participant } from '@/types/event.model';
import EventListCard from '@/components/Event/EventListCard.vue';
@Component({
components: {
EventListCard,
},
apollo: {
futureParticipations: {
query: LOGGED_USER_PARTICIPATIONS,
variables: {
page: 1,
limit: 10,
afterDateTime: (new Date()).toISOString(),
},
update: data => data.loggedUser.participations.map(participation => new Participant(participation)),
},
pastParticipations: {
query: LOGGED_USER_PARTICIPATIONS,
variables: {
page: 1,
limit: 10,
beforeDateTime: (new Date()).toISOString(),
},
update: data => data.loggedUser.participations.map(participation => new Participant(participation)),
},
},
})
export default class MyEvents extends Vue {
@Prop(String) location!: string;
futurePage: number = 1;
pastPage: number = 1;
limit: number = 10;
futureParticipations: IParticipant[] = [];
hasMoreFutureParticipations: boolean = true;
pastParticipations: IParticipant[] = [];
hasMorePastParticipations: boolean = true;
private monthlyParticipations(participations: IParticipant[]): Map<string, Participant[]> {
const res = participations.filter(({ event }) => event.beginsOn != null);
res.sort(
(a: IParticipant, b: IParticipant) => a.event.beginsOn.getTime() - b.event.beginsOn.getTime(),
);
return res.reduce((acc: Map<string, IParticipant[]>, participation: IParticipant) => {
const month = (new Date(participation.event.beginsOn)).toLocaleDateString(undefined, { year: 'numeric', month: 'long' });
const participations: IParticipant[] = acc.get(month) || [];
participations.push(participation);
acc.set(month, participations);
return acc;
}, new Map());
}
get monthlyFutureParticipations(): Map<string, Participant[]> {
return this.monthlyParticipations(this.futureParticipations);
}
get monthlyPastParticipations(): Map<string, Participant[]> {
return this.monthlyParticipations(this.pastParticipations);
}
loadMoreFutureParticipations() {
this.futurePage += 1;
this.$apollo.queries.futureParticipations.fetchMore({
// New variables
variables: {
page: this.futurePage,
limit: this.limit,
},
// Transform the previous result with new data
updateQuery: (previousResult, { fetchMoreResult }) => {
const newParticipations = fetchMoreResult.loggedUser.participations;
this.hasMoreFutureParticipations = newParticipations.length === this.limit;
return {
loggedUser: {
__typename: previousResult.loggedUser.__typename,
participations: [...previousResult.loggedUser.participations, ...newParticipations],
},
};
},
});
}
loadMorePastParticipations() {
this.pastPage += 1;
this.$apollo.queries.pastParticipations.fetchMore({
// New variables
variables: {
page: this.pastPage,
limit: this.limit,
},
// Transform the previous result with new data
updateQuery: (previousResult, { fetchMoreResult }) => {
const newParticipations = fetchMoreResult.loggedUser.participations;
this.hasMorePastParticipations = newParticipations.length === this.limit;
return {
loggedUser: {
__typename: previousResult.loggedUser.__typename,
participations: [...previousResult.loggedUser.participations, ...newParticipations],
},
};
},
});
}
eventDeleted(eventid) {
this.futureParticipations = this.futureParticipations.filter(participation => participation.event.id !== eventid);
this.pastParticipations = this.pastParticipations.filter(participation => participation.event.id !== eventid);
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss" scoped>
@import "../../variables";
.participation {
margin: 1rem auto;
}
section {
margin: 3rem auto;
& > h2 {
display: block;
color: $primary;
font-size: 3rem;
text-decoration: underline;
text-decoration-color: $secondary;
}
h3 {
margin-top: 2rem;
font-weight: bold;
}
}
</style>

@ -1,8 +1,8 @@
<template>
<div class="container" v-if="config">
<section class="hero is-link" v-if="!currentUser.id || !loggedPerson">
<section class="hero is-link" v-if="!currentUser.id || !currentActor">
<div class="hero-body">
<div class="container">
<div>
<h1 class="title">{{ config.name }}</h1>
<h2 class="subtitle">{{ config.description }}</h2>
<router-link class="button" :to="{ name: 'Register' }" v-if="config.registrationsOpen">
@ -16,7 +16,7 @@
</section>
<section v-else>
<h1>
{{ $t('Welcome back {username}', {username: loggedPerson.preferredUsername}) }}
{{ $t('Welcome back {username}', {username: `@${currentActor.preferredUsername}`}) }}
</h1>
</section>
<b-dropdown aria-role="list">
@ -24,7 +24,7 @@
<span>{{ $t('Create') }}</span>
<b-icon icon="menu-down"></b-icon>
</button>
.organizerActor.id
<b-dropdown-item aria-role="listitem">
<router-link :to="{ name: RouteName.CREATE_EVENT }">{{ $t('Event') }}</router-link>
</b-dropdown-item>
@ -32,14 +32,14 @@
<router-link :to="{ name: RouteName.CREATE_GROUP }">{{ $t('Group') }}</router-link>
</b-dropdown-item>
</b-dropdown>
<section v-if="loggedPerson" class="container">
<span class="events-nearby title">
{{ $t("Events you're going at") }}
</span>
<section v-if="currentActor" class="container">
<h3 class="title">
{{ $t("Upcoming") }}
</h3>
<b-loading :active.sync="$apollo.loading"></b-loading>
<div v-if="goingToEvents.size > 0" v-for="row in Array.from(goingToEvents.entries())">
<!-- Iterators will be supported in v-for with VueJS 3 -->
<date-component :date="row[0]"></date-component>
<div v-if="goingToEvents.size > 0" v-for="row in goingToEvents" class="upcoming-events">
<span class="date-component-container" v-if="isInLessThanSevenDays(row[0])">
<date-component :date="row[0]"></date-component>
<h3 class="subtitle"
v-if="isToday(row[0])">
{{ $tc('You have one event today.', row[1].length, {count: row[1].length}) }}
@ -49,24 +49,42 @@
{{ $tc('You have one event tomorrow.', row[1].length, {count: row[1].length}) }}
</h3>
<h3 class="subtitle"
v-else>
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])}) }}
</h3>
<div class="columns">
<EventCard
v-for="event in row[1]"
:key="event.uuid"
:event="event"
:options="{loggedPerson: loggedPerson}"
class="column is-one-quarter-desktop is-half-mobile"
</span>
<div class="level">
<EventListCard
v-for="participation in row[1]"
v-if="isInLessThanSevenDays(row[0])"
:key="participation[1].event.uuid"
:participation="participation[1]"
class="level-item"
/>
</div>
</div>
<b-message v-else type="is-danger">
{{ $t("You're not going to any event yet") }}
</b-message>
<span class="view-all">
<router-link :to=" { name: EventRouteName.MY_EVENTS }">{{ $t('View everything')}} >></router-link>
</span>
</section>
<section v-if="currentActor && lastWeekEvents.length > 0">
<h3 class="title">
{{ $t("Last week") }}
</h3>
<b-loading :active.sync="$apollo.loading"></b-loading>
<div class="level">
<EventListCard
v-for="participation in lastWeekEvents"
:key="participation.event.uuid"
:participation="participation"
class="level-item"
/>
</div>
</section>
<section class="container">
<section>
<h3 class="events-nearby title">{{ $t('Events nearby you') }}</h3>
<b-loading :active.sync="$apollo.loading"></b-loading>
<div v-if="events.length > 0" class="columns is-multiline">
@ -87,16 +105,18 @@
import ngeohash from 'ngeohash';
import { FETCH_EVENTS } from '@/graphql/event';
import { Component, Vue } from 'vue-property-decorator';
import EventListCard from '@/components/Event/EventListCard.vue';
import EventCard from '@/components/Event/EventCard.vue';
import { LOGGED_PERSON_WITH_GOING_TO_EVENTS } from '@/graphql/actor';
import { CURRENT_ACTOR_CLIENT, LOGGED_USER_PARTICIPATIONS } from '@/graphql/actor';
import { IPerson, Person } from '@/types/actor';
import { ICurrentUser } from '@/types/current-user.model';
import { CURRENT_USER_CLIENT } from '@/graphql/user';
import { RouteName } from '@/router';
import { IEvent } from '@/types/event.model';
import { EventModel, IEvent, IParticipant, Participant } from '@/types/event.model';
import DateComponent from '@/components/Event/DateCalendarIcon.vue';
import { CONFIG } from '@/graphql/config';
import { IConfig } from '@/types/config.model';
import { EventRouteName } from '@/router/event';
@Component({
apollo: {
@ -104,8 +124,8 @@ import { IConfig } from '@/types/config.model';
query: FETCH_EVENTS,
fetchPolicy: 'no-cache', // Debug me: https://github.com/apollographql/apollo-client/issues/3030
},
loggedPerson: {
query: LOGGED_PERSON_WITH_GOING_TO_EVENTS,
currentActor: {
query: CURRENT_ACTOR_CLIENT,
},
currentUser: {
query: CURRENT_USER_CLIENT,
@ -116,6 +136,7 @@ import { IConfig } from '@/types/config.model';
},
components: {
DateComponent,
EventListCard,
EventCard,
},
})
@ -124,10 +145,12 @@ export default class Home extends Vue {
locations = [];
city = { name: null };
country = { name: null };
loggedPerson: IPerson = new Person();
currentUserParticipations: IParticipant[] = [];
currentUser!: ICurrentUser;
currentActor!: IPerson;
config: IConfig = { description: '', name: '', registrationsOpen: false };
RouteName = RouteName;
EventRouteName = EventRouteName;
// get displayed_name() {
// return this.loggedPerson && this.loggedPerson.name === null
@ -135,7 +158,23 @@ export default class Home extends Vue {