From 941599315535dec1d71007729a2f10e40b2f1bb2 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 13 May 2025 21:24:07 -0600 Subject: [PATCH] API browser, overview API & more --- Cargo.lock | 104 +++- Cargo.toml | 4 +- assets/icons.woff2 | Bin 5888 -> 6356 bytes biome.json | 11 +- crates/core/src/util.rs | 13 + crates/github/graphql/queries.graphql | 10 +- crates/github/src/webhook.rs | 26 +- crates/images/Cargo.toml | 18 +- crates/images/src/badge.rs | 63 ++- crates/web/Cargo.toml | 1 + crates/web/src/handlers/api.rs | 45 ++ crates/web/src/handlers/common.rs | 62 ++- crates/web/src/handlers/csp.rs | 2 +- crates/web/src/handlers/manage.rs | 9 +- crates/web/src/handlers/mod.rs | 36 +- crates/web/src/handlers/project.rs | 223 +++++++-- crates/web/src/handlers/report.rs | 132 +++++- css/history.css | 2 +- css/main.scss | 5 +- js/api.module.css | 21 + js/api.tsx | 652 ++++++++++++++++++++++++++ js/main.ts | 163 ++++--- js/manage.ts | 20 +- js/treemap.ts | 491 ++++++++++--------- package-lock.json | 168 ++++++- package.json | 7 +- public/apple-touch-icon.png | Bin 7037 -> 3296 bytes public/favicon-96x96.png | Bin 4058 -> 0 bytes public/favicon.png | Bin 0 -> 1995 bytes public/web-app-manifest-192x192.png | Bin 6796 -> 3151 bytes public/web-app-manifest-512x512.png | Bin 20289 -> 6403 bytes rsbuild.config.ts | 4 +- tsconfig.json | 45 +- 33 files changed, 1869 insertions(+), 468 deletions(-) create mode 100644 crates/web/src/handlers/api.rs create mode 100644 js/api.module.css create mode 100644 js/api.tsx delete mode 100644 public/favicon-96x96.png create mode 100644 public/favicon.png diff --git a/Cargo.lock b/Cargo.lock index a8b6372..0588c70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -288,7 +288,7 @@ dependencies = [ "cfg-if", "libc", "miniz_oxide", - "object", + "object 0.36.7 (registry+https://github.com/rust-lang/crates.io-index)", "rustc-demangle", "windows-targets 0.52.6", ] @@ -772,6 +772,7 @@ dependencies = [ "decomp-dev-core", "image", "mime", + "num-format", "objdiff-core", "palette", "resvg", @@ -795,6 +796,7 @@ dependencies = [ "decomp-dev-images", "image", "itertools 0.14.0", + "lexicmp", "libsystemd", "maud", "mime", @@ -851,6 +853,12 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + [[package]] name = "digest" version = "0.10.7" @@ -889,6 +897,15 @@ dependencies = [ "serde", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "english-to-cron" version = "0.1.4" @@ -982,12 +999,29 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "filetime" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.59.0", +] + [[package]] name = "fixedbitset" version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flagset" +version = "0.4.6" +source = "git+https://github.com/enarx/flagset.git?rev=a1fe9369b3741e43fec45da1998e83b9d78966a2#a1fe9369b3741e43fec45da1998e83b9d78966a2" + [[package]] name = "flate2" version = "1.1.1" @@ -1856,6 +1890,15 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" +[[package]] +name = "lexicmp" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8f89da8fd95c4eb6274e914694bea90c7826523b26f2a2fd863d44b9d42c43" +dependencies = [ + "deunicode", +] + [[package]] name = "libc" version = "0.2.172" @@ -1878,6 +1921,17 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.9.0", + "libc", + "redox_syscall", +] + [[package]] name = "libsqlite3-sys" version = "0.30.1" @@ -2210,6 +2264,16 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "num-format" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" +dependencies = [ + "arrayvec", + "itoa", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -2253,18 +2317,26 @@ dependencies = [ [[package]] name = "objdiff-core" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b114e70a6ede280511c1690609afbe802f84f7aaf27c71486c0510df07e647b8" +version = "3.0.0-beta.7" +source = "git+https://github.com/encounter/objdiff?rev=0c48d711c7bd51f791b353d7d85ba948b277e2f2#0c48d711c7bd51f791b353d7d85ba948b277e2f2" dependencies = [ "anyhow", - "object", + "encoding_rs", + "filetime", + "flagset", + "itertools 0.14.0", + "log", + "memmap2", + "num-traits", + "object 0.36.7 (git+https://github.com/gimli-rs/object?rev=a74579249e21ab8fcd3a86be588de336f18297cb)", "pbjson", "pbjson-build", "prost", "prost-build", "serde", "serde_json", + "similar", + "typed-path", ] [[package]] @@ -2276,6 +2348,14 @@ dependencies = [ "memchr", ] +[[package]] +name = "object" +version = "0.36.7" +source = "git+https://github.com/gimli-rs/object?rev=a74579249e21ab8fcd3a86be588de336f18297cb#a74579249e21ab8fcd3a86be588de336f18297cb" +dependencies = [ + "memchr", +] + [[package]] name = "octocrab" version = "0.44.0" @@ -3426,6 +3506,14 @@ dependencies = [ "quote", ] +[[package]] +name = "similar" +version = "2.7.0" +source = "git+https://github.com/encounter/similar.git?branch=no_std#3568aba9173fad9f66740344dbdc6bc4c1970aab" +dependencies = [ + "hashbrown", +] + [[package]] name = "simple_asn1" version = "0.6.3" @@ -4351,6 +4439,12 @@ dependencies = [ "core_maths", ] +[[package]] +name = "typed-path" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c462d18470a2857aa657d338af5fa67170bb48bcc80a296710ce3b0802a32566" + [[package]] name = "typenum" version = "1.18.0" diff --git a/Cargo.toml b/Cargo.toml index 9a19a2c..b86332b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,8 +21,8 @@ hex = "0.4" image = "0.25" maud = { version = "0.27", features = ["axum"] } mime = "0.3" -objdiff-core = { version = "2.5", features = ["bindings"] } -#objdiff-core = { path = "../objdiff/objdiff-core", features = ["bindings"] } +objdiff-core = { git = "https://github.com/encounter/objdiff", rev = "0c48d711c7bd51f791b353d7d85ba948b277e2f2", features = ["bindings", "serde"] } +#objdiff-core = { path = "../objdiff/objdiff-core", features = ["bindings", "serde"] } octocrab = { version = "0.44", features = ["stream"] } regex = "1.11" reqwest = { version = "0.12", default-features = false, features = ["json", "gzip", "zstd", "http2", "rustls-tls-native-roots"] } diff --git a/assets/icons.woff2 b/assets/icons.woff2 index 34311ea6040681f817245d63aea17611fa6241aa..ab0cb948787f5138432269cda7011a59b01157a6 100644 GIT binary patch literal 6356 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, path: impl AsRef) -> PathBu out.extend(path.as_ref().components().filter(|v| matches!(v, std::path::Component::Normal(_)))); out } + +/// Format a size in bytes to a human-readable string. +/// Uses SI (kilo = 1000) units, formatted to two decimal places. +pub fn size(value: u64) -> String { + let units = ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; + let mut value = value as f64; + let mut unit = 0; + while value >= 1000.0 && unit < units.len() - 1 { + value /= 1000.0; + unit += 1; + } + format!("{:.2} {}", value, units[unit]) +} diff --git a/crates/github/graphql/queries.graphql b/crates/github/graphql/queries.graphql index fff3d79..c6aa2d7 100644 --- a/crates/github/graphql/queries.graphql +++ b/crates/github/graphql/queries.graphql @@ -3,10 +3,10 @@ query ViewerQuery($after: String) { login url repositories( - first: 100, - after: $after, - ownerAffiliations: [OWNER, COLLABORATOR, ORGANIZATION_MEMBER], - visibility: PUBLIC, + first: 100 + after: $after + ownerAffiliations: [OWNER, COLLABORATOR, ORGANIZATION_MEMBER] + visibility: PUBLIC ) { nodes { ...repositoryFields @@ -28,4 +28,4 @@ fragment repositoryFields on Repository { } name viewerPermission -} \ No newline at end of file +} diff --git a/crates/github/src/webhook.rs b/crates/github/src/webhook.rs index d56a761..255cc18 100644 --- a/crates/github/src/webhook.rs +++ b/crates/github/src/webhook.rs @@ -239,6 +239,14 @@ async fn handle_workflow_run_completed( if !project_info.project.enable_pr_comments { return Ok(()); } + // Actions pull_request builds always merge with the latest commit on the base branch. + // We can't use the base commit from the workflow run or pull request APIs, those are + // both lies. For simplicity, we'll just always compare against the latest commit that + // we have stored. + let Some(base_commit) = project_info.commit else { + tracing::warn!("No base commit found for repository ID {}", repository_id); + return Ok(()); + }; // Fetch any associated pull requests if pull_requests.is_empty() { let head = if let Some(head_owner) = @@ -262,6 +270,22 @@ async fn handle_workflow_run_completed( } for pull_request in pull_requests { if pull_request.head.sha != workflow_run.head_sha { + tracing::warn!( + "Pull request {} head SHA {} does not match workflow run head SHA {}", + pull_request.id, + pull_request.head.sha, + workflow_run.head_sha + ); + continue; + } + if repository.default_branch.as_ref().is_none_or(|b| pull_request.base.ref_field != *b) + { + tracing::warn!( + "Pull request {} base branch {} does not match default branch {}", + pull_request.id, + pull_request.base.ref_field, + repository.default_branch.as_deref().unwrap_or("[unknown]") + ); continue; } tracing::info!("Processing pull request {}", pull_request.id); @@ -274,7 +298,7 @@ async fn handle_workflow_run_completed( .get_report( &project_info.project.owner, &project_info.project.repo, - &pull_request.base.sha, + &base_commit.sha, &artifact.version, ) .await? diff --git a/crates/images/Cargo.toml b/crates/images/Cargo.toml index c64b2c3..42ee22f 100644 --- a/crates/images/Cargo.toml +++ b/crates/images/Cargo.toml @@ -6,15 +6,17 @@ publish = false [dependencies] anyhow.workspace = true -resvg = "0.45" -tracing.workspace = true -objdiff-core.workspace = true -image.workspace = true -serde.workspace = true -badge-maker = "0.3" axum.workspace = true +badge-maker = "0.3" decomp-dev-core = { path = "../core" } -tokio.workspace = true +image.workspace = true mime.workspace = true -streemap = "0.1" +num-format = "0.4" +objdiff-core.workspace = true palette = "0.7" +resvg = "0.45" +serde.workspace = true +streemap = "0.1" +tokio.workspace = true +tracing.workspace = true + diff --git a/crates/images/src/badge.rs b/crates/images/src/badge.rs index 29a2bd8..5c2bfbb 100644 --- a/crates/images/src/badge.rs +++ b/crates/images/src/badge.rs @@ -1,4 +1,5 @@ use anyhow::{Result, anyhow}; +use decomp_dev_core::util::size; use image::ImageFormat; use objdiff_core::bindings::report::Measures; use serde::{Deserialize, Serialize}; @@ -29,6 +30,26 @@ pub struct ShieldResponse { label_color: Option, } +fn format_percent(value: f32) -> String { format!("{:.2}%", value) } + +fn format_bytes(a: u64, b: u64) -> String { + let mut a_buf = num_format::Buffer::default(); + let mut b_buf = num_format::Buffer::default(); + a_buf.write_formatted(&a, &num_format::Locale::en); + b_buf.write_formatted(&b, &num_format::Locale::en); + format!("{} B / {} B", a_buf, b_buf) +} + +fn format_num(a: u32, b: u32) -> String { + let mut a_buf = num_format::Buffer::default(); + let mut b_buf = num_format::Buffer::default(); + a_buf.write_formatted(&a, &num_format::Locale::en); + b_buf.write_formatted(&b, &num_format::Locale::en); + format!("{} / {}", a_buf, b_buf) +} + +fn format_size(a: u64, b: u64) -> String { format!("{} / {}", size(a), size(b)) } + pub fn render( measures: &Measures, default_label: &str, @@ -37,16 +58,44 @@ pub fn render( let label = params.label.clone().unwrap_or_else(|| default_label.to_string()); let message = if let Some(measure) = ¶ms.measure { match measure.as_str() { - "code" => format!("{:.2}%", measures.matched_code_percent), - "data" => format!("{:.2}%", measures.matched_data_percent), - "functions" => format!("{}/{}", measures.matched_functions, measures.total_functions), - "complete_code" => format!("{:.2}%", measures.complete_code_percent), - "complete_data" => format!("{:.2}%", measures.complete_data_percent), - "complete_units" => format!("{}/{}", measures.complete_units, measures.total_units), + "fuzzy_match_percent" | "fuzzy_match" => format_percent(measures.fuzzy_match_percent), + "matched_code_percent" | "matched_code" | "code" => { + format_percent(measures.matched_code_percent) + } + "matched_code_bytes" => format_bytes(measures.matched_code, measures.total_code), + "matched_code_size" => format_size(measures.matched_code, measures.total_code), + "matched_data_percent" | "matched_data" | "data" => { + format_percent(measures.matched_data_percent) + } + "matched_data_bytes" => format_bytes(measures.matched_data, measures.total_data), + "matched_data_size" => format_size(measures.matched_data, measures.total_data), + "matched_functions" | "functions" => { + format_num(measures.matched_functions, measures.total_functions) + } + "matched_functions_percent" => format_percent(measures.matched_functions_percent), + "complete_code_percent" | "complete_code" => { + format_percent(measures.complete_code_percent) + } + "complete_code_bytes" => format_bytes(measures.complete_code, measures.total_code), + "complete_code_size" => format_size(measures.complete_code, measures.total_code), + "complete_data_percent" | "complete_data" => { + format_percent(measures.complete_data_percent) + } + "complete_data_bytes" => format_bytes(measures.complete_data, measures.total_data), + "complete_data_size" => format_size(measures.complete_data, measures.total_data), + "complete_units" => format_num(measures.complete_units, measures.total_units), + "complete_units_percent" => { + let percent = if measures.total_units == 0 { + 100.0 + } else { + measures.complete_units as f32 / measures.total_units as f32 * 100.0 + }; + format_percent(percent) + } _ => return Err(anyhow!("Unknown measure")), } } else { - format!("{:.2}%", measures.matched_code_percent) + format_percent(measures.matched_code_percent) }; Ok(ShieldResponse { schema_version: 1, diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml index 3d3774d..a4e8112 100644 --- a/crates/web/Cargo.toml +++ b/crates/web/Cargo.toml @@ -13,6 +13,7 @@ decomp-dev-db = { path = "../db" } decomp-dev-github = { path = "../github" } decomp-dev-images = { path = "../images" } itertools = "0.14" +lexicmp = "0.2" maud.workspace = true mime.workspace = true objdiff-core.workspace = true diff --git a/crates/web/src/handlers/api.rs b/crates/web/src/handlers/api.rs new file mode 100644 index 0000000..571a071 --- /dev/null +++ b/crates/web/src/handlers/api.rs @@ -0,0 +1,45 @@ +use axum::response::{IntoResponse, Response}; +use decomp_dev_auth::CurrentUser; +use decomp_dev_core::AppError; +use maud::{DOCTYPE, html}; + +use crate::handlers::common::{Load, TemplateContext, nav_links}; + +pub async fn overview( + mut ctx: TemplateContext, + current_user: Option, +) -> Result { + let rendered = html! { + (DOCTYPE) + html lang="en" { + head { + meta charset="utf-8"; + title { "API • decomp.dev" } + (ctx.header().await) + (ctx.chunks("main", Load::Deferred).await) + (ctx.chunks("api", Load::Deferred).await) + } + body { + header { + nav { + ul { + li { + a href="/" { strong { "decomp.dev" } } + } + li { + a href="/api" { "API" } + } + } + (nav_links()) + } + } + main { + h1 { "API" } + #root {} + } + } + (ctx.footer(current_user.as_ref())) + } + }; + Ok((ctx, rendered).into_response()) +} diff --git a/crates/web/src/handlers/common.rs b/crates/web/src/handlers/common.rs index 65f98ca..c05090a 100644 --- a/crates/web/src/handlers/common.rs +++ b/crates/web/src/handlers/common.rs @@ -13,7 +13,7 @@ use axum::{ response::{IntoResponseParts, ResponseParts}, }; use decomp_dev_auth::CurrentUser; -use decomp_dev_core::AppError; +use decomp_dev_core::{AppError, util::size}; use maud::{Markup, PreEscaped, Render, html}; use objdiff_core::bindings::report::Measures; use regex::Regex; @@ -120,12 +120,11 @@ impl TemplateContext { meta name="apple-mobile-web-app-title" content="decomp.dev"; meta name="theme-color" content="#181c25" media="(prefers-color-scheme: dark)"; meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)"; - (self.chunks("entry", Load::Blocking).await) - link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96"; + link rel="icon" type="image/png" href="/favicon.png" sizes="96x96"; link rel="icon" type="image/svg+xml" href="/favicon.svg"; - link rel="shortcut icon" href="/favicon.ico"; link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180"; link rel="manifest" href="/site.webmanifest"; + (self.chunks("entry", Load::Blocking).await) } } @@ -163,7 +162,7 @@ impl TemplateContext { Style, Font, } - let mut push = |kind: ResourceKind, path: &str, load: Load| { + let mut push = async |kind: ResourceKind, path: &str, load: Load| { match load { Load::Preload => { if self.added_resources.contains_key(path) { @@ -202,14 +201,34 @@ impl TemplateContext { ResourceKind::Script | ResourceKind::Style => self.nonce.as_deref(), _ => None, }; + let can_inline = path.starts_with('/') + && matches!(kind, ResourceKind::Script | ResourceKind::Style) + && tokio::fs::metadata(format!("dist{path}")) + .await + .is_ok_and(|m| m.is_file() && m.len() <= 2048); let rendered = match load { - Load::Preload => html! { + Load::Preload if !can_inline => html! { link rel="preload" href=(path) as=(match kind { ResourceKind::Script => "script", ResourceKind::Style => "style", ResourceKind::Font => "font", }) crossorigin[crossorigin] nonce=[nonce]; }, + Load::Preload => Markup::default(), + Load::Blocking | Load::Deferred if can_inline => { + let content = + tokio::fs::read_to_string(format!("dist{path}")).await.unwrap_or_default(); + let content = escape_script(&content); + match kind { + ResourceKind::Script => html! { + script type=[(load == Load::Deferred).then_some("module")] nonce=[nonce] { (content) } + }, + ResourceKind::Style => html! { + style nonce=[nonce] { (content) } + }, + ResourceKind::Font => Markup::default(), + } + } Load::Blocking | Load::Deferred => match kind { ResourceKind::Script => html! { script src=(path) defer[load == Load::Deferred] crossorigin[crossorigin] nonce=[nonce] {}; @@ -223,14 +242,14 @@ impl TemplateContext { out.push_str(&rendered.0); }; for path in &entry.initial.js { - push(ResourceKind::Script, path, load); + push(ResourceKind::Script, path, load).await; } for path in &entry.initial.css { - push(ResourceKind::Style, path, load); + push(ResourceKind::Style, path, load).await; } for path in &entry.assets { if path.ends_with(".woff2") { - push(ResourceKind::Font, path, Load::Preload); + push(ResourceKind::Font, path, Load::Preload).await; } } PreEscaped(out) @@ -268,6 +287,10 @@ impl TemplateContext { } } } + // Analytics + script src="https://umami.decomp.dev/script.js" defer crossorigin + nonce=[self.nonce.as_deref()] + data-website-id="80d6109f-52cc-42a9-98c9-9820dc8c4435" {}; } } @@ -378,7 +401,7 @@ impl Render for ProgressSections { }; out.0.push_str(&rendered.0); } - if self.rendered.0.is_empty() { + if self.kind.is_empty() { return out; } html! { @@ -404,19 +427,6 @@ pub fn nav_links() -> Markup { } } -/// Format a size in bytes to a human-readable string. -/// Uses SI (kilo = 1000) units, formatted to two decimal places. -pub fn size(value: u64) -> String { - let units = ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; - let mut value = value as f64; - let mut unit = 0; - while value >= 1000.0 && unit < units.len() - 1 { - value /= 1000.0; - unit += 1; - } - format!("{:.2} {}", value, units[unit]) -} - pub async fn get_robots() -> Result { static ROBOTS_CACHE: LazyLock>> = LazyLock::new(|| std::sync::RwLock::new(None)); @@ -445,7 +455,7 @@ pub async fn get_robots() -> Result { } pub fn escape_script(text: &str) -> PreEscaped> { - static REGEX: LazyLock = LazyLock::new(|| Regex::new("<(?:!--|/?script)").unwrap()); + static REGEX: LazyLock = LazyLock::new(|| Regex::new("<(?i:!--|/?script)").unwrap()); PreEscaped(REGEX.replace_all(text, |caps: ®ex::Captures| format!("\\x3C{}", &caps[0][1..]))) } @@ -455,8 +465,8 @@ mod test { #[test] fn test_escape_script() { - let input = r#""#; - let expected = "\\x3C!--\\x3Cscript>alert('test');\\x3C/script>-->"; + let input = r#""#; + let expected = "\\x3C!--\\x3CSCRIPT>alert('test');\\x3C/ScRiPt>-->"; assert_eq!(escape_script(input).0.as_ref(), expected); } } diff --git a/crates/web/src/handlers/csp.rs b/crates/web/src/handlers/csp.rs index ec16ab3..895209c 100644 --- a/crates/web/src/handlers/csp.rs +++ b/crates/web/src/handlers/csp.rs @@ -77,7 +77,7 @@ pub async fn csp_middleware(mut req: Request, next: Next) -> Response { header.push(' '); header.push_str(domain); } - header.push_str(";connect-src 'self'"); + header.push_str(";connect-src 'self' https://umami.decomp.dev"); for domain in &extra_domains { header.push(' '); header.push_str(domain); diff --git a/crates/web/src/handlers/manage.rs b/crates/web/src/handlers/manage.rs index 30ddb98..26ad599 100644 --- a/crates/web/src/handlers/manage.rs +++ b/crates/web/src/handlers/manage.rs @@ -31,7 +31,7 @@ pub async fn manage( .await? .into_iter() .filter(|p| current_user.can_manage_repo(p.project.id)) - .sorted_by(|a, b| a.project.name().cmp(&b.project.name())) + .sorted_by(|a, b| lexicmp::natural_lexical_cmp(&a.project.name(), &b.project.name())) .collect::>(); let rendered = html! { @@ -49,7 +49,7 @@ pub async fn manage( nav { ul { li { - a href="https://decomp.dev" { strong { "decomp.dev" } } + a href="/" { strong { "decomp.dev" } } } li { a href="/manage" { "Manage" } @@ -116,6 +116,7 @@ async fn render_new( .map(|r| { (r.id, format!("{}/{}", r.owner, r.name), projects.iter().any(|p| p.project.id == r.id)) }) + .sorted_by(|a, b| lexicmp::lexical_cmp(&a.1, &b.1)) .collect::>(); let current_name = prefill.as_ref().and_then(|p| p.name.as_deref()).unwrap_or(""); @@ -149,7 +150,7 @@ async fn render_new( nav { ul { li { - a href="https://decomp.dev" { strong { "decomp.dev" } } + a href="/" { strong { "decomp.dev" } } } li { a href="/manage" { "Manage" } @@ -366,7 +367,7 @@ async fn render_manage_project( nav { ul { li { - a href="https://decomp.dev" { strong { "decomp.dev" } } + a href="/" { strong { "decomp.dev" } } } li { a href="/manage" { "Manage" } diff --git a/crates/web/src/handlers/mod.rs b/crates/web/src/handlers/mod.rs index 3e5c029..e4e3a5e 100644 --- a/crates/web/src/handlers/mod.rs +++ b/crates/web/src/handlers/mod.rs @@ -3,7 +3,8 @@ use std::str::FromStr; use axum::{ Router, extract::Request, - http::{HeaderMap, HeaderValue, header}, + http::{HeaderMap, HeaderValue, header, header::Entry}, + response::Response, routing::{get, post}, }; use decomp_dev_images::image_mime_from_ext; @@ -13,6 +14,7 @@ use tower_http::services::ServeDir; use crate::AppState; +mod api; mod auth; mod common; pub mod csp; @@ -28,9 +30,10 @@ pub fn build_router() -> Router { >::map_response( ServeDir::new("dist/static"), |mut response| { + add_charset(&mut response); // Cache static (hashed) files for a year, mark immutable - response.headers_mut().insert( - header::CACHE_CONTROL, + add_cache_control( + &mut response, HeaderValue::from_static("public, max-age=31536000, immutable"), ); response @@ -40,15 +43,17 @@ pub fn build_router() -> Router { .fallback_service(>::map_response( ServeDir::new("dist"), |mut response| { + add_charset(&mut response); // Cache non-hashed public files for a day, mark must-revalidate - response.headers_mut().insert( - header::CACHE_CONTROL, + add_cache_control( + &mut response, HeaderValue::from_static("public, max-age=86400, must-revalidate"), ); response }, )) .route("/robots.txt", get(common::get_robots)) + .route("/api", get(api::overview)) .route("/api/github/webhook", post(decomp_dev_github::webhook::webhook)) .route("/api/github/oauth", get(decomp_dev_auth::oauth)) .route("/login", get(auth::login)) @@ -61,11 +66,32 @@ pub fn build_router() -> Router { .route("/manage/{owner}/{repo}/refresh", post(manage::manage_project_refresh)) .route("/og.png", get(decomp_dev_images::get_og)) .route("/", get(project::get_projects)) + .route("/projects", get(project::get_projects)) + .route("/projects.json", get(project::get_projects)) + .route("/projects/{id}", get(report::get_report)) .route("/{owner}/{repo}", get(report::get_report)) .route("/{owner}/{repo}/{version}", get(report::get_report)) .route("/{owner}/{repo}/{version}/{commit}", get(report::get_report)) } +/// Adds a charset to the Content-Type header if it is missing and the type is text/*. +fn add_charset(response: &mut Response) { + let Entry::Occupied(mut existing) = response.headers_mut().entry(header::CONTENT_TYPE) else { + return; + }; + let Some(mime) = existing.get().to_str().ok().and_then(|v| Mime::from_str(v).ok()) else { + return; + }; + if mime.type_() == mime::TEXT && mime.get_param("charset").is_none() { + existing.insert(format!("{};charset=utf-8", mime).parse().unwrap()); + } +} + +/// Adds a Cache-Control header if it is missing. +fn add_cache_control(response: &mut Response, value: HeaderValue) { + response.headers_mut().entry(header::CACHE_CONTROL).or_insert(value); +} + pub fn parse_accept(headers: &HeaderMap, ext: Option<&str>) -> Vec { // Explicit extension takes precedence if let Some(ext) = ext { diff --git a/crates/web/src/handlers/project.rs b/crates/web/src/handlers/project.rs index 2237417..f6a3322 100644 --- a/crates/web/src/handlers/project.rs +++ b/crates/web/src/handlers/project.rs @@ -2,31 +2,37 @@ use std::{str::FromStr, sync::Arc}; use anyhow::{Context, anyhow}; use axum::{ + Json, extract::{Query, State}, - http::StatusCode, + http::{HeaderMap, StatusCode, Uri}, response::{IntoResponse, Response}, }; use decomp_dev_auth::CurrentUser; use decomp_dev_core::{ AppError, FullUri, - models::{Commit, Platform, Project}, - util::UrlExt, + models::{CachedReportFile, Commit, Platform, ProjectInfo, ReportInner}, + util::{UrlExt, size}, }; use maud::{DOCTYPE, Markup, html}; use objdiff_core::bindings::report::Measures; use serde::{Deserialize, Serialize}; +use time::{UtcDateTime, format_description::well_known::Rfc3339}; use tokio::{sync::Semaphore, task::JoinSet}; use url::Url; use crate::{ AppState, - handlers::common::{Load, ProgressSections, TemplateContext, date, nav_links, size, timeago}, + handlers::{ + common::{Load, ProgressSections, TemplateContext, date, nav_links, timeago}, + parse_accept, + report::TemplateMeasures, + }, }; struct ProjectInfoContext { - project: Project, - commit: Commit, + info: ProjectInfo, measures: Measures, + report: Option, code_progress: ProgressSections, } @@ -56,24 +62,113 @@ pub struct ProgressSection { pub tooltip: String, } +#[derive(serde::Serialize)] +pub struct ProjectsResponse { + pub projects: Vec, +} + +#[derive(serde::Serialize)] +pub struct ProjectResponse { + pub id: u64, + pub owner: String, + pub repo: String, + pub repo_url: String, + pub name: Option, + pub short_name: Option, + pub platform: Option, + pub default_version: Option, + pub default_category: Option, + pub commit: Option, + pub measures: TemplateMeasures, + pub report_versions: Vec, + pub report_categories: Vec, +} + +impl ProjectResponse { + pub fn new(info: &ProjectInfo, measures: &Measures, report: &ReportInner) -> Self { + ProjectResponse { + id: info.project.id, + owner: info.project.owner.clone(), + repo: info.project.repo.clone(), + repo_url: info.project.repo_url(), + name: info.project.name.clone(), + short_name: info.project.short_name.clone(), + platform: info.project.platform.clone(), + default_version: info.default_version().map(|v| v.to_string()), + default_category: info.project.default_category.clone(), + commit: info.commit.clone().map(CommitResponse::from), + measures: measures.into(), + report_versions: info.report_versions.clone(), + report_categories: report + .categories + .iter() + .map(|c| CategoryResponse { + id: c.id.clone(), + name: c.name.clone(), + measures: c.measures.as_ref().map(TemplateMeasures::from).unwrap_or_default(), + }) + .collect(), + } + } +} + +#[derive(serde::Serialize)] +pub struct CategoryResponse { + pub id: String, + pub name: String, + pub measures: TemplateMeasures, +} + +#[derive(serde::Serialize)] +pub struct CommitResponse { + pub sha: String, + pub message: Option, + pub timestamp: String, +} + +impl From for CommitResponse { + fn from(value: Commit) -> Self { + Self { + sha: value.sha, + message: value.message, + timestamp: value.timestamp.format(&Rfc3339).unwrap_or_else(|_| "[invalid]".to_string()), + } + } +} + +fn extract_extension(uri: &Uri) -> Option { + let path = uri.path(); + if let Some(pos) = path.rfind('.') { + let ext = &path[pos + 1..]; + if !ext.is_empty() { + return Some(ext.to_string()); + } + } + None +} + pub async fn get_projects( - mut ctx: TemplateContext, + ctx: TemplateContext, State(state): State, Query(query): Query, FullUri(uri): FullUri, current_user: Option, + headers: HeaderMap, ) -> Result { + let ext = extract_extension(&uri); + let acceptable = parse_accept(&headers, ext.as_deref()); + if acceptable.is_empty() { + return Err(AppError::Status(StatusCode::NOT_ACCEPTABLE)); + } + let projects = state.db.get_projects().await?; let mut out = projects .iter() - .filter_map(|p| { - let commit = p.commit.as_ref()?; - Some(ProjectInfoContext { - project: p.project.clone(), - commit: commit.clone(), - measures: Default::default(), - code_progress: Default::default(), - }) + .map(|p| ProjectInfoContext { + info: p.clone(), + measures: Default::default(), + report: None, + code_progress: Default::default(), }) .collect::>(); @@ -105,8 +200,9 @@ pub async fn get_projects( while let Some(result) = join_set.join_next().await { match result { Ok((info, Ok(Some(file)))) => { - if let Some(c) = out.iter_mut().find(|i| i.project.id == info.project.id) { + if let Some(c) = out.iter_mut().find(|i| i.info.project.id == info.project.id) { c.measures = *file.report.measures(info.project.default_category.as_deref()); + c.report = Some(file.clone()); c.code_progress = ctx.code_progress_sections(&c.measures); } } @@ -122,11 +218,6 @@ pub async fn get_projects( } } - let mut combined_styles = ProgressSections { nonce: ctx.nonce.clone(), ..Default::default() }; - for info in &mut out { - combined_styles.width_classes.append(&mut info.code_progress.width_classes); - } - let current_sort_key = query.sort.as_deref().unwrap_or("updated"); let current_sort = SORT_OPTIONS .iter() @@ -134,8 +225,16 @@ pub async fn get_projects( .copied() .ok_or(AppError::Status(StatusCode::BAD_REQUEST))?; match current_sort.key { - "name" => out.sort_by(|a, b| a.project.name().cmp(&b.project.name())), - "updated" => out.sort_by(|a, b| b.commit.timestamp.cmp(&a.commit.timestamp)), + "name" => out.sort_by(|a, b| { + lexicmp::natural_lexical_cmp(&a.info.project.name(), &b.info.project.name()) + }), + "updated" => out.sort_by(|a, b| { + let a_ts = + a.report.as_ref().map(|r| r.commit.timestamp).unwrap_or(UtcDateTime::UNIX_EPOCH); + let b_ts = + b.report.as_ref().map(|r| r.commit.timestamp).unwrap_or(UtcDateTime::UNIX_EPOCH); + b_ts.cmp(&a_ts) + }), "matched_code_percent" => out.sort_by(|a, b| { b.measures .matched_code_percent @@ -157,8 +256,42 @@ pub async fn get_projects( _ => return Err(AppError::Status(StatusCode::BAD_REQUEST)), } + for mime in acceptable { + 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; + } else if mime.type_() == mime::APPLICATION && mime.subtype() == mime::JSON { + let projects = out + .into_iter() + .filter_map(|info| { + Some(ProjectResponse::new( + &info.info, + &info.measures, + &info.report.as_ref()?.report, + )) + }) + .collect::>(); + return Ok(Json(ProjectsResponse { projects }).into_response()); + } + } + Err(AppError::Status(StatusCode::NOT_ACCEPTABLE)) +} + +async fn render_project( + mut ctx: TemplateContext, + mut out: Vec, + uri: Uri, + current_sort: SortOption, + current_user: Option<&CurrentUser>, +) -> Result { + let mut combined_styles = ProgressSections { nonce: ctx.nonce.clone(), ..Default::default() }; + for info in &mut out { + combined_styles.width_classes.append(&mut info.code_progress.width_classes); + } + let request_url = Url::parse(&uri.to_string()).context("Failed to parse URI")?; - let canonical_url = request_url.with_path(""); + let canonical_url = request_url.with_path("/projects"); let rendered = html! { (DOCTYPE) @@ -168,6 +301,7 @@ pub async fn get_projects( title { "Projects • decomp.dev" } (ctx.header().await) (ctx.chunks("main", 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"; @@ -179,10 +313,10 @@ pub async fn get_projects( nav { ul { li { - a href="https://decomp.dev" { strong { "decomp.dev" } } + a href="/" { strong { "decomp.dev" } } } li { - a href="/" { "Projects" } + a href="/projects" { "Projects" } } li class="md" { details class="dropdown" { @@ -231,7 +365,7 @@ pub async fn get_projects( (project_fragment(project, current_sort, &canonical_url)) } } - (ctx.footer(current_user.as_ref())) + (ctx.footer(current_user)) } } }; @@ -239,48 +373,49 @@ pub async fn get_projects( } fn project_fragment( - info: ProjectInfoContext, + ctx: ProjectInfoContext, current_sort: SortOption, canonical_url: &Url, ) -> Markup { - let project_path = - canonical_url.with_path(&format!("/{}/{}", info.project.owner, info.project.repo)); - let commit_url = format!( - "https://github.com/{}/{}/commit/{}", - info.project.owner, info.project.repo, info.commit.sha - ); + let project = &ctx.info.project; + 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 commit_url = + format!("https://github.com/{}/{}/commit/{}", project.owner, project.repo, commit.sha); html! { article class="project" { div class="project-header" { h3 class="project-title" { - a href=(project_path) { (info.project.name()) } + a href=(project_path) { (project.name()) } } - @if let Some(platform) = &info.project.platform { + @if let Some(platform) = &project.platform { @let platform_name = Platform::from_str(platform).map(|p| p.name()).unwrap_or(platform); span class=(format!("platform-icon icon-{platform}")) title=(platform_name) {} } } h6 { @if current_sort.key == "total_code" || current_sort.key == "matched_code" { - (size(info.measures.matched_code)) + (size(ctx.measures.matched_code)) " matched code | " - (size(info.measures.total_code)) + (size(ctx.measures.total_code)) " total code" } @else { - (format!("{:.2}%", info.measures.matched_code_percent)) + (format!("{:.2}%", ctx.measures.matched_code_percent)) " decompiled" - @if info.measures.complete_code_percent > 0.0 { + @if ctx.measures.complete_code_percent > 0.0 { " | " - (format!("{:.2}%", info.measures.complete_code_percent)) + (format!("{:.2}%", ctx.measures.complete_code_percent)) " fully linked" } } } - (info.code_progress) + (ctx.code_progress) small class="muted" { - span title=(date(info.commit.timestamp)) { "Updated " (timeago(info.commit.timestamp)) } + span title=(date(commit.timestamp)) { "Updated " (timeago(commit.timestamp)) } " in commit " - a href=(commit_url) target="_blank" { (info.commit.sha[..7]) } + a href=(commit_url) target="_blank" { (commit.sha[..7]) } } } } diff --git a/crates/web/src/handlers/report.rs b/crates/web/src/handlers/report.rs index 7214295..4554605 100644 --- a/crates/web/src/handlers/report.rs +++ b/crates/web/src/handlers/report.rs @@ -11,7 +11,7 @@ use decomp_dev_auth::CurrentUser; use decomp_dev_core::{ AppError, FullUri, models::{FullReportFile, ProjectInfo}, - util::UrlExt, + util::{UrlExt, size}, }; use decomp_dev_images::{ badge, @@ -28,14 +28,18 @@ use url::Url; use super::{parse_accept, treemap}; use crate::{ AppState, - handlers::common::{Load, TemplateContext, escape_script, nav_links, size}, + handlers::{ + common::{Load, TemplateContext, escape_script, nav_links}, + project::ProjectResponse, + }, proto::{PROTOBUF, Protobuf}, }; #[derive(Deserialize)] pub struct ReportParams { - owner: String, - repo: String, + id: Option, + owner: Option, + repo: Option, version: Option, commit: Option, } @@ -47,7 +51,9 @@ const DEFAULT_IMAGE_HEIGHT: u32 = 475; #[serde(rename_all = "camelCase")] pub struct ReportQuery { mode: Option, + version: Option, category: Option, + commit: Option, w: Option, h: Option, #[serde(flatten)] @@ -173,9 +179,23 @@ fn extract_extension(params: ReportParams) -> (ReportParams, Option) { ); } } - } else if let Some((repo, ext)) = params.repo.rsplit_once('.') { - if is_valid_extension(ext) { - return (ReportParams { repo: repo.to_string(), ..params }, Some(ext.to_string())); + } else if let Some(repo) = params.repo.as_deref() { + if let Some((repo, ext)) = repo.rsplit_once('.') { + if is_valid_extension(ext) { + return ( + ReportParams { repo: Some(repo.to_string()), ..params }, + Some(ext.to_string()), + ); + } + } + } else if let Some(id) = params.id.as_deref() { + if let Some((id, ext)) = id.rsplit_once('.') { + if is_valid_extension(ext) { + return ( + ReportParams { id: Some(id.to_string()), ..params }, + Some(ext.to_string()), + ); + } } } (params, None) @@ -196,45 +216,95 @@ pub async fn get_report( return Err(AppError::Status(StatusCode::NOT_ACCEPTABLE)); } - let mut commit = params.commit.as_deref(); + let mut commit = query.commit.as_deref().or(params.commit.as_deref()); if matches!(commit, Some(c) if c.eq_ignore_ascii_case("latest")) { commit = None; } - let Some(project_info) = state.db.get_project_info(¶ms.owner, ¶ms.repo, commit).await? - else { + let Some(project_info) = (match (¶ms.id, ¶ms.owner, ¶ms.repo) { + (Some(id), _, _) => { + let id: u64 = id.parse().map_err(|_| AppError::Status(StatusCode::BAD_REQUEST))?; + state.db.get_project_info_by_id(id, commit).await? + } + (_, Some(owner), Some(repo)) => state.db.get_project_info(owner, repo, commit).await?, + _ => return Err(AppError::Status(StatusCode::BAD_REQUEST)), + }) else { return Err(AppError::Status(StatusCode::NOT_FOUND)); }; let Some(commit) = project_info.commit.as_ref() else { return Err(AppError::Status(StatusCode::NOT_FOUND)); }; - let version = if let Some(version) = ¶ms.version { + let version = if let Some(version) = query.version.as_deref().or(params.version.as_deref()) { if version.eq_ignore_ascii_case("default") { project_info.default_version().ok_or(AppError::Status(StatusCode::NOT_FOUND))? } else { - version.as_str() + version } } else if let Some(default_version) = project_info.default_version() { default_version } else { return Err(AppError::Status(StatusCode::NOT_FOUND)); }; - let Some(report) = - state.db.get_report(¶ms.owner, ¶ms.repo, &commit.sha, version).await? + let Some(report) = state + .db + .get_report(&project_info.project.owner, &project_info.project.repo, &commit.sha, version) + .await? else { return Err(AppError::Status(StatusCode::NOT_FOUND)); }; let report = state.db.upgrade_report(&report).await?; let scope = apply_scope(&report, &project_info, &query)?; - match query.mode.as_deref().unwrap_or("report").to_ascii_lowercase().as_str() { - "shield" => mode_shield(&scope, query, &acceptable), - "report" => mode_report(&scope, &state, uri, query, ctx, &acceptable, current_user).await, - "measures" => mode_measures(&scope, &acceptable), + match query.mode.as_deref().unwrap_or("overview").to_ascii_lowercase().as_str() { "history" => mode_history(&scope, &state, uri, query, ctx, &acceptable, current_user).await, + "measures" => mode_measures(&scope, &acceptable), + "overview" => { + mode_overview(&scope, &state, uri, query, ctx, &acceptable, current_user).await + } + "report" => mode_report(&scope, &state, uri, query, ctx, &acceptable, current_user).await, + "shield" => mode_shield(&scope, query, &acceptable), _ => Err(AppError::Status(StatusCode::BAD_REQUEST)), } } +#[allow(clippy::too_many_arguments)] +async fn mode_overview( + scope: &Scope<'_>, + state: &AppState, + uri: Uri, + query: ReportQuery, + ctx: TemplateContext, + acceptable: &[Mime], + current_user: Option, +) -> Result { + for mime in acceptable { + if (mime.type_() == mime::STAR && mime.subtype() == mime::STAR) + || (mime.type_() == mime::TEXT && mime.subtype() == mime::HTML) + { + return render_report(scope, state, uri, current_user, ctx).await; + } else if mime.type_() == mime::APPLICATION && mime.subtype() == mime::JSON { + let result = + ProjectResponse::new(scope.project_info, scope.measures, &scope.report.report); + return Ok(Json(result).into_response()); + } else if mime.type_() == mime::IMAGE && mime.subtype() == mime::SVG { + let (w, h) = query.size(); + let svg = treemap::render_svg(&scope.units, w, h); + return Ok(([(header::CONTENT_TYPE, mime::IMAGE_SVG.as_ref())], svg).into_response()); + } else if mime.type_() == mime::IMAGE { + let format = if mime.subtype() == mime::STAR { + // Default to PNG + ImageFormat::Png + } else { + ImageFormat::from_mime_type(mime.essence_str()) + .ok_or_else(|| AppError::Status(StatusCode::NOT_ACCEPTABLE))? + }; + let (w, h) = query.size(); + let data = treemap::render_image(&scope.units, w, h, format)?; + return Ok(([(header::CONTENT_TYPE, format.to_mime_type())], data).into_response()); + } + } + Err(AppError::Status(StatusCode::NOT_ACCEPTABLE)) +} + #[allow(clippy::too_many_arguments)] async fn mode_report( scope: &Scope<'_>, @@ -327,6 +397,7 @@ fn mode_measures( struct ReportHistoryEntry { timestamp: String, commit_sha: String, + commit_message: Option, measures: TemplateMeasures, } @@ -373,6 +444,7 @@ async fn mode_history( .format(&Rfc3339) .unwrap_or_else(|_| "[invalid]".to_string()), commit_sha: report.commit.sha, + commit_message: report.commit.message, measures: TemplateMeasures::from(measures), }); } @@ -576,7 +648,9 @@ async fn render_report( "/{}/{}/{}/{}", project_info.project.owner, project_info.project.repo, report.version, report.commit.sha )); - let image_url = canonical_url.with_path(&format!("{}.png", canonical_url.path())); + let image_url = canonical_url + .with_path(&format!("{}.png", canonical_url.path())) + .query_param("mode", Some("report")); let versions = project_info .report_versions @@ -671,6 +745,7 @@ async fn render_report( (header) (ctx.chunks("main", Load::Deferred).await) (ctx.chunks("report", Load::Preload).await) + link rel="canonical" href=(canonical_url); meta name="description" content=(format!("Decompilation progress report for {project_name}")); meta property="og:title" content=(format!("{project_short_name_with_label} is {:.2}% decompiled", measures.matched_code_percent)); meta property="og:description" content=(format!("Decompilation progress report for {project_name}")); @@ -682,10 +757,10 @@ async fn render_report( nav { ul { li { - a href="https://decomp.dev" { strong { "decomp.dev" } } + a href="/" { strong { "decomp.dev" } } } li { - a href="/" { "Projects" } + a href="/projects" { "Projects" } } li { a href=(project_base_path) { (project_short_name) } @@ -711,6 +786,12 @@ async fn render_report( details class="dropdown" { summary {} ul dir="rtl" { + li { + a href=(format!("/api?project={}", project_info.project.id)) { + "API " + span .icon-code { " " } + } + } li { a href=(project_history_path) { "History " @@ -865,7 +946,9 @@ async fn render_history( "/{}/{}/{}", project_info.project.owner, project_info.project.repo, report.version )); - let image_url = canonical_url.with_path(&format!("{}.png", canonical_url.path())); + let image_url = canonical_url + .with_path(&format!("{}.png", canonical_url.path())) + .query_param("mode", Some("report")); let versions = project_info .report_versions @@ -929,6 +1012,7 @@ async fn render_history( (header) (ctx.chunks("main", Load::Deferred).await) (ctx.chunks("history", Load::Preload).await) + link rel="canonical" href=(canonical_url); meta name="description" content=(format!("Decompilation progress history for {project_name}")); meta property="og:title" content=(format!("{project_short_name_with_label} is {:.2}% decompiled", measures.matched_code_percent)); meta property="og:description" content=(format!("Decompilation progress history for {project_name}")); @@ -940,10 +1024,10 @@ async fn render_history( nav { ul { li { - a href="https://decomp.dev" { strong { "decomp.dev" } } + a href="/" { strong { "decomp.dev" } } } li { - a href="/" { "Projects" } + a href="/projects" { "Projects" } } li { a href=(project_base_path) { (project_short_name) } diff --git a/css/history.css b/css/history.css index 609cb9b..759bfa5 100644 --- a/css/history.css +++ b/css/history.css @@ -1 +1 @@ -@import url('~uplot/dist/uPlot.min.css'); +@import url("~uplot/dist/uPlot.min.css"); diff --git a/css/main.scss b/css/main.scss index 8fac4dc..0c42a85 100644 --- a/css/main.scss +++ b/css/main.scss @@ -69,7 +69,7 @@ $breakpoints: ( // Forms "forms/basics": true, "forms/checkbox-radio-switch": true, - "forms/input-color": false, + "forms/input-color": true, "forms/input-date": false, "forms/input-file": false, "forms/input-range": false, @@ -161,6 +161,9 @@ $breakpoints: ( .icon-github-circled:before { content: '\e804'; } .icon-left-open:before { content: '\e805'; } .icon-right-open:before { content: '\e806'; } +.icon-copy:before { content: '\e807'; } +.icon-cancel:before { content: '\e808'; } +.icon-code:before { content: '\e809'; } .icon-gc:before { content: '\ea00'; } .icon-wii:before { content: '\ea01'; } diff --git a/js/api.module.css b/js/api.module.css new file mode 100644 index 0000000..7c25246 --- /dev/null +++ b/js/api.module.css @@ -0,0 +1,21 @@ +.code-block { + position: relative; +} + +.copy-button { + position: absolute; + top: 0.5em; + right: 0.5em; + padding: 0.5em; + vertical-align: bottom; +} + +.clear-container { + display: flex; + gap: calc(var(--pico-spacing) * 0.5); + padding-top: calc(var(--pico-spacing) * 0.25); + + input { + margin-bottom: 0; + } +} diff --git a/js/api.tsx b/js/api.tsx new file mode 100644 index 0000000..8eca059 --- /dev/null +++ b/js/api.tsx @@ -0,0 +1,652 @@ +import { StrictMode, useState, useEffect, useMemo, useRef } from 'react'; +import { createRoot } from 'react-dom/client'; +import hljs from 'highlight.js/lib/core'; +import 'highlight.js/styles/hybrid.css'; +import styles from './api.module.css'; + +hljs.registerLanguage('json', require('highlight.js/lib/languages/json')); +hljs.registerLanguage( + 'plaintext', + require('highlight.js/lib/languages/plaintext'), +); + +const CodeBlock = ({ + value, + language, + originalValue, +}: { value: string; language?: string | null; originalValue?: string }) => { + const codeRef = useRef(null); + const langClass = language ? `language-${language}` : undefined; + + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + if (codeRef.current && !codeRef.current.dataset.highlighted) { + hljs.highlightElement(codeRef.current); + } + }, [value]); + + const copyToClipboard = () => { + navigator.clipboard.writeText(originalValue || value).catch((e) => { + console.error('Failed to copy text', e); + }); + }; + + return ( +
+      
+ ); +}; + +type ProjectsResponse = { + projects: ProjectResponse[]; +}; + +type ProjectResponse = { + id: string; + owner: string; + repo: string; + name: string | null; + short_name: string | null; + platform: string | null; + default_version: string; + report_versions: string[]; + report_categories: CategoryResponse[]; +}; + +type CategoryResponse = { + id: string; + name: string; +}; + +const fetchProjects = async () => { + const url = new URL('/projects', window.location.origin); + url.searchParams.append('sort', 'name'); + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error('Failed to fetch projects'); + } + return (await response.json()) as ProjectsResponse; +}; + +const fetchProjectById = async (id: string, version: string | null) => { + const url = new URL(`/projects/${id}`, window.location.origin); + if (version) { + url.searchParams.append('version', version); + } + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error('Failed to fetch projects'); + } + return (await response.json()) as ProjectResponse; +}; + +const fetchApiUrl = async (url: string) => { + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error('Failed to fetch projects'); + } + return await response.text(); +}; + +const ProjectForm = () => { + const [projects, setProjects] = useState([]); + const [loadingProjects, setLoadingProjects] = useState(true); + const [loadingProject, setLoadingProject] = useState(true); + + type Selected = { + project: string | null; + version: string | null; + category: string | null; + }; + const [selected, setSelected] = useState({ + project: null, + version: null, + category: null, + }); + const [mode, setMode] = useState('overview'); + const [format, setFormat] = useState('json'); + const [formatOptions, setFormatOptions] = useState({}); + const [useProjectId, setUseProjectId] = useState(false); + + const [currentProject, setCurrentProject] = useState( + null, + ); + + useEffect(() => { + setLoadingProjects(true); + fetchProjects() + .then((response) => { + setProjects(response.projects); + const url = new URL(document.location.href); + const deferredProject = url.searchParams.get('project'); + if (deferredProject) { + const project = response.projects.find( + (p) => p.id.toString() === deferredProject, + ); + if (project) { + setCurrentProject(project); + setSelected({ + project: project.id, + version: null, + category: null, + }); + } + } + setLoadingProjects(false); + }) + .catch((error) => { + console.error('Error fetching projects:', error); + setProjects([]); + setLoadingProjects(false); + }); + }, []); + + useEffect(() => { + if (selected.project === null) { + return; + } + setLoadingProject(true); + fetchProjectById(selected.project, selected.version) + .then((response) => { + setCurrentProject(response); + setLoadingProject(false); + }) + .catch((error) => { + console.error('Error fetching project by ID:', error); + setCurrentProject(null); + setLoadingProject(false); + }); + }, [selected.project, selected.version]); + + const url = new URL('/', window.location.origin); + url.searchParams.append('mode', mode); + if (currentProject) { + if (useProjectId) { + url.pathname = `/projects/${currentProject.id}`; + if (selected.version) { + url.searchParams.append('version', selected.version); + } + } else { + url.pathname = `/${currentProject.owner}/${currentProject.repo}`; + if (selected.version) { + url.pathname += `/${selected.version || currentProject.default_version}`; + } + } + if (selected.category) { + url.searchParams.append('category', selected.category); + } + } + url.pathname += `.${format}`; + if (mode === 'shield') { + if (formatOptions.label) { + url.searchParams.append('label', formatOptions.label); + } + if (formatOptions.labelColor) { + url.searchParams.append('labelColor', formatOptions.labelColor); + } + if (formatOptions.color) { + url.searchParams.append('color', formatOptions.color); + } + if (formatOptions.style) { + url.searchParams.append('style', formatOptions.style); + } + if (formatOptions.measure) { + url.searchParams.append('measure', formatOptions.measure); + } + } + + const updateMode = (newMode: string) => { + setMode(newMode); + setFormat(formatsByMode[newMode][0]); + setFormatOptions({}); + }; + + const updateFormat = (newFormat: string) => { + setFormat(newFormat); + }; + + let modeOptions = null; + if (mode === 'shield') { + modeOptions = ( + <> +
+ +
+
+ + +
+
+ + +
+ + ); + } + + return ( +
+
+ + + +
+
+ + +
+
+ +
+
+ {modeOptions} + {currentProject && } +
+ ); +}; + +const formatsByMode: Record = { + overview: ['json', 'svg', 'png'], + measures: ['json', 'binpb'], + report: ['json', 'binpb'], + shield: ['svg', 'png', 'json'], + history: ['json'], +}; + +const prettifyJson = (data: string): string => { + let parsed: any; + try { + parsed = JSON.parse(data); + } catch (error) { + // Return the original data if parsing fails + return data; + } + return JSON.stringify(parsed, null, 2); +}; + +const ApiPreview = ({ + url, + format, +}: { url: string | null; format: string }) => { + type ResponseData = { + data: string; + format: string; + }; + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [response, setResponse] = useState(null); + + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + setResponse(null); + }, [url]); + + const fetchData = async () => { + if (!url) { + return; + } + setLoading(true); + setError(null); + try { + const response = await fetchApiUrl(url); + setResponse({ data: response, format }); + } catch (error) { + console.error('Error fetching API URL:', error); + setError('Failed to fetch data'); + } finally { + setLoading(false); + } + }; + + const stringified = useMemo(() => { + if (!response) { + return ''; + } + const maxLength = 250000; // 250 KB + let stringified: string; + if (response.format === 'json') { + stringified = prettifyJson(response.data); + } else if (response.format === 'binpb') { + stringified = `// Binary protobuf size ${formatBytes(response.data.length)}`; + } else { + stringified = response.data; + } + if (stringified.length > maxLength) { + return `// Response was ${formatBytes(response.data.length)}, preview truncated to ${formatBytes(maxLength, 0)}\n${stringified.slice(0, maxLength)}`; + } + return stringified; + }, [response]); + + if (!url) { + return null; + } + + let preview = null; + if (response) { + if (response.format === 'json' || response.format === 'binpb') { + preview = ( + + ); + } + } + if (error) { + preview = ; + } + + if (format === 'svg' || format === 'png') { + preview = Preview; + } else if (format === 'json' || format === 'binpb') { + preview = ( + <> +
+ +
+
+ {preview} + + ); + } + + return ( + <> + + {preview} + + ); +}; + +function formatBytes(bytes: number, decimals = 2): string { + if (bytes === 0) return '0 B'; + + const k = 1000; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const sign = bytes < 0 ? '-' : ''; + const absoluteBytes = Math.abs(bytes); + const i = Math.min( + Math.floor(Math.log10(absoluteBytes) / Math.log10(k)), + sizes.length - 1, + ); + + const value = absoluteBytes / k ** i; + return `${sign}${value.toFixed(dm)} ${sizes[i]}`; +} + +const rootNode = document.getElementById('root'); +if (rootNode) { + const root = createRoot(rootNode); + root.render( + + + , + ); +} diff --git a/js/main.ts b/js/main.ts index f82758a..79f586f 100644 --- a/js/main.ts +++ b/js/main.ts @@ -1,87 +1,110 @@ const themeToggleButton = document.getElementById('theme-toggle'); if (themeToggleButton) { - themeToggleButton.addEventListener('click', (e) => { - const root = document.documentElement; - const defaultDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? true; - const themeAttr = root.getAttribute('data-theme'); - const isDark = themeAttr === null ? defaultDark : themeAttr === 'dark'; - root.setAttribute('data-theme', isDark ? 'light' : 'dark'); - try { - localStorage.setItem('theme', isDark ? 'light' : 'dark'); - } catch (e) { - console.error('Failed to store theme', e); - } - e.preventDefault(); - }); + themeToggleButton.addEventListener('click', (e) => { + const root = document.documentElement; + const defaultDark = + window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? true; + const themeAttr = root.getAttribute('data-theme'); + const isDark = themeAttr === null ? defaultDark : themeAttr === 'dark'; + root.setAttribute('data-theme', isDark ? 'light' : 'dark'); + try { + localStorage.setItem('theme', isDark ? 'light' : 'dark'); + } catch (e) { + console.error(e); + } + e.preventDefault(); + }); } let tooltipElem: HTMLElement | null = null; const applyTooltip = (elem: HTMLElement) => { - const text = elem.getAttribute('data-tooltip'); - if (!text) { - return; - } - let tooltip = document.getElementById('tooltip'); - if (!tooltip) { - tooltip = document.createElement('div'); - tooltip.id = 'tooltip'; - tooltip.addEventListener('transitionend', (e) => { - if (e.propertyName === 'opacity' && - e.target instanceof HTMLElement && - e.target.style.opacity === '0') { - e.target.remove(); - } - }); - document.body.appendChild(tooltip); - } - tooltip.innerText = text; - tooltip.style.opacity = '1'; - const rect = elem.getBoundingClientRect(); - let left = rect.left + window.scrollX + (rect.width - tooltip.offsetWidth) / 2; - const docWidth = document.documentElement.clientWidth || document.body.clientWidth; - if (left + tooltip.offsetWidth > docWidth) { - left = docWidth - tooltip.offsetWidth; - } - if (left < 0) { - left = 0; - } - let top = rect.top + window.scrollY - tooltip.offsetHeight; - if (top < 0) { - top = rect.bottom + window.scrollY; - } - tooltip.style.left = `${left}px`; - tooltip.style.top = `${top}px`; - const arrowLeft = rect.left + window.scrollX + rect.width / 2 - left; - tooltip.style.setProperty('--arrow-left', `${arrowLeft}px`); - tooltipElem = elem; + const text = elem.getAttribute('data-tooltip'); + if (!text) { + return; + } + let tooltip = document.getElementById('tooltip'); + if (!tooltip) { + tooltip = document.createElement('div'); + tooltip.id = 'tooltip'; + tooltip.addEventListener('transitionend', (e) => { + if ( + e.propertyName === 'opacity' && + e.target instanceof HTMLElement && + e.target.style.opacity === '0' + ) { + e.target.remove(); + } + }); + document.body.appendChild(tooltip); + } + tooltip.innerText = text; + tooltip.style.opacity = '1'; + const rect = elem.getBoundingClientRect(); + const scrollGutter = + document.documentElement.clientWidth - document.body.clientWidth; + const scrollX = window.scrollX - scrollGutter; + let left = rect.left + scrollX + (rect.width - tooltip.offsetWidth) / 2; + const docWidth = + document.documentElement.clientWidth || document.body.clientWidth; + if (left + tooltip.offsetWidth > docWidth) { + left = docWidth - tooltip.offsetWidth; + } + if (left < 0) { + left = 0; + } + let top = rect.top + window.scrollY - tooltip.offsetHeight; + if (top < 0) { + top = rect.bottom + window.scrollY; + } + tooltip.style.left = `${left}px`; + tooltip.style.top = `${top}px`; + const arrowLeft = rect.left + scrollX + rect.width / 2 - left; + tooltip.style.setProperty('--arrow-left', `${arrowLeft}px`); + tooltipElem = elem; }; const removeTooltip = (elem: HTMLElement) => { - if (elem !== tooltipElem) { - return; - } - const tooltip = document.getElementById('tooltip'); - if (tooltip) { - tooltip.style.opacity = '0'; - } - tooltipElem = null; + if (elem !== tooltipElem) { + return; + } + const tooltip = document.getElementById('tooltip'); + if (tooltip) { + tooltip.style.opacity = '0'; + } + tooltipElem = null; }; -document.addEventListener('mouseover', (e) => { +document.addEventListener( + 'mouseover', + (e) => { if (e.target instanceof HTMLElement) { - applyTooltip(e.target); + applyTooltip(e.target); } -}, { passive: true }); -document.addEventListener('mouseout', (e) => { + }, + { passive: true }, +); +document.addEventListener( + 'mouseout', + (e) => { if (e.target instanceof HTMLElement) { - removeTooltip(e.target); + removeTooltip(e.target); } -}, { passive: true }); -document.addEventListener('touchstart', (e) => { + }, + { passive: true }, +); +document.addEventListener( + 'touchstart', + (e) => { if (e.target instanceof HTMLElement) { - applyTooltip(e.target); + applyTooltip(e.target); } -}, { passive: true }); -document.addEventListener('touchend', (e) => { + }, + { passive: true }, +); +document.addEventListener( + 'touchend', + (e) => { if (e.target instanceof HTMLElement) { - removeTooltip(e.target); + removeTooltip(e.target); } -}, { passive: true }); + }, + { passive: true }, +); diff --git a/js/manage.ts b/js/manage.ts index eb8c64b..b26334e 100644 --- a/js/manage.ts +++ b/js/manage.ts @@ -1,12 +1,12 @@ document.querySelectorAll('form[data-loading]').forEach((form) => { - const loadingText = form.getAttribute('data-loading'); - const submitButton = form.querySelector('[type="submit"]'); - if (!submitButton || !(submitButton instanceof HTMLButtonElement)) { - return; - } - form.addEventListener('submit', () => { - submitButton.disabled = true; - submitButton.setAttribute('aria-busy', 'true'); - submitButton.innerText = loadingText || 'Loading...'; - }); + const loadingText = form.getAttribute('data-loading'); + const submitButton = form.querySelector('[type="submit"]'); + if (!submitButton || !(submitButton instanceof HTMLButtonElement)) { + return; + } + form.addEventListener('submit', () => { + submitButton.disabled = true; + submitButton.setAttribute('aria-busy', 'true'); + submitButton.innerText = loadingText || 'Loading...'; + }); }); diff --git a/js/treemap.ts b/js/treemap.ts index 3badc47..7fa5fcb 100644 --- a/js/treemap.ts +++ b/js/treemap.ts @@ -1,114 +1,128 @@ const unitBounds = (unit: Unit, width: number, height: number) => { - return { - x: unit.x * width, - y: unit.y * height, - w: unit.w * width, - h: unit.h * height, - }; -} + return { + x: unit.x * width, + y: unit.y * height, + w: unit.w * width, + h: unit.h * height, + }; +}; const BORDER_RADIUS = 5; const PADDING_W = 10; const PADDING_H = 5; const MARGIN = 5; -const ellipsize = (ctx: CanvasRenderingContext2D, text: string, width: number) => { - const ellipsis = "…"; - const padding = PADDING_W * 2; - let m = ctx.measureText(text); +const ellipsize = ( + ctx: CanvasRenderingContext2D, + text: string, + width: number, +) => { + const ellipsis = '…'; + const padding = PADDING_W * 2; + let m = ctx.measureText(text); + if (m.actualBoundingBoxRight + m.actualBoundingBoxLeft + padding <= width) { + return text; + } + let n = 3; + while (true) { + const start = text.length / 4 - n / 4; + const ellipsized = text.slice(0, start) + ellipsis + text.slice(start + n); + m = ctx.measureText(ellipsized); if (m.actualBoundingBoxRight + m.actualBoundingBoxLeft + padding <= width) { - return text; - } - let n = 3; - while (true) { - const start = text.length / 4 - n / 4; - const ellipsized = text.slice(0, start) + ellipsis + text.slice(start + n); - m = ctx.measureText(ellipsized); - if (m.actualBoundingBoxRight + m.actualBoundingBoxLeft + padding <= width) { - return ellipsized; - } - n++; + return ellipsized; } + n++; + } }; -const UNITS = ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; +const UNITS = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; const formatSize = (value: number): string => { - let unit = 0; - while (value >= 1000.0 && unit < UNITS.length - 1) { - // biome-ignore lint/style/noParameterAssign: don't care - value /= 1000.0; - unit += 1; - } - return `${value.toFixed(2)} ${UNITS[unit]}`; + let unit = 0; + while (value >= 1000.0 && unit < UNITS.length - 1) { + // biome-ignore lint/style/noParameterAssign: don't care + value /= 1000.0; + unit += 1; + } + return `${value.toFixed(2)} ${UNITS[unit]}`; }; -const drawTooltip = (ctx: CanvasRenderingContext2D, unit: Unit, width: number, height: number) => { - const style = getComputedStyle(ctx.canvas); - const fontWeight = style.getPropertyValue('--font-weight') || 'normal'; - const fontSize = style.getPropertyValue('--font-size') || '16px'; - const fontFamily = style.getPropertyValue('--font-family') || 'sans-serif'; - const tooltipBackground = style.getPropertyValue('--tooltip-background') || "#fff" - const tooltipColor = style.getPropertyValue('--tooltip-color') || "#000" - ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`; - ctx.textBaseline = "middle"; +const drawTooltip = ( + ctx: CanvasRenderingContext2D, + unit: Unit, + width: number, + height: number, +) => { + const style = getComputedStyle(ctx.canvas); + const fontWeight = style.getPropertyValue('--font-weight') || 'normal'; + const fontSize = style.getPropertyValue('--font-size') || '16px'; + const fontFamily = style.getPropertyValue('--font-family') || 'sans-serif'; + const tooltipBackground = + style.getPropertyValue('--tooltip-background') || '#fff'; + const tooltipColor = style.getPropertyValue('--tooltip-color') || '#000'; + ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`; + ctx.textBaseline = 'middle'; - const {x, y, w, h} = unitBounds(unit, width, height); - const text = ellipsize(ctx, `${unit.name} • ${formatSize(unit.total_code)} • ${unit.fuzzy_match_percent.toFixed(2)}%`, width); - const m = ctx.measureText(text); - const bw = m.actualBoundingBoxRight + m.actualBoundingBoxLeft + PADDING_W * 2; - const bh = m.fontBoundingBoxAscent + m.fontBoundingBoxDescent + PADDING_H * 2; - let bx = x + (w - bw) / 2; - let by = y - bh - MARGIN; - let ay = y; - if (isTouch) { - bx = (width - bw) / 2; - if (y + h / 2 < height / 2) { - // Draw at the bottom - by = height - bh - MARGIN; - } else { - // Draw at the top - by = MARGIN; - } + const { x, y, w, h } = unitBounds(unit, width, height); + const text = ellipsize( + ctx, + `${unit.name} • ${formatSize(unit.total_code)} • ${unit.fuzzy_match_percent.toFixed(2)}%`, + width, + ); + const m = ctx.measureText(text); + const bw = m.actualBoundingBoxRight + m.actualBoundingBoxLeft + PADDING_W * 2; + const bh = m.fontBoundingBoxAscent + m.fontBoundingBoxDescent + PADDING_H * 2; + let bx = x + (w - bw) / 2; + let by = y - bh - MARGIN; + let ay = y; + if (isTouch) { + bx = (width - bw) / 2; + if (y + h / 2 < height / 2) { + // Draw at the bottom + by = height - bh - MARGIN; } else { - if (bx + bw > width) { - bx = width - bw; - } - if (bx < 0) { - bx = 0; - } - if (by < 0) { - // Draw below the box - by = y + h + MARGIN; - ay = y + h; - } - if (by + bh > height) { - // Draw inside the box - by = y + MARGIN; - ay = y; - } + // Draw at the top + by = MARGIN; } - ctx.fillStyle = tooltipBackground; - ctx.beginPath(); - ctx.roundRect(bx, by, bw, bh, BORDER_RADIUS); - if (!isTouch) { - // Arrow - const ax = x + w / 2; - if (ay < by) { - // Top - ctx.moveTo(ax, ay); - ctx.lineTo(ax + MARGIN, by); - ctx.lineTo(ax - MARGIN, by); - } else { - // Bottom - ctx.moveTo(ax, ay); - ctx.lineTo(ax + MARGIN, by + bh); - ctx.lineTo(ax - MARGIN, by + bh); - } + } else { + if (bx + bw > width) { + bx = width - bw; } - ctx.fill(); - ctx.fillStyle = tooltipColor; - ctx.fillText(text, bx + PADDING_W, by + bh / 2); + if (bx < 0) { + bx = 0; + } + if (by < 0) { + // Draw below the box + by = y + h + MARGIN; + ay = y + h; + } + if (by + bh > height) { + // Draw inside the box + by = y + MARGIN; + ay = y; + } + } + ctx.fillStyle = tooltipBackground; + ctx.beginPath(); + ctx.roundRect(bx, by, bw, bh, BORDER_RADIUS); + if (!isTouch) { + // Arrow + const ax = x + w / 2; + if (ay < by) { + // Top + ctx.moveTo(ax, ay); + ctx.lineTo(ax + MARGIN, by); + ctx.lineTo(ax - MARGIN, by); + } else { + // Bottom + ctx.moveTo(ax, ay); + ctx.lineTo(ax + MARGIN, by + bh); + ctx.lineTo(ax - MARGIN, by + bh); + } + } + ctx.fill(); + ctx.fillStyle = tooltipColor; + ctx.fillText(text, bx + PADDING_W, by + bh / 2); }; let hovered: Unit | null = null; @@ -116,152 +130,177 @@ let dirty = false; let isTouch = false; let cachedCanvas: HTMLCanvasElement | null = null; -const setup = (ctx: CanvasRenderingContext2D, ratio: number, width: number, height: number) => { - ctx.setTransform(ratio, 0, 0, ratio, 0, 0); // Scale to device pixel ratio - ctx.clearRect(0, 0, width, height); - ctx.lineWidth = 1; - ctx.strokeStyle = "#000"; -} - -const drawUnits = (ctx: CanvasRenderingContext2D, units: Unit[], width: number, height: number) => { - for (const unit of units) { - const {x, y, w, h} = unitBounds(unit, width, height); - ctx.fillStyle = unit.color; - ctx.beginPath(); - ctx.rect(x, y, w, h); - ctx.fill(); - ctx.stroke(); - } -} - -const draw = (canvas: HTMLCanvasElement, units: Unit[]) => { - const {width, height} = canvas.getBoundingClientRect(); - const ratio = window.devicePixelRatio; - const renderWidth = width * ratio; - const renderHeight = height * ratio; - if (!dirty && canvas.width === renderWidth && canvas.height === renderHeight) { - // Nothing changed - return; - } - dirty = false; - // High DPI support - if (canvas.width !== renderWidth || canvas.height !== renderHeight) { - canvas.width = renderWidth; - canvas.height = renderHeight; - } - // Update cached canvas if needed - if (!cachedCanvas) { - cachedCanvas = document.createElement("canvas"); - } - if (cachedCanvas.width !== renderWidth || cachedCanvas.height !== renderHeight) { - cachedCanvas.width = renderWidth; - cachedCanvas.height = renderHeight; - const cachedCtx = cachedCanvas.getContext("2d"); - if (!cachedCtx) { - return; - } - setup(cachedCtx, ratio, width, height); - drawUnits(cachedCtx, units, width, height); - } - const ctx = canvas.getContext("2d"); - if (!ctx) { - return; - } - // Use 1:1 scale for rendering cached canvas - setup(ctx, 1, renderWidth, renderHeight); - ctx.drawImage(cachedCanvas, 0, 0); - ctx.scale(ratio, ratio); // Restore device scale - if (hovered) { - const {x, y, w, h} = unitBounds(hovered, width, height); - ctx.lineWidth = 2; - ctx.strokeStyle = "#fff"; - ctx.strokeRect(x, y, w, h); - drawTooltip(ctx, hovered, width, height); - } +const setup = ( + ctx: CanvasRenderingContext2D, + ratio: number, + width: number, + height: number, +) => { + ctx.setTransform(ratio, 0, 0, ratio, 0, 0); // Scale to device pixel ratio + ctx.clearRect(0, 0, width, height); + ctx.lineWidth = 1; + ctx.strokeStyle = '#000'; }; -const findUnit = (canvas: HTMLCanvasElement, units: Unit[], clientX: number, clientY: number): Unit | null => { - const {width, height, left, top} = canvas.getBoundingClientRect(); - const mx = clientX - left; - const my = clientY - top; - for (const unit of units) { - const {x, y, w, h} = unitBounds(unit, width, height); - if (mx >= x && mx <= x + w && my >= y && my <= y + h) { - return unit; - } +const drawUnits = ( + ctx: CanvasRenderingContext2D, + units: Unit[], + width: number, + height: number, +) => { + for (const unit of units) { + const { x, y, w, h } = unitBounds(unit, width, height); + ctx.fillStyle = unit.color; + ctx.beginPath(); + ctx.rect(x, y, w, h); + ctx.fill(); + ctx.stroke(); + } +}; + +const draw = (canvas: HTMLCanvasElement, units: Unit[]) => { + const { width, height } = canvas.getBoundingClientRect(); + const ratio = window.devicePixelRatio; + const renderWidth = Math.round(width * ratio); + const renderHeight = Math.round(height * ratio); + if ( + !dirty && + canvas.width === renderWidth && + canvas.height === renderHeight + ) { + // Nothing changed + return; + } + dirty = false; + // High DPI support + if (canvas.width !== renderWidth || canvas.height !== renderHeight) { + canvas.width = renderWidth; + canvas.height = renderHeight; + } + // Update cached canvas if needed + if (!cachedCanvas) { + cachedCanvas = document.createElement('canvas'); + } + if ( + cachedCanvas.width !== renderWidth || + cachedCanvas.height !== renderHeight + ) { + cachedCanvas.width = renderWidth; + cachedCanvas.height = renderHeight; + const cachedCtx = cachedCanvas.getContext('2d'); + if (!cachedCtx) { + return; } - return null; -} + setup(cachedCtx, ratio, width, height); + drawUnits(cachedCtx, units, width, height); + } + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + // Use 1:1 scale for rendering cached canvas + setup(ctx, 1, renderWidth, renderHeight); + ctx.drawImage(cachedCanvas, 0, 0); + ctx.scale(ratio, ratio); // Restore device scale + if (hovered) { + const { x, y, w, h } = unitBounds(hovered, width, height); + ctx.lineWidth = 2; + ctx.strokeStyle = '#fff'; + ctx.strokeRect(x, y, w, h); + drawTooltip(ctx, hovered, width, height); + } +}; + +const findUnit = ( + canvas: HTMLCanvasElement, + units: Unit[], + clientX: number, + clientY: number, +): Unit | null => { + const { width, height, left, top } = canvas.getBoundingClientRect(); + const mx = clientX - left; + const my = clientY - top; + for (const unit of units) { + const { x, y, w, h } = unitBounds(unit, width, height); + if (mx >= x && mx <= x + w && my >= y && my <= y + h) { + return unit; + } + } + return null; +}; const drawTreemap = (id: string, clickable: boolean, units: Unit[]) => { - const canvas = document.getElementById(id) as HTMLCanvasElement; - if (!canvas || !canvas.getContext) { - return; + const canvas = document.getElementById(id) as HTMLCanvasElement; + if (!canvas || !canvas.getContext) { + return; + } + const queueDraw = () => requestAnimationFrame(() => draw(canvas, units)); + const resizeObserver = new ResizeObserver(queueDraw); + resizeObserver.observe(canvas); + const handleHover = ({ + clientX, + clientY, + }: { clientX: number; clientY: number }) => { + const unit = findUnit(canvas, units, clientX, clientY); + if (unit === hovered) { + return; } - const queueDraw = () => requestAnimationFrame(() => draw(canvas, units)); - const resizeObserver = new ResizeObserver(queueDraw); - resizeObserver.observe(canvas); - const handleHover = ({clientX, clientY}: { clientX: number, clientY: number }) => { - const unit = findUnit(canvas, units, clientX, clientY); - if (unit === hovered) { - return; - } - if (clickable) { - canvas.style.cursor = unit ? "pointer" : "default"; - } - hovered = unit; - dirty = true; - queueDraw(); + if (clickable) { + canvas.style.cursor = unit ? 'pointer' : 'default'; } - const handleLeave = () => { - if (!hovered) { - return; - } - if (clickable) { - canvas.style.cursor = "default"; - } - hovered = null; - dirty = true; - queueDraw(); - }; - canvas.addEventListener("mousemove", (e) => { - isTouch = false; - handleHover(e); - }); - canvas.addEventListener("mouseleave", handleLeave); - canvas.addEventListener("touchmove", (e) => { - isTouch = true; - handleHover(e.touches[0]); - }); - canvas.addEventListener("touchend", handleLeave); - canvas.addEventListener("click", ({clientX, clientY}) => { - const unit = findUnit(canvas, units, clientX, clientY); - if (!unit || !unit.name || !clickable) { - return; - } - const url = new URL(window.location.href); - url.searchParams.set("unit", unit.name); - window.location.href = url.toString(); - }); - updatePixelRatio(queueDraw, false); - draw(canvas, units); + hovered = unit; + dirty = true; + queueDraw(); + }; + const handleLeave = () => { + if (!hovered) { + return; + } + if (clickable) { + canvas.style.cursor = 'default'; + } + hovered = null; + dirty = true; + queueDraw(); + }; + canvas.addEventListener('mousemove', (e) => { + isTouch = false; + handleHover(e); + }); + canvas.addEventListener('mouseleave', handleLeave); + canvas.addEventListener('touchmove', (e) => { + isTouch = true; + handleHover(e.touches[0]); + }); + canvas.addEventListener('touchend', handleLeave); + canvas.addEventListener('click', ({ clientX, clientY }) => { + const unit = findUnit(canvas, units, clientX, clientY); + if (!unit || !unit.name || !clickable) { + return; + } + const url = new URL(window.location.href); + url.searchParams.set('unit', unit.name); + window.location.href = url.toString(); + }); + updatePixelRatio(queueDraw, false); + draw(canvas, units); }; let remove: (() => void) | null = null; const updatePixelRatio = (redraw: () => void, now: boolean) => { - if (remove != null) { - remove(); - } - const media = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); - const cb = () => updatePixelRatio(redraw, true); - media.addEventListener("change", cb); - remove = () => { - media.removeEventListener("change", cb); - }; - if (now) { - redraw(); - } + if (remove != null) { + remove(); + } + const media = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); + const cb = () => updatePixelRatio(redraw, true); + media.addEventListener('change', cb); + remove = () => { + media.removeEventListener('change', cb); + }; + if (now) { + redraw(); + } }; window.drawTreemap = drawTreemap; diff --git a/package-lock.json b/package-lock.json index 2270ea2..39c96f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,13 +6,18 @@ "": { "dependencies": { "@picocss/pico": "^2.0.6", + "highlight.js": "^11.11.1", + "react-dom": "^19.1.0", "uplot": "^1.6.32" }, "devDependencies": { "@biomejs/biome": "^1.9.4", "@rsbuild/core": "^1.3.12", + "@rsbuild/plugin-react": "^1.3.0", "@rsbuild/plugin-sass": "^1.3.1", - "@rsbuild/plugin-type-check": "^1.2.1" + "@rsbuild/plugin-type-check": "^1.2.1", + "@types/node": "^22.15.14", + "@types/react-dom": "^19.1.3" } }, "node_modules/@babel/code-frame": { @@ -356,6 +361,20 @@ "node": ">=16.10.0" } }, + "node_modules/@rsbuild/plugin-react": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@rsbuild/plugin-react/-/plugin-react-1.3.0.tgz", + "integrity": "sha512-3LxUoRTGDH9KB5ol5+4792DFJU5w3PKCcbF9QVRbL2Ma31WP/cEETTN7m5uZYyUZN/hOwrxU+ueRNNKD3XtaYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rspack/plugin-react-refresh": "~1.4.1", + "react-refresh": "^0.17.0" + }, + "peerDependencies": { + "@rsbuild/core": "1.x" + } + }, "node_modules/@rsbuild/plugin-sass": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@rsbuild/plugin-sass/-/plugin-sass-1.3.1.tgz", @@ -572,6 +591,26 @@ "node": ">=16.0.0" } }, + "node_modules/@rspack/plugin-react-refresh": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@rspack/plugin-react-refresh/-/plugin-react-refresh-1.4.1.tgz", + "integrity": "sha512-bRALP4qEauvrB7RcMQ95rUHu1dw19V6c6uYukUTpA2OZDyMHTQ9cpEe28kaDwH/xsAuDNuYqnUZOW3NdLO/q3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "error-stack-parser": "^2.1.4", + "html-entities": "^2.6.0" + }, + "peerDependencies": { + "react-refresh": ">=0.10.0 <1.0.0", + "webpack-hot-middleware": "2.x" + }, + "peerDependenciesMeta": { + "webpack-hot-middleware": { + "optional": true + } + } + }, "node_modules/@swc/helpers": { "version": "0.5.17", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", @@ -582,6 +621,37 @@ "tslib": "^2.8.0" } }, + "node_modules/@types/node": { + "version": "22.15.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.14.tgz", + "integrity": "sha512-BL1eyu/XWsFGTtDWOYULQEs4KR0qdtYfCxYAUYRoB7JP7h9ETYLgQTww6kH8Sj2C0pFGgrpM0XKv6/kbIzYJ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.1.3", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.3.tgz", + "integrity": "sha512-dLWQ+Z0CkIvK1J8+wrDPwGxEYFA4RAyHoZPxHVGspYmFVnwGSNT24cGIhFJrtfRnWVuW8X7NO52gCXmhkVUWGQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.3.tgz", + "integrity": "sha512-rJXC08OG0h3W6wDMFxQrZF00Kq6qQvw0djHRdzl3U5DnIERz0MRce3WVc7IS6JYBwtaP/DwYtRRjVlvivNveKg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -721,6 +791,14 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -741,6 +819,16 @@ "node": ">= 4" } }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -792,6 +880,32 @@ "node": ">=8" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, "node_modules/hyperdyperid": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", @@ -1014,6 +1128,38 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1429,6 +1575,12 @@ "node": ">=14.0.0" } }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1439,6 +1591,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "dev": true, + "license": "MIT" + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -1570,6 +1729,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/uplot": { "version": "1.6.32", "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz", diff --git a/package.json b/package.json index 79f1980..a886881 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,17 @@ }, "dependencies": { "@picocss/pico": "^2.0.6", + "highlight.js": "^11.11.1", + "react-dom": "^19.1.0", "uplot": "^1.6.32" }, "devDependencies": { "@biomejs/biome": "^1.9.4", "@rsbuild/core": "^1.3.12", + "@rsbuild/plugin-react": "^1.3.0", "@rsbuild/plugin-sass": "^1.3.1", - "@rsbuild/plugin-type-check": "^1.2.1" + "@rsbuild/plugin-type-check": "^1.2.1", + "@types/node": "^22.15.14", + "@types/react-dom": "^19.1.3" } } diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index 79db12f18405261e6339e08989c23ecb5a51eb9b..8a1a8965e68f8757734f2d677cb9fa2e7ed276fb 100644 GIT binary patch literal 3296 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 Nc)I$ztaD0e0st&L5{&== literal 7037 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 diff --git a/public/favicon-96x96.png b/public/favicon-96x96.png deleted file mode 100644 index 381683a53561fa27e1392ad21f272575604fd2e5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4058 zcmeAS@N?(olHy`uVBq!ia0y~yU`POA4mJh`hDS5XEf^RW7>k44ofy`glX=O&z(2*) z#WAEJ?(N+2p75h$$Is_$W{HTbNq8?JD$TKNMUKfUyX4Lv?Hi@5a~cxzb(&Q912n(I zIaYi-B30eJ_q$Z}`vdRx)uerYch7qJ*)wy0-@e~9dB&bM`Ol`jw=8}($GEssR4`MJ zfAOR43G9)#JXJVCvx}roOg1~3klE03G-Un{_YBc#3k@1wu66~NE_twHjmhs4v4^i7 zKWI#5=j1HB3gFpLBG25i#mkMm%9d@w4LyQ z)peD|Porn&Kd3&jpXpZ`aOTM)M;3mSKN35YL^C(Ocx$kKuXu=Y)Q^LUx&v0$UdfI> zyFYoe6m+9<&vuV?nAn%p?jc3iCE&9X$|DVk@f4^S;^lJ6`Q^x0Ql<(Jm zpZn`{+q+E@-Oig#K%icuXto!{ox$4i~@Ar#Ns!rF~ zrFS@6k?q9lb-Px{6rE7qbW|*wqe+^dw~^oOheO`3mui(i9<~?Tn4M!}HZd_#k$SLh z_q$Dt+Wo5EY;2dQ`|}6w>F{ZqwQS z=hIHP`R*e6syQ*$Z?_hGK5KruiIw})v@QSNon~m*|M8gg>7UQ%>)Td+>G}TN^7E^fC<-^r&&oAU=1tkbg3+UQ zHj&e!^L9%7;5hC0`_pOt)3)F5Ear=HNWIIYy+*;goi8+cPyGM7ON*;lJAQHeGtVS* zlFg3?&2FbQG5@=KL15=>-g69-1zLD4jvwh1PThRgth@35)$sU}C)MZc%&+<6`MNRY zL1x{=tz7Dl9<^`^r+hwZ-rv}MDMjwcC-eI?#`Ds)6~4c=c6QFzt6@bC8re@pWv`to zk!APmg|g+#C6k}r&fkBQ*VWZo;N>SlcbSEo|Ns3y&B!hz zAUx%VZ_g2nCFzkX=YRU?ncK-C4#_7-AF=;bra4`Jv zvwo|x*uB5+=-;28pRb<#g&Dxt5>DQx<9`CPxvq9b*O7-u?FFkE_bb93soHIb{% zCK#2(wQLLaw+(gclaXBf_uudLr*{-Se$p;q=b`#GsbQXNwbs1bIh%c}SF0p^`1N}I z>B#iCOSQhdnb;h4=zOKc@=v#`Uat-9a=XLFuXVWg+fDba;+wqx<%BXk+H%>iThu=1 ze{!Ftn}N%$|2L!Nd|Xw*{VSxgB;;|+Uj6-lHchb0yk+&4p+&`Y@h#qVd4~Pp z?^QoN#I3(Y$jp-?$zXTKl`whn_?nG(3XjXKw8`NSkl7{Su>Q{e|Np9&s$13zrk^S@ zT`tR@VVrg*V55ukGiyZE}czU)%L`HyXz0}hL!S> z`j3zGcc*IR@BKRMR9#Q-d701O-`)-{b^EH*eR%Gz$KCq-Jg(e3!f?>hzuj2oJEv*C z?Y9{LH-k4$x1CdVpzinE<*Tmo9gIFHAjKhFw=Ku@)hjKd^lvgRFPlrrE%0Aa{q1J@ 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 diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..f3e8cb8109a0d4fd8ab3229af096cbf40b7f60fd GIT binary patch literal 1995 zcmeAS@N?(olHy`uVBq!ia0y~yU`POA4rT@hhO2JvAPLR_kH}&M2F5H9W~^R&!;FD} zL9)a(q9iy!t)x7$D3zhSyj(9cFS|H7u^?41zbJk7I~yqm2BxwApAgso|Nk=#KETMx z$i&3N%*@Qf!oteR%ErdV&d$!k!NJAF#m&vl!^6YN%ge{dCmx>FE6j4prELzsHCK%tgNi2rlz5xp{c2Dz7Yiny~XJ>D3@8ICzgML=?(Xj4;o<4&>E-3+?d|R3*wbe7#J876ciE?5*ivB78Vu}5fK>~85I>3 z9UUDT8ygoF7at#=kdTm=n3$ZLoRX4~mX?;Do}Q7Bk(rs9ot>SNlarg9o0pfDpPye) zP*7M{SX5M0TwGjQT3S|CR$g9SQBhG@Sy@$8Rb5?OQ&Uq{S65$O-_X#|*x1ILrI(6#w z>C9UtNpttkxMxu;Gn{CE6Jn92$3#OA;I(+eo>I zY8}Js53{mZo+*6Zc52f58m4I_&8tqnlb=xIyZc$Z_w=Ppcb}RyFYCFg9fRlE1)1K0 z9~QB_S#O#jFtMK1^!w!>4ay5DCa6AGmc3#7GMBq^%opd$i+?b+3Yh31b0nVqHeYkR zRpGVYV#N%te+%ODgfd8eH`fSWDSxZua7fkS z_gy`v6KuDaTc&Mhn$yZ8wm^)%bYXzrEv92v_gCgeg+%rz%O4M`raffLxV416>voZ9w~ z*)CD?n!{y2+XrmB)>TV1r2P}P>UdeqHsNvVkE$8Ud=_n!kI#Sdo;PmG7WRGXty&q| zS3bS_Bw_yMM-I6HY;T^fm&v^llGF8MuEoXsT}-PE{1G{Pu5W7GG){s0OzzCCO$|Z@ z`RO-OAJ!b3xjB=K%{jbHB7rl^q37sIo+!ObuN@7;O6o0I7z*X$mYBZUdu79-(An8i z^R;iW8r<`qX}Q5jCxzk7@`)P)IU1&4_O&$A6WKl^)b*9GMfDtgrh=7c0=lz}m&qo_ zei5vSNT1~q`^7wMX3F9)ZC>$0t=(Qz<0S=`ZC|lpVoK%z#v=ReW`BFPKj-z`wk(-mB)qDo{9*l<8ift^7Apj8wyrjw6jXFN zddugk`POsvL;RAKGWLl*<9n6T^xigV?n#3%n9qK;LoUCgYS=YBio;(M`^ns4tc`HpkX-6QXDRbG5+Ty%EAv;E%d13~qwr>mdK II;Vst0F*~AI{*Lx literal 0 HcmV?d00001 diff --git a/public/web-app-manifest-192x192.png b/public/web-app-manifest-192x192.png index 60ab1203139c53bf0e3a9b1fd4fb1c3e6a456e97..6c92d1d8d046b403a4c3c215db767607d513eed4 100644 GIT binary patch literal 3151 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 O1_n=8KbLh*2~7agLc8+- literal 6796 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 D_St>? literal 20289 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