✨ Image cropper and account settings
This commit is contained in:
		| @@ -26,6 +26,7 @@ | |||||||
|     "pinia": "^2.1.7", |     "pinia": "^2.1.7", | ||||||
|     "universal-cookie": "^7.1.0", |     "universal-cookie": "^7.1.0", | ||||||
|     "vue": "^3.4.21", |     "vue": "^3.4.21", | ||||||
|  |     "vue-advanced-cropper": "^2.8.8", | ||||||
|     "vue-easy-lightbox": "^1.19.0", |     "vue-easy-lightbox": "^1.19.0", | ||||||
|     "vue-router": "^4.3.0", |     "vue-router": "^4.3.0", | ||||||
|     "vuetify": "^3.5.12" |     "vuetify": "^3.5.12" | ||||||
|   | |||||||
| @@ -11,6 +11,7 @@ body, | |||||||
|  |  | ||||||
| .no-scrollbar::-webkit-scrollbar { | .no-scrollbar::-webkit-scrollbar { | ||||||
|   width: 0; |   width: 0; | ||||||
|  |   display: none; | ||||||
| } | } | ||||||
|  |  | ||||||
| html, body { | html, body { | ||||||
|   | |||||||
| @@ -1,43 +1,32 @@ | |||||||
| <template> | <template> | ||||||
|   <v-menu> |   <v-menu> | ||||||
|     <template #activator="{ props }"> |     <template #activator="{ props }"> | ||||||
|       <v-btn flat exact v-bind="props" icon> |       <v-btn icon="mdi-menu-up" size="small" variant="text" v-bind="props" /> | ||||||
|         <v-avatar color="transparent" icon="mdi-account-circle" :image="'/api/avatar/' + id.userinfo.data?.avatar" /> |  | ||||||
|       </v-btn> |  | ||||||
|     </template> |     </template> | ||||||
|  |  | ||||||
|     <v-list density="compact" v-if="!id.userinfo.isLoggedIn"> |     <v-list density="compact" v-if="!id.userinfo.isLoggedIn"> | ||||||
|       <v-list-item title="Sign in" prepend-icon="mdi-login-variant" :to="{ name: 'auth.sign-in' }" /> |       <v-list-item title="Sign in" prepend-icon="mdi-login-variant" :to="{ name: 'auth.sign-in' }" /> | ||||||
|       <v-list-item title="Create account" prepend-icon="mdi-account-plus" :to="{ name: 'auth.sign-up' }" /> |       <v-list-item title="Create account" prepend-icon="mdi-account-plus" :to="{ name: 'auth.sign-up' }" /> | ||||||
|     </v-list> |     </v-list> | ||||||
|  |  | ||||||
|     <v-list density="compact" v-else> |     <v-list density="compact" v-else> | ||||||
|       <v-list-item :title="nickname" :subtitle="username" /> |       <v-list-item title="Settings" prepend-icon="mdi-cog" exact :to="{ name: 'settings' }" /> | ||||||
|  |  | ||||||
|       <v-divider class="border-opacity-50 my-2" /> |       <v-divider class="border-opacity-50 my-2" /> | ||||||
|  |  | ||||||
|       <v-list-item title="User Center" prepend-icon="mdi-account-supervisor" exact :to="{ name: 'dashboard' }" /> |       <v-list-item title="Sign out" prepend-icon="mdi-logout-variant" @click="signout" /> | ||||||
|     </v-list> |     </v-list> | ||||||
|   </v-menu> |   </v-menu> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
| import { useUserinfo } from "@/stores/userinfo" | import { signout as signoutAccount, useUserinfo } from "@/stores/userinfo" | ||||||
| import { computed } from "vue" |  | ||||||
|  |  | ||||||
| const id = useUserinfo() | const id = useUserinfo() | ||||||
|  |  | ||||||
| const username = computed(() => { | async function signout() { | ||||||
|   if (id.userinfo.isLoggedIn) { |   signoutAccount().then(() => { | ||||||
|     return "@" + id.userinfo.data?.name |     window.location.reload() | ||||||
|   } else { |   }) | ||||||
|     return "@vistor" | } | ||||||
|   } |  | ||||||
| }) |  | ||||||
| const nickname = computed(() => { |  | ||||||
|   if (id.userinfo.isLoggedIn) { |  | ||||||
|     return id.userinfo.data?.nick |  | ||||||
|   } else { |  | ||||||
|     return "Anonymous" |  | ||||||
|   } |  | ||||||
| }) |  | ||||||
| </script> | </script> | ||||||
|   | |||||||
| @@ -54,27 +54,7 @@ | |||||||
|             <v-avatar icon="mdi-account-circle" :image="id.userinfo.data?.picture" /> |             <v-avatar icon="mdi-account-circle" :image="id.userinfo.data?.picture" /> | ||||||
|           </template> |           </template> | ||||||
|           <template #append> |           <template #append> | ||||||
|             <v-menu v-if="id.userinfo.isLoggedIn"> |             <user-menu /> | ||||||
|               <template #activator="{ props }"> |  | ||||||
|                 <v-btn v-bind="props" icon="mdi-menu-down" size="small" variant="text" /> |  | ||||||
|               </template> |  | ||||||
|  |  | ||||||
|               <v-list density="compact"> |  | ||||||
|                 <v-list-item |  | ||||||
|                   title="Sign out" |  | ||||||
|                   prepend-icon="mdi-logout-variant" |  | ||||||
|                   @click="signout" |  | ||||||
|                 /> |  | ||||||
|                 <v-list-item |  | ||||||
|                   title="Solarpass" |  | ||||||
|                   prepend-icon="mdi-passport-biometric" |  | ||||||
|                   target="_blank" |  | ||||||
|                   :href="passportUrl" |  | ||||||
|                 /> |  | ||||||
|               </v-list> |  | ||||||
|             </v-menu> |  | ||||||
|  |  | ||||||
|             <v-btn v-else icon="mdi-login-variant" size="small" variant="text" :to="{ name: 'auth.sign-in' }" /> |  | ||||||
|           </template> |           </template> | ||||||
|         </v-list-item> |         </v-list-item> | ||||||
|       </v-list> |       </v-list> | ||||||
| @@ -110,6 +90,7 @@ import { useUI } from "@/stores/ui" | |||||||
| import RealmList from "@/components/realms/RealmList.vue" | import RealmList from "@/components/realms/RealmList.vue" | ||||||
| import NotificationList from "@/components/users/NotificationList.vue" | import NotificationList from "@/components/users/NotificationList.vue" | ||||||
| import ChannelList from "@/components/chat/channels/ChannelList.vue" | import ChannelList from "@/components/chat/channels/ChannelList.vue" | ||||||
|  | import UserMenu from "@/components/users/UserMenu.vue" | ||||||
|  |  | ||||||
| const ui = useUI() | const ui = useUI() | ||||||
| const expanded = ref<string[]>(["channels"]) | const expanded = ref<string[]>(["channels"]) | ||||||
| @@ -141,21 +122,9 @@ const nickname = computed(() => { | |||||||
|  |  | ||||||
| id.readProfiles() | id.readProfiles() | ||||||
|  |  | ||||||
| const meta = useWellKnown() | useWellKnown().readWellKnown() | ||||||
|  |  | ||||||
| const passportUrl = computed(() => { |  | ||||||
|   return meta.wellKnown?.components?.identity |  | ||||||
| }) |  | ||||||
|  |  | ||||||
| meta.readWellKnown() |  | ||||||
|  |  | ||||||
| const drawerOpen = ref(true) | const drawerOpen = ref(true) | ||||||
| const drawerMini = ref(false) | const drawerMini = ref(false) | ||||||
|  |  | ||||||
| async function signout() { |  | ||||||
|   signoutAccount().then(() => { |  | ||||||
|     window.location.reload() |  | ||||||
|   }) |  | ||||||
| } |  | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										46
									
								
								src/layouts/settings.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/layouts/settings.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | |||||||
|  | <template> | ||||||
|  |   <v-container class="wrapper pt-6 px-6"> | ||||||
|  |     <div class="content"> | ||||||
|  |       <router-view /> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|  |     <div class="aside-nav max-md:order-first"> | ||||||
|  |       <v-card prepend-icon="mdi-cog" title="Settings"> | ||||||
|  |         <v-list density="comfortable"> | ||||||
|  |           <v-list-item title="Personalize" prepend-icon="mdi-card-bulleted-outline" :to="{ name: 'settings.account.personalize' }" /> | ||||||
|  |           <v-list-item title="Personal Page" prepend-icon="mdi-sitemap" :to="{ name: 'settings.account.personal-page' }" /> | ||||||
|  |  | ||||||
|  |           <v-divider class="border-[#000] my-2" /> | ||||||
|  |  | ||||||
|  |           <v-list-item title="Solarpass" prepend-icon="mdi-passport-biometric" append-icon="mdi-launch"  target="_blank" :href="passportUrl" /> | ||||||
|  |         </v-list> | ||||||
|  |       </v-card> | ||||||
|  |     </div> | ||||||
|  |   </v-container> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup lang="ts"> | ||||||
|  | import { useWellKnown } from "@/stores/wellKnown" | ||||||
|  | import { computed } from "vue" | ||||||
|  |  | ||||||
|  | const meta = useWellKnown() | ||||||
|  |  | ||||||
|  | const passportUrl = computed(() => { | ||||||
|  |   return meta.wellKnown?.components?.identity | ||||||
|  | }) | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <style scoped> | ||||||
|  | .wrapper { | ||||||
|  |   display: grid; | ||||||
|  |   grid-template-columns: 2fr 1fr; | ||||||
|  |  | ||||||
|  |   gap: 0.75rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @media (max-width: 768px) { | ||||||
|  |   .wrapper { | ||||||
|  |     grid-template-columns: 1fr; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | </style> | ||||||
| @@ -6,6 +6,7 @@ import nprogress from "nprogress"; | |||||||
| import { authRouter } from "@/router/auth" | import { authRouter } from "@/router/auth" | ||||||
| import { plazaRouter } from "@/router/plaza" | import { plazaRouter } from "@/router/plaza" | ||||||
| import { chatRouter } from "@/router/chat" | import { chatRouter } from "@/router/chat" | ||||||
|  | import { settingRouter } from "@/router/settings" | ||||||
|  |  | ||||||
| const router = createRouter({ | const router = createRouter({ | ||||||
|   history: createWebHistory(import.meta.env.BASE_URL), |   history: createWebHistory(import.meta.env.BASE_URL), | ||||||
| @@ -20,6 +21,12 @@ const router = createRouter({ | |||||||
|           component: () => import("@/views/users/page.vue") |           component: () => import("@/views/users/page.vue") | ||||||
|         }, |         }, | ||||||
|  |  | ||||||
|  |         { | ||||||
|  |           path: "/settings", | ||||||
|  |           component: () => import("@/layouts/settings.vue"), | ||||||
|  |           children: settingRouter, | ||||||
|  |         }, | ||||||
|  |  | ||||||
|         { |         { | ||||||
|           path: "/", |           path: "/", | ||||||
|           component: () => import("@/layouts/plaza.vue"), |           component: () => import("@/layouts/plaza.vue"), | ||||||
|   | |||||||
							
								
								
									
										18
									
								
								src/router/settings.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src/router/settings.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | |||||||
|  | export const settingRouter = [ | ||||||
|  |   { | ||||||
|  |     path: "", | ||||||
|  |     name: "settings", | ||||||
|  |     component: () => import("@/views/settings.vue") | ||||||
|  |   }, | ||||||
|  |  | ||||||
|  |   { | ||||||
|  |     path: "account/personalize", | ||||||
|  |     name: "settings.account.personalize", | ||||||
|  |     component: () => import("@/views/users/me/personalize.vue") | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     path: "account/personal-page", | ||||||
|  |     name: "settings.account.personal-page", | ||||||
|  |     component: () => import("@/views/users/me/personal-page.vue") | ||||||
|  |   } | ||||||
|  | ] | ||||||
							
								
								
									
										5
									
								
								src/views/settings.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/views/settings.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | <template> | ||||||
|  |   <div> | ||||||
|  |  | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
							
								
								
									
										65
									
								
								src/views/users/me/personal-page.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								src/views/users/me/personal-page.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,65 @@ | |||||||
|  | <template> | ||||||
|  |   <div> | ||||||
|  |     <v-card class="mb-3" title="Design" prepend-icon="mdi-pencil-ruler" :loading="loading"> | ||||||
|  |       <template #text> | ||||||
|  |         <v-form class="mt-1" @submit.prevent="submit"> | ||||||
|  |           <v-row dense> | ||||||
|  |             <v-col :cols="12"> | ||||||
|  |               <v-textarea hide-details label="Content" density="comfortable" variant="outlined" | ||||||
|  |                           v-model="data.content" /> | ||||||
|  |             </v-col> | ||||||
|  |           </v-row> | ||||||
|  |  | ||||||
|  |           <v-btn type="submit" class="mt-2" variant="text" prepend-icon="mdi-content-save" :disabled="loading"> | ||||||
|  |             Apply Changes | ||||||
|  |           </v-btn> | ||||||
|  |         </v-form> | ||||||
|  |       </template> | ||||||
|  |     </v-card> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup lang="ts"> | ||||||
|  | import { ref } from "vue" | ||||||
|  | import { getAtk } from "@/stores/userinfo" | ||||||
|  | import { request } from "@/scripts/request" | ||||||
|  | import { useUI } from "@/stores/ui" | ||||||
|  |  | ||||||
|  | const { showSnackbar, showErrorSnackbar } = useUI() | ||||||
|  | const loading = ref(false) | ||||||
|  |  | ||||||
|  | const data = ref<any>({}) | ||||||
|  |  | ||||||
|  | async function read() { | ||||||
|  |   loading.value = true | ||||||
|  |   const res = await request("identity", "/api/users/me/page", { | ||||||
|  |     headers: { Authorization: `Bearer ${(await getAtk())}` } | ||||||
|  |   }) | ||||||
|  |   if (res.status !== 200) { | ||||||
|  |     showErrorSnackbar(await res.text()) | ||||||
|  |   } else { | ||||||
|  |     data.value = await res.json() | ||||||
|  |   } | ||||||
|  |   loading.value = false | ||||||
|  | } | ||||||
|  |  | ||||||
|  | async function submit() { | ||||||
|  |   const payload = data.value | ||||||
|  |  | ||||||
|  |   loading.value = true | ||||||
|  |   const res = await request("identity", "/api/users/me/page", { | ||||||
|  |     method: "PUT", | ||||||
|  |     headers: { "Content-Type": "application/json", Authorization: `Bearer ${await getAtk()}` }, | ||||||
|  |     body: JSON.stringify(payload) | ||||||
|  |   }) | ||||||
|  |   if (res.status !== 200) { | ||||||
|  |     showErrorSnackbar(await res.text()) | ||||||
|  |   } else { | ||||||
|  |     await read() | ||||||
|  |     showSnackbar("Your personal page has been updated.") | ||||||
|  |   } | ||||||
|  |   loading.value = false | ||||||
|  | } | ||||||
|  |  | ||||||
|  | read() | ||||||
|  | </script> | ||||||
							
								
								
									
										234
									
								
								src/views/users/me/personalize.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										234
									
								
								src/views/users/me/personalize.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,234 @@ | |||||||
|  | <template> | ||||||
|  |   <div> | ||||||
|  |     <v-card class="mb-3" title="Information" prepend-icon="mdi-face-man-profile" :loading="loading"> | ||||||
|  |       <template #text> | ||||||
|  |         <v-form class="mt-1" @submit.prevent="submit"> | ||||||
|  |           <v-row dense> | ||||||
|  |             <v-col :xs="12" :md="6"> | ||||||
|  |               <v-text-field readonly hide-details label="Username" density="comfortable" variant="outlined" | ||||||
|  |                             v-model="data.name" /> | ||||||
|  |             </v-col> | ||||||
|  |             <v-col :xs="12" :md="6"> | ||||||
|  |               <v-text-field hide-details label="Nickname" density="comfortable" variant="outlined" | ||||||
|  |                             v-model="data.nick" /> | ||||||
|  |             </v-col> | ||||||
|  |             <v-col :cols="12"> | ||||||
|  |               <v-textarea hide-details label="Description" density="comfortable" variant="outlined" | ||||||
|  |                           v-model="data.description" /> | ||||||
|  |             </v-col> | ||||||
|  |             <v-col :xs="12" :md="6" :lg="4"> | ||||||
|  |               <v-text-field hide-details label="First Name" density="comfortable" variant="outlined" | ||||||
|  |                             v-model="data.first_name" /> | ||||||
|  |             </v-col> | ||||||
|  |             <v-col :xs="12" :md="6" :lg="4"> | ||||||
|  |               <v-text-field hide-details label="Last Name" density="comfortable" variant="outlined" | ||||||
|  |                             v-model="data.last_name" /> | ||||||
|  |             </v-col> | ||||||
|  |             <v-col :xs="12" :lg="4"> | ||||||
|  |               <v-text-field hide-details label="Birthday" density="comfortable" variant="outlined" type="datetime-local" | ||||||
|  |                             v-model="data.birthday" /> | ||||||
|  |             </v-col> | ||||||
|  |           </v-row> | ||||||
|  |  | ||||||
|  |           <v-btn type="submit" class="mt-2" variant="text" prepend-icon="mdi-content-save" :disabled="loading"> | ||||||
|  |             Apply Changes | ||||||
|  |           </v-btn> | ||||||
|  |         </v-form> | ||||||
|  |       </template> | ||||||
|  |     </v-card> | ||||||
|  |  | ||||||
|  |     <v-card> | ||||||
|  |       <v-card-text class="flex items-center gap-3"> | ||||||
|  |         <v-avatar | ||||||
|  |           color="grey-lighten-2" | ||||||
|  |           icon="mdi-account-circle" | ||||||
|  |           class="rounded-card" | ||||||
|  |           size="large" | ||||||
|  |           :image="accountPicture ?? ''" | ||||||
|  |         /> | ||||||
|  |         <v-file-input | ||||||
|  |           clearable | ||||||
|  |           hide-details | ||||||
|  |           label="Upload another avatar" | ||||||
|  |           variant="outlined" | ||||||
|  |           density="comfortable" | ||||||
|  |           accept="image/*" | ||||||
|  |           prepend-icon="" | ||||||
|  |           @input="(val: InputEvent) => loadImage(val, 'avatar')" | ||||||
|  |         /> | ||||||
|  |       </v-card-text> | ||||||
|  |  | ||||||
|  |       <v-img | ||||||
|  |         cover | ||||||
|  |         class="bg-grey-lighten-2" | ||||||
|  |         max-height="280px" | ||||||
|  |         :aspect-ratio="16 / 9" | ||||||
|  |         :src="accountBanner ?? ''" | ||||||
|  |       /> | ||||||
|  |  | ||||||
|  |       <v-card-text> | ||||||
|  |         <v-file-input | ||||||
|  |           clearable | ||||||
|  |           hide-details | ||||||
|  |           label="Update your banner" | ||||||
|  |           variant="outlined" | ||||||
|  |           density="comfortable" | ||||||
|  |           accept="image/*" | ||||||
|  |           prepend-icon="" | ||||||
|  |           @input="(val: InputEvent) => loadImage(val, 'banner')" | ||||||
|  |         /> | ||||||
|  |       </v-card-text> | ||||||
|  |     </v-card> | ||||||
|  |  | ||||||
|  |     <v-bottom-sheet class="max-w-[480px]" v-model="cropping"> | ||||||
|  |       <v-card prepend-icon="mdi-crop" title="Crop the image" class="no-scrollbar"> | ||||||
|  |         <div class="pt-3"> | ||||||
|  |           <vue-cropper | ||||||
|  |             ref="cropper" | ||||||
|  |             class="w-ful" | ||||||
|  |             :src="image.url" | ||||||
|  |             :stencil-props="{ aspectRatio: image.ratio }" | ||||||
|  |           /> | ||||||
|  |         </div> | ||||||
|  |         <v-card-actions> | ||||||
|  |           <v-spacer></v-spacer> | ||||||
|  |  | ||||||
|  |           <v-btn color="grey-darken-3" @click="cropping = false">Cancel</v-btn> | ||||||
|  |           <v-btn :disabled="loading" @click="applyImage">Apply</v-btn> | ||||||
|  |         </v-card-actions> | ||||||
|  |       </v-card> | ||||||
|  |     </v-bottom-sheet> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup lang="ts"> | ||||||
|  | import { computed, onUnmounted, ref, watch } from "vue" | ||||||
|  | import { useUserinfo, getAtk } from "@/stores/userinfo" | ||||||
|  | import { buildRequestUrl, request } from "@/scripts/request" | ||||||
|  | import { useUI } from "@/stores/ui" | ||||||
|  | import { Cropper as VueCropper } from "vue-advanced-cropper" | ||||||
|  |  | ||||||
|  | const id = useUserinfo() | ||||||
|  |  | ||||||
|  | const { showSnackbar, showErrorSnackbar } = useUI() | ||||||
|  | const loading = ref(false) | ||||||
|  |  | ||||||
|  | const data = ref<any>({}) | ||||||
|  | const image = ref<any>({ | ||||||
|  |   url: null, | ||||||
|  |   type: null, | ||||||
|  |   ratio: 1 | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | const cropper = ref<any>() | ||||||
|  | const cropping = ref(false) | ||||||
|  |  | ||||||
|  | watch( | ||||||
|  |   id.userinfo, | ||||||
|  |   (val) => { | ||||||
|  |     if (val.isReady) { | ||||||
|  |       data.value.name = id.userinfo.data.name | ||||||
|  |       data.value.nick = id.userinfo.data.nick | ||||||
|  |       data.value.description = id.userinfo.data.description | ||||||
|  |       data.value.first_name = id.userinfo.data.profile.first_name | ||||||
|  |       data.value.last_name = id.userinfo.data.profile.last_name | ||||||
|  |       data.value.birthday = id.userinfo.data.profile.birthday | ||||||
|  |  | ||||||
|  |       if (data.value.birthday) data.value.birthday = data.value.birthday.substring(0, 16) | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   { immediate: true, deep: true } | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | async function submit() { | ||||||
|  |   const payload = data.value | ||||||
|  |   if (payload.birthday) payload.birthday = new Date(payload.birthday).toISOString() | ||||||
|  |  | ||||||
|  |   loading.value = true | ||||||
|  |   const res = await request("identity", "/api/users/me", { | ||||||
|  |     method: "PUT", | ||||||
|  |     headers: { "Content-Type": "application/json", Authorization: `Bearer ${await getAtk()}` }, | ||||||
|  |     body: JSON.stringify(payload) | ||||||
|  |   }) | ||||||
|  |   if (res.status !== 200) { | ||||||
|  |     showErrorSnackbar(await res.text()) | ||||||
|  |   } else { | ||||||
|  |     await id.readProfiles() | ||||||
|  |     showSnackbar("Your personal information has been updated.") | ||||||
|  |   } | ||||||
|  |   loading.value = false | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function loadImage(event: InputEvent, type: string) { | ||||||
|  |   const { files } = event.target as HTMLInputElement | ||||||
|  |   if (!(files && files[0])) { | ||||||
|  |     return | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (image.value.url) URL.revokeObjectURL(image.value.url) | ||||||
|  |   const blob = URL.createObjectURL(files[0]) | ||||||
|  |   const reader = new FileReader() | ||||||
|  |   reader.addEventListener("load", () => image.value.url = blob) | ||||||
|  |   reader.readAsArrayBuffer(files[0]) | ||||||
|  |  | ||||||
|  |   if (type === "avatar") image.value.ratio = 1 | ||||||
|  |   if (type === "banner") image.value.ratio = 16 / 9 | ||||||
|  |  | ||||||
|  |   image.value.type = type | ||||||
|  |   cropping.value = true | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const accountPicture = computed(() => id.userinfo.data?.avatar ? | ||||||
|  |   buildRequestUrl("identity", `/api/avatar/${id.userinfo.data?.avatar}`) : | ||||||
|  |   null | ||||||
|  | ) | ||||||
|  | const accountBanner = computed(() => id.userinfo.data?.banner ? | ||||||
|  |   buildRequestUrl("identity", `/api/avatar/${id.userinfo.data?.banner}`) : | ||||||
|  |   null | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | onUnmounted(() => { | ||||||
|  |   if (image.value.url) URL.revokeObjectURL(image.value.url) | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | async function applyImage() { | ||||||
|  |   if (loading.value) return | ||||||
|  |   if (!image.value.url || !image.value.type) return | ||||||
|  |  | ||||||
|  |   const { canvas }: { canvas: HTMLCanvasElement } = cropper.value.getResult() | ||||||
|  |  | ||||||
|  |   const payload = new FormData() | ||||||
|  |   payload.set(image.value.type, await new Promise<Blob>((resolve, reject) => { | ||||||
|  |     canvas.toBlob((data: Blob | null) => { | ||||||
|  |       if (data == null) reject("Cannot get blob data") | ||||||
|  |       else resolve(data) | ||||||
|  |     }) | ||||||
|  |   })) | ||||||
|  |  | ||||||
|  |   cropping.value = false | ||||||
|  |  | ||||||
|  |   loading.value = true | ||||||
|  |   const res = await request("identity", `/api/users/me/${image.value.type}`, { | ||||||
|  |     method: "PUT", | ||||||
|  |     headers: { Authorization: `Bearer ${await getAtk()}` }, | ||||||
|  |     body: payload | ||||||
|  |   }) | ||||||
|  |   if (res.status !== 200) { | ||||||
|  |     showErrorSnackbar(await res.text()) | ||||||
|  |   } else { | ||||||
|  |     await id.readProfiles() | ||||||
|  |     showSnackbar("Your avatar has been updated.") | ||||||
|  |     image.value.url = null | ||||||
|  |   } | ||||||
|  |   loading.value = false | ||||||
|  | } | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <style> | ||||||
|  | @import "vue-advanced-cropper/dist/style.css"; | ||||||
|  | @import "vue-advanced-cropper/dist/theme.compact.css"; | ||||||
|  |  | ||||||
|  | .rounded-card { | ||||||
|  |   border-radius: 8px; | ||||||
|  | } | ||||||
|  | </style> | ||||||
| @@ -2,7 +2,7 @@ | |||||||
|   <v-container class="wrapper h-auto no-scrollbar"> |   <v-container class="wrapper h-auto no-scrollbar"> | ||||||
|     <div class="min-w-0 name-card md:col-span-2 max-md:order-first"> |     <div class="min-w-0 name-card md:col-span-2 max-md:order-first"> | ||||||
|       <v-card class="w-full"> |       <v-card class="w-full"> | ||||||
|         <v-img v-if="accountBanner" cover max-height="280px"  :aspect-ratio="16 / 9" :src="accountBanner" /> |         <v-img v-if="accountBanner" cover max-height="280px" :aspect-ratio="16 / 9" :src="accountBanner" /> | ||||||
|  |  | ||||||
|         <v-card-text class="flex px-5 gap-1"> |         <v-card-text class="flex px-5 gap-1"> | ||||||
|           <v-avatar |           <v-avatar | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user