chore(ui): accounts page part of the settings layout (#693)

* feat(settings): move accounts into the settings shell

* fix(settings): keep the sidebar still on accounts, enlarge the model
This commit is contained in:
LynithDev 2026-08-06 10:51:07 +02:00 • committed by GitHub
parent 8511048293
commit ef53badeff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 220 additions and 155 deletions

View file

@ -235,7 +235,7 @@ impl Component for Footer {
let dispatch = use_dispatch();
let open_accounts = move |_| {
dispatch.close_account_switcher();
let _ = RouterContext::get().push(Route::Accounts {});
let _ = RouterContext::get().push(Route::SettingsAccounts {});
};
Button::new()

View file

@ -14,11 +14,15 @@ enum Enter {
Fade,
}
/// Every route nested under [`SettingsShell`](crate::layout::SettingsShell). The
/// shell runs its own content-only transition, so this outlet must stay still for
/// them — otherwise the sidebar animates along with the page.
fn is_sidebar_route(route: &Route) -> bool {
matches!(
route,
Route::SettingsAppearance {}
| Route::SettingsMinecraft {}
| Route::SettingsAccounts {}
| Route::SettingsLauncher {}
| Route::SettingsJava {}
| Route::SettingsStorage {}

View file

@ -265,7 +265,7 @@ const SEARCH_INDEX: &[SearchItem] = &[
title: "Accounts",
description: "Manage Minecraft / Microsoft accounts.",
keywords: &["account", "login", "microsoft", "msa"],
route: Route::Accounts {},
route: Route::SettingsAccounts {},
},
SearchItem {
id: "nav.changelog",
@ -325,7 +325,7 @@ impl SettingsTab {
fn route(self) -> Option<Route> {
match self {
Self::MinecraftSettings => Some(Route::SettingsMinecraft {}),
Self::Accounts => Some(Route::Accounts {}),
Self::Accounts => Some(Route::SettingsAccounts {}),
Self::LauncherSettings => Some(Route::SettingsLauncher {}),
Self::Java => Some(Route::SettingsJava {}),
Self::Storage => Some(Route::SettingsStorage {}),
@ -371,6 +371,7 @@ pub struct SettingsShell;
fn route_tab(route: &Route) -> SettingsTab {
match route {
Route::SettingsMinecraft {} => SettingsTab::MinecraftSettings,
Route::SettingsAccounts {} => SettingsTab::Accounts,
Route::SettingsLauncher {} => SettingsTab::LauncherSettings,
Route::SettingsJava {} => SettingsTab::Java,
Route::SettingsStorage {} => SettingsTab::Storage,

View file

@ -10,15 +10,16 @@ use crate::layout::SettingsShell;
use crate::view::{
NotFound, Startup,
app::{
AccountSkins, Accounts, Clusters, Debug, Home, Stats,
AccountSkins, Clusters, Debug, Home, Stats,
browser::{Browser, BrowserPackage},
cluster::{
ClusterLogs, ClusterMods, ClusterOverview, ClusterScreenshots, ClusterSettings,
ClusterShaders, ClusterTextures, ProcessLogs,
},
settings::{
SettingsApis, SettingsAppearance, SettingsChangelog, SettingsDeveloper, SettingsJava,
SettingsLanguage, SettingsLauncher, SettingsMinecraft, SettingsStorage,
SettingsAccounts, SettingsApis, SettingsAppearance, SettingsChangelog,
SettingsDeveloper, SettingsJava, SettingsLanguage, SettingsLauncher, SettingsMinecraft,
SettingsStorage,
},
},
onboarding::{
@ -82,8 +83,6 @@ pub enum Route {
Browser { cluster_id: i64, package_type: String, pick_cluster: bool },
#[route("/app/browser/:cluster_id/:package_type/package/:package_id")]
BrowserPackage { cluster_id: i64, package_type: String, package_id: String },
#[route("/app/accounts")]
Accounts {},
#[route("/app/account/skins")]
AccountSkins {},
#[route("/app/stats")]
@ -96,6 +95,8 @@ pub enum Route {
SettingsAppearance {},
#[route("/app/settings/minecraft")]
SettingsMinecraft {},
#[route("/app/settings/accounts")]
SettingsAccounts {},
#[route("/app/settings/launcher")]
SettingsLauncher {},
#[route("/app/settings/java")]
@ -143,12 +144,12 @@ impl Route {
Route::ClusterSettings { .. } => "Cluster Settings".to_string(),
Route::Browser { .. } => "Browser".to_string(),
Route::BrowserPackage { .. } => "Browser".to_string(),
Route::Accounts { .. } => "Accounts".to_string(),
Route::AccountSkins { .. } => "Skins".to_string(),
Route::Stats { .. } => "Statistics".to_string(),
Route::Debug { .. } => "Debug".to_string(),
Route::SettingsAppearance { .. } => "Settings".to_string(),
Route::SettingsMinecraft { .. } => "Minecraft Settings".to_string(),
Route::SettingsAccounts { .. } => "Accounts".to_string(),
Route::SettingsLauncher { .. } => "Launcher Settings".to_string(),
Route::SettingsJava { .. } => "Java".to_string(),
Route::SettingsStorage { .. } => "Storage".to_string(),

View file

@ -1,4 +1,3 @@
mod accounts;
mod analytics;
mod clusters;
mod debug;
@ -35,7 +34,6 @@ pub fn launch_button_state(
state
}
pub use accounts::Accounts;
pub(crate) use analytics::{analytics_body, analytics_placeholder};
pub use clusters::Clusters;
pub use debug::Debug;

View file

@ -5,9 +5,9 @@ use freya::query::{MutationCapability, MutationStateData, UseMutation};
use oneclient_auth::{AccountKind, MinecraftAccount};
use uuid::Uuid;
use super::{section_header, settings_page};
use crate::components::{
Avatar, Button, Icon, IconType, OverlayPopup, PlayerModel, ScrollArea, TextInput,
use_microsoft_login,
Avatar, Button, Icon, IconType, OverlayPopup, PlayerModel, TextInput, use_microsoft_login,
};
use crate::hooks::{
AddOfflineAccountKeys, RefreshAccountKeys, RemoveAccountKeys, SetDefaultAccountKeys,
@ -18,10 +18,27 @@ use crate::hooks::{
use crate::theme::colors;
use crate::ui::border_all_color;
#[derive(PartialEq)]
pub struct Accounts;
/// The model is framed by the height of its canvas, so the hero pins both sides
/// instead of letting it grow with the page.
///
/// The SkSL renderer raymarches twelve boxes per fragment, so its cost is linear
/// in `WIDTH * HEIGHT`. At 160x264 that is ~42k fragments — about 2.2x the old
/// 116x168 frame, and still under a tenth of the window, so the larger preview is
/// free in practice.
///
/// The shader scales the model off `res.y` alone and only crops horizontally, so
/// the height is what actually makes the player bigger. The width is kept just
/// wide enough to clear the arms (~26 model units visible vs ~17 needed) and
/// otherwise left to the text column.
const MODEL_WIDTH_PX: f32 = 160.;
const MODEL_HEIGHT_PX: f32 = 264.;
impl Component for Accounts {
const AVATAR_SIZE_PX: f32 = 36.;
#[derive(PartialEq)]
pub struct SettingsAccounts;
impl Component for SettingsAccounts {
fn render(&self) -> impl IntoElement {
let accounts_query = use_accounts();
let default_query = use_current_account();
@ -93,47 +110,20 @@ impl Component for Accounts {
rows.push(empty_state());
}
rect()
.horizontal()
.width(Size::fill())
.height(Size::fill())
.overflow(Overflow::Clip)
.content(Content::Flex)
.padding(40.)
.spacing(24.)
.child(render_panel(default_account))
.child(
rect()
.vertical()
.width(Size::flex(1.0))
.height(Size::fill())
.overflow(Overflow::Clip)
.spacing(20.)
.child(
label()
.text("Accounts")
.font_size(32.)
.font_weight(FontWeight::BOLD)
.color(colors::fg_primary()),
)
.child(add_bar(
has_microsoft,
msa.pending,
msa.error.clone(),
move |_| show_offline.set(true),
{
let msa = msa.clone();
move |_| msa.start()
},
))
.child(
ScrollArea::new()
.width(Size::fill())
.height(Size::flex(1.0))
.spacing(12.)
.children(rows),
),
)
settings_page()
.child(hero(
default_account,
has_microsoft,
msa.pending,
msa.error.clone(),
move |_| show_offline.set(true),
{
let msa = msa.clone();
move |_| msa.start()
},
))
.child(section_header("YOUR ACCOUNTS"))
.children(rows)
.maybe_child(show_offline.read().then(|| {
offline_dialog(
username,
@ -148,27 +138,146 @@ impl Component for Accounts {
}
}
fn render_panel(account: Option<MinecraftAccount>) -> impl IntoElement {
/// The active account, its model, and the two ways to add another one.
fn hero(
account: Option<MinecraftAccount>,
has_microsoft: bool,
microsoft_pending: bool,
error: Option<String>,
on_open_offline: impl FnMut(Event<PressEventData>) + 'static,
on_add_microsoft: impl FnMut(Event<PressEventData>) + 'static,
) -> impl IntoElement {
let (name, subtitle) = match &account {
Some(account) => (account.username.clone(), kind_label(account.kind)),
None => (
"No active account".to_string(),
"Add an account to start playing",
),
};
rect()
.horizontal()
.width(Size::fill())
.content(Content::Flex)
.cross_align(Alignment::Center)
.spacing(16.)
.padding(Gaps::new_all(16.))
.corner_radius(CornerRadius::new_all(12.))
.background(colors::page_elevated())
.child(model_frame(account.as_ref().map(|account| account.id)))
.child(
rect()
.vertical()
.width(Size::flex(1.0))
.spacing(16.)
.child(
rect()
.vertical()
.width(Size::fill())
.spacing(2.)
.child(
label()
.text("ACTIVE ACCOUNT")
.font_size(11.)
.font_weight(FontWeight::MEDIUM)
.color(colors::fg_secondary()),
)
.child(
label()
.text(name)
.font_size(24.)
.font_weight(FontWeight::SEMI_BOLD)
.max_lines(1)
.color(colors::fg_primary()),
)
.child(
label()
.text(subtitle)
.font_size(12.)
.color(colors::fg_secondary()),
),
)
.child(
rect()
.vertical()
.width(Size::fill())
.spacing(8.)
.child(
// The bigger model leaves ~245px beside it at the
// 800px minimum, which the two buttons overflow, so
// they wrap onto a second line there and sit side by
// side again once there is room.
rect()
.horizontal()
.width(Size::fill())
.content(Content::wrap_spacing(8.))
.spacing(8.)
.child(
Button::new()
.primary()
.enabled(!microsoft_pending)
.on_press(on_add_microsoft)
.child(Icon::new(IconType::Globe01).size(16.))
.text(if microsoft_pending {
"Signing in..."
} else {
"Add Microsoft"
}),
)
.child(
Button::new()
.secondary()
.enabled(has_microsoft)
.on_press(on_open_offline)
.child(Icon::new(IconType::Plus).size(16.))
.text("Add offline"),
),
)
.map(error, |el, msg| {
el.child(hint_line(IconType::AlertTriangle, msg, colors::danger()))
})
.maybe(!has_microsoft, |el| {
el.child(hint_line(
IconType::InfoCircle,
"Add a Microsoft account before creating offline accounts."
.to_string(),
colors::fg_secondary(),
))
}),
),
)
.into_element()
}
fn model_frame(id: Option<Uuid>) -> impl IntoElement {
rect()
.vertical()
.width(Size::px(300.))
.height(Size::fill())
.center()
.spacing(16.)
.padding(Gaps::new_all(24.))
.corner_radius(CornerRadius::new_all(16.))
.background(colors::page_elevated())
.border(border_all_color(1., colors::component_border()))
.maybe_child(account.as_ref().map(|account| {
PlayerModel::new(account.id)
.yaw(-0.5)
.width(Size::fill())
.height(Size::fill())
.into_element()
}))
.maybe_child(account.as_ref().map(|_| {
.cross_align(Alignment::Center)
.spacing(6.)
.child(
rect()
.width(Size::px(MODEL_WIDTH_PX))
.height(Size::px(MODEL_HEIGHT_PX))
.center()
.overflow(Overflow::Clip)
.corner_radius(CornerRadius::new_all(12.))
.background(colors::component_bg())
.border(border_all_color(1., colors::component_border()))
.child(match id {
Some(id) => PlayerModel::new(id)
.yaw(-0.5)
.width(Size::fill())
.height(Size::fill())
.into_element(),
None => Icon::new(IconType::Users01)
.size(28.)
.color(colors::fg_secondary())
.into_element(),
}),
)
.maybe_child(id.map(|_| {
label()
.text("Drag to rotate the model")
.text("Drag to rotate")
.font_size(10.)
.color(colors::fg_secondary())
.into_element()
@ -190,62 +299,6 @@ where
}
}
fn add_bar(
has_microsoft: bool,
microsoft_pending: bool,
error: Option<String>,
on_open_offline: impl FnMut(Event<PressEventData>) + 'static,
on_add_microsoft: impl FnMut(Event<PressEventData>) + 'static,
) -> impl IntoElement {
rect()
.vertical()
.width(Size::fill())
.spacing(8.)
.padding(Gaps::new_all(12.))
.corner_radius(CornerRadius::new_all(12.))
.background(colors::page_elevated())
.border(border_all_color(1., colors::component_border()))
.child(
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.main_align(Alignment::End)
.spacing(12.)
.child(
Button::new()
.secondary()
.enabled(has_microsoft)
.on_press(on_open_offline)
.child(Icon::new(IconType::Plus).size(16.))
.text("Add offline"),
)
.child(
Button::new()
.primary()
.enabled(!microsoft_pending)
.on_press(on_add_microsoft)
.child(Icon::new(IconType::Globe01).size(16.))
.text(if microsoft_pending {
"Signing in..."
} else {
"Add Microsoft"
}),
),
)
.map(error, |el, msg| {
el.child(hint_line(IconType::AlertTriangle, msg, colors::danger()))
})
.maybe(!has_microsoft, |el| {
el.child(hint_line(
IconType::InfoCircle,
"Add a Microsoft account before creating offline accounts.".to_string(),
colors::fg_secondary(),
))
})
.into_element()
}
fn offline_dialog(
username: State<String>,
uuid_preview: Option<String>,
@ -335,13 +388,23 @@ fn field_label(text: &str) -> impl IntoElement {
.into_element()
}
/// The hint sits in whatever is left of the hero beside the model, so the text
/// has to be free to wrap instead of pushing the row wider.
fn hint_line(icon: IconType, text: String, color: Color) -> impl IntoElement {
rect()
.horizontal()
.width(Size::fill())
.content(Content::Flex)
.cross_align(Alignment::Center)
.spacing(6.)
.child(Icon::new(icon).size(13.).color(color))
.child(label().text(text).font_size(12.).color(color))
.child(
label()
.text(text)
.font_size(12.)
.width(Size::flex(1.0))
.color(color),
)
.into_element()
}
@ -423,7 +486,7 @@ impl Component for AccountRow {
.width(Size::fill())
.cross_align(Alignment::Center)
.content(Content::Flex)
.spacing(16.)
.spacing(12.)
.padding(Gaps::new_all(12.))
.corner_radius(CornerRadius::new_all(12.))
.background(colors::page_elevated())
@ -439,8 +502,8 @@ impl Component for AccountRow {
})
.child(
Avatar::new(id.to_string())
.width(Size::px(40.))
.height(Size::px(40.)),
.width(Size::px(AVATAR_SIZE_PX))
.height(Size::px(AVATAR_SIZE_PX)),
)
.child(
rect()
@ -451,22 +514,26 @@ impl Component for AccountRow {
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(8.)
.spacing(6.)
.child(
label()
.text(self.username.clone())
.font_size(16.)
.font_size(15.)
.font_weight(FontWeight::MEDIUM)
.max_lines(1)
.color(colors::fg_primary()),
)
.child(kind_badge(self.kind))
.maybe_child(is_default.then(default_badge))
.maybe_child(expired.then(expired_badge)),
)
// The kind rides along with the id on the second line: a
// third badge and a full uuid do not both fit beside the
// settings sidebar.
.child(
label()
.text(id.to_string())
.text(format!("{} · {id}", kind_label(self.kind)))
.font_size(11.)
.max_lines(1)
.color(colors::fg_secondary()),
),
)
@ -523,21 +590,11 @@ impl Component for AccountRow {
}
}
fn kind_badge(kind: AccountKind) -> impl IntoElement {
let (icon, text) = match kind {
AccountKind::Microsoft => (IconType::Globe01, "Microsoft"),
AccountKind::Offline => (IconType::Users01, "Offline"),
};
badge(
Icon::new(icon)
.size(12.)
.color(colors::fg_secondary())
.into_element(),
text.to_string(),
colors::component_border(),
colors::fg_secondary(),
)
fn kind_label(kind: AccountKind) -> &'static str {
match kind {
AccountKind::Microsoft => "Microsoft",
AccountKind::Offline => "Offline",
}
}
fn default_badge() -> impl IntoElement {
@ -589,8 +646,10 @@ fn empty_state() -> Element {
.vertical()
.width(Size::fill())
.center()
.padding(Gaps::new_all(48.))
.padding(Gaps::new_all(32.))
.spacing(8.)
.corner_radius(CornerRadius::new_all(12.))
.background(colors::page_elevated())
.child(
Icon::new(IconType::Users01)
.size(32.)

View file

@ -1,3 +1,4 @@
mod accounts;
mod appearance;
mod apis;
@ -12,6 +13,7 @@ mod storage;
use freya::prelude::*;
pub use accounts::SettingsAccounts;
pub use apis::SettingsApis;
pub use changelog::SettingsChangelog;
pub use developer::SettingsDeveloper;