summaryrefslogtreecommitdiff
path: root/packages/client/src
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2022-07-01 18:55:45 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2022-07-01 18:55:45 +0900
commitadd6e9b14b5229016e71acf5a4061da8a9731c17 (patch)
tree15728336b6b9f1299013b3b7f4d0dcd5375e0f21 /packages/client/src
parentchore(client): tweak style (diff)
downloadsharkey-add6e9b14b5229016e71acf5a4061da8a9731c17.tar.gz
sharkey-add6e9b14b5229016e71acf5a4061da8a9731c17.tar.bz2
sharkey-add6e9b14b5229016e71acf5a4061da8a9731c17.zip
chore(client): tweak ui
Diffstat (limited to 'packages/client/src')
-rw-r--r--packages/client/src/components/global/page-header.vue24
-rw-r--r--packages/client/src/pages/admin/_header_.vue24
-rw-r--r--packages/client/src/pages/user/followers.vue62
-rw-r--r--packages/client/src/pages/user/following.vue62
-rw-r--r--packages/client/src/pages/user/home.vue478
-rw-r--r--packages/client/src/pages/user/index.vue489
-rw-r--r--packages/client/src/router.ts14
7 files changed, 655 insertions, 498 deletions
diff --git a/packages/client/src/components/global/page-header.vue b/packages/client/src/components/global/page-header.vue
index a080c39dde..5395a87961 100644
--- a/packages/client/src/components/global/page-header.vue
+++ b/packages/client/src/components/global/page-header.vue
@@ -34,7 +34,7 @@
</template>
<script lang="ts" setup>
-import { computed, onMounted, onUnmounted, ref, inject, watch } from 'vue';
+import { computed, onMounted, onUnmounted, ref, inject, watch, shallowReactive, nextTick, reactive } from 'vue';
import tinycolor from 'tinycolor2';
import { popupMenu } from '@/os';
import { scrollToTop } from '@/scripts/scroll';
@@ -137,16 +137,18 @@ onMounted(() => {
calcBg();
globalEvents.on('themeChanged', calcBg);
- watch(() => props.tab, () => {
- const tabEl = tabRefs[props.tab];
- if (tabEl && tabHighlightEl) {
- // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある
- // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4
- const parentRect = tabEl.parentElement.getBoundingClientRect();
- const rect = tabEl.getBoundingClientRect();
- tabHighlightEl.style.width = rect.width + 'px';
- tabHighlightEl.style.left = (rect.left - parentRect.left) + 'px';
- }
+ watch(() => [props.tab, props.tabs], () => {
+ nextTick(() => {
+ const tabEl = tabRefs[props.tab];
+ if (tabEl && tabHighlightEl) {
+ // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある
+ // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4
+ const parentRect = tabEl.parentElement.getBoundingClientRect();
+ const rect = tabEl.getBoundingClientRect();
+ tabHighlightEl.style.width = rect.width + 'px';
+ tabHighlightEl.style.left = (rect.left - parentRect.left) + 'px';
+ }
+ });
}, {
immediate: true,
});
diff --git a/packages/client/src/pages/admin/_header_.vue b/packages/client/src/pages/admin/_header_.vue
index 1883b4abee..1c3cdcb51f 100644
--- a/packages/client/src/pages/admin/_header_.vue
+++ b/packages/client/src/pages/admin/_header_.vue
@@ -28,7 +28,7 @@
</template>
<script lang="ts" setup>
-import { computed, onMounted, onUnmounted, ref, inject, watch } from 'vue';
+import { computed, onMounted, onUnmounted, ref, inject, watch, nextTick } from 'vue';
import tinycolor from 'tinycolor2';
import { popupMenu } from '@/os';
import { url } from '@/config';
@@ -126,16 +126,18 @@ onMounted(() => {
calcBg();
globalEvents.on('themeChanged', calcBg);
- watch(() => props.tab, () => {
- const tabEl = tabRefs[props.tab];
- if (tabEl && tabHighlightEl) {
- // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある
- // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4
- const parentRect = tabEl.parentElement.getBoundingClientRect();
- const rect = tabEl.getBoundingClientRect();
- tabHighlightEl.style.width = rect.width + 'px';
- tabHighlightEl.style.left = (rect.left - parentRect.left) + 'px';
- }
+ watch(() => [props.tab, props.tabs], () => {
+ nextTick(() => {
+ const tabEl = tabRefs[props.tab];
+ if (tabEl && tabHighlightEl) {
+ // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある
+ // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4
+ const parentRect = tabEl.parentElement.getBoundingClientRect();
+ const rect = tabEl.getBoundingClientRect();
+ tabHighlightEl.style.width = rect.width + 'px';
+ tabHighlightEl.style.left = (rect.left - parentRect.left) + 'px';
+ }
+ });
}, {
immediate: true,
});
diff --git a/packages/client/src/pages/user/followers.vue b/packages/client/src/pages/user/followers.vue
new file mode 100644
index 0000000000..3feec15e4a
--- /dev/null
+++ b/packages/client/src/pages/user/followers.vue
@@ -0,0 +1,62 @@
+<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="fetch()"/>
+ <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: 'fas fa-user',
+ title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`,
+ subtitle: i18n.ts.followers,
+ userName: user,
+ avatar: user,
+ bg: 'var(--bg)',
+} : null));
+</script>
+
+<style lang="scss" scoped>
+</style>
diff --git a/packages/client/src/pages/user/following.vue b/packages/client/src/pages/user/following.vue
new file mode 100644
index 0000000000..0c6bb1c9f4
--- /dev/null
+++ b/packages/client/src/pages/user/following.vue
@@ -0,0 +1,62 @@
+<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="fetch()"/>
+ <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: 'fas fa-user',
+ title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`,
+ subtitle: i18n.ts.following,
+ userName: user,
+ avatar: user,
+ bg: 'var(--bg)',
+} : null));
+</script>
+
+<style lang="scss" scoped>
+</style>
diff --git a/packages/client/src/pages/user/home.vue b/packages/client/src/pages/user/home.vue
new file mode 100644
index 0000000000..f7c25f077c
--- /dev/null
+++ b/packages/client/src/pages/user/home.vue
@@ -0,0 +1,478 @@
+<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="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSuspended }}</div> -->
+ <!-- <div class="punished" v-if="user.isSilenced"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $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="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span>
+ <span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span>
+ <span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span>
+ <span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-robot"></i></span>
+ </div>
+ </div>
+ <span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ $ts.followsYou }}</span>
+ <div v-if="$i" class="actions">
+ <button class="menu _button" @click="menu"><i class="fas fa-ellipsis-h"></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="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span>
+ <span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span>
+ <span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span>
+ <span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-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">{{ $ts.noAccountDescription }}</p>
+ </div>
+ <div class="fields system">
+ <dl v-if="user.location" class="field">
+ <dt class="name"><i class="fas fa-map-marker fa-fw"></i> {{ $ts.location }}</dt>
+ <dd class="value">{{ user.location }}</dd>
+ </dl>
+ <dl v-if="user.birthday" class="field">
+ <dt class="name"><i class="fas fa-birthday-cake fa-fw"></i> {{ $ts.birthday }}</dt>
+ <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd>
+ </dl>
+ <dl class="field">
+ <dt class="name"><i class="fas fa-calendar-alt fa-fw"></i> {{ $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>{{ $ts.notes }}</span>
+ </MkA>
+ <MkA v-click-anime :to="userPage(user, 'following')" :class="{ active: page === 'following' }">
+ <b>{{ number(user.followingCount) }}</b>
+ <span>{{ $ts.following }}</span>
+ </MkA>
+ <MkA v-click-anime :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }">
+ <b>{{ number(user.followersCount) }}</b>
+ <span>{{ $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">{{ $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/note.vue';
+import MkFollowButton from '@/components/follow-button.vue';
+import MkContainer from '@/components/ui/container.vue';
+import MkFolder from '@/components/ui/folder.vue';
+import MkRemoteCaution from '@/components/remote-caution.vue';
+import MkTab from '@/components/tab.vue';
+import MkInfo from '@/components/ui/info.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), 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);
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/user/index.vue b/packages/client/src/pages/user/index.vue
index 7b2e2cde1a..bd1bb11a5b 100644
--- a/packages/client/src/pages/user/index.vue
+++ b/packages/client/src/pages/user/index.vue
@@ -1,124 +1,15 @@
<template>
<MkStickyContainer>
- <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
- <div ref="rootEl">
+ <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+ <div>
<transition name="fade" mode="out-in">
- <MkSpacer v-if="user" :content-max="narrow ? 800 : 1100">
- <div v-size="{ max: [500] }" class="ftskorzw" :class="{ wide: !narrow }">
- <div class="main">
- <!-- TODO -->
- <!-- <div class="punished" v-if="user.isSuspended"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSuspended }}</div> -->
- <!-- <div class="punished" v-if="user.isSilenced"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $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="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span>
- <span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span>
- <span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span>
- <span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-robot"></i></span>
- </div>
- </div>
- <span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ $ts.followsYou }}</span>
- <div v-if="$i" class="actions">
- <button class="menu _button" @click="menu"><i class="fas fa-ellipsis-h"></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="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span>
- <span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span>
- <span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span>
- <span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-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">{{ $ts.noAccountDescription }}</p>
- </div>
- <div class="fields system">
- <dl v-if="user.location" class="field">
- <dt class="name"><i class="fas fa-map-marker fa-fw"></i> {{ $ts.location }}</dt>
- <dd class="value">{{ user.location }}</dd>
- </dl>
- <dl v-if="user.birthday" class="field">
- <dt class="name"><i class="fas fa-birthday-cake fa-fw"></i> {{ $ts.birthday }}</dt>
- <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd>
- </dl>
- <dl class="field">
- <dt class="name"><i class="fas fa-calendar-alt fa-fw"></i> {{ $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>{{ $ts.notes }}</span>
- </MkA>
- <MkA v-click-anime :to="userPage(user, 'following')" :class="{ active: page === 'following' }">
- <b>{{ number(user.followingCount) }}</b>
- <span>{{ $ts.following }}</span>
- </MkA>
- <MkA v-click-anime :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }">
- <b>{{ number(user.followersCount) }}</b>
- <span>{{ $ts.followers }}</span>
- </MkA>
- </div>
- </div>
- </div>
-
- <div class="contents">
- <template v-if="page === 'index'">
- <div>
- <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">{{ $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>
- </template>
- <XFollowList v-else-if="page === 'following'" type="following" :user="user" class="_content _gap"/>
- <XFollowList v-else-if="page === 'followers'" type="followers" :user="user" class="_content _gap"/>
- <XReactions v-else-if="page === 'reactions'" :user="user" class="_gap"/>
- <XClips v-else-if="page === 'clips'" :user="user" class="_gap"/>
- <XPages v-else-if="page === 'pages'" :user="user" class="_gap"/>
- <XGallery v-else-if="page === 'gallery'" :user="user" class="_gap"/>
- </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>
+ <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="fetch()"/>
<MkLoading v-else/>
</transition>
@@ -131,14 +22,6 @@ import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch }
import calcAge from 's-age';
import * as Acct from 'misskey-js/built/acct';
import * as misskey from 'misskey-js';
-import XUserTimeline from './index.timeline.vue';
-import XNote from '@/components/note.vue';
-import MkFollowButton from '@/components/follow-button.vue';
-import MkContainer from '@/components/ui/container.vue';
-import MkFolder from '@/components/ui/folder.vue';
-import MkRemoteCaution from '@/components/remote-caution.vue';
-import MkTab from '@/components/tab.vue';
-import MkInfo from '@/components/ui/info.vue';
import { getScrollPosition } from '@/scripts/scroll';
import { getUserMenu } from '@/scripts/get-user-menu';
import number from '@/filters/number';
@@ -149,41 +32,24 @@ import { definePageMetadata } from '@/scripts/page-metadata';
import { i18n } from '@/i18n';
import { $i } from '@/account';
-const XFollowList = defineAsyncComponent(() => import('./follow-list.vue'));
+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 XPhotos = defineAsyncComponent(() => import('./index.photos.vue'));
-const XActivity = defineAsyncComponent(() => import('./index.activity.vue'));
const props = withDefaults(defineProps<{
acct: string;
page?: string;
}>(), {
- page: 'index',
+ page: 'home',
});
const router = useRouter();
+let tab = $ref(props.page);
let user = $ref<null | misskey.entities.UserDetailed>(null);
let error = $ref(null);
-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 (user?.bannerUrl == null) return {};
- return {
- backgroundImage: `url(${ user.bannerUrl })`,
- };
-});
-
-const age = $computed(() => {
- if (user == null) return null;
- return calcAge(user.birthday);
-});
function fetchUser(): void {
if (props.acct == null) return;
@@ -203,62 +69,28 @@ function menu(ev) {
os.popupMenu(getUserMenu(user), 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);
- }
-});
-
const headerActions = $computed(() => []);
const headerTabs = $computed(() => user ? [{
- active: props.page === 'index',
+ key: 'home',
title: i18n.ts.overview,
icon: 'fas fa-home',
- onClick: () => { router.push('/@' + getAcct(user)); },
}, ...($i && ($i.id === user.id)) || user.publicReactions ? [{
- active: props.page === 'reactions',
+ key: 'reactions',
title: i18n.ts.reaction,
icon: 'fas fa-laugh',
- onClick: () => { router.push('/@' + getAcct(user) + '/reactions'); },
}] : [], {
- active: props.page === 'clips',
+ key: 'clips',
title: i18n.ts.clips,
icon: 'fas fa-paperclip',
- onClick: () => { router.push('/@' + getAcct(user) + '/clips'); },
}, {
- active: props.page === 'pages',
+ key: 'pages',
title: i18n.ts.pages,
icon: 'fas fa-file-alt',
- onClick: () => { router.push('/@' + getAcct(user) + '/pages'); },
}, {
- active: props.page === 'gallery',
+ key: 'gallery',
title: i18n.ts.gallery,
icon: 'fas fa-icons',
- onClick: () => { router.push('/@' + getAcct(user) + '/gallery'); },
}] : null);
definePageMetadata(computed(() => user ? {
@@ -284,291 +116,4 @@ definePageMetadata(computed(() => user ? {
.fade-leave-to {
opacity: 0;
}
-
-.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);
- }
- }
-}
</style>
diff --git a/packages/client/src/router.ts b/packages/client/src/router.ts
index 1197976d5e..828708309c 100644
--- a/packages/client/src/router.ts
+++ b/packages/client/src/router.ts
@@ -12,16 +12,22 @@ const page = (loader: AsyncComponentLoader<any>) => defineAsyncComponent({
});
export const routes = [{
- name: 'user',
- path: '/@:acct/:page?',
- component: page(() => import('./pages/user/index.vue')),
-}, {
path: '/@:initUser/pages/:initPageName/view-source',
component: page(() => import('./pages/page-editor/page-editor.vue')),
}, {
path: '/@:username/pages/:pageName',
component: page(() => import('./pages/page.vue')),
}, {
+ path: '/@:acct/following',
+ component: page(() => import('./pages/user/following.vue')),
+}, {
+ path: '/@:acct/followers',
+ component: page(() => import('./pages/user/followers.vue')),
+}, {
+ name: 'user',
+ path: '/@:acct/:page?',
+ component: page(() => import('./pages/user/index.vue')),
+}, {
name: 'note',
path: '/notes/:noteId',
component: page(() => import('./pages/note.vue')),