✨ User personal page
This commit is contained in:
		| @@ -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