Skip to main content

logicaffeine_web/ui/components/
combo_indicator.rs

1//! Combo streak indicator component.
2//!
3//! Displays the current combo count with animated flames and multiplier.
4//! Shows "NEW RECORD!" toast when achieving a personal best.
5//!
6//! # Props
7//!
8//! - `combo` - Current combo count (0 hides the component)
9//! - `multiplier` - XP multiplier (e.g., 1.5x)
10//! - `is_new_record` - Whether this combo beats the personal record
11
12use dioxus::prelude::*;
13use crate::ui::components::icon::{Icon, IconVariant, IconSize};
14
15const COMBO_STYLE: &str = r#"
16.combo-record {
17    position: absolute;
18    top: -24px;
19    left: 50%;
20    transform: translateX(-50%);
21    font-size: 12px;
22    color: #fbbf24;
23    font-weight: 600;
24    animation: record-flash 1.5s ease-out forwards;
25    white-space: nowrap;
26    pointer-events: none;
27}
28
29@keyframes record-flash {
30    0% { opacity: 0; transform: translateX(-50%) translateY(10px) scale(0.8); }
31    20% { opacity: 1; transform: translateX(-50%) translateY(0) scale(1.1); }
32    40% { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
33    100% { opacity: 0; transform: translateX(-50%) translateY(-10px) scale(0.9); }
34}
35.combo-indicator {
36    display: flex;
37    align-items: center;
38    gap: 8px;
39    padding: 8px 16px;
40    background: rgba(249, 115, 22, 0.15);
41    border: 1px solid rgba(249, 115, 22, 0.3);
42    border-radius: 24px;
43    animation: combo-pulse 0.3s ease-out;
44}
45
46@keyframes combo-pulse {
47    0% { transform: scale(1); }
48    50% { transform: scale(1.1); }
49    100% { transform: scale(1); }
50}
51
52.combo-flames {
53    display: flex;
54    gap: 2px;
55}
56
57.flame {
58    font-size: 16px;
59    animation: flame-dance 0.5s ease-in-out infinite alternate;
60}
61
62.flame:nth-child(2) { animation-delay: 0.1s; }
63.flame:nth-child(3) { animation-delay: 0.2s; }
64
65@keyframes flame-dance {
66    from { transform: translateY(0) scale(1); }
67    to { transform: translateY(-2px) scale(1.1); }
68}
69
70.combo-count {
71    font-size: 20px;
72    font-weight: 700;
73    color: #f97316;
74}
75
76.combo-multiplier {
77    font-size: 14px;
78    color: #fb923c;
79    font-weight: 500;
80}
81
82.combo-wrapper {
83    position: relative;
84    display: inline-flex;
85}
86"#;
87
88#[component]
89pub fn ComboIndicator(combo: u32, multiplier: f64, is_new_record: bool) -> Element {
90    let mut show_record = use_signal(|| false);
91    let mut last_record_combo = use_signal(|| 0u32);
92
93    if is_new_record && combo > last_record_combo() {
94        show_record.set(true);
95        last_record_combo.set(combo);
96
97        spawn(async move {
98            gloo_timers::future::TimeoutFuture::new(1500).await;
99            show_record.set(false);
100        });
101    }
102
103    if combo == 0 {
104        return rsx! {};
105    }
106
107    let flame_count = (combo.min(5)) as usize;
108
109    rsx! {
110        style { "{COMBO_STYLE}" }
111        div { class: "combo-wrapper",
112            if show_record() {
113                div { class: "combo-record", "NEW RECORD!" }
114            }
115            div { class: "combo-indicator",
116                div { class: "combo-flames",
117                    for _ in 0..flame_count {
118                        span { class: "flame",
119                            Icon { variant: IconVariant::Fire, size: IconSize::Medium, color: "#f97316" }
120                        }
121                    }
122                }
123                span { class: "combo-count", "{combo}x" }
124                span { class: "combo-multiplier", "({multiplier:.1}x)" }
125            }
126        }
127    }
128}