summaryrefslogtreecommitdiff
path: root/packages/frontend/src/pages/user
diff options
context:
space:
mode:
Diffstat (limited to 'packages/frontend/src/pages/user')
-rw-r--r--packages/frontend/src/pages/user/clips.vue47
-rw-r--r--packages/frontend/src/pages/user/follow-list.vue47
-rw-r--r--packages/frontend/src/pages/user/followers.vue61
-rw-r--r--packages/frontend/src/pages/user/following.vue61
-rw-r--r--packages/frontend/src/pages/user/gallery.vue38
-rw-r--r--packages/frontend/src/pages/user/home.vue530
-rw-r--r--packages/frontend/src/pages/user/index.activity.vue52
-rw-r--r--packages/frontend/src/pages/user/index.photos.vue102
-rw-r--r--packages/frontend/src/pages/user/index.timeline.vue45
-rw-r--r--packages/frontend/src/pages/user/index.vue113
-rw-r--r--packages/frontend/src/pages/user/pages.vue30
-rw-r--r--packages/frontend/src/pages/user/reactions.vue61
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>