Profile page

This commit is contained in:
2025-09-20 17:18:13 +08:00
parent ae0990a6cc
commit a119be72d1
6 changed files with 544 additions and 3 deletions

View File

@@ -17,6 +17,5 @@
html, html,
body { body {
--font-family: "Nunito Variable", sans-serif;
font-family: var(--font-family); font-family: var(--font-family);
} }

View File

@@ -39,7 +39,7 @@ export const useSolarNetwork = () => {
}) })
} }
export const useSolarNetworkUrl = () => { export const useSolarNetworkUrl = (withoutProxy = false) => {
const config = useRuntimeConfig() const config = useRuntimeConfig()
return config.public.development ? "/api" : config.public.apiBase return (config.public.development && !withoutProxy) ? "/api" : config.public.apiBase
} }

View File

@@ -0,0 +1,313 @@
<template>
<div v-if="user">
<img
:src="userBackground"
class="object-cover w-full max-h-48 mb-8"
style="aspect-ratio: 16/7"
/>
<div class="container mx-auto px-8 pb-8">
<div class="flex items-center gap-6 mb-8">
<v-avatar size="80" rounded="circle" :image="userPicture" />
<div>
<div class="text-2xl font-bold">
{{ user.nick || user.name }}
</div>
<div class="text-body-2 text-medium-emphasis">@{{ user.name }}</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex flex-col gap-4">
<v-card
title="Info"
prepend-icon="mdi-information"
density="comfortable"
>
<v-card-text class="flex flex-col gap-2">
<div class="flex gap-2" v-if="user?.profile?.time_zone">
<span class="flex items-center gap-2 flex-grow">
<v-icon>mdi-clock-outline</v-icon>
Time Zone
</span>
<span class="flex gap-2">
<span>
{{
new Date().toLocaleTimeString(void 0, {
timeZone: user.profile.time_zone
})
}}
</span>
<span class="font-bold">·</span>
<span>{{ getOffsetUTCString(user.profile.time_zone) }}</span>
<span class="font-bold">·</span>
<span>{{ user.profile.time_zone }}</span>
</span>
</div>
<div class="flex gap-2" v-if="user?.profile?.location">
<span class="flex items-center gap-2 flex-grow">
<v-icon>mdi-map-marker-outline</v-icon>
Location
</span>
<span>
{{ user.profile.location }}
</span>
</div>
<div
class="flex gap-2"
v-if="user?.profile?.first_name || user?.profile?.last_name"
>
<span class="flex items-center gap-2 flex-grow">
<v-icon>mdi-account-edit-outline</v-icon>
Name
</span>
<span>
{{
[
user.profile.first_name,
user.profile.middle_name,
user.profile.last_name
]
.filter(Boolean)
.join(" ")
}}
</span>
</div>
<div
class="flex gap-2"
v-if="user?.profile?.gender || user?.profile?.pronouns"
>
<span class="flex items-center gap-2 flex-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 flex-grow">
<v-icon>mdi-calendar-month-outline</v-icon>
Joined at
</span>
<span>{{
user ? new Date(user.created_at).toLocaleDateString() : ""
}}</span>
</div>
<div class="flex gap-2" v-if="user?.profile?.birthday">
<span class="flex items-center gap-2 flex-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>{{
new Date(user.profile.birthday).toLocaleDateString()
}}</span>
</span>
</div>
</v-card-text>
</v-card>
<v-card v-if="user?.perk_subscription">
<v-card-text>
<div class="flex justify-between items-center">
<div class="flex flex-col">
<div class="text-xl font-bold">
{{
perkSubscriptionNames[user.perk_subscription.identifier]
?.name || "Unknown"
}}
Tier
</div>
<div class="text-sm">Stellar Program Member</div>
</div>
<v-icon
size="48"
:color="
perkSubscriptionNames[user.perk_subscription.identifier]
?.color || '#2196f3'
"
>
mdi-star-circle
</v-icon>
</div>
</v-card-text>
</v-card>
<v-card>
<v-card-text>
<div class="flex justify-between items-center mb-2">
<div>Level {{ user?.profile?.level || 0 }}</div>
<div>{{ user?.profile?.experience || 0 }} XP</div>
</div>
<v-progress-linear
:model-value="user?.profile?.leveling_progress || 0"
color="success"
class="mb-0"
rounded
/>
</v-card-text>
</v-card>
</div>
<div>
<v-card v-if="htmlBio" title="Bio" prepend-icon="mdi-pencil">
<v-card-text class="px-8">
<article
class="bio-prose prose prose-sm dark:prose-invert prose-slate"
v-html="htmlBio"
></article>
</v-card-text>
</v-card>
</div>
</div>
</div>
</div>
<div v-else-if="notFound" class="flex justify-center items-center h-full">
<v-empty-state
icon="mdi-account-off"
title="User not found"
text="The user profile you're trying to access is not found."
/>
</div>
<div v-else class="flex justify-center items-center h-full">
<v-progress-circular indeterminate size="64" color="primary" />
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue"
import { Marked } from "marked"
const route = useRoute()
const notFound = ref<boolean>(false)
const user = ref<any>(null)
const username = computed(() => {
const nameStr = route.params.name?.toString()
if (nameStr?.startsWith("@")) return nameStr.substring(1)
return nameStr
})
// Use useFetch with the correct API URL to avoid router conflicts
const apiBase = useSolarNetworkUrl()
const apiBaseServer = useSolarNetworkUrl(true)
try {
const { data, error } = await useFetch(
`${apiBaseServer}/id/accounts/${username.value}`,
{ server: true }
)
if (error.value) {
console.error("Failed to fetch user:", error.value)
notFound.value = true
} else {
user.value = data.value
}
} catch (err) {
console.error("Failed to fetch user:", err)
notFound.value = true
}
interface PerkSubscriptionInfo {
name: string
tier: number
color: string
}
const perkSubscriptionNames: Record<string, PerkSubscriptionInfo> = {
"solian.stellar.primary": {
name: "Stellar",
tier: 1,
color: "#2196f3"
},
"solian.stellar.nova": {
name: "Nova",
tier: 2,
color: "#39c5bb"
},
"solian.stellar.supernova": {
name: "Supernova",
tier: 3,
color: "#ffc109"
}
}
const marked = new Marked()
const htmlBio = ref<string | undefined>(undefined)
watch(
user,
async (value) => {
htmlBio.value = value?.profile.bio
? await marked.parse(value.profile.bio, { breaks: true })
: undefined
},
{ immediate: true, deep: true }
)
const userBackground = computed(() => {
return user.value?.profile.background
? `${apiBase}/drive/files/${user.value.profile.background.id}?original=true`
: undefined
})
const userPicture = computed(() => {
return user.value?.profile.picture
? `${apiBase}/drive/files/${user.value.profile.picture.id}`
: undefined
})
function calculateAge(birthday: Date) {
const birthDate = new Date(birthday)
const today = new Date()
let age = today.getFullYear() - birthDate.getFullYear()
// Check if the birthday hasn't occurred yet this year
const monthDiff = today.getMonth() - birthDate.getMonth()
const dayDiff = today.getDate() - birthDate.getDate()
if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) {
age--
}
return age
}
function getOffsetUTCString(targetTimeZone: string): string {
const now = new Date()
const localOffset = now.getTimezoneOffset() // in minutes
const targetTime = new Date(
now.toLocaleString("en-US", { timeZone: targetTimeZone })
)
const targetOffset = (now.getTime() - targetTime.getTime()) / 60000
const diff = targetOffset - localOffset
const sign = diff <= 0 ? "+" : "-" // inverted because positive offset is west of UTC
const abs = Math.abs(diff)
const hours = String(Math.floor(abs / 60)).padStart(2, "0")
const minutes = String(Math.floor(abs % 60)).padStart(2, "0")
return `${sign}${hours}:${minutes}`
}
definePageMeta({
alias: ["/@[name]"]
})
</script>
<style>
.bio-prose img {
display: inline !important;
margin: 0 !important;
}
</style>

