Merge branch 'feature/comments' into 'master'

Comments

See merge request framasoft/mobilizon!335
This commit is contained in:
Thomas Citharel
2019-11-28 12:49:43 +01:00
71 changed files with 2642 additions and 879 deletions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -0,0 +1,351 @@
<template>
<li :class="{ reply: comment.inReplyToComment }">
<article class="media" :class="{ selected: commentSelected, organizer: commentFromOrganizer }" :id="commentId">
<figure class="media-left" v-if="!comment.deletedAt && comment.actor.avatar">
<p class="image is-48x48">
<img :src="comment.actor.avatar.url" alt="">
</p>
</figure>
<b-icon class="media-left" v-else size="is-large" icon="account-circle" />
<div class="media-content">
<div class="content">
<span class="first-line" v-if="!comment.deletedAt">
<strong>{{ comment.actor.name }}</strong>
<small>@{{ comment.actor.preferredUsername }}</small>
<a class="comment-link has-text-grey" :href="commentId">
<small>{{ timeago(new Date(comment.updatedAt)) }}</small>
</a>
</span>
<a v-else class="comment-link has-text-grey" :href="commentId">
<span>{{ $t('[deleted]') }}</span>
</a>
<span class="icons" v-if="!comment.deletedAt">
<span v-if="comment.actor.id === currentActor.id"
@click="$emit('delete-comment', comment)">
<b-icon
icon="delete"
size="is-small"
/>
</span>
<span @click="reportModal()">
<b-icon
icon="alert"
size="is-small"
/>
</span>
</span>
<br>
<div v-if="!comment.deletedAt" v-html="comment.text" />
<div v-else>{{ $t('[This comment has been deleted]') }}</div>
<span class="load-replies" v-if="comment.totalReplies">
<span v-if="!showReplies" @click="fetchReplies">
{{ $tc('View a reply', comment.totalReplies, { totalReplies: comment.totalReplies }) }}
</span>
<span v-else-if="comment.totalReplies && showReplies" @click="showReplies = false">
{{ $t('Hide replies') }}
</span>
</span>
</div>
<nav class="reply-action level is-mobile" v-if="currentActor.id && event.options.commentModeration !== CommentModeration.CLOSED">
<div class="level-left">
<span style="cursor: pointer" class="level-item" @click="createReplyToComment(comment)">
<span class="icon is-small">
<b-icon icon="reply" />
</span>
{{ $t('Reply') }}
</span>
</div>
</nav>
</div>
</article>
<form class="reply" @submit.prevent="replyToComment" v-if="currentActor.id" v-show="replyTo">
<article class="media reply">
<figure class="media-left" v-if="currentActor.avatar">
<p class="image is-48x48">
<img :src="currentActor.avatar.url" alt="">
</p>
</figure>
<b-icon class="media-left" v-else size="is-large" icon="account-circle" />
<div class="media-content">
<div class="content">
<span class="first-line">
<strong>{{ currentActor.name}}</strong>
<small>@{{ currentActor.preferredUsername }}</small>
</span>
<br>
<span class="editor-line">
<editor class="editor" ref="commenteditor" v-model="newComment.text" mode="comment" />
<b-button :disabled="newComment.text.trim().length === 0" native-type="submit" type="is-info">{{ $t('Post a reply') }}</b-button>
</span>
</div>
</div>
</article>
</form>
<transition-group name="comment-replies" v-if="showReplies" class="comment-replies" tag="ul">
<comment
class="reply"
v-for="reply in comment.replies"
:key="reply.id"
:comment="reply"
:event="event"
@create-comment="$emit('create-comment', $event)"
@delete-comment="$emit('delete-comment', $event)" />
</transition-group>
</li>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { CommentModel, IComment } from '@/types/comment.model';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { IPerson } from '@/types/actor';
import { Refs } from '@/shims-vue';
import EditorComponent from '@/components/Editor.vue';
import TimeAgo from 'javascript-time-ago';
import { COMMENTS_THREADS, FETCH_THREAD_REPLIES } from '@/graphql/comment';
import { IEvent, CommentModeration } from '@/types/event.model';
import ReportModal from '@/components/Report/ReportModal.vue';
import { IReport } from '@/types/report.model';
import { CREATE_REPORT } from '@/graphql/report';
@Component({
apollo: {
currentActor: {
query: CURRENT_ACTOR_CLIENT,
},
},
components: {
editor: () => import(/* webpackChunkName: "editor" */ '@/components/Editor.vue'),
Comment,
},
})
export default class Comment extends Vue {
@Prop({ required: true, type: Object }) comment!: IComment;
@Prop({ required: true, type: Object }) event!: IEvent;
$refs!: Refs<{
commenteditor: EditorComponent,
}>;
currentActor!: IPerson;
newComment: IComment = new CommentModel();
replyTo: boolean = false;
showReplies: boolean = false;
timeAgoInstance = null;
CommentModeration = CommentModeration;
async mounted() {
const localeName = this.$i18n.locale;
const locale = await import(`javascript-time-ago/locale/${localeName}`);
TimeAgo.addLocale(locale);
this.timeAgoInstance = new TimeAgo(localeName);
const hash = this.$route.hash;
if (hash.includes(`#comment-${this.comment.uuid}`)) {
this.fetchReplies();
}
}
async createReplyToComment(comment: IComment) {
if (this.replyTo) {
this.replyTo = false;
this.newComment = new CommentModel();
return;
}
this.replyTo = true;
// this.newComment.inReplyToComment = comment;
await this.$nextTick();
await this.$nextTick(); // For some reason commenteditor needs two $nextTick() to fully render
const commentEditor = this.$refs.commenteditor;
commentEditor.replyToComment(comment);
}
replyToComment() {
this.newComment.inReplyToComment = this.comment;
this.newComment.originComment = this.comment.originComment || this.comment;
this.newComment.actor = this.currentActor;
this.$emit('create-comment', this.newComment);
this.newComment = new CommentModel();
this.replyTo = false;
}
async fetchReplies() {
const parentId = this.comment.id;
const { data } = await this.$apollo.query<{ thread: IComment[] }>({
query: FETCH_THREAD_REPLIES,
variables: {
threadId: parentId,
},
});
if (!data) return;
const { thread } = data;
const eventData = this.$apollo.getClient().readQuery<{ event: IEvent }>({
query: COMMENTS_THREADS,
variables: {
eventUUID: this.event.uuid,
},
});
if (!eventData) return;
const { event } = eventData;
const { comments } = event;
const parentCommentIndex = comments.findIndex(oldComment => oldComment.id === parentId);
const parentComment = comments[parentCommentIndex];
if (!parentComment) return;
parentComment.replies = thread;
comments[parentCommentIndex] = parentComment;
event.comments = comments;
this.$apollo.getClient().writeQuery({
query: COMMENTS_THREADS,
data: { event },
});
this.showReplies = true;
}
timeago(dateTime): String {
if (this.timeAgoInstance != null) {
// @ts-ignore
return this.timeAgoInstance.format(dateTime);
}
return '';
}
get commentSelected(): boolean {
return this.commentId === this.$route.hash;
}
get commentFromOrganizer(): boolean {
return this.event.organizerActor !== undefined && this.comment.actor.id === this.event.organizerActor.id;
}
get commentId(): String {
if (this.comment.originComment) return `#comment-${this.comment.originComment.uuid}:${this.comment.uuid}`;
return `#comment-${this.comment.uuid}`;
}
reportModal() {
console.log('report modal');
this.$buefy.modal.open({
parent: this,
component: ReportModal,
props: {
title: this.$t('Report this comment'),
comment: this.comment,
onConfirm: this.reportComment,
},
});
}
async reportComment(content: String, forward: boolean) {
try {
await this.$apollo.mutate<IReport>({
mutation: CREATE_REPORT,
variables: {
eventId: this.event.id,
reporterId: this.currentActor.id,
reportedId: this.comment.actor.id,
commentsIds: [this.comment.id],
content,
},
});
this.$buefy.notification.open({
message: this.$t('Comment from @{username} reported', { username: this.comment.actor.preferredUsername }) as string,
type: 'is-success',
position: 'is-bottom-right',
duration: 5000,
});
} catch (error) {
console.error(error);
}
}
}
</script>
<style lang="scss" scoped>
@import "@/variables.scss";
.first-line {
* {
padding: 0 5px 0 0;
}
}
.editor-line {
display: flex;
max-width: calc(80rem - 64px);
.editor {
flex: 1;
padding-right: 10px;
margin-bottom: 0;
}
}
.comment-link small:hover {
color: hsl(0, 0%, 21%);
}
.root-comment .comment-replies > .reply {
padding-left: 3rem;
}
.media .media-content {
.content .editor-line {
display: flex;
align-items: center;
}
.icons {
display: none;
}
}
.media:hover .media-content .icons {
display: inline;
cursor: pointer;
}
.load-replies {
cursor: pointer;
}
article {
border-radius: 4px;
&.selected {
background-color: lighten($secondary, 30%);
}
&.organizer:not(.selected) {
background-color: lighten($primary, 50%);
}
}
.comment-replies-enter-active,
.comment-replies-leave-active,
.comment-replies-move {
transition: 500ms cubic-bezier(0.59, 0.12, 0.34, 0.95);
transition-property: opacity, transform;
}
.comment-replies-enter {
opacity: 0;
transform: translateX(50px) scaleY(0.5);
}
.comment-replies-enter-to {
opacity: 1;
transform: translateX(0) scaleY(1);
}
.comment-replies-leave-active {
position: absolute;
}
.comment-replies-leave-to {
opacity: 0;
transform: scaleY(0);
transform-origin: center top;
}
.reply-action .icon {
padding-right: 0.4rem;
}
</style>

