User profile page webpage

This commit is contained in:
2025-08-02 20:30:48 +08:00
parent a932108c87
commit be7d7536fc
13 changed files with 381 additions and 15 deletions

View File

@@ -1,6 +1,7 @@
{
"Debug": true,
"BaseUrl": "http://localhost:5071",
"BaseUrl": "http://localhost:5090",
"GatewayUrl": "http://localhost:5094",
"Logging": {
"LogLevel": {
"Default": "Information",

View File

@@ -31,8 +31,11 @@
"DirectRoutes": [
{
"Path": "/ws",
"Service": "DysonNetwork.Pusher",
"IsWebsocket": true
"Service": "DysonNetwork.Pusher"
},
{
"Path": "/api/tus",
"Service": "DysonNetwork.Drive"
},
{
"Path": "/.well-known/openid-configuration",

View File

@@ -7,10 +7,12 @@
"@fingerprintjs/fingerprintjs": "^4.6.2",
"@fontsource-variable/nunito": "^5.2.6",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.11",
"@vueuse/core": "^13.5.0",
"aspnet-prerendering": "^3.0.1",
"cfturnstile-vue3": "^2.0.0",
"marked": "^16.1.1",
"pinia": "^3.0.3",
"tailwindcss": "^4.1.11",
"vue": "^3.5.17",
@@ -257,6 +259,8 @@
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.11", "", { "os": "win32", "cpu": "x64" }, "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg=="],
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.16", "", { "dependencies": { "lodash.castarray": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.merge": "^4.6.2", "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.11", "", { "dependencies": { "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "tailwindcss": "4.1.11" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw=="],
"@tsconfig/node22": ["@tsconfig/node22@22.0.2", "", {}, "sha512-Kmwj4u8sDRDrMYRoN9FDEcXD8UpBSaPQQ24Gz+Gamqfm7xxn+GBR7ge/Z7pK8OXNGyUzbSwJj+TH6B+DS/epyA=="],
@@ -625,12 +629,18 @@
"lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="],
"lodash.castarray": ["lodash.castarray@4.4.0", "", {}, "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q=="],
"lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
"marked": ["marked@16.1.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-ij/2lXfCRT71L6u0M29tJPhP0bM5shLL3u5BePhFwPELj2blMJ6GDtD7PfJhRLhJ/c2UwrK17ySVcDzy2YHjHQ=="],
"memorystream": ["memorystream@0.3.1", "", {}, "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
@@ -863,6 +873,8 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tailwindcss/typography/postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],

View File

@@ -2,7 +2,7 @@
<html lang="">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<link rel="icon" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Solarpass</title>
<app-data />

View File

@@ -18,10 +18,12 @@
"@fingerprintjs/fingerprintjs": "^4.6.2",
"@fontsource-variable/nunito": "^5.2.6",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.11",
"@vueuse/core": "^13.5.0",
"aspnet-prerendering": "^3.0.1",
"cfturnstile-vue3": "^2.0.0",
"marked": "^16.1.1",
"pinia": "^3.0.3",
"tailwindcss": "^4.1.11",
"vue": "^3.5.17",

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@@ -1,4 +1,5 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@layer theme, base, components, utilities;

View File

@@ -0,0 +1,43 @@
<template>
<div>
<img :src="userBackground" class="object-cover w-full max-h-48" style="aspect-ratio: 16/7" />
<n-tabs
animated
justify-content="center"
type="line"
placement="top"
:value="route.name?.toString()"
@update:value="onSwitchTab"
>
<n-tab-pane name="dashboardCurrent" tab="Information">
<router-view />
</n-tab-pane>
<n-tab-pane name="dashboardSecurity" tab="Security">
<router-view />
</n-tab-pane>
</n-tabs>
</div>
</template>
<script setup lang="ts">
import { useUserStore } from '@/stores/user'
import { NTabs, NTabPane } from 'naive-ui'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
function onSwitchTab(name: string) {
router.push({ name })
}
const userBackground = computed(() => {
return userStore.user.profile.background
? `/cgi/drive/files/${userStore.user.profile.background.id}?original=true`
: undefined
})
</script>

View File

@@ -15,7 +15,7 @@
</n-dropdown>
</div>
</n-layout-header>
<n-layout-content embedded content-style="padding: 24px;">
<n-layout-content embedded>
<router-view />
</n-layout-content>
</n-layout>

View File

@@ -7,40 +7,60 @@ const router = createRouter({
{
path: '/',
name: 'index',
component: () => import('../views/index.vue')
component: () => import('../views/index.vue'),
},
{
path: '/captcha',
name: 'captcha',
component: () => import('../views/captcha.vue')
component: () => import('../views/captcha.vue'),
},
{
path: '/spells/:word',
name: 'spells',
component: () => import('../views/spells.vue')
component: () => import('../views/spells.vue'),
},
{
path: '/login',
name: 'login',
component: () => import('../views/login.vue')
component: () => import('../views/login.vue'),
},
{
path: '/create-account',
name: 'create-account',
component: () => import('../views/create-account.vue')
component: () => import('../views/create-account.vue'),
},
{
path: '/accounts/:name',
alias: ['/@:name'],
name: 'accountProfilePage',
component: () => import('../views/pfp/index.vue'),
},
{
path: '/accounts/me',
name: 'me',
component: () => import('../views/accounts/me.vue'),
meta: { requiresAuth: true }
name: 'dashboard',
meta: { requiresAuth: true },
component: () => import('../layouts/dashboard.vue'),
children: [
{
path: '',
name: 'dashboardCurrent',
component: () => import('../views/accounts/info.vue'),
meta: { requiresAuth: true },
},
{
path: 'security',
name: 'dashboardSecurity',
component: () => import('../views/accounts/security.vue'),
meta: { requiresAuth: true },
},
],
},
{
path: '/:notFound(.*)',
name: 'errorNotFound',
component: () => import('../views/not-found.vue'),
},
]
],
})
router.beforeEach(async (to, from, next) => {

View File

@@ -1,5 +1,5 @@
<template>
<div class="max-w-3xl mx-auto p-8">
<div class="container mx-auto">
<div class="flex items-center gap-6 mb-8">
<n-avatar round :size="100" :alt="userStore.user.name" :src="userPicture">
<n-icon size="48" v-if="!userPicture">

View File

@@ -0,0 +1,3 @@
<template>
<p>Security</p>
</template>

View File

@@ -0,0 +1,281 @@
<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">
<div class="flex items-center gap-6 mb-8">
<n-avatar round :size="100" :alt="user.name" :src="userPicture">
<n-icon size="48" v-if="!userPicture">
<person-round />
</n-icon>
</n-avatar>
<div>
<n-text strong class="text-2xl">
{{ user.nick || user.name }}
</n-text>
<n-text depth="3" class="block">@{{ user.name }}</n-text>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex flex-col gap-4">
<n-card title="Info">
<div class="flex gap-2" v-if="user.profile.location">
<span class="flex-grow-1 flex items-center gap-2">
<n-icon>
<access-time-outlined />
</n-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-grow-1 flex items-center gap-2">
<n-icon>
<location-on-outlined />
</n-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-grow-1 flex items-center gap-2">
<n-icon>
<drive-file-rename-outline-outlined />
</n-icon>
Name
</span>
<span>
{{
[user.profile.first_name, user.profile.middle_name, user.profile.last_name].join(
' ',
)
}}
</span>
</div>
<div class="flex gap-2" v-if="user.profile.gender || user.profile.pronouns">
<span class="flex-grow-1 flex items-center gap-2">
<n-icon>
<person-round />
</n-icon>
Gender
</span>
<span class="flex gap-2">
<span>{{ user.profile.gender || 'Unspecified' }}</span>
<span class="font-bold">·</span>
<span>{{ user.profile.pronouns || 'Unspeificed' }}</span>
</span>
</div>
<div class="flex gap-2">
<span class="flex-grow-1 flex items-center gap-2">
<n-icon>
<calendar-month-outlined />
</n-icon>
Joined at
</span>
<span>{{ new Date(user.created_at).toLocaleDateString() }}</span>
</div>
<div class="flex gap-2" v-if="user.profile.birthday">
<span class="flex-grow-1 flex items-center gap-2">
<n-icon>
<cake-outlined />
</n-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>
</n-card>
<n-card v-if="user.perk_subscription">
<div class="flex justify-between items-center">
<div class="flex flex-col">
<n-text class="font-bold text-xl">
{{ perkSubscriptionNames[user.perk_subscription.identifier].name }}
Tier
</n-text>
<n-text>Stellar Program Member</n-text>
</div>
<n-icon :size="48" :color="perkSubscriptionNames[user.perk_subscription.identifier].color">
<star-round />
</n-icon>
</div>
</n-card>
<n-card>
<div class="flex justify-between mb-2">
<n-text>Level {{ user.profile.level }}</n-text>
<n-text>{{ user.profile.experience }} XP</n-text>
</div>
<n-progress
type="line"
:percentage="user.profile.leveling_progress"
:height="8"
status="success"
:show-indicator="false"
/>
</n-card>
</div>
<div>
<n-card v-if="htmlBio" title="Bio">
<article
class="bio-prose prose dark:prose-invert prose-slate"
v-html="htmlBio"
></article>
</n-card>
</div>
</div>
</div>
</div>
<div v-else-if="notFound" class="flex justify-center items-center h-full">
<n-result
status="404"
title="User not found"
description="The user profile you're trying to access is not found."
/>
</div>
<div v-else class="flex justify-center items-center h-full">
<n-spin />
</div>
</template>
<script setup lang="ts">
import { NResult, NSpin, NCard, NAvatar, NText, NProgress, NIcon } from 'naive-ui'
import {
PersonRound,
CalendarMonthOutlined,
CakeOutlined,
DriveFileRenameOutlineOutlined,
LocationOnOutlined,
AccessTimeOutlined,
StarRound,
} from '@vicons/material'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { Marked } from 'marked'
const route = useRoute()
const notFound = ref<boolean>(false)
const user = ref<any>(null)
async function fetchUser() {
// @ts-ignore
if (window.__APP_DATA__?.Account != null) {
console.log('[Fetch] Use the pre-rendered account data.')
// @ts-ignore
user.value = window.__APP_DATA__['Account']
return
}
console.log('[Fetch] Using the API to load user data.')
try {
const resp = await fetch(`/api/accounts/${route.params.name}`)
user.value = await resp.json()
} catch (err) {
console.error(err)
notFound.value = true
}
}
onMounted(() => fetchUser())
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) : undefined
})
const userBackground = computed(() => {
return user.value?.profile.background
? `/cgi/drive/files/${user.value.profile.background.id}?original=true`
: undefined
})
const userPicture = computed(() => {
return user.value?.profile.picture
? `/cgi/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}`
}
</script>
<style>
.bio-prose img {
display: inline !important;
margin: 0 !important;
}
</style>