View File

@@ -0,0 +1,225 @@
<template>
<v-container class="d-flex align-center justify-center fill-height">
<v-card max-width="1000" rounded="lg" width="100%">
<div v-if="isLoading" class="d-flex justify-center mb-4">
<v-progress-linear indeterminate color="primary" height="4" />
</div>
<div class="pa-8">
<div class="mb-4">
<img
:src="$vuetify.theme.current.dark ? IconDark : IconLight"
alt="CloudyLamb"
height="60"
width="60"
/>
</div>
<v-row>
<v-col cols="12" lg="6" class="d-flex align-start justify-start">
<div class="md:text-left h-auto">
<h2 class="text-2xl font-bold mb-1">Authorize Application</h2>
<p class="text-lg">Grant access to your Solar Network account</p>
</div>
</v-col>
<v-col cols="12" lg="6" class="d-flex align-center justify-stretch">
<div class="w-full d-flex flex-column md:text-right">
<div v-if="error" class="mb-4">
<v-alert
type="error"
closable
@update:model-value="error = null"
>
{{ error }}
</v-alert>
</div>
<!-- App Info Section -->
<div v-if="clientInfo" class="mb-6">
<div class="d-flex align-center mb-4">
<v-avatar
v-if="clientInfo.picture"
:src="clientInfo.picture.url"
:alt="clientInfo.client_name"
size="large"
class="mr-3"
/>
<div>
<h3 class="text-xl font-semibold">
{{ clientInfo.client_name || 'Unknown Application' }}
</h3>
<p class="text-base">
{{ isNewApp ? 'wants to access your Solar Network account' : 'wants to access your account' }}
</p>
</div>
</div>
<!-- Requested Permissions -->
<v-card variant="outlined" class="pa-4 mb-4">
<h4 class="font-medium mb-2">
This will allow {{ clientInfo.client_name || 'the app' }} to:
</h4>
<ul class="space-y-1">
<li v-for="scope in requestedScopes" :key="scope" class="d-flex align-start">
<v-icon class="mt-1 mr-2" color="success">mdi-check-box</v-icon>
<span>{{ scope }}</span>
</li>
</ul>
</v-card>
<!-- Buttons -->
<div class="d-flex gap-3 mt-4">
<v-btn
color="primary"
:loading="isAuthorizing"
@click="handleAuthorize"
class="flex-grow-1"
size="large"
>
Authorize
</v-btn>
<v-btn
variant="outlined"
:disabled="isAuthorizing"
@click="handleDeny"
class="flex-grow-1"
size="large"
>
Deny
</v-btn>
</div>
<div class="mt-4 text-sm text-center">
By authorizing, you agree to the
<v-btn
variant="text"
size="small"
@click="openTerms"
class="px-1 text-capitalize"
>
Terms of Service
</v-btn>
and
<v-btn
variant="text"
size="small"
@click="openPrivacy"
class="px-1 text-capitalize"
>
Privacy Policy
</v-btn>
</div>
</div>
</div>
</v-col>
</v-row>
</div>
</v-card>
<footer-compact />
</v-container>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useSolarNetwork } from '~/composables/useSolarNetwork'
import IconLight from '~/assets/images/cloudy-lamb.png'
import IconDark from '~/assets/images/cloudy-lamb@dark.png'
const route = useRoute()
const api = useSolarNetwork()
// State
const isLoading = ref(true)
const isAuthorizing = ref(false)
const error = ref<string | null>(null)
const clientInfo = ref<{
client_name?: string
home_uri?: string
picture?: { url: string }
terms_of_service_uri?: string
privacy_policy_uri?: string
scopes?: string[]
} | null>(null)
const isNewApp = ref(false)
// Computed properties
const requestedScopes = computed(() => {
return clientInfo.value?.scopes || []
})
// Methods
async function fetchClientInfo() {
try {
const queryString = window.location.search.slice(1)
clientInfo.value = await api(`/id/auth/open/authorize?${queryString}`)
checkIfNewApp()
} catch (err: any) {
error.value = err.message || 'An error occurred while loading the authorization request'
} finally {
isLoading.value = false
}
}
function checkIfNewApp() {
// In a real app, you might want to check if this is the first time authorizing this app
// For now, we'll just set it to false
isNewApp.value = false
}
async function handleAuthorize() {
isAuthorizing.value = true
try {
const data = await api<{ redirect_uri?: string }>('/auth/open/authorize', {
method: 'POST',
body: {
...route.query,
authorize: 'true',
},
})
if (data.redirect_uri) {
window.location.href = data.redirect_uri
}
} catch (err: any) {
error.value = err.message || 'An error occurred during authorization'
} finally {
isAuthorizing.value = false
}
}
function handleDeny() {
// Redirect back to the client with an error
// Ensure redirect_uri is always a string (not an array)
const redirectUriStr = Array.isArray(route.query.redirect_uri)
? route.query.redirect_uri[0] || clientInfo.value?.home_uri || '/'
: route.query.redirect_uri || clientInfo.value?.home_uri || '/'
const redirectUri = new URL(redirectUriStr)
// Ensure state is always a string (not an array)
const state = Array.isArray(route.query.state)
? route.query.state[0] || ''
: route.query.state || ''
const params = new URLSearchParams({
error: 'access_denied',
error_description: 'The user denied the authorization request',
state: state,
})
window.open(`${redirectUri}?${params}`, "_self")
}
function openTerms() {
window.open(clientInfo.value?.terms_of_service_uri || '#', "_blank")
}
function openPrivacy() {
window.open(clientInfo.value?.privacy_policy_uri || '#', "_blank")
}
// Lifecycle
onMounted(() => {
fetchClientInfo()
})
</script>
<style scoped>
/* Add any custom styles here */
</style>

