✨ User personal page
This commit is contained in:
parent
1f3f4a7370
commit
031c3dee3b
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="text-xs text-center opacity-80">
|
||||
<p>Copyright © {{ new Date().getFullYear() }} Solsynth</p>
|
||||
<p>Powered by <a class="underline" href="#">Hydrogen.Identity</a></p>
|
||||
<p>Powered by <a class="underline" href="#">Hydrogen</a></p>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -1,12 +1,14 @@
|
||||
<template>
|
||||
<div class="flex gap-3">
|
||||
<div>
|
||||
<v-avatar
|
||||
color="grey-lighten-2"
|
||||
icon="mdi-account-circle"
|
||||
class="rounded-card"
|
||||
:image="props.item?.author.avatar"
|
||||
/>
|
||||
<router-link :to="{ name: 'users.page', params: { alias: props.item?.author.name ?? 'ghost' } }">
|
||||
<v-avatar
|
||||
color="grey-lighten-2"
|
||||
icon="mdi-account-circle"
|
||||
class="rounded-card"
|
||||
:image="props.item?.author.avatar"
|
||||
/>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow-1">
|
||||
|
@ -3,7 +3,7 @@
|
||||
<v-infinite-scroll :items="props.posts" :onLoad="props.loader">
|
||||
<template v-for="(item, idx) in props.posts" :key="item.id">
|
||||
<div class="mb-3 px-[8px]">
|
||||
<v-card>
|
||||
<v-card :variant="props.variant ?? 'elevated'">
|
||||
<template #text>
|
||||
<post-item brief :item="item" @update:item="(val) => updateItem(idx, val)" />
|
||||
</template>
|
||||
@ -17,7 +17,7 @@
|
||||
<script setup lang="ts">
|
||||
import PostItem from "@/components/posts/PostItem.vue"
|
||||
|
||||
const props = defineProps<{ posts: any[]; loader: (opts: any) => Promise<any> }>()
|
||||
const props = defineProps<{ variant?: string, posts: any[]; loader: (opts: any) => Promise<any> }>()
|
||||
const emits = defineEmits(["update:posts"])
|
||||
|
||||
function updateItem(idx: number, data: any) {
|
||||
|
@ -34,11 +34,14 @@
|
||||
</div>
|
||||
</v-toolbar>
|
||||
|
||||
<v-list class="flex-grow-1" :opened="drawerMini ? [] : expanded" @update:opened="(val) => expanded = val">
|
||||
<channel-list />
|
||||
<v-divider class="border-opacity-75 my-2" />
|
||||
<realm-list />
|
||||
</v-list>
|
||||
<div class="flex-grow-1">
|
||||
<v-list density="compact" :opened="drawerMini ? [] : expanded"
|
||||
@update:opened="(val) => expanded = val">
|
||||
<channel-list />
|
||||
<v-divider class="border-opacity-75 my-2" />
|
||||
<realm-list />
|
||||
</v-list>
|
||||
</div>
|
||||
|
||||
<!-- User info -->
|
||||
<v-list
|
||||
|
14
src/router/auth.ts
Normal file
14
src/router/auth.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export const authRouter = [
|
||||
{
|
||||
path: "sign-in",
|
||||
name: "auth.sign-in",
|
||||
component: () => import("@/views/auth/sign-in.vue"),
|
||||
meta: { public: true, title: "Sign in" }
|
||||
},
|
||||
{
|
||||
path: "sign-up",
|
||||
name: "auth.sign-up",
|
||||
component: () => import("@/views/auth/sign-up.vue"),
|
||||
meta: { public: true, title: "Sign up" }
|
||||
}
|
||||
]
|
7
src/router/chat.ts
Normal file
7
src/router/chat.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export const chatRouter = [
|
||||
{
|
||||
path: "",
|
||||
name: "chat.channel",
|
||||
component: () => import("@/views/chat/page.vue"),
|
||||
}
|
||||
]
|
@ -3,6 +3,10 @@ import MasterLayout from "@/layouts/master.vue"
|
||||
|
||||
import nprogress from "nprogress";
|
||||
|
||||
import { authRouter } from "@/router/auth"
|
||||
import { plazaRouter } from "@/router/plaza"
|
||||
import { chatRouter } from "@/router/chat"
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
@ -10,63 +14,27 @@ const router = createRouter({
|
||||
path: "/",
|
||||
component: MasterLayout,
|
||||
children: [
|
||||
{
|
||||
path: "/u/:alias",
|
||||
name: "users.page",
|
||||
component: () => import("@/views/users/page.vue")
|
||||
},
|
||||
|
||||
{
|
||||
path: "/",
|
||||
component: () => import("@/layouts/plaza.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
name: "explore",
|
||||
component: () => import("@/views/explore.vue")
|
||||
},
|
||||
|
||||
{
|
||||
path: "/p/moments/:alias",
|
||||
name: "posts.details.moments",
|
||||
component: () => import("@/views/posts/moments.vue")
|
||||
},
|
||||
{
|
||||
path: "/p/articles/:alias",
|
||||
name: "posts.details.articles",
|
||||
component: () => import("@/views/posts/articles.vue")
|
||||
},
|
||||
|
||||
{
|
||||
path: "/realms/:realmId",
|
||||
name: "realms.page",
|
||||
component: () => import("@/views/realms/page.vue")
|
||||
}
|
||||
]
|
||||
children: plazaRouter,
|
||||
},
|
||||
|
||||
{
|
||||
path: "/chat/:channel",
|
||||
component: () => import("@/layouts/chat.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
name: "chat.channel",
|
||||
component: () => import("@/views/chat/page.vue"),
|
||||
}
|
||||
]
|
||||
children: chatRouter,
|
||||
},
|
||||
|
||||
{
|
||||
path: "/auth",
|
||||
children: [
|
||||
{
|
||||
path: "sign-in",
|
||||
name: "auth.sign-in",
|
||||
component: () => import("@/views/auth/sign-in.vue"),
|
||||
meta: { public: true, title: "Sign in" }
|
||||
},
|
||||
{
|
||||
path: "sign-up",
|
||||
name: "auth.sign-up",
|
||||
component: () => import("@/views/auth/sign-up.vue"),
|
||||
meta: { public: true, title: "Sign up" }
|
||||
}
|
||||
]
|
||||
children: authRouter,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
24
src/router/plaza.ts
Normal file
24
src/router/plaza.ts
Normal file
@ -0,0 +1,24 @@
|
||||
export const plazaRouter = [
|
||||
{
|
||||
path: "/",
|
||||
name: "explore",
|
||||
component: () => import("@/views/explore.vue")
|
||||
},
|
||||
|
||||
{
|
||||
path: "/p/moments/:alias",
|
||||
name: "posts.details.moments",
|
||||
component: () => import("@/views/posts/moments.vue")
|
||||
},
|
||||
{
|
||||
path: "/p/articles/:alias",
|
||||
name: "posts.details.articles",
|
||||
component: () => import("@/views/posts/articles.vue")
|
||||
},
|
||||
|
||||
{
|
||||
path: "/realms/:realmId",
|
||||
name: "realms.page",
|
||||
component: () => import("@/views/realms/page.vue")
|
||||
}
|
||||
]
|
@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<v-container class="flex max-md:flex-col gap-3 overflow-auto h-auto no-scrollbar">
|
||||
<div class="timeline flex-grow-1 mt-[-16px]">
|
||||
<v-container class="wrapper overflow-auto h-auto no-scrollbar">
|
||||
<div class="timeline mt-[-16px]">
|
||||
<post-list v-model:posts="posts" :loader="readMore" />
|
||||
</div>
|
||||
|
||||
<div class="aside w-full h-full md:min-w-[320px] md:max-w-[320px] max-md:order-first">
|
||||
<div class="aside w-full max-md:order-first">
|
||||
<v-card title="Categories">
|
||||
<v-list density="compact">
|
||||
<v-list-item title="All" prepend-icon="mdi-apps" active></v-list-item>
|
||||
@ -28,10 +28,10 @@ async function readPosts() {
|
||||
const res = await request(
|
||||
"interactive",
|
||||
`/api/feed?` +
|
||||
new URLSearchParams({
|
||||
take: pagination.pageSize.toString(),
|
||||
offset: ((pagination.page - 1) * pagination.pageSize).toString()
|
||||
})
|
||||
new URLSearchParams({
|
||||
take: pagination.pageSize.toString(),
|
||||
offset: ((pagination.page - 1) * pagination.pageSize).toString()
|
||||
})
|
||||
)
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
@ -62,3 +62,18 @@ async function readMore({ done }: any) {
|
||||
|
||||
readPosts()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.wrapper {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -18,7 +18,12 @@
|
||||
<v-divider class="my-5 mx-[-16px] border-opacity-50" />
|
||||
|
||||
<div class="px-3 text-xs opacity-80 flex gap-1">
|
||||
<span>Written by {{ post?.author?.nick }}</span>
|
||||
<router-link
|
||||
class="underline"
|
||||
:to="{ name: 'users.page', params: { alias: post?.author.name ?? 'ghost' } }"
|
||||
>
|
||||
<span>Written by {{ post?.author?.nick }}</span>
|
||||
</router-link>
|
||||
<span>·</span>
|
||||
<span>Published at {{ new Date(post?.created_at).toLocaleString() }}</span>
|
||||
</div>
|
||||
|
@ -6,12 +6,14 @@
|
||||
<v-card-text>
|
||||
<div class="flex justify-between px-3">
|
||||
<div class="flex gap-1">
|
||||
<v-avatar
|
||||
color="grey-lighten-2"
|
||||
icon="mdi-account-circle"
|
||||
class="rounded-card me-2"
|
||||
:image="post?.author.avatar"
|
||||
/>
|
||||
<router-link :to="{ name: 'users.page', params: { alias: post?.author.name ?? 'ghost' } }">
|
||||
<v-avatar
|
||||
color="grey-lighten-2"
|
||||
icon="mdi-account-circle"
|
||||
class="rounded-card me-2"
|
||||
:image="post?.author.avatar"
|
||||
/>
|
||||
</router-link>
|
||||
|
||||
<div>
|
||||
<p class="font-bold">{{ post?.author.nick }}</p>
|
||||
@ -26,6 +28,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<v-divider class="mb-5 mt-3.5 mx-[-16px] border-opacity-50" />
|
||||
|
||||
<div class="px-3">
|
||||
|
203
src/views/users/page.vue
Normal file
203
src/views/users/page.vue
Normal file
@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<v-container class="wrapper overflow-auto h-auto no-scrollbar">
|
||||
<div class="name-card col-span-2">
|
||||
<v-card class="w-full">
|
||||
<v-img v-if="accountBanner" cover height="280px" :src="accountBanner" />
|
||||
|
||||
<v-card-text class="flex px-5 gap-1">
|
||||
<v-avatar
|
||||
color="grey-lighten-2"
|
||||
icon="mdi-account-circle"
|
||||
class="rounded-card me-2"
|
||||
:image="accountPicture ?? ''"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-medium">{{ metadata?.nick }}</h2>
|
||||
<span class="text-sm opacity-80">@{{ metadata?.name }}</span>
|
||||
</div>
|
||||
<p v-if="metadata?.description" class="mt-[-4px]">{{ metadata?.description }}</p>
|
||||
<p v-else class="mt-[-4px] italic">No description yet.</p>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<v-card class="browser h-fit">
|
||||
<v-tabs v-model="tab" align-tabs="center" bg-color="grey-lighten-4">
|
||||
<v-tab value="page">Personal Page</v-tab>
|
||||
<v-tab value="timeline">Timeline</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-card-text class="content">
|
||||
<v-window v-model="tab" content-class="px-5">
|
||||
<v-window-item value="page">
|
||||
<div class="px-3">
|
||||
<article v-if="page?.content" class="prose max-w-none" v-html="parseContent(page?.content)" />
|
||||
<article v-else>
|
||||
<v-alert variant="tonal" type="info">
|
||||
The user didn't customize its personal page.
|
||||
</v-alert>
|
||||
</article>
|
||||
</div>
|
||||
</v-window-item>
|
||||
|
||||
<v-window-item value="timeline">
|
||||
<post-list class="mt-[-16px]" variant="outlined" :loader="readMore" v-model:posts="posts" />
|
||||
</v-window-item>
|
||||
</v-window>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<div class="aside h-fit max-md:order-first">
|
||||
<v-card prepend-icon="mdi-account-details" title="Bio">
|
||||
<v-card-text class="flex flex-col gap-2.5">
|
||||
<div>
|
||||
<h3 class="font-bold">Power level</h3>
|
||||
<v-chip :color="parsePowerLevel(metadata?.power_level).color" size="small">
|
||||
<span>{{ parsePowerLevel(metadata?.power_level).title }}</span>
|
||||
<span> · </span>
|
||||
<span class="font-mono">{{ metadata?.power_level }}</span>
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-bold">Joined at</h3>
|
||||
<p>{{ new Date(metadata?.created_at).toLocaleString() }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-bold">UID</h3>
|
||||
<p class="text-mono opacity-90">#{{ metadata?.id.toString().padStart(12, '0') }}</p>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from "vue"
|
||||
import { buildRequestUrl, request } from "@/scripts/request"
|
||||
import { useRoute } from "vue-router"
|
||||
import PostList from "@/components/posts/PostList.vue"
|
||||
import { parse } from "marked"
|
||||
import Articles from "@/views/posts/articles.vue"
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const error = ref<null | string>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const tab = ref("page")
|
||||
|
||||
const pagination = reactive({ page: 1, pageSize: 5, total: 0 })
|
||||
|
||||
const metadata = ref<any>(null)
|
||||
const page = ref<any>(null)
|
||||
const posts = ref<any[]>([])
|
||||
|
||||
const accountPicture = computed(() => metadata.value?.avatar ?
|
||||
buildRequestUrl("identity", `/api/avatar/${metadata.value?.avatar}`) :
|
||||
null
|
||||
)
|
||||
const accountBanner = computed(() => metadata.value?.banner ?
|
||||
buildRequestUrl("identity", `/api/avatar/${metadata.value?.banner}`) :
|
||||
null
|
||||
)
|
||||
|
||||
async function readMetadata() {
|
||||
loading.value = true
|
||||
const res = await request("identity", `/api/users/${route.params.alias}`)
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
} else {
|
||||
metadata.value = await res.json()
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function readPage() {
|
||||
loading.value = true
|
||||
const res = await request("identity", `/api/users/${route.params.alias}/page`)
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
} else {
|
||||
page.value = await res.json()
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function readPosts() {
|
||||
const res = await request(
|
||||
"interactive",
|
||||
`/api/feed?` +
|
||||
new URLSearchParams({
|
||||
take: pagination.pageSize.toString(),
|
||||
offset: ((pagination.page - 1) * pagination.pageSize).toString(),
|
||||
authorId: route.params.alias as string
|
||||
})
|
||||
)
|
||||
if (res.status !== 200) {
|
||||
error.value = await res.text()
|
||||
} else {
|
||||
error.value = null
|
||||
const data = await res.json()
|
||||
pagination.total = data["count"]
|
||||
posts.value.push(...(data["data"] ?? []))
|
||||
}
|
||||
}
|
||||
|
||||
async function readMore({ done }: any) {
|
||||
// Reach the end of data
|
||||
if (pagination.total <= pagination.page * pagination.pageSize) {
|
||||
done("empty")
|
||||
return
|
||||
}
|
||||
|
||||
pagination.page++
|
||||
await readPosts()
|
||||
|
||||
if (error.value != null) done("error")
|
||||
else {
|
||||
if (pagination.total > 0) done("ok")
|
||||
else done("empty")
|
||||
}
|
||||
}
|
||||
|
||||
Promise.all([readMetadata(), readPage(), readPosts()])
|
||||
|
||||
function parsePowerLevel(level: number): { color: string, title: string } {
|
||||
if (level < 50) {
|
||||
return { color: "green", title: "User" }
|
||||
} else if (level < 100) {
|
||||
return { color: "orange", title: "Moderator" }
|
||||
} else {
|
||||
return { color: "red", title: "Administrator" }
|
||||
}
|
||||
}
|
||||
|
||||
function parseContent(src: string): string {
|
||||
return parse(src) as string
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.wrapper {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.rounded-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
Reference in New Issue
Block a user