diff --git a/Cargo.toml b/Cargo.toml index e53d526..7ebbe73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" [dependencies] csv = "1.4.0" dioxus = { version = "0.7.2", features = ["router"] } +dioxus-sdk-time = "0.7.0" dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false } reqwest = "0.12.25" strum = { version = "0.27.2", default-features = false, features = ["derive"] } diff --git a/src/components/toast/style.css b/assets/dx-components-theme.css similarity index 52% rename from src/components/toast/style.css rename to assets/dx-components-theme.css index 6731a46..b893d23 100644 --- a/src/components/toast/style.css +++ b/assets/dx-components-theme.css @@ -23,7 +23,7 @@ display: flex; overflow: hidden; width: 18rem; - height: 4rem; + height: 5rem; box-sizing: border-box; align-items: center; justify-content: space-between; @@ -178,3 +178,171 @@ .toast-close:hover { color: var(--secondary-color-1); } + +.skeleton { + border-radius: 0.375rem; + animation: skeleton-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + background-color: var(--primary-color-7); +} + +@keyframes skeleton-pulse { + 0%, + 100% { + opacity: 1; + } + + 61.8% { + opacity: 0.5; + } +} + +.select { + position: relative; +} + +.select-trigger { + position: relative; + display: flex; + box-sizing: border-box; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 0.25rem; + padding: 8px 12px; + border: none; + border-radius: 0.5rem; + border-radius: calc(0.5rem); + background: none; + background: var(--primary-color-3); + box-shadow: inset 0 0 0 1px var(--primary-color-7); + color: var(--secondary-color-4); + cursor: pointer; + gap: 0.25rem; + transition: background-color 100ms ease-out; +} + +.select-trigger span[data-placeholder="true"] { + color: var(--secondary-color-5); +} + +.select[data-state="open"] .select-trigger { + pointer-events: none; +} + +.select-expand-icon { + width: 20px; + height: 20px; + fill: none; + stroke: var(--secondary-color-5); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; +} + +.select-check-icon { + width: 1rem; + height: 1rem; + fill: none; + stroke: var(--secondary-color-5); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; +} + +.select[data-disabled="true"] .select-trigger { + color: var(--secondary-color-5); + cursor: not-allowed; +} + +.select-trigger:hover:not([data-disabled="true"]), +.select-trigger:focus-visible { + background: var(--light, var(--primary-color-4)) + var(--dark, var(--primary-color-5)); + color: var(--secondary-color-1); + outline: none; +} + +.select-list { + position: absolute; + z-index: 1000; + top: 100%; + left: 0; + min-width: 100%; + box-sizing: border-box; + padding: 0.25rem; + border-radius: 0.5rem; + margin-top: 0.25rem; + background: var(--primary-color-5); + box-shadow: inset 0 0 0 1px var(--primary-color-7); + opacity: 0; + pointer-events: none; + transform-origin: top; + will-change: transform, opacity; +} + +.select-list[data-state="closed"] { + animation: select-list-animate-out 150ms ease-in forwards; + pointer-events: none; +} + +@keyframes select-list-animate-out { + 0% { + opacity: 1; + transform: scale(1) translateY(0); + } + + 100% { + opacity: 0; + transform: scale(0.95) translateY(-2px); + } +} + +.select-list[data-state="open"] { + animation: select-list-animate-in 150ms ease-out forwards; + pointer-events: auto; +} + +@keyframes select-list-animate-in { + 0% { + opacity: 0; + transform: scale(0.95) translateY(-2px); + } + + 100% { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.select-option { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-radius: calc(0.5rem - 0.25rem); + cursor: pointer; + font-size: 14px; +} + +.select-option[data-disabled="true"] { + color: var(--secondary-color-5); + cursor: not-allowed; +} + +.select-option:hover:not([data-disabled="true"]), +.select-option:focus-visible { + background: var(--primary-color-7); + color: var(--secondary-color-1); + outline: none; +} + +.select-group-label { + padding: 4px 12px; + color: var(--secondary-color-5); + font-size: 0.75rem; +} + +[data-disabled="true"] { + cursor: not-allowed; + opacity: 0.5; +} diff --git a/src/components/mod.rs b/src/components/mod.rs index 4a7361d..b4a26a9 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,3 +1,4 @@ // AUTOGENERTED Components module pub mod select; pub mod toast; +pub mod skeleton; diff --git a/src/components/select/component.rs b/src/components/select/component.rs index 65d58e7..85dddc8 100644 --- a/src/components/select/component.rs +++ b/src/components/select/component.rs @@ -7,7 +7,6 @@ use dioxus_primitives::select::{ #[component] pub fn Select(props: SelectProps) -> Element { rsx! { - document::Link { rel: "stylesheet", href: asset!("./style.css") } select::Select { class: "select", value: props.value, diff --git a/src/components/select/style.css b/src/components/select/style.css deleted file mode 100644 index 32d98e7..0000000 --- a/src/components/select/style.css +++ /dev/null @@ -1,150 +0,0 @@ -.select { - position: relative; -} - -.select-trigger { - position: relative; - display: flex; - box-sizing: border-box; - flex-direction: row; - align-items: center; - justify-content: space-between; - padding: 0.25rem; - padding: 8px 12px; - border: none; - border-radius: 0.5rem; - border-radius: calc(0.5rem); - background: none; - background: var(--primary-color-3); - box-shadow: inset 0 0 0 1px var(--primary-color-7); - color: var(--secondary-color-4); - cursor: pointer; - gap: 0.25rem; - transition: background-color 100ms ease-out; -} - -.select-trigger span[data-placeholder="true"] { - color: var(--secondary-color-5); -} - -.select[data-state="open"] .select-trigger { - pointer-events: none; -} - -.select-expand-icon { - width: 20px; - height: 20px; - fill: none; - stroke: var(--secondary-color-5); - stroke-linecap: round; - stroke-linejoin: round; - stroke-width: 2; -} - -.select-check-icon { - width: 1rem; - height: 1rem; - fill: none; - stroke: var(--secondary-color-5); - stroke-linecap: round; - stroke-linejoin: round; - stroke-width: 2; -} - -.select[data-disabled="true"] .select-trigger { - color: var(--secondary-color-5); - cursor: not-allowed; -} - -.select-trigger:hover:not([data-disabled="true"]), -.select-trigger:focus-visible { - background: var(--light, var(--primary-color-4)) - var(--dark, var(--primary-color-5)); - color: var(--secondary-color-1); - outline: none; -} - -.select-list { - position: absolute; - z-index: 1000; - top: 100%; - left: 0; - min-width: 100%; - box-sizing: border-box; - padding: 0.25rem; - border-radius: 0.5rem; - margin-top: 0.25rem; - background: var(--primary-color-5); - box-shadow: inset 0 0 0 1px var(--primary-color-7); - opacity: 0; - pointer-events: none; - transform-origin: top; - will-change: transform, opacity; -} - -.select-list[data-state="closed"] { - animation: select-list-animate-out 150ms ease-in forwards; - pointer-events: none; -} - -@keyframes select-list-animate-out { - 0% { - opacity: 1; - transform: scale(1) translateY(0); - } - - 100% { - opacity: 0; - transform: scale(0.95) translateY(-2px); - } -} - -.select-list[data-state="open"] { - animation: select-list-animate-in 150ms ease-out forwards; - pointer-events: auto; -} - -@keyframes select-list-animate-in { - 0% { - opacity: 0; - transform: scale(0.95) translateY(-2px); - } - - 100% { - opacity: 1; - transform: scale(1) translateY(0); - } -} - -.select-option { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 12px; - border-radius: calc(0.5rem - 0.25rem); - cursor: pointer; - font-size: 14px; -} - -.select-option[data-disabled="true"] { - color: var(--secondary-color-5); - cursor: not-allowed; -} - -.select-option:hover:not([data-disabled="true"]), -.select-option:focus-visible { - background: var(--primary-color-7); - color: var(--secondary-color-1); - outline: none; -} - -.select-group-label { - padding: 4px 12px; - color: var(--secondary-color-5); - font-size: 0.75rem; -} - -[data-disabled="true"] { - cursor: not-allowed; - opacity: 0.5; -} diff --git a/src/components/skeleton/component.rs b/src/components/skeleton/component.rs new file mode 100644 index 0000000..0978e80 --- /dev/null +++ b/src/components/skeleton/component.rs @@ -0,0 +1,8 @@ +use dioxus::prelude::*; + +#[component] +pub fn Skeleton(#[props(extends=GlobalAttributes)] attributes: Vec) -> Element { + rsx! { + div { class: "skeleton", ..attributes } + } +} diff --git a/src/components/skeleton/mod.rs b/src/components/skeleton/mod.rs new file mode 100644 index 0000000..9a8ae55 --- /dev/null +++ b/src/components/skeleton/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; \ No newline at end of file diff --git a/src/components/toast/component.rs b/src/components/toast/component.rs index 4b6878f..8ae5fcd 100644 --- a/src/components/toast/component.rs +++ b/src/components/toast/component.rs @@ -4,7 +4,6 @@ use dioxus_primitives::toast::{self, ToastProviderProps}; #[component] pub fn ToastProvider(props: ToastProviderProps) -> Element { rsx! { - document::Link { rel: "stylesheet", href: asset!("./style.css") } toast::ToastProvider { default_duration: props.default_duration, max_toasts: props.max_toasts, diff --git a/src/landing.rs b/src/landing.rs index e16f032..9b0ee91 100644 --- a/src/landing.rs +++ b/src/landing.rs @@ -1,17 +1,40 @@ +use crate::components::{select::*, skeleton::*}; +use crate::loader::{Loader, Suspense}; +use csv::{ReaderBuilder, StringRecord}; use dioxus::prelude::*; - -use std::{collections::HashMap, thread::current}; -use std::str::FromStr; - -use csv::StringRecord; +use dioxus_primitives::toast::{ToastOptions, use_toast}; +use dioxus_sdk_time::*; use std::f32::consts::PI; - +use std::str::FromStr; +use std::fmt; +use std::time::Duration; +use std::{collections::HashMap, thread::current}; use strum::{EnumCount, IntoEnumIterator}; -use crate::components::select::*; - const PAGE_SIZE: usize = 100_usize; +// Wrapper for displaying a duration in h:m:s (integer seconds, rounded down) +struct HMSDuration(Duration); +impl fmt::Display for HMSDuration { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let mut ms = self.0.as_millis(); + let hours = ms / 3_600_000; + ms %= 3_600_000; + let mins = ms / 60_000; + ms %= 60_000; + let secs = ms / 1000; + ms %= 1000; + + if hours > 0 { + write!(f, "{}:{:02}:{:02}.{}", hours, mins, secs, ms) + } else if mins > 0 { + write!(f, "0:{}:{:02}.{}", mins, secs, ms) + } else { + write!(f, "0:0:{}.{}", secs, ms) + } + } +} + #[derive( Debug, Eq, @@ -58,9 +81,162 @@ fn percentage(count: usize, total: f32) -> f32 { (count as f32 * 100.0) / total } +#[component] +fn LandingPlaceholder() -> Element { + let stats_cards = TestStatus::iter().map(|s| { + rsx! { + StatCardPlaceholder { + name: s.to_string(), + color: s.color().to_string(), + count: 0, + stat: 0.0_f32, + } + } + }); + + let statuses = TestStatus::iter().enumerate().map(|(i, s)| { + rsx! { + SelectOption::> { index: i, value: s, text_value: "{s}", + {format!("{} {s}", s.emoji())} + SelectItemIndicator {} + } + } + }); + + rsx! { + div { + class: "flex flex-col space-y-4 rounded-3xl p-4 pt-8 w-full h-fit shadow-xl shadow-slate-950", + style: "background: linear-gradient(145deg, #020617 0, #02081f 60%, #020617 100%);", + div { class: "flex flex-row space-x-4", + div { class: "border-1 border-[#38bdf8] bg-[#38bdf8]/15 text-slate-400 w-fit rounded-3xl py-1 px-2 flex flex-row space-x-1 items-center", + div { class: "bg-[#38bdf8] rounded-full size-3" } + p { class: "text-xs", "Total: 0 tests" } + } + div { class: "border-1 border-[#22c55e] bg-[#22c55e]/15 text-slate-400 w-fit rounded-3xl py-1 px-2 flex flex-row space-x-1 items-center", + div { class: "bg-[#22c55e] rounded-full size-3" } + p { class: "text-xs", "Filtered: 0 tests" } + } + } + div { class: "grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4", + {stats_cards} + } + div { class: "mx-auto size-[200px]", + Skeleton { class: "skeleton size-[200px] !rounded-full" } + } + div { class: "mt-12 w-full flex flex-col md:flex-row justify-between text-gray-400 text-sm gap-4 items-center", + Skeleton { class: "hidden lg:block skeleton w-full h-9" } + p { class: "my-auto w-fit text-nowrap", "Page 1 of 1" } + div { class: "hidden lg:block", + PaginationPlaceholder { small: false } + } + div { class: "block lg:hidden", + PaginationPlaceholder { small: true } + } + } + Skeleton { class: "block lg:hidden skeleton w-full h-9" } + div { class: "w-full bg-gray-900 overflow-hidden border-1 border-slate-700 rounded-lg text-gray-400", + table { class: "w-full border-collapse border-spacing-0", + tr { + class: "border-b-1 border-slate-700", + style: "background: radial-gradient(circle at top, rgba(56, 189, 248, 0.1), rgba(15, 23, 42, 1));", + th { class: "text-left uppercase bold whitespace-nowrap py-2 px-3", + "Test name" + } + th { class: "text-left uppercase bold whitespace-nowrap py-2 px-3", + "Duration (H:M:S.MS)" + } + th { class: "uppercase bold whitespace-nowrap py-2 px-3", + Select::> { placeholder: "STATUS", + SelectTrigger { + class: "select-trigger mx-auto w-fit !bg-transparent !shadow-none !text-gray-400 cursor-pointer uppercase", + aria_label: "Select Trigger", + SelectValue { class: "!bg-transparent !shadow-none !text-gray-400" } + } + SelectList { aria_label: "Select status", + SelectGroup { + {statuses} + SelectOption::> { + index: TestStatus::COUNT, + value: None, + text_value: "Status", + "🔄 None" + SelectItemIndicator {} + } + } + } + } + } + } + for _ in 0..12 { + tr { class: "text-sm hover:bg-[#38bef7]/5", + td { class: "py-2 px-3 w-full", + Skeleton { class: "skeleton w-[60%] h-6" } + } + td { class: "py-2", + Skeleton { class: "skeleton w-24 h-6 mx-auto" } + } + td { class: "py-2", + Skeleton { class: "skeleton w-16 h-9 !rounded-3xl mx-auto" } + } + } + } + } + } + } + } +} + #[component] pub fn Landing() -> Element { - let result = use_context::>>(); + let toast = use_toast(); + + let resource = use_resource(move || { + toast.info( + "Loading...".to_string(), + ToastOptions::new() + .description("Loading CTS results") + .duration(Duration::from_secs(12)) + ); + get_results() + }).load_with(rsx!{ + LandingPlaceholder {} + })?; + let mut result = use_signal(Vec::::new); + + use_effect(move || match &*resource.read() { + Ok(results) => { + let mut reader = ReaderBuilder::new().from_reader(results.as_bytes()); + let records = match reader + .into_records() + .collect::, csv::Error>>() + { + Ok(res) => res, + Err(e) => { + error!("Failed to parse results: {e}"); + toast.error( + "Error".to_string(), + ToastOptions::new() + .description("Failed to parse CTS results") + ); + Vec::new() + } + }; + result.set(records); + toast.success( + "Success".to_string(), + ToastOptions::new() + .description("Successfully loaded CTS results") + ); + }, + Err(e) => { + error!("Failed to fetch results: {e}"); + toast.error( + "Error".to_string(), + ToastOptions::new() + .description("Failed to fetch CTS results") + ); + } + }); let global_stats = use_memo(move || result .read() @@ -86,59 +262,113 @@ pub fn Landing() -> Element { } }); - let mut filter: Signal> = use_signal(|| None); - let mut filtered_count = use_signal(|| 0_usize); - let mut current_page = use_memo(move || 0_usize); - let mut page_count = use_memo(move || filtered_count().max(PAGE_SIZE - 1) / PAGE_SIZE); + let mut search_input: Signal> = use_signal(|| None); + let mut search_name: Signal> = use_signal(|| None); + let mut filter: Signal> = use_signal(|| None); + let filtered_count = use_memo(move || { + let f = filter(); + let search = search_name(); + let rows = result.read(); - let page = use_memo(move || { - if let Some(f) = filter() { - current_page.set(0); - let mut count = 0_usize; - filtered_count.set(0_usize); - let shift = (current_page() * PAGE_SIZE); - result - .read() - .iter() - .filter(|r| { - if let Ok(status) = TestStatus::from_str(&r[1]) && status == f { - filtered_count += 1; - if *filtered_count.peek() < shift { - return false; - } - count += 1; - if count >= PAGE_SIZE { - false - } else { - true - } - } else { - false - } - }) - .cloned() - .collect() - } else { - filtered_count.set(total() as usize); - let start = current_page() * PAGE_SIZE; - let end = result.read().len().min((current_page() * PAGE_SIZE) + PAGE_SIZE); - result.read()[start..end].to_vec() + current_page.set(0_usize); + + let mut total = 0_usize; + + for r in rows.iter() { + let name = &r[0]; + + if let Some(ref s) = search { + if !name.contains(s) { + continue; + } + } + + if let Some(wanted) = f { + let Ok(status) = TestStatus::from_str(&r[1]) else { continue }; + if status != wanted { + continue; + } + } + + total += 1; } + + total + }); + let mut page_count = use_memo(move || filtered_count().max(PAGE_SIZE - 1) / PAGE_SIZE); + let page = use_memo(move || { + let _ = *filtered_count.read(); + + let f = filter(); + let search = search_name(); + let shift = current_page() * PAGE_SIZE; + + let rows = result.read(); + + let mut idx = 0_usize; + let mut out = Vec::with_capacity(PAGE_SIZE); + + for r in rows.iter() { + let name = &r[0]; + + if let Some(ref s) = search { + if !name.contains(s) { + continue; + } + } + + if let Some(wanted) = f { + let Ok(status) = TestStatus::from_str(&r[1]) else { continue }; + if status != wanted { + continue; + } + } + + if idx >= shift && idx < shift + PAGE_SIZE { + out.push(r.clone()); + if out.len() == PAGE_SIZE { + break; + } + } + + idx += 1; + } + + out }); let statuses = TestStatus::iter().enumerate().map(|(i, s)| { rsx! { - SelectOption::> { - index: i, - value: s, - text_value: "{s}", + SelectOption::> { index: i, value: s, text_value: "{s}", {format!("{} {s}", s.emoji())} SelectItemIndicator {} } } }); + let mut search_timeout: Signal> = use_signal(|| None); + let timeout = use_timeout(Duration::from_secs(1), move |()| { + search_timeout.set(None); + search_name.set(search_input()); + }); + + let onsearch_input = move |event: FormEvent| { + if event.value().is_empty() { + search_input.set(None); + } else { + search_input.set(Some(event.value())); + } + }; + + let onsearch_keyup = move |_| { + if let Some(handle) = *search_timeout.read() { + handle.cancel(); + } + let handle = timeout.action(()); + search_timeout.set(Some(handle)); + }; + rsx! { div { class: "flex flex-col space-y-4 rounded-3xl p-4 pt-8 w-full h-fit shadow-xl shadow-slate-950", @@ -146,15 +376,11 @@ pub fn Landing() -> Element { div { class: "flex flex-row space-x-4", div { class: "border-1 border-[#38bdf8] bg-[#38bdf8]/15 text-slate-400 w-fit rounded-3xl py-1 px-2 flex flex-row space-x-1 items-center", div { class: "bg-[#38bdf8] rounded-full size-3" } - p { class: "text-xs", - "Total: {total} tests" - } + p { class: "text-xs", "Total: {total} tests" } } div { class: "border-1 border-[#22c55e] bg-[#22c55e]/15 text-slate-400 w-fit rounded-3xl py-1 px-2 flex flex-row space-x-1 items-center", div { class: "bg-[#22c55e] rounded-full size-3" } - p { class: "text-xs", - "Filtered: {filtered_count} tests" - } + p { class: "text-xs", "Filtered: {filtered_count} tests" } } } div { class: "grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4", @@ -163,55 +389,43 @@ pub fn Landing() -> Element { div { class: "mx-auto size-[200px]", StatsPieChart { stats: global_stats, total } } - div { class: "mt-12 w-full flex flex-row justify-between text-gray-400 text-sm", - p { "Page {current_page() + 1} of {page_count() + 1}" } - div { class: "flex flex-row space-x-2", - button { class: "pagination-button", - disabled: current_page() == 0, - onclick: move |_| current_page.set(0_usize), - "First" - } - button { class: "pagination-button", - disabled: current_page() == 0, - onclick: move |_| current_page -= 1, - "Prev" - } - - if current_page() > 2 { - p { "..." } - } - - for i in ((current_page() as i32 - 2).max(0) as usize)..=(current_page() + 2).min(page_count()) { - button { class: "pagination-button", - "data-active": i == current_page(), - onclick: move |_| current_page.set(i), - "{i + 1}" - } - } - - if current_page() < (page_count() as i32 - 2).max(0) as usize { - p { "..." } - } - - button { class: "pagination-button", - disabled: current_page() >= page_count(), - onclick: move |_| current_page += 1, - "Next" - } - button { class: "pagination-button", - disabled: current_page() >= page_count(), - onclick: move |_| current_page.set(page_count()), - "Last" - } + div { class: "mt-12 w-full flex flex-col md:flex-row justify-between text-gray-400 text-sm gap-4 items-center", + input { + class: "hidden lg:block w-full border-1 border-gray-700 px-3 py-1 rounded-lg text-sm", + style: "background: radial-gradient(circle at top, rgba(56, 189, 248, 0.1), rgba(15, 23, 42, 1));", + r#type: "search", + placeholder: "Search tests...", + oninput: onsearch_input, + onkeyup: onsearch_keyup + } + p { class: "my-auto w-fit text-nowrap", + "Page {current_page() + 1} of {page_count() + 1}" + } + div { class: "hidden lg:block", + Pagination { current_page, page_count, small: false } + } + div { class: "block lg:hidden", + Pagination { current_page, page_count, small: true } } } - div { class: "w-full bg-gray-900 overflow-hidden border-1 border-slate-700 rounded-lg text-gray-400", + input { + class: "block lg:hidden w-full border-1 border-gray-700 px-3 py-1 rounded-lg text-sm", + style: "background: radial-gradient(circle at top, rgba(56, 189, 248, 0.1), rgba(15, 23, 42, 1));", + r#type: "search", + placeholder: "Search tests...", + oninput: onsearch_input, + onkeyup: onsearch_keyup + } + div { class: "w-full bg-gray-900 overflow-auto border-1 border-slate-700 rounded-lg text-gray-400", table { class: "w-full border-collapse border-spacing-0", tr { class: "border-b-1 border-slate-700", style: "background: radial-gradient(circle at top, rgba(56, 189, 248, 0.1), rgba(15, 23, 42, 1));", th { class: "text-left uppercase bold whitespace-nowrap py-2 px-3", - "Test name", + "Test name" + } + th { class: "text-left uppercase bold whitespace-nowrap py-2 px-3", + "Duration (H:M:S.MS)" } th { class: "uppercase bold whitespace-nowrap py-2 px-3", Select::> { @@ -222,8 +436,7 @@ pub fn Landing() -> Element { aria_label: "Select Trigger", SelectValue { class: "!bg-transparent !shadow-none !text-gray-400" } } - SelectList { - aria_label: "Select status", + SelectList { aria_label: "Select status", SelectGroup { {statuses} SelectOption::> { @@ -240,8 +453,15 @@ pub fn Landing() -> Element { } for test in page.iter() { tr { class: "text-sm hover:bg-[#38bef7]/5", + td { class: "py-2 px-3", "{&test[0]}" } td { class: "py-2 px-3", - "{&test[0]}" + p { class: "mx-auto w-fit", + if let Ok(duration) = test[2].parse::() { + "{HMSDuration(Duration::from_secs_f32(duration))}" + } else { + "Invalid data" + } + } } td { class: "py-2 px-3", StatusBadge { @@ -259,6 +479,23 @@ pub fn Landing() -> Element { } } +#[component] +fn StatCardPlaceholder(name: String, color: String, count: usize, stat: f32) -> Element { + rsx! { + div { class: "rounded-2xl p-4 border-1 border-slate-800 shadow-xl shadow-[#02081f] w-full h-fit bg-[#090f21] flex flex-col space-y-2", + div { class: "flex flex-row space-x-2 flex items-center", + div { + class: "rounded-full size-4", + style: format!("background-color: {color};"), + } + h3 { class: "text-sm text-gray-300", "{name}" } + } + Skeleton { class: "skeleton w-32 h-7" } + Skeleton { class: "skeleton w-24 h-4" } + } + } +} + #[component] fn StatCard(name: String, color: String, count: usize, stat: f32) -> Element { rsx! { @@ -268,18 +505,14 @@ fn StatCard(name: String, color: String, count: usize, stat: f32) -> Element { class: "rounded-full size-4", style: format!("background-color: {color};"), } - h3 { class: "text-sm text-gray-300", - "{name}" - } + h3 { class: "text-sm text-gray-300", "{name}" } } h2 { class: "text-2xl font-bold", style: format!("color: {color};"), - "{count}", - } - p { class: "text-xs text-gray-400", - "{stat:.1}% of total" + "{count}" } + p { class: "text-xs text-gray-400", "{stat:.1}% of total" } } } } @@ -298,11 +531,13 @@ fn StatusBadge(status: Option) -> Element { rsx! { div { class: "mx-auto border-1 py-1 px-3 rounded-3xl text-xs w-fit select-none", - style: format!(r#" - background-color: {color}0F; - color: {color}; - border-color: {color}; - "#), + style: format!( + r#" + background-color: {color}0F; + color: {color}; + border-color: {color}; + "#, + ), "{name}" } } @@ -368,10 +603,138 @@ fn StatsPieChart(stats: ReadSignal>, total: ReadSigna }); rsx! { - svg { - class: "max-w-[200px] h-auto", - view_box: "0 0 200 200", - {paths} + svg { class: "max-w-[200px] h-auto", view_box: "0 0 200 200", {paths} } + } +} + +#[component] +fn Pagination( + current_page: Memo, + page_count: ReadSignal, + small: bool, +) -> Element { + let range = if small { 1_usize } else { 2_usize }; + + rsx! { + div { + class: "flex flex-row data-[is-small=true]:flow-col gap-2 max-w-screen overflow-x-auto", + "data-is-small": small, + div { class: "flex flex-row gap-2", + button { + class: "pagination-button", + disabled: current_page() == 0, + onclick: move |_| current_page.set(0_usize), + if small { + "<<" + } else { + "First" + } + } + if !small { + button { + class: "pagination-button", + disabled: current_page() == 0, + onclick: move |_| current_page -= 1, + "Prev" + } + } + } + + div { class: "flex flex-row gap-2", + if current_page() > range { + p { "..." } + } + + for i in ((current_page() as i32 - range as i32).max(0) + as usize)..=(current_page() + range).min(page_count()) + { + button { + class: "pagination-button", + "data-active": i == current_page(), + onclick: move |_| current_page.set(i), + "{i + 1}" + } + } + + if current_page() < (page_count() as i32 - range as i32).max(0) as usize { + p { "..." } + } + } + + div { class: "flex flex-row gap-2", + if !small { + button { + class: "pagination-button", + disabled: current_page() >= page_count(), + onclick: move |_| current_page += 1, + "Next" + } + } + button { + class: "pagination-button", + disabled: current_page() >= page_count(), + onclick: move |_| current_page.set(page_count()), + if small { + ">>" + } else { + "Last" + } + } + } } } } + +#[component] +fn PaginationPlaceholder(small: bool) -> Element { + let range = if small { 1_usize } else { 2_usize }; + + rsx! { + div { + class: "flex flex-row data-[is-small=true]:flow-col gap-2 max-w-screen overflow-x-auto", + "data-is-small": small, + div { class: "flex flex-row gap-2", + button { class: "pagination-button", disabled: true, + if small { + "<<" + } else { + "First" + } + } + if !small { + button { class: "pagination-button", disabled: true, "Prev" } + } + } + + div { class: "flex flex-row gap-2", + for i in 1..(if small { 3 } else { 4 }) { + button { class: "pagination-button", disabled: true, "{i}" } + } + + p { "..." } + } + + div { class: "flex flex-row gap-2", + if !small { + button { class: "pagination-button", disabled: true, "Next" } + } + button { class: "pagination-button", disabled: true, + if small { + ">>" + } else { + "Last" + } + } + } + } + } +} + +async fn get_results() -> Result { + Ok( + reqwest::get(format!("{}/assets/results.csv", std::env!("URL"))) + .await? + .text() + .await?, + ) +} diff --git a/src/loader.rs b/src/loader.rs new file mode 100644 index 0000000..364d424 --- /dev/null +++ b/src/loader.rs @@ -0,0 +1,61 @@ +use dioxus::prelude::*; + +/// Default placeholder for loading state +#[component] +pub fn LoadingPlaceholder(message: String) -> Element { + rsx! { + div { class: "rounded-radius h-[50vh] w-1/2 bg-foreground/20 animate-pulse text-foreground flex justify-center items-center place-self-center mt-14 mx-auto", + h3 { class: "h3", {message} } + } + } +} + +#[derive(Default, Clone)] +struct SuspenseContextPlaceholder { + element: Option, +} + +/// Trait to create a suspense with a default loading placeholder +pub trait Loader { + fn load( + &self, + message: impl ToString, + ) -> Result> /* wtf Dioxus ??? */>, RenderError>; + fn load_with( + &self, + element: Element, + ) -> Result>>, RenderError>; +} + +impl Loader for Resource { + fn load( + &self, + message: impl ToString, + ) -> Result>>, RenderError> { + let mut context = use_context::>(); + context.write().element = Some(rsx! { + LoadingPlaceholder { message: message.to_string() } + }); + self.suspend() + } + + fn load_with( + &self, + element: Element, + ) -> Result>>, RenderError> { + let mut context = use_context::>(); + context.write().element = Some(element); + self.suspend() + } +} + +#[component] +pub fn Suspense(children: Element) -> Element { + let context = use_context_provider(|| Signal::new(SuspenseContextPlaceholder::default())); + + rsx! { + SuspenseBoundary { fallback: move |_| { context.read().clone().element.unwrap_or_else(|| rsx! { "Loading..." }) }, + {children} + } + } +} diff --git a/src/main.rs b/src/main.rs index d97ffbc..826e1db 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,81 +1,38 @@ -use dioxus::prelude::*; +#![allow(non_snake_case)] -use csv::{ReaderBuilder, StringRecord}; +use dioxus::prelude::*; +use std::time::Duration; mod components; mod landing; +mod loader; mod navbar; mod routes; use crate::routes::Route; use dioxus_primitives::toast::{ToastOptions, ToastProvider, use_toast}; -use std::time::Duration; const FAVICON: Asset = asset!("/assets/favicon.ico"); const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css"); +const COMPONENTS_CSS: Asset = asset!("/assets/dx-components-theme.css"); const RESULT: Asset = asset!( "/assets/results.csv", AssetOptions::builder().with_hash_suffix(false) ); fn main() { - dioxus::launch(AppWrapper); -} - -#[component] -fn AppWrapper() -> Element { - rsx! { - ToastProvider { App{} } - } + dioxus::launch(App); } #[component] fn App() -> Element { - let toast = use_toast(); - - let resource = use_resource(move || get_results()); - let mut result = use_context_provider(|| Signal::new(Vec::::new())); - - use_effect(move || match &*resource.read() { - Some(Ok(results)) => { - let mut reader = ReaderBuilder::new().from_reader(results.as_bytes()); - let records = match reader - .into_records() - .collect::, csv::Error>>() - { - Ok(res) => res, - Err(e) => { - error!("Failed to parse results: {e}"); - toast.error( - "Error".to_string(), - ToastOptions::new() - .description(format!("Failed to parse CTS results data: {e}")) - .duration(Duration::from_secs(2)) - .permanent(false), - ); - Vec::new() - } - }; - result.set(records); - } - Some(Err(e)) => { - error!("Failed to fetch results: {e}"); - toast.error( - "Error".to_string(), - ToastOptions::new() - .description(format!("Failed to fetch CTS results data: {e}")) - .duration(Duration::from_secs(2)) - .permanent(false), - ); - } - None => {} - }); - rsx! { document::Link { rel: "icon", href: FAVICON } document::Link { rel: "stylesheet", href: TAILWIND_CSS } - document::Style { " + document::Link { rel: "stylesheet", href: COMPONENTS_CSS } + document::Style { + " :root {{ --primary-color: #000; --primary-color-1: #020618; @@ -95,30 +52,28 @@ fn App() -> Element { --secondary-color-6: #5d5d5d; --focused-border-color: #2b7fff; - --primary-success-color: #02271c; + --primary-success-color: #1A7D35; --secondary-success-color: #b6fae3; --primary-warning-color: #342203; --secondary-warning-color: #feeac7; --primary-error-color: #a22e2e; --secondary-error-color: #9b1c1c; - --contrast-error-color: var(--secondary-color-3)); - --primary-info-color: var(--primary-color-5)); - --secondary-info-color: var(--primary-color-7)); + --contrast-error-color: var(--secondary-color-3); + --primary-info-color: var(--primary-color-5); + --secondary-info-color: var(--primary-color-7); }} - "} - div { - class: "text-white min-h-screen", - style: "background: radial-gradient(circle at top, #1e293b 0, #020617 45%, #000 100%);", - Router:: {} + + body {{ + background-color: #000; + }} + " + } + ToastProvider { default_duration: Duration::from_secs(4), max_toasts: 1_usize, + div { + class: "text-white min-h-screen", + style: "background: radial-gradient(circle at top, #1e293b 0, #020617 45%, #000 100%);", + Router:: {} + } } } } - -async fn get_results() -> Result { - Ok( - reqwest::get(format!("{}/assets/results.csv", std::env!("URL"))) - .await? - .text() - .await?, - ) -} diff --git a/src/navbar.rs b/src/navbar.rs index f873262..40e9e9a 100644 --- a/src/navbar.rs +++ b/src/navbar.rs @@ -1,34 +1,35 @@ use dioxus::prelude::*; -use crate::routes::Route; +use crate::{loader::Suspense, routes::Route}; #[component] pub fn Navbar() -> Element { rsx! { div { class: "mx-auto container mb-12 py-2 px-6 sm:px-0 flex flex-row justify-between", - Link { class: "flex flex-row h-16 text-4xl md:text-5xl select-none cursor-pointer", + Link { + class: "flex flex-row h-16 text-4xl md:text-5xl select-none cursor-pointer", to: Route::Landing {}, VulkanVSvg {} p { class: "hidden md:block mt-auto font-bold -ml-3.5 text-[#9d1b1f]", "ulkan" } - p { class: "mt-auto ml-2 font-bold text-gray-300", - "CTS Report" - } + p { class: "mt-auto ml-2 font-bold text-gray-300", "CTS Report" } } } main { class: "mx-auto container mb-24", - Outlet:: {} + Suspense { Outlet:: {} } } footer { class: "w-screen h-11 flex flex-row justify-start space-x-1 px-6 text-sm text-gray-400", - p { "Made by"} - a { class: "hover:underline text-white", + p { "Made by" } + a { + class: "hover:underline text-white", href: "https://portfolio.kbz8.me/", "kbz_8" } p { "with" } - a { class: "hover:underline text-white", + a { + class: "hover:underline text-white", href: "https://dioxuslabs.com/", "Dioxus" } @@ -39,24 +40,15 @@ pub fn Navbar() -> Element { #[component] fn VulkanVSvg() -> Element { rsx! { - svg { - view_box: "0 0 192 192", + svg { view_box: "0 0 192 192", g { transform: "translate(0.0, 192.0) scale(0.1, -0.1)", fill: "#9d1b1f", stroke: "none", - path { - d: "M320 1703 c1 -10 18 -70 38 -133 35 -106 40 -115 63 -114 41 2 311 33 315 36 1 2 -8 35 -21 73 -14 39 -29 89 -35 113 l-11 42 -174 0 c-160 0 -175 -1 -175 -17z", - } - path { - d: "M1336 1678 c-38 -104 -77 -240 -73 -251 3 -7 31 -21 63 -31 33 -10 97 -34 143 -52 46 -19 88 -33 93 -32 8 3 138 380 138 401 0 4 -78 7 -174 7 l-174 0 -16 -42z", - } - path { - d: "M550 1369 c-235 -26 -412 -107 -509 -232 l-41 -54 0 -91 c0 -89 1 -92 39 -149 50 -75 117 -142 206 -205 124 -87 339 -197 350 -178 3 6 -35 50 -84 98 -160 157 -189 276 -96 402 35 47 116 105 183 130 285 108 789 43 1208 -155 107 -50 114 -52 113 -32 0 14 -26 44 -74 86 -218 191 -465 307 -765 361 -134 24 -401 34 -530 19z", - } - path { - d: "M739 1025 c-75 -15 -166 -51 -172 -68 -4 -13 221 -717 242 -755 12 -22 14 -23 192 -20 98 1 184 5 189 8 9 6 81 213 205 589 31 95 53 177 48 182 -10 10 -294 67 -308 62 -10 -3 -83 -220 -106 -312 -9 -38 -12 -42 -25 -29 -8 8 -39 91 -68 184 l-53 169 -39 2 c-21 1 -69 -4 -105 -12z", - } + path { d: "M320 1703 c1 -10 18 -70 38 -133 35 -106 40 -115 63 -114 41 2 311 33 315 36 1 2 -8 35 -21 73 -14 39 -29 89 -35 113 l-11 42 -174 0 c-160 0 -175 -1 -175 -17z" } + path { d: "M1336 1678 c-38 -104 -77 -240 -73 -251 3 -7 31 -21 63 -31 33 -10 97 -34 143 -52 46 -19 88 -33 93 -32 8 3 138 380 138 401 0 4 -78 7 -174 7 l-174 0 -16 -42z" } + path { d: "M550 1369 c-235 -26 -412 -107 -509 -232 l-41 -54 0 -91 c0 -89 1 -92 39 -149 50 -75 117 -142 206 -205 124 -87 339 -197 350 -178 3 6 -35 50 -84 98 -160 157 -189 276 -96 402 35 47 116 105 183 130 285 108 789 43 1208 -155 107 -50 114 -52 113 -32 0 14 -26 44 -74 86 -218 191 -465 307 -765 361 -134 24 -401 34 -530 19z" } + path { d: "M739 1025 c-75 -15 -166 -51 -172 -68 -4 -13 221 -717 242 -755 12 -22 14 -23 192 -20 98 1 184 5 189 8 9 6 81 213 205 589 31 95 53 177 48 182 -10 10 -294 67 -308 62 -10 -3 -83 -220 -106 -312 -9 -38 -12 -42 -25 -29 -8 8 -39 91 -68 184 l-53 169 -39 2 c-21 1 -69 -4 -105 -12z" } } } } diff --git a/src/routes.rs b/src/routes.rs index a89dd69..fc6ef35 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -17,7 +17,157 @@ pub enum Route { #[component] fn PageNotFound(route: Vec) -> Element { + let nav = use_navigator(); + rsx! { - "test" + div { class: "w-full flex justify-center my-32", + div { class: "flex bg-slate-800 flex-col md:flex-row justify-center items-center gap-2 border-1 border-gray-700 rounded-xl p-4 lg:p-8 w-fit shadow-xl shadow-slate-950", + PageNotFoundSVG { size_class: "size-64 lg:size-[300px] xl:size-96" } + div { class: "flex flex-col gap-8 items-center", + h1 { class: "h1 text-6xl text-gray-400", "Oops!" } + p { class: "p text-gray-500 text-2xl whitespace-pre-line text-center", + "We couldn't find the page + you were looking for" + } + Link { + class: "mx-auto cursor-pointer hover:underline", + to: Route::Landing {}, + "Go back home" + } + } + } + } + } +} + +#[component] +fn PageNotFoundSVG(size_class: String) -> Element { + rsx! { + svg { + class: size_class, + "version": "1.1", + "xmlns:svg": "http://www.w3.org/2000/svg", + "xml:space": "preserve", + "viewBox": "0 0 64 64", + "fill": "#000000", + "xmlns": "http://www.w3.org/2000/svg", + id: "svg5", + g { "stroke-width": "0", id: "SVGRepo_bgCarrier" } + g { + "stroke-linecap": "round", + "stroke-linejoin": "round", + id: "SVGRepo_tracerCarrier", + } + g { id: "SVGRepo_iconCarrier", + defs { id: "defs2" } + g { "transform": "translate(-384,-96)", id: "layer1", + path { + "d": "m 393.99999,105 h 49 v 6 h -49 z", + style: "fill:#333333;fill-opacity:1;fill-rule:evenodd;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4.1", + id: "path27804", + } + path { + "d": "m 393.99999,111 h 49 v 40 h -49 z", + style: "fill:#acbec2;fill-opacity:1;fill-rule:evenodd;stroke-width:2.00001;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4.1", + id: "path27806", + } + path { + "d": "m 393.99999,111 v 40 h 29.76954 a 28.484051,41.392605 35.599482 0 0 18.625,-40 z", + style: "fill:#e8edee;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2.00002;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4.1", + id: "path27808", + } + path { + "d": "m 395.99999,104 c -1.64501,0 -3,1.355 -3,3 v 40 c 0,0.55229 0.44772,1 1,1 0.55229,0 1,-0.44771 1,-1 v -40 c 0,-0.56413 0.43587,-1 1,-1 h 45 c 0.56414,0 1,0.43587 1,1 v 3 h -42 c -0.55228,0 -1,0.44772 -1,1 0,0.55229 0.44772,1 1,1 h 42 v 37 c 0,0.56413 -0.43586,1 -1,1 h -49 c -0.55228,0 -1,0.44772 -1,1 0,0.55229 0.44772,1 1,1 h 49 c 1.64501,0 3,-1.35499 3,-3 0,-14 0,-28 0,-42 0,-1.645 -1.35499,-3 -3,-3 z", + style: "color:#000000;fill:#000000;fill-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4.1", + id: "path27810", + } + path { + style: "color:#000000;fill:#ed7161;fill-opacity:1;fill-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4.1;-inkscape-stroke:none", + "d": "m 438.99999,107 c -0.55228,0 -1,0.44772 -1,1 0,0.55229 0.44772,1 1,1 0.55229,0 1,-0.44771 1,-1 0,-0.55228 -0.44771,-1 -1,-1 z", + id: "path27812", + } + path { + "d": "m 434.99999,107 c -0.55228,0 -1,0.44772 -1,1 0,0.55229 0.44772,1 1,1 0.55229,0 1,-0.44771 1,-1 0,-0.55228 -0.44771,-1 -1,-1 z", + style: "color:#000000;fill:#ecba16;fill-opacity:1;fill-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4.1;-inkscape-stroke:none", + id: "path27814", + } + path { + "d": "m 430.99999,107 c -0.55228,0 -1,0.44772 -1,1 0,0.55229 0.44772,1 1,1 0.55229,0 1,-0.44771 1,-1 0,-0.55228 -0.44771,-1 -1,-1 z", + style: "color:#000000;fill:#42b05c;fill-opacity:1;fill-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4.1;-inkscape-stroke:none", + id: "path27816", + } + path { + style: "color:#000000;fill:#000000;fill-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4.1;-inkscape-stroke:none", + "d": "m 388.99999,150 a 1,1 0 0 0 -1,1 1,1 0 0 0 1,1 1,1 0 0 0 1,-1 1,1 0 0 0 -1,-1 z", + id: "path27818", + } + path { + "d": "m 396.99999,110 c -0.55228,0 -1,0.44772 -1,1 0,0.55229 0.44772,1 1,1 0.55229,0 1,-0.44771 1,-1 0,-0.55228 -0.44771,-1 -1,-1 z", + style: "color:#000000;fill:#000000;fill-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4.1;-inkscape-stroke:none", + id: "path27820", + } + rect { + "y": "120", + "rx": "2", + width: "29", + style: "fill:#256ada;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4.1", + height: "22", + "ry": "2", + "x": "404", + id: "rect4427", + } + path { + "d": "m 406,120 c -1.108,0 -2,0.892 -2,2 v 18 c 0,1.108 0.892,2 2,2 h 19.58398 A 19.317461,16.374676 0 0 0 430.2207,131.36719 19.317461,16.374676 0 0 0 424.80273,120 Z", + style: "fill:#6b9ae6;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4.1", + id: "path27648", + } + rect { + height: "6", + style: "fill:#50a824;fill-opacity:1;fill-rule:evenodd;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4.1", + width: "29", + "y": "120", + "x": "404", + id: "rect8552", + } + path { + "d": "m 404,120 v 6 h 24.58984 a 14,8.5 0 0 0 0.10938,-1 14,8.5 0 0 0 -2.67969,-5 z", + style: "fill:#83db57;fill-opacity:1;fill-rule:evenodd;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4.1", + id: "path8626", + } + g { "transform": "translate(0,-4)", id: "path4429", + path { + "d": "m 404,130 h 29", + style: "color:#000000;fill:#918383;fill-rule:evenodd;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4.1;-inkscape-stroke:none", + id: "path7162", + } + path { + style: "color:#000000;fill:#000000;fill-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4.1;-inkscape-stroke:none", + "d": "m 406,123 c -1.6447,0 -3,1.3553 -3,3 0,1.97201 0,3.94401 0,5.91602 0,0.55228 0.44772,1 1,1 0.55228,0 1,-0.44772 1,-1 V 131 h 27 v 6 c 0,0.55228 0.44772,1 1,1 0.55228,0 1,-0.44772 1,-1 0,-3.66667 0,-7.33333 0,-11 0,-1.6447 -1.3553,-3 -3,-3 z m 0,2 h 25 c 0.5713,0 1,0.4287 1,1 v 3 h -27 v -3 c 0,-0.5713 0.4287,-1 1,-1 z m -2,10 c -0.55228,0 -1,0.44772 -1,1 v 8 c 0,1.6447 1.3553,3 3,3 h 25 c 1.6447,0 3,-1.3553 3,-3 v -3 c 0,-0.55228 -0.44772,-1 -1,-1 -0.55228,0 -1,0.44772 -1,1 v 3 c 0,0.5713 -0.4287,1 -1,1 h -25 c -0.5713,0 -1,-0.4287 -1,-1 v -8 c 0,-0.55228 -0.44772,-1 -1,-1 z", + id: "path7164", + } + } + path { + "d": "m 409.93555,129.00195 c -0.45187,0.0293 -0.82765,0.35863 -0.91602,0.80274 l -1,5 C 407.89645,135.42313 408.36944,135.99975 409,136 h 3 v 2 c 0,0.55228 0.44772,1 1,1 0.55228,0 1,-0.44772 1,-1 0,-1.66667 0,-3.33333 0,-5 0,-0.55228 -0.44772,-1 -1,-1 -0.55228,0 -1,0.44772 -1,1 v 1 h -1.78125 l 0.76172,-3.80469 c 0.10771,-0.54147 -0.24375,-1.06778 -0.78516,-1.17578 -0.0854,-0.0172 -0.17278,-0.0231 -0.25976,-0.0176 z", + style: "color:#000000;fill:#000000;fill-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4.1;-inkscape-stroke:none", + id: "path8873", + } + path { + style: "fill:#ffc343;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4.1", + "d": "m 418.99999,130 c 1.10801,0 2.00002,0.89201 2.00002,2.00002 v 2.99996 c 0,1.10801 -0.89201,2.00002 -2.00002,2.00002 -1.10801,0 -2.00002,-0.89201 -2.00002,-2.00002 v -2.99996 c 0,-1.10801 0.89201,-2.00002 2.00002,-2.00002 z", + id: "rect5745", + } + path { + "d": "m 419,129 c -1.64471,0 -3,1.35529 -3,3 v 3 c 0,1.64471 1.35529,3 3,3 1.64471,0 3,-1.35529 3,-3 v -3 a 1,1 0 0 0 -1,-1 1,1 0 0 0 -1,1 v 3 c 0,0.57131 -0.42869,1 -1,1 -0.57131,0 -1,-0.42869 -1,-1 v -3 c 0,-0.57131 0.42869,-1 1,-1 a 1,1 0 0 0 1,-1 1,1 0 0 0 -1,-1 z", + style: "color:#000000;fill:#000000;fill-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4.1;-inkscape-stroke:none", + id: "path7169", + } + path { + style: "color:#000000;fill:#000000;fill-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4.1;-inkscape-stroke:none", + "d": "m 425.93555,129.00195 c -0.45187,0.0293 -0.82765,0.35863 -0.91602,0.80274 l -1,5 C 423.89645,135.42313 424.36944,135.99975 425,136 h 3 v 2 c 0,0.55228 0.44772,1 1,1 0.55228,0 1,-0.44772 1,-1 0,-1.66667 0,-3.33333 0,-5 0,-0.55228 -0.44772,-1 -1,-1 -0.55228,0 -1,0.44772 -1,1 v 1 h -1.78125 l 0.76172,-3.80469 c 0.10771,-0.54147 -0.24375,-1.06778 -0.78516,-1.17578 -0.0854,-0.0172 -0.17278,-0.0231 -0.25976,-0.0176 z", + id: "path69785", + } + } + } + } } }