530 lines
15 KiB
Vue
530 lines
15 KiB
Vue
<template>
|
|
<article class="container mx-auto post" v-if="post">
|
|
<breadcrumbs-nav
|
|
v-if="post.attributedTo"
|
|
:links="[
|
|
{ name: RouteName.MY_GROUPS, text: t('My groups') },
|
|
{
|
|
name: RouteName.GROUP,
|
|
params: { preferredUsername: usernameWithDomain(post.attributedTo) },
|
|
text: displayName(post.attributedTo),
|
|
},
|
|
{
|
|
name: RouteName.POST,
|
|
params: { slug: post.slug },
|
|
text: post.title,
|
|
},
|
|
]"
|
|
/>
|
|
<header>
|
|
<div class="flex justify-center">
|
|
<lazy-image-wrapper :picture="post.picture" />
|
|
</div>
|
|
<div class="relative flex flex-col">
|
|
<div
|
|
class="px-2 py-3 flex flex-wrap gap-4 justify-center items-center"
|
|
dir="auto"
|
|
>
|
|
<div class="flex-auto min-w-[300px] max-w-screen-lg">
|
|
<div class="inline">
|
|
<tag
|
|
class="mr-2"
|
|
variant="warning"
|
|
size="medium"
|
|
v-if="post.draft"
|
|
>{{ t("Draft") }}</tag
|
|
>
|
|
<h1 class="inline" :lang="post.language">
|
|
{{ post.title }}
|
|
</h1>
|
|
</div>
|
|
<p class="mt-2 flex flex-col flex-wrap justify-start">
|
|
<router-link
|
|
:to="{
|
|
name: RouteName.GROUP,
|
|
params: {
|
|
preferredUsername: usernameWithDomain(post.attributedTo),
|
|
},
|
|
}"
|
|
>
|
|
<actor-inline
|
|
v-if="post.attributedTo"
|
|
:actor="post.attributedTo"
|
|
/>
|
|
</router-link>
|
|
<span
|
|
class="inline-flex gap-2 items-center mt-2"
|
|
v-if="!post.draft && post.publishAt"
|
|
>
|
|
<Clock :size="16" />
|
|
{{ formatDateTimeString(post.publishAt) }}
|
|
</span>
|
|
<span
|
|
class="inline-flex gap-2 items-center mt-2"
|
|
:title="
|
|
formatDateTimeString(post.updatedAt, undefined, true, 'short')
|
|
"
|
|
v-else-if="post.updatedAt"
|
|
>
|
|
<Clock :size="16" />
|
|
{{
|
|
t("Edited {relative_time} ago", {
|
|
relative_time: formatDistanceToNowStrict(
|
|
new Date(post.updatedAt),
|
|
{
|
|
locale: dateFnsLocale,
|
|
}
|
|
),
|
|
})
|
|
}}
|
|
</span>
|
|
<span
|
|
v-if="post.visibility === PostVisibility.UNLISTED"
|
|
class="flex gap-2 items-center"
|
|
>
|
|
<Link :size="16" />
|
|
{{ t("Accessible only by link") }}
|
|
</span>
|
|
<span
|
|
v-else-if="post.visibility === PostVisibility.PRIVATE"
|
|
class="flex gap-2 items-center"
|
|
>
|
|
<Lock :size="16" />
|
|
{{
|
|
t("Accessible only to members", {
|
|
group: post.attributedTo?.name,
|
|
})
|
|
}}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
<o-dropdown position="bottom-left" aria-role="list">
|
|
<template #trigger>
|
|
<o-button role="button" icon-right="dots-horizontal">
|
|
{{ t("Actions") }}
|
|
</o-button>
|
|
</template>
|
|
<o-dropdown-item
|
|
aria-role="listitem"
|
|
has-link
|
|
tabIndex="-1"
|
|
v-if="
|
|
currentActor?.id === post?.author?.id ||
|
|
isCurrentActorAGroupModerator
|
|
"
|
|
>
|
|
<router-link
|
|
class="flex gap-1 whitespace-nowrap flex-1"
|
|
:to="{
|
|
name: RouteName.POST_EDIT,
|
|
params: { slug: post.slug },
|
|
}"
|
|
>
|
|
<Pencil />
|
|
{{ t("Edit") }}
|
|
</router-link>
|
|
</o-dropdown-item>
|
|
<o-dropdown-item
|
|
aria-role="listitem"
|
|
v-if="
|
|
currentActor?.id === post?.author?.id ||
|
|
isCurrentActorAGroupModerator
|
|
"
|
|
tabIndex="-1"
|
|
>
|
|
<button
|
|
@click="openDeletePostModal"
|
|
class="flex gap-1 whitespace-nowrap"
|
|
>
|
|
<Delete />
|
|
{{ t("Delete") }}
|
|
</button>
|
|
</o-dropdown-item>
|
|
|
|
<hr
|
|
role="presentation"
|
|
class="dropdown-divider"
|
|
aria-role="menuitem"
|
|
v-if="
|
|
currentActor?.id === post?.author?.id ||
|
|
isCurrentActorAGroupModerator
|
|
"
|
|
/>
|
|
<o-dropdown-item
|
|
aria-role="listitem"
|
|
v-if="!post.draft"
|
|
tabIndex="-1"
|
|
>
|
|
<button
|
|
@click="triggerShare()"
|
|
class="flex gap-1 whitespace-nowrap"
|
|
>
|
|
<Share />
|
|
{{ t("Share this event") }}
|
|
</button>
|
|
</o-dropdown-item>
|
|
|
|
<o-dropdown-item
|
|
aria-role="listitem"
|
|
v-if="ableToReport"
|
|
tabIndex="-1"
|
|
>
|
|
<button
|
|
@click="isReportModalActive = true"
|
|
class="flex gap-1 whitespace-nowrap"
|
|
>
|
|
<Flag />
|
|
{{ t("Report") }}
|
|
</button>
|
|
</o-dropdown-item>
|
|
</o-dropdown>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
<o-notification
|
|
:title="t('Members-only post')"
|
|
class="mx-4"
|
|
variant="warning"
|
|
:closable="false"
|
|
v-if="
|
|
!membershipsLoading &&
|
|
!postLoading &&
|
|
isInstanceModerator &&
|
|
!isCurrentActorAGroupMember &&
|
|
post.visibility === PostVisibility.PRIVATE
|
|
"
|
|
>
|
|
{{
|
|
t(
|
|
"This post is accessible only for members. You have access to it for moderation purposes only because you are an instance moderator."
|
|
)
|
|
}}
|
|
</o-notification>
|
|
|
|
<section
|
|
v-html="post.body"
|
|
dir="auto"
|
|
class="px-2 md:px-4 py-4 prose lg:prose-xl prose-p:mt-6 dark:prose-invert bg-white dark:bg-zinc-700 mx-auto"
|
|
:lang="post.language"
|
|
/>
|
|
<section class="flex gap-2 my-6 justify-center" dir="auto">
|
|
<router-link
|
|
v-for="tag in post.tags"
|
|
:key="tag.title"
|
|
:to="{ name: RouteName.TAG, params: { tag: tag.title } }"
|
|
>
|
|
<tag>{{ tag.title }}</tag>
|
|
</router-link>
|
|
</section>
|
|
<o-modal
|
|
:close-button-aria-label="t('Close')"
|
|
v-model:active="isReportModalActive"
|
|
has-modal-card
|
|
ref="reportModal"
|
|
:autoFocus="false"
|
|
:trapFocus="false"
|
|
>
|
|
<ReportModal
|
|
:on-confirm="reportPost"
|
|
:title="t('Report this post')"
|
|
:outside-domain="groupDomain"
|
|
@close="isReportModalActive = false"
|
|
/>
|
|
</o-modal>
|
|
<o-modal
|
|
v-model:active="isShareModalActive"
|
|
has-modal-card
|
|
ref="shareModal"
|
|
:close-button-aria-label="t('Close')"
|
|
>
|
|
<share-post-modal :post="post" />
|
|
</o-modal>
|
|
</article>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import { ICurrentUserRole, MemberRole, PostVisibility } from "@/types/enums";
|
|
import { PERSON_MEMBERSHIPS } from "@/graphql/actor";
|
|
import {
|
|
IGroup,
|
|
IPerson,
|
|
usernameWithDomain,
|
|
displayName,
|
|
} from "@/types/actor";
|
|
import RouteName from "@/router/name";
|
|
import Tag from "@/components/TagElement.vue";
|
|
import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
|
|
import ActorInline from "@/components/Account/ActorInline.vue";
|
|
import { formatDistanceToNowStrict, Locale } from "date-fns";
|
|
import SharePostModal from "@/components/Post/SharePostModal.vue";
|
|
import ReportModal from "@/components/Report/ReportModal.vue";
|
|
import { useAnonymousReportsConfig } from "@/composition/apollo/config";
|
|
import {
|
|
useCurrentActorClient,
|
|
usePersonStatusGroup,
|
|
} from "@/composition/apollo/actor";
|
|
import { useCurrentUserClient } from "@/composition/apollo/user";
|
|
import { useMutation, useQuery } from "@vue/apollo-composable";
|
|
import { computed, inject, ref } from "vue";
|
|
import { IPost } from "@/types/post.model";
|
|
import { DELETE_POST, FETCH_POST } from "@/graphql/post";
|
|
import { useHead } from "@vueuse/head";
|
|
import { formatDateTimeString } from "@/filters/datetime";
|
|
import { useRouter } from "vue-router";
|
|
import { useCreateReport } from "@/composition/apollo/report";
|
|
import Clock from "vue-material-design-icons/Clock.vue";
|
|
import Lock from "vue-material-design-icons/Lock.vue";
|
|
import Pencil from "vue-material-design-icons/Pencil.vue";
|
|
import Delete from "vue-material-design-icons/Delete.vue";
|
|
import Share from "vue-material-design-icons/Share.vue";
|
|
import Flag from "vue-material-design-icons/Flag.vue";
|
|
import Link from "vue-material-design-icons/Link.vue";
|
|
import { Dialog } from "@/plugins/dialog";
|
|
import { useI18n } from "vue-i18n";
|
|
import { Notifier } from "@/plugins/notifier";
|
|
|
|
const props = defineProps<{
|
|
slug: string;
|
|
}>();
|
|
|
|
const { anonymousReportsConfig } = useAnonymousReportsConfig();
|
|
const { currentUser } = useCurrentUserClient();
|
|
const { currentActor } = useCurrentActorClient();
|
|
|
|
const { result: membershipsResult, loading: membershipsLoading } = useQuery<{
|
|
person: Pick<IPerson, "memberships">;
|
|
}>(
|
|
PERSON_MEMBERSHIPS,
|
|
() => ({ id: currentActor.value?.id }),
|
|
() => ({
|
|
enabled:
|
|
currentActor.value?.id !== undefined && currentActor.value?.id !== null,
|
|
})
|
|
);
|
|
const memberships = computed(() => membershipsResult.value?.person.memberships);
|
|
|
|
const { result: postResult, loading: postLoading } = useQuery<{
|
|
post: IPost;
|
|
}>(FETCH_POST, () => ({ slug: props.slug }));
|
|
|
|
const post = computed(() => postResult.value?.post);
|
|
|
|
usePersonStatusGroup(usernameWithDomain(post.value?.attributedTo as IGroup));
|
|
|
|
useHead({
|
|
title: computed(
|
|
() => `${post.value?.title} - ${displayName(post.value?.attributedTo)}`
|
|
),
|
|
});
|
|
|
|
const notifier = inject<Notifier>("notifier");
|
|
|
|
const isShareModalActive = ref(false);
|
|
const isReportModalActive = ref(false);
|
|
const reportModal = ref();
|
|
|
|
const isInstanceModerator = computed((): boolean => {
|
|
return (
|
|
currentUser.value?.role !== undefined &&
|
|
[ICurrentUserRole.ADMINISTRATOR, ICurrentUserRole.MODERATOR].includes(
|
|
currentUser.value?.role
|
|
)
|
|
);
|
|
});
|
|
|
|
const ableToReport = computed((): boolean => {
|
|
return (
|
|
currentActor.value?.id != undefined ||
|
|
anonymousReportsConfig.value?.allowed === true
|
|
);
|
|
});
|
|
|
|
const triggerShare = (): void => {
|
|
if (navigator.share) {
|
|
navigator
|
|
.share({
|
|
title: post.value?.title,
|
|
url: post.value?.url,
|
|
})
|
|
.then(() => console.debug("Successful share"))
|
|
.catch((error: any) => console.debug("Error sharing", error));
|
|
} else {
|
|
isShareModalActive.value = true;
|
|
// send popup
|
|
}
|
|
};
|
|
|
|
const {
|
|
mutate: createReportMutation,
|
|
onDone: onCreateReportDone,
|
|
onError: onCreateReportError,
|
|
} = useCreateReport();
|
|
|
|
onCreateReportDone(() => {
|
|
isReportModalActive.value = false;
|
|
reportModal.value.close();
|
|
const postTitle = post.value?.title;
|
|
notifier?.success(t("Post {eventTitle} reported", { postTitle }));
|
|
});
|
|
|
|
onCreateReportError((error) => {
|
|
console.error(error);
|
|
});
|
|
|
|
const reportPost = async (content: string, forward: boolean): Promise<void> => {
|
|
createReportMutation({
|
|
// postId: post.value?.id,
|
|
reportedId: post.value?.attributedTo?.id as string,
|
|
content,
|
|
forward,
|
|
});
|
|
};
|
|
const groupDomain = computed((): string | undefined | null => {
|
|
return post.value?.attributedTo?.domain;
|
|
});
|
|
|
|
const dateFnsLocale = inject<Locale>("dateFnsLocale");
|
|
|
|
const isCurrentActorAGroupModerator = computed((): boolean => {
|
|
return hasCurrentActorThisRole([
|
|
MemberRole.MODERATOR,
|
|
MemberRole.ADMINISTRATOR,
|
|
]);
|
|
});
|
|
|
|
const hasCurrentActorThisRole = (givenRole: string | string[]): boolean => {
|
|
const roles = Array.isArray(givenRole)
|
|
? givenRole
|
|
: ([givenRole] as MemberRole[]);
|
|
return (
|
|
(memberships.value?.total ?? 0) > 0 &&
|
|
roles.includes(memberships.value?.elements[0].role as MemberRole)
|
|
);
|
|
};
|
|
|
|
const isCurrentActorAGroupMember = computed((): boolean => {
|
|
return hasCurrentActorThisRole([
|
|
MemberRole.MODERATOR,
|
|
MemberRole.ADMINISTRATOR,
|
|
MemberRole.MEMBER,
|
|
]);
|
|
});
|
|
|
|
const { t } = useI18n({ useScope: "global" });
|
|
const dialog = inject<Dialog>("dialog");
|
|
|
|
const openDeletePostModal = async (): Promise<void> => {
|
|
dialog?.confirm({
|
|
variant: "danger",
|
|
title: t("Delete post"),
|
|
message: t(
|
|
"Are you sure you want to delete this post? This action cannot be reverted."
|
|
),
|
|
onConfirm: () =>
|
|
deletePost({
|
|
id: post.value?.id,
|
|
}),
|
|
});
|
|
};
|
|
|
|
const router = useRouter();
|
|
|
|
const { mutate: deletePost, onDone: onDeletePostDone } =
|
|
useMutation(DELETE_POST);
|
|
|
|
onDeletePostDone(({ data }) => {
|
|
if (data && post.value?.attributedTo) {
|
|
router.push({
|
|
name: RouteName.POSTS,
|
|
params: {
|
|
preferredUsername: usernameWithDomain(post.value?.attributedTo),
|
|
},
|
|
});
|
|
}
|
|
});
|
|
</script>
|
|
<style lang="scss" scoped>
|
|
@use "@/styles/_mixins" as *;
|
|
article.post {
|
|
header {
|
|
display: flex;
|
|
flex-direction: column;
|
|
.banner-container {
|
|
display: flex;
|
|
justify-content: center;
|
|
height: 30vh;
|
|
}
|
|
|
|
.heading-section {
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
margin-bottom: 2rem;
|
|
|
|
.heading-wrapper {
|
|
padding: 15px 10px;
|
|
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: 10px;
|
|
display: flex;
|
|
justify-content: flex-start;
|
|
flex-wrap: wrap;
|
|
flex-direction: column;
|
|
|
|
*:not(:first-child) {
|
|
@include padding-left(5px);
|
|
}
|
|
}
|
|
}
|
|
p.buttons {
|
|
flex: 1;
|
|
}
|
|
}
|
|
|
|
h1.title {
|
|
margin: 0;
|
|
font-weight: 500;
|
|
font-family: "Roboto", "Helvetica", "Arial", serif;
|
|
}
|
|
|
|
.authors {
|
|
display: inline-block;
|
|
}
|
|
|
|
&::after {
|
|
height: 0.2rem;
|
|
content: " ";
|
|
display: block;
|
|
}
|
|
|
|
.buttons {
|
|
justify-content: center;
|
|
}
|
|
}
|
|
}
|
|
|
|
margin: 0 auto;
|
|
}
|
|
</style>
|