Skip to main content

logicaffeine_web/ui/components/
guide_sidebar.rs

1//! Sidebar navigation component for the Programmer's Guide.
2//!
3//! Features:
4//! - Sticky positioning
5//! - Sections grouped by Part
6//! - Active section highlighting
7//! - Click navigation with anchor links
8
9use 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/// Information about a section for the sidebar
137#[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    // Group sections by part
155    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                                            // Smooth scroll to section
187                                            #[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
214/// Groups sections by their part name, preserving order
215fn 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 == &section.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/// Mobile sidebar toggle button component
230#[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}