From 005e7ce77b65927c9ea9e33fa7a78fe022f65e66 Mon Sep 17 00:00:00 2001 From: oiupoyt Date: Sat, 29 Aug 2026 18:47:44 +0530 Subject: [PATCH] fix(skins): isolate UrlModal and PreviewPanel as Component structs to prevent conditional hooks --- packages/oneclient_app/src/view/app/skins.rs | 790 ++++++++++--------- 1 file changed, 430 insertions(+), 360 deletions(-) diff --git a/packages/oneclient_app/src/view/app/skins.rs b/packages/oneclient_app/src/view/app/skins.rs index 66f25a37..3d028b23 100644 --- a/packages/oneclient_app/src/view/app/skins.rs +++ b/packages/oneclient_app/src/view/app/skins.rs @@ -26,6 +26,10 @@ impl Component for AccountSkins { .and_then(|id| accounts.iter().find(|a| a.id == id).cloned()) .or_else(|| default_account.clone()); + let account_uuid = active_account.as_ref().map(|a| a.id.to_string()); + let account_id = active_account.as_ref().map(|a| a.id); + let username = active_account.as_ref().map(|a| a.username.clone()); + let status_msg = use_state(|| None::<(String, bool)>); // (message, is_error) let show_url_modal = use_state(|| false); let is_loading = use_state(|| false); @@ -37,7 +41,12 @@ impl Component for AccountSkins { .overflow(Overflow::Clip) .padding(40.) .spacing(28.) - .child(preview_panel(active_account.clone(), status_msg)) + .child(PreviewPanel { + account_uuid, + account_id, + username, + status_msg, + }) .child(side_panel( active_account, accounts, @@ -46,168 +55,184 @@ impl Component for AccountSkins { show_url_modal, is_loading, )) - .maybe_child((*show_url_modal.read()).then(|| { - url_modal(selected_id, status_msg, show_url_modal, is_loading) + .maybe_child((*show_url_modal.read()).then(|| UrlModal { + selected_id, + status_msg, + show_url_modal, + is_loading, })) } } -fn preview_panel( - account: Option, +#[derive(PartialEq, Clone)] +struct PreviewPanel { + account_uuid: Option, + account_id: Option, + username: Option, status_msg: State>, -) -> impl IntoElement { - let account_uuid = account.as_ref().map(|a| a.id.to_string()); - let custom_query = use_custom_skin(account_uuid.clone().unwrap_or_default()); - let custom_data = crate::hooks::settled_or_loading(&custom_query); - let is_slim = custom_data.as_ref().map_or(false, |d| d.is_slim); - let has_custom = custom_data.as_ref().map_or(false, |d| d.has_custom); +} - let acc_id_copy = account.as_ref().map(|a| a.id); - let username_copy = account.as_ref().map(|a| a.username.clone()); +impl Component for PreviewPanel { + fn render_key(&self) -> DiffKey { + DiffKey::from(&self.account_id) + } - let set_classic = { - let acc_id = acc_id_copy; - let uname = username_copy.clone(); - let mut status = status_msg; - move |_| { - let Some(id) = acc_id else { return }; - let uname = uname.clone(); - let uuid_str = id.to_string(); - spawn(async move { - if let Ok(skin_path) = oneclient_common::paths::skin_file_path(&uuid_str) { - if let Ok(bytes) = polyio::read(&skin_path).await { - let _ = save_account_skin(&uuid_str, uname.as_deref(), &bytes, false).await; - status.set(Some(( - "Model set to Classic (4px arms)".to_string(), - false, - ))); - } else if let Some(steve_bytes) = crate::AppAssets::get_bytes("steve.png") { - let _ = save_account_skin(&uuid_str, uname.as_deref(), &steve_bytes, false) - .await; - status.set(Some(("Model set to Classic".to_string(), false))); + fn render(&self) -> impl IntoElement { + let account_uuid = self.account_uuid.clone(); + let custom_query = use_custom_skin(account_uuid.clone().unwrap_or_default()); + let custom_data = crate::hooks::settled_or_loading(&custom_query); + let is_slim = custom_data.as_ref().map_or(false, |d| d.is_slim); + let has_custom = custom_data.as_ref().map_or(false, |d| d.has_custom); + + let acc_id_copy = self.account_id; + let username_copy = self.username.clone(); + let status_msg = self.status_msg; + + let set_classic = { + let acc_id = acc_id_copy; + let uname = username_copy.clone(); + let mut status = status_msg; + move |_| { + let Some(id) = acc_id else { return }; + let uname = uname.clone(); + let uuid_str = id.to_string(); + spawn(async move { + if let Ok(skin_path) = oneclient_common::paths::skin_file_path(&uuid_str) { + if let Ok(bytes) = polyio::read(&skin_path).await { + let _ = save_account_skin(&uuid_str, uname.as_deref(), &bytes, false) + .await; + status.set(Some(( + "Model set to Classic (4px arms)".to_string(), + false, + ))); + } else if let Some(steve_bytes) = crate::AppAssets::get_bytes("steve.png") { + let _ = + save_account_skin(&uuid_str, uname.as_deref(), &steve_bytes, false) + .await; + status.set(Some(("Model set to Classic".to_string(), false))); + } } - } - }); - } - }; + }); + } + }; - let set_slim = { - let acc_id = acc_id_copy; - let uname = username_copy.clone(); - let mut status = status_msg; - move |_| { - let Some(id) = acc_id else { return }; - let uname = uname.clone(); - let uuid_str = id.to_string(); - spawn(async move { - if let Ok(skin_path) = oneclient_common::paths::skin_file_path(&uuid_str) { - if let Ok(bytes) = polyio::read(&skin_path).await { - let _ = save_account_skin(&uuid_str, uname.as_deref(), &bytes, true).await; - status.set(Some(( - "Model set to Slim (3px arms)".to_string(), - false, - ))); - } else if let Some(alex_bytes) = crate::AppAssets::get_bytes("alex.png") { - let _ = save_account_skin(&uuid_str, uname.as_deref(), &alex_bytes, true) - .await; - status.set(Some(("Model set to Slim".to_string(), false))); + let set_slim = { + let acc_id = acc_id_copy; + let uname = username_copy.clone(); + let mut status = status_msg; + move |_| { + let Some(id) = acc_id else { return }; + let uname = uname.clone(); + let uuid_str = id.to_string(); + spawn(async move { + if let Ok(skin_path) = oneclient_common::paths::skin_file_path(&uuid_str) { + if let Ok(bytes) = polyio::read(&skin_path).await { + let _ = + save_account_skin(&uuid_str, uname.as_deref(), &bytes, true).await; + status.set(Some(("Model set to Slim (3px arms)".to_string(), false))); + } else if let Some(alex_bytes) = crate::AppAssets::get_bytes("alex.png") { + let _ = + save_account_skin(&uuid_str, uname.as_deref(), &alex_bytes, true) + .await; + status.set(Some(("Model set to Slim".to_string(), false))); + } } - } - }); - } - }; + }); + } + }; - rect() - .vertical() - .width(Size::px(340.)) - .height(Size::fill()) - .spacing(16.) - .child( - rect() - .center() - .width(Size::fill()) - .height(Size::flex(1.0)) - .corner_radius(CornerRadius::new_all(16.)) - .background(colors::page_elevated()) - .border(border_all_color(1., colors::component_border())) - .child(match account_uuid { - Some(uuid) => PlayerModel::new(uuid) - .width(Size::fill()) - .height(Size::fill()) - .into_element(), - None => Icon::new(IconType::Users01) - .size(64.) - .color(colors::fg_secondary()) - .into_element(), - }), - ) - .child( - rect() - .horizontal() - .width(Size::fill()) - .main_align(Alignment::Center) - .spacing(8.) - .child( - rect() - .center() - .padding(Gaps::new_symmetric(6., 14.)) - .corner_radius(CornerRadius::new_all(8.)) - .background(if !is_slim { - colors::brand() - } else { - colors::component_bg() - }) - .cursor(CursorIcon::Pointer) - .on_press(set_classic) - .child( - label() - .text("Classic (4px)") - .font_size(12.) - .font_weight(if !is_slim { - FontWeight::SEMI_BOLD - } else { - FontWeight::NORMAL - }) - .color(colors::fg_primary()), - ), - ) - .child( - rect() - .center() - .padding(Gaps::new_symmetric(6., 14.)) - .corner_radius(CornerRadius::new_all(8.)) - .background(if is_slim { - colors::brand() - } else { - colors::component_bg() - }) - .cursor(CursorIcon::Pointer) - .on_press(set_slim) - .child( - label() - .text("Slim (3px)") - .font_size(12.) - .font_weight(if is_slim { - FontWeight::SEMI_BOLD - } else { - FontWeight::NORMAL - }) - .color(colors::fg_primary()), - ), - ), - ) - .maybe_child(has_custom.then(|| { - rect() - .center() - .child( - label() - .text("Custom Skin Active") - .font_size(11.) - .color(colors::success()), - ) - .into_element() - })) - .into_element() + rect() + .vertical() + .width(Size::px(340.)) + .height(Size::fill()) + .spacing(16.) + .child( + rect() + .center() + .width(Size::fill()) + .height(Size::flex(1.0)) + .corner_radius(CornerRadius::new_all(16.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child(match account_uuid { + Some(uuid) => PlayerModel::new(uuid) + .width(Size::fill()) + .height(Size::fill()) + .into_element(), + None => Icon::new(IconType::Users01) + .size(64.) + .color(colors::fg_secondary()) + .into_element(), + }), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::Center) + .spacing(8.) + .child( + rect() + .center() + .padding(Gaps::new_symmetric(6., 14.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(if !is_slim { + colors::brand() + } else { + colors::component_bg() + }) + .cursor(CursorIcon::Pointer) + .on_press(set_classic) + .child( + label() + .text("Classic (4px)") + .font_size(12.) + .font_weight(if !is_slim { + FontWeight::SEMI_BOLD + } else { + FontWeight::NORMAL + }) + .color(colors::fg_primary()), + ), + ) + .child( + rect() + .center() + .padding(Gaps::new_symmetric(6., 14.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(if is_slim { + colors::brand() + } else { + colors::component_bg() + }) + .cursor(CursorIcon::Pointer) + .on_press(set_slim) + .child( + label() + .text("Slim (3px)") + .font_size(12.) + .font_weight(if is_slim { + FontWeight::SEMI_BOLD + } else { + FontWeight::NORMAL + }) + .color(colors::fg_primary()), + ), + ), + ) + .maybe_child(has_custom.then(|| { + rect() + .center() + .child( + label() + .text("Custom Skin Active") + .font_size(11.) + .color(colors::success()), + ) + .into_element() + })) + .into_element() + } } fn side_panel( @@ -302,7 +327,7 @@ fn side_panel( }); }; - // Remove / Reset skin + // Reset skin to default let id_for_reset = account_id; let uname_for_reset = username_opt.clone(); let mut status_reset = status_msg; @@ -313,190 +338,176 @@ fn side_panel( let uuid_str = id.to_string(); spawn(async move { - let _ = delete_account_skin(&uuid_str, uname.as_deref()).await; - status_reset.set(Some(("Skin reset to default Steve/Alex".to_string(), false))); + match delete_account_skin(&uuid_str, uname.as_deref()).await { + Ok(()) => { + status_reset + .set(Some(("Skin reset to default skin.".to_string(), false))); + } + Err(err) => { + status_reset.set(Some((err, true))); + } + } }); }; - let mut open_url_modal = show_url_modal; - let open_from_url = move |_| { - open_url_modal.set(true); - }; + let mut open_url_dialog = show_url_modal; rect() .vertical() .width(Size::flex(1.0)) .height(Size::fill()) - .overflow(Overflow::Clip) - .spacing(18.) + .spacing(24.) .child( rect() .vertical() .spacing(4.) .child( label() - .text("Skin Manager") - .font_size(32.) + .text("Skin Customizer") + .font_size(24.) .font_weight(FontWeight::BOLD) .color(colors::fg_primary()), ) .child( label() - .text(format!("Managing skin for: {username}")) - .font_size(14.) + .text(format!("Managing skin for {username}")) + .font_size(13.) .color(colors::fg_secondary()), ), ) - .maybe_child((*status_msg.read()).as_ref().map(|(msg, is_err)| { + // Status message notification banner + .maybe_child(status_msg.read().as_ref().map(|(msg, is_err)| { + let is_err = *is_err; rect() .horizontal() - .cross_align(Alignment::Center) - .spacing(8.) - .padding(Gaps::new_symmetric(8., 12.)) + .width(Size::fill()) + .padding(Gaps::new_symmetric(10., 14.)) .corner_radius(CornerRadius::new_all(8.)) - .background(if *is_err { + .background(if is_err { colors::danger().with_a(30) } else { colors::success().with_a(30) }) .border(border_all_color( 1., - if *is_err { + if is_err { colors::danger() } else { colors::success() }, )) - .child( - Icon::new(if *is_err { - IconType::AlertTriangle - } else { - IconType::CheckCircle - }) - .size(16.) - .color(if *is_err { - colors::danger() - } else { - colors::success() - }), - ) .child( label() .text(msg.clone()) .font_size(13.) - .color(colors::fg_primary()), + .color(if is_err { + colors::danger() + } else { + colors::success() + }), ) .into_element() })) + // Account selector dropdown/buttons + .child(account_selector(accounts, selected_id)) + // Action buttons .child( rect() - .horizontal() - .content(Content::wrap_spacing(8.)) - .spacing(8.) + .vertical() + .spacing(12.) .child( Button::new() .primary() + .enabled(!*is_loading.read() && account_id.is_some()) .on_press(import_from_file) - .child(Icon::new(IconType::FilePlus02).size(16.)) - .text("Upload File (.png)"), - ) - .child( - Button::new() - .secondary() - .on_press(open_from_url) - .child(Icon::new(IconType::Globe01).size(16.)) - .text("From Username / URL"), - ) - .child( - Button::new() - .secondary() - .on_press(download_skin) - .child(Icon::new(IconType::Download01).size(16.)) - .text("Export Skin"), + .child( + rect() + .horizontal() + .spacing(8.) + .cross_align(Alignment::Center) + .child(Icon::new(IconType::FilePlus02).size(18.)) + .child(label().text("Upload PNG from Computer").font_size(14.)), + ), ) .child( Button::new() .ghost() - .on_press(reset_skin) - .child(Icon::new(IconType::Trash01).size(16.)) - .text("Reset Skin"), - ), - ) - .child( - rect() - .vertical() - .spacing(8.) - .child(section_label("SWITCH ACCOUNT")) + .enabled(!*is_loading.read() && account_id.is_some()) + .on_press(move |_| open_url_dialog.set(true)) + .child( + rect() + .horizontal() + .spacing(8.) + .cross_align(Alignment::Center) + .child(Icon::new(IconType::Globe01).size(18.)) + .child( + label() + .text("Import by Username / URL") + .font_size(14.), + ), + ), + ) .child( rect() .horizontal() - .content(Content::wrap_spacing(8.)) - .spacing(8.) - .children(accounts.into_iter().map(|acc| { - let id = acc.id; - let is_sel = selected_id.read().map_or(false, |sel| sel == id); - let mut sel_state = selected_id; - rect() - .horizontal() - .cross_align(Alignment::Center) - .spacing(6.) - .padding(Gaps::new_symmetric(6., 12.)) - .corner_radius(CornerRadius::new_all(8.)) - .background(if is_sel { - colors::brand().with_a(50) - } else { - colors::page_elevated() - }) - .border(border_all_color( - 1., - if is_sel { - colors::brand() - } else { - colors::component_border() - }, - )) - .cursor(CursorIcon::Pointer) - .on_press(move |_| sel_state.set(Some(id))) + .spacing(12.) + .child( + Button::new() + .ghost() + .enabled(!*is_loading.read() && account_id.is_some()) + .on_press(download_skin) .child( - label() - .text(acc.username) - .font_size(13.) - .color(colors::fg_primary()), - ) - .into_element() - })), + rect() + .horizontal() + .spacing(8.) + .cross_align(Alignment::Center) + .child(Icon::new(IconType::Download01).size(16.)) + .child(label().text("Save PNG").font_size(13.)), + ), + ) + .child( + Button::new() + .ghost() + .enabled(!*is_loading.read() && account_id.is_some()) + .on_press(reset_skin) + .child( + rect() + .horizontal() + .spacing(8.) + .cross_align(Alignment::Center) + .child( + Icon::new(IconType::Trash01) + .size(16.) + .color(colors::danger()), + ) + .child( + label() + .text("Reset to Default") + .font_size(13.) + .color(colors::danger()), + ), + ), + ), ), ) + // Information note .child( rect() .vertical() - .spacing(10.) .padding(Gaps::new_all(14.)) - .corner_radius(CornerRadius::new_all(10.)) - .background(colors::page_elevated()) - .border(border_all_color(1., colors::component_border())) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .spacing(6.) .child( label() - .text("💡 Offline Skin Tips") - .font_size(14.) + .text("Offline Skin Loader Integration") + .font_size(13.) .font_weight(FontWeight::SEMI_BOLD) .color(colors::fg_primary()), ) .child( label() - .text("• Supports all standard Minecraft skins (64x64 or legacy 64x32 PNGs).") - .font_size(12.) - .color(colors::fg_secondary()), - ) - .child( - label() - .text("• Interactive 3D preview: Click & drag on the character to rotate!") - .font_size(12.) - .color(colors::fg_secondary()), - ) - .child( - label() - .text("• Skins automatically sync with CustomSkinLoader for in-game rendering on offline servers.") + .text("Custom skins are saved locally to your profile and automatically synchronized to CustomSkinLoader so your skin shows up in game seamlessly on offline/cracked clusters.") .font_size(12.) .color(colors::fg_secondary()), ), @@ -504,107 +515,166 @@ fn side_panel( .into_element() } -fn url_modal( +fn account_selector( + accounts: Vec, + selected_id: State>, +) -> impl IntoElement { + let mut selected_state = selected_id; + + rect() + .vertical() + .spacing(8.) + .child( + label() + .text("SELECT ACCOUNT") + .font_size(11.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .spacing(8.) + .content(Content::Flex) + .children(accounts.into_iter().map(|acc| { + let id = acc.id; + let uname = acc.username.clone(); + let is_active = *selected_state.read() == Some(id); + + rect() + .center() + .padding(Gaps::new_symmetric(8., 16.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(if is_active { + colors::brand() + } else { + colors::component_bg() + }) + .border(border_all_color( + 1., + if is_active { + colors::brand() + } else { + colors::component_border() + }, + )) + .cursor(CursorIcon::Pointer) + .on_press(move |_| selected_state.set(Some(id))) + .child( + label() + .text(uname) + .font_size(13.) + .font_weight(if is_active { + FontWeight::SEMI_BOLD + } else { + FontWeight::NORMAL + }) + .color(colors::fg_primary()), + ) + .into_element() + })), + ) +} + +#[derive(PartialEq, Clone)] +struct UrlModal { selected_id: State>, status_msg: State>, show_url_modal: State, is_loading: State, -) -> impl IntoElement { - let input_text = use_state(String::new); - let mut modal_open = show_url_modal; - let mut status = status_msg; - let mut loading = is_loading; +} - let target_uuid = (*selected_id.peek()).map(|id| id.to_string()); +impl Component for UrlModal { + fn render(&self) -> impl IntoElement { + let input_text = use_state(String::new); + let mut modal_open = self.show_url_modal; + let mut status = self.status_msg; + let mut loading = self.is_loading; - let apply_url = move |_| { - let Some(ref uuid_str) = target_uuid else { - return; - }; - let uuid_str = uuid_str.clone(); - let query = input_text.peek().trim().to_string(); - if query.is_empty() { - return; - } + let target_uuid = (*self.selected_id.peek()).map(|id| id.to_string()); - loading.set(true); - modal_open.set(false); - - spawn(async move { - match fetch_skin_online(&query).await { - Ok((bytes, is_slim)) => { - let _ = save_account_skin(&uuid_str, None, &bytes, is_slim).await; - status.set(Some(( - format!("Skin for \"{query}\" applied successfully!"), - false, - ))); - } - Err(err) => { - status.set(Some((err, true))); - } + let apply_url = move |_| { + let Some(ref uuid_str) = target_uuid else { + return; + }; + let uuid_str = uuid_str.clone(); + let query = input_text.peek().trim().to_string(); + if query.is_empty() { + return; } - loading.set(false); - }); - }; - OverlayPopup::new() - .position(Position::new_global().top(140.).left(280.)) - .on_close(move |()| modal_open.set(false)) - .child( - rect() - .vertical() - .width(Size::px(380.)) - .padding(Gaps::new_all(20.)) - .spacing(16.) - .corner_radius(CornerRadius::new_all(12.)) - .background(colors::page_elevated()) - .border(border_all_color(1., colors::component_border())) - .shadow(Shadow::from((0., 8., 32., 0., Color::from_argb(140, 0, 0, 0)))) - .child( - label() - .text("Import Skin by Username or URL") - .font_size(16.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_primary()), - ) - .child( - label() - .text("Enter any Minecraft player name (e.g. Technoblade, Notch) or a direct skin image URL:") - .font_size(12.) - .color(colors::fg_secondary()), - ) - .child( - TextInput::new(input_text) - .placeholder("Username (e.g. Dream) or https://...") - .width(Size::fill()), - ) - .child( - rect() - .horizontal() - .main_align(Alignment::End) - .spacing(8.) - .child( - Button::new() - .ghost() - .on_press(move |_| modal_open.set(false)) - .text("Cancel"), - ) - .child( - Button::new() - .primary() - .on_press(apply_url) - .text("Apply Skin"), - ), - ), - ) - .into_element() -} + loading.set(true); + modal_open.set(false); -fn section_label(text: &'static str) -> impl IntoElement { - label() - .text(text) - .font_size(11.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_secondary()) - .into_element() + spawn(async move { + match fetch_skin_online(&query).await { + Ok((bytes, is_slim)) => { + let _ = save_account_skin(&uuid_str, None, &bytes, is_slim).await; + status.set(Some(( + format!("Skin for \"{query}\" applied successfully!"), + false, + ))); + } + Err(err) => { + status.set(Some((err, true))); + } + } + loading.set(false); + }); + }; + + OverlayPopup::new() + .position(Position::new_global().top(140.).left(280.)) + .on_close(move |()| modal_open.set(false)) + .child( + rect() + .vertical() + .width(Size::px(380.)) + .padding(Gaps::new_all(20.)) + .spacing(16.) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .shadow(Shadow::from((0., 8., 32., 0., Color::from_argb(140, 0, 0, 0)))) + .child( + label() + .text("Import Skin by Username or URL") + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text( + "Enter any Minecraft player name (e.g. Technoblade, Notch) or a direct skin image URL:", + ) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child( + TextInput::new(input_text) + .placeholder("Username (e.g. Dream) or https://...") + .width(Size::fill()), + ) + .child( + rect() + .horizontal() + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .ghost() + .on_press(move |_| modal_open.set(false)) + .text("Cancel"), + ) + .child( + Button::new() + .primary() + .on_press(apply_url) + .text("Apply Skin"), + ), + ), + ) + .into_element() + } }