Add admin interface to manage instances subscriptions

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2019-12-03 11:29:51 +01:00
parent 0a96d70348
commit 334d66bf5d
141 changed files with 4198 additions and 1923 deletions

View File

@@ -26,7 +26,8 @@
</div>
<div class="media-content">
<span class="title" ref="title">{{ actorDisplayName }}</span><br>
<small class="has-text-grey">@{{ participant.actor.preferredUsername }}</small>
<small class="has-text-grey" v-if="participant.actor.domain">@{{ participant.actor.preferredUsername }}@{{ participant.actor.domain }}</small>
<small class="has-text-grey" v-else>@{{ participant.actor.preferredUsername }}</small>
</div>
</div>
</div>
@@ -41,7 +42,7 @@
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IActor, IPerson, Person } from '@/types/actor';
import { Person } from '@/types/actor';
import { IParticipant, ParticipantRole } from '@/types/event.model';
@Component

View File

@@ -0,0 +1,141 @@
<template>
<div>
<b-table
v-show="relayFollowers.elements.length > 0"
:data="relayFollowers.elements"
:loading="$apollo.queries.relayFollowers.loading"
ref="table"
:checked-rows.sync="checkedRows"
:is-row-checkable="(row) => row.id !== 3"
detailed
:show-detail-icon="false"
paginated
backend-pagination
:total="relayFollowers.total"
:per-page="perPage"
@page-change="onPageChange"
checkable
checkbox-position="left">
<template slot-scope="props">
<b-table-column field="actor.id" label="ID" width="40" numeric>
{{ props.row.actor.id }}
</b-table-column>
<b-table-column field="actor.type" :label="$t('Type')" width="80">
<b-icon icon="lan" v-if="isInstance(props.row.actor)" />
<b-icon icon="account-circle" v-else />
</b-table-column>
<b-table-column field="approved" :label="$t('Status')" width="100" sortable centered>
<span :class="`tag ${props.row.approved ? 'is-success' : 'is-danger' }`">
{{ props.row.approved ? $t('Accepted') : $t('Pending') }}
</span>
</b-table-column>
<b-table-column field="actor.domain" :label="$t('Domain')" sortable>
<template>
<a @click="toggle(props.row)" v-if="isInstance(props.row.actor)">
{{ props.row.actor.domain }}
</a>
<a @click="toggle(props.row)" v-else>
{{ `${props.row.actor.preferredUsername}@${props.row.actor.domain}` }}
</a>
</template>
</b-table-column>
<b-table-column field="actor.updatedAt" :label="$t('Date')" sortable>
{{ props.row.updatedAt | formatDateTimeString }}
</b-table-column>
</template>
<template slot="detail" slot-scope="props">
<article>
<div class="content">
<strong>{{ props.row.actor.domain }}</strong>
<small>@{{ props.row.actor.preferredUsername }}</small>
<small>31m</small>
<br>
<p v-html="props.row.actor.summary" />
</div>
</article>
</template>
<template slot="bottom-left" v-if="checkedRows.length > 0">
<div class="buttons">
<b-button @click="acceptRelays" type="is-success" v-if="checkedRowsHaveAtLeastOneToApprove">
{{ $tc('No instance to approve|Approve instance|Approve {number} instances', checkedRows.length, { number: checkedRows.length }) }}
</b-button>
<b-button @click="rejectRelays" type="is-danger">
{{ $tc('No instance to reject|Reject instance|Reject {number} instances', checkedRows.length, { number: checkedRows.length }) }}
</b-button>
</div>
</template>
</b-table>
<b-message type="is-danger" v-if="relayFollowers.elements.length === 0">
{{ $t("No instance follows your instance yet.") }}
</b-message>
</div>
</template>
<script lang="ts">
import { Component, Mixins } from 'vue-property-decorator';
import { ACCEPT_RELAY, REJECT_RELAY, RELAY_FOLLOWERS } from '@/graphql/admin';
import { Paginate } from '@/types/paginate';
import { IFollower } from '@/types/actor/follower.model';
import RelayMixin from '@/mixins/relay';
@Component({
apollo: {
relayFollowers: {
query: RELAY_FOLLOWERS,
fetchPolicy: 'cache-and-network',
},
},
metaInfo() {
return {
title: this.$t('Followers') as string,
titleTemplate: '%s | Mobilizon',
};
},
})
export default class Followers extends Mixins(RelayMixin) {
relayFollowers: Paginate<IFollower> = { elements: [], total: 0 };
async acceptRelays() {
await this.checkedRows.forEach((row: IFollower) => {
this.acceptRelay(`${row.actor.preferredUsername}@${row.actor.domain}`);
});
}
async rejectRelays() {
await this.checkedRows.forEach((row: IFollower) => {
this.rejectRelay(`${row.actor.preferredUsername}@${row.actor.domain}`);
});
}
async acceptRelay(address: String) {
await this.$apollo.mutate({
mutation: ACCEPT_RELAY,
variables: {
address,
},
});
await this.$apollo.queries.relayFollowers.refetch();
this.checkedRows = [];
}
async rejectRelay(address: String) {
await this.$apollo.mutate({
mutation: REJECT_RELAY,
variables: {
address,
},
});
await this.$apollo.queries.relayFollowers.refetch();
this.checkedRows = [];
}
get checkedRowsHaveAtLeastOneToApprove(): boolean {
return this.checkedRows.some(checkedRow => !checkedRow.approved);
}
}
</script>

View File

