1
0
Fork 0
mirror of https://framagit.org/framasoft/mobilizon.git synced 2024-12-21 23:44:30 +00:00

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
No known key found for this signature in database
GPG key ID: A061B9DDE0CA0773
51 changed files with 2057 additions and 1092 deletions

View file

@ -157,3 +157,10 @@ p {
background-color: whitesmoke;
color: #0a0a0a;
}
/**
* Bulma/Buefy fixes
*/
.icon {
vertical-align: middle;
}

View file

@ -12,18 +12,17 @@
</docs>
<template>
<time
<div
class="datetime-container"
:class="{ small }"
:datetime="dateObj.getUTCSeconds()"
:style="`--small: ${smallStyle}`"
>
<div class="datetime-container-header" />
<div class="datetime-container-content">
<span class="day">{{ day }}</span>
<span class="month">{{ month }}</span>
<time :datetime="dateObj.toISOString()" class="day">{{ day }}</time>
<time :datetime="dateObj.toISOString()" class="month">{{ month }}</time>
</div>
</div>
</time>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
@ -54,7 +53,7 @@ export default class DateCalendarIcon extends Vue {
</script>
<style lang="scss" scoped>
time.datetime-container {
div.datetime-container {
border-radius: 8px;
display: flex;
flex-direction: column;
@ -76,7 +75,7 @@ time.datetime-container {
height: calc(30px * var(--small));
}
span {
time {
display: block;
font-weight: 600;
color: $violet-3;

View file

@ -0,0 +1,36 @@
<template>
<div
:title="
isDescriptionDifferentFromLocality
? `${physicalAddress.description}, ${physicalAddress.locality}`
: physicalAddress.description
"
>
<b-icon icon="map-marker" />
<span v-if="isDescriptionDifferentFromLocality">
{{ physicalAddress.description }},
{{ physicalAddress.locality }}
</span>
<span v-else>
{{ physicalAddress.description }}
</span>
</div>
</template>
<script lang="ts">
import { IAddress } from "@/types/address.model";
import { PropType } from "vue";
import { Prop, Vue, Component } from "vue-property-decorator";
@Component
export default class EventAddress extends Vue {
@Prop({ required: true, type: Object as PropType<IAddress> })
physicalAddress!: IAddress;
get isDescriptionDifferentFromLocality(): boolean {
return (
this.physicalAddress?.description !== this.physicalAddress?.locality &&
this.physicalAddress?.description !== undefined
);
}
}
</script>

View file

@ -56,24 +56,11 @@
{{ organizerDisplayName(event) }}
</span>
</div>
<div
class="event-subtitle"
<event-address
v-if="event.physicalAddress"
:title="
isDescriptionDifferentFromLocality
? `${event.physicalAddress.description}, ${event.physicalAddress.locality}`
: event.physicalAddress.description
"
>
<b-icon icon="map-marker" />
<span v-if="isDescriptionDifferentFromLocality">
{{ event.physicalAddress.description }},
{{ event.physicalAddress.locality }}
</span>
<span v-else>
{{ event.physicalAddress.description }}
</span>
</div>
class="event-subtitle"
:physical-address="event.physicalAddress"
/>
<div
class="event-subtitle"
v-else-if="event.options && event.options.isOnline"
@ -84,41 +71,6 @@
</div>
</div>
</div>
<!-- <div class="date-and-title">-->
<!-- <div class="date-component">-->
<!-- <date-calendar-icon v-if="!mergedOptions.hideDate" :date="event.beginsOn" />-->
<!-- </div>-->
<!-- <div class="title-wrapper">-->
<!-- <h4>{{ event.title }}</h4>-->
<!-- <div class="organizer-place-wrapper has-text-grey">-->
<!-- <span>{{ $t('By @{username}', { username: actor.preferredUsername }) }}</span>-->
<!-- ·-->
<!-- <span v-if="event.physicalAddress">-->
<!-- {{ event.physicalAddress.description }}, {{ event.physicalAddress.locality }}-->
<!-- </span>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div v-if="!mergedOptions.hideDetails" class="details">-->
<!-- <div v-if="event.participants.length > 0 &&-->
<!-- mergedOptions.loggedPerson &&-->
<!-- event.participants[0].actor.id === mergedOptions.loggedPerson.id">-->
<!-- <b-tag type="is-info"><translate>Organizer</translate></b-tag>-->
<!-- </div>-->
<!-- <div v-else-if="event.participants.length === 1">-->
<!-- <translate-->
<!-- :translate-params="{name: event.participants[0].actor.preferredUsername}"-->
<!-- >{name} organizes this event</translate>-->
<!-- </div>-->
<!-- <div v-else>-->
<!-- <span v-for="participant in event.participants" :key="participant.actor.uuid">-->
<!-- {{ participant.actor.preferredUsername }}-->
<!-- <span v-if="participant.role === ParticipantRole.CREATOR">(organizer)</span>,-->
<!-- &lt;!&ndash; <translate-->
<!-- :translate-params="{name: participant.actor.preferredUsername}"-->
<!-- >&nbsp;{name} is in,</translate>&ndash;&gt;-->
<!-- </span>-->
<!-- </div>-->
</router-link>
</template>
@ -135,11 +87,13 @@ import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
import { Actor, Person } from "@/types/actor";
import { EventStatus, ParticipantRole } from "@/types/enums";
import RouteName from "../../router/name";
import EventAddress from "@/components/Event/EventAddress.vue";
@Component({
components: {
DateCalendarIcon,
LazyImageWrapper,
EventAddress,
},
})
export default class EventCard extends Vue {
@ -175,18 +129,12 @@ export default class EventCard extends Vue {
this.event.organizerActor || this.mergedOptions.organizerActor
);
}
get isDescriptionDifferentFromLocality(): boolean {
return (
this.event?.physicalAddress?.description !==
this.event?.physicalAddress?.locality &&
this.event?.physicalAddress?.description !== undefined
);
}
}
</script>
<style lang="scss" scoped>
@use "@/styles/_event-card";
a.card {
display: block;
background: $secondary;
@ -283,43 +231,16 @@ a.card {
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
// min-height: 2.4rem;
padding-bottom: 8px;
font-weight: bold;
}
.event-organizer {
display: flex;
align-items: center;
padding-top: 8px;
.organizer-name {
font-size: 14px;
padding-left: 5px;
font-weight: 600;
}
}
.event-subtitle {
font-size: 0.85rem;
display: flex;
align-items: center;
// flex-wrap: wrap;
color: #3c376e;
span:not(.icon) {
padding-left: 5px;
}
// span {
// width: 14rem;
// display: block;
// overflow: hidden;
// flex-grow: 1;
// text-overflow: ellipsis;
// white-space: nowrap;
// }
.organizer-name {
font-size: 14px;
}
}
}

View file

@ -14,9 +14,9 @@
/>
</span>
<b-icon v-else-if="icon" :icon="icon" size="is-medium" />
<p :class="{ 'padding-left': icon }">
<div class="content-wrapper" :class="{ 'padding-left': icon }">
<slot></slot>
</p>
</div>
</div>
</div>
</template>
@ -42,7 +42,7 @@ div.eventMetadataBlock {
align-items: center;
margin-bottom: 1.75rem;
p {
.content-wrapper {
overflow: hidden;
&.padding-left {

View file

@ -3,17 +3,55 @@
class="event-minimalist-card-wrapper"
:to="{ name: RouteName.EVENT, params: { uuid: event.uuid } }"
>
<date-calendar-icon
class="calendar-icon"
:date="event.beginsOn"
:small="true"
<div class="event-preview mr-0 ml-0">
<div>
<div class="date-component">
<date-calendar-icon :date="event.beginsOn" :small="true" />
</div>
<lazy-image-wrapper
:picture="event.picture"
:rounded="true"
style="height: 100%; position: absolute; top: 0; left: 0; width: 100%"
/>
<div class="title-info-wrapper">
<p class="event-minimalist-title">{{ event.title }}</p>
<p v-if="event.physicalAddress" class="has-text-grey">
{{ event.physicalAddress.description }}
</p>
<p v-else>
</div>
</div>
<div class="title-info-wrapper has-text-grey-dark">
<h3 class="event-minimalist-title">
<b-tag
class="mr-2"
type="is-warning"
size="is-medium"
v-if="event.draft"
>{{ $t("Draft") }}</b-tag
>
{{ event.title }}
</h3>
<event-address
v-if="event.physicalAddress"
class="event-subtitle"
:physical-address="event.physicalAddress"
/>
<div
class="event-subtitle"
v-else-if="event.options && event.options.isOnline"
>
<b-icon icon="video" />
<span>{{ $t("Online") }}</span>
</div>
<div class="event-subtitle event-organizer" v-if="showOrganizer">
<figure
class="image is-24x24"
v-if="organizer(event) && organizer(event).avatar"
>
<img class="is-rounded" :src="organizer(event).avatar.url" alt="" />
</figure>
<b-icon v-else icon="account-circle" />
<span class="organizer-name">
{{ organizerDisplayName(event) }}
</span>
</div>
<p class="participant-metadata">
<b-icon icon="account-multiple" />
<span v-if="event.options.maximumAttendeeCapacity !== 0">
{{
$tc(
@ -64,30 +102,65 @@
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { IEvent } from "@/types/event.model";
import { IEvent, organizer, organizerDisplayName } from "@/types/event.model";
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
import { ParticipantRole } from "@/types/enums";
import RouteName from "../../router/name";
import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
import EventAddress from "@/components/Event/EventAddress.vue";
@Component({
components: {
DateCalendarIcon,
LazyImageWrapper,
EventAddress,
},
})
export default class EventMinimalistCard extends Vue {
@Prop({ required: true, type: Object }) event!: IEvent;
@Prop({ required: false, type: Boolean, default: false })
showOrganizer!: boolean;
RouteName = RouteName;
ParticipantRole = ParticipantRole;
organizerDisplayName = organizerDisplayName;
organizer = organizer;
}
</script>
<style lang="scss" scoped>
@use "@/styles/_event-card";
@import "~bulma/sass/utilities/mixins.sass";
@import "@/variables.scss";
.event-minimalist-card-wrapper {
display: flex;
width: 100%;
display: grid;
grid-gap: 5px 10px;
grid-template-areas: "preview" "body";
color: initial;
align-items: flex-start;
@include desktop {
grid-template-columns: 200px 3fr;
grid-template-areas: "preview body";
}
.event-preview {
& > div {
position: relative;
height: 120px;
width: 100%;
div.date-component {
display: flex;
position: absolute;
bottom: 5px;
left: 5px;
z-index: 1;
}
}
}
.calendar-icon {
margin-right: 1rem;
@ -97,11 +170,19 @@ export default class EventMinimalistCard extends Vue {
flex: 2;
.event-minimalist-title {
color: #3c376e;
font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial,
serif;
font-size: 1.25rem;
font-weight: 700;
padding-bottom: 5px;
font-size: 18px;
line-height: 24px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
font-weight: bold;
color: $title-color;
}
::v-deep .icon {
vertical-align: middle;
}
}
}

View file

@ -1,5 +1,5 @@
<template>
<article class="box">
<article class="box mb-5 mt-4">
<div class="identity-header">
<figure class="image is-24x24" v-if="participation.actor.avatar">
<img
@ -10,16 +10,39 @@
width="24"
/>
</figure>
<b-icon v-else icon="account-circle" />
{{ displayNameAndUsername(participation.actor) }}
</div>
<div class="list-card">
<div class="content-and-actions">
<div class="event-preview mr-0 ml-0">
<div>
<div class="date-component">
<date-calendar-icon
:date="participation.event.beginsOn"
:small="true"
/>
</div>
<div class="content-and-actions">
<router-link
:to="{
name: RouteName.EVENT,
params: { uuid: participation.event.uuid },
}"
>
<lazy-image-wrapper
:rounded="true"
:picture="participation.event.picture"
style="
height: 100%;
position: absolute;
top: 0;
left: 0;
width: 100%;
"
/>
</router-link>
</div>
</div>
<div class="list-card-content">
<div class="title-wrapper">
<router-link
@ -31,59 +54,61 @@
<h3 class="title">{{ participation.event.title }}</h3>
</router-link>
</div>
<div class="participation-actor">
<span>
<b-icon
icon="earth"
v-if="participation.event.visibility === EventVisibility.PUBLIC"
<event-address
v-if="participation.event.physicalAddress"
class="event-subtitle"
:physical-address="participation.event.physicalAddress"
/>
<b-icon
icon="link"
<div
class="event-subtitle"
v-else-if="
participation.event.visibility === EventVisibility.UNLISTED
participation.event.options &&
participation.event.options.isOnline
"
/>
<b-icon
icon="lock"
v-else-if="
participation.event.visibility === EventVisibility.PRIVATE
"
/>
</span>
<span
v-if="
participation.event.physicalAddress &&
participation.event.physicalAddress.locality
"
>{{ participation.event.physicalAddress.locality }} -</span
>
<i18n
tag="span"
path="Organized by {name}"
v-if="organizerActor.id !== currentActor.id"
>
<popover-actor-card
slot="name"
:actor="organizerActor"
:inline="true"
>
{{ organizerActor.displayName() }}
</popover-actor-card>
</i18n>
<span v-else>{{ $t("Organized by you") }}</span>
<b-icon icon="video" />
<span>{{ $t("Online") }}</span>
</div>
<div>
<div class="event-subtitle event-organizer">
<figure
class="image is-24x24"
v-if="
organizer(participation.event) &&
organizer(participation.event).avatar
"
>
<img
class="is-rounded"
:src="organizer(participation.event).avatar.url"
alt=""
/>
</figure>
<b-icon v-else icon="account-circle" />
<span class="organizer-name">
{{ organizerDisplayName(participation.event) }}
</span>
</div>
<div class="event-subtitle event-participants">
<b-icon
:class="{ 'has-text-danger': lastSeatsLeft }"
icon="account-group"
/>
<span
class="participant-stats"
v-if="
![
ParticipantRole.PARTICIPANT,
ParticipantRole.NOT_APPROVED,
].includes(participation.role)
"
v-if="participation.role !== ParticipantRole.NOT_APPROVED"
>
<!-- Less than 10 seats left -->
<span class="has-text-danger" v-if="lastSeatsLeft">
{{
$t("{number} seats left", {
number: seatsLeft,
})
}}
</span>
<span
v-if="participation.event.options.maximumAttendeeCapacity !== 0"
v-else-if="
participation.event.options.maximumAttendeeCapacity !== 0
"
>
{{
$tc(
@ -111,8 +136,8 @@
)
}}
</span>
<span v-if="participation.event.participantStats.notApproved > 0">
<b-button
v-if="participation.event.participantStats.notApproved > 0"
type="is-text"
@click="
gotToWithCheck(participation, {
@ -133,7 +158,6 @@
}}
</b-button>
</span>
</span>
</div>
</div>
<div class="actions">
@ -233,7 +257,11 @@ import { mixins } from "vue-class-component";
import { RawLocation, Route } from "vue-router";
import { EventVisibility, ParticipantRole } from "@/types/enums";
import { IParticipant } from "../../types/participant.model";
import { IEventCardOptions } from "../../types/event.model";
import {
IEventCardOptions,
organizer,
organizerDisplayName,
} from "../../types/event.model";
import { displayNameAndUsername, IActor, IPerson } from "../../types/actor";
import ActorMixin from "../../mixins/actor";
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
@ -241,6 +269,9 @@ import EventMixin from "../../mixins/event";
import RouteName from "../../router/name";
import { changeIdentity } from "../../utils/auth";
import PopoverActorCard from "../Account/PopoverActorCard.vue";
import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
import EventAddress from "@/components/Event/EventAddress.vue";
import { PropType } from "vue";
const defaultOptions: IEventCardOptions = {
hideDate: true,
@ -254,6 +285,8 @@ const defaultOptions: IEventCardOptions = {
components: {
DateCalendarIcon,
PopoverActorCard,
LazyImageWrapper,
EventAddress,
},
apollo: {
currentActor: {
@ -261,11 +294,15 @@ const defaultOptions: IEventCardOptions = {
},
},
})
export default class EventListCard extends mixins(ActorMixin, EventMixin) {
export default class EventParticipationCard extends mixins(
ActorMixin,
EventMixin
) {
/**
* The participation associated
*/
@Prop({ required: true }) participation!: IParticipant;
@Prop({ required: true, type: Object as PropType<IParticipant> })
participation!: IParticipant;
/**
* Options are merged with default options
@ -281,6 +318,10 @@ export default class EventListCard extends mixins(ActorMixin, EventMixin) {
displayNameAndUsername = displayNameAndUsername;
organizerDisplayName = organizerDisplayName;
organizer = organizer;
RouteName = RouteName;
get mergedOptions(): IEventCardOptions {
@ -304,13 +345,13 @@ export default class EventListCard extends mixins(ActorMixin, EventMixin) {
participation.actor.id !== this.currentActor.id &&
participation.event.organizerActor
) {
const organizer = participation.event.organizerActor as IPerson;
await changeIdentity(this.$apollo.provider.defaultClient, organizer);
const organizerActor = participation.event.organizerActor as IPerson;
await changeIdentity(this.$apollo.provider.defaultClient, organizerActor);
this.$buefy.notification.open({
message: this.$t(
"Current identity has been changed to {identityName} in order to manage this event.",
{
identityName: organizer.preferredUsername,
identityName: organizerActor.preferredUsername,
}
) as string,
type: "is-info",
@ -330,10 +371,30 @@ export default class EventListCard extends mixins(ActorMixin, EventMixin) {
}
return this.participation.event.organizerActor;
}
get seatsLeft(): number | null {
if (this.participation.event.options.maximumAttendeeCapacity > 0) {
return (
this.participation.event.options.maximumAttendeeCapacity -
this.participation.event.participantStats.participant
);
}
return null;
}
get lastSeatsLeft(): boolean {
if (this.seatsLeft) {
return this.seatsLeft < 10;
}
return false;
}
}
</script>
<style lang="scss" scoped>
@use "@/styles/_event-card";
@import "~bulma/sass/utilities/mixins.sass";
article.box {
div.tag-container {
position: absolute;
@ -359,49 +420,67 @@ article.box {
.list-card {
display: flex;
padding: 0 6px;
padding: 0 6px 0 0;
position: relative;
flex-direction: column;
div.date-component {
align-self: flex-start;
padding: 5px;
position: absolute;
top: 0;
left: 0;
margin-top: 1px;
height: 0;
display: flex;
align-items: flex-end;
margin-bottom: 15px;
margin-left: 0rem;
.content-and-actions {
display: grid;
grid-gap: 5px 10px;
grid-template-areas: "preview" "body" "actions";
@include tablet {
grid-template-columns: 1fr 3fr;
grid-template-areas: "preview body" "actions actions";
}
.content-and-actions {
@include desktop {
grid-template-columns: 1fr 3fr 1fr;
grid-template-areas: "preview body actions";
}
.event-preview {
grid-area: preview;
& > div {
height: 128px;
width: 100%;
position: relative;
div.date-component {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
padding-bottom: 1rem;
position: absolute;
bottom: 5px;
left: 5px;
z-index: 1;
}
img {
width: 100%;
object-position: center;
object-fit: cover;
height: 100%;
}
}
}
.actions {
padding-right: 7.5px;
padding: 7px;
cursor: pointer;
align-self: center;
justify-self: center;
grid-area: actions;
}
div.list-card-content {
flex: 1;
padding: 5px;
min-width: 350px;
grid-area: body;
.participation-actor span,
.participant-stats span {
.participant-stats {
display: flex;
align-items: center;
padding: 0 5px;
button {
height: auto;
padding-top: 0;
}
}
div.title-wrapper {
@ -419,11 +498,11 @@ article.box {
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
font-weight: 400;
line-height: 1em;
font-size: 1.4em;
padding-bottom: 5px;
font-size: 18px;
line-height: 24px;
margin: auto 0;
font-weight: bold;
color: $title-color;
}
}
}
@ -434,9 +513,9 @@ article.box {
background: $yellow-2;
display: flex;
padding: 5px;
padding-left: calc(48px + 15px);
figure {
figure,
span.icon {
padding-right: 3px;
}
}

View file

@ -0,0 +1,90 @@
<template>
<div class="events-wrapper">
<div class="month-group" v-for="key of keys" :key="key">
<h2 class="is-size-5 month-name">
{{ monthName(groupEvents(key)[0]) }}
</h2>
<event-minimalist-card
class="py-4"
v-for="event in groupEvents(key)"
:key="event.id"
:event="event"
:isCurrentActorMember="isCurrentActorMember"
/>
</div>
</div>
</template>
<script lang="ts">
import { IEvent } from "@/types/event.model";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
import EventMinimalistCard from "./EventMinimalistCard.vue";
@Component({
components: {
EventMinimalistCard,
},
})
export default class GroupedMultiEventMinimalistCard extends Vue {
@Prop({ type: Array as PropType<IEvent[]>, required: true })
events!: IEvent[];
@Prop({ required: false, type: Boolean, default: false })
isCurrentActorMember!: boolean;
get monthlyGroupedEvents(): Map<string, IEvent[]> {
return this.events.reduce((acc: Map<string, IEvent[]>, event: IEvent) => {
const beginsOn = new Date(event.beginsOn);
const month = `${beginsOn.getUTCMonth()}-${beginsOn.getUTCFullYear()}`;
const monthEvents = acc.get(month) || [];
acc.set(month, [...monthEvents, event]);
return acc;
}, new Map());
}
get keys(): string[] {
return Array.from(this.monthlyGroupedEvents.keys()).sort((a, b) =>
b.localeCompare(a)
);
}
groupEvents(key: string): IEvent[] {
return this.monthlyGroupedEvents.get(key) || [];
}
monthName(event: IEvent): string {
const beginsOn = new Date(event.beginsOn);
return new Intl.DateTimeFormat(undefined, {
month: "long",
year: "numeric",
}).format(beginsOn);
}
}
</script>
<style lang="scss" scoped>
.events-wrapper {
display: grid;
grid-gap: 20px;
grid-template: 1fr;
}
.month-group {
.month-name {
text-transform: capitalize;
text-transform: capitalize;
display: inline-block;
position: relative;
font-size: 1.3rem;
&::after {
background: $orange-3;
position: absolute;
left: 0;
right: 0;
top: 100%;
content: "";
width: calc(100% + 30px);
height: 3px;
max-width: 150px;
}
}
}
</style>

View file

@ -0,0 +1,38 @@
<template>
<div class="events-wrapper">
<event-minimalist-card
v-for="event in events"
:key="event.id"
:event="event"
:isCurrentActorMember="isCurrentActorMember"
:showOrganizer="showOrganizer"
/>
</div>
</template>
<script lang="ts">
import { IEvent } from "@/types/event.model";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
import EventMinimalistCard from "./EventMinimalistCard.vue";
@Component({
components: {
EventMinimalistCard,
},
})
export default class MultiEventMinimalistCard extends Vue {
@Prop({ type: Array as PropType<IEvent[]>, required: true })
events!: IEvent[];
@Prop({ required: false, type: Boolean, default: false })
isCurrentActorMember!: boolean;
@Prop({ required: false, type: Boolean, default: false })
showOrganizer!: boolean;
}
</script>
<style lang="scss" scoped>
.events-wrapper {
display: grid;
grid-gap: 20px;
grid-template: 1fr;
}
</style>

View file

@ -47,6 +47,7 @@
</li>
<li>
<a
rel="external"
hreflang="en"
href="https://framagit.org/framasoft/mobilizon/blob/master/LICENSE"
>
@ -62,13 +63,16 @@
tag="span"
path="Powered by {mobilizon}. © 2018 - {date} The Mobilizon Contributors - Made with the financial support of {contributors}."
>
<a slot="mobilizon" href="https://joinmobilizon.org">{{
<a rel="external" slot="mobilizon" href="https://joinmobilizon.org">{{
$t("Mobilizon")
}}</a>
<span slot="date">{{ new Date().getFullYear() }}</span>
<a href="https://joinmobilizon.org/hall-of-fame" slot="contributors">{{
$t("more than 1360 contributors")
}}</a>
<a
rel="external"
href="https://joinmobilizon.org/hall-of-fame"
slot="contributors"
>{{ $t("more than 1360 contributors") }}</a
>
</i18n>
</div>
</footer>

View file

@ -4,6 +4,7 @@
<figure class="image is-24x24" v-if="member.actor.avatar">
<img class="is-rounded" :src="member.actor.avatar.url" alt="" />
</figure>
<b-icon v-else icon="account-circle" />
{{ displayNameAndUsername(member.actor) }}
</div>
<div class="card-content">
@ -47,7 +48,7 @@
</div>
</div>
<div class="content" v-if="member.parent.summary">
<p>{{ member.parent.summary }}</p>
<p v-html="member.parent.summary" />
</div>
</div>
<div>
@ -110,7 +111,8 @@ export default class GroupMemberCard extends Vue {
display: flex;
padding: 5px;
figure {
figure,
span.icon {
padding-right: 3px;
}
}

View file

@ -16,7 +16,7 @@
:width="width"
:height="height"
class="absolute top-0 left-0 transition-opacity duration-500"
:class="isLoaded ? 'opacity-100' : 'opacity-0'"
:class="{ isLoaded: isLoaded ? 'opacity-100' : 'opacity-0', rounded }"
alt=""
/>
</div>
@ -37,6 +37,7 @@ export default class LazyImage extends Vue {
@Prop({ type: String, required: false, default: null }) blurhash!: string;
@Prop({ type: Number, default: 1 }) width!: number;
@Prop({ type: Number, default: 1 }) height!: number;
@Prop({ type: Boolean, default: false }) rounded!: boolean;
inheritAttrs = false;
isLoaded = false;
@ -115,5 +116,8 @@ img {
height: 100%;
object-fit: cover;
object-position: 50% 50%;
&.rounded {
border-radius: 8px;
}
}
</style>

View file

@ -5,6 +5,7 @@
:width="pictureOrDefault.metadata.width"
:height="pictureOrDefault.metadata.height"
:blurhash="pictureOrDefault.metadata.blurhash"
:rounded="rounded"
/>
</template>
<script lang="ts">
@ -34,6 +35,7 @@ const DEFAULT_PICTURE = {
export default class LazyImageWrapper extends Vue {
@Prop({ required: false, type: Object as PropType<IMedia | null> })
picture!: IMedia | null;
@Prop({ required: false, type: Boolean, default: false }) rounded!: boolean;
get pictureOrDefault(): Partial<IMedia> {
if (this.picture === null) {

View file

@ -58,7 +58,7 @@
tag="a"
href="https://mediation.koena.net/framasoft/mobilizon/"
target="_blank"
rel="noopener"
rel="noopener external"
hreflang="fr"
>
<img

View file

@ -0,0 +1,34 @@
<template>
<div class="posts-wrapper">
<post-list-item
v-for="post in posts"
:key="post.id"
:post="post"
:isCurrentActorMember="isCurrentActorMember"
/>
</div>
</template>
<script lang="ts">
import { IPost } from "@/types/post.model";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
import PostListItem from "./PostListItem.vue";
@Component({
components: {
PostListItem,
},
})
export default class MultiPostListItem extends Vue {
@Prop({ type: Array as PropType<IPost[]>, required: true }) posts!: IPost[];
@Prop({ required: false, type: Boolean, default: false })
isCurrentActorMember!: boolean;
}
</script>
<style lang="scss" scoped>
.posts-wrapper {
display: grid;
grid-gap: 20px;
grid-template: 1fr;
}
</style>

View file

@ -1,134 +0,0 @@
<template>
<router-link
class="post-minimalist-card-wrapper"
:to="{ name: RouteName.POST, params: { slug: post.slug } }"
>
<div class="title-info-wrapper">
<div class="media">
<div class="media-left">
<figure class="image is-96x96" v-if="post.picture">
<img :src="post.picture.url" alt="" />
</figure>
<b-icon v-else size="is-large" icon="post" />
</div>
<div class="media-content">
<p class="post-minimalist-title">{{ post.title }}</p>
<div class="metadata">
<b-tag type="is-warning" size="is-small" v-if="post.draft">{{
$t("Draft")
}}</b-tag>
<small
v-if="
post.visibility === PostVisibility.PUBLIC &&
isCurrentActorMember
"
class="has-text-grey-dark"
>
<b-icon icon="earth" size="is-small" />{{ $t("Public") }}</small
>
<small
v-else-if="post.visibility === PostVisibility.UNLISTED"
class="has-text-grey-dark"
>
<b-icon icon="link" size="is-small" />{{
$t("Accessible through link")
}}</small
>
<small
v-else-if="post.visibility === PostVisibility.PRIVATE"
class="has-text-grey-dark"
>
<b-icon icon="lock" size="is-small" />{{
$t("Accessible only to members", {
group: post.attributedTo.name,
})
}}</small
>
<small class="has-text-grey-dark">{{
$options.filters.formatDateTimeString(
new Date(post.insertedAt),
undefined,
false
)
}}</small>
<small class="has-text-grey-dark" v-if="isCurrentActorMember">{{
$t("Created by {username}", {
username: `@${usernameWithDomain(post.author)}`,
})
}}</small>
</div>
</div>
</div>
</div>
</router-link>
</template>
<script lang="ts">
import { usernameWithDomain } from "@/types/actor";
import { PostVisibility } from "@/types/enums";
import { Component, Prop, Vue } from "vue-property-decorator";
import RouteName from "../../router/name";
import { IPost } from "../../types/post.model";
@Component
export default class PostElementItem extends Vue {
@Prop({ required: true, type: Object }) post!: IPost;
@Prop({ required: false, type: Boolean, default: false })
isCurrentActorMember!: boolean;
RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
PostVisibility = PostVisibility;
}
</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;
}
.media .media-left {
& > span.icon {
height: 96px;
width: 96px;
}
& > figure.image > img {
object-fit: cover;
height: 100%;
object-position: center;
width: 100%;
}
}
.metadata {
& > span.tag {
margin-right: 5px;
}
& > small:not(:last-child):after {
content: "·";
padding: 0 5px;
}
}
}
}
</style>

View file

@ -3,53 +3,116 @@
class="post-minimalist-card-wrapper"
:to="{ name: RouteName.POST, params: { slug: post.slug } }"
>
<div class="title-info-wrapper">
<lazy-image-wrapper
:picture="post.picture"
:rounded="true"
style="height: 120px"
/>
<div class="title-info-wrapper has-text-grey-dark">
<p class="post-minimalist-title">{{ post.title }}</p>
<small class="has-text-grey-dark">{{
formatDistanceToNow(new Date(post.publishAt || post.insertedAt), {
<p class="post-publication-date">
<b-icon icon="clock" />
<span class="has-text-grey-dark" v-if="isBeforeLastWeek">{{
publishedAt | formatDateTimeString(undefined, false, "short")
}}</span>
<span v-else>{{
formatDistanceToNow(publishedAt, {
locale: $dateFnsLocale,
addSuffix: true,
})
}}</small>
}}</span>
</p>
<b-taglist v-if="post.tags.length > 0" style="display: inline">
<b-icon icon="tag" />
<b-tag v-for="tag in post.tags" :key="tag.slug">{{ tag.title }}</b-tag>
</b-taglist>
<p class="post-publisher has-text-grey-dark" v-if="isCurrentActorMember">
<b-icon icon="account-edit" />
<i18n path="Published by {name}">
<b class="has-text-weight-medium" slot="name">{{
displayName(post.author)
}}</b>
</i18n>
</p>
</div>
</router-link>
</template>
<script lang="ts">
import { formatDistanceToNow } from "date-fns";
import { formatDistanceToNow, subWeeks, isBefore } from "date-fns";
import { Component, Prop, Vue } from "vue-property-decorator";
import RouteName from "../../router/name";
import { IPost } from "../../types/post.model";
import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
import { displayName } from "@/types/actor";
@Component
@Component({
components: {
LazyImageWrapper,
},
})
export default class PostListItem extends Vue {
@Prop({ required: true, type: Object }) post!: IPost;
@Prop({ required: false, type: Boolean, default: false })
isCurrentActorMember!: boolean;
RouteName = RouteName;
formatDistanceToNow = formatDistanceToNow;
displayName = displayName;
get publishedAt(): Date {
return new Date((this.post.publishAt || this.post.insertedAt) as Date);
}
get isBeforeLastWeek(): boolean {
return isBefore(this.publishedAt, subWeeks(new Date(), 1));
}
}
</script>
<style lang="scss" scoped>
@import "~bulma/sass/utilities/mixins.sass";
.post-minimalist-card-wrapper {
display: grid;
grid-gap: 5px 10px;
grid-template-areas: "preview" "body";
text-decoration: none;
display: flex;
width: 100%;
color: initial;
border-bottom: 1px solid #e9e9e9;
align-items: center;
@include desktop {
grid-template-columns: 200px 3fr;
grid-template-areas: "preview body";
}
.title-info-wrapper {
flex: 2;
.post-minimalist-title {
color: #3c376e;
font-family: Roboto, Helvetica, Arial, serif;
font-size: 16px;
font-weight: 500;
font-size: 18px;
line-height: 24px;
font-weight: 700;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
-webkit-line-clamp: 3;
}
}
::v-deep .icon {
vertical-align: middle;
margin-right: 5px;
}
::v-deep .tags {
display: inline;
span.tag {
max-width: 200px;
& > span {
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}

View file

@ -0,0 +1,219 @@
<template>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{{ $t("Share this post") }}</p>
</header>
<section class="modal-card-body is-flex" v-if="post">
<div class="container has-text-centered">
<b-notification
type="is-warning"
v-if="post.visibility !== PostVisibility.PUBLIC"
:closable="false"
>
{{
$t(
"This post is accessible only through it's link. Be careful where you post this link."
)
}}
</b-notification>
<b-field :label="$t('Post URL')" label-for="post-url-text">
<b-input
id="post-url-text"
ref="postURLInput"
:value="postURL"
expanded
/>
<p class="control">
<b-tooltip
:label="$t('URL copied to clipboard')"
:active="showCopiedTooltip"
always
type="is-success"
position="is-left"
>
<b-button
type="is-primary"
icon-right="content-paste"
native-type="button"
@click="copyURL"
@keyup.enter="copyURL"
:title="$t('Copy URL to clipboard')"
/>
</b-tooltip>
</p>
</b-field>
<div>
<a
:href="twitterShareUrl"
target="_blank"
rel="nofollow noopener"
title="Twitter"
><b-icon icon="twitter" size="is-large" type="is-primary"
/></a>
<a
:href="mastodonShareUrl"
class="mastodon"
target="_blank"
rel="nofollow noopener"
title="Mastodon"
>
<mastodon-logo />
</a>
<a
:href="facebookShareUrl"
target="_blank"
rel="nofollow noopener"
title="Facebook"
><b-icon icon="facebook" size="is-large" type="is-primary"
/></a>
<a
:href="whatsAppShareUrl"
target="_blank"
rel="nofollow noopener"
title="WhatsApp"
><b-icon icon="whatsapp" size="is-large" type="is-primary"
/></a>
<a
:href="telegramShareUrl"
class="telegram"
target="_blank"
rel="nofollow noopener"
title="Telegram"
>
<telegram-logo />
</a>
<a
:href="linkedInShareUrl"
target="_blank"
rel="nofollow noopener"
title="LinkedIn"
><b-icon icon="linkedin" size="is-large" type="is-primary"
/></a>
<a
:href="diasporaShareUrl"
class="diaspora"
target="_blank"
rel="nofollow noopener"
title="Diaspora"
>
<diaspora-logo />
</a>
<a
:href="emailShareUrl"
target="_blank"
rel="nofollow noopener"
title="Email"
><b-icon icon="email" size="is-large" type="is-primary"
/></a>
</div>
</div>
</section>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Ref } from "vue-property-decorator";
import { PostVisibility } from "@/types/enums";
import { IPost } from "../../types/post.model";
import DiasporaLogo from "../Share/DiasporaLogo.vue";
import MastodonLogo from "../Share/MastodonLogo.vue";
import TelegramLogo from "../Share/TelegramLogo.vue";
import { PropType } from "vue";
import RouteName from "@/router/name";
@Component({
components: {
DiasporaLogo,
MastodonLogo,
TelegramLogo,
},
})
export default class SharePostModal extends Vue {
@Prop({ type: Object as PropType<IPost>, required: true }) post!: IPost;
@Ref("postURLInput") readonly postURLInput!: any;
PostVisibility = PostVisibility;
RouteName = RouteName;
showCopiedTooltip = false;
get twitterShareUrl(): string {
return `https://twitter.com/intent/tweet?url=${encodeURIComponent(
this.postURL
)}&text=${this.post.title}`;
}
get facebookShareUrl(): string {
return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(
this.postURL
)}`;
}
get linkedInShareUrl(): string {
return `https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(
this.postURL
)}&title=${this.post.title}`;
}
get whatsAppShareUrl(): string {
return `https://wa.me/?text=${encodeURIComponent(this.basicTextToEncode)}`;
}
get telegramShareUrl(): string {
return `https://t.me/share/url?url=${encodeURIComponent(
this.postURL
)}&text=${encodeURIComponent(this.post.title)}`;
}
get emailShareUrl(): string {
return `mailto:?to=&body=${this.postURL}&subject=${this.post.title}`;
}
get diasporaShareUrl(): string {
return `https://share.diasporafoundation.org/?title=${encodeURIComponent(
this.post.title
)}&url=${encodeURIComponent(this.postURL)}`;
}
get mastodonShareUrl(): string {
return `https://toot.karamoff.dev/?text=${encodeURIComponent(
this.basicTextToEncode
)}`;
}
get basicTextToEncode(): string {
return `${this.post.title}\r\n${this.postURL}`;
}
get postURL(): string {
if (this.post.id) {
return this.$router.resolve({
name: RouteName.POST,
params: { id: this.post.id },
}).href;
}
return "";
}
copyURL(): void {
this.postURLInput.$refs.input.select();
document.execCommand("copy");
this.showCopiedTooltip = true;
setTimeout(() => {
this.showCopiedTooltip = false;
}, 2000);
}
}
</script>
<style lang="scss" scoped>
.diaspora,
.mastodon,
.telegram {
::v-deep span svg {
width: 2.25rem;
}
}
</style>

View file

@ -61,7 +61,7 @@ export default class FolderItem extends Mixins(ResourceMixin) {
list = [];
groupObject: Record<string, unknown> = {
name: `folder-${this.resource.title}`,
name: `folder-${this.resource?.title}`,
pull: false,
put: ["resources"],
};

View file

@ -198,67 +198,6 @@ export const UPDATE_CURRENT_ACTOR_CLIENT = gql`
}
`;
export const LOGGED_USER_PARTICIPATIONS = gql`
query LoggedUserParticipations(
$afterDateTime: DateTime
$beforeDateTime: DateTime
$page: Int
$limit: Int
) {
loggedUser {
id
participations(
afterDatetime: $afterDateTime
beforeDatetime: $beforeDateTime
page: $page
limit: $limit
) {
total
elements {
event {
id
uuid
title
picture {
id
url
alt
}
beginsOn
visibility
organizerActor {
...ActorFragment
}
attributedTo {
...ActorFragment
}
participantStats {
going
notApproved
participant
}
options {
maximumAttendeeCapacity
remainingAttendeeCapacity
}
tags {
id
slug
title
}
}
id
role
actor {
...ActorFragment
}
}
}
}
}
${ACTOR_FRAGMENT}
`;
export const LOGGED_USER_DRAFTS = gql`
query LoggedUserDrafts($page: Int, $limit: Int) {
loggedUser {
@ -267,6 +206,7 @@ export const LOGGED_USER_DRAFTS = gql`
id
uuid
title
draft
picture {
id
url

View file

@ -1,66 +1,13 @@
import gql from "graphql-tag";
import { ACTOR_FRAGMENT } from "./actor";
import { ADDRESS_FRAGMENT } from "./address";
import { EVENT_OPTIONS_FRAGMENT } from "./event_options";
import {
PARTICIPANTS_QUERY_FRAGMENT,
PARTICIPANT_QUERY_FRAGMENT,
} from "./participant";
import { TAG_FRAGMENT } from "./tags";
const PARTICIPANT_QUERY_FRAGMENT = gql`
fragment ParticipantQuery on Participant {
role
id
actor {
...ActorFragment
}
event {
id
uuid
}
metadata {
cancellationToken
message
}
insertedAt
}
${ACTOR_FRAGMENT}
`;
const PARTICIPANTS_QUERY_FRAGMENT = gql`
fragment ParticipantsQuery on PaginatedParticipantList {
total
elements {
...ParticipantQuery
}
}
${PARTICIPANT_QUERY_FRAGMENT}
`;
export const EVENT_OPTIONS_FRAGMENT = gql`
fragment EventOptions on EventOptions {
maximumAttendeeCapacity
remainingAttendeeCapacity
showRemainingAttendeeCapacity
anonymousParticipation
showStartTime
showEndTime
timezone
offers {
price
priceCurrency
url
}
participationConditions {
title
content
url
}
attendees
program
commentModeration
showParticipationPrice
hideOrganizerWhenGroupEvent
isOnline
}
`;
const FULL_EVENT_FRAGMENT = gql`
fragment FullEvent on Event {
id
@ -473,7 +420,7 @@ export const FETCH_GROUP_EVENTS = gql`
$afterDateTime: DateTime
$beforeDateTime: DateTime
$organisedEventsPage: Int
$organisedEventslimit: Int
$organisedEventsLimit: Int
) {
group(preferredUsername: $name) {
...ActorFragment
@ -481,7 +428,7 @@ export const FETCH_GROUP_EVENTS = gql`
afterDatetime: $afterDateTime
beforeDatetime: $beforeDateTime
page: $organisedEventsPage
limit: $organisedEventslimit
limit: $organisedEventsLimit
) {
elements {
id
@ -490,7 +437,7 @@ export const FETCH_GROUP_EVENTS = gql`
beginsOn
draft
options {
maximumAttendeeCapacity
...EventOptions
}
participantStats {
participant
@ -502,12 +449,21 @@ export const FETCH_GROUP_EVENTS = gql`
organizerActor {
...ActorFragment
}
physicalAddress {
...AdressFragment
}
picture {
url
id
}
}
total
}
}
}
${EVENT_OPTIONS_FRAGMENT}
${ACTOR_FRAGMENT}
${ADDRESS_FRAGMENT}
`;
export const CLOSE_EVENTS = gql`

View file

@ -0,0 +1,29 @@
import gql from "graphql-tag";
export const EVENT_OPTIONS_FRAGMENT = gql`
fragment EventOptions on EventOptions {
maximumAttendeeCapacity
remainingAttendeeCapacity
showRemainingAttendeeCapacity
anonymousParticipation
showStartTime
showEndTime
timezone
offers {
price
priceCurrency
url
}
participationConditions {
title
content
url
}
attendees
program
commentModeration
showParticipationPrice
hideOrganizerWhenGroupEvent
isOnline
}
`;

View file

@ -3,6 +3,9 @@ import { DISCUSSION_BASIC_FIELDS_FRAGMENT } from "./discussion";
import { RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT } from "./resources";
import { POST_BASIC_FIELDS } from "./post";
import { ACTOR_FRAGMENT } from "./actor";
import { ADDRESS_FRAGMENT } from "./address";
import { TAG_FRAGMENT } from "./tags";
import { EVENT_OPTIONS_FRAGMENT } from "./event_options";
export const LIST_GROUPS = gql`
query ListGroups(
@ -68,6 +71,7 @@ export const GROUP_FIELDS_FRAGMENTS = gql`
type
id
originId
url
}
avatar {
id
@ -93,7 +97,7 @@ export const GROUP_FIELDS_FRAGMENTS = gql`
afterDatetime: $afterDateTime
beforeDatetime: $beforeDateTime
page: $organisedEventsPage
limit: $organisedEventslimit
limit: $organisedEventsLimit
) {
elements {
id
@ -114,6 +118,19 @@ export const GROUP_FIELDS_FRAGMENTS = gql`
organizerActor {
...ActorFragment
}
picture {
id
url
}
physicalAddress {
...AdressFragment
}
options {
...EventOptions
}
tags {
...TagFragment
}
}
total
}
@ -177,6 +194,9 @@ export const GROUP_FIELDS_FRAGMENTS = gql`
}
}
${ACTOR_FRAGMENT}
${ADDRESS_FRAGMENT}
${EVENT_OPTIONS_FRAGMENT}
${TAG_FRAGMENT}
`;
export const FETCH_GROUP = gql`
@ -185,7 +205,7 @@ export const FETCH_GROUP = gql`
$afterDateTime: DateTime
$beforeDateTime: DateTime
$organisedEventsPage: Int
$organisedEventslimit: Int
$organisedEventsLimit: Int
$postsPage: Int
$postsLimit: Int
$membersPage: Int
@ -209,7 +229,7 @@ export const GET_GROUP = gql`
$afterDateTime: DateTime
$beforeDateTime: DateTime
$organisedEventsPage: Int
$organisedEventslimit: Int
$organisedEventsLimit: Int
$postsPage: Int
$postsLimit: Int
$membersPage: Int

View file

@ -1,7 +1,7 @@
import gql from "graphql-tag";
import { ACTOR_FRAGMENT } from "./actor";
import { ADDRESS_FRAGMENT } from "./address";
import { EVENT_OPTIONS_FRAGMENT } from "./event";
import { EVENT_OPTIONS_FRAGMENT } from "./event_options";
import { TAG_FRAGMENT } from "./tags";
import { USER_SETTINGS_FRAGMENT } from "./user";

View file

@ -0,0 +1,210 @@
import gql from "graphql-tag";
import { ACTOR_FRAGMENT } from "./actor";
import { ADDRESS_FRAGMENT } from "./address";
import { EVENT_OPTIONS_FRAGMENT } from "./event_options";
import { TAG_FRAGMENT } from "./tags";
export const LOGGED_USER_PARTICIPATIONS = gql`
query LoggedUserParticipations(
$afterDateTime: DateTime
$beforeDateTime: DateTime
$page: Int
$limit: Int
) {
loggedUser {
id
participations(
afterDatetime: $afterDateTime
beforeDatetime: $beforeDateTime
page: $page
limit: $limit
) {
total
elements {
event {
id
uuid
url
title
picture {
id
url
alt
}
beginsOn
visibility
organizerActor {
...ActorFragment
}
attributedTo {
...ActorFragment
}
participantStats {
going
notApproved
participant
}
options {
...EventOptions
}
tags {
id
slug
title
}
physicalAddress {
...AdressFragment
}
}
id
role
actor {
...ActorFragment
}
}
}
}
}
${ACTOR_FRAGMENT}
${ADDRESS_FRAGMENT}
${EVENT_OPTIONS_FRAGMENT}
`;
export const LOGGED_USER_UPCOMING_EVENTS = gql`
query LoggedUserUpcomingEvents(
$afterDateTime: DateTime
$beforeDateTime: DateTime
$page: Int
$limit: Int
) {
loggedUser {
id
participations(
afterDatetime: $afterDateTime
beforeDatetime: $beforeDateTime
page: $page
limit: $limit
) {
total
elements {
event {
id
uuid
url
title
picture {
id
url
alt
}
beginsOn
visibility
organizerActor {
...ActorFragment
}
attributedTo {
...ActorFragment
}
participantStats {
going
notApproved
rejected
participant
}
options {
...EventOptions
}
tags {
id
slug
title
}
physicalAddress {
...AdressFragment
}
}
id
role
actor {
...ActorFragment
}
}
}
followedGroupEvents(afterDatetime: $afterDateTime) {
total
elements {
profile {
id
}
group {
...ActorFragment
}
event {
id
uuid
title
beginsOn
picture {
url
}
attributedTo {
...ActorFragment
}
organizerActor {
...ActorFragment
}
options {
...EventOptions
}
physicalAddress {
...AdressFragment
}
tags {
...TagFragment
}
participantStats {
going
notApproved
rejected
participant
}
}
}
}
}
}
${ACTOR_FRAGMENT}
${ADDRESS_FRAGMENT}
${EVENT_OPTIONS_FRAGMENT}
${TAG_FRAGMENT}
`;
export const PARTICIPANT_QUERY_FRAGMENT = gql`
fragment ParticipantQuery on Participant {
role
id
actor {
...ActorFragment
}
event {
id
uuid
}
metadata {
cancellationToken
message
}
insertedAt
}
${ACTOR_FRAGMENT}
`;
export const PARTICIPANTS_QUERY_FRAGMENT = gql`
fragment ParticipantsQuery on PaginatedParticipantList {
total
elements {
...ParticipantQuery
}
}
${PARTICIPANT_QUERY_FRAGMENT}
`;

View file

@ -61,8 +61,12 @@ export const POST_BASIC_FIELDS = gql`
url
name
}
tags {
...TagFragment
}
}
${ACTOR_FRAGMENT}
${TAG_FRAGMENT}
`;
export const FETCH_GROUP_POSTS = gql`

View file

@ -1021,7 +1021,6 @@
"A member has been updated": "A member has been updated",
"User settings": "User settings",
"You changed your email or password": "You changed your email or password",
"Organized by you": "Organized by you",
"Move resource to the root folder": "Move resource to the root folder",
"Share this group": "Share this group",
"This group is accessible only through it's link. Be careful where you post this link.": "This group is accessible only through it's link. Be careful where you post this link.",
@ -1208,7 +1207,24 @@
"Unfollow": "Unfollow",
"your notification settings": "your notification settings",
"You will receive notifications about this group's public activity depending on %{notification_settings}.": "You will receive notifications about this group's public activity depending on %{notification_settings}.",
"Recent events from your groups": "Recent events from your groups",
"Online": "Online",
"That you follow or of which you are a member": "That you follow or of which you are a member"
"That you follow or of which you are a member": "That you follow or of which you are a member",
"{number} seats left": "{number} seats left",
"Published by {name}": "Published by {name}",
"Share this post": "Share this post",
"This post is accessible only through it's link. Be careful where you post this link.": "This post is accessible only through it's link. Be careful where you post this link.",
"Post URL": "Post URL",
"Are you sure you want to delete this post? This action cannot be reverted.": "Are you sure you want to delete this post? This action cannot be reverted.",
"Attending": "Attending",
"From my groups": "From my groups",
"You don't have any upcoming events. Maybe try another filter?": "You don't have any upcoming events. Maybe try another filter?",
"Leave group": "Leave group",
"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.": "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.",
"Upcoming events from your groups": "Upcoming events from your groups",
"Accessible only by link": "Accessible only by link",
"Report this post": "Report this post",
"Post {eventTitle} reported": "Post {eventTitle} reported",
"You have attended {count} events in the past.": "You have not attended any events in the past.|You have attended one event in the past.|You have attended {count} events in the past.",
"Showing events starting on": "Showing events starting on",
"Showing events before": "Showing events before"
}

View file

@ -1267,8 +1267,6 @@
"Time in your timezone ({timezone})": "Heure dans votre fuseau horaire ({timezone})",
"Export": "Export",
"Times in your timezone ({timezone})": "Heures dans votre fuseau horaire ({timezone})",
"has loaded": "a chargé",
"Skip to main": "",
"Navigated to {pageTitle}": "Navigué vers {pageTitle}",
"Comment body": "Corps du commentaire",
"Confirm participation": "Confirmer la participation",
@ -1314,7 +1312,23 @@
"Unfollow": "Ne plus suivre",
"your notification settings": "vos paramètres de notification",
"You will receive notifications about this group's public activity depending on %{notification_settings}.": "Vous recevrez des notifications à propos de l'activité publique de ce groupe en fonction de %{notification_settings}.",
"Recent events from your groups": "Événements récents de vos groupes",
"Online": "En ligne",
"That you follow or of which you are a member": "Que vous suivez ou dont vous êtes membre"
"That you follow or of which you are a member": "Que vous suivez ou dont vous êtes membre",
"{number} seats left": "{number} places restantes",
"Published by {name}": "Publié par {name}",
"Share this post": "Partager ce billet",
"This post is accessible only through it's link. Be careful where you post this link.": "Ce billet est accessible uniquement à travers son lien. Faites attention où vous le diffusez.",
"Post URL": "URL du billet",
"Are you sure you want to delete this post? This action cannot be reverted.": "Voulez-vous vraiment supprimer ce billet ? Cette action ne peut pas être annulée.",
"Attending": "Participant⋅e",
"From my groups": "De mes groupes",
"You don't have any upcoming events. Maybe try another filter?": "Vous n'avez pas d'événements à venir. Essayez peut-être un autre filtre ?",
"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.": "Êtes-vous certain⋅e de vouloir quitter le groupe {groupName}? Vous perdrez accès au contenu privé de ce groupe. Cette action ne peut être annulée.",
"Upcoming events from your groups": "Événements de vos groupes à venir",
"Accessible only by link": "Accessible uniquement par lien",
"Report this post": "Signaler ce billet",
"Post {eventTitle} reported": "Billet {eventTitle} signalé",
"You have attended {count} events in the past.": "Vous n'avez participé à aucun événement par le passé.|Vous avez participé à un événement par le passé.|Vous avez participé à {count} événements par le passé.",
"Showing events starting on": "Afficher les événements à partir de",
"Showing events before": "Afficher les événements avant"
}

View file

@ -3,7 +3,7 @@ import {
GROUP_MEMBERSHIP_SUBSCRIPTION_CHANGED,
PERSON_STATUS_GROUP,
} from "@/graphql/actor";
import { FETCH_GROUP } from "@/graphql/group";
import { DELETE_GROUP, FETCH_GROUP } from "@/graphql/group";
import RouteName from "@/router/name";
import {
IActor,
@ -14,6 +14,7 @@ import {
} from "@/types/actor";
import { MemberRole } from "@/types/enums";
import { Component, Vue } from "vue-property-decorator";
import { Route } from "vue-router";
const now = new Date();
@ -135,4 +136,28 @@ export default class GroupMixin extends Vue {
this.$router.replace({ name: RouteName.PAGE_NOT_FOUND });
}
}
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 });
}
}

64
js/src/mixins/post.ts Normal file
View file

@ -0,0 +1,64 @@
import { DELETE_POST, FETCH_POST } from "@/graphql/post";
import { usernameWithDomain } from "@/types/actor";
import { IPost } from "@/types/post.model";
import { Component, Vue } from "vue-property-decorator";
import RouteName from "../router/name";
@Component({
apollo: {
post: {
query: FETCH_POST,
fetchPolicy: "cache-and-network",
variables() {
return {
slug: this.slug,
};
},
skip() {
return !this.slug;
},
error({ graphQLErrors }) {
this.handleErrors(graphQLErrors);
},
},
},
})
export default class PostMixin extends Vue {
post!: IPost;
RouteName = RouteName;
protected async openDeletePostModal(): Promise<void> {
this.$buefy.dialog.confirm({
type: "is-danger",
title: this.$t("Delete post") as string,
message: this.$t(
"Are you sure you want to delete this post? This action cannot be reverted."
) as string,
onConfirm: () => this.deletePost(),
});
}
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),
},
});
}
}
handleErrors(errors: any[]): void {
if (errors.some((error) => error.status_code === 404)) {
this.$router.replace({ name: RouteName.PAGE_NOT_FOUND });
}
}
}

View file

@ -52,6 +52,7 @@ export const eventRoutes: RouteConfig[] = [
path: "/events/me",
name: EventRouteName.MY_EVENTS,
component: myEvents,
props: true,
meta: {
requiredAuth: true,
announcer: { message: (): string => i18n.t("My events") as string },

View file

@ -0,0 +1,18 @@
.event-organizer {
display: flex;
align-items: center;
.organizer-name {
padding-left: 5px;
font-weight: 600;
}
}
.event-subtitle {
display: flex;
align-items: center;
& > span:not(.icon) {
padding-left: 5px;
}
}

View file

@ -259,7 +259,7 @@ export function toEditJSON(event: IEditableEvent): IEventEditJSON {
}
export function organizer(event: IEvent): IActor | null {
if (event.attributedTo) {
if (event.attributedTo?.id) {
return event.attributedTo;
}
if (event.organizerActor) {

View file

@ -53,7 +53,7 @@ $success: #0d8758;
$success-invert: findColorInvert($success);
$info: #36bcd4;
$info-invert: findColorInvert($info);
$danger: #ff2e54;
$danger: #cd2026;
$danger-invert: findColorInvert($danger);
$link: $primary;
$link-invert: $primary-invert;

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 }"
<grouped-multi-event-minimalist-card
:events="group.organizedEvents.elements"
:isCurrentActorMember="isCurrentActorMember"
/>
</transition-group>
<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,28 +18,102 @@
>
</div>
<b-loading :active.sync="$apollo.loading"></b-loading>
<section v-if="futureParticipations.length > 0">
<subtitle>
{{ $t("Upcoming") }}
</subtitle>
<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"
>
<multi-event-minimalist-card :events="drafts" :showOrganizer="true" />
</section>
<section
class="py-4"
v-if="
showUpcoming && monthlyFutureEvents && monthlyFutureEvents.size > 0
"
>
<transition-group name="list" tag="p">
<div v-for="month in monthlyFutureParticipations" :key="month[0]">
<div
class="mb-5"
v-for="month in monthlyFutureEvents"
:key="month[0]"
>
<span class="upcoming-month">{{ month[0] }}</span>
<EventListCard
v-for="participation in month[1]"
:key="participation.id"
:participation="participation"
<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.length === limit
hasMoreFutureParticipations &&
futureParticipations &&
futureParticipations.length === limit
"
@click="loadMoreFutureParticipations"
size="is-large"
@ -48,64 +122,28 @@
>
</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"
v-if="
hasMorePastParticipations && pastParticipations.length === limit
"
@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 &&
showUpcoming &&
monthlyFutureEvents &&
monthlyFutureEvents.size === 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}?">
{{
$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"
@ -117,11 +155,38 @@
>{{ $t("explore the events") }}</router-link
>
</i18n>
</p>
</div>
</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>
</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: {
variables() {
return {
page: 1,
limit: 10,
afterDateTime: new Date().toISOString(),
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: {
variables() {
return {
page: 1,
limit: 10,
beforeDateTime: new Date().toISOString(),
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()
): Map<string, Eventable[]> {
const res = elements.filter((element: Eventable) => {
if ("role" in element) {
return (
element.event.beginsOn != null &&
element.role !== ParticipantRole.REJECTED
);
}
return res.reduce(
(acc: Map<string, IParticipant[]>, participation: IParticipant) => {
const month = new Date(participation.event.beginsOn).toLocaleDateString(
undefined,
{
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 filteredParticipations: IParticipant[] = acc.get(month) || [];
filteredParticipations.push(participation);
acc.set(month, filteredParticipations);
});
const filteredElements: Eventable[] = acc.get(month) || [];
filteredElements.push(element);
acc.set(month, filteredElements);
return acc;
},
new Map()
);
}, 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(
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"
<multi-post-list-item
v-if="group.posts.total > 0"
:posts="group.posts.elements.slice(0, 3)"
:isCurrentActorMember="isCurrentActorAGroupMember"
/>
</div>
<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"
<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
)
"
/>
</div>
<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">
<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 = "";

View file

@ -1,106 +0,0 @@
import { config, createLocalVue, mount } from "@vue/test-utils";
import PostElementItem from "@/components/Post/PostElementItem.vue";
import { formatDateTimeString } from "@/filters/datetime";
import Buefy from "buefy";
import VueRouter from "vue-router";
import { routes } from "@/router";
import { PostVisibility } from "@/types/enums";
const localVue = createLocalVue();
localVue.use(Buefy);
localVue.use(VueRouter);
const router = new VueRouter({ routes, mode: "history" });
localVue.filter("formatDateTimeString", formatDateTimeString);
config.mocks.$t = (key: string): string => key;
const postData = {
id: "1",
slug: "my-blog-post-some-uuid",
title: "My Blog Post",
body: "My content",
insertedAt: "2020-12-02T09:01:20.873Z",
visibility: PostVisibility.PUBLIC,
author: {
preferredUsername: "author",
domain: "remote-domain.tld",
name: "Author",
},
attributedTo: {
preferredUsername: "my-awesome-group",
domain: null,
name: "My Awesome Group",
},
};
const generateWrapper = (
customPostData: Record<string, unknown> = {},
isCurrentActorMember = false
) => {
return mount(PostElementItem, {
localVue,
router,
propsData: {
post: { ...postData, ...customPostData },
isCurrentActorMember,
},
});
};
describe("PostElementItem", () => {
it("renders post with basic informations", () => {
const wrapper = generateWrapper();
expect(wrapper.html()).toMatchSnapshot();
expect(
wrapper.find("a.post-minimalist-card-wrapper").attributes("href")
).toBe(`/p/${postData.slug}`);
expect(wrapper.find(".post-minimalist-title").text()).toContain(
postData.title
);
expect(wrapper.find(".metadata").text()).toContain(
formatDateTimeString(postData.insertedAt, undefined, false)
);
expect(wrapper.find(".metadata small").text()).not.toContain("Public");
});
it("shows the author if actor is a group member", () => {
const wrapper = generateWrapper({}, true);
expect(wrapper.html()).toMatchSnapshot();
expect(wrapper.find(".metadata").text()).toContain(`Created by {username}`);
});
it("shows the draft tag if post is a draft", () => {
const wrapper = generateWrapper({ draft: true });
expect(wrapper.html()).toMatchSnapshot();
expect(wrapper.findComponent({ name: "b-tag" }).exists()).toBe(true);
});
it("tells if the post is public when the actor is a group member", () => {
const wrapper = generateWrapper({}, true);
expect(wrapper.html()).toMatchSnapshot();
expect(wrapper.find(".metadata small").text()).toContain("Public");
});
it("tells if the post is accessible only through link", () => {
const wrapper = generateWrapper({ visibility: PostVisibility.UNLISTED });
expect(wrapper.html()).toMatchSnapshot();
expect(wrapper.find(".metadata small").text()).toContain(
"Accessible through link"
);
});
it("tells if the post is accessible only to members", () => {
const wrapper = generateWrapper({ visibility: PostVisibility.PRIVATE });
expect(wrapper.html()).toMatchSnapshot();
expect(wrapper.find(".metadata small").text()).toContain(
"Accessible only to members"
);
});
});

View file

@ -4,6 +4,8 @@ import Buefy from "buefy";
import VueRouter from "vue-router";
import { routes } from "@/router";
import { enUS } from "date-fns/locale";
import { formatDateTimeString } from "@/filters/datetime";
import { i18n } from "@/utils/i18n";
const localVue = createLocalVue();
localVue.use(Buefy);
@ -20,14 +22,23 @@ const postData = {
title: "My Blog Post",
body: "My content",
insertedAt: "2020-12-02T09:01:20.873Z",
tags: [],
};
const generateWrapper = (customPostData: Record<string, unknown> = {}) => {
const generateWrapper = (
customPostData: Record<string, unknown> = {},
customProps: Record<string, unknown> = {}
) => {
return mount(PostListItem, {
localVue,
router,
i18n,
propsData: {
post: { ...postData, ...customPostData },
...customProps,
},
filters: {
formatDateTimeString,
},
});
};
@ -36,14 +47,40 @@ describe("PostListItem", () => {
it("renders post list item with basic informations", () => {
const wrapper = generateWrapper();
// can't use the snapshot feature because of `ago`
expect(wrapper.html()).toMatchSnapshot();
expect(
wrapper.find("a.post-minimalist-card-wrapper").attributes("href")
).toBe(`/p/${postData.slug}`);
expect(wrapper.find(".post-minimalist-title").text()).toContain(
postData.title
expect(wrapper.find(".post-minimalist-title").text()).toBe(postData.title);
expect(wrapper.find(".post-publication-date").text()).toBe("Dec 2, 2020");
expect(wrapper.find(".post-publisher").exists()).toBeFalsy();
});
it("renders post list item with tags", () => {
const wrapper = generateWrapper({
tags: [{ slug: "a-tag", title: "A tag" }],
});
expect(wrapper.html()).toMatchSnapshot();
expect(wrapper.find(".tags").text()).toContain("A tag");
expect(wrapper.find(".post-publisher").exists()).toBeFalsy();
});
it("renders post list item with publisher name", () => {
const wrapper = generateWrapper(
{ author: { name: "An author" } },
{ isCurrentActorMember: true }
);
expect(wrapper.html()).toMatchSnapshot();
expect(wrapper.find(".post-publisher").exists()).toBeTruthy();
expect(wrapper.find(".post-publisher").text()).toContain("An author");
});
});

View file

@ -1,103 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PostElementItem renders post with basic informations 1`] = `
<a href="/p/my-blog-post-some-uuid" class="post-minimalist-card-wrapper">
<div class="title-info-wrapper">
<div class="media">
<div class="media-left"><span class="icon is-large"><i class="mdi mdi-post mdi-48px"></i></span></div>
<div class="media-content">
<p class="post-minimalist-title">My Blog Post</p>
<div class="metadata">
<!---->
<!----> <small class="has-text-grey-dark">December 2, 2020</small>
<!---->
</div>
</div>
</div>
</div>
</a>
`;
exports[`PostElementItem shows the author if actor is a group member 1`] = `
<a href="/p/my-blog-post-some-uuid" class="post-minimalist-card-wrapper">
<div class="title-info-wrapper">
<div class="media">
<div class="media-left"><span class="icon is-large"><i class="mdi mdi-post mdi-48px"></i></span></div>
<div class="media-content">
<p class="post-minimalist-title">My Blog Post</p>
<div class="metadata">
<!----> <small class="has-text-grey-dark"><span class="icon is-small"><i class="mdi mdi-earth"></i></span>Public</small> <small class="has-text-grey-dark">December 2, 2020</small> <small class="has-text-grey-dark">Created by {username}</small>
</div>
</div>
</div>
</div>
</a>
`;
exports[`PostElementItem shows the draft tag if post is a draft 1`] = `
<a href="/p/my-blog-post-some-uuid" class="post-minimalist-card-wrapper">
<div class="title-info-wrapper">
<div class="media">
<div class="media-left"><span class="icon is-large"><i class="mdi mdi-post mdi-48px"></i></span></div>
<div class="media-content">
<p class="post-minimalist-title">My Blog Post</p>
<div class="metadata"><span class="tag is-warning is-small"><span class="">Draft</span>
<!----></span>
<!----> <small class="has-text-grey-dark">December 2, 2020</small>
<!---->
</div>
</div>
</div>
</div>
</a>
`;
exports[`PostElementItem tells if the post is accessible only through link 1`] = `
<a href="/p/my-blog-post-some-uuid" class="post-minimalist-card-wrapper">
<div class="title-info-wrapper">
<div class="media">
<div class="media-left"><span class="icon is-large"><i class="mdi mdi-post mdi-48px"></i></span></div>
<div class="media-content">
<p class="post-minimalist-title">My Blog Post</p>
<div class="metadata">
<!----> <small class="has-text-grey-dark"><span class="icon is-small"><i class="mdi mdi-link"></i></span>Accessible through link</small> <small class="has-text-grey-dark">December 2, 2020</small>
<!---->
</div>
</div>
</div>
</div>
</a>
`;
exports[`PostElementItem tells if the post is accessible only to members 1`] = `
<a href="/p/my-blog-post-some-uuid" class="post-minimalist-card-wrapper">
<div class="title-info-wrapper">
<div class="media">
<div class="media-left"><span class="icon is-large"><i class="mdi mdi-post mdi-48px"></i></span></div>
<div class="media-content">
<p class="post-minimalist-title">My Blog Post</p>
<div class="metadata">
<!----> <small class="has-text-grey-dark"><span class="icon is-small"><i class="mdi mdi-lock"></i></span>Accessible only to members</small> <small class="has-text-grey-dark">December 2, 2020</small>
<!---->
</div>
</div>
</div>
</div>
</a>
`;
exports[`PostElementItem tells if the post is public when the actor is a group member 1`] = `
<a href="/p/my-blog-post-some-uuid" class="post-minimalist-card-wrapper">
<div class="title-info-wrapper">
<div class="media">
<div class="media-left"><span class="icon is-large"><i class="mdi mdi-post mdi-48px"></i></span></div>
<div class="media-content">
<p class="post-minimalist-title">My Blog Post</p>
<div class="metadata">
<!----> <small class="has-text-grey-dark"><span class="icon is-small"><i class="mdi mdi-earth"></i></span>Public</small> <small class="has-text-grey-dark">December 2, 2020</small> <small class="has-text-grey-dark">Created by {username}</small>
</div>
</div>
</div>
</div>
</a>
`;

View file

@ -0,0 +1,39 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PostListItem renders post list item with basic informations 1`] = `
<a href="/p/my-blog-post-some-uuid" class="post-minimalist-card-wrapper">
<!---->
<div class="title-info-wrapper has-text-grey-dark">
<p class="post-minimalist-title">My Blog Post</p>
<p class="post-publication-date"><span class="icon"><i class="mdi mdi-clock mdi-24px"></i></span> <span class="has-text-grey-dark">Dec 2, 2020</span></p>
<!---->
<!---->
</div>
</a>
`;
exports[`PostListItem renders post list item with publisher name 1`] = `
<a href="/p/my-blog-post-some-uuid" class="post-minimalist-card-wrapper">
<!---->
<div class="title-info-wrapper has-text-grey-dark">
<p class="post-minimalist-title">My Blog Post</p>
<p class="post-publication-date"><span class="icon"><i class="mdi mdi-clock mdi-24px"></i></span> <span class="has-text-grey-dark">Dec 2, 2020</span></p>
<!---->
<p class="post-publisher has-text-grey-dark"><span class="icon"><i class="mdi mdi-account-edit mdi-24px"></i></span> <span>Published by <b class="has-text-weight-medium">An author</b></span></p>
</div>
</a>
`;
exports[`PostListItem renders post list item with tags 1`] = `
<a href="/p/my-blog-post-some-uuid" class="post-minimalist-card-wrapper">
<!---->
<div class="title-info-wrapper has-text-grey-dark">
<p class="post-minimalist-title">My Blog Post</p>
<p class="post-publication-date"><span class="icon"><i class="mdi mdi-clock mdi-24px"></i></span> <span class="has-text-grey-dark">Dec 2, 2020</span></p>
<div class="tags" style="display: inline;"><span class="icon"><i class="mdi mdi-tag mdi-24px"></i></span> <span class="tag"><span class="">A tag</span>
<!----></span>
</div>
<!---->
</div>
</a>
`;

View file

@ -21,7 +21,9 @@ module.exports = {
css: {
loaderOptions: {
scss: {
additionalData: `@import "@/variables.scss";`,
additionalData: `
@use "@/variables.scss" as *;
`,
sassOptions: {
quietDeps: true,
},