Introduce group basic federation, event new page and notifications

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2020-02-18 08:57:00 +01:00
parent 300ef8f245
commit 4144e9ffd0
416 changed files with 32220 additions and 16750 deletions

View File

@@ -1,50 +1,41 @@
<template>
<div class="container root">
<h1>{{ $t('Create a new group') }}</h1>
<section class="section container">
<h1>{{ $t("Create a new group") }}</h1>
<div>
<b-field :label="$t('Group name')">
<b-input aria-required="true" required v-model="group.preferred_username"/>
<b-input aria-required="true" required v-model="group.preferredUsername" />
</b-field>
<b-field :label="$t('Group full name')">
<b-input aria-required="true" required v-model="group.name"/>
<b-input aria-required="true" required v-model="group.name" />
</b-field>
<b-field :label="$t('Description')">
<b-input aria-required="true" required v-model="group.description" type="textarea"/>
<b-input aria-required="true" required v-model="group.summary" type="textarea" />
</b-field>
<div>
Avatar
<picture-upload v-model="avatarFile"></picture-upload>
<picture-upload v-model="avatarFile" />
</div>
<div>
Banner
<picture-upload v-model="avatarFile"></picture-upload>
<picture-upload v-model="avatarFile" />
</div>
<button class="button is-primary" @click="createGroup()">
{{ $t('Create my group') }}
</button>
<button class="button is-primary" @click="createGroup()">{{ $t("Create my group") }}</button>
</div>
</div>
</section>
</template>
<style lang="scss" scoped>
.root {
width: 400px;
margin: auto;
}
</style>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { Group, IPerson } from '@/types/actor';
import { CREATE_GROUP, CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { RouteName } from '@/router';
import PictureUpload from '@/components/PictureUpload.vue';
import { Component, Vue } from "vue-property-decorator";
import { Group, IPerson } from "@/types/actor";
import { CREATE_GROUP, CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
import PictureUpload from "@/components/PictureUpload.vue";
import RouteName from "../../router/name";
@Component({
components: {
@@ -62,6 +53,7 @@ export default class CreateGroup extends Vue {
group = new Group();
avatarFile: File | null = null;
bannerFile: File | null = null;
async createGroup() {
@@ -74,10 +66,15 @@ export default class CreateGroup extends Vue {
},
});
await this.$router.push({ name: RouteName.GROUP, params: { identityName: this.group.preferredUsername } });
await this.$router.push({
name: RouteName.GROUP,
params: { identityName: this.group.preferredUsername },
});
this.$notifier.success(
this.$t('Group {displayName} created', { displayName: this.group.displayName() }) as string,
this.$t("Group {displayName} created", {
displayName: this.group.displayName(),
}) as string
);
} catch (err) {
this.handleError(err);
@@ -114,7 +111,12 @@ export default class CreateGroup extends Vue {
creatorActorId: this.currentActor.id,
};
return Object.assign({}, this.group, avatarObj, bannerObj, currentActor);
return {
...this.group,
...avatarObj,
...bannerObj,
...currentActor,
};
}
private handleError(err: any) {

View File

@@ -1,66 +1,194 @@
<template>
<section class="container">
<div v-if="group">
<div class="card-image" v-if="group.banner.url">
<figure class="image">
<img :src="group.banner.url">
</figure>
</div>
<div class="box">
<div class="media">
<div class="media-left">
<figure class="image is-48x48">
<img :src="group.avatar.url">
<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"
>
<img
class="is-rounded"
:src="member.actor.avatar.url"
v-if="member.actor.avatar"
alt
/>
</figure>
</div>
</section>
<section>
<subtitle>{{ $t("Upcoming events") }}</subtitle>
<div class="organized-events-wrapper" v-if="group.organizedEvents.total > 0">
<EventMinimalistCard
v-for="event in group.organizedEvents.elements"
:event="event"
:key="event.uuid"
class="organized-event"
/>
</div>
<router-link :to="{}">{{ $t("View all upcoming events") }}</router-link>
</section>
<section>
<subtitle>{{ $t("Resources") }}</subtitle>
<div v-if="group.resources.elements.length > 0">
<div v-for="resource in group.resources.elements" :key="resource.id">
<resource-item
:resource="resource"
v-if="resource.type !== 'folder'"
:inline="true"
/>
<folder-item :resource="resource" :group="group" v-else :inline="true" />
</div>
</div>
<router-link
:to="{
name: RouteName.RESOURCE_FOLDER_ROOT,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("View all resources") }}</router-link
>
</section>
</div>
<div class="block-column">
<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>
</section>
<section>
<subtitle>{{ $t("Ongoing tasks") }}</subtitle>
<div
v-if="group.todoLists.elements.length > 0"
v-for="todoList in group.todoLists.elements"
:key="todoList.id"
>
<router-link :to="{ name: RouteName.TODO_LIST, params: { id: todoList.id } }">
<h2 class="is-size-3">
{{
$tc("{title} ({count} todos)", todoList.todos.total, {
count: todoList.todos.total,
title: todoList.title,
})
}}
</h2>
</router-link>
<compact-todo
:todo="todo"
v-for="todo in todoList.todos.elements.slice(0, 3)"
:key="todo.id"
/>
</div>
<router-link :to="{ name: RouteName.TODO_LISTS }">{{ $t("View all todos") }}</router-link>
</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"
/>
<router-link
:to="{
name: RouteName.CONVERSATION_LIST,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("View all conversations") }}</router-link
>
</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">
<p class="title">{{ group.name }}</p>
<p class="subtitle">@{{ group.preferredUsername }}</p>
<h2>{{ group.name }}</h2>
<small class="has-text-grey">@{{ usernameWithDomain(group) }}</small>
</div>
</div>
<div class="content">
<p v-html="group.summary"></p>
</div>
</div>
<section class="box" v-if="group.organizedEvents.length > 0">
<subtitle>
{{ $t('Organized') }}
</subtitle>
<div class="columns">
<EventCard
v-for="event in group.organizedEvents"
:event="event"
:options="{ hideDetails: true }"
:key="event.uuid"
class="column is-one-third"
/>
</div>
</section>
<section v-if="group.members.length > 0">
<subtitle>
{{ $t('Members') }}
</subtitle>
<div class="columns">
<span
v-for="member in group.members"
:key="member.actor.preferredUsername"
>{{ member.actor.preferredUsername }}</span>
<section>
<subtitle>{{ $t("Upcoming events") }}</subtitle>
<div class="organized-events-wrapper" v-if="group.organizedEvents.total > 0">
<EventMinimalistCard
v-for="event in group.organizedEvents.elements"
:event="event"
:key="event.uuid"
class="organized-event"
/>
<router-link :to="{}">{{ $t("View all upcoming events") }}</router-link>
</div>
<span v-else>{{ $t("No public upcoming events") }}</span>
</section>
<!-- {{ group }}-->
<section>
<subtitle>{{ $t("Latest posts") }}</subtitle>
</section>
</div>
<b-message v-else-if="!group && $apollo.loading === false" type="is-danger">
{{ $t('No group found') }}
{{ $t("No group found") }}
</b-message>
</section>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
import EventCard from '@/components/Event/EventCard.vue';
import { FETCH_GROUP, CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { IGroup } from '@/types/actor';
import Subtitle from '@/components/Utils/Subtitle.vue';
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 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 ResourceItem from "@/components/Resource/ResourceItem.vue";
import FolderItem from "@/components/Resource/FolderItem.vue";
import RouteName from "../../router/name";
@Component({
apollo: {
@@ -68,49 +196,141 @@ import Subtitle from '@/components/Utils/Subtitle.vue';
query: FETCH_GROUP,
variables() {
return {
name: this.$route.params.preferredUsername,
name: this.preferredUsername,
};
},
},
currentActor: {
query: CURRENT_ACTOR_CLIENT,
person: {
query: PERSON_MEMBERSHIPS,
variables() {
return {
id: this.currentActor.id,
};
},
skip() {
return !this.currentActor || !this.currentActor.id;
},
},
currentActor: CURRENT_ACTOR_CLIENT,
},
components: {
ConversationListItem,
EventMinimalistCard,
CompactTodo,
Subtitle,
EventCard,
FolderItem,
ResourceItem,
},
metaInfo() {
return {
// if no subcomponents specify a metaInfo.title, this title will be used
// @ts-ignore
title: this.groupTitle,
// all titles will be injected into this template
titleTemplate: "%s | Mobilizon",
meta: [
// @ts-ignore
{ name: "description", content: this.groupSummary },
],
};
},
})
export default class Group extends Vue {
@Prop({ type: String, required: true }) preferredUsername!: string;
currentActor!: IActor;
person!: IPerson;
group!: IGroup;
loading = true;
created() {
this.fetchData();
RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
@Watch("currentActor")
watchCurrentActor(currentActor: IActor, oldActor: IActor) {
if (currentActor.id && oldActor && currentActor.id !== oldActor.id) {
this.$apollo.queries.group.refetch();
}
}
@Watch('$route')
onRouteChanged() {
// call again the method if the route changes
this.fetchData();
get groupTitle() {
if (!this.group) return undefined;
return this.group.preferredUsername;
}
fetchData() {
// FIXME: remove eventFetch
// eventFetch(`/actors/${this.name}`, this.$store)
// .then(response => response.json())
// .then((response) => {
// this.group = response.data;
// this.loading = false;
// console.log(this.group);
// });
get groupSummary() {
if (!this.group) return undefined;
return this.group.summary;
}
get groupMemberships() {
if (!this.person || !this.person.id) return undefined;
return this.person.memberships.elements.map(({ parent: { id } }) => id);
}
}
</script>
<style lang="scss" scoped>
section.container {
min-height: 30em;
div.container {
background: white;
margin-bottom: 3rem;
padding: 2rem 0;
.block-container {
display: flex;
flex-wrap: wrap;
.block-column {
flex: 1;
margin: 0 2rem;
section {
/deep/ h2 span {
display: block;
}
&.presentation {
.members {
display: flex;
}
}
.organized-events-wrapper {
display: flex;
flex-wrap: wrap;
.organized-event {
margin: 0.25rem 0;
}
}
&.presentation {
.media-left {
span.icon.is-large {
height: 5rem;
width: 5rem;
/deep/ i.mdi.mdi-account-group.mdi-48px:before {
font-size: 100px;
}
}
}
.media-content {
h2 {
color: #3c376e;
font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial, serif;
font-size: 1.5rem;
font-weight: 700;
}
}
}
}
}
}
}
</style>

View File

@@ -1,78 +1,62 @@
<template>
<section class="container">
<h1>
{{ $t('Group List') }}
</h1>
<b-loading :active.sync="$apollo.loading"></b-loading>
<section class="container section">
<h1>{{ $t("Group List") }} ({{ groups.total }})</h1>
<b-loading :active.sync="$apollo.loading" />
<div class="columns">
<GroupCard
v-for="group in groups"
v-for="group in groups.elements"
:key="group.uuid"
:group="group"
class="column is-one-quarter-desktop is-half-mobile"
/>
</div>
<router-link class="button" :to="{ name: RouteName.CREATE_GROUP }">
{{ $t('Create group') }}
</router-link>
<router-link class="button" :to="{ name: RouteName.CREATE_GROUP }">{{
$t("Create group")
}}</router-link>
</section>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { RouteName } from '@/router';
import { Component, Vue } from "vue-property-decorator";
import { LIST_GROUPS } from "@/graphql/actor";
import { Group, IGroup } from "@/types/actor";
import GroupCard from "@/components/Group/GroupCard.vue";
import RouteName from "../../router/name";
@Component
@Component({
apollo: {
groups: {
query: LIST_GROUPS,
},
},
components: {
GroupCard,
},
})
export default class GroupList extends Vue {
groups = [];
groups: { elements: IGroup[]; total: number } = { elements: [], total: 0 };
loading = true;
RouteName = RouteName;
//
// usernameWithDomain(actor) {
// return actor.username + (actor.domain === null ? '' : `@${actor.domain}`);
// }
created() {
this.fetchData();
}
usernameWithDomain(actor) {
return actor.username + (actor.domain === null ? '' : `@${actor.domain}`);
}
fetchData() {
// FIXME: remove eventFetch
// eventFetch('/groups', this.$store)
// .then(response => response.json())
// .then((data) => {
// console.log(data);
// this.loading = false;
// this.groups = data.data;
// });
}
deleteGroup(group) {
const router = this.$router;
// FIXME: remove eventFetch
// eventFetch(`/groups/${this.usernameWithDomain(group)}`, this.$store, { method: 'DELETE' })
// .then(response => response.json())
// .then(() => router.push('/groups'));
}
viewActor(actor) {
this.$router.push({
name: RouteName.GROUP,
params: { name: this.usernameWithDomain(actor) },
});
}
joinGroup(group) {
const router = this.$router;
// FIXME: remove eventFetch
// eventFetch(`/groups/${this.usernameWithDomain(group)}/join`, this.$store, { method: 'POST' })
// .then(response => response.json())
// .then(() => router.push({ name: 'Group', params: { name: this.usernameWithDomain(group) } }));
}
// viewActor(actor) {
// this.$router.push({
// name: RouteName.GROUP,
// params: { name: this.usernameWithDomain(actor) },
// });
// }
//
// joinGroup(group) {
// const router = this.$router;
// // FIXME: remove eventFetch
// // eventFetch(`/groups/${this.usernameWithDomain(group)}/join`, this.$store, { method: 'POST' })
// // .then(response => response.json())
// // .then(() => router.push({ name: 'Group', params: { name: this.usernameWithDomain(group) } }));
// }
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View File

@@ -0,0 +1,62 @@
<template>
<section class="container section" v-if="group">
<form @submit.prevent="inviteMember">
<b-field :label="$t('Invite a new member')" custom-class="add-relay" horizontal>
<b-field grouped expanded size="is-large">
<p class="control">
<b-input v-model="newMemberUsername" :placeholder="$t('Ex: someone@mobilizon.org')" />
</p>
<p class="control">
<b-button type="is-primary" native-type="submit">{{ $t("Invite member") }}</b-button>
</p>
</b-field>
</b-field>
</form>
<h1>{{ $t("Group Members") }} ({{ group.members.total }})</h1>
<pre>{{ group.members }}</pre>
</section>
</template>
<script lang="ts">
import { Component, Vue } 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";
@Component({
apollo: {
group: {
query: FETCH_GROUP,
variables() {
return {
name: this.$route.params.preferredUsername,
};
},
skip() {
return !this.$route.params.preferredUsername;
},
},
},
})
export default class GroupMembers extends Vue {
group!: IGroup;
loading = true;
RouteName = RouteName;
newMemberUsername = "";
async inviteMember() {
await this.$apollo.mutate<{ inviteMember: IMember }>({
mutation: INVITE_MEMBER,
variables: {
groupId: this.group.id,
targetActorUsername: this.newMemberUsername,
},
});
}
}
</script>

View File

@@ -0,0 +1,102 @@
<template>
<section class="section container">
<h1 class="title">{{ $t("My groups") }}</h1>
<router-link :to="{ name: RouteName.CREATE_GROUP }">{{ $t("Create group") }}</router-link>
<b-loading :active.sync="$apollo.loading"></b-loading>
<section v-if="invitations && invitations.length > 0">
<InvitationCard
v-for="member in invitations"
:key="member.id"
:member="member"
@accept="acceptInvitation"
/>
</section>
<section v-if="memberships && memberships.length > 0">
<GroupCard v-for="member in memberships" :key="member.id" :member="member" />
</section>
<b-message v-if="$apollo.loading === false && memberships.length === 0" type="is-danger">
{{ $t("No groups found") }}
</b-message>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import { LOGGED_USER_MEMBERSHIPS } from "@/graphql/actor";
import GroupCard from "@/components/Group/GroupCard.vue";
import InvitationCard from "@/components/Group/InvitationCard.vue";
import { Paginate } from "@/types/paginate";
import { IGroup, IMember, MemberRole } from "@/types/actor";
import RouteName from "../../router/name";
import { ACCEPT_INVITATION } from "../../graphql/member";
@Component({
components: {
GroupCard,
InvitationCard,
},
apollo: {
paginatedGroups: {
query: LOGGED_USER_MEMBERSHIPS,
fetchPolicy: "network-only",
variables: {
page: 1,
limit: 10,
beforeDateTime: new Date().toISOString(),
},
update: (data) => data.loggedUser.memberships,
},
},
metaInfo() {
return {
// if no subcomponents specify a metaInfo.title, this title will be used
title: this.$t("My groups") as string,
// all titles will be injected into this template
titleTemplate: "%s | Mobilizon",
};
},
})
export default class MyEvents extends Vue {
paginatedGroups!: Paginate<IMember>;
RouteName = RouteName;
get invitations() {
if (!this.paginatedGroups) return [];
return this.paginatedGroups.elements.filter((member) => member.role === MemberRole.INVITED);
}
get memberships() {
if (!this.paginatedGroups) return [];
return this.paginatedGroups.elements.filter((member) => member.role !== MemberRole.INVITED);
}
async acceptInvitation(id: string) {
await this.$apollo.mutate<{ acceptInvitation: IMember }>({
mutation: ACCEPT_INVITATION,
variables: {
id,
},
});
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss" scoped>
@import "../../variables";
main > .container {
background: $white;
}
.participation {
margin: 1rem auto;
}
section {
.upcoming-month {
text-transform: capitalize;
}
}
</style>

View File

@@ -0,0 +1,96 @@
<template>
<aside class="section container">
<h1 class="title">{{ $t("Settings") }}</h1>
<div class="columns">
<SettingsMenu class="column is-one-quarter-desktop" :menu="menu" />
<div class="column">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li
v-for="route in routes.get($route.name)"
:class="{ 'is-active': route.to.name === $route.name }"
:key="route.title"
>
<router-link :to="{ name: route.to.name }">{{ route.title }}</router-link>
</li>
</ul>
</nav>
<router-view />
</div>
</div>
</aside>
</template>
<script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator";
import SettingsMenu from "@/components/Settings/SettingsMenu.vue";
import { ISettingMenuSection } from "@/types/setting-menu.model";
import { Route } from "vue-router";
import { IGroup, IPerson } from "@/types/actor";
import { FETCH_GROUP } from "@/graphql/actor";
import RouteName from "../../router/name";
@Component({
components: { SettingsMenu },
apollo: {
group: {
query: FETCH_GROUP,
},
},
})
export default class Settings extends Vue {
RouteName = RouteName;
menu: ISettingMenuSection[] = [];
group!: IGroup[];
mounted() {
this.menu = [
{
title: this.$t("Settings") as string,
to: { name: RouteName.GROUP_SETTINGS } as Route,
items: [
{
title: this.$t("Public") as string,
to: { name: RouteName.GROUP_PUBLIC_SETTINGS } as Route,
},
{
title: this.$t("Members") as string,
to: { name: RouteName.GROUP_MEMBERS_SETTINGS } as Route,
},
],
},
];
}
get routes(): Map<string, Route[]> {
return this.getPath(this.menu);
}
getPath(object: ISettingMenuSection[]) {
function iter(menu: ISettingMenuSection[] | ISettingMenuSection, acc: ISettingMenuSection[]) {
if (Array.isArray(menu)) {
return menu.forEach((item: ISettingMenuSection) => {
iter(item, acc.concat(item));
});
}
if (menu.items && menu.items.length > 0) {
return menu.items.forEach((item: ISettingMenuSection) => {
iter(item, acc.concat(item));
});
}
result.set(menu.to.name, acc);
}
const result = new Map();
iter(object, []);
return result;
}
}
</script>
<style lang="scss" scoped>
aside.section {
padding-top: 1rem;
}
</style>