use crate::views::selectable_text::text;
use iced::widget::{button, column, container, row, scrollable, text_input};
use iced::{Alignment, Element, Length};
use modde_sources::nexus::graphql::{GqlCollectionTile, GqlModTile};
use crate::action_button::{ButtonAction, DescribedButtonExt};
use crate::app::Message;
use crate::views::game_picker::{game_pick_list, nexus_game_options};
use crate::views::tabs::{Tab, tab_bar};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BrowseTab {
Top,
Month,
Collections,
Search,
}
impl BrowseTab {
#[must_use]
pub fn label(self) -> &'static str {
match self {
BrowseTab::Top => "Top",
BrowseTab::Month => "Month",
BrowseTab::Collections => "Collections",
BrowseTab::Search => "Search",
}
}
pub const ALL: [BrowseTab; 4] = [
BrowseTab::Top,
BrowseTab::Month,
BrowseTab::Collections,
BrowseTab::Search,
];
}
#[derive(Debug, Clone)]
pub struct NexusBrowseState {
pub active_tab: BrowseTab,
pub selected_game_id: Option<String>,
pub search_query: String,
pub loading: bool,
pub error: Option<String>,
pub mods: Vec<GqlModTile>,
pub collections: Vec<GqlCollectionTile>,
pub install_status: Option<String>,
}
impl Default for NexusBrowseState {
fn default() -> Self {
Self {
active_tab: BrowseTab::Top,
selected_game_id: None,
search_query: String::new(),
loading: false,
error: None,
mods: Vec::new(),
collections: Vec::new(),
install_status: None,
}
}
}
pub fn view<'a>(
state: &'a NexusBrowseState,
available_games: &'a [(String, String)],
game_domain: Option<String>,
) -> Element<'a, Message> {
let title = text("Browse Nexus").size(20);
let tab_bar = render_tab_bar(state.active_tab);
let game_options = nexus_game_options(available_games.iter());
let selected_game = state.selected_game_id.as_ref().and_then(|game_id| {
game_options
.iter()
.find(|option| option.value == *game_id)
.cloned()
});
let game_selector = game_pick_list(game_options, selected_game, "Select game", |option| {
Message::BrowseGameChanged(Some(option.value))
});
let search_bar = text_input("Search Nexus mods & collections…", &state.search_query)
.on_input(Message::BrowseSearchChanged)
.on_submit(Message::BrowseSearchSubmit)
.padding(8)
.width(Length::Fill);
let content: Element<'a, Message> = if game_domain.is_none() {
container(text("Select a supported Nexus game to browse mods.").size(14))
.padding(20)
.width(Length::Fill)
.center_x(Length::Fill)
.into()
} else if state.loading {
container(text("Loading…").size(14))
.padding(20)
.width(Length::Fill)
.center_x(Length::Fill)
.into()
} else if let Some(err) = state.error.as_deref() {
container(text(format!("Nexus error: {err}")).size(14))
.padding(20)
.width(Length::Fill)
.center_x(Length::Fill)
.into()
} else {
match state.active_tab {
BrowseTab::Collections => collections_grid(&state.collections, game_domain),
_ => mods_grid(&state.mods, game_domain),
}
};
let status_bar: Element<Message> = if let Some(msg) = state.install_status.as_deref() {
text(msg).size(12).into()
} else {
iced::widget::space::horizontal().into()
};
column![
title,
tab_bar,
row![game_selector, search_bar].spacing(8),
iced::widget::rule::horizontal(1),
content,
status_bar,
]
.spacing(8)
.padding(16)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn render_tab_bar(active: BrowseTab) -> Element<'static, Message> {
tab_bar(BrowseTab::ALL.map(|tab| {
Tab::new(
tab.label(),
tab == active,
ButtonAction::BrowseTabSwitched(tab),
)
}))
}
fn mods_grid(mods: &[GqlModTile], game_domain: Option<String>) -> Element<'_, Message> {
if mods.is_empty() {
return container(text("No mods in this feed yet.").size(13))
.padding(16)
.center_x(Length::Fill)
.into();
}
let col = mods.iter().fold(column![].spacing(8), |col, tile| {
col.push(mod_card(tile, game_domain.clone()))
});
scrollable(col).height(Length::Fill).into()
}
fn mod_card(tile: &GqlModTile, game_domain: Option<String>) -> Element<'_, Message> {
let header = row![
text(&tile.name).size(16),
iced::widget::space::horizontal(),
text(
tile.version
.as_deref()
.map(|v| format!("v{v}"))
.unwrap_or_default(),
)
.size(12),
]
.align_y(Alignment::Center);
let author_line = format!(
"by {}{}",
tile.author.as_deref().unwrap_or("unknown"),
tile.endorsements
.map(|e| format!(" • {e} endorsements"))
.unwrap_or_default(),
);
let summary = tile.summary.as_deref().unwrap_or("No summary provided.");
let install_btn: Element<Message> = match game_domain {
Some(domain) => button(text("Install").size(13))
.style(button::primary)
.padding([6, 14])
.on_action(ButtonAction::BrowseInstallMod {
game_domain: domain,
mod_id: tile.mod_id,
}),
None => button(text("Install").size(13))
.padding([6, 14])
.style(button::secondary)
.described_disabled("Load a profile before installing Nexus mods."),
};
container(
column![
header,
text(author_line).size(12),
text(summary).size(13),
install_btn,
]
.spacing(6)
.padding(12),
)
.width(Length::Fill)
.style(container::rounded_box)
.into()
}
fn collections_grid(
collections: &[GqlCollectionTile],
game_domain: Option<String>,
) -> Element<'_, Message> {
if collections.is_empty() {
return container(text("No collections in this feed yet.").size(13))
.padding(16)
.center_x(Length::Fill)
.into();
}
let col = collections.iter().fold(column![].spacing(8), |col, tile| {
col.push(collection_card(tile, game_domain.clone()))
});
scrollable(col).height(Length::Fill).into()
}
fn collection_card(tile: &GqlCollectionTile, _game_domain: Option<String>) -> Element<'_, Message> {
let header = row![
text(&tile.name).size(16),
iced::widget::space::horizontal(),
text(
tile.endorsements
.map(|e| format!("{e} endorsements"))
.unwrap_or_default(),
)
.size(12),
]
.align_y(Alignment::Center);
let summary = tile.summary.as_deref().unwrap_or("No summary provided.");
let install_btn = button(text("Install collection").size(13))
.style(button::primary)
.padding([6, 14])
.on_action(ButtonAction::InstallCollection {
slug: tile.slug.clone(),
version: String::new(),
});
container(
column![header, text(summary).size(13), install_btn]
.spacing(6)
.padding(12),
)
.width(Length::Fill)
.style(container::rounded_box)
.into()
}