View File

@@ -0,0 +1,328 @@
<template>
<div class="columns">
<div class="column is-two-thirds-desktop">
<form class="new-comment" v-if="currentActor.id && event.options.commentModeration !== CommentModeration.CLOSED" @submit.prevent="createCommentForEvent(newComment)" @keyup.ctrl.enter="createCommentForEvent(newComment)">
<article class="media">
<figure class="media-left">
<identity-picker-wrapper :inline="false" v-model="newComment.actor" />
</figure>
<div class="media-content">
<div class="field">
<p class="control">
<editor ref="commenteditor" mode="comment" v-model="newComment.text" />
</p>
</div>
<div class="send-comment">
<b-button native-type="submit" type="is-info">{{ $t('Post a comment') }}</b-button>
</div>
</div>
</article>
</form>
<b-notification v-else-if="event.options.commentModeration === CommentModeration.CLOSED" :closable="false">
{{ $t('Comments have been closed.') }}
</b-notification>
<transition name="comment-empty-list" mode="out-in">
<transition-group name="comment-list" v-if="comments.length" class="comment-list" tag="ul">
<comment
class="root-comment"
:comment="comment"
:event="event"
v-for="comment in orderedComments"
v-if="!comment.deletedAt || comment.totalReplies > 0"
:key="comment.id"
@create-comment="createCommentForEvent"
@delete-comment="deleteComment"
/>
</transition-group>
<div v-else class="no-comments">
<span>{{ $t('No comments yet') }}</span>
<img src="../../assets/undraw_just_saying.svg" alt="" />
</div>
</transition>
</div>
</div>
</template>
<script lang="ts">
import { Prop, Vue, Component, Watch } from 'vue-property-decorator';
import { CommentModel, IComment } from '@/types/comment.model';
import {
CREATE_COMMENT_FROM_EVENT,
DELETE_COMMENT, COMMENTS_THREADS, FETCH_THREAD_REPLIES,
} from '@/graphql/comment';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { IPerson } from '@/types/actor';
import Comment from '@/components/Comment/Comment.vue';
import { IEvent, CommentModeration } from '@/types/event.model';
import IdentityPickerWrapper from '@/views/Account/IdentityPickerWrapper.vue';
@Component({
apollo: {
currentActor: {
query: CURRENT_ACTOR_CLIENT,
},
comments: {
query: COMMENTS_THREADS,
variables() {
return {
eventUUID: this.event.uuid,
};
},
update(data) {
return data.event.comments.map((comment) => new CommentModel(comment));
},
skip() {
return !this.event.uuid;
},
},
},
components: {
Comment,
IdentityPickerWrapper,
editor: () => import(/* webpackChunkName: "editor" */ '@/components/Editor.vue'),
},
})
export default class CommentTree extends Vue {
@Prop({ required: false, type: Object }) event!: IEvent;
newComment: IComment = new CommentModel();
currentActor!: IPerson;
comments: IComment[] = [];
CommentModeration = CommentModeration;
@Watch('currentActor')
watchCurrentActor(currentActor: IPerson) {
this.newComment.actor = currentActor;
}
async createCommentForEvent(comment: IComment) {
try {
await this.$apollo.mutate({
mutation: CREATE_COMMENT_FROM_EVENT,
variables: {
eventId: this.event.id,
actorId: comment.actor.id,
text: comment.text,
inReplyToCommentId: comment.inReplyToComment ? comment.inReplyToComment.id : null,
},
update: (store, { data }) => {
if (data == null) return;
const newComment = data.createComment;
// comments are attached to the event, so we can pass it to replies later
newComment.event = this.event;
// we load all existing threads
const commentThreadsData = store.readQuery<{ event: IEvent }>({
query: COMMENTS_THREADS,
variables: {
eventUUID: this.event.uuid,
},
});
if (!commentThreadsData) return;
const { event } = commentThreadsData;
const { comments: oldComments } = event;
// if it's no a root comment, we first need to find existing replies and add the new reply to it
if (comment.originComment) {
// @ts-ignore
const parentCommentIndex = oldComments.findIndex(oldComment => oldComment.id === comment.originComment.id);
const parentComment = oldComments[parentCommentIndex];
let oldReplyList: IComment[] = [];
try {
const threadData = store.readQuery<{ thread: IComment[] }>({
query: FETCH_THREAD_REPLIES,
variables: {
threadId: parentComment.id,
},
});
if (!threadData) return;
oldReplyList = threadData.thread;
} catch (e) {
// This simply means there's no loaded replies yet
} finally {
oldReplyList.push(newComment);
// save the updated list of replies (with the one we've just added)
store.writeQuery({
query: FETCH_THREAD_REPLIES,
data: { thread: oldReplyList },
variables: {
threadId: parentComment.id,
},
});
// replace the root comment with has the updated list of replies in the thread list
parentComment.replies = oldReplyList;
event.comments.splice(parentCommentIndex, 1, parentComment);
}
} else {
// otherwise it's simply a new thread and we add it to the list
oldComments.push(newComment);
}
// finally we save the thread list
event.comments = oldComments;
store.writeQuery({
query: COMMENTS_THREADS,
data: { event },
variables: {
eventUUID: this.event.uuid,
},
});
},
});
// and reset the new comment field
this.newComment = new CommentModel();
} catch (e) {
console.error(e);
}
}
async deleteComment(comment: IComment) {
await this.$apollo.mutate({
mutation: DELETE_COMMENT,
variables: {
commentId: comment.id,
actorId: this.currentActor.id,
},
update: (store, { data }) => {
if (data == null) return;
const deletedCommentId = data.deleteComment.id;
const commentsData = store.readQuery<{ event: IEvent }>({
query: COMMENTS_THREADS,
variables: {
eventUUID: this.event.uuid,
},
});
if (!commentsData) return;
const { event } = commentsData;
const { comments: oldComments } = event;
if (comment.originComment) {
// we have deleted a reply to a thread
const data = store.readQuery<{ thread: IComment[] }>({
query: FETCH_THREAD_REPLIES,
variables: {
threadId: comment.originComment.id,
},
});
if (!data) return;
const { thread: oldReplyList } = data;
const replies = oldReplyList.filter(reply => reply.id !== deletedCommentId);
store.writeQuery({
query: FETCH_THREAD_REPLIES,
variables: {
threadId: comment.originComment.id,
},
data: { thread: replies },
});
// @ts-ignore
const parentCommentIndex = oldComments.findIndex(oldComment => oldComment.id === comment.originComment.id);
const parentComment = oldComments[parentCommentIndex];
parentComment.replies = replies;
parentComment.totalReplies -= 1;
oldComments.splice(parentCommentIndex, 1, parentComment);
event.comments = oldComments;
} else {
// we have deleted a thread itself
event.comments = oldComments.filter(reply => reply.id !== deletedCommentId);
}
store.writeQuery({
query: COMMENTS_THREADS,
variables: {
eventUUID: this.event.uuid,
},
data: { event },
});
},
});
// this.comments = this.comments.filter(commentItem => commentItem.id !== comment.id);
}
get orderedComments(): IComment[] {
return this.comments.filter((comment => comment.inReplyToComment == null)).sort((a, b) => {
if (a.updatedAt && b.updatedAt) {
return (new Date(b.updatedAt)).getTime() - (new Date(a.updatedAt)).getTime();
}
return 0;
});
}
}
</script>
<style lang="scss" scoped>
.new-comment {
.media-content {
display: flex;
align-items: center;
align-content: center;
.field {
flex: 1;
padding-right: 10px;
margin-bottom: 0;
}
}
}
.no-comments {
display: flex;
flex-direction: column;
span {
text-align: center;
margin-bottom: 10px;
}
img {
max-width: 250px;
align-self: center;
}
}
ul.comment-list li {
margin-bottom: 16px;
}
.comment-list-enter-active,
.comment-list-leave-active,
.comment-list-move {
transition: 500ms cubic-bezier(0.59, 0.12, 0.34, 0.95);
transition-property: opacity, transform;
}
.comment-list-enter {
opacity: 0;
transform: translateX(50px) scaleY(0.5);
}
.comment-list-enter-to {
opacity: 1;
transform: translateX(0) scaleY(1);
}
.comment-list-leave-active,
.comment-empty-list-active {
position: absolute;
}
.comment-list-leave-to,
.comment-empty-list-leave-to {
opacity: 0;
transform: scaleY(0);
transform-origin: center top;
}
/*.comment-empty-list-enter-active {*/
/* transition: opacity .5s;*/
/*}*/
/*.comment-empty-list-enter {*/
/* opacity: 0;*/
/*}*/
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div v-if="editor">
<div class="editor" id="tiptab-editor" :data-actor-id="currentActor && currentActor.id">
<editor-menu-bar :editor="editor" v-slot="{ commands, isActive, focused }">
<div class="editor" :class="{ mode_description: isDescriptionMode }" id="tiptab-editor" :data-actor-id="currentActor && currentActor.id">
<editor-menu-bar v-if="isDescriptionMode" :editor="editor" v-slot="{ commands, isActive, focused }">
<div class="menubar bar-is-hidden" :class="{ 'is-focused': focused }">
<button
@@ -121,6 +121,33 @@
</div>
</editor-menu-bar>
<editor-menu-bubble v-if="isCommentMode" :editor="editor" :keep-in-bounds="true" v-slot="{ commands, isActive, menu }">
<div
class="menububble"
:class="{ 'is-active': menu.isActive }"
:style="`left: ${menu.left}px; bottom: ${menu.bottom}px;`"
>
<button
class="menububble__button"
:class="{ 'is-active': isActive.bold() }"
@click="commands.bold"
type="button"
>
<b-icon icon="format-bold" />
</button>
<button
class="menububble__button"
:class="{ 'is-active': isActive.italic() }"
@click="commands.italic"
type="button"
>
<b-icon icon="format-italic" />
</button>
</div>
</editor-menu-bubble>
<editor-content class="editor__content" :editor="editor" />
</div>
<div class="suggestion-list" v-show="showSuggestions" ref="suggestions">
@@ -129,14 +156,14 @@
v-for="(actor, index) in filteredActors"
:key="actor.id"
class="suggestion-list__item"
:class="{ 'is-selected': navigatedUserIndex === index }"
:class="{ 'is-selected': navigatedActorIndex === index }"
@click="selectActor(actor)"
>
{{ actor.name }}
</div>
</template>
<div v-else class="suggestion-list__item is-empty">
No actors found
{{ $t('No actors found') }}
</div>
</div>
</div>
@@ -165,11 +192,12 @@ import {
} from 'tiptap-extensions';
import tippy, { Instance } from 'tippy.js';
import { SEARCH_PERSONS } from '@/graphql/search';
import { IActor, IPerson } from '@/types/actor';
import { Actor, IActor, IPerson } from '@/types/actor';
import Image from '@/components/Editor/Image';
import { UPLOAD_PICTURE } from '@/graphql/upload';
import { listenFileUpload } from '@/utils/upload';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { IComment } from '@/types/comment.model';
@Component({
components: { EditorContent, EditorMenuBar, EditorMenuBubble },
@@ -181,6 +209,7 @@ import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
})
export default class EditorComponent extends Vue {
@Prop({ required: true }) value!: string;
@Prop({ required: false, default: 'description' }) mode!: string;
currentActor!: IPerson;
@@ -192,9 +221,17 @@ export default class EditorComponent extends Vue {
query!: string|null;
filteredActors: IActor[] = [];
suggestionRange!: object|null;
navigatedUserIndex: number = 0;
navigatedActorIndex: number = 0;
popup!: Instance|null;
get isDescriptionMode() {
return this.mode === 'description';
}
get isCommentMode() {
return this.mode === 'comment';
}
get hasResults() {
return this.filteredActors.length;
}
@@ -232,7 +269,7 @@ export default class EditorComponent extends Vue {
this.query = query;
this.filteredActors = items;
this.suggestionRange = range;
this.navigatedUserIndex = 0;
this.navigatedActorIndex = 0;
this.renderPopup(virtualNode);
},
@@ -244,7 +281,7 @@ export default class EditorComponent extends Vue {
this.query = null;
this.filteredActors = [];
this.suggestionRange = null;
this.navigatedUserIndex = 0;
this.navigatedActorIndex = 0;
this.destroyPopup();
},
@@ -335,7 +372,7 @@ export default class EditorComponent extends Vue {
}
upHandler() {
this.navigatedUserIndex = ((this.navigatedUserIndex + this.filteredActors.length) - 1) % this.filteredActors.length;
this.navigatedActorIndex = ((this.navigatedActorIndex + this.filteredActors.length) - 1) % this.filteredActors.length;
}
/**
@@ -343,11 +380,11 @@ export default class EditorComponent extends Vue {
* if it's the last item, navigate to the first one
*/
downHandler() {
this.navigatedUserIndex = (this.navigatedUserIndex + 1) % this.filteredActors.length;
this.navigatedActorIndex = (this.navigatedActorIndex + 1) % this.filteredActors.length;
}
enterHandler() {
const actor = this.filteredActors[this.navigatedUserIndex];
const actor = this.filteredActors[this.navigatedActorIndex];
if (actor) {
this.selectActor(actor);
}
@@ -359,17 +396,26 @@ export default class EditorComponent extends Vue {
* @param actor IActor
*/
selectActor(actor: IActor) {
const actorModel = new Actor(actor);
this.insertMention({
range: this.suggestionRange,
attrs: {
id: actor.id,
label: actor.name,
id: actorModel.id,
label: actorModel.usernameWithDomain().substring(1), // usernameWithDomain returns with a @ prefix and tiptap adds one itself
},
});
if (!this.editor) return;
this.editor.focus();
}
replyToComment(comment: IComment) {
console.log('called replyToComment', comment);
const actorModel = new Actor(comment.actor);
if (!this.editor) return;
this.editor.commands.mention({ id: actorModel.id, label: actorModel.usernameWithDomain().substring(1) });
this.editor.focus();
}
/**
* renders a popup with suggestions
* tiptap provides a virtualNode object for using popper.js (or tippy.js) for popups
@@ -443,6 +489,8 @@ export default class EditorComponent extends Vue {
}
</script>
<style lang="scss">
@import "@/variables.scss";
$color-black: #000;
$color-white: #eee;
@@ -474,7 +522,6 @@ export default class EditorComponent extends Vue {
.editor {
position: relative;
margin: 0 0 1rem;
p.is-empty:first-child::before {
content: attr(data-empty-text);
@@ -485,18 +532,25 @@ export default class EditorComponent extends Vue {
font-style: italic;
}
&__content {
&.mode_description {
div.ProseMirror {
min-height: 10rem;
}
}
&__content {
div.ProseMirror {
min-height: 2.5rem;
box-shadow: inset 0 1px 2px rgba(10, 10, 10, 0.1);
background-color: white;
border-radius: 4px;
color: #363636;
border: 1px solid #dbdbdb;
padding: 12px 6px;
&:focus {
border-color: $primary;
outline: none;
}
}
@@ -607,7 +661,7 @@ export default class EditorComponent extends Vue {
.mention {
background: rgba($color-black, 0.1);
color: rgba($color-black, 0.6);
font-size: 0.8rem;
font-size: 0.9rem;
font-weight: bold;
border-radius: 5px;
padding: 0.2rem 0.5rem;

View File

@@ -17,7 +17,7 @@
<label class="label">{{ label }}</label>
</div>
<div class="field-body">
<div class="field is-narrow is-grouped">
<div class="field is-narrow is-grouped calendar-picker">
<b-datepicker
:day-names="localeShortWeekDayNamesProxy"
:month-names="localeMonthNamesProxy"
@@ -108,4 +108,10 @@ export default class DateTimePicker extends Vue {
padding: 0;
}
}
.calendar-picker {
/deep/ .dropdown-menu {
z-index: 200;
}
}
</style>

View File

@@ -108,7 +108,7 @@ export default class EventCard extends Vue {
}
</script>
<style lang="scss">
<style lang="scss" scoped>
@import "../../variables";
a.card {

View File

@@ -9,8 +9,9 @@
<div class="media">
<div class="media-left">
<figure class="image is-48x48" v-if="report.reported.avatar">
<img :src="report.reported.avatar.url" />
<img alt="" :src="report.reported.avatar.url" />
</figure>
<b-icon v-else size="is-large" icon="account-circle" />
</div>
<div class="media-content">
<p class="title is-4">{{ report.reported.name }}</p>
@@ -19,12 +20,8 @@
</div>
<div class="content columns">
<div class="column is-one-quarter box">Reported by <img v-if="report.reporter.avatar" class="image" :src="report.reporter.avatar.url" /> @{{ report.reporter.preferredUsername }}</div>
<div class="column box" v-if="report.event">
<img class="image" v-if="report.event.picture" :src="report.event.picture.url" />
<span>{{ report.event.title }}</span>
</div>
<div class="column box" v-if="report.reportContent">{{ report.reportContent }}</div>
<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" v-if="report.content">{{ report.content }}</div>
</div>
</div>
</div>

View File

@@ -16,6 +16,23 @@
size="is-large"/>
</div>
<div class="media-content">
<div class="box" v-if="comment">
<article class="media">
<div class="media-left">
<figure class="image is-48x48" v-if="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>
<br>
<p v-html="comment.text"></p>
</div>
</div>
</article>
</div>
<p>{{ $t('The report will be sent to the moderators of your instance. You can explain why you report this content below.') }}</p>
<div class="control">
@@ -57,7 +74,7 @@
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { removeElement } from 'buefy/src/utils/helpers';
import { IComment } from '@/types/comment.model';
@Component({
mounted() {
@@ -67,6 +84,7 @@ import { removeElement } from 'buefy/src/utils/helpers';
export default class ReportModal extends Vue {
@Prop({ type: Function, default: () => {} }) onConfirm;
@Prop({ type: String }) title;
@Prop({ type: Object }) comment!: IComment;
@Prop({ type: String, default: '' }) outsideDomain;
@Prop({ type: String }) cancelText;
@Prop({ type: String }) confirmText;
@@ -97,8 +115,23 @@ export default class ReportModal extends Vue {
}
}
</script>
<style lang="scss">
<style lang="scss" scoped>
.modal-card .modal-card-foot {
justify-content: flex-end;
}
</style>
.modal-card-body {
.media-content {
.box {
.media {
padding-top: 0;
border-top: none;
}
}
& > p {
margin-bottom: 2rem;
}
}
}
</style>

View File

@@ -1,9 +1,11 @@
import { formatDateString, formatTimeString, formatDateTimeString } from './datetime';
import { nl2br } from '@/filters/utils';
export default {
install(vue) {
vue.filter('formatDateString', formatDateString);
vue.filter('formatTimeString', formatTimeString);
vue.filter('formatDateTimeString', formatDateTimeString);
vue.filter('nl2br', nl2br);
},
};

9
js/src/filters/utils.ts Normal file
View File

@@ -0,0 +1,9 @@
/**
* New Line to <br>
*
* @param {string} str Input text
* @return {string} Filtered text
*/
export function nl2br(str: String): String {
return `${str}`.replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, '$1<br>');
}

83
js/src/graphql/comment.ts Normal file
View File

@@ -0,0 +1,83 @@
import gql from 'graphql-tag';
export const COMMENT_FIELDS_FRAGMENT_NAME = 'CommentFields';
export const COMMENT_FIELDS_FRAGMENT = gql`
fragment CommentFields on Comment {
id,
uuid,
url,
text,
visibility,
actor {
avatar {
url
},
id,
preferredUsername,
name
},
totalReplies,
updatedAt,
deletedAt
},
`;
export const COMMENT_RECURSIVE_FRAGMENT = gql`
fragment CommentRecursive on Comment {
...CommentFields
inReplyToComment {
...CommentFields
},
originComment {
...CommentFields
},
replies {
...CommentFields
replies {
...CommentFields
}
},
},
${COMMENT_FIELDS_FRAGMENT}
`;
export const FETCH_THREAD_REPLIES = gql`
query($threadId: ID!) {
thread(id: $threadId) {
...CommentRecursive
}
}
${COMMENT_RECURSIVE_FRAGMENT}
`;
export const COMMENTS_THREADS = gql`
query($eventUUID: UUID!) {
event(uuid: $eventUUID) {
id,
uuid,
comments {
...CommentFields,
}
}
}
${COMMENT_RECURSIVE_FRAGMENT}
`;
export const CREATE_COMMENT_FROM_EVENT = gql`
mutation CreateCommentFromEvent($eventId: ID!, $actorId: ID!, $text: String!, $inReplyToCommentId: ID) {
createComment(eventId: $eventId, actorId: $actorId, text: $text, inReplyToCommentId: $inReplyToCommentId) {
...CommentRecursive
}
}
${COMMENT_RECURSIVE_FRAGMENT}
`;
export const DELETE_COMMENT = gql`
mutation DeleteComment($commentId: ID!, $actorId: ID!) {
deleteComment(commentId: $commentId, actorId: $actorId) {
id
}
}
`;

View File

@@ -1,4 +1,5 @@
import gql from 'graphql-tag';
import { COMMENT_FIELDS_FRAGMENT } from '@/graphql/comment';
const participantQuery = `
role,
@@ -135,6 +136,7 @@ export const FETCH_EVENT = gql`
}
}
}
${COMMENT_FIELDS_FRAGMENT}
`;
export const FETCH_EVENTS = gql`

