Improve post & events cards, homepage and my events page

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2021-11-02 19:47:54 +01:00
parent 39f40a86f7
commit 4923c52f3b
51 changed files with 2057 additions and 1092 deletions

View File

@@ -606,7 +606,6 @@ import {
CURRENT_ACTOR_CLIENT,
IDENTITIES,
LOGGED_USER_DRAFTS,
LOGGED_USER_PARTICIPATIONS,
PERSON_STATUS_GROUP,
} from "../../graphql/actor";
import {
@@ -635,6 +634,7 @@ import { IEventOptions } from "@/types/event-options.model";
import { USER_SETTINGS } from "@/graphql/user";
import { IUser } from "@/types/current-user.model";
import { IAddress } from "@/types/address.model";
import { LOGGED_USER_PARTICIPATIONS } from "@/graphql/participant";
const DEFAULT_LIMIT_NUMBER_OF_PLACES = 10;

View File

@@ -52,14 +52,10 @@
{{ showPassedEvents ? $t("Past events") : $t("Upcoming events") }}
</subtitle>
<b-switch v-model="showPassedEvents">{{ $t("Past events") }}</b-switch>
<transition-group name="list" tag="div" class="event-list">
<EventListViewCard
v-for="event in group.organizedEvents.elements"
:key="event.id"
:event="event"
:options="{ memberofGroup: isCurrentActorMember }"
/>
</transition-group>
<grouped-multi-event-minimalist-card
:events="group.organizedEvents.elements"
:isCurrentActorMember="isCurrentActorMember"
/>
<b-message
v-if="
group.organizedEvents.elements.length === 0 &&
@@ -88,7 +84,7 @@ import { Component } from "vue-property-decorator";
import { mixins } from "vue-class-component";
import RouteName from "@/router/name";
import Subtitle from "@/components/Utils/Subtitle.vue";
import EventListViewCard from "@/components/Event/EventListViewCard.vue";
import GroupedMultiEventMinimalistCard from "@/components/Event/GroupedMultiEventMinimalistCard.vue";
import { PERSON_MEMBERSHIPS } from "@/graphql/actor";
import GroupMixin from "@/mixins/group";
import { IMember } from "@/types/actor/member.model";
@@ -120,14 +116,14 @@ const EVENTS_PAGE_LIMIT = 10;
beforeDateTime: this.showPassedEvents ? new Date() : null,
afterDateTime: this.showPassedEvents ? null : new Date(),
organisedEventsPage: this.eventsPage,
organisedEventslimit: EVENTS_PAGE_LIMIT,
organisedEventsLimit: EVENTS_PAGE_LIMIT,
};
},
},
},
components: {
Subtitle,
EventListViewCard,
GroupedMultiEventMinimalistCard,
},
metaInfo() {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment

View File

@@ -18,110 +18,175 @@
>
</div>
<b-loading :active.sync="$apollo.loading"></b-loading>
<section v-if="futureParticipations.length > 0">
<subtitle>
{{ $t("Upcoming") }}
</subtitle>
<transition-group name="list" tag="p">
<div v-for="month in monthlyFutureParticipations" :key="month[0]">
<span class="upcoming-month">{{ month[0] }}</span>
<EventListCard
v-for="participation in month[1]"
:key="participation.id"
:participation="participation"
:options="{ hideDate: false }"
@event-deleted="eventDeleted"
class="participation"
/>
</div>
</transition-group>
<div class="columns is-centered">
<b-button
class="column is-narrow"
v-if="
hasMoreFutureParticipations && futureParticipations.length === limit
"
@click="loadMoreFutureParticipations"
size="is-large"
type="is-primary"
>{{ $t("Load more") }}</b-button
<div class="wrapper">
<div class="event-filter">
<b-field grouped group-multiline>
<b-field>
<b-switch v-model="showUpcoming">{{
showUpcoming ? $t("Upcoming events") : $t("Past events")
}}</b-switch>
</b-field>
<b-field v-if="showUpcoming">
<b-checkbox v-model="showDrafts">{{ $t("Drafts") }}</b-checkbox>
</b-field>
<b-field v-if="showUpcoming">
<b-checkbox v-model="showAttending">{{
$t("Attending")
}}</b-checkbox>
</b-field>
<b-field v-if="showUpcoming">
<b-checkbox v-model="showMyGroups">{{
$t("From my groups")
}}</b-checkbox>
</b-field>
<p v-if="!showUpcoming">
{{
$tc(
"You have attended {count} events in the past.",
pastParticipations.total,
{
count: pastParticipations.total,
}
)
}}
</p>
<b-field
class="date-filter"
expanded
:label="
showUpcoming
? $t('Showing events starting on')
: $t('Showing events before')
"
>
<b-datepicker v-model="dateFilter" />
<b-button
@click="dateFilter = new Date()"
class="reset-area"
icon-left="close"
:title="$t('Clear date filter field')"
/>
</b-field>
</b-field>
</div>
<div class="my-events">
<section
class="py-4"
v-if="showUpcoming && showDrafts && drafts.length > 0"
>
</div>
</section>
<section v-if="drafts.length > 0">
<subtitle>
{{ $t("Drafts") }}
</subtitle>
<div class="columns is-multiline">
<EventCard
v-for="draft in drafts"
:key="draft.uuid"
:event="draft"
class="is-one-quarter-desktop column"
/>
</div>
</section>
<section v-if="pastParticipations.length > 0">
<subtitle>
{{ $t("Past events") }}
</subtitle>
<transition-group name="list" tag="p">
<div v-for="month in monthlyPastParticipations" :key="month[0]">
<span class="past-month">{{ month[0] }}</span>
<EventListCard
v-for="participation in month[1]"
:key="participation.id"
:participation="participation"
:options="{ hideDate: false }"
@event-deleted="eventDeleted"
class="participation"
/>
</div>
</transition-group>
<div class="columns is-centered">
<b-button
class="column is-narrow"
<multi-event-minimalist-card :events="drafts" :showOrganizer="true" />
</section>
<section
class="py-4"
v-if="
hasMorePastParticipations && pastParticipations.length === limit
showUpcoming && monthlyFutureEvents && monthlyFutureEvents.size > 0
"
>
<transition-group name="list" tag="p">
<div
class="mb-5"
v-for="month in monthlyFutureEvents"
:key="month[0]"
>
<span class="upcoming-month">{{ month[0] }}</span>
<div v-for="element in month[1]" :key="element.id">
<event-participation-card
v-if="'role' in element"
:participation="element"
:options="{ hideDate: false }"
@event-deleted="eventDeleted"
class="participation"
/>
<event-minimalist-card
v-else-if="
!monthParticipationsIds(month[1]).includes(element.id)
"
:event="element"
class="participation"
/>
</div>
</div>
</transition-group>
<div class="columns is-centered">
<b-button
class="column is-narrow"
v-if="
hasMoreFutureParticipations &&
futureParticipations &&
futureParticipations.length === limit
"
@click="loadMoreFutureParticipations"
size="is-large"
type="is-primary"
>{{ $t("Load more") }}</b-button
>
</div>
</section>
<section
class="has-text-centered not-found"
v-if="
showUpcoming &&
monthlyFutureEvents &&
monthlyFutureEvents.size === 0 &&
!$apollo.loading
"
@click="loadMorePastParticipations"
size="is-large"
type="is-primary"
>{{ $t("Load more") }}</b-button
>
</div>
</section>
<section
class="has-text-centered not-found"
v-if="
futureParticipations.length === 0 &&
pastParticipations.length === 0 &&
!$apollo.loading
"
>
<div class="columns is-vertical is-centered">
<div class="column is-three-quarters">
<div class="img-container" :class="{ webp: supportsWebPFormat }" />
<div class="content has-text-centered">
<p>
{{ $t("You didn't create or join any event yet.") }}
<i18n path="Do you wish to {create_event} or {explore_events}?">
<router-link
:to="{ name: RouteName.CREATE_EVENT }"
slot="create_event"
>{{ $t("create an event") }}</router-link
>
<router-link
:to="{ name: RouteName.SEARCH }"
slot="explore_events"
>{{ $t("explore the events") }}</router-link
>
</i18n>
{{
$t(
"You don't have any upcoming events. Maybe try another filter?"
)
}}
</p>
<i18n
path="Do you wish to {create_event} or {explore_events}?"
tag="p"
>
<router-link
:to="{ name: RouteName.CREATE_EVENT }"
slot="create_event"
>{{ $t("create an event") }}</router-link
>
<router-link
:to="{ name: RouteName.SEARCH }"
slot="explore_events"
>{{ $t("explore the events") }}</router-link
>
</i18n>
</div>
</div>
</section>
<section v-if="!showUpcoming && pastParticipations.elements.length > 0">
<transition-group name="list" tag="p">
<div v-for="month in monthlyPastParticipations" :key="month[0]">
<span class="past-month">{{ month[0] }}</span>
<event-participation-card
v-for="participation in month[1]"
:key="participation.id"
:participation="participation"
:options="{ hideDate: false }"
@event-deleted="eventDeleted"
class="participation"
/>
</div>
</transition-group>
<div class="columns is-centered">
<b-button
class="column is-narrow"
v-if="
hasMorePastParticipations &&
pastParticipations.elements.length === limit
"
@click="loadMorePastParticipations"
size="is-large"
type="is-primary"
>{{ $t("Load more") }}</b-button
>
</div>
</section>
</div>
</section>
</div>
</div>
</template>
@@ -133,35 +198,47 @@ import { ParticipantRole } from "@/types/enums";
import RouteName from "@/router/name";
import { supportsWebPFormat } from "@/utils/support";
import { IParticipant, Participant } from "../../types/participant.model";
import { LOGGED_USER_DRAFTS } from "../../graphql/actor";
import { EventModel, IEvent } from "../../types/event.model";
import EventParticipationCard from "../../components/Event/EventParticipationCard.vue";
import MultiEventMinimalistCard from "../../components/Event/MultiEventMinimalistCard.vue";
import EventMinimalistCard from "../../components/Event/EventMinimalistCard.vue";
import Subtitle from "../../components/Utils/Subtitle.vue";
import {
LOGGED_USER_PARTICIPATIONS,
LOGGED_USER_DRAFTS,
} from "../../graphql/actor";
import { EventModel, IEvent } from "../../types/event.model";
import EventListCard from "../../components/Event/EventListCard.vue";
import EventCard from "../../components/Event/EventCard.vue";
import Subtitle from "../../components/Utils/Subtitle.vue";
LOGGED_USER_UPCOMING_EVENTS,
} from "@/graphql/participant";
import { Paginate } from "@/types/paginate";
type Eventable = IParticipant | IEvent;
@Component({
components: {
Subtitle,
EventCard,
EventListCard,
MultiEventMinimalistCard,
EventParticipationCard,
EventMinimalistCard,
},
apollo: {
config: CONFIG,
futureParticipations: {
query: LOGGED_USER_PARTICIPATIONS,
userUpcomingEvents: {
query: LOGGED_USER_UPCOMING_EVENTS,
fetchPolicy: "cache-and-network",
variables: {
page: 1,
limit: 10,
afterDateTime: new Date().toISOString(),
variables() {
return {
page: 1,
limit: 10,
afterDateTime: this.dateFilter,
};
},
update: (data) =>
data.loggedUser.participations.elements.map(
update(data) {
this.futureParticipations = data.loggedUser.participations.elements.map(
(participation: IParticipant) => new Participant(participation)
),
);
this.groupEvents = data.loggedUser.followedGroupEvents.elements.map(
({ event }: { event: IEvent }) => event
);
},
},
drafts: {
query: LOGGED_USER_DRAFTS,
@@ -176,15 +253,14 @@ import Subtitle from "../../components/Utils/Subtitle.vue";
pastParticipations: {
query: LOGGED_USER_PARTICIPATIONS,
fetchPolicy: "cache-and-network",
variables: {
page: 1,
limit: 10,
beforeDateTime: new Date().toISOString(),
variables() {
return {
page: 1,
limit: 10,
beforeDateTime: this.dateFilter,
};
},
update: (data) =>
data.loggedUser.participations.elements.map(
(participation: IParticipant) => new Participant(participation)
),
update: (data) => data.loggedUser.participations,
},
},
metaInfo() {
@@ -200,13 +276,89 @@ export default class MyEvents extends Vue {
limit = 10;
get showUpcoming(): boolean {
return ((this.$route.query.showUpcoming as string) || "true") === "true";
}
set showUpcoming(showUpcoming: boolean) {
this.$router.push({
name: RouteName.MY_EVENTS,
query: { ...this.$route.query, showUpcoming: showUpcoming.toString() },
});
}
get showDrafts(): boolean {
return ((this.$route.query.showDrafts as string) || "true") === "true";
}
set showDrafts(showDrafts: boolean) {
this.$router.push({
name: RouteName.MY_EVENTS,
query: { ...this.$route.query, showDrafts: showDrafts.toString() },
});
}
get showAttending(): boolean {
return ((this.$route.query.showAttending as string) || "true") === "true";
}
set showAttending(showAttending: boolean) {
this.$router.push({
name: RouteName.MY_EVENTS,
query: { ...this.$route.query, showAttending: showAttending.toString() },
});
}
get showMyGroups(): boolean {
return ((this.$route.query.showMyGroups as string) || "false") === "true";
}
set showMyGroups(showMyGroups: boolean) {
this.$router.push({
name: RouteName.MY_EVENTS,
query: { ...this.$route.query, showMyGroups: showMyGroups.toString() },
});
}
get dateFilter(): Date {
const query = this.$route.query.dateFilter as string;
if (query && /(\d{4}-\d{2}-\d{2})/.test(query)) {
return new Date(`${query}T00:00:00Z`);
}
return new Date();
}
set dateFilter(date: Date) {
const pad = (number: number) => {
if (number < 10) {
return "0" + number;
}
return number;
};
const stringifiedDate = `${date.getFullYear()}-${pad(
date.getMonth() + 1
)}-${pad(date.getDate())}`;
if (this.$route.query.dateFilter !== stringifiedDate) {
this.$router.push({
name: RouteName.MY_EVENTS,
query: {
...this.$route.query,
dateFilter: stringifiedDate,
},
});
}
}
config!: IConfig;
futureParticipations: IParticipant[] = [];
groupEvents: IEvent[] = [];
hasMoreFutureParticipations = true;
pastParticipations: IParticipant[] = [];
pastParticipations: Paginate<IParticipant> = { elements: [], total: 0 };
hasMorePastParticipations = true;
@@ -216,49 +368,68 @@ export default class MyEvents extends Vue {
supportsWebPFormat = supportsWebPFormat;
static monthlyParticipations(
participations: IParticipant[],
static monthlyEvents(
elements: Eventable[],
revertSort = false
): Map<string, Participant[]> {
const res = participations.filter(
({ event, role }) =>
event.beginsOn != null && role !== ParticipantRole.REJECTED
);
if (revertSort) {
res.sort(
(a: IParticipant, b: IParticipant) =>
b.event.beginsOn.getTime() - a.event.beginsOn.getTime()
);
} else {
res.sort(
(a: IParticipant, b: IParticipant) =>
a.event.beginsOn.getTime() - b.event.beginsOn.getTime()
);
}
return res.reduce(
(acc: Map<string, IParticipant[]>, participation: IParticipant) => {
const month = new Date(participation.event.beginsOn).toLocaleDateString(
undefined,
{
year: "numeric",
month: "long",
}
): Map<string, Eventable[]> {
const res = elements.filter((element: Eventable) => {
if ("role" in element) {
return (
element.event.beginsOn != null &&
element.role !== ParticipantRole.REJECTED
);
const filteredParticipations: IParticipant[] = acc.get(month) || [];
filteredParticipations.push(participation);
acc.set(month, filteredParticipations);
return acc;
},
new Map()
);
}
return element.beginsOn != null;
});
if (revertSort) {
res.sort((a: Eventable, b: Eventable) => {
const aTime = "role" in a ? a.event.beginsOn : a.beginsOn;
const bTime = "role" in b ? b.event.beginsOn : b.beginsOn;
return new Date(bTime).getTime() - new Date(aTime).getTime();
});
} else {
res.sort((a: Eventable, b: Eventable) => {
const aTime = "role" in a ? a.event.beginsOn : a.beginsOn;
const bTime = "role" in b ? b.event.beginsOn : b.beginsOn;
return new Date(aTime).getTime() - new Date(bTime).getTime();
});
}
return res.reduce((acc: Map<string, Eventable[]>, element: Eventable) => {
const month = new Date(
"role" in element ? element.event.beginsOn : element.beginsOn
).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
});
const filteredElements: Eventable[] = acc.get(month) || [];
filteredElements.push(element);
acc.set(month, filteredElements);
return acc;
}, new Map());
}
get monthlyFutureParticipations(): Map<string, Participant[]> {
return MyEvents.monthlyParticipations(this.futureParticipations);
get monthlyFutureEvents(): Map<string, Eventable[]> {
let eventable = [] as Eventable[];
if (this.showAttending) {
eventable = [...eventable, ...this.futureParticipations];
}
if (this.showMyGroups) {
eventable = [...eventable, ...this.groupEvents];
}
return MyEvents.monthlyEvents(eventable);
}
get monthlyPastParticipations(): Map<string, Participant[]> {
return MyEvents.monthlyParticipations(this.pastParticipations, true);
get monthlyPastParticipations(): Map<string, Eventable[]> {
return MyEvents.monthlyEvents(this.pastParticipations.elements, true);
}
monthParticipationsIds(elements: Eventable[]): string[] {
let res = elements.filter((element: Eventable) => {
return "role" in element;
}) as IParticipant[];
return res.map(({ event }: { event: IEvent }) => {
return event.id as string;
});
}
loadMoreFutureParticipations(): void {
@@ -287,9 +458,12 @@ export default class MyEvents extends Vue {
this.futureParticipations = this.futureParticipations.filter(
(participation) => participation.event.id !== eventid
);
this.pastParticipations = this.pastParticipations.filter(
(participation) => participation.event.id !== eventid
);
this.pastParticipations = {
elements: this.pastParticipations.elements.filter(
(participation) => participation.event.id !== eventid
),
total: this.pastParticipations.total - 1,
};
}
get hideCreateEventButton(): boolean {
@@ -300,6 +474,8 @@ export default class MyEvents extends Vue {
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss" scoped>
@import "~bulma/sass/utilities/mixins.sass";
main > .container {
background: $white;
@@ -335,6 +511,7 @@ section {
}
.not-found {
margin-top: 2rem;
.img-container {
background-image: url("../../../public/img/pics/event_creation-480w.jpg");
@media (min-resolution: 2dppx) {
@@ -359,4 +536,41 @@ section {
margin: auto auto 1rem;
}
}
.wrapper {
display: grid;
grid-template-areas: "filter" "events";
align-items: start;
@include desktop {
gap: 2rem;
grid-template-columns: 1fr 3fr;
grid-template-areas: "filter events";
}
.event-filter {
grid-area: filter;
background: lightgray;
border-radius: 5px;
padding: 0.75rem 1.25rem 0.25rem;
@include desktop {
padding: 2rem 1.25rem;
::v-deep .field.is-grouped {
display: block;
}
}
::v-deep .field > .field {
margin: 0 auto 1.25rem !important;
}
.date-filter ::v-deep .field-body {
display: block;
}
}
.my-events {
grid-area: events;
}
}
</style>

View File

@@ -149,6 +149,7 @@
currentActor.id
"
@click="joinGroup"
@keyup.enter="joinGroup"
type="is-primary"
:disabled="previewPublic"
>{{ $t("Join group") }}</b-button
@@ -172,6 +173,7 @@
currentActor.id
"
@click="followGroup"
@keyup.enter="followGroup"
type="is-primary"
:disabled="isCurrentActorPendingFollow"
>{{ $t("Follow") }}</b-button
@@ -195,6 +197,7 @@
outlined
v-if="isCurrentActorPendingFollow && currentActor.id"
@click="unFollowGroup"
@keyup.enter="unFollowGroup"
type="is-primary"
>{{ $t("Cancel follow request") }}</b-button
><b-button
@@ -208,6 +211,7 @@
<b-button
v-if="isCurrentActorFollowing"
@click="toggleFollowNotify"
@keyup.enter="toggleFollowNotify"
:icon-left="
isCurrentActorFollowingNotify
? 'bell-outline'
@@ -218,6 +222,7 @@
outlined
icon-left="share"
@click="triggerShare()"
@keyup.enter="triggerShare()"
v-if="!isCurrentActorAGroupMember || previewPublic"
>
{{ $t("Share") }}
@@ -246,6 +251,7 @@
v-if="!previewPublic && isCurrentActorAGroupMember"
aria-role="menuitem"
@click="triggerShare()"
@keyup.enter="triggerShare()"
>
<span>
<b-icon icon="share" />
@@ -280,6 +286,7 @@
v-if="ableToReport"
aria-role="menuitem"
@click="isReportModalActive = true"
@keyup.enter="isReportModalActive = true"
>
<span>
<b-icon icon="flag" />
@@ -289,7 +296,8 @@
<b-dropdown-item
aria-role="menuitem"
v-if="isCurrentActorAGroupMember && !previewPublic"
@click="leaveGroup"
@click="openLeaveGroupModal"
@keyup.enter="openLeaveGroupModal"
>
<span>
<b-icon icon="exit-to-app" />
@@ -401,8 +409,8 @@
class="organized-events-wrapper"
v-if="group && group.organizedEvents.total > 0"
>
<EventMinimalistCard
v-for="event in group.organizedEvents.elements"
<event-minimalist-card
v-for="event in group.organizedEvents.elements.slice(0, 3)"
:event="event"
:key="event.uuid"
class="organized-event"
@@ -436,13 +444,11 @@
}"
>
<template v-slot:default>
<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>
<multi-post-list-item
v-if="group.posts.total > 0"
:posts="group.posts.elements.slice(0, 3)"
:isCurrentActorMember="isCurrentActorAGroupMember"
/>
<empty-content v-else-if="group" icon="bullhorn" :inline="true">
{{ $t("No posts yet") }}
</empty-content>
@@ -502,6 +508,7 @@
<span
class="map-show-button"
@click="showMap = !showMap"
@keyup.enter="showMap = !showMap"
v-if="physicalAddress.geom"
>{{ $t("Show map") }}</span
>
@@ -527,8 +534,8 @@
class="organized-events-wrapper"
v-if="group && organizedEvents.elements.length > 0"
>
<EventMinimalistCard
v-for="event in organizedEvents.elements"
<event-minimalist-card
v-for="event in organizedEvents.elements.slice(0, 3)"
:event="event"
:key="event.uuid"
class="organized-event"
@@ -562,13 +569,21 @@
</section>
<section>
<subtitle>{{ $t("Latest posts") }}</subtitle>
<div v-if="posts.elements.length > 0" class="posts-wrapper">
<post-list-item
v-for="post in posts.elements"
:key="post.id"
:post="post"
/>
</div>
<multi-post-list-item
v-if="
posts.elements.filter(
(post) =>
!post.draft && post.visibility === PostVisibility.PUBLIC
).length > 0
"
:posts="
posts.elements.filter(
(post) =>
!post.draft && post.visibility === PostVisibility.PUBLIC
)
"
/>
<empty-content v-else-if="group" icon="bullhorn" :inline="true">
{{ $t("No posts yet") }}
</empty-content>
@@ -630,7 +645,7 @@ import Subtitle from "@/components/Utils/Subtitle.vue";
import CompactTodo from "@/components/Todo/CompactTodo.vue";
import EventMinimalistCard from "@/components/Event/EventMinimalistCard.vue";
import DiscussionListItem from "@/components/Discussion/DiscussionListItem.vue";
import PostListItem from "@/components/Post/PostListItem.vue";
import MultiPostListItem from "@/components/Post/MultiPostListItem.vue";
import ResourceItem from "@/components/Resource/ResourceItem.vue";
import FolderItem from "@/components/Resource/FolderItem.vue";
import { Address } from "@/types/address.model";
@@ -668,7 +683,7 @@ import {
},
components: {
DiscussionListItem,
PostListItem,
MultiPostListItem,
EventMinimalistCard,
CompactTodo,
Subtitle,
@@ -712,6 +727,8 @@ export default class Group extends mixins(GroupMixin) {
usernameWithDomain = usernameWithDomain;
PostVisibility = PostVisibility;
Openness = Openness;
showMap = false;
@@ -751,6 +768,20 @@ export default class Group extends mixins(GroupMixin) {
});
}
protected async openLeaveGroupModal(): Promise<void> {
this.$buefy.dialog.confirm({
type: "is-danger",
title: this.$t("Leave group") as string,
message: this.$t(
"Are you sure you want to leave the group {groupName}? You'll loose access to this group's private content. This action cannot be undone.",
{ groupName: `<b>${displayName(this.group)}</b>` }
) as string,
onConfirm: () => this.leaveGroup(),
confirmText: this.$t("Leave group") as string,
cancelText: this.$t("Cancel") as string,
});
}
async leaveGroup(): Promise<void> {
try {
const [group, currentActorId] = [
@@ -1016,8 +1047,8 @@ export default class Group extends mixins(GroupMixin) {
return {
total: this.group.posts.total,
elements: this.group.posts.elements.filter((post: IPost) => {
if (this.previewPublic) {
return !(post.draft || post.visibility == PostVisibility.PRIVATE);
if (this.previewPublic || !this.isCurrentActorAGroupMember) {
return !post.draft && post.visibility == PostVisibility.PUBLIC;
}
return true;
}),
@@ -1144,19 +1175,6 @@ div.container {
section {
background: $white;
.posts-wrapper {
padding-bottom: 1rem;
}
.organized-events-wrapper {
display: flex;
flex-wrap: wrap;
.organized-event {
margin: 0.25rem 0;
}
}
&.presentation {
.media-left {
span.icon.is-large {
@@ -1306,10 +1324,6 @@ div.container {
section {
margin-top: 0;
.posts-wrapper {
margin-bottom: 1rem;
}
}
}
@@ -1319,5 +1333,12 @@ div.container {
padding-right: 1rem;
}
}
.organized-events-wrapper,
.posts-wrapper {
display: grid;
grid-gap: 20px;
grid-template: 1fr;
}
}
</style>

View File

@@ -31,6 +31,7 @@
</li>
</ul>
</nav>
<b-loading :active="$apollo.loading" />
<section
class="container section"
v-if="group && isCurrentActorAGroupAdmin && followers"
@@ -125,7 +126,7 @@
</template>
</b-table>
</section>
<b-message v-else-if="group">
<b-message v-else-if="!$apollo.loading && group">
{{ $t("You are not an administrator for this group.") }}
</b-message>
</div>

View File

@@ -31,6 +31,7 @@
</li>
</ul>
</nav>
<b-loading :active="$apollo.loading" />
<section
class="container section"
v-if="group && isCurrentActorAGroupAdmin"
@@ -230,7 +231,7 @@
</template>
</b-table>
</section>
<b-message v-else-if="group">
<b-message v-else-if="!$apollo.loading && group">
{{ $t("You are not an administrator for this group.") }}
</b-message>
</div>

View File

@@ -32,6 +32,7 @@
</li>
</ul>
</nav>
<b-loading :active="$apollo.loading" />
<section
class="container section"
v-if="group && isCurrentActorAGroupAdmin"
@@ -169,7 +170,7 @@
{{ value }}
</b-message>
</section>
<b-message v-else>
<b-message v-else-if="!$apollo.loading">
{{ $t("You are not an administrator for this group.") }}
</b-message>
</div>
@@ -178,12 +179,11 @@
<script lang="ts">
import { Component, Watch } from "vue-property-decorator";
import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.vue";
import { Route } from "vue-router";
import PictureUpload from "@/components/PictureUpload.vue";
import { mixins } from "vue-class-component";
import GroupMixin from "@/mixins/group";
import { GroupVisibility, Openness } from "@/types/enums";
import { UPDATE_GROUP, DELETE_GROUP } from "../../graphql/group";
import { UPDATE_GROUP } from "../../graphql/group";
import { IGroup, usernameWithDomain } from "../../types/actor";
import { Address, IAddress } from "../../types/address.model";
import { CONFIG } from "@/graphql/config";
@@ -246,31 +246,6 @@ export default class GroupSettings extends mixins(GroupMixin) {
this.handleError(err);
}
}
confirmDeleteGroup(): void {
this.$buefy.dialog.confirm({
title: this.$t("Delete group") as string,
message: this.$t(
"Are you sure you want to <b>completely delete</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>."
) as string,
confirmText: this.$t("Delete group") as string,
cancelText: this.$t("Cancel") as string,
type: "is-danger",
hasIcon: true,
onConfirm: () => this.deleteGroup(),
});
}
async deleteGroup(): Promise<Route> {
await this.$apollo.mutate<{ deleteGroup: IGroup }>({
mutation: DELETE_GROUP,
variables: {
groupId: this.group.id,
},
});
return this.$router.push({ name: RouteName.MY_GROUPS });
}
async copyURL(): Promise<void> {
await window.navigator.clipboard.writeText(this.group.url);
this.showCopiedTooltip = true;

View File

@@ -213,7 +213,7 @@
</span>
</p>
<div>
<EventListCard
<event-participation-card
v-for="participation in thisWeek(row)"
@event-deleted="eventDeleted"
:key="participation[1].id"
@@ -230,24 +230,34 @@
<hr
role="presentation"
class="home-separator"
v-if="canShowMyUpcomingEvents && canShowFollowActivity"
v-if="canShowMyUpcomingEvents && canShowFollowedGroupEvents"
/>
<!-- Events from your followed groups -->
<section class="followActivity" v-if="canShowFollowActivity">
<section class="followActivity" v-if="canShowFollowedGroupEvents">
<h2 class="title">
{{ $t("Recent events from your groups") }}
{{ $t("Upcoming events from your groups") }}
</h2>
<p>{{ $t("That you follow or of which you are a member") }}</p>
<multi-card
:events="
followedGroupEvents.elements.map(({ event }) => event).slice(0, 3)
"
/>
<multi-card :events="filteredFollowedGroupsEvents" />
<span class="view-all">
<router-link
:to="{
name: RouteName.MY_EVENTS,
query: {
showUpcoming: 'true',
showDrafts: 'false',
showAttending: 'false',
showMyGroups: 'true',
},
}"
>{{ $t("View everything") }} >></router-link
>
</span>
</section>
<hr
role="presentation"
class="home-separator"
v-if="canShowFollowActivity && canShowCloseEvents"
v-if="canShowFollowedGroupEvents && canShowCloseEvents"
/>
<!-- Events close to you -->
@@ -319,7 +329,7 @@ import { Paginate } from "@/types/paginate";
import { supportsWebPFormat } from "@/utils/support";
import { IParticipant, Participant } from "../types/participant.model";
import { CLOSE_EVENTS, FETCH_EVENTS } from "../graphql/event";
import EventListCard from "../components/Event/EventListCard.vue";
import EventParticipationCard from "../components/Event/EventParticipationCard.vue";
import MultiCard from "../components/Event/MultiCard.vue";
import { CURRENT_ACTOR_CLIENT } from "../graphql/actor";
import { IPerson, Person } from "../types/actor";
@@ -392,7 +402,7 @@ import Subtitle from "../components/Utils/Subtitle.vue";
components: {
Subtitle,
DateComponent,
EventListCard,
EventParticipationCard,
MultiCard,
"settings-onboard": () => import("./User/SettingsOnboard.vue"),
},
@@ -580,8 +590,20 @@ export default class Home extends Vue {
);
}
get canShowFollowActivity(): boolean {
return this.followedGroupEvents.total > 0;
get canShowFollowedGroupEvents(): boolean {
return this.filteredFollowedGroupsEvents.length > 0;
}
get filteredFollowedGroupsEvents(): IEvent[] {
return this.followedGroupEvents.elements
.map(({ event }: { event: IEvent }) => event)
.filter(
({ id }) =>
!this.thisWeekGoingToEvents
.map(({ event: { id: event_id } }) => event_id)
.includes(id)
)
.slice(0, 3);
}
}
</script>

View File

@@ -119,9 +119,11 @@
}}</b-button>
</span>
<span class="navbar-item" v-if="this.isUpdate">
<b-button type="is-danger is-outlined" @click="deletePost">{{
$t("Delete post")
}}</b-button>
<b-button
type="is-danger is-outlined"
@click="openDeletePostModal"
>{{ $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">
@@ -167,12 +169,7 @@ import {
import GroupMixin from "@/mixins/group";
import { PostVisibility } from "@/types/enums";
import { CONFIG } from "../../graphql/config";
import {
FETCH_POST,
CREATE_POST,
UPDATE_POST,
DELETE_POST,
} from "../../graphql/post";
import { CREATE_POST, UPDATE_POST } from "../../graphql/post";
import { IPost } from "../../types/post.model";
import Editor from "../../components/Editor.vue";
@@ -183,6 +180,7 @@ import Subtitle from "../../components/Utils/Subtitle.vue";
import PictureUpload from "../../components/PictureUpload.vue";
import { PERSON_STATUS_GROUP } from "@/graphql/actor";
import { FETCH_GROUP } from "@/graphql/group";
import PostMixin from "../../mixins/post";
@Component({
apollo: {
@@ -198,18 +196,6 @@ import { FETCH_GROUP } from "@/graphql/group";
return !this.preferredUsername;
},
},
post: {
query: FETCH_POST,
fetchPolicy: "cache-and-network",
variables() {
return {
slug: this.slug,
};
},
skip() {
return !this.slug;
},
},
person: {
query: PERSON_STATUS_GROUP,
fetchPolicy: "cache-and-network",
@@ -242,7 +228,7 @@ import { FETCH_GROUP } from "@/graphql/group";
};
},
})
export default class EditPost extends mixins(GroupMixin) {
export default class EditPost extends mixins(GroupMixin, PostMixin) {
@Prop({ required: false, type: String }) slug: undefined | string;
@Prop({ required: false, type: String }) preferredUsername!: string;
@@ -338,23 +324,6 @@ export default class EditPost extends mixins(GroupMixin) {
}
}
async deletePost(): Promise<void> {
const { data } = await this.$apollo.mutate({
mutation: DELETE_POST,
variables: {
id: this.post.id,
},
});
if (data && this.post.attributedTo) {
this.$router.push({
name: RouteName.POSTS,
params: {
preferredUsername: usernameWithDomain(this.post.attributedTo),
},
});
}
}
static transformMessage(message: string[] | string): string | undefined {
if (Array.isArray(message) && message.length > 0) {
return message[0];

View File

@@ -49,10 +49,8 @@
>
</div>
<div class="post-list">
<post-element-item
v-for="post in group.posts.elements"
:key="post.id"
:post="post"
<multi-post-list-item
:posts="group.posts.elements"
:isCurrentActorMember="isCurrentActorMember"
/>
</div>
@@ -88,7 +86,7 @@ import { Paginate } from "../../types/paginate";
import { IPost } from "../../types/post.model";
import { usernameWithDomain } from "../../types/actor";
import RouteName from "../../router/name";
import PostElementItem from "../../components/Post/PostElementItem.vue";
import MultiPostListItem from "../../components/Post/MultiPostListItem.vue";
const POSTS_PAGE_LIMIT = 10;
@@ -124,7 +122,7 @@ const POSTS_PAGE_LIMIT = 10;
},
},
components: {
PostElementItem,
MultiPostListItem,
},
metaInfo() {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -132,7 +130,7 @@ const POSTS_PAGE_LIMIT = 10;
const { group } = this;
return {
title: this.$t("{group} posts", {
group: group.name || usernameWithDomain(group),
group: group?.name || usernameWithDomain(group),
}) as string,
};
},

View File

@@ -7,7 +7,16 @@
<div class="heading-section">
<div class="heading-wrapper">
<div class="title-metadata">
<h1 class="title">{{ post.title }}</h1>
<div class="title-wrapper">
<b-tag
class="mr-2"
type="is-warning"
size="is-medium"
v-if="post.draft"
>{{ $t("Draft") }}</b-tag
>
<h1 class="title">{{ post.title }}</h1>
</div>
<p class="metadata">
<router-link
slot="author"
@@ -49,7 +58,14 @@
}}
</span>
<span
v-if="post.visibility === PostVisibility.PRIVATE"
v-if="post.visibility === PostVisibility.UNLISTED"
class="has-text-grey-dark"
>
<b-icon icon="link" size="is-small" />
{{ $t("Accessible only by link") }}
</span>
<span
v-else-if="post.visibility === PostVisibility.PRIVATE"
class="has-text-grey-dark"
>
<b-icon icon="lock" size="is-small" />
@@ -61,21 +77,72 @@
</span>
</p>
</div>
<p class="buttons" v-if="isCurrentActorMember">
<b-tag type="is-warning" size="is-medium" v-if="post.draft">{{
$t("Draft")
}}</b-tag>
<router-link
<b-dropdown position="is-bottom-left" aria-role="list">
<b-button slot="trigger" role="button" icon-right="dots-horizontal">
{{ $t("Actions") }}
</b-button>
<b-dropdown-item
aria-role="listitem"
has-link
v-if="
currentActor.id === post.author.id ||
isCurrentActorAGroupModerator
"
:to="{ name: RouteName.POST_EDIT, params: { slug: post.slug } }"
tag="button"
class="button is-text"
>{{ $t("Edit") }}</router-link
>
</p>
<router-link
:to="{
name: RouteName.POST_EDIT,
params: { slug: post.slug },
}"
>{{ $t("Edit") }} <b-icon icon="pencil"
/></router-link>
</b-dropdown-item>
<b-dropdown-item
aria-role="listitem"
v-if="
currentActor.id === post.author.id ||
isCurrentActorAGroupModerator
"
@click="openDeletePostModal"
@keyup.enter="openDeletePostModal"
>
{{ $t("Delete") }}
<b-icon icon="delete" />
</b-dropdown-item>
<hr
role="presentation"
class="dropdown-divider"
aria-role="menuitem"
v-if="
currentActor.id === post.author.id ||
isCurrentActorAGroupModerator
"
/>
<b-dropdown-item
aria-role="listitem"
v-if="!post.draft"
@click="triggerShare()"
@keyup.enter="triggerShare()"
>
<span>
{{ $t("Share this event") }}
<b-icon icon="share" />
</span>
</b-dropdown-item>
<b-dropdown-item
aria-role="listitem"
v-if="ableToReport"
@click="isReportModalActive = true"
@keyup.enter="isReportModalActive = true"
>
<span>
{{ $t("Report") }}
<b-icon icon="flag" />
</span>
</b-dropdown-item>
</b-dropdown>
</div>
</div>
</header>
@@ -108,6 +175,21 @@
<tag>{{ tag.title }}</tag>
</router-link>
</section>
<b-modal
:active.sync="isReportModalActive"
has-modal-card
ref="reportModal"
>
<report-modal
:on-confirm="reportPost"
:title="$t('Report this post')"
:outside-domain="groupDomain"
@close="$refs.reportModal.close()"
/>
</b-modal>
<b-modal :active.sync="isShareModalActive" has-modal-card ref="shareModal">
<share-post-modal :post="post" />
</b-modal>
</article>
</template>
@@ -122,8 +204,6 @@ import {
PERSON_MEMBERSHIPS,
PERSON_STATUS_GROUP,
} from "../../graphql/actor";
import { FETCH_POST } from "../../graphql/post";
import { IPost } from "../../types/post.model";
import { usernameWithDomain } from "../../types/actor";
import RouteName from "../../router/name";
import Tag from "../../components/Tag.vue";
@@ -132,9 +212,17 @@ import ActorInline from "../../components/Account/ActorInline.vue";
import { formatDistanceToNowStrict } from "date-fns";
import { CURRENT_USER_CLIENT } from "@/graphql/user";
import { ICurrentUser } from "@/types/current-user.model";
import { CONFIG } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
import SharePostModal from "../../components/Post/SharePostModal.vue";
import { IReport } from "@/types/report.model";
import { CREATE_REPORT } from "@/graphql/report";
import ReportModal from "../../components/Report/ReportModal.vue";
import PostMixin from "../../mixins/post";
@Component({
apollo: {
config: CONFIG,
currentUser: CURRENT_USER_CLIENT,
currentActor: CURRENT_ACTOR_CLIENT,
memberships: {
@@ -150,21 +238,6 @@ import { ICurrentUser } from "@/types/current-user.model";
return !this.currentActor || !this.currentActor.id;
},
},
post: {
query: FETCH_POST,
fetchPolicy: "cache-and-network",
variables() {
return {
slug: this.slug,
};
},
skip() {
return !this.slug;
},
error({ graphQLErrors }) {
this.handleErrors(graphQLErrors);
},
},
person: {
query: PERSON_STATUS_GROUP,
fetchPolicy: "cache-and-network",
@@ -187,6 +260,8 @@ import { ICurrentUser } from "@/types/current-user.model";
Tag,
LazyImageWrapper,
ActorInline,
SharePostModal,
ReportModal,
},
metaInfo() {
return {
@@ -200,13 +275,13 @@ import { ICurrentUser } from "@/types/current-user.model";
};
},
})
export default class Post extends mixins(GroupMixin) {
export default class Post extends mixins(GroupMixin, PostMixin) {
@Prop({ required: true, type: String }) slug!: string;
post!: IPost;
memberships!: IMember[];
config!: IConfig;
RouteName = RouteName;
currentUser!: ICurrentUser;
@@ -217,11 +292,9 @@ export default class Post extends mixins(GroupMixin) {
PostVisibility = PostVisibility;
handleErrors(errors: any[]): void {
if (errors.some((error) => error.status_code === 404)) {
this.$router.replace({ name: RouteName.PAGE_NOT_FOUND });
}
}
isShareModalActive = false;
isReportModalActive = false;
get isCurrentActorMember(): boolean {
if (!this.post.attributedTo || !this.memberships) return false;
@@ -236,6 +309,62 @@ export default class Post extends mixins(GroupMixin) {
ICurrentUserRole.MODERATOR,
].includes(this.currentUser.role);
}
get ableToReport(): boolean {
return (
this.config &&
(this.currentActor.id != null || this.config.anonymous.reports.allowed)
);
}
triggerShare(): void {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-start
if (navigator.share) {
navigator
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
.share({
title: this.post.title,
url: this.post.url,
})
.then(() => console.log("Successful share"))
.catch((error: any) => console.log("Error sharing", error));
} else {
this.isShareModalActive = true;
// send popup
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-end
}
async reportPost(content: string, forward: boolean): Promise<void> {
this.isReportModalActive = false;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.$refs.reportModal.close();
const postTitle = this.post.title;
try {
await this.$apollo.mutate<IReport>({
mutation: CREATE_REPORT,
variables: {
postId: this.post.id,
reportedId: this.post.attributedTo?.id,
content,
forward,
},
});
this.$notifier.success(
this.$t("Post {eventTitle} reported", { postTitle }) as string
);
} catch (error) {
console.error(error);
}
}
get groupDomain(): string | undefined | null {
return this.post.attributedTo?.domain;
}
}
</script>
<style lang="scss" scoped>
@@ -261,16 +390,31 @@ article.post {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
.title-metadata {
min-width: 300px;
flex: 20;
.title-wrapper {
display: inline;
.tag {
height: 38px;
vertical-align: text-bottom;
}
& > h1 {
display: inline;
}
}
p.metadata {
margin-top: 16px;
margin-top: 10px;
display: flex;
justify-content: flex-start;
flex-wrap: wrap;
flex-direction: column;
*:not(:first-child) {
padding-left: 5px;
@@ -328,5 +472,14 @@ article.post {
}
margin: 0 auto;
a.dropdown-item,
.dropdown .dropdown-menu .has-link a,
button.dropdown-item {
white-space: nowrap;
width: 100%;
padding-right: 1rem;
text-align: right;
}
}
</style>

View File

@@ -60,11 +60,11 @@
<hr
role="presentation"
class="dropdown-divider"
v-if="config.resourceProviders.length"
v-if="resourceProviders.length"
/>
<b-dropdown-item
aria-role="listitem"
v-for="resourceProvider in config.resourceProviders"
v-for="resourceProvider in resourceProviders"
:key="resourceProvider.software"
@click="createResourceFromProvider(resourceProvider)"
>
@@ -418,6 +418,10 @@ export default class Resources extends Mixins(ResourceMixin) {
return this.filteredPath.slice(-1)[0];
}
get resourceProviders(): IProvider[] {
return this.config?.resourceProviders || [];
}
async createResource(): Promise<void> {
if (!this.resource.actor) return;
this.modalError = "";