| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,280 @@ | ||
| use sqlx::Row; | ||
|
|
||
| #[derive(actix_multipart::form::MultipartForm)] | ||
| pub struct UserForm { | ||
| first_name: Option<actix_multipart::form::text::Text<String>>, | ||
| last_name: Option<actix_multipart::form::text::Text<String>>, | ||
| thumbnail: Option<actix_multipart::form::tempfile::TempFile>, | ||
| phone_number: Option<actix_multipart::form::text::Text<String>>, | ||
| birth_date: Option<actix_multipart::form::text::Text<chrono::NaiveDate>>, | ||
| github_link: Option<actix_multipart::form::text::Text<String>>, | ||
| } | ||
|
|
||
| #[derive(serde::Deserialize, Debug)] | ||
| pub struct UpdateUser { | ||
| first_name: Option<String>, | ||
| thumbnail: Option<String>, | ||
| last_name: Option<String>, | ||
| } | ||
| #[derive(serde::Deserialize, Debug)] | ||
| pub struct UpdateUserProfile { | ||
| phone_number: Option<String>, | ||
| birth_date: Option<chrono::NaiveDate>, | ||
| github_link: Option<String>, | ||
| } | ||
|
|
||
| #[derive(serde::Deserialize)] | ||
| pub struct Thumbnail { | ||
| pub thumbnail: Option<String>, | ||
| } | ||
|
|
||
| #[tracing::instrument(name = "Updating an user", skip(pool, form, session))] | ||
| #[actix_web::patch("/update-user/")] | ||
| pub async fn update_users_details( | ||
| pool: actix_web::web::Data<sqlx::postgres::PgPool>, | ||
| form: actix_multipart::form::MultipartForm<UserForm>, | ||
| session: actix_session::Session, | ||
| s3_client: actix_web::web::Data<crate::uploads::Client>, | ||
| ) -> actix_web::HttpResponse { | ||
| let session_uuid = match crate::routes::users::logout::session_user_id(&session).await { | ||
| Ok(id) => id, | ||
| Err(e) => { | ||
| tracing::event!(target: "session",tracing::Level::ERROR, "Failed to get user from session. User unauthorized: {}", e); | ||
| return actix_web::HttpResponse::Unauthorized().json(crate::types::ErrorResponse { | ||
| error: "You are not logged in. Kindly ensure you are logged in and try again" | ||
| .to_string(), | ||
| }); | ||
| } | ||
| }; | ||
|
|
||
| // Create a transaction object. | ||
| let mut transaction = match pool.begin().await { | ||
| Ok(transaction) => transaction, | ||
| Err(e) => { | ||
| tracing::event!(target: "backend", tracing::Level::ERROR, "Unable to begin DB transaction: {:#?}", e); | ||
| return actix_web::HttpResponse::InternalServerError().json( | ||
| crate::types::ErrorResponse { | ||
| error: "Something unexpected happend. Kindly try again.".to_string(), | ||
| }, | ||
| ); | ||
| } | ||
| }; | ||
|
|
||
| // At first, set all fields to update to None except user_id | ||
| let mut updated_user = UpdateUser { | ||
| first_name: None, | ||
| last_name: None, | ||
| thumbnail: None, | ||
| }; | ||
|
|
||
| let mut user_profile = UpdateUserProfile { | ||
| phone_number: None, | ||
| birth_date: None, | ||
| github_link: None, | ||
| }; | ||
|
|
||
| // If thumbnail was included for update | ||
| if let Some(thumbnail) = &form.0.thumbnail { | ||
| // Get user's current thumbnail from the DB | ||
| let user_current_thumbnail = match sqlx::query("SELECT thumbnail FROM users WHERE id=$1") | ||
| .bind(session_uuid) | ||
| .map(|row: sqlx::postgres::PgRow| Thumbnail { | ||
| thumbnail: row.get("thumbnail"), | ||
| }) | ||
| .fetch_one(&mut *transaction) | ||
| .await | ||
| { | ||
| Ok(image_url) => image_url.thumbnail, | ||
| Err(e) => { | ||
| tracing::event!(target: "sqlx",tracing::Level::ERROR, "Failed to get user thumbnail from the DB: {:#?}", e); | ||
| None | ||
| } | ||
| }; | ||
| // If there is a current image, delete it | ||
| if let Some(url) = user_current_thumbnail { | ||
| let s3_image_key = &url[url.find("media").unwrap_or(url.len())..]; | ||
|
|
||
| if !s3_client.delete_file(s3_image_key).await { | ||
| tracing::event!(target: "backend",tracing::Level::INFO, "We could not delete the current thumbnail of user with ID: {}", session_uuid); | ||
| } | ||
| } | ||
| // make key prefix (make sure it ends with a forward slash) | ||
| let s3_key_prefix = format!("media/rust-auth/{session_uuid}/"); | ||
| // upload temp files to s3 and then remove them | ||
| let uploaded_file = s3_client.upload(thumbnail, &s3_key_prefix).await; | ||
| updated_user.thumbnail = Some(uploaded_file.s3_url); | ||
| } | ||
|
|
||
| // If first_name is updated | ||
| if let Some(f_name) = form.0.first_name { | ||
| updated_user.first_name = Some(f_name.0); | ||
| } | ||
|
|
||
| // If last_name is updated | ||
| if let Some(l_name) = form.0.last_name { | ||
| updated_user.last_name = Some(l_name.0); | ||
| } | ||
|
|
||
| // If phone_number is updated | ||
| if let Some(phone) = form.0.phone_number { | ||
| user_profile.phone_number = Some(phone.0); | ||
| } | ||
| // If birth_date is updated | ||
| if let Some(bd) = form.0.birth_date { | ||
| user_profile.birth_date = Some(bd.0); | ||
| } | ||
| // If github_link is updated | ||
| if let Some(gl) = form.0.github_link { | ||
| user_profile.github_link = Some(gl.0); | ||
| } | ||
|
|
||
| // Update a user in the DB | ||
| match update_user_in_db(&mut transaction, &updated_user, &user_profile, session_uuid).await { | ||
| Ok(u) => u, | ||
| Err(e) => { | ||
| tracing::event!(target: "sqlx",tracing::Level::ERROR, "Failed to update user in DB: {:#?}", e); | ||
| let error_message = crate::types::ErrorResponse { | ||
| error: format!("User could not be updated: {e}"), | ||
| }; | ||
| return actix_web::HttpResponse::InternalServerError().json(error_message); | ||
| } | ||
| }; | ||
|
|
||
| let updated_user = match crate::utils::get_active_user_from_db( | ||
| None, | ||
| Some(&mut transaction), | ||
| Some(session_uuid), | ||
| None, | ||
| ) | ||
| .await | ||
| { | ||
| Ok(user) => { | ||
| tracing::event!(target: "backend", tracing::Level::INFO, "User retrieved from the DB."); | ||
| crate::types::UserVisible { | ||
| id: user.id, | ||
| email: user.email, | ||
| first_name: user.first_name, | ||
| last_name: user.last_name, | ||
| is_active: user.is_active, | ||
| is_staff: user.is_staff, | ||
| is_superuser: user.is_superuser, | ||
| date_joined: user.date_joined, | ||
| thumbnail: user.thumbnail, | ||
| profile: crate::types::UserProfile { | ||
| id: user.profile.id, | ||
| user_id: user.profile.user_id, | ||
| phone_number: user.profile.phone_number, | ||
| birth_date: user.profile.birth_date, | ||
| github_link: user.profile.github_link, | ||
| }, | ||
| } | ||
| } | ||
| Err(e) => { | ||
| tracing::event!(target: "backend", tracing::Level::ERROR, "User cannot be retrieved from the DB: {:#?}", e); | ||
| let error_message = crate::types::ErrorResponse { | ||
| error: "User was not found".to_string(), | ||
| }; | ||
| return actix_web::HttpResponse::NotFound().json(error_message); | ||
| } | ||
| }; | ||
|
|
||
| if transaction.commit().await.is_err() { | ||
| return actix_web::HttpResponse::InternalServerError().finish(); | ||
| } | ||
|
|
||
| tracing::event!(target: "backend", tracing::Level::INFO, "User updated successfully."); | ||
| actix_web::HttpResponse::Ok().json(updated_user) | ||
| } | ||
|
|
||
| #[tracing::instrument(name = "Updating user in DB.", skip(transaction))] | ||
| async fn update_user_in_db( | ||
| transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, | ||
| user_to_update: &UpdateUser, | ||
| user_profile: &UpdateUserProfile, | ||
| user_id: uuid::Uuid, | ||
| ) -> Result<(), sqlx::Error> { | ||
| match sqlx::query( | ||
| " | ||
| UPDATE | ||
| users | ||
| SET | ||
| first_name = COALESCE($1, first_name), | ||
| last_name = COALESCE($2, last_name), | ||
| thumbnail = COALESCE($3, thumbnail) | ||
| WHERE | ||
| id = $4 | ||
| AND is_active = true | ||
| AND ( | ||
| $1 IS NOT NULL | ||
| AND $1 IS DISTINCT | ||
| FROM | ||
| first_name | ||
| OR $2 IS NOT NULL | ||
| AND $2 IS DISTINCT | ||
| FROM | ||
| last_name | ||
| OR $3 IS NOT NULL | ||
| AND $3 IS DISTINCT | ||
| FROM | ||
| thumbnail | ||
| )", | ||
| ) | ||
| .bind(&user_to_update.first_name) | ||
| .bind(&user_to_update.last_name) | ||
| .bind(&user_to_update.thumbnail) | ||
| .bind(user_id) | ||
| .execute(&mut *transaction) | ||
| .await | ||
| { | ||
| Ok(r) => { | ||
| tracing::event!(target: "sqlx", tracing::Level::INFO, "User has been updated successfully: {:#?}", r); | ||
| } | ||
| Err(e) => { | ||
| tracing::event!(target: "sqlx",tracing::Level::ERROR, "Failed to update user into DB: {:#?}", e); | ||
| return Err(e); | ||
| } | ||
| } | ||
|
|
||
| match sqlx::query( | ||
| " | ||
| UPDATE | ||
| user_profile | ||
| SET | ||
| phone_number = COALESCE($1, phone_number), | ||
| birth_date = $2, | ||
| github_link = COALESCE($3, github_link) | ||
| WHERE | ||
| user_id = $4 | ||
| AND ( | ||
| $1 IS NOT NULL | ||
| AND $1 IS DISTINCT | ||
| FROM | ||
| phone_number | ||
| OR $2 IS NOT NULL | ||
| AND $2 IS DISTINCT | ||
| FROM | ||
| birth_date | ||
| OR $3 IS NOT NULL | ||
| AND $3 IS DISTINCT | ||
| FROM | ||
| github_link | ||
| )", | ||
| ) | ||
| .bind(&user_profile.phone_number) | ||
| .bind(user_profile.birth_date) | ||
| .bind(&user_profile.github_link) | ||
| .bind(user_id) | ||
| .execute(&mut *transaction) | ||
| .await | ||
| { | ||
| Ok(r) => { | ||
| tracing::event!(target: "sqlx", tracing::Level::INFO, "User profile has been updated successfully: {:#?}", r); | ||
| } | ||
| Err(e) => { | ||
| tracing::event!(target: "sqlx",tracing::Level::ERROR, "Failed to update user profile into DB: {:#?}", e); | ||
| return Err(e); | ||
| } | ||
| } | ||
|
|
||
| Ok(()) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,12 @@ | ||
| mod general; | ||
| mod tokens; | ||
| mod upload; | ||
| mod users; | ||
|
|
||
| pub use general::{ | ||
| ErrorResponse, SuccessResponse, USER_EMAIL_KEY, USER_ID_KEY, USER_IS_STAFF_KEY, | ||
| USER_IS_SUPERUSER_KEY, | ||
| }; | ||
| pub use tokens::ConfirmationToken; | ||
| pub use upload::UploadedFile; | ||
| pub use users::{LoggedInUser, User, UserProfile, UserVisible}; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| #[derive(Debug, serde::Serialize, Clone)] | ||
| pub struct UploadedFile { | ||
| filename: String, | ||
| s3_key: String, | ||
| pub s3_url: String, | ||
| } | ||
|
|
||
| impl UploadedFile { | ||
| /// Construct new uploaded file info container. | ||
| pub fn new( | ||
| filename: impl Into<String>, | ||
| s3_key: impl Into<String>, | ||
| s3_url: impl Into<String>, | ||
| ) -> Self { | ||
| Self { | ||
| filename: filename.into(), | ||
| s3_key: s3_key.into(), | ||
| s3_url: s3_url.into(), | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| use tokio::io::AsyncReadExt as _; | ||
|
|
||
| /// S3 client wrapper to expose semantic upload operations. | ||
| #[derive(Debug, Clone)] | ||
| pub struct Client { | ||
| s3: aws_sdk_s3::Client, | ||
| bucket_name: String, | ||
| } | ||
|
|
||
| impl Client { | ||
| /// Construct S3 client wrapper. | ||
| pub fn new(config: aws_sdk_s3::Config) -> Client { | ||
| Client { | ||
| s3: aws_sdk_s3::Client::from_conf(config), | ||
| bucket_name: std::env::var("AWS_S3_BUCKET_NAME").unwrap(), | ||
| } | ||
| } | ||
|
|
||
| pub fn url(&self, key: &str) -> String { | ||
| format!( | ||
| "https://{}.s3.{}.amazonaws.com/{key}", | ||
| std::env::var("AWS_S3_BUCKET_NAME").unwrap(), | ||
| std::env::var("AWS_REGION").unwrap(), | ||
| ) | ||
| } | ||
|
|
||
| /// Facilitate the upload of file to s3. | ||
| pub async fn upload( | ||
| &self, | ||
| file: &actix_multipart::form::tempfile::TempFile, | ||
| key_prefix: &str, | ||
| ) -> crate::types::UploadedFile { | ||
| let filename = file.file_name.as_deref().expect("TODO"); | ||
| let key = format!("{key_prefix}{filename}"); | ||
| let s3_url = self | ||
| .put_object_from_file(file.file.path().to_str().unwrap(), &key) | ||
| .await; | ||
| crate::types::UploadedFile::new(filename, key, s3_url) | ||
| } | ||
|
|
||
| /// Real upload of file to S3 | ||
| async fn put_object_from_file(&self, local_path: &str, key: &str) -> String { | ||
| let mut file = tokio::fs::File::open(local_path).await.unwrap(); | ||
|
|
||
| let size_estimate = file | ||
| .metadata() | ||
| .await | ||
| .map(|md| md.len()) | ||
| .unwrap_or(1024) | ||
| .try_into() | ||
| .expect("file too big"); | ||
|
|
||
| let mut contents = Vec::with_capacity(size_estimate); | ||
| file.read_to_end(&mut contents).await.unwrap(); | ||
|
|
||
| let _res = self | ||
| .s3 | ||
| .put_object() | ||
| .bucket(&self.bucket_name) | ||
| .key(key) | ||
| .body(aws_sdk_s3::primitives::ByteStream::from(contents)) | ||
| .send() | ||
| .await | ||
| .expect("Failed to put object"); | ||
|
|
||
| self.url(key) | ||
| } | ||
|
|
||
| /// Attempts to delete object from S3. Returns true if successful. | ||
| pub async fn delete_file(&self, key: &str) -> bool { | ||
| self.s3 | ||
| .delete_object() | ||
| .bucket(&self.bucket_name) | ||
| .key(key) | ||
| .send() | ||
| .await | ||
| .is_ok() | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| mod client; | ||
| pub use client::Client; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,8 @@ | ||
| mod auth; | ||
| mod emails; | ||
| mod users; | ||
|
|
||
| pub use auth::password::{hash, verify_password}; | ||
| pub use auth::tokens::{issue_confirmation_token_pasetors, verify_confirmation_token_pasetor}; | ||
| pub use emails::{send_email, send_multipart_email}; | ||
| pub use users::get_active_user_from_db; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| use sqlx::Row; | ||
|
|
||
| #[tracing::instrument(name = "Getting an active user from the DB.", skip(pool))] | ||
| pub async fn get_active_user_from_db( | ||
| pool: Option<&sqlx::postgres::PgPool>, | ||
| transaction: Option<&mut sqlx::Transaction<'_, sqlx::Postgres>>, | ||
| id: Option<uuid::Uuid>, | ||
| email: Option<&String>, | ||
| ) -> Result<crate::types::User, sqlx::Error> { | ||
| let mut query_builder = | ||
| sqlx::query_builder::QueryBuilder::new(crate::queries::USER_AND_USER_PROFILE_QUERY); | ||
|
|
||
| if let Some(id) = id { | ||
| query_builder.push(" u.id="); | ||
| query_builder.push_bind(id); | ||
| } | ||
|
|
||
| if let Some(e) = email { | ||
| query_builder.push(" u.email="); | ||
| query_builder.push_bind(e); | ||
| } | ||
|
|
||
| let sqlx_query = query_builder | ||
| .build() | ||
| .map(|row: sqlx::postgres::PgRow| crate::types::User { | ||
| id: row.get("u_id"), | ||
| email: row.get("u_email"), | ||
| first_name: row.get("u_first_name"), | ||
| password: row.get("u_password"), | ||
| last_name: row.get("u_last_name"), | ||
| is_active: row.get("u_is_active"), | ||
| is_staff: row.get("u_is_staff"), | ||
| is_superuser: row.get("u_is_superuser"), | ||
| thumbnail: row.get("u_thumbnail"), | ||
| date_joined: row.get("u_date_joined"), | ||
| profile: crate::types::UserProfile { | ||
| id: row.get("p_id"), | ||
| user_id: row.get("p_user_id"), | ||
| phone_number: row.get("p_phone_number"), | ||
| birth_date: row.get("p_birth_date"), | ||
| github_link: row.get("p_github_link"), | ||
| }, | ||
| }); | ||
|
|
||
| let fetched_query = { | ||
| if pool.is_some() { | ||
| let p = pool.unwrap(); | ||
| sqlx_query.fetch_one(p).await | ||
| } else { | ||
| let t = transaction.unwrap(); | ||
| sqlx_query.fetch_one(&mut *t).await | ||
| } | ||
| }; | ||
| match fetched_query { | ||
| Ok(user) => Ok(user), | ||
| Err(e) => { | ||
| tracing::event!(target: "sqlx",tracing::Level::ERROR, "User not found in DB: {:#?}", e); | ||
| Err(e) | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| <script lang="ts"> | ||
| import { HIGHEST_IMAGE_UPLOAD_SIZE, IMAGE_UPLOAD_SIZE } from '$lib/utils/constant'; | ||
| import { returnFileSize } from '$lib/utils/helpers/image.file.size'; | ||
| import type { CustomError } from '$lib/utils/types'; | ||
| export let title: string; | ||
| export let image: string | Blob; | ||
| export let avatar: string | null; | ||
| export let errors: Array<CustomError>; | ||
| let thumbnail: HTMLInputElement; | ||
| const onFileSelected = (e: Event) => { | ||
| const target = e.target as HTMLInputElement; | ||
| if (target && target.files) { | ||
| if (target.files[0].size < HIGHEST_IMAGE_UPLOAD_SIZE) { | ||
| errors = []; | ||
| image = target.files[0]; | ||
| let reader = new FileReader(); | ||
| reader.readAsDataURL(image); | ||
| reader.onload = (e) => { | ||
| avatar = e.target?.result as string; | ||
| }; | ||
| } else { | ||
| errors = [ | ||
| ...errors, | ||
| { | ||
| id: Math.floor(Math.random() * 100), | ||
| error: `Image size ${returnFileSize( | ||
| target.files[0].size | ||
| )} is too large. Please keep it below ${IMAGE_UPLOAD_SIZE}kB.` | ||
| } | ||
| ]; | ||
| } | ||
| } | ||
| }; | ||
| </script> | ||
|
|
||
| <div id="app"> | ||
| <h1>{title}</h1> | ||
|
|
||
| {#if avatar} | ||
| <img class="avatar" src={avatar} alt="d" /> | ||
| {:else} | ||
| <img | ||
| class="avatar" | ||
| src="https://cdn4.iconfinder.com/data/icons/small-n-flat/24/user-alt-512.png" | ||
| alt="" | ||
| /> | ||
| {/if} | ||
| <!-- svelte-ignore a11y-click-events-have-key-events --> | ||
| <i | ||
| class="upload fa-solid fa-3x fa-camera" | ||
| title="Upload image. Max size is 49kB." | ||
| on:click={() => { | ||
| thumbnail.click(); | ||
| }} | ||
| /> | ||
|
|
||
| <!-- svelte-ignore a11y-click-events-have-key-events --> | ||
| <div | ||
| class="chan" | ||
| on:click={() => { | ||
| thumbnail.click(); | ||
| }} | ||
| > | ||
| Choose Image | ||
| </div> | ||
| <input | ||
| style="display:none" | ||
| type="file" | ||
| name="thumbnail" | ||
| accept="image/*" | ||
| on:change={(e) => onFileSelected(e)} | ||
| bind:this={thumbnail} | ||
| /> | ||
| </div> | ||
|
|
||
| <style> | ||
| #app { | ||
| margin-top: 1rem; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| flex-flow: column; | ||
| color: rgb(148 163 184); | ||
| } | ||
| .upload { | ||
| display: flex; | ||
| height: 50px; | ||
| width: 50px; | ||
| cursor: pointer; | ||
| } | ||
| .avatar { | ||
| display: flex; | ||
| height: 200px; | ||
| width: 200px; | ||
| } | ||
| </style> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,7 +37,7 @@ | |
| <style> | ||
| .loader-container { | ||
| position: fixed; | ||
| top: 0; | ||
| left: 0; | ||
| right: 0; | ||
| bottom: 0; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| <script lang="ts"> | ||
| import { createEventDispatcher } from 'svelte'; | ||
| import { quintOut } from 'svelte/easing'; | ||
| import type { TransitionConfig } from 'svelte/transition'; | ||
| type ModalParams = { duration?: number }; | ||
| type Modal = (node: Element, params?: ModalParams) => TransitionConfig; | ||
| const modal: Modal = (node, { duration = 300 } = {}) => { | ||
| const transform = getComputedStyle(node).transform; | ||
| return { | ||
| duration, | ||
| easing: quintOut, | ||
| css: (t, u) => { | ||
| return `transform: | ||
| ${transform} | ||
| scale(${t}) | ||
| translateY(${u * -100}%) | ||
| `; | ||
| } | ||
| }; | ||
| }; | ||
| const dispatch = createEventDispatcher(); | ||
| function closeModal() { | ||
| dispatch('close', {}); | ||
| } | ||
| </script> | ||
|
|
||
| <!-- svelte-ignore a11y-click-events-have-key-events --> | ||
| <div class="modal-background" on:click={closeModal} /> | ||
|
|
||
| <div transition:modal={{ duration: 1000 }} class="modal" role="dialog" aria-modal="true"> | ||
| <button title="Close" class="modal-close" on:click={closeModal}> | ||
| <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 384 512"> | ||
| <path | ||
| d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z" | ||
| /> | ||
| </svg> | ||
| </button> | ||
| <slot /> | ||
| </div> | ||
|
|
||
| <style> | ||
| .modal-background { | ||
| width: 100%; | ||
| height: 100%; | ||
| position: fixed; | ||
| top: 0; | ||
| left: 0; | ||
| background: rgba(0, 0, 0, 0.25); | ||
| } | ||
| .modal { | ||
| position: absolute; | ||
| left: 50%; | ||
| top: 50%; | ||
| max-width: 32em; | ||
| max-height: calc(100vh - 4em); | ||
| overflow: auto; | ||
| background: rgb(15, 23, 42); | ||
| box-shadow: 0 0 10px hsl(0 0% 0% / 10%); | ||
| transform: translate(-50%, -50%); | ||
| border-radius: 0.5rem; | ||
| } | ||
| .modal-close { | ||
| position: absolute; | ||
| top: 0.5rem; | ||
| right: 0.5rem; | ||
| } | ||
| .modal-close svg { | ||
| fill: rgb(14 165 233 /1); | ||
| } | ||
| .modal-close:hover svg { | ||
| fill: rgb(225 29 72); | ||
| } | ||
| </style> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| /** | ||
| * Determine and nicely format the file size of an item. | ||
| * @file lib/utils/helpers/image.file.size.ts | ||
| * @param {number} num - The size of the file. | ||
| * @returns {string} - The nicely formatted file size. | ||
| */ | ||
| export const returnFileSize = (num: number): string => { | ||
| let returnString = ''; | ||
| if (num < 1024) { | ||
| returnString = `${num} bytes`; | ||
| } else if (num >= 1024 && num < 1048576) { | ||
| returnString = `${(num / 1024).toFixed(1)} kB`; | ||
| } else if (num >= 1048576) { | ||
| returnString = `${(num / 1048576).toFixed(1)} MB`; | ||
| } | ||
| return returnString; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,262 @@ | ||
| <script lang="ts"> | ||
| import { page } from '$app/stores'; | ||
| import ImageInput from '$lib/component/Input/ImageInput.svelte'; | ||
| import Modal from '$lib/component/Modal/Modal.svelte'; | ||
| import { loading } from '$lib/stores/loading.store'; | ||
| import { notification } from '$lib/stores/notification.store'; | ||
| import { loggedInUser } from '$lib/stores/user.store'; | ||
| import Avatar from '$lib/svgs/teamavatar.png'; | ||
| import { BASE_API_URI, happyEmoji } from '$lib/utils/constant'; | ||
| import { receive, send } from '$lib/utils/helpers/animate.crossfade'; | ||
| import { post } from '$lib/utils/requests/posts.requests'; | ||
| import type { CustomError, User } from '$lib/utils/types'; | ||
| let showModal = false, | ||
| errors: Array<CustomError> = [], | ||
| image: string | Blob, | ||
| avatar: string | null = $loggedInUser.thumbnail, | ||
| first_name = $loggedInUser.first_name, | ||
| last_name = $loggedInUser.last_name, | ||
| phone_number = $loggedInUser.profile.phone_number, | ||
| birth_date = $loggedInUser.profile.birth_date, | ||
| github_link = $loggedInUser.profile.github_link; | ||
| const open = () => (showModal = true); | ||
| const close = () => (showModal = false); | ||
| async function handleUpdate(event: Event) { | ||
| loading.setLoading(true, 'Please wait while your profile is being updated...'); | ||
| let data = new FormData(); | ||
| if (first_name !== $loggedInUser.first_name) { | ||
| data.append('first_name', first_name); | ||
| } | ||
| if (last_name !== $loggedInUser.last_name) { | ||
| data.append('last_name', last_name); | ||
| } | ||
| if (image !== null && image !== undefined) { | ||
| data.append('thumbnail', image); | ||
| } | ||
| if (phone_number && phone_number !== $loggedInUser.profile.phone_number) { | ||
| data.append('phone_number', phone_number); | ||
| } | ||
| if (birth_date && birth_date !== $loggedInUser.profile.birth_date) { | ||
| data.append('birth_date', birth_date); | ||
| } | ||
| if (github_link && github_link !== $loggedInUser.profile.github_link) { | ||
| data.append('github_link', github_link); | ||
| } | ||
| const [res, err] = await post( | ||
| $page.data.fetch, | ||
| `${BASE_API_URI}/users/update-user/`, | ||
| data, | ||
| 'include', | ||
| 'PATCH' | ||
| ); | ||
| if (err.length > 0) { | ||
| loading.setLoading(false); | ||
| errors = err; | ||
| } else { | ||
| loading.setLoading(false); | ||
| (event.target as HTMLFormElement).reset(); | ||
| close(); | ||
| $notification = { | ||
| message: `Your profile has been saved successfully ${happyEmoji}...`, | ||
| colorName: 'green' | ||
| }; | ||
| loggedInUser.set(res as User); | ||
| } | ||
| } | ||
| </script> | ||
|
|
||
| <svelte:head> | ||
| <script src="https://kit.fontawesome.com/e9a50f7f89.js" crossorigin="anonymous"></script> | ||
|
|
||
| <title> | ||
| Auth - About {`${$loggedInUser.first_name} ${$loggedInUser.last_name}`} | Actix Web & SvelteKit | ||
| </title> | ||
| </svelte:head> | ||
|
|
||
| <h2 style="text-align:center"> | ||
| {`${$loggedInUser.first_name} ${$loggedInUser.last_name}`} Profile | ||
| </h2> | ||
| <div class="card"> | ||
| <img | ||
| src={$loggedInUser.thumbnail ? $loggedInUser.thumbnail : Avatar} | ||
| alt={`${$loggedInUser.first_name} ${$loggedInUser.last_name}`} | ||
| style="width:90%; margin:auto;" | ||
| /> | ||
| <h1>{`${$loggedInUser.first_name} ${$loggedInUser.last_name}`}</h1> | ||
|
|
||
| <div class="details"> | ||
| {#if $loggedInUser.profile.phone_number} | ||
| <p><i class="fa-solid fa-phone" /> <span>{$loggedInUser.profile.phone_number}</span></p> | ||
| {/if} | ||
|
|
||
| {#if $loggedInUser.profile.birth_date} | ||
| <p><i class="fa-solid fa-calendar" /> <span>{$loggedInUser.profile.birth_date}</span></p> | ||
| {/if} | ||
|
|
||
| {#if $loggedInUser.profile.github_link} | ||
| <p><i class="fa-brands fa-github" /><a href={$loggedInUser.profile.github_link}>Github</a></p> | ||
| {/if} | ||
| </div> | ||
|
|
||
| <button on:click={open}>Edit</button> | ||
| </div> | ||
|
|
||
| {#if showModal} | ||
| <Modal on:close={close}> | ||
| <form enctype="multipart/form-data" on:submit|preventDefault={handleUpdate}> | ||
| <h2 style="text-align:center">User Profile Update</h2> | ||
|
|
||
| {#if errors} | ||
| {#each errors as error (error.id)} | ||
| <p | ||
| class="text-center text-rose-600" | ||
| in:receive={{ key: error.id }} | ||
| out:send={{ key: error.id }} | ||
| > | ||
| {error.error} | ||
| </p> | ||
| {/each} | ||
| {/if} | ||
|
|
||
| <ImageInput bind:image title="Upload user image" bind:avatar bind:errors /> | ||
|
|
||
| <input | ||
| type="text" | ||
| name="first_name" | ||
| bind:value={first_name} | ||
| placeholder="Your first name..." | ||
| /> | ||
| <input type="text" name="last_name" bind:value={last_name} placeholder="Your last name..." /> | ||
| <input | ||
| type="tel" | ||
| name="phone_number" | ||
| bind:value={phone_number} | ||
| placeholder="Your phone number e.g +2348135703593..." | ||
| /> | ||
| <input | ||
| type="date" | ||
| name="birth_date" | ||
| bind:value={birth_date} | ||
| placeholder="Your date of birth..." | ||
| /> | ||
| <input | ||
| type="tel" | ||
| name="github_link" | ||
| bind:value={github_link} | ||
| placeholder="Your github link e.g https://github.com/Sirneij/..." | ||
| /> | ||
| <button type="submit">Update</button> | ||
| </form> | ||
| </Modal> | ||
| {/if} | ||
|
|
||
| <style> | ||
| :root { | ||
| --tw-bg-opacity: 1; | ||
| --tw-text-opacity: 1; | ||
| } | ||
| h1, | ||
| h2 { | ||
| color: rgb(14 165 233 / var(--tw-text-opacity)); | ||
| } | ||
| h2 { | ||
| font-size: 1.5rem; | ||
| } | ||
| h1 { | ||
| font-size: 2rem; | ||
| } | ||
| .card { | ||
| box-shadow: 0px 4px 6px 0px rgba(0, 0, 0, 0.75); | ||
| -webkit-box-shadow: 0px 4px 6px 0px rgba(0, 0, 0, 0.75); | ||
| -moz-box-shadow: 0px 4px 6px 0px rgba(0, 0, 0, 0.75); | ||
| max-width: 20rem; | ||
| margin: auto; | ||
| text-align: center; | ||
| } | ||
| button { | ||
| border: none; | ||
| outline: 0; | ||
| display: inline-block; | ||
| padding: 0.5rem; | ||
| color: rgb(239 246 255 / var(--tw-bg-opacity)); | ||
| background-color: rgb(7 89 133 / var(--tw-bg-opacity)); | ||
| text-align: center; | ||
| cursor: pointer; | ||
| width: 100%; | ||
| font-size: 18px; | ||
| } | ||
| .details { | ||
| display: flex; | ||
| flex-wrap: wrap; | ||
| align-items: center; | ||
| justify-content: center; | ||
| margin-top: 0.5rem; | ||
| margin-bottom: 0.5rem; | ||
| color: rgb(239 246 255 / var(--tw-bg-opacity)); | ||
| } | ||
| .details p i { | ||
| opacity: 0.6; | ||
| margin-right: 0.3rem; | ||
| } | ||
| .details p:not(:last-of-type) { | ||
| margin-right: 1rem; | ||
| } | ||
| .details p:not(:last-of-type) { | ||
| border-right: 2px solid rgb(14 165 233 / var(--tw-text-opacity)); | ||
| } | ||
| .details p span, | ||
| .details p a { | ||
| margin-right: 0.3rem; | ||
| } | ||
| button:hover, | ||
| a:hover { | ||
| opacity: 0.7; | ||
| } | ||
| a:hover { | ||
| color: rgb(14 165 233 / var(--tw-text-opacity)); | ||
| text-decoration: underline; | ||
| } | ||
| form { | ||
| border-radius: 5px; | ||
| background-color: rgb(30 41 59); | ||
| padding: 1.25rem; | ||
| } | ||
| input { | ||
| width: 100%; | ||
| padding: 0.75rem 1.25rem; | ||
| margin: 0.25rem 0; | ||
| display: inline-block; | ||
| border: none; | ||
| outline: none; | ||
| background-color: #0f172a; | ||
| color: rgb(14 165 233); | ||
| border-radius: 4px; | ||
| box-sizing: border-box; | ||
| } | ||
| ::-webkit-input-placeholder { | ||
| /* Edge */ | ||
| color: rgb(148 163 184); | ||
| } | ||
| :-ms-input-placeholder { | ||
| /* Internet Explorer 10-11 */ | ||
| color: rgb(148 163 184); | ||
| } | ||
| ::placeholder { | ||
| color: rgb(148 163 184); | ||
| } | ||
| </style> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { notification } from '$lib/stores/notification.store'; | ||
| import { isAuthenticated } from '$lib/stores/user.store'; | ||
| import { angryEmoji } from '$lib/utils/constant'; | ||
| import { get } from 'svelte/store'; | ||
| import type { PageLoad } from './$types'; | ||
| import { redirect } from '@sveltejs/kit'; | ||
|
|
||
| export const load: PageLoad = async ({ params }) => { | ||
| if (!get(isAuthenticated)) { | ||
| notification.set({ | ||
| message: `You are not logged in ${angryEmoji}...`, | ||
| colorName: `red` | ||
| }); | ||
| throw redirect(302, `/auth/login?next=/auth/about/${params.id}`); | ||
| } | ||
| }; |