View File

@@ -18,6 +18,7 @@
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"cfturnstile-vue3": "^2.0.0", "cfturnstile-vue3": "^2.0.0",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"fslightbox-vue": "^2.2.1",
"luxon": "^3.7.2", "luxon": "^3.7.2",
"marked": "^16.3.0", "marked": "^16.3.0",
"nuxt": "^4.1.2", "nuxt": "^4.1.2",
@@ -1031,6 +1032,8 @@
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"fslightbox-vue": ["fslightbox-vue@2.2.1", "", { "peerDependencies": { "vue": ">=2.5.0" } }, "sha512-GMlp8JoyRxN8dJuIGQCoB2O9CWnxG7uTK4bBzaw+VyXyVUHFA30UPRXSUFFnHuprX1qF+L0f7oimVF/FGSDwgA=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"fuse.js": ["fuse.js@7.1.0", "", {}, "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="], "fuse.js": ["fuse.js@7.1.0", "", {}, "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="],

View File

@@ -24,6 +24,7 @@
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"cfturnstile-vue3": "^2.0.0", "cfturnstile-vue3": "^2.0.0",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"fslightbox-vue": "^2.2.1",
"luxon": "^3.7.2", "luxon": "^3.7.2",
"marked": "^16.3.0", "marked": "^16.3.0",
"nuxt": "^4.1.2", "nuxt": "^4.1.2",