Introduce group posts

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2020-07-09 17:24:28 +02:00
parent bec1c69d4b
commit 9c9f1385fb
249 changed files with 11886 additions and 5023 deletions

View File

@@ -83,6 +83,7 @@ import { IStatistics } from "../../types/statistics.model";
})
export default class AboutInstance extends Vue {
config!: IConfig;
statistics!: IStatistics;
get isContactEmail(): boolean {
@@ -97,7 +98,8 @@ export default class AboutInstance extends Vue {
if (!this.config.contact) return null;
if (this.isContactEmail) {
return { uri: `mailto:${this.config.contact}`, text: this.config.contact };
} else if (this.isContactURL) {
}
if (this.isContactURL) {
return {
uri: this.config.contact,
text: this.urlToHostname(this.config.contact) || (this.$t("Contact") as string),

View File

@@ -160,7 +160,7 @@ const EVENTS_PER_PAGE = 10;
},
})
export default class AdminProfile extends Vue {
@Prop({ required: true }) id!: String;
@Prop({ required: true }) id!: string;
person!: IPerson;
@@ -171,6 +171,7 @@ export default class AdminProfile extends Vue {
EVENTS_PER_PAGE = EVENTS_PER_PAGE;
organizedEventsPage = 1;
participationsPage = 1;
get metadata(): Array<object> {

View File

@@ -81,7 +81,7 @@ import { IPerson } from "../../types/actor";
},
})
export default class AdminUserProfile extends Vue {
@Prop({ required: true }) id!: String;
@Prop({ required: true }) id!: string;
user!: IUser;

View File

@@ -105,13 +105,19 @@ const PROFILES_PER_PAGE = 10;
})
export default class Profiles extends Vue {
page = 1;
preferredUsername = "";
name = "";
domain = "";
local = true;
suspended = false;
PROFILES_PER_PAGE = PROFILES_PER_PAGE;
RouteName = RouteName;
async onPageChange(page: number) {

View File

@@ -270,6 +270,7 @@ export default class Settings extends Vue {
adminSettings!: IAdminSettings;
InstanceTermsType = InstanceTermsType;
InstancePrivacyType = InstancePrivacyType;
RouteName = RouteName;

View File

@@ -109,9 +109,11 @@ const USERS_PER_PAGE = 10;
})
export default class Users extends Vue {
page = 1;
email = "";
USERS_PER_PAGE = USERS_PER_PAGE;
RouteName = RouteName;
async onPageChange(page: number) {

View File

@@ -1,243 +0,0 @@
<template>
<div class="container section" v-if="conversation">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.MY_GROUPS }">{{ $t("My groups") }}</router-link>
</li>
<li>
<router-link
:to="{
name: RouteName.GROUP,
params: { preferredUsername: conversation.actor.preferredUsername },
}"
>{{ `@${conversation.actor.preferredUsername}` }}</router-link
>
</li>
<li>
<router-link
:to="{
name: RouteName.CONVERSATION_LIST,
params: { preferredUsername: conversation.actor.preferredUsername },
}"
>{{ $t("Discussions") }}</router-link
>
</li>
<li class="is-active">
<router-link :to="{ name: RouteName.CONVERSATION, params: { id: conversation.id } }">{{
conversation.title
}}</router-link>
</li>
</ul>
</nav>
<section>
<div class="conversation-title">
<h2 class="title" v-if="!editTitleMode">
{{ conversation.title }}
<span
@click="
() => {
newTitle = conversation.title;
editTitleMode = true;
}
"
>
<b-icon icon="pencil" />
</span>
</h2>
<form v-else @submit.prevent="updateConversation" class="title-edit">
<b-input :value="conversation.title" v-model="newTitle" />
<div class="buttons">
<b-button type="is-primary" native-type="submit" icon-right="check" />
<b-button
@click="
() => {
editTitleMode = false;
newTitle = '';
}
"
icon-right="close"
/>
</div>
</form>
</div>
<conversation-comment
v-for="comment in conversation.comments.elements"
:key="comment.id"
:comment="comment"
/>
<b-button
v-if="conversation.comments.elements.length < conversation.comments.total"
@click="loadMoreComments"
>Fetch more</b-button
>
<form @submit.prevent="reply">
<b-field :label="$t('Text')">
<editor v-model="newComment" />
</b-field>
<b-button native-type="submit" type="is-primary">{{ $t("Reply") }}</b-button>
</form>
</section>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import {
GET_CONVERSATION,
REPLY_TO_CONVERSATION,
UPDATE_CONVERSATION,
} from "@/graphql/conversation";
import { IConversation } from "@/types/conversations";
import ConversationComment from "@/components/Conversation/ConversationComment.vue";
import RouteName from "../../router/name";
@Component({
apollo: {
conversation: {
query: GET_CONVERSATION,
variables() {
return {
id: this.id,
page: 1,
};
},
skip() {
return !this.id;
},
},
},
components: {
ConversationComment,
editor: () => import(/* webpackChunkName: "editor" */ "@/components/Editor.vue"),
},
})
export default class Conversation extends Vue {
@Prop({ type: String, required: true }) id!: string;
conversation!: IConversation;
newComment = "";
newTitle = "";
editTitleMode = false;
page = 1;
hasMoreComments = true;
RouteName = RouteName;
async reply() {
await this.$apollo.mutate({
mutation: REPLY_TO_CONVERSATION,
variables: {
conversationId: this.conversation.id,
text: this.newComment,
},
update: (store, { data: { replyToConversation } }) => {
const conversationData = store.readQuery<{
conversation: IConversation;
}>({
query: GET_CONVERSATION,
variables: {
id: this.id,
page: this.page,
},
});
if (!conversationData) return;
const { conversation } = conversationData;
conversation.lastComment = replyToConversation.lastComment;
conversation.comments.elements.push(replyToConversation.lastComment);
conversation.comments.total += 1;
store.writeQuery({
query: GET_CONVERSATION,
variables: { id: this.id, page: this.page },
data: { conversation },
});
},
});
this.newComment = "";
}
async loadMoreComments() {
this.page += 1;
try {
console.log(this.$apollo.queries.conversation);
await this.$apollo.queries.conversation.fetchMore({
// New variables
variables: {
id: this.id,
page: this.page,
},
// Transform the previous result with new data
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult) return previousResult;
const newComments = fetchMoreResult.conversation.comments.elements;
this.hasMoreComments = newComments.length === 1;
const { conversation } = previousResult;
conversation.comments.elements = [
...previousResult.conversation.comments.elements,
...newComments,
];
return { conversation };
},
});
} catch (e) {
console.error(e);
}
}
async updateConversation() {
await this.$apollo.mutate({
mutation: UPDATE_CONVERSATION,
variables: {
conversationId: this.conversation.id,
title: this.newTitle,
},
update: (store, { data: { updateConversation } }) => {
const conversationData = store.readQuery<{
conversation: IConversation;
}>({
query: GET_CONVERSATION,
variables: {
id: this.id,
page: this.page,
},
});
if (!conversationData) return;
const { conversation } = conversationData;
conversation.title = updateConversation.title;
store.writeQuery({
query: GET_CONVERSATION,
variables: { id: this.id, page: this.page },
data: { conversation },
});
},
});
this.editTitleMode = false;
}
}
</script>
<style lang="scss" scoped>
div.container.section {
background: white;
div.conversation-title {
margin-bottom: 0.75rem;
h2.title {
span {
cursor: pointer;
}
}
form.title-edit {
div.control {
margin-bottom: 0.75rem;
}
}
}
}
</style>