@@ -0,0 +1,142 @@
<template>
<div>
<form @submit="followRelay">
<b-field :label="$t('Add an instance')" custom-class="add-relay" horizontal>
<b-field grouped expanded size="is-large">
<p class="control">
<b-input v-model="newRelayAddress" :placeholder="$t('Ex: test.mobilizon.org')" />
</p>
<p class="control">
<b-button type="is-primary" native-type="submit">{{ $t('Add an instance') }}</b-button>
</p>
</b-field>
</b-field>
</form>
<b-table
v-show="relayFollowings.elements.length > 0"
:data="relayFollowings.elements"
:loading="$apollo.queries.relayFollowings.loading"
ref="table"
:checked-rows.sync="checkedRows"
:is-row-checkable="(row) => row.id !== 3"
detailed
:show-detail-icon="false"
paginated
backend-pagination
:total="relayFollowings.total"
:per-page="perPage"
@page-change="onPageChange"
checkable
checkbox-position="left">
<template slot-scope="props">
<b-table-column field="targetActor.id" label="ID" width="40" numeric>
{{ props.row.targetActor.id }}
</b-table-column>
<b-table-column field="targetActor.type" :label="$t('Type')" width="80">
<b-icon icon="lan" v-if="isInstance(props.row.targetActor)" />
<b-icon icon="account-circle" v-else />
</b-table-column>
<b-table-column field="approved" :label="$t('Status')" width="100" sortable centered>
<span :class="`tag ${props.row.approved ? 'is-success' : 'is-danger' }`">
{{ props.row.approved ? $t('Accepted') : $t('Pending') }}
</span>
</b-table-column>
<b-table-column field="targetActor.domain" :label="$t('Domain')" sortable>
<template>
<a @click="toggle(props.row)" v-if="isInstance(props.row.targetActor)">
{{ props.row.targetActor.domain }}
</a>
<a @click="toggle(props.row)" v-else>
{{ `${props.row.targetActor.preferredUsername}@${props.row.targetActor.domain}` }}
</a>
</template>
</b-table-column>
<b-table-column field="targetActor.updatedAt" :label="$t('Date')" sortable>
{{ props.row.updatedAt | formatDateTimeString }}
</b-table-column>
</template>
<template slot="detail" slot-scope="props">
<article>
<div class="content">
<strong>{{ props.row.targetActor.domain }}</strong>
<small>@{{ props.row.targetActor.preferredUsername }}</small>
<small>31m</small>
<br>
<p v-html="props.row.targetActor.summary" />
</div>
</article>
</template>
<template slot="bottom-left" v-if="checkedRows.length > 0">
<b-button @click="removeRelays" type="is-danger">
{{ $tc('No instance to remove|Remove instance|Remove {number} instances', checkedRows.length, { number: checkedRows.length }) }}
</b-button>
</template>
</b-table>
<b-message type="is-danger" v-if="relayFollowings.elements.length === 0">
{{ $t("You don't follow any instances yet.") }}
</b-message>
</div>
</template>
<script lang="ts">
import { Component, Mixins } from 'vue-property-decorator';
import { ADD_RELAY, RELAY_FOLLOWINGS, REMOVE_RELAY } from '@/graphql/admin';
import { IFollower } from '@/types/actor/follower.model';
import { Paginate } from '@/types/paginate';
import RelayMixin from '@/mixins/relay';
@Component({
apollo: {
relayFollowings: {
query: RELAY_FOLLOWINGS,
fetchPolicy: 'cache-and-network',
},
},
metaInfo() {
return {
title: this.$t('Followings') as string,
titleTemplate: '%s | Mobilizon',
};
},
})
export default class Followings extends Mixins(RelayMixin) {
relayFollowings: Paginate<IFollower> = { elements: [], total: 0 };
newRelayAddress: String = '';
async followRelay(e) {
e.preventDefault();
await this.$apollo.mutate({
mutation: ADD_RELAY,
variables: {
address: this.newRelayAddress,
},
// TODO: Handle cache update properly without refreshing
});
await this.$apollo.queries.relayFollowings.refetch();
this.newRelayAddress = '';
}
async removeRelays() {
await this.checkedRows.forEach((row: IFollower) => {
this.removeRelay(`${row.targetActor.preferredUsername}@${row.targetActor.domain}`);
});
}
async removeRelay(address: String) {
await this.$apollo.mutate({
mutation: REMOVE_RELAY,
variables: {
address,
},
});
await this.$apollo.queries.relayFollowings.refetch();
this.checkedRows = [];
}
}
</script>

View File

