✨ User profile page webpage
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"Debug": true,
|
||||
"BaseUrl": "http://localhost:5071",
|
||||
"BaseUrl": "http://localhost:5090",
|
||||
"GatewayUrl": "http://localhost:5094",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
|
@@ -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",
|
||||
|
@@ -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=="],
|
||||
|
@@ -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 />
|
||||
|
@@ -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",
|
||||
|
BIN
DysonNetwork.Pass/Client/public/favicon.png
Executable file
BIN
DysonNetwork.Pass/Client/public/favicon.png
Executable file
Binary file not shown.
After Width: | Height: | Size: 70 KiB |
@@ -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