View File

@@ -2,13 +2,13 @@
<section class="section container">
<h1>{{ $t("Create a discussion") }}</h1>
<form @submit.prevent="createConversation">
<form @submit.prevent="createDiscussion">
<b-field :label="$t('Title')">
<b-input aria-required="true" required v-model="conversation.title" />
<b-input aria-required="true" required v-model="discussion.title" />
</b-field>
<b-field :label="$t('Text')">
<editor v-model="conversation.text" />
<editor v-model="discussion.text" />
</b-field>
<button class="button is-primary" type="submit">{{ $t("Create the discussion") }}</button>
@@ -20,7 +20,7 @@
import { Component, Prop, Vue } from "vue-property-decorator";
import { IGroup, IPerson } from "@/types/actor";
import { CURRENT_ACTOR_CLIENT, FETCH_GROUP } from "@/graphql/actor";
import { CREATE_CONVERSATION } from "@/graphql/conversation";
import { CREATE_DISCUSSION } from "@/graphql/discussion";
import RouteName from "../../router/name";
@Component({
@@ -41,36 +41,45 @@ import RouteName from "../../router/name";
},
},
},
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
title: this.$t("Create a discussion") as string,
// all titles will be injected into this template
titleTemplate: "%s | Mobilizon",
};
},
})
export default class CreateConversation extends Vue {
export default class CreateDiscussion extends Vue {
@Prop({ type: String, required: true }) preferredUsername!: string;
group!: IGroup;
currentActor!: IPerson;
conversation = { title: "", text: "" };
discussion = { title: "", text: "" };
async createConversation() {
async createDiscussion() {
try {
const { data } = await this.$apollo.mutate({
mutation: CREATE_CONVERSATION,
mutation: CREATE_DISCUSSION,
variables: {
title: this.conversation.title,
text: this.conversation.text,
title: this.discussion.title,
text: this.discussion.text,
actorId: this.group.id,
creatorId: this.currentActor.id,
},
// update: (store, { data: { createConversation } }) => {
// update: (store, { data: { createDiscussion } }) => {
// // TODO: update group list cache
// },
});
await this.$router.push({
name: RouteName.CONVERSATION,
name: RouteName.DISCUSSION,
params: {
id: data.createConversation.id,
slug: data.createConversation.slug,
id: data.createDiscussion.id,
slug: data.createDiscussion.slug,
},
});
} catch (err) {

View File

@@ -0,0 +1,350 @@
<template>
<div class="container section" v-if="discussion">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.MY_GROUPS }">{{ $t("My groups") }}</router-link>
</li>
<li>
<router-link
v-if="discussion.actor"
:to="{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(discussion.actor) },
}"
>{{ discussion.actor.name }}</router-link
>
<b-skeleton v-else animated />
</li>
<li>
<router-link
v-if="discussion.actor"
:to="{
name: RouteName.DISCUSSION_LIST,
params: { preferredUsername: usernameWithDomain(discussion.actor) },
}"
>{{ $t("Discussions") }}</router-link
>
<b-skeleton animated v-else />
</li>
<li class="is-active">
<router-link :to="{ name: RouteName.DISCUSSION, params: { id: discussion.id } }">{{
discussion.title
}}</router-link>
</li>
</ul>
</nav>
<section>
<div class="discussion-title">
<h2 class="title" v-if="discussion.title && !editTitleMode">
{{ discussion.title }}
<span
@click="
() => {
newTitle = discussion.title;
editTitleMode = true;
}
"
>
<b-icon icon="pencil" />
</span>
</h2>
<b-skeleton v-else-if="!editTitleMode" height="50px" animated />
<form v-else @submit.prevent="updateDiscussion" class="title-edit">
<b-input :value="discussion.title" v-model="newTitle" />
<div class="buttons">
<b-button type="is-primary" native-type="submit" icon-right="check" />
<b-button
@click="
() => {
editTitleMode = false;
newTitle = '';
}
"
icon-right="close"
/>
<b-button
@click="deleteConversation"
type="is-danger"
native-type="button"
icon-left="delete"
>{{ $t("Delete conversation") }}</b-button
>
</div>
</form>
</div>
<discussion-comment
v-for="comment in discussion.comments.elements"
:key="comment.id"
:comment="comment"
/>
<b-button
v-if="discussion.comments.elements.length < discussion.comments.total"
@click="loadMoreComments"
>{{ $t("Fetch more") }}</b-button
>
<form @submit.prevent="reply">
<b-field :label="$t('Text')">
<editor v-model="newComment" />
</b-field>
<b-button native-type="submit" type="is-primary">{{ $t("Reply") }}</b-button>
</form>
</section>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import {
GET_DISCUSSION,
REPLY_TO_DISCUSSION,
UPDATE_DISCUSSION,
DELETE_DISCUSSION,
DISCUSSION_COMMENT_CHANGED,
} from "@/graphql/discussion";
import { IDiscussion, Discussion } from "@/types/discussions";
import { usernameWithDomain } from "@/types/actor";
import DiscussionComment from "@/components/Discussion/DiscussionComment.vue";
import { GraphQLError } from "graphql";
import RouteName from "../../router/name";
import { IComment } from "../../types/comment.model";
@Component({
apollo: {
discussion: {
query: GET_DISCUSSION,
variables() {
return {
slug: this.slug,
page: 1,
limit: this.COMMENTS_PER_PAGE,
};
},
skip() {
return !this.slug;
},
error({ graphQLErrors }) {
this.handleErrors(graphQLErrors);
},
update: (data) => new Discussion(data.discussion),
subscribeToMore: {
document: DISCUSSION_COMMENT_CHANGED,
variables() {
return {
slug: this.slug,
};
},
updateQuery: (previousResult, { subscriptionData }) => {
const previousDiscussion = previousResult.discussion;
console.log("updating subscription with ", subscriptionData);
if (
!previousDiscussion.comments.elements.find(
(comment: IComment) =>
comment.id === subscriptionData.data.discussionCommentChanged.lastComment.id
)
) {
previousDiscussion.lastComment =
subscriptionData.data.discussionCommentChanged.lastComment;
previousDiscussion.comments.elements.push(
subscriptionData.data.discussionCommentChanged.lastComment
);
previousDiscussion.comments.total += 1;
}
return previousDiscussion;
},
},
},
},
components: {
DiscussionComment,
editor: () => import(/* webpackChunkName: "editor" */ "@/components/Editor.vue"),
},
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
title: this.discussion.title,
// all titles will be injected into this template
titleTemplate: "%s | Mobilizon",
};
},
})
export default class discussion extends Vue {
@Prop({ type: String, required: true }) slug!: string;
discussion: IDiscussion = new Discussion();
newComment = "";
newTitle = "";
editTitleMode = false;
page = 1;
hasMoreComments = true;
COMMENTS_PER_PAGE = 10;
RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
async reply() {
await this.$apollo.mutate({
mutation: REPLY_TO_DISCUSSION,
variables: {
discussionId: this.discussion.id,
text: this.newComment,
},
update: (store, { data: { replyToDiscussion } }) => {
const discussionData = store.readQuery<{
discussion: IDiscussion;
}>({
query: GET_DISCUSSION,
variables: {
slug: this.slug,
page: this.page,
},
});
if (!discussionData) return;
const { discussion } = discussionData;
discussion.lastComment = replyToDiscussion.lastComment;
discussion.comments.elements.push(replyToDiscussion.lastComment);
discussion.comments.total += 1;
store.writeQuery({
query: GET_DISCUSSION,
variables: { slug: this.slug, page: this.page },
data: { discussion },
});
},
// We don't need to handle cache update since there's the subscription that handles this for us
});
this.newComment = "";
}
async loadMoreComments() {
if (!this.hasMoreComments) return;
this.page += 1;
try {
await this.$apollo.queries.discussion.fetchMore({
// New variables
variables: {
slug: this.slug,
page: this.page,
limit: this.COMMENTS_PER_PAGE,
},
// Transform the previous result with new data
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult) return previousResult;
const newComments = fetchMoreResult.discussion.comments.elements;
this.hasMoreComments = newComments.length === 1;
const { discussion } = previousResult;
discussion.comments.elements = [
...previousResult.discussion.comments.elements,
...newComments,
];
return { discussion };
},
});
} catch (e) {
console.error(e);
}
}
async updateDiscussion() {
await this.$apollo.mutate({
mutation: UPDATE_DISCUSSION,
variables: {
discussionId: this.discussion.id,
title: this.newTitle,
},
update: (store, { data: { updateDiscussion } }) => {
const discussionData = store.readQuery<{
discussion: IDiscussion;
}>({
query: GET_DISCUSSION,
variables: {
slug: this.slug,
page: this.page,
},
});
if (!discussionData) return;
const { discussion } = discussionData;
discussion.title = updateDiscussion.title;
store.writeQuery({
query: GET_DISCUSSION,
variables: { slug: this.slug, page: this.page },
data: { discussion },
});
},
});
this.editTitleMode = false;
}
async deleteConversation() {
await this.$apollo.mutate({
mutation: DELETE_DISCUSSION,
variables: {
discussionId: this.discussion.id,
},
});
if (this.discussion.actor) {
return this.$router.push({
name: RouteName.DISCUSSION_LIST,
params: { preferredUsername: usernameWithDomain(this.discussion.actor) },
});
}
}
async handleErrors(errors: GraphQLError[]) {
if (errors[0].message.includes("No such discussion")) {
await this.$router.push({ name: RouteName.PAGE_NOT_FOUND });
}
}
mounted() {
window.addEventListener("scroll", this.handleScroll);
}
destroyed() {
window.removeEventListener("scroll", this.handleScroll);
}
handleScroll() {
const scrollTop =
(document.documentElement && document.documentElement.scrollTop) || document.body.scrollTop;
const scrollHeight =
(document.documentElement && document.documentElement.scrollHeight) ||
document.body.scrollHeight;
const clientHeight = document.documentElement.clientHeight || window.innerHeight;
const scrolledToBottom = Math.ceil(scrollTop + clientHeight + 800) >= scrollHeight;
if (scrolledToBottom) {
this.loadMoreComments();
}
}
}
</script>
<style lang="scss" scoped>
div.container.section {
background: white;
padding: 1rem 5% 4rem;
div.discussion-title {
margin-bottom: 0.75rem;
h2.title {
span {
cursor: pointer;
}
}
form.title-edit {
div.control {
margin-bottom: 0.75rem;
}
}
}
}
</style>

