1use std::rc::Rc;
11use wasm_bindgen::prelude::*;
12use wasm_bindgen::JsCast;
13use web_sys::{window, ScrollIntoViewOptions, ScrollBehavior};
14use ferrox_front_core::dom::*;
15use ferrox_front_core::reactivity::Signal;
16use crate::icons::Icon;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum AdmonitionType {
25 Note,
26 Tip,
27 Warning,
28 Danger,
29 Caution,
30 Info,
31}
32
33impl AdmonitionType {
34 pub fn default_title(&self) -> &'static str {
35 match self {
36 Self::Note => "NOTE",
37 Self::Tip => "TIP",
38 Self::Warning => "WARNING",
39 Self::Danger => "DANGER",
40 Self::Caution => "CAUTION",
41 Self::Info => "INFO",
42 }
43 }
44
45 pub fn title(&self) -> &'static str {
47 self.default_title()
48 }
49
50 pub fn icon_name(&self) -> &'static str {
51 match self {
52 Self::Note => "info",
53 Self::Tip => "zap",
54 Self::Warning => "alert-triangle",
55 Self::Danger => "alert-circle",
56 Self::Caution => "shield",
57 Self::Info => "info",
58 }
59 }
60
61 pub fn style_tokens(&self) -> (&'static str, &'static str, &'static str, &'static str) {
63 match self {
64 Self::Note => ("#6366f1", "rgba(99, 102, 241, 0.08)", "#c7d2fe", "#818cf8"),
65 Self::Tip => ("#10b981", "rgba(16, 185, 129, 0.08)", "#a7f3d0", "#34d399"),
66 Self::Warning => ("#f59e0b", "rgba(245, 158, 11, 0.08)", "#fde68a", "#fbbf24"),
67 Self::Danger => ("#ef4444", "rgba(239, 68, 68, 0.08)", "#fecaca", "#f87171"),
68 Self::Caution => ("#f97316", "rgba(249, 115, 22, 0.08)", "#fed7aa", "#fb923c"),
69 Self::Info => ("#06b6d4", "rgba(6, 182, 212, 0.08)", "#a5f3fc", "#22d3ee"),
70 }
71 }
72
73 pub fn border_color(&self) -> &'static str {
74 self.style_tokens().0
75 }
76
77 pub fn bg_tint(&self) -> &'static str {
78 self.style_tokens().1
79 }
80
81 pub fn text_color(&self) -> &'static str {
82 self.style_tokens().2
83 }
84
85 pub fn icon_color(&self) -> &'static str {
86 self.style_tokens().3
87 }
88
89 pub fn from_str_loose(s: &str) -> Self {
90 match s.trim().to_lowercase().as_str() {
91 "tip" => Self::Tip,
92 "warning" => Self::Warning,
93 "danger" | "error" => Self::Danger,
94 "caution" => Self::Caution,
95 "info" => Self::Info,
96 _ => Self::Note,
97 }
98 }
99}
100
101pub fn admonition(
103 kind: AdmonitionType,
104 title: Option<&str>,
105 content: DomBuilder,
106) -> DomBuilder {
107 let (border_col, bg_tint, text_col, icon_col) = kind.style_tokens();
108 let display_title = title.unwrap_or_else(|| kind.default_title());
109
110 let header = div()
111 .class("ferrox-admonition-header flex items-center gap-2 mb-2 font-bold text-xs tracking-wider uppercase")
112 .style_attr(&format!("color: {};", text_col))
113 .child(Icon::render(kind.icon_name(), 16, icon_col))
114 .child(span().text(display_title));
115
116 let body = div()
117 .class("ferrox-admonition-body text-sm leading-relaxed text-slate-300")
118 .child(content);
119
120 div()
121 .class("ferrox-admonition my-4 p-4 rounded-xl backdrop-blur-md transition-all duration-200")
122 .style_attr(&format!(
123 "background: {}; border: 1px solid rgba(255,255,255,0.06); border-left: 4px solid {}; box-shadow: 0 4px 20px -2px rgba(0,0,0,0.25);",
124 bg_tint, border_col
125 ))
126 .child(header)
127 .child(body)
128}
129
130pub fn admonition_note(title: Option<&str>, content: DomBuilder) -> DomBuilder {
131 admonition(AdmonitionType::Note, title, content)
132}
133
134pub fn admonition_tip(title: Option<&str>, content: DomBuilder) -> DomBuilder {
135 admonition(AdmonitionType::Tip, title, content)
136}
137
138pub fn admonition_warning(title: Option<&str>, content: DomBuilder) -> DomBuilder {
139 admonition(AdmonitionType::Warning, title, content)
140}
141
142pub fn admonition_danger(title: Option<&str>, content: DomBuilder) -> DomBuilder {
143 admonition(AdmonitionType::Danger, title, content)
144}
145
146pub fn admonition_caution(title: Option<&str>, content: DomBuilder) -> DomBuilder {
147 admonition(AdmonitionType::Caution, title, content)
148}
149
150pub fn admonition_info(title: Option<&str>, content: DomBuilder) -> DomBuilder {
151 admonition(AdmonitionType::Info, title, content)
152}
153
154pub fn copy_to_clipboard(text: &str) {
160 if let Some(win) = window() {
161 let nav = win.navigator();
162 let clip = nav.clipboard();
163 let _ = clip.write_text(text);
164 }
165}
166
167pub fn code_block(
169 code: &str,
170 lang: Option<&str>,
171 title: Option<&str>,
172) -> DomBuilder {
173 code_block_highlighted(None, code, lang, title)
174}
175
176pub fn code_block_highlighted(
178 highlighted_html: Option<&str>,
179 raw_code: &str,
180 lang: Option<&str>,
181 title: Option<&str>,
182) -> DomBuilder {
183 let raw_code_owned = raw_code.to_string();
184 let copied_signal = Signal::new(false);
185 let lang_display = lang.unwrap_or("text").to_uppercase();
186
187 let btn_id = format!("copy-btn-{}", (js_sys::Math::random() * 1_000_000.0) as u32);
189 let btn_id_clone = btn_id.clone();
190 let code_to_copy = raw_code_owned.clone();
191 let copied_signal_clone = copied_signal.clone();
192
193 let copy_btn = button()
195 .id(&btn_id)
196 .class("ferrox-code-copy-btn flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-semibold text-slate-400 hover:text-white bg-slate-800/60 hover:bg-slate-700/80 border border-slate-700/50 transition-all duration-150 cursor-pointer user-select-none")
197 .children(vec![
198 Icon::render("copy", 13, "#94a3b8"),
199 span().text("Copy"),
200 ])
201 .on_click(move || {
202 copy_to_clipboard(&code_to_copy);
203 copied_signal_clone.set(true);
204
205 if let Some(doc) = window().and_then(|w| w.document()) {
207 if let Some(el) = doc.get_element_by_id(&btn_id_clone) {
208 el.set_inner_html(
209 r##"<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="ferrox-icon" style="display: inline-block; vertical-align: middle;"><polyline points="20 6 9 17 4 12"></polyline></svg><span style="color: #10b981;">Copied!</span>"##
210 );
211
212 let id_restore = btn_id_clone.clone();
213 let cb = Closure::wrap(Box::new(move || {
214 if let Some(d) = window().and_then(|w| w.document()) {
215 if let Some(b) = d.get_element_by_id(&id_restore) {
216 b.set_inner_html(
217 r##"<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="ferrox-icon" style="display: inline-block; vertical-align: middle;"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg><span>Copy</span>"##
218 );
219 }
220 }
221 }) as Box<dyn FnMut()>);
222
223 if let Some(w) = window() {
224 let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(cb.as_ref().unchecked_ref(), 2000);
225 cb.forget();
226 }
227 }
228 }
229 });
230
231 let mut header_left = div().class("flex items-center gap-2");
233
234 let dots = div()
236 .class("flex items-center gap-1.5 mr-2")
237 .children(vec![
238 span().class("w-2.5 h-2.5 rounded-full bg-rose-500/80 inline-block"),
239 span().class("w-2.5 h-2.5 rounded-full bg-amber-500/80 inline-block"),
240 span().class("w-2.5 h-2.5 rounded-full bg-emerald-500/80 inline-block"),
241 ]);
242 header_left = header_left.child(dots);
243
244 if let Some(t) = title {
245 header_left = header_left.child(
246 span().class("text-xs font-mono font-medium text-slate-300").text(t)
247 );
248 }
249
250 let lang_badge = span()
252 .class("px-2 py-0.5 rounded text-[10px] font-mono font-bold tracking-wider bg-slate-800 text-indigo-300 border border-indigo-500/20")
253 .text(&lang_display);
254
255 let header = div()
256 .class("ferrox-code-header flex items-center justify-between px-4 py-2 border-b border-slate-800/80 bg-slate-900/70")
257 .children(vec![
258 header_left,
259 div().class("flex items-center gap-2.5").child(lang_badge).child(copy_btn),
260 ]);
261
262 let code_el = if let Some(html) = highlighted_html {
264 div()
265 .class("p-5 overflow-x-auto text-sm sm:text-base font-mono leading-relaxed text-slate-200 whitespace-pre")
266 .inner_html(html)
267 } else {
268 div()
269 .class("p-5 overflow-x-auto text-sm sm:text-base font-mono leading-relaxed text-slate-200 whitespace-pre")
270 .text(&raw_code_owned)
271 };
272
273 div()
274 .class("ferrox-code-block my-6 rounded-2xl overflow-hidden bg-slate-950/80 border border-slate-800/80 shadow-2xl backdrop-blur-xl transition-all duration-200")
275 .child(header)
276 .child(code_el)
277}
278
279pub struct TabPanelDef {
285 pub id: String,
286 pub label: String,
287 pub icon: Option<String>,
288 pub content: DomBuilder,
289}
290
291impl TabPanelDef {
292 pub fn new(id: impl Into<String>, label: impl Into<String>, content: DomBuilder) -> Self {
293 Self {
294 id: id.into(),
295 label: label.into(),
296 icon: None,
297 content,
298 }
299 }
300
301 pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
302 self.icon = Some(icon.into());
303 self
304 }
305}
306
307static TAB_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(1000);
308
309pub fn tab_group(panels: Vec<TabPanelDef>, default_index: usize) -> DomBuilder {
311 let group_id = format!("tabgroup-{}", TAB_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
312 let active_index = Signal::new(default_index.min(panels.len().saturating_sub(1)));
313
314 let mut header_bar = div()
315 .class("ferrox-tabgroup-header flex items-center gap-1.5 p-1.5 rounded-xl bg-slate-900/60 border border-slate-800/80 mb-3 backdrop-blur-md");
316
317 let mut panels_container = div().class("ferrox-tabgroup-panels w-full");
318
319 let count = panels.len();
320
321 for (idx, panel) in panels.into_iter().enumerate() {
322 let is_active = idx == active_index.get();
323 let idx_owned = idx;
324 let group_id_clone = group_id.clone();
325
326 let mut tab_btn = button()
327 .id(&format!("{}-tab-{}", group_id, idx))
328 .class(&format!(
329 "flex items-center gap-2 px-3.5 py-1.5 rounded-lg text-xs font-semibold transition-all duration-150 cursor-pointer {}",
330 if is_active {
331 "bg-indigo-600 text-white shadow-md font-bold"
332 } else {
333 "text-slate-400 hover:text-white hover:bg-slate-800/60"
334 }
335 ))
336 .on_click(move || {
337 if let Some(doc) = window().and_then(|w| w.document()) {
338 for i in 0..count {
339 if let Some(btn) = doc.get_element_by_id(&format!("{}-tab-{}", group_id_clone, i)) {
340 if i == idx_owned {
341 btn.set_attribute("class", "flex items-center gap-2 px-3.5 py-1.5 rounded-lg text-xs font-bold bg-indigo-600 text-white shadow-md cursor-pointer transition-all duration-150").unwrap();
342 } else {
343 btn.set_attribute("class", "flex items-center gap-2 px-3.5 py-1.5 rounded-lg text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800/60 cursor-pointer transition-all duration-150").unwrap();
344 }
345 }
346 if let Some(p) = doc.get_element_by_id(&format!("{}-panel-{}", group_id_clone, i)) {
347 if i == idx_owned {
348 p.set_attribute("style", "display: block;").unwrap();
349 } else {
350 p.set_attribute("style", "display: none;").unwrap();
351 }
352 }
353 }
354 }
355 });
356
357 if let Some(ref ico) = panel.icon {
358 tab_btn = tab_btn.child(Icon::render(ico, 14, if is_active { "#ffffff" } else { "#94a3b8" }));
359 }
360
361 tab_btn = tab_btn.child(span().text(&panel.label));
362 header_bar = header_bar.child(tab_btn);
363
364 let panel_body = div()
366 .id(&format!("{}-panel-{}", group_id, idx))
367 .class("ferrox-tab-panel w-full transition-opacity duration-150")
368 .style_attr(if is_active { "display: block;" } else { "display: none;" })
369 .child(panel.content);
370
371 panels_container = panels_container.child(panel_body);
372 }
373
374 div()
375 .id(&group_id)
376 .class("ferrox-tab-group my-4 w-full")
377 .child(header_bar)
378 .child(panels_container)
379}
380
381#[derive(Debug, Clone)]
387pub struct TocItem {
388 pub id: String,
389 pub title: String,
390 pub level: u32,
391}
392
393impl TocItem {
394 pub fn new(id: impl Into<String>, title: impl Into<String>, level: u32) -> Self {
395 Self {
396 id: id.into(),
397 title: title.into(),
398 level,
399 }
400 }
401}
402
403pub fn scroll_to_anchor(id: &str) {
405 if let Some(win) = window() {
406 if let Some(doc) = win.document() {
407 if let Some(el) = doc.get_element_by_id(id) {
408 let options = ScrollIntoViewOptions::new();
409 options.set_behavior(ScrollBehavior::Smooth);
410 el.scroll_into_view_with_scroll_into_view_options(&options);
411
412 if let Ok(hist) = win.history() {
414 let _ = hist.replace_state_with_url(&JsValue::NULL, "", Some(&format!("#{}", id)));
415 }
416 }
417 }
418 }
419}
420
421pub fn table_of_contents(
423 items: Vec<TocItem>,
424 active_id: Signal<Option<String>>,
425) -> DomBuilder {
426 let mut nav_list = div().class("flex flex-col space-y-1.5 text-sm font-medium");
427
428 let cur_active = active_id.get();
429
430 for item in items {
431 let is_active = cur_active.as_deref() == Some(&item.id);
432 let id_owned = item.id.clone();
433 let indent_class = match item.level {
434 1 | 2 => "pl-3",
435 3 => "pl-5",
436 4 => "pl-7",
437 _ => "pl-9",
438 };
439
440 let link_class = if is_active {
441 "text-indigo-400 font-semibold border-l-2 border-indigo-500 -ml-[1px]"
442 } else {
443 "text-slate-400 hover:text-slate-200 border-l-2 border-transparent -ml-[1px]"
444 };
445
446 let anchor_link = div()
447 .class(&format!(
448 "py-1.5 {} {} transition-all duration-150 cursor-pointer flex items-center justify-between",
449 indent_class, link_class
450 ))
451 .child(span().class("break-words leading-snug").text(&item.title))
452 .on_click(move || {
453 scroll_to_anchor(&id_owned);
454 });
455
456 nav_list = nav_list.child(anchor_link);
457 }
458
459 div()
460 .class("ferrox-toc w-full py-4 px-3 border-l border-slate-800/80")
461 .children(vec![
462 div()
463 .class("flex items-center gap-2 mb-3.5 text-xs font-bold uppercase tracking-wider text-slate-400")
464 .children(vec![
465 Icon::render("layers", 15, "#818cf8"),
466 span().text("On this page"),
467 ]),
468 nav_list,
469 ])
470}
471
472#[derive(Clone)]
478pub struct DocNavItem {
479 pub id: String,
480 pub label: String,
481 pub href: Option<String>,
482 pub icon: Option<String>,
483 pub badge: Option<String>,
484 pub is_expanded: Signal<bool>,
485 pub children: Vec<DocNavItem>,
486}
487
488impl DocNavItem {
489 pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
490 Self {
491 id: id.into(),
492 label: label.into(),
493 href: None,
494 icon: None,
495 badge: None,
496 is_expanded: Signal::new(false),
497 children: Vec::new(),
498 }
499 }
500
501 pub fn has_active_child(&self, current_path: &str) -> bool {
502 let clean_cur = current_path.trim_start_matches('/');
503 if let Some(ref h) = self.href {
504 if h.trim_start_matches('/') == clean_cur {
505 return true;
506 }
507 }
508 for child in &self.children {
509 if child.has_active_child(current_path) {
510 return true;
511 }
512 }
513 false
514 }
515
516 pub fn with_href(mut self, href: impl Into<String>) -> Self {
517 self.href = Some(href.into());
518 self
519 }
520
521 pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
522 self.icon = Some(icon.into());
523 self
524 }
525
526 pub fn with_badge(mut self, badge: impl Into<String>) -> Self {
527 self.badge = Some(badge.into());
528 self
529 }
530
531 pub fn with_children(mut self, children: Vec<DocNavItem>) -> Self {
532 self.children = children;
533 self
534 }
535
536 pub fn expanded(self, exp: bool) -> Self {
537 self.is_expanded.set(exp);
538 self
539 }
540}
541
542fn render_doc_nav_item(
544 item: &DocNavItem,
545 current_path: &str,
546 depth: usize,
547 on_navigate: &Rc<dyn Fn(&str)>,
548) -> DomBuilder {
549 let has_children = !item.children.is_empty();
550 let clean_cur = current_path.trim_start_matches('/');
551
552 let has_active = if clean_cur.is_empty() || clean_cur == "overview" {
555 false
556 } else {
557 item.has_active_child(current_path)
558 };
559
560 let is_exp = item.is_expanded.get() || has_active;
561 let is_active = item.href.as_deref().map(|h| h.trim_start_matches('/') == clean_cur).unwrap_or(false);
562
563 let on_nav_clone = on_navigate.clone();
564 let href_opt = item.href.clone();
565
566 let clean_id = item.id.replace('/', "-").replace(' ', "-").replace(':', "-");
568 let box_id = format!("doc-nav-box-{}", clean_id);
569 let chev_id = format!("doc-nav-chev-{}", clean_id);
570 let box_id_clone = box_id.clone();
571 let chev_id_clone = chev_id.clone();
572
573 let (row_class, icon_size) = if depth == 0 {
574 (
575 format!(
576 "flex items-center justify-between py-2.5 px-3.5 rounded-xl cursor-pointer transition-all duration-150 text-[15px] font-semibold tracking-tight {}",
577 if is_active {
578 "bg-indigo-600/25 text-indigo-300 border border-indigo-500/40 shadow-sm"
579 } else {
580 "text-slate-200 hover:text-white hover:bg-slate-900/90 border border-transparent"
581 }
582 ),
583 16,
584 )
585 } else if has_children {
586 (
587 format!(
588 "flex items-center justify-between py-2 px-3 rounded-lg cursor-pointer transition-all duration-150 text-sm font-medium {}",
589 if is_active {
590 "bg-indigo-600/20 text-indigo-300 font-semibold border border-indigo-500/30 shadow-sm"
591 } else {
592 "text-slate-300 hover:text-white hover:bg-slate-800/70 border border-transparent"
593 }
594 ),
595 15,
596 )
597 } else {
598 (
599 format!(
600 "flex items-center justify-between py-2 px-3 rounded-lg cursor-pointer transition-all duration-150 text-sm {}",
601 if is_active {
602 "bg-indigo-600/25 text-indigo-300 font-semibold border border-indigo-500/30 shadow-sm"
603 } else {
604 "text-slate-400 hover:text-slate-100 hover:bg-slate-800/60 border border-transparent font-normal"
605 }
606 ),
607 14,
608 )
609 };
610
611 let mut row = div()
612 .class(&row_class)
613 .style_attr(&format!("padding-left: {}px;", depth * 16 + 12))
614 .on_click(move || {
615 if has_children {
616 if let Some(doc) = window().and_then(|w| w.document()) {
617 if let Some(b_el) = doc.get_element_by_id(&box_id_clone) {
618 let style_str = b_el.get_attribute("style").unwrap_or_default();
619 if style_str.contains("display: none") {
620 let _ = b_el.set_attribute("style", "display: flex; flex-direction: column;");
621 if let Some(c_el) = doc.get_element_by_id(&chev_id_clone) {
622 let _ = c_el.set_attribute("style", "transform: rotate(90deg);");
623 }
624 } else {
625 let _ = b_el.set_attribute("style", "display: none;");
626 if let Some(c_el) = doc.get_element_by_id(&chev_id_clone) {
627 let _ = c_el.set_attribute("style", "transform: rotate(0deg);");
628 }
629 }
630 }
631 }
632 }
633 if let Some(ref h) = href_opt {
634 on_nav_clone(h);
635 }
636 });
637
638 let mut left = div().class("flex items-center gap-2.5 min-w-0");
639
640 if has_children {
641 let chevron_style = if is_exp { "transform: rotate(90deg);" } else { "transform: rotate(0deg);" };
642 left = left.child(
643 span()
644 .id(&chev_id)
645 .class("w-4 h-4 flex items-center justify-center shrink-0 transition-transform duration-150")
646 .style_attr(chevron_style)
647 .child(Icon::render("chevron-right", 14, "#94a3b8"))
648 );
649 } else {
650 left = left.child(span().class("w-4 h-4 shrink-0"));
651 }
652
653 if let Some(ref ico) = item.icon {
654 left = left.child(Icon::render(ico, icon_size, if is_active { "#818cf8" } else { "#94a3b8" }));
655 }
656
657 left = left.child(span().class("truncate").text(&item.label));
658 row = row.child(left);
659
660 if let Some(ref b) = item.badge {
661 row = row.child(
662 span()
663 .class("shrink-0 ml-2 px-2 py-0.5 rounded text-[11px] font-mono font-bold bg-slate-800 text-indigo-300 border border-indigo-500/25 shadow-sm")
664 .text(b)
665 );
666 }
667
668 let mut container = div().class("flex flex-col").child(row);
669
670 if has_children {
671 let initial_display = if is_exp { "display: flex; flex-direction: column;" } else { "display: none;" };
672 let mut children_box = div()
673 .id(&box_id)
674 .class("space-y-0.5 mt-0.5")
675 .style_attr(initial_display);
676 for child in &item.children {
677 children_box = children_box.child(render_doc_nav_item(child, current_path, depth + 1, on_navigate));
678 }
679 container = container.child(children_box);
680 }
681
682 container
683}
684
685pub fn doc_sidebar(
687 items: Vec<DocNavItem>,
688 current_path: Signal<String>,
689 on_navigate: impl Fn(&str) + 'static,
690) -> DomBuilder {
691 let on_nav_rc: Rc<dyn Fn(&str)> = Rc::new(on_navigate);
692 let cur = current_path.get();
693
694 let mut root = div().class("ferrox-doc-sidebar w-full flex flex-col space-y-1 p-2");
695
696 for item in &items {
697 root = root.child(render_doc_nav_item(item, &cur, 0, &on_nav_rc));
698 }
699
700 root
701}
702
703pub fn mermaid_container(raw_code: &str, title: Option<&str>) -> DomBuilder {
709 let title_str = title.unwrap_or("Mermaid SVG Vector Diagram");
710
711 div()
712 .class("ferrox-mermaid group relative my-6 flex flex-col items-center justify-center p-4 md:p-5 rounded-2xl bg-slate-900/70 border border-slate-800/90 shadow-2xl backdrop-blur-xl transition-all duration-200")
713 .children(vec![
714 div()
716 .class("w-full flex flex-wrap items-center justify-between pb-3 mb-3 border-b border-slate-800/80 text-xs font-mono text-indigo-400 font-semibold gap-2")
717 .children(vec![
718 span().class("flex items-center gap-1.5").children(vec![
719 span().text("⚡"),
720 span().text(title_str),
721 ]),
722 div().class("flex items-center gap-1.5 flex-wrap").children(vec![
723 span()
725 .class("ferrox-mermaid-zoom-badge px-2 py-0.5 rounded text-[10px] bg-slate-950/80 text-cyan-300 border border-cyan-500/30 font-mono shadow-inner")
726 .text("100%"),
727 button()
729 .class("ferrox-mermaid-btn ferrox-mermaid-zoom-in px-2.5 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 active:scale-95 text-slate-200 border border-slate-700/80 text-xs font-bold transition-all cursor-pointer shadow-sm")
730 .attr("title", "Zoom In (+25%)")
731 .attr("aria-label", "Zoom In")
732 .text("+"),
733 button()
735 .class("ferrox-mermaid-btn ferrox-mermaid-zoom-out px-2.5 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 active:scale-95 text-slate-200 border border-slate-700/80 text-xs font-bold transition-all cursor-pointer shadow-sm")
736 .attr("title", "Zoom Out (-25%)")
737 .attr("aria-label", "Zoom Out")
738 .text("-"),
739 button()
741 .class("ferrox-mermaid-btn ferrox-mermaid-reset px-2.5 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 active:scale-95 text-slate-200 border border-slate-700/80 text-xs transition-all cursor-pointer shadow-sm flex items-center gap-1")
742 .attr("title", "Reset Zoom & Pan")
743 .attr("aria-label", "Reset Zoom and Pan")
744 .text("↺ Reset"),
745 button()
747 .class("ferrox-mermaid-btn ferrox-mermaid-fullscreen px-2.5 py-1 rounded-lg bg-indigo-950/60 hover:bg-indigo-900/80 active:scale-95 text-indigo-300 border border-indigo-500/40 text-xs transition-all cursor-pointer shadow-sm flex items-center gap-1")
748 .attr("title", "Toggle Fullscreen View")
749 .attr("aria-label", "Toggle Fullscreen View")
750 .text("⛶ Expand"),
751 span()
752 .class("hidden sm:inline-block px-2 py-0.5 rounded text-[10px] bg-slate-800/80 text-indigo-300 border border-indigo-500/20")
753 .text("Vector SVG"),
754 ]),
755 ]),
756 div()
758 .class("ferrox-mermaid-viewport relative w-full overflow-hidden rounded-xl bg-slate-950/60 border border-slate-800/60 flex items-center justify-center min-h-[240px] select-none")
759 .attr("style", "touch-action: none; cursor: grab;")
760 .children(vec![
761 div()
762 .class("ferrox-mermaid-canvas w-full flex items-center justify-center p-2")
763 .attr("style", "transform-origin: center center; will-change: transform;")
764 .children(vec![
765 div()
766 .class("ferrox-mermaid-raw text-slate-200 text-sm font-mono w-full flex justify-center")
767 .text(raw_code),
768 ]),
769 div()
771 .class("ferrox-mermaid-hint absolute bottom-2 right-2 text-[10px] font-mono text-slate-400 bg-slate-900/90 px-2.5 py-1 rounded-md border border-slate-800 pointer-events-none opacity-50 group-hover:opacity-100 transition-opacity backdrop-blur-sm")
772 .text("Drag to pan • Scroll to zoom"),
773 ]),
774 ])
775}
776
777pub fn installation_tabs(
783 cargo: Option<&str>,
784 npm: Option<&str>,
785 pip: Option<&str>,
786 composer: Option<&str>,
787) -> DomBuilder {
788 let mut panels = Vec::new();
789
790 if let Some(cmd) = cargo {
791 panels.push(
792 TabPanelDef::new(
793 "install-cargo",
794 "Cargo (Rust)",
795 code_block(cmd, Some("bash"), Some("Cargo")),
796 )
797 .with_icon("terminal")
798 );
799 }
800
801 if let Some(cmd) = npm {
802 panels.push(
803 TabPanelDef::new(
804 "install-npm",
805 "npm (Node.js)",
806 code_block(cmd, Some("bash"), Some("npm")),
807 )
808 .with_icon("terminal")
809 );
810 }
811
812 if let Some(cmd) = pip {
813 panels.push(
814 TabPanelDef::new(
815 "install-pip",
816 "pip (Python)",
817 code_block(cmd, Some("bash"), Some("pip")),
818 )
819 .with_icon("terminal")
820 );
821 }
822
823 if let Some(cmd) = composer {
824 panels.push(
825 TabPanelDef::new(
826 "install-composer",
827 "Composer (PHP)",
828 code_block(cmd, Some("bash"), Some("composer")),
829 )
830 .with_icon("terminal")
831 );
832 }
833
834 tab_group(panels, 0)
835}
836
837pub fn language_selector(
843 current_lang: &str,
844 on_select: impl Fn(String) + 'static,
845) -> DomBuilder {
846 let languages = vec![
847 ("en", "English 🇺🇸"),
848 ("it", "Italiano 🇮🇹"),
849 ("es", "Español 🇪🇸"),
850 ("fr", "Français 🇫🇷"),
851 ("de", "Deutsch 🇩🇪"),
852 ("zh", "中文 🇨🇳"),
853 ("ja", "日本語 🇯🇵"),
854 ("ko", "한국어 🇰🇷"),
855 ];
856
857 let on_sel_rc: Rc<dyn Fn(String)> = Rc::new(on_select);
858
859 let mut select_el = select()
860 .class("bg-slate-900/80 border border-slate-700/60 rounded-xl px-2.5 py-1 text-xs font-medium text-slate-200 focus:outline-none focus:border-indigo-500 shadow-sm cursor-pointer");
861
862 for (code, label) in languages {
863 let is_selected = code.eq_ignore_ascii_case(current_lang);
864 let mut opt = option().attr("value", code).text(label);
865 if is_selected {
866 opt = opt.attr("selected", "selected");
867 }
868 select_el = select_el.child(opt);
869 }
870
871 let on_change = on_sel_rc.clone();
872 select_el = select_el.on("change", move |e: web_sys::Event| {
873 if let Some(target) = e.target() {
874 if let Some(sel) = target.dyn_ref::<web_sys::HtmlSelectElement>() {
875 on_change(sel.value());
876 }
877 }
878 });
879
880 div()
881 .class("ferrox-lang-selector flex items-center gap-1.5")
882 .child(span().class("text-xs text-slate-400").text("🌐"))
883 .child(select_el)
884}
885
886pub fn sandbox_container(
892 title: &str,
893 badge: &str,
894 icon_name: &str,
895 description: &str,
896 content: DomBuilder,
897) -> DomBuilder {
898 let header = div()
899 .class("flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-5 border-b border-slate-800/80 bg-slate-900/60 backdrop-blur-xl rounded-t-2xl")
900 .children(vec![
901 div().class("flex items-center gap-3").children(vec![
902 div()
903 .class("w-9 h-9 rounded-xl flex items-center justify-center bg-indigo-500/20 text-indigo-400 border border-indigo-500/30")
904 .child(Icon::render(icon_name, 18, "#818cf8")),
905 div().children(vec![
906 div().class("flex items-center gap-2").children(vec![
907 h3().class("text-base font-bold text-white tracking-tight").text(title),
908 span().class("px-2 py-0.5 rounded-full text-[10px] font-mono font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30").text(badge),
909 ]),
910 p().class("text-xs text-slate-400 mt-0.5").text(description),
911 ]),
912 ]),
913 div().class("flex items-center gap-2").children(vec![
914 span().class("flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-mono font-medium bg-slate-800/80 text-indigo-300 border border-slate-700/60").children(vec![
915 span().class("w-2 h-2 rounded-full bg-emerald-400 animate-pulse"),
916 span().text("LIVE WASM ENGINE"),
917 ]),
918 ]),
919 ]);
920
921 div()
922 .class("ferrox-sandbox-frame my-10 rounded-2xl border border-indigo-500/30 bg-slate-950/80 shadow-2xl shadow-indigo-950/40 overflow-hidden")
923 .children(vec![header, div().class("p-5 sm:p-6").child(content)])
924}
925
926pub fn password_strength_bar(entropy_percent: f64) -> DomBuilder {
928 let clamped = entropy_percent.clamp(0.0, 100.0);
929 let (label, color_class, width_style) = if clamped < 25.0 {
930 ("Weak (Low Entropy)", "bg-rose-500 shadow-rose-500/50", "25%")
931 } else if clamped < 50.0 {
932 ("Fair (Basic)", "bg-amber-500 shadow-amber-500/50", "50%")
933 } else if clamped < 75.0 {
934 ("Strong (Recommended)", "bg-cyan-500 shadow-cyan-500/50", "75%")
935 } else {
936 ("Cyber-Secured (High Entropy)", "bg-emerald-500 shadow-emerald-500/50", "100%")
937 };
938
939 div()
940 .class("flex flex-col gap-1.5 w-full mt-2")
941 .children(vec![
942 div().class("flex justify-between items-center text-xs font-mono font-semibold").children(vec![
943 span().class("text-slate-400").text("Strength:"),
944 span().class("text-slate-200").text(label),
945 ]),
946 div()
947 .class("w-full h-2 rounded-full bg-slate-800 overflow-hidden")
948 .child(
949 div()
950 .class(&format!("h-full rounded-full transition-all duration-300 shadow-sm {}", color_class))
951 .attr("style", &format!("width: {};", width_style))
952 ),
953 ])
954}
955
956pub fn telemetry_metric_card(
958 label: &str,
959 value: &str,
960 change: Option<&str>,
961 icon_name: &str,
962 color_hex: &str,
963) -> DomBuilder {
964 let mut val_col = div().children(vec![
965 span().class("text-xs font-bold uppercase tracking-wider text-slate-400").text(label),
966 div().class("text-2xl font-black text-white font-mono mt-1").text(value),
967 ]);
968
969 if let Some(ch) = change {
970 val_col = val_col.child(span().class("text-xs font-semibold text-emerald-400").text(ch));
971 }
972
973 div()
974 .class("p-4 rounded-xl bg-slate-900/70 border border-slate-800/80 flex items-center justify-between shadow-lg")
975 .children(vec![
976 val_col,
977 div()
978 .class("w-10 h-10 rounded-xl flex items-center justify-center bg-slate-800/80 border border-slate-700/50")
979 .child(Icon::render(icon_name, 20, color_hex)),
980 ])
981}
982
983