diff --git a/CHANGELOG.md b/CHANGELOG.md index 09f7bba18b..5d74090b35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Enhance: リアクション受け入れが「いいねのみ」の場合はリアクション絵文字一覧を表示しないように - Enhance: 設定>プラグインのページからプラグインの簡易的なログやエラーを見られるように - 実装の都合により、プラグインは1つエラーを起こした時に即時停止するようになりました +- Enhance: ページのデザインを変更 - Fix: 一部のページ内リンクが正しく動作しない問題を修正 - Fix: 周年の実績が閏年を考慮しない問題を修正 - Fix: ローカルURLのプレビューポップアップが左上に表示される diff --git a/packages/frontend/src/components/page/page.image.vue b/packages/frontend/src/components/page/page.image.vue index ced02943db..fc1ce9fc7b 100644 --- a/packages/frontend/src/components/page/page.image.vue +++ b/packages/frontend/src/components/page/page.image.vue @@ -4,19 +4,15 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> - <MediaImage - v-if="image" - :image="image" - :disableImageLink="true" - /> +<div :class="$style.root"> + <MkMediaList v-if="image" :mediaList="[image]" :class="$style.mediaList"/> </div> </template> <script lang="ts" setup> import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import MediaImage from '@/components/MkMediaImage.vue'; +import MkMediaList from '@/components/MkMediaList.vue'; const props = defineProps<{ block: Misskey.entities.PageBlock, @@ -28,5 +24,17 @@ const image = ref<Misskey.entities.DriveFile | null>(null); onMounted(() => { image.value = props.page.attachedFiles.find(x => x.id === props.block.fileId) ?? null; }); - </script> + +<style lang="scss" module> +.root { + border: 1px solid var(--divider); + border-radius: var(--radius); + overflow: hidden; +} +.mediaList { + // MkMediaList 内の上部マージン 4px + margin-top: -4px; + height: calc(100% + 4px); +} +</style> diff --git a/packages/frontend/src/components/page/page.note.vue b/packages/frontend/src/components/page/page.note.vue index 7b56494a6e..b5ba407806 100644 --- a/packages/frontend/src/components/page/page.note.vue +++ b/packages/frontend/src/components/page/page.note.vue @@ -4,9 +4,9 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div style="margin: 1em 0;"> - <MkNote v-if="note && !block.detailed" :key="note.id + ':normal'" v-model:note="note"/> - <MkNoteDetailed v-if="note && block.detailed" :key="note.id + ':detail'" v-model:note="note"/> +<div :class="$style.root"> + <MkNote v-if="note && !block.detailed" :key="note.id + ':normal'" :note="note"/> + <MkNoteDetailed v-if="note && block.detailed" :key="note.id + ':detail'" :note="note"/> </div> </template> @@ -32,3 +32,10 @@ onMounted(() => { }); }); </script> + +<style lang="scss" module> +.root { + border: 1px solid var(--divider); + border-radius: var(--radius); +} +</style> diff --git a/packages/frontend/src/components/page/page.text.vue b/packages/frontend/src/components/page/page.text.vue index 81a4c4fa93..61247b381f 100644 --- a/packages/frontend/src/components/page/page.text.vue +++ b/packages/frontend/src/components/page/page.text.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div class="_gaps"> +<div class="_gaps" :class="$style.textRoot"> <Mfm :text="block.text ?? ''" :isNote="false"/> <MkUrlPreview v-for="url in urls" :key="url" :url="url"/> </div> @@ -25,3 +25,9 @@ const props = defineProps<{ const urls = props.block.text ? extractUrlFromMfm(mfm.parse(props.block.text)) : []; </script> + +<style lang="scss" module> +.textRoot { + font-size: 1.1rem; +} +</style> diff --git a/packages/frontend/src/components/page/page.vue b/packages/frontend/src/components/page/page.vue index 53c70b01f4..a31c5eff28 100644 --- a/packages/frontend/src/components/page/page.vue +++ b/packages/frontend/src/components/page/page.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="{ [$style.center]: page.alignCenter, [$style.serif]: page.font === 'serif' }" class="_gaps_s"> +<div :class="{ [$style.center]: page.alignCenter, [$style.serif]: page.font === 'serif' }" class="_gaps"> <XBlock v-for="child in page.content" :key="child.id" :page="page" :block="child" :h="2"/> </div> </template> diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue index 194a276f89..0a28386986 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <XContainer :draggable="true" @remove="() => $emit('remove')"> <template #header><i class="ti ti-note"></i> {{ i18n.ts._pages.blocks.note }}</template> - <section style="padding: 0 16px 0 16px;"> + <section style="padding: 16px;" class="_gaps_s"> <MkInput v-model="id"> <template #label>{{ i18n.ts._pages.blocks._note.id }}</template> <template #caption>{{ i18n.ts._pages.blocks._note.idDescription }}</template> diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue index bece32fc11..ab44533b81 100644 --- a/packages/frontend/src/pages/page.vue +++ b/packages/frontend/src/pages/page.vue @@ -6,48 +6,73 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :contentMax="700"> - <Transition :name="defaultStore.state.animation ? 'fade' : ''" mode="out-in"> - <div v-if="page" :key="page.id" class="xcukqgmh"> - <div class="main"> - <!-- - <div class="header"> - <h1>{{ page.title }}</h1> - </div> - --> - <div class="banner"> - <MkMediaImage - v-if="page.eyeCatchingImageId" - :image="page.eyeCatchingImage" - :cover="true" - :disableImageLink="true" - class="thumbnail" - /> + <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 : ''" + > + <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 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> </div> - <div class="content"> + <div :class="$style.pageContent"> <XPage :page="page"/> </div> - <div class="actions"> - <div class="like"> + <div :class="$style.pageActions"> + <div> <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> <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> </div> - <div class="other"> - <button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button> - <button v-tooltip="i18n.ts.copyLink" v-click-anime class="_button" @click="copyLink"><i class="ti ti-link ti-fw"></i></button> - <button v-if="isSupportShare()" v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button> + <div :class="$style.other"> + <button v-tooltip="i18n.ts.copyLink" class="_button" :class="$style.generalActionButton" @click="copyLink"><i class="ti ti-link ti-fw"></i></button> + <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 class="user"> - <MkAvatar :user="page.user" class="avatar" link preview/> - <div class="name"> - <MkUserName :user="page.user" style="display: block;"/> - <MkAcct :user="page.user"/> - </div> - <MkFollowButton v-if="!$i || $i.id != page.user.id" :user="page.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/> + <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"/> </div> - <div class="links"> - <MkA :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ i18n.ts._pages.viewSource }}</MkA> + <div :class="$style.pageDate"> + <div><i class="ti ti-clock"></i> {{ i18n.ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div> + <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> + <div :class="$style.pageLinks"> + <MkA v-if="!$i || $i.id !== page.userId" :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ i18n.ts._pages.viewSource }}</MkA> <template v-if="$i && $i.id === page.userId"> <MkA :to="`/pages/edit/${page.id}`" class="link">{{ i18n.ts._pages.editThisPage }}</MkA> <button v-if="$i.pinnedPageId === page.id" class="link _textButton" @click="pin(false)">{{ i18n.ts.unpin }}</button> @@ -55,10 +80,6 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </div> </div> - <div class="footer"> - <div><i class="ti ti-clock"></i> {{ i18n.ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div> - <div v-if="page.createdAt != page.updatedAt"><i class="ti ti-clock"></i> {{ i18n.ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div> - </div> <MkAd :prefer="['horizontal', 'horizontal-big']"/> <MkContainer :max-height="300" :foldable="true" class="other"> <template #icon><i class="ti ti-clock"></i></template> @@ -84,6 +105,7 @@ import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; 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'; @@ -94,6 +116,8 @@ 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'; const props = defineProps<{ @@ -133,35 +157,63 @@ function fetchPage() { }); } -function share() { - navigator.share({ - title: page.value.title ?? page.value.name, - text: page.value.summary, - url: `${url}/@${page.value.user.username}/pages/${page.value.name}`, - }); +function share(ev: MouseEvent) { + if (!page.value) return; + + os.popupMenu([ + { + text: i18n.ts.shareWithNote, + icon: 'ti ti-pencil', + action: shareWithNote, + }, + ...(isSupportShare() ? [{ + text: i18n.ts.share, + 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} ${url}/@${page.value.user.username}/pages/${page.value.name}`, + 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++; + 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, @@ -170,12 +222,14 @@ async function unlike() { os.apiWithDialog('pages/unlike', { pageId: page.value.id, }).then(() => { - page.value.isLiked = false; - page.value.likedCount--; + page.value!.isLiked = false; + page.value!.likedCount--; }); } function pin(pin) { + if (!page.value) return; + os.apiWithDialog('i/update', { pinnedPageId: pin ? page.value.id : null, }); @@ -200,109 +254,185 @@ definePageMetadata(() => ({ })); </script> -<style lang="scss" scoped> -.fade-enter-active, -.fade-leave-active { +<style lang="scss" module> +.fadeEnterActive, +.fadeLeaveActive { transition: opacity 0.125s ease; } -.fade-enter-from, -.fade-leave-to { +.fadeEnterFrom, +.fadeLeaveTo { opacity: 0; } -.xcukqgmh { - > .main { - padding: 32px; +.generalActionButton { + height: 2.5rem; + width: 2.5rem; + text-align: center; + border-radius: 99rem; - > .header { - padding: 16px; - - > h1 { - margin: 0; - } - } - - > .banner { - > .thumbnail { - // TODO: 良い感じのアスペクト比で表示 - display: block; - width: 100%; - height: auto; - aspect-ratio: 3/1; - border-radius: var(--radius); - overflow: hidden; - object-fit: cover; - } - } - - > .content { - margin-top: 16px; - padding: 16px 0 0 0; - } - - > .actions { - display: flex; - align-items: center; - margin-top: 16px; - padding: 16px 0 0 0; - border-top: solid 0.5px var(--divider); - - > .other { - margin-left: auto; - - > button { - padding: 8px; - margin: 0 8px; - - &:hover { - color: var(--fgHighlighted); - } - } - } - } - - > .user { - margin-top: 16px; - padding: 16px 0 0 0; - border-top: solid 0.5px var(--divider); - display: flex; - align-items: center; - - > .avatar { - width: 52px; - height: 52px; - } - - > .name { - margin: 0 0 0 12px; - font-size: 90%; - } - - > .koudoku { - margin-left: auto; - } - } - - > .links { - margin-top: 16px; - padding: 24px 0 0 0; - border-top: solid 0.5px var(--divider); - - > .link { - margin-right: 0.75em; - } - } + & :global(.ti) { + line-height: 2.5rem; } - > .footer { - margin: var(--margin) 0 var(--margin) 0; - font-size: 85%; - opacity: 0.75; + &:hover, + &:focus-visible { + background-color: var(--accentedBg); + color: var(--accent); + text-decoration: none; } } -</style> -<style module> +.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); + } + + .pageBannerBgFallback1 { + filter: blur(20px); + } + + .pageBannerBgFallback2 { + background-color: var(--accentedBg); + } + + &::after { + content: ''; + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: 100px; + background: linear-gradient(0deg, var(--panel), transparent); + } + } + + > .pageBannerImage { + position: relative; + padding-top: 56.25%; + + > .thumbnail { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + } + + > .pageBannerTitle { + position: relative; + padding: 1.5rem 2rem; + + h1 { + font-size: 2rem; + font-weight: 700; + color: var(--fg); + margin: 0; + } + + .pageBannerTitleUser { + --height: 32px; + + .avatar { + height: var(--height); + width: var(--height); + } + + line-height: var(--height); + } + } +} + +.pageContent { + margin-bottom: 1.5rem; +} + +.pageActions { + display: flex; + align-items: center; + + 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; + } + + .follow { + margin-left: auto; + } +} + +.pageDate { + margin-bottom: 1.5rem; +} + +.pageLinks { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--marginHalf); +} + .relatedPagesRoot { padding: var(--margin); }