View File

@@ -29,7 +29,8 @@ export const REPORTS = gql`
url
}
},
status
status,
content
}
}
`;
@@ -63,10 +64,23 @@ const REPORT_FRAGMENT = gql`
url
}
},
comments {
id,
text,
actor {
id,
preferredUsername,
name,
avatar {
url
}
}
}
notes {
id,
content
moderator {
id,
preferredUsername,
name,
avatar {
@@ -94,11 +108,12 @@ export const REPORT = gql`
export const CREATE_REPORT = gql`
mutation CreateReport(
$eventId: ID!,
$reporterActorId: ID!,
$reportedActorId: ID!,
$content: String
$reporterId: ID!,
$reportedId: ID!,
$content: String,
$commentsIds: [ID]
) {
createReport(eventId: $eventId, reporterActorId: $reporterActorId, reportedActorId: $reportedActorId, content: $content) {
createReport(eventId: $eventId, reporterId: $reporterId, reportedId: $reportedId, content: $content, commentsIds: $commentsIds) {
id
}
}

View File

@@ -40,7 +40,8 @@
"Click to select": "Click to select",
"Click to upload": "Click to upload",
"Close comments for all (except for admins)": "Close comments for all (except for admins)",
"Comments on the event page": "Comments on the event page",
"Comment from @{username} reported": "Comment from @{username} reported",
"Comments have been closed.": "Comments have been closed.",
"Comments": "Comments",
"Confirm my particpation": "Confirm my particpation",
"Confirmed: Will happen": "Confirmed: Will happen",
@@ -117,6 +118,7 @@
"Group {displayName} created": "Group {displayName} created",
"Groups": "Groups",
"Headline picture": "Headline picture",
"Hide replies": "Hide replies",
"I create an identity": "I create an identity",
"I participate": "I participate",
"I want to approve every participation request": "I want to approve every participation request",
@@ -155,7 +157,9 @@
"My identities": "My identities",
"Name": "Name",
"New password": "New password",
"No actors found": "No actors found",
"No address defined": "No address defined",
"No comments yet": "No comments yet",
"No end date": "No end date",
"No events found": "No events found",
"No group found": "No group found",
@@ -196,6 +200,8 @@
"Please make sure the address is correct and that the page hasn't been moved.": "Please make sure the address is correct and that the page hasn't been moved.",
"Please read the full rules": "Please read the full rules",
"Please refresh the page and retry.": "Please refresh the page and retry.",
"Post a comment": "Post a comment",
"Post a reply": "Post a reply",
"Postal Code": "Postal Code",
"Private event": "Private event",
"Private feeds": "Private feeds",
@@ -216,6 +222,7 @@
"Reject": "Reject",
"Rejected participations": "Rejected participations",
"Rejected": "Rejected",
"Report this comment": "Report this comment",
"Report this event": "Report this event",
"Report": "Report",
"Requests": "Requests",
@@ -276,6 +283,7 @@
"User accounts and every other data is currently deleted every 48 hours, so you may want to register again.": "User accounts and every other data is currently deleted every 48 hours, so you may want to register again.",
"Username": "Username",
"Users": "Users",
"View a reply": "|View one reply|View {totalReplies} replies",
"View event page": "View event page",
"View everything": "View everything",
"View page on {hostname} (in a new window)": "View page on {hostname} (in a new window)",
@@ -313,6 +321,8 @@
"Your local administrator resumed its policy:": "Your local administrator resumed its policy:",
"Your participation has been confirmed": "Your participation has been confirmed",
"Your participation has been requested": "Your participation has been requested",
"[This comment has been deleted]": "[This comment has been deleted]",
"[deleted]": "[deleted]",
"a decentralised federation protocol": "a decentralised federation protocol",
"e.g. 10 Rue Jangot": "e.g. 10 Rue Jangot",
"firstDayOfWeek": "0",
@@ -330,4 +340,4 @@
"{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"
}
}

View File

@@ -40,6 +40,8 @@
"Click to select": "Cliquez pour sélectionner",
"Click to upload": "Cliquez pour uploader",
"Close comments for all (except for admins)": "Fermer les commentaires à tout le monde (excepté les administrateurs)",
"Comment from @{username} reported": "Commentaire de @{username} signalé",
"Comments have been closed.": "Les commentaires sont fermés.",
"Comments on the event page": "Commentaires sur la page de l'événement",
"Comments": "Commentaires",
"Confirm my particpation": "Confirmer ma participation",
@@ -59,6 +61,7 @@
"Create": "Créer",
"Creator": "Créateur",
"Current identity has been changed to {identityName} in order to manage this event.": "L'identité actuelle a été changée à {identityName} pour pouvoir gérer cet événement.",
"Dashboard": "Tableau de bord",
"Date and time settings": "Paramètres de date et d'heure",
"Date parameters": "Paramètres de date",
"Delete event": "Supprimer un événement",
@@ -117,6 +120,7 @@
"Group {displayName} created": "Groupe {displayName} créé",
"Groups": "Groupes",
"Headline picture": "Image à la une",
"Hide replies": "Masquer les réponses",
"I create an identity": "Je crée une identité",
"I participate": "Je participe",
"I want to approve every participation request": "Je veux approuver chaque demande de participation",
@@ -155,7 +159,9 @@
"My identities": "Mes identités",
"Name": "Nom",
"New password": "Nouveau mot de passe",
"No actors found": "Aucun acteur trouvé",
"No address defined": "Aucune adresse définie",
"No comments yet": "Pas encore de commentaires",
"No end date": "Pas de date de fin",
"No events found": "Aucun événement trouvé",
"No group found": "Aucun groupe trouvé",
@@ -197,6 +203,8 @@
"Please read the full rules": "Merci de lire les règles complètes",
"Please refresh the page and retry.": "Merci de rafraîchir la page puis réessayer.",
"Please type at least 5 characters": "Merci d'entrer au moins 5 caractères",
"Post a comment": "Ajouter un commentaire",
"Post a reply": "Envoyer une réponse",
"Postal Code": "Code postal",
"Private event": "Événement privé",
"Private feeds": "Flux privés",
@@ -217,8 +225,10 @@
"Reject": "Rejetter",
"Rejected participations": "Participations rejetées",
"Rejected": "Rejetés",
"Report this comment": "Signaler ce commentaire",
"Report this event": "Signaler cet événement",
"Report": "Signaler",
"Report": "Signalement",
"Reports": "Signalements",
"Requests": "Requêtes",
"Resend confirmation email": "Envoyer à nouveau l'email de confirmation",
"Reset my password": "Réinitialiser mon mot de passe",
@@ -277,6 +287,7 @@
"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",
"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",
"View page on {hostname} (in a new window)": "Voir la page sur {hostname} (dans une nouvelle fenêtre)",
@@ -314,6 +325,8 @@
"Your local administrator resumed its policy:": "Votre administrateur local a résumé sa politique ainsi :",
"Your participation has been confirmed": "Votre participation a été confirmée",
"Your participation has been requested": "Votre participation a été demandée",
"[This comment has been deleted]": "[Ce commentaire a été supprimé]",
"[deleted]": "[supprimé]",
"a decentralised federation protocol": "un protocole de fédération décentralisée",
"e.g. 10 Rue Jangot": "par exemple : 10 Rue Jangot",
"firstDayOfWeek": "1",
@@ -331,4 +344,4 @@
"{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"
}
}

View File

@@ -3,6 +3,7 @@
import Vue from 'vue';
import Buefy from 'buefy';
import Component from 'vue-class-component';
import VueScrollTo from 'vue-scrollto';
import App from '@/App.vue';
import router from '@/router';
import { apolloProvider } from './vue-apollo';
@@ -17,6 +18,7 @@ Vue.use(Buefy);
Vue.use(NotifierPlugin);
Vue.use(filters);
Vue.use(VueMeta);
Vue.use(VueScrollTo);
// Register the router hooks with their names
Component.registerHooks([

View File

@@ -7,7 +7,8 @@ import { IPerson } from '@/types/actor';
@Component
export default class EventMixin extends mixins(Vue) {
async openDeleteEventModal (event: IEvent, currentActor: IPerson) {
async openDeleteEventModal(event: IEvent, currentActor: IPerson) {
const participantsLength = event.participantStats.participant;
const prefix = participantsLength
? this.$tc('There are {participants} participants.', event.participantStats.participant, {

View File

@@ -1,5 +1,6 @@
import Vue from 'vue';
import Router from 'vue-router';
import VueScrollTo from 'vue-scrollto';
import PageNotFound from '@/views/PageNotFound.vue';
import Home from '@/views/Home.vue';
import { UserRouteName, userRoutes } from './user';
@@ -20,13 +21,18 @@ enum GlobalRouteName {
SEARCH = 'Search',
}
function scrollBehavior(to) {
function scrollBehavior(to, from, savedPosition) {
if (to.hash) {
VueScrollTo.scrollTo(to.hash, 700);
return {
selector: to.hash,
// , offset: { x: 0, y: 10 }
offset: { x: 0, y: 10 },
};
}
if (savedPosition) {
return savedPosition;
}
return { x: 0, y: 0 };
}

View File

@@ -1,4 +1,8 @@
import { Vue } from 'vue/types/vue';
declare module '*.vue' {
import Vue from 'vue';
export default Vue;
}
type Refs<T extends object> = Vue['$refs'] & T;

View File

@@ -0,0 +1,50 @@
import { Actor, IActor } from '@/types/actor';
import { EventModel, IEvent } from '@/types/event.model';
export interface IComment {
id?: string;
uuid?: string;
url?: string;
text: string;
actor: IActor;
inReplyToComment?: IComment;
originComment?: IComment;
replies: IComment[];
event?: IEvent;
updatedAt?: Date;
deletedAt?: Date;
totalReplies: number;
}
export class CommentModel implements IComment {
actor: IActor = new Actor();
id?: string;
text: string = '';
url?: string;
uuid?: string;
inReplyToComment?: IComment = undefined;
originComment?: IComment = undefined;
replies: IComment[] = [];
event?: IEvent = undefined;
updatedAt?: Date = undefined;
deletedAt?: Date = undefined;
totalReplies: number = 0;
constructor(hash?: IComment) {
if (!hash) return;
this.id = hash.id;
this.uuid = hash.uuid;
this.url = hash.url;
this.text = hash.text;
this.inReplyToComment = hash.inReplyToComment;
this.originComment = hash.originComment;
this.actor = new Actor(hash.actor);
this.event = new EventModel(hash.event);
this.replies = hash.replies;
this.updatedAt = hash.updatedAt;
this.deletedAt = hash.deletedAt;
this.totalReplies = hash.totalReplies;
}
}

View File

@@ -2,6 +2,7 @@ import { Actor, IActor, IPerson } from './actor';
import { Address, IAddress } from '@/types/address.model';
import { ITag } from '@/types/tag.model';
import { IPicture } from '@/types/picture.model';
import { IComment } from '@/types/comment.model';
export enum EventStatus {
TENTATIVE = 'TENTATIVE',
@@ -129,6 +130,7 @@ export interface IEvent {
participants: IParticipant[];
relatedEvents: IEvent[];
comments: IComment[];
onlineAddress?: string;
phoneAddress?: string;
@@ -199,9 +201,10 @@ export class EventModel implements IEvent {
participants: IParticipant[] = [];
relatedEvents: IEvent[] = [];
comments: IComment[] = [];
attributedTo = new Actor();
organizerActor?: IActor;
organizerActor?: IActor = new Actor();
tags: ITag[] = [];
options: IEventOptions = new EventOptions();

View File

@@ -1,5 +1,6 @@
import { IActor, IPerson } from '@/types/actor';
import { IEvent } from '@/types/event.model';
import { IComment } from '@/types/comment.model';
export enum ReportStatusEnum {
OPEN = 'OPEN',
@@ -12,6 +13,7 @@ export interface IReport extends IActionLogObject {
reported: IActor;
reporter: IPerson;
event?: IEvent;
comments: IComment[];
content: string;
notes: IReportNote[];
insertedAt: Date;
@@ -36,6 +38,7 @@ export enum ActionLogAction {
REPORT_UPDATE_OPENED = 'REPORT_UPDATE_OPENED',
REPORT_UPDATE_RESOLVED = 'REPORT_UPDATE_RESOLVED',
EVENT_DELETION = 'EVENT_DELETION',
COMMENT_DELETION = 'COMMENT_DELETION',
}
export interface IActionLog {

View File

@@ -2,6 +2,9 @@ declare module 'tiptap' {
import Vue from 'vue';
export class Editor {
public constructor({});
public commands: {
mention: Function,
};
public setOptions({}): void;
public setContent(content: string): void;

View File

@@ -32,6 +32,3 @@ $navbar-height: 4rem;
// Footer
$footer-padding: 3rem 1.5rem 4rem;
$footer-background-color: $primary;
// Card
$card-background-color: $secondary;

View File

@@ -7,7 +7,8 @@
<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" />
<img class="media-left image" 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>
<small>{{ identity.name }}</small>

View File

@@ -1,9 +1,15 @@
<template>
<div class="identity-picker">
<img class="image" v-if="currentIdentity.avatar" :src="currentIdentity.avatar.url" :alt="currentIdentity.avatar.alt"/> {{ currentIdentity.name || `@${currentIdentity.preferredUsername}` }}
<b-button type="is-text" @click="isComponentModalActive = true">
{{ $t('Change') }}
</b-button>
<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}` }}
<b-button type="is-text" @click="isComponentModalActive = true">
{{ $t('Change') }}
</b-button>
</span>
<span v-else class="block" @click="isComponentModalActive = true">
<img class="image is-48x48" v-if="currentIdentity.avatar" :src="currentIdentity.avatar.url" :alt="currentIdentity.avatar.alt"/>
<b-icon v-else size="is-large" icon="account-circle" />
</span>
<b-modal :active.sync="isComponentModalActive" has-modal-card>
<identity-picker v-model="currentIdentity" @input="relay" />
</b-modal>
@@ -19,6 +25,7 @@ import IdentityPicker from './IdentityPicker.vue';
})
export default class IdentityPickerWrapper extends Vue {
@Prop() value!: IActor;
@Prop({ default: true, type: Boolean }) inline!: boolean;
isComponentModalActive: boolean = false;
currentIdentity: IActor = this.value;
@@ -36,9 +43,16 @@ export default class IdentityPickerWrapper extends Vue {
}
</script>
<style lang="scss">
.identity-picker img.image {
display: inline;
height: 1.5em;
vertical-align: text-bottom;
.identity-picker {
.block {
cursor: pointer;
}
.inline img.image {
display: inline;
height: 1.5em;
vertical-align: text-bottom;
}
}
</style>

View File

@@ -94,7 +94,7 @@
<b-field :label="$t('Number of places')">
<b-numberinput controls-position="compact" min="0" v-model="event.options.maximumAttendeeCapacity"></b-numberinput>
</b-field>
<!--
<!--
<b-field>
<b-switch v-model="event.options.showRemainingAttendeeCapacity">
{{ $t('Show remaining number of places') }}
@@ -108,12 +108,10 @@
</b-field> -->
</div>
<!-- <h2 class="subtitle">
<h2 class="subtitle">
{{ $t('Public comment moderation') }}
</h2>
<label>{{ $t('Comments on the event page') }}</label>
<div class="field">
<b-radio v-model="event.options.commentModeration"
name="commentModeration"
@@ -122,13 +120,13 @@
</b-radio>
</div>
<div class="field">
<b-radio v-model="event.options.commentModeration"
name="commentModeration"
:native-value="CommentModeration.MODERATED">
{{ $t('Moderated comments (shown after approval)') }}
</b-radio>
</div>
<!-- <div class="field">-->
<!-- <b-radio v-model="event.options.commentModeration"-->
<!-- name="commentModeration"-->
<!-- :native-value="CommentModeration.MODERATED">-->
<!-- {{ $t('Moderated comments (shown after approval)') }}-->
<!-- </b-radio>-->
<!-- </div>-->
<div class="field">
<b-radio v-model="event.options.commentModeration"
@@ -136,7 +134,7 @@
:native-value="CommentModeration.CLOSED">
{{ $t('Close comments for all (except for admins)') }}
</b-radio>
</div> -->
</div>
<h2 class="subtitle">
{{ $t('Status') }}
@@ -542,13 +540,17 @@ export default class EditEvent extends Vue {
const pictureObj = buildFileVariable(this.pictureFile, 'picture');
res = Object.assign({}, res, pictureObj);
if (this.event.picture) {
const oldPictureFile = await buildFileFromIPicture(this.event.picture) as File;
const oldPictureFileContent = await readFileAsync(oldPictureFile);
const newPictureFileContent = await readFileAsync(this.pictureFile as File);
if (oldPictureFileContent === newPictureFileContent) {
res.picture = { pictureId: this.event.picture.id };
try {
if (this.event.picture) {
const oldPictureFile = await buildFileFromIPicture(this.event.picture) as File;
const oldPictureFileContent = await readFileAsync(oldPictureFile);
const newPictureFileContent = await readFileAsync(this.pictureFile as File);
if (oldPictureFileContent === newPictureFileContent) {
res.picture = { pictureId: this.event.picture.id };
}
}
} catch (e) {
console.error(e);
}
return res;
}

View File

@@ -2,7 +2,7 @@
<div class="container">
<b-loading :active.sync="$apollo.loading"></b-loading>
<transition appear name="fade" mode="out-in">
<div v-if="event">
<div>
<div class="header-picture" v-if="event.picture" :style="`background-image: url('${event.picture.url}')`" />
<div class="header-picture-default" v-else />
<section>
@@ -160,7 +160,7 @@
</div>
</div>
</section>
<div class="description">
<div class="description" :class="{ exists: event.description }">
<div class="description-container container">
<h3 class="title">
{{ $t('About this event') }}
@@ -174,6 +174,12 @@
</div>
</div>
</div>
<section class="comments" ref="commentsObserver">
<a href="#comments">
<h3 class="title" id="comments">{{ $t('Comments') }}</h3>
</a>
<comment-tree v-if="loadComments" :event="event" />
</section>
<section class="share" v-if="!event.draft">
<div class="container">
<div class="columns is-centered is-multiline">
@@ -199,7 +205,7 @@
</div>
<hr />
<div class="column is-half-widescreen has-text-right add-to-calendar">
<img src="../../assets/undraw_events.svg" class="is-hidden-mobile is-hidden-tablet-only" />
<img src="../../assets/undraw_events.svg" class="is-hidden-mobile is-hidden-tablet-only" alt="" />
<h3 @click="downloadIcsEvent()">
{{ $t('Add to my calendar') }}
</h3>
@@ -247,7 +253,7 @@
import { EVENT_PERSON_PARTICIPATION, FETCH_EVENT, JOIN_EVENT, LEAVE_EVENT } from '@/graphql/event';
import { Component, Prop } from 'vue-property-decorator';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { EventStatus, EventVisibility, IEvent, IParticipant, ParticipantRole } from '@/types/event.model';
import { EventModel, EventStatus, EventVisibility, IEvent, IParticipant, ParticipantRole } from '@/types/event.model';
import { IPerson, Person } from '@/types/actor';
import { GRAPHQL_API_ENDPOINT } from '@/api/_entrypoint';
import DateCalendarIcon from '@/components/Event/DateCalendarIcon.vue';
@@ -264,6 +270,8 @@ import ParticipationButton from '@/components/Event/ParticipationButton.vue';
import { GraphQLError } from 'graphql';
import { RouteName } from '@/router';
import { Address } from '@/types/address.model';
import CommentTree from '@/components/Comment/CommentTree.vue';
import 'intersection-observer';
@Component({
components: {
@@ -275,6 +283,7 @@ import { Address } from '@/types/address.model';
ReportModal,
IdentityPicker,
ParticipationButton,
CommentTree,
// tslint:disable:space-in-parens
'map-leaflet': () => import(/* webpackChunkName: "map" */ '@/components/Map.vue'),
// tslint:enable
@@ -328,7 +337,7 @@ import { Address } from '@/types/address.model';
export default class Event extends EventMixin {
@Prop({ type: String, required: true }) uuid!: string;
event!: IEvent;
event: IEvent = new EventModel();
currentActor!: IPerson;
identity: IPerson = new Person();
participations: IParticipant[] = [];
@@ -338,6 +347,8 @@ export default class Event extends EventMixin {
EventVisibility = EventVisibility;
EventStatus = EventStatus;
RouteName = RouteName;
observer!: IntersectionObserver;
loadComments: boolean = false;
get eventTitle() {
if (!this.event) return undefined;
@@ -351,10 +362,24 @@ export default class Event extends EventMixin {
mounted() {
this.identity = this.currentActor;
if (this.$route.hash.includes('#comment-')) {
this.loadComments = true;
}
this.observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry) {
this.loadComments = entry.isIntersecting || this.loadComments;
}
}
}, {
rootMargin: '-50px 0px -50px',
});
this.observer.observe(this.$refs.commentsObserver as Element);
this.$watch('eventDescription', function (eventDescription) {
if (!eventDescription) return;
const eventDescriptionElement = this.$refs['eventDescriptionElement'] as HTMLElement;
const eventDescriptionElement = this.$refs.eventDescriptionElement as HTMLElement;
eventDescriptionElement.addEventListener('click', ($event) => {
// TODO: Find the right type for target
@@ -404,8 +429,8 @@ export default class Event extends EventMixin {
mutation: CREATE_REPORT,
variables: {
eventId: this.event.id,
reporterActorId: this.currentActor.id,
reportedActorId: this.event.organizerActor.id,
reporterId: this.currentActor.id,
reportedId: this.event.organizerActor.id,
content,
},
});
@@ -845,19 +870,24 @@ export default class Event extends EventMixin {
}
h3.title {
font-size: 3rem;
font-weight: 300;
font-weight: 300;
}
.description {
padding-top: 10px;
min-height: 40rem;
padding: 10px 0;
min-height: 7rem;
&.exists {
min-height: 40rem;
}
@media screen and (min-width: 1216px) {
background-repeat: no-repeat;
background-size: 600px;
background-position: 95% 101%;
background-image: url('../../assets/texting.svg');
&.exists {
background-image: url('../../assets/texting.svg');
}
}
border-top: solid 1px #111;
border-bottom: solid 1px #111;
@@ -906,8 +936,17 @@ export default class Event extends EventMixin {
}
}
.comments {
margin: 1rem auto 2rem;
a h3#comments {
margin-bottom: 5px;
}
}
.share {
border-bottom: solid 1px #111;
border-bottom: solid 1px $primary;
border-top: solid 1px $primary;
.diaspora span svg {
height: 2rem;
@@ -917,7 +956,7 @@ export default class Event extends EventMixin {
.columns {
& > * {
padding: 10rem 0;
padding: 2rem 0;
}
h3 {
@@ -957,7 +996,7 @@ export default class Event extends EventMixin {
}
img {
max-width: 400px;
max-width: 250px;
}
&::before {
@@ -965,7 +1004,6 @@ export default class Event extends EventMixin {
background: #B3B3B2;
position: absolute;
bottom: 25%;
left: 0;
height: 40%;
width: 1px;
}

View File

@@ -4,68 +4,81 @@
<div class="container" v-if="report">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li><router-link :to="{ name: RouteName.DASHBOARD }">Dashboard</router-link></li>
<li><router-link :to="{ name: RouteName.REPORTS }">Reports</router-link></li>
<li class="is-active"><router-link :to="{ name: RouteName.REPORT, params: { reportId: this.report.id} }" aria-current="page">Report</router-link></li>
<li><router-link :to="{ name: RouteName.DASHBOARD }">{{ $t('Dashboard') }}</router-link></li>
<li><router-link :to="{ name: RouteName.REPORTS }">{{ $t('Reports') }}</router-link></li>
<li class="is-active"><router-link :to="{ name: RouteName.REPORT, params: { reportId: this.report.id} }" aria-current="page">{{ $t('Report') }}</router-link></li>
</ul>
</nav>
<div class="buttons">
<b-button v-if="report.status !== ReportStatusEnum.RESOLVED" @click="updateReport(ReportStatusEnum.RESOLVED)" type="is-primary">Mark as resolved</b-button>
<b-button v-if="report.status !== ReportStatusEnum.OPEN" @click="updateReport(ReportStatusEnum.OPEN)" type="is-success">Reopen</b-button>
<b-button v-if="report.status !== ReportStatusEnum.CLOSED" @click="updateReport(ReportStatusEnum.CLOSED)" type="is-danger">Close</b-button>
<b-button v-if="report.status !== ReportStatusEnum.RESOLVED" @click="updateReport(ReportStatusEnum.RESOLVED)" type="is-primary">{{ $t('Mark as resolved') }}</b-button>
<b-button v-if="report.status !== ReportStatusEnum.OPEN" @click="updateReport(ReportStatusEnum.OPEN)" type="is-success">{{ $t('Reopen') }}</b-button>
<b-button v-if="report.status !== ReportStatusEnum.CLOSED" @click="updateReport(ReportStatusEnum.CLOSED)" type="is-danger">{{ $t('Close') }}</b-button>
</div>
<div class="columns">
<div class="column">
<div class="table-container">
<table class="box table is-striped">
<tbody>
<tr>
<td>Compte signalé</td>
<td>
<router-link :to="{ name: RouteName.PROFILE, params: { name: report.reported.preferredUsername } }">
<img v-if="report.reported.avatar" class="image" :src="report.reported.avatar.url" /> @{{ report.reported.preferredUsername }}
</router-link>
</td>
</tr>
<tr>
<td>Signalé par</td>
<td>
<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>
</td>
</tr>
<tr>
<td>Signalé</td>
<td>{{ report.insertedAt | formatDateTimeString }}</td>
</tr>
<tr v-if="report.updatedAt !== report.insertedAt">
<td>Mis à jour</td>
<td>{{ report.updatedAt | formatDateTimeString }}</td>
</tr>
<tr>
<td>Statut</td>
<td>
<span v-if="report.status === ReportStatusEnum.OPEN">Ouvert</span>
<span v-else-if="report.status === ReportStatusEnum.CLOSED">Fermé</span>
<span v-else-if="report.status === ReportStatusEnum.RESOLVED">Résolu</span>
<span v-else>Inconnu</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="column">
<div class="box">
<p v-if="report.content">{{ report.content }}</p>
<p v-else>Pas de commentaire</p>
</div>
</div>
<div class="table-container">
<table class="table is-striped is-fullwidth">
<tbody>
<tr>
<td>{{ $t('Reported identity') }}</td>
<td>
<router-link :to="{ name: RouteName.PROFILE, params: { name: report.reported.preferredUsername } }">
<img v-if="report.reported.avatar" class="image" :src="report.reported.avatar.url" /> @{{ report.reported.preferredUsername }}
</router-link>
</td>
</tr>
<tr>
<td>{{ $t('Reported by') }}</td>
<td>
<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>
</td>
</tr>
<tr>
<td>{{ $t('Reported')}}</td>
<td>{{ report.insertedAt | formatDateTimeString }}</td>
</tr>
<tr v-if="report.updatedAt !== report.insertedAt">
<td>{{ $t('Updated') }}</td>
<td>{{ report.updatedAt | formatDateTimeString }}</td>
</tr>
<tr>
<td>{{ $t('Status') }}</td>
<td>
<span v-if="report.status === ReportStatusEnum.OPEN">{{ $t('Open') }}</span>
<span v-else-if="report.status === ReportStatusEnum.CLOSED">{{ $t('Closed') }}</span>
<span v-else-if="report.status === ReportStatusEnum.RESOLVED">{{ $t('Resolved') }}</span>
<span v-else>{{ $t('Unknown') }}</span>
</td>
</tr>
<tr v-if="report.event && report.comments.length > 0">
<td>{{ $t('Event') }}</td>
<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
type="is-danger"
@click="confirmDelete()"
icon-left="delete"
size="is-small">{{ $t('Delete') }}</b-button>
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="box" v-if="report.event">
<div class="box report-content">
<p v-if="report.content" v-html="nl2br(report.content)"></p>
<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>
@@ -75,28 +88,50 @@
type="is-primary"
:to="{ name: RouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"
icon-left="pencil"
size="is-small">Edit</b-button>
size="is-small">{{ $t('Edit') }}</b-button>
<b-button
type="is-danger"
@click="confirmDelete()"
icon-left="delete"
size="is-small">Delete</b-button>
size="is-small">{{ $t('Delete') }}</b-button>
</div>
<h2 class="title" v-if="report.notes.length > 0">Notes</h2>
<ul v-for="comment in report.comments" v-if="report.comments.length > 0">
<li>
<div class="box" v-if="comment">
<article class="media">
<div class="media-left">
<figure class="image is-48x48" v-if="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>
<br>
<p v-html="comment.text"></p>
</div>
</div>
</article>
</div>
</li>
</ul>
<h2 class="title" v-if="report.notes.length > 0">{{ $t('Notes') }}</h2>
<div class="box note" v-for="note in report.notes" :id="`note-${note.id}`">
<p>{{ note.content }}</p>
<router-link :to="{ name: RouteName.PROFILE, params: { name: note.moderator.preferredUsername } }">
<img class="image" :src="note.moderator.avatar.url" /> @{{ note.moderator.preferredUsername }}
<img alt="" class="image" :src="note.moderator.avatar.url" /> @{{ note.moderator.preferredUsername }}
</router-link><br />
<small><a :href="`#note-${note.id}`" v-if="note.insertedAt">{{ note.insertedAt | formatDateTimeString }}</a></small>
</div>
<form @submit="addNote()">
<b-field label="Nouvelle note">
<b-field :label="$t('New note')">
<b-input type="textarea" v-model="noteContent"></b-input>
</b-field>
<b-button type="submit" @click="addNote">Ajouter une note</b-button>
<b-button type="submit" @click="addNote">{{ $t('Ajouter une note') }}</b-button>
</form>
</div>
</section>
@@ -110,6 +145,7 @@ import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { IPerson } from '@/types/actor';
import { DELETE_EVENT } from '@/graphql/event';
import { uniq } from 'lodash';
import { nl2br } from '@/utils/html';
@Component({
apollo: {
@@ -137,6 +173,7 @@ export default class Report extends Vue {
ReportStatusEnum = ReportStatusEnum;
RouteName = RouteName;
nl2br = nl2br;
noteContent: string = '';
@@ -175,9 +212,9 @@ export default class Report extends Vue {
confirmDelete() {
this.$buefy.dialog.confirm({
title: 'Deleting event',
message: '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.',
confirmText: 'Delete Event',
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,
confirmText: this.$t('Delete Event') as string,
type: 'is-danger',
hasIcon: true,
onConfirm: () => this.deleteEvent(),
@@ -232,6 +269,7 @@ export default class Report extends Vue {
store.writeQuery({ query: REPORT, variables: { id: this.report.id }, data: { report } });
},
});
await this.$router.push({ name: RouteName.REPORTS });
} catch (error) {
console.error(error);
}
@@ -248,7 +286,9 @@ export default class Report extends Vue {
}
</script>
<style lang="scss">
<style lang="scss" scoped>
@import "@/variables.scss";
.container li {
margin: 10px auto;
}
@@ -262,4 +302,8 @@ export default class Report extends Vue {
.dialog .modal-card-foot {
justify-content: flex-end;
}
.report-content {
border-left: 4px solid $primary;
}
</style>

View File

@@ -2,8 +2,8 @@
<section class="container">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li><router-link :to="{ name: RouteName.DASHBOARD }">Dashboard</router-link></li>
<li class="is-active"><router-link :to="{ name: RouteName.REPORTS }" aria-current="page">Reports</router-link></li>
<li><router-link :to="{ name: RouteName.DASHBOARD }">{{ $t('Dashboard') }}</router-link></li>
<li class="is-active"><router-link :to="{ name: RouteName.REPORTS }" aria-current="page">{{ $t('Reports') }}</router-link></li>
</ul>
</nav>
<b-field>