hippofish/packages/frontend/src/pages/page.vue

528 lines
14 KiB
Vue
Raw Normal View History

<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
2022-06-21 07:12:39 +02:00
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :displayBackButton="true" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="800">
<Transition
:enterActiveClass="defaultStore.state.animation ? $style.fadeEnterActive : ''"
:leaveActiveClass="defaultStore.state.animation ? $style.fadeLeaveActive : ''"
:enterFromClass="defaultStore.state.animation ? $style.fadeEnterFrom : ''"
:leaveToClass="defaultStore.state.animation ? $style.fadeLeaveTo : ''"
mode="out-in"
>
<div v-if="page" :key="page.id" class="_gaps">
<div :class="$style.pageMain">
<div :class="$style.pageBanner">
<div :class="$style.pageBannerBgRoot">
<MkImgWithBlurhash
v-if="page.eyeCatchingImageId"
:class="$style.pageBannerBg"
:hash="page.eyeCatchingImage?.blurhash"
:cover="true"
:forceBlurhash="true"
/>
<img
v-else-if="instance.backgroundImageUrl || instance.bannerUrl"
:class="[$style.pageBannerBg, $style.pageBannerBgFallback1]"
:src="getStaticImageUrl(instance.backgroundImageUrl ?? instance.bannerUrl!)"
/>
<div v-else :class="[$style.pageBannerBg, $style.pageBannerBgFallback2]"></div>
</div>
<div v-if="page.eyeCatchingImageId" :class="$style.pageBannerImage">
<MkMediaImage
:image="page.eyeCatchingImage!"
:cover="true"
:disableImageLink="true"
:class="$style.thumbnail"
/>
</div>
<div :class="$style.pageBannerTitle" class="_gaps_s">
<h1>{{ page.title || page.name }}</h1>
<div :class="$style.pageBannerTitleSub">
<div v-if="page.user" :class="$style.pageBannerTitleUser">
<MkAvatar :user="page.user" :class="$style.avatar" indicator link preview/> <MkA :to="`/@${username}`"><MkUserName :user="page.user" :nowrap="false"/></MkA>
</div>
<div :class="$style.pageBannerTitleSubActions">
2024-06-21 14:44:45 +02:00
<MkA v-if="page.userId === $i?.id" v-tooltip="i18n.ts._pages.editThisPage" :to="`/pages/edit/${page.id}`" class="_button" :class="$style.generalActionButton"><i class="ti ti-pencil ti-fw"></i></MkA>
2024-06-22 15:36:39 +02:00
<button v-tooltip="i18n.ts.share" class="_button" :class="$style.generalActionButton" @click="share"><i class="ti ti-share ti-fw"></i></button>
</div>
</div>
</div>
2021-10-14 11:51:15 +02:00
</div>
<div :class="$style.pageContent">
2022-06-21 07:12:39 +02:00
<XPage :page="page"/>
2021-10-14 11:51:15 +02:00
</div>
<div :class="$style.pageActions">
<div>
2024-06-22 16:12:43 +02:00
<MkButton v-if="page.isLiked" v-tooltip="i18n.ts._pages.unlike" class="button" asLike primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
2024-06-21 15:38:16 +02:00
<MkButton v-else v-tooltip="i18n.ts._pages.like" class="button" asLike @click="like()"><i class="ti ti-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
2022-06-21 07:12:39 +02:00
</div>
<div :class="$style.other">
<MkA v-if="page.userId === $i?.id" v-tooltip="i18n.ts._pages.editThisPage" :to="`/pages/edit/${page.id}`" class="_button" :class="$style.generalActionButton"><i class="ti ti-pencil ti-fw"></i></MkA>
2024-06-21 15:48:54 +02:00
<button v-tooltip="i18n.ts.copyLink" class="_button" :class="$style.generalActionButton" @click="copyLink"><i class="ti ti-link ti-fw"></i></button>
2024-06-22 15:36:39 +02:00
<button v-tooltip="i18n.ts.share" class="_button" :class="$style.generalActionButton" @click="share"><i class="ti ti-share ti-fw"></i></button>
<button v-if="$i" v-click-anime class="_button" :class="$style.generalActionButton" @mousedown="showMenu"><i class="ti ti-dots ti-fw"></i></button>
2022-06-21 07:12:39 +02:00
</div>
</div>
<div :class="$style.pageUser">
<MkAvatar :user="page.user" :class="$style.avatar" link preview/>
<MkA :to="`/@${username}`">
<MkUserName :user="page.user" :class="$style.name"/>
<MkAcct :user="page.user" :class="$style.acct"/>
</MkA>
<MkFollowButton v-if="!$i || $i.id != page.user.id" :user="page.user!" :inline="true" :transparent="false" :full="true" :class="$style.follow"/>
2022-06-21 07:12:39 +02:00
</div>
<div :class="$style.pageDate">
2024-06-21 14:30:42 +02:00
<div><i class="ti ti-clock"></i> {{ i18n.ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div>
2024-06-22 16:12:43 +02:00
<div v-if="page.createdAt != page.updatedAt"><i class="ti ti-clock-edit"></i> {{ i18n.ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div>
</div>
2021-04-25 08:14:26 +02:00
</div>
2022-06-21 07:12:39 +02:00
<MkAd :prefer="['horizontal', 'horizontal-big']"/>
<MkContainer :max-height="300" :foldable="true" class="other">
2024-06-21 14:30:42 +02:00
<template #icon><i class="ti ti-clock"></i></template>
2023-01-15 00:30:29 +01:00
<template #header>{{ i18n.ts.recentPosts }}</template>
<MkPagination v-slot="{items}" :pagination="otherPostsPagination" :class="$style.relatedPagesRoot" class="_gaps">
<MkPagePreview v-for="page in items" :key="page.id" :page="page" :class="$style.relatedPagesItem"/>
2022-06-21 07:12:39 +02:00
</MkPagination>
</MkContainer>
2021-04-25 08:14:26 +02:00
</div>
2022-06-21 07:12:39 +02:00
<MkError v-else-if="error" @retry="fetchPage()"/>
<MkLoading v-else/>
2022-12-30 05:37:14 +01:00
</Transition>
2022-06-21 07:12:39 +02:00
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed, watch, ref, defineAsyncComponent } from 'vue';
import * as Misskey from 'misskey-js';
2021-11-11 18:02:25 +01:00
import XPage from '@/components/page/page.vue';
import MkButton from '@/components/MkButton.vue';
2023-09-19 09:37:43 +02:00
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
2023-09-19 09:37:43 +02:00
import { url } from '@/config.js';
import MkMediaImage from '@/components/MkMediaImage.vue';
import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
import MkFollowButton from '@/components/MkFollowButton.vue';
import MkContainer from '@/components/MkContainer.vue';
import MkPagination from '@/components/MkPagination.vue';
import MkPagePreview from '@/components/MkPagePreview.vue';
2023-09-19 09:37:43 +02:00
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { pageViewInterruptors, defaultStore } from '@/store.js';
import { deepClone } from '@/scripts/clone.js';
import { $i } from '@/account.js';
import { isSupportShare } from '@/scripts/navigator.js';
import { instance } from '@/instance.js';
import { getStaticImageUrl } from '@/scripts/media-proxy.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { useRouter } from '@/router/supplier.js';
import { MenuItem } from '@/types/menu';
const router = useRouter();
const props = defineProps<{
pageName: string;
username: string;
}>();
const page = ref<Misskey.entities.Page | null>(null);
const error = ref<any>(null);
const otherPostsPagination = {
endpoint: 'users/pages' as const,
limit: 6,
params: computed(() => ({
userId: page.value.user.id,
})),
};
const path = computed(() => props.username + '/' + props.pageName);
function fetchPage() {
page.value = null;
misskeyApi('pages/show', {
name: props.pageName,
username: props.username,
2023-03-15 02:44:24 +01:00
}).then(async _page => {
page.value = _page;
2023-03-15 02:44:24 +01:00
// plugin
if (pageViewInterruptors.length > 0) {
let result = deepClone(_page);
for (const interruptor of pageViewInterruptors) {
result = await interruptor.handler(result);
}
page.value = result;
2023-03-15 02:44:24 +01:00
}
}).catch(err => {
error.value = err;
});
}
function share(ev: MouseEvent) {
if (!page.value) return;
os.popupMenu([
{
text: i18n.ts.shareWithNote,
2024-06-22 16:12:43 +02:00
icon: 'ti ti-pencil',
action: shareWithNote,
},
...(isSupportShare() ? [{
text: i18n.ts.share,
2024-06-22 15:36:39 +02:00
icon: 'ti ti-share',
action: shareWithNavigator,
}] : []),
], ev.currentTarget ?? ev.target);
}
function copyLink() {
if (!page.value) return;
copyToClipboard(`${url}/@${page.value.user.username}/pages/${page.value.name}`);
os.success();
}
function shareWithNote() {
if (!page.value) return;
os.post({
initialText: `${page.value.title || page.value.name}\n${url}/@${page.value.user.username}/pages/${page.value.name}`,
instant: true,
});
}
function shareWithNavigator() {
if (!page.value) return;
navigator.share({
title: page.value.title ?? page.value.name,
text: page.value.summary ?? undefined,
url: `${url}/@${page.value.user.username}/pages/${page.value.name}`,
});
}
function like() {
if (!page.value) return;
os.apiWithDialog('pages/like', {
pageId: page.value.id,
}).then(() => {
page.value!.isLiked = true;
page.value!.likedCount++;
});
}
async function unlike() {
if (!page.value) return;
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.unlikeConfirm,
});
if (confirm.canceled) return;
os.apiWithDialog('pages/unlike', {
pageId: page.value.id,
}).then(() => {
page.value!.isLiked = false;
page.value!.likedCount--;
});
}
function pin(pin) {
if (!page.value) return;
os.apiWithDialog('i/update', {
pinnedPageId: pin ? page.value.id : null,
});
}
function reportAbuse() {
if (!page.value) return;
const pageUrl = `${url}/@${props.username}/pages/${props.pageName}`;
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
user: page.value.user,
initialComment: `Page: ${pageUrl}\n-----\n`,
}, {
closed: () => dispose(),
});
}
function showMenu(ev: MouseEvent) {
if (!page.value) return;
const menu: MenuItem[] = [
...($i && $i.id === page.value.userId ? [
{
icon: 'ti ti-code',
text: i18n.ts._pages.viewSource,
action: () => router.push(`/@${props.username}/pages/${props.pageName}/view-source`),
},
...($i.pinnedPageId === page.value.id ? [{
icon: 'ti ti-pinned-off',
text: i18n.ts.unpin,
action: () => pin(false),
}] : [{
icon: 'ti ti-pin',
text: i18n.ts.pin,
action: () => pin(true),
}]),
] : []),
...($i && $i.id !== page.value.userId ? [
{
icon: 'ti ti-exclamation-circle',
text: i18n.ts.reportAbuse,
action: reportAbuse,
},
...($i.isModerator || $i.isAdmin ? [
{
type: 'divider' as const,
},
{
icon: 'ti ti-trash',
text: i18n.ts.delete,
danger: true,
action: () => os.confirm({
type: 'warning',
text: i18n.ts.deleteConfirm,
}).then(({ canceled }) => {
if (canceled || !page.value) return;
os.apiWithDialog('pages/delete', { pageId: page.value.id });
}),
},
] : []),
] : []),
];
os.popupMenu(menu, ev.currentTarget ?? ev.target);
}
watch(() => path.value, fetchPage, { immediate: true });
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
title: page.value ? page.value.title || page.value.name : i18n.ts.pages,
...page.value ? {
avatar: page.value.user,
path: `/@${page.value.user.username}/pages/${page.value.name}`,
share: {
title: page.value.title || page.value.name,
text: page.value.summary,
},
} : {},
}));
</script>
<style lang="scss" module>
.fadeEnterActive,
.fadeLeaveActive {
2021-04-25 08:14:26 +02:00
transition: opacity 0.125s ease;
}
.fadeEnterFrom,
.fadeLeaveTo {
2021-04-25 08:14:26 +02:00
opacity: 0;
}
.generalActionButton {
height: 2.5rem;
width: 2.5rem;
text-align: center;
border-radius: 99rem;
2021-04-25 08:14:26 +02:00
& :global(.ti) {
line-height: 2.5rem;
}
2021-04-10 16:52:45 +02:00
&:hover,
&:focus-visible {
background-color: var(--accentedBg);
color: var(--accent);
text-decoration: none;
fix(frontend): フォーカスの挙動を修正 (#14158) * fix(frontend): 直前のパターンを記録するように * fix(frontend): フォーカス/タブ移動に関する挙動を調整 (#226) Cherry-pick commit e8c030673326871edf3623cf2b8675d68f9e1b13 Co-authored-by: taiyme <53635909+taiyme@users.noreply.github.com> * focusのデザイン修正 * move scripts * Modalにfocus trapを追加 * 記録するホットキーはレートリミット式にする * escキーのハンドリングをMkModalに統一 * fix * enterで子メニューを開けるように * lint * fix focus trap * improve switch accessibility * 一部のmodalのフォーカストラップが外れない問題を修正 * fix * fix * Revert "記録するホットキーはレートリミット式にする" This reverts commit 40a7509286a87911ad4cc06d9482e8a2e5d0e7e8. * Revert "fix(frontend): 直前のパターンを記録するように" This reverts commit 5372b2594023952cff34aa62253ed4efef15b5dd. * Revert "Revert "fix(frontend): 直前のパターンを記録するように"" This reverts commit a9bb52e799e110927ad92cd8f26af980819334e1. * Revert "Revert "記録するホットキーはレートリミット式にする"" This reverts commit bdac34273e0bc5f13604c7e2f9fa6b1321a0df3d. * 試験的にCypressでのFocustrapを無効化 * fix * fix focus-trap * Update Changelog * :v: * fix focustrap invocation logic * スクロールがsticky headerを考慮するように * :art: * スタイルの微調整 * :art: * remove deprecated key aliases * focusElementが足りなかったので修正 * preview系にfocus時スタイルが足りなかったので修正 * `returnFocusElement` -> `returnFocusTo` * lint * Update packages/frontend/src/components/MkModalWindow.vue * Apply suggestions from code review Co-authored-by: taiy <53635909+taiyme@users.noreply.github.com> * keydownイベントをまとめる * use correct pesudo-element selector * fix * rename --------- Co-authored-by: taiyme <53635909+taiyme@users.noreply.github.com> Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
2024-07-12 09:25:44 +02:00
outline: none;
}
}
2021-04-10 16:52:45 +02:00
.pageMain {
border-radius: var(--radius);
padding: 2rem;
background: var(--panel);
box-sizing: border-box;
}
.pageBanner {
width: calc(100% + 4rem);
margin: -2rem -2rem 1.5rem;
border-radius: var(--radius) var(--radius) 0 0;
overflow: hidden;
position: relative;
> .pageBannerBgRoot {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
.pageBannerBg {
width: 100%;
height: 100%;
object-fit: cover;
opacity: .2;
filter: brightness(1.2);
2021-04-10 16:52:45 +02:00
}
.pageBannerBgFallback1 {
filter: blur(20px);
2021-04-10 16:52:45 +02:00
}
.pageBannerBgFallback2 {
background-color: var(--accentedBg);
}
2021-04-10 16:52:45 +02:00
&::after {
content: '';
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 100px;
background: linear-gradient(0deg, var(--panel), transparent);
}
}
2021-04-25 08:14:26 +02:00
> .pageBannerImage {
position: relative;
padding-top: 56.25%;
2021-04-25 08:14:26 +02:00
> .thumbnail {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
2021-04-25 08:14:26 +02:00
}
}
2021-04-25 08:14:26 +02:00
> .pageBannerTitle {
position: relative;
padding: 1.5rem 2rem;
2021-04-25 08:14:26 +02:00
h1 {
font-size: 2rem;
font-weight: 700;
color: var(--fg);
margin: 0;
}
2021-04-25 08:14:26 +02:00
.pageBannerTitleSub {
display: flex;
align-items: center;
width: 100%;
}
.pageBannerTitleUser {
--height: 32px;
flex-shrink: 0;
2021-04-25 08:14:26 +02:00
.avatar {
height: var(--height);
width: var(--height);
2021-04-25 08:14:26 +02:00
}
line-height: var(--height);
2020-04-17 13:36:51 +02:00
}
.pageBannerTitleSubActions {
flex-shrink: 0;
display: flex;
align-items: center;
gap: var(--marginHalf);
margin-left: auto;
}
}
}
.pageContent {
margin-bottom: 1.5rem;
}
2020-04-17 13:36:51 +02:00
.pageActions {
display: flex;
align-items: center;
2021-04-10 16:52:45 +02:00
border-top: 1px solid var(--divider);
padding-top: 1.5rem;
margin-bottom: 1.5rem;
> .other {
margin-left: auto;
display: flex;
gap: var(--marginHalf);
}
}
.pageUser {
display: flex;
align-items: center;
border-top: 1px solid var(--divider);
padding-top: 1.5rem;
margin-bottom: 1.5rem;
.avatar,
.name,
.acct {
display: block;
}
.avatar {
width: 4rem;
height: 4rem;
margin-right: 1rem;
}
.name {
font-size: 110%;
font-weight: 700;
}
.acct {
font-size: 90%;
opacity: 0.7;
}
2021-04-10 16:52:45 +02:00
.follow {
margin-left: auto;
2021-04-10 16:52:45 +02:00
}
}
.pageDate {
margin-bottom: 1.5rem;
}
.pageLinks {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--marginHalf);
}
.relatedPagesRoot {
padding: var(--margin);
}
.relatedPagesItem > article {
background-color: var(--panelHighlight) !important;
}
</style>