logicaffeine_web/ui/components/
guide_sidebar.rs1use dioxus::prelude::*;
10
11const SIDEBAR_STYLE: &str = r#"
12.guide-sidebar {
13 position: sticky;
14 top: 90px;
15 width: 260px;
16 max-height: calc(100vh - 120px);
17 overflow-y: auto;
18 flex-shrink: 0;
19 padding: 4px 0;
20
21 /* Custom scrollbar */
22 scrollbar-width: thin;
23 scrollbar-color: rgba(255,255,255,0.1) transparent;
24}
25
26.guide-sidebar::-webkit-scrollbar {
27 width: 6px;
28}
29
30.guide-sidebar::-webkit-scrollbar-track {
31 background: transparent;
32}
33
34.guide-sidebar::-webkit-scrollbar-thumb {
35 background: rgba(255,255,255,0.1);
36 border-radius: 3px;
37}
38
39.guide-sidebar::-webkit-scrollbar-thumb:hover {
40 background: rgba(255,255,255,0.2);
41}
42
43.sidebar-part {
44 margin-bottom: 24px;
45}
46
47.sidebar-part:last-child {
48 margin-bottom: 0;
49}
50
51.sidebar-part-title {
52 font-size: 11px;
53 font-weight: 700;
54 text-transform: uppercase;
55 letter-spacing: 0.8px;
56 color: rgba(229,231,235,0.45);
57 padding: 0 16px;
58 margin-bottom: 10px;
59}
60
61.sidebar-section {
62 display: block;
63 padding: 9px 16px;
64 margin: 2px 8px;
65 border-radius: 8px;
66 color: rgba(229,231,235,0.65);
67 font-size: 13px;
68 font-weight: 500;
69 text-decoration: none;
70 transition: all 0.18s ease;
71 cursor: pointer;
72 border-left: 2px solid transparent;
73}
74
75.sidebar-section:hover {
76 background: rgba(255,255,255,0.05);
77 color: rgba(229,231,235,0.9);
78}
79
80.sidebar-section.active {
81 background: rgba(167,139,250,0.12);
82 color: #a78bfa;
83 border-left-color: #a78bfa;
84 font-weight: 600;
85}
86
87.sidebar-section-number {
88 display: inline-block;
89 min-width: 22px;
90 color: rgba(229,231,235,0.35);
91 font-weight: 500;
92}
93
94.sidebar-section.active .sidebar-section-number {
95 color: rgba(167,139,250,0.7);
96}
97
98@media (max-width: 1024px) {
99 .guide-sidebar {
100 display: none;
101 }
102}
103
104/* Mobile sidebar toggle - shown on mobile */
105.sidebar-mobile-toggle {
106 display: none;
107 position: fixed;
108 bottom: 24px;
109 right: 24px;
110 width: 56px;
111 height: 56px;
112 border-radius: 50%;
113 background: linear-gradient(135deg, #60a5fa, #a78bfa);
114 border: none;
115 color: #060814;
116 font-size: 24px;
117 cursor: pointer;
118 box-shadow: 0 8px 32px rgba(0,0,0,0.4);
119 z-index: 100;
120 transition: transform 0.2s ease;
121}
122
123.sidebar-mobile-toggle:hover {
124 transform: scale(1.05);
125}
126
127@media (max-width: 1024px) {
128 .sidebar-mobile-toggle {
129 display: flex;
130 align-items: center;
131 justify-content: center;
132 }
133}
134"#;
135
136#[derive(Clone, PartialEq, Debug)]
138pub struct SectionInfo {
139 pub id: String,
140 pub number: u8,
141 pub title: String,
142 pub part: String,
143}
144
145#[derive(Props, Clone, PartialEq)]
146pub struct GuideSidebarProps {
147 pub sections: Vec<SectionInfo>,
148 pub active_section: String,
149 pub on_section_click: EventHandler<String>,
150}
151
152#[component]
153pub fn GuideSidebar(props: GuideSidebarProps) -> Element {
154 let grouped = group_sections_by_part(&props.sections);
156
157 rsx! {
158 style { "{SIDEBAR_STYLE}" }
159
160 nav { class: "guide-sidebar",
161 for (part, sections) in grouped {
162 div { class: "sidebar-part",
163 h4 { class: "sidebar-part-title", "{part}" }
164
165 for section in sections {
166 {
167 let section_id = section.id.clone();
168 let is_active = props.active_section == section.id;
169 let class_name = if is_active {
170 "sidebar-section active"
171 } else {
172 "sidebar-section"
173 };
174
175 rsx! {
176 a {
177 class: "{class_name}",
178 href: "#{section_id}",
179 onclick: {
180 let id = section.id.clone();
181 let handler = props.on_section_click.clone();
182 move |evt: Event<MouseData>| {
183 evt.prevent_default();
184 handler.call(id.clone());
185
186 #[cfg(target_arch = "wasm32")]
188 {
189 if let Some(window) = web_sys::window() {
190 if let Some(document) = window.document() {
191 if let Some(element) = document.get_element_by_id(&id) {
192 let options = web_sys::ScrollIntoViewOptions::new();
193 options.set_behavior(web_sys::ScrollBehavior::Smooth);
194 options.set_block(web_sys::ScrollLogicalPosition::Start);
195 let _ = element.scroll_into_view_with_scroll_into_view_options(&options);
196 }
197 }
198 }
199 }
200 }
201 },
202 span { class: "sidebar-section-number", "{section.number}." }
203 " {section.title}"
204 }
205 }
206 }
207 }
208 }
209 }
210 }
211 }
212}
213
214fn group_sections_by_part(sections: &[SectionInfo]) -> Vec<(String, Vec<SectionInfo>)> {
216 let mut result: Vec<(String, Vec<SectionInfo>)> = Vec::new();
217
218 for section in sections {
219 if let Some((_, group)) = result.iter_mut().find(|(part, _)| part == §ion.part) {
220 group.push(section.clone());
221 } else {
222 result.push((section.part.clone(), vec![section.clone()]));
223 }
224 }
225
226 result
227}
228
229#[component]
231pub fn SidebarMobileToggle(on_toggle: EventHandler<()>) -> Element {
232 rsx! {
233 button {
234 class: "sidebar-mobile-toggle",
235 onclick: move |_| on_toggle.call(()),
236 title: "Toggle navigation",
237 "☰"
238 }
239 }
240}