diff options
author | 2025-06-11 01:55:09 +0100 | |
---|---|---|
committer | 2025-06-11 01:55:09 +0100 | |
commit | 8bd186fe47eda25b36f945f926ce19093d16fe39 (patch) | |
tree | b787e6fc48e3e1430deb520417eeb1653ca122c4 | |
parent | 089005c8d2f94d5ca42366fdd82cb4429fcc2dfa (diff) | |
download | macaw-web-8bd186fe47eda25b36f945f926ce19093d16fe39.tar.gz macaw-web-8bd186fe47eda25b36f945f926ce19093d16fe39.tar.bz2 macaw-web-8bd186fe47eda25b36f945f926ce19093d16fe39.zip |
feat: profile settings panel
-rw-r--r-- | Cargo.toml | 3 | ||||
-rw-r--r-- | assets/style.scss | 73 | ||||
-rw-r--r-- | src/views/macaw/settings.rs | 173 |
3 files changed, 227 insertions, 22 deletions
@@ -11,6 +11,7 @@ console_error_panic_hook = "0.1.7" filamento = { path = "../luz/filamento", features = [ "reactive_stores", "opfs", + # "serde" ] } futures = "0.3.31" indexmap = "2.9.0" @@ -29,7 +30,7 @@ tracing = "0.1.41" tracing-wasm = "0.2.1" uuid = { version = "1.16.0", features = ["v4"] } # wasm-bindgen = "0.2.100" -web-sys = { version = "0.3.77", features = ["HtmlInputElement", "FileList"] } +web-sys = { version = "0.3.77", features = ["HtmlInputElement", "FileList", "Url"] } [patch.crates-io] tokio_with_wasm = { path = "../tokio-with-wasm/tokio_with_wasm" } diff --git a/assets/style.scss b/assets/style.scss index 6903145..8838937 100644 --- a/assets/style.scss +++ b/assets/style.scss @@ -102,6 +102,7 @@ label { input[type="text"], input[type="password"] { + font-size: 1rem; border: 2px solid black; font-family: "k2d"; } @@ -118,6 +119,7 @@ main { button, .button { + font-size: 1rem; background: #FACC34; color: #000000; text-decoration: none !important; @@ -127,6 +129,7 @@ button, font-family: K2D; font-weight: 600; box-shadow: inset 0px -0.75em 0.5em #ebb62e, 0 0.25em 0.25em #00000048; + cursor: pointer; } h1, @@ -656,6 +659,76 @@ background: #00000060; .settings-page { flex: 1 1 0; + overflow-y: scroll; +} + +.profile-settings { + display: flex; + justify-content: flex-end; + flex-wrap: wrap; + flex-direction: row-reverse; +} + +.profile-form { + flex: 1 1 auto; + margin: 16px; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; +} + +.profile-form label { + font-size: 1rem; +} + +.profile-form .change-avatar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 16px; +} + +#client-user-avatar { + opacity: 0; + position: absolute; + z-index: -1; +} + +.profile-preview { + margin: 24px; + border: 2px solid black; + min-width: 240px; +} + +.profile-preview .preview { + display: flex; + flex-direction: column; + gap: 8px; +} + +.profile-preview h2 { + padding: 8px 16px; + border-bottom: 2px solid black; +} + +.profile-preview .avatar { + width: 64px; + height: 64px; +} + +.profile-preview .nick { + font-size: 20px; + font-weight: bold; +} + +hr { + width: 100%; + border: 1px solid black; +} + +.profile-preview .preview { + padding: 16px; } .modal .overlay { diff --git a/src/views/macaw/settings.rs b/src/views/macaw/settings.rs index 11a3fc3..c4cc99b 100644 --- a/src/views/macaw/settings.rs +++ b/src/views/macaw/settings.rs @@ -4,23 +4,53 @@ use profile_settings::ProfileSettings; use crate::{components::{icon::IconComponent, modal::Modal}, icon::Icon}; mod profile_settings { - use filamento::error::{AvatarPublishError, CommandError}; + use filamento::{error::{AvatarPublishError, CommandError, NickError}, user::User}; use thiserror::Error; use leptos::prelude::*; - use web_sys::{js_sys::Uint8Array, wasm_bindgen::{prelude::Closure, JsCast, UnwrapThrowExt}, Event, FileReader, HtmlInputElement, ProgressEvent}; + use web_sys::{js_sys::Uint8Array, wasm_bindgen::{prelude::Closure, JsCast, UnwrapThrowExt}, Event, FileReader, HtmlInputElement, ProgressEvent, Url}; - use crate::{client::Client, files::Files}; + use crate::{client::Client, files::Files, user::{fetch_avatar, NO_AVATAR}}; #[derive(Debug, Clone, Error)] pub enum ProfileSaveError { #[error("avatar publish: {0}")] Avatar(#[from] CommandError<AvatarPublishError<Files>>), + #[error("nick publish: {0}")] + Nick(#[from] CommandError<NickError>), } #[component] pub fn ProfileSettings() -> impl IntoView { let client: Client = use_context().expect("no client in context"); + let old_profile = LocalResource::new(move || { + let value = client.clone(); + async move { + // TODO: error + let jid = &*value.jid; + let old_profile = value.get_user(jid.clone()).await.unwrap(); + old_profile + } + }); + + view! { + {move || { + if let Some(old_profile) = old_profile.get() { + view! { + <ProfileForm old_profile /> + }.into_any() + } else { + view! {}.into_any() + } + }} + } + } + + #[component] + pub fn ProfileForm(old_profile: User) -> impl IntoView { + + let client: Client = use_context().expect("no client in context"); + // TODO: compartmentalise into error component, form component... let (error, set_error) = signal(None::<ProfileSaveError>); let error_message = move || { @@ -32,9 +62,24 @@ mod profile_settings { } }) }; + + let (success_message, set_success_message) = signal(None::<String>); + let success_message = move || { + if let Some(message) = success_message.get() { + view! { <div class="success">{message}</div> }.into_any() + } else { + view! {}.into_any() + } + }; + let (profile_save_pending, set_profile_save_pending) = signal(false); let profile_upload_data = RwSignal::new(None::<Vec<u8>>); + let new_nick= RwSignal::new(old_profile.nick.clone().unwrap_or_default().to_string()); + let has_avatar = RwSignal::new(old_profile.avatar.is_some()); + let new_avatar_preview_url = RwSignal::new(None::<String>); + let remove_avatar = RwSignal::new(false); + let from_input = move |ev: Event| { let elem = ev.target().unwrap().unchecked_into::<HtmlInputElement>(); @@ -43,6 +88,9 @@ mod profile_settings { let files = elem.files().unwrap_throw(); if let Some(file) = files.get(0) { + let url = Url::create_object_url_with_blob(&file).unwrap_throw(); + + new_avatar_preview_url.set(Some(url)); let reader = FileReader::new().unwrap_throw(); // let name = file.name(); @@ -92,32 +140,115 @@ mod profile_settings { let save_profile = Action::new_local(move |_| { let client = client.clone(); - async move {} + let old_nick = old_profile.nick.clone(); + async move { + set_profile_save_pending.set(true); + + let new_nick = new_nick.get(); + let new_nick = if new_nick.is_empty() { + None + } else { + Some(new_nick) + }; + if new_nick != old_nick { + match client.change_nick(new_nick).await { + Ok(_) => {}, + Err(e) => { + set_error.set(Some(ProfileSaveError::Nick(e))); + set_profile_save_pending.set(false); + return; + }, + } + } + + if let Some(profile_data) = profile_upload_data.get() { + match client.change_avatar(Some(profile_data)).await { + Ok(_) => {}, + Err(e) => { + set_error.set(Some(ProfileSaveError::Avatar(e))); + set_profile_save_pending.set(false); + return; + }, + } + } else if remove_avatar.get() { + match client.change_avatar(None).await { + Ok(_) => {}, + Err(e) => { + set_error.set(Some(ProfileSaveError::Avatar(e))); + set_profile_save_pending.set(false); + return; + }, + } + } + + set_profile_save_pending.set(false); + set_success_message.set(Some("Profile Updated!".to_string())); + } }); - let new_nick= RwSignal::new("".to_string()); + let _old_account_avatar = LocalResource::new(move || { + let avatar = old_profile.avatar.clone(); + async move { + let url = fetch_avatar(avatar.as_deref()).await; + new_avatar_preview_url.set(Some(url)); + } + }); view! { <div class="profile-settings"> - <form on:submit=move |ev| { - ev.prevent_default(); - save_profile.dispatch(()); + <div class="profile-preview"> + <h2>Profile Preview</h2> + <div class="preview"> + <img class="avatar" src=new_avatar_preview_url /> + <div class="nick">{move || { + let nick = new_nick.get(); + if nick.is_empty() { + old_profile.jid.to_string() + } else { + nick + } + }}</div> + </div> + </div> + <form class="profile-form" on:submit=move |ev| { + ev.prevent_default(); + save_profile.dispatch(()); }> + {success_message} {error_message} - <div class="change-avatar"> - <input type="file" id="client-user-avatar" on:change=from_input /> + <div> + <h3>Nick</h3> + <input disabled=profile_save_pending placeholder="Nick" type="text" id="client-user-nick" bind:value=new_nick name="client-user-nick" /> + </div> + <div> + <h3>Avatar</h3> + <div class="change-avatar"> + <label for="client-user-avatar"><div class="button">Change Avatar</div></label> + <input type="file" id="client-user-avatar" on:change=move |e| { + has_avatar.set(true); + remove_avatar.set(false); + from_input(e); + } /> + {move || { + if has_avatar.get() { + view! { + <a on:click=move |_| { + profile_upload_data.set(None); + remove_avatar.set(true); + has_avatar.set(false); + new_avatar_preview_url.set(Some(NO_AVATAR.to_string())); + } style="cursor: pointer">Remove Avatar</a> + }.into_any() + } else { + view! {}.into_any() + } + }} + </div> </div> - <input disabled=profile_save_pending placeholder="Nickname" type="text" id="client-user-nick" bind:value=new_nick name="client-user-nick" /> + <hr /> <input disabled=profile_save_pending class="button" type="submit" value="Save Changes" /> </form> </div> - <div class="profile-preview"> - <h2>Profile Preview</h2> - <div class="preview"> - <img /> - <div>nick</div> - </div> - </div> } } } @@ -153,10 +284,10 @@ pub fn Settings() -> impl IntoView { <div class="settings-page"> {move || if let Some(page) = show_settings.get() { match page { - SettingsPage::Account => view! { <div>"account"</div> }.into_any(), - SettingsPage::Chat => view! { <div>"chat"</div> }.into_any(), + SettingsPage::Account => view! { <div style="padding: 16px">"Account settings coming soon!"</div> }.into_any(), + SettingsPage::Chat => view! { <div style="padding: 16px">"Chat settings coming soon!"</div> }.into_any(), SettingsPage::Profile => view! { <ProfileSettings /> }.into_any(), - SettingsPage::Privacy => view! { <div>"privacy"</div> }.into_any(), + SettingsPage::Privacy => view! { <div style="padding: 16px">"Privacy settings coming soon!"</div> }.into_any(), } } else { view! {}.into_any() |