@@ -11,7 +11,8 @@
<div class="content">
<span class="first-line" v-if="!comment.deletedAt">
<strong>{{ comment.actor.name }}</strong>
<small>@{{ comment.actor.preferredUsername }}</small>
<small v-if="comment.actor.domain">@{{ comment.actor.preferredUsername }}@{{ comment.actor.domain }}</small>
<small v-else>@{{ comment.actor.preferredUsername }}</small>
<a class="comment-link has-text-grey" :href="commentId">
<small>{{ timeago(new Date(comment.updatedAt)) }}</small>
</a>
@@ -202,7 +203,7 @@ export default class Comment extends Vue {
timeago(dateTime): String {
if (this.timeAgoInstance != null) {
// @ts-ignore
// @ts-ignore
return this.timeAgoInstance.format(dateTime);
}
return '';
@@ -213,7 +214,7 @@ export default class Comment extends Vue {
}
get commentFromOrganizer(): boolean {
return this.event.organizerActor !== undefined && this.comment.actor.id === this.event.organizerActor.id;
return this.event.organizerActor !== undefined && this.comment.actor && this.comment.actor.id === this.event.organizerActor.id;
}
get commentId(): String {
@@ -230,6 +231,7 @@ export default class Comment extends Vue {
title: this.$t('Report this comment'),
comment: this.comment,
onConfirm: this.reportComment,
outsideDomain: this.comment.actor.domain,
},
});
}
@@ -244,6 +246,7 @@ export default class Comment extends Vue {
reportedId: this.comment.actor.id,
commentsIds: [this.comment.id],
content,
forward,
},
});
this.$buefy.notification.open({

View File

@@ -221,7 +221,7 @@ export default class CommentTree extends Vue {
data: { thread: replies },
});
// @ts-ignore
// @ts-ignore
const parentCommentIndex = oldComments.findIndex(oldComment => oldComment.id === comment.originComment.id);
const parentComment = oldComments[parentCommentIndex];
parentComment.replies = replies;

View File

@@ -409,9 +409,9 @@ export default class EditorComponent extends Vue {
}
replyToComment(comment: IComment) {
console.log('called replyToComment', comment);
const actorModel = new Actor(comment.actor);
if (!this.editor) return;
console.log(this.editor.commands);
this.editor.commands.mention({ id: actorModel.id, label: actorModel.usernameWithDomain().substring(1) });
this.editor.focus();
}

View File

@@ -112,7 +112,7 @@ export default class AddressAutoComplete extends Vue {
addressData: IAddress[] = [];
selected: IAddress = new Address();
isFetching: boolean = false;
queryText: string = this.value && (new Address(this.value)).fullName || '';
queryText: string = (this.value && (new Address(this.value)).fullName) || '';
addressModalActive: boolean = false;
private gettingLocation: boolean = false;
private location!: Position;
@@ -164,6 +164,7 @@ export default class AddressAutoComplete extends Vue {
@Watch('value')
updateEditing() {
if (!(this.value && this.value.id)) return;
this.selected = this.value;
const address = new Address(this.selected);
this.queryText = `${address.poiInfos.name} ${address.poiInfos.alternativeName}`;

View File

@@ -26,11 +26,11 @@ A button to set your participation
<div class="participation-button">
<b-dropdown aria-role="list" position="is-bottom-left" v-if="participation && participation.role === ParticipantRole.PARTICIPANT">
<button class="button is-success" type="button" slot="trigger">
<b-icon icon="check"></b-icon>
<b-icon icon="check" />
<template>
<span>{{ $t('I participate') }}</span>
</template>
<b-icon icon="menu-down"></b-icon>
<b-icon icon="menu-down" />
</button>
<!-- <b-dropdown-item :value="false" aria-role="listitem">-->
@@ -45,11 +45,11 @@ A button to set your participation
<div v-else-if="participation && participation.role === ParticipantRole.NOT_APPROVED">
<b-dropdown aria-role="list" position="is-bottom-left" class="dropdown-disabled">
<button class="button is-success" type="button" slot="trigger">
<b-icon icon="timer-sand-empty"></b-icon>
<b-icon icon="timer-sand-empty" />
<template>
<span>{{ $t('I participate') }}</span>
</template>
<b-icon icon="menu-down"></b-icon>
<b-icon icon="menu-down" />
</button>
<!-- <b-dropdown-item :value="false" aria-role="listitem">-->
@@ -73,7 +73,7 @@ A button to set your participation
<template>
<span>{{ $t('Participate') }}</span>
</template>
<b-icon icon="menu-down"></b-icon>
<b-icon icon="menu-down" />
</button>
<b-dropdown-item :value="true" aria-role="listitem" @click="joinEvent(currentActor)">
@@ -84,12 +84,12 @@ A button to set your participation
</figure>
</div>
<div class="media-content">
<span>{{ $t('with {identity}', {identity: currentActor.preferredUsername }) }}</span>
<span>{{ $t('as {identity}', {identity: currentActor.preferredUsername }) }}</span>
</div>
</div>
</b-dropdown-item>
<b-dropdown-item :value="false" aria-role="listitem" @click="joinModal">
<b-dropdown-item :value="false" aria-role="listitem" @click="joinModal" v-if="identities.length > 1">
{{ $t('with another identity…')}}
</b-dropdown-item>
</b-dropdown>
@@ -99,14 +99,32 @@ A button to set your participation
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IParticipant, ParticipantRole } from '@/types/event.model';
import { IPerson } from '@/types/actor';
import { IPerson, Person } from '@/types/actor';
import { IDENTITIES } from '@/graphql/actor';
import { CURRENT_USER_CLIENT } from '@/graphql/user';
import { ICurrentUser } from '@/types/current-user.model';
@Component
@Component({
apollo: {
currentUser: {
query: CURRENT_USER_CLIENT,
},
identities: {
query: IDENTITIES,
update: ({ identities }) => identities ? identities.map(identity => new Person(identity)) : [],
skip() {
return this.currentUser.isLoggedIn === false;
},
},
},
})
export default class ParticipationButton extends Vue {
@Prop({ required: true }) participation!: IParticipant;
@Prop({ required: true }) currentActor!: IPerson;
ParticipantRole = ParticipantRole;
currentUser!: ICurrentUser;
identities: IPerson[] = [];
joinEvent(actor: IPerson) {
this.$emit('joinEvent', actor);

View File

@@ -16,7 +16,7 @@ import { Component, Prop, Vue } from 'vue-property-decorator';
@Component({
beforeDestroy() {
// @ts-ignore
// @ts-ignore
this.parentContainer.removeLayer(this);
},
})

View File

@@ -20,7 +20,14 @@
</div>
<div class="content columns">
<div class="column is-one-quarter-desktop">Reported by <img v-if="report.reporter.avatar" class="image" :src="report.reporter.avatar.url" /> @{{ report.reporter.preferredUsername }}</div>
<div class="column is-one-quarter-desktop">
<span v-if="report.reporter.type === ActorType.APPLICATION">
{{ $t('Reported by someone on {domain}', { domain: report.reporter.domain}) }}
</span>
<span v-else>
{{ $t('Reported by {reporter}', { reporter: report.reporter.preferredUsername}) }}
</span>
</div>
<div class="column" v-if="report.content">{{ report.content }}</div>
</div>
</div>
@@ -29,10 +36,13 @@
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IReport } from '@/types/report.model';
import { ActorType } from '@/types/actor';
@Component
export default class ReportCard extends Vue {
@Prop({ required: true }) report!: IReport;
ActorType = ActorType;
}
</script>
<style lang="scss">

View File

@@ -44,11 +44,8 @@
/>
</div>
<p v-if="outsideDomain">
{{ $t('The content came from another server. Transfer an anonymous copy of the report?') }}
</p>
<div class="control" v-if="outsideDomain">
<p>{{ $t('The content came from another server. Transfer an anonymous copy of the report?') }}</p>
<b-switch v-model="forward">{{ $t('Transfer to {outsideDomain}', { outsideDomain }) }}</b-switch>
</div>
</div>

View File

@@ -19,3 +19,87 @@ export const DASHBOARD = gql`
}
}
`;
export const RELAY_FRAGMENT = gql`
fragment relayFragment on Follower {
actor {
id,
preferredUsername,
name,
domain,
type,
summary
},
targetActor {
id,
preferredUsername,
name,
domain,
type,
summary
},
approved,
insertedAt,
updatedAt
}
`;
export const RELAY_FOLLOWERS = gql`
query relayFollowers($page: Int, $limit: Int) {
relayFollowers(page: $page, limit: $limit) {
elements {
...relayFragment
},
total
}
}
${RELAY_FRAGMENT}
`;
export const RELAY_FOLLOWINGS = gql`
query relayFollowings($page: Int, $limit: Int) {
relayFollowings(page: $page, limit: $limit) {
elements {
...relayFragment
},
total
}
}
${RELAY_FRAGMENT}
`;
export const ADD_RELAY = gql`
mutation addRelay($address: String!) {
addRelay(address: $address) {
...relayFragment
}
}
${RELAY_FRAGMENT}
`;
export const REMOVE_RELAY = gql`
mutation removeRelay($address: String!) {
removeRelay(address: $address) {
...relayFragment
}
}
${RELAY_FRAGMENT}
`;
export const ACCEPT_RELAY = gql`
mutation acceptRelay($address: String!) {
acceptRelay(address: $address) {
...relayFragment
}
}
${RELAY_FRAGMENT}
`;
export const REJECT_RELAY = gql`
mutation rejectRelay($address: String!) {
rejectRelay(address: $address) {
...relayFragment
}
}
${RELAY_FRAGMENT}
`;

View File

@@ -13,6 +13,7 @@ export const COMMENT_FIELDS_FRAGMENT = gql`
url
},
id,
domain,
preferredUsername,
name
},

View File

@@ -10,7 +10,8 @@ const participantQuery = `
url
},
name,
id
id,
domain
},
event {
id
@@ -441,3 +442,21 @@ export const EVENT_PERSON_PARTICIPATION = gql`
}
}
`;
export const EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED = gql`
subscription ($actorId: ID!, $eventId: ID!) {
eventPersonParticipationChanged(personId: $actorId) {
id,
participations(eventId: $eventId) {
id,
role,
actor {
id
},
event {
id
}
}
}
}
`;

View File

@@ -18,7 +18,9 @@ export const REPORTS = gql`
name,
avatar {
url
}
},
domain,
type
},
event {
id,
@@ -52,7 +54,9 @@ const REPORT_FRAGMENT = gql`
name,
avatar {
url
}
},
domain,
type
},
event {
id,
@@ -111,9 +115,10 @@ export const CREATE_REPORT = gql`
$reporterId: ID!,
$reportedId: ID!,
$content: String,
$commentsIds: [ID]
$commentsIds: [ID],
$forward: Boolean
) {
createReport(eventId: $eventId, reporterId: $reporterId, reportedId: $reportedId, content: $content, commentsIds: $commentsIds) {
createReport(eventId: $eventId, reporterId: $reporterId, reportedId: $reportedId, content: $content, commentsIds: $commentsIds, forward: $forward) {
id
}
}

View File

@@ -322,7 +322,6 @@
"resend confirmation email": "Bestätigungsmail erneut senden",
"respect of the fundamental freedoms": "Respekt für die fundamentalen Freiheiten",
"with another identity…": "mit einer anderen Identität.…",
"with {identity}": "mit {identity}",
"{approved} / {total} seats": "{approved} / {total} Plätze",
"{count} participants": "Noch keine Teilnehmer | Ein Teilnehmer | {count} Teilnehmer",
"{count} requests waiting": "{count} Anfragen ausstehend",

View File

@@ -333,11 +333,59 @@
"resend confirmation email": "resend confirmation email",
"respect of the fundamental freedoms": "respect of the fundamental freedoms",
"with another identity…": "with another identity…",
"with {identity}": "with {identity}",
"as {identity}": "as {identity}",
"{approved} / {total} seats": "{approved} / {total} seats",
"{count} participants": "No participants yet | One participant | {count} participants",
"{count} requests waiting": "{count} requests waiting",
"{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.": "{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.",
"© 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 OpenStreetMap Contributors": "© The OpenStreetMap Contributors"
"© The OpenStreetMap Contributors": "© The OpenStreetMap Contributors",
"Reply": "Reply",
"Accepted": "Accepted",
"Pending": "Pending",
"No instance to remove|Remove instance|Remove {number} instances": "No instances to remove|Remove instance|Remove {number} instances",
"Dashboard": "Dashboard",
"Reports": "Reports",
"Mark as resolved": "Mark as resolved",
"Reopen": "Reopen",
"Close": "Close",
"Reported identity": "Reported identity",
"Reported by": "Reported by",
"Reported": "Reported",
"Updated": "Updated",
"Open": "Open",
"Closed": "Closed",
"Resolved": "Resolved",
"Unknown": "Unknown",
"No comment": "No comment",
"Notes": "Notes",
"New note": "New note",
"Add a note": "Add a note",
"Deleting event": "Deleting event",
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.": "Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.",
"Delete Event": "Delete Event",
"Type": "Type",
"Domain": "Domain",
"Date": "Date",
"No instance to approve|Approve instance|Approve {number} instances": "No instance to approve|Approve instance|Approve {number} instances",
"No instance to reject|Reject instance|Reject {number} instances": "No instance to reject|Reject instance|Reject {number} instances",
"No instance follows your instance yet.": "No instance follows your instance yet.",
"Followers": "Followers",
"Add an instance": "Add an instance",
"Ex: test.mobilizon.org": "Ex: test.mobilizon.org",
"You don't follow any instances yet.": "You don't follow any instances yet.",
"Followings": "Followings",
"Instances": "Instances",
"Reported by {reporter}": "Reported by {reporter}",
"No open reports yet": "No open reports yet",
"No resolved reports yet": "No resolved reports yet",
"No closed reports yet": "No closed reports yet",
"Reported by someone on {domain}": "Reported by someone on {domain}",
"Your participation has been rejected": "Your participation has been rejected",
"Your participation status has been changed": "Your participation status has been changed",
"Unknown actor": "Unknown actor",
"Deleting comment": "Deleting comment",
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Are you sure you want to <b>delete</b> this comment? This action cannot be undone.",
"Delete Comment": "Delete Comment",
"Comment deleted": "Comment deleted"
}

View File

@@ -286,7 +286,7 @@
"Update my event": "Éditer mon événement",
"User accounts and every other data is currently deleted every 48 hours, so you may want to register again.": "Les comptes utilisateurs et toutes les autres données sont actuellement supprimées toutes les 48 heures, donc vous voulez peut-être vous inscrire à nouveau.",
"Username": "Pseudo",
"Users": "Utilisateurs",
"Users": "Utilisateur⋅ice⋅s",
"View a reply": "Aucune réponse | Voir une réponse | Voir {totalReplies} réponses",
"View event page": "Voir la page de l'événement",
"View everything": "Voir tout",
@@ -337,11 +337,57 @@
"resend confirmation email": "réenvoyer l'email de confirmation",
"respect of the fundamental freedoms": "le respect des libertés fondamentales",
"with another identity…": "avec une autre identité…",
"with {identity}": "avec {identity}",
"as {identity}": "en tant que {identity}",
"{approved} / {total} seats": "{approved} / {total} places",
"{count} participants": "Aucun⋅e participant⋅e | Un⋅e participant⋅e | {count} participant⋅e⋅s",
"{count} requests waiting": "Une demande en attente|{count} demandes en attente",
"{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.": "{license} garantit {respect} des personnes qui l'utiliseront. Puisque {source}, il est publiquement auditable, ce qui garantit sa transparence.",
"© 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 OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap"
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap",
"Reply": "Répondre",
"Accepted": "Accepté",
"Pending": "En attente",
"No instance to remove|Remove instance|Remove {number} instances": "Pas d'instances à supprimer|Supprimer une instance|Supprimer {number} instances",
"Mark as resolved": "Marquer comme résolu",
"Reopen": "Réouvrir",
"Close": "Fermé",
"Reported identity": "Identité signalée",
"Reported by": "Signalée par",
"Reported": "Signalée",
"Updated": "Mis à jour",
"Open": "Ouvert",
"Closed": "Fermé",
"Resolved": "Résolu",
"Unknown": "Inconnu",
"No comment": "Pas de commentaire",
"Notes": "Notes",
"New note": "Nouvelle note",
"Add a note": "Ajouter une note",
"Deleting event": "Suppression de l'événement",
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.": "Êtes-vous certain⋅e de vouloir <b>supprimer</b> cet événement ? Cette action n'est pas réversible. Vous voulez peut-être engager la conversation avec le créateur de l'événement ou bien éditer son événement à la place.",
"Delete Event": "Supprimer l'événement",
"Type": "Type",
"Domain": "Domaine",
"Date": "Date",
"No instance to approve|Approve instance|Approve {number} instances": "Aucune instance à approuver|Approuver une instance|Approuver {number} instances",
"No instance to reject|Reject instance|Reject {number} instances": "Aucune instance à rejetter|Rejetter une instance|Rejetter {number} instances",
"No instance follows your instance yet.": "Aucune instance ne suit votre instance pour le moment.",
"Followers": "Abonnés",
"Add an instance": "Ajouter une instance",
"Ex: test.mobilizon.org": "Ex: test.mobilizon.org",
"You don't follow any instances yet.": "Vous ne suivez aucune instance pour le moment.",
"Followings": "Abonnements",
"Instances": "Instances",
"Reported by {reporter}": "Signalé par {reporter}",
"No open reports yet": "Aucun signalement ouvert pour le moment",
"No resolved reports yet": "Aucun signalement résolu pour le moment",
"No closed reports yet": "Aucun signalement fermé pour le moment",
"Reported by someone on {domain}": "Signalé par quelqu'un depuis {domain}",
"Your participation has been rejected": "Votre participation a été rejettée",
"Your participation status has been changed": "Le statut de votre participation a été mis à jour",
"Unknown actor": "Acteur inconnu",
"Deleting comment": "Suppression du commentaire en cours",
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Êtes-vous certain⋅e de vouloir <b>supprimer</b> ce commentaire? Cette action ne peut pas être annulée.",
"Delete Comment": "Supprimer le commentaire",
"Comment deleted": "Commentaire supprimé"
}

View File

@@ -321,7 +321,6 @@
"resend confirmation email": "bevestigingsemail opnieuw versturen",
"respect of the fundamental freedoms": "respect voor de fundamentele vrijheden",
"with another identity…": "met een andere identiteit…",
"with {identity}": "met {identity}",
"{approved} / {total} seats": "{approved} / {total} plaatsen",
"{count} participants": "Nog geen deelnemers | Eén deelnemer | {count} deelnemers",
"{count} requests waiting": "{count} aanvragen in afwachting",

View File

@@ -368,7 +368,6 @@
"resend confirmation email": "tornar enviar lo messatge de confirmacion",
"respect of the fundamental freedoms": "lo respet de las libertats fondamentalas",
"with another identity…": "amb una autra identitat…",
"with {identity}": "amb {identity}",
"{actor}'s avatar": "Avatar de {actor}",
"{approved} / {total} seats": "{approved} / {total} plaças",
"{count} participants": "Cap de participacion pel moment|Un participant|{count} participants",

View File

@@ -324,7 +324,6 @@
"resend confirmation email": "skicka bekräftelsemail igen",
"respect of the fundamental freedoms": "respektera våra grundläggande friheter",
"with another identity…": "med en annan identitet…",
"with {identity}": "med {identity}",
"{approved} / {total} seats": "{approved} / {total} platser",
"{count} participants": "Inga deltagande ännu|En deltagande|{count} deltagande",
"{count} requests waiting": "{count} förfrågningar väntar",

44
js/src/mixins/relay.ts Normal file
View File

@@ -0,0 +1,44 @@
import { Component, Vue } from 'vue-property-decorator';
import { Refs } from '@/shims-vue';
import { ActorType, IActor } from '@/types/actor';
import { IFollower } from '@/types/actor/follower.model';
@Component
export default class RelayMixin extends Vue {
$refs!: Refs<{
table: any,
}>;
checkedRows: IFollower[] = [];
page: number = 1;
perPage: number = 2;
toggle(row) {
this.$refs.table.toggleDetails(row);
}
async onPageChange(page: number) {
this.page = page;
await this.$apollo.queries.relayFollowings.fetchMore({
variables: {
page: this.page,
limit: this.perPage,
},
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult) return previousResult;
const newFollowings = fetchMoreResult.relayFollowings.elements;
return {
relayFollowings: {
__typename: previousResult.relayFollowings.__typename,
total: previousResult.relayFollowings.total,
elements: [...previousResult.relayFollowings.elements, ...newFollowings],
},
};
},
});
}
isInstance(actor: IActor): boolean {
return actor.type === ActorType.APPLICATION && actor.preferredUsername === 'relay';
}
}

View File

@@ -1,10 +1,12 @@
import Vue from 'vue';
import { ColorModifiers } from 'buefy/types/helpers';
declare module 'vue/types/vue' {
interface Vue {
$notifier: {
success: (message: string) => void;
error: (message: string) => void;
info: (message: string) => void;
};
}
}
@@ -17,21 +19,23 @@ export class Notifier {
}
success(message: string) {
this.vue.prototype.$buefy.notification.open({
message,
duration: 5000,
position: 'is-bottom-right',
type: 'is-success',
hasIcon: true,
});
this.notification(message, 'is-success');
}
error(message: string) {
this.notification(message, 'is-danger');
}
info(message: string) {
this.notification(message, 'is-info');
}
private notification(message: string, type: ColorModifiers) {
this.vue.prototype.$buefy.notification.open({
message,
duration: 5000,
position: 'is-bottom-right',
type: 'is-danger',
type,
hasIcon: true,
});
}

View File

@@ -1,8 +1,14 @@
import { RouteConfig } from 'vue-router';
import Dashboard from '@/views/Admin/Dashboard.vue';
import Follows from '@/views/Admin/Follows.vue';
import Followings from '@/components/Admin/Followings.vue';
import Followers from '@/components/Admin/Followers.vue';
export enum AdminRouteName {
DASHBOARD = 'Dashboard',
RELAYS = 'Relays',
RELAY_FOLLOWINGS = 'Followings',
RELAY_FOLLOWERS = 'Followers',
}
export const adminRoutes: RouteConfig[] = [
@@ -13,4 +19,24 @@ export const adminRoutes: RouteConfig[] = [
props: true,
meta: { requiredAuth: true },
},
{
path: '/admin/relays',
name: AdminRouteName.RELAYS,
redirect: { name: AdminRouteName.RELAY_FOLLOWINGS },
component: Follows,
children: [
{
path: 'followings',
name: AdminRouteName.RELAY_FOLLOWINGS,
component: Followings,
},
{
path: 'followers',
name: AdminRouteName.RELAY_FOLLOWERS,
component: Followers,
},
],
props: true,
meta: { requiredAuth: true },
},
];

View File

@@ -1,5 +1,13 @@
import { IPicture } from '@/types/picture.model';
export enum ActorType {
PERSON = 'PERSON',
APPLICATION = 'APPLICATION',
GROUP = 'GROUP',
ORGANISATION = 'ORGANISATION',
SERVICE = 'SERVICE',
}
export interface IActor {
id?: number;
url: string;
@@ -10,6 +18,7 @@ export interface IActor {
suspended: boolean;
avatar: IPicture | null;
banner: IPicture | null;
type: ActorType;
}
export class Actor implements IActor {
@@ -22,6 +31,7 @@ export class Actor implements IActor {
summary: string = '';
suspended: boolean = false;
url: string = '';
type: ActorType = ActorType.PERSON;
constructor (hash: IActor | {} = {}) {
Object.assign(this, hash);

View File

@@ -0,0 +1,8 @@
import { IActor } from '@/types/actor/actor.model';
export interface IFollower {
id?: string;
actor: IActor;
targetActor: IActor;
approved: boolean;
}

View File

@@ -242,7 +242,7 @@ export class EventModel implements IEvent {
this.onlineAddress = hash.onlineAddress;
this.phoneAddress = hash.phoneAddress;
this.physicalAddress = new Address(hash.physicalAddress);
this.physicalAddress = hash.physicalAddress ? new Address(hash.physicalAddress) : undefined;
this.participantStats = hash.participantStats;
this.tags = hash.tags;

4
js/src/types/paginate.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface Paginate<T> {
elements: T[];
total: number;
}

View File

@@ -7,7 +7,7 @@
<div class="list is-hoverable">
<a class="list-item" v-for="identity in identities" :class="{ 'is-active': identity.id === currentIdentity.id }" @click="changeCurrentIdentity(identity)">
<div class="media">
<img class="media-left image" v-if="identity.avatar" :src="identity.avatar.url" alt="" />
<img class="media-left image is-48x48" v-if="identity.avatar" :src="identity.avatar.url" alt="" />
<b-icon class="media-left" v-else size="is-large" icon="account-circle" />
<div class="media-content">
<h3>@{{ identity.preferredUsername }}</h3>
@@ -17,7 +17,7 @@
</a>
</div>
</section>
<slot name="footer"></slot>
<slot name="footer" />
</div>
</template>
<script lang="ts">

View File

@@ -1,7 +1,9 @@
<template>
<div class="identity-picker">
<span v-if="inline" class="inline">
<img class="image" v-if="currentIdentity.avatar" :src="currentIdentity.avatar.url" :alt="currentIdentity.avatar.alt"/> {{ currentIdentity.name || `@${currentIdentity.preferredUsername}` }}
<img class="image" v-if="currentIdentity.avatar" :src="currentIdentity.avatar.url" :alt="currentIdentity.avatar.alt"/>
<b-icon v-else size="is-small" icon="account-circle" />
{{ currentIdentity.name || `@${currentIdentity.preferredUsername}` }}
<b-button type="is-text" @click="isComponentModalActive = true">
{{ $t('Change') }}
</b-button>

View File

@@ -38,6 +38,13 @@
</article>
</router-link>
</div>
<div class="tile is-parent">
<router-link :to="{ name: RouteName.RELAYS }">
<article class="tile is-child box">
<p class="subtitle">{{ $t('Instances') }}</p>
</article>
</router-link>
</div>
</div>
<div class="tile is-parent">
<article class="tile is-child box">
@@ -67,6 +74,12 @@ import { RouteName } from '@/router';
query: DASHBOARD,
},
},
metaInfo() {
return {
title: this.$t('Administration') as string,
titleTemplate: '%s | Mobilizon',
};
},
})
export default class Dashboard extends Vue {
dashboard!: IDashboard;

View File

@@ -0,0 +1,57 @@
<template>
<div class="container">
<h1 class="title">{{ $t('Instances') }}</h1>
<div class="tabs is-boxed">
<ul>
<router-link tag="li" active-class="is-active" :to="{name: RouteName.RELAY_FOLLOWINGS}" exact>
<a>
<b-icon icon="inbox-arrow-down"></b-icon>
<span>{{ $t('Followings') }} <b-tag rounded> {{ relayFollowings.total }} </b-tag> </span>
</a>
</router-link>
<router-link tag="li" active-class="is-active" :to="{name: RouteName.RELAY_FOLLOWERS}" exact>
<a>
<b-icon icon="inbox-arrow-up"></b-icon>
<span>{{ $t('Followers') }} <b-tag rounded> {{ relayFollowers.total }} </b-tag> </span>
</a>
</router-link>
</ul>
</div>
<router-view></router-view>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { RouteName } from '@/router';
import { RELAY_FOLLOWERS, RELAY_FOLLOWINGS } from '@/graphql/admin';
import { Paginate } from '@/types/paginate';
import { IFollower } from '@/types/actor/follower.model';
@Component({
apollo: {
relayFollowings: {
query: RELAY_FOLLOWINGS,
fetchPolicy: 'cache-and-network',
},
relayFollowers: {
query: RELAY_FOLLOWERS,
fetchPolicy: 'cache-and-network',
},
},
})
export default class Follows extends Vue {
RouteName = RouteName;
activeTab: number = 0;
relayFollowings: Paginate<IFollower> = { elements: [], total: 0 };
relayFollowers: Paginate<IFollower> = { elements: [], total: 0 };
}
</script>
<style lang="scss">
.tab-item {
form {
margin-bottom: 1.5rem;
}
}
</style>

View File

@@ -29,7 +29,7 @@
<address-auto-complete v-model="event.physicalAddress" />
<b-field :label="$t('Organizer')">
<identity-picker-wrapper v-model="event.organizerActor"></identity-picker-wrapper>
<identity-picker-wrapper v-model="event.organizerActor" />
</b-field>
<div class="field">
@@ -92,7 +92,7 @@
<div class="box" v-if="limitedPlaces">
<b-field :label="$t('Number of places')">
<b-numberinput controls-position="compact" min="0" v-model="event.options.maximumAttendeeCapacity"></b-numberinput>
<b-numberinput controls-position="compact" min="0" v-model="event.options.maximumAttendeeCapacity" />
</b-field>
<!--
<b-field>
@@ -145,21 +145,21 @@
name="status"
type="is-warning"
:native-value="EventStatus.TENTATIVE">
<b-icon icon="calendar-question"></b-icon>
<b-icon icon="calendar-question" />
{{ $t('Tentative: Will be confirmed later') }}
</b-radio-button>
<b-radio-button v-model="event.status"
name="status"
type="is-success"
:native-value="EventStatus.CONFIRMED">
<b-icon icon="calendar-check"></b-icon>
<b-icon icon="calendar-check" />
{{ $t('Confirmed: Will happen') }}
</b-radio-button>
<b-radio-button v-model="event.status"
name="status"
type="is-danger"
:native-value="EventStatus.CANCELLED">
<b-icon icon="calendar-remove"></b-icon>
<b-icon icon="calendar-remove" />
{{ $t("Cancelled: Won't happen") }}
</b-radio-button>
</b-field>
@@ -191,7 +191,7 @@
</div>
</form>
</b-modal>
<span ref="bottomObserver"></span>
<span ref="bottomObserver" />
<nav role="navigation" aria-label="main navigation" class="navbar" :class="{'is-fixed-bottom': showFixedNavbar }">
<div class="container">
<div class="navbar-menu">
@@ -395,6 +395,11 @@ export default class EditEvent extends Vue {
}
}
@Watch('currentActor')
setCurrentActor() {
this.event.organizerActor = this.currentActor;
}
private validateForm() {
const form = this.$refs.form as HTMLFormElement;
if (form.checkValidity()) {

View File

@@ -1,6 +1,8 @@
import {ParticipantRole} from "@/types/event.model";
import {ParticipantRole} from "@/types/event.model";
<template>
<div class="container">
<b-loading :active.sync="$apollo.loading"></b-loading>
<b-loading :active.sync="$apollo.loading" />
<transition appear name="fade" mode="out-in">
<div>
<div class="header-picture" v-if="event.picture" :style="`background-image: url('${event.picture.url}')`" />
@@ -9,7 +11,7 @@
<div class="title-and-participate-button">
<div class="title-wrapper">
<div class="date-component">
<date-calendar-icon :date="event.beginsOn"></date-calendar-icon>
<date-calendar-icon :date="event.beginsOn" />
</div>
<div class="title-and-informations">
<h1 class="title">{{ event.title }}</h1>
@@ -49,7 +51,7 @@
<template>
<span>{{ $t('Event already passed')}}</span>
</template>
<b-icon icon="menu-down"></b-icon>
<b-icon icon="menu-down" />
</button>
</div>
</div>
@@ -65,6 +67,9 @@
<b-tag type="is-info" v-if="event.visibility === EventVisibility.PUBLIC">{{ $t('Public event') }}</b-tag>
<b-tag type="is-info" v-if="event.visibility === EventVisibility.UNLISTED">{{ $t('Private event') }}</b-tag>
</span>
<span v-if="!event.local">
<b-tag type="is-primary">{{ event.organizerActor.domain }}</b-tag>
</span>
<router-link
v-if="event.tags && event.tags.length > 0"
v-for="tag in event.tags"
@@ -136,7 +141,7 @@
</b-modal>
</div>
<span class="online-address" v-if="event.onlineAddress && urlToHostname(event.onlineAddress)">
<b-icon icon="link"></b-icon>
<b-icon icon="link" />
<a
target="_blank"
rel="noopener noreferrer"
@@ -250,8 +255,14 @@
</template>
<script lang="ts">
import { EVENT_PERSON_PARTICIPATION, FETCH_EVENT, JOIN_EVENT, LEAVE_EVENT } from '@/graphql/event';
import { Component, Prop } from 'vue-property-decorator';
import {
EVENT_PERSON_PARTICIPATION,
EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED,
FETCH_EVENT,
JOIN_EVENT,
LEAVE_EVENT,
} from '@/graphql/event';
import { Component, Prop, Watch } from 'vue-property-decorator';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { EventModel, EventStatus, EventVisibility, IEvent, IParticipant, ParticipantRole } from '@/types/event.model';
import { IPerson, Person } from '@/types/actor';
@@ -311,6 +322,15 @@ import 'intersection-observer';
actorId: this.currentActor.id,
};
},
subscribeToMore: {
document: EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED,
variables() {
return {
eventId: this.event.id,
actorId: this.currentActor.id,
};
},
},
update: (data) => {
if (data && data.person) return data.person.participations;
return [];
@@ -341,6 +361,7 @@ export default class Event extends EventMixin {
currentActor!: IPerson;
identity: IPerson = new Person();
participations: IParticipant[] = [];
oldParticipationRole!: String;
showMap: boolean = false;
isReportModalActive: boolean = false;
isJoinModalActive: boolean = false;
@@ -432,14 +453,10 @@ export default class Event extends EventMixin {
reporterId: this.currentActor.id,
reportedId: this.event.organizerActor.id,
content,
forward,
},
});
this.$buefy.notification.open({
message: this.$t('Event {eventTitle} reported', { eventTitle }) as string,
type: 'is-success',
position: 'is-bottom-right',
duration: 5000,
});
this.$notifier.success(this.$t('Event {eventTitle} reported', { eventTitle }) as string);
} catch (error) {
console.error(error);
}
@@ -493,12 +510,11 @@ export default class Event extends EventMixin {
},
});
if (data) {
this.$buefy.notification.open({
message: (data.joinEvent.role === ParticipantRole.NOT_APPROVED ? this.$t('Your participation has been requested') : this.$t('Your participation has been confirmed')) as string,
type: 'is-success',
position: 'is-bottom-right',
duration: 5000,
});
if (data.joinEvent.role === ParticipantRole.NOT_APPROVED) {
this.participationRequestedMessage();
} else {
this.participationConfirmedMessage();
}
}
} catch (error) {
console.error(error);
@@ -563,18 +579,55 @@ export default class Event extends EventMixin {
},
});
if (data) {
this.$buefy.notification.open({
message: this.$t('You have cancelled your participation') as string,
type: 'is-success',
position: 'is-bottom-right',
duration: 5000,
});
this.participationCancelledMessage();
}
} catch (error) {
console.error(error);
}
}
@Watch('participations')
watchParticipations() {
if (this.participations.length > 0) {
if (this.oldParticipationRole
&& this.participations[0].role !== ParticipantRole.NOT_APPROVED
&& this.oldParticipationRole !== this.participations[0].role) {
switch (this.participations[0].role) {
case ParticipantRole.PARTICIPANT:
this.participationConfirmedMessage();
break;
case ParticipantRole.REJECTED:
this.participationRejectedMessage();
break;
default:
this.participationChangedMessage();
break;
}
}
this.oldParticipationRole = this.participations[0].role;
}
}
private participationConfirmedMessage() {
this.$notifier.success(this.$t('Your participation has been confirmed') as string);
}
private participationRequestedMessage() {
this.$notifier.success(this.$t('Your participation has been requested') as string);
}
private participationRejectedMessage() {
this.$notifier.error(this.$t('Your participation has been rejected') as string);
}
private participationChangedMessage() {
this.$notifier.info(this.$t('Your participation status has been changed') as string);
}
private participationCancelledMessage() {
this.$notifier.success(this.$t('You have cancelled your participation') as string);
}
async downloadIcsEvent() {
const data = await (await fetch(`${GRAPHQL_API_ENDPOINT}/events/${this.uuid}/export/ics`)).text();
const blob = new Blob([data], { type: 'text/calendar' });

View File

@@ -64,7 +64,7 @@ export default class ReportList extends Vue {
RouteName = RouteName;
}
</script>
<style lang="scss">
<style lang="scss" scoped>
.container li {
margin: 10px auto;
}

View File

@@ -27,7 +27,10 @@
</tr>
<tr>
<td>{{ $t('Reported by') }}</td>
<td>
<td v-if="report.reporter.type === ActorType.APPLICATION">
{{ report.reporter.domain }}
</td>
<td v-else>
<router-link :to="{ name: RouteName.PROFILE, params: { name: report.reporter.preferredUsername } }">
<img v-if="report.reporter.avatar" class="image" :src="report.reporter.avatar.url" /> @{{ report.reporter.preferredUsername }}
</router-link>
@@ -55,15 +58,15 @@
<td>
<router-link :to="{ name: RouteName.EVENT, params: { uuid: report.event.uuid }}">{{ report.event.title }}</router-link>
<span class="is-pulled-right">
<b-button
tag="router-link"
type="is-primary"
:to="{ name: RouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"
icon-left="pencil"
size="is-small">{{ $t('Edit') }}</b-button>
<!-- <b-button-->
<!-- tag="router-link"-->
<!-- type="is-primary"-->
<!-- :to="{ name: RouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"-->
<!-- icon-left="pencil"-->
<!-- size="is-small">{{ $t('Edit') }}</b-button>-->
<b-button
type="is-danger"
@click="confirmDelete()"
@click="confirmEventDelete()"
icon-left="delete"
size="is-small">{{ $t('Delete') }}</b-button>
</span>
@@ -74,24 +77,24 @@
</div>
<div class="box report-content">
<p v-if="report.content" v-html="nl2br(report.content)"></p>
<p v-if="report.content" v-html="nl2br(report.content)" />
<p v-else>{{ $t('No comment') }}</p>
</div>
<div class="box" v-if="report.event && report.comments.length === 0">
<router-link :to="{ name: RouteName.EVENT, params: { uuid: report.event.uuid }}">
<h3 class="title">{{ report.event.title }}</h3>
<p v-html="report.event.description"></p>
<p v-html="report.event.description" />
</router-link>
<b-button
tag="router-link"
type="is-primary"
:to="{ name: RouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"
icon-left="pencil"
size="is-small">{{ $t('Edit') }}</b-button>
<!-- <b-button-->
<!-- tag="router-link"-->
<!-- type="is-primary"-->
<!-- :to="{ name: RouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"-->
<!-- icon-left="pencil"-->
<!-- size="is-small">{{ $t('Edit') }}</b-button>-->
<b-button
type="is-danger"
@click="confirmDelete()"
@click="confirmEventDelete()"
icon-left="delete"
size="is-small">{{ $t('Delete') }}</b-button>
</div>
@@ -101,17 +104,25 @@
<div class="box" v-if="comment">
<article class="media">
<div class="media-left">
<figure class="image is-48x48" v-if="comment.actor.avatar">
<figure class="image is-48x48" v-if="comment.actor && comment.actor.avatar">
<img :src="comment.actor.avatar.url" alt="Image">
</figure>
<b-icon class="media-left" v-else size="is-large" icon="account-circle" />
</div>
<div class="media-content">
<div class="content">
<strong>{{ comment.actor.name }}</strong> <small>@{{ comment.actor.preferredUsername }}</small>
<span v-if="comment.actor">
<strong>{{ comment.actor.name }}</strong> <small>@{{ comment.actor.preferredUsername }}</small>
</span>
<span v-else>{{ $t('Unknown actor') }}</span>
<br>
<p v-html="comment.text"></p>
<p v-html="comment.text" />
</div>
<b-button
type="is-danger"
@click="confirmCommentDelete(comment)"
icon-left="delete"
size="is-small">{{ $t('Delete') }}</b-button>
</div>
</article>
</div>
@@ -131,21 +142,23 @@
<b-field :label="$t('New note')">
<b-input type="textarea" v-model="noteContent"></b-input>
</b-field>
<b-button type="submit" @click="addNote">{{ $t('Ajouter une note') }}</b-button>
<b-button type="submit" @click="addNote">{{ $t('Add a note') }}</b-button>
</form>
</div>
</section>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { CREATE_REPORT_NOTE, REPORT, REPORTS, UPDATE_REPORT } from '@/graphql/report';
import { CREATE_REPORT_NOTE, REPORT, UPDATE_REPORT } from '@/graphql/report';
import { IReport, IReportNote, ReportStatusEnum } from '@/types/report.model';
import { RouteName } from '@/router';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { IPerson } from '@/types/actor';
import { IPerson, ActorType } from '@/types/actor';
import { DELETE_EVENT } from '@/graphql/event';
import { uniq } from 'lodash';
import { nl2br } from '@/utils/html';
import { DELETE_COMMENT } from '@/graphql/comment';
import { IComment } from '@/types/comment.model';
@Component({
apollo: {
@@ -164,6 +177,12 @@ import { nl2br } from '@/utils/html';
query: CURRENT_ACTOR_CLIENT,
},
},
metaInfo() {
return {
title: this.$t('Report') as string,
titleTemplate: '%s | Mobilizon',
};
},
})
export default class Report extends Vue {
@Prop({ required: true }) reportId!: number;
@@ -173,6 +192,7 @@ export default class Report extends Vue {
ReportStatusEnum = ReportStatusEnum;
RouteName = RouteName;
ActorType = ActorType;
nl2br = nl2br;
noteContent: string = '';
@@ -210,7 +230,7 @@ export default class Report extends Vue {
}
}
confirmDelete() {
confirmEventDelete() {
this.$buefy.dialog.confirm({
title: this.$t('Deleting event') as string,
message: this.$t('Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.') as string,
@@ -221,6 +241,17 @@ export default class Report extends Vue {
});
}
confirmCommentDelete(comment: IComment) {
this.$buefy.dialog.confirm({
title: this.$t('Deleting comment') as string,
message: this.$t('Are you sure you want to <b>delete</b> this comment? This action cannot be undone.') as string,
confirmText: this.$t('Delete Comment') as string,
type: 'is-danger',
hasIcon: true,
onConfirm: () => this.deleteComment(comment),
});
}
async deleteEvent() {
if (!this.report.event || !this.report.event.id) return;
const eventTitle = this.report.event.title;
@@ -245,6 +276,21 @@ export default class Report extends Vue {
}
}
async deleteComment(comment: IComment) {
try {
await this.$apollo.mutate({
mutation: DELETE_COMMENT,
variables: {
commentId: comment.id,
actorId: this.currentActor.id,
},
});
this.$notifier.success(this.$t('Comment deleted') as string);
} catch (error) {
console.error(error);
}
}
async updateReport(status: ReportStatusEnum) {
try {
await this.$apollo.mutate({
@@ -289,10 +335,6 @@ export default class Report extends Vue {
<style lang="scss" scoped>
@import "@/variables.scss";
.container li {
margin: 10px auto;
}
tbody td img.image, .note img.image {
display: inline;
height: 1.5em;

View File

@@ -9,15 +9,15 @@
<b-field>
<b-radio-button v-model="filterReports"
:native-value="ReportStatusEnum.OPEN">
Ouvert
{{ $t('Open') }}
</b-radio-button>
<b-radio-button v-model="filterReports"
:native-value="ReportStatusEnum.RESOLVED">
Résolus
{{ $t('Resolved') }}
</b-radio-button>
<b-radio-button v-model="filterReports"
:native-value="ReportStatusEnum.CLOSED">
Fermés
{{ $t('Closed') }}
</b-radio-button>
</b-field>
<ul v-if="reports.length > 0">
@@ -28,9 +28,9 @@
</li>
</ul>
<div v-else>
<b-message v-if="filterReports === ReportStatusEnum.OPEN" type="is-info">No open reports yet</b-message>
<b-message v-if="filterReports === ReportStatusEnum.RESOLVED" type="is-info">No resolved reports yet</b-message>
<b-message v-if="filterReports === ReportStatusEnum.CLOSED" type="is-info">No closed reports yet</b-message>
<b-message v-if="filterReports === ReportStatusEnum.OPEN" type="is-info">{{ $t('No open reports yet') }}</b-message>
<b-message v-if="filterReports === ReportStatusEnum.RESOLVED" type="is-info">{{ $t('No resolved reports yet') }}</b-message>
<b-message v-if="filterReports === ReportStatusEnum.CLOSED" type="is-info">{{ $t('No closed reports yet') }}</b-message>
</div>
</section>
</template>
@@ -80,8 +80,3 @@ export default class ReportList extends Vue {
}
}
</script>
<style lang="scss">
.container li {
margin: 10px auto;
}
</style>

View File

@@ -1,10 +1,10 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { ApolloLink, Observable } from 'apollo-link';
import { ApolloLink, Observable, split } from 'apollo-link';
import { defaultDataIdFromObject, InMemoryCache, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import { onError } from 'apollo-link-error';
import { createLink } from 'apollo-absinthe-upload-link';
import { GRAPHQL_API_ENDPOINT, GRAPHQL_API_FULL_PATH } from './api/_entrypoint';
import { GRAPHQL_API_ENDPOINT, GRAPHQL_API_FULL_PATH, MOBILIZON_INSTANCE_HOST } from './api/_entrypoint';
import { ApolloClient } from 'apollo-client';
import { buildCurrentUserResolver } from '@/apollo/user';
import { isServerError } from '@/types/apollo';
@@ -13,13 +13,18 @@ import { AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN } from '@/constants';
import { logout, saveTokenData } from '@/utils/auth';
import { SnackbarProgrammatic as Snackbar } from 'buefy';
import { defaultError, errors, IError, refreshSuggestion } from '@/utils/errors';
import { Socket as PhoenixSocket } from 'phoenix';
import * as AbsintheSocket from '@absinthe/socket';
import { createAbsintheSocketLink } from '@absinthe/socket-apollo-link';
import { getMainDefinition } from 'apollo-utilities';
// Install the vue plugin
Vue.use(VueApollo);
// Http endpoint
// Endpoints
const httpServer = GRAPHQL_API_ENDPOINT || 'http://localhost:4000';
const httpEndpoint = GRAPHQL_API_FULL_PATH || `${httpServer}/api`;
const wsEndpoint = `ws${httpServer.substring(httpServer.indexOf(':'))}/graphql_socket`;
const fragmentMatcher = new IntrospectionFragmentMatcher({
introspectionQueryResultData: {
@@ -60,10 +65,6 @@ const authMiddleware = new ApolloLink((operation, forward) => {
return null;
});
const uploadLink = createLink({
uri: httpEndpoint,
});
let refreshingTokenPromise: Promise<boolean> | undefined;
let alreadyRefreshedToken = false;
const errorLink = onError(({ graphQLErrors, networkError, forward, operation }) => {
@@ -126,9 +127,38 @@ const computeErrorMessage = (message) => {
return error.suggestRefresh === false ? error.value : `${error.value}<br>${refreshSuggestion}`;
};
const link = authMiddleware
const uploadLink = createLink({
uri: httpEndpoint,
});
const phoenixSocket = new PhoenixSocket(wsEndpoint, {
params: () => {
const token = localStorage.getItem(AUTH_ACCESS_TOKEN);
if (token) {
return { token };
}
return {};
},
});
const absintheSocket = AbsintheSocket.create(phoenixSocket);
const wsLink = createAbsintheSocketLink(absintheSocket);
const link = split(
// split based on operation type
({ query }) => {
const definition = getMainDefinition(query);
return definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription';
},
wsLink,
uploadLink,
);
const fullLink = authMiddleware
.concat(errorLink)
.concat(uploadLink);
.concat(link);
const cache = new InMemoryCache({
fragmentMatcher,
@@ -143,7 +173,7 @@ const cache = new InMemoryCache({
const apolloClient = new ApolloClient({
cache,
link,
link: fullLink,
connectToDevTools: true,
resolvers: buildCurrentUserResolver(cache),
});