logicaffeine_web/ui/components/
learn_sidebar.rs1use 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#[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)>, }
170
171#[component]
172pub fn LearnSidebar(props: LearnSidebarProps) -> Element {
173 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 #[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 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
244fn 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}