diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2022-12-27 14:36:33 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2022-12-27 14:36:33 +0900 |
| commit | 9384f5399da39e53855beb8e7f8ded1aa56bf72e (patch) | |
| tree | ce5959571a981b9c4047da3c7b3fd080aa44222c /packages/frontend/src/pages/user | |
| parent | wip: retention for dashboard (diff) | |
| download | misskey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.gz misskey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.bz2 misskey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.zip | |
rename: client -> frontend
Diffstat (limited to 'packages/frontend/src/pages/user')
| -rw-r--r-- | packages/frontend/src/pages/user/clips.vue | 47 | ||||
| -rw-r--r-- | packages/frontend/src/pages/user/follow-list.vue | 47 | ||||
| -rw-r--r-- | packages/frontend/src/pages/user/followers.vue | 61 | ||||
| -rw-r--r-- | packages/frontend/src/pages/user/following.vue | 61 | ||||
| -rw-r--r-- | packages/frontend/src/pages/user/gallery.vue | 38 | ||||
| -rw-r--r-- | packages/frontend/src/pages/user/home.vue | 530 | ||||
| -rw-r--r-- | packages/frontend/src/pages/user/index.activity.vue | 52 | ||||
| -rw-r--r-- | packages/frontend/src/pages/user/index.photos.vue | 102 | ||||
| -rw-r--r-- | packages/frontend/src/pages/user/index.timeline.vue | 45 | ||||
| -rw-r--r-- | packages/frontend/src/pages/user/index.vue | 113 | ||||
| -rw-r--r-- | packages/frontend/src/pages/user/pages.vue | 30 | ||||
| -rw-r--r-- | packages/frontend/src/pages/user/reactions.vue | 61 |
12 files changed, 1187 insertions, 0 deletions
diff --git a/packages/frontend/src/pages/user/clips.vue b/packages/frontend/src/pages/user/clips.vue new file mode 100644 index 0000000000..8c71aacb0c --- /dev/null +++ b/packages/frontend/src/pages/user/clips.vue @@ -0,0 +1,47 @@ +<template> +<MkSpacer :content-max="700"> + <div class="pages-user-clips"> + <MkPagination v-slot="{items}" ref="list" :pagination="pagination" class="list"> + <MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap"> + <b>{{ item.name }}</b> + <div v-if="item.description" class="description">{{ item.description }}</div> + </MkA> + </MkPagination> + </div> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import * as misskey from 'misskey-js'; +import MkPagination from '@/components/MkPagination.vue'; + +const props = defineProps<{ + user: misskey.entities.User; +}>(); + +const pagination = { + endpoint: 'users/clips' as const, + limit: 20, + params: computed(() => ({ + userId: props.user.id, + })), +}; +</script> + +<style lang="scss" scoped> +.pages-user-clips { + > .list { + > .item { + display: block; + padding: 16px; + + > .description { + margin-top: 8px; + padding-top: 8px; + border-top: solid 0.5px var(--divider); + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/user/follow-list.vue b/packages/frontend/src/pages/user/follow-list.vue new file mode 100644 index 0000000000..d42acd838f --- /dev/null +++ b/packages/frontend/src/pages/user/follow-list.vue @@ -0,0 +1,47 @@ +<template> +<div> + <MkPagination v-slot="{items}" ref="list" :pagination="type === 'following' ? followingPagination : followersPagination" class="mk-following-or-followers"> + <div class="users"> + <MkUserInfo v-for="user in items.map(x => type === 'following' ? x.followee : x.follower)" :key="user.id" class="user" :user="user"/> + </div> + </MkPagination> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import * as misskey from 'misskey-js'; +import MkUserInfo from '@/components/MkUserInfo.vue'; +import MkPagination from '@/components/MkPagination.vue'; + +const props = defineProps<{ + user: misskey.entities.User; + type: 'following' | 'followers'; +}>(); + +const followingPagination = { + endpoint: 'users/following' as const, + limit: 20, + params: computed(() => ({ + userId: props.user.id, + })), +}; + +const followersPagination = { + endpoint: 'users/followers' as const, + limit: 20, + params: computed(() => ({ + userId: props.user.id, + })), +}; +</script> + +<style lang="scss" scoped> +.mk-following-or-followers { + > .users { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: var(--margin); + } +} +</style> diff --git a/packages/frontend/src/pages/user/followers.vue b/packages/frontend/src/pages/user/followers.vue new file mode 100644 index 0000000000..17c2843381 --- /dev/null +++ b/packages/frontend/src/pages/user/followers.vue @@ -0,0 +1,61 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="1000"> + <transition name="fade" mode="out-in"> + <div v-if="user"> + <XFollowList :user="user" type="followers"/> + </div> + <MkError v-else-if="error" @retry="fetchUser()"/> + <MkLoading v-else/> + </transition> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import * as misskey from 'misskey-js'; +import XFollowList from './follow-list.vue'; +import * as os from '@/os'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; + +const props = withDefaults(defineProps<{ + acct: string; +}>(), { +}); + +let user = $ref<null | misskey.entities.UserDetailed>(null); +let error = $ref(null); + +function fetchUser(): void { + if (props.acct == null) return; + user = null; + os.api('users/show', Acct.parse(props.acct)).then(u => { + user = u; + }).catch(err => { + error = err; + }); +} + +watch(() => props.acct, fetchUser, { + immediate: true, +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => user ? { + icon: 'ti ti-user', + title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`, + subtitle: i18n.ts.followers, + userName: user, + avatar: user, +} : null)); +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/frontend/src/pages/user/following.vue b/packages/frontend/src/pages/user/following.vue new file mode 100644 index 0000000000..03892ec03d --- /dev/null +++ b/packages/frontend/src/pages/user/following.vue @@ -0,0 +1,61 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="1000"> + <transition name="fade" mode="out-in"> + <div v-if="user"> + <XFollowList :user="user" type="following"/> + </div> + <MkError v-else-if="error" @retry="fetchUser()"/> + <MkLoading v-else/> + </transition> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import * as misskey from 'misskey-js'; +import XFollowList from './follow-list.vue'; +import * as os from '@/os'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; + +const props = withDefaults(defineProps<{ + acct: string; +}>(), { +}); + +let user = $ref<null | misskey.entities.UserDetailed>(null); +let error = $ref(null); + +function fetchUser(): void { + if (props.acct == null) return; + user = null; + os.api('users/show', Acct.parse(props.acct)).then(u => { + user = u; + }).catch(err => { + error = err; + }); +} + +watch(() => props.acct, fetchUser, { + immediate: true, +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => user ? { + icon: 'ti ti-user', + title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`, + subtitle: i18n.ts.following, + userName: user, + avatar: user, +} : null)); +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/frontend/src/pages/user/gallery.vue b/packages/frontend/src/pages/user/gallery.vue new file mode 100644 index 0000000000..b80e83fb11 --- /dev/null +++ b/packages/frontend/src/pages/user/gallery.vue @@ -0,0 +1,38 @@ +<template> +<MkSpacer :content-max="700"> + <MkPagination v-slot="{items}" :pagination="pagination"> + <div class="jrnovfpt"> + <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> + </div> + </MkPagination> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import * as misskey from 'misskey-js'; +import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue'; +import MkPagination from '@/components/MkPagination.vue'; + +const props = withDefaults(defineProps<{ + user: misskey.entities.User; +}>(), { +}); + +const pagination = { + endpoint: 'users/gallery/posts' as const, + limit: 6, + params: computed(() => ({ + userId: props.user.id, + })), +}; +</script> + +<style lang="scss" scoped> +.jrnovfpt { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: 12px; + margin: var(--margin); +} +</style> diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue new file mode 100644 index 0000000000..43c1b37e1d --- /dev/null +++ b/packages/frontend/src/pages/user/home.vue @@ -0,0 +1,530 @@ +<template> +<MkSpacer :content-max="narrow ? 800 : 1100"> + <div ref="rootEl" v-size="{ max: [500] }" class="ftskorzw" :class="{ wide: !narrow }"> + <div class="main"> + <!-- TODO --> + <!-- <div class="punished" v-if="user.isSuspended"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSuspended }}</div> --> + <!-- <div class="punished" v-if="user.isSilenced"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> --> + + <div class="profile"> + <MkRemoteCaution v-if="user.host != null" :href="user.url" class="warn"/> + + <div :key="user.id" class="_block main"> + <div class="banner-container" :style="style"> + <div ref="bannerEl" class="banner" :style="style"></div> + <div class="fade"></div> + <div class="title"> + <MkUserName class="name" :user="user" :nowrap="true"/> + <div class="bottom"> + <span class="username"><MkAcct :user="user" :detail="true"/></span> + <span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--badge);"><i class="ti ti-shield"></i></span> + <span v-if="!user.isAdmin && user.isModerator" :title="i18n.ts.isModerator" style="color: var(--badge);"><i class="ti ti-shield"></i></span> + <span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span> + <span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span> + </div> + </div> + <span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span> + <div v-if="$i" class="actions"> + <button class="menu _button" @click="menu"><i class="ti ti-dots"></i></button> + <MkFollowButton v-if="$i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/> + </div> + </div> + <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/> + <div class="title"> + <MkUserName :user="user" :nowrap="false" class="name"/> + <div class="bottom"> + <span class="username"><MkAcct :user="user" :detail="true"/></span> + <span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--badge);"><i class="ti ti-shield"></i></span> + <span v-if="!user.isAdmin && user.isModerator" :title="i18n.ts.isModerator" style="color: var(--badge);"><i class="ti ti-shield"></i></span> + <span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span> + <span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span> + </div> + </div> + <div class="description"> + <Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$i" :custom-emojis="user.emojis"/> + <p v-else class="empty">{{ i18n.ts.noAccountDescription }}</p> + </div> + <div class="fields system"> + <dl v-if="user.location" class="field"> + <dt class="name"><i class="ti ti-map-pin ti-fw"></i> {{ i18n.ts.location }}</dt> + <dd class="value">{{ user.location }}</dd> + </dl> + <dl v-if="user.birthday" class="field"> + <dt class="name"><i class="ti ti-cake ti-fw"></i> {{ i18n.ts.birthday }}</dt> + <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd> + </dl> + <dl class="field"> + <dt class="name"><i class="ti ti-calendar ti-fw"></i> {{ i18n.ts.registeredDate }}</dt> + <dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd> + </dl> + </div> + <div v-if="user.fields.length > 0" class="fields"> + <dl v-for="(field, i) in user.fields" :key="i" class="field"> + <dt class="name"> + <Mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/> + </dt> + <dd class="value"> + <Mfm :text="field.value" :author="user" :i="$i" :custom-emojis="user.emojis" :colored="false"/> + </dd> + </dl> + </div> + <div class="status"> + <MkA v-click-anime :to="userPage(user)" :class="{ active: page === 'index' }"> + <b>{{ number(user.notesCount) }}</b> + <span>{{ i18n.ts.notes }}</span> + </MkA> + <MkA v-click-anime :to="userPage(user, 'following')" :class="{ active: page === 'following' }"> + <b>{{ number(user.followingCount) }}</b> + <span>{{ i18n.ts.following }}</span> + </MkA> + <MkA v-click-anime :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }"> + <b>{{ number(user.followersCount) }}</b> + <span>{{ i18n.ts.followers }}</span> + </MkA> + </div> + </div> + </div> + + <div class="contents"> + <div v-if="user.pinnedNotes.length > 0" class="_gap"> + <XNote v-for="note in user.pinnedNotes" :key="note.id" class="note _block" :note="note" :pinned="true"/> + </div> + <MkInfo v-else-if="$i && $i.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo> + <template v-if="narrow"> + <XPhotos :key="user.id" :user="user"/> + <XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/> + </template> + </div> + <div> + <XUserTimeline :user="user"/> + </div> + </div> + <div v-if="!narrow" class="sub"> + <XPhotos :key="user.id" :user="user"/> + <XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/> + </div> + </div> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue'; +import calcAge from 's-age'; +import * as misskey from 'misskey-js'; +import XUserTimeline from './index.timeline.vue'; +import XNote from '@/components/MkNote.vue'; +import MkFollowButton from '@/components/MkFollowButton.vue'; +import MkContainer from '@/components/MkContainer.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkRemoteCaution from '@/components/MkRemoteCaution.vue'; +import MkTab from '@/components/MkTab.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import { getScrollPosition } from '@/scripts/scroll'; +import { getUserMenu } from '@/scripts/get-user-menu'; +import number from '@/filters/number'; +import { userPage, acct as getAcct } from '@/filters/user'; +import * as os from '@/os'; +import { useRouter } from '@/router'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; + +const XPhotos = defineAsyncComponent(() => import('./index.photos.vue')); +const XActivity = defineAsyncComponent(() => import('./index.activity.vue')); + +const props = withDefaults(defineProps<{ + user: misskey.entities.UserDetailed; +}>(), { +}); + +const router = useRouter(); + +let parallaxAnimationId = $ref<null | number>(null); +let narrow = $ref<null | boolean>(null); +let rootEl = $ref<null | HTMLElement>(null); +let bannerEl = $ref<null | HTMLElement>(null); + +const style = $computed(() => { + if (props.user.bannerUrl == null) return {}; + return { + backgroundImage: `url(${ props.user.bannerUrl })`, + }; +}); + +const age = $computed(() => { + return calcAge(props.user.birthday); +}); + +function menu(ev) { + os.popupMenu(getUserMenu(props.user, router), ev.currentTarget ?? ev.target); +} + +function parallaxLoop() { + parallaxAnimationId = window.requestAnimationFrame(parallaxLoop); + parallax(); +} + +function parallax() { + const banner = bannerEl as any; + if (banner == null) return; + + const top = getScrollPosition(rootEl); + + if (top < 0) return; + + const z = 1.75; // 奥行き(小さいほど奥) + const pos = -(top / z); + banner.style.backgroundPosition = `center calc(50% - ${pos}px)`; +} + +onMounted(() => { + window.requestAnimationFrame(parallaxLoop); + narrow = rootEl!.clientWidth < 1000; +}); + +onUnmounted(() => { + if (parallaxAnimationId) { + window.cancelAnimationFrame(parallaxAnimationId); + } +}); +</script> + +<style lang="scss" scoped> +.ftskorzw { + + > .main { + + > .punished { + font-size: 0.8em; + padding: 16px; + } + + > .profile { + + > .main { + position: relative; + overflow: hidden; + + > .banner-container { + position: relative; + height: 250px; + overflow: hidden; + background-size: cover; + background-position: center; + + > .banner { + height: 100%; + background-color: #4c5e6d; + background-size: cover; + background-position: center; + box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset; + will-change: background-position; + } + + > .fade { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 78px; + background: linear-gradient(transparent, rgba(#000, 0.7)); + } + + > .followed { + position: absolute; + top: 12px; + left: 12px; + padding: 4px 8px; + color: #fff; + background: rgba(0, 0, 0, 0.7); + font-size: 0.7em; + border-radius: 6px; + } + + > .actions { + position: absolute; + top: 12px; + right: 12px; + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + background: rgba(0, 0, 0, 0.2); + padding: 8px; + border-radius: 24px; + + > .menu { + vertical-align: bottom; + height: 31px; + width: 31px; + color: #fff; + text-shadow: 0 0 8px #000; + font-size: 16px; + } + + > .koudoku { + margin-left: 4px; + vertical-align: bottom; + } + } + + > .title { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + padding: 0 0 8px 154px; + box-sizing: border-box; + color: #fff; + + > .name { + display: block; + margin: 0; + line-height: 32px; + font-weight: bold; + font-size: 1.8em; + text-shadow: 0 0 8px #000; + } + + > .bottom { + > * { + display: inline-block; + margin-right: 16px; + line-height: 20px; + opacity: 0.8; + + &.username { + font-weight: bold; + } + } + } + } + } + + > .title { + display: none; + text-align: center; + padding: 50px 8px 16px 8px; + font-weight: bold; + border-bottom: solid 0.5px var(--divider); + + > .bottom { + > * { + display: inline-block; + margin-right: 8px; + opacity: 0.8; + } + } + } + + > .avatar { + display: block; + position: absolute; + top: 170px; + left: 16px; + z-index: 2; + width: 120px; + height: 120px; + box-shadow: 1px 1px 3px rgba(#000, 0.2); + } + + > .description { + padding: 24px 24px 24px 154px; + font-size: 0.95em; + + > .empty { + margin: 0; + opacity: 0.5; + } + } + + > .fields { + padding: 24px; + font-size: 0.9em; + border-top: solid 0.5px var(--divider); + + > .field { + display: flex; + padding: 0; + margin: 0; + align-items: center; + + &:not(:last-child) { + margin-bottom: 8px; + } + + > .name { + width: 30%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-weight: bold; + text-align: center; + } + + > .value { + width: 70%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin: 0; + } + } + + &.system > .field > .name { + } + } + + > .status { + display: flex; + padding: 24px; + border-top: solid 0.5px var(--divider); + + > a { + flex: 1; + text-align: center; + + &.active { + color: var(--accent); + } + + &:hover { + text-decoration: none; + } + + > b { + display: block; + line-height: 16px; + } + + > span { + font-size: 70%; + } + } + } + } + } + + > .contents { + > .content { + margin-bottom: var(--margin); + } + } + } + + &.max-width_500px { + > .main { + > .profile > .main { + > .banner-container { + height: 140px; + + > .fade { + display: none; + } + + > .title { + display: none; + } + } + + > .title { + display: block; + } + + > .avatar { + top: 90px; + left: 0; + right: 0; + width: 92px; + height: 92px; + margin: auto; + } + + > .description { + padding: 16px; + text-align: center; + } + + > .fields { + padding: 16px; + } + + > .status { + padding: 16px; + } + } + + > .contents { + > .nav { + font-size: 80%; + } + } + } + } + + &.wide { + display: flex; + width: 100%; + + > .main { + width: 100%; + min-width: 0; + } + + > .sub { + max-width: 350px; + min-width: 350px; + margin-left: var(--margin); + } + } +} + +@container (max-width: 500px) { + .ftskorzw { + > .main { + > .profile > .main { + > .banner-container { + height: 140px; + + > .fade { + display: none; + } + + > .title { + display: none; + } + } + + > .title { + display: block; + } + + > .avatar { + top: 90px; + left: 0; + right: 0; + width: 92px; + height: 92px; + margin: auto; + } + + > .description { + padding: 16px; + text-align: center; + } + + > .fields { + padding: 16px; + } + + > .status { + padding: 16px; + } + } + + > .contents { + > .nav { + font-size: 80%; + } + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/user/index.activity.vue b/packages/frontend/src/pages/user/index.activity.vue new file mode 100644 index 0000000000..523072d2e6 --- /dev/null +++ b/packages/frontend/src/pages/user/index.activity.vue @@ -0,0 +1,52 @@ +<template> +<MkContainer> + <template #header><i class="ti ti-chart-line" style="margin-right: 0.5em;"></i>{{ $ts.activity }}</template> + <template #func> + <button class="_button" @click="showMenu"> + <i class="ti ti-dots"></i> + </button> + </template> + + <div style="padding: 8px;"> + <MkChart :src="chartSrc" :args="{ user, withoutAll: true }" span="day" :limit="limit" :bar="true" :stacked="true" :detailed="false" :aspect-ratio="5"/> + </div> +</MkContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import * as misskey from 'misskey-js'; +import MkContainer from '@/components/MkContainer.vue'; +import MkChart from '@/components/MkChart.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const props = withDefaults(defineProps<{ + user: misskey.entities.User; + limit?: number; +}>(), { + limit: 50, +}); + +let chartSrc = $ref('per-user-notes'); + +function showMenu(ev: MouseEvent) { + os.popupMenu([{ + text: i18n.ts.notes, + active: true, + action: () => { + chartSrc = 'per-user-notes'; + }, + }, /*, { + text: i18n.ts.following, + action: () => { + chartSrc = 'per-user-following'; + } + }, { + text: i18n.ts.followers, + action: () => { + chartSrc = 'per-user-followers'; + } + }*/], ev.currentTarget ?? ev.target); +} +</script> diff --git a/packages/frontend/src/pages/user/index.photos.vue b/packages/frontend/src/pages/user/index.photos.vue new file mode 100644 index 0000000000..b33979a79d --- /dev/null +++ b/packages/frontend/src/pages/user/index.photos.vue @@ -0,0 +1,102 @@ +<template> +<MkContainer :max-height="300" :foldable="true"> + <template #header><i class="ti ti-photo" style="margin-right: 0.5em;"></i>{{ $ts.images }}</template> + <div class="ujigsodd"> + <MkLoading v-if="fetching"/> + <div v-if="!fetching && images.length > 0" class="stream"> + <MkA + v-for="image in images" + :key="image.note.id + image.file.id" + class="img" + :to="notePage(image.note)" + > + <ImgWithBlurhash :hash="image.file.blurhash" :src="thumbnail(image.file)" :title="image.file.name"/> + </MkA> + </div> + <p v-if="!fetching && images.length == 0" class="empty">{{ $ts.nothing }}</p> + </div> +</MkContainer> +</template> + +<script lang="ts" setup> +import { onMounted } from 'vue'; +import * as misskey from 'misskey-js'; +import { getStaticImageUrl } from '@/scripts/get-static-image-url'; +import { notePage } from '@/filters/note'; +import * as os from '@/os'; +import MkContainer from '@/components/MkContainer.vue'; +import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; +import { defaultStore } from '@/store'; + +const props = defineProps<{ + user: misskey.entities.UserDetailed; +}>(); + +let fetching = $ref(true); +let images = $ref<{ + note: misskey.entities.Note; + file: misskey.entities.DriveFile; +}[]>([]); + +function thumbnail(image: misskey.entities.DriveFile): string { + return defaultStore.state.disableShowingAnimatedImages + ? getStaticImageUrl(image.thumbnailUrl) + : image.thumbnailUrl; +} + +onMounted(() => { + const image = [ + 'image/jpeg', + 'image/webp', + 'image/avif', + 'image/png', + 'image/gif', + 'image/apng', + 'image/vnd.mozilla.apng', + ]; + os.api('users/notes', { + userId: props.user.id, + fileType: image, + excludeNsfw: defaultStore.state.nsfw !== 'ignore', + limit: 10, + }).then(notes => { + for (const note of notes) { + for (const file of note.files) { + images.push({ + note, + file, + }); + } + } + fetching = false; + }); +}); +</script> + +<style lang="scss" scoped> +.ujigsodd { + padding: 8px; + + > .stream { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + grid-gap: 6px; + + > .img { + height: 128px; + border-radius: 6px; + overflow: clip; + } + } + + > .empty { + margin: 0; + padding: 16px; + text-align: center; + + > i { + margin-right: 4px; + } + } +} +</style> diff --git a/packages/frontend/src/pages/user/index.timeline.vue b/packages/frontend/src/pages/user/index.timeline.vue new file mode 100644 index 0000000000..41983a5ae8 --- /dev/null +++ b/packages/frontend/src/pages/user/index.timeline.vue @@ -0,0 +1,45 @@ +<template> +<MkStickyContainer> + <template #header> + <MkTab v-model="include" :class="$style.tab"> + <option :value="null">{{ i18n.ts.notes }}</option> + <option value="replies">{{ i18n.ts.notesAndReplies }}</option> + <option value="files">{{ i18n.ts.withFiles }}</option> + </MkTab> + </template> + <XNotes :no-gap="true" :pagination="pagination"/> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { ref, computed } from 'vue'; +import * as misskey from 'misskey-js'; +import XNotes from '@/components/MkNotes.vue'; +import MkTab from '@/components/MkTab.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + user: misskey.entities.UserDetailed; +}>(); + +const include = ref<string | null>(null); + +const pagination = { + endpoint: 'users/notes' as const, + limit: 10, + params: computed(() => ({ + userId: props.user.id, + includeReplies: include.value === 'replies', + withFiles: include.value === 'files', + })), +}; +</script> + +<style lang="scss" module> +.tab { + margin: calc(var(--margin) / 2) 0; + padding: calc(var(--margin) / 2) 0; + background: var(--bg); +} +</style> diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue new file mode 100644 index 0000000000..6e895cd8d7 --- /dev/null +++ b/packages/frontend/src/pages/user/index.vue @@ -0,0 +1,113 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <div> + <transition name="fade" mode="out-in"> + <div v-if="user"> + <XHome v-if="tab === 'home'" :user="user"/> + <XReactions v-else-if="tab === 'reactions'" :user="user"/> + <XClips v-else-if="tab === 'clips'" :user="user"/> + <XPages v-else-if="tab === 'pages'" :user="user"/> + <XGallery v-else-if="tab === 'gallery'" :user="user"/> + </div> + <MkError v-else-if="error" @retry="fetchUser()"/> + <MkLoading v-else/> + </transition> + </div> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue'; +import calcAge from 's-age'; +import * as Acct from 'misskey-js/built/acct'; +import * as misskey from 'misskey-js'; +import { getScrollPosition } from '@/scripts/scroll'; +import number from '@/filters/number'; +import { userPage, acct as getAcct } from '@/filters/user'; +import * as os from '@/os'; +import { useRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; + +const XHome = defineAsyncComponent(() => import('./home.vue')); +const XReactions = defineAsyncComponent(() => import('./reactions.vue')); +const XClips = defineAsyncComponent(() => import('./clips.vue')); +const XPages = defineAsyncComponent(() => import('./pages.vue')); +const XGallery = defineAsyncComponent(() => import('./gallery.vue')); + +const props = withDefaults(defineProps<{ + acct: string; + page?: string; +}>(), { + page: 'home', +}); + +const router = useRouter(); + +let tab = $ref(props.page); +let user = $ref<null | misskey.entities.UserDetailed>(null); +let error = $ref(null); + +function fetchUser(): void { + if (props.acct == null) return; + user = null; + os.api('users/show', Acct.parse(props.acct)).then(u => { + user = u; + }).catch(err => { + error = err; + }); +} + +watch(() => props.acct, fetchUser, { + immediate: true, +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => user ? [{ + key: 'home', + title: i18n.ts.overview, + icon: 'ti ti-home', +}, ...($i && ($i.id === user.id)) || user.publicReactions ? [{ + key: 'reactions', + title: i18n.ts.reaction, + icon: 'ti ti-mood-happy', +}] : [], { + key: 'clips', + title: i18n.ts.clips, + icon: 'ti ti-paperclip', +}, { + key: 'pages', + title: i18n.ts.pages, + icon: 'ti ti-news', +}, { + key: 'gallery', + title: i18n.ts.gallery, + icon: 'ti ti-icons', +}] : null); + +definePageMetadata(computed(() => user ? { + icon: 'ti ti-user', + title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`, + subtitle: `@${getAcct(user)}`, + userName: user, + avatar: user, + path: `/@${user.username}`, + share: { + title: user.name, + }, +} : null)); +</script> + +<style lang="scss" scoped> +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.125s ease; +} +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} +</style> diff --git a/packages/frontend/src/pages/user/pages.vue b/packages/frontend/src/pages/user/pages.vue new file mode 100644 index 0000000000..7833d6c42c --- /dev/null +++ b/packages/frontend/src/pages/user/pages.vue @@ -0,0 +1,30 @@ +<template> +<MkSpacer :content-max="700"> + <MkPagination v-slot="{items}" ref="list" :pagination="pagination"> + <MkPagePreview v-for="page in items" :key="page.id" :page="page" class="_gap"/> + </MkPagination> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import * as misskey from 'misskey-js'; +import MkPagePreview from '@/components/MkPagePreview.vue'; +import MkPagination from '@/components/MkPagination.vue'; + +const props = defineProps<{ + user: misskey.entities.User; +}>(); + +const pagination = { + endpoint: 'users/pages' as const, + limit: 20, + params: computed(() => ({ + userId: props.user.id, + })), +}; +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/user/reactions.vue b/packages/frontend/src/pages/user/reactions.vue new file mode 100644 index 0000000000..ab3df34301 --- /dev/null +++ b/packages/frontend/src/pages/user/reactions.vue @@ -0,0 +1,61 @@ +<template> +<MkSpacer :content-max="700"> + <MkPagination v-slot="{items}" ref="list" :pagination="pagination"> + <div v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap afdcfbfb"> + <div class="header"> + <MkAvatar class="avatar" :user="user"/> + <MkReactionIcon class="reaction" :reaction="item.type" :custom-emojis="item.note.emojis" :no-style="true"/> + <MkTime :time="item.createdAt" class="createdAt"/> + </div> + <MkNote :key="item.id" :note="item.note"/> + </div> + </MkPagination> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import * as misskey from 'misskey-js'; +import MkPagination from '@/components/MkPagination.vue'; +import MkNote from '@/components/MkNote.vue'; +import MkReactionIcon from '@/components/MkReactionIcon.vue'; + +const props = defineProps<{ + user: misskey.entities.User; +}>(); + +const pagination = { + endpoint: 'users/reactions' as const, + limit: 20, + params: computed(() => ({ + userId: props.user.id, + })), +}; +</script> + +<style lang="scss" scoped> +.afdcfbfb { + > .header { + display: flex; + align-items: center; + padding: 8px 16px; + margin-bottom: 8px; + border-bottom: solid 2px var(--divider); + + > .avatar { + width: 24px; + height: 24px; + margin-right: 8px; + } + + > .reaction { + width: 32px; + height: 32px; + } + + > .createdAt { + margin-left: auto; + } + } +} +</style> |