free-coding-models 0.3.37 → 0.3.40

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.
Files changed (58) hide show
  1. package/CHANGELOG.md +10 -1794
  2. package/README.md +4 -1
  3. package/bin/free-coding-models.js +8 -0
  4. package/package.json +13 -3
  5. package/src/app.js +3 -0
  6. package/src/cli-help.js +2 -0
  7. package/src/command-palette.js +3 -0
  8. package/src/endpoint-installer.js +1 -1
  9. package/src/tool-bootstrap.js +34 -0
  10. package/src/tool-launchers.js +137 -1
  11. package/src/tool-metadata.js +9 -0
  12. package/src/utils.js +10 -0
  13. package/web/app.legacy.js +900 -0
  14. package/web/index.html +20 -0
  15. package/web/server.js +382 -0
  16. package/web/src/App.jsx +150 -0
  17. package/web/src/components/analytics/AnalyticsView.jsx +109 -0
  18. package/web/src/components/analytics/AnalyticsView.module.css +186 -0
  19. package/web/src/components/atoms/Sparkline.jsx +44 -0
  20. package/web/src/components/atoms/StabilityCell.jsx +18 -0
  21. package/web/src/components/atoms/StabilityCell.module.css +8 -0
  22. package/web/src/components/atoms/StatusDot.jsx +10 -0
  23. package/web/src/components/atoms/StatusDot.module.css +17 -0
  24. package/web/src/components/atoms/TierBadge.jsx +10 -0
  25. package/web/src/components/atoms/TierBadge.module.css +18 -0
  26. package/web/src/components/atoms/Toast.jsx +25 -0
  27. package/web/src/components/atoms/Toast.module.css +35 -0
  28. package/web/src/components/atoms/ToastContainer.jsx +16 -0
  29. package/web/src/components/atoms/ToastContainer.module.css +10 -0
  30. package/web/src/components/atoms/VerdictBadge.jsx +13 -0
  31. package/web/src/components/atoms/VerdictBadge.module.css +19 -0
  32. package/web/src/components/dashboard/DetailPanel.jsx +131 -0
  33. package/web/src/components/dashboard/DetailPanel.module.css +99 -0
  34. package/web/src/components/dashboard/ExportModal.jsx +79 -0
  35. package/web/src/components/dashboard/ExportModal.module.css +99 -0
  36. package/web/src/components/dashboard/FilterBar.jsx +73 -0
  37. package/web/src/components/dashboard/FilterBar.module.css +43 -0
  38. package/web/src/components/dashboard/ModelTable.jsx +86 -0
  39. package/web/src/components/dashboard/ModelTable.module.css +46 -0
  40. package/web/src/components/dashboard/StatsBar.jsx +40 -0
  41. package/web/src/components/dashboard/StatsBar.module.css +28 -0
  42. package/web/src/components/layout/Footer.jsx +19 -0
  43. package/web/src/components/layout/Footer.module.css +10 -0
  44. package/web/src/components/layout/Header.jsx +38 -0
  45. package/web/src/components/layout/Header.module.css +73 -0
  46. package/web/src/components/layout/Sidebar.jsx +41 -0
  47. package/web/src/components/layout/Sidebar.module.css +76 -0
  48. package/web/src/components/settings/SettingsView.jsx +264 -0
  49. package/web/src/components/settings/SettingsView.module.css +377 -0
  50. package/web/src/global.css +199 -0
  51. package/web/src/hooks/useFilter.js +83 -0
  52. package/web/src/hooks/useSSE.js +49 -0
  53. package/web/src/hooks/useTheme.js +27 -0
  54. package/web/src/main.jsx +15 -0
  55. package/web/src/utils/download.js +15 -0
  56. package/web/src/utils/format.js +42 -0
  57. package/web/src/utils/ranks.js +37 -0
  58. package/web/styles.legacy.css +963 -0
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @file web/src/components/dashboard/ExportModal.jsx
3
+ * @description Modal dialog for exporting model data as JSON, CSV, or clipboard text.
4
+ * 📖 Triggered by the export button in the header. Accepts `models` (filtered list),
5
+ * `onClose` callback, and `onToast` for feedback notifications.
6
+ * @functions ExportModal → renders the modal with three export option buttons
7
+ */
8
+ import { downloadFile } from '../../utils/download.js'
9
+ import styles from './ExportModal.module.css'
10
+
11
+ export default function ExportModal({ models, onClose, onToast }) {
12
+ if (!models) return null
13
+
14
+ const handleJson = () => {
15
+ const data = JSON.stringify(models, null, 2)
16
+ downloadFile(data, 'free-coding-models-export.json', 'application/json')
17
+ onToast?.('Exported as JSON', 'success')
18
+ onClose()
19
+ }
20
+
21
+ const handleCsv = () => {
22
+ const headers = ['Rank', 'Tier', 'Model', 'Provider', 'SWE%', 'Context', 'LatestPing', 'AvgPing', 'Stability', 'Verdict', 'Uptime']
23
+ const rows = models.map((m, i) =>
24
+ [i + 1, m.tier, m.label, m.origin, m.sweScore || '', m.ctx || '', m.latestPing || '', m.avg === Infinity ? '' : m.avg, m.stability || '', m.verdict || '', m.uptime || ''].join(',')
25
+ )
26
+ const csv = [headers.join(','), ...rows].join('\n')
27
+ downloadFile(csv, 'free-coding-models-export.csv', 'text/csv')
28
+ onToast?.('Exported as CSV', 'success')
29
+ onClose()
30
+ }
31
+
32
+ const handleClipboard = async () => {
33
+ const online = models.filter(m => m.status === 'up')
34
+ const text = `free-coding-models Dashboard Export\n` +
35
+ `Total: ${models.length} | Online: ${online.length}\n\n` +
36
+ online.slice(0, 20).map((m, i) =>
37
+ `${i + 1}. ${m.label} [${m.tier}] — ${m.avg !== Infinity ? m.avg + 'ms' : 'N/A'} (${m.origin})`
38
+ ).join('\n')
39
+ await navigator.clipboard.writeText(text)
40
+ onToast?.('Copied to clipboard', 'success')
41
+ onClose()
42
+ }
43
+
44
+ return (
45
+ <div className={styles.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
46
+ <div className={styles.modal}>
47
+ <div className={styles.modalHeader}>
48
+ <h2 className={styles.modalTitle}>📤 Export Data</h2>
49
+ <button className={styles.modalClose} onClick={onClose}>&times;</button>
50
+ </div>
51
+ <div className={styles.modalBody}>
52
+ <div className={styles.options}>
53
+ <button className={styles.option} onClick={handleJson}>
54
+ <span className={styles.optionIcon}>{'{ }'}</span>
55
+ <div>
56
+ <span className={styles.optionLabel}>Export as JSON</span>
57
+ <span className={styles.optionDesc}>Full model data with all metrics</span>
58
+ </div>
59
+ </button>
60
+ <button className={styles.option} onClick={handleCsv}>
61
+ <span className={styles.optionIcon}>📊</span>
62
+ <div>
63
+ <span className={styles.optionLabel}>Export as CSV</span>
64
+ <span className={styles.optionDesc}>Spreadsheet-compatible format</span>
65
+ </div>
66
+ </button>
67
+ <button className={styles.option} onClick={handleClipboard}>
68
+ <span className={styles.optionIcon}>📋</span>
69
+ <div>
70
+ <span className={styles.optionLabel}>Copy to Clipboard</span>
71
+ <span className={styles.optionDesc}>Copy model summary as text</span>
72
+ </div>
73
+ </button>
74
+ </div>
75
+ </div>
76
+ </div>
77
+ </div>
78
+ )
79
+ }
@@ -0,0 +1,99 @@
1
+ .overlay {
2
+ position: fixed;
3
+ inset: 0;
4
+ z-index: 1000;
5
+ background: rgba(0, 0, 0, 0.6);
6
+ backdrop-filter: blur(4px);
7
+ display: flex;
8
+ align-items: center;
9
+ justify-content: center;
10
+ animation: fadeIn 200ms ease-out;
11
+ }
12
+
13
+ .overlay[hidden] { display: none; }
14
+
15
+ @keyframes fadeIn {
16
+ from { opacity: 0; } to { opacity: 1; } }
17
+
18
+ .modal {
19
+ background: var(--color-bg-elevated);
20
+ border: 1px solid var(--color-border);
21
+ border-radius: var(--radius-xl);
22
+ width: min(500px, 90vw);
23
+ max-height: 80vh;
24
+ overflow-y: auto;
25
+ box-shadow: var(--shadow-lg);
26
+ animation: modalSlideIn 300ms var(--ease-out);
27
+ }
28
+ @keyframes modalSlideIn {
29
+ from { transform: translateY(20px) scale(0.96); opacity: 0; }
30
+ to { transform: translateY(0) scale(1); opacity: 1; }
31
+ }
32
+
33
+ .modalHeader {
34
+ display: flex;
35
+ align-items: center;
36
+ justify-content: space-between;
37
+ padding: 20px 24px;
38
+ border-bottom: 1px solid var(--color-border);
39
+ }
40
+ .modalTitle {
41
+ font-size: 18px;
42
+ font-weight: 700;
43
+ margin: 0;
44
+ }
45
+ .modalClose {
46
+ background: none;
47
+ border: none;
48
+ color: var(--color-text-muted);
49
+ font-size: 24px;
50
+ cursor: pointer;
51
+ padding: 0;
52
+ line-height: 1;
53
+ }
54
+ .modalClose:hover {
55
+ color: var(--color-text);
56
+ }
57
+ .modalBody {
58
+ padding: 24px;
59
+ }
60
+ .options {
61
+ display: flex;
62
+ flex-direction: column;
63
+ gap: 10px;
64
+ }
65
+ .option {
66
+ display: flex;
67
+ align-items: center;
68
+ gap: 14px;
69
+ padding: 14px 18px;
70
+ border: 1px solid var(--color-border);
71
+ border-radius: var(--radius-md);
72
+ background: var(--color-surface);
73
+ cursor: pointer;
74
+ transition: all var(--transition-fast);
75
+ text-align: left;
76
+ font-family: var(--font-sans);
77
+ color: var(--color-text);
78
+ width: 100%;
79
+ }
80
+ .option:hover {
81
+ border-color: var(--color-accent);
82
+ background: var(--color-accent-dim);
83
+ }
84
+ .optionIcon {
85
+ font-size: 24px;
86
+ flex-shrink: 0;
87
+ }
88
+ .optionText {}
89
+ .optionLabel {
90
+ display: block;
91
+ font-weight: 600;
92
+ font-size: 14px;
93
+ }
94
+ .optionDesc {
95
+ display: block;
96
+ font-size: 11px;
97
+ color: var(--color-text-muted);
98
+ margin-top: 2px;
99
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @file web/src/components/dashboard/FilterBar.jsx
3
+ * @description Filter controls for tier, status, provider, and search + live indicator.
4
+ */
5
+ import styles from './FilterBar.module.css'
6
+
7
+ const TIERS = ['All', 'S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
8
+ const STATUSES = [
9
+ { key: 'all', label: 'All' },
10
+ { key: 'up', label: 'Online' },
11
+ { key: 'down', label: 'Offline' },
12
+ { key: 'pending', label: 'Pending' },
13
+ ]
14
+
15
+ export default function FilterBar({
16
+ filterTier, setFilterTier,
17
+ filterStatus, setFilterStatus,
18
+ filterProvider, setFilterProvider,
19
+ providers,
20
+ }) {
21
+ return (
22
+ <section className={styles.filters}>
23
+ <div className={styles.group}>
24
+ <label className={styles.filterLabel}>Tier</label>
25
+ <div className={styles.tierRow}>
26
+ {TIERS.map(t => (
27
+ <button
28
+ key={t}
29
+ className={`${styles.tierBtn} ${filterTier === t ? styles.active : ''}`}
30
+ onClick={() => setFilterTier(t)}
31
+ >
32
+ {t}
33
+ </button>
34
+ ))}
35
+ </div>
36
+ </div>
37
+ <div className={styles.group}>
38
+ <label className={styles.filterLabel}>Status</label>
39
+ <div className={styles.tierRow}>
40
+ {STATUSES.map(s => (
41
+ <button
42
+ key={s.key}
43
+ className={`${styles.tierBtn} ${filterStatus === s.key ? styles.active : ''}`}
44
+ onClick={() => setFilterStatus(s.key)}
45
+ >
46
+ {s.label}
47
+ </button>
48
+ ))}
49
+ </div>
50
+ </div>
51
+ <div className={styles.group}>
52
+ <label className={styles.filterLabel}>Provider</label>
53
+ <select
54
+ className={styles.providerSelect}
55
+ value={filterProvider}
56
+ onChange={e => setFilterProvider(e.target.value)}
57
+ >
58
+ <option value="all">All Providers</option>
59
+ {providers.map(p => (
60
+ <option key={p.key} value={p.key}>{p.name} ({p.count})</option>
61
+ ))}
62
+ </select>
63
+ </div>
64
+ <div className={styles.spacer} />
65
+ <div className={styles.group}>
66
+ <div className={styles.live}>
67
+ <span className={styles.liveDot} />
68
+ <span>LIVE</span>
69
+ </div>
70
+ </div>
71
+ </section>
72
+ )
73
+ }
@@ -0,0 +1,43 @@
1
+ .filters {
2
+ position: relative; z-index: 1;
3
+ display: flex; align-items: center; gap: 20px; flex-wrap: wrap;
4
+ padding: 16px 24px;
5
+ }
6
+ .group { display: flex; align-items: center; gap: 8px; }
7
+ .filterLabel {
8
+ font-size: 11px; font-weight: 600; color: var(--color-text-muted);
9
+ text-transform: uppercase; letter-spacing: 0.5px;
10
+ }
11
+ .tierRow { display: flex; gap: 4px; }
12
+ .tierBtn {
13
+ font-size: 12px; font-weight: 600; font-family: var(--font-mono);
14
+ padding: 4px 10px; border-radius: 6px;
15
+ border: 1px solid var(--color-border); background: var(--color-surface);
16
+ color: var(--color-text-muted); cursor: pointer; transition: all 150ms;
17
+ }
18
+ .tierBtn:hover { background: var(--color-bg-hover); color: var(--color-text); }
19
+ .active {
20
+ background: var(--color-accent) !important; color: #000 !important;
21
+ border-color: var(--color-accent) !important; font-weight: 700;
22
+ }
23
+ .providerSelect {
24
+ background: var(--color-surface); color: var(--color-text);
25
+ border: 1px solid var(--color-border); border-radius: 6px;
26
+ padding: 5px 10px; font-size: 12px; font-family: var(--font-sans);
27
+ cursor: pointer; outline: none;
28
+ }
29
+ .providerSelect:focus { border-color: var(--color-accent); }
30
+ .spacer { flex: 1; }
31
+ .live {
32
+ display: flex; align-items: center; gap: 6px;
33
+ font-size: 11px; font-weight: 700; color: var(--color-accent);
34
+ text-transform: uppercase; letter-spacing: 1px;
35
+ }
36
+ .liveDot {
37
+ width: 8px; height: 8px; border-radius: 50%;
38
+ background: var(--color-accent); animation: pulseDot 2s ease-in-out infinite;
39
+ }
40
+ @keyframes pulseDot {
41
+ 0%, 100% { box-shadow: 0 0 0 0 var(--color-accent-glow); }
42
+ 50% { box-shadow: 0 0 0 6px transparent; }
43
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @file web/src/components/dashboard/ModelTable.jsx
3
+ * @description Main data table with medal rankings for top 3 fastest models.
4
+ */
5
+ import { useMemo } from 'react'
6
+ import TierBadge from '../atoms/TierBadge.jsx'
7
+ import StatusDot from '../atoms/StatusDot.jsx'
8
+ import VerdictBadge from '../atoms/VerdictBadge.jsx'
9
+ import StabilityCell from '../atoms/StabilityCell.jsx'
10
+ import Sparkline from '../atoms/Sparkline.jsx'
11
+ import { pingClass } from '../../utils/format.js'
12
+ import { sweClass } from '../../utils/ranks.js'
13
+ import styles from './ModelTable.module.css'
14
+
15
+ export default function ModelTable({ filtered, onSelectModel }) {
16
+ const top3Ids = useMemo(() => {
17
+ const online = filtered.filter(m => m.status === 'up' && m.avg !== Infinity)
18
+ return new Set([...online].sort((a, b) => a.avg - b.avg).slice(0, 3).map(m => m.modelId))
19
+ }, [filtered])
20
+
21
+ const top3Arr = useMemo(() => {
22
+ const online = filtered.filter(m => m.status === 'up' && m.avg !== Infinity)
23
+ return [...online].sort((a, b) => a.avg - b.avg).slice(0, 3).map(m => m.modelId)
24
+ }, [filtered])
25
+
26
+ if (filtered.length === 0) {
27
+ return <div className={styles.empty}>No models match your filters</div>
28
+ }
29
+
30
+ return (
31
+ <div className={styles.container}>
32
+ <table className={styles.table}>
33
+ <thead>
34
+ <tr>
35
+ <th className={styles.th}>#</th>
36
+ <th className={styles.th}>Tier</th>
37
+ <th className={styles.th}>Model</th>
38
+ <th className={styles.th}>Provider</th>
39
+ <th className={styles.th}>SWE %</th>
40
+ <th className={styles.th}>Ctx</th>
41
+ <th className={styles.th}>Ping</th>
42
+ <th className={styles.th}>Avg</th>
43
+ <th className={styles.th}>Stability</th>
44
+ <th className={styles.th}>Verdict</th>
45
+ <th className={styles.th}>Uptime</th>
46
+ <th className={styles.th}>Trend</th>
47
+ </tr>
48
+ </thead>
49
+ <tbody>
50
+ {filtered.map((m, i) => {
51
+ const rankIdx = top3Arr.indexOf(m.modelId)
52
+ const medal = rankIdx === 0 ? '🥇' : rankIdx === 1 ? '🥈' : rankIdx === 2 ? '🥉' : ''
53
+ const rowCls = rankIdx >= 0 ? styles[`rank${rankIdx + 1}`] : ''
54
+ return (
55
+ <tr key={m.modelId} className={rowCls} onClick={() => onSelectModel(m)}>
56
+ <td className={styles.tdRank}>{medal || (i + 1)}</td>
57
+ <td><TierBadge tier={m.tier} /></td>
58
+ <td>
59
+ <div className={styles.modelCell}>
60
+ <StatusDot status={m.status} />
61
+ <span className={styles.modelName}>{m.label}</span>
62
+ {!m.hasApiKey && !m.cliOnly && <span className={styles.noKey}>🔑 NO KEY</span>}
63
+ <div className={styles.modelId}>{m.modelId}</div>
64
+ </div>
65
+ </td>
66
+ <td><span className={styles.providerPill}>{m.origin}</span></td>
67
+ <td className={`${styles.swe} ${styles[sweClass(m.sweScore)]}`}>{m.sweScore || '—'}</td>
68
+ <td className={styles.ctx}>{m.ctx || '—'}</td>
69
+ <td className={`${styles.ping} ${styles[pingClass(m.latestPing)]}`}>
70
+ {m.latestPing == null ? '—' : m.latestCode === '429' ? '429' : m.latestCode === '000' ? 'TIMEOUT' : `${m.latestPing}ms`}
71
+ </td>
72
+ <td className={`${styles.ping} ${styles[pingClass(m.avg)]}`}>
73
+ {m.avg == null || m.avg === Infinity || m.avg > 99000 ? '—' : `${m.avg}ms`}
74
+ </td>
75
+ <td><StabilityCell score={m.stability} /></td>
76
+ <td><VerdictBadge verdict={m.verdict} httpCode={m.httpCode} /></td>
77
+ <td className={styles.uptime}>{m.uptime > 0 ? `${m.uptime}%` : '—'}</td>
78
+ <td><Sparkline history={m.pingHistory} /></td>
79
+ </tr>
80
+ )
81
+ })}
82
+ </tbody>
83
+ </table>
84
+ </div>
85
+ )
86
+ }
@@ -0,0 +1,46 @@
1
+ .container {
2
+ background: var(--color-bg-card);
3
+ backdrop-filter: blur(12px) saturate(1.5);
4
+ -webkit-backdrop-filter: blur(12px) saturate(1.5);
5
+ border: 1px solid var(--color-border);
6
+ border-radius: 16px;
7
+ overflow: hidden;
8
+ margin: 16px 0;
9
+ }
10
+ .table { width: 100%; border-collapse: collapse; font-size: 13px; }
11
+ .th {
12
+ padding: 12px 14px; font-size: 11px; font-weight: 700; text-transform: uppercase;
13
+ letter-spacing: 0.8px; color: var(--color-text-muted); background: var(--color-bg-elevated);
14
+ border-bottom: 1px solid var(--color-border); white-space: nowrap; user-select: none; cursor: default;
15
+ }
16
+ .th:hover { color: var(--color-accent); }
17
+ .table td { padding: 10px 14px; border-bottom: 1px solid var(--color-border); white-space: nowrap; }
18
+ .table tbody tr { cursor: pointer; transition: background 150ms; }
19
+ .table tbody tr:hover { background: var(--color-bg-hover); }
20
+ .tdRank { text-align: center; font-family: var(--font-mono); font-weight: 600; color: var(--color-text-dim); }
21
+ .rank1 td:first-child { border-left: 3px solid #ffd700; }
22
+ .rank2 td:first-child { border-left: 3px solid #c0c0c0; }
23
+ .rank3 td:first-child { border-left: 3px solid #cd7f32; }
24
+ .modelCell { display: flex; flex-direction: column; }
25
+ .modelName { font-weight: 600; font-size: 13px; display: flex; align-items: center; gap: 4px; }
26
+ .modelId { font-family: var(--font-mono); font-size: 10px; color: var(--color-text-dim); margin-top: 1px; }
27
+ .noKey {
28
+ font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 3px;
29
+ background: rgba(255,170,0,0.12); color: #ffaa00; border: 1px solid rgba(255,170,0,0.2); margin-left: 6px;
30
+ }
31
+ .providerPill {
32
+ display: inline-block; font-size: 11px; font-weight: 500; padding: 2px 8px; border-radius: 999px;
33
+ background: var(--color-accent-dim); color: var(--color-text-muted); border: 1px solid var(--color-border);
34
+ }
35
+ .swe { font-family: var(--font-mono); font-weight: 600; }
36
+ .sweHigh { color: #ffd700; }
37
+ .sweMid { color: #3ddc84; }
38
+ .sweLow { color: var(--color-text-dim); }
39
+ .ctx { font-family: var(--font-mono); font-size: 12px; color: var(--color-text-muted); }
40
+ .ping { font-family: var(--font-mono); font-weight: 600; }
41
+ .pingFast { color: #00ff88; }
42
+ .pingMedium { color: #ffaa00; }
43
+ .pingSlow { color: #ff4444; }
44
+ .pingNone { color: var(--color-text-dim); }
45
+ .uptime { font-family: var(--font-mono); font-size: 12px; }
46
+ .empty { text-align: center; padding: 60px 0; color: var(--color-text-muted); }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @file web/src/components/dashboard/StatsBar.jsx
3
+ * @description Stats cards row showing total models, online count, avg latency, fastest model, providers.
4
+ */
5
+ import { useMemo } from 'react'
6
+ import styles from './StatsBar.module.css'
7
+
8
+ export default function StatsBar({ models }) {
9
+ const stats = useMemo(() => {
10
+ const total = models.length
11
+ const online = models.filter(m => m.status === 'up').length
12
+ const onlineWithPing = models.filter(m => m.status === 'up' && m.avg !== Infinity && m.avg < 99000)
13
+ const avgLatency = onlineWithPing.length > 0
14
+ ? Math.round(onlineWithPing.reduce((s, m) => s + m.avg, 0) / onlineWithPing.length)
15
+ : null
16
+ const fastest = [...onlineWithPing].sort((a, b) => a.avg - b.avg)[0]
17
+ const providers = new Set(models.map(m => m.providerKey)).size
18
+ return [
19
+ { icon: '📊', value: total, label: 'Total Models' },
20
+ { icon: '🟢', value: online, label: 'Online' },
21
+ { icon: '⚡', value: avgLatency != null ? `${avgLatency}ms` : '—', label: 'Avg Latency' },
22
+ { icon: '🏆', value: fastest ? fastest.label : '—', label: 'Fastest Model' },
23
+ { icon: '🌐', value: providers, label: 'Providers' },
24
+ ]
25
+ }, [models])
26
+
27
+ return (
28
+ <section className={styles.statsBar}>
29
+ {stats.map(s => (
30
+ <div key={s.label} className={styles.card}>
31
+ <div className={styles.icon}>{s.icon}</div>
32
+ <div className={styles.body}>
33
+ <div className={styles.value}>{s.value}</div>
34
+ <div className={styles.label}>{s.label}</div>
35
+ </div>
36
+ </div>
37
+ ))}
38
+ </section>
39
+ )
40
+ }
@@ -0,0 +1,28 @@
1
+ .statsBar {
2
+ position: relative; z-index: 1;
3
+ display: grid; grid-template-columns: repeat(5, 1fr); gap: 14px;
4
+ padding: 20px 24px 0;
5
+ }
6
+ .card {
7
+ display: flex; align-items: center; gap: 12px;
8
+ background: var(--color-bg-card);
9
+ backdrop-filter: blur(12px);
10
+ -webkit-backdrop-filter: blur(12px);
11
+ border: 1px solid var(--color-border);
12
+ border-radius: 16px;
13
+ padding: 16px 18px;
14
+ transition: all 250ms cubic-bezier(0.16, 1, 0.3, 1);
15
+ }
16
+ .card:hover {
17
+ border-color: var(--color-border-hover);
18
+ transform: translateY(-2px);
19
+ box-shadow: var(--shadow-md);
20
+ }
21
+ .icon { font-size: 28px; }
22
+ .body { flex: 1; }
23
+ .value { font-size: 22px; font-weight: 800; font-family: var(--font-mono); line-height: 1; }
24
+ .label { font-size: 11px; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 500; margin-top: 2px; }
25
+
26
+ @media (max-width: 1024px) { .statsBar { grid-template-columns: repeat(3, 1fr); } }
27
+ @media (max-width: 768px) { .statsBar { grid-template-columns: repeat(2, 1fr); } }
28
+ @media (max-width: 480px) { .statsBar { grid-template-columns: 1fr; } }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @file web/src/components/layout/Footer.jsx
3
+ * @description Simple footer with author credit and links.
4
+ */
5
+ import styles from './Footer.module.css'
6
+
7
+ export default function Footer() {
8
+ return (
9
+ <footer className={styles.footer}>
10
+ <div className={styles.left}>
11
+ Made with ❤️ by <a href="https://vavanessa.dev" target="_blank" rel="noopener">Vava-Nessa</a>
12
+ </div>
13
+ <div className={styles.right}>
14
+ <a href="https://github.com/vava-nessa/free-coding-models" target="_blank" rel="noopener">GitHub</a>
15
+ <a href="https://discord.gg/ZTNFHvvCkU" target="_blank" rel="noopener">Discord</a>
16
+ </div>
17
+ </footer>
18
+ )
19
+ }
@@ -0,0 +1,10 @@
1
+ .footer {
2
+ position: relative; z-index: 1;
3
+ display: flex; align-items: center; justify-content: space-between;
4
+ padding: 16px 24px;
5
+ border-top: 1px solid var(--color-border);
6
+ font-size: 12px; color: var(--color-text-dim);
7
+ }
8
+ .left a, .right a { color: var(--color-text-dim); text-decoration: none; }
9
+ .left a:hover, .right a:hover { color: var(--color-accent); }
10
+ .right { display: flex; gap: 16px; }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @file web/src/components/layout/Header.jsx
3
+ * @description Top header bar with search, export button, settings shortcut, and theme toggle.
4
+ */
5
+ import styles from './Header.module.css'
6
+
7
+ export default function Header({ searchQuery, onSearchChange, onToggleTheme, onOpenSettings, onOpenExport }) {
8
+ return (
9
+ <header className={styles.header}>
10
+ <div className={styles.left}>
11
+ <div className={styles.logo}>
12
+ <span className={styles.logoIcon}>⚡</span>
13
+ <span className={styles.logoText}>free-coding-models</span>
14
+ </div>
15
+ <span className={styles.version}>v{__APP_VERSION__}</span>
16
+ </div>
17
+ <div className={styles.center}>
18
+ <div className={styles.searchBar}>
19
+ <span className={styles.searchIcon}>🔍</span>
20
+ <input
21
+ type="text"
22
+ className={styles.searchInput}
23
+ placeholder="Search models, providers, tiers..."
24
+ value={searchQuery}
25
+ onChange={(e) => onSearchChange(e.target.value)}
26
+ autoComplete="off"
27
+ />
28
+ <kbd className={styles.kbd}>Ctrl+K</kbd>
29
+ </div>
30
+ </div>
31
+ <div className={styles.right}>
32
+ <button className={styles.iconBtn} onClick={onToggleTheme} title="Toggle theme">☽</button>
33
+ <button className={styles.iconBtn} onClick={onOpenExport} title="Export Data">↓</button>
34
+ <button className={styles.primaryBtn} onClick={onOpenSettings}>⚙ Settings</button>
35
+ </div>
36
+ </header>
37
+ )
38
+ }
@@ -0,0 +1,73 @@
1
+ .header {
2
+ position: sticky;
3
+ top: 0;
4
+ z-index: 100;
5
+ height: 60px;
6
+ display: flex;
7
+ align-items: center;
8
+ justify-content: space-between;
9
+ padding: 0 24px;
10
+ gap: 20px;
11
+ background: var(--color-bg-card);
12
+ backdrop-filter: blur(20px) saturate(1.5);
13
+ -webkit-backdrop-filter: blur(20px) saturate(1.5);
14
+ border-bottom: 1px solid var(--color-border);
15
+ }
16
+ .left { display: flex; align-items: center; gap: 12px; }
17
+ .center { flex: 1; max-width: 480px; }
18
+ .right { display: flex; align-items: center; gap: 10px; }
19
+
20
+ .logo { display: flex; align-items: center; gap: 8px; }
21
+ .logoIcon { font-size: 22px; }
22
+ .logoText {
23
+ font-size: 16px; font-weight: 700;
24
+ background: linear-gradient(135deg, var(--color-accent), #06b6d4);
25
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
26
+ }
27
+ .version {
28
+ font-size: 11px; font-weight: 500; font-family: var(--font-mono);
29
+ color: var(--color-text-dim); background: var(--color-accent-dim);
30
+ padding: 2px 8px; border-radius: 999px; border: 1px solid var(--color-border);
31
+ }
32
+
33
+ .searchBar {
34
+ display: flex; align-items: center; gap: 8px;
35
+ background: var(--color-surface); border: 1px solid var(--color-border);
36
+ border-radius: 10px; padding: 0 12px; height: 36px;
37
+ transition: border-color 150ms, box-shadow 150ms;
38
+ }
39
+ .searchBar:focus-within {
40
+ border-color: var(--color-accent);
41
+ box-shadow: 0 0 0 3px var(--color-accent-glow);
42
+ }
43
+ .searchIcon { font-size: 14px; flex-shrink: 0; }
44
+ .searchInput {
45
+ flex: 1; background: none; border: none; outline: none;
46
+ color: var(--color-text); font-size: 13px; font-family: var(--font-sans);
47
+ }
48
+ .searchInput::placeholder { color: var(--color-text-dim); }
49
+ .kbd {
50
+ font-size: 10px; font-family: var(--font-mono); color: var(--color-text-dim);
51
+ background: var(--color-bg); border: 1px solid var(--color-border);
52
+ padding: 2px 6px; border-radius: 4px; flex-shrink: 0;
53
+ }
54
+
55
+ .iconBtn {
56
+ display: inline-flex; align-items: center; justify-content: center;
57
+ padding: 6px 8px; border: 1px solid var(--color-border); border-radius: 6px;
58
+ background: var(--color-surface); color: var(--color-text);
59
+ cursor: pointer; font-size: 16px; transition: all 150ms;
60
+ }
61
+ .iconBtn:hover { background: var(--color-bg-hover); border-color: var(--color-border-hover); }
62
+ .primaryBtn {
63
+ display: inline-flex; align-items: center; gap: 6px;
64
+ padding: 6px 14px; font-size: 13px; font-weight: 600;
65
+ border: 1px solid var(--color-accent); border-radius: 6px;
66
+ background: var(--color-accent); color: #000; cursor: pointer;
67
+ font-family: var(--font-sans); transition: all 150ms;
68
+ }
69
+ .primaryBtn:hover { background: var(--color-accent-hover); }
70
+
71
+ @media (max-width: 768px) {
72
+ .center { display: none; }
73
+ }