Add platform selection on projects page

This commit is contained in:
Luke Street
2025-07-30 23:47:47 -06:00
parent aeaa175dc6
commit 64771ccf41
8 changed files with 235 additions and 60 deletions
+25 -30
View File
@@ -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<Self, Self::Err> {
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(()),
}
}
+1 -1
View File
@@ -224,7 +224,7 @@ fn generate_changes_list(changes: Vec<ChangeLine>, out: &mut String) {
} else {
out.push_str("<details>\n");
}
out.push_str(&format!("<summary>{emoji} {total_changes} {description}:</summary>\n"));
out.push_str(&format!("<summary>{emoji} {total_changes} {description}</summary>\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");
+95 -26
View File
@@ -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<String>,
platform: Option<String>,
}
#[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::<Vec<_>>();
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::<Vec<_>>();
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<Response, AppError> {
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 {
-2
View File
@@ -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,
+1
View File
@@ -1,3 +1,4 @@
#![allow(clippy::too_many_arguments)]
mod cron;
mod frogress;
mod handlers;
+47 -1
View File
@@ -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;
}
}
}
+65
View File
@@ -0,0 +1,65 @@
const platforms: Map<string, HTMLInputElement> = 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();
});
});
+1
View File
@@ -12,6 +12,7 @@ export default defineConfig({
manage: ['./js/manage.ts'],
report: ['./js/treemap.ts'],
api: ['./js/api.tsx'],
projects: ['./js/projects.ts'],
},
},
output: {