free-coding-models 0.5.0 → 0.5.4
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/README.md +40 -6
- package/bin/free-coding-models.js +10 -0
- package/changelog/v0.5.1.md +24 -0
- package/changelog/v0.5.4.md +24 -0
- package/package.json +11 -3
- package/sources.js +2 -0
- package/src/core/router-daemon.js +166 -1
- package/src/core/utils.js +2 -0
- package/src/tui/cli-help.js +2 -0
- package/src/tui/key-handler.js +1 -1
- package/src/tui/render-table.js +1 -1
- package/web/README.md +8 -5
- package/web/assets/providers/aistudio/aistudio-text.svg +1 -0
- package/web/assets/providers/aistudio/aistudio.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-brand-color.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-brand.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-color.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-text.svg +1 -0
- package/web/assets/providers/cerebras/cerebras.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare-color.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare-text.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare.svg +1 -0
- package/web/assets/providers/gemini/gemini-color.svg +1 -0
- package/web/assets/providers/gemini/gemini-text.svg +1 -0
- package/web/assets/providers/gemini/gemini.svg +1 -0
- package/web/assets/providers/github/github-text.svg +1 -0
- package/web/assets/providers/github/github.svg +1 -0
- package/web/assets/providers/groq/groq-text.svg +1 -0
- package/web/assets/providers/groq/groq.svg +1 -0
- package/web/assets/providers/mistral/mistral-color.svg +1 -0
- package/web/assets/providers/mistral/mistral-text.svg +1 -0
- package/web/assets/providers/mistral/mistral.svg +1 -0
- package/web/assets/providers/nvidia/nvidia-color.svg +1 -0
- package/web/assets/providers/nvidia/nvidia-text.svg +1 -0
- package/web/assets/providers/nvidia/nvidia.svg +1 -0
- package/web/assets/providers/opencode/opencode-text.svg +1 -0
- package/web/assets/providers/opencode/opencode.svg +1 -0
- package/web/assets/providers/openrouter/openrouter (1).svg +1 -0
- package/web/assets/providers/openrouter/openrouter-text.svg +1 -0
- package/web/assets/providers/openrouter/openrouter.svg +1 -0
- package/web/assets/providers/ovhcloud/ovhcloud.svg +12 -0
- package/web/assets/providers/qwen/qwen-color.svg +1 -0
- package/web/assets/providers/qwen/qwen-text.svg +1 -0
- package/web/assets/providers/qwen/qwen.svg +1 -0
- package/web/assets/providers/sambanova/sambanova-color.svg +1 -0
- package/web/assets/providers/sambanova/sambanova-text.svg +1 -0
- package/web/assets/providers/sambanova/sambanova.svg +1 -0
- package/web/assets/providers/scalewaylogo/ScalewayLogo.svg +13 -0
- package/web/assets/providers/zai/zai-text.svg +1 -0
- package/web/assets/providers/zai/zai.svg +1 -0
- package/web/dist/assets/index-BoWmUveV.js +39 -0
- package/web/dist/assets/index-BrpHevg4.css +1 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/favicons/apple-touch-icon.png +0 -0
- package/web/dist/favicons/browserconfig.xml +12 -0
- package/web/dist/favicons/favicon-16x16.png +0 -0
- package/web/dist/favicons/favicon-192x192.png +0 -0
- package/web/dist/favicons/favicon-32x32.png +0 -0
- package/web/dist/favicons/favicon-48x48.png +0 -0
- package/web/dist/favicons/favicon-512x512.png +0 -0
- package/web/dist/favicons/favicon-96x96.png +0 -0
- package/web/dist/favicons/favicon.ico +0 -0
- package/web/dist/favicons/mstile-150x150.png +0 -0
- package/web/dist/favicons/mstile-310x150.png +0 -0
- package/web/dist/favicons/mstile-310x310.png +0 -0
- package/web/dist/favicons/mstile-512x512.png +0 -0
- package/web/dist/favicons/mstile-70x70.png +0 -0
- package/web/dist/favicons/site.webmanifest +25 -0
- package/web/dist/index.html +42 -6
- package/web/index.html +40 -4
- package/web/package.json +11 -0
- package/web/public/favicon.ico +0 -0
- package/web/public/favicons/apple-touch-icon.png +0 -0
- package/web/public/favicons/browserconfig.xml +12 -0
- package/web/public/favicons/favicon-16x16.png +0 -0
- package/web/public/favicons/favicon-192x192.png +0 -0
- package/web/public/favicons/favicon-32x32.png +0 -0
- package/web/public/favicons/favicon-48x48.png +0 -0
- package/web/public/favicons/favicon-512x512.png +0 -0
- package/web/public/favicons/favicon-96x96.png +0 -0
- package/web/public/favicons/favicon.ico +0 -0
- package/web/public/favicons/mstile-150x150.png +0 -0
- package/web/public/favicons/mstile-310x150.png +0 -0
- package/web/public/favicons/mstile-310x310.png +0 -0
- package/web/public/favicons/mstile-512x512.png +0 -0
- package/web/public/favicons/mstile-70x70.png +0 -0
- package/web/public/favicons/site.webmanifest +25 -0
- package/web/server.js +676 -211
- package/web/src/App.jsx +233 -71
- package/web/src/components/analytics/AnalyticsView.jsx +40 -6
- package/web/src/components/analytics/AnalyticsView.module.css +4 -0
- package/web/src/components/atoms/AILatencyCell.jsx +38 -0
- package/web/src/components/atoms/AILatencyCell.module.css +51 -0
- package/web/src/components/atoms/HealthCell.jsx +138 -0
- package/web/src/components/atoms/HealthCell.module.css +56 -0
- package/web/src/components/atoms/LastPingCell.jsx +35 -0
- package/web/src/components/atoms/LastPingCell.module.css +45 -0
- package/web/src/components/atoms/MoodCell.jsx +25 -0
- package/web/src/components/atoms/MoodCell.module.css +6 -0
- package/web/src/components/atoms/NoKeyIcon.jsx +65 -0
- package/web/src/components/atoms/NoKeyIcon.module.css +30 -0
- package/web/src/components/atoms/ProviderLogo.jsx +188 -0
- package/web/src/components/atoms/ProviderLogo.module.css +103 -0
- package/web/src/components/atoms/RankCell.jsx +9 -0
- package/web/src/components/atoms/RankCell.module.css +9 -0
- package/web/src/components/atoms/Sparkline.jsx +21 -2
- package/web/src/components/atoms/StabilityCell.module.css +5 -0
- package/web/src/components/atoms/StatusDot.module.css +7 -0
- package/web/src/components/atoms/TPSCell.jsx +36 -0
- package/web/src/components/atoms/TPSCell.module.css +44 -0
- package/web/src/components/atoms/TierBadge.module.css +11 -0
- package/web/src/components/atoms/Toast.module.css +6 -0
- package/web/src/components/atoms/VerdictBadge.jsx +30 -7
- package/web/src/components/atoms/VerdictBadge.module.css +36 -15
- package/web/src/components/dashboard/DetailPanel.jsx +63 -2
- package/web/src/components/dashboard/DetailPanel.module.css +130 -0
- package/web/src/components/dashboard/ExportModal.jsx +9 -4
- package/web/src/components/dashboard/FilterBar.jsx +228 -33
- package/web/src/components/dashboard/FilterBar.module.css +187 -22
- package/web/src/components/dashboard/ModelTable.jsx +500 -54
- package/web/src/components/dashboard/ModelTable.module.css +318 -31
- package/web/src/components/layout/Footer.jsx +1 -1
- package/web/src/components/layout/Header.jsx +171 -9
- package/web/src/components/layout/Header.module.css +223 -25
- package/web/src/components/palette/CommandPalette.jsx +146 -0
- package/web/src/components/palette/CommandPalette.module.css +121 -0
- package/web/src/components/settings/SettingsView.jsx +24 -6
- package/web/src/components/settings/SettingsView.module.css +0 -1
- package/web/src/global.css +118 -84
- package/web/src/hooks/useColumnSizing.js +211 -0
- package/web/src/hooks/useFavorites.js +150 -0
- package/web/src/hooks/useFilter.js +262 -37
- package/web/src/hooks/useSSE.js +33 -9
- package/web/src/hooks/useSocket.js +200 -0
- package/web/src/hooks/useTheme.js +72 -16
- package/web/src/hooks/useUrlState.js +93 -0
- package/web/vite.config.js +41 -0
- package/web/dist/assets/index-CGN-0_A0.css +0 -1
- package/web/dist/assets/index-CvMUM9Jr.js +0 -11
- package/web/src/components/dashboard/StatsBar.jsx +0 -40
- package/web/src/components/dashboard/StatsBar.module.css +0 -28
- package/web/src/components/layout/Sidebar.jsx +0 -42
- package/web/src/components/layout/Sidebar.module.css +0 -76
- package/web/src/components/map/MapView.jsx +0 -17
- package/web/src/components/map/MapView.module.css +0 -25
package/web/src/global.css
CHANGED
|
@@ -8,23 +8,24 @@
|
|
|
8
8
|
|
|
9
9
|
/* ─── CSS Custom Properties (Design Tokens) ─── */
|
|
10
10
|
:root {
|
|
11
|
-
--color-
|
|
12
|
-
--color-bg
|
|
13
|
-
--color-bg-
|
|
14
|
-
--color-bg-
|
|
15
|
-
--color-bg-
|
|
16
|
-
--color-
|
|
17
|
-
--color-
|
|
18
|
-
--color-border
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
--color-text
|
|
22
|
-
--color-text-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
--color-accent
|
|
26
|
-
--color-accent-
|
|
27
|
-
--color-accent-
|
|
11
|
+
--color-brand: #76b900;
|
|
12
|
+
--color-bg: #000000;
|
|
13
|
+
--color-bg-elevated: #0a0a0a;
|
|
14
|
+
--color-bg-card: #0a0a0a;
|
|
15
|
+
--color-bg-hover: #161616;
|
|
16
|
+
--color-bg-active: #222222;
|
|
17
|
+
--color-surface: #111111;
|
|
18
|
+
--color-border: #262626;
|
|
19
|
+
--color-border-hover: #404040;
|
|
20
|
+
|
|
21
|
+
--color-text: #ffffff;
|
|
22
|
+
--color-text-muted: #888888;
|
|
23
|
+
--color-text-dim: #444444;
|
|
24
|
+
|
|
25
|
+
--color-accent: #ffffff;
|
|
26
|
+
--color-accent-hover: #eaeaea;
|
|
27
|
+
--color-accent-glow: rgba(255, 255, 255, 0.1);
|
|
28
|
+
--color-accent-dim: rgba(255, 255, 255, 0.05);
|
|
28
29
|
|
|
29
30
|
--color-danger: #ff4444;
|
|
30
31
|
--color-danger-dim: rgba(255, 68, 68, 0.1);
|
|
@@ -58,17 +59,15 @@
|
|
|
58
59
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
|
|
59
60
|
|
|
60
61
|
--header-h: 60px;
|
|
61
|
-
--
|
|
62
|
-
--
|
|
63
|
-
--radius-
|
|
64
|
-
--radius-
|
|
65
|
-
|
|
66
|
-
--
|
|
67
|
-
|
|
68
|
-
--shadow-
|
|
69
|
-
--shadow-
|
|
70
|
-
--shadow-lg: 0 8px 32px rgba(0,0,0,0.5);
|
|
71
|
-
--shadow-glow: 0 0 30px var(--color-accent-glow);
|
|
62
|
+
--radius-sm: 4px;
|
|
63
|
+
--radius-md: 6px;
|
|
64
|
+
--radius-lg: 8px;
|
|
65
|
+
--radius-xl: 8px;
|
|
66
|
+
|
|
67
|
+
--shadow-sm: none;
|
|
68
|
+
--shadow-md: none;
|
|
69
|
+
--shadow-lg: none;
|
|
70
|
+
--shadow-glow: none;
|
|
72
71
|
|
|
73
72
|
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
|
74
73
|
--transition-fast: 150ms var(--ease-out);
|
|
@@ -78,26 +77,60 @@
|
|
|
78
77
|
|
|
79
78
|
/* ─── Light Theme ─── */
|
|
80
79
|
[data-theme="light"] {
|
|
81
|
-
--color-bg: #
|
|
80
|
+
--color-bg: #ffffff;
|
|
82
81
|
--color-bg-elevated: #ffffff;
|
|
83
|
-
--color-bg-card:
|
|
84
|
-
--color-bg-hover:
|
|
85
|
-
--color-bg-active:
|
|
86
|
-
--color-surface: #
|
|
87
|
-
--color-border:
|
|
88
|
-
--color-border-hover:
|
|
89
|
-
--color-text: #
|
|
90
|
-
--color-text-muted: #
|
|
91
|
-
--color-text-dim: #
|
|
92
|
-
--color-accent
|
|
93
|
-
--color-accent-
|
|
94
|
-
--color-
|
|
95
|
-
--color-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
82
|
+
--color-bg-card: #ffffff;
|
|
83
|
+
--color-bg-hover: #fafafa;
|
|
84
|
+
--color-bg-active: #f0f0f0;
|
|
85
|
+
--color-surface: #fafafa;
|
|
86
|
+
--color-border: #eaeaea;
|
|
87
|
+
--color-border-hover: #d5d5d5;
|
|
88
|
+
--color-text: #000000;
|
|
89
|
+
--color-text-muted: #666666;
|
|
90
|
+
--color-text-dim: #999999;
|
|
91
|
+
--color-accent: #000000;
|
|
92
|
+
--color-accent-hover: #333333;
|
|
93
|
+
--color-accent-glow: rgba(0, 0, 0, 0.05);
|
|
94
|
+
--color-accent-dim: rgba(0, 0, 0, 0.02);
|
|
95
|
+
|
|
96
|
+
/* 📖 Bright neon colors that look great on black become unreadable on white.
|
|
97
|
+
📖 We swap them for darker, AA-contrast equivalents in light mode. The
|
|
98
|
+
📖 hex values are picked to stay visually similar to the dark variant
|
|
99
|
+
📖 (still "green" / "cyan" / "gold") while passing WCAG AA on #fff. */
|
|
100
|
+
--color-success: #008f4d; /* was #00ff88 (neon) */
|
|
101
|
+
--color-warning: #b86b00; /* was #ffaa00 (neon amber) */
|
|
102
|
+
--color-danger: #c8143a; /* was #ff4444 (neon red) */
|
|
103
|
+
--color-info: #0e7490; /* was #06b6d4 (neon cyan) */
|
|
104
|
+
--color-danger-dim: rgba(200, 20, 58, 0.08);
|
|
105
|
+
--color-warning-dim: rgba(184, 107, 0, 0.08);
|
|
106
|
+
--color-success-dim: rgba(0, 143, 77, 0.08);
|
|
107
|
+
--color-info-dim: rgba(14, 116, 144, 0.08);
|
|
108
|
+
|
|
109
|
+
/* 📖 Tier + verdict palette: shift toward darker, more saturated variants
|
|
110
|
+
📖 so badges stay legible on white. The original "neon" feel is dropped
|
|
111
|
+
📖 in favor of "saturated-but-deep" colors that pass contrast. */
|
|
112
|
+
--tier-splus: #b08800; /* was #ffd700 (gold) */
|
|
113
|
+
--tier-s: #c45a00; /* was #ff8c00 (dark orange) */
|
|
114
|
+
--tier-aplus: #0073a8; /* was #00c8ff (neon cyan) */
|
|
115
|
+
--tier-a: #1f7a3e; /* was #3ddc84 (mid green) */
|
|
116
|
+
--tier-aminus: #2f7a2f; /* was #7ecf7e (light green) */
|
|
117
|
+
--tier-bplus: #5a5a8a; /* was #a8a8c8 (lavender) */
|
|
118
|
+
--tier-b: #555566; /* was #808098 (gray-blue) */
|
|
119
|
+
--tier-c: #6a6a7a; /* was #606078 (slate) */
|
|
120
|
+
|
|
121
|
+
--verdict-perfect: #008f4d; /* was #00ff88 */
|
|
122
|
+
--verdict-normal: #4a7a00; /* was #76b900 */
|
|
123
|
+
--verdict-slow: #b86b00; /* was #ffaa00 */
|
|
124
|
+
--verdict-spiky: #c45000; /* was #ff6600 */
|
|
125
|
+
--verdict-veryslow: #c8143a; /* was #ff4444 */
|
|
126
|
+
--verdict-overloaded: #b8101f; /* was #ff2222 */
|
|
127
|
+
--verdict-unstable: #990000; /* was #cc0000 */
|
|
128
|
+
--verdict-notactive: #6a6a7a; /* was #555570 */
|
|
129
|
+
--verdict-pending: #6a6a8a; /* was #444460 */
|
|
130
|
+
|
|
131
|
+
--shadow-sm: none;
|
|
132
|
+
--shadow-md: none;
|
|
133
|
+
--shadow-lg: none;
|
|
101
134
|
}
|
|
102
135
|
|
|
103
136
|
/* ─── Reset & Base ─── */
|
|
@@ -118,6 +151,7 @@ body {
|
|
|
118
151
|
overflow-x: hidden;
|
|
119
152
|
position: relative;
|
|
120
153
|
display: flex;
|
|
154
|
+
flex-direction: column;
|
|
121
155
|
}
|
|
122
156
|
|
|
123
157
|
a { color: var(--color-accent); text-decoration: none; }
|
|
@@ -126,41 +160,13 @@ code { font-family: var(--font-mono); font-size: 12px; background: var(--color-s
|
|
|
126
160
|
|
|
127
161
|
/* ─── Background Effects ─── */
|
|
128
162
|
.bg-grid {
|
|
129
|
-
|
|
130
|
-
background-image:
|
|
131
|
-
linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
|
|
132
|
-
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px);
|
|
133
|
-
background-size: 60px 60px;
|
|
163
|
+
display: none;
|
|
134
164
|
}
|
|
135
165
|
|
|
136
166
|
.bg-glow {
|
|
137
|
-
|
|
138
|
-
border-radius: 50%;
|
|
139
|
-
filter: blur(120px);
|
|
140
|
-
opacity: 0.3;
|
|
141
|
-
animation: float 20s ease-in-out infinite;
|
|
142
|
-
}
|
|
143
|
-
.bg-glow--1 {
|
|
144
|
-
width: 600px; height: 600px;
|
|
145
|
-
background: radial-gradient(circle, var(--color-accent) 0%, transparent 70%);
|
|
146
|
-
top: -200px; left: -200px;
|
|
147
|
-
}
|
|
148
|
-
.bg-glow--2 {
|
|
149
|
-
width: 400px; height: 400px;
|
|
150
|
-
background: radial-gradient(circle, #6366f1 0%, transparent 70%);
|
|
151
|
-
top: 50%; right: -150px; animation-delay: -7s;
|
|
152
|
-
}
|
|
153
|
-
.bg-glow--3 {
|
|
154
|
-
width: 500px; height: 500px;
|
|
155
|
-
background: radial-gradient(circle, #06b6d4 0%, transparent 70%);
|
|
156
|
-
bottom: -200px; left: 30%; animation-delay: -14s;
|
|
167
|
+
display: none;
|
|
157
168
|
}
|
|
158
169
|
|
|
159
|
-
@keyframes float {
|
|
160
|
-
0%, 100% { transform: translate(0, 0) scale(1); }
|
|
161
|
-
33% { transform: translate(30px, -30px) scale(1.05); }
|
|
162
|
-
66% { transform: translate(-20px, 20px) scale(0.95); }
|
|
163
|
-
}
|
|
164
170
|
|
|
165
171
|
[data-theme="light"] .bg-glow { opacity: 0.12; }
|
|
166
172
|
[data-theme="light"] .bg-grid {
|
|
@@ -170,30 +176,58 @@ code { font-family: var(--font-mono); font-size: 12px; background: var(--color-s
|
|
|
170
176
|
}
|
|
171
177
|
|
|
172
178
|
/* ─── App Layout ─── */
|
|
179
|
+
.app-shell {
|
|
180
|
+
flex: 1;
|
|
181
|
+
display: flex;
|
|
182
|
+
flex-direction: column;
|
|
183
|
+
min-height: 100vh;
|
|
184
|
+
width: 100%;
|
|
185
|
+
position: relative;
|
|
186
|
+
z-index: 1;
|
|
187
|
+
}
|
|
188
|
+
|
|
173
189
|
.app-content {
|
|
174
190
|
flex: 1;
|
|
175
|
-
margin-left: var(--sidebar-w);
|
|
176
191
|
position: relative;
|
|
177
192
|
z-index: 1;
|
|
178
|
-
min-height: 100vh;
|
|
193
|
+
min-height: calc(100vh - var(--header-h));
|
|
179
194
|
display: flex;
|
|
180
195
|
flex-direction: column;
|
|
181
|
-
transition: margin-left var(--transition-medium);
|
|
182
196
|
}
|
|
183
197
|
|
|
184
198
|
.view {
|
|
185
199
|
display: flex;
|
|
186
200
|
flex-direction: column;
|
|
187
201
|
flex: 1;
|
|
202
|
+
min-height: 0;
|
|
188
203
|
}
|
|
189
204
|
|
|
190
205
|
/* ─── Scrollbar ─── */
|
|
191
|
-
::-webkit-scrollbar { width:
|
|
192
|
-
::-webkit-scrollbar-track { background:
|
|
193
|
-
::-webkit-scrollbar-thumb { background: var(--color-border); border-radius:
|
|
206
|
+
::-webkit-scrollbar { width: 8px; height: 8px; }
|
|
207
|
+
::-webkit-scrollbar-track { background: var(--color-bg); }
|
|
208
|
+
::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 4px; }
|
|
194
209
|
::-webkit-scrollbar-thumb:hover { background: var(--color-text-dim); }
|
|
210
|
+
::-webkit-scrollbar-corner { background: var(--color-bg); }
|
|
195
211
|
|
|
196
212
|
/* ─── Responsive ─── */
|
|
197
213
|
@media (max-width: 768px) {
|
|
198
|
-
|
|
214
|
+
/* 📖 No sidebar to collapse on small viewports — the header itself collapses
|
|
215
|
+
📖 (see Header.module.css). The model table keeps its 100% width. */
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/* ─── Full-page horizontal/vertical scroll layout ─── */
|
|
219
|
+
.app-content {
|
|
220
|
+
flex: 1;
|
|
221
|
+
position: relative;
|
|
222
|
+
z-index: 1;
|
|
223
|
+
min-height: calc(100vh - var(--header-h));
|
|
224
|
+
display: flex;
|
|
225
|
+
flex-direction: column;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.view {
|
|
229
|
+
display: flex;
|
|
230
|
+
flex-direction: column;
|
|
231
|
+
flex: 1;
|
|
232
|
+
min-height: 0;
|
|
199
233
|
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/hooks/useColumnSizing.js
|
|
3
|
+
* @description React hook for resizable table columns with localStorage persistence.
|
|
4
|
+
*
|
|
5
|
+
* 📖 Wires TanStack Table's `columnSizing` state to a localStorage entry so the user's
|
|
6
|
+
* 📖 custom column widths survive page reloads and Dashboard/Desktop restarts.
|
|
7
|
+
*
|
|
8
|
+
* 📖 Storage strategy:
|
|
9
|
+
* 📖 - Key: `fcm.columnSizing.v1`
|
|
10
|
+
* 📖 - Value: JSON object { [columnId]: number } with the user's chosen widths in px
|
|
11
|
+
* 📖 - Read once on mount, written on every sizing change (debounced via React batching)
|
|
12
|
+
*
|
|
13
|
+
* 📖 Why a versioned key:
|
|
14
|
+
* 📖 - Future column additions or remodels can invalidate by bumping the suffix.
|
|
15
|
+
* 📖 - Old saved widths won't silently apply to a table whose columns have changed.
|
|
16
|
+
*
|
|
17
|
+
* 📖 Why not Zustand/Redux:
|
|
18
|
+
* 📖 - localStorage is the simplest possible cross-tab/cross-session persistence layer.
|
|
19
|
+
* 📖 - The data is 200 bytes max — no need for IndexedDB or a migration story.
|
|
20
|
+
* 📖 - SSR safety: every localStorage access is guarded with `typeof window !== 'undefined'`.
|
|
21
|
+
*
|
|
22
|
+
* @functions
|
|
23
|
+
* → useColumnSizing(defaultSizing) — returns { columnSizing, setColumnSizing, resetColumnSizing, hasCustomSizing }
|
|
24
|
+
* → sanitizeSizing / clampSizing / mergeSizing / hasCustomSizing — pure helpers (unit-tested)
|
|
25
|
+
* → readSizingFromStorage / writeSizingToStorage / removeSizingFromStorage — storage adapters
|
|
26
|
+
* @exports useColumnSizing, sanitizeSizing, clampSizing, mergeSizing, hasCustomSizing,
|
|
27
|
+
* readSizingFromStorage, writeSizingToStorage, removeSizingFromStorage,
|
|
28
|
+
* COLUMN_SIZING_STORAGE_KEY, COLUMN_SIZING_MIN, COLUMN_SIZING_MAX
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { useState, useCallback, useEffect, useRef } from 'react'
|
|
32
|
+
|
|
33
|
+
export const COLUMN_SIZING_STORAGE_KEY = 'fcm.columnSizing.v1'
|
|
34
|
+
|
|
35
|
+
// 📖 Bounds (px) for every column width. Mirrored in the model's CSS so the
|
|
36
|
+
// 📖 rendering layer never has to ask the hook about min/max.
|
|
37
|
+
export const COLUMN_SIZING_MIN = 24
|
|
38
|
+
export const COLUMN_SIZING_MAX = 1200
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 📖 sanitizeSizing: Validate + clean an arbitrary object into a { id: number } map.
|
|
42
|
+
* 📖 Strips non-numbers, NaN, Infinity, zero, and negative widths.
|
|
43
|
+
* 📖 Exported for unit testing and for any future code path that ingests raw input.
|
|
44
|
+
*/
|
|
45
|
+
export function sanitizeSizing(input) {
|
|
46
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return {}
|
|
47
|
+
const out = {}
|
|
48
|
+
for (const [k, v] of Object.entries(input)) {
|
|
49
|
+
if (typeof v === 'number' && Number.isFinite(v) && v > 0) out[k] = v
|
|
50
|
+
}
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 📖 clampSizing: Force every value into [min, max], rounded to an integer.
|
|
56
|
+
* 📖 Drops entries whose value is not a finite number so a typo in storage
|
|
57
|
+
* 📖 never crashes the dashboard.
|
|
58
|
+
*/
|
|
59
|
+
export function clampSizing(sizing, min = COLUMN_SIZING_MIN, max = COLUMN_SIZING_MAX) {
|
|
60
|
+
const out = {}
|
|
61
|
+
for (const [k, v] of Object.entries(sizing || {})) {
|
|
62
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) continue
|
|
63
|
+
out[k] = Math.max(min, Math.min(max, Math.round(v)))
|
|
64
|
+
}
|
|
65
|
+
return out
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 📖 mergeSizing: Layer the stored widths on top of the defaults, with the
|
|
70
|
+
* 📖 runtime (in-memory) state winning on conflicts. This three-way merge is
|
|
71
|
+
* 📖 what keeps new columns (added in newer releases) from disappearing when
|
|
72
|
+
* 📖 a stale localStorage entry is loaded.
|
|
73
|
+
*/
|
|
74
|
+
export function mergeSizing(defaults, stored, runtime) {
|
|
75
|
+
return { ...(defaults || {}), ...(stored || {}), ...(runtime || {}) }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 📖 hasCustomSizing: True when at least one column differs from its default.
|
|
80
|
+
* 📖 Used by the UI to decide whether to show a "Reset" affordance.
|
|
81
|
+
*/
|
|
82
|
+
export function hasCustomSizing(current, defaults) {
|
|
83
|
+
if (!current || !defaults) return false
|
|
84
|
+
for (const k of Object.keys(current)) {
|
|
85
|
+
if (current[k] !== defaults[k]) return true
|
|
86
|
+
}
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 📖 readSizingFromStorage: Read JSON from a localStorage-like object, returns
|
|
92
|
+
* 📖 a sanitized empty object on any failure. Exported separately from the
|
|
93
|
+
* 📖 React hook so unit tests can drive it without a DOM.
|
|
94
|
+
*
|
|
95
|
+
* 📖 @param {Storage|null|undefined} storage - the localStorage-like object
|
|
96
|
+
* 📖 @param {string} key - the storage key
|
|
97
|
+
* 📖 @param {Object} fallback - default if missing/empty
|
|
98
|
+
*/
|
|
99
|
+
export function readSizingFromStorage(storage, key, fallback = {}) {
|
|
100
|
+
if (!storage || typeof storage.getItem !== 'function') return fallback
|
|
101
|
+
let raw
|
|
102
|
+
try { raw = storage.getItem(key) } catch { return fallback }
|
|
103
|
+
if (!raw) return fallback
|
|
104
|
+
let parsed
|
|
105
|
+
try { parsed = JSON.parse(raw) } catch { return fallback }
|
|
106
|
+
return sanitizeSizing(parsed)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 📖 writeSizingToStorage: Persist a sizing object to a localStorage-like store.
|
|
111
|
+
* 📖 Returns true on success, false on any failure (quota, private mode, etc.).
|
|
112
|
+
*/
|
|
113
|
+
export function writeSizingToStorage(storage, key, sizing) {
|
|
114
|
+
if (!storage || typeof storage.setItem !== 'function') return false
|
|
115
|
+
try {
|
|
116
|
+
storage.setItem(key, JSON.stringify(sizing))
|
|
117
|
+
return true
|
|
118
|
+
} catch {
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 📖 removeSizingFromStorage: Best-effort delete; used when the runtime sizing
|
|
125
|
+
* 📖 returns to defaults so we don't keep stale entries around.
|
|
126
|
+
*/
|
|
127
|
+
export function removeSizingFromStorage(storage, key) {
|
|
128
|
+
if (!storage || typeof storage.removeItem !== 'function') return false
|
|
129
|
+
try { storage.removeItem(key); return true } catch { return false }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 📖 useColumnSizing: TanStack-compatible state for the table's columnSizing slot.
|
|
134
|
+
*
|
|
135
|
+
* 📖 Pass the default sizing object (e.g. `{ mood: 28, idx: 36, ... }`) used to seed
|
|
136
|
+
* 📖 new sessions; it also acts as the "reset to defaults" target.
|
|
137
|
+
*
|
|
138
|
+
* @param {Object} defaultSizing - { [columnId]: number } — defaults for the current column set
|
|
139
|
+
* @returns {{
|
|
140
|
+
* columnSizing: Object,
|
|
141
|
+
* setColumnSizing: (updater: Object | ((prev: Object) => Object)) => void,
|
|
142
|
+
* resetColumnSizing: () => void,
|
|
143
|
+
* hasCustomSizing: boolean,
|
|
144
|
+
* }}
|
|
145
|
+
*/
|
|
146
|
+
export function useColumnSizing(defaultSizing) {
|
|
147
|
+
const defaultRef = useRef(defaultSizing)
|
|
148
|
+
defaultRef.current = defaultSizing
|
|
149
|
+
|
|
150
|
+
const [columnSizing, setColumnSizingState] = useState(() => {
|
|
151
|
+
// 📖 SSR / unit-test safe: in Node, window is undefined and we fall back to defaults.
|
|
152
|
+
if (typeof window === 'undefined') return { ...defaultSizing }
|
|
153
|
+
const stored = readSizingFromStorage(window.localStorage, COLUMN_SIZING_STORAGE_KEY, {})
|
|
154
|
+
// 📖 Merge: defaults first, then stored overrides. New columns added in a
|
|
155
|
+
// 📖 future release still get their declared default width.
|
|
156
|
+
return mergeSizing(defaultSizing, stored, {})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
// 📖 Keep a ref to the latest sizing for the cross-tab sync listener.
|
|
160
|
+
const sizingRef = useRef(columnSizing)
|
|
161
|
+
sizingRef.current = columnSizing
|
|
162
|
+
|
|
163
|
+
// 📖 Persist on every change. React batches updates, so this only fires once per real change.
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
if (typeof window === 'undefined' || !window.localStorage) return
|
|
166
|
+
// 📖 When the runtime matches the defaults, drop the stored entry so a "reset"
|
|
167
|
+
// 📖 is also persisted (no orphan entry, no waste of quota).
|
|
168
|
+
if (!hasCustomSizing(columnSizing, defaultRef.current)) {
|
|
169
|
+
removeSizingFromStorage(window.localStorage, COLUMN_SIZING_STORAGE_KEY)
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
writeSizingToStorage(window.localStorage, COLUMN_SIZING_STORAGE_KEY, columnSizing)
|
|
173
|
+
}, [columnSizing])
|
|
174
|
+
|
|
175
|
+
// 📖 Cross-tab sync: if the user resizes columns in another tab/window, apply it live here.
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (typeof window === 'undefined' || !window.addEventListener) return
|
|
178
|
+
const onStorage = (e) => {
|
|
179
|
+
if (e.key !== COLUMN_SIZING_STORAGE_KEY) return
|
|
180
|
+
const next = readSizingFromStorage(window.localStorage, COLUMN_SIZING_STORAGE_KEY, {})
|
|
181
|
+
// 📖 Merge incoming changes into the current default-sourced state so removed
|
|
182
|
+
// 📖 columns in the incoming payload don't drop widths for columns we still show.
|
|
183
|
+
setColumnSizingState((prev) => mergeSizing(defaultRef.current, next, prev))
|
|
184
|
+
}
|
|
185
|
+
window.addEventListener('storage', onStorage)
|
|
186
|
+
return () => window.removeEventListener('storage', onStorage)
|
|
187
|
+
}, [])
|
|
188
|
+
|
|
189
|
+
const setColumnSizing = useCallback((updater) => {
|
|
190
|
+
setColumnSizingState((prev) => {
|
|
191
|
+
const next = typeof updater === 'function' ? updater(prev) : updater
|
|
192
|
+
// 📖 Clamp every value: TanStack can momentarily receive 0-width from a drag,
|
|
193
|
+
// 📖 but rendering a 0-width column breaks layout.
|
|
194
|
+
return { ...prev, ...clampSizing(next) }
|
|
195
|
+
})
|
|
196
|
+
}, [])
|
|
197
|
+
|
|
198
|
+
const resetColumnSizing = useCallback(() => {
|
|
199
|
+
setColumnSizingState({ ...defaultRef.current })
|
|
200
|
+
}, [])
|
|
201
|
+
|
|
202
|
+
// 📖 hasCustomSizing is memoized cheaply — one pass over the current keys is enough.
|
|
203
|
+
const isCustom = hasCustomSizing(columnSizing, defaultRef.current)
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
columnSizing,
|
|
207
|
+
setColumnSizing,
|
|
208
|
+
resetColumnSizing,
|
|
209
|
+
hasCustomSizing: isCustom,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/hooks/useFavorites.js
|
|
3
|
+
* @description Favorites management hook for the Web Dashboard — M1 parity with TUI `F` / `Y` / `Shift+↑↓`.
|
|
4
|
+
* 📖 Persists through the existing ~/.free-coding-models.json config file (same source as the TUI),
|
|
5
|
+
* 📖 via the /api/favorites endpoint. Both surfaces share the same favorites list.
|
|
6
|
+
*
|
|
7
|
+
* 📖 Storage shape (mirrors src/core/favorites.js):
|
|
8
|
+
* 📖 config.favorites — string[] of "providerKey/modelId", insertion-ordered
|
|
9
|
+
* 📖 config.settings.favoritesPinnedAndSticky — boolean (TUI `Y` key behavior)
|
|
10
|
+
*
|
|
11
|
+
* 📖 Returns a stable shape the rest of the Web UI can consume:
|
|
12
|
+
* 📖 {
|
|
13
|
+
* 📖 favorites: string[], — current favorites in priority order
|
|
14
|
+
* 📖 pinnedAndSticky: boolean, — true = favorites bypass filters
|
|
15
|
+
* 📖 isFavorite(model): boolean, — predicate
|
|
16
|
+
* 📖 favoriteRank(model): number, — 0-based index, Number.MAX_SAFE_INTEGER if not favorited
|
|
17
|
+
* 📖 toggle(model): Promise<void>, — add/remove
|
|
18
|
+
* 📖 reorder(model, dir): Promise<void>, — 'up' or 'down'
|
|
19
|
+
* 📖 setPinnedAndSticky(bool): Promise<void>,
|
|
20
|
+
* 📖 refresh(): Promise<void>, — manual reload from server
|
|
21
|
+
* 📖 loading: boolean, — initial load
|
|
22
|
+
* 📖 }
|
|
23
|
+
*
|
|
24
|
+
* @functions
|
|
25
|
+
* → useFavorites({ models }) — hook
|
|
26
|
+
*
|
|
27
|
+
* @see src/core/favorites.js — TUI engine (same storage format)
|
|
28
|
+
* @see web/server.js — /api/favorites endpoint
|
|
29
|
+
*/
|
|
30
|
+
import { useCallback, useEffect, useState, useMemo } from 'react'
|
|
31
|
+
|
|
32
|
+
const EMPTY_FAVORITES = Object.freeze([])
|
|
33
|
+
|
|
34
|
+
export function useFavorites({ models } = {}) {
|
|
35
|
+
const [favorites, setFavorites] = useState(EMPTY_FAVORITES)
|
|
36
|
+
const [pinnedAndSticky, setPinnedAndStickyState] = useState(false)
|
|
37
|
+
const [loading, setLoading] = useState(true)
|
|
38
|
+
const [error, setError] = useState(null)
|
|
39
|
+
|
|
40
|
+
// 📖 Initial load: fetch the current favorites + pinnedAndSticky from the server.
|
|
41
|
+
const refresh = useCallback(async () => {
|
|
42
|
+
try {
|
|
43
|
+
const resp = await fetch('/api/favorites')
|
|
44
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
|
45
|
+
const data = await resp.json()
|
|
46
|
+
setFavorites(Array.isArray(data.favorites) ? data.favorites : [])
|
|
47
|
+
setPinnedAndStickyState(Boolean(data.pinnedAndSticky))
|
|
48
|
+
setError(null)
|
|
49
|
+
} catch (err) {
|
|
50
|
+
setError(err.message || 'Failed to load favorites')
|
|
51
|
+
// 📖 On error, fall back to empty state — the UI stays usable.
|
|
52
|
+
setFavorites(EMPTY_FAVORITES)
|
|
53
|
+
setPinnedAndStickyState(false)
|
|
54
|
+
} finally {
|
|
55
|
+
setLoading(false)
|
|
56
|
+
}
|
|
57
|
+
}, [])
|
|
58
|
+
|
|
59
|
+
useEffect(() => { refresh() }, [refresh])
|
|
60
|
+
|
|
61
|
+
// 📖 Build a map "providerKey/modelId" → index for O(1) lookup.
|
|
62
|
+
const favoriteIndex = useMemo(() => {
|
|
63
|
+
const map = new Map()
|
|
64
|
+
favorites.forEach((key, idx) => map.set(key, idx))
|
|
65
|
+
return map
|
|
66
|
+
}, [favorites])
|
|
67
|
+
|
|
68
|
+
const isFavorite = useCallback((model) => {
|
|
69
|
+
if (!model) return false
|
|
70
|
+
const key = `${model.providerKey}/${model.modelId}`
|
|
71
|
+
return favoriteIndex.has(key)
|
|
72
|
+
}, [favoriteIndex])
|
|
73
|
+
|
|
74
|
+
const favoriteRank = useCallback((model) => {
|
|
75
|
+
if (!model) return Number.MAX_SAFE_INTEGER
|
|
76
|
+
const key = `${model.providerKey}/${model.modelId}`
|
|
77
|
+
const idx = favoriteIndex.get(key)
|
|
78
|
+
return idx === undefined ? Number.MAX_SAFE_INTEGER : idx
|
|
79
|
+
}, [favoriteIndex])
|
|
80
|
+
|
|
81
|
+
// 📖 Build a key for the API — same shape the TUI uses (providerKey/modelId).
|
|
82
|
+
const keyOf = useCallback((model) => {
|
|
83
|
+
if (!model) return null
|
|
84
|
+
return `${model.providerKey}/${model.modelId}`
|
|
85
|
+
}, [])
|
|
86
|
+
|
|
87
|
+
// 📖 Send the updated favorites list to the server.
|
|
88
|
+
const persistFavorites = useCallback(async (next) => {
|
|
89
|
+
try {
|
|
90
|
+
const resp = await fetch('/api/favorites', {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: { 'Content-Type': 'application/json' },
|
|
93
|
+
body: JSON.stringify({ favorites: next }),
|
|
94
|
+
})
|
|
95
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
|
96
|
+
setFavorites(next)
|
|
97
|
+
} catch (err) {
|
|
98
|
+
setError(err.message || 'Failed to save favorites')
|
|
99
|
+
}
|
|
100
|
+
}, [])
|
|
101
|
+
|
|
102
|
+
const toggle = useCallback(async (model) => {
|
|
103
|
+
const key = keyOf(model)
|
|
104
|
+
if (!key) return
|
|
105
|
+
const exists = favoriteIndex.has(key)
|
|
106
|
+
const next = exists
|
|
107
|
+
? favorites.filter((k) => k !== key)
|
|
108
|
+
: [...favorites, key]
|
|
109
|
+
await persistFavorites(next)
|
|
110
|
+
}, [favorites, favoriteIndex, keyOf, persistFavorites])
|
|
111
|
+
|
|
112
|
+
const reorder = useCallback(async (model, direction) => {
|
|
113
|
+
const key = keyOf(model)
|
|
114
|
+
if (!key) return
|
|
115
|
+
const idx = favorites.indexOf(key)
|
|
116
|
+
if (idx < 0) return
|
|
117
|
+
const swapIdx = direction === 'up' ? idx - 1 : idx + 1
|
|
118
|
+
if (swapIdx < 0 || swapIdx >= favorites.length) return
|
|
119
|
+
const next = [...favorites]
|
|
120
|
+
;[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]]
|
|
121
|
+
await persistFavorites(next)
|
|
122
|
+
}, [favorites, keyOf, persistFavorites])
|
|
123
|
+
|
|
124
|
+
const setPinnedAndSticky = useCallback(async (value) => {
|
|
125
|
+
setPinnedAndStickyState(Boolean(value))
|
|
126
|
+
try {
|
|
127
|
+
await fetch('/api/favorites', {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
headers: { 'Content-Type': 'application/json' },
|
|
130
|
+
body: JSON.stringify({ pinnedAndSticky: Boolean(value) }),
|
|
131
|
+
})
|
|
132
|
+
} catch (err) {
|
|
133
|
+
setError(err.message || 'Failed to save display mode')
|
|
134
|
+
}
|
|
135
|
+
}, [])
|
|
136
|
+
|
|
137
|
+
// 📖 Return a stable object — callers can destructure safely across renders.
|
|
138
|
+
return {
|
|
139
|
+
favorites,
|
|
140
|
+
pinnedAndSticky,
|
|
141
|
+
isFavorite,
|
|
142
|
+
favoriteRank,
|
|
143
|
+
toggle,
|
|
144
|
+
reorder,
|
|
145
|
+
setPinnedAndSticky,
|
|
146
|
+
refresh,
|
|
147
|
+
loading,
|
|
148
|
+
error,
|
|
149
|
+
}
|
|
150
|
+
}
|