Compare commits

...

3 Commits

Author SHA1 Message Date
013059b62f 💄 Account profile page new UI 2025-11-29 22:16:26 +08:00
8d28e8a6ad ♻️ Migrated some auth pages 2025-11-29 19:10:37 +08:00
7a4a13736e ♻️ Migrated callback pages 2025-11-29 19:03:52 +08:00
8 changed files with 271 additions and 187 deletions

View File

@@ -41,6 +41,11 @@
height: calc(100vh - 64px*2); height: calc(100vh - 64px*2);
} }
/* for the minimal layout */
.h-compact-layout {
height: calc(100vh - 48px);
}
.min-h-layout { .min-h-layout {
/* margin of the navbar + actual navbar */ /* margin of the navbar + actual navbar */
min-height: calc(100vh - 64px*2); min-height: calc(100vh - 64px*2);

4
app/components.d.ts vendored
View File

@@ -26,6 +26,7 @@ declare module 'vue' {
NDialog: typeof import('naive-ui')['NDialog'] NDialog: typeof import('naive-ui')['NDialog']
NDialogProvider: typeof import('naive-ui')['NDialogProvider'] NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDropdown: typeof import('naive-ui')['NDropdown'] NDropdown: typeof import('naive-ui')['NDropdown']
NEmpty: typeof import('naive-ui')['NEmpty']
NForm: typeof import('naive-ui')['NForm'] NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem'] NFormItem: typeof import('naive-ui')['NFormItem']
NIcon: typeof import('naive-ui')['NIcon'] NIcon: typeof import('naive-ui')['NIcon']
@@ -43,6 +44,7 @@ declare module 'vue' {
NProgress: typeof import('naive-ui')['NProgress'] NProgress: typeof import('naive-ui')['NProgress']
NRadio: typeof import('naive-ui')['NRadio'] NRadio: typeof import('naive-ui')['NRadio']
NRadioGroup: typeof import('naive-ui')['NRadioGroup'] NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NResult: typeof import('naive-ui')['NResult']
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']
@@ -70,6 +72,7 @@ declare global {
const NDialog: typeof import('naive-ui')['NDialog'] const NDialog: typeof import('naive-ui')['NDialog']
const NDialogProvider: typeof import('naive-ui')['NDialogProvider'] const NDialogProvider: typeof import('naive-ui')['NDialogProvider']
const NDropdown: typeof import('naive-ui')['NDropdown'] const NDropdown: typeof import('naive-ui')['NDropdown']
const NEmpty: typeof import('naive-ui')['NEmpty']
const NForm: typeof import('naive-ui')['NForm'] const NForm: typeof import('naive-ui')['NForm']
const NFormItem: typeof import('naive-ui')['NFormItem'] const NFormItem: typeof import('naive-ui')['NFormItem']
const NIcon: typeof import('naive-ui')['NIcon'] const NIcon: typeof import('naive-ui')['NIcon']
@@ -87,6 +90,7 @@ declare global {
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']
const NRadioGroup: typeof import('naive-ui')['NRadioGroup'] const NRadioGroup: typeof import('naive-ui')['NRadioGroup']
const NResult: typeof import('naive-ui')['NResult']
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']

View File

@@ -3,7 +3,7 @@
<div v-if="provider === 'cloudflare'"> <div v-if="provider === 'cloudflare'">
<turnstile v-if="!!apiKey" :sitekey="apiKey" @callback="handleSuccess" /> <turnstile v-if="!!apiKey" :sitekey="apiKey" @callback="handleSuccess" />
<div v-else class="mx-auto"> <div v-else class="mx-auto">
<n-spin /> <span class="loading loading-spinner loading-md"></span>
</div> </div>
</div> </div>
<div v-else-if="provider === 'hcaptcha'"> <div v-else-if="provider === 'hcaptcha'">
@@ -11,9 +11,10 @@
v-if="!!apiKey" v-if="!!apiKey"
:sitekey="apiKey" :sitekey="apiKey"
@verify="(tk: string) => handleSuccess(tk)" @verify="(tk: string) => handleSuccess(tk)"
/> >
</hcaptcha>
<div v-else class="mx-auto"> <div v-else class="mx-auto">
<n-spin /> <span class="loading loading-spinner loading-md"></span>
</div> </div>
</div> </div>
<div <div
@@ -22,7 +23,7 @@
:data-sitekey="apiKey" :data-sitekey="apiKey"
/> />
<div v-else class="flex flex-col items-center justify-center gap-1"> <div v-else class="flex flex-col items-center justify-center gap-1">
<span class="mdi mdi-alert-circle-outline text-3xl"></span> <n-icon :component="AlertTriangleIcon" />
<span>Captcha provider not configured correctly.</span> <span>Captcha provider not configured correctly.</span>
</div> </div>
</div> </div>
@@ -32,6 +33,7 @@
import { ref, onMounted } from "vue" import { ref, onMounted } from "vue"
import Turnstile from "cfturnstile-vue3" import Turnstile from "cfturnstile-vue3"
import Hcaptcha from "@hcaptcha/vue3-hcaptcha" import Hcaptcha from "@hcaptcha/vue3-hcaptcha"
import { AlertTriangleIcon } from "lucide-vue-next"
const props = defineProps({ const props = defineProps({
provider: { provider: {

View File

@@ -31,7 +31,7 @@
</div> </div>
</header> </header>
<main class="grow container mx-auto py-4 mt-[64px]"> <main class="grow mt-[64px]">
<slot /> <slot />
</main> </main>
</div> </div>

View File

@@ -1,121 +1,133 @@
<template> <template>
<div v-if="user"> <div>
<div class="fixed inset-0" :style="pageStyle" />
<img <img
:src="userBackground" :src="userBackground"
class="object-cover w-full max-h-48 mb-8" class="w-full max-h-48 object-cover object-top"
style="aspect-ratio: 16/7" :style="{ aspectRatio: '16/7', opacity: headerOpacity }"
/> />
<div class="container mx-auto px-8 pb-8"> <div v-if="user" class="relative min-h-layout backdrop-blur-md">
<div class="flex items-center gap-6 mb-8"> <div class="container mx-auto p-8 pt-12">
<v-avatar size="80" rounded="circle" :image="userPicture" /> <div class="flex items-center gap-6 mb-8">
<div> <n-avatar :size="80" round :src="userPicture" />
<div class="text-2xl font-bold"> <div>
{{ user.nick || user.name }} <div class="text-2xl font-bold">
{{ user.nick || user.name }}
</div>
<div class="text-sm opacity-80">@{{ user.name }}</div>
</div> </div>
<div class="text-body-2 text-medium-emphasis">@{{ user.name }}</div>
</div> </div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<v-card <n-card
title="Info" title="Info"
prepend-icon="mdi-information" size="small"
density="comfortable" :class="cardClass"
> :style="cardStyle"
<v-card-text class="flex flex-col gap-2"> :content-style="cardContentStyle"
<div v-if="user?.profile?.timeZone" class="flex gap-2"> >
<span class="flex items-center gap-2 grow"> <template #header-extra>
<v-icon>mdi-clock-outline</v-icon> <n-icon :component="Info" />
Time Zone </template>
</span> <div class="flex flex-col gap-2">
<span class="flex gap-2"> <div v-if="user?.profile?.timeZone" class="flex gap-2">
<span class="flex items-center gap-2 grow">
<n-icon :component="Clock" />
Time Zone
</span>
<span class="flex gap-2">
<span>
{{
new Date().toLocaleTimeString(void 0, {
timeZone: user.profile.timeZone
})
}}
</span>
<span class="font-bold">·</span>
<span>{{ getOffsetUTCString(user.profile.timeZone) }}</span>
<span class="font-bold">·</span>
<span>{{ user.profile.timeZone }}</span>
</span>
</div>
<div v-if="user?.profile?.location" class="flex gap-2">
<span class="flex items-center gap-2 grow">
<n-icon :component="MapPin" />
Location
</span>
<span>
{{ user.profile.location }}
</span>
</div>
<div
v-if="user?.profile?.firstName || user?.profile?.lastName"
class="flex gap-2"
>
<span class="flex items-center gap-2 grow">
<n-icon :component="UserPen" />
Name
</span>
<span> <span>
{{ {{
new Date().toLocaleTimeString(void 0, { [
timeZone: user.profile.timeZone user.profile.firstName,
}) user.profile.middleName,
user.profile.lastName
]
.filter(Boolean)
.join(" ")
}} }}
</span> </span>
<span class="font-bold">·</span> </div>
<span>{{ getOffsetUTCString(user.profile.timeZone) }}</span> <div
<span class="font-bold">·</span> v-if="user?.profile?.gender || user?.profile?.pronouns"
<span>{{ user.profile.timeZone }}</span> class="flex gap-2"
</span> >
</div> <span class="flex items-center gap-2 grow">
<div v-if="user?.profile?.location" class="flex gap-2"> <n-icon :component="User" />
<span class="flex items-center gap-2 grow"> Gender
<v-icon>mdi-map-marker-outline</v-icon> </span>
Location <span class="flex gap-2">
</span> <span>{{ user.profile.gender || "Unspecified" }}</span>
<span> <span class="font-bold">·</span>
{{ user.profile.location }} <span>{{ user.profile.pronouns || "Unspecified" }}</span>
</span> </span>
</div> </div>
<div <div class="flex gap-2">
v-if="user?.profile?.firstName || user?.profile?.lastName" <span class="flex items-center gap-2 grow">
class="flex gap-2" <n-icon :component="Calendar" />
> Joined at
<span class="flex items-center gap-2 grow"> </span>
<v-icon>mdi-account-edit-outline</v-icon>
Name
</span>
<span>
{{
[
user.profile.firstName,
user.profile.middleName,
user.profile.lastName
]
.filter(Boolean)
.join(" ")
}}
</span>
</div>
<div
v-if="user?.profile?.gender || user?.profile?.pronouns"
class="flex gap-2"
>
<span class="flex items-center gap-2 grow">
<v-icon>mdi-account-circle</v-icon>
Gender
</span>
<span class="flex gap-2">
<span>{{ user.profile.gender || "Unspecified" }}</span>
<span class="font-bold">·</span>
<span>{{ user.profile.pronouns || "Unspecified" }}</span>
</span>
</div>
<div class="flex gap-2">
<span class="flex items-center gap-2 grow">
<v-icon>mdi-calendar-month-outline</v-icon>
Joined at
</span>
<span>{{
user ? new Date(user.createdAt).toLocaleDateString() : ""
}}</span>
</div>
<div v-if="user?.profile?.birthday" class="flex gap-2">
<span class="flex items-center gap-2 grow">
<v-icon>mdi-cake-variant-outline</v-icon>
Birthday
</span>
<span class="flex gap-2">
<span
>{{ calculateAge(new Date(user.profile.birthday)) }} yrs
old</span
>
<span class="font-bold">·</span>
<span>{{ <span>{{
new Date(user.profile.birthday).toLocaleDateString() user ? new Date(user.createdAt).toLocaleDateString() : ""
}}</span> }}</span>
</span> </div>
<div v-if="user?.profile?.birthday" class="flex gap-2">
<span class="flex items-center gap-2 grow">
<n-icon :component="Cake" />
Birthday
</span>
<span class="flex gap-2">
<span
>{{ calculateAge(new Date(user.profile.birthday)) }} yrs
old</span
>
<span class="font-bold">·</span>
<span>{{
new Date(user.profile.birthday).toLocaleDateString()
}}</span>
</span>
</div>
</div> </div>
</v-card-text> </n-card>
</v-card> <n-card
<v-card v-if="user?.perkSubscription"> v-if="user?.perkSubscription"
<v-card-text> size="small"
:class="cardClass"
:style="cardStyle"
:content-style="cardContentStyle"
>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<div class="flex flex-col"> <div class="flex flex-col">
<div class="text-xl font-bold"> <div class="text-xl font-bold">
@@ -127,62 +139,93 @@
</div> </div>
<div class="text-sm">Stellar Program Member</div> <div class="text-sm">Stellar Program Member</div>
</div> </div>
<v-icon <n-icon
size="48" :size="48"
:color=" :color="
perkSubscriptionNames[user.perkSubscription.identifier] perkSubscriptionNames[user.perkSubscription.identifier]
?.color || '#2196f3' ?.color || '#2196f3'
" "
> :component="Star"
mdi-star-circle />
</v-icon>
</div> </div>
</v-card-text> </n-card>
</v-card> <n-card
<v-card> size="small"
<v-card-text> :class="cardClass"
:style="cardStyle"
:content-style="cardContentStyle"
>
<div class="flex justify-between items-center mb-2"> <div class="flex justify-between items-center mb-2">
<div>Level {{ user?.profile?.level || 0 }}</div> <div>Level {{ user?.profile?.level || 0 }}</div>
<div>{{ user?.profile?.experience || 0 }} XP</div> <div>{{ user?.profile?.experience || 0 }} XP</div>
</div> </div>
<v-progress-linear <n-progress
:model-value="user?.profile?.levelingProgress || 0" type="line"
color="success" :percentage="user?.profile?.levelingProgress || 0"
class="mb-0" status="success"
rounded :show-indicator="false"
/> />
</v-card-text> </n-card>
</v-card> </div>
</div> <div>
<div> <n-card
<v-card v-if="htmlBio" title="Bio" prepend-icon="mdi-pencil"> v-if="htmlBio"
<v-card-text> title="Bio"
size="small"
:class="cardClass"
:style="cardStyle"
:content-style="cardContentStyle"
>
<template #header-extra>
<n-icon :component="PenLine" />
</template>
<article <article
class="bio-prose prose prose-sm dark:prose-invert prose-slate" class="bio-prose prose prose-sm dark:prose-invert prose-slate"
v-html="htmlBio" v-html="htmlBio"
></article> ></article>
</v-card-text> </n-card>
</v-card> </div>
</div> </div>
</div> </div>
</div> </div>
</div> <div
<div v-else-if="notFound" class="flex justify-center items-center h-full"> v-else-if="notFound"
<v-empty-state class="relative flex justify-center items-center h-full"
icon="mdi-account-off" >
title="User not found" <n-empty
text="The user profile you're trying to access is not found." description="The user profile you're trying to access is not found."
/> >
</div> <template #icon>
<div v-else class="flex justify-center items-center h-full"> <n-icon :component="UserX" />
<v-progress-circular indeterminate size="64" color="primary" /> </template>
<template #extra>
<div class="text-lg font-bold">User not found</div>
</template>
</n-empty>
</div>
<div v-else class="relative flex justify-center items-center h-full">
<n-spin size="large" />
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from "vue" import { computed, ref, watch } from "vue"
import { useWindowScroll } from "@vueuse/core"
import { useMarkdownProcessor } from "~/composables/useMarkdownProcessor" import { useMarkdownProcessor } from "~/composables/useMarkdownProcessor"
import type { SnAccount } from "~/types/api" import type { SnAccount } from "~/types/api"
import {
Info,
Clock,
MapPin,
UserPen,
User,
Calendar,
Cake,
Star,
PenLine,
UserX
} from "lucide-vue-next"
const route = useRoute() const route = useRoute()
@@ -247,9 +290,7 @@ const htmlBio = ref<string | undefined>(undefined)
watch( watch(
user, user,
(value) => { (value) => {
htmlBio.value = value?.profile.bio htmlBio.value = value?.profile.bio ? render(value.profile.bio) : undefined
? render(value.profile.bio)
: undefined
}, },
{ immediate: true, deep: true } { immediate: true, deep: true }
) )
@@ -259,12 +300,48 @@ const userBackground = computed(() => {
? `${apiBase}/drive/files/${user.value.profile.background.id}?original=true` ? `${apiBase}/drive/files/${user.value.profile.background.id}?original=true`
: undefined : undefined
}) })
const { y: scrollY } = useWindowScroll()
const scrollThreshold = 192 // max-h-48 is 12rem = 192px
const backgroundOpacity = computed(() => {
return Math.max(0, Math.min(scrollY.value / scrollThreshold, 1))
})
const headerOpacity = computed(() => {
return 1 - backgroundOpacity.value
})
const pageStyle = computed(() => {
if (!userBackground.value) return {}
return {
backgroundImage: `linear-gradient(rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.4)), url('${userBackground.value}')`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
backgroundAttachment: "fixed",
opacity: backgroundOpacity.value
}
})
const userPicture = computed(() => { const userPicture = computed(() => {
return user.value?.profile.picture return user.value?.profile.picture
? `${apiBase}/drive/files/${user.value.profile.picture.id}` ? `${apiBase}/drive/files/${user.value.profile.picture.id}`
: undefined : undefined
}) })
const cardClass = computed(() => ({
"backdrop-blur-2xl": !!userBackground.value,
"shadow-xl": !!userBackground.value
}))
const cardStyle = computed(() =>
userBackground.value ? "background-color: rgba(255, 255, 255, 0.1)" : ""
)
const cardContentStyle = computed(() =>
userBackground.value ? "background-color: transparent" : ""
)
function calculateAge(birthday: Date) { function calculateAge(birthday: Date) {
const birthDate = new Date(birthday) const birthDate = new Date(birthday)
const today = new Date() const today = new Date()
@@ -328,6 +405,7 @@ useHead({
defineOgImage({ defineOgImage({
component: "ImageCard", component: "ImageCard",
// @ts-ignore
title: computed(() => title: computed(() =>
user.value ? user.value.nick || user.value.name : "User Profile" user.value ? user.value.nick || user.value.name : "User Profile"
), ),

View File

@@ -1,12 +1,14 @@
<template> <template>
<div class="d-flex align-center justify-center fill-height"> <div class="flex items-center justify-center h-compact-layout">
<v-card class="pa-6 text-center" max-width="400"> <n-result
<v-card-text> status="info"
<v-progress-circular indeterminate color="primary" class="mb-4" /> title="Almost There"
<h2 class="text-xl font-bold">Redirecting...</h2> description="Please wait while we redirect you, it won't take too long..."
<p class="opacity-80">Please wait while we redirect you.</p> >
</v-card-text> <template #icon>
</v-card> <span class="loading loading-spinner loading-xl"></span>
</template>
</n-result>
</div> </div>
</template> </template>

View File

@@ -1,12 +1,10 @@
<template> <template>
<div class="d-flex align-center justify-center fill-height"> <div class="flex items-center justify-center h-compact-layout">
<v-card class="pa-6 text-center" max-width="400"> <n-result
<v-card-text> status="success"
<v-icon size="64" color="success" class="mb-4">mdi-check-circle</v-icon> title="Auth Completed"
<h2 class="text-xl font-bold">Auth completed</h2> description="Now you can close this tab safely."
<p class="opacity-80">Now you can close this tab</p> ></n-result>
</v-card-text>
</v-card>
</div> </div>
</template> </template>

View File

@@ -1,32 +1,27 @@
<template> <template>
<div class="d-flex align-center justify-center fill-height"> <div class="flex flex-col items-center justify-center h-compact-layout">
<v-card <div class="mb-4">
class="pa-6 text-center" <client-only>
max-width="600" <captcha-widget @verified="onCaptchaVerified" />
title="Captcha Verification" </client-only>
> </div>
<v-card-text> <div class="text-center">
<div class="mb-8 mt-4"> <div class="text-sm font-bold">Solar Network Anti-Robot</div>
<client-only> <p class="opacity-80 text-sm mb-2">
<captcha-widget @verified="onCaptchaVerified" /> You might need to wait a while before the puzzle fully loaded.
</client-only> </p>
</div> <div class="opacity-80 text-xs">
<div> Hosted by
<div class="text-sm font-bold mb-1">Solar Network Anti-Robot</div> <a
<div class="opacity-80 text-xs"> href="https://github.com/Solsynth/DysonNetwork"
Hosted by class="text-primary"
<a target="_blank"
href="https://github.com/Solsynth/DysonNetwork" rel="noopener noreferrer"
class="text-primary" >
target="_blank" DysonNetwork.Sphere
rel="noopener noreferrer" </a>
> </div>
DysonNetwork.Sphere </div>
</a>
</div>
</div>
</v-card-text>
</v-card>
</div> </div>
</template> </template>