✨ Image cropper and account settings
This commit is contained in:
		| @@ -26,6 +26,7 @@ | ||||
|     "pinia": "^2.1.7", | ||||
|     "universal-cookie": "^7.1.0", | ||||
|     "vue": "^3.4.21", | ||||
|     "vue-advanced-cropper": "^2.8.8", | ||||
|     "vue-easy-lightbox": "^1.19.0", | ||||
|     "vue-router": "^4.3.0", | ||||
|     "vuetify": "^3.5.12" | ||||
|   | ||||
| @@ -11,6 +11,7 @@ body, | ||||
|  | ||||
| .no-scrollbar::-webkit-scrollbar { | ||||
|   width: 0; | ||||
|   display: none; | ||||
| } | ||||
|  | ||||
| html, body { | ||||
|   | ||||
| @@ -1,43 +1,32 @@ | ||||
| <template> | ||||
|   <v-menu> | ||||
|     <template #activator="{ props }"> | ||||
|       <v-btn flat exact v-bind="props" icon> | ||||
|         <v-avatar color="transparent" icon="mdi-account-circle" :image="'/api/avatar/' + id.userinfo.data?.avatar" /> | ||||
|       </v-btn> | ||||
|       <v-btn icon="mdi-menu-up" size="small" variant="text" v-bind="props" /> | ||||
|     </template> | ||||
|  | ||||
|     <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="Create account" prepend-icon="mdi-account-plus" :to="{ name: 'auth.sign-up' }" /> | ||||
|     </v-list> | ||||
|  | ||||
|     <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-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-menu> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { useUserinfo } from "@/stores/userinfo" | ||||
| import { computed } from "vue" | ||||
| import { signout as signoutAccount, useUserinfo } from "@/stores/userinfo" | ||||
|  | ||||
| const id = useUserinfo() | ||||
|  | ||||
| const username = computed(() => { | ||||
|   if (id.userinfo.isLoggedIn) { | ||||
|     return "@" + id.userinfo.data?.name | ||||
|   } else { | ||||
|     return "@vistor" | ||||
|   } | ||||
| async function signout() { | ||||
|   signoutAccount().then(() => { | ||||
|     window.location.reload() | ||||
|   }) | ||||
| const nickname = computed(() => { | ||||
|   if (id.userinfo.isLoggedIn) { | ||||
|     return id.userinfo.data?.nick | ||||
|   } else { | ||||
|     return "Anonymous" | ||||
| } | ||||
| }) | ||||
| </script> | ||||
|   | ||||
| @@ -54,27 +54,7 @@ | ||||
|             <v-avatar icon="mdi-account-circle" :image="id.userinfo.data?.picture" /> | ||||
|           </template> | ||||
|           <template #append> | ||||
|             <v-menu v-if="id.userinfo.isLoggedIn"> | ||||
|               <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' }" /> | ||||
|             <user-menu /> | ||||
|           </template> | ||||
|         </v-list-item> | ||||
|       </v-list> | ||||
| @@ -110,6 +90,7 @@ import { useUI } from "@/stores/ui" | ||||
| import RealmList from "@/components/realms/RealmList.vue" | ||||
| import NotificationList from "@/components/users/NotificationList.vue" | ||||
| import ChannelList from "@/components/chat/channels/ChannelList.vue" | ||||
| import UserMenu from "@/components/users/UserMenu.vue" | ||||
|  | ||||
| const ui = useUI() | ||||
| const expanded = ref<string[]>(["channels"]) | ||||
| @@ -141,21 +122,9 @@ const nickname = computed(() => { | ||||
|  | ||||
| id.readProfiles() | ||||
|  | ||||
| const meta = useWellKnown() | ||||
|  | ||||
| const passportUrl = computed(() => { | ||||
|   return meta.wellKnown?.components?.identity | ||||
| }) | ||||
|  | ||||
| meta.readWellKnown() | ||||
| useWellKnown().readWellKnown() | ||||
|  | ||||
| const drawerOpen = ref(true) | ||||
| const drawerMini = ref(false) | ||||
|  | ||||
| async function signout() { | ||||
|   signoutAccount().then(() => { | ||||
|     window.location.reload() | ||||
|   }) | ||||
| } | ||||
| </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 { plazaRouter } from "@/router/plaza" | ||||
| import { chatRouter } from "@/router/chat" | ||||
| import { settingRouter } from "@/router/settings" | ||||
|  | ||||
| const router = createRouter({ | ||||
|   history: createWebHistory(import.meta.env.BASE_URL), | ||||
| @@ -20,6 +21,12 @@ const router = createRouter({ | ||||
|           component: () => import("@/views/users/page.vue") | ||||
|         }, | ||||
|  | ||||
|         { | ||||
|           path: "/settings", | ||||
|           component: () => import("@/layouts/settings.vue"), | ||||
|           children: settingRouter, | ||||
|         }, | ||||
|  | ||||
|         { | ||||
|           path: "/", | ||||
|           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> | ||||
		Reference in New Issue
	
	Block a user