Merge branch 'feature/dashboard' into 'master'

Feature/dashboard

Closes #154

See merge request framasoft/mobilizon!187
This commit is contained in:
Thomas Citharel 2019-09-22 19:35:49 +02:00
commit 9b6eadde54
56 changed files with 2273 additions and 588 deletions

View File

@ -1,12 +1,12 @@
<!DOCTYPE html> <!DOCTYPE html>
<html class="has-navbar-fixed-top"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<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

@ -1,7 +1,7 @@
<template> <template>
<div id="mobilizon"> <div id="mobilizon">
<NavBar /> <NavBar />
<main> <main class="container">
<router-view /> <router-view />
</main> </main>
<mobilizon-footer /> <mobilizon-footer />
@ -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>
@ -100,6 +82,7 @@ export default class App extends Vue {
@import "~bulma/sass/components/dropdown.sass"; @import "~bulma/sass/components/dropdown.sass";
@import "~bulma/sass/components/breadcrumb.sass"; @import "~bulma/sass/components/breadcrumb.sass";
@import "~bulma/sass/components/list.sass"; @import "~bulma/sass/components/list.sass";
@import "~bulma/sass/components/tabs";
@import "~bulma/sass/elements/box.sass"; @import "~bulma/sass/elements/box.sass";
@import "~bulma/sass/elements/button.sass"; @import "~bulma/sass/elements/button.sass";
@import "~bulma/sass/elements/container.sass"; @import "~bulma/sass/elements/container.sass";
@ -107,6 +90,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,12 +106,14 @@ 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";
@import "~buefy/src/scss/components/radio"; @import "~buefy/src/scss/components/radio";
@import "~buefy/src/scss/components/switch"; @import "~buefy/src/scss/components/switch";
@import "~buefy/src/scss/components/table"; @import "~buefy/src/scss/components/table";
@import "~buefy/src/scss/components/tabs";
.router-enter-active, .router-enter-active,
.router-leave-active { .router-leave-active {

View File

@ -1,10 +1,10 @@
<template> <template>
<span> <span>
<router-link v-if="actor.domain === null" <span v-if="actor.domain === null"
:to="{name: 'Profile', params: { name: actor.preferredUsername } }" :to="{name: 'Profile', params: { name: actor.preferredUsername } }"
> >
<slot></slot> <slot></slot>
</router-link> </span>
<a v-else :href="actor.url"> <a v-else :href="actor.url">
<slot></slot> <slot></slot>
</a> </a>

View File

@ -0,0 +1,48 @@
<template>
<article class="card">
<div class="card-content">
<div class="media">
<div class="media-left" v-if="participant.actor.avatar">
<figure class="image is-48x48">
<img :src="participant.actor.avatar.url" />
</figure>
</div>
<div class="media-content">
<span class="title" ref="title">{{ actorDisplayName }}</span><br>
<small class="has-text-grey">@{{ participant.actor.preferredUsername }}</small>
</div>
</div>
</div>
<footer class="card-footer">
<b-button v-if="participant.role === ParticipantRole.NOT_APPROVED" @click="accept(participant)" type="is-success" class="card-footer-item">{{ $t('Approve') }}</b-button>
<b-button v-if="participant.role === ParticipantRole.NOT_APPROVED" @click="reject(participant)" type="is-danger" class="card-footer-item">{{ $t('Reject')}} </b-button>
<b-button v-if="participant.role === ParticipantRole.PARTICIPANT" @click="exclude(participant)" type="is-danger" class="card-footer-item">{{ $t('Exclude')}} </b-button>
<span v-if="participant.role === ParticipantRole.CREATOR" class="card-footer-item">{{ $t('Creator')}} </span>
</footer>
</article>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IActor, IPerson, Person } from '@/types/actor';
import { IParticipant, ParticipantRole } from '@/types/event.model';
@Component
export default class ActorCard extends Vue {
@Prop({ required: true }) participant!: IParticipant;
@Prop({ type: Function }) accept;
@Prop({ type: Function }) reject;
@Prop({ type: Function }) exclude;
ParticipantRole = ParticipantRole;
get actorDisplayName(): string {
const actor = new Person(this.participant.actor);
return actor.displayName();
}
}
</script>
<style lang="scss">
</style>

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

@ -16,8 +16,10 @@
<h2 class="title" ref="title">{{ event.title }}</h2> <h2 class="title" ref="title">{{ event.title }}</h2>
</div> </div>
<span> <span>
<span v-if="event.physicalAddress && event.physicalAddress.locality">{{ event.physicalAddress.locality }} - </span> <span v-if="actorDisplayName && actorDisplayName !== '@'">{{ $t('By {name}', { name: actorDisplayName }) }}</span>
<span v-if="actorDisplayName && actorDisplayName !== '@'">{{ actorDisplayName }}</span> <span v-if="event.physicalAddress && (event.physicalAddress.locality || event.physicalAddress.description)">
- {{ event.physicalAddress.locality || event.physicalAddress.description }}
</span>
</span> </span>
</div> </div>
<!-- <div v-if="!mergedOptions.hideDetails" class="details">--> <!-- <div v-if="!mergedOptions.hideDetails" class="details">-->
@ -60,7 +62,6 @@ export interface IEventCardOptions {
@Component({ @Component({
components: { components: {
DateCalendarIcon, DateCalendarIcon,
EventCard,
}, },
mounted() { mounted() {
lineClamp(this.$refs.title, 3); lineClamp(this.$refs.title, 3);

View File

@ -0,0 +1,186 @@
<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 v-if="participation.event.beginsOn < new Date()">{{ $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))">
<router-link :to="{ name: EventRouteName.PARTICIPATIONS, params: { eventId: participation.event.uuid } }">
<b-icon icon="account-multiple-plus" /> {{ $t('Manage participations') }}
</router-link>
</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

@ -1,7 +1,7 @@
<template> <template>
<div class="modal-card"> <div class="modal-card">
<header class="modal-card-head"> <header class="modal-card-head">
<p class="modal-card-title">Join event {{ event.title }}</p> <p class="modal-card-title">{{ $t('Join event {title}', {title: event.title}) }}</p>
</header> </header>
<section class="modal-card-body is-flex"> <section class="modal-card-body is-flex">
@ -14,14 +14,18 @@
size="is-large"/> size="is-large"/>
</div> </div>
<div class="media-content"> <div class="media-content">
<p>Do you want to participate in {{ event.title }}?</p> <p>{{ $t('Do you want to participate in {title}?', {title: event.title}) }}?</p>
<b-field :label="$t('Identity')"> <b-field :label="$t('Identity')">
<identity-picker v-model="identity"></identity-picker> <identity-picker v-model="identity"></identity-picker>
</b-field> </b-field>
<p v-if="event.joinOptions === EventJoinOptions.RESTRICTED">
{{ $t('The event organizer has chosen to approve manually the participations to this event. You will receive a notification when your participation has been approved')}}
</p>
<p v-if="!event.local"> <p v-if="!event.local">
The event came from another instance. Your participation will be confirmed after we confirm it with the other instance. {{ $t('The event came from another instance. Your participation will be confirmed after we confirm it with the other instance.') }}
</p> </p>
</div> </div>
</div> </div>
@ -32,13 +36,13 @@
class="button" class="button"
ref="cancelButton" ref="cancelButton"
@click="close"> @click="close">
Cancel {{ $t('Cancel') }}
</button> </button>
<button <button
class="button is-primary" class="button is-primary"
ref="confirmButton" ref="confirmButton"
@click="confirm"> @click="confirm">
Confirm my particpation {{ $t('Confirm my particpation') }}
</button> </button>
</footer> </footer>
</div> </div>
@ -46,7 +50,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'; import { Component, Prop, Vue } from 'vue-property-decorator';
import { IEvent } from '@/types/event.model'; import { IEvent, EventJoinOptions } from '@/types/event.model';
import IdentityPicker from '@/views/Account/IdentityPicker.vue'; import IdentityPicker from '@/views/Account/IdentityPicker.vue';
import { IPerson } from '@/types/actor'; import { IPerson } from '@/types/actor';
@ -66,6 +70,8 @@ export default class ReportModal extends Vue {
isActive: boolean = false; isActive: boolean = false;
identity: IPerson = this.defaultIdentity; identity: IPerson = this.defaultIdentity;
EventJoinOptions = EventJoinOptions;
confirm() { confirm() {
this.onConfirm(this.identity); this.onConfirm(this.identity);
} }

View File

@ -55,8 +55,8 @@ export default class Map extends Vue {
return { ...this.defaultOptions, ...this.options }; return { ...this.defaultOptions, ...this.options };
} }
get lat() { return this.$props.coords.split(';')[0]; } get lat() { return this.$props.coords.split(';')[1]; }
get lon() { return this.$props.coords.split(';')[1]; } get lon() { return this.$props.coords.split(';')[0]; }
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -1,86 +1,68 @@
<template> <template>
<nav class="navbar is-fixed-top" role="navigation" aria-label="main navigation"> <b-navbar type="is-secondary" shadow wrapper-class="container">
<div class="container"> <template slot="brand">
<div class="navbar-brand"> <b-navbar-item tag="router-link" :to="{ name: 'Home' }"><logo /></b-navbar-item>
<router-link class="navbar-item" :to="{ name: 'Home' }"><logo /></router-link> </template>
<template slot="start">
<a <b-navbar-item tag="router-link" :to="{ name: EventRouteName.EXPLORE }">{{ $t('Explore') }}</b-navbar-item>
role="button" <b-navbar-item tag="router-link" :to="{ name: EventRouteName.MY_EVENTS }">{{ $t('Events') }}</b-navbar-item>
class="navbar-burger burger" </template>
aria-label="menu" <template slot="end">
aria-expanded="false" <b-navbar-item tag="div">
data-target="navbarBasicExample"
@click="showNavbar = !showNavbar" :class="{ 'is-active': showNavbar }"
>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div class="navbar-menu" :class="{ 'is-active': showNavbar }">
<div class="navbar-end">
<div class="navbar-item">
<search-field /> <search-field />
</div> </b-navbar-item>
<div class="navbar-item has-dropdown is-hoverable" v-if="currentUser.isLoggedIn"> <b-navbar-dropdown v-if="currentUser.isLoggedIn" right>
<a <template slot="label" v-if="currentActor" class="navbar-dropdown-profile">
class="navbar-link" <figure class="image is-32x32" v-if="currentActor.avatar">
v-if="currentActor" <img class="is-rounded" alt="avatarUrl" :src="currentActor.avatar.url">
>
<figure class="image is-24x24" v-if="currentActor.avatar">
<img alt="avatarUrl" :src="currentActor.avatar.url">
</figure> </figure>
<span>{{ currentActor.preferredUsername }}</span> <span>{{ currentActor.preferredUsername }}</span>
</a> </template>
<div class="navbar-dropdown is-boxed"> <b-navbar-item tag="span" v-for="identity in identities" v-if="identities.length > 0" :active="identity.id === currentActor.id">
<div v-for="identity in identities" v-if="identities.length > 0"> <span @click="setIdentity(identity)">
<a class="navbar-item" @click="setIdentity(identity)" :class="{ 'is-active': identity.id === currentActor.id }">
<div class="media-left"> <div class="media-left">
<figure class="image is-24x24" v-if="identity.avatar"> <figure class="image is-32x32" v-if="identity.avatar">
<img class="is-rounded" :src="identity.avatar.url"> <img class="is-rounded" :src="identity.avatar.url" alt="" />
</figure> </figure>
</div> </div>
<div class="media-content"> <div class="media-content">
<h3>{{ identity.displayName() }}</h3> <span>{{ identity.displayName() }}</span>
</div> </div>
</a> </span>
<hr class="navbar-divider"> <hr class="navbar-divider">
</div> </b-navbar-item>
<a class="navbar-item">
<b-navbar-item>
<router-link :to="{ name: 'UpdateIdentity' }">{{ $t('My account') }}</router-link> <router-link :to="{ name: 'UpdateIdentity' }">{{ $t('My account') }}</router-link>
</a> </b-navbar-item>
<a class="navbar-item"> <b-navbar-item>
<router-link :to="{ name: ActorRouteName.CREATE_GROUP }">{{ $t('Create group') }}</router-link> <router-link :to="{ name: ActorRouteName.CREATE_GROUP }">{{ $t('Create group') }}</router-link>
</a> </b-navbar-item>
<a class="navbar-item" v-if="currentUser.role === ICurrentUserRole.ADMINISTRATOR"> <b-navbar-item v-if="currentUser.role === ICurrentUserRole.ADMINISTRATOR">
<router-link :to="{ name: AdminRouteName.DASHBOARD }">{{ $t('Administration') }}</router-link> <router-link :to="{ name: AdminRouteName.DASHBOARD }">{{ $t('Administration') }}</router-link>
</a> </b-navbar-item>
<a class="navbar-item" v-on:click="logout()">{{ $t('Log out') }}</a> <b-navbar-item v-on:click="logout()">{{ $t('Log out') }}</b-navbar-item>
</div> </b-navbar-dropdown>
</div>
<div class="navbar-item" v-else> <b-navbar-item v-else tag="div">
<div class="buttons"> <div class="buttons">
<router-link class="button is-primary" v-if="config && config.registrationsOpen" :to="{ name: 'Register' }"> <router-link class="button is-primary" v-if="config && config.registrationsOpen" :to="{ name: 'Register' }">
<strong>{{ $t('Sign up') }}</strong> <strong>{{ $t('Sign up') }}</strong>
</router-link> </router-link>
<router-link class="button is-primary" :to="{ name: 'Login' }">{{ $t('Log in') }}</router-link> <router-link class="button is-light" :to="{ name: 'Login' }">{{ $t('Log in') }}</router-link>
</div> </div>
</div> </b-navbar-item>
</div> </template>
</div> </b-navbar>
</div>
</nav>
</template> </template>
<script lang="ts"> <script lang="ts">
@ -97,6 +79,7 @@ import SearchField from '@/components/SearchField.vue';
import { ActorRouteName } from '@/router/actor'; import { ActorRouteName } from '@/router/actor';
import { AdminRouteName } from '@/router/admin'; import { AdminRouteName } from '@/router/admin';
import { RouteName } from '@/router'; import { RouteName } from '@/router';
import { EventRouteName } from '@/router/event';
@Component({ @Component({
apollo: { apollo: {
@ -108,7 +91,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,11 +111,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;
EventRouteName = EventRouteName;
@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() {
@ -165,10 +159,26 @@ export default class NavBar extends Vue {
@import "../variables.scss"; @import "../variables.scss";
nav { nav {
border-bottom: solid 1px #0a0a0a; /*border-bottom: solid 1px #0a0a0a;*/
.navbar-item img { .navbar-dropdown .navbar-item {
cursor: pointer;
span {
display: inherit;
}
&.is-active {
background: $secondary;
}
img {
max-height: 2.5em; max-height: 2.5em;
} }
}
.navbar-item.has-dropdown a.navbar-link figure {
margin-right: 0.75rem;
}
} }
</style> </style>

View File

@ -16,24 +16,23 @@
size="is-large"/> size="is-large"/>
</div> </div>
<div class="media-content"> <div class="media-content">
<p>The report will be sent to the moderators of your instance. <p>{{ $t('The report will be sent to the moderators of your instance. You can explain why you report this content below.') }}</p>
You can explain why you report this content below.</p>
<div class="control"> <div class="control">
<b-input <b-input
v-model="content" v-model="content"
type="textarea" type="textarea"
@keyup.enter="confirm" @keyup.enter="confirm"
placeholder="Additional comments" :placeholder="$t('Additional comments')"
/> />
</div> </div>
<p v-if="outsideDomain"> <p v-if="outsideDomain">
The content came from another server. Transfer an anonymous copy of the report ? {{ $t('The content came from another server. Transfer an anonymous copy of the report?') }}
</p> </p>
<div class="control" v-if="outsideDomain"> <div class="control" v-if="outsideDomain">
<b-switch v-model="forward">Transfer to {{ outsideDomain }}</b-switch> <b-switch v-model="forward">{{ $t('Transfer to {outsideDomain}', { outsideDomain }) }}</b-switch>
</div> </div>
</div> </div>
</div> </div>
@ -44,13 +43,13 @@
class="button" class="button"
ref="cancelButton" ref="cancelButton"
@click="close"> @click="close">
{{ cancelText }} {{ translatedCancelText }}
</button> </button>
<button <button
class="button is-primary" class="button is-primary"
ref="confirmButton" ref="confirmButton"
@click="confirm"> @click="confirm">
{{ confirmText }} {{ translatedConfirmText }}
</button> </button>
</footer> </footer>
</div> </div>
@ -69,13 +68,21 @@ export default class ReportModal extends Vue {
@Prop({ type: Function, default: () => {} }) onConfirm; @Prop({ type: Function, default: () => {} }) onConfirm;
@Prop({ type: String }) title; @Prop({ type: String }) title;
@Prop({ type: String, default: '' }) outsideDomain; @Prop({ type: String, default: '' }) outsideDomain;
@Prop({ type: String, default: 'Cancel' }) cancelText; @Prop({ type: String }) cancelText;
@Prop({ type: String, default: 'Send the report' }) confirmText; @Prop({ type: String }) confirmText;
isActive: boolean = false; isActive: boolean = false;
content: string = ''; content: string = '';
forward: boolean = false; forward: boolean = false;
get translatedCancelText() {
return this.cancelText || this.$t('Cancel');
}
get translatedConfirmText() {
return this.confirmText || this.$t('Send the report');
}
confirm() { confirm() {
this.onConfirm(this.content, this.forward); this.onConfirm(this.content, this.forward);
this.close(); this.close();

View File

@ -59,25 +59,50 @@ 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
}
},
id,
role,
actor {
id,
preferredUsername,
name,
domain,
avatar {
url
}
}
}
} }
}`; }`;

View File

@ -2,6 +2,7 @@ import gql from 'graphql-tag';
const participantQuery = ` const participantQuery = `
role, role,
id,
actor { actor {
preferredUsername, preferredUsername,
avatar { avatar {
@ -20,7 +21,8 @@ const physicalAddressQuery = `
postalCode, postalCode,
region, region,
country, country,
geom geom,
id
`; `;
const tagsQuery = ` const tagsQuery = `
@ -50,7 +52,7 @@ const optionsQuery = `
`; `;
export const FETCH_EVENT = gql` export const FETCH_EVENT = gql`
query($uuid:UUID!) { query($uuid:UUID!, $roles: String) {
event(uuid: $uuid) { event(uuid: $uuid) {
id, id,
uuid, uuid,
@ -63,6 +65,7 @@ export const FETCH_EVENT = gql`
endsOn, endsOn,
status, status,
visibility, visibility,
joinOptions,
picture { picture {
id id
url url
@ -92,7 +95,7 @@ export const FETCH_EVENT = gql`
# preferredUsername, # preferredUsername,
# name, # name,
# }, # },
participants { participants (roles: $roles) {
${participantQuery} ${participantQuery}
}, },
participantStats { participantStats {
@ -146,23 +149,25 @@ export const FETCH_EVENTS = gql`
# online_address, # online_address,
# phone_address, # phone_address,
physicalAddress { physicalAddress {
id,
description, description,
locality locality
} },
organizerActor { organizerActor {
id,
avatar { avatar {
url url
}, },
preferredUsername, preferredUsername,
name, name,
}, },
attributedTo { # attributedTo {
avatar { # avatar {
url # url
}, # },
preferredUsername, # preferredUsername,
name, # name,
}, # },
category, category,
participants { participants {
${participantQuery} ${participantQuery}
@ -183,7 +188,8 @@ export const CREATE_EVENT = gql`
$beginsOn: DateTime!, $beginsOn: DateTime!,
$endsOn: DateTime, $endsOn: DateTime,
$status: EventStatus, $status: EventStatus,
$visibility: EventVisibility $visibility: EventVisibility,
$joinOptions: EventJoinOptions,
$tags: [String], $tags: [String],
$picture: PictureInput, $picture: PictureInput,
$onlineAddress: String, $onlineAddress: String,
@ -200,6 +206,7 @@ export const CREATE_EVENT = gql`
endsOn: $endsOn, endsOn: $endsOn,
status: $status, status: $status,
visibility: $visibility, visibility: $visibility,
joinOptions: $joinOptions,
tags: $tags, tags: $tags,
picture: $picture, picture: $picture,
onlineAddress: $onlineAddress, onlineAddress: $onlineAddress,
@ -216,6 +223,7 @@ export const CREATE_EVENT = gql`
endsOn, endsOn,
status, status,
visibility, visibility,
joinOptions,
picture { picture {
id id
url url
@ -245,7 +253,8 @@ export const EDIT_EVENT = gql`
$beginsOn: DateTime, $beginsOn: DateTime,
$endsOn: DateTime, $endsOn: DateTime,
$status: EventStatus, $status: EventStatus,
$visibility: EventVisibility $visibility: EventVisibility,
$joinOptions: EventJoinOptions,
$tags: [String], $tags: [String],
$picture: PictureInput, $picture: PictureInput,
$onlineAddress: String, $onlineAddress: String,
@ -262,6 +271,7 @@ export const EDIT_EVENT = gql`
endsOn: $endsOn, endsOn: $endsOn,
status: $status, status: $status,
visibility: $visibility, visibility: $visibility,
joinOptions: $joinOptions,
tags: $tags, tags: $tags,
picture: $picture, picture: $picture,
onlineAddress: $onlineAddress, onlineAddress: $onlineAddress,
@ -278,6 +288,7 @@ export const EDIT_EVENT = gql`
endsOn, endsOn,
status, status,
visibility, visibility,
joinOptions,
picture { picture {
id id
url url
@ -323,6 +334,23 @@ export const LEAVE_EVENT = gql`
} }
`; `;
export const ACCEPT_PARTICIPANT = gql`
mutation AcceptParticipant($id: ID!, $moderatorActorId: ID!) {
acceptParticipation(id: $id, moderatorActorId: $moderatorActorId) {
role,
id
}
}
`;
export const REJECT_PARTICIPANT = gql`
mutation RejectParticipant($id: ID!, $moderatorActorId: ID!) {
rejectParticipation(id: $id, moderatorActorId: $moderatorActorId) {
id
}
}
`;
export const DELETE_EVENT = gql` export const DELETE_EVENT = gql`
mutation DeleteEvent($eventId: ID!, $actorId: ID!) { mutation DeleteEvent($eventId: ID!, $actorId: ID!) {
deleteEvent( deleteEvent(
@ -333,3 +361,17 @@ export const DELETE_EVENT = gql`
} }
} }
`; `;
export const PARTICIPANTS = gql`
query($uuid: UUID!, $page: Int, $limit: Int, $roles: String) {
event(uuid: $uuid) {
participants(page: $page, limit: $limit, roles: $roles) {
${participantQuery}
},
participantStats {
approved,
unapproved
}
}
}
`;

View File

@ -8,12 +8,16 @@
"Add an address": "Add an address", "Add an address": "Add an address",
"Add to my calendar": "Add to my calendar", "Add to my calendar": "Add to my calendar",
"Add": "Add", "Add": "Add",
"Additional comments": "Additional comments",
"Administration": "Administration", "Administration": "Administration",
"Allow all comments": "Allow all comments", "Allow all comments": "Allow all comments",
"Approve": "Approve",
"Are you going to this event?": "Are you going to this event?", "Are you going to this event?": "Are you going to this event?",
"Are you sure you want to cancel your participation at event \"{title}\"?": "Are you sure you want to cancel your participation at event \"{title}\"?",
"Are you sure you want to delete this event? This action cannot be reverted.": "Are you sure you want to delete this event? This action cannot be reverted.", "Are you sure you want to delete this event? This action cannot be reverted.": "Are you sure you want to delete this event? This action cannot be reverted.",
"Before you can login, you need to click on the link inside it to validate your account": "Before you can login, you need to click on the link inside it to validate your account", "Before you can login, you need to click on the link inside it to validate your account": "Before you can login, you need to click on the link inside it to validate your account",
"By {name}": "By {name}", "By {name}": "By {name}",
"Cancel": "Cancel",
"Category": "Category", "Category": "Category",
"Change": "Change", "Change": "Change",
"Clear": "Clear", "Clear": "Clear",
@ -22,6 +26,7 @@
"Close comments for all (except for admins)": "Close comments for all (except for admins)", "Close comments for all (except for admins)": "Close comments for all (except for admins)",
"Comments on the event page": "Comments on the event page", "Comments on the event page": "Comments on the event page",
"Comments": "Comments", "Comments": "Comments",
"Confirm my particpation": "Confirm my particpation",
"Confirmed: Will happen": "Confirmed: Will happen", "Confirmed: Will happen": "Confirmed: Will happen",
"Country": "Country", "Country": "Country",
"Create a new event": "Create a new event", "Create a new event": "Create a new event",
@ -34,6 +39,7 @@
"Create token": "Create token", "Create token": "Create token",
"Create your communities and your events": "Create your communities and your events", "Create your communities and your events": "Create your communities and your events",
"Create": "Create", "Create": "Create",
"Creator": "Creator",
"Current": "Current", "Current": "Current",
"Delete event": "Delete event", "Delete event": "Delete event",
"Delete this identity": "Delete this identity", "Delete this identity": "Delete this identity",
@ -47,6 +53,7 @@
"Display name": "Display name", "Display name": "Display name",
"Display participation price": "Display participation price", "Display participation price": "Display participation price",
"Displayed name": "Displayed name", "Displayed name": "Displayed name",
"Do you want to participate in {title}?": "Do you want to participate in {title}?",
"Edit": "Edit", "Edit": "Edit",
"Either the account is already validated, either the validation token is incorrect.": "Either the account is already validated, either the validation token is incorrect.", "Either the account is already validated, either the validation token is incorrect.": "Either the account is already validated, either the validation token is incorrect.",
"Email": "Email", "Email": "Email",
@ -60,11 +67,14 @@
"Events nearby you": "Events nearby you", "Events nearby you": "Events nearby you",
"Events you're going at": "Events you're going at", "Events you're going at": "Events you're going at",
"Events": "Events", "Events": "Events",
"Exclude": "Exclude",
"Explore": "Explore",
"Features": "Features", "Features": "Features",
"Find an address": "Find an address", "Find an address": "Find an address",
"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",
@ -80,41 +90,54 @@
"Identity": "Identity", "Identity": "Identity",
"If an account with this email exists, we just sent another confirmation email to {email}": "If an account with this email exists, we just sent another confirmation email to {email}", "If an account with this email exists, we just sent another confirmation email to {email}": "If an account with this email exists, we just sent another confirmation email to {email}",
"If this identity is the only administrator of some groups, you need to delete them before being able to delete this identity.": "If this identity is the only administrator of some groups, you need to delete them before being able to delete this identity.", "If this identity is the only administrator of some groups, you need to delete them before being able to delete this identity.": "If this identity is the only administrator of some groups, you need to delete them before being able to delete this identity.",
"Join event {title}": "Join event {title}",
"Join": "Join", "Join": "Join",
"Last published event": "Last published event", "Last published event": "Last published event",
"Last week": "Last week",
"Learn more on {0}": "Learn more on {0}", "Learn more on {0}": "Learn more on {0}",
"Learn more on": "Learn more on", "Learn more on": "Learn more on",
"Leave event": "Leave event",
"Leave": "Leave", "Leave": "Leave",
"Leaving event \"{title}\"": "Leaving event \"{title}\"",
"Legal": "Legal", "Legal": "Legal",
"License": "License", "License": "License",
"Limited places": "Limited places", "Limited places": "Limited places",
"Load more": "Load more",
"Loading…": "Loading…", "Loading…": "Loading…",
"Locality": "Locality", "Locality": "Locality",
"Log in": "Log in", "Log in": "Log in",
"Log out": "Log out", "Log out": "Log out",
"Login": "Login", "Login": "Login",
"Manage participants": "Manage participants",
"Manage participations": "Manage participations",
"Members": "Members", "Members": "Members",
"Moderated comments (shown after approval)": "Moderated comments (shown after approval)", "Moderated comments (shown after approval)": "Moderated comments (shown after approval)",
"My account": "My account", "My account": "My account",
"My events": "My events",
"My identities": "My identities", "My identities": "My identities",
"Name": "Name", "Name": "Name",
"No address defined": "No address defined", "No address defined": "No address defined",
"No events found": "No events found", "No events found": "No events found",
"No group found": "No group found", "No group found": "No group found",
"No groups found": "No groups found", "No groups found": "No groups found",
"No participants yet.": "No participants yet.",
"No results for \"{queryText}\"": "No results for \"{queryText}\"", "No results for \"{queryText}\"": "No results for \"{queryText}\"",
"Number of places": "Number of places", "Number of places": "Number of places",
"One person is going": "No one is going | One person is going | {approved} persons are going", "One person is going": "No one is going | One person is going | {approved} persons are going",
"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 by {name}": "Organized by {name}",
"Organized": "Organized", "Organized": "Organized",
"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.",
"Page limited to my group (asks for auth)": "Page limited to my group (asks for auth)", "Page limited to my group (asks for auth)": "Page limited to my group (asks for auth)",
"Participants": "Participants",
"Participation approval": "Participation approval", "Participation approval": "Participation approval",
"Password (confirmation)": "Password (confirmation)",
"Password reset": "Password reset", "Password reset": "Password reset",
"Password": "Password", "Password": "Password",
"Past events": "Passed events",
"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.",
@ -123,10 +146,12 @@
"Please read the full rules": "Please read the full rules", "Please read the full rules": "Please read the full rules",
"Please type at least 5 characters": "Please type at least 5 characters", "Please type at least 5 characters": "Please type at least 5 characters",
"Postal Code": "Postal Code", "Postal Code": "Postal Code",
"Private event": "Private event",
"Private feeds": "Private feeds", "Private feeds": "Private feeds",
"Promotion": "Promotion", "Promotion": "Promotion",
"Public RSS/Atom Feed": "Public RSS/Atom Feed", "Public RSS/Atom Feed": "Public RSS/Atom Feed",
"Public comment moderation": "Public comment moderation", "Public comment moderation": "Public comment moderation",
"Public event": "Public event",
"Public feeds": "Public feeds", "Public feeds": "Public feeds",
"Public iCal Feed": "Public iCal Feed", "Public iCal Feed": "Public iCal Feed",
"Published events": "Published events", "Published events": "Published events",
@ -135,6 +160,8 @@
"Register an account on Mobilizon!": "Register an account on Mobilizon!", "Register an account on Mobilizon!": "Register an account on Mobilizon!",
"Register": "Register", "Register": "Register",
"Registration is currently closed.": "Registration is currently closed.", "Registration is currently closed.": "Registration is currently closed.",
"Reject": "Reject",
"Report this event": "Report this event",
"Report": "Signaler", "Report": "Signaler",
"Resend confirmation email": "Resend confirmation email", "Resend confirmation email": "Resend confirmation email",
"Reset my password": "Reset my password", "Reset my password": "Reset my password",
@ -145,6 +172,7 @@
"Searching…": "Searching…", "Searching…": "Searching…",
"Send confirmation email again": "Send confirmation email again", "Send confirmation email again": "Send confirmation email again",
"Send email to reset my password": "Send email to reset my password", "Send email to reset my password": "Send email to reset my password",
"Send the report": "Send the report",
"Share this event": "Share this event", "Share this event": "Share this event",
"Show map": "Show map", "Show map": "Show map",
"Show remaining number of places": "Show remaining number of places", "Show remaining number of places": "Show remaining number of places",
@ -153,8 +181,12 @@
"Status": "Status", "Status": "Status",
"Street": "Street", "Street": "Street",
"Tentative: Will be confirmed later": "Tentative: Will be confirmed later", "Tentative: Will be confirmed later": "Tentative: Will be confirmed later",
"The content came from another server. Transfer an anonymous copy of the report?": "The content came from another server. Transfer an anonymous copy of the report ?",
"The event came from another instance. Your participation will be confirmed after we confirm it with the other instance.": "The event came from another instance. Your participation will be confirmed after we confirm it with the other instance.",
"The event organizer didn't add any description.": "The event organizer didn't add any description.", "The event organizer didn't add any description.": "The event organizer didn't add any description.",
"The event organizer has chosen to approve manually the participations to this event. You will receive a notification when your participation has been approved": "The event organizer has chosen to approve manually the participations to this event. You will receive a notification when your participation has been approved",
"The page you're looking for doesn't exist.": "The page you're looking for doesn't exist.", "The page you're looking for doesn't exist.": "The page you're looking for doesn't exist.",
"The report will be sent to the moderators of your instance. You can explain why you report this content below.": "The report will be sent to the moderators of your instance. You can explain why you report this content below.",
"The {date} at {time}": "The {date} at {time}", "The {date} at {time}": "The {date} at {time}",
"The {date} from {startTime} to {endTime}": "The {date} from {startTime} to {endTime}", "The {date} from {startTime} to {endTime}": "The {date} from {startTime} to {endTime}",
"There are {participants} participants.": "There's only one participant | There are {participants} participants.", "There are {participants} participants.": "There's only one participant | There are {participants} participants.",
@ -164,13 +196,18 @@
"Title": "Title", "Title": "Title",
"To confirm, type your event title \"{eventTitle}\"": "To confirm, type your event title \"{eventTitle}\"", "To confirm, type your event title \"{eventTitle}\"": "To confirm, type your event title \"{eventTitle}\"",
"To confirm, type your identity username \"{preferredUsername}\"": "To confirm, type your identity username \"{preferredUsername}\"", "To confirm, type your identity username \"{preferredUsername}\"": "To confirm, type your identity username \"{preferredUsername}\"",
"Transfer to {outsideDomain}": "Transfer to {outsideDomain}",
"Unknown error.": "Unknown error.", "Unknown error.": "Unknown error.",
"Upcoming": "Upcoming",
"Update event {name}": "Update event {name}", "Update event {name}": "Update event {name}",
"Update my event": "Update my event", "Update my event": "Update my event",
"User logout": "User logout", "User logout": "User logout",
"Username": "Username", "Username": "Username",
"Users": "Users", "Users": "Users",
"View event page": "View event page",
"View everything": "View everything",
"Visible everywhere on the web (public)": "Visible everywhere on the web (public)", "Visible everywhere on the web (public)": "Visible everywhere on the web (public)",
"Waiting list": "Waiting list",
"We just sent an email to {email}": "We just sent an email to {email}", "We just sent an email to {email}": "We just sent an email to {email}",
"Website / URL": "Website / URL", "Website / URL": "Website / URL",
"Welcome back {username}": "Welcome back {username}", "Welcome back {username}": "Welcome back {username}",
@ -187,6 +224,7 @@
"You have one event tomorrow.": "You have no events tomorrow | You have one event tomorrow. | You have {count} events tomorrow", "You have one event tomorrow.": "You have no events tomorrow | You have one event tomorrow. | You have {count} events tomorrow",
"You need to login.": "You need to login.", "You need to login.": "You need to login.",
"You're not going to any event yet": "You're not going to any event yet", "You're not going to any event yet": "You're not going to any event yet",
"You're organizing this event": "You're organizing this event",
"Your account has been validated": "Your account has been validated", "Your account has been validated": "Your account has been validated",
"Your account is being validated": "Your account is being validated", "Your account is being validated": "Your account is being validated",
"Your account is nearly ready, {username}": "Your account is nearly ready, {username}", "Your account is nearly ready, {username}": "Your account is nearly ready, {username}",
@ -194,7 +232,9 @@
"e.g. 10 Rue Jangot": "e.g. 10 Rue Jangot", "e.g. 10 Rue Jangot": "e.g. 10 Rue Jangot",
"iCal Feed": "iCal Feed", "iCal Feed": "iCal Feed",
"meditate a bit": "meditate a bit", "meditate a bit": "meditate a bit",
"public event": "public event",
"{actor}'s avatar": "{actor}'s avatar", "{actor}'s avatar": "{actor}'s avatar",
"{approved} / {total} seats": "{approved} / {total} seats",
"{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" "© 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"
} }

View File

@ -8,12 +8,16 @@
"Add an address": "Ajouter une adresse", "Add an address": "Ajouter une adresse",
"Add to my calendar": "Ajouter à mon agenda", "Add to my calendar": "Ajouter à mon agenda",
"Add": "Ajouter", "Add": "Ajouter",
"Additional comments": "Commentaires additionnels",
"Administration": "Administration", "Administration": "Administration",
"Allow all comments": "Autoriser tous les commentaires", "Allow all comments": "Autoriser tous les commentaires",
"Approve": "Approuver",
"Are you going to this event?": "Allez-vous à cet événement ?", "Are you going to this event?": "Allez-vous à cet événement ?",
"Are you sure you want to cancel your participation at event \"{title}\"?": "Êtes-vous certain⋅e de vouloir annuler votre participation à l'événement « {title} » ?",
"Are you sure you want to delete this event? This action cannot be reverted.": "Êtes-vous certain⋅e de vouloir supprimer cet événement ? Cette action ne peut être annulée.", "Are you sure you want to delete this event? This action cannot be reverted.": "Êtes-vous certain⋅e de vouloir supprimer cet événement ? Cette action ne peut être annulée.",
"Before you can login, you need to click on the link inside it to validate your account": "Avant que vous puissiez vous enregistrer, vous devez cliquer sur le lien à l'intérieur pour valider votre compte", "Before you can login, you need to click on the link inside it to validate your account": "Avant que vous puissiez vous enregistrer, vous devez cliquer sur le lien à l'intérieur pour valider votre compte",
"By {name}": "Par {name}", "By {name}": "Par {name}",
"Cancel": "Annuler",
"Category": "Catégorie", "Category": "Catégorie",
"Change": "Modifier", "Change": "Modifier",
"Clear": "Effacer", "Clear": "Effacer",
@ -22,6 +26,7 @@
"Close comments for all (except for admins)": "Fermer les commentaires à tout le monde (excepté les administrateurs)", "Close comments for all (except for admins)": "Fermer les commentaires à tout le monde (excepté les administrateurs)",
"Comments on the event page": "Commentaires sur la page de l'événement", "Comments on the event page": "Commentaires sur la page de l'événement",
"Comments": "Commentaires", "Comments": "Commentaires",
"Confirm my particpation": "Confirmer ma particpation",
"Confirmed: Will happen": "Confirmé : aura lieu", "Confirmed: Will happen": "Confirmé : aura lieu",
"Country": "Pays", "Country": "Pays",
"Create a new event": "Créer un nouvel événement", "Create a new event": "Créer un nouvel événement",
@ -34,6 +39,7 @@
"Create token": "Créer un jeton", "Create token": "Créer un jeton",
"Create your communities and your events": "Créer vos communautés et vos événements", "Create your communities and your events": "Créer vos communautés et vos événements",
"Create": "Créer", "Create": "Créer",
"Creator": "Créateur",
"Current": "Actuel", "Current": "Actuel",
"Delete event": "Supprimer un événement", "Delete event": "Supprimer un événement",
"Delete this identity": "Supprimer cette identité", "Delete this identity": "Supprimer cette identité",
@ -47,6 +53,7 @@
"Display name": "Nom affiché", "Display name": "Nom affiché",
"Display participation price": "Afficher un prix de participation", "Display participation price": "Afficher un prix de participation",
"Displayed name": "Nom affiché", "Displayed name": "Nom affiché",
"Do you want to participate in {title}?": "Voulez-vous participer à {title} ?",
"Edit": "Éditer", "Edit": "Éditer",
"Either the account is already validated, either the validation token is incorrect.": "Soit le compte est déjà validé, soit le jeton de validation est incorrect.", "Either the account is already validated, either the validation token is incorrect.": "Soit le compte est déjà validé, soit le jeton de validation est incorrect.",
"Email": "Email", "Email": "Email",
@ -60,11 +67,14 @@
"Events nearby you": "Événements près de chez vous", "Events nearby you": "Événements près de chez vous",
"Events you're going at": "Événements auxquels vous vous rendez", "Events you're going at": "Événements auxquels vous vous rendez",
"Events": "Événements", "Events": "Événements",
"Exclude": "Exclure",
"Explore": "Explorer",
"Features": "Fonctionnalités", "Features": "Fonctionnalités",
"Find an address": "Trouver une adresse", "Find an address": "Trouver une adresse",
"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",
@ -80,41 +90,54 @@
"Identity": "Identité", "Identity": "Identité",
"If an account with this email exists, we just sent another confirmation email to {email}": "Si un compte avec un tel email existe, nous venons juste d'envoyer un nouvel email de confirmation à {email}", "If an account with this email exists, we just sent another confirmation email to {email}": "Si un compte avec un tel email existe, nous venons juste d'envoyer un nouvel email de confirmation à {email}",
"If this identity is the only administrator of some groups, you need to delete them before being able to delete this identity.": "Si cette identité est la seule administratrice de certains groupes, vous devez les supprimer avant de pouvoir supprimer cette identité.", "If this identity is the only administrator of some groups, you need to delete them before being able to delete this identity.": "Si cette identité est la seule administratrice de certains groupes, vous devez les supprimer avant de pouvoir supprimer cette identité.",
"Join event {title}": "Rejoindre {title}",
"Join": "Rejoindre", "Join": "Rejoindre",
"Last published event": "Dernier événement publié", "Last published event": "Dernier événement publié",
"Last week": "La semaine dernière",
"Learn more on {0}": "En apprendre plus sur {0}", "Learn more on {0}": "En apprendre plus sur {0}",
"Learn more on": "En apprendre plus sur", "Learn more on": "En apprendre plus sur",
"Leave event": "Annuler ma participation à l'événement",
"Leave": "Quitter", "Leave": "Quitter",
"Leaving event \"{title}\"": "Annuler ma participation à l'événement",
"Legal": "Mentions légales", "Legal": "Mentions légales",
"License": "Licence", "License": "Licence",
"Limited places": "Places limitées", "Limited places": "Places limitées",
"Load more": "Voir plus",
"Loading…": "Chargement en cours…", "Loading…": "Chargement en cours…",
"Locality": "Commune", "Locality": "Commune",
"Log in": "Se connecter", "Log in": "Se connecter",
"Log out": "Se déconnecter", "Log out": "Se déconnecter",
"Login": "Se connecter", "Login": "Se connecter",
"Manage participants": "Gérer les participants",
"Manage participations": "Gérer les participations",
"Members": "Membres", "Members": "Membres",
"Moderated comments (shown after approval)": "Commentaires modérés (affichés après validation)", "Moderated comments (shown after approval)": "Commentaires modérés (affichés après validation)",
"My account": "Mon compte", "My account": "Mon compte",
"My events": "Mes événements",
"My identities": "Mes identités", "My identities": "Mes identités",
"Name": "Nom", "Name": "Nom",
"No address defined": "Aucune adresse définie", "No address defined": "Aucune adresse définie",
"No events found": "Aucun événement trouvé", "No events found": "Aucun événement trouvé",
"No group found": "Aucun groupe trouvé", "No group found": "Aucun groupe trouvé",
"No groups found": "Aucun groupe trouvé", "No groups found": "Aucun groupe trouvé",
"No participants yet.": "Pas de participants pour le moment.",
"No results for \"{queryText}\"": "Pas de résultats pour « {queryText} »", "No results for \"{queryText}\"": "Pas de résultats pour « {queryText} »",
"Number of places": "Nombre de places", "Number of places": "Nombre de places",
"One person is going": "Personne n'y va | Une personne y va | {approved} personnes y vont", "One person is going": "Personne n'y va | Une personne y va | {approved} personnes y vont",
"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 by {name}": "Organisé par {name}",
"Organized": "Organisés", "Organized": "Organisés",
"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.",
"Page limited to my group (asks for auth)": "Accès limité à mon groupe (demande authentification)", "Page limited to my group (asks for auth)": "Accès limité à mon groupe (demande authentification)",
"Participants": "Participants",
"Participation approval": "Validation des participations", "Participation approval": "Validation des participations",
"Password (confirmation)": "Mot de passe (confirmation)",
"Password reset": "Réinitialisation du mot de passe", "Password reset": "Réinitialisation du mot de passe",
"Password": "Mot de passe", "Password": "Mot de passe",
"Past events": "Événements passés",
"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.",
@ -123,10 +146,12 @@
"Please read the full rules": "Merci de lire les règles complètes", "Please read the full rules": "Merci de lire les règles complètes",
"Please type at least 5 characters": "Merci d'entrer au moins 5 caractères", "Please type at least 5 characters": "Merci d'entrer au moins 5 caractères",
"Postal Code": "Code postal", "Postal Code": "Code postal",
"Private event": "Événement privé",
"Private feeds": "Flux privés", "Private feeds": "Flux privés",
"Promotion": "Mise en avant", "Promotion": "Mise en avant",
"Public RSS/Atom Feed": "Flux RSS/Atom public", "Public RSS/Atom Feed": "Flux RSS/Atom public",
"Public comment moderation": "Modération des commentaires publics", "Public comment moderation": "Modération des commentaires publics",
"Public event": "Événement public",
"Public feeds": "Flux publics", "Public feeds": "Flux publics",
"Public iCal Feed": "Flux iCal public", "Public iCal Feed": "Flux iCal public",
"Published events": "Événements publiés", "Published events": "Événements publiés",
@ -135,7 +160,9 @@
"Register an account on Mobilizon!": "S'inscrire sur Mobilizon !", "Register an account on Mobilizon!": "S'inscrire sur Mobilizon !",
"Register": "S'inscrire", "Register": "S'inscrire",
"Registration is currently closed.": "Les inscriptions sont actuellement fermées.", "Registration is currently closed.": "Les inscriptions sont actuellement fermées.",
"Report": "Report", "Reject": "Rejetter",
"Report this event": "Signaler cet événement",
"Report": "Signaler",
"Resend confirmation email": "Envoyer à nouveau l'email de confirmation", "Resend confirmation email": "Envoyer à nouveau l'email de confirmation",
"Reset my password": "Réinitialiser mon mot de passe", "Reset my password": "Réinitialiser mon mot de passe",
"Save": "Enregistrer", "Save": "Enregistrer",
@ -145,6 +172,7 @@
"Searching…": "Recherche en cours…", "Searching…": "Recherche en cours…",
"Send confirmation email again": "Envoyer l'email de confirmation à nouveau", "Send confirmation email again": "Envoyer l'email de confirmation à nouveau",
"Send email to reset my password": "Envoyer un email pour réinitialiser mon mot de passe", "Send email to reset my password": "Envoyer un email pour réinitialiser mon mot de passe",
"Send the report": "Envoyer le signalement",
"Share this event": "Partager l'événement", "Share this event": "Partager l'événement",
"Show map": "Afficher la carte", "Show map": "Afficher la carte",
"Show remaining number of places": "Afficher le nombre de places restantes", "Show remaining number of places": "Afficher le nombre de places restantes",
@ -153,8 +181,12 @@
"Status": "Statut", "Status": "Statut",
"Street": "Rue", "Street": "Rue",
"Tentative: Will be confirmed later": "Provisoire : sera confirmé plus tard", "Tentative: Will be confirmed later": "Provisoire : sera confirmé plus tard",
"The content came from another server. Transfer an anonymous copy of the report?": "Le contenu provient d'une autre instance. Transférer une copie anonyme du signalement ?",
"The event came from another instance. Your participation will be confirmed after we confirm it with the other instance.": "L'événement provient d'une autre instance. Votre participation sera confirmée après que nous ayons la confirmation de l'autre instance.",
"The event organizer didn't add any description.": "L'organisateur de l'événement n'a pas ajouté de description.", "The event organizer didn't add any description.": "L'organisateur de l'événement n'a pas ajouté de description.",
"The event organizer has chosen to approve manually the participations to this event. You will receive a notification when your participation has been approved": "L'organisateur⋅ice de l'événement a choisi d'approuver manuellement les participations à cet événement. Vous recevrez une notification lorsque votre participation sera approuvée",
"The page you're looking for doesn't exist.": "La page que vous recherchez n'existe pas.", "The page you're looking for doesn't exist.": "La page que vous recherchez n'existe pas.",
"The report will be sent to the moderators of your instance. You can explain why you report this content below.": "Le signalement sera envoyé aux modérateur⋅ices de votre instance. Vous pouvez expliquer pourquoi vous signalez ce contenu ci-dessous.",
"The {date} at {time}": "Le {date} à {time}", "The {date} at {time}": "Le {date} à {time}",
"The {date} from {startTime} to {endTime}": "Le {date} de {startTime} à {endTime}", "The {date} from {startTime} to {endTime}": "Le {date} de {startTime} à {endTime}",
"There are {participants} participants.": "Il n'y a qu'un⋅e participant⋅e. | Il y a {participants} participants.", "There are {participants} participants.": "Il n'y a qu'un⋅e participant⋅e. | Il y a {participants} participants.",
@ -164,13 +196,18 @@
"Title": "Titre", "Title": "Titre",
"To confirm, type your event title \"{eventTitle}\"": "Pour confirmer, entrez le titre de l'événement « {eventTitle} »", "To confirm, type your event title \"{eventTitle}\"": "Pour confirmer, entrez le titre de l'événement « {eventTitle} »",
"To confirm, type your identity username \"{preferredUsername}\"": "Pour confirmer, entrez le nom de lidentité « {preferredUsername} »", "To confirm, type your identity username \"{preferredUsername}\"": "Pour confirmer, entrez le nom de lidentité « {preferredUsername} »",
"Transfer to {outsideDomain}": "Transférer à {outsideDomain}",
"Unknown error.": "Erreur inconnue.", "Unknown error.": "Erreur inconnue.",
"Upcoming": "À venir",
"Update event {name}": "Éditer l'événement {name}", "Update event {name}": "Éditer l'événement {name}",
"Update my event": "Éditer mon événement", "Update my event": "Éditer mon événement",
"User logout": "Déconnexion", "User logout": "Déconnexion",
"Username": "Pseudo", "Username": "Pseudo",
"Users": "Utilisateurs", "Users": "Utilisateurs",
"View event page": "Voir la page de l'événement",
"View everything": "Voir tout",
"Visible everywhere on the web (public)": "Visible partout sur le web (public)", "Visible everywhere on the web (public)": "Visible partout sur le web (public)",
"Waiting list": "Liste d'attente",
"We just sent an email to {email}": "Nous venons d'envoyer un email à {email}", "We just sent an email to {email}": "Nous venons d'envoyer un email à {email}",
"Website / URL": "Site web / URL", "Website / URL": "Site web / URL",
"Welcome back {username}": "Bon retour {username}", "Welcome back {username}": "Bon retour {username}",
@ -187,6 +224,7 @@
"You have one event tomorrow.": "Vous n'avez pas d'événement demain | Vous avez un événement demain. | Vous avez {count} événements demain", "You have one event tomorrow.": "Vous n'avez pas d'événement demain | Vous avez un événement demain. | Vous avez {count} événements demain",
"You need to login.": "Vous devez vous connecter.", "You need to login.": "Vous devez vous connecter.",
"You're not going to any event yet": "Vous n'allez à aucun événement pour le moment", "You're not going to any event yet": "Vous n'allez à aucun événement pour le moment",
"You're organizing this event": "Vous organisez cet événement",
"Your account has been validated": "Votre compte a été validé", "Your account has been validated": "Votre compte a été validé",
"Your account is being validated": "Votre compte est en cours de validation", "Your account is being validated": "Votre compte est en cours de validation",
"Your account is nearly ready, {username}": "Votre compte est presque prêt, {username}", "Your account is nearly ready, {username}": "Votre compte est presque prêt, {username}",
@ -194,7 +232,9 @@
"e.g. 10 Rue Jangot": "par exemple : 10 Rue Jangot", "e.g. 10 Rue Jangot": "par exemple : 10 Rue Jangot",
"iCal Feed": "Flux iCal", "iCal Feed": "Flux iCal",
"meditate a bit": "méditez un peu", "meditate a bit": "méditez un peu",
"public event": "événement public",
"{actor}'s avatar": "Avatar de {actor}", "{actor}'s avatar": "Avatar de {actor}",
"{approved} / {total} seats": "{approved} / {total} places",
"{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" "© 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"
} }

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

@ -3,14 +3,20 @@ import Location from '@/views/Location.vue';
import { RouteConfig } from 'vue-router'; 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 participations = () => import(/* webpackChunkName: "participations" */ '@/views/Event/Participants.vue');
const editEvent = () => import(/* webpackChunkName: "edit-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: "my-events" */ '@/views/Event/MyEvents.vue');
const explore = () => import(/* webpackChunkName: "explore" */ '@/views/Event/Explore.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',
EXPLORE = 'Explore',
EDIT_EVENT = 'EditEvent', EDIT_EVENT = 'EditEvent',
PARTICIPATIONS = 'Participations',
EVENT = 'Event', EVENT = 'Event',
LOCATION = 'Location', LOCATION = 'Location',
} }
@ -28,6 +34,18 @@ export const eventRoutes: RouteConfig[] = [
component: editEvent, component: editEvent,
meta: { requiredAuth: true }, meta: { requiredAuth: true },
}, },
{
path: '/events/explore',
name: EventRouteName.EXPLORE,
component: explore,
meta: { requiredAuth: false },
},
{
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,
@ -35,6 +53,13 @@ export const eventRoutes: RouteConfig[] = [
meta: { requiredAuth: true }, meta: { requiredAuth: true },
props: { isUpdate: true }, props: { isUpdate: true },
}, },
{
path: '/events/participations/:eventId',
name: EventRouteName.PARTICIPATIONS,
component: participations,
meta: { requiredAuth: true },
props: true,
},
{ {
path: '/location/new', path: '/location/new',
name: EventRouteName.LOCATION, name: EventRouteName.LOCATION,

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

@ -29,11 +29,11 @@ export enum EventVisibilityJoinOptions {
} }
export enum ParticipantRole { export enum ParticipantRole {
NOT_APPROVED = 'not_approved', NOT_APPROVED = 'NOT_APPROVED',
PARTICIPANT = 'participant', PARTICIPANT = 'PARTICIPANT',
MODERATOR = 'moderator', MODERATOR = 'MODERATOR',
ADMINISTRATOR = 'administrator', ADMINISTRATOR = 'ADMINISTRATOR',
CREATOR = 'creator', CREATOR = 'CREATOR',
} }
export enum Category { export enum Category {
@ -45,11 +45,28 @@ export enum Category {
} }
export interface IParticipant { export interface IParticipant {
id?: string;
role: ParticipantRole; role: ParticipantRole;
actor: IActor; actor: IActor;
event: IEvent; event: IEvent;
} }
export class Participant implements IParticipant {
id?: string;
event!: IEvent;
actor!: IActor;
role: ParticipantRole = ParticipantRole.NOT_APPROVED;
constructor(hash?: IParticipant) {
if (!hash) return;
this.id = hash.id;
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;
@ -69,7 +86,7 @@ export enum CommentModeration {
} }
export interface IEvent { export interface IEvent {
id?: number; id?: string;
uuid: string; uuid: string;
url: string; url: string;
local: boolean; local: boolean;
@ -133,7 +150,7 @@ export class EventOptions implements IEventOptions {
} }
export class EventModel implements IEvent { export class EventModel implements IEvent {
id?: number; id?: string;
beginsOn = new Date(); beginsOn = new Date();
endsOn: Date | null = new Date(); endsOn: Date | null = new Date();
@ -203,6 +220,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;
@ -217,6 +235,7 @@ export class EventModel implements IEvent {
endsOn: this.endsOn ? this.endsOn.toISOString() : null, endsOn: this.endsOn ? this.endsOn.toISOString() : null,
status: this.status, status: this.status,
visibility: this.visibility, visibility: this.visibility,
joinOptions: this.joinOptions,
tags: this.tags.map(t => t.title), tags: this.tags.map(t => t.title),
picture: this.picture, picture: this.picture,
onlineAddress: this.onlineAddress, onlineAddress: this.onlineAddress,

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

@ -1,3 +1,4 @@
import {EventJoinOptions} from "@/types/event.model";
<template> <template>
<section class="container"> <section class="container">
<h1 class="title" v-if="isUpdate === false"> <h1 class="title" v-if="isUpdate === false">
@ -54,23 +55,23 @@
{{ $t('Who can view this event and participate') }} {{ $t('Who can view this event and participate') }}
</h2> </h2>
<div class="field"> <div class="field">
<b-radio v-model="eventVisibilityJoinOptions" <b-radio v-model="event.visibility"
name="eventVisibilityJoinOptions" name="eventVisibility"
:native-value="EventVisibilityJoinOptions.PUBLIC"> :native-value="EventVisibility.PUBLIC">
{{ $t('Visible everywhere on the web (public)') }} {{ $t('Visible everywhere on the web (public)') }}
</b-radio> </b-radio>
</div> </div>
<div class="field"> <div class="field">
<b-radio v-model="eventVisibilityJoinOptions" <b-radio v-model="event.visibility"
name="eventVisibilityJoinOptions" name="eventVisibility"
:native-value="EventVisibilityJoinOptions.LINK"> :native-value="EventVisibility.UNLISTED">
{{ $t('Only accessible through link and search (private)') }} {{ $t('Only accessible through link and search (private)') }}
</b-radio> </b-radio>
</div> </div>
<div class="field"> <div class="field">
<b-radio v-model="eventVisibilityJoinOptions" <b-radio v-model="event.visibility"
name="eventVisibilityJoinOptions" name="eventVisibility"
:native-value="EventVisibilityJoinOptions.LIMITED"> :native-value="EventVisibility.PRIVATE">
{{ $t('Page limited to my group (asks for auth)') }} {{ $t('Page limited to my group (asks for auth)') }}
</b-radio> </b-radio>
</div> </div>
@ -82,13 +83,6 @@
</b-switch> </b-switch>
</div> </div>
<div class="field">
<label class="label">{{ $t('Promotion') }}</label>
<b-switch v-model="doNotPromote" :disabled="canPromote === false">
{{ $t('Disallow promoting on Mobilizon')}}
</b-switch>
</div>
<div class="field"> <div class="field">
<b-switch v-model="limitedPlaces"> <b-switch v-model="limitedPlaces">
{{ $t('Limited places') }} {{ $t('Limited places') }}
@ -187,11 +181,17 @@
</style> </style>
<script lang="ts"> <script lang="ts">
import { CREATE_EVENT, EDIT_EVENT, FETCH_EVENT, FETCH_EVENTS } from '@/graphql/event'; import { CREATE_EVENT, EDIT_EVENT, FETCH_EVENT } from '@/graphql/event';
import { Component, Prop, Vue, Watch } from 'vue-property-decorator'; import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
import { EventModel, EventStatus, EventVisibility, EventVisibilityJoinOptions, CommentModeration, IEvent } from '@/types/event.model'; import {
CommentModeration, EventJoinOptions,
EventModel,
EventStatus,
EventVisibility,
EventVisibilityJoinOptions,
} from '@/types/event.model';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor'; import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { IPerson, Person } from '@/types/actor'; import { Person } from '@/types/actor';
import PictureUpload from '@/components/PictureUpload.vue'; import PictureUpload from '@/components/PictureUpload.vue';
import Editor from '@/components/Editor.vue'; import Editor from '@/components/Editor.vue';
import DateTimePicker from '@/components/Event/DateTimePicker.vue'; import DateTimePicker from '@/components/Event/DateTimePicker.vue';
@ -225,10 +225,8 @@ export default class EditEvent extends Vue {
pictureFile: File | null = null; pictureFile: File | null = null;
EventStatus = EventStatus; EventStatus = EventStatus;
EventVisibilityJoinOptions = EventVisibilityJoinOptions; EventVisibility = EventVisibility;
eventVisibilityJoinOptions: EventVisibilityJoinOptions = EventVisibilityJoinOptions.PUBLIC;
needsApproval: boolean = false; needsApproval: boolean = false;
doNotPromote: boolean = false;
canPromote: boolean = true; canPromote: boolean = true;
limitedPlaces: boolean = false; limitedPlaces: boolean = false;
CommentModeration = CommentModeration; CommentModeration = CommentModeration;
@ -332,23 +330,12 @@ export default class EditEvent extends Vue {
return new EventModel(result.data.event); return new EventModel(result.data.event);
} }
@Watch('eventVisibilityJoinOptions') @Watch('needsApproval')
calculateVisibilityAndJoinOptions(eventVisibilityJoinOptions) { updateEventJoinOptions(needsApproval) {
switch (eventVisibilityJoinOptions) { if (needsApproval === true) {
case EventVisibilityJoinOptions.PUBLIC: this.event.joinOptions = EventJoinOptions.RESTRICTED;
this.event.visibility = EventVisibility.UNLISTED; } else {
this.canPromote = true; this.event.joinOptions = EventJoinOptions.FREE;
break;
case EventVisibilityJoinOptions.LINK:
this.event.visibility = EventVisibility.PRIVATE;
this.canPromote = false;
this.doNotPromote = false;
break;
case EventVisibilityJoinOptions.LIMITED:
this.event.visibility = EventVisibility.RESTRICTED;
this.canPromote = false;
this.doNotPromote = false;
break;
} }
} }

View File

@ -38,10 +38,11 @@
<div class="metadata columns"> <div class="metadata columns">
<div class="column is-three-quarters-desktop"> <div class="column is-three-quarters-desktop">
<p class="tags" v-if="event.category || event.tags.length > 0"> <p class="tags" v-if="event.category || event.tags.length > 0">
<span class="tag" v-if="event.category">{{ event.category }}</span> <!-- <span class="tag" v-if="event.category">{{ event.category }}</span>-->
<span class="tag" v-if="event.tags" v-for="tag in event.tags">{{ tag.title }}</span> <span class="tag" v-if="event.tags" v-for="tag in event.tags">{{ tag.title }}</span>
<span class="visibility"> <span class="visibility">
<span v-if="event.visibility === EventVisibility.PUBLIC">{{ $t('public event') }}</span> <span v-if="event.visibility === EventVisibility.PUBLIC">{{ $t('Public event') }}</span>
<span v-if="event.visibility === EventVisibility.UNLISTED">{{ $t('Private event') }}</span>
</span> </span>
</p> </p>
<div class="date-and-add-to-calendar"> <div class="date-and-add-to-calendar">
@ -69,7 +70,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>
@ -84,7 +85,7 @@
<span v-if="!event.physicalAddress">{{ $t('No address defined') }}</span> <span v-if="!event.physicalAddress">{{ $t('No address defined') }}</span>
<div class="address" v-if="event.physicalAddress"> <div class="address" v-if="event.physicalAddress">
<address> <address>
<span class="addressDescription">{{ event.physicalAddress.description }}</span> <span class="addressDescription" :title="event.physicalAddress.description">{{ event.physicalAddress.description }}</span>
<span>{{ event.physicalAddress.floor }} {{ event.physicalAddress.street }}</span> <span>{{ event.physicalAddress.floor }} {{ event.physicalAddress.street }}</span>
<span>{{ event.physicalAddress.postalCode }} {{ event.physicalAddress.locality }}</span> <span>{{ event.physicalAddress.postalCode }} {{ event.physicalAddress.locality }}</span>
<!-- <span>{{ event.physicalAddress.region }} {{ event.physicalAddress.country }}</span>--> <!-- <span>{{ event.physicalAddress.region }} {{ event.physicalAddress.country }}</span>-->
@ -93,7 +94,7 @@
{{ $t('Show map') }} {{ $t('Show map') }}
</span> </span>
</div> </div>
<b-modal v-if="event.physicalAddress && event.physicalAddress.geom" :active.sync="showMap" :width="800" scroll="keep"> <b-modal v-if="event.physicalAddress && event.physicalAddress.geom" :active.sync="showMap" scroll="keep">
<div class="map"> <div class="map">
<map-leaflet <map-leaflet
:coords="event.physicalAddress.geom" :coords="event.physicalAddress.geom"
@ -103,7 +104,7 @@
</b-modal> </b-modal>
</div> </div>
<div class="organizer"> <div class="organizer">
<actor-link :actor="event.organizerActor"> <span>
<span v-if="event.organizerActor"> <span v-if="event.organizerActor">
{{ $t('By {name}', {name: event.organizerActor.name ? event.organizerActor.name : event.organizerActor.preferredUsername}) }} {{ $t('By {name}', {name: event.organizerActor.name ? event.organizerActor.name : event.organizerActor.preferredUsername}) }}
</span> </span>
@ -111,33 +112,13 @@
<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> </span>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<!-- <p v-if="actorIsOrganizer()">-->
<!-- <translate>You are an organizer.</translate>-->
<!-- </p>-->
<!-- <div v-else>-->
<!-- <p v-if="actorIsParticipant()">-->
<!-- <translate>You announced that you're going to this event.</translate>-->
<!-- </p>-->
<!-- <p v-else>-->
<!-- <translate>Are you going to this event?</translate><br />-->
<!-- <span>-->
<!-- <translate-->
<!-- :translate-n="event.participants.length"-->
<!-- translate-plural="{event.participants.length} persons are going"-->
<!-- >-->
<!-- One person is going.-->
<!-- </translate>-->
<!-- </span>-->
<!-- </p>-->
<!-- </div>-->
<div class="description"> <div class="description">
<div class="description-container container"> <div class="description-container container">
<h3 class="title"> <h3 class="title">
@ -147,63 +128,31 @@
{{ $t("The event organizer didn't add any description.") }} {{ $t("The event organizer didn't add any description.") }}
</p> </p>
<div class="columns" v-else> <div class="columns" v-else>
<div class="column is-half"> <div class="column is-half" v-html="event.description">
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Suspendisse vehicula ex dapibus augue volutpat, ultrices cursus mi rutrum.
Nunc ante nunc, facilisis a tellus quis, tempor mollis diam. Aenean consectetur quis est a ultrices.
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
</p>
<p><a href="https://framasoft.org">https://framasoft.org</a>
<p>
Nam sit amet est eget velit tristique commodo. Etiam sollicitudin dignissim diam, ut ultricies tortor.
Sed quis blandit diam, a tincidunt nunc. Donec tincidunt tristique neque at rhoncus. Ut eget vulputate felis.
Pellentesque nibh purus, viverra ac augue sed, iaculis feugiat velit. Nulla ut hendrerit elit.
Etiam at justo eu nunc tempus sagittis. Sed ac tincidunt tellus, sit amet luctus velit.
Nam ullamcorper eros eleifend, eleifend diam vitae, lobortis risus.
</p>
<p>
<em>
Curabitur rhoncus sapien tortor, vitae imperdiet massa scelerisque non.
Aliquam eu augue mi. Donec hendrerit lorem orci.
</em>
</p>
<p>
Donec volutpat, enim eu laoreet dictum, urna quam varius enim, eu convallis urna est vitae massa.
Morbi porttitor lacus a sem efficitur blandit. Mauris in est in quam tincidunt iaculis non vitae ipsum.
Phasellus eget velit tellus. Curabitur ac neque pharetra velit viverra mollis.
</p>
<img src="https://framasoft.org/img/biglogo-notxt.png" alt="logo Framasoft"/>
<p>Aenean gravida, ante vitae aliquet aliquet, elit quam tristique orci, sit amet dictum lorem ipsum nec tortor.
Vestibulum est eros, faucibus et semper vel, dapibus ac est. Suspendisse potenti. Suspendisse potenti.
Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.
Nulla molestie nisi ac risus hendrerit, dapibus mattis sapien scelerisque.
</p>
<p>Maecenas id pretium justo, nec dignissim sapien. Mauris in venenatis odio, in congue augue. </p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- <section class="container">--> <section class="container">
<!-- <h2 class="title">Participants</h2>--> <h3 class="title">{{ $t('Participants') }}</h3>
<!-- <span v-if="event.participants.length === 0">No participants yet.</span>--> <router-link v-if="currentActor.id === event.organizerActor.id" :to="{ name: EventRouteName.PARTICIPATIONS, params: { eventId: event.uuid } }">
<!-- <div class="columns">--> {{ $t('Manage participants') }}
<!-- <router-link--> </router-link>
<!-- class="column"--> <span v-if="event.participants.length === 0">{{ $t('No participants yet.') }}</span>
<!-- v-for="participant in event.participants"--> <div class="columns">
<!-- :key="participant.preferredUsername"--> <div
<!-- :to="{name: 'Profile', params: { name: participant.actor.preferredUsername }}"--> class="column"
<!-- >--> v-for="participant in event.participants"
<!-- <div>--> :key="participant.id"
<!-- <figure>--> >
<!-- <img v-if="!participant.actor.avatar.url" src="https://picsum.photos/125/125/">--> <figure class="image is-48x48">
<!-- <img v-else :src="participant.actor.avatar.url">--> <img v-if="!participant.actor.avatar.url" src="https://picsum.photos/48/48/" class="is-rounded">
<!-- </figure>--> <img v-else :src="participant.actor.avatar.url" class="is-rounded">
<!-- <span>{{ participant.actor.preferredUsername }}</span>--> </figure>
<!-- </div>--> <span>{{ participant.actor.preferredUsername }}</span>
<!-- </router-link>--> </div>
<!-- </div>--> </div>
<!-- </section>--> </section>
<section class="share"> <section class="share">
<div class="container"> <div class="container">
<div class="columns"> <div class="columns">
@ -236,7 +185,7 @@
</div> </div>
</section> </section>
<b-modal :active.sync="isReportModalActive" has-modal-card ref="reportModal"> <b-modal :active.sync="isReportModalActive" has-modal-card ref="reportModal">
<report-modal :on-confirm="reportEvent" title="Report this event" :outside-domain="event.organizerActor.domain" @close="$refs.reportModal.close()" /> <report-modal :on-confirm="reportEvent" :title="$t('Report this event')" :outside-domain="event.organizerActor.domain" @close="$refs.reportModal.close()" />
</b-modal> </b-modal>
<b-modal :active.sync="isJoinModalActive" has-modal-card ref="participationModal"> <b-modal :active.sync="isJoinModalActive" has-modal-card ref="participationModal">
<participation-modal :on-confirm="joinEvent" :event="event" :defaultIdentity="currentActor" @close="$refs.participationModal.close()" /> <participation-modal :on-confirm="joinEvent" :event="event" :defaultIdentity="currentActor" @close="$refs.participationModal.close()" />
@ -249,7 +198,7 @@
import { DELETE_EVENT, FETCH_EVENT, JOIN_EVENT, LEAVE_EVENT } from '@/graphql/event'; import { DELETE_EVENT, FETCH_EVENT, JOIN_EVENT, LEAVE_EVENT } from '@/graphql/event';
import { Component, Prop, Vue } from 'vue-property-decorator'; import { Component, Prop, Vue } from 'vue-property-decorator';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor'; import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { EventVisibility, IEvent, IParticipant } from '@/types/event.model'; import { EventVisibility, IEvent, IParticipant, ParticipantRole } from '@/types/event.model';
import { IPerson } from '@/types/actor'; import { IPerson } from '@/types/actor';
import { RouteName } from '@/router'; import { RouteName } from '@/router';
import { GRAPHQL_API_ENDPOINT } from '@/api/_entrypoint'; import { GRAPHQL_API_ENDPOINT } from '@/api/_entrypoint';
@ -262,6 +211,8 @@ 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';
import { EventRouteName } from '@/router/event';
@Component({ @Component({
components: { components: {
@ -282,6 +233,7 @@ import { CREATE_REPORT } from '@/graphql/report';
variables() { variables() {
return { return {
uuid: this.uuid, uuid: this.uuid,
roles: [ParticipantRole.CREATOR, ParticipantRole.MODERATOR, ParticipantRole.MODERATOR, ParticipantRole.PARTICIPANT].join(),
}; };
}, },
}, },
@ -290,7 +242,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;
@ -301,32 +253,14 @@ export default class Event extends Vue {
isJoinModalActive: boolean = false; isJoinModalActive: boolean = false;
EventVisibility = EventVisibility; EventVisibility = EventVisibility;
EventRouteName = EventRouteName;
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) {
@ -385,9 +319,10 @@ export default class Event extends Vue {
confirmLeave() { confirmLeave() {
this.$buefy.dialog.confirm({ this.$buefy.dialog.confirm({
title: `Leaving event « ${this.event.title} »`, title: this.$t('Leaving event "{title}"', { title: this.event.title }) as string,
message: `Are you sure you want to leave event « ${this.event.title} »`, message: this.$t('Are you sure you want to cancel your participation at event "{title}"?', { title: this.event.title }) as string,
confirmText: 'Leave event', confirmText: this.$t('Leave event') as string,
cancelText: this.$t('Cancel') as string,
type: 'is-danger', type: 'is-danger',
hasIcon: true, hasIcon: true,
onConfirm: () => this.leaveEvent(), onConfirm: () => this.leaveEvent(),
@ -464,31 +399,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>
@ -535,6 +445,8 @@ export default class Event extends Vue {
white-space: nowrap; white-space: nowrap;
flex: 1 0 auto; flex: 1 0 auto;
min-width: 100%; min-width: 100%;
max-width: 4rem;
overflow: hidden;
} }
:not(.addressDescription) { :not(.addressDescription) {

View File

@ -0,0 +1,42 @@
<template>
<section>
<h1 class="title">{{ $t('Explore') }}</h1>
<!-- <pre>{{ events }}</pre>-->
<b-loading :active.sync="$apollo.loading"></b-loading>
<div v-if="events.length > 0" class="columns is-multiline">
<EventCard
v-for="event in events"
:key="event.uuid"
:event="event"
class="column is-one-quarter-desktop"
/>
</div>
<b-message v-else-if="events.length === 0 && $apollo.loading === false" type="is-danger">
{{ $t('No events found') }}
</b-message>
</section>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import EventCard from '@/components/Event/EventCard.vue';
import { FETCH_EVENTS } from '@/graphql/event';
import { IEvent } from '@/types/event.model';
@Component({
components: {
EventCard,
},
apollo: {
events: {
query: FETCH_EVENTS,
},
},
})
export default class Explore extends Vue {
events: IEvent[] = [];
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,199 @@
<template>
<main>
<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 {
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

@ -0,0 +1,197 @@
<template>
<main class="container">
<b-tabs type="is-boxed" v-if="event">
<b-tab-item>
<template slot="header">
<b-icon icon="information-outline"></b-icon>
<span> Participants <b-tag rounded> {{ participantStats.approved }} </b-tag> </span>
</template>
<section v-if="participantsAndCreators.length > 0">
<h2 class="title">{{ $t('Participants') }}</h2>
<div class="columns">
<div class="column is-one-quarter-desktop" v-for="participant in participantsAndCreators" :key="participant.actor.id">
<participant-card
:participant="participant"
:accept="acceptParticipant"
:reject="refuseParticipant"
:exclude="refuseParticipant"
/>
</div>
</div>
</section>
</b-tab-item>
<b-tab-item>
<template slot="header">
<b-icon icon="source-pull"></b-icon>
<span> Demandes <b-tag rounded> {{ participantStats.unapproved }} </b-tag> </span>
</template>
<section v-if="queue.length > 0">
<h2 class="title">{{ $t('Waiting list') }}</h2>
<div class="columns">
<div class="column is-one-quarter-desktop" v-for="participant in queue" :key="participant.actor.id">
<participant-card
:participant="participant"
:accept="acceptParticipant"
:reject="refuseParticipant"
:exclude="refuseParticipant"
/>
</div>
</div>
</section>
</b-tab-item>
</b-tabs>
</main>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IEvent, IParticipant, Participant, ParticipantRole } from '@/types/event.model';
import { ACCEPT_PARTICIPANT, PARTICIPANTS, REJECT_PARTICIPANT } from '@/graphql/event';
import ParticipantCard from '@/components/Account/ParticipantCard.vue';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { IPerson } from '@/types/actor';
@Component({
components: {
ParticipantCard,
},
apollo: {
currentActor: {
query: CURRENT_ACTOR_CLIENT,
},
event: {
query: PARTICIPANTS,
variables() {
return {
uuid: this.eventId,
page: 1,
limit: 10,
roles: [ParticipantRole.PARTICIPANT].join(),
};
},
},
organizers: {
query: PARTICIPANTS,
variables() {
return {
uuid: this.eventId,
page: 1,
limit: 20,
roles: [ParticipantRole.CREATOR].join(),
};
},
update: data => data.event.participants.map(participation => new Participant(participation)),
},
queue: {
query: PARTICIPANTS,
variables() {
return {
uuid: this.eventId,
page: 1,
limit: 20,
roles: [ParticipantRole.NOT_APPROVED].join(),
};
},
update: data => data.event.participants.map(participation => new Participant(participation)),
},
},
})
export default class Participants extends Vue {
@Prop({ required: true }) eventId!: string;
page: number = 1;
limit: number = 10;
// participants: IParticipant[] = [];
organizers: IParticipant[] = [];
queue: IParticipant[] = [];
event!: IEvent;
ParticipantRole = ParticipantRole;
currentActor!: IPerson;
hasMoreParticipants: boolean = false;
get participants(): IParticipant[] {
return this.event.participants.map(participant => new Participant(participant));
}
get participantStats(): Object {
return this.event.participantStats;
}
get participantsAndCreators(): IParticipant[] {
if (this.event) {
return [...this.organizers, ...this.participants];
}
return [];
}
loadMoreParticipants() {
this.page += 1;
this.$apollo.queries.participants.fetchMore({
// New variables
variables: {
page: this.page,
limit: this.limit,
},
// Transform the previous result with new data
updateQuery: (previousResult, { fetchMoreResult }) => {
const newParticipations = fetchMoreResult.event.participants;
this.hasMoreParticipants = newParticipations.length === this.limit;
return {
loggedUser: {
__typename: previousResult.event.__typename,
participations: [...previousResult.event.participants, ...newParticipations],
},
};
},
});
}
async acceptParticipant(participant: IParticipant) {
try {
const { data } = await this.$apollo.mutate({
mutation: ACCEPT_PARTICIPANT,
variables: {
id: participant.id,
moderatorActorId: this.currentActor.id,
},
});
if (data) {
console.log('accept', data);
this.queue.filter(participant => participant !== data.acceptParticipation.id);
this.participants.push(participant);
}
} catch (e) {
console.error(e);
}
}
async refuseParticipant(participant: IParticipant) {
try {
const { data } = await this.$apollo.mutate({
mutation: REJECT_PARTICIPANT,
variables: {
id: participant.id,
moderatorActorId: this.currentActor.id,
},
});
if (data) {
this.participants.filter(participant => participant !== data.rejectParticipation.id);
this.queue.filter(participant => participant !== data.rejectParticipation.id);
}
} catch (e) {
console.error(e);
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss" scoped>
section {
padding: 3rem 0;
}
</style>

View File

@ -107,7 +107,7 @@ export default class Group extends Vue {
} }
} }
</script> </script>
<style lang="scss"> <style lang="scss" scoped>
section.container { section.container {
min-height: 30em; min-height: 30em;
} }

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,6 @@
<span>{{ $t('Create') }}</span> <span>{{ $t('Create') }}</span>
<b-icon icon="menu-down"></b-icon> <b-icon icon="menu-down"></b-icon>
</button> </button>
<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 +31,14 @@
<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 && goingToEvents.size > 0" class="container">
<span class="events-nearby title"> <h3 class="title">
{{ $t("Events you're going at") }} {{ $t("Upcoming") }}
</span> </h3>
<pre>{{ Array.from(goingToEvents.entries()) }}</pre>
<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-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,40 @@
{{ $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"> <span class="view-all">
{{ $t("You're not going to any event yet") }} <router-link :to=" { name: EventRouteName.MY_EVENTS }">{{ $t('View everything')}} >></router-link>
</b-message> </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.id"
:participation="participation"
class="level-item"
:options="{ hideDate: false }"
/>
</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 +103,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 +122,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 +134,7 @@ import { IConfig } from '@/types/config.model';
}, },
components: { components: {
DateComponent, DateComponent,
EventListCard,
EventCard, EventCard,
}, },
}) })
@ -124,10 +143,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 +156,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 +185,47 @@ 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;
}
isAfter(date: string, nbDays: number) :boolean {
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.isAfter(event.beginsOn.toDateString(), 0) && this.isBefore(event.beginsOn.toDateString(), 7);
}); });
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.actor.id}`, 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 +275,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" scoped>
.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 +284,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

@ -2193,9 +2193,9 @@ browserslist@^4.0.0, browserslist@^4.3.4, browserslist@^4.5.4, browserslist@^4.6
node-releases "^1.1.29" node-releases "^1.1.29"
buefy@^0.8.2: buefy@^0.8.2:
version "0.8.2" version "0.8.4"
resolved "https://registry.yarnpkg.com/buefy/-/buefy-0.8.2.tgz#26bfc931c8c7fbe5a90d4b814a8205501eee816a" resolved "https://registry.yarnpkg.com/buefy/-/buefy-0.8.4.tgz#0c62d559e63aee8a18876ff90056f9a8b90f686f"
integrity sha512-fS4sXYE0ge7fN5tP9k67j1fSCS/yxbTrnEhJ5MBt89gcbmVe5x8/SAXdADjx5W4SdERtjKjE9mzoIoRb+ZC29Q== integrity sha512-hDUUKbKxQmtYlo/IPH9H+ewEN6KulpDxfNFIPvO5z3ukYqEG29psW6oFbJGisZDEIYGxqE2jMPcBOOjm8LxJVQ==
dependencies: dependencies:
bulma "0.7.5" bulma "0.7.5"

View File

@ -67,6 +67,7 @@ defmodule Mobilizon.Events.Event do
@attrs @required_attrs ++ @optional_attrs @attrs @required_attrs ++ @optional_attrs
@update_required_attrs @required_attrs @update_required_attrs @required_attrs
@update_optional_attrs [ @update_optional_attrs [
:slug, :slug,
:description, :description,
@ -74,6 +75,7 @@ defmodule Mobilizon.Events.Event do
:category, :category,
:status, :status,
:visibility, :visibility,
:join_options,
:publish_at, :publish_at,
:online_address, :online_address,
:phone_address, :phone_address,

View File

@ -522,6 +522,26 @@ defmodule Mobilizon.Events do
@doc """ @doc """
Gets a single participant. Gets a single participant.
## Examples
iex> get_participant(123)
%Participant{}
iex> get_participant(456)
nil
"""
@spec get_participant(integer) :: Participant.t()
def get_participant(participant_id) do
Participant
|> where([p], p.id == ^participant_id)
|> preload([p], [:event, :actor])
|> Repo.one()
end
@doc """
Gets a single participation for an event and actor.
""" """
@spec get_participant(integer | String.t(), integer | String.t()) :: @spec get_participant(integer | String.t(), integer | String.t()) ::
{:ok, Participant.t()} | {:error, :participant_not_found} {:ok, Participant.t()} | {:error, :participant_not_found}
@ -536,8 +556,18 @@ defmodule Mobilizon.Events do
end end
@doc """ @doc """
Gets a single participant. Gets a single participation for an event and actor.
Raises `Ecto.NoResultsError` if the participant does not exist.
Raises `Ecto.NoResultsError` if the Participant does not exist.
## Examples
iex> get_participant!(123, 19)
%Participant{}
iex> get_participant!(456, 5)
** (Ecto.NoResultsError)
""" """
@spec get_participant!(integer | String.t(), integer | String.t()) :: Participant.t() @spec get_participant!(integer | String.t(), integer | String.t()) :: Participant.t()
def get_participant!(event_id, actor_id) do def get_participant!(event_id, actor_id) do
@ -554,73 +584,82 @@ defmodule Mobilizon.Events do
|> Repo.one() |> Repo.one()
end end
@doc """ @default_participant_roles [:participant, :moderator, :administrator, :creator]
Gets the default participant role depending on the event join options.
"""
@spec get_default_participant_role(Event.t()) :: :participant | :not_approved
def get_default_participant_role(%Event{join_options: :free}), do: :participant
def get_default_participant_role(%Event{join_options: _}), do: :not_approved
@doc """
Creates a participant.
"""
@spec create_participant(map) :: {:ok, Participant.t()} | {:error, Ecto.Changeset.t()}
def create_participant(attrs \\ %{}) do
with {:ok, %Participant{} = participant} <-
%Participant{}
|> Participant.changeset(attrs)
|> Repo.insert() do
{:ok, Repo.preload(participant, [:event, :actor])}
end
end
@doc """
Updates a participant.
"""
@spec update_participant(Participant.t(), map) ::
{:ok, Participant.t()} | {:error, Ecto.Changeset.t()}
def update_participant(%Participant{} = participant, attrs) do
participant
|> Participant.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a participant.
"""
@spec delete_participant(Participant.t()) ::
{:ok, Participant.t()} | {:error, Ecto.Changeset.t()}
def delete_participant(%Participant{} = participant), do: Repo.delete(participant)
@doc """
Returns the list of participants.
"""
@spec list_participants :: [Participant.t()]
def list_participants, do: Repo.all(Participant)
@doc """ @doc """
Returns the list of participants for an event. Returns the list of participants for an event.
Default behaviour is to not return :not_approved participants Default behaviour is to not return :not_approved participants
""" """
@spec list_participants_for_event(String.t(), integer | nil, integer | nil, boolean) :: @spec list_participants_for_event(String.t(), list(atom()), integer | nil, integer | nil) ::
[Participant.t()] [Participant.t()]
def list_participants_for_event( def list_participants_for_event(
event_uuid, uuid,
roles \\ @default_participant_roles,
page \\ nil, page \\ nil,
limit \\ nil, limit \\ nil
include_not_improved \\ false ) do
) uuid
|> list_participants_for_event_query()
def list_participants_for_event(event_uuid, page, limit, include_not_improved) do |> filter_role(roles)
event_uuid
|> participants_for_event()
|> filter_role(include_not_improved)
|> Page.paginate(page, limit) |> Page.paginate(page, limit)
|> Repo.all() |> Repo.all()
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{}, ...]
"""
@spec list_participations_for_user(
integer,
DateTime.t() | nil,
DateTime.t() | nil,
integer | nil,
integer | nil
) :: list(Participant.t())
def list_participations_for_user(user_id, after_datetime, before_datetime, page, limit) do
user_id
|> list_participations_for_user_query()
|> participation_filter_begins_on(after_datetime, before_datetime)
|> Page.paginate(page, limit)
|> Repo.all()
end
@doc """
Returns the list of moderator participants for an event.
## Examples
iex> moderator_for_event?(5, 3)
true
"""
@spec moderator_for_event?(integer, integer) :: boolean
def moderator_for_event?(event_id, actor_id) do
!(Repo.one(
from(
p in Participant,
where:
p.event_id == ^event_id and
p.actor_id ==
^actor_id and p.role in ^[:moderator, :administrator, :creator]
)
) == nil)
end
@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(),
@ -679,6 +718,44 @@ defmodule Mobilizon.Events do
|> Repo.aggregate(:count, :id) |> Repo.aggregate(:count, :id)
end end
@doc """
Gets the default participant role depending on the event join options.
"""
@spec get_default_participant_role(Event.t()) :: :participant | :not_approved
def get_default_participant_role(%Event{join_options: :free}), do: :participant
def get_default_participant_role(%Event{join_options: _}), do: :not_approved
@doc """
Creates a participant.
"""
@spec create_participant(map) :: {:ok, Participant.t()} | {:error, Ecto.Changeset.t()}
def create_participant(attrs \\ %{}) do
with {:ok, %Participant{} = participant} <-
%Participant{}
|> Participant.changeset(attrs)
|> Repo.insert() do
{:ok, Repo.preload(participant, [:event, :actor])}
end
end
@doc """
Updates a participant.
"""
@spec update_participant(Participant.t(), map) ::
{:ok, Participant.t()} | {:error, Ecto.Changeset.t()}
def update_participant(%Participant{} = participant, attrs) do
participant
|> Participant.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a participant.
"""
@spec delete_participant(Participant.t()) ::
{:ok, Participant.t()} | {:error, Ecto.Changeset.t()}
def delete_participant(%Participant{} = participant), do: Repo.delete(participant)
@doc """ @doc """
Gets a single session. Gets a single session.
Raises `Ecto.NoResultsError` if the session does not exist. Raises `Ecto.NoResultsError` if the session does not exist.
@ -1143,17 +1220,6 @@ defmodule Mobilizon.Events do
) )
end end
@spec participants_for_event(String.t()) :: Ecto.Query.t()
defp participants_for_event(event_uuid) do
from(
p in Participant,
join: e in Event,
on: p.event_id == e.id,
where: e.uuid == ^event_uuid,
preload: [:actor]
)
end
defp organizers_participants_for_event(event_id) do defp organizers_participants_for_event(event_id) do
from( from(
p in Participant, p in Participant,
@ -1214,6 +1280,30 @@ defmodule Mobilizon.Events do
) )
end end
@spec list_participants_for_event_query(String.t()) :: Ecto.Query.t()
defp list_participants_for_event_query(event_uuid) do
from(
p in Participant,
join: e in Event,
on: p.event_id == e.id,
where: e.uuid == ^event_uuid,
preload: [:actor]
)
end
@spec list_participations_for_user_query(integer()) :: Ecto.Query.t()
defp list_participations_for_user_query(user_id) 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]
)
end
@spec count_comments_query(integer) :: Ecto.Query.t() @spec count_comments_query(integer) :: Ecto.Query.t()
defp count_comments_query(actor_id) do defp count_comments_query(actor_id) do
from(c in Comment, select: count(c.id), where: c.actor_id == ^actor_id) from(c in Comment, select: count(c.id), where: c.actor_id == ^actor_id)
@ -1281,9 +1371,33 @@ defmodule Mobilizon.Events do
from(p in query, where: p.role == ^:not_approved) from(p in query, where: p.role == ^:not_approved)
end end
@spec filter_role(Ecto.Query.t(), boolean) :: Ecto.Query.t() @spec filter_role(Ecto.Query.t(), list(atom())) :: Ecto.Query.t()
defp filter_role(query, false), do: filter_approved_role(query) defp filter_role(query, []), do: query
defp filter_role(query, true), do: query
defp filter_role(query, roles) do
where(query, [p], p.role in ^roles)
end
defp participation_filter_begins_on(query, nil, nil),
do: participation_order_begins_on_desc(query)
defp participation_filter_begins_on(query, %DateTime{} = after_datetime, nil) do
query
|> where([_p, e, _a], e.begins_on > ^after_datetime)
|> participation_order_begins_on_asc()
end
defp participation_filter_begins_on(query, nil, %DateTime{} = before_datetime) do
query
|> where([_p, e, _a], e.begins_on < ^before_datetime)
|> participation_order_begins_on_desc()
end
defp participation_order_begins_on_asc(query),
do: order_by(query, [_p, e, _a], asc: e.begins_on)
defp participation_order_begins_on_desc(query),
do: order_by(query, [_p, e, _a], desc: e.begins_on)
@spec preload_for_event(Ecto.Query.t()) :: Ecto.Query.t() @spec preload_for_event(Ecto.Query.t()) :: Ecto.Query.t()
defp preload_for_event(query), do: preload(query, ^@event_preloads) defp preload_for_event(query), do: preload(query, ^@event_preloads)

View File

@ -24,6 +24,7 @@ defmodule MobilizonWeb.API.Events do
begins_on: begins_on, begins_on: begins_on,
ends_on: ends_on, ends_on: ends_on,
category: category, category: category,
join_options: join_options,
options: options options: options
} <- prepare_args(args), } <- prepare_args(args),
event <- event <-
@ -39,7 +40,8 @@ defmodule MobilizonWeb.API.Events do
ends_on: ends_on, ends_on: ends_on,
physical_address: physical_address, physical_address: physical_address,
category: category, category: category,
options: options options: options,
join_options: join_options
} }
) do ) do
ActivityPub.create(%{ ActivityPub.create(%{
@ -73,6 +75,7 @@ defmodule MobilizonWeb.API.Events do
begins_on: begins_on, begins_on: begins_on,
ends_on: ends_on, ends_on: ends_on,
category: category, category: category,
join_options: join_options,
options: options options: options
} <- } <-
prepare_args(Map.merge(event, args)), prepare_args(Map.merge(event, args)),
@ -89,6 +92,7 @@ defmodule MobilizonWeb.API.Events do
ends_on: ends_on, ends_on: ends_on,
physical_address: physical_address, physical_address: physical_address,
category: category, category: category,
join_options: join_options,
options: options options: options
}, },
event.uuid, event.uuid,
@ -112,7 +116,8 @@ defmodule MobilizonWeb.API.Events do
options: options, options: options,
tags: tags, tags: tags,
begins_on: begins_on, begins_on: begins_on,
category: category category: category,
join_options: join_options
} = args } = args
) do ) do
with physical_address <- Map.get(args, :physical_address, nil), with physical_address <- Map.get(args, :physical_address, nil),
@ -132,6 +137,7 @@ defmodule MobilizonWeb.API.Events do
begins_on: begins_on, begins_on: begins_on,
ends_on: Map.get(args, :ends_on, nil), ends_on: Map.get(args, :ends_on, nil),
category: category, category: category,
join_options: join_options,
options: options options: options
} }
end end

View File

@ -4,9 +4,9 @@ defmodule MobilizonWeb.API.Participations do
""" """
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Events
alias Mobilizon.Events.{Event, Participant} alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Service.ActivityPub alias Mobilizon.Service.ActivityPub
require Logger
@spec join(Event.t(), Actor.t()) :: {:ok, Participant.t()} @spec join(Event.t(), Actor.t()) :: {:ok, Participant.t()}
def join(%Event{id: event_id} = event, %Actor{id: actor_id} = actor) do def join(%Event{id: event_id} = event, %Actor{id: actor_id} = actor) do
@ -21,4 +21,42 @@ defmodule MobilizonWeb.API.Participations do
{:ok, activity, participant} {:ok, activity, participant}
end end
end end
def accept(
%Participant{} = participation,
%Actor{} = moderator
) do
with {:ok, activity, _} <-
ActivityPub.accept(
%{
to: [participation.actor.url],
actor: moderator.url,
object: participation.url
},
"#{MobilizonWeb.Endpoint.url()}/accept/join/#{participation.id}"
),
{:ok, %Participant{role: :participant} = participation} <-
Events.update_participant(participation, %{"role" => :participant}) do
{:ok, activity, participation}
end
end
def reject(
%Participant{} = participation,
%Actor{} = moderator
) do
with {:ok, activity, _} <-
ActivityPub.reject(
%{
to: [participation.actor.url],
actor: moderator.url,
object: participation.url
},
"#{MobilizonWeb.Endpoint.url()}/reject/join/#{participation.id}"
),
{:ok, %Participant{} = participation} <-
Events.delete_participant(participation) do
{:ok, activity, participation}
end
end
end end

View File

@ -7,6 +7,8 @@ defmodule MobilizonWeb.API.Utils do
alias Mobilizon.Config alias Mobilizon.Config
alias Mobilizon.Service.Formatter alias Mobilizon.Service.Formatter
@ap_public "https://www.w3.org/ns/activitystreams#Public"
@doc """ @doc """
Determines the full audience based on mentions for a public audience Determines the full audience based on mentions for a public audience
@ -16,7 +18,7 @@ defmodule MobilizonWeb.API.Utils do
""" """
@spec get_to_and_cc(Actor.t(), list(), map(), String.t()) :: {list(), list()} @spec get_to_and_cc(Actor.t(), list(), map(), String.t()) :: {list(), list()}
def get_to_and_cc(%Actor{} = actor, mentions, inReplyTo, :public) do def get_to_and_cc(%Actor{} = actor, mentions, inReplyTo, :public) do
to = ["https://www.w3.org/ns/activitystreams#Public" | mentions] to = [@ap_public | mentions]
cc = [actor.followers_url] cc = [actor.followers_url]
if inReplyTo do if inReplyTo do
@ -36,7 +38,7 @@ defmodule MobilizonWeb.API.Utils do
@spec get_to_and_cc(Actor.t(), list(), map(), String.t()) :: {list(), list()} @spec get_to_and_cc(Actor.t(), list(), map(), String.t()) :: {list(), list()}
def get_to_and_cc(%Actor{} = actor, mentions, inReplyTo, :unlisted) do def get_to_and_cc(%Actor{} = actor, mentions, inReplyTo, :unlisted) do
to = [actor.followers_url | mentions] to = [actor.followers_url | mentions]
cc = ["https://www.w3.org/ns/activitystreams#Public"] cc = [@ap_public]
if inReplyTo do if inReplyTo do
{Enum.uniq([inReplyTo.actor | to]), cc} {Enum.uniq([inReplyTo.actor | to]), cc}
@ -49,7 +51,7 @@ defmodule MobilizonWeb.API.Utils do
Determines the full audience based on mentions based on a private audience Determines the full audience based on mentions based on a private audience
Audience is: Audience is:
* `to` : the mentionned actors, actor's followers and the eventual actor we're replying to * `to` : the mentioned actors, actor's followers and the eventual actor we're replying to
* `cc` : none * `cc` : none
""" """
@spec get_to_and_cc(Actor.t(), list(), map(), String.t()) :: {list(), list()} @spec get_to_and_cc(Actor.t(), list(), map(), String.t()) :: {list(), list()}
@ -62,7 +64,7 @@ defmodule MobilizonWeb.API.Utils do
Determines the full audience based on mentions based on a direct audience Determines the full audience based on mentions based on a direct audience
Audience is: Audience is:
* `to` : the mentionned actors and the eventual actor we're replying to * `to` : the mentioned actors and the eventual actor we're replying to
* `cc` : none * `cc` : none
""" """
@spec get_to_and_cc(Actor.t(), list(), map(), String.t()) :: {list(), list()} @spec get_to_and_cc(Actor.t(), list(), map(), String.t()) :: {list(), list()}

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
@ -42,14 +42,30 @@ defmodule MobilizonWeb.Resolvers.Event do
List participant for event (separate request) List participant for event (separate request)
""" """
def list_participants_for_event(_parent, %{uuid: uuid, page: page, limit: limit}, _resolution) do def list_participants_for_event(_parent, %{uuid: uuid, page: page, limit: limit}, _resolution) do
{:ok, Mobilizon.Events.list_participants_for_event(uuid, page, limit)} {:ok, Mobilizon.Events.list_participants_for_event(uuid, [], page, limit)}
end end
@doc """ @doc """
List participants for event (through an event request) List participants for event (through an event request)
""" """
def list_participants_for_event(%Event{uuid: uuid}, _args, _resolution) do def list_participants_for_event(
{:ok, Mobilizon.Events.list_participants_for_event(uuid, 1, 10)} %Event{uuid: uuid},
%{page: page, limit: limit, roles: roles},
_resolution
) do
roles =
case roles do
"" ->
[]
roles ->
roles
|> String.split(",")
|> Enum.map(&String.downcase/1)
|> Enum.map(&String.to_existing_atom/1)
end
{:ok, Mobilizon.Events.list_participants_for_event(uuid, roles, page, limit)}
end end
def stats_participants_for_event(%Event{id: id}, _args, _resolution) do def stats_participants_for_event(%Event{id: id}, _args, _resolution) do
@ -175,6 +191,87 @@ defmodule MobilizonWeb.Resolvers.Event do
{:error, "You need to be logged-in to leave an event"} {:error, "You need to be logged-in to leave an event"}
end end
def accept_participation(
_parent,
%{id: participation_id, moderator_actor_id: moderator_actor_id},
%{
context: %{
current_user: user
}
}
) do
# Check that moderator provided is rightly authenticated
with {:is_owned, moderator_actor} <- User.owns_actor(user, moderator_actor_id),
# Check that participation already exists
{:has_participation, %Participant{role: :not_approved} = participation} <-
{:has_participation, Mobilizon.Events.get_participant(participation_id)},
# Check that moderator has right
{:actor_approve_permission, true} <-
{:actor_approve_permission,
Mobilizon.Events.moderator_for_event?(participation.event.id, moderator_actor_id)},
{:ok, _activity, participation} <-
MobilizonWeb.API.Participations.accept(participation, moderator_actor) do
{:ok, participation}
else
{:is_owned, nil} ->
{:error, "Moderator Actor ID is not owned by authenticated user"}
{:has_participation, %Participant{role: role, id: id}} ->
{:error,
"Participant #{id} can't be approved since it's already a participant (with role #{role})"}
{:actor_approve_permission, _} ->
{:error, "Provided moderator actor ID doesn't have permission on this event"}
{:error, :participant_not_found} ->
{:error, "Participant not found"}
end
end
def reject_participation(
_parent,
%{id: participation_id, moderator_actor_id: moderator_actor_id},
%{
context: %{
current_user: user
}
}
) do
# Check that moderator provided is rightly authenticated
with {:is_owned, moderator_actor} <- User.owns_actor(user, moderator_actor_id),
# Check that participation really exists
{:has_participation, %Participant{} = participation} <-
{:has_participation, Mobilizon.Events.get_participant(participation_id)},
# Check that moderator has right
{:actor_approve_permission, true} <-
{:actor_approve_permission,
Mobilizon.Events.moderator_for_event?(participation.event.id, moderator_actor_id)},
{:ok, _activity, participation} <-
MobilizonWeb.API.Participations.reject(participation, moderator_actor) do
{
:ok,
%{
id: participation.id,
event: %{
id: participation.event.id
},
actor: %{
id: participation.actor.id
}
}
}
else
{:is_owned, nil} ->
{:error, "Moderator Actor ID is not owned by authenticated user"}
{:actor_approve_permission, _} ->
{:error, "Provided moderator actor ID doesn't have permission on this event"}
{:has_participation, nil} ->
{:error, "Participant not found"}
end
end
@doc """ @doc """
Create an event Create an event
""" """

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

@ -24,6 +24,7 @@ defmodule MobilizonWeb.Schema.EventType do
field(:ends_on, :datetime, description: "Datetime for when the event ends") field(:ends_on, :datetime, description: "Datetime for when the event ends")
field(:status, :event_status, description: "Status of the event") field(:status, :event_status, description: "Status of the event")
field(:visibility, :event_visibility, description: "The event's visibility") field(:visibility, :event_visibility, description: "The event's visibility")
field(:join_options, :event_join_options, description: "The event's visibility")
field(:picture, :picture, field(:picture, :picture,
description: "The event's picture", description: "The event's picture",
@ -56,10 +57,12 @@ defmodule MobilizonWeb.Schema.EventType do
field(:participant_stats, :participant_stats, resolve: &Event.stats_participants_for_event/3) field(:participant_stats, :participant_stats, resolve: &Event.stats_participants_for_event/3)
field(:participants, list_of(:participant), field(:participants, list_of(:participant), description: "The event's participants") do
resolve: &Event.list_participants_for_event/3, arg(:page, :integer, default_value: 1)
description: "The event's participants" arg(:limit, :integer, default_value: 10)
) arg(:roles, :string, default_value: "")
resolve(&Event.list_participants_for_event/3)
end
field(:related_events, list_of(:event), field(:related_events, list_of(:event),
resolve: &Event.list_related_events/3, resolve: &Event.list_related_events/3,
@ -78,13 +81,18 @@ defmodule MobilizonWeb.Schema.EventType do
enum :event_visibility do enum :event_visibility do
value(:public, description: "Publicly listed and federated. Can be shared.") value(:public, description: "Publicly listed and federated. Can be shared.")
value(:unlisted, description: "Visible only to people with the link - or invited") value(:unlisted, description: "Visible only to people with the link - or invited")
value(:restricted, description: "Visible only after a moderator accepted")
value(:private, value(:private,
description: "Visible only to people members of the group or followers of the person" description: "Visible only to people members of the group or followers of the person"
) )
end
value(:moderated, description: "Visible only after a moderator accepted") @desc "The list of join options for an event"
value(:invite, description: "visible only to people invited") enum :event_join_options do
value(:free, description: "Anyone can join and is automatically accepted")
value(:restricted, description: "Manual acceptation")
value(:invite, description: "Participants must be invited")
end end
@desc "The list of possible options for the event's status" @desc "The list of possible options for the event's status"
@ -217,7 +225,8 @@ defmodule MobilizonWeb.Schema.EventType do
arg(:begins_on, non_null(:datetime)) arg(:begins_on, non_null(:datetime))
arg(:ends_on, :datetime) arg(:ends_on, :datetime)
arg(:status, :event_status) arg(:status, :event_status)
arg(:visibility, :event_visibility, default_value: :private) arg(:visibility, :event_visibility, default_value: :public)
arg(:join_options, :event_join_options, default_value: :free)
arg(:tags, list_of(:string), arg(:tags, list_of(:string),
default_value: [], default_value: [],
@ -249,7 +258,8 @@ defmodule MobilizonWeb.Schema.EventType do
arg(:begins_on, :datetime) arg(:begins_on, :datetime)
arg(:ends_on, :datetime) arg(:ends_on, :datetime)
arg(:status, :event_status) arg(:status, :event_status)
arg(:visibility, :event_visibility) arg(:visibility, :event_visibility, default_value: :public)
arg(:join_options, :event_join_options, default_value: :free)
arg(:tags, list_of(:string), description: "The list of tags associated to the event") arg(:tags, list_of(:string), description: "The list of tags associated to the event")

View File

@ -10,6 +10,8 @@ defmodule MobilizonWeb.Schema.Events.ParticipantType do
@desc "Represents a participant to an event" @desc "Represents a participant to an event"
object :participant do object :participant do
field(:id, :id, description: "The participation ID")
field( field(
:event, :event,
:event, :event,
@ -24,11 +26,20 @@ defmodule MobilizonWeb.Schema.Events.ParticipantType do
description: "The actor that participates to the event" description: "The actor that participates to the event"
) )
field(:role, :integer, description: "The role of this actor at this event") field(:role, :participant_role_enum, description: "The role of this actor at this event")
end
enum :participant_role_enum do
value(:not_approved)
value(:participant)
value(:moderator)
value(:administrator)
value(:creator)
end end
@desc "Represents a deleted participant" @desc "Represents a deleted participant"
object :deleted_participant do object :deleted_participant do
field(:id, :id)
field(:event, :deleted_object) field(:event, :deleted_object)
field(:actor, :deleted_object) field(:actor, :deleted_object)
end end
@ -59,5 +70,21 @@ defmodule MobilizonWeb.Schema.Events.ParticipantType do
resolve(&Resolvers.Event.actor_leave_event/3) resolve(&Resolvers.Event.actor_leave_event/3)
end end
@desc "Accept a participation"
field :accept_participation, :participant do
arg(:id, non_null(:id))
arg(:moderator_actor_id, non_null(:id))
resolve(&Resolvers.Event.accept_participation/3)
end
@desc "Reject a participation"
field :reject_participation, :deleted_participant do
arg(:id, non_null(:id))
arg(:moderator_actor_id, non_null(:id))
resolve(&Resolvers.Event.reject_participation/3)
end
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

@ -25,7 +25,8 @@ defmodule Mobilizon.Service.ActivityPub do
alias Mobilizon.Service.ActivityPub.{Activity, Convertible} alias Mobilizon.Service.ActivityPub.{Activity, Convertible}
require Logger require Logger
import Mobilizon.Service.ActivityPub.{Utils, Visibility} import Mobilizon.Service.ActivityPub.Utils
import Mobilizon.Service.ActivityPub.Visibility
@doc """ @doc """
Get recipients for an activity or object Get recipients for an activity or object

View File

@ -35,6 +35,7 @@ defmodule Mobilizon.Service.ActivityPub.Converters.Event do
{:address, address_id} <- {:address, address_id} <-
{:address, get_address(object["location"])}, {:address, get_address(object["location"])},
{:tags, tags} <- {:tags, fetch_tags(object["tag"])}, {:tags, tags} <- {:tags, fetch_tags(object["tag"])},
{:visibility, visibility} <- {:visibility, get_visibility(object)},
{:options, options} <- {:options, get_options(object)} do {:options, options} <- {:options, get_options(object)} do
picture_id = picture_id =
with true <- Map.has_key?(object, "attachment") && length(object["attachment"]) > 0, with true <- Map.has_key?(object, "attachment") && length(object["attachment"]) > 0,
@ -59,6 +60,8 @@ defmodule Mobilizon.Service.ActivityPub.Converters.Event do
"begins_on" => object["startTime"], "begins_on" => object["startTime"],
"ends_on" => object["endTime"], "ends_on" => object["endTime"],
"category" => object["category"], "category" => object["category"],
"visibility" => visibility,
"join_options" => object["joinOptions"],
"url" => object["id"], "url" => object["id"],
"uuid" => object["uuid"], "uuid" => object["uuid"],
"tags" => tags, "tags" => tags,
@ -147,6 +150,16 @@ defmodule Mobilizon.Service.ActivityPub.Converters.Event do
end) end)
end end
@ap_public "https://www.w3.org/ns/activitystreams#Public"
defp get_visibility(object) do
cond do
@ap_public in object["to"] -> :public
@ap_public in object["cc"] -> :unlisted
true -> :private
end
end
@doc """ @doc """
Convert an event struct to an ActivityStream representation Convert an event struct to an ActivityStream representation
""" """
@ -173,6 +186,7 @@ defmodule Mobilizon.Service.ActivityPub.Converters.Event do
"mediaType" => "text/html", "mediaType" => "text/html",
"startTime" => event.begins_on |> date_to_string(), "startTime" => event.begins_on |> date_to_string(),
"endTime" => event.ends_on |> date_to_string(), "endTime" => event.ends_on |> date_to_string(),
"joinOptions" => to_string(event.join_options),
"tag" => event.tags |> build_tags(), "tag" => event.tags |> build_tags(),
"id" => event.url, "id" => event.url,
"url" => event.url "url" => event.url

View File

@ -315,8 +315,9 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
%{"type" => "Update", "object" => %{"type" => "Event"} = object, "actor" => actor} = %{"type" => "Update", "object" => %{"type" => "Event"} = object, "actor" => actor} =
_update _update
) do ) do
with {:ok, %{"actor" => existing_organizer_actor_url} = _existing_event_data} <- with {:ok, %{"actor" => existing_organizer_actor_url} = existing_event_data} <-
fetch_obj_helper_as_activity_streams(object), fetch_obj_helper_as_activity_streams(object),
object <- Map.merge(existing_event_data, object),
{:ok, %Actor{url: actor_url}} <- actor |> Utils.get_url() |> Actors.get_actor_by_url(), {:ok, %Actor{url: actor_url}} <- actor |> Utils.get_url() |> Actors.get_actor_by_url(),
true <- Utils.get_url(existing_organizer_actor_url) == actor_url do true <- Utils.get_url(existing_organizer_actor_url) == actor_url do
ActivityPub.update(%{ ActivityPub.update(%{

View File

@ -328,6 +328,7 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
"category" => metadata.category, "category" => metadata.category,
"actor" => actor, "actor" => actor,
"id" => url || Routes.page_url(Endpoint, :event, uuid), "id" => url || Routes.page_url(Endpoint, :event, uuid),
"joinOptions" => metadata.join_options,
"uuid" => uuid, "uuid" => uuid,
"tag" => "tag" =>
tags |> Enum.uniq() |> Enum.map(fn tag -> %{"type" => "Hashtag", "name" => "##{tag}"} end) tags |> Enum.uniq() |> Enum.map(fn tag -> %{"type" => "Hashtag", "name" => "##{tag}"} 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: Fri Sep 20 2019 16:55:10 GMT+0200 (GMT+02:00)
schema { schema {
query: RootQueryType query: RootQueryType
@ -244,6 +244,7 @@ type DeletedObject {
type DeletedParticipant { type DeletedParticipant {
actor: DeletedObject actor: DeletedObject
event: DeletedObject event: DeletedObject
id: ID
} }
"""An event""" """An event"""
@ -269,6 +270,9 @@ type Event implements ActionLogObject {
"""Internal ID for this event""" """Internal ID for this event"""
id: ID id: ID
"""The event's visibility"""
joinOptions: EventJoinOptions
"""Whether the event is local or not""" """Whether the event is local or not"""
local: Boolean local: Boolean
@ -283,7 +287,7 @@ type Event implements ActionLogObject {
participantStats: ParticipantStats participantStats: ParticipantStats
"""The event's participants""" """The event's participants"""
participants: [Participant] participants(limit: Int = 10, page: Int = 1, roles: String = ""): [Participant]
"""Phone address for the event""" """Phone address for the event"""
phoneAddress: String phoneAddress: String
@ -337,6 +341,18 @@ enum EventCommentModeration {
MODERATED MODERATED
} }
"""The list of join options for an event"""
enum EventJoinOptions {
"""Anyone can join and is automatically accepted"""
FREE
"""Participants must be invited"""
INVITE
"""Manual acceptation"""
RESTRICTED
}
type EventOffer { type EventOffer {
"""The price amount for this offer""" """The price amount for this offer"""
price: Float price: Float
@ -462,18 +478,15 @@ enum EventStatus {
"""The list of visibility options for an event""" """The list of visibility options for an event"""
enum EventVisibility { enum EventVisibility {
"""visible only to people invited"""
INVITE
"""Visible only after a moderator accepted"""
MODERATED
"""Visible only to people members of the group or followers of the person""" """Visible only to people members of the group or followers of the person"""
PRIVATE PRIVATE
"""Publicly listed and federated. Can be shared.""" """Publicly listed and federated. Can be shared."""
PUBLIC PUBLIC
"""Visible only after a moderator accepted"""
RESTRICTED
"""Visible only to people with the link - or invited""" """Visible only to people with the link - or invited"""
UNLISTED UNLISTED
} }
@ -645,8 +658,19 @@ type Participant {
"""The event which the actor participates in""" """The event which the actor participates in"""
event: Event event: Event
"""The participation ID"""
id: ID
"""The role of this actor at this event""" """The role of this actor at this event"""
role: Int role: ParticipantRoleEnum
}
enum ParticipantRoleEnum {
ADMINISTRATOR
CREATOR
MODERATOR
NOT_APPROVED
PARTICIPANT
} }
type ParticipantStats { type ParticipantStats {
@ -855,6 +879,9 @@ enum ReportStatus {
} }
type RootMutationType { type RootMutationType {
"""Accept a participation"""
acceptParticipation(id: ID!, moderatorActorId: ID!): Participant
"""Change default actor for user""" """Change default actor for user"""
changeDefaultActor(preferredUsername: String!): User changeDefaultActor(preferredUsername: String!): User
@ -867,6 +894,7 @@ type RootMutationType {
category: String = "meeting" category: String = "meeting"
description: String! description: String!
endsOn: DateTime endsOn: DateTime
joinOptions: EventJoinOptions = FREE
onlineAddress: String onlineAddress: String
options: EventOptionsInput options: EventOptionsInput
organizerActorId: ID! organizerActorId: ID!
@ -997,6 +1025,9 @@ type RootMutationType {
summary: String = "" summary: String = ""
): Person ): Person
"""Reject a participation"""
rejectParticipation(id: ID!, moderatorActorId: ID!): DeletedParticipant
"""Resend registration confirmation token""" """Resend registration confirmation token"""
resendConfirmationEmail(email: String!, locale: String = "en"): String resendConfirmationEmail(email: String!, locale: String = "en"): String
@ -1013,6 +1044,7 @@ type RootMutationType {
description: String description: String
endsOn: DateTime endsOn: DateTime
eventId: ID! eventId: ID!
joinOptions: EventJoinOptions
onlineAddress: String onlineAddress: String
options: EventOptionsInput options: EventOptionsInput
phoneAddress: String phoneAddress: String
@ -1188,6 +1220,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
@ -318,13 +316,6 @@ defmodule Mobilizon.EventsTest do
{:ok, participant: participant, event: event, actor: actor} {:ok, participant: participant, event: event, actor: actor}
end end
test "list_participants/0 returns all participants", %{
participant: %Participant{event_id: participant_event_id, actor_id: participant_actor_id}
} do
assert [participant_event_id] == Events.list_participants() |> Enum.map(& &1.event_id)
assert [participant_actor_id] == Events.list_participants() |> Enum.map(& &1.actor_id)
end
test "get_participant!/1 returns the participant for a given event and given actor", %{ test "get_participant!/1 returns the participant for a given event and given actor", %{
event: %Event{id: event_id}, event: %Event{id: event_id},
actor: %Actor{id: actor_id} actor: %Actor{id: actor_id}

View File

@ -784,7 +784,7 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do
assert :error == Transmogrifier.handle_incoming(reject_data) assert :error == Transmogrifier.handle_incoming(reject_data)
# Organiser is not present since we use factories directly # Organiser is not present since we use factories directly
assert Events.list_participants_for_event(event.uuid, 1, 10, true) |> Enum.map(& &1.id) == assert Events.list_participants_for_event(event.uuid) |> Enum.map(& &1.id) ==
[] []
end end
@ -812,7 +812,7 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do
assert activity.data["actor"] == participant_url assert activity.data["actor"] == participant_url
# The only participant left is the organizer # The only participant left is the organizer
assert Events.list_participants_for_event(event.uuid, 1, 10, true) |> Enum.map(& &1.id) == [ assert Events.list_participants_for_event(event.uuid) |> Enum.map(& &1.id) == [
organizer_participation.id organizer_participation.id
] ]
end end

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

@ -50,7 +50,7 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) |> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["errors"] == nil assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["joinEvent"]["role"] == "participant" assert json_response(res, 200)["data"]["joinEvent"]["role"] == "PARTICIPANT"
assert json_response(res, 200)["data"]["joinEvent"]["event"]["id"] == to_string(event.id) assert json_response(res, 200)["data"]["joinEvent"]["event"]["id"] == to_string(event.id)
assert json_response(res, 200)["data"]["joinEvent"]["actor"]["id"] == to_string(actor.id) assert json_response(res, 200)["data"]["joinEvent"]["actor"]["id"] == to_string(actor.id)
@ -161,25 +161,27 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
query = """ query = """
{ {
participants(uuid: "#{event.uuid}") { event(uuid: "#{event.uuid}") {
participants {
role, role,
actor { actor {
preferredUsername preferredUsername
} }
} }
} }
}
""" """
res = res =
conn conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "participants")) |> get("/api", AbsintheHelpers.query_skeleton(query, "participants"))
assert json_response(res, 200)["data"]["participants"] == [ assert json_response(res, 200)["data"]["event"]["participants"] == [
%{ %{
"actor" => %{ "actor" => %{
"preferredUsername" => participant2.actor.preferred_username "preferredUsername" => participant2.actor.preferred_username
}, },
"role" => "creator" "role" => "CREATOR"
} }
] ]
end end
@ -331,25 +333,27 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
query = """ query = """
{ {
participants(uuid: "#{event.uuid}") { event(uuid: "#{event.uuid}") {
participants(roles: "participant,moderator,administrator,creator") {
role, role,
actor { actor {
preferredUsername preferredUsername
} }
} }
} }
}
""" """
res = res =
context.conn context.conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "participants")) |> get("/api", AbsintheHelpers.query_skeleton(query, "participants"))
assert json_response(res, 200)["data"]["participants"] == [ assert json_response(res, 200)["data"]["event"]["participants"] == [
%{ %{
"actor" => %{ "actor" => %{
"preferredUsername" => context.actor.preferred_username "preferredUsername" => context.actor.preferred_username
}, },
"role" => "creator" "role" => "CREATOR"
} }
] ]
@ -361,12 +365,59 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
# This one will (as a participant) # This one will (as a participant)
participant2 = insert(:participant, event: event, actor: actor3, role: :participant) participant2 = insert(:participant, event: event, actor: actor3, role: :participant)
query = """
{
event(uuid: "#{event.uuid}") {
participants(page: 1, limit: 1, roles: "participant,moderator,administrator,creator") {
role,
actor {
preferredUsername
}
}
}
}
"""
res = res =
context.conn context.conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "participants")) |> get("/api", AbsintheHelpers.query_skeleton(query, "participants"))
sorted_participants = sorted_participants =
json_response(res, 200)["data"]["participants"] json_response(res, 200)["data"]["event"]["participants"]
|> Enum.sort_by(
&(&1
|> Map.get("actor")
|> Map.get("preferredUsername"))
)
assert sorted_participants == [
%{
"actor" => %{
"preferredUsername" => participant2.actor.preferred_username
},
"role" => "PARTICIPANT"
}
]
query = """
{
event(uuid: "#{event.uuid}") {
participants(page: 2, limit: 1, roles: "participant,moderator,administrator,creator") {
role,
actor {
preferredUsername
}
}
}
}
"""
res =
context.conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "participants"))
sorted_participants =
json_response(res, 200)["data"]["event"]["participants"]
|> Enum.sort_by( |> Enum.sort_by(
&(&1 &(&1
|> Map.get("actor") |> Map.get("actor")
@ -378,13 +429,7 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
"actor" => %{ "actor" => %{
"preferredUsername" => context.actor.preferred_username "preferredUsername" => context.actor.preferred_username
}, },
"role" => "creator" "role" => "CREATOR"
},
%{
"actor" => %{
"preferredUsername" => participant2.actor.preferred_username
},
"role" => "participant"
} }
] ]
end end
@ -456,4 +501,281 @@ defmodule MobilizonWeb.Resolvers.ParticipantResolverTest do
assert json_response(res, 200)["data"]["event"]["participantStats"]["unapproved"] == 1 assert json_response(res, 200)["data"]["event"]["participantStats"]["unapproved"] == 1
end end
end end
describe "Participant approval" do
test "accept_participation/3", %{conn: conn, actor: actor, user: user} do
user_creator = insert(:user)
actor_creator = insert(:actor, user: user_creator)
event = insert(:event, join_options: :restricted, organizer_actor: actor_creator)
insert(:participant, event: event, actor: actor_creator, role: :creator)
mutation = """
mutation {
joinEvent(
actor_id: #{actor.id},
event_id: #{event.id}
) {
id,
role,
actor {
id
},
event {
id
}
}
}
"""
res =
conn
|> auth_conn(user)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["joinEvent"]["role"] == "NOT_APPROVED"
assert json_response(res, 200)["data"]["joinEvent"]["event"]["id"] == to_string(event.id)
assert json_response(res, 200)["data"]["joinEvent"]["actor"]["id"] == to_string(actor.id)
participation_id = json_response(res, 200)["data"]["joinEvent"]["id"]
mutation = """
mutation {
acceptParticipation(
id: "#{participation_id}",
moderator_actor_id: #{actor_creator.id}
) {
id,
role,
actor {
id
},
event {
id
}
}
}
"""
res =
conn
|> auth_conn(user_creator)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["acceptParticipation"]["role"] == "PARTICIPANT"
assert json_response(res, 200)["data"]["acceptParticipation"]["event"]["id"] ==
to_string(event.id)
assert json_response(res, 200)["data"]["acceptParticipation"]["actor"]["id"] ==
to_string(actor.id)
res =
conn
|> auth_conn(user_creator)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert hd(json_response(res, 200)["errors"])["message"] =~
" can't be approved since it's already a participant (with role participant)"
end
test "accept_participation/3 with bad parameters", %{conn: conn, actor: actor, user: user} do
user_creator = insert(:user)
actor_creator = insert(:actor, user: user_creator)
event = insert(:event, join_options: :restricted)
insert(:participant, event: event, role: :creator)
mutation = """
mutation {
joinEvent(
actor_id: #{actor.id},
event_id: #{event.id}
) {
id,
role,
actor {
id
},
event {
id
}
}
}
"""
res =
conn
|> auth_conn(user)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["joinEvent"]["role"] == "NOT_APPROVED"
assert json_response(res, 200)["data"]["joinEvent"]["event"]["id"] == to_string(event.id)
assert json_response(res, 200)["data"]["joinEvent"]["actor"]["id"] == to_string(actor.id)
participation_id = json_response(res, 200)["data"]["joinEvent"]["id"]
mutation = """
mutation {
acceptParticipation(
id: "#{participation_id}",
moderator_actor_id: #{actor_creator.id}
) {
id,
role,
actor {
id
},
event {
id
}
}
}
"""
res =
conn
|> auth_conn(user_creator)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert hd(json_response(res, 200)["errors"])["message"] ==
"Provided moderator actor ID doesn't have permission on this event"
end
end
describe "reject participation" do
test "reject_participation/3", %{conn: conn, actor: actor, user: user} do
user_creator = insert(:user)
actor_creator = insert(:actor, user: user_creator)
event = insert(:event, join_options: :restricted, organizer_actor: actor_creator)
insert(:participant, event: event, actor: actor_creator, role: :creator)
mutation = """
mutation {
joinEvent(
actor_id: #{actor.id},
event_id: #{event.id}
) {
id,
role,
actor {
id
},
event {
id
}
}
}
"""
res =
conn
|> auth_conn(user)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["joinEvent"]["role"] == "NOT_APPROVED"
assert json_response(res, 200)["data"]["joinEvent"]["event"]["id"] == to_string(event.id)
assert json_response(res, 200)["data"]["joinEvent"]["actor"]["id"] == to_string(actor.id)
participation_id = json_response(res, 200)["data"]["joinEvent"]["id"]
mutation = """
mutation {
rejectParticipation(
id: "#{participation_id}",
moderator_actor_id: #{actor_creator.id}
) {
id,
actor {
id
},
event {
id
}
}
}
"""
res =
conn
|> auth_conn(user_creator)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["rejectParticipation"]["id"] == participation_id
assert json_response(res, 200)["data"]["rejectParticipation"]["event"]["id"] ==
to_string(event.id)
assert json_response(res, 200)["data"]["rejectParticipation"]["actor"]["id"] ==
to_string(actor.id)
res =
conn
|> auth_conn(user_creator)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert hd(json_response(res, 200)["errors"])["message"] == "Participant not found"
end
test "reject_participation/3 with bad parameters", %{conn: conn, actor: actor, user: user} do
user_creator = insert(:user)
actor_creator = insert(:actor, user: user_creator)
event = insert(:event, join_options: :restricted)
insert(:participant, event: event, role: :creator)
mutation = """
mutation {
joinEvent(
actor_id: #{actor.id},
event_id: #{event.id}
) {
id,
role,
actor {
id
},
event {
id
}
}
}
"""
res =
conn
|> auth_conn(user)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["joinEvent"]["role"] == "NOT_APPROVED"
assert json_response(res, 200)["data"]["joinEvent"]["event"]["id"] == to_string(event.id)
assert json_response(res, 200)["data"]["joinEvent"]["actor"]["id"] == to_string(actor.id)
participation_id = json_response(res, 200)["data"]["joinEvent"]["id"]
mutation = """
mutation {
rejectParticipation(
id: "#{participation_id}",
moderator_actor_id: #{actor_creator.id}
) {
id,
actor {
id
},
event {
id
}
}
}
"""
res =
conn
|> auth_conn(user_creator)
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert hd(json_response(res, 200)["errors"])["message"] ==
"Provided moderator actor ID doesn't have permission on this event"
end
end
end end

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