From 64771ccf41ebcfe92af4e7da5ed428c3bf42c138 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 30 Jul 2025 23:47:47 -0600 Subject: [PATCH] Add platform selection on projects page --- crates/core/src/models.rs | 55 ++++++------- crates/github/src/changes.rs | 2 +- crates/web/src/handlers/project.rs | 121 ++++++++++++++++++++++------- crates/web/src/handlers/report.rs | 2 - crates/web/src/main.rs | 1 + css/main.scss | 48 +++++++++++- js/projects.ts | 65 ++++++++++++++++ rsbuild.config.ts | 1 + 8 files changed, 235 insertions(+), 60 deletions(-) create mode 100644 js/projects.ts diff --git a/crates/core/src/models.rs b/crates/core/src/models.rs index f72605c..e2cd897 100644 --- a/crates/core/src/models.rs +++ b/crates/core/src/models.rs @@ -156,57 +156,53 @@ pub struct FrogressMapping { pub project_measure: String, } -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] pub enum Platform { - GBA, - GBC, - N64, - DS, - PS, - PS2, - Switch, - GC, - Wii, + PS, // 1994 + N64, // 1996 + PS2, // 2000 + GBA, // 2001 + GC, // 2001 + DS, // 2004 + Wii, // 2006 + Switch, // 2017 } pub const ALL_PLATFORMS: &[Platform] = &[ - Platform::GBA, - Platform::GBC, - Platform::N64, - Platform::DS, Platform::PS, + Platform::N64, Platform::PS2, - Platform::Switch, + Platform::GBA, Platform::GC, + Platform::DS, Platform::Wii, + Platform::Switch, ]; impl Platform { pub fn to_str(self) -> &'static str { match self { - Self::GBA => "gba", - Self::GBC => "gbc", - Self::N64 => "n64", - Self::DS => "nds", Self::PS => "ps", + Self::N64 => "n64", Self::PS2 => "ps2", - Self::Switch => "switch", + Self::GBA => "gba", Self::GC => "gc", + Self::DS => "nds", Self::Wii => "wii", + Self::Switch => "switch", } } pub fn name(self) -> &'static str { match self { - Platform::GBA => "Game Boy Advance", - Platform::GBC => "Game Boy Color", - Platform::N64 => "Nintendo 64", - Platform::DS => "Nintendo DS", Platform::PS => "PlayStation", + Platform::N64 => "Nintendo 64", Platform::PS2 => "PlayStation 2", - Platform::Switch => "Nintendo Switch", + Platform::GBA => "Game Boy Advance", Platform::GC => "GameCube", + Platform::DS => "Nintendo DS", Platform::Wii => "Wii", + Platform::Switch => "Switch", } } } @@ -216,15 +212,14 @@ impl FromStr for Platform { fn from_str(s: &str) -> Result { match s { - "gba" => Ok(Self::GBA), - "gbc" => Ok(Self::GBC), - "n64" => Ok(Self::N64), - "nds" => Ok(Self::DS), "ps" => Ok(Self::PS), + "n64" => Ok(Self::N64), "ps2" => Ok(Self::PS2), - "switch" => Ok(Self::Switch), + "gba" => Ok(Self::GBA), "gc" => Ok(Self::GC), + "nds" => Ok(Self::DS), "wii" => Ok(Self::Wii), + "switch" => Ok(Self::Switch), _ => Err(()), } } diff --git a/crates/github/src/changes.rs b/crates/github/src/changes.rs index dd41422..0ccedba 100644 --- a/crates/github/src/changes.rs +++ b/crates/github/src/changes.rs @@ -224,7 +224,7 @@ fn generate_changes_list(changes: Vec, out: &mut String) { } else { out.push_str("
\n"); } - out.push_str(&format!("{emoji} {total_changes} {description}:\n")); + out.push_str(&format!("{emoji} {total_changes} {description}\n")); out.push('\n'); // Must include a blank line before a table out.push_str("| Unit | Function | Bytes | Before | After |\n"); out.push_str("| - | - | - | - | - |\n"); diff --git a/crates/web/src/handlers/project.rs b/crates/web/src/handlers/project.rs index 3cae20a..43e0ebc 100644 --- a/crates/web/src/handlers/project.rs +++ b/crates/web/src/handlers/project.rs @@ -11,11 +11,12 @@ use decomp_dev_auth::CurrentUser; use decomp_dev_core::{ AppError, FullUri, models::{ - CachedReportFile, Commit, Platform, ProjectInfo, ProjectVisibility, ReportInner, - project_visibility, + ALL_PLATFORMS, CachedReportFile, Commit, Platform, ProjectInfo, ProjectVisibility, + ReportInner, project_visibility, }, util::{UrlExt, format_percent, size}, }; +use itertools::Itertools; use maud::{DOCTYPE, Markup, html}; use objdiff_core::bindings::report::Measures; use serde::{Deserialize, Serialize}; @@ -42,6 +43,7 @@ struct ProjectInfoContext { #[derive(Deserialize)] pub struct ProjectsQuery { sort: Option, + platform: Option, } #[derive(Serialize, Copy, Clone)] @@ -157,7 +159,36 @@ pub async fn get_projects( return Err(AppError::Status(StatusCode::NOT_ACCEPTABLE)); } - let projects = state.db.get_projects().await?; + let platforms = query + .platform + .as_deref() + .into_iter() + .flat_map(|s| s.split(',')) + .filter_map(|s| Platform::from_str(s).ok()) + .sorted() + .dedup() + .collect::>(); + let show_all = platforms.is_empty() || platforms == ALL_PLATFORMS; + + let mut projects = state.db.get_projects().await?; + + let available_platforms = projects + .iter() + .filter_map(|p| p.project.platform.as_deref()) + .filter_map(|s| Platform::from_str(s).ok()) + .sorted() + .dedup_with_count() + .collect::>(); + if !show_all { + projects.retain(|p| { + p.project + .platform + .as_deref() + .and_then(|p| Platform::from_str(p).ok()) + .is_some_and(|p| platforms.contains(&p)) + }); + } + let mut out = projects .iter() .map(|p| ProjectInfoContext { @@ -261,7 +292,17 @@ pub async fn get_projects( if (mime.type_() == mime::STAR && mime.subtype() == mime::STAR) || (mime.type_() == mime::TEXT && mime.subtype() == mime::HTML) { - return render_project(ctx, out, uri, current_sort, current_user.as_ref()).await; + return render_project( + ctx, + out, + uri, + current_sort, + current_user.as_ref(), + &available_platforms, + &platforms, + show_all, + ) + .await; } else if mime.type_() == mime::APPLICATION && mime.subtype() == mime::JSON { let projects = out .into_iter() @@ -285,6 +326,9 @@ async fn render_project( uri: Uri, current_sort: SortOption, current_user: Option<&CurrentUser>, + available_platforms: &[(usize, Platform)], + platforms: &[Platform], + show_all: bool, ) -> Result { let mut combined_styles = ProgressSections { nonce: ctx.nonce.clone(), ..Default::default() }; for info in &mut out { @@ -293,6 +337,7 @@ async fn render_project( let request_url = Url::parse(&uri.to_string()).context("Failed to parse URI")?; let canonical_url = request_url.with_path("/projects"); + let is_primary_view = show_all && current_sort.key == "updated"; let rendered = html! { (DOCTYPE) @@ -302,12 +347,17 @@ async fn render_project( title { "Projects • decomp.dev" } (ctx.header().await) (ctx.chunks("main", Load::Deferred).await) + (ctx.chunks("projects", Load::Deferred).await) link rel="canonical" href=(canonical_url); meta name="description" content="Decompilation progress reports"; meta property="og:title" content="Decompilation progress reports"; meta property="og:description" content="Progress reports for matching decompilation projects"; meta property="og:image" content=(canonical_url.with_path("/og.png")); meta property="og:url" content=(canonical_url); + @if !is_primary_view { + // Prevent search engines from indexing anything but the primary view + meta name="robots" content="noindex"; + } } body { header { @@ -319,20 +369,6 @@ async fn render_project( li { a href="/projects" { "Projects" } } - li.md { - details.dropdown { - summary { (current_sort.name) } - ul { - @for option in SORT_OPTIONS { - li { - a href=(request_url.query_param("sort", Some(option.key))) { - (option.name) - } - } - } - } - } - } } (nav_links()) } @@ -349,13 +385,45 @@ async fn render_project( } } main { - details.dropdown.sm { - summary { (current_sort.name) } - ul { - @for option in SORT_OPTIONS { + .grid.platform-grid { + details.dropdown.platform-dropdown { + summary { + @if show_all { + "All Platforms" + } @else if platforms.len() == 1 { + (platforms[0].name()) + } @else { + (platforms.len()) " Platforms" + } + } + ul { li { - a href=(request_url.query_param("sort", Some(option.key))) { - (option.name) + a href=(request_url.query_param("platform", None)) { + "All Platforms" + } + } + @for (n, platform) in available_platforms { + li.platform-item { + label { + input type="checkbox" name="platform" value=(platform.to_str()) + checked[show_all || platforms.contains(platform)]; + span.platform-icon.(format!("icon-{}", platform.to_str())) {} + (platform.name()) + span.count-badge { (n) } + } + button.secondary { "Only" } + } + } + } + } + details.dropdown { + summary { (current_sort.name) } + ul { + @for option in SORT_OPTIONS { + li { + a href=(request_url.query_param("sort", Some(option.key))) { + (option.name) + } } } } @@ -382,13 +450,14 @@ fn project_fragment( let Some(commit) = ctx.report.as_ref().map(|r| r.commit.clone()) else { return Markup::default(); }; - let project_path = canonical_url.with_path(&format!("/{}/{}", project.owner, project.repo)); + let mut project_path = canonical_url.with_path(&format!("/{}/{}", project.owner, project.repo)); + project_path.set_query(None); let commit_url = format!("https://github.com/{}/{}/commit/{}", project.owner, project.repo, commit.sha); let header_image_id = project.header_image_id.map(hex::encode); const HEADER_QUERY: &str = "?w=1024&h=256"; html! { - article.project { + article.project data-platform=[project.platform.as_deref()] { a.project-link href=(project_path) aria-label="View project" {} @if let Some(header_image_id) = header_image_id { .project-image-container { diff --git a/crates/web/src/handlers/report.rs b/crates/web/src/handlers/report.rs index 406a7d2..82abbf2 100644 --- a/crates/web/src/handlers/report.rs +++ b/crates/web/src/handlers/report.rs @@ -262,7 +262,6 @@ pub async fn get_report( } } -#[allow(clippy::too_many_arguments)] async fn mode_overview( scope: &Scope<'_>, state: &AppState, @@ -301,7 +300,6 @@ async fn mode_overview( Err(AppError::Status(StatusCode::NOT_ACCEPTABLE)) } -#[allow(clippy::too_many_arguments)] async fn mode_report( scope: &Scope<'_>, state: &AppState, diff --git a/crates/web/src/main.rs b/crates/web/src/main.rs index 91f2788..b65ecfb 100644 --- a/crates/web/src/main.rs +++ b/crates/web/src/main.rs @@ -1,3 +1,4 @@ +#![allow(clippy::too_many_arguments)] mod cron; mod frogress; mod handlers; diff --git a/css/main.scss b/css/main.scss index 6c15605..29dbf2a 100644 --- a/css/main.scss +++ b/css/main.scss @@ -264,7 +264,8 @@ $progress-height: 2rem; background-color: $jade-500; } - .progress-section:nth-of-type(3) { + .progress-section:nth-of-type(3), + .progress-section.fuzzy { background-color: transparent; } } @@ -548,3 +549,48 @@ details.dropdown { label:has(> input[type="checkbox"]) { margin-bottom: var(--pico-spacing); } + +.platform-grid { + margin-top: calc(var(--pico-block-spacing-vertical) * -2); +} + +.platform-dropdown { + margin: 0; + + li.platform-item { + display: flex; + padding: 0 !important; + flex: 1; + + label { + flex: 1; + display: flex; + align-items: center; + gap: 0.5em; + margin: 0; + padding-inline-start: var(--pico-spacing); + + .platform-icon { + color: var(--pico-form-element-color); + font-size: 2em; + } + } + + button { + padding: 0.25em 0.5em; + margin: 0.25em; + margin-inline-end: var(--pico-spacing); + } + + .count-badge { + font-size: 0.8em; + background: var(--pico-code-kbd-background-color); + color: var(--pico-code-kbd-color); + border-radius: 1em; + min-width: 1.5em; + height: 1.5em; + padding: 0 0.5em; + text-align: center; + } + } +} \ No newline at end of file diff --git a/js/projects.ts b/js/projects.ts new file mode 100644 index 0000000..c680ac0 --- /dev/null +++ b/js/projects.ts @@ -0,0 +1,65 @@ +const platforms: Map = new Map(); + +function updateProjectVisibility() { + const selectedPlatforms: string[] = []; + let allSelected = true; + for (const [platform, checkbox] of platforms.entries()) { + if (checkbox.checked) { + selectedPlatforms.push(platform); + } else { + allSelected = false; + } + } + if (selectedPlatforms.length === 0) { + allSelected = true; + for (const cb of platforms.values()) { + cb.checked = true; + } + } + const url = new URL(window.location.href); + if (allSelected) { + if (url.searchParams.has('platform')) { + url.searchParams.delete('platform'); + window.location.replace(url); + } + } else { + url.searchParams.set('platform', selectedPlatforms.join(',')); + window.location.replace(url.toString().replace(/%2C/g, ',')); + } +} + +document.querySelectorAll('.platform-item').forEach((item) => { + const checkbox = item.querySelector('input[type="checkbox"]') as HTMLInputElement | null; + const button = item.querySelector('button') as HTMLButtonElement | null; + if (!checkbox || !button) { + return; + } + platforms.set(checkbox.value, checkbox); + checkbox.addEventListener('click', (e) => e.stopPropagation()); + checkbox.addEventListener('change', () => updateProjectVisibility()); + button.addEventListener('click', () => { + let allUnchecked = checkbox.checked; + if (allUnchecked) { + for (const cb of platforms.values()) { + if (cb !== checkbox && cb.checked) { + allUnchecked = false; + break; + } + } + } + if (allUnchecked) { + for (const cb of platforms.values()) { + cb.checked = true; + } + } else { + // Enable this checkbox and disable all others + checkbox.checked = true; + for (const cb of platforms.values()) { + if (cb !== checkbox) { + cb.checked = false; + } + } + } + updateProjectVisibility(); + }); +}); diff --git a/rsbuild.config.ts b/rsbuild.config.ts index 459b7cd..d9e12fc 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ manage: ['./js/manage.ts'], report: ['./js/treemap.ts'], api: ['./js/api.tsx'], + projects: ['./js/projects.ts'], }, }, output: {