feat: added filter for loaders in browser view (#946)

* feat: added filter for loaders in browser view

* changeset
This commit is contained in:
Maciek 2026-10-10 00:54:45 +02:00 • committed by GitHub
parent 5a8728ef04
commit d21c0dc22b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 77 additions and 12 deletions

View file

@ -0,0 +1,5 @@
---
default: patch
---
# Added a filter for choosing modpack loaders

View file

@ -1,6 +1,7 @@
use std::collections::{HashMap, HashSet};
use freya::prelude::*;
use oneclient_common::domain::GameLoader;
use oneclient_content::packages::ProviderId;
use oneclient_content::packages::types::SearchSort;
use oneclient_db::models::ClusterId;
@ -124,6 +125,7 @@ pub struct BrowserUiState {
pub provider: ProviderId,
pub categories: Vec<String>,
pub sort: SearchSort,
pub loader: Option<GameLoader>,
pub page: usize,
}
@ -134,6 +136,7 @@ impl Default for BrowserUiState {
provider: ProviderId::Modrinth,
categories: Vec::new(),
sort: SearchSort::Relevance,
loader: None,
page: 0,
}
}

View file

@ -5,6 +5,7 @@ use freya::animation::{
};
use freya::prelude::*;
use freya::router::RouterContext;
use oneclient_common::domain::GameLoader;
use oneclient_common::parse_mc_version;
use oneclient_common::search::normalize_query;
use oneclient_content::packages::types::ProjectSummary;
@ -68,6 +69,13 @@ const SORTS: [(SearchSort, &str); 4] = [
(SearchSort::Updated, "Updated"),
];
const LOADERS: [(Option<GameLoader>, &str); 4] = [
(None, "Any loader"),
(Some(GameLoader::Fabric), "Fabric"),
(Some(GameLoader::Forge), "Forge"),
(Some(GameLoader::NeoForge), "NeoForge"),
];
const BROWSE_TYPES: [(&str, &str); 5] = [
("mod", "Mods"),
("texture", "Textures"),
@ -183,6 +191,7 @@ impl Component for BrowserBody {
let compatible_only = use_browser_compat();
let selected_categories = use_state(|| saved.categories.clone());
let sort = use_state(|| saved.sort);
let loader = use_state(|| saved.loader);
let page = use_state(|| saved.page);
{
@ -194,6 +203,7 @@ impl Component for BrowserBody {
provider: *provider.read(),
categories: selected_categories.read().clone(),
sort: *sort.read(),
loader: *loader.read(),
page: *page.read(),
};
store.write().insert(key.clone(), snapshot);
@ -212,23 +222,24 @@ impl Component for BrowserBody {
let cats = selected_categories.read().clone();
let targets_cluster = content_type != ContentType::Modpack;
let (game_versions, loaders) = match (compat && targets_cluster, &cluster) {
(true, Some(c)) => {
let loaders = if content_type == ContentType::Mod {
vec![c.mc_loader]
} else {
Vec::new()
};
(vec![c.mc_version.clone()], loaders)
}
_ => (Vec::new(), Vec::new()),
let compat_cluster = cluster.as_ref().filter(|_| compat && targets_cluster);
let game_versions = compat_cluster
.map(|c| vec![c.mc_version.clone()])
.unwrap_or_default();
let loader_fixed = content_type == ContentType::Mod && compat_cluster.is_some();
let loader_filterable =
matches!(content_type, ContentType::Mod | ContentType::Modpack) && !loader_fixed;
let picked_loader = (*loader.read()).filter(|_| loader_filterable);
let loaders = match (loader_fixed, compat_cluster) {
(true, Some(c)) => vec![c.mc_loader],
_ => picked_loader.into_iter().collect(),
};
let mut page_state = page;
// Normalised like the search key so a query differing only by a space does not reset the page
let sort_by = *sort.read();
let signature = format!(
"{provider_id:?}|{}|{compat}|{}|{sort_by:?}",
"{provider_id:?}|{}|{compat}|{}|{sort_by:?}|{picked_loader:?}",
normalize_query(&debounced_query.read()).to_lowercase(),
cats.join(",")
);
@ -396,6 +407,7 @@ impl Component for BrowserBody {
pages,
pending,
sort,
loader_filterable.then_some(loader),
view_mode,
))
.child(
@ -528,6 +540,7 @@ fn results_toolbar(
pages: Option<usize>,
pending: bool,
sort: State<SearchSort>,
loader: Option<State<Option<GameLoader>>>,
view_mode: State<ViewLayout>,
) -> impl IntoElement {
// Blank rather than a stale count while a page is in flight
@ -554,6 +567,7 @@ fn results_toolbar(
.color(colors::fg_primary().with_a(140)),
)
.child(rect().width(Size::flex(1.0)))
.maybe_child(loader.map(|loader| LoaderPicker { loader }))
.child(SortPicker { sort })
.child(
SegmentedControl::new(view_mode)
@ -596,6 +610,33 @@ impl Component for SortPicker {
}
}
#[derive(PartialEq)]
struct LoaderPicker {
loader: State<Option<GameLoader>>,
}
impl Component for LoaderPicker {
fn render(&self) -> impl IntoElement {
let mut loader = self.loader;
let current = *loader.read();
let labels: Vec<String> = LOADERS.iter().map(|(_, l)| (*l).to_string()).collect();
let selected = LOADERS
.iter()
.find(|(l, _)| *l == current)
.map_or("Any loader", |(_, l)| *l);
Dropdown::new(selected, labels)
.width(Size::px(132.))
.height(Size::px(30.))
.on_select(move |idx: usize| {
if let Some((picked, _)) = LOADERS.get(idx) {
loader.set(*picked);
}
})
}
}
/// Falls back to the bare version while the manifest hasn't arrived or doesn't cover it
fn version_name(metadata: &[VersionMetadata], cluster: &Cluster) -> String {
if cluster.user_created {

View file

@ -57,7 +57,14 @@ impl PackageProvider for CurseForgeProvider {
.to_string(),
);
params.append_pair("index", &filters.offset.unwrap_or(0).to_string());
params.append_pair("sortField", "6");
params.append_pair(
"sortField",
&filters
.sort
.unwrap_or_default()
.curseforge_sort_field()
.to_string(),
);
params.append_pair("sortOrder", "desc");
let query = filters.normalized_query();
if !query.is_empty() {

View file

@ -50,6 +50,15 @@ impl SearchSort {
Self::Updated => "updated",
}
}
pub fn curseforge_sort_field(self) -> u32 {
match self {
Self::Relevance => 1,
Self::Downloads => 6,
Self::Newest => 11,
Self::Updated => 3,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]