fix(skins): isolate UrlModal and PreviewPanel as Component structs to prevent conditional hooks

This commit is contained in:
katR 2026-08-29 18:47:44 +05:30
parent 5a7ecf9293
commit 005e7ce77b

View file

@ -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<MinecraftAccount>,
#[derive(PartialEq, Clone)]
struct PreviewPanel {
account_uuid: Option<String>,
account_id: Option<uuid::Uuid>,
username: Option<String>,
status_msg: State<Option<(String, bool)>>,
) -> 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<MinecraftAccount>,
selected_id: State<Option<uuid::Uuid>>,
) -> 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<Option<uuid::Uuid>>,
status_msg: State<Option<(String, bool)>>,
show_url_modal: State<bool>,
is_loading: State<bool>,
) -> 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()
}
}