feat(ui): Stats page tweaks (pie charts and consistent spacing)
This commit is contained in:
parent
d593ecb09f
commit
0a51018954
16 changed files with 697 additions and 140 deletions
|
|
@ -1,3 +1,5 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use oneclient_core::notification::{Notification, NotificationLevel, NotificationService};
|
||||
use oneclient_core::packages::PackageStore;
|
||||
use oneclient_core::settings::store::{
|
||||
|
|
@ -813,7 +815,7 @@ fn refresh_settings(snapshots: &mut BridgeSnapshot) -> anyhow::Result<()> {
|
|||
}
|
||||
|
||||
async fn install_package(
|
||||
state: &std::sync::Arc<LauncherState>,
|
||||
state: &Arc<LauncherState>,
|
||||
provider: oneclient_core::packages::ProviderId,
|
||||
project_id: &str,
|
||||
version_id: &str,
|
||||
|
|
@ -856,10 +858,7 @@ fn apply_game_stage(snapshots: &mut BridgeSnapshot, cluster_id: i64, stage: Laun
|
|||
snapshots.game.stages.insert(cluster_id, stage);
|
||||
if stage == LaunchStage::Checking {
|
||||
snapshots.game.error = None;
|
||||
snapshots
|
||||
.game
|
||||
.logs
|
||||
.insert(cluster_id, std::sync::Arc::new(Vec::new()));
|
||||
snapshots.game.logs.insert(cluster_id, Arc::new(Vec::new()));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -873,7 +872,7 @@ fn apply_game_failed(snapshots: &mut BridgeSnapshot, cluster_id: i64, message: S
|
|||
|
||||
fn apply_game_log(snapshots: &mut BridgeSnapshot, cluster_id: i64, line: String) {
|
||||
let buffer = snapshots.game.logs.entry(cluster_id).or_default();
|
||||
std::sync::Arc::make_mut(buffer).push(std::sync::Arc::from(line));
|
||||
Arc::make_mut(buffer).push(Arc::from(line));
|
||||
}
|
||||
|
||||
fn bump_profiles_generation(snapshots: &mut BridgeSnapshot) {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use freya::engine::prelude::{Paint, PaintStyle, PathBuilder, SkColor, SkRect};
|
||||
use freya::prelude::*;
|
||||
|
||||
use crate::theme::colors;
|
||||
|
|
@ -21,11 +22,259 @@ impl ValueUnit {
|
|||
const DEFAULT_HEIGHT: f32 = 150.;
|
||||
const Y_AXIS_WIDTH: f32 = 44.;
|
||||
|
||||
/// Slice colors, cycled when there are more slices than entries.
|
||||
pub const SLICE_COLORS: [Color; 10] = [
|
||||
Color::from_rgb(43, 75, 255),
|
||||
Color::from_rgb(27, 217, 106),
|
||||
Color::from_rgb(241, 100, 54),
|
||||
Color::from_rgb(198, 120, 221),
|
||||
Color::from_rgb(97, 175, 239),
|
||||
Color::from_rgb(229, 192, 123),
|
||||
Color::from_rgb(35, 154, 96),
|
||||
Color::from_rgb(224, 108, 117),
|
||||
Color::from_rgb(120, 129, 141),
|
||||
Color::from_rgb(152, 195, 121),
|
||||
];
|
||||
|
||||
pub fn slice_color(i: usize) -> Color {
|
||||
SLICE_COLORS[i % SLICE_COLORS.len()]
|
||||
}
|
||||
|
||||
const PIE_SIZE: f32 = 190.;
|
||||
/// Inner hole as a fraction of the radius, making it a donut.
|
||||
const PIE_HOLE: f32 = 0.58;
|
||||
|
||||
/// A donut chart. Hovering a slice reports it via `on_hover` so callers can
|
||||
/// sync an external legend; the hovered slice is also expanded slightly.
|
||||
pub struct PieChart {
|
||||
values: Vec<i64>,
|
||||
labels: Vec<String>,
|
||||
unit: ValueUnit,
|
||||
size: f32,
|
||||
hovered: Option<State<Option<usize>>>,
|
||||
}
|
||||
|
||||
impl PieChart {
|
||||
pub fn new(values: Vec<i64>, labels: Vec<String>) -> Self {
|
||||
Self {
|
||||
values,
|
||||
labels,
|
||||
unit: ValueUnit::Duration,
|
||||
size: PIE_SIZE,
|
||||
hovered: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unit(mut self, unit: ValueUnit) -> Self {
|
||||
self.unit = unit;
|
||||
self
|
||||
}
|
||||
|
||||
/// Shares the hovered slice index with the caller (e.g. to highlight a legend row).
|
||||
pub fn hovered(mut self, hovered: State<Option<usize>>) -> Self {
|
||||
self.hovered = Some(hovered);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for PieChart {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.values == other.values
|
||||
&& self.labels == other.labels
|
||||
&& self.unit == other.unit
|
||||
&& self.size == other.size
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for PieChart {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let internal = use_state(|| Option::<usize>::None);
|
||||
let mut hovered = self.hovered.unwrap_or(internal);
|
||||
let active = *hovered.read();
|
||||
|
||||
let size = self.size;
|
||||
let total: i64 = self.values.iter().sum();
|
||||
let values = self.values.clone();
|
||||
let hit_values = self.values.clone();
|
||||
|
||||
let render_cb = RenderCallback::new(move |ctx: &mut CanvasContext| {
|
||||
let (w, h) = (ctx.size.width, ctx.size.height);
|
||||
if w <= 0.0 || h <= 0.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let cx = w * 0.5;
|
||||
let cy = h * 0.5;
|
||||
let radius = (w.min(h) * 0.5) - 6.0;
|
||||
if radius <= 0.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut paint = Paint::default();
|
||||
paint.set_anti_alias(true);
|
||||
paint.set_style(PaintStyle::Fill);
|
||||
|
||||
// With nothing recorded, draw an empty track so the ring is still there.
|
||||
if total <= 0 {
|
||||
paint.set_color(SkColor::from(colors::component_bg_hover()));
|
||||
ctx.canvas.draw_circle((cx, cy), radius - 5.0, &paint);
|
||||
paint.set_color(SkColor::from(colors::page_elevated()));
|
||||
ctx.canvas
|
||||
.draw_circle((cx, cy), (radius - 5.0) * PIE_HOLE, &paint);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut start = -90.0_f32;
|
||||
for (i, &v) in values.iter().enumerate() {
|
||||
if v <= 0 {
|
||||
continue;
|
||||
}
|
||||
let sweep = (v as f32 / total as f32) * 360.0;
|
||||
// Grow the hovered slice a touch, and dim the rest.
|
||||
let is_active = Some(i) == active;
|
||||
let r = if is_active { radius } else { radius - 5.0 };
|
||||
let color = slice_color(i);
|
||||
let color = if active.is_some() && !is_active {
|
||||
color.with_a(120)
|
||||
} else {
|
||||
color
|
||||
};
|
||||
paint.set_color(SkColor::from(color));
|
||||
|
||||
let oval = SkRect::new(cx - r, cy - r, cx + r, cy + r);
|
||||
let mut builder = PathBuilder::new();
|
||||
builder.move_to((cx, cy));
|
||||
builder.arc_to(oval, start, sweep, false);
|
||||
builder.close();
|
||||
ctx.canvas.draw_path(&builder.detach(), &paint);
|
||||
|
||||
start += sweep;
|
||||
}
|
||||
|
||||
// Punch the donut hole using the card background.
|
||||
paint.set_color(SkColor::from(colors::page_elevated()));
|
||||
ctx.canvas
|
||||
.draw_circle((cx, cy), (radius - 5.0) * PIE_HOLE, &paint);
|
||||
});
|
||||
|
||||
let (center_name, center_value) = match active {
|
||||
Some(i) if i < self.values.len() => (
|
||||
self.labels
|
||||
.get(i)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("#{}", i + 1)),
|
||||
self.unit.format(self.values[i]),
|
||||
),
|
||||
_ => ("Total".to_string(), self.unit.format(total)),
|
||||
};
|
||||
|
||||
rect()
|
||||
.width(Size::px(size))
|
||||
.height(Size::px(size))
|
||||
.on_pointer_move(move |e: Event<PointerEventData>| {
|
||||
let loc = e.element_location();
|
||||
let next = slice_at(&hit_values, loc.x as f32, loc.y as f32, size);
|
||||
if *hovered.peek() != next {
|
||||
hovered.set(next);
|
||||
}
|
||||
})
|
||||
.on_pointer_leave(move |_| {
|
||||
if hovered.peek().is_some() {
|
||||
hovered.set(None);
|
||||
}
|
||||
})
|
||||
.child(
|
||||
rect()
|
||||
.width(Size::fill())
|
||||
.height(Size::fill())
|
||||
.position(Position::new_absolute())
|
||||
.interactive(false)
|
||||
// The canvas defaults to auto-sizing and has no children, so
|
||||
// without an explicit size it measures 0x0 and draws nothing.
|
||||
.child(canvas(render_cb).width(Size::fill()).height(Size::fill())),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.width(Size::fill())
|
||||
.height(Size::fill())
|
||||
.position(Position::new_absolute())
|
||||
.interactive(false)
|
||||
// Sibling absolute rects on an equal layer paint in undefined
|
||||
// order, so pin the readout above the canvas.
|
||||
.layer(Layer::Relative(1))
|
||||
.center()
|
||||
// Keeps the readout inside the donut hole (Gaps is vertical, horizontal).
|
||||
.padding(Gaps::new_symmetric(0., size * 0.25))
|
||||
.child(
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(2.)
|
||||
.child(
|
||||
label()
|
||||
.text(center_value)
|
||||
.font_size(16.)
|
||||
.font_weight(FontWeight::BOLD)
|
||||
.max_lines(1)
|
||||
.width(Size::fill())
|
||||
.text_align(TextAlign::Center)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text(center_name)
|
||||
.font_size(10.)
|
||||
.max_lines(2)
|
||||
.width(Size::fill())
|
||||
.text_align(TextAlign::Center)
|
||||
.color(colors::fg_secondary()),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a pointer position inside the chart box to a slice index.
|
||||
fn slice_at(values: &[i64], x: f32, y: f32, size: f32) -> Option<usize> {
|
||||
let total: i64 = values.iter().sum();
|
||||
if total <= 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cx = size * 0.5;
|
||||
let cy = size * 0.5;
|
||||
let radius = (size * 0.5) - 6.0;
|
||||
let (dx, dy) = (x - cx, y - cy);
|
||||
let dist = (dx * dx + dy * dy).sqrt();
|
||||
if dist > radius || dist < (radius - 5.0) * PIE_HOLE {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Angle measured clockwise from 12 o'clock, matching the drawing order.
|
||||
let mut angle = dy.atan2(dx).to_degrees() + 90.0;
|
||||
if angle < 0.0 {
|
||||
angle += 360.0;
|
||||
}
|
||||
|
||||
let mut start = 0.0_f32;
|
||||
for (i, &v) in values.iter().enumerate() {
|
||||
if v <= 0 {
|
||||
continue;
|
||||
}
|
||||
let sweep = (v as f32 / total as f32) * 360.0;
|
||||
if angle >= start && angle < start + sweep {
|
||||
return Some(i);
|
||||
}
|
||||
start += sweep;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub struct BarChart {
|
||||
values: Vec<i64>,
|
||||
labels: Vec<String>,
|
||||
readout_labels: Vec<String>,
|
||||
highlight: Option<usize>,
|
||||
unit: ValueUnit,
|
||||
height: f32,
|
||||
|
|
@ -37,7 +286,6 @@ impl BarChart {
|
|||
Self {
|
||||
values,
|
||||
labels,
|
||||
readout_labels: Vec::new(),
|
||||
highlight: None,
|
||||
unit: ValueUnit::Duration,
|
||||
height: DEFAULT_HEIGHT,
|
||||
|
|
@ -55,11 +303,6 @@ impl BarChart {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn readout_labels(mut self, labels: Vec<String>) -> Self {
|
||||
self.readout_labels = labels;
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn height(mut self, height: f32) -> Self {
|
||||
self.height = height;
|
||||
|
|
@ -72,9 +315,8 @@ impl BarChart {
|
|||
}
|
||||
|
||||
fn readout_name(&self, i: usize) -> String {
|
||||
self.readout_labels
|
||||
self.labels
|
||||
.get(i)
|
||||
.or_else(|| self.labels.get(i))
|
||||
.cloned()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| format!("#{}", i + 1))
|
||||
|
|
@ -85,6 +327,7 @@ impl Component for BarChart {
|
|||
fn render(&self) -> impl IntoElement {
|
||||
let mut hovered = use_state(|| Option::<usize>::None);
|
||||
let active = *hovered.read();
|
||||
let mut plot_width = use_state(|| 0f32);
|
||||
|
||||
let unit = self.unit;
|
||||
let height = self.height;
|
||||
|
|
@ -146,22 +389,26 @@ impl Component for BarChart {
|
|||
);
|
||||
}
|
||||
|
||||
let stride = label_stride(&self.labels, *plot_width.read(), self.gap);
|
||||
let mut label_row = rect()
|
||||
.horizontal()
|
||||
.content(Content::Flex)
|
||||
.width(Size::fill())
|
||||
.height(Size::px(14.))
|
||||
.spacing(self.gap);
|
||||
for text in &self.labels {
|
||||
for (i, text) in self.labels.iter().enumerate() {
|
||||
let shown = i % stride == 0;
|
||||
label_row = label_row.child(
|
||||
rect()
|
||||
.width(Size::flex(1.0))
|
||||
.main_align(Alignment::Center)
|
||||
.child(
|
||||
label()
|
||||
.text(text.clone())
|
||||
.text(if shown { text.clone() } else { String::new() })
|
||||
.font_size(10.)
|
||||
.max_lines(1)
|
||||
.width(Size::fill())
|
||||
.text_align(TextAlign::Center)
|
||||
.color(colors::fg_secondary()),
|
||||
),
|
||||
);
|
||||
|
|
@ -180,7 +427,17 @@ impl Component for BarChart {
|
|||
.width(Size::fill())
|
||||
.spacing(6.)
|
||||
.child(y_axis(max, unit, height))
|
||||
.child(rect().width(Size::flex(1.0)).child(plot));
|
||||
.child(
|
||||
rect()
|
||||
.width(Size::flex(1.0))
|
||||
.on_sized(move |e: Event<SizedEventData>| {
|
||||
let w = e.area.width();
|
||||
if (*plot_width.peek() - w).abs() > 0.5 {
|
||||
plot_width.set(w);
|
||||
}
|
||||
})
|
||||
.child(plot),
|
||||
);
|
||||
|
||||
let labels_row = rect()
|
||||
.horizontal()
|
||||
|
|
@ -200,6 +457,36 @@ impl Component for BarChart {
|
|||
}
|
||||
}
|
||||
|
||||
/// How many bars to skip between shown labels so text never overlaps.
|
||||
/// Derived from the measured plot width: a wider chart (e.g. maximised window)
|
||||
/// fits more labels and returns a smaller stride.
|
||||
fn label_stride(labels: &[String], plot_width: f32, gap: f32) -> usize {
|
||||
let n = labels.len();
|
||||
if n == 0 {
|
||||
return 1;
|
||||
}
|
||||
// Before the first measurement, thin conservatively to avoid an overlap flash.
|
||||
if plot_width <= 0.0 {
|
||||
return (n / 12).max(1);
|
||||
}
|
||||
|
||||
let max_chars = labels
|
||||
.iter()
|
||||
.map(|s| s.chars().count())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.max(1);
|
||||
// ~6.2px per char at font_size 10, plus breathing room between labels.
|
||||
let slot_px = max_chars as f32 * 6.2 + gap.max(8.0) + 8.0;
|
||||
let max_labels = (plot_width / slot_px).floor().max(1.0) as usize;
|
||||
|
||||
if n <= max_labels {
|
||||
1
|
||||
} else {
|
||||
n.div_ceil(max_labels)
|
||||
}
|
||||
}
|
||||
|
||||
fn gridlines(height: f32) -> Element {
|
||||
let mut overlay = rect()
|
||||
.width(Size::fill())
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ pub use account_switcher::AccountSwitcher;
|
|||
pub use active_cluster_panel::ActiveClusterPanel;
|
||||
pub use avatar::Avatar;
|
||||
pub use button::{Button, ButtonSize, ButtonVariant, link_button, open_folder_button};
|
||||
pub use charts::{BarChart, ValueUnit};
|
||||
pub use charts::{BarChart, PieChart, ValueUnit, slice_color};
|
||||
pub use cluster_landscape_art::ClusterLandscapeArt;
|
||||
pub use cluster_update_popup::ClusterUpdatePopup;
|
||||
pub use context_menu::ContextMenu;
|
||||
|
|
|
|||
|
|
@ -68,8 +68,7 @@ impl Component for WhenChart {
|
|||
.unwrap_or_else(|| "—".to_string());
|
||||
(
|
||||
format!("Peak around {hour}"),
|
||||
BarChart::new(self.per_hour.clone(), hour_labels())
|
||||
.readout_labels((0..24).map(format_hour).collect())
|
||||
BarChart::new(self.per_hour.clone(), (0..24).map(format_hour).collect())
|
||||
.highlight(self.peak_hour)
|
||||
.unit(ValueUnit::Duration)
|
||||
.gap(3.)
|
||||
|
|
@ -135,18 +134,7 @@ impl Component for DailyChart {
|
|||
let slice = &series[start..end];
|
||||
|
||||
let values: Vec<i64> = slice.iter().map(|(_, s)| *s).collect();
|
||||
let readout_labels: Vec<String> = slice.iter().map(|(d, _)| format_day(*d)).collect();
|
||||
let labels: Vec<String> = slice
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (d, _))| {
|
||||
if i == 0 || i == slice.len() - 1 {
|
||||
format_day(*d)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let labels: Vec<String> = slice.iter().map(|(d, _)| format_day(*d)).collect();
|
||||
|
||||
let win_total: i64 = values.iter().sum();
|
||||
let subtitle = match (slice.first(), slice.last()) {
|
||||
|
|
@ -192,7 +180,6 @@ impl Component for DailyChart {
|
|||
subtitle,
|
||||
Some(nav),
|
||||
BarChart::new(values, labels)
|
||||
.readout_labels(readout_labels)
|
||||
.unit(ValueUnit::Duration)
|
||||
.gap(3.)
|
||||
.into_element(),
|
||||
|
|
@ -224,17 +211,17 @@ fn continuous_series(daily: &[DayPlaytime]) -> Vec<(NaiveDate, i64)> {
|
|||
out
|
||||
}
|
||||
|
||||
pub(super) fn distribution_card(session_secs: &[i64]) -> Option<Element> {
|
||||
if session_secs.len() < 2 {
|
||||
pub(super) fn distribution_card(session_secs: &[i64], force: bool) -> Option<Element> {
|
||||
if session_secs.len() < 2 && !force {
|
||||
return None;
|
||||
}
|
||||
|
||||
const EDGES: [(i64, &str); 6] = [
|
||||
(15 * 60, "<15m"),
|
||||
(30 * 60, "15–30"),
|
||||
(60 * 60, "30–60"),
|
||||
(2 * 3600, "1–2h"),
|
||||
(4 * 3600, "2–4h"),
|
||||
(30 * 60, "15m-30m"),
|
||||
(60 * 60, "30m-60m"),
|
||||
(2 * 3600, "1h-2h"),
|
||||
(4 * 3600, "2h-4h"),
|
||||
(i64::MAX, "4h+"),
|
||||
];
|
||||
|
||||
|
|
@ -269,15 +256,3 @@ pub(super) fn distribution_card(session_secs: &[i64]) -> Option<Element> {
|
|||
fn weekday_labels() -> Vec<String> {
|
||||
WEEKDAY_LABELS.iter().map(|s| (*s).to_string()).collect()
|
||||
}
|
||||
|
||||
fn hour_labels() -> Vec<String> {
|
||||
(0..24)
|
||||
.map(|h| {
|
||||
if h % 6 == 0 {
|
||||
format_hour(h)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use chrono::{Duration, Local};
|
||||
use freya::prelude::*;
|
||||
|
||||
use oneclient_core::game::Analytics;
|
||||
use oneclient_core::game::{Analytics, DayPlaytime, PlaytimeStats};
|
||||
|
||||
use crate::components::{Icon, IconType};
|
||||
use crate::theme::colors;
|
||||
|
|
@ -15,11 +16,18 @@ use servers::servers_section;
|
|||
use tiles::{personas_row, tiles_row};
|
||||
|
||||
pub fn analytics_body(analytics: &Analytics) -> Element {
|
||||
analytics_body_inner(analytics, false)
|
||||
}
|
||||
|
||||
/// `force_all` keeps the sections that are normally hidden for lack of data
|
||||
/// (session length, servers) on screen, so the zeroed empty state still shows
|
||||
/// the whole layout.
|
||||
fn analytics_body_inner(analytics: &Analytics, force_all: bool) -> Element {
|
||||
let stats = &analytics.playtime;
|
||||
|
||||
let mut root = rect().vertical().width(Size::fill()).spacing(24.);
|
||||
|
||||
root = root.child(tiles_row(analytics));
|
||||
root = root.child(tiles_row(analytics, force_all));
|
||||
|
||||
if !stats.personas.is_empty() {
|
||||
root = root.child(personas_row(&stats.personas));
|
||||
|
|
@ -29,20 +37,95 @@ pub fn analytics_body(analytics: &Analytics) -> Element {
|
|||
WhenChart::from_stats(stats).into_element(),
|
||||
DailyChart::new(stats.daily.clone()).into_element(),
|
||||
];
|
||||
if let Some(dist) = distribution_card(&stats.session_secs) {
|
||||
if let Some(dist) = distribution_card(&stats.session_secs, force_all) {
|
||||
charts.push(dist);
|
||||
}
|
||||
root = root.child(charts_grid(charts));
|
||||
|
||||
if !analytics.servers.is_empty() {
|
||||
if force_all || !analytics.servers.is_empty() {
|
||||
root = root.child(servers_section(&analytics.servers));
|
||||
}
|
||||
|
||||
root.into_element()
|
||||
}
|
||||
|
||||
/// Renders the full analytics layout with zeroed data, dimmed behind a scrim
|
||||
/// with `note` centered on top. Used by the cluster overview and global
|
||||
/// statistics pages when nothing has been recorded yet, so the charts and stats
|
||||
/// are always visible without inventing numbers.
|
||||
pub fn analytics_placeholder(note: &str) -> Element {
|
||||
rect()
|
||||
.width(Size::fill())
|
||||
.child(analytics_body_inner(&empty_analytics(), true))
|
||||
.child(
|
||||
rect()
|
||||
.position(Position::new_absolute())
|
||||
.width(Size::fill())
|
||||
.height(Size::fill())
|
||||
.layer(Layer::Relative(1))
|
||||
.center()
|
||||
.padding(40.)
|
||||
.background(colors::page_overlay())
|
||||
// swallow clicks so the placeholder controls aren't interactive
|
||||
.on_press(|_| {})
|
||||
.child(
|
||||
rect()
|
||||
.max_width(Size::px(360.))
|
||||
.padding(Gaps::new_symmetric(16., 24.))
|
||||
.corner_radius(CornerRadius::new_all(12.))
|
||||
.background(colors::page_elevated())
|
||||
.border(border_all_color(1., colors::component_border()))
|
||||
.center()
|
||||
.child(
|
||||
label()
|
||||
.text(note.to_string())
|
||||
.font_size(14.)
|
||||
.text_align(TextAlign::Center)
|
||||
.color(colors::fg_secondary()),
|
||||
),
|
||||
),
|
||||
)
|
||||
.into_element()
|
||||
}
|
||||
|
||||
/// Every field zeroed: real, honest "nothing recorded" data rather than
|
||||
/// invented numbers. The daily series is a run of zero-second days ending today
|
||||
/// so the timeline still renders its columns for each range selector.
|
||||
fn empty_analytics() -> Analytics {
|
||||
const EMPTY_DAYS: i64 = 90;
|
||||
|
||||
let today = Local::now().date_naive();
|
||||
let daily: Vec<DayPlaytime> = (0..EMPTY_DAYS)
|
||||
.rev()
|
||||
.map(|i| DayPlaytime {
|
||||
date: (today - Duration::days(i)).format("%Y-%m-%d").to_string(),
|
||||
secs: 0,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let playtime = PlaytimeStats {
|
||||
total_secs: 0,
|
||||
session_count: 0,
|
||||
per_weekday: [0; 7],
|
||||
per_hour: [0; 24],
|
||||
daily,
|
||||
session_secs: Vec::new(),
|
||||
active_days: 0,
|
||||
avg_secs_per_active_day: 0.0,
|
||||
peak_hour: None,
|
||||
peak_weekday: None,
|
||||
night_share: 0.0,
|
||||
personas: Vec::new(),
|
||||
};
|
||||
|
||||
Analytics {
|
||||
playtime,
|
||||
servers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn charts_grid(cards: Vec<Element>) -> Element {
|
||||
let mut grid = rect().vertical().width(Size::fill()).spacing(16.);
|
||||
let mut grid = rect().vertical().width(Size::fill()).spacing(24.);
|
||||
for pair in cards.chunks(2) {
|
||||
if pair.len() == 1 {
|
||||
grid = grid.child(rect().width(Size::fill()).child(pair[0].clone()));
|
||||
|
|
|
|||
|
|
@ -3,46 +3,261 @@ use freya::query::QueryStateData;
|
|||
|
||||
use oneclient_core::game::ServerStat;
|
||||
|
||||
use crate::components::{Icon, IconType};
|
||||
use crate::components::{
|
||||
Button, Icon, IconType, OverlayPopup, PieChart, ScrollArea, ValueUnit, slice_color,
|
||||
};
|
||||
use crate::hooks::use_cached_image;
|
||||
use crate::theme::colors;
|
||||
use crate::ui::border_all_color;
|
||||
use crate::utils::{format_duration_hm, plural};
|
||||
|
||||
use super::{card, card_header};
|
||||
|
||||
pub(super) fn servers_section(servers: &[ServerStat]) -> Element {
|
||||
let total_joins: i64 = servers.iter().map(|s| s.joins).sum();
|
||||
let max_secs = servers
|
||||
.iter()
|
||||
.map(|s| s.total_secs)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.max(1);
|
||||
/// Number of servers charted; the rest are grouped into an "Other" slice.
|
||||
const TOP_SERVERS: usize = 10;
|
||||
|
||||
let mut list = rect().vertical().width(Size::fill()).spacing(4.);
|
||||
for server in servers.iter().take(10) {
|
||||
list = list.child(ServerRow {
|
||||
server: server.clone(),
|
||||
max_secs,
|
||||
pub(super) fn servers_section(servers: &[ServerStat]) -> Element {
|
||||
ServersSection {
|
||||
servers: servers.to_vec(),
|
||||
}
|
||||
.into_element()
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct ServersSection {
|
||||
servers: Vec<ServerStat>,
|
||||
}
|
||||
|
||||
impl Component for ServersSection {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let hovered = use_state(|| Option::<usize>::None);
|
||||
let mut show_all = use_state(|| false);
|
||||
|
||||
let total_joins: i64 = self.servers.iter().map(|s| s.joins).sum();
|
||||
|
||||
let mut sorted = self.servers.clone();
|
||||
sorted.sort_by_key(|s| std::cmp::Reverse(s.total_secs));
|
||||
|
||||
let (top, rest) = sorted.split_at(sorted.len().min(TOP_SERVERS));
|
||||
let mut values: Vec<i64> = top.iter().map(|s| s.total_secs).collect();
|
||||
let mut labels: Vec<String> = top.iter().map(server_label).collect();
|
||||
if !rest.is_empty() {
|
||||
values.push(rest.iter().map(|s| s.total_secs).sum());
|
||||
labels.push(format!("{} more", rest.len()));
|
||||
}
|
||||
|
||||
let details = (!self.servers.is_empty()).then(|| {
|
||||
Button::new()
|
||||
.ghost()
|
||||
.small()
|
||||
.on_press(move |_| show_all.set(true))
|
||||
.child(Icon::new(IconType::Eye).size(12.))
|
||||
.text("Details")
|
||||
.into_element()
|
||||
});
|
||||
|
||||
card()
|
||||
.width(Size::fill())
|
||||
.spacing(14.)
|
||||
.child(card_header(
|
||||
"Servers",
|
||||
format!(
|
||||
"{} server{} · {} join{}",
|
||||
self.servers.len(),
|
||||
plural(self.servers.len() as i64),
|
||||
total_joins,
|
||||
plural(total_joins),
|
||||
),
|
||||
details,
|
||||
))
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.content(Content::Flex)
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(20.)
|
||||
.child(
|
||||
PieChart::new(values.clone(), labels.clone())
|
||||
.unit(ValueUnit::Duration)
|
||||
.hovered(hovered),
|
||||
)
|
||||
.child(rect().width(Size::flex(1.0)).child(legend(
|
||||
&values,
|
||||
&labels,
|
||||
*hovered.read(),
|
||||
))),
|
||||
)
|
||||
.maybe_child(show_all.read().then(|| {
|
||||
OverlayPopup::new()
|
||||
.on_close(move |_| show_all.set(false))
|
||||
.child(
|
||||
rect()
|
||||
.width(Size::window_percent(100.))
|
||||
.height(Size::window_percent(100.))
|
||||
.center()
|
||||
.child(ServerDetailsPopup {
|
||||
servers: sorted.clone(),
|
||||
on_close: EventHandler::new(move |()| show_all.set(false)),
|
||||
}),
|
||||
)
|
||||
.into_element()
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn legend(values: &[i64], labels: &[String], active: Option<usize>) -> Element {
|
||||
let total: i64 = values.iter().sum();
|
||||
let mut list = rect().vertical().width(Size::fill()).spacing(4.);
|
||||
|
||||
for (i, (value, name)) in values.iter().zip(labels).enumerate() {
|
||||
let is_active = Some(i) == active;
|
||||
let pct = if total > 0 {
|
||||
(*value as f32 / total as f32) * 100.
|
||||
} else {
|
||||
0.
|
||||
};
|
||||
let dim = active.is_some() && !is_active;
|
||||
|
||||
list = list.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.content(Content::Flex)
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(8.)
|
||||
.padding(Gaps::new_symmetric(3., 6.))
|
||||
.corner_radius(CornerRadius::new_all(6.))
|
||||
.background(if is_active {
|
||||
colors::component_bg()
|
||||
} else {
|
||||
Color::TRANSPARENT
|
||||
})
|
||||
.child(
|
||||
rect()
|
||||
.width(Size::px(8.))
|
||||
.height(Size::px(8.))
|
||||
.corner_radius(CornerRadius::new_all(2.))
|
||||
.background(slice_color(i).with_a(if dim { 120 } else { 255 })),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text(name.clone())
|
||||
.font_size(11.)
|
||||
.max_lines(1)
|
||||
.width(Size::flex(1.0))
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text(format!("{pct:.0}%"))
|
||||
.font_size(11.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text(format_duration_hm(*value))
|
||||
.font_size(11.)
|
||||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.color(colors::fg_primary()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
card()
|
||||
.width(Size::fill())
|
||||
.spacing(14.)
|
||||
.child(card_header(
|
||||
"Servers",
|
||||
format!(
|
||||
"{} server{} · {} join{}",
|
||||
servers.len(),
|
||||
plural(servers.len() as i64),
|
||||
total_joins,
|
||||
plural(total_joins),
|
||||
),
|
||||
None,
|
||||
))
|
||||
.child(list)
|
||||
.into_element()
|
||||
list.into_element()
|
||||
}
|
||||
|
||||
/// A Component (not a plain builder) because it mounts a `ScrollArea`, whose
|
||||
/// hooks run in the caller's render scope — mounting it conditionally from
|
||||
/// `ServersSection` would change that scope's hook count and panic Freya.
|
||||
#[derive(PartialEq)]
|
||||
struct ServerDetailsPopup {
|
||||
servers: Vec<ServerStat>,
|
||||
on_close: EventHandler<()>,
|
||||
}
|
||||
|
||||
impl Component for ServerDetailsPopup {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let servers = &self.servers;
|
||||
let on_close = self.on_close.clone();
|
||||
let max_secs = servers
|
||||
.iter()
|
||||
.map(|s| s.total_secs)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.max(1);
|
||||
|
||||
let mut list = rect().vertical().width(Size::fill()).spacing(4.);
|
||||
for server in servers {
|
||||
list = list.child(ServerRow {
|
||||
server: server.clone(),
|
||||
max_secs,
|
||||
});
|
||||
}
|
||||
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::px(520.))
|
||||
.max_height(Size::window_percent(70.))
|
||||
.padding(20.)
|
||||
.spacing(14.)
|
||||
.corner_radius(CornerRadius::new_all(14.))
|
||||
.background(colors::page_elevated())
|
||||
.border(border_all_color(1., colors::component_border()))
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.content(Content::Flex)
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(12.)
|
||||
.child(
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::flex(1.0))
|
||||
.spacing(2.)
|
||||
.child(
|
||||
label()
|
||||
.text("All servers")
|
||||
.font_size(16.)
|
||||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text(format!(
|
||||
"{} server{} by playtime",
|
||||
servers.len(),
|
||||
plural(servers.len() as i64)
|
||||
))
|
||||
.font_size(12.)
|
||||
.color(colors::fg_secondary()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new()
|
||||
.ghost()
|
||||
.small()
|
||||
.on_press(move |_| on_close.call(()))
|
||||
.child(Icon::new(IconType::XClose).size(12.)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
ScrollArea::new()
|
||||
.width(Size::fill())
|
||||
.height(Size::px(380.))
|
||||
.child(list),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// IP addresses stay masked in the always-visible chart legend; the details
|
||||
/// popup reveals them on hover only.
|
||||
fn server_label(s: &ServerStat) -> String {
|
||||
if s.is_ip {
|
||||
return "Direct IP".to_string();
|
||||
}
|
||||
display_address(s)
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use crate::utils::format_duration_hm;
|
|||
|
||||
use super::card;
|
||||
|
||||
pub(super) fn tiles_row(analytics: &Analytics) -> Element {
|
||||
pub(super) fn tiles_row(analytics: &Analytics, force_all: bool) -> Element {
|
||||
let stats = &analytics.playtime;
|
||||
let avg_session = if stats.session_count > 0 {
|
||||
stats.total_secs / stats.session_count as i64
|
||||
|
|
@ -36,7 +36,7 @@ pub(super) fn tiles_row(analytics: &Analytics) -> Element {
|
|||
stats.active_days.to_string(),
|
||||
),
|
||||
];
|
||||
if !analytics.servers.is_empty() {
|
||||
if force_all || !analytics.servers.is_empty() {
|
||||
tiles.push(stat_tile(
|
||||
IconType::Globe01,
|
||||
"Server joins",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use crate::components::ScrollArea;
|
|||
use crate::hooks::{try_cluster_analytics, use_cluster_analytics};
|
||||
use crate::layout::cluster_content;
|
||||
use crate::theme::colors;
|
||||
use crate::view::app::analytics_body;
|
||||
use crate::view::app::{analytics_body, analytics_placeholder};
|
||||
|
||||
use super::{cluster_not_found, load_cluster};
|
||||
|
||||
|
|
@ -26,9 +26,9 @@ impl Component for ClusterOverview {
|
|||
|
||||
let body: Element = match &analytics {
|
||||
None => centered_note("Loading play history…"),
|
||||
Some(a) if a.playtime.session_count == 0 => {
|
||||
centered_note("No sessions recorded for this cluster yet. Launch it and come back!")
|
||||
}
|
||||
Some(a) if a.playtime.session_count == 0 => analytics_placeholder(
|
||||
"No sessions recorded for this cluster yet. Launch it and come back!",
|
||||
),
|
||||
Some(a) => analytics_body(a),
|
||||
};
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ impl Component for ClusterOverview {
|
|||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.spacing(20.)
|
||||
.spacing(24.)
|
||||
.child(overview_header(&cluster.name))
|
||||
.child(body),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ pub fn launch_button_state(game: &GameSnapshot, cluster_id: i64) -> (&'static st
|
|||
}
|
||||
|
||||
pub use accounts::Accounts;
|
||||
pub(crate) use analytics::analytics_body;
|
||||
pub(crate) use analytics::{analytics_body, analytics_placeholder};
|
||||
pub use clusters::Clusters;
|
||||
pub use debug::Debug;
|
||||
pub use home::Home;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use freya::prelude::*;
|
|||
use crate::components::ScrollArea;
|
||||
use crate::hooks::{try_global_analytics, use_global_analytics};
|
||||
use crate::theme::colors;
|
||||
use crate::view::app::analytics_body;
|
||||
use crate::view::app::{analytics_body, analytics_placeholder};
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub struct Stats;
|
||||
|
|
@ -15,7 +15,9 @@ impl Component for Stats {
|
|||
|
||||
let body: Element = match &analytics {
|
||||
None => loading_state(),
|
||||
Some(a) if a.playtime.session_count == 0 => empty_state(),
|
||||
Some(a) if a.playtime.session_count == 0 => {
|
||||
analytics_placeholder("No playtime recorded yet. Launch a game and come back!")
|
||||
}
|
||||
Some(a) => analytics_body(a),
|
||||
};
|
||||
|
||||
|
|
@ -32,7 +34,7 @@ impl Component for Stats {
|
|||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.padding(40.)
|
||||
.padding(Gaps::new(0., 40., 40., 40.))
|
||||
.spacing(24.)
|
||||
.child(header())
|
||||
.child(body),
|
||||
|
|
@ -65,10 +67,6 @@ fn loading_state() -> Element {
|
|||
centered_note("Crunching your playtime…")
|
||||
}
|
||||
|
||||
fn empty_state() -> Element {
|
||||
centered_note("No playtime recorded yet. Launch a game and come back!")
|
||||
}
|
||||
|
||||
fn centered_note(text: &str) -> Element {
|
||||
rect()
|
||||
.width(Size::fill())
|
||||
|
|
|
|||
|
|
@ -69,7 +69,13 @@ impl OptInBundle {
|
|||
fn mod_count(&self) -> usize {
|
||||
self.members
|
||||
.first()
|
||||
.map(|(_, a)| a.manifest.files.iter().filter(|f| f.enabled && !f.hidden).count())
|
||||
.map(|(_, a)| {
|
||||
a.manifest
|
||||
.files
|
||||
.iter()
|
||||
.filter(|f| f.enabled && !f.hidden)
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
|
@ -133,7 +139,12 @@ fn optional_mods(clusters: &[ClusterBundles]) -> Vec<OptionalMod> {
|
|||
|
||||
for cb in clusters {
|
||||
for archive in &cb.archives {
|
||||
for file in archive.manifest.files.iter().filter(|f| is_optional_file(f)) {
|
||||
for file in archive
|
||||
.manifest
|
||||
.files
|
||||
.iter()
|
||||
.filter(|f| is_optional_file(f))
|
||||
{
|
||||
let package_id = file.kind.package_id();
|
||||
let entry = map.entry(package_id.clone()).or_insert_with(|| {
|
||||
order.push(package_id.clone());
|
||||
|
|
@ -227,9 +238,8 @@ impl Component for OnboardingBundles {
|
|||
let mut sections: Vec<Element> = Vec::new();
|
||||
|
||||
for bundle in &opt_ins {
|
||||
sections.push(
|
||||
opt_in_card(bundle, &selected_set, selected, user_touched).into_element(),
|
||||
);
|
||||
sections
|
||||
.push(opt_in_card(bundle, &selected_set, selected, user_touched).into_element());
|
||||
}
|
||||
|
||||
if !extras.is_empty() {
|
||||
|
|
@ -336,12 +346,7 @@ fn opt_in_card(
|
|||
if wanted {
|
||||
return;
|
||||
}
|
||||
set_bundle_versions(
|
||||
selected,
|
||||
user_touched,
|
||||
&yes_bundle,
|
||||
&yes_ids,
|
||||
)
|
||||
set_bundle_versions(selected, user_touched, &yes_bundle, &yes_ids)
|
||||
},
|
||||
)),
|
||||
)
|
||||
|
|
@ -354,9 +359,7 @@ fn opt_in_card(
|
|||
"",
|
||||
!wanted,
|
||||
Size::fill(),
|
||||
move |()| {
|
||||
set_bundle_versions(selected, user_touched, &no_bundle, &[])
|
||||
},
|
||||
move |()| set_bundle_versions(selected, user_touched, &no_bundle, &[]),
|
||||
)),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ use std::time::{Duration, Instant};
|
|||
use freya::animation::*;
|
||||
use freya::prelude::*;
|
||||
use freya::router::RouterContext;
|
||||
use oneclient_core::{BundleArchive, BundleFile, ImportTarget};
|
||||
use oneclient_core::notification::{
|
||||
GroupedProgressEvent, GroupedProgressSession, Notification, NotificationService,
|
||||
};
|
||||
use oneclient_core::{BundleArchive, BundleFile, ImportTarget};
|
||||
use oneclient_db::models::OverrideType;
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
|
|
@ -484,7 +484,11 @@ fn archive_overrides(
|
|||
|
||||
let mut overrides = Vec::new();
|
||||
for file in &archive.manifest.files {
|
||||
let wanted = if file.hidden { bundle_taken } else { wants(file) };
|
||||
let wanted = if file.hidden {
|
||||
bundle_taken
|
||||
} else {
|
||||
wants(file)
|
||||
};
|
||||
|
||||
let override_type = match (wanted, file.enabled) {
|
||||
// Matches the manifest default; nothing to record.
|
||||
|
|
@ -702,13 +706,9 @@ async fn install_one(
|
|||
"Saving your package choices...".to_string(),
|
||||
));
|
||||
}
|
||||
oneclient_core::set_bundle_package_overrides(
|
||||
plan.cluster_id,
|
||||
&plan.overrides,
|
||||
&state.services,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
oneclient_core::set_bundle_package_overrides(plan.cluster_id, &plan.overrides, &state.services)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
if !predownload {
|
||||
return Ok(());
|
||||
|
|
@ -963,14 +963,16 @@ mod tests {
|
|||
archives: vec![sb.clone()],
|
||||
},
|
||||
];
|
||||
let selected: HashSet<String> =
|
||||
[pkg_key(1, &sb.manifest.name, "skyblock-main")].into();
|
||||
let selected: HashSet<String> = [pkg_key(1, &sb.manifest.name, "skyblock-main")].into();
|
||||
let plans = build_plans(&both, &selected);
|
||||
|
||||
assert!(plans[0].overrides.is_empty());
|
||||
assert!(plans[1].overrides.iter().any(|(_, pid, ty)| pid
|
||||
== "skyblock-main"
|
||||
&& *ty == OverrideType::Removed));
|
||||
assert!(
|
||||
plans[1]
|
||||
.overrides
|
||||
.iter()
|
||||
.any(|(_, pid, ty)| pid == "skyblock-main" && *ty == OverrideType::Removed)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -985,8 +987,7 @@ mod tests {
|
|||
let accepted = rough_download_estimate(&all, &keys(&sb, &["skyblock-main"]));
|
||||
assert_eq!(accepted, baseline + 2);
|
||||
|
||||
let with_extra =
|
||||
rough_download_estimate(&all, &keys(&sb, &["skyblock-main", "skycubed"]));
|
||||
let with_extra = rough_download_estimate(&all, &keys(&sb, &["skyblock-main", "skycubed"]));
|
||||
assert_eq!(with_extra, baseline + 3);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ use crate::hooks::{
|
|||
use crate::routes::Route;
|
||||
use crate::theme::colors;
|
||||
use crate::ui::border_all_color;
|
||||
use crate::view::onboarding::{choice_row, onboarding_illustration, onboarding_slide, step_heading};
|
||||
use crate::view::onboarding::{
|
||||
choice_row, onboarding_illustration, onboarding_slide, step_heading,
|
||||
};
|
||||
|
||||
fn migrated_categories(source: &[SourceInstance]) -> Vec<String> {
|
||||
let mut categories: Vec<String> = Vec::new();
|
||||
|
|
|
|||
|
|
@ -83,11 +83,7 @@ pub fn set_archive_selected(
|
|||
selected: &mut HashSet<String>,
|
||||
) {
|
||||
for file in archive.manifest.files.iter().filter(|f| is_default_file(f)) {
|
||||
let key = pkg_key(
|
||||
cluster_id,
|
||||
&archive.manifest.name,
|
||||
&file.kind.package_id(),
|
||||
);
|
||||
let key = pkg_key(cluster_id, &archive.manifest.name, &file.kind.package_id());
|
||||
if on {
|
||||
selected.insert(key);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -64,4 +64,3 @@ pub fn cluster(id: i64) -> Cluster {
|
|||
linked_modpack_hash: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ pub const MODRINTH_CDN_PREFIX: &str = "https://cdn.modrinth.com/data/";
|
|||
pub const CURSEFORGE_API_URL: &str = "https://api.curseforge.com/v1";
|
||||
pub const CURSEFORGE_GAME_ID: u32 = 432;
|
||||
pub const METADATA_API_URL: &str = "https://meta.polyfrost.org";
|
||||
pub const FEATURED_PACKAGES_URL: &str = "https://polyfrost.org/meta/onelauncher/featured.json";
|
||||
pub const MCLOGS_API_URL: &str = "https://api.mclo.gs/1";
|
||||
pub const SKYCLIENT_BASE_URL: &str =
|
||||
"https://raw.githubusercontent.com/SkyblockClient/SkyblockClient-REPO/refs/heads/main/v1";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue