free-coding-models 0.3.37 â 0.3.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -1800
- package/README.md +10 -1
- package/bin/free-coding-models.js +8 -0
- package/package.json +13 -3
- package/src/app.js +30 -0
- package/src/cli-help.js +2 -0
- package/src/command-palette.js +3 -0
- package/src/config.js +7 -0
- package/src/endpoint-installer.js +1 -1
- package/src/key-handler.js +27 -1
- package/src/overlays.js +11 -1
- package/src/shell-env.js +393 -0
- package/src/tool-bootstrap.js +41 -0
- package/src/tool-launchers.js +166 -1
- package/src/tool-metadata.js +12 -0
- package/src/utils.js +12 -0
- package/web/app.legacy.js +900 -0
- package/web/index.html +20 -0
- package/web/server.js +443 -0
- package/web/src/App.jsx +150 -0
- package/web/src/components/analytics/AnalyticsView.jsx +109 -0
- package/web/src/components/analytics/AnalyticsView.module.css +186 -0
- package/web/src/components/atoms/Sparkline.jsx +44 -0
- package/web/src/components/atoms/StabilityCell.jsx +18 -0
- package/web/src/components/atoms/StabilityCell.module.css +8 -0
- package/web/src/components/atoms/StatusDot.jsx +10 -0
- package/web/src/components/atoms/StatusDot.module.css +17 -0
- package/web/src/components/atoms/TierBadge.jsx +10 -0
- package/web/src/components/atoms/TierBadge.module.css +18 -0
- package/web/src/components/atoms/Toast.jsx +25 -0
- package/web/src/components/atoms/Toast.module.css +35 -0
- package/web/src/components/atoms/ToastContainer.jsx +16 -0
- package/web/src/components/atoms/ToastContainer.module.css +10 -0
- package/web/src/components/atoms/VerdictBadge.jsx +13 -0
- package/web/src/components/atoms/VerdictBadge.module.css +19 -0
- package/web/src/components/dashboard/DetailPanel.jsx +131 -0
- package/web/src/components/dashboard/DetailPanel.module.css +99 -0
- package/web/src/components/dashboard/ExportModal.jsx +79 -0
- package/web/src/components/dashboard/ExportModal.module.css +99 -0
- package/web/src/components/dashboard/FilterBar.jsx +73 -0
- package/web/src/components/dashboard/FilterBar.module.css +43 -0
- package/web/src/components/dashboard/ModelTable.jsx +86 -0
- package/web/src/components/dashboard/ModelTable.module.css +46 -0
- package/web/src/components/dashboard/StatsBar.jsx +40 -0
- package/web/src/components/dashboard/StatsBar.module.css +28 -0
- package/web/src/components/layout/Footer.jsx +19 -0
- package/web/src/components/layout/Footer.module.css +10 -0
- package/web/src/components/layout/Header.jsx +38 -0
- package/web/src/components/layout/Header.module.css +73 -0
- package/web/src/components/layout/Sidebar.jsx +41 -0
- package/web/src/components/layout/Sidebar.module.css +76 -0
- package/web/src/components/settings/SettingsView.jsx +264 -0
- package/web/src/components/settings/SettingsView.module.css +377 -0
- package/web/src/global.css +199 -0
- package/web/src/hooks/useFilter.js +83 -0
- package/web/src/hooks/useSSE.js +49 -0
- package/web/src/hooks/useTheme.js +27 -0
- package/web/src/main.jsx +15 -0
- package/web/src/utils/download.js +15 -0
- package/web/src/utils/format.js +42 -0
- package/web/src/utils/ranks.js +37 -0
- package/web/styles.legacy.css +963 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/analytics/AnalyticsView.jsx
|
|
3
|
+
* @description Analytics dashboard page showing provider health, fastest models leaderboard, and tier distribution.
|
|
4
|
+
* đ Purely derived from the `models` SSE data. No API calls needed beyond the live model feed.
|
|
5
|
+
* @functions AnalyticsView â renders the three analytics cards
|
|
6
|
+
*/
|
|
7
|
+
import { useMemo } from 'react'
|
|
8
|
+
import TierBadge from '../atoms/TierBadge.jsx'
|
|
9
|
+
import styles from './AnalyticsView.module.css'
|
|
10
|
+
|
|
11
|
+
const TIER_COLORS = {
|
|
12
|
+
'S+': '#ffd700', S: '#ff8c00', 'A+': '#00c8ff', A: '#3ddc84',
|
|
13
|
+
'A-': '#7ecf7e', 'B+': '#a8a8c8', B: '#808098', C: '#606078',
|
|
14
|
+
}
|
|
15
|
+
const TIERS = ['S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
|
|
16
|
+
|
|
17
|
+
export default function AnalyticsView({ models }) {
|
|
18
|
+
const providerHealth = useMemo(() => {
|
|
19
|
+
const map = {}
|
|
20
|
+
models.forEach((m) => {
|
|
21
|
+
if (!map[m.origin]) map[m.origin] = { total: 0, online: 0, key: m.providerKey }
|
|
22
|
+
map[m.origin].total++
|
|
23
|
+
if (m.status === 'up') map[m.origin].online++
|
|
24
|
+
})
|
|
25
|
+
return Object.entries(map).sort((a, b) => (b[1].online / b[1].total) - (a[1].online / a[1].total))
|
|
26
|
+
}, [models])
|
|
27
|
+
|
|
28
|
+
const leaderboard = useMemo(() => {
|
|
29
|
+
const online = models.filter((m) => m.status === 'up' && m.avg !== Infinity && m.avg < 99000)
|
|
30
|
+
return [...online].sort((a, b) => a.avg - b.avg).slice(0, 10)
|
|
31
|
+
}, [models])
|
|
32
|
+
|
|
33
|
+
const tierCounts = useMemo(() => {
|
|
34
|
+
const counts = {}
|
|
35
|
+
models.forEach((m) => { counts[m.tier] = (counts[m.tier] || 0) + 1 })
|
|
36
|
+
const maxCount = Math.max(...Object.values(counts), 1)
|
|
37
|
+
return TIERS.map((t) => ({ tier: t, count: counts[t] || 0, pct: ((counts[t] || 0) / maxCount) * 100 }))
|
|
38
|
+
}, [models])
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className={styles.page}>
|
|
42
|
+
<div className={styles.pageHeader}>
|
|
43
|
+
<h1 className={styles.pageTitle}>đ Analytics</h1>
|
|
44
|
+
<p className={styles.pageSubtitle}>Real-time insights across all providers and models</p>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div className={styles.grid}>
|
|
48
|
+
<div className={`${styles.card} ${styles.cardWide}`}>
|
|
49
|
+
<h3 className={styles.cardTitle}>Provider Health Overview</h3>
|
|
50
|
+
<div className={styles.cardBody}>
|
|
51
|
+
{providerHealth.length === 0 ? (
|
|
52
|
+
<div className={styles.empty}>Waiting for data...</div>
|
|
53
|
+
) : (
|
|
54
|
+
providerHealth.map(([name, data]) => {
|
|
55
|
+
const pct = data.total > 0 ? Math.round((data.online / data.total) * 100) : 0
|
|
56
|
+
const pctCls = pct > 70 ? styles.pctFast : pct > 30 ? styles.pctMedium : styles.pctSlow
|
|
57
|
+
return (
|
|
58
|
+
<div key={name} className={styles.healthItem}>
|
|
59
|
+
<span className={styles.healthName}>{name}</span>
|
|
60
|
+
<div className={styles.healthBar}>
|
|
61
|
+
<div className={styles.healthFill} style={{ width: `${pct}%` }} />
|
|
62
|
+
</div>
|
|
63
|
+
<span className={`${styles.healthPct} ${pctCls}`}>{pct}%</span>
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
})
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div className={styles.card}>
|
|
72
|
+
<h3 className={styles.cardTitle}>đ Fastest Models</h3>
|
|
73
|
+
<div className={styles.cardBody}>
|
|
74
|
+
{leaderboard.length === 0 ? (
|
|
75
|
+
<div className={styles.empty}>Waiting for ping data...</div>
|
|
76
|
+
) : (
|
|
77
|
+
leaderboard.map((m, i) => {
|
|
78
|
+
const rankCls = i < 3 ? styles[`rank${i + 1}`] : ''
|
|
79
|
+
const medal = i === 0 ? 'đĨ' : i === 1 ? 'đĨ' : i === 2 ? 'đĨ' : (i + 1)
|
|
80
|
+
return (
|
|
81
|
+
<div key={m.modelId} className={styles.leaderItem}>
|
|
82
|
+
<div className={`${styles.leaderRank} ${rankCls}`}>{medal}</div>
|
|
83
|
+
<span className={styles.leaderName}>{m.label}</span>
|
|
84
|
+
<span className={styles.leaderLatency}>{m.avg}ms</span>
|
|
85
|
+
</div>
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<div className={styles.card}>
|
|
93
|
+
<h3 className={styles.cardTitle}>Tier Distribution</h3>
|
|
94
|
+
<div className={styles.cardBody}>
|
|
95
|
+
{tierCounts.map(({ tier, count, pct }) => (
|
|
96
|
+
<div key={tier} className={styles.tierItem}>
|
|
97
|
+
<div className={styles.tierBadge}><TierBadge tier={tier} /></div>
|
|
98
|
+
<div className={styles.tierBar}>
|
|
99
|
+
<div className={styles.tierFill} style={{ width: `${pct}%`, background: TIER_COLORS[tier] }} />
|
|
100
|
+
</div>
|
|
101
|
+
<span className={styles.tierCount}>{count}</span>
|
|
102
|
+
</div>
|
|
103
|
+
))}
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
.page {
|
|
2
|
+
padding: 24px;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.pageHeader {
|
|
6
|
+
margin-bottom: 24px;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.pageTitle {
|
|
10
|
+
font-size: 24px;
|
|
11
|
+
font-weight: 800;
|
|
12
|
+
margin: 0 0 6px;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.pageSubtitle {
|
|
16
|
+
font-size: 13px;
|
|
17
|
+
color: var(--color-text-muted);
|
|
18
|
+
margin: 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.grid {
|
|
22
|
+
display: grid;
|
|
23
|
+
grid-template-columns: repeat(2, 1fr);
|
|
24
|
+
gap: 16px;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.card {
|
|
28
|
+
background: var(--color-bg-card);
|
|
29
|
+
backdrop-filter: blur(12px);
|
|
30
|
+
border: 1px solid var(--color-border);
|
|
31
|
+
border-radius: var(--radius-lg);
|
|
32
|
+
padding: 20px;
|
|
33
|
+
overflow: hidden;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.cardWide {
|
|
37
|
+
grid-column: span 2;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.cardTitle {
|
|
41
|
+
font-size: 14px;
|
|
42
|
+
font-weight: 700;
|
|
43
|
+
margin: 0 0 16px;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.cardBody {
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.empty {
|
|
50
|
+
color: var(--color-text-dim);
|
|
51
|
+
text-align: center;
|
|
52
|
+
padding: 20px 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.healthItem {
|
|
56
|
+
display: flex;
|
|
57
|
+
align-items: center;
|
|
58
|
+
gap: 12px;
|
|
59
|
+
padding: 8px 0;
|
|
60
|
+
border-bottom: 1px solid var(--color-border);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.healthItem:last-child {
|
|
64
|
+
border-bottom: none;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.healthName {
|
|
68
|
+
width: 120px;
|
|
69
|
+
font-size: 12px;
|
|
70
|
+
font-weight: 600;
|
|
71
|
+
flex-shrink: 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.healthBar {
|
|
75
|
+
flex: 1;
|
|
76
|
+
height: 8px;
|
|
77
|
+
border-radius: 4px;
|
|
78
|
+
background: var(--color-surface);
|
|
79
|
+
overflow: hidden;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.healthFill {
|
|
83
|
+
height: 100%;
|
|
84
|
+
border-radius: 4px;
|
|
85
|
+
background: linear-gradient(90deg, var(--color-accent), var(--color-success));
|
|
86
|
+
transition: width var(--transition-medium);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.healthPct {
|
|
90
|
+
width: 40px;
|
|
91
|
+
text-align: right;
|
|
92
|
+
font-family: var(--font-mono);
|
|
93
|
+
font-size: 12px;
|
|
94
|
+
font-weight: 600;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.pctFast { color: #00ff88; }
|
|
98
|
+
.pctMedium { color: #ffaa00; }
|
|
99
|
+
.pctSlow { color: #ff4444; }
|
|
100
|
+
|
|
101
|
+
.leaderItem {
|
|
102
|
+
display: flex;
|
|
103
|
+
align-items: center;
|
|
104
|
+
gap: 10px;
|
|
105
|
+
padding: 8px 0;
|
|
106
|
+
border-bottom: 1px solid var(--color-border);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.leaderItem:last-child {
|
|
110
|
+
border-bottom: none;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.leaderRank {
|
|
114
|
+
width: 28px;
|
|
115
|
+
height: 28px;
|
|
116
|
+
display: flex;
|
|
117
|
+
align-items: center;
|
|
118
|
+
justify-content: center;
|
|
119
|
+
border-radius: 50%;
|
|
120
|
+
font-size: 12px;
|
|
121
|
+
font-weight: 700;
|
|
122
|
+
background: var(--color-surface);
|
|
123
|
+
color: var(--color-text-dim);
|
|
124
|
+
flex-shrink: 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.rank1 { background: rgba(255, 215, 0, 0.15); color: #ffd700; }
|
|
128
|
+
.rank2 { background: rgba(192, 192, 192, 0.15); color: #c0c0c0; }
|
|
129
|
+
.rank3 { background: rgba(205, 127, 50, 0.15); color: #cd7f32; }
|
|
130
|
+
|
|
131
|
+
.leaderName {
|
|
132
|
+
flex: 1;
|
|
133
|
+
font-size: 12px;
|
|
134
|
+
font-weight: 600;
|
|
135
|
+
min-width: 0;
|
|
136
|
+
overflow: hidden;
|
|
137
|
+
text-overflow: ellipsis;
|
|
138
|
+
white-space: nowrap;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.leaderLatency {
|
|
142
|
+
font-family: var(--font-mono);
|
|
143
|
+
font-size: 12px;
|
|
144
|
+
font-weight: 600;
|
|
145
|
+
color: var(--color-success);
|
|
146
|
+
flex-shrink: 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.tierItem {
|
|
150
|
+
display: flex;
|
|
151
|
+
align-items: center;
|
|
152
|
+
gap: 10px;
|
|
153
|
+
padding: 6px 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.tierBadge {
|
|
157
|
+
width: 40px;
|
|
158
|
+
flex-shrink: 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.tierBar {
|
|
162
|
+
flex: 1;
|
|
163
|
+
height: 10px;
|
|
164
|
+
border-radius: 5px;
|
|
165
|
+
background: var(--color-surface);
|
|
166
|
+
overflow: hidden;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.tierFill {
|
|
170
|
+
height: 100%;
|
|
171
|
+
border-radius: 5px;
|
|
172
|
+
transition: width var(--transition-medium);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.tierCount {
|
|
176
|
+
width: 30px;
|
|
177
|
+
text-align: right;
|
|
178
|
+
font-family: var(--font-mono);
|
|
179
|
+
font-size: 12px;
|
|
180
|
+
color: var(--color-text-muted);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
@media (max-width: 1024px) {
|
|
184
|
+
.grid { grid-template-columns: 1fr; }
|
|
185
|
+
.cardWide { grid-column: span 1; }
|
|
186
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/atoms/Sparkline.jsx
|
|
3
|
+
* @description SVG sparkline chart for ping history trend visualization.
|
|
4
|
+
* đ Renders a polyline with gradient area fill and endpoint dot.
|
|
5
|
+
*/
|
|
6
|
+
import { useMemo } from 'react'
|
|
7
|
+
|
|
8
|
+
export default function Sparkline({ history }) {
|
|
9
|
+
const svg = useMemo(() => {
|
|
10
|
+
if (!history || history.length < 2) return null
|
|
11
|
+
const valid = history.filter((p) => p.code === '200' || p.code === '401')
|
|
12
|
+
if (valid.length < 2) return null
|
|
13
|
+
|
|
14
|
+
const values = valid.map((p) => p.ms)
|
|
15
|
+
const max = Math.max(...values, 1)
|
|
16
|
+
const min = Math.min(...values, 0)
|
|
17
|
+
const range = max - min || 1
|
|
18
|
+
const w = 80,
|
|
19
|
+
h = 22
|
|
20
|
+
const step = w / (values.length - 1)
|
|
21
|
+
|
|
22
|
+
const points = values
|
|
23
|
+
.map((v, i) => {
|
|
24
|
+
const x = i * step
|
|
25
|
+
const y = h - ((v - min) / range) * (h - 4) - 2
|
|
26
|
+
return `${x.toFixed(1)},${y.toFixed(1)}`
|
|
27
|
+
})
|
|
28
|
+
.join(' ')
|
|
29
|
+
|
|
30
|
+
const lastVal = values[values.length - 1]
|
|
31
|
+
const color = lastVal < 500 ? '#00ff88' : lastVal < 1500 ? '#ffaa00' : '#ff4444'
|
|
32
|
+
const lastX = ((values.length - 1) * step).toFixed(1)
|
|
33
|
+
const lastY = (h - ((lastVal - min) / range) * (h - 4) - 2).toFixed(1)
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<svg className="sparkline-svg" width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
|
|
37
|
+
<polyline fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" points={points} opacity="0.8" />
|
|
38
|
+
<circle cx={lastX} cy={lastY} r="2.5" fill={color} />
|
|
39
|
+
</svg>
|
|
40
|
+
)
|
|
41
|
+
}, [history])
|
|
42
|
+
|
|
43
|
+
return svg || null
|
|
44
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/atoms/StabilityCell.jsx
|
|
3
|
+
* @description Renders stability score with a progress bar and numeric value.
|
|
4
|
+
*/
|
|
5
|
+
import styles from './StabilityCell.module.css'
|
|
6
|
+
|
|
7
|
+
export default function StabilityCell({ score }) {
|
|
8
|
+
if (score == null || score < 0) return <span className={styles.none}>â</span>
|
|
9
|
+
const cls = score >= 70 ? 'high' : score >= 40 ? 'mid' : 'low'
|
|
10
|
+
return (
|
|
11
|
+
<div className={styles.cell}>
|
|
12
|
+
<div className={styles.bar}>
|
|
13
|
+
<div className={`${styles.fill} ${styles[cls]}`} style={{ width: `${score}%` }} />
|
|
14
|
+
</div>
|
|
15
|
+
<span className={styles.value}>{score}</span>
|
|
16
|
+
</div>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
.cell { display: flex; align-items: center; gap: 6px; }
|
|
2
|
+
.bar { width: 48px; height: 6px; border-radius: 3px; background: var(--color-surface); overflow: hidden; }
|
|
3
|
+
.fill { height: 100%; border-radius: 3px; transition: width 250ms cubic-bezier(0.16, 1, 0.3, 1); }
|
|
4
|
+
.high { background: linear-gradient(90deg, #00ff88, #76b900); }
|
|
5
|
+
.mid { background: linear-gradient(90deg, #ffaa00, #ff8c00); }
|
|
6
|
+
.low { background: linear-gradient(90deg, #ff4444, #cc0000); }
|
|
7
|
+
.value { font-family: 'JetBrains Mono', monospace; font-size: 12px; font-weight: 600; }
|
|
8
|
+
.none { color: var(--color-text-dim); }
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/atoms/StatusDot.jsx
|
|
3
|
+
* @description Renders a colored status indicator dot (green=up, red=down, gray=pending).
|
|
4
|
+
*/
|
|
5
|
+
import styles from './StatusDot.module.css'
|
|
6
|
+
|
|
7
|
+
export default function StatusDot({ status }) {
|
|
8
|
+
const cls = status === 'up' ? styles.up : status === 'timeout' ? styles.timeout : status === 'down' ? styles.down : styles.pending
|
|
9
|
+
return <span className={`${styles.dot} ${cls}`} />
|
|
10
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
.dot {
|
|
2
|
+
display: inline-block;
|
|
3
|
+
width: 8px;
|
|
4
|
+
height: 8px;
|
|
5
|
+
border-radius: 50%;
|
|
6
|
+
margin-right: 6px;
|
|
7
|
+
vertical-align: middle;
|
|
8
|
+
}
|
|
9
|
+
.up { background: #00ff88; box-shadow: 0 0 6px rgba(0,255,136,0.4); }
|
|
10
|
+
.down { background: #ff4444; }
|
|
11
|
+
.timeout { background: #ff4444; }
|
|
12
|
+
.pending { background: #555570; animation: pulse 1.5s ease-in-out infinite; }
|
|
13
|
+
|
|
14
|
+
@keyframes pulse {
|
|
15
|
+
0%, 100% { box-shadow: 0 0 0 0 rgba(118,185,0,0.25); }
|
|
16
|
+
50% { box-shadow: 0 0 0 6px transparent; }
|
|
17
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/atoms/TierBadge.jsx
|
|
3
|
+
* @description Renders a tier badge (S+, S, A+, etc.) with color-coded styling.
|
|
4
|
+
*/
|
|
5
|
+
import styles from './TierBadge.module.css'
|
|
6
|
+
|
|
7
|
+
export default function TierBadge({ tier }) {
|
|
8
|
+
const cls = tier.replace('+', 'plus').replace('-', 'minus').toLowerCase()
|
|
9
|
+
return <span className={`${styles.badge} ${styles[`tier_${cls}`]}`}>{tier}</span>
|
|
10
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
.badge {
|
|
2
|
+
display: inline-block;
|
|
3
|
+
font-family: 'JetBrains Mono', monospace;
|
|
4
|
+
font-weight: 700;
|
|
5
|
+
font-size: 11px;
|
|
6
|
+
padding: 2px 8px;
|
|
7
|
+
border-radius: 4px;
|
|
8
|
+
text-align: center;
|
|
9
|
+
min-width: 32px;
|
|
10
|
+
}
|
|
11
|
+
.tier_splus { background: rgba(255,215,0,0.15); color: #ffd700; border: 1px solid rgba(255,215,0,0.3); }
|
|
12
|
+
.tier_s { background: rgba(255,140,0,0.12); color: #ff8c00; border: 1px solid rgba(255,140,0,0.25); }
|
|
13
|
+
.tier_aplus { background: rgba(0,200,255,0.1); color: #00c8ff; border: 1px solid rgba(0,200,255,0.2); }
|
|
14
|
+
.tier_a { background: rgba(61,220,132,0.1); color: #3ddc84; border: 1px solid rgba(61,220,132,0.2); }
|
|
15
|
+
.tier_aminus{ background: rgba(126,207,126,0.1); color: #7ecf7e; border: 1px solid rgba(126,207,126,0.2); }
|
|
16
|
+
.tier_bplus { background: rgba(168,168,200,0.1); color: #a8a8c8; border: 1px solid rgba(168,168,200,0.15); }
|
|
17
|
+
.tier_b { background: rgba(128,128,152,0.1); color: #808098; border: 1px solid rgba(128,128,152,0.15); }
|
|
18
|
+
.tier_c { background: rgba(96,96,120,0.1); color: #606078; border: 1px solid rgba(96,96,120,0.15); }
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/atoms/Toast.jsx
|
|
3
|
+
* @description Toast notification component with auto-dismiss and animated entrance/exit.
|
|
4
|
+
*/
|
|
5
|
+
import { useEffect, useRef } from 'react'
|
|
6
|
+
import styles from './Toast.module.css'
|
|
7
|
+
|
|
8
|
+
const ICONS = { success: 'â
', error: 'â', warning: 'â ī¸', info: 'âšī¸' }
|
|
9
|
+
|
|
10
|
+
export default function Toast({ message, type = 'info', duration = 3500, onDismiss }) {
|
|
11
|
+
const timerRef = useRef(null)
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
timerRef.current = setTimeout(onDismiss, duration)
|
|
15
|
+
return () => clearTimeout(timerRef.current)
|
|
16
|
+
}, [duration, onDismiss])
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className={`${styles.toast} ${styles[type]}`}>
|
|
20
|
+
<span className={styles.icon}>{ICONS[type] || 'đ'}</span>
|
|
21
|
+
<span className={styles.message}>{message}</span>
|
|
22
|
+
<button className={styles.close} onClick={onDismiss}>Ã</button>
|
|
23
|
+
</div>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
.toast {
|
|
2
|
+
pointer-events: auto;
|
|
3
|
+
display: flex;
|
|
4
|
+
align-items: center;
|
|
5
|
+
gap: 10px;
|
|
6
|
+
padding: 12px 18px;
|
|
7
|
+
border-radius: var(--radius-md, 10px);
|
|
8
|
+
background: var(--color-bg-elevated);
|
|
9
|
+
border: 1px solid var(--color-border);
|
|
10
|
+
box-shadow: var(--shadow-lg);
|
|
11
|
+
font-size: 13px;
|
|
12
|
+
font-weight: 500;
|
|
13
|
+
animation: slideIn 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
14
|
+
min-width: 280px;
|
|
15
|
+
max-width: 420px;
|
|
16
|
+
}
|
|
17
|
+
.success { border-left: 4px solid #00ff88; }
|
|
18
|
+
.error { border-left: 4px solid #ff4444; }
|
|
19
|
+
.warning { border-left: 4px solid #ffaa00; }
|
|
20
|
+
.info { border-left: 4px solid #06b6d4; }
|
|
21
|
+
.icon { font-size: 18px; flex-shrink: 0; }
|
|
22
|
+
.message { flex: 1; }
|
|
23
|
+
.close {
|
|
24
|
+
background: none;
|
|
25
|
+
border: none;
|
|
26
|
+
color: var(--color-text-dim);
|
|
27
|
+
cursor: pointer;
|
|
28
|
+
font-size: 16px;
|
|
29
|
+
padding: 0;
|
|
30
|
+
line-height: 1;
|
|
31
|
+
}
|
|
32
|
+
.exiting { animation: slideOut 300ms cubic-bezier(0.16, 1, 0.3, 1) forwards; }
|
|
33
|
+
|
|
34
|
+
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
|
35
|
+
@keyframes slideOut { from { transform: translateX(0); opacity: 1; } to { transform: translateX(100%); opacity: 0; } }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/atoms/ToastContainer.jsx
|
|
3
|
+
* @description Fixed-position container that renders all active toasts.
|
|
4
|
+
*/
|
|
5
|
+
import styles from './ToastContainer.module.css'
|
|
6
|
+
import Toast from './Toast.jsx'
|
|
7
|
+
|
|
8
|
+
export default function ToastContainer({ toasts, dismissToast }) {
|
|
9
|
+
return (
|
|
10
|
+
<div className={styles.container}>
|
|
11
|
+
{toasts.map((t) => (
|
|
12
|
+
<Toast key={t.id} message={t.message} type={t.type} onDismiss={() => dismissToast(t.id)} />
|
|
13
|
+
))}
|
|
14
|
+
</div>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/atoms/VerdictBadge.jsx
|
|
3
|
+
* @description Renders a verdict badge (Perfect, Normal, Slow, etc.) with styled pill.
|
|
4
|
+
*/
|
|
5
|
+
import { verdictCls } from '../../utils/ranks.js'
|
|
6
|
+
import styles from './VerdictBadge.module.css'
|
|
7
|
+
|
|
8
|
+
export default function VerdictBadge({ verdict, httpCode }) {
|
|
9
|
+
if (!verdict) return <span className={`${styles.badge} ${styles.pending}`}>Pending</span>
|
|
10
|
+
if (httpCode === '429') return <span className={`${styles.badge} ${styles.ratelimited}`}>â ī¸ Rate Limited</span>
|
|
11
|
+
const cls = verdictCls(verdict)
|
|
12
|
+
return <span className={`${styles.badge} ${styles[cls]}`}>{verdict}</span>
|
|
13
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
.badge {
|
|
2
|
+
display: inline-block;
|
|
3
|
+
font-size: 10px;
|
|
4
|
+
font-weight: 700;
|
|
5
|
+
text-transform: uppercase;
|
|
6
|
+
padding: 2px 8px;
|
|
7
|
+
border-radius: 999px;
|
|
8
|
+
letter-spacing: 0.5px;
|
|
9
|
+
}
|
|
10
|
+
.perfect { background: rgba(0,255,136,0.12); color: #00ff88; border: 1px solid rgba(0,255,136,0.25); }
|
|
11
|
+
.normal { background: rgba(118,185,0,0.12); color: #76b900; border: 1px solid rgba(118,185,0,0.25); }
|
|
12
|
+
.slow { background: rgba(255,170,0,0.12); color: #ffaa00; border: 1px solid rgba(255,170,0,0.25); }
|
|
13
|
+
.spiky { background: rgba(255,102,0,0.12); color: #ff6600; border: 1px solid rgba(255,102,0,0.25); }
|
|
14
|
+
.veryslow { background: rgba(255,68,68,0.12); color: #ff4444; border: 1px solid rgba(255,68,68,0.2); }
|
|
15
|
+
.overloaded { background: rgba(255,34,34,0.12); color: #ff2222; border: 1px solid rgba(255,34,34,0.2); }
|
|
16
|
+
.unstable { background: rgba(204,0,0,0.12); color: #cc0000; border: 1px solid rgba(204,0,0,0.2); }
|
|
17
|
+
.notactive { background: rgba(85,85,112,0.12); color: #555570; border: 1px solid rgba(85,85,112,0.15); }
|
|
18
|
+
.pending { background: rgba(68,68,96,0.1); color: #444460; border: 1px solid rgba(68,68,96,0.15); }
|
|
19
|
+
.ratelimited { background: rgba(255,170,0,0.15); color: #ffaa00; border: 1px solid rgba(255,170,0,0.3); }
|