Merge branch 'group-posts' into 'master'

Introduce group posts

See merge request framasoft/mobilizon!516
This commit is contained in:
Thomas Citharel
2020-07-30 17:16:53 +02:00
249 changed files with 11886 additions and 5023 deletions

View File

@@ -59,6 +59,7 @@ import { initializeCurrentActor } from "./utils/auth";
import { CONFIG } from "./graphql/config";
import { IConfig } from "./types/config.model";
import { ICurrentUser } from "./types/current-user.model";
@Component({
apollo: {
currentUser: CURRENT_USER_CLIENT,
@@ -72,6 +73,7 @@ import { ICurrentUser } from "./types/current-user.model";
})
export default class App extends Vue {
config!: IConfig;
currentUser!: ICurrentUser;
async created() {

View File

@@ -138,7 +138,7 @@ import { IEvent, CommentModeration } from "../../types/event.model";
import ReportModal from "../Report/ReportModal.vue";
import { IReport } from "../../types/report.model";
import { CREATE_REPORT } from "../../graphql/report";
import PopoverActorCard from "../../components/Account/PopoverActorCard.vue";
import PopoverActorCard from "../Account/PopoverActorCard.vue";
@Component({
apollo: {

View File

@@ -12,7 +12,9 @@
<span>@{{ comment.actor.preferredUsername }}</span>
</div>
<div class="post-infos">
<span>{{ comment.updatedAt | formatDateTimeString }}</span>
<span :title="comment.insertedAt | formatDateTimeString">
{{ $timeAgo.format(comment.insertedAt, "twitter") || $t("Right now") }}</span
>
</div>
</div>
<div class="description-content" v-html="comment.text"></div>
@@ -21,10 +23,10 @@
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { IComment } from "../../types/comment.model";
import { IComment, CommentModel } from "../../types/comment.model";
@Component
export default class ConversationComment extends Vue {
export default class DiscussionComment extends Vue {
@Prop({ required: true, type: Object }) comment!: IComment;
}
</script>

View File

@@ -1,42 +1,45 @@
<template>
<router-link
class="conversation-minimalist-card-wrapper"
:to="{ name: RouteName.CONVERSATION, params: { slug: conversation.slug, id: conversation.id } }"
class="discussion-minimalist-card-wrapper"
:to="{ name: RouteName.DISCUSSION, params: { slug: discussion.slug, id: discussion.id } }"
>
<div class="media-left">
<figure class="image is-32x32" v-if="conversation.lastComment.actor.avatar">
<img class="is-rounded" :src="conversation.lastComment.actor.avatar.url" alt />
<figure class="image is-32x32" v-if="discussion.lastComment.actor.avatar">
<img class="is-rounded" :src="discussion.lastComment.actor.avatar.url" alt />
</figure>
<b-icon v-else size="is-medium" icon="account-circle" />
</div>
<div class="title-info-wrapper">
<p class="conversation-minimalist-title">{{ conversation.title }}</p>
<p class="discussion-minimalist-title">{{ discussion.title }}</p>
<div class="has-text-grey">{{ htmlTextEllipsis }}</div>
</div>
</router-link>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { IConversation } from "../../types/conversations";
import { IDiscussion } from "../../types/discussions";
import RouteName from "../../router/name";
@Component
export default class ConversationListItem extends Vue {
@Prop({ required: true, type: Object }) conversation!: IConversation;
export default class DiscussionListItem extends Vue {
@Prop({ required: true, type: Object }) discussion!: IDiscussion;
RouteName = RouteName;
get htmlTextEllipsis() {
const element = document.createElement("div");
element.innerHTML = this.conversation.lastComment.text
.replace(/<br\s*\/?>/gi, " ")
.replace(/<p>/gi, " ");
if (this.discussion.lastComment) {
element.innerHTML = this.discussion.lastComment.text
.replace(/<br\s*\/?>/gi, " ")
.replace(/<p>/gi, " ");
}
return element.innerText;
}
}
</script>
<style lang="scss" scoped>
.conversation-minimalist-card-wrapper {
.discussion-minimalist-card-wrapper {
text-decoration: none;
display: flex;
width: 100%;
color: initial;
@@ -50,7 +53,7 @@ export default class ConversationListItem extends Vue {
.title-info-wrapper {
flex: 2;
.conversation-minimalist-title {
.discussion-minimalist-title {
color: #3c376e;
font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial, serif;
font-size: 1.25rem;

View File

@@ -247,6 +247,7 @@ export default class EventListCard extends mixins(ActorMixin, EventMixin) {
* Delete the event
*/
async openDeleteEventModalWrapper() {
// @ts-ignore
await this.openDeleteEventModal(this.participation.event, this.currentActor);
}

View File

@@ -87,13 +87,16 @@ import DiasporaLogo from "../../assets/diaspora-icon.svg?inline";
})
export default class ShareEventModal extends Vue {
@Prop({ type: Object, required: true }) event!: IEvent;
@Prop({ type: Boolean, required: false, default: true }) eventCapacityOK!: boolean;
@Ref("eventURLInput") readonly eventURLInput!: any;
EventVisibility = EventVisibility;
EventStatus = EventStatus;
showCopiedTooltip: boolean = false;
showCopiedTooltip = false;
get twitterShareUrl(): string {
return `https://twitter.com/intent/tweet?url=${encodeURIComponent(this.event.url)}&text=${

View File

@@ -23,7 +23,7 @@
<router-link
:to="{
name: RouteName.GROUP,
params: { preferredUsername: member.parent.preferredUsername },
params: { preferredUsername: usernameWithDomain(member.parent) },
}"
>
<h3>{{ member.parent.name }}</h3>
@@ -57,7 +57,7 @@
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { IGroup, IMember } from "@/types/actor";
import { IGroup, IMember, usernameWithDomain } from "@/types/actor";
import RouteName from "../../router/name";
@Component
@@ -65,6 +65,8 @@ export default class InvitationCard extends Vue {
@Prop({ required: true }) member!: IMember;
RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
}
</script>

View File

@@ -7,6 +7,7 @@ import { Component, Prop, Vue } from "vue-property-decorator";
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
import MobilizonLogo from "../assets/mobilizon_logo.svg?inline";
@Component({
components: {
MobilizonLogo,

View File

@@ -0,0 +1,48 @@
<template>
<router-link
class="post-minimalist-card-wrapper"
:to="{ name: RouteName.POST, params: { slug: post.slug } }"
>
<div class="title-info-wrapper">
<p class="post-minimalist-title">{{ post.title }}</p>
<small class="has-text-grey">{{ $timeAgo.format(new Date(post.insertedAt)) }}</small>
</div>
</router-link>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import RouteName from "../../router/name";
import { IPost } from "../../types/post.model";
@Component
export default class PostListItem extends Vue {
@Prop({ required: true, type: Object }) post!: IPost;
RouteName = RouteName;
}
</script>
<style lang="scss" scoped>
.post-minimalist-card-wrapper {
text-decoration: none;
display: flex;
width: 100%;
color: initial;
border-bottom: 1px solid #e9e9e9;
align-items: center;
.title-info-wrapper {
flex: 2;
.post-minimalist-title {
color: #3c376e;
font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial, serif;
font-size: 1rem;
font-weight: 700;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
}
}
</style>

View File

@@ -142,7 +142,7 @@ a {
position: relative;
.preview {
flex: 0 0 100px;
flex: 0 0 50px;
position: relative;
display: flex;
align-items: center;
@@ -159,7 +159,7 @@ a {
display: block;
font-weight: 500;
margin-bottom: 5px;
color: $background-color;
color: $primary;
overflow: hidden;
text-overflow: ellipsis;
text-decoration: none;

View File

@@ -81,7 +81,7 @@ a {
flex: 1;
.preview {
flex: 0 0 100px;
flex: 0 0 50px;
position: relative;
display: flex;
align-items: center;

View File

@@ -76,6 +76,7 @@ import { IResource } from "../../types/resource";
})
export default class ResourceSelector extends Vue {
@Prop({ required: true }) initialResource!: IResource;
@Prop({ required: true }) username!: string;
resource: IResource | undefined = this.initialResource.parent;

View File

@@ -13,6 +13,7 @@ import { Route } from "vue-router";
@Component
export default class SettingMenuItem extends Vue {
@Prop({ required: false, type: String }) title!: string;
@Prop({ required: true, type: Object }) to!: Route;
get isActive() {

View File

@@ -11,11 +11,13 @@
import { Component, Prop, Vue } from "vue-property-decorator";
import SettingMenuItem from "@/components/Settings/SettingMenuItem.vue";
import { Route } from "vue-router";
@Component({
components: { SettingMenuItem },
})
export default class SettingMenuSection extends Vue {
@Prop({ required: false, type: String }) title!: string;
@Prop({ required: true, type: Object }) to!: Route;
get sectionActive() {

View File

@@ -63,6 +63,7 @@ import { CURRENT_USER_CLIENT } from "../../graphql/user";
import { ICurrentUser, ICurrentUserRole } from "../../types/current-user.model";
import RouteName from "../../router/name";
@Component({
components: { SettingMenuSection, SettingMenuItem },
apollo: {

View File

@@ -24,6 +24,7 @@ import RouteName from "../../router/name";
import { UPDATE_TODO } from "../../graphql/todos";
import ActorAutoComplete from "../Account/ActorAutoComplete.vue";
import { IPerson } from "../../types/actor";
@Component({
components: { ActorAutoComplete },
})

View File

@@ -1,6 +1,7 @@
import gql from "graphql-tag";
import { CONVERSATION_BASIC_FIELDS_FRAGMENT } from "@/graphql/conversation";
import { DISCUSSION_BASIC_FIELDS_FRAGMENT } from "@/graphql/discussion";
import { RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT } from "@/graphql/resources";
import { POST_BASIC_FIELDS } from "./post";
export const FETCH_PERSON = gql`
query($username: String!) {
@@ -479,10 +480,16 @@ export const FETCH_GROUP = gql`
}
total
}
conversations {
discussions {
total
elements {
...ConversationBasicFields
...DiscussionBasicFields
}
}
posts {
total
elements {
...PostBasicFields
}
}
members {
@@ -497,6 +504,7 @@ export const FETCH_GROUP = gql`
url
}
}
insertedAt
}
total
}
@@ -537,9 +545,11 @@ export const FETCH_GROUP = gql`
}
}
}
${CONVERSATION_BASIC_FIELDS_FRAGMENT}
${DISCUSSION_BASIC_FIELDS_FRAGMENT}
${POST_BASIC_FIELDS}
${RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT}
`;
export const CREATE_GROUP = gql`
mutation CreateGroup(
$creatorActorId: ID!
@@ -571,6 +581,29 @@ export const CREATE_GROUP = gql`
}
`;
export const UPDATE_GROUP = gql`
mutation UpdateGroup(
$id: ID!
$name: String
$summary: String
$avatar: PictureInput
$banner: PictureInput
) {
createGroup(id: $id, name: $name, summary: $summary, banner: $banner, avatar: $avatar) {
id
preferredUsername
name
summary
avatar {
url
}
banner {
url
}
}
}
`;
export const SUSPEND_PROFILE = gql`
mutation SuspendProfile($id: ID!) {
suspendProfile(id: $id) {

View File

@@ -1,120 +0,0 @@
import gql from "graphql-tag";
export const CONVERSATION_BASIC_FIELDS_FRAGMENT = gql`
fragment ConversationBasicFields on Conversation {
id
title
slug
lastComment {
id
text
actor {
preferredUsername
avatar {
url
}
}
}
}
`;
export const CONVERSATION_FIELDS_FOR_REPLY_FRAGMENT = gql`
fragment ConversationFieldsReply on Conversation {
id
title
slug
lastComment {
id
text
updatedAt
actor {
id
preferredUsername
avatar {
url
}
}
}
actor {
id
preferredUsername
}
creator {
id
preferredUsername
}
}
`;
export const CONVERSATION_FIELDS_FRAGMENT = gql`
fragment ConversationFields on Conversation {
id
title
slug
lastComment {
id
text
updatedAt
}
actor {
id
preferredUsername
}
creator {
id
preferredUsername
}
}
`;
export const CREATE_CONVERSATION = gql`
mutation createConversation($title: String!, $creatorId: ID!, $actorId: ID!, $text: String!) {
createConversation(title: $title, text: $text, creatorId: $creatorId, actorId: $actorId) {
...ConversationFields
}
}
${CONVERSATION_FIELDS_FRAGMENT}
`;
export const REPLY_TO_CONVERSATION = gql`
mutation replyToConversation($conversationId: ID!, $text: String!) {
replyToConversation(conversationId: $conversationId, text: $text) {
...ConversationFieldsReply
}
}
${CONVERSATION_FIELDS_FOR_REPLY_FRAGMENT}
`;
export const GET_CONVERSATION = gql`
query getConversation($id: ID!, $page: Int, $limit: Int) {
conversation(id: $id) {
comments(page: $page, limit: $limit) {
total
elements {
id
text
actor {
id
avatar {
url
}
preferredUsername
}
insertedAt
updatedAt
}
}
...ConversationFields
}
}
${CONVERSATION_FIELDS_FRAGMENT}
`;
export const UPDATE_CONVERSATION = gql`
mutation updateConversation($conversationId: ID!, $title: String!) {
updateConversation(conversationId: $conversationId, title: $title) {
...ConversationFields
}
}
${CONVERSATION_FIELDS_FRAGMENT}
`;

View File

@@ -0,0 +1,158 @@
import gql from "graphql-tag";
export const DISCUSSION_BASIC_FIELDS_FRAGMENT = gql`
fragment DiscussionBasicFields on Discussion {
id
title
slug
lastComment {
id
text
actor {
id
preferredUsername
avatar {
url
}
}
}
}
`;
export const DISCUSSION_FIELDS_FOR_REPLY_FRAGMENT = gql`
fragment DiscussionFieldsReply on Discussion {
id
title
slug
lastComment {
id
text
updatedAt
actor {
id
preferredUsername
avatar {
url
}
}
}
actor {
id
preferredUsername
}
creator {
id
preferredUsername
}
}
`;
export const DISCUSSION_FIELDS_FRAGMENT = gql`
fragment DiscussionFields on Discussion {
id
title
slug
lastComment {
id
text
updatedAt
}
actor {
id
domain
name
preferredUsername
}
creator {
id
domain
name
preferredUsername
}
}
`;
export const CREATE_DISCUSSION = gql`
mutation createDiscussion($title: String!, $creatorId: ID!, $actorId: ID!, $text: String!) {
createDiscussion(title: $title, text: $text, creatorId: $creatorId, actorId: $actorId) {
...DiscussionFields
}
}
${DISCUSSION_FIELDS_FRAGMENT}
`;
export const REPLY_TO_DISCUSSION = gql`
mutation replyToDiscussion($discussionId: ID!, $text: String!) {
replyToDiscussion(discussionId: $discussionId, text: $text) {
...DiscussionFields
}
}
${DISCUSSION_FIELDS_FRAGMENT}
`;
export const GET_DISCUSSION = gql`
query getDiscussion($slug: String!, $page: Int, $limit: Int) {
discussion(slug: $slug) {
comments(page: $page, limit: $limit)
@connection(key: "discussion-comments", filter: ["slug"]) {
total
elements {
id
text
actor {
id
avatar {
url
}
name
domain
preferredUsername
}
insertedAt
updatedAt
}
}
...DiscussionFields
}
}
${DISCUSSION_FIELDS_FRAGMENT}
`;
export const UPDATE_DISCUSSION = gql`
mutation updateDiscussion($discussionId: ID!, $title: String!) {
updateDiscussion(discussionId: $discussionId, title: $title) {
...DiscussionFields
}
}
${DISCUSSION_FIELDS_FRAGMENT}
`;
export const DELETE_DISCUSSION = gql`
mutation deleteDiscussion($discussionId: ID!) {
deleteDiscussion(discussionId: $discussionId) {
id
}
}
`;
export const DISCUSSION_COMMENT_CHANGED = gql`
subscription($slug: String!) {
discussionCommentChanged(slug: $slug) {
id
lastComment {
id
text
updatedAt
insertedAt
actor {
id
preferredUsername
domain
avatar {
url
}
}
}
}
}
`;

View File

@@ -22,3 +22,31 @@ export const ACCEPT_INVITATION = gql`
}
}
`;
export const GROUP_MEMBERS = gql`
query($name: String!, $roles: String, $page: Int, $limit: Int) {
group(preferredUsername: $name) {
id
url
name
domain
preferredUsername
members(page: $page, limit: $limit, roles: $roles) {
elements {
role
actor {
id
name
domain
preferredUsername
avatar {
url
}
}
insertedAt
}
total
}
}
}
`;

151
js/src/graphql/post.ts Normal file
View File

@@ -0,0 +1,151 @@
import gql from "graphql-tag";
import { TAG_FRAGMENT } from "./tags";
export const POST_FRAGMENT = gql`
fragment PostFragment on Post {
id
title
slug
url
body
author {
id
preferredUsername
name
domain
avatar {
url
}
}
attributedTo {
id
preferredUsername
name
domain
avatar {
url
}
}
insertedAt
updatedAt
publishAt
draft
visibility
tags {
...TagFragment
}
}
${TAG_FRAGMENT}
`;
export const POST_BASIC_FIELDS = gql`
fragment PostBasicFields on Post {
id
title
slug
url
author {
id
preferredUsername
name
avatar {
url
}
}
attributedTo {
id
preferredUsername
name
avatar {
url
}
}
insertedAt
updatedAt
publishAt
draft
}
`;
export const FETCH_GROUP_POSTS = gql`
query GroupPosts($preferredUsername: String!, $page: Int, $limit: Int) {
group(preferredUsername: $preferredUsername) {
id
preferredUsername
domain
name
posts(page: $page, limit: $limit) {
total
elements {
...PostBasicFields
}
}
}
}
${POST_BASIC_FIELDS}
`;
export const FETCH_POST = gql`
query Post($slug: String!) {
post(slug: $slug) {
...PostFragment
}
}
${POST_FRAGMENT}
`;
export const CREATE_POST = gql`
mutation CreatePost(
$title: String!
$body: String
$attributedToId: ID!
$visibility: PostVisibility
$draft: Boolean
$tags: [String]
) {
createPost(
title: $title
body: $body
attributedToId: $attributedToId
visibility: $visibility
draft: $draft
tags: $tags
) {
...PostFragment
}
}
${POST_FRAGMENT}
`;
export const UPDATE_POST = gql`
mutation UpdatePost(
$id: ID!
$title: String
$body: String
$attributedToId: ID
$visibility: PostVisibility
$draft: Boolean
$tags: [String]
) {
updatePost(
id: $id
title: $title
body: $body
attributedToId: $attributedToId
visibility: $visibility
draft: $draft
tags: $tags
) {
...PostFragment
}
}
${POST_FRAGMENT}
`;
export const DELETE_POST = gql`
mutation DeletePost($id: ID!) {
deletePost(id: $id) {
id
}
}
`;

View File

@@ -1,6 +1,13 @@
import gql from "graphql-tag";
/* eslint-disable import/prefer-default-export */
export const TAG_FRAGMENT = gql`
fragment TagFragment on Tag {
id
slug
title
}
`;
export const TAGS = gql`
query {
tags {

View File

@@ -55,7 +55,7 @@
"Continue editing": "مواصلة التحرير",
"Country": "البلد",
"Create": "انشاء",
"Create a new conversation": "أنشئ محادثة جديدة",
"Create a new discussion": "أنشئ محادثة جديدة",
"Create a new event": "انشاء فعالية جديدة",
"Create a new group": "إنشاء فريق جديد",
"Create a new identity": "إنشاء هوية جديدة",
@@ -186,7 +186,7 @@
"My events": "فعالياتي",
"My identities": "هوياتي",
"Name": "الإسم",
"New conversation": "محادثة جديدة",
"New discussion": "محادثة جديدة",
"New email": "العنوان الجديد للبريد الإلكتروني",
"New folder": "مجلد جديد",
"New link": "رابط جديد",

View File

@@ -21,7 +21,7 @@
"An error has occurred.": "Адбылася памылка.",
"Approve": "Пацвердзіць",
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Вы сапраўды хочаце <b>выдаліць</b> гэты каментарый? Гэта дзеянне нельга адмяніць.",
"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.": "Вы сапраўды жадаеце <b>выдаліць</b> гэту падзею? Гэта дзеянне нельга адмяніць. Магчыма, варта замест гэтага пагаварыць з аўтарам ці аўтаркай падзеі ці адрэдагаваць падзею.",
"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.": "Вы сапраўды жадаеце <b>выдаліць</b> гэту падзею? Гэта дзеянне нельга адмяніць. Магчыма, варта замест гэтага пагаварыць з аўтарам ці аўтаркай падзеі ці адрэдагаваць падзею.",
"Are you sure you want to cancel the event creation? You'll lose all modifications.": "Вы сапраўды хочаце адмяніць стварэнне падзеі? Вы страціце ўсе свае рэдагаванні.",
"Are you sure you want to cancel the event edition? You'll lose all modifications.": "Вы сапраўды хочаце адмяніць рэдагаванне падзеі? Вы страціце ўсе рэдагаванні.",
"Are you sure you want to cancel your participation at event \"{title}\"?": "Вы сапраўды хочаце адмовіцца ад удзелу ў падзеі «{title}»?",

View File

@@ -30,7 +30,7 @@
"Approve": "Aprova",
"Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.": "Segur que voleu suprimir tot el compte? Ho perdràs tot. Les identitats, la configuració, els esdeveniments creats, els missatges i les participacions desapareixeran per sempre.",
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Segur que vols <b>esborrar</b> aquest comentari? Aquesta acció és irreversible.",
"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.": "Segur que vols <b>esborrar</b> aquesta activitat? Aquesta acció és irreversible. En comptes d'això, pots parlar amb la persona creadora de l'activitat o modificar l'activitat.",
"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.": "Segur que vols <b>esborrar</b> aquesta activitat? Aquesta acció és irreversible. En comptes d'això, pots parlar amb la persona creadora de l'activitat o modificar l'activitat.",
"Are you sure you want to cancel the event creation? You'll lose all modifications.": "Segur que vols esborrar aquesta activitat? Perdràs tots els canvis.",
"Are you sure you want to cancel the event edition? You'll lose all modifications.": "Segur que vols canceŀlar l'edició? Perdràs tots els canvis que hagis fet.",
"Are you sure you want to cancel your participation at event \"{title}\"?": "Segur que vols deixar de participar a l'activitat \"{title}\"?",

View File

@@ -27,7 +27,7 @@
"Approve": "Bestätigen",
"Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.": "Bist du dir sicher, dass du den gesamten Account löschen möchtest? Du verlierst dadurch alles. Identitäten, Einstellungen, erstellte Events, Nachrichten, Teilnahmen sind dann für immer verschwunden.",
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Bist du sicher, dass du diesen Kommentar <b>löschen</b> willst? Diese Aktion kann nicht rückgängig gemacht werden.",
"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.": "Bist du sicher, dass du diese Veranstaltung <b>löschen</b> willst? Diese Aktion kann nicht rückgängig gemacht werden.",
"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.": "Bist du sicher, dass du diese Veranstaltung <b>löschen</b> willst? Diese Aktion kann nicht rückgängig gemacht werden.",
"Are you sure you want to cancel the event creation? You'll lose all modifications.": "Bist Du dir sicher, dass du das Erstellen der Veranstaltung abbrechen möchtest? Alle Änderungen werden verloren gehen.",
"Are you sure you want to cancel the event edition? You'll lose all modifications.": "Bist du dir sicher, dass Du die Bearbeitung der Veranstaltung abbrechen möchtest? Alle Änderungen werden verloren gehen.",
"Are you sure you want to cancel your participation at event \"{title}\"?": "Bist Du dir sicher, dass Du nicht mehr an der Veranstaltung \"{title}\" teilnehmen möchtest?",

View File

@@ -29,7 +29,7 @@
"Approve": "Approve",
"Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.": "Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.",
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Are you sure you want to <b>delete</b> this comment? This action cannot be undone.",
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.": "Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.",
"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.": "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.",
"Are you sure you want to cancel the event creation? You'll lose all modifications.": "Are you sure you want to cancel the event creation? You'll lose all modifications.",
"Are you sure you want to cancel the event edition? You'll lose all modifications.": "Are you sure you want to cancel the event edition? You'll lose all modifications.",
"Are you sure you want to cancel your participation at event \"{title}\"?": "Are you sure you want to cancel your participation at event \"{title}\"?",
@@ -710,5 +710,9 @@
"Error while login with {provider}. Retry or login another way.": "Error while login with {provider}. Retry or login another way.",
"Error while login with {provider}. This login provider doesn't exist.": "Error while login with {provider}. This login provider doesn't exist.",
"This user has been disabled": "This user has been disabled",
"You can't reset your password because you use a 3rd-party auth provider to login.": "You can't reset your password because you use a 3rd-party auth provider to login."
"You can't reset your password because you use a 3rd-party auth provider to login.": "You can't reset your password because you use a 3rd-party auth provider to login.",
"Update post {name}": "Update post {name}",
"Create a new post": "Create a new post",
"Post": "Post",
"By {author}": "By {author}"
}

View File

@@ -55,7 +55,7 @@
"Approve": "Aprobar",
"Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.": "¿Estás realmente seguro de que deseas eliminar toda tu cuenta? Lo perderás todo. Las identidades, la configuración, los eventos creados, los mensajes y las participaciones desaparecerán para siempre.",
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "¿Estás seguro de que quieres <b> eliminar </b> este comentario? Esta acción no se puede deshacer.",
"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.": "¿Estás seguro de que quieres <b> eliminar </b> este evento? Esta acción no se puede deshacer. Es posible que desee entablar una conversación con el creador del evento o editar el evento en su lugar.",
"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.": "¿Estás seguro de que quieres <b> eliminar </b> este evento? Esta acción no se puede deshacer. Es posible que desee entablar una conversación con el creador del evento o editar el evento en su lugar.",
"Are you sure you want to cancel the event creation? You'll lose all modifications.": "¿Seguro que quieres cancelar la creación del evento? Perderás todas las modificaciones.",
"Are you sure you want to cancel the event edition? You'll lose all modifications.": "¿Seguro que quieres cancelar la edición del evento? Perderás todas las modificaciones.",
"Are you sure you want to cancel your participation at event \"{title}\"?": "¿Está seguro de que desea cancelar su participación en el evento \"{title}\"?",
@@ -103,14 +103,14 @@
"Confirmed: Will happen": "Confirmado: sucederá",
"Contact": "Contacto",
"Continue editing": "Continua editando",
"Conversations": "Conversaciones",
"Discussions": "Conversaciones",
"Cookies and Local storage": "Cookies y almacenamiento local",
"Country": "País",
"Create": "Crear",
"Create a calc": "Crear un calco",
"Create a discussion": "Crear una discusión",
"Create a folder": "Crear una carpeta",
"Create a new conversation": "Crea una nueva conversación",
"Create a new discussion": "Crea una nueva conversación",
"Create a new event": "Crear un nuevo evento",
"Create a new group": "Crear un nuevo grupo",
"Create a new identity": "Crear una nueva identidad",
@@ -349,7 +349,7 @@
"My identities": "Mis identidades",
"NOTE! The default terms have not been checked over by a lawyer and thus are unlikely to provide full legal protection for all situations for an instance admin using them. They are also not specific to all countries and jurisdictions. If you are unsure, please check with a lawyer.": "¡NOTA! Los términos predeterminados no han sido revisados por un abogado y, por lo tanto, es poco probable que brinden protección legal completa para todas las situaciones para un administrador de instancia que los use. Tampoco son específicos de todos los países y jurisdicciones. Si no está seguro, consulte con un abogado.",
"Name": "Nombre",
"New conversation": "Nueva conversación",
"New discussion": "Nueva conversación",
"New discussion": "Nueva discusión",
"New email": "Nuevo correo electrónico",
"New folder": "Nueva carpeta",
@@ -624,7 +624,7 @@
"Username": "Nombre de usuario",
"Users": "Los usuarios",
"View a reply": "|Ver una respuesta|Ver {totalReplies} respuestas",
"View all conversations": "Ver todas las conversaciones",
"View all discussions": "Ver todas las conversaciones",
"View all discussions": "Ver todas las discusiones",
"View all resources": "Ver todos los recursos",
"View all todos": "Ver todas las tareas pendientes",

View File

@@ -54,7 +54,7 @@
"Approve": "Hyväksy",
"Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.": "Haluatko varmasti poistaa koko tilin? Tällöin kaikki poistetaan. Identiteetit, asetukset, luodut tapahtumat, viestit ja osallistumiset poistetaan pysyvästi.",
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Haluatko varmasti <b>poistaa</b> tämän kommentin? Toimintoa ei voi perua.",
"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.": "Haluatko varmasti <b>poistaa</b> tämän tapahtuman? Toimintoa ei voi perua. Poistamisen sijaan voisit ehkä keskustella tapahtuman luojan kanssa tai muokata tapahtumaa.",
"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.": "Haluatko varmasti <b>poistaa</b> tämän tapahtuman? Toimintoa ei voi perua. Poistamisen sijaan voisit ehkä keskustella tapahtuman luojan kanssa tai muokata tapahtumaa.",
"Are you sure you want to cancel the event creation? You'll lose all modifications.": "Haluatko varmasti keskeyttää tapahtuman luomisen? Kaikki muutokset menetetään.",
"Are you sure you want to cancel the event edition? You'll lose all modifications.": "Haluatko varmasti keskeyttää tapahtuman muokkaamisen? Kaikki muutokset menetetään.",
"Are you sure you want to cancel your participation at event \"{title}\"?": "Haluatko varmasti perua osallistumisesi tapahtumaan {title}?",
@@ -101,14 +101,14 @@
"Confirmed: Will happen": "Vahvistettu: Tapahtuu",
"Contact": "Ota yhteyttä",
"Continue editing": "Jatka muokkausta",
"Conversations": "Keskustelut",
"Discussions": "Keskustelut",
"Cookies and Local storage": "Evästeet ja paikallisesti tallennettavat tiedot",
"Country": "Maa",
"Create": "Luo",
"Create a calc": "Luo taulukko",
"Create a discussion": "Luo keskustelu",
"Create a folder": "Luo kansio",
"Create a new conversation": "Luo uusi keskustelu",
"Create a new discussion": "Luo uusi keskustelu",
"Create a new event": "Luo uusi tapahtuma",
"Create a new group": "Luo uusi ryhmä",
"Create a new identity": "Luo uusi identiteetti",
@@ -341,7 +341,7 @@
"My identities": "Omat identiteetit",
"NOTE! The default terms have not been checked over by a lawyer and thus are unlikely to provide full legal protection for all situations for an instance admin using them. They are also not specific to all countries and jurisdictions. If you are unsure, please check with a lawyer.": "HUOM! Oletusehdot eivät ole juristin tarkistamia, joten palvelimen ylläpitäjän ei ole syytä luottaa niiden tarjoamaan juridiseen suojaan. Niitä ei ole myöskään sovitettu eri maiden ja lainkäyttöalueiden olosuhteisiin. Epävarmoissa tilanteissa suosittelemme tarkistuttamaan ehdot lakiasiantuntijalla.",
"Name": "Nimi",
"New conversation": "Uusi keskustelu",
"New discussion": "Uusi keskustelu",
"New discussion": "Uusi keskustelu",
"New email": "Uusi sähköpostiosoite",
"New folder": "Uusi kansio",
@@ -615,7 +615,7 @@
"Username": "Käyttäjänimi",
"Users": "Käyttäjät",
"View a reply": "|Näytä vastaus|Näytä {totalReplies} vastausta",
"View all conversations": "Näytä kaikki keskustelut",
"View all discussions": "Näytä kaikki keskustelut",
"View all discussions": "Näytä kaikki keskustelut",
"View all resources": "Näytä kaikki resurssit",
"View all todos": "Näytä kaikki tehtävät",

View File

@@ -53,7 +53,7 @@
"Approve": "Approuver",
"Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.": "Êtes-vous vraiment certain⋅e de vouloir supprimer votre compte ? Vous allez tout perdre. Identités, paramètres, événements créés, messages et participations disparaîtront pour toujours.",
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Êtes-vous certain⋅e de vouloir <b>supprimer</b> ce commentaire ? Cette action ne peut pas être annulée.",
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.": "Êtes-vous certain⋅e de vouloir <b>supprimer</b> cet évènement ? Cette action n'est pas réversible. Vous voulez peut-être engager la conversation avec le créateur de l'évènement ou bien modifier son évènement à la place.",
"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.": "Êtes-vous certain⋅e de vouloir <b>supprimer</b> cet évènement ? Cette action n'est pas réversible. Vous voulez peut-être engager la discussion avec le créateur de l'évènement ou bien modifier son évènement à la place.",
"Are you sure you want to cancel the event creation? You'll lose all modifications.": "Étes-vous certain⋅e de vouloir annuler la création de l'évènement ? Vous allez perdre toutes vos modifications.",
"Are you sure you want to cancel the event edition? You'll lose all modifications.": "Êtes-vous certain⋅e de vouloir annuler la modification de l'évènement ? Vous allez perdre toutes vos modifications.",
"Are you sure you want to cancel your participation at event \"{title}\"?": "Êtes-vous certain⋅e de vouloir annuler votre participation à l'évènement « {title} » ?",
@@ -710,5 +710,9 @@
"Error while login with {provider}. Retry or login another way.": "Erreur lors de la connexion avec {provider}. Réessayez ou bien connectez vous autrement.",
"Error while login with {provider}. This login provider doesn't exist.": "Erreur lors de la connexion avec {provider}. Cette méthode de connexion n'existe pas.",
"This user has been disabled": "Cet utilisateur·ice a été désactivé·e",
"You can't reset your password because you use a 3rd-party auth provider to login.": "Vous ne pouvez pas réinitialiser votre mot de passe car vous vous connectez via une méthode externe."
"You can't reset your password because you use a 3rd-party auth provider to login.": "Vous ne pouvez pas réinitialiser votre mot de passe car vous vous connectez via une méthode externe.",
"Update post {name}": "Mettre à jour le billet {name}",
"Create a new post": "Créer un nouveau billet",
"Post": "Billet",
"By {author}": "Par {author}"
}

View File

@@ -41,7 +41,7 @@
"Are you going to this event?": "Anatz a aqueste eveniment ?",
"Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.": "Volètz vertadièrament suprimir vòstre compte? O perdretz tot. Identitats, paramètres, eveniments creats, messatges e participacions desapareisseràn per totjorn.",
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Volètz vertadièrament <b>suprimir</b> aqueste comentari? Aquesta accion es irreversibla.",
"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.": "Volètz vertadièrament <b>suprimir</b> aqueste eveniment? Aquesta accion es irreversibla. Benlèu qua la plaça volètz començar una conversacion amb lorganizaire o modificar sos eveniment.",
"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.": "Volètz vertadièrament <b>suprimir</b> aqueste eveniment? Aquesta accion es irreversibla. Benlèu qua la plaça volètz començar una conversacion amb lorganizaire o modificar sos eveniment.",
"Are you sure you want to cancel the event creation? You'll lose all modifications.": "Volètz vertadièrament anullar la creacion de leveniment ? Perdretz totas vòstras modificacions.",
"Are you sure you want to cancel the event edition? You'll lose all modifications.": "Volètz vertadièrament anullar la modificacion de leveniment ? Perdretz totas vòstras modificacions.",
"Are you sure you want to cancel your participation at event \"{title}\"?": "Volètz vertadièrament anullar vòstra participacion a leveniment « {title} » ?",
@@ -84,10 +84,10 @@
"Confirmed: Will happen": "Confirmat : se tendrà",
"Contact": "Contacte",
"Continue editing": "Contunhar la modificacion",
"Conversations": "Conversacions",
"Discussions": "Conversacions",
"Country": "País",
"Create": "Crear",
"Create a new conversation": "Crear una conversacion novèla",
"Create a new discussion": "Crear una conversacion novèla",
"Create a new event": "Crear un eveniment novèl",
"Create a new group": "Crear un grop novèl",
"Create a new identity": "Crear una identitat novèla",
@@ -273,7 +273,7 @@
"My groups": "Mos grops",
"My identities": "Mas identitats",
"Name": "Nom",
"New conversation": "Conversacion novèla",
"New discussion": "Conversacion novèla",
"New email": "Adreça novèla",
"New folder": "Dossièr novèl",
"New link": "Ligam novèl",

View File

@@ -26,7 +26,7 @@
"Anonymous participations": "Participações anônimas",
"Approve": "Aprovar",
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Você está seguro que quer <b>apagar</b> este comentário? Esta ação não pode ser desfeita.",
"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.": "Você está seguro que quer <b>apagar</b> este evento? Esta ação não pode ser desfeita. Talvez você queira tentar uma conversa com o criador do evento ou, então, editar este evento.",
"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.": "Você está seguro que quer <b>apagar</b> este evento? Esta ação não pode ser desfeita. Talvez você queira tentar uma conversa com o criador do evento ou, então, editar este evento.",
"Are you sure you want to cancel the event creation? You'll lose all modifications.": "Você está seguro que quer cancelar a criação do evento? Você perderá todas as modificações.",
"Are you sure you want to cancel the event edition? You'll lose all modifications.": "Você está seguro que quer cancelar a edição do evento? Você perderá todas as modificações.",
"Are you sure you want to cancel your participation at event \"{title}\"?": "Você está seguro que quer cancelar a sua participação no evento \"{title}\"?",

View File

@@ -6,15 +6,29 @@ import Component from "vue-class-component";
import VueScrollTo from "vue-scrollto";
import VueMeta from "vue-meta";
import VTooltip from "v-tooltip";
import TimeAgo from "javascript-time-ago";
import App from "./App.vue";
import router from "./router";
import { NotifierPlugin } from "./plugins/notifier";
import filters from "./filters";
import { i18n } from "./utils/i18n";
import messages from "./i18n";
import apolloProvider from "./vue-apollo";
Vue.config.productionTip = false;
let language = document.documentElement.getAttribute("lang") as string;
language =
language ||
((window.navigator as any).userLanguage || window.navigator.language).replace(/-/, "_");
export const locale =
language && messages.hasOwnProperty(language) ? language : language.split("-")[0];
import(`javascript-time-ago/locale/${locale}`).then((localeFile) => {
TimeAgo.addLocale(localeFile);
Vue.prototype.$timeAgo = new TimeAgo(locale);
});
Vue.use(Buefy);
Vue.use(NotifierPlugin);
Vue.use(filters);

View File

@@ -1,34 +0,0 @@
import { RouteConfig } from "vue-router";
import CreateConversation from "@/views/Conversations/Create.vue";
import ConversationsList from "@/views/Conversations/ConversationsList.vue";
import Conversation from "@/views/Conversations/Conversation.vue";
export enum ConversationRouteName {
CONVERSATION_LIST = "CONVERSATION_LIST",
CREATE_CONVERSATION = "CREATE_CONVERSATION",
CONVERSATION = "CONVERSATION",
}
export const conversationRoutes: RouteConfig[] = [
{
path: "/@:preferredUsername/conversations",
name: ConversationRouteName.CONVERSATION_LIST,
component: ConversationsList,
props: true,
meta: { requiredAuth: false },
},
{
path: "/@:preferredUsername/conversations/new",
name: ConversationRouteName.CREATE_CONVERSATION,
component: CreateConversation,
props: true,
meta: { requiredAuth: true },
},
{
path: "/@:preferredUsername/:slug/:id/:comment_id?",
name: ConversationRouteName.CONVERSATION,
component: Conversation,
props: true,
meta: { requiredAuth: false },
},
];

View File

@@ -0,0 +1,34 @@
import { RouteConfig } from "vue-router";
import CreateDiscussion from "@/views/Discussions/Create.vue";
import DiscussionsList from "@/views/Discussions/DiscussionsList.vue";
import discussion from "@/views/Discussions/Discussion.vue";
export enum DiscussionRouteName {
DISCUSSION_LIST = "DISCUSSION_LIST",
CREATE_DISCUSSION = "CREATE_DISCUSSION",
DISCUSSION = "DISCUSSION",
}
export const discussionRoutes: RouteConfig[] = [
{
path: "/@:preferredUsername/discussions",
name: DiscussionRouteName.DISCUSSION_LIST,
component: DiscussionsList,
props: true,
meta: { requiredAuth: false },
},
{
path: "/@:preferredUsername/discussions/new",
name: DiscussionRouteName.CREATE_DISCUSSION,
component: CreateDiscussion,
props: true,
meta: { requiredAuth: true },
},
{
path: "/@:preferredUsername/c/:slug/:comment_id?",
name: DiscussionRouteName.DISCUSSION,
component: discussion,
props: true,
meta: { requiredAuth: false },
},
];

View File

@@ -1,4 +1,4 @@
import { RouteConfig } from "vue-router";
import { RouteConfig, Route } from "vue-router";
export enum GroupsRouteName {
TODO_LISTS = "TODO_LISTS",
@@ -10,6 +10,10 @@ export enum GroupsRouteName {
RESOURCES = "RESOURCES",
RESOURCE_FOLDER_ROOT = "RESOURCE_FOLDER_ROOT",
RESOURCE_FOLDER = "RESOURCE_FOLDER",
POST_CREATE = "POST_CREATE",
POST_EDIT = "POST_EDIT",
POST = "POST",
POSTS = "POSTS",
}
const resourceFolder = () => import("@/views/Resources/ResourceFolder.vue");
@@ -61,6 +65,7 @@ export const groupsRoutes: RouteConfig[] = [
{
path: "public",
name: GroupsRouteName.GROUP_PUBLIC_SETTINGS,
component: () => import("../views/Group/GroupSettings.vue"),
},
{
path: "members",
@@ -70,4 +75,28 @@ export const groupsRoutes: RouteConfig[] = [
},
],
},
{
path: "/@:preferredUsername/p/new",
component: () => import("@/views/Posts/Edit.vue"),
props: true,
name: GroupsRouteName.POST_CREATE,
},
{
path: "/p/:slug/edit",
component: () => import("@/views/Posts/Edit.vue"),
props: (route: Route) => ({ ...route.params, ...{ isUpdate: true } }),
name: GroupsRouteName.POST_EDIT,
},
{
path: "/p/:slug",
component: () => import("@/views/Posts/Post.vue"),
props: true,
name: GroupsRouteName.POST,
},
{
path: "/@:preferredUsername/p",
component: () => import("@/views/Posts/List.vue"),
props: true,
name: GroupsRouteName.POSTS,
},
];

View File

@@ -11,7 +11,7 @@ import { authGuardIfNeeded } from "./guards/auth-guard";
import Search from "../views/Search.vue";
import { settingsRoutes } from "./settings";
import { groupsRoutes } from "./groups";
import { conversationRoutes } from "./conversation";
import { discussionRoutes } from "./discussion";
import { userRoutes } from "./user";
import RouteName from "./name";
@@ -46,7 +46,7 @@ const router = new Router({
...settingsRoutes,
...actorRoutes,
...groupsRoutes,
...conversationRoutes,
...discussionRoutes,
...errorRoutes,
{
path: "/search/:searchTerm/:searchType?",

View File

@@ -3,7 +3,7 @@ import { ActorRouteName } from "./actor";
import { ErrorRouteName } from "./error";
import { SettingsRouteName } from "./settings";
import { GroupsRouteName } from "./groups";
import { ConversationRouteName } from "./conversation";
import { DiscussionRouteName } from "./discussion";
import { UserRouteName } from "./user";
enum GlobalRouteName {
@@ -29,6 +29,6 @@ export default {
...ActorRouteName,
...SettingsRouteName,
...GroupsRouteName,
...ConversationRouteName,
...DiscussionRouteName,
...ErrorRouteName,
};

View File

@@ -3,8 +3,9 @@ import { Paginate } from "../paginate";
import { IResource } from "../resource";
import { ITodoList } from "../todos";
import { IEvent } from "../event.model";
import { IConversation } from "../conversations";
import { IDiscussion } from "../discussions";
import { IPerson } from "./person.model";
import { IPost } from "../post.model";
export enum MemberRole {
NOT_APPROVED = "NOT_APPROVED",
@@ -20,7 +21,7 @@ export interface IGroup extends IActor {
members: Paginate<IMember>;
resources: Paginate<IResource>;
todoLists: Paginate<ITodoList>;
conversations: Paginate<IConversation>;
discussions: Paginate<IDiscussion>;
organizedEvents: Paginate<IEvent>;
}
@@ -39,9 +40,11 @@ export class Group extends Actor implements IGroup {
todoLists: Paginate<ITodoList> = { elements: [], total: 0 };
conversations: Paginate<IConversation> = { elements: [], total: 0 };
discussions: Paginate<IDiscussion> = { elements: [], total: 0 };
organizedEvents!: Paginate<IEvent>;
organizedEvents: Paginate<IEvent> = { elements: [], total: 0 };
posts: Paginate<IPost> = { elements: [], total: 0 };
constructor(hash: IGroup | {} = {}) {
super(hash);

View File

@@ -12,9 +12,10 @@ export interface IComment {
originComment?: IComment;
replies: IComment[];
event?: IEvent;
updatedAt?: Date;
deletedAt?: Date;
updatedAt?: Date | string;
deletedAt?: Date | string;
totalReplies: number;
insertedAt?: Date | string;
}
export class CommentModel implements IComment {
@@ -38,9 +39,11 @@ export class CommentModel implements IComment {
event?: IEvent = undefined;
updatedAt?: Date = undefined;
updatedAt?: Date | string = undefined;
deletedAt?: Date = undefined;
deletedAt?: Date | string = undefined;
insertedAt?: Date | string = undefined;
totalReplies = 0;
@@ -58,6 +61,7 @@ export class CommentModel implements IComment {
this.replies = hash.replies;
this.updatedAt = hash.updatedAt;
this.deletedAt = hash.deletedAt;
this.insertedAt = new Date(hash.insertedAt as string);
this.totalReplies = hash.totalReplies;
}
}

View File

@@ -1,13 +0,0 @@
import { IActor, IPerson } from "@/types/actor";
import { IComment } from "@/types/comment.model";
import { Paginate } from "@/types/paginate";
export interface IConversation {
id: string;
title: string;
slug: string;
creator: IPerson;
actor: IActor;
lastComment: IComment;
comments: Paginate<IComment>;
}

View File

@@ -0,0 +1,44 @@
import { IActor, IPerson } from "@/types/actor";
import { IComment, CommentModel } from "@/types/comment.model";
import { Paginate } from "@/types/paginate";
export interface IDiscussion {
id?: string;
title: string;
slug?: string;
creator?: IPerson;
actor?: IActor;
lastComment?: IComment;
comments: Paginate<IComment>;
}
export class Discussion implements IDiscussion {
id?: string;
title = "";
comments: Paginate<IComment> = { total: 0, elements: [] };
slug?: string = undefined;
creator?: IPerson = undefined;
actor?: IActor = undefined;
lastComment?: IComment = undefined;
constructor(hash?: IDiscussion) {
if (!hash) return;
this.id = hash.id;
this.title = hash.title;
this.comments = {
total: hash.comments.total,
elements: hash.comments.elements.map((comment: IComment) => new CommentModel(comment)),
};
this.slug = hash.slug;
this.creator = hash.creator;
this.actor = hash.actor;
this.lastComment = hash.lastComment;
}
}

View File

@@ -0,0 +1,26 @@
import { ITag } from "./tag.model";
import { IPicture } from "./picture.model";
import { IActor } from "./actor";
export enum PostVisibility {
PUBLIC = "PUBLIC",
UNLISTED = "UNLISTED",
RESTRICTED = "RESTRICTED",
PRIVATE = "PRIVATE",
}
export interface IPost {
id?: string;
slug?: string;
url?: string;
local: boolean;
title: string;
body: string;
tags?: ITag[];
picture?: IPicture | null;
draft: boolean;
visibility: PostVisibility;
author?: IActor;
attributedTo?: IActor;
publishAt?: Date;
}

View File

@@ -4,7 +4,7 @@ import messages from "../i18n/index";
let language = document.documentElement.getAttribute("lang") as string;
language = language || ((window.navigator as any).userLanguage || window.navigator.language).replace(/-/, "_");
const locale = language && messages.hasOwnProperty(language) ? language : language.split("-")[0];
export const locale = language && messages.hasOwnProperty(language) ? language : language.split("-")[0];
Vue.use(VueI18n);

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) {