logicaffeine_web/ui/components/
learn_roadmap.rs1use dioxus::prelude::*;
11use crate::content::ContentEngine;
12
13const ROADMAP_STYLE: &str = r#"
14.learn-roadmap {
15 --route-line: rgba(var(--accent-secondary-rgb), 0.35);
16 scroll-margin-top: 112px;
17}
18
19.roadmap-era + .roadmap-era {
20 margin-top: var(--spacing-xl);
21}
22
23.roadmap-era-title {
24 font-size: var(--font-body-md);
25 font-weight: 700;
26 color: var(--text-secondary);
27 margin: 0 0 var(--spacing-sm);
28}
29
30.roadmap-modules {
31 list-style: none;
32 margin: 0;
33 padding: 0;
34 position: relative;
35}
36
37/* The route: one line through every stop of an era. */
38.roadmap-modules::before {
39 content: "";
40 position: absolute;
41 left: 11px;
42 top: 14px;
43 bottom: 14px;
44 width: 2px;
45 background: var(--route-line);
46 border-radius: 1px;
47}
48
49.roadmap-module {
50 position: relative;
51}
52
53.roadmap-module > summary {
54 list-style: none;
55 display: grid;
56 grid-template-columns: 24px minmax(0, 1fr) auto;
57 align-items: center;
58 column-gap: var(--spacing-md);
59 padding: 8px 4px 8px 0;
60 cursor: pointer;
61 border-radius: var(--radius-md);
62}
63
64.roadmap-module > summary::-webkit-details-marker {
65 display: none;
66}
67
68.roadmap-module > summary:focus-visible {
69 outline: 2px solid var(--accent-primary);
70 outline-offset: 2px;
71}
72
73/* A module's stop on the route: hollow ahead, ringed while open, filled once finished. */
74.roadmap-stop {
75 position: relative;
76 z-index: 1;
77 width: 24px;
78 height: 24px;
79 border-radius: 50%;
80 border: 2px solid var(--route-line);
81 background: var(--bg-gradient-start);
82 display: grid;
83 place-items: center;
84 font-size: 11px;
85 font-weight: 700;
86 color: var(--text-tertiary);
87}
88
89.roadmap-module[data-state="current"] .roadmap-stop {
90 border-color: var(--accent-primary);
91 color: var(--accent-primary);
92 box-shadow: 0 0 0 4px rgba(var(--accent-primary-rgb), 0.14);
93}
94
95.roadmap-module[data-state="done"] .roadmap-stop {
96 border-color: var(--accent-secondary);
97 background: var(--accent-secondary);
98 color: var(--bg-gradient-start);
99}
100
101.roadmap-module-title {
102 margin: 0;
103 font-size: var(--font-body-md);
104 font-weight: 600;
105 line-height: 1.35;
106 color: var(--text-primary);
107}
108
109.roadmap-module-count {
110 font-size: var(--font-caption-md);
111 color: var(--text-tertiary);
112 white-space: nowrap;
113}
114
115.roadmap-lessons {
116 list-style: none;
117 margin: 2px 0 var(--spacing-sm);
118 padding: 0 0 0 36px;
119}
120
121.roadmap-lesson {
122 position: relative;
123 display: grid;
124 grid-template-columns: 2.6em minmax(0, 1fr);
125 align-items: baseline;
126 width: 100%;
127 min-height: 40px;
128 padding: 8px 10px;
129 border: 1px solid transparent;
130 border-radius: var(--radius-md);
131 background: none;
132 color: var(--text-secondary);
133 font: inherit;
134 font-size: var(--font-body-sm);
135 line-height: 1.35;
136 text-align: left;
137 cursor: pointer;
138}
139
140/* A lesson's stop: a small dot on the route, filled once finished. */
141.roadmap-lesson::before {
142 content: "";
143 position: absolute;
144 left: -30px;
145 top: 50%;
146 width: 10px;
147 height: 10px;
148 margin-top: -5px;
149 border-radius: 50%;
150 border: 2px solid var(--route-line);
151 background: var(--bg-gradient-start);
152}
153
154.roadmap-lesson[data-done="true"]::before {
155 border-color: var(--accent-secondary);
156 background: var(--accent-secondary);
157}
158
159.roadmap-lesson:hover {
160 background: rgba(255, 255, 255, 0.04);
161 color: var(--text-primary);
162}
163
164.roadmap-lesson:focus-visible {
165 outline: 2px solid var(--accent-primary);
166 outline-offset: 1px;
167}
168
169.roadmap-lesson[aria-current="step"] {
170 background: rgba(var(--accent-primary-rgb), 0.1);
171 border-color: rgba(var(--accent-primary-rgb), 0.35);
172 color: var(--text-primary);
173 font-weight: 600;
174}
175
176.roadmap-lesson[aria-current="step"]::before {
177 border-color: var(--accent-primary);
178 background: var(--accent-primary);
179}
180
181.roadmap-lesson-number {
182 font-variant-numeric: tabular-nums;
183 color: var(--text-tertiary);
184}
185
186.roadmap-sr {
187 position: absolute;
188 width: 1px;
189 height: 1px;
190 margin: -1px;
191 padding: 0;
192 overflow: hidden;
193 clip: rect(0 0 0 0);
194 white-space: nowrap;
195 border: 0;
196}
197"#;
198
199#[component]
202pub fn LearnRoadmap(open: Option<String>, done: Vec<String>, on_open: EventHandler<String>) -> Element {
203 let is_done = |id: &str| done.iter().any(|d| d == id);
204 let mut number = 0usize;
205 let eras: Vec<_> = ContentEngine::shared()
206 .eras()
207 .iter()
208 .map(|era| {
209 let modules: Vec<_> = era.modules.iter().map(|m| {
210 number += 1;
211 (number, m)
212 }).collect();
213 (era, modules)
214 })
215 .collect();
216 rsx! {
217 style { "{ROADMAP_STYLE}" }
218 nav { class: "learn-roadmap", id: "roadmap", aria_label: "Roadmap",
219 for (era, modules) in eras {
220 section { class: "roadmap-era", key: "{era.meta.id}", aria_labelledby: "roadmap-era-{era.meta.id}",
221 h2 { class: "roadmap-era-title", id: "roadmap-era-{era.meta.id}", "{era.meta.title}" }
222 ol { class: "roadmap-modules",
223 for (n, module) in modules {
224 {
225 let lessons = &module.sections;
226 let finished = lessons.iter().filter(|s| is_done(&s.id)).count();
227 let holds_open = lessons.iter().any(|s| open.as_deref() == Some(s.id.as_str()));
228 let state = if holds_open {
229 "current"
230 } else if finished == lessons.len() && !lessons.is_empty() {
231 "done"
232 } else {
233 "ahead"
234 };
235 rsx! {
236 li { key: "{module.meta.id}",
237 details {
238 class: "roadmap-module",
239 id: "{module.meta.id}",
240 "data-state": "{state}",
241 open: holds_open,
242 summary {
243 span { class: "roadmap-stop", aria_hidden: "true", "{n}" }
244 h3 { class: "roadmap-module-title", "{module.meta.title}" }
245 span { class: "roadmap-module-count",
246 "{finished} of {lessons.len()}"
247 span { class: "roadmap-sr", " lessons done" }
248 }
249 }
250 ol { class: "roadmap-lessons",
251 for section in lessons.iter() {
252 {
253 let id = section.id.clone();
254 let current = open.as_deref() == Some(id.as_str());
255 let finished = is_done(&id);
256 rsx! {
257 li { key: "{id}",
258 button {
259 class: "roadmap-lesson",
260 r#type: "button",
261 "data-lesson": "{id}",
262 "data-done": "{finished}",
263 aria_current: if current { "step" } else { "false" },
264 onclick: {
265 let id = id.clone();
266 move |_| on_open.call(id.clone())
267 },
268 span { class: "roadmap-lesson-number", "{id}" }
269 span { class: "roadmap-lesson-title",
270 "{section.title}"
271 if finished {
272 span { class: "roadmap-sr", ", done" }
273 }
274 }
275 }
276 }
277 }
278 }
279 }
280 }
281 }
282 }
283 }
284 }
285 }
286 }
287 }
288 }
289 }
290 }
291}