Work on dashboard

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2019-09-18 17:32:37 +02:00
parent 48fd14bf9c
commit ffa4ec9209
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
33 changed files with 931 additions and 204 deletions

View File

@ -6,7 +6,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0"> <meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico"> <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> <title>mobilizon</title>
<!--server-generated-meta--> <!--server-generated-meta-->
</head> </head>

View File

@ -24,7 +24,7 @@ import Footer from '@/components/Footer.vue';
import Logo from '@/components/Logo.vue'; import Logo from '@/components/Logo.vue';
import { CURRENT_ACTOR_CLIENT, IDENTITIES, UPDATE_CURRENT_ACTOR_CLIENT } from '@/graphql/actor'; import { CURRENT_ACTOR_CLIENT, IDENTITIES, UPDATE_CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { IPerson } from '@/types/actor'; import { IPerson } from '@/types/actor';
import { changeIdentity, saveActorData } from '@/utils/auth'; import { changeIdentity, initializeCurrentActor, saveActorData } from '@/utils/auth';
@Component({ @Component({
apollo: { apollo: {
@ -40,18 +40,19 @@ import { changeIdentity, saveActorData } from '@/utils/auth';
}) })
export default class App extends Vue { export default class App extends Vue {
async created() { async created() {
await this.initializeCurrentUser(); if (await this.initializeCurrentUser()) {
await this.initializeCurrentActor(); await initializeCurrentActor(this.$apollo.provider.defaultClient);
}
} }
private initializeCurrentUser() { private async initializeCurrentUser() {
const userId = localStorage.getItem(AUTH_USER_ID); const userId = localStorage.getItem(AUTH_USER_ID);
const userEmail = localStorage.getItem(AUTH_USER_EMAIL); const userEmail = localStorage.getItem(AUTH_USER_EMAIL);
const accessToken = localStorage.getItem(AUTH_ACCESS_TOKEN); const accessToken = localStorage.getItem(AUTH_ACCESS_TOKEN);
const role = localStorage.getItem(AUTH_USER_ROLE); const role = localStorage.getItem(AUTH_USER_ROLE);
if (userId && userEmail && accessToken && role) { if (userId && userEmail && accessToken && role) {
return this.$apollo.mutate({ return await this.$apollo.mutate({
mutation: UPDATE_CURRENT_USER_CLIENT, mutation: UPDATE_CURRENT_USER_CLIENT,
variables: { variables: {
id: userId, id: userId,
@ -61,26 +62,7 @@ export default class App extends Vue {
}, },
}); });
} }
} return false;
/**
* 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);
}
} }
} }
</script> </script>
@ -107,6 +89,7 @@ export default class App extends Vue {
@import "~bulma/sass/elements/icon.sass"; @import "~bulma/sass/elements/icon.sass";
@import "~bulma/sass/elements/image.sass"; @import "~bulma/sass/elements/image.sass";
@import "~bulma/sass/elements/other.sass"; @import "~bulma/sass/elements/other.sass";
@import "~bulma/sass/elements/progress.sass";
@import "~bulma/sass/elements/tag.sass"; @import "~bulma/sass/elements/tag.sass";
@import "~bulma/sass/elements/title.sass"; @import "~bulma/sass/elements/title.sass";
@import "~bulma/sass/elements/notification"; @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/autocomplete";
@import "~buefy/src/scss/components/form"; @import "~buefy/src/scss/components/form";
@import "~buefy/src/scss/components/modal"; @import "~buefy/src/scss/components/modal";
@import "~buefy/src/scss/components/progress";
@import "~buefy/src/scss/components/tag"; @import "~buefy/src/scss/components/tag";
@import "~buefy/src/scss/components/taginput"; @import "~buefy/src/scss/components/taginput";
@import "~buefy/src/scss/components/upload"; @import "~buefy/src/scss/components/upload";

View File

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

View File

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

View File

@ -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>

View File

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

View File

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

View File

@ -65,6 +65,7 @@
"Forgot your password ?": "Forgot your password ?", "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}", "From the {startDate} at {startTime} to the {endDate} at {endTime}": "From the {startDate} at {startTime} to the {endDate} at {endTime}",
"General information": "General information", "General information": "General information",
"Going as {name}": "Going as {name}",
"Group List": "Group List", "Group List": "Group List",
"Group full name": "Group full name", "Group full name": "Group full name",
"Group name": "Group name", "Group name": "Group name",
@ -108,6 +109,7 @@
"Only accessible through link and search (private)": "Only accessible through link and search (private)", "Only accessible through link and search (private)": "Only accessible through link and search (private)",
"Opened reports": "Opened reports", "Opened reports": "Opened reports",
"Organized": "Organized", "Organized": "Organized",
"Organized by {name}": "Organized by {name}",
"Organizer": "Organizer", "Organizer": "Organizer",
"Other stuff…": "Other stuff…", "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.", "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", "Participation approval": "Participation approval",
"Password reset": "Password reset", "Password reset": "Password reset",
"Password": "Password", "Password": "Password",
"Password (confirmation)": "Password (confirmation)",
"Pick an identity": "Pick an identity", "Pick an identity": "Pick an identity",
"Please be nice to each other": "Please be nice to each other", "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.", "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", "meditate a bit": "meditate a bit",
"public event": "public event", "public event": "public event",
"{actor}'s avatar": "{actor}'s avatar", "{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"
} }

View File

@ -65,6 +65,7 @@
"Forgot your password ?": "Mot de passe oublié ?", "Forgot your password ?": "Mot de passe oublié ?",
"From the {startDate} at {startTime} to the {endDate} at {endTime}": "Du {startDate} à {startTime} au {endDate} à {endTime}", "From the {startDate} at {startTime} to the {endDate} at {endTime}": "Du {startDate} à {startTime} au {endDate} à {endTime}",
"General information": "Information générales", "General information": "Information générales",
"Going as {name}": "En tant que {name}",
"Group List": "Liste de groupes", "Group List": "Liste de groupes",
"Group full name": "Nom complet du groupe", "Group full name": "Nom complet du groupe",
"Group name": "Nom 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é)", "Only accessible through link and search (private)": "Uniquement accessibles par lien et la recherche (privé)",
"Opened reports": "Signalements ouverts", "Opened reports": "Signalements ouverts",
"Organized": "Organisés", "Organized": "Organisés",
"Organized by {name}": "Organisé par {name}",
"Organizer": "Organisateur", "Organizer": "Organisateur",
"Other stuff…": "Autres trucs…", "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.", "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", "Participation approval": "Validation des participations",
"Password reset": "Réinitialisation du mot de passe", "Password reset": "Réinitialisation du mot de passe",
"Password": "Mot de passe", "Password": "Mot de passe",
"Password (confirmation)": "Mot de passe (confirmation)",
"Pick an identity": "Choisissez une identité", "Pick an identity": "Choisissez une identité",
"Please be nice to each other": "Soyez sympas entre vous", "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.", "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", "meditate a bit": "méditez un peu",
"public event": "événement public", "public event": "événement public",
"{actor}'s avatar": "Avatar de {actor}", "{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"
} }

12
js/src/mixins/actor.ts Normal file
View File

@ -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;
}
}

61
js/src/mixins/event.ts Normal file
View File

@ -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);
}
}
}

View File

@ -5,11 +5,13 @@ import { RouteConfig } from 'vue-router';
// tslint:disable:space-in-parens // tslint:disable:space-in-parens
const editEvent = () => import(/* webpackChunkName: "create-event" */ '@/views/Event/Edit.vue'); const editEvent = () => import(/* webpackChunkName: "create-event" */ '@/views/Event/Edit.vue');
const event = () => import(/* webpackChunkName: "event" */ '@/views/Event/Event.vue'); const event = () => import(/* webpackChunkName: "event" */ '@/views/Event/Event.vue');
const myEvents = () => import(/* webpackChunkName: "event" */ '@/views/Event/MyEvents.vue');
// tslint:enable // tslint:enable
export enum EventRouteName { export enum EventRouteName {
EVENT_LIST = 'EventList', EVENT_LIST = 'EventList',
CREATE_EVENT = 'CreateEvent', CREATE_EVENT = 'CreateEvent',
MY_EVENTS = 'MyEvents',
EDIT_EVENT = 'EditEvent', EDIT_EVENT = 'EditEvent',
EVENT = 'Event', EVENT = 'Event',
LOCATION = 'Location', LOCATION = 'Location',
@ -28,6 +30,12 @@ export const eventRoutes: RouteConfig[] = [
component: editEvent, component: editEvent,
meta: { requiredAuth: true }, meta: { requiredAuth: true },
}, },
{
path: '/events/me',
name: EventRouteName.MY_EVENTS,
component: myEvents,
meta: { requiredAuth: true },
},
{ {
path: '/events/edit/:eventId', path: '/events/edit/:eventId',
name: EventRouteName.EDIT_EVENT, name: EventRouteName.EDIT_EVENT,

View File

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

View File

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

View File

@ -50,6 +50,20 @@ export interface IParticipant {
event: IEvent; 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 { export interface IOffer {
price: number; price: number;
priceCurrency: string; priceCurrency: string;
@ -203,6 +217,7 @@ export class EventModel implements IEvent {
this.onlineAddress = hash.onlineAddress; this.onlineAddress = hash.onlineAddress;
this.phoneAddress = hash.phoneAddress; this.phoneAddress = hash.phoneAddress;
this.physicalAddress = hash.physicalAddress; this.physicalAddress = hash.physicalAddress;
this.participantStats = hash.participantStats;
this.tags = hash.tags; this.tags = hash.tags;
if (hash.options) this.options = hash.options; if (hash.options) this.options = hash.options;

View File

@ -12,7 +12,7 @@ import { onLogout } from '@/vue-apollo';
import ApolloClient from 'apollo-client'; import ApolloClient from 'apollo-client';
import { ICurrentUserRole } from '@/types/current-user.model'; import { ICurrentUserRole } from '@/types/current-user.model';
import { IPerson } from '@/types/actor'; 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) { export function saveUserData(obj: ILogin) {
localStorage.setItem(AUTH_USER_ID, `${obj.user.id}`); localStorage.setItem(AUTH_USER_ID, `${obj.user.id}`);
@ -32,11 +32,31 @@ export function saveTokenData(obj: IToken) {
} }
export function deleteUserData() { 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); 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) { export async function changeIdentity(apollo: ApolloClient<any>, identity: IPerson) {
await apollo.mutate({ await apollo.mutate({
mutation: UPDATE_CURRENT_ACTOR_CLIENT, mutation: UPDATE_CURRENT_ACTOR_CLIENT,
@ -45,8 +65,8 @@ export async function changeIdentity(apollo: ApolloClient<any>, identity: IPerso
saveActorData(identity); saveActorData(identity);
} }
export function logout(apollo: ApolloClient<any>) { export async function logout(apollo: ApolloClient<any>) {
apollo.mutate({ await apollo.mutate({
mutation: UPDATE_CURRENT_USER_CLIENT, mutation: UPDATE_CURRENT_USER_CLIENT,
variables: { variables: {
id: null, 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(); deleteUserData();
onLogout(); await onLogout();
} }

View File

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

View File

@ -69,7 +69,7 @@
</router-link> </router-link>
</p> </p>
<p class="control" v-if="actorIsOrganizer()"> <p class="control" v-if="actorIsOrganizer()">
<a class="button is-danger" @click="openDeleteEventModal()"> <a class="button is-danger" @click="openDeleteEventModalWrapper">
{{ $t('Delete') }} {{ $t('Delete') }}
</a> </a>
</p> </p>
@ -111,7 +111,7 @@
<img <img
class="is-rounded" class="is-rounded"
:src="event.organizerActor.avatar.url" :src="event.organizerActor.avatar.url"
:alt="$t("{actor}'s avatar", {actor: event.organizerActor.preferredUsername})" /> :alt="event.organizerActor.avatar.alt" />
</figure> </figure>
</actor-link> </actor-link>
</div> </div>
@ -262,6 +262,7 @@ import ReportModal from '@/components/Report/ReportModal.vue';
import ParticipationModal from '@/components/Event/ParticipationModal.vue'; import ParticipationModal from '@/components/Event/ParticipationModal.vue';
import { IReport } from '@/types/report.model'; import { IReport } from '@/types/report.model';
import { CREATE_REPORT } from '@/graphql/report'; import { CREATE_REPORT } from '@/graphql/report';
import EventMixin from '@/mixins/event';
@Component({ @Component({
components: { 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; @Prop({ type: String, required: true }) uuid!: string;
event!: IEvent; event!: IEvent;
@ -302,31 +303,12 @@ export default class Event extends Vue {
EventVisibility = EventVisibility; EventVisibility = EventVisibility;
async openDeleteEventModal () { /**
const participantsLength = this.event.participants.length; * Delete the event, then redirect to home.
const prefix = participantsLength */
? this.$tc('There are {participants} participants.', this.event.participants.length, { async openDeleteEventModalWrapper() {
participants: this.event.participants.length, await this.openDeleteEventModal(this.event, this.currentActor);
}) await this.$router.push({ name: RouteName.HOME });
: '';
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(),
});
} }
async reportEvent(content: string, forward: boolean) { 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}`; 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -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>

View File

@ -1,8 +1,8 @@
<template> <template>
<div class="container" v-if="config"> <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="hero-body">
<div class="container"> <div>
<h1 class="title">{{ config.name }}</h1> <h1 class="title">{{ config.name }}</h1>
<h2 class="subtitle">{{ config.description }}</h2> <h2 class="subtitle">{{ config.description }}</h2>
<router-link class="button" :to="{ name: 'Register' }" v-if="config.registrationsOpen"> <router-link class="button" :to="{ name: 'Register' }" v-if="config.registrationsOpen">
@ -16,7 +16,7 @@
</section> </section>
<section v-else> <section v-else>
<h1> <h1>
{{ $t('Welcome back {username}', {username: loggedPerson.preferredUsername}) }} {{ $t('Welcome back {username}', {username: `@${currentActor.preferredUsername}`}) }}
</h1> </h1>
</section> </section>
<b-dropdown aria-role="list"> <b-dropdown aria-role="list">
@ -24,7 +24,7 @@
<span>{{ $t('Create') }}</span> <span>{{ $t('Create') }}</span>
<b-icon icon="menu-down"></b-icon> <b-icon icon="menu-down"></b-icon>
</button> </button>
.organizerActor.id
<b-dropdown-item aria-role="listitem"> <b-dropdown-item aria-role="listitem">
<router-link :to="{ name: RouteName.CREATE_EVENT }">{{ $t('Event') }}</router-link> <router-link :to="{ name: RouteName.CREATE_EVENT }">{{ $t('Event') }}</router-link>
</b-dropdown-item> </b-dropdown-item>
@ -32,13 +32,13 @@
<router-link :to="{ name: RouteName.CREATE_GROUP }">{{ $t('Group') }}</router-link> <router-link :to="{ name: RouteName.CREATE_GROUP }">{{ $t('Group') }}</router-link>
</b-dropdown-item> </b-dropdown-item>
</b-dropdown> </b-dropdown>
<section v-if="loggedPerson" class="container"> <section v-if="currentActor" class="container">
<span class="events-nearby title"> <h3 class="title">
{{ $t("Events you're going at") }} {{ $t("Upcoming") }}
</span> </h3>
<b-loading :active.sync="$apollo.loading"></b-loading> <b-loading :active.sync="$apollo.loading"></b-loading>
<div v-if="goingToEvents.size > 0" v-for="row in Array.from(goingToEvents.entries())"> <div v-if="goingToEvents.size > 0" v-for="row in goingToEvents" class="upcoming-events">
<!-- Iterators will be supported in v-for with VueJS 3 --> <span class="date-component-container" v-if="isInLessThanSevenDays(row[0])">
<date-component :date="row[0]"></date-component> <date-component :date="row[0]"></date-component>
<h3 class="subtitle" <h3 class="subtitle"
v-if="isToday(row[0])"> v-if="isToday(row[0])">
@ -49,24 +49,42 @@
{{ $tc('You have one event tomorrow.', row[1].length, {count: row[1].length}) }} {{ $tc('You have one event tomorrow.', row[1].length, {count: row[1].length}) }}
</h3> </h3>
<h3 class="subtitle" <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])}) }} {{ $tc('You have one event in {days} days.', row[1].length, {count: row[1].length, days: calculateDiffDays(row[0])}) }}
</h3> </h3>
<div class="columns"> </span>
<EventCard <div class="level">
v-for="event in row[1]" <EventListCard
:key="event.uuid" v-for="participation in row[1]"
:event="event" v-if="isInLessThanSevenDays(row[0])"
:options="{loggedPerson: loggedPerson}" :key="participation[1].event.uuid"
class="column is-one-quarter-desktop is-half-mobile" :participation="participation[1]"
class="level-item"
/> />
</div> </div>
</div> </div>
<b-message v-else type="is-danger"> <b-message v-else type="is-danger">
{{ $t("You're not going to any event yet") }} {{ $t("You're not going to any event yet") }}
</b-message> </b-message>
<span class="view-all">
<router-link :to=" { name: EventRouteName.MY_EVENTS }">{{ $t('View everything')}} >></router-link>
</span>
</section> </section>
<section class="container"> <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>
<h3 class="events-nearby title">{{ $t('Events nearby you') }}</h3> <h3 class="events-nearby title">{{ $t('Events nearby you') }}</h3>
<b-loading :active.sync="$apollo.loading"></b-loading> <b-loading :active.sync="$apollo.loading"></b-loading>
<div v-if="events.length > 0" class="columns is-multiline"> <div v-if="events.length > 0" class="columns is-multiline">
@ -87,16 +105,18 @@
import ngeohash from 'ngeohash'; import ngeohash from 'ngeohash';
import { FETCH_EVENTS } from '@/graphql/event'; import { FETCH_EVENTS } from '@/graphql/event';
import { Component, Vue } from 'vue-property-decorator'; import { Component, Vue } from 'vue-property-decorator';
import EventListCard from '@/components/Event/EventListCard.vue';
import EventCard from '@/components/Event/EventCard.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 { IPerson, Person } from '@/types/actor';
import { ICurrentUser } from '@/types/current-user.model'; import { ICurrentUser } from '@/types/current-user.model';
import { CURRENT_USER_CLIENT } from '@/graphql/user'; import { CURRENT_USER_CLIENT } from '@/graphql/user';
import { RouteName } from '@/router'; 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 DateComponent from '@/components/Event/DateCalendarIcon.vue';
import { CONFIG } from '@/graphql/config'; import { CONFIG } from '@/graphql/config';
import { IConfig } from '@/types/config.model'; import { IConfig } from '@/types/config.model';
import { EventRouteName } from '@/router/event';
@Component({ @Component({
apollo: { apollo: {
@ -104,8 +124,8 @@ import { IConfig } from '@/types/config.model';
query: FETCH_EVENTS, query: FETCH_EVENTS,
fetchPolicy: 'no-cache', // Debug me: https://github.com/apollographql/apollo-client/issues/3030 fetchPolicy: 'no-cache', // Debug me: https://github.com/apollographql/apollo-client/issues/3030
}, },
loggedPerson: { currentActor: {
query: LOGGED_PERSON_WITH_GOING_TO_EVENTS, query: CURRENT_ACTOR_CLIENT,
}, },
currentUser: { currentUser: {
query: CURRENT_USER_CLIENT, query: CURRENT_USER_CLIENT,
@ -116,6 +136,7 @@ import { IConfig } from '@/types/config.model';
}, },
components: { components: {
DateComponent, DateComponent,
EventListCard,
EventCard, EventCard,
}, },
}) })
@ -124,10 +145,12 @@ export default class Home extends Vue {
locations = []; locations = [];
city = { name: null }; city = { name: null };
country = { name: null }; country = { name: null };
loggedPerson: IPerson = new Person(); currentUserParticipations: IParticipant[] = [];
currentUser!: ICurrentUser; currentUser!: ICurrentUser;
currentActor!: IPerson;
config: IConfig = { description: '', name: '', registrationsOpen: false }; config: IConfig = { description: '', name: '', registrationsOpen: false };
RouteName = RouteName; RouteName = RouteName;
EventRouteName = EventRouteName;
// get displayed_name() { // get displayed_name() {
// return this.loggedPerson && this.loggedPerson.name === null // return this.loggedPerson && this.loggedPerson.name === null
@ -135,7 +158,23 @@ export default class Home extends Vue {
// : this.loggedPerson.name; // : this.loggedPerson.name;
// } // }
isToday(date: string) { async mounted() {
const lastWeek = new Date();
lastWeek.setDate(new Date().getDate() - 7);
const { data } = await this.$apollo.query({
query: LOGGED_USER_PARTICIPATIONS,
variables: {
afterDateTime: lastWeek.toISOString(),
},
});
if (data) {
this.currentUserParticipations = data.loggedUser.participations.map(participation => new Participant(participation));
}
}
isToday(date: Date) {
return (new Date(date)).toDateString() === (new Date()).toDateString(); return (new Date(date)).toDateString() === (new Date()).toDateString();
} }
@ -148,35 +187,43 @@ export default class Home extends Vue {
} }
isBefore(date: string, nbDays: number) :boolean { isBefore(date: string, nbDays: number) :boolean {
return this.calculateDiffDays(date) > nbDays; return this.calculateDiffDays(date) < nbDays;
} }
// FIXME: Use me
isInLessThanSevenDays(date: string): boolean { isInLessThanSevenDays(date: string): boolean {
return this.isInDays(date, 7); return this.isBefore(date, 7);
} }
calculateDiffDays(date: string): number { calculateDiffDays(date: string): number {
const dateObj = new Date(date); return Math.ceil(((new Date(date)).getTime() - (new Date()).getTime()) / 1000 / 60 / 60 / 24);
return Math.ceil((dateObj.getTime() - (new Date()).getTime()) / 1000 / 60 / 60 / 24);
} }
get goingToEvents(): Map<string, IEvent[]> { get goingToEvents(): Map<string, Map<string, IParticipant>> {
const res = this.$data.loggedPerson.goingToEvents.filter((event) => { const res = this.currentUserParticipations.filter(({ event }) => {
return event.beginsOn != null && this.isBefore(event.beginsOn, 0); return event.beginsOn != null && !this.isBefore(event.beginsOn.toDateString(), 0);
}); });
res.sort( res.sort(
(a: IEvent, b: IEvent) => new Date(a.beginsOn) > new Date(b.beginsOn), (a: IParticipant, b: IParticipant) => a.event.beginsOn.getTime() - b.event.beginsOn.getTime(),
); );
return res.reduce((acc: Map<string, IEvent[]>, event: IEvent) => { return res.reduce((acc: Map<string, Map<string, IParticipant>>, participation: IParticipant) => {
const day = (new Date(event.beginsOn)).toDateString(); const day = (new Date(participation.event.beginsOn)).toDateString();
const events: IEvent[] = acc.get(day) || []; const participations: Map<string, IParticipant> = acc.get(day) || new Map();
events.push(event); participations.set(participation.event.uuid, participation);
acc.set(day, events); acc.set(day, participations);
return acc; return acc;
}, new Map()); }, new Map());
} }
get lastWeekEvents() {
const res = this.currentUserParticipations.filter(({ event }) => {
return event.beginsOn != null && this.isBefore(event.beginsOn.toDateString(), 0);
});
res.sort(
(a: IParticipant, b: IParticipant) => a.event.beginsOn.getTime() - b.event.beginsOn.getTime(),
);
return res;
}
geoLocalize() { geoLocalize() {
const router = this.$router; const router = this.$router;
const sessionCity = sessionStorage.getItem('City'); const sessionCity = sessionStorage.getItem('City');
@ -226,7 +273,7 @@ export default class Home extends Vue {
</script> </script>
<!-- Add "scoped" attribute to limit CSS to this component only --> <!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped> <style lang="scss">
.search-autocomplete { .search-autocomplete {
border: 1px solid #dbdbdb; border: 1px solid #dbdbdb;
color: rgba(0, 0, 0, 0.87); color: rgba(0, 0, 0, 0.87);
@ -235,4 +282,34 @@ export default class Home extends Vue {
.events-nearby { .events-nearby {
margin: 25px auto; margin: 25px auto;
} }
.date-component-container {
display: flex;
align-items: center;
margin: 1.5rem auto;
h3.subtitle {
margin-left: 7px;
}
}
.upcoming-events {
.level {
margin-left: 4rem;
}
}
section.container {
margin: auto auto 3rem;
}
span.view-all {
display: block;
margin-top: 2rem;
text-align: right;
a {
text-decoration: underline;
}
}
</style> </style>

View File

@ -65,7 +65,7 @@
import { Component, Prop, Vue } from 'vue-property-decorator'; import { Component, Prop, Vue } from 'vue-property-decorator';
import { LOGIN } from '@/graphql/auth'; import { LOGIN } from '@/graphql/auth';
import { validateEmailField, validateRequiredField } from '@/utils/validators'; import { validateEmailField, validateRequiredField } from '@/utils/validators';
import { saveUserData } from '@/utils/auth'; import { initializeCurrentActor, saveUserData } from '@/utils/auth';
import { ILogin } from '@/types/login.model'; import { ILogin } from '@/types/login.model';
import { CURRENT_USER_CLIENT, UPDATE_CURRENT_USER_CLIENT } from '@/graphql/user'; import { CURRENT_USER_CLIENT, UPDATE_CURRENT_USER_CLIENT } from '@/graphql/user';
import { onLogin } from '@/vue-apollo'; import { onLogin } from '@/vue-apollo';
@ -146,6 +146,7 @@ export default class Login extends Vue {
role: data.login.user.role, role: data.login.user.role,
}, },
}); });
await initializeCurrentActor(this.$apollo.provider.defaultClient);
onLogin(this.$apollo); onLogin(this.$apollo);

View File

@ -6,7 +6,7 @@
</h1> </h1>
<b-message title="Error" type="is-danger" v-for="error in errors" :key="error">{{ error }}</b-message> <b-message title="Error" type="is-danger" v-for="error in errors" :key="error">{{ error }}</b-message>
<form @submit="resetAction"> <form @submit="resetAction">
<b-field label="Password"> <b-field :label="$t('Password')">
<b-input <b-input
aria-required="true" aria-required="true"
required required
@ -16,7 +16,7 @@
v-model="credentials.password" v-model="credentials.password"
/> />
</b-field> </b-field>
<b-field label="Password (confirmation)"> <b-field :label="$t('Password (confirmation)')">
<b-input <b-input
aria-required="true" aria-required="true"
required required

View File

@ -39,7 +39,7 @@
<div class="column"> <div class="column">
<form @submit="submit"> <form @submit="submit">
<b-field <b-field
label="Email" :label="$t('Email')"
:type="errors.email ? 'is-danger' : null" :type="errors.email ? 'is-danger' : null"
:message="errors.email" :message="errors.email"
> >
@ -54,7 +54,7 @@
</b-field> </b-field>
<b-field <b-field
label="Password" :label="$t('Password')"
:type="errors.password ? 'is-danger' : null" :type="errors.password ? 'is-danger' : null"
:message="errors.password" :message="errors.password"
> >

View File

@ -127,12 +127,14 @@ export function onLogin(apolloClient) {
export async function onLogout() { export async function onLogout() {
// if (apolloClient.wsClient) restartWebsockets(apolloClient.wsClient); // if (apolloClient.wsClient) restartWebsockets(apolloClient.wsClient);
try { // We don't reset store because we rely on currentUser & currentActor
await apolloClient.resetStore(); // which are in the cache (even null). Maybe try to rerun cache init after resetStore ?
} catch (e) { // try {
// eslint-disable-next-line no-console // await apolloClient.resetStore();
console.log('%cError on cache reset (logout)', 'color: orange;', e.message); // } catch (e) {
} // // eslint-disable-next-line no-console
// console.log('%cError on cache reset (logout)', 'color: orange;', e.message);
// }
} }
async function refreshAccessToken() { async function refreshAccessToken() {

View File

@ -585,6 +585,61 @@ defmodule Mobilizon.Events do
|> Repo.update() |> Repo.update()
end end
@doc """
Returns the list of participations for an actor.
Default behaviour is to not return :not_approved participants
## Examples
iex> list_event_participations_for_user(5)
[%Participant{}, ...]
"""
def list_participations_for_user(
user_id,
after_datetime \\ nil,
before_datetime \\ nil,
page \\ nil,
limit \\ nil
)
def list_participations_for_user(user_id, %DateTime{} = after_datetime, nil, page, limit) do
user_id
|> do_list_participations_for_user(page, limit)
|> where([_p, e, _a], e.begins_on > ^after_datetime)
|> order_by([_p, e, _a], asc: e.begins_on)
|> Repo.all()
end
def list_participations_for_user(user_id, nil, %DateTime{} = before_datetime, page, limit) do
user_id
|> do_list_participations_for_user(page, limit)
|> where([_p, e, _a], e.begins_on < ^before_datetime)
|> order_by([_p, e, _a], desc: e.begins_on)
|> Repo.all()
end
def list_participations_for_user(user_id, nil, nil, page, limit) do
user_id
|> do_list_participations_for_user(page, limit)
|> order_by([_p, e, _a], desc: e.begins_on)
|> Repo.all()
end
defp do_list_participations_for_user(user_id, page, limit) do
from(
p in Participant,
join: e in Event,
join: a in Actor,
on: p.actor_id == a.id,
on: p.event_id == e.id,
where: a.user_id == ^user_id and p.role != ^:not_approved,
preload: [:event, :actor]
)
|> Page.paginate(page, limit)
end
@doc """ @doc """
Deletes a participant. Deletes a participant.
""" """
@ -621,6 +676,11 @@ defmodule Mobilizon.Events do
@doc """ @doc """
Returns the list of organizers participants for an event. Returns the list of organizers participants for an event.
## Examples
iex> list_organizers_participants_for_event(id)
[%Participant{role: :creator}, ...]
""" """
@spec list_organizers_participants_for_event( @spec list_organizers_participants_for_event(
integer | String.t(), integer | String.t(),

View File

@ -29,12 +29,12 @@ defmodule MobilizonWeb.Resolvers.Event do
end end
def find_event(_parent, %{uuid: uuid}, _resolution) do def find_event(_parent, %{uuid: uuid}, _resolution) do
case Mobilizon.Events.get_public_event_by_uuid_with_preload(uuid) do case {:has_event, Mobilizon.Events.get_public_event_by_uuid_with_preload(uuid)} do
nil -> {:has_event, %Event{} = event} ->
{:error, "Event with UUID #{uuid} not found"}
event ->
{:ok, Map.put(event, :organizer_actor, Person.proxify_pictures(event.organizer_actor))} {:ok, Map.put(event, :organizer_actor, Person.proxify_pictures(event.organizer_actor))}
{:has_event, _} ->
{:error, "Event with UUID #{uuid} not found"}
end end
end end

View File

@ -3,7 +3,7 @@ defmodule MobilizonWeb.Resolvers.User do
Handles the user-related GraphQL calls Handles the user-related GraphQL calls
""" """
alias Mobilizon.{Actors, Config, Users} alias Mobilizon.{Actors, Config, Users, Events}
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Service.Users.{ResetPassword, Activation} alias Mobilizon.Service.Users.{ResetPassword, Activation}
alias Mobilizon.Users.User alias Mobilizon.Users.User
@ -220,4 +220,22 @@ defmodule MobilizonWeb.Resolvers.User do
{:error, :unable_to_change_default_actor} {:error, :unable_to_change_default_actor}
end end
end end
@doc """
Returns the list of events for all of this user's identities are going to
"""
def user_participations(_parent, args, %{
context: %{current_user: %User{id: user_id}}
}) do
with participations <-
Events.list_participations_for_user(
user_id,
Map.get(args, :after_datetime),
Map.get(args, :before_datetime),
Map.get(args, :page),
Map.get(args, :limit)
) do
{:ok, participations}
end
end
end end

View File

@ -45,6 +45,16 @@ defmodule MobilizonWeb.Schema.UserType do
) )
field(:role, :user_role, description: "The role for the user") field(:role, :user_role, description: "The role for the user")
field(:participations, list_of(:participant),
description: "The list of events this person goes to"
) do
arg(:after_datetime, :datetime)
arg(:before_datetime, :datetime)
arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10)
resolve(&User.user_participations/3)
end
end end
enum :user_role do enum :user_role do

View File

@ -5,13 +5,27 @@ defmodule MobilizonWeb.ErrorView do
use MobilizonWeb, :view use MobilizonWeb, :view
def render("404.html", _assigns) do def render("404.html", _assigns) do
"Page not found" with {:ok, index_content} <- File.read(index_file_path()) do
{:safe, index_content}
end
end end
def render("404.json", _assigns) do def render("404.json", _assigns) do
%{msg: "Resource not found"} %{msg: "Resource not found"}
end end
def render("404.activity-json", _assigns) do
%{msg: "Resource not found"}
end
def render("404.ics", _assigns) do
"Bad feed"
end
def render("404.atom", _assigns) do
"Bad feed"
end
def render("invalid_request.json", _assigns) do def render("invalid_request.json", _assigns) do
%{errors: "Invalid request"} %{errors: "Invalid request"}
end end
@ -31,8 +45,11 @@ defmodule MobilizonWeb.ErrorView do
# template is found, let's render it as 500 # template is found, let's render it as 500
def template_not_found(template, assigns) do def template_not_found(template, assigns) do
require Logger require Logger
Logger.warn("Template not found") Logger.warn("Template #{inspect(template)} not found")
Logger.debug(inspect(template))
render("500.html", assigns) render("500.html", assigns)
end end
defp index_file_path() do
Path.join(Application.app_dir(:mobilizon, "priv/static"), "index.html")
end
end end

View File

@ -1,5 +1,5 @@
# source: http://localhost:4000/api # source: http://localhost:4000/api
# timestamp: Wed Sep 11 2019 11:53:12 GMT+0200 (GMT+02:00) # timestamp: Wed Sep 18 2019 17:12:13 GMT+0200 (GMT+02:00)
schema { schema {
query: RootQueryType query: RootQueryType
@ -1188,6 +1188,9 @@ type User {
"""The user's ID""" """The user's ID"""
id: ID! id: ID!
"""The list of events this person goes to"""
participations(afterDatetime: DateTime, beforeDatetime: DateTime, limit: Int = 10, page: Int = 1): [Participant]
"""The user's list of profiles (identities)""" """The user's list of profiles (identities)"""
profiles: [Person]! profiles: [Person]!

View File

@ -93,16 +93,14 @@ defmodule Mobilizon.EventsTest do
|> Map.put(:organizer_actor_id, actor.id) |> Map.put(:organizer_actor_id, actor.id)
|> Map.put(:address_id, address.id) |> Map.put(:address_id, address.id)
case Events.create_event(valid_attrs) do {:ok, %Event{} = event} = Events.create_event(valid_attrs)
{:ok, %Event{} = event} ->
assert event.begins_on == DateTime.from_naive!(~N[2010-04-17 14:00:00Z], "Etc/UTC") assert event.begins_on == DateTime.from_naive!(~N[2010-04-17 14:00:00Z], "Etc/UTC")
assert event.description == "some description" assert event.description == "some description"
assert event.ends_on == DateTime.from_naive!(~N[2010-04-17 14:00:00Z], "Etc/UTC") assert event.ends_on == DateTime.from_naive!(~N[2010-04-17 14:00:00Z], "Etc/UTC")
assert event.title == "some title" assert event.title == "some title"
err -> assert hd(Events.list_participants_for_event(event.uuid)).actor.id == actor.id
flunk("Failed to create an event #{inspect(err)}") assert hd(Events.list_participants_for_event(event.uuid)).role == :creator
end
end end
test "create_event/1 with invalid data returns error changeset" do test "create_event/1 with invalid data returns error changeset" do

View File

@ -523,7 +523,11 @@ defmodule MobilizonWeb.Resolvers.EventResolverTest do
} do } do
event = insert(:event, organizer_actor: actor) event = insert(:event, organizer_actor: actor)
begins_on = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() begins_on =
event.begins_on
|> Timex.shift(hours: 3)
|> DateTime.truncate(:second)
|> DateTime.to_iso8601()
mutation = """ mutation = """
mutation { mutation {
@ -545,6 +549,7 @@ defmodule MobilizonWeb.Resolvers.EventResolverTest do
title, title,
uuid, uuid,
url, url,
beginsOn,
picture { picture {
name, name,
url url
@ -572,6 +577,9 @@ defmodule MobilizonWeb.Resolvers.EventResolverTest do
assert json_response(res, 200)["data"]["updateEvent"]["uuid"] == event.uuid assert json_response(res, 200)["data"]["updateEvent"]["uuid"] == event.uuid
assert json_response(res, 200)["data"]["updateEvent"]["url"] == event.url assert json_response(res, 200)["data"]["updateEvent"]["url"] == event.url
assert json_response(res, 200)["data"]["updateEvent"]["beginsOn"] ==
DateTime.to_iso8601(event.begins_on |> Timex.shift(hours: 3))
assert json_response(res, 200)["data"]["updateEvent"]["picture"]["name"] == assert json_response(res, 200)["data"]["updateEvent"]["picture"]["name"] ==
"picture for my event" "picture for my event"
end end
@ -692,24 +700,24 @@ defmodule MobilizonWeb.Resolvers.EventResolverTest do
assert json_response(res, 200)["data"]["event"]["uuid"] == to_string(event.uuid) assert json_response(res, 200)["data"]["event"]["uuid"] == to_string(event.uuid)
end end
test "find_event/3 doesn't return a private event", context do # test "find_event/3 doesn't return a private event", context do
event = insert(:event, visibility: :private) # event = insert(:event, visibility: :private)
#
query = """ # query = """
{ # {
event(uuid: "#{event.uuid}") { # event(uuid: "#{event.uuid}") {
uuid, # uuid,
} # }
} # }
""" # """
#
res = # res =
context.conn # context.conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "event")) # |> get("/api", AbsintheHelpers.query_skeleton(query, "event"))
#
assert json_response(res, 200)["errors"] |> hd |> Map.get("message") == # assert json_response(res, 200)["errors"] |> hd |> Map.get("message") ==
"Event with UUID #{event.uuid} not found" # "Event with UUID #{event.uuid} not found"
end # end
test "delete_event/3 deletes an event", %{conn: conn, user: user, actor: actor} do test "delete_event/3 deletes an event", %{conn: conn, user: user, actor: actor} do
event = insert(:event, organizer_actor: actor) event = insert(:event, organizer_actor: actor)

View File

@ -5,7 +5,8 @@ defmodule MobilizonWeb.ErrorViewTest do
import Phoenix.View import Phoenix.View
test "renders 404.html" do test "renders 404.html" do
assert render_to_string(MobilizonWeb.ErrorView, "404.html", []) == "Page not found" assert render_to_string(MobilizonWeb.ErrorView, "404.html", []) =~
"We're sorry but mobilizon doesn't work properly without JavaScript enabled. Please enable it to continue."
end end
test "render 500.html" do test "render 500.html" do