use freya::prelude::*; use freya::router::RouterContext; use oneclient_content::packages::ProviderId; use oneclient_core::SeenStatus; use crate::components::{Icon, IconType, toggle_controlled}; use crate::hooks::{ClusterAction, loaded_image, use_cached_image, use_cluster_mutation}; use crate::routes::Route; use crate::theme::colors; use crate::ui::border_all_color; use crate::utils::format_size; pub(crate) const CARD_BG: Color = Color::from_rgb(26, 34, 41); pub(crate) const CARD_NAME: Color = Color::from_rgb(213, 219, 255); pub(crate) const CARD_H: f32 = 84.; pub(crate) const CARD_GRID_H: f32 = 148.; pub(crate) const GRID_GAP: f32 = 10.; pub(crate) const GRID_MIN_W: f32 = 260.; #[derive(Clone, Copy, PartialEq)] pub enum CardLayout { List, Grid, } impl From for CardLayout { fn from(layout: oneclient_core::settings::ViewLayout) -> Self { match layout { oneclient_core::settings::ViewLayout::Grid => CardLayout::Grid, oneclient_core::settings::ViewLayout::List => CardLayout::List, } } } #[derive(Clone, PartialEq)] pub struct PackageEntry { pub package_id: String, pub bundle_name: Option, pub provider: ProviderId, pub name: String, pub file_name: String, pub author: String, pub description: String, pub icon_url: Option, pub size: u64, pub categories: Vec, pub enabled: bool, pub installed: bool, pub hash: Option, pub manifest_default: bool, /// Private bundle dependency only set for bundle rows pub hidden: bool, /// Only set for browser-installed content bundle packages use the bundle update flow pub update_available: bool, /// Recency badge state cleared once the user views the list pub seen_status: SeenStatus, } impl PackageEntry { pub fn is_remote(&self) -> bool { self.provider != ProviderId::Local } pub fn in_bundle(&self) -> bool { self.bundle_name.is_some() } /// Bundle packages are never marked here so the two update flows cannot contradict pub fn is_outdated(&self) -> bool { self.update_available && self.is_remote() && !self.in_bundle() } pub fn recency_badge(&self) -> Option { match self.seen_status { SeenStatus::New => Some(new_badge()), SeenStatus::Updated => Some(updated_badge()), SeenStatus::Seen => None, } } } #[derive(PartialEq)] pub struct PackageRow { item: PackageEntry, cluster_id: i64, package_type: &'static str, layout: CardLayout, key: DiffKey, } impl PackageRow { pub fn new(item: PackageEntry, cluster_id: i64, package_type: &'static str) -> Self { Self { item, cluster_id, package_type, layout: CardLayout::List, key: DiffKey::None, } } pub fn layout(mut self, layout: CardLayout) -> Self { self.layout = layout; self } } impl KeyExt for PackageRow { fn write_key(&mut self) -> &mut DiffKey { &mut self.key } } impl Component for PackageRow { fn render(&self) -> impl IntoElement { let item = self.item.clone(); let cluster_id = self.cluster_id; let package_type = self.package_type; let layout = self.layout; let cluster = use_cluster_mutation(); let remove_hover = use_state(|| false); let icon_size = match layout { CardLayout::List => 44., CardLayout::Grid => 52., }; let icon_query = use_cached_image(item.icon_url.clone(), 256); let icon = package_icon(&item, &icon_query, icon_size); let on_toggle: EventHandler<()> = { let hash = item.hash.clone(); let bundle_name = item.bundle_name.clone(); let package_id = item.package_id.clone(); let enabled_now = item.enabled; let manifest_default = item.manifest_default; (move |()| { if let Some(h) = &hash { cluster.mutate(ClusterAction::ToggleArtifact { cluster_id, hash: h.clone(), }); } else if let Some(bundle) = &bundle_name { cluster.mutate(ClusterAction::SetBundlePackageEnabled { cluster_id, bundle_name: bundle.clone(), package_id: package_id.clone(), enabled: !enabled_now, manifest_default, }); } }) .into() }; match layout { CardLayout::List => { let removable = !item.in_bundle(); let can_remove = removable && item.installed; let rm_hash = item.hash.clone(); let on_remove = move || { if let Some(h) = &rm_hash { cluster.mutate(ClusterAction::RemoveArtifact { cluster_id, hash: h.clone(), }); } }; list_card( &item, package_type, cluster_id, icon, on_toggle, can_remove, on_remove, remove_hover, ) } CardLayout::Grid => grid_card(&item, package_type, cluster_id, icon, on_toggle, true), } } } #[allow(clippy::too_many_arguments)] fn list_card( item: &PackageEntry, package_type: &'static str, cluster_id: i64, icon: impl IntoElement, on_toggle: EventHandler<()>, can_remove: bool, on_remove: impl FnMut() + 'static, hovering: State, ) -> Element { rect() .horizontal() .width(Size::fill()) .height(Size::px(CARD_H)) .cross_align(Alignment::Center) .spacing(12.) .padding(Gaps::new_all(10.)) .corner_radius(CornerRadius::new_all(8.)) .background(CARD_BG) .content(Content::Flex) .child(package_info(item, package_type, cluster_id, icon)) .child(meta_size(item.size)) .child(toggle_controlled(item.enabled, on_toggle)) .child(remove_button(can_remove, on_remove, hovering)) .into_element() } pub(crate) fn grid_card( item: &PackageEntry, package_type: &'static str, cluster_id: i64, icon: impl IntoElement, on_toggle: EventHandler<()>, navigable: bool, // if true, it takes the user to the mod page ) -> Element { let remote = item.is_remote(); let enabled = item.enabled; let title = item.name.clone(); let (bg, border, content_alpha) = if enabled { (colors::brand().with_a(38), colors::brand(), 255) } else { (CARD_BG, colors::component_border(), 140) }; let badge = if remote && navigable { let provider = item.provider; let package_id = item.package_id.clone(); let package_type_owned = package_type.to_string(); rect() .cursor(CursorIcon::Pointer) .on_press(move |e: Event| { e.stop_propagation(); let _ = RouterContext::get().push(Route::BrowserPackage { cluster_id, package_type: package_type_owned.clone(), package_id: format!("{}:{}", provider as u8, package_id), }); }) .child(provider_badge(item.provider)) .into_element() } else if remote { provider_badge(item.provider) } else { local_badge() }; let header = rect() .horizontal() .width(Size::fill()) .cross_align(Alignment::Center) .spacing(10.) .content(Content::Flex) .child(icon) .child( rect() .vertical() .width(Size::flex(1.0)) .spacing(3.) .child( label() .text(title) .font_size(14.) .font_weight(FontWeight::MEDIUM) .max_lines(1) .width(Size::fill()) .color(CARD_NAME.with_a(content_alpha)), ) .maybe(!item.author.is_empty(), |el| { el.child( label() .text(format!("by {}", item.author)) .font_size(10.) .max_lines(1) .width(Size::fill()) .color(colors::fg_secondary().with_a(content_alpha)), ) }) .child( rect() .horizontal() .width(Size::fill()) .cross_align(Alignment::Center) .spacing(6.) .content(Content::wrap_spacing(4.)) .child(badge) .maybe_child(item.is_outdated().then(outdated_badge)) .maybe_child(item.recency_badge()), ), ) .into_element(); let description = if item.description.is_empty() { None } else { Some( label() .text(item.description.clone()) .font_size(11.) .max_lines(3) .width(Size::fill()) .color(colors::fg_secondary().with_a(content_alpha)) .into_element(), ) }; rect() .vertical() .width(Size::fill()) .height(Size::fill()) .spacing(8.) .padding(Gaps::new_all(12.)) .corner_radius(CornerRadius::new_all(8.)) .background(bg) .border(border_all_color(1.5, border)) .content(Content::Flex) .cursor(CursorIcon::Pointer) .on_press(move |_| on_toggle.call(())) .child(header) .child( rect() .vertical() .width(Size::fill()) .height(Size::flex(1.0)) .maybe_child(description), ) .into_element() } fn package_info( item: &PackageEntry, package_type: &'static str, cluster_id: i64, icon: impl IntoElement, ) -> impl IntoElement { let remote = item.is_remote(); let provider = item.provider; let package_id = item.package_id.clone(); let package_type = package_type.to_string(); let title = item.name.clone(); rect() .horizontal() .width(Size::flex(1.0)) .cross_align(Alignment::Center) .spacing(12.) .content(Content::Flex) .maybe(remote, |el| { el.cursor(CursorIcon::Pointer).on_press(move |_| { let _ = RouterContext::get().push(Route::BrowserPackage { cluster_id, package_type: package_type.clone(), package_id: format!("{}:{}", provider as u8, package_id), }); }) }) .child(icon) .child( rect() .vertical() .width(Size::flex(1.0)) .spacing(3.) .child( rect() .horizontal() .cross_align(Alignment::Center) .spacing(8.) .child( label() .text(title) .font_size(15.) .font_weight(FontWeight::MEDIUM) .max_lines(1) .color(CARD_NAME), ) .child(if remote { provider_badge(item.provider) } else { local_badge() }) .maybe_child(item.is_outdated().then(outdated_badge)) .maybe_child(item.recency_badge()), ) .maybe(!item.author.is_empty(), |el| { el.child( label() .text(format!("by {}", item.author)) .font_size(10.) .color(colors::fg_secondary()), ) }) .maybe(!item.description.is_empty(), |el| { el.child( label() .text(item.description.clone()) .font_size(11.) .max_lines(2) .width(Size::fill()) .color(colors::fg_secondary()), ) }), ) .into_element() } pub(crate) fn package_icon( item: &PackageEntry, icon_query: &freya::query::UseQuery, size: f32, ) -> Element { let icon_url = &item.icon_url; let loaded = loaded_image(icon_url.as_deref(), icon_query); match loaded { Some((url, bytes)) => ImageViewer::new((url, bytes)) .width(Size::px(size)) .height(Size::px(size)) .aspect_ratio(AspectRatio::Min) .corner_radius(CornerRadius::new_all(8.)) .into_element(), None if icon_url.is_some() => icon_box(IconType::DotsGrid, size), None => icon_box(IconType::HelpCircle, size), } } fn icon_box(icon: IconType, size: f32) -> Element { rect() .center() .width(Size::px(size)) .height(Size::px(size)) .corner_radius(CornerRadius::new_all(8.)) .background(colors::component_bg()) .child( Icon::new(icon) .size(size * 0.4) .color(colors::fg_secondary()), ) .into_element() } fn meta_size(size: u64) -> impl IntoElement { rect() .maybe_child((size > 0).then(|| { label() .text(format_size(size)) .font_size(11.) .color(colors::fg_secondary()) })) .into_element() } pub fn provider_badge(provider: ProviderId) -> Element { badge( Icon::new(provider).size(12.).into_element(), provider.to_string(), ) } fn outdated_badge() -> Element { accent_badge( Icon::new(IconType::RefreshCw01) .size(12.) .color(colors::brand()) .into_element(), "Update available".to_string(), colors::brand(), ) } fn new_badge() -> Element { accent_badge( Icon::new(IconType::Plus) .size(12.) .color(colors::success()) .into_element(), "New".to_string(), colors::success(), ) } fn updated_badge() -> Element { accent_badge( Icon::new(IconType::RefreshCcw02) .size(12.) .color(colors::success()) .into_element(), "Updated".to_string(), colors::success(), ) } fn local_badge() -> Element { badge( Icon::new(IconType::File02) .size(12.) .color(colors::fg_secondary()) .into_element(), "Local file".to_string(), ) } fn badge(icon: impl IntoElement, text: String) -> Element { accent_badge(icon, text, colors::fg_secondary()) } fn accent_badge(icon: impl IntoElement, text: String, accent: Color) -> Element { rect() .horizontal() .cross_align(Alignment::Center) .spacing(4.) .padding(Gaps::new_symmetric(2., 8.)) .corner_radius(CornerRadius::new_all(999.)) .border(border_all_color(1., colors::component_border())) .background(colors::component_bg()) .child(icon) .child( label() .text(text) .font_size(10.) .font_weight(FontWeight::MEDIUM) .color(accent), ) .into_element() } fn remove_button( enabled: bool, mut on_remove: impl FnMut() + 'static, mut hovering: State, ) -> impl IntoElement { let hot = enabled && *hovering.read(); let color = if !enabled { colors::fg_secondary().with_a(90) } else if hot { colors::danger() } else { colors::fg_secondary() }; rect() .center() .width(Size::px(30.)) .height(Size::px(30.)) .corner_radius(CornerRadius::new_all(7.)) .background(if hot { colors::danger().with_a(30) } else { Color::TRANSPARENT }) .maybe(enabled, |el| { el.cursor(CursorIcon::Pointer) .on_pointer_enter(move |_| hovering.set(true)) .on_pointer_leave(move |_| hovering.set(false)) .on_press(move |_| on_remove()) }) .child(Icon::new(IconType::Trash01).size(14.).color(color)) .into_element() }