Skip to main content

logicaffeine_web/ui/components/
learn_sidebar.rs

1//! Sidebar navigation component for the Learn/Curriculum page.
2//!
3//! Features:
4//! - Sticky positioning
5//! - Eras grouped with modules
6//! - Active module highlighting
7//! - Click navigation with scroll behavior
8//! - Difficulty indicators
9
10use dioxus::prelude::*;
11
12const SIDEBAR_STYLE: &str = r#"
13.learn-sidebar {
14    position: sticky;
15    top: 90px;
16    width: 280px;
17    max-height: calc(100vh - 120px);
18    overflow-y: auto;
19    flex-shrink: 0;
20    padding: var(--spacing-sm) 0;
21
22    /* Custom scrollbar */
23    scrollbar-width: thin;
24    scrollbar-color: rgba(255,255,255,0.1) transparent;
25}
26
27.learn-sidebar::-webkit-scrollbar {
28    width: 6px;
29}
30
31.learn-sidebar::-webkit-scrollbar-track {
32    background: transparent;
33}
34
35.learn-sidebar::-webkit-scrollbar-thumb {
36    background: rgba(255,255,255,0.1);
37    border-radius: 3px;
38}
39
40.learn-sidebar::-webkit-scrollbar-thumb:hover {
41    background: rgba(255,255,255,0.2);
42}
43
44.learn-sidebar-era {
45    margin-bottom: var(--spacing-xl);
46}
47
48.learn-sidebar-era:last-child {
49    margin-bottom: 0;
50}
51
52.learn-sidebar-era-title {
53    font-size: var(--font-caption-md);
54    font-weight: 700;
55    text-transform: uppercase;
56    letter-spacing: 0.8px;
57    color: var(--text-tertiary);
58    padding: 0 var(--spacing-lg);
59    margin-bottom: var(--spacing-sm);
60    display: flex;
61    align-items: center;
62    gap: var(--spacing-sm);
63}
64
65.learn-sidebar-era-title::before {
66    content: "";
67    width: 8px;
68    height: 8px;
69    border-radius: 50%;
70    background: linear-gradient(135deg, var(--color-accent-blue), var(--color-accent-purple));
71    opacity: 0.6;
72}
73
74.learn-sidebar-module {
75    display: flex;
76    align-items: center;
77    justify-content: space-between;
78    padding: 10px var(--spacing-lg);
79    margin: 2px var(--spacing-sm);
80    border-radius: var(--radius-md);
81    color: var(--text-secondary);
82    font-size: var(--font-body-md);
83    font-weight: 500;
84    text-decoration: none;
85    transition: all 0.18s ease;
86    cursor: pointer;
87    border-left: 3px solid transparent;
88}
89
90.learn-sidebar-module:hover {
91    background: rgba(255,255,255,0.06);
92    color: var(--text-primary);
93}
94
95.learn-sidebar-module.active {
96    background: rgba(96,165,250,0.15);
97    color: var(--color-accent-blue);
98    border-left-color: var(--color-accent-blue);
99    font-weight: 600;
100}
101
102.learn-sidebar-module-name {
103    flex: 1;
104    white-space: nowrap;
105    overflow: hidden;
106    text-overflow: ellipsis;
107}
108
109.learn-sidebar-difficulty {
110    display: flex;
111    gap: 2px;
112    margin-left: var(--spacing-sm);
113    flex-shrink: 0;
114}
115
116.learn-sidebar-dot {
117    width: 6px;
118    height: 6px;
119    border-radius: 50%;
120    background: rgba(255,255,255,0.15);
121}
122
123.learn-sidebar-dot.filled {
124    background: linear-gradient(135deg, var(--color-accent-blue), var(--color-accent-purple));
125}
126
127.learn-sidebar-module.active .learn-sidebar-dot {
128    background: rgba(96,165,250,0.3);
129}
130
131.learn-sidebar-module.active .learn-sidebar-dot.filled {
132    background: var(--color-accent-blue);
133}
134
135.learn-sidebar-count {
136    font-size: var(--font-caption-md);
137    color: var(--text-muted);
138    margin-left: 6px;
139    flex-shrink: 0;
140}
141
142.learn-sidebar-module.active .learn-sidebar-count {
143    color: rgba(96,165,250,0.7);
144}
145
146@media (max-width: 1024px) {
147    .learn-sidebar {
148        display: none;
149    }
150}
151"#;
152
153/// Information about a module for the sidebar
154#[derive(Clone, PartialEq, Debug)]
155pub struct ModuleInfo {
156    pub era_id: String,
157    pub era_title: String,
158    pub module_id: String,
159    pub module_title: String,
160    pub exercise_count: u32,
161    pub difficulty: u8,
162}
163
164#[derive(Props, Clone, PartialEq)]
165pub struct LearnSidebarProps {
166    pub modules: Vec<ModuleInfo>,
167    pub active_module: Option<String>,
168    pub on_module_click: EventHandler<(String, String)>, // (era_id, module_id)
169}
170
171#[component]
172pub fn LearnSidebar(props: LearnSidebarProps) -> Element {
173    // Group modules by era
174    let grouped = group_modules_by_era(&props.modules);
175
176    rsx! {
177        style { "{SIDEBAR_STYLE}" }
178
179        nav { class: "learn-sidebar",
180            for (era_id, era_title, modules) in grouped {
181                div { class: "learn-sidebar-era",
182                    h4 { class: "learn-sidebar-era-title", "{era_title}" }
183
184                    for module in modules {
185                        {
186                            let is_active = props.active_module.as_ref() == Some(&module.module_id);
187                            let class_name = if is_active {
188                                "learn-sidebar-module active"
189                            } else {
190                                "learn-sidebar-module"
191                            };
192
193                            let era_for_click = era_id.clone();
194                            let mod_for_click = module.module_id.clone();
195
196                            rsx! {
197                                a {
198                                    class: "{class_name}",
199                                    href: "#{module.module_id}",
200                                    onclick: {
201                                        let era = era_for_click.clone();
202                                        let module_id = mod_for_click.clone();
203                                        let handler = props.on_module_click.clone();
204                                        move |evt: Event<MouseData>| {
205                                            evt.prevent_default();
206                                            handler.call((era.clone(), module_id.clone()));
207
208                                            // Smooth scroll to section
209                                            #[cfg(target_arch = "wasm32")]
210                                            {
211                                                if let Some(window) = web_sys::window() {
212                                                    if let Some(document) = window.document() {
213                                                        if let Some(element) = document.get_element_by_id(&module_id) {
214                                                            let options = web_sys::ScrollIntoViewOptions::new();
215                                                            options.set_behavior(web_sys::ScrollBehavior::Smooth);
216                                                            options.set_block(web_sys::ScrollLogicalPosition::Start);
217                                                            let _ = element.scroll_into_view_with_scroll_into_view_options(&options);
218                                                        }
219                                                    }
220                                                }
221                                            }
222                                        }
223                                    },
224                                    span { class: "learn-sidebar-module-name", "{module.module_title}" }
225
226                                    // Difficulty dots
227                                    div { class: "learn-sidebar-difficulty",
228                                        for i in 1..=5u8 {
229                                            div {
230                                                class: if i <= module.difficulty { "learn-sidebar-dot filled" } else { "learn-sidebar-dot" }
231                                            }
232                                        }
233                                    }
234                                }
235                            }
236                        }
237                    }
238                }
239            }
240        }
241    }
242}
243
244/// Groups modules by their era, preserving order
245fn group_modules_by_era(modules: &[ModuleInfo]) -> Vec<(String, String, Vec<ModuleInfo>)> {
246    let mut result: Vec<(String, String, Vec<ModuleInfo>)> = Vec::new();
247
248    for module in modules {
249        if let Some((_, _, group)) = result.iter_mut().find(|(era_id, _, _)| era_id == &module.era_id) {
250            group.push(module.clone());
251        } else {
252            result.push((module.era_id.clone(), module.era_title.clone(), vec![module.clone()]));
253        }
254    }
255
256    result
257}