Rewind renders most of the data

This commit is contained in:
2025-12-27 02:22:19 +08:00
parent df44c4525e
commit 9d6eb5c378
8 changed files with 695 additions and 327 deletions

4
app/components.d.ts vendored
View File

@@ -42,6 +42,7 @@ declare module 'vue' {
NMessageProvider: typeof import('naive-ui')['NMessageProvider'] NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal'] NModal: typeof import('naive-ui')['NModal']
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider'] NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
NNumberAnimation: typeof import('naive-ui')['NNumberAnimation']
NPopover: typeof import('naive-ui')['NPopover'] NPopover: typeof import('naive-ui')['NPopover']
NProgress: typeof import('naive-ui')['NProgress'] NProgress: typeof import('naive-ui')['NProgress']
NRadio: typeof import('naive-ui')['NRadio'] NRadio: typeof import('naive-ui')['NRadio']
@@ -50,6 +51,7 @@ declare module 'vue' {
NSelect: typeof import('naive-ui')['NSelect'] NSelect: typeof import('naive-ui')['NSelect']
NSpace: typeof import('naive-ui')['NSpace'] NSpace: typeof import('naive-ui')['NSpace']
NSpin: typeof import('naive-ui')['NSpin'] NSpin: typeof import('naive-ui')['NSpin']
NStatistic: typeof import('naive-ui')['NStatistic']
NTab: typeof import('naive-ui')['NTab'] NTab: typeof import('naive-ui')['NTab']
NTabs: typeof import('naive-ui')['NTabs'] NTabs: typeof import('naive-ui')['NTabs']
NTag: typeof import('naive-ui')['NTag'] NTag: typeof import('naive-ui')['NTag']
@@ -91,6 +93,7 @@ declare global {
const NMessageProvider: typeof import('naive-ui')['NMessageProvider'] const NMessageProvider: typeof import('naive-ui')['NMessageProvider']
const NModal: typeof import('naive-ui')['NModal'] const NModal: typeof import('naive-ui')['NModal']
const NNotificationProvider: typeof import('naive-ui')['NNotificationProvider'] const NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
const NNumberAnimation: typeof import('naive-ui')['NNumberAnimation']
const NPopover: typeof import('naive-ui')['NPopover'] const NPopover: typeof import('naive-ui')['NPopover']
const NProgress: typeof import('naive-ui')['NProgress'] const NProgress: typeof import('naive-ui')['NProgress']
const NRadio: typeof import('naive-ui')['NRadio'] const NRadio: typeof import('naive-ui')['NRadio']
@@ -99,6 +102,7 @@ declare global {
const NSelect: typeof import('naive-ui')['NSelect'] const NSelect: typeof import('naive-ui')['NSelect']
const NSpace: typeof import('naive-ui')['NSpace'] const NSpace: typeof import('naive-ui')['NSpace']
const NSpin: typeof import('naive-ui')['NSpin'] const NSpin: typeof import('naive-ui')['NSpin']
const NStatistic: typeof import('naive-ui')['NStatistic']
const NTab: typeof import('naive-ui')['NTab'] const NTab: typeof import('naive-ui')['NTab']
const NTabs: typeof import('naive-ui')['NTabs'] const NTabs: typeof import('naive-ui')['NTabs']
const NTag: typeof import('naive-ui')['NTag'] const NTag: typeof import('naive-ui')['NTag']

View File

@@ -0,0 +1,97 @@
<template>
<div v-if="loading" class="flex justify-center items-center py-4">
<n-spin size="large" />
</div>
<div v-else-if="error" class="text-red-500 text-center py-4">
{{ error }}
</div>
<post-item
v-else-if="post"
:item="post"
:compact="compact"
:flat="flat"
:slim="slim"
:show-referenced="showReferenced"
@react="handleReaction"
/>
</template>
<script lang="ts" setup>
import { ref, watch, onMounted } from "vue"
import { useSolarNetwork } from "~/composables/useSolarNetwork"
import type { SnPost } from "~/types/api"
import PostItem from "./PostItem.vue"
import { keysToCamel } from "~/utils/transformKeys"
const props = withDefaults(
defineProps<{
postId: string
showReferenced?: boolean
compact?: boolean
flat?: boolean
slim?: boolean
}>(),
{ showReferenced: true, compact: false, flat: false, slim: false }
)
const emit = defineEmits<{
react: [symbol: string, attitude: number, delta: number]
loaded: [post: SnPost]
}>()
const api = useSolarNetwork()
const post = ref<SnPost | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const fetchPost = async () => {
if (!props.postId) {
error.value = "No post ID provided"
return
}
try {
loading.value = true
error.value = null
const response = await api<SnPost>(`/sphere/posts/${props.postId}`, {
method: "GET",
onResponse({ response }) {
if (response._data) {
response._data = keysToCamel(response._data)
}
}
})
post.value = response
emit("loaded", response)
} catch (err) {
console.error("Error fetching post:", err)
error.value = err instanceof Error ? err.message : "Failed to fetch post"
} finally {
loading.value = false
}
}
const handleReaction = (symbol: string, attitude: number, delta: number) => {
emit("react", symbol, attitude, delta)
}
// Watch for postId changes and refetch
watch(
() => props.postId,
(newId) => {
if (newId) {
fetchPost()
}
},
{ immediate: true }
)
// Initial fetch on mount
onMounted(() => {
if (props.postId) {
fetchPost()
}
})
</script>

View File

@@ -0,0 +1,41 @@
<template>
<n-card>
<div class="flex flex-col justify-center gap-4">
<img
v-if="userBackground"
:src="userBackground"
style="aspect-ratio: 16/7"
class="rounded-xl"
/>
<div class="flex items-center gap-4">
<n-avatar :src="userPicture" />
<div class="grow">
<div class="font-bold text-lg">{{ data.nick }}</div>
<div class="text-sm opacity-80">@{{ data.name }}</div>
</div>
<div><slot name="suffix" /></div>
</div>
</div>
</n-card>
</template>
<script setup lang="ts">
import type { SnAccount } from "~/types/api"
const props = defineProps<{ data: SnAccount }>()
const _ = defineSlots<{ suffix(): unknown }>()
const apiBase = useSolarNetworkUrl()
const userPicture = computed(() => {
return props.data.profile.picture
? `${apiBase}/drive/files/${props.data.profile.picture.id}`
: undefined
})
const userBackground = computed(() => {
return props.data.profile.background
? `${apiBase}/drive/files/${props.data.profile.background.id}`
: undefined
})
</script>

View File

@@ -0,0 +1,41 @@
<template>
<n-card>
<div class="flex flex-col justify-center gap-4">
<img
v-if="userBackground"
:src="userBackground"
style="aspect-ratio: 16/7"
class="rounded-xl"
/>
<div class="flex items-center gap-4">
<n-avatar :src="userPicture" />
<div class="grow">
<div class="font-bold text-lg">{{ data.nick }}</div>
<div class="text-sm opacity-80">@{{ data.name }}</div>
</div>
<div><slot name="suffix" /></div>
</div>
</div>
</n-card>
</template>
<script setup lang="ts">
import type { SnPublisher } from "~/types/api"
const props = defineProps<{ data: SnPublisher }>()
const _ = defineSlots<{ suffix(): unknown }>()
const apiBase = useSolarNetworkUrl()
const userPicture = computed(() => {
return props.data.picture
? `${apiBase}/drive/files/${props.data.picture.id}`
: undefined
})
const userBackground = computed(() => {
return props.data.background
? `${apiBase}/drive/files/${props.data.background.id}`
: undefined
})
</script>

View File

@@ -1,13 +1,19 @@
<template> <template>
<div class="py-6 px-5 min-h-screen"> <div class="py-6 px-5 min-h-screen">
<!-- Loading State --> <!-- Loading State -->
<div v-if="pending" class="text-center py-12 h-layout flex flex-col justify-center"> <div
v-if="pending"
class="text-center py-12 h-layout flex flex-col justify-center"
>
<n-spin size="large" /> <n-spin size="large" />
<p class="mt-4 text-lg">Loading your rewind data...</p> <p class="mt-4 text-lg">正在整理你的回顾数据</p>
</div> </div>
<!-- Error State --> <!-- Error State -->
<div v-else-if="error" class="text-center py-12 h-layout flex flex-col justify-center"> <div
v-else-if="error"
class="text-center py-12 h-layout flex flex-col justify-center max-w-2xl mx-auto"
>
<n-alert <n-alert
type="error" type="error"
title="Error Loading Rewind" title="Error Loading Rewind"
@@ -15,10 +21,12 @@
:closable="false" :closable="false"
> >
{{ {{
error instanceof Error ? error.message : "Failed to load rewind data" error instanceof Error
? error.message
: "看起来出了点问题,请稍后再试。"
}} }}
</n-alert> </n-alert>
<n-button @click="fetchRewindData">Try Again</n-button> <n-button @click="fetchRewindData">重试</n-button>
</div> </div>
<!-- Main Content --> <!-- Main Content -->
@@ -26,254 +34,372 @@
<!-- Header Section --> <!-- Header Section -->
<div class="text-center mb-8 h-layout flex flex-col justify-center"> <div class="text-center mb-8 h-layout flex flex-col justify-center">
<img :src="CloudyRewind" class="w-36 h-36 mx-auto" /> <img :src="CloudyRewind" class="w-36 h-36 mx-auto" />
<h1 class="text-4xl font-bold mb-1"> <h1 class="text-4xl font-bold mb-1">Solar Network 年度回顾</h1>
Solar Network Rewind <n-tooltip placement="bottom">
</h1> <template #trigger>
<p class="text-lg opacity-80 mb-3"> <div class="text-lg opacity-80">
Reliving your {{ rewindData.year }} on the Solar Network 回顾你的 {{ rewindData.year }} 年在 Solar Network 的精彩旅程
</p> </div>
<p class="text-sm opacity-60">Cooming soon!</p> </template>
数据范围 2024/12/26 - 2025/12/25
</n-tooltip>
</div> </div>
<!-- Scroll-based Sections --> <!-- Scroll-based Sections -->
<div class="space-y-0"> <div class="space-y-0">
<!-- Section 1: Pass Data --> <!-- Section 1: Pass Data -->
<div <div
ref="section1" ref="section1"
class="scroll-section min-h-screen flex items-center justify-center" class="scroll-section min-h-screen flex items-center justify-center"
:class="{ 'animate-in': inView1 }" :class="{ 'animate-in': inView1 }"
> >
<n-card class="w-full max-w-4xl bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm shadow-lg"> <n-card class="w-full max-w-4xl">
<template #header> <template #header>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<n-icon size="28" :component="ShieldIcon" class="text-blue-600" /> <n-icon
<h2 class="text-2xl font-bold">Pass Achievements</h2> size="28"
:component="CalendarDaysIcon"
class="text-blue-600"
/>
<h2 class="text-2xl font-bold">活动数据</h2>
</div> </div>
</template> </template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="text-center p-6 bg-blue-50 dark:bg-blue-900/20 rounded-xl"> <div class="flex flex-col gap-6">
<div class="text-4xl font-bold mb-2"> <n-statistic label="最长连续签到" tabular-nums>
{{ rewindData.data.pass.maxCheckInStrike }} <n-number-animation
:to="rewindData.data.pass.maxCheckInStreak"
/>
<template #suffix></template>
</n-statistic>
<n-statistic label="签到完成度" tabular-nums>
<n-number-animation
:to="rewindData.data.pass.checkInCompleteness * 100"
:precision="2"
/>
<template #suffix>%</template>
</n-statistic>
<n-statistic label="最晚活动时间">
{{ rewindData.data.pass.latestActiveTime }}
</n-statistic>
<div class="flex flex-row gap-8 flex-wrap">
<n-statistic label="最活跃的日期">
{{ rewindData.data.pass.mostActiveDay }}
</n-statistic>
<n-statistic label="最活跃的日子">
{{ rewindData.data.pass.mostActiveWeekday }}
</n-statistic>
</div> </div>
<div class="text-sm opacity-80"> </div>
Longest Check-in Streak
<div class="md:text-right pr-4 max-md:order-first">
<div class="text-5xl mb-3">🔥</div>
<div class="text-2xl font-bold mb-1">
{{ getStreakMessage(rewindData.data.pass.maxCheckInStreak) }}
</div>
<div class="text-lg opacity-80">
{{
getStreakDescription(rewindData.data.pass.maxCheckInStreak)
}}
</div> </div>
</div> </div>
</div> </div>
</n-card> </n-card>
</div> </div>
<!-- Section 2: Sphere Overview --> <!-- Section 2: Creator Career Overview -->
<div <div
ref="section2" ref="section2"
class="scroll-section min-h-screen flex items-center justify-center" class="scroll-section min-h-screen flex items-center justify-center"
:class="{ 'animate-in': inView2 }" :class="{ 'animate-in': inView2 }"
> >
<n-card class="w-full max-w-4xl bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm shadow-lg"> <n-card
class="w-full max-w-4xl bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm shadow-lg"
>
<template #header> <template #header>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<n-icon size="28" :component="GlobeIcon" class="text-green-600" /> <n-icon
<h2 class="text-2xl font-bold">Sphere Activity</h2> size="28"
:component="PencilLineIcon"
class="text-green-600"
/>
<h2 class="text-2xl font-bold">创作生涯</h2>
</div> </div>
</template> </template>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div class="text-center p-6 bg-green-50 dark:bg-green-900/20 rounded-xl"> <n-statistic label="总发帖数量" tabular-nums>
<div class="text-4xl font-bold mb-2"> <n-number-animation
{{ rewindData.data.sphere.totalCount }} :to="rewindData.data.sphere.totalPostCount"
</div> />
<div class="text-sm opacity-80"> <template #suffix></template>
Total Posts </n-statistic>
</div> <n-statistic label="总获顶数量" tabular-nums>
<n-number-animation
:to="rewindData.data.sphere.totalUpvoteCount"
/>
<template #suffix></template>
</n-statistic>
<n-statistic label="高产记录" tabular-nums>
<n-number-animation
:to="rewindData.data.sphere.mostProductiveDay.postCount"
/>
<template #suffix>
篇帖子于
{{
rewindData.data.sphere.mostProductiveDay.date
.split(" ")[0]
?.split("/")
.slice(0, 2)
.join("/")
}}
</template>
</n-statistic>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 class="font-bold mb-2 flex items-center gap-2">
<n-icon :component="PartyPopperIcon" size="16" />
最受欢迎的帖子
</h3>
<nuxt-link
:to="`/posts/${rewindData.data.sphere.mostPopularPost.id}`"
target="_blank"
>
<post-item-contained
:post-id="rewindData.data.sphere.mostPopularPost.id"
/>
</nuxt-link>
</div> </div>
<div class="text-center p-6 bg-purple-50 dark:bg-purple-900/20 rounded-xl"> <div>
<div class="text-4xl font-bold mb-2"> <h3 class="font-bold mb-2 flex items-center gap-2">
{{ rewindData.data.sphere.upvoteCounts }} <n-icon :component="HeartIcon" size="16" />
</div> 最喜欢你的观众
<div class="text-sm opacity-80"> </h3>
Upvotes Received <account-nameplate
</div> :data="rewindData.data.sphere.mostLovedAudience.account"
</div> >
<div class="text-center p-6 bg-orange-50 dark:bg-orange-900/20 rounded-xl"> <template #suffix>
<div class="text-4xl font-bold mb-2"> <n-config-provider
{{ rewindData.data.sphere.mostProductiveDay.postCount }} :theme-overrides="{
</div> Statistic: {
<div class="text-sm opacity-80"> valueFontSize: '1.3rem',
Posts on Best Day labelFontSize: '0.8rem'
</div> }
<div class="text-xs opacity-60 mt-1"> }"
{{ formatDate(rewindData.data.sphere.mostProductiveDay.date) }} >
</div> <n-statistic label="贡献的顶数量" tabular-nums>
<n-number-animation
:to="
rewindData.data.sphere.mostLovedAudience
.upvoteCounts
"
/>
<template #suffix></template>
</n-statistic>
</n-config-provider>
</template>
</account-nameplate>
</div> </div>
</div> </div>
</n-card> </n-card>
</div> </div>
<!-- Section 3: Most Called Chat --> <!-- Section 3: Explore History -->
<div <div
ref="section3" ref="section3"
class="scroll-section min-h-screen flex items-center justify-center" class="scroll-section min-h-screen flex items-center justify-center"
:class="{ 'animate-in': inView3 }" :class="{ 'animate-in': inView3 }"
> >
<n-card class="w-full max-w-4xl bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm shadow-lg"> <n-card
class="w-full max-w-4xl bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm shadow-lg"
>
<template #header> <template #header>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<n-icon size="28" :component="MessageCircleIcon" class="text-indigo-600" /> <n-icon
<h2 class="text-2xl font-bold">Favorite Chat Room</h2> size="28"
:component="GlobeIcon"
class="text-indigo-600"
/>
<h2 class="text-2xl font-bold">探索历史</h2>
</div> </div>
</template> </template>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="p-6 bg-indigo-50 dark:bg-indigo-900/20 rounded-xl"> <div>
<div class="flex items-center gap-4 mb-4"> <h3 class="font-bold mb-2 flex items-center gap-2">
<n-avatar <n-icon :component="HeartIcon" size="16" />
round 你最喜欢的创作者
:size="48" </h3>
:src="getChatAvatar(rewindData.data.sphere.mostCalledChat)" <nuxt-link
/> :to="`/publishers/${rewindData.data.sphere.mostLovedPublisher.publisher.id}`"
<div> target="_blank"
<h3 class="text-xl font-semibold"> >
{{ rewindData.data.sphere.mostCalledChat.name }} <publisher-nameplate
</h3> :data="rewindData.data.sphere.mostLovedPublisher.publisher"
<p class="text-sm opacity-80"> >
Most visited chat room <template #suffix>
</p> <n-config-provider
</div> :theme-overrides="{
</div> Statistic: {
<p class="text-slate-700 dark:text-slate-300"> valueFontSize: '1.3rem',
{{ rewindData.data.sphere.mostCalledChat.description }} labelFontSize: '0.8rem'
</p> }
}"
>
<n-statistic label="给予的顶数" tabular-nums>
<n-number-animation
:to="
rewindData.data.sphere.mostLovedPublisher
.upvoteCounts
"
/>
<template #suffix></template>
</n-statistic>
</n-config-provider>
</template>
</publisher-nameplate>
</nuxt-link>
</div> </div>
<div class="p-6 bg-cyan-50 dark:bg-cyan-900/20 rounded-xl"> <div class="text-right flex flex-col justify-center px-5 gap-2">
<div class="text-center"> <div class="text-4xl">🤔</div>
<div class="text-3xl font-bold mb-2"> <p class="text-lg">
Most Popular Post 看起来你真的喜欢他/她呢 (´) <br />
</div> 新的一年不妨试试探索更多优秀创作者吧
<div class="text-lg font-medium"> </p>
{{ rewindData.data.sphere.mostPopularPost.title }} <p class="text-xs opacity-80">
</div> <del>绝对不是因为没有别的东西放在这里所以写一些废话</del>
<div class="text-sm opacity-80 mt-2"> </p>
{{ rewindData.data.sphere.mostPopularPost.upvotes }} upvotes
{{ rewindData.data.sphere.mostPopularPost.viewsTotal }} views
</div>
</div>
</div> </div>
</div> </div>
</n-card> </n-card>
</div> </div>
<!-- Section 4: Most Called Accounts --> <!-- Section 4: Chat Summary -->
<div <div
ref="section4" ref="section4"
class="scroll-section min-h-screen flex items-center justify-center" class="scroll-section min-h-screen flex items-center justify-center"
:class="{ 'animate-in': inView4 }" :class="{ 'animate-in': inView4 }"
> >
<n-card class="w-full max-w-4xl bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm shadow-lg"> <n-card
class="w-full max-w-4xl bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm shadow-lg"
>
<template #header> <template #header>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<n-icon size="28" :component="UsersIcon" class="text-teal-600" /> <n-icon
<h2 class="text-2xl font-bold">Top Connections</h2> size="28"
:component="MessageCircleIcon"
class="text-teal-600"
/>
<h2 class="text-2xl font-bold">社交经历</h2>
</div> </div>
</template> </template>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div <div class="flex flex-col gap-6 mt-2">
v-for="(account, index) in rewindData.data.sphere.mostCalledAccounts" <div>
:key="account.id" <h3 class="font-bold mb-2 flex items-center gap-2">
class="p-6 bg-teal-50 dark:bg-teal-900/20 rounded-xl" <n-icon :component="HeartIcon" size="16" />
> 最常出没的聊天室
<div class="flex items-center gap-4"> </h3>
<n-avatar <n-card size="small">
round <div class="flex items-center gap-4">
:size="48" <n-avatar
:src="getAccountAvatar(account)" :src="
/> getChatRoomAvatar(
<div> rewindData.data.sphere.mostCalledChat.chat
<h3 class="text-lg font-semibold">{{ account.nick }}</h3> )
<p class="text-sm opacity-80"> "
@{{ account.name }} >{{
</p> rewindData.data.sphere.mostMessagedChat.chat.name?.substring(
<p class="text-xs opacity-60 mt-1"> 0,
Rank #{{ index + 1 }} 1
</p> )
</div> }}</n-avatar
</div> >
</div> <div class="grow flex flex-col">
</div> <div class="text-md font-bold">
</n-card> {{
</div> rewindData.data.sphere.mostMessagedChat.chat.name
}}
<!-- Section 5: Most Loved Publisher --> </div>
<div <p>
ref="section5" <n-number-animation
class="scroll-section min-h-screen flex items-center justify-center" :to="
:class="{ 'animate-in': inView5 }" rewindData.data.sphere.mostMessagedChat
> .messageCounts
<n-card class="w-full max-w-4xl bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm shadow-lg"> "
<template #header> />
<div class="flex items-center gap-3"> 条消息
<n-icon size="28" :component="HeartIcon" class="text-pink-600" /> </p>
<h2 class="text-2xl font-bold">Most Loved Publisher</h2> </div>
</div>
</template>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="p-6 bg-pink-50 dark:bg-pink-900/20 rounded-xl">
<div class="flex items-center gap-4 mb-4">
<n-avatar
round
:size="64"
:src="getPublisherAvatar(rewindData.data.sphere.mostLovedPublisher)"
/>
<div>
<h3 class="text-2xl font-bold">
{{ rewindData.data.sphere.mostLovedPublisher.publisher.nick }}
</h3>
<p class="text-sm opacity-80">
@{{ rewindData.data.sphere.mostLovedPublisher.publisher.name }}
</p>
<div class="flex gap-2 mt-2">
<n-tag type="primary" size="small">
{{ rewindData.data.sphere.mostLovedPublisher.publisher.level }} Level
</n-tag>
<n-tag type="success" size="small">
{{ rewindData.data.sphere.mostLovedPublisher.upvoteCounts }} Upvotes
</n-tag>
</div> </div>
</div> </n-card>
</div>
<div>
<h3 class="font-bold mb-2 flex items-center gap-2">
<n-icon :component="MessageCircleHeartIcon" size="16" />
最常联系的人
</h3>
<n-card size="small">
<div class="flex items-center gap-4">
<n-avatar
object-fit="cover"
:src="getChatMemberAvatar(rewindData.data.sphere.mostMessagedDirectChat.chat.members[0]!)"
/>
<div class="grow flex flex-col">
<div class="text-md font-bold">
{{
rewindData.data.sphere.mostMessagedDirectChat.chat
.members[0]!.account.name || "Direct Message"
}}
</div>
<p>
<n-number-animation
:to="
rewindData.data.sphere.mostMessagedDirectChat
.messageCounts
"
/>
条消息
</p>
</div>
</div>
</n-card>
</div> </div>
<p class="text-slate-700 dark:text-slate-300">
{{ rewindData.data.sphere.mostLovedPublisher.publisher.bio }}
</p>
</div> </div>
<div class="p-6 bg-amber-50 dark:bg-amber-900/20 rounded-xl"> <div class="text-right flex flex-col justify-center px-5 gap-2">
<div class="text-center"> <div class="text-4xl">💬</div>
<div class="text-3xl font-bold mb-2"> <p class="text-lg">
Most Messaged Chat 一眼丁真鉴定为 <br/>
</div> <b>纯纯的话唠</b>
<div class="text-lg font-medium"> </p>
{{ rewindData.data.sphere.mostMessagedChat.name || 'Direct Message' }}
</div>
<p class="text-sm opacity-80 mt-2">
Your most active conversation
</p>
</div>
</div> </div>
</div> </div>
</n-card> </n-card>
</div> </div>
<!-- Section 6: Summary --> <!-- Section 6: Summary -->
<div <div
ref="section6" ref="section6"
class="scroll-section min-h-screen flex items-center justify-center" class="scroll-section min-h-screen flex items-center justify-center"
:class="{ 'animate-in': inView6 }" :class="{ 'animate-in': inView6 }"
> >
<n-card class="w-full max-w-4xl bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm shadow-lg"> <n-card
class="w-full max-w-4xl bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm shadow-lg"
>
<template #header> <template #header>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<n-icon size="28" :component="StarIcon" class="text-yellow-600" /> <n-icon
size="28"
:component="StarIcon"
class="text-yellow-600"
/>
<h2 class="text-2xl font-bold"> <h2 class="text-2xl font-bold">
Your {{ rewindData.year }} Summary Your {{ rewindData.year }} Summary
</h2> </h2>
@@ -287,19 +413,30 @@
<div class="space-y-3"> <div class="space-y-3">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<span class="opacity-80">Total Posts</span> <span class="opacity-80">Total Posts</span>
<span class="font-bold">{{ rewindData.data.sphere.totalCount }}</span> <span class="font-bold">{{
rewindData.data.sphere.totalPostCount
}}</span>
</div> </div>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<span class="opacity-80">Upvotes Received</span> <span class="opacity-80">Upvotes Received</span>
<span class="font-bold">{{ rewindData.data.sphere.upvoteCounts }}</span> <span class="font-bold">{{
rewindData.data.sphere.totalUpvoteCount
}}</span>
</div> </div>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<span class="opacity-80">Longest Streak</span> <span class="opacity-80">Longest Streak</span>
<span class="font-bold">{{ rewindData.data.pass.maxCheckInStrike }} days</span> <span class="font-bold"
>{{ rewindData.data.pass.maxCheckInStreak }} days</span
>
</div> </div>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<span class="opacity-80">Best Day</span> <span class="opacity-80">Best Day</span>
<span class="font-bold">{{ rewindData.data.sphere.mostProductiveDay.postCount }} posts</span> <span class="font-bold"
>{{
rewindData.data.sphere.mostProductiveDay.postCount
}}
posts</span
>
</div> </div>
</div> </div>
</div> </div>
@@ -309,15 +446,20 @@
<div class="space-y-2"> <div class="space-y-2">
<p class="text-sm"> <p class="text-sm">
<span class="font-medium opacity-80">Favorite Chat:</span> <span class="font-medium opacity-80">Favorite Chat:</span>
{{ rewindData.data.sphere.mostCalledChat.name }}
</p> </p>
<p class="text-sm"> <p class="text-sm">
<span class="font-medium opacity-80">Top Connection:</span> <span class="font-medium opacity-80"
>Top Connection:</span
>
{{ rewindData.data.sphere.mostCalledAccounts[0]?.nick }} {{ rewindData.data.sphere.mostCalledAccounts[0]?.nick }}
</p> </p>
<p class="text-sm"> <p class="text-sm">
<span class="font-medium opacity-80">Loved Publisher:</span> <span class="font-medium opacity-80"
{{ rewindData.data.sphere.mostLovedPublisher.publisher.nick }} >Loved Publisher:</span
>
{{
rewindData.data.sphere.mostLovedPublisher.publisher.nick
}}
</p> </p>
</div> </div>
</div> </div>
@@ -327,11 +469,16 @@
<div class="p-6 bg-indigo-50 dark:bg-indigo-900/20 rounded-xl"> <div class="p-6 bg-indigo-50 dark:bg-indigo-900/20 rounded-xl">
<h3 class="text-xl font-bold mb-4">Share Your Year</h3> <h3 class="text-xl font-bold mb-4">Share Your Year</h3>
<p class="opacity-80 mb-4"> <p class="opacity-80 mb-4">
Capture this moment and share your Solar Network journey with friends! Capture this moment and share your Solar Network journey
with friends!
</p> </p>
<div class="flex gap-3"> <div class="flex gap-3">
<n-button type="primary" size="large" @click="downloadSummary"> <n-button
type="primary"
size="large"
@click="downloadSummary"
>
<template #icon> <template #icon>
<n-icon :component="DownloadIcon" /> <n-icon :component="DownloadIcon" />
</template> </template>
@@ -346,10 +493,13 @@
</div> </div>
</div> </div>
<div class="p-6 bg-emerald-50 dark:bg-emerald-900/20 rounded-xl"> <div
class="p-6 bg-emerald-50 dark:bg-emerald-900/20 rounded-xl"
>
<h3 class="text-xl font-bold mb-4">What's Next?</h3> <h3 class="text-xl font-bold mb-4">What's Next?</h3>
<p class="opacity-80"> <p class="opacity-80">
Keep creating, connecting, and exploring. Your 2026 rewind will be even more amazing! Keep creating, connecting, and exploring. Your 2026 rewind
will be even more amazing!
</p> </p>
</div> </div>
</div> </div>
@@ -365,23 +515,25 @@
import { import {
GlobeIcon, GlobeIcon,
MessageCircleIcon, MessageCircleIcon,
UsersIcon, MessageCircleHeartIcon,
HeartIcon, HeartIcon,
StarIcon, StarIcon,
ShieldIcon, CalendarDaysIcon,
PencilLineIcon,
DownloadIcon, DownloadIcon,
ShareIcon ShareIcon,
PartyPopperIcon
} from "lucide-vue-next" } from "lucide-vue-next"
import { ref, onMounted, onUnmounted } from "vue" import { ref, onMounted, onUnmounted } from "vue"
import { DateTime } from "luxon" import { DateTime } from "luxon"
import type { import type {
SnRewind, SnRewind,
SnRewindMostCalledChat, SnRewindMostCalledChat,
SnRewindMostCalledAccount, SnRewindChat,
SnRewindMostLovedPublisher SnRewindChatMember
} from "~/types/api" } from "~/types/api"
import CloudyRewind from "~/assets/images/cloudy-lamb-rewind.png"; import CloudyRewind from "~/assets/images/cloudy-lamb-rewind.png"
const api = useSolarNetwork() const api = useSolarNetwork()
@@ -425,16 +577,19 @@ const fetchRewindData = async () => {
const setupScrollAnimations = () => { const setupScrollAnimations = () => {
const options = { const options = {
threshold: 0.1, threshold: 0.1,
rootMargin: '0px 0px -50px 0px' rootMargin: "0px 0px -50px 0px"
} }
const createObserver = (element: HTMLElement, inViewRef: { value: boolean }) => { const createObserver = (
element: HTMLElement,
inViewRef: { value: boolean }
) => {
const observer = new IntersectionObserver((entries) => { const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => { entries.forEach((entry) => {
inViewRef.value = entry.isIntersecting inViewRef.value = entry.isIntersecting
}) })
}, options) }, options)
if (element) { if (element) {
observer.observe(element) observer.observe(element)
observers.push(observer) observers.push(observer)
@@ -456,7 +611,7 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
observers.forEach(observer => observer.disconnect()) observers.forEach((observer) => observer.disconnect())
}) })
// Helper methods // Helper methods
@@ -470,17 +625,17 @@ const getChatAvatar = (chat: SnRewindMostCalledChat) => {
return "/api/placeholder/48/48" return "/api/placeholder/48/48"
} }
const getAccountAvatar = (account: SnRewindMostCalledAccount) => { const getChatRoomAvatar = (item: SnRewindChat) => {
const apiBase = useSolarNetworkUrl() const apiBase = useSolarNetworkUrl()
return account.profile?.picture return item.picture
? `${apiBase}/drive/files/${account.profile.picture.id}` ? `${apiBase}/drive/files/${item.picture.id}`
: "/api/placeholder/48/48" : "/api/placeholder/48/48"
} }
const getPublisherAvatar = (publisher: SnRewindMostLovedPublisher) => { const getChatMemberAvatar = (member: SnRewindChatMember) => {
const apiBase = useSolarNetworkUrl() const apiBase = useSolarNetworkUrl()
return publisher.publisher?.picture return member.account?.profile?.picture
? `${apiBase}/drive/files/${publisher.publisher.picture.id}` ? `${apiBase}/drive/files/${member.account.profile.picture.id}`
: "/api/placeholder/64/64" : "/api/placeholder/64/64"
} }
@@ -489,22 +644,6 @@ const downloadSummary = () => {
// Create a simple text summary for download // Create a simple text summary for download
const summary = ` const summary = `
Solar Network Rewind ${rewindData.value?.year} Solar Network Rewind ${rewindData.value?.year}
Your Journey:
- Total Posts: ${rewindData.value?.data.sphere.totalCount}
- Upvotes Received: ${rewindData.value?.data.sphere.upvoteCounts}
- Longest Check-in Streak: ${rewindData.value?.data.pass.maxCheckInStrike} days
- Best Day: ${
rewindData.value?.data.sphere.mostProductiveDay.postCount
} posts on ${formatDate(rewindData.value?.data.sphere.mostProductiveDay.date ?? 'none')}
Highlights:
- Favorite Chat: ${rewindData.value?.data.sphere.mostCalledChat.name}
- Top Connection: ${rewindData.value?.data.sphere.mostCalledAccounts[0]?.nick}
- Loved Publisher: ${
rewindData.value?.data.sphere.mostLovedPublisher.publisher.nick
}
Generated on: ${new Date().toLocaleDateString()} Generated on: ${new Date().toLocaleDateString()}
` `
@@ -520,7 +659,7 @@ Generated on: ${new Date().toLocaleDateString()}
} }
const shareOnSocial = () => { const shareOnSocial = () => {
const text = `Just checked out my Solar Network Rewind ${rewindData.value?.year}! 🚀\n\nI made ${rewindData.value?.data.sphere.totalCount} posts and got ${rewindData.value?.data.sphere.upvoteCounts} upvotes. What was your highlight of the year?` const text = `Just checked out my Solar Network Rewind ${rewindData.value?.year}! 🚀\n\nI made ${rewindData.value?.data.sphere.totalPostCount} posts and got ${rewindData.value?.data.sphere.totalUpvoteCount} upvotes. What was your highlight of the year?`
if (navigator.share) { if (navigator.share) {
navigator.share({ navigator.share({
@@ -535,8 +674,45 @@ const shareOnSocial = () => {
} }
} }
// Helper methods for streak messages
const getStreakMessage = (streak: number): string => {
if (streak >= 365) {
return "年度签到王"
} else if (streak >= 300) {
return "签到狂人"
} else if (streak >= 200) {
return "签到达人"
} else if (streak >= 100) {
return "签到高手"
} else if (streak >= 50) {
return "签到积极分子"
} else if (streak >= 20) {
return "签到新手"
} else {
return "继续签到"
}
}
const getStreakDescription = (streak: number): string => {
if (streak >= 365) {
return `连续签到 ${streak} 天,你就是 Solar Network 的签到传奇`
} else if (streak >= 300) {
return `连续签到 ${streak} 天,你的坚持让人佩服`
} else if (streak >= 200) {
return `连续签到 ${streak} 天,签到已经成为你的习惯`
} else if (streak >= 100) {
return `连续签到 ${streak} 天,你真的很用心在使用 Solar Network`
} else if (streak >= 50) {
return `连续签到 ${streak} 天,继续保持这个好习惯`
} else if (streak >= 20) {
return `连续签到 ${streak} 天,开始养成好习惯`
} else {
return `连续签到 ${streak} 天,每天签到让生活更有仪式感`
}
}
useHead({ useHead({
title: "Your Solar Network Rewind", title: "Solar Network Rewind 2025",
meta: [ meta: [
{ {
name: "description", name: "description",
@@ -559,10 +735,22 @@ useHead({
} }
/* Staggered animation delays */ /* Staggered animation delays */
.scroll-section:nth-child(1).animate-in { transition-delay: 0.1s; } .scroll-section:nth-child(1).animate-in {
.scroll-section:nth-child(2).animate-in { transition-delay: 0.2s; } transition-delay: 0.1s;
.scroll-section:nth-child(3).animate-in { transition-delay: 0.3s; } }
.scroll-section:nth-child(4).animate-in { transition-delay: 0.4s; } .scroll-section:nth-child(2).animate-in {
.scroll-section:nth-child(5).animate-in { transition-delay: 0.5s; } transition-delay: 0.2s;
.scroll-section:nth-child(6).animate-in { transition-delay: 0.6s; } }
.scroll-section:nth-child(3).animate-in {
transition-delay: 0.3s;
}
.scroll-section:nth-child(4).animate-in {
transition-delay: 0.4s;
}
.scroll-section:nth-child(5).animate-in {
transition-delay: 0.5s;
}
.scroll-section:nth-child(6).animate-in {
transition-delay: 0.6s;
}
</style> </style>

View File

@@ -1,125 +1,122 @@
// Rewind data interfaces // Rewind data interfaces
import type { SnCloudFile } from './post' import type { SnCloudFile } from "./post"
import type { SnPublisher } from "./publisher"
import type { SnAccount } from "./user"
export interface SnRewindPassData { export interface SnRewindActiveData {
maxCheckInStrike: number; maxCheckInStreak: number
mostActiveDay: string
mostActiveWeekday: string
latestActiveTime: string
checkInCompleteness: number
} }
export interface SnRewindMostCalledChat { export interface SnRewindMostCalledChat {
id: string; id: string
name: string; name: string
type: number; type: number
description: string; description: string
picture: SnCloudFile | null; picture: SnCloudFile | null
realmId: string | null; realmId: string | null
accountId: string; accountId: string
isPublic: boolean; isPublic: boolean
isCommunity: boolean; isCommunity: boolean
background: SnCloudFile | null; background: SnCloudFile | null
createdAt: string; createdAt: string
updatedAt: string; updatedAt: string
deletedAt: string | null; deletedAt: string | null
} }
export interface SnRewindMostPopularPost { export interface SnRewindMostPopularPost {
id: string; id: string
title: string; title: string
upvotes: number; upvotes: number
viewsTotal: number; viewsTotal: number
viewsUnique: number; viewsUnique: number
createdAt: string; createdAt: string
updatedAt: string; updatedAt: string
publishedAt: string; publishedAt: string
} }
export interface SnRewindMostProductiveDay { export interface SnRewindMostProductiveDay {
date: string; date: string
postCount: number; postCount: number
}
export interface SnRewindMostCalledAccount {
id: string;
name: string;
nick: string;
profile: {
id: string;
bio: string;
level: number;
picture: SnCloudFile | null;
background: SnCloudFile | null;
createdAt: string;
updatedAt: string;
};
createdAt: string;
updatedAt: string;
} }
export interface SnRewindMostLovedPublisher { export interface SnRewindMostLovedPublisher {
publisher: { publisher: SnPublisher
id: string; upvoteCounts: number
name: string;
nick: string;
bio: string;
level: number;
picture: SnCloudFile | null;
background: SnCloudFile | null;
createdAt: string;
updatedAt: string;
};
upvoteCounts: number;
} }
export interface SnRewindMostMessagedChat { export interface SnRewindChat {
id: string; id: string
name: string | null; name: string | null
type: number; type: number
members: SnRewindChatMember[]; members: SnRewindChatMember[]
createdAt: string; picture: SnCloudFile | null
updatedAt: string; background: SnCloudFile | null
createdAt: string
updatedAt: string
}
export interface SnRewindCallSummary {
chat: SnRewindChat
duration: number
}
export interface SnRewindChatSummary {
chat: SnRewindChat
messageCounts: number
} }
export interface SnRewindChatMember { export interface SnRewindChatMember {
id: string; id: string
nick: string | null; nick: string | null
role: number; role: number
isBot: boolean;
account: { account: {
id: string; id: string
name: string; name: string
nick: string; nick: string
profile: { profile: {
id: string; id: string
bio: string; bio: string
level: number; level: number
picture: SnCloudFile | null; picture: SnCloudFile | null
background: SnCloudFile | null; background: SnCloudFile | null
createdAt: string; createdAt: string
updatedAt: string; updatedAt: string
}; }
}; }
} }
export interface SnRewindSphereData { export interface SnRewindMostLovedAudience {
totalCount: number; account: SnAccount
upvoteCounts: number; upvoteCounts: number
mostCalledChat: SnRewindMostCalledChat; }
mostPopularPost: SnRewindMostPopularPost;
mostProductiveDay: SnRewindMostProductiveDay; export interface SnRewindSocialData {
mostCalledAccounts: SnRewindMostCalledAccount[]; totalPostCount: number
mostLovedPublisher: SnRewindMostLovedPublisher; totalUpvoteCount: number
mostMessagedChat: SnRewindMostMessagedChat; mostCalledChat: SnRewindCallSummary
mostMessagedDirectChat: SnRewindChatSummary
mostPopularPost: SnRewindMostPopularPost
mostProductiveDay: SnRewindMostProductiveDay
mostCalledAccounts: SnAccount[]
mostLovedPublisher: SnRewindMostLovedPublisher
mostLovedAudience: SnRewindMostLovedAudience
mostMessagedChat: SnRewindChatSummary
} }
export interface SnRewind { export interface SnRewind {
id: string; id: string
year: number; year: number
schemaVersion: number; schemaVersion: number
data: { data: {
pass: SnRewindPassData; pass: SnRewindActiveData
sphere: SnRewindSphereData; sphere: SnRewindSocialData
}; }
accountId: string; accountId: string
createdAt: string; createdAt: string
updatedAt: string; updatedAt: string
deletedAt: string | null; deletedAt: string | null
} }