View File

@@ -17,7 +17,7 @@
<li class="is-active">
<router-link
:to="{
name: RouteName.CONVERSATION_LIST,
name: RouteName.DISCUSSION_LIST,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Discussions") }}</router-link
@@ -26,17 +26,17 @@
</ul>
</nav>
<section>
<div v-if="group.conversations.elements.length > 0">
<conversation-list-item
:conversation="conversation"
v-for="conversation in group.conversations.elements"
:key="conversation.id"
<div v-if="group.discussions.elements.length > 0">
<discussion-list-item
:discussion="discussion"
v-for="discussion in group.discussions.elements"
:key="discussion.id"
/>
</div>
<b-button
tag="router-link"
:to="{
name: RouteName.CREATE_CONVERSATION,
name: RouteName.CREATE_DISCUSSION,
params: { preferredUsername: this.preferredUsername },
}"
>{{ $t("New discussion") }}</b-button
@@ -48,11 +48,11 @@
import { Component, Prop, Vue } from "vue-property-decorator";
import { FETCH_GROUP } from "@/graphql/actor";
import { IGroup, usernameWithDomain } from "@/types/actor";
import ConversationListItem from "@/components/Conversation/ConversationListItem.vue";
import DiscussionListItem from "@/components/Discussion/DiscussionListItem.vue";
import RouteName from "../../router/name";
@Component({
components: { ConversationListItem },
components: { DiscussionListItem },
apollo: {
group: {
query: FETCH_GROUP,
@@ -66,8 +66,17 @@ import RouteName from "../../router/name";
},
},
},
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
title: this.$t("Discussions") as string,
// all titles will be injected into this template
titleTemplate: "%s | Mobilizon",
};
},
})
export default class ConversationsList extends Vue {
export default class DiscussionsList extends Vue {
@Prop({ type: String, required: true }) preferredUsername!: string;
group!: IGroup;

View File

@@ -777,10 +777,8 @@ export default class Event extends EventMixin {
let reporterId = null;
if (this.currentActor.id) {
reporterId = this.currentActor.id;
} else {
if (this.config.anonymous.reports.allowed) {
reporterId = this.config.anonymous.actorId;
}
} else if (this.config.anonymous.reports.allowed) {
reporterId = this.config.anonymous.actorId;
}
if (!reporterId) return;
try {

View File

@@ -171,7 +171,7 @@ export default class MyEvents extends Vue {
static monthlyParticipations(
participations: IParticipant[],
revertSort: boolean = false
revertSort = false
): Map<string, Participant[]> {
const res = participations.filter(
({ event, role }) => event.beginsOn != null && role !== ParticipantRole.REJECTED

View File

@@ -91,7 +91,7 @@
<span v-if="props.row.actor.name">{{ props.row.actor.name }}</span
><br />
<span class="is-size-7 has-text-grey"
>@{{ props.row.actor.preferredUsername }}</span
>@{{ usernameWithDomain(props.row.actor) }}</span
>
</span>
<span v-else>
@@ -184,6 +184,7 @@
<script lang="ts">
import { Component, Prop, Vue, Watch, Ref } from "vue-property-decorator";
import { DataProxy } from "apollo-cache";
import {
IEvent,
IEventParticipantStats,
@@ -192,13 +193,11 @@ import {
ParticipantRole,
} from "../../types/event.model";
import { PARTICIPANTS, UPDATE_PARTICIPANT } from "../../graphql/event";
import ParticipantCard from "../../components/Account/ParticipantCard.vue";
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
import { IPerson } from "../../types/actor";
import { IPerson, usernameWithDomain } from "../../types/actor";
import { CONFIG } from "../../graphql/config";
import { IConfig } from "../../types/config.model";
import { Paginate } from "../../types/paginate";
import { DataProxy } from "apollo-cache";
import { nl2br } from "../../utils/html";
import { asyncForEach } from "../../utils/asyncForEach";
import RouteName from "../../router/name";
@@ -207,9 +206,6 @@ const PARTICIPANTS_PER_PAGE = 10;
const MESSAGE_ELLIPSIS_LENGTH = 130;
@Component({
components: {
ParticipantCard,
},
apollo: {
currentActor: {
query: CURRENT_ACTOR_CLIENT,
@@ -259,6 +255,8 @@ export default class Participants extends Vue {
RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
@Ref("queueTable") readonly queueTable!: any;
mounted() {

View File

@@ -1,60 +1,71 @@
<template>
<div class="container is-widescreen">
<div
v-if="group && groupMemberships && groupMemberships.includes(group.id)"
class="block-container"
>
<div class="block-column">
<nav class="breadcrumb" aria-label="breadcrumbs" v-if="group">
<ul>
<li>
<router-link :to="{ name: RouteName.MY_GROUPS }">{{ $t("My groups") }}</router-link>
</li>
<li class="is-active">
<router-link
:to="{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group.preferredUsername) },
}"
>{{ group.name }}</router-link
>
</li>
</ul>
</nav>
<section class="presentation">
<div class="media">
<div class="media-left">
<figure class="image is-128x128" v-if="group.avatar">
<img :src="group.avatar.url" />
</figure>
<b-icon v-else size="is-large" icon="account-group" />
</div>
<div class="media-content">
<h1>{{ group.name }}</h1>
<small class="has-text-grey">@{{ usernameWithDomain(group) }}</small>
</div>
</div>
<div class="members">
<figure
class="image is-48x48"
:title="
$t(`@{username} ({role})`, {
username: usernameWithDomain(member.actor),
role: member.role,
})
"
v-for="member in group.members.elements"
:key="member.actor.id"
<div class="header">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.MY_GROUPS }">{{ $t("My groups") }}</router-link>
</li>
<li class="is-active">
<router-link
v-if="group.preferredUsername"
:to="{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ group.name }}</router-link
>
<img
class="is-rounded"
:src="member.actor.avatar.url"
v-if="member.actor.avatar"
alt
/>
<b-skeleton v-else :animated="true"></b-skeleton>
</li>
</ul>
</nav>
<header class="block-container presentation">
<div class="block-column media">
<div class="media-left">
<figure class="image rounded is-128x128" v-if="group.avatar">
<img :src="group.avatar.url" />
</figure>
<b-icon v-else size="is-large" icon="account-group" />
</div>
</section>
<div class="media-content">
<h1 v-if="group.name">{{ group.name }}</h1>
<b-skeleton v-else :animated="true" />
<small class="has-text-grey" v-if="group.preferredUsername"
>@{{ usernameWithDomain(group) }}</small
>
<b-skeleton v-else :animated="true" />
<br />
<router-link
v-if="isCurrentActorAGroupAdmin"
:to="{
name: RouteName.GROUP_PUBLIC_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
}"
class="button is-outlined"
>{{ $t("Group settings") }}</router-link
>
</div>
</div>
<div class="block-column members">
<figure
class="image is-48x48"
:title="
$t(`@{username} ({role})`, {
username: usernameWithDomain(member.actor),
role: member.role,
})
"
v-for="member in group.members.elements"
:key="member.actor.id"
>
<img class="is-rounded" :src="member.actor.avatar.url" v-if="member.actor.avatar" alt />
<b-icon v-else size="is-large" icon="account-circle" />
</figure>
</div>
</header>
</div>
<div v-if="isCurrentActorAGroupMember" class="block-container">
<div class="block-column">
<section>
<subtitle>{{ $t("Upcoming events") }}</subtitle>
<div class="organized-events-wrapper" v-if="group.organizedEvents.total > 0">
@@ -92,8 +103,17 @@
<section>
<subtitle>{{ $t("Public page") }}</subtitle>
<p>{{ $t("Followed by {count} persons", { count: group.members.total }) }}</p>
<b-button type="is-light">{{ $t("Edit biography") }}</b-button>
<b-button type="is-primary">{{ $t("Post a public message") }}</b-button>
<div v-if="group.posts.total > 0" class="posts-wrapper">
<post-list-item v-for="post in group.posts.elements" :key="post.id" :post="post" />
</div>
<router-link
:to="{
name: RouteName.POST_CREATE,
params: { preferredUsername: usernameWithDomain(group) },
}"
class="button is-primary"
>{{ $t("Post a public message") }}</router-link
>
</section>
<section>
<subtitle>{{ $t("Ongoing tasks") }}</subtitle>
@@ -122,15 +142,15 @@
</section>
<section>
<subtitle>{{ $t("Discussions") }}</subtitle>
<conversation-list-item
v-if="group.conversations.total > 0"
v-for="conversation in group.conversations.elements"
:key="conversation.id"
:conversation="conversation"
<discussion-list-item
v-if="group.discussions.total > 0"
v-for="discussion in group.discussions.elements"
:key="discussion.id"
:discussion="discussion"
/>
<router-link
:to="{
name: RouteName.CONVERSATION_LIST,
name: RouteName.DISCUSSION_LIST,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("View all discussions") }}</router-link
@@ -138,24 +158,13 @@
</section>
</div>
</div>
<div v-else-if="group">
<section class="presentation">
<div class="media">
<div class="media-left">
<figure class="image is-128x128" v-if="group.avatar">
<img :src="group.avatar.url" alt />
</figure>
<b-icon v-else size="is-large" icon="account-group" />
</div>
<div class="media-content">
<h2>{{ group.name }}</h2>
<small class="has-text-grey">@{{ usernameWithDomain(group) }}</small>
</div>
</div>
</section>
<b-message v-else-if="!group && $apollo.loading === false" type="is-danger">
{{ $t("No group found") }}
</b-message>
<div v-else class="public-container">
<section>
<subtitle>{{ $t("Upcoming events") }}</subtitle>
<div class="organized-events-wrapper" v-if="group.organizedEvents.total > 0">
<div class="organized-events-wrapper" v-if="group && group.organizedEvents.total > 0">
<EventMinimalistCard
v-for="event in group.organizedEvents.elements"
:event="event"
@@ -164,16 +173,24 @@
/>
<router-link :to="{}">{{ $t("View all upcoming events") }}</router-link>
</div>
<span v-else>{{ $t("No public upcoming events") }}</span>
<span v-else-if="group">{{ $t("No public upcoming events") }}</span>
<b-skeleton animated v-else></b-skeleton>
</section>
<!-- {{ group }}-->
<section>
<subtitle>{{ $t("Latest posts") }}</subtitle>
<div v-if="group && group.posts.elements">
<router-link
v-for="post in group.posts.elements"
:key="post.id"
:to="{ name: RouteName.POST, params: { slug: post.slug } }"
>
{{ post.title }}
</router-link>
</div>
<b-skeleton animated v-else></b-skeleton>
</section>
</div>
<b-message v-else-if="!group && $apollo.loading === false" type="is-danger">
{{ $t("No group found") }}
</b-message>
</div>
</template>
@@ -181,11 +198,19 @@
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import EventCard from "@/components/Event/EventCard.vue";
import { FETCH_GROUP, CURRENT_ACTOR_CLIENT, PERSON_MEMBERSHIPS } from "@/graphql/actor";
import { IActor, IGroup, IPerson, usernameWithDomain } from "@/types/actor";
import {
IActor,
IGroup,
IPerson,
usernameWithDomain,
Group as GroupModel,
MemberRole,
} from "@/types/actor";
import Subtitle from "@/components/Utils/Subtitle.vue";
import CompactTodo from "@/components/Todo/CompactTodo.vue";
import EventMinimalistCard from "@/components/Event/EventMinimalistCard.vue";
import ConversationListItem from "@/components/Conversation/ConversationListItem.vue";
import DiscussionListItem from "@/components/Discussion/DiscussionListItem.vue";
import PostListItem from "@/components/Post/PostListItem.vue";
import ResourceItem from "@/components/Resource/ResourceItem.vue";
import FolderItem from "@/components/Resource/FolderItem.vue";
import RouteName from "../../router/name";
@@ -214,7 +239,8 @@ import RouteName from "../../router/name";
currentActor: CURRENT_ACTOR_CLIENT,
},
components: {
ConversationListItem,
DiscussionListItem,
PostListItem,
EventMinimalistCard,
CompactTodo,
Subtitle,
@@ -243,7 +269,7 @@ export default class Group extends Vue {
person!: IPerson;
group!: IGroup;
group: IGroup = new GroupModel();
loading = true;
@@ -272,18 +298,63 @@ export default class Group extends Vue {
if (!this.person || !this.person.id) return undefined;
return this.person.memberships.elements.map(({ parent: { id } }) => id);
}
get isCurrentActorAGroupMember(): boolean {
return this.groupMemberships != undefined && this.groupMemberships.includes(this.group.id);
}
get isCurrentActorAGroupAdmin(): boolean {
return (
this.person &&
this.person.memberships.elements.some(
({ parent: { id }, role }) => id === this.group.id && role === MemberRole.ADMINISTRATOR
)
);
}
}
</script>
<style lang="scss" scoped>
@import "../../variables.scss";
div.container {
background: white;
margin-bottom: 3rem;
padding: 2rem 0;
.header,
.public-container {
margin: auto 2rem;
display: flex;
flex-direction: column;
}
.block-container {
display: flex;
flex-wrap: wrap;
&.presentation {
border: 2px solid $purple-2;
padding: 10px 0;
h1 {
color: $purple-1;
font-size: 2rem;
font-weight: 500;
}
.button.is-outlined {
border-color: $purple-2;
}
}
.members {
display: flex;
figure:not(:first-child) {
margin-left: -10px;
}
}
.block-column {
flex: 1;
margin: 0 2rem;
@@ -293,10 +364,8 @@ div.container {
display: block;
}
&.presentation {
.members {
display: flex;
}
.posts-wrapper {
padding-bottom: 1rem;
}
.organized-events-wrapper {

View File

@@ -3,15 +3,31 @@
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.GROUP }">{{ group.name }}</router-link>
<router-link
:to="{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ group.name }}</router-link
>
</li>
<li>
<router-link :to="{ name: RouteName.GROUP_SETTINGS }">{{ $t("Settings") }}</router-link>
<router-link
:to="{
name: RouteName.GROUP_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Settings") }}</router-link
>
</li>
<li class="is-active">
<router-link :to="{ name: RouteName.GROUP_MEMBERS_SETTINGS }">{{
$t("Members")
}}</router-link>
<router-link
:to="{
name: RouteName.GROUP_MEMBERS_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Members") }}</router-link
>
</li>
</ul>
</nav>
@@ -29,26 +45,127 @@
</b-field>
</form>
<h1>{{ $t("Group Members") }} ({{ group.members.total }})</h1>
<b-field :label="$t('Status')" horizontal>
<b-select v-model="roles">
<option value="">
{{ $t("Everything") }}
</option>
<option :value="MemberRole.ADMINISTRATOR">
{{ $t("Administrator") }}
</option>
<option :value="MemberRole.MODERATOR">
{{ $t("Moderator") }}
</option>
<option :value="MemberRole.MEMBER">
{{ $t("Member") }}
</option>
<option :value="MemberRole.INVITED">
{{ $t("Invited") }}
</option>
<option :value="MemberRole.NOT_APPROVED">
{{ $t("Not approved") }}
</option>
<option :value="MemberRole.REJECTED">
{{ $t("Rejected") }}
</option>
</b-select>
</b-field>
<b-table
:data="group.members.elements"
ref="queueTable"
:loading="this.$apollo.loading"
paginated
backend-pagination
:pagination-simple="true"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
:total="group.members.total"
:per-page="MEMBERS_PER_PAGE"
backend-sorting
:default-sort-direction="'desc'"
:default-sort="['insertedAt', 'desc']"
@page-change="(newPage) => (page = newPage)"
@sort="(field, order) => $emit('sort', field, order)"
>
<template slot-scope="props">
<b-table-column field="actor.preferredUsername" :label="$t('Member')">
<article class="media">
<figure class="media-left image is-48x48" v-if="props.row.actor.avatar">
<img class="is-rounded" :src="props.row.actor.avatar.url" alt="" />
</figure>
<b-icon class="media-left" v-else size="is-large" icon="account-circle" />
<div class="media-content">
<div class="content">
<span v-if="props.row.actor.name">{{ props.row.actor.name }}</span
><br />
<span class="is-size-7 has-text-grey"
>@{{ usernameWithDomain(props.row.actor) }}</span
>
</div>
</div>
</article>
</b-table-column>
<b-table-column field="role" :label="$t('Role')">
<b-tag type="is-primary" v-if="props.row.role === MemberRole.ADMINISTRATOR">
{{ $t("Administrator") }}
</b-tag>
<b-tag type="is-primary" v-else-if="props.row.role === MemberRole.MODERATOR">
{{ $t("Moderator") }}
</b-tag>
<b-tag v-else-if="props.row.role === MemberRole.MEMBER">
{{ $t("Member") }}
</b-tag>
<b-tag type="is-warning" v-else-if="props.row.role === MemberRole.NOT_APPROVED">
{{ $t("Not approved") }}
</b-tag>
<b-tag type="is-danger" v-else-if="props.row.role === MemberRole.REJECTED">
{{ $t("Rejected") }}
</b-tag>
<b-tag type="is-danger" v-else-if="props.row.role === MemberRole.INVITED">
{{ $t("Invited") }}
</b-tag>
</b-table-column>
<b-table-column field="insertedAt" :label="$t('Date')">
<span class="has-text-centered">
{{ props.row.insertedAt | formatDateString }}<br />{{
props.row.insertedAt | formatTimeString
}}
</span>
</b-table-column>
</template>
<template slot="empty">
<section class="section">
<div class="content has-text-grey has-text-centered">
<p>{{ $t("No member matches the filters") }}</p>
</div>
</section>
</template>
</b-table>
<pre>{{ group.members }}</pre>
</section>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import { Component, Vue, Watch } from "vue-property-decorator";
import RouteName from "../../router/name";
import { FETCH_GROUP } from "../../graphql/actor";
import { INVITE_MEMBER } from "../../graphql/member";
import { IGroup } from "../../types/actor";
import { IMember } from "../../types/actor/group.model";
import { INVITE_MEMBER, GROUP_MEMBERS } from "../../graphql/member";
import { IGroup, usernameWithDomain } from "../../types/actor";
import { IMember, MemberRole } from "../../types/actor/group.model";
@Component({
apollo: {
group: {
query: FETCH_GROUP,
query: GROUP_MEMBERS,
// fetchPolicy: "network-only",
variables() {
return {
name: this.$route.params.preferredUsername,
page: 1,
limit: this.MEMBERS_PER_PAGE,
roles: this.roles,
};
},
skip() {
@@ -66,6 +183,23 @@ export default class GroupMembers extends Vue {
newMemberUsername = "";
MemberRole = MemberRole;
roles: MemberRole | "" = "";
page = 1;
MEMBERS_PER_PAGE = 10;
usernameWithDomain = usernameWithDomain;
mounted() {
const roleQuery = this.$route.query.role as string;
if (Object.values(MemberRole).includes(roleQuery as MemberRole)) {
this.roles = roleQuery as MemberRole;
}
}
async inviteMember() {
await this.$apollo.mutate<{ inviteMember: IMember }>({
mutation: INVITE_MEMBER,
@@ -75,5 +209,32 @@ export default class GroupMembers extends Vue {
},
});
}
@Watch("page")
loadMoreMembers() {
this.$apollo.queries.event.fetchMore({
// New variables
variables: {
page: this.page,
limit: this.MEMBERS_PER_PAGE,
},
// Transform the previous result with new data
updateQuery: (previousResult, { fetchMoreResult }) => {
const oldMembers = previousResult.group.members;
const newMembers = fetchMoreResult.group.members;
return {
group: {
...previousResult.event,
members: {
elements: [...oldMembers.elements, ...newMembers.elements],
total: newMembers.total,
__typename: oldMembers.__typename,
},
},
};
},
});
}
}
</script>

View File

@@ -0,0 +1,91 @@
<template>
<div>
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link
:to="{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ group.name }}</router-link
>
</li>
<li>
<router-link
:to="{
name: RouteName.GROUP_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Settings") }}</router-link
>
</li>
<li class="is-active">
<router-link
:to="{
name: RouteName.GROUP_PUBLIC_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Group settings") }}</router-link
>
</li>
</ul>
</nav>
<section class="container section">
<form @submit.prevent="updateGroup">
<b-field :label="$t('Group name')">
<b-input v-model="group.name" />
</b-field>
<b-field :label="$t('Group short description')">
<b-input type="textarea" v-model="group.summary"
/></b-field>
<b-button native-type="submit" type="is-primary">{{ $t("Update group") }}</b-button>
</form>
</section>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import RouteName from "../../router/name";
import { FETCH_GROUP, UPDATE_GROUP } from "../../graphql/actor";
import { IGroup, usernameWithDomain } from "../../types/actor";
import { IMember, Group } from "../../types/actor/group.model";
import { Paginate } from "../../types/paginate";
@Component({
apollo: {
group: {
query: FETCH_GROUP,
variables() {
return {
name: this.$route.params.preferredUsername,
};
},
skip() {
return !this.$route.params.preferredUsername;
},
},
},
})
export default class GroupSettings extends Vue {
group: IGroup = new Group();
loading = true;
RouteName = RouteName;
newMemberUsername = "";
usernameWithDomain = usernameWithDomain;
async updateGroup() {
await this.$apollo.mutate<{ updateGroup: IGroup }>({
mutation: UPDATE_GROUP,
variables: {
...this.group,
},
});
}
}
</script>

View File

@@ -36,7 +36,7 @@ import { ACCEPT_INVITATION } from "../../graphql/member";
InvitationCard,
},
apollo: {
paginatedGroups: {
membershipsPages: {
query: LOGGED_USER_MEMBERSHIPS,
fetchPolicy: "network-only",
variables: {
@@ -57,18 +57,22 @@ import { ACCEPT_INVITATION } from "../../graphql/member";
},
})
export default class MyEvents extends Vue {
paginatedGroups!: Paginate<IMember>;
membershipsPages!: Paginate<IMember>;
RouteName = RouteName;
get invitations() {
if (!this.paginatedGroups) return [];
return this.paginatedGroups.elements.filter((member) => member.role === MemberRole.INVITED);
if (!this.membershipsPages) return [];
return this.membershipsPages.elements.filter(
(member: IMember) => member.role === MemberRole.INVITED
);
}
get memberships() {
if (!this.paginatedGroups) return [];
return this.paginatedGroups.elements.filter((member) => member.role !== MemberRole.INVITED);
if (!this.membershipsPages) return [];
return this.membershipsPages.elements.filter(
(member: IMember) => member.role !== MemberRole.INVITED
);
}
async acceptInvitation(id: string) {

View File

@@ -315,7 +315,7 @@ export default class Report extends Vue {
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."
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the discussion with the event creator or edit its event instead."
) as string,
confirmText: this.$t("Delete Event") as string,
type: "is-danger",

217
js/src/views/Posts/Edit.vue Normal file
View File

@@ -0,0 +1,217 @@
<template>
<form @submit.prevent="publish(false)">
<div class="container section">
<h1 class="title" v-if="isUpdate === true">
{{ $t("Edit post") }}
</h1>
<h1 class="title" v-else>
{{ $t("Add a new post") }}
</h1>
<subtitle>{{ $t("General information") }}</subtitle>
<picture-upload v-model="pictureFile" :textFallback="$t('Headline picture')" />
<b-field :label="$t('Title')">
<b-input size="is-large" aria-required="true" required v-model="post.title" />
</b-field>
<tag-input v-model="post.tags" :data="tags" path="title" />
<div class="field">
<label class="label">{{ $t("Post") }}</label>
<editor v-model="post.body" />
</div>
<subtitle>{{ $t("Who can view this post") }}</subtitle>
<div class="field">
<b-radio
v-model="post.visibility"
name="postVisibility"
:native-value="PostVisibility.PUBLIC"
>{{ $t("Visible everywhere on the web") }}</b-radio
>
</div>
<div class="field">
<b-radio
v-model="post.visibility"
name="postVisibility"
:native-value="PostVisibility.UNLISTED"
>{{ $t("Only accessible through link") }}</b-radio
>
</div>
<div class="field">
<b-radio
v-model="post.visibility"
name="postVisibility"
:native-value="PostVisibility.PRIVATE"
>{{ $t("Only accessible to members of the group") }}</b-radio
>
</div>
</div>
<nav class="navbar">
<div class="container">
<div class="navbar-menu">
<div class="navbar-end">
<span class="navbar-item">
<b-button type="is-text" @click="$router.go(-1)">{{ $t("Cancel") }}</b-button>
</span>
<span class="navbar-item">
<b-button type="is-danger is-outlined" @click="deletePost">{{
$t("Delete post")
}}</b-button>
</span>
<!-- If an post has been published we can't make it draft anymore -->
<span class="navbar-item" v-if="post.draft === true">
<b-button type="is-primary" outlined @click="publish(true)">{{
$t("Save draft")
}}</b-button>
</span>
<span class="navbar-item">
<b-button type="is-primary" native-type="submit">
<span v-if="isUpdate === false || post.draft === true">{{ $t("Publish") }}</span>
<span v-else>{{ $t("Update post") }}</span>
</b-button>
</span>
</div>
</div>
</div>
</nav>
</form>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
import { CURRENT_ACTOR_CLIENT, FETCH_GROUP } from "../../graphql/actor";
import { TAGS } from "../../graphql/tags";
import { CONFIG } from "../../graphql/config";
import { FETCH_POST, CREATE_POST, UPDATE_POST, DELETE_POST } from "../../graphql/post";
import { IPost, PostVisibility } from "../../types/post.model";
import Editor from "../../components/Editor.vue";
import { IGroup } from "../../types/actor";
import TagInput from "../../components/Event/TagInput.vue";
import RouteName from "../../router/name";
@Component({
apollo: {
currentActor: CURRENT_ACTOR_CLIENT,
tags: TAGS,
config: CONFIG,
post: {
query: FETCH_POST,
variables() {
return {
slug: this.slug,
};
},
skip() {
return !this.slug;
},
},
group: {
query: FETCH_GROUP,
variables() {
return {
name: this.preferredUsername,
};
},
skip() {
return !this.preferredUsername;
},
},
},
components: {
Editor,
TagInput,
},
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
title: this.isUpdate
? (this.$t("Edit post") as string)
: (this.$t("Add a new post") as string),
// all titles will be injected into this template
titleTemplate: "%s | Mobilizon",
};
},
})
export default class EditPost extends Vue {
@Prop({ required: false, type: String }) slug: undefined | string;
@Prop({ required: false, type: String }) preferredUsername!: string;
@Prop({ type: Boolean, default: false }) isUpdate!: boolean;
post: IPost = {
title: "",
body: "",
local: true,
draft: true,
visibility: PostVisibility.PUBLIC,
tags: [],
};
group!: IGroup;
PostVisibility = PostVisibility;
async publish(draft: boolean) {
if (this.isUpdate) {
const { data } = await this.$apollo.mutate({
mutation: UPDATE_POST,
variables: {
id: this.post.id,
title: this.post.title,
body: this.post.body,
tags: (this.post.tags || []).map(({ title }) => title),
visibility: this.post.visibility,
draft,
},
});
if (data && data.updatePost) {
return this.$router.push({ name: RouteName.POST, params: { slug: data.updatePost.slug } });
}
} else {
const { data } = await this.$apollo.mutate({
mutation: CREATE_POST,
variables: {
...this.post,
tags: (this.post.tags || []).map(({ title }) => title),
attributedToId: this.group.id,
draft,
},
});
if (data && data.createPost) {
return this.$router.push({ name: RouteName.POST, params: { slug: data.createPost.slug } });
}
}
}
async deletePost() {
const { data } = await this.$apollo.mutate({
mutation: DELETE_POST,
variables: {
id: this.post.id,
},
});
if (data && this.post.attributedTo) {
return this.$router.push({
name: RouteName.POSTS,
params: { preferredUsername: this.post.attributedTo.preferredUsername },
});
}
}
}
</script>
<style lang="scss" scoped>
form {
nav.navbar {
position: sticky;
bottom: 0;
min-height: 2rem;
.container {
min-height: 2rem;
}
}
}
</style>

View File

@@ -0,0 +1,86 @@
<template>
<div>
<section class="section container">
<nav class="breadcrumb" aria-label="breadcrumbs" v-if="group">
<ul>
<li>
<router-link :to="{ name: RouteName.MY_GROUPS }">{{ $t("My groups") }}</router-link>
</li>
<li>
<router-link
v-if="group"
:to="{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ group.name || group.preferredUsername }}</router-link
>
<b-skeleton v-else :animated="true"></b-skeleton>
</li>
<li class="is-active">
<router-link
v-if="group"
:to="{
name: RouteName.POSTS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Posts") }}</router-link
>
<b-skeleton v-else :animated="true"></b-skeleton>
</li>
</ul>
</nav>
<div v-if="group">
<router-link
v-for="post in group.posts.elements"
:key="post.id"
:to="{ name: RouteName.POST, params: { slug: post.slug } }"
>
{{ post.title }}
</router-link>
</div>
<b-skeleton v-else :animated="true"></b-skeleton>
</section>
<pre>{{ group }}</pre>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
import { FETCH_GROUP_POSTS } from "../../graphql/post";
import { Paginate } from "../../types/paginate";
import { IPost } from "../../types/post.model";
import { IGroup, usernameWithDomain } from "../../types/actor";
import RouteName from "../../router/name";
@Component({
apollo: {
group: {
query: FETCH_GROUP_POSTS,
variables() {
return {
preferredUsername: this.preferredUsername,
};
},
// update(data) {
// console.log(data);
// return data.group.posts;
// },
skip() {
return !this.preferredUsername;
},
},
},
})
export default class PostList extends Vue {
@Prop({ required: true, type: String }) preferredUsername!: string;
group!: IGroup;
posts!: Paginate<IPost>;
RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
}
</script>

184
js/src/views/Posts/Post.vue Normal file
View File

@@ -0,0 +1,184 @@
<template>
<div>
<article class="container" v-if="post">
<section class="heading-section">
<h1 class="title">{{ post.title }}</h1>
<i18n tag="span" path="By {author}" class="authors">
<router-link
slot="author"
:to="{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(post.attributedTo) },
}"
>{{ post.attributedTo.name }}</router-link
>
</i18n>
<p class="published" v-if="!post.draft">{{ post.publishAt | formatDateTimeString }}</p>
<p class="buttons" v-if="isCurrentActorMember">
<b-tag type="is-warning" size="is-medium" v-if="post.draft">{{ $t("Draft") }}</b-tag>
<router-link
:to="{ name: RouteName.POST_EDIT, params: { slug: post.slug } }"
tag="button"
class="button is-text"
>{{ $t("Edit") }}</router-link
>
</p>
</section>
<section v-html="post.body" class="content" />
<section class="tags">
<router-link
v-for="tag in post.tags"
:key="tag.title"
:to="{ name: RouteName.TAG, params: { tag: tag.title } }"
>
<tag>{{ tag.title }}</tag>
</router-link>
</section>
</article>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
import Editor from "@/components/Editor.vue";
import { GraphQLError } from "graphql";
import { CURRENT_ACTOR_CLIENT, PERSON_MEMBERSHIPS, FETCH_GROUP } from "../../graphql/actor";
import { TAGS } from "../../graphql/tags";
import { CONFIG } from "../../graphql/config";
import { FETCH_POST, CREATE_POST } from "../../graphql/post";
import { IPost, PostVisibility } from "../../types/post.model";
import { IGroup, IMember, usernameWithDomain } from "../../types/actor";
import RouteName from "../../router/name";
import Tag from "../../components/Tag.vue";
@Component({
apollo: {
currentActor: CURRENT_ACTOR_CLIENT,
memberships: {
query: PERSON_MEMBERSHIPS,
variables() {
return {
id: this.currentActor.id,
};
},
update: (data) => data.person.memberships.elements,
skip() {
return !this.currentActor || !this.currentActor.id;
},
},
post: {
query: FETCH_POST,
variables() {
return {
slug: this.slug,
};
},
skip() {
return !this.slug;
},
error({ graphQLErrors }) {
this.handleErrors(graphQLErrors);
},
},
},
components: {
Tag,
},
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
title: this.post ? this.post.title : "",
// all titles will be injected into this template
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
titleTemplate: this.post ? "%s | Mobilizon" : "Mobilizon",
};
},
})
export default class Post extends Vue {
@Prop({ required: true, type: String }) slug!: string;
post!: IPost;
memberships!: IMember[];
RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
get isCurrentActorMember(): boolean {
if (!this.post.attributedTo || !this.memberships) return false;
return this.memberships.map(({ parent: { id } }) => id).includes(this.post.attributedTo.id);
}
async handleErrors(errors: GraphQLError[]) {
if (errors[0].message.includes("No such post")) {
await this.$router.push({ name: RouteName.PAGE_NOT_FOUND });
}
}
}
</script>
<style lang="scss" scoped>
@import "../../variables.scss";
article {
section.heading-section {
text-align: center;
h1.title {
margin: 0 auto;
padding-top: 3rem;
font-size: 3rem;
font-weight: 700;
}
.authors {
margin-top: 2rem;
display: inline-block;
}
.published {
margin-top: 1rem;
color: rgba(0, 0, 0, 0.5);
}
&::after {
height: 0.4rem;
margin-bottom: 2rem;
content: " ";
display: block;
width: 100%;
background-color: $purple-1;
margin-top: 1rem;
}
.buttons {
justify-content: center;
}
}
section.content {
font-size: 1.1rem;
}
section.tags {
padding-bottom: 5rem;
a {
text-decoration: none;
}
span {
&.tag {
margin: 0 2px;
}
}
}
background: $white;
max-width: 700px;
margin: 0 auto;
padding: 0 3rem;
}
</style>

View File

@@ -118,7 +118,7 @@
</div>
</transition-group>
</draggable>
<div class="content has-text-centered has-text-grey">
<div class="content has-text-centered has-text-grey" v-if="resource.children.total === 0">
<p>{{ $t("No resources in this folder") }}</p>
</div>
</section>
@@ -470,12 +470,12 @@ export default class Resources extends Mixins(ResourceMixin) {
handleRename(resource: IResource) {
this.renameModal = true;
this.updatedResource = Object.assign({}, resource);
this.updatedResource = { ...resource };
}
handleMove(resource: IResource) {
this.moveModal = true;
this.updatedResource = Object.assign({}, resource);
this.updatedResource = { ...resource };
}
async moveResource(resource: IResource, oldParent: IResource | undefined) {