✨ User profile page webpage
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@layer theme, base, components, utilities;
|
||||
|
||||
|
43
DysonNetwork.Pass/Client/src/layouts/dashboard.vue
Normal file
43
DysonNetwork.Pass/Client/src/layouts/dashboard.vue
Normal 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>
|
@@ -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>
|
||||
|
@@ -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) => {
|
||||
|
@@ -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">
|
3
DysonNetwork.Pass/Client/src/views/accounts/security.vue
Normal file
3
DysonNetwork.Pass/Client/src/views/accounts/security.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<p>Security</p>
|
||||
</template>
|
281
DysonNetwork.Pass/Client/src/views/pfp/index.vue
Normal file
281
DysonNetwork.Pass/Client/src/views/pfp/index.vue
Normal 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>
|
Reference in New Issue
Block a user