diff --git a/.changes/update-profile.md b/.changes/update-profile.md new file mode 100644 index 0000000..a96f9ca --- /dev/null +++ b/.changes/update-profile.md @@ -0,0 +1,5 @@ +--- +"algohub": patch:feat +--- + +Support for updating profile information and account activation. diff --git a/.cspell.json b/.cspell.json index 78deead..706b598 100644 --- a/.cspell.json +++ b/.cspell.json @@ -1,6 +1,7 @@ { "words": [ "algohub", + "confirmationservice", "covector", "icns", "persistedstate", diff --git a/package.json b/package.json index a4661dc..7970719 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "primeicons": "^7.0.0", "primevue": "^4.2.2", "vue": "^3.5.13", + "vue-picture-cropper": "^0.7.0", "vue-router": "^4.4.5", "zod": "^3.23.8" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d46fa9..714f87e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: vue: specifier: ^3.5.13 version: 3.5.13(typescript@5.6.3) + vue-picture-cropper: + specifier: ^0.7.0 + version: 0.7.0(vue@3.5.13(typescript@5.6.3)) vue-router: specifier: ^4.4.5 version: 4.4.5(vue@3.5.13(typescript@5.6.3)) @@ -156,6 +159,9 @@ packages: resolution: {integrity: sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==} engines: {node: '>=6.9.0'} + '@bassist/utils@0.4.0': + resolution: {integrity: sha512-aoFTl0jUjm8/tDZodP41wnEkvB+C5O9NFCuYN/ztL6jSUSsuBkXq90/1ifBm1XhV/zySHgLYlU1+tgo3XtQ+nA==} + '@chainsafe/abort-controller@3.0.1': resolution: {integrity: sha512-oyq0qgFJDIIgLpyPwTv4j/sHX/MITatFzY3/b42VSldyZfnUC1lYBx5RwFvzBv1Sq4APOj2VCZO23pDRwy5kew==} engines: {node: '>=6.5'} @@ -683,6 +689,9 @@ packages: '@vue/shared@3.5.13': resolution: {integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==} + '@withtypes/mime@0.1.2': + resolution: {integrity: sha512-PB9BfZGzwblUONJY0LiOwsHCA6uV3DIPj/w9ReekdHxPOl0VdUFgI5s4avKycuuq9Gf5Nz2ZPA2O36GAUzlMPA==} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -860,6 +869,9 @@ packages: engines: {node: '>=18'} hasBin: true + cropperjs@1.6.2: + resolution: {integrity: sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==} + cross-fetch@3.1.5: resolution: {integrity: sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==} @@ -1297,6 +1309,11 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + mimic-fn@4.0.0: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} @@ -1896,6 +1913,11 @@ packages: '@vue/composition-api': optional: true + vue-picture-cropper@0.7.0: + resolution: {integrity: sha512-NF7+Dgso6d0GB16E5d/BbrcTIHm1VWz8dS3IjLhoBl+ZeC+yDA46CyJphQuO32SisaPmrKHN8VbiE2LgAfhnkQ==} + peerDependencies: + vue: '>=3.2.13' + vue-router@4.4.5: resolution: {integrity: sha512-4fKZygS8cH1yCyuabAXGUAsyi1b2/o/OKgu/RUb+znIYOxPRxdkytJEx+0wGcpBE1pX6vUgh5jwWOKRGvuA/7Q==} peerDependencies: @@ -2105,6 +2127,10 @@ snapshots: '@babel/helper-string-parser': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 + '@bassist/utils@0.4.0': + dependencies: + '@withtypes/mime': 0.1.2 + '@chainsafe/abort-controller@3.0.1': dependencies: event-target-shim: 5.0.1 @@ -2120,18 +2146,17 @@ snapshots: picocolors: 1.1.1 sisteransi: 1.0.5 - '@covector/apply@0.10.0(mocha@10.8.2)': + '@covector/apply@0.10.0': dependencies: '@covector/files': 0.8.0 effection: 2.0.8(mocha@10.8.2) semver: 7.6.3 transitivePeerDependencies: - encoding - - mocha - '@covector/assemble@0.12.0': + '@covector/assemble@0.12.0(mocha@10.8.2)': dependencies: - '@covector/command': 0.8.0 + '@covector/command': 0.8.0(mocha@10.8.2) '@covector/files': 0.8.0 effection: 2.0.8(mocha@10.8.2) js-yaml: 4.1.0 @@ -2142,6 +2167,7 @@ snapshots: unified: 9.2.2 transitivePeerDependencies: - encoding + - mocha - supports-color '@covector/changelog@0.12.0': @@ -2156,12 +2182,13 @@ snapshots: - encoding - supports-color - '@covector/command@0.8.0': + '@covector/command@0.8.0(mocha@10.8.2)': dependencies: '@effection/process': 2.1.4 effection: 2.0.8(mocha@10.8.2) transitivePeerDependencies: - encoding + - mocha '@covector/files@0.8.0': dependencies: @@ -2643,6 +2670,10 @@ snapshots: '@vue/shared@3.5.13': {} + '@withtypes/mime@0.1.2': + dependencies: + mime: 3.0.0 + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -2807,10 +2838,10 @@ snapshots: covector@0.12.3(mocha@10.8.2): dependencies: '@clack/prompts': 0.7.0 - '@covector/apply': 0.10.0(mocha@10.8.2) - '@covector/assemble': 0.12.0 + '@covector/apply': 0.10.0 + '@covector/assemble': 0.12.0(mocha@10.8.2) '@covector/changelog': 0.12.0 - '@covector/command': 0.8.0 + '@covector/command': 0.8.0(mocha@10.8.2) '@covector/files': 0.8.0 effection: 2.0.8(mocha@10.8.2) globby: 11.1.0 @@ -2825,6 +2856,8 @@ snapshots: - mocha - supports-color + cropperjs@1.6.2: {} + cross-fetch@3.1.5: dependencies: node-fetch: 2.6.7 @@ -3246,6 +3279,8 @@ snapshots: dependencies: mime-db: 1.52.0 + mime@3.0.0: {} + mimic-fn@4.0.0: {} minimatch@5.1.6: @@ -3877,6 +3912,12 @@ snapshots: dependencies: vue: 3.5.13(typescript@5.6.3) + vue-picture-cropper@0.7.0(vue@3.5.13(typescript@5.6.3)): + dependencies: + '@bassist/utils': 0.4.0 + cropperjs: 1.6.2 + vue: 3.5.13(typescript@5.6.3) + vue-router@4.4.5(vue@3.5.13(typescript@5.6.3)): dependencies: '@vue/devtools-api': 6.6.4 diff --git a/src/main.ts b/src/main.ts index 6ce7b65..344d5fe 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,6 +7,7 @@ import App from "@/App.vue"; import PrimeVue from "primevue/config"; import Aura from "@primevue/themes/aura"; import ToastService from "primevue/toastservice"; +import ConfirmationService from 'primevue/confirmationservice'; import router from "./router"; import { createPinia } from "pinia"; @@ -30,5 +31,6 @@ app.use(PrimeVue, { }, }); app.use(ToastService); +app.use(ConfirmationService); app.mount("#app"); diff --git a/src/scripts/api.ts b/src/scripts/api.ts index d8b3363..7e48333 100644 --- a/src/scripts/api.ts +++ b/src/scripts/api.ts @@ -35,3 +35,61 @@ export const register = async (form: Register) => { } as ErrorResponse; } }; + +interface UploadAvatar { + id: string; + token: string; + file: File; +} + +interface UploadAvatarResponse { + uri: string; +} + +export const uploadAvatar = async (form: UploadAvatar) => { + try { + const response = await axios.put("/account/content/upload", form, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + return response.data as Response; + } catch (error) { + return { + success: false, + message: AxiosError.from(error).message, + } as ErrorResponse; + } +}; + +interface Profile { + avatar?: string; + signature?: string; + links?: string[]; + nickname?: string; + sex?: boolean; + birthday?: string; + name?: string; + student_id?: string; + school?: string; + college?: string; + major?: string; +} + +interface ProfileForm { + id: string; + token: string; + profile: Profile; +} + +export const updateProfile = async (form: ProfileForm) => { + try { + const response = await axios.post("/account/profile", form); + return response.data as Response; + } catch (error) { + return { + success: false, + message: AxiosError.from(error).message, + }; + } +}; diff --git a/src/scripts/store.ts b/src/scripts/store.ts index d667ac9..5b26c08 100644 --- a/src/scripts/store.ts +++ b/src/scripts/store.ts @@ -1,5 +1,5 @@ import { defineStore } from "pinia"; -import { ref } from "vue"; +import { computed, ref } from "vue"; const prefersDarkMode = () => { return ( @@ -34,3 +34,54 @@ export const useThemeStore = defineStore( persist: true, } ); + +interface Account { + id?: string; + token?: string; + + username: string; + email: string; + avatar?: string; + signature?: string; + links?: string[]; + + nickname?: string; + sex?: boolean; + birthday?: Date; + + name?: string; + student_id?: string; + school?: string; + college?: string; + major?: string; + + rating?: number; + role?: number; + active?: boolean; +} + +export const useAccountStore = defineStore( + "account", + () => { + const account = ref(null); + + const isLoggedIn = computed( + () => account.value !== null && account.value.token + ); + + const mergeProfile = (profile: Partial) => { + if (account.value) { + Object.assign(account.value, profile); + } + }; + + const logout = () => { + account.value = null; + }; + + return { account, isLoggedIn, mergeProfile, logout }; + }, + { + persist: true, + } +); diff --git a/src/views/index.vue b/src/views/index.vue index 6fb167e..585f369 100644 --- a/src/views/index.vue +++ b/src/views/index.vue @@ -1,11 +1,15 @@ diff --git a/src/views/signup.vue b/src/views/signup.vue index df06fc0..e501554 100644 --- a/src/views/signup.vue +++ b/src/views/signup.vue @@ -2,14 +2,21 @@ import { reactive, type Ref, ref } from "vue"; import { useToast } from 'primevue/usetoast'; import { useRouter } from "vue-router"; -import { useThemeStore } from "../scripts/store"; +import { useAccountStore, useThemeStore } from "../scripts/store"; +import VuePictureCropper, { cropper } from 'vue-picture-cropper' import * as api from "@/scripts/api"; +import { type FileUploadSelectEvent, useConfirm } from "primevue"; const toast = useToast(); +const confirm = useConfirm(); const router = useRouter(); const themeStore = useThemeStore(); +const accountStore = useAccountStore(); -const activeStep = ref("1"); +const activeStep = ref("3"); +const isShowAvatarCutter = ref(false); +const avatarString = ref(''); +const croppedAvatar = ref(''); interface RegisterForm { username?: T; @@ -19,7 +26,7 @@ interface RegisterForm { terms?: T; } -const resolver = ({ values }: { values: RegisterForm }) => { +const registerResolver = ({ values }: { values: RegisterForm }) => { const errors: RegisterForm<{ message: string }[]> = {}; if (!values.username) { @@ -59,12 +66,216 @@ const onRegister = async ({ valid, states }: { valid: boolean, states: RegisterF email: states.email!.value, password: states.password!.value, }) + if (!res.success) { + inProgress.value = false; + return toast.add({ severity: "error", summary: "Registration failed", detail: res.message, life: 3000 }); + } + accountStore.account = { + username: states.username!.value, + email: states.email!.value, + ...res.data! + }; inProgress.value = false; - if (res.success) - toast.add({ severity: "success", summary: "Registered successfully", detail: "You are now logged in, perhaps filled with your profile." }); + if (res.success) { + toast.add({ + severity: "success", summary: "Registered successfully", detail: "You are now logged in, perhaps filled with your profile.", life: 3000 + }); + activeStep.value = "2"; + } else - toast.add({ severity: "error", summary: "Registration failed", detail: res.message }); + toast.add({ severity: "error", summary: "Registration failed", detail: res.message, life: 3000 }); +} + +const selectAvatar = async (event: FileUploadSelectEvent) => { + inProgress.value = true + avatarString.value = '' + + const file = event.files[0]; + const reader = new FileReader() + reader.readAsDataURL(file) + reader.onload = () => { + // Update the picture source of the `img` prop + avatarString.value = String(reader.result) + + // Show the cutter + isShowAvatarCutter.value = true + confirm.require({ + group: 'templating', + header: 'Crop your avatar', + rejectProps: { + label: 'Cancel', + icon: 'pi pi-times', + outlined: true, + size: 'small' + }, + acceptProps: { + label: 'Save', + icon: 'pi pi-check', + size: 'small' + }, + accept: async () => { + if (!cropper) return + croppedAvatar.value = cropper.getDataURL(); + const cropped = await cropper.getFile({ + fileName: file.name, + }) + if (cropped) { + const res = await api.uploadAvatar({ + id: accountStore.account!.id!, + token: accountStore.account!.token!, + file: cropped, + }) + if (!res.success) { + inProgress.value = false + return toast.add({ severity: "error", summary: "Upload failed", detail: res.message }); + } + accountStore.account!.avatar = res.data!.uri; + toast.add({ severity: "success", summary: "Avatar uploaded", detail: "Your new avatar has been saved." }); + } else { + toast.add({ severity: "error", summary: "Crop failed", detail: "Failed to initialize cropper." }); + } + inProgress.value = false + }, + reject: () => { + avatarString.value = '' + inProgress.value = false + } + }) + } +} + +const sexOptions = [ + { name: 'Male', value: true }, + { name: 'Female', value: false }, +] + +interface UpdateProfileForm { + nickname?: T; + signature?: T; + sex?: S; + birthday?: T; + avatar?: T; +} + +const updateProfileResolver = ({ values }: { values: UpdateProfileForm }) => { + const errors: UpdateProfileForm<{ message: string }[], { message: string }[]> = {}; + + if (values.nickname && values.nickname.length > 16) { + errors.nickname = [{ message: "Nickname is too long (16 characters max)." }] + } + if (values.signature && values.signature.length > 64) { + errors.signature = [{ message: "Signature is too long (64 characters max)." }] + } +} + +const onUpdateProfile = async ({ valid, states }: { + valid: boolean, + states: UpdateProfileForm, Ref> +}) => { + if (!valid) return; + + inProgress.value = true; + const res = await api.updateProfile({ + id: accountStore.account!.id!, + token: accountStore.account!.token!, + profile: { + nickname: states.nickname!.value, + signature: states.signature!.value, + sex: states.sex!.value, + // birthday: states.birthday!.value, + avatar: accountStore.account!.avatar, + } + }) + if (!res.success) { + toast.add({ severity: "error", summary: "Update failed", detail: res.message }); + } else { + toast.add({ severity: "success", summary: "Profile updated", detail: "Your profile has been updated." }); + accountStore.mergeProfile({ + nickname: states.nickname!.value, + signature: states.signature!.value, + sex: states.sex!.value, + // birthday: states.birthday!.value, + }) + } + inProgress.value = false; + + activeStep.value = "3"; +} + +interface CompleteForm { + name?: T, + student_id?: T, + school?: T, + college?: T, + major?: T, +} + +const completeResolver = ({ values }: { values: CompleteForm }) => { + const errors: CompleteForm<{ message: string }[]> = {}; + + if (!values.name) { + errors.name = [{ message: "Name is required." }]; + } else if (values.name.length > 16) { + errors.name = [{ message: "Name is too long (16 characters max)." }] + } + + if (!values.student_id) { + errors.student_id = [{ message: "Student ID is required." }]; + } else if (values.student_id.length !== 12) { + errors.student_id = [{ message: "Student ID is invalid (12 characters expected)." }] + } + + if (!values.school) { + errors.school = [{ message: "School is required." }] + } + if (!values.college) { + errors.college = [{ message: "College is required." }] + } + if (!values.major) { + errors.major = [{ message: "Major is required." }] + } + + return { errors }; +} + +const completeInitialValues = reactive({ + name: "", + student_id: "", + school: "西南石油大学", + college: "", + major: "", +}) + +const onComplete = async ({ valid, states }: { valid: boolean, states: CompleteForm> }) => { + if (!valid) return; + + inProgress.value = true; + const res = await api.updateProfile({ + id: accountStore.account!.id!, + token: accountStore.account!.token!, + profile: { + name: states.name!.value, + student_id: states.student_id!.value, + school: states.school!.value, + college: states.college!.value, + major: states.major!.value, + } + }) + if (!res.success) { + toast.add({ severity: "error", summary: "Update failed", detail: res.message, life: 3000 }); + } else { + toast.add({ severity: "success", summary: "Profile updated", detail: "Your profile has been updated.", life: 3000 }); + accountStore.mergeProfile({ + name: states.name!.value, + student_id: states.student_id!.value, + school: states.school!.value, + college: states.college!.value, + major: states.major!.value, + }) + } + inProgress.value = false; + router.push('/') } @@ -78,8 +289,8 @@ const onRegister = async ({ valid, states }: { valid: boolean, states: RegisterF Signup - - + Profile + Complete @@ -87,8 +298,8 @@ const onRegister = async ({ valid, states }: { valid: boolean, states: RegisterF + + + + + + + + + + + + + + + + + +