free-coding-models 0.5.4 → 0.5.6
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 +8 -5
- package/bin/free-coding-models.js +29 -8
- package/changelog/v0.5.5.md +16 -0
- package/changelog/v0.5.6.md +24 -0
- package/package.json +4 -4
- package/src/core/changelog-loader.js +5 -1
- package/src/core/router-daemon.js +11 -0
- package/src/core/updater.js +174 -10
- package/src/tui/app.js +11 -31
- package/src/tui/render-table.js +9 -3
- package/src/tui/tui-state.js +6 -0
- package/web/dist/assets/index-Blp9QJev.js +39 -0
- package/web/dist/assets/{index-BrpHevg4.css → index-Cz_aCLTR.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/server.js +158 -2
- package/web/src/App.jsx +107 -58
- package/web/src/components/changelog/ChangelogView.jsx +135 -0
- package/web/src/components/changelog/ChangelogView.module.css +160 -0
- package/web/src/components/help/HelpView.jsx +188 -0
- package/web/src/components/help/HelpView.module.css +157 -0
- package/web/src/components/layout/Header.jsx +8 -3
- package/web/src/components/palette/CommandPalette.jsx +228 -74
- package/web/src/components/settings/SettingsView.jsx +281 -8
- package/web/src/components/settings/SettingsView.module.css +174 -0
- package/web/src/components/update/UpdateChip.jsx +104 -0
- package/web/src/components/update/UpdateChip.module.css +146 -0
- package/web/src/global.css +15 -0
- package/web/src/hooks/urlState.constants.js +25 -0
- package/web/src/hooks/useChangelog.js +51 -0
- package/web/src/hooks/useSocket.js +3 -0
- package/web/src/hooks/useUpdateChecker.js +91 -0
- package/web/src/hooks/useUrlState.js +122 -62
- package/web/dist/assets/index-BoWmUveV.js +0 -39
package/web/src/App.jsx
CHANGED
|
@@ -1,24 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file web/src/App.jsx
|
|
3
3
|
* @description Root application component — orchestrates all views, header nav, Socket.IO
|
|
4
|
-
* connection, toast notifications, and global state.
|
|
5
|
-
*
|
|
4
|
+
* connection, toast notifications, and global state. M2 layout: no sidebar, header
|
|
5
|
+
* menu + ⌘K palette, full Settings parity, Help + Changelog modals, UpdateChip,
|
|
6
|
+
* URL write-back.
|
|
6
7
|
*
|
|
7
|
-
* 📖
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
12
|
-
* -
|
|
8
|
+
* 📖 M2 additions on top of M1:
|
|
9
|
+
* 📖 - Full command palette (TUI registry via `buildCommandPaletteEntries`)
|
|
10
|
+
* 📖 - HelpView modal (TUI parity help)
|
|
11
|
+
* 📖 - ChangelogView modal (2-phase: index + details)
|
|
12
|
+
* 📖 - UpdateChip in header (polls /api/version, popover with "Update now" + "What's new")
|
|
13
|
+
* 📖 - URL write-back (every filter / sort / view / palette / toolMode change
|
|
14
|
+
* 📖 updates the URL via history.replaceState, debounced at 80ms)
|
|
15
|
+
* 📖 - New Settings rows: theme dropdown, favorites mode toggle, startup AI scan
|
|
16
|
+
* 📖 toggle, shell-env toggle, legacy proxy cleanup button, open Changelog link,
|
|
17
|
+
* 📖 update status row, per-provider test key button
|
|
13
18
|
*
|
|
14
19
|
* @functions App → root component with all state and layout composition
|
|
15
20
|
*/
|
|
16
|
-
import { useState, useCallback, useEffect, useRef } from 'react'
|
|
21
|
+
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
|
|
17
22
|
import { useSocket } from './hooks/useSocket.js'
|
|
18
23
|
import { useFilter } from './hooks/useFilter.js'
|
|
19
24
|
import { useTheme } from './hooks/useTheme.js'
|
|
20
25
|
import { useFavorites } from './hooks/useFavorites.js'
|
|
21
26
|
import { useUrlState } from './hooks/useUrlState.js'
|
|
27
|
+
import { useUpdateChecker } from './hooks/useUpdateChecker.js'
|
|
22
28
|
import Header from './components/layout/Header.jsx'
|
|
23
29
|
import Footer from './components/layout/Footer.jsx'
|
|
24
30
|
import FilterBar from './components/dashboard/FilterBar.jsx'
|
|
@@ -28,23 +34,19 @@ import ExportModal from './components/dashboard/ExportModal.jsx'
|
|
|
28
34
|
import SettingsView from './components/settings/SettingsView.jsx'
|
|
29
35
|
import AnalyticsView from './components/analytics/AnalyticsView.jsx'
|
|
30
36
|
import CommandPalette from './components/palette/CommandPalette.jsx'
|
|
37
|
+
import HelpView from './components/help/HelpView.jsx'
|
|
38
|
+
import ChangelogView from './components/changelog/ChangelogView.jsx'
|
|
39
|
+
import UpdateChip from './components/update/UpdateChip.jsx'
|
|
31
40
|
import ToastContainer from './components/atoms/ToastContainer.jsx'
|
|
32
41
|
|
|
33
42
|
let toastIdCounter = 0
|
|
34
43
|
|
|
35
|
-
// 📖 Map current view to the header nav id. M1 only ships dashboard/settings/analytics;
|
|
36
|
-
// 📖 recommend/router/help/changelog/install-endpoints/installed-models are wired but
|
|
37
|
-
// 📖 still show a "Coming in M2/M3/M4" toast from the header for now.
|
|
38
44
|
const VIEW_TO_NAV = {
|
|
39
45
|
dashboard: 'dashboard',
|
|
40
46
|
settings: 'settings',
|
|
41
47
|
analytics: 'analytics',
|
|
42
48
|
recommend: 'recommend',
|
|
43
49
|
router: 'router',
|
|
44
|
-
help: 'help',
|
|
45
|
-
changelog: 'changelog',
|
|
46
|
-
'install-endpoints': 'install-endpoints',
|
|
47
|
-
'installed-models': 'installed-models',
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
export default function App() {
|
|
@@ -54,43 +56,63 @@ export default function App() {
|
|
|
54
56
|
const [selectedModel, setSelectedModel] = useState(null)
|
|
55
57
|
const [exportOpen, setExportOpen] = useState(false)
|
|
56
58
|
const [paletteOpen, setPaletteOpen] = useState(false)
|
|
59
|
+
const [helpOpen, setHelpOpen] = useState(false)
|
|
60
|
+
const [changelogOpen, setChangelogOpen] = useState(false)
|
|
61
|
+
const [changelogDefaultVersion, setChangelogDefaultVersion] = useState(null)
|
|
62
|
+
const [toolMode, setToolMode] = useState(null) // 📖 M3 will use this; the row highlighter already keys off it
|
|
57
63
|
const [toasts, setToasts] = useState([])
|
|
58
64
|
const lastActivityRef = useRef(Date.now())
|
|
59
65
|
|
|
60
|
-
// 📖 URL deep-linking (M1 = read-only hydration on mount; write-back lands in M2).
|
|
61
|
-
// 📖 Syncs `currentView`, filter state, and selection from query params so users
|
|
62
|
-
// 📖 can share pre-configured dashboard URLs.
|
|
63
|
-
useUrlState({
|
|
64
|
-
currentView, setCurrentView,
|
|
65
|
-
filterState: null, // wired in M2
|
|
66
|
-
})
|
|
67
|
-
|
|
68
66
|
const {
|
|
69
67
|
filtered,
|
|
70
68
|
filterTier, setFilterTier,
|
|
71
69
|
filterStatus, setFilterStatus,
|
|
72
70
|
filterProvider, setFilterProvider,
|
|
73
|
-
searchQuery, setSearchQuery,
|
|
74
|
-
sortColumn, sortDirection, toggleSort,
|
|
75
71
|
filterVerdict, setFilterVerdict,
|
|
76
72
|
filterHealth, setFilterHealth,
|
|
77
73
|
visibilityMode, setVisibilityMode,
|
|
74
|
+
searchQuery, setSearchQuery,
|
|
78
75
|
customTextFilter, setCustomTextFilter,
|
|
76
|
+
sortColumn, sortDirection, setSortColumn, setSortDirection, toggleSort,
|
|
79
77
|
resetView,
|
|
80
78
|
} = useFilter(models)
|
|
81
79
|
|
|
82
|
-
// 📖
|
|
80
|
+
// 📖 URL deep-linking (M2 = read + write). Hydrates on mount, then pushes
|
|
81
|
+
// 📖 every change back via history.replaceState (debounced 80ms).
|
|
82
|
+
useUrlState({
|
|
83
|
+
currentView, setCurrentView,
|
|
84
|
+
filterState: {
|
|
85
|
+
filterTier, setFilterTier,
|
|
86
|
+
filterStatus, setFilterStatus,
|
|
87
|
+
filterProvider, setFilterProvider,
|
|
88
|
+
filterVerdict, setFilterVerdict,
|
|
89
|
+
filterHealth, setFilterHealth,
|
|
90
|
+
sortColumn, sortDirection, setSortColumn, setSortDirection, toggleSort,
|
|
91
|
+
setSearchQuery,
|
|
92
|
+
filterState: null, // sentinel; useFilter doesn't expose this name
|
|
93
|
+
searchQuery,
|
|
94
|
+
},
|
|
95
|
+
paletteOpen, setPaletteOpen,
|
|
96
|
+
toolMode, setToolMode,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// 📖 Favorites — single source of truth shared with the TUI.
|
|
83
100
|
const favorites = useFavorites({ models })
|
|
84
101
|
|
|
102
|
+
// 📖 Update checker (5-minute poll). Returns `updateAvailable` for the chip.
|
|
103
|
+
const {
|
|
104
|
+
localVersion, latestVersion, updateAvailable, runUpdate, checkNow, error: updateError,
|
|
105
|
+
} = useUpdateChecker({ onToast: addToastInternal })
|
|
106
|
+
|
|
85
107
|
// 📖 Build the provider list for the FilterBar dropdown.
|
|
86
|
-
const providers = (() => {
|
|
108
|
+
const providers = useMemo(() => {
|
|
87
109
|
const map = {}
|
|
88
110
|
models.forEach((m) => {
|
|
89
111
|
if (!map[m.providerKey]) map[m.providerKey] = { key: m.providerKey, name: m.origin, count: 0 }
|
|
90
112
|
map[m.providerKey].count++
|
|
91
113
|
})
|
|
92
114
|
return Object.values(map).sort((a, b) => a.name.localeCompare(b.name))
|
|
93
|
-
})
|
|
115
|
+
}, [models])
|
|
94
116
|
|
|
95
117
|
// ── Global benchmark (AI Speed Test) ─────────────────────────────────────
|
|
96
118
|
const handleBenchmark = useCallback(async () => {
|
|
@@ -118,7 +140,7 @@ export default function App() {
|
|
|
118
140
|
})
|
|
119
141
|
if (!resp.ok && resp.status !== 202) {
|
|
120
142
|
const err = await resp.json().catch(() => ({}))
|
|
121
|
-
|
|
143
|
+
addToastInternal?.(`Benchmark failed: ${err?.error || resp.statusText}`, 'error')
|
|
122
144
|
}
|
|
123
145
|
} catch (err) {
|
|
124
146
|
console.error('[Benchmark] per-row failed:', err.message)
|
|
@@ -126,14 +148,14 @@ export default function App() {
|
|
|
126
148
|
}, [])
|
|
127
149
|
|
|
128
150
|
// ── Toast helpers ────────────────────────────────────────────────────────
|
|
129
|
-
|
|
151
|
+
function addToastInternal(message, type = 'info') {
|
|
130
152
|
const id = ++toastIdCounter
|
|
131
153
|
setToasts((prev) => [...prev, { id, message, type }])
|
|
132
154
|
setTimeout(() => {
|
|
133
155
|
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
134
156
|
}, 4000)
|
|
135
|
-
}
|
|
136
|
-
|
|
157
|
+
}
|
|
158
|
+
const addToast = useCallback(addToastInternal, [])
|
|
137
159
|
const dismissToast = useCallback((id) => {
|
|
138
160
|
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
139
161
|
}, [])
|
|
@@ -143,10 +165,9 @@ export default function App() {
|
|
|
143
165
|
setSelectedModel(model)
|
|
144
166
|
lastActivityRef.current = Date.now()
|
|
145
167
|
}, [])
|
|
146
|
-
|
|
147
168
|
const handleCloseDetail = useCallback(() => setSelectedModel(null), [])
|
|
148
169
|
|
|
149
|
-
// ── Ping mode change → server → broadcast
|
|
170
|
+
// ── Ping mode change → server → broadcast ─────────────────────────────
|
|
150
171
|
const handlePingModeChange = useCallback(async (mode) => {
|
|
151
172
|
try {
|
|
152
173
|
await fetch(`/api/ping-mode?action=${mode}`, { method: 'POST' })
|
|
@@ -155,55 +176,56 @@ export default function App() {
|
|
|
155
176
|
|
|
156
177
|
// ── Navigation handler (Header nav + overflow menu) ──────────────────────
|
|
157
178
|
const handleNavigate = useCallback((viewId) => {
|
|
179
|
+
// 📖 'help' / 'changelog' / 'recommend' / 'router' open modals (M2) or
|
|
180
|
+
// 📖 toasts (M3/M4) — they don't switch the currentView.
|
|
181
|
+
if (viewId === 'help') { setHelpOpen(true); return }
|
|
182
|
+
if (viewId === 'changelog') { setChangelogOpen(true); setChangelogDefaultVersion(null); return }
|
|
183
|
+
if (viewId === 'recommend') { addToast?.('Smart Recommend arrives in M3', 'info'); return }
|
|
184
|
+
if (viewId === 'router') { addToast?.('Router dashboard arrives in M4', 'info'); return }
|
|
185
|
+
if (viewId === 'install-endpoints') { addToast?.('Install Endpoints arrives in M4', 'info'); return }
|
|
186
|
+
if (viewId === 'installed-models') { addToast?.('Installed Models arrives in M4', 'info'); return }
|
|
158
187
|
setCurrentView(VIEW_TO_NAV[viewId] || viewId)
|
|
159
188
|
lastActivityRef.current = Date.now()
|
|
160
|
-
}, [])
|
|
189
|
+
}, [addToast])
|
|
161
190
|
|
|
162
191
|
// ── Reset view (N key equivalent) ────────────────────────────────────────
|
|
163
192
|
const handleResetView = useCallback(() => {
|
|
164
193
|
resetView()
|
|
165
194
|
setSearchQuery('')
|
|
166
|
-
|
|
167
|
-
}, [resetView, setSearchQuery
|
|
195
|
+
addToastInternal('View reset to defaults.', 'info')
|
|
196
|
+
}, [resetView, setSearchQuery])
|
|
197
|
+
|
|
198
|
+
// ── Changelog open with optional version (e.g. from UpdateChip "What's new") ─
|
|
199
|
+
const openChangelogAt = useCallback((version) => {
|
|
200
|
+
setChangelogDefaultVersion(version)
|
|
201
|
+
setChangelogOpen(true)
|
|
202
|
+
}, [])
|
|
168
203
|
|
|
169
|
-
// ── Keyboard shortcuts: only ⌘K / Ctrl+P for the palette
|
|
204
|
+
// ── Keyboard shortcuts: only ⌘K / Ctrl+P for the palette, Esc for any modal ─
|
|
170
205
|
useEffect(() => {
|
|
171
206
|
const handler = (e) => {
|
|
172
207
|
const cmdOrCtrl = e.metaKey || e.ctrlKey
|
|
173
|
-
// ⌘K / Ctrl+K → toggle command palette (the Web's only global shortcut)
|
|
174
208
|
if (cmdOrCtrl && (e.key === 'k' || e.key === 'K')) {
|
|
175
209
|
e.preventDefault()
|
|
176
210
|
setPaletteOpen((o) => !o)
|
|
177
211
|
return
|
|
178
212
|
}
|
|
179
|
-
// Ctrl+P is also accepted as a TUI-style alias for the same palette.
|
|
180
213
|
if (cmdOrCtrl && (e.key === 'p' || e.key === 'P') && !e.shiftKey) {
|
|
181
214
|
e.preventDefault()
|
|
182
215
|
setPaletteOpen((o) => !o)
|
|
183
216
|
return
|
|
184
217
|
}
|
|
185
|
-
// Esc closes whatever is open.
|
|
186
218
|
if (e.key === 'Escape') {
|
|
187
219
|
if (paletteOpen) { setPaletteOpen(false); return }
|
|
220
|
+
if (helpOpen) { setHelpOpen(false); return }
|
|
221
|
+
if (changelogOpen) { setChangelogOpen(false); return }
|
|
188
222
|
if (selectedModel) { setSelectedModel(null); return }
|
|
189
223
|
if (exportOpen) { setExportOpen(false); return }
|
|
190
224
|
}
|
|
191
225
|
}
|
|
192
226
|
window.addEventListener('keydown', handler)
|
|
193
227
|
return () => window.removeEventListener('keydown', handler)
|
|
194
|
-
}, [paletteOpen, selectedModel, exportOpen])
|
|
195
|
-
|
|
196
|
-
// 📖 Reset view if URL contains the reset flag.
|
|
197
|
-
useEffect(() => {
|
|
198
|
-
const params = new URLSearchParams(window.location.search)
|
|
199
|
-
if (params.get('reset') === '1') {
|
|
200
|
-
handleResetView()
|
|
201
|
-
const url = new URL(window.location.href)
|
|
202
|
-
url.searchParams.delete('reset')
|
|
203
|
-
window.history.replaceState({}, '', url.toString())
|
|
204
|
-
}
|
|
205
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
206
|
-
}, [])
|
|
228
|
+
}, [paletteOpen, helpOpen, changelogOpen, selectedModel, exportOpen])
|
|
207
229
|
|
|
208
230
|
return (
|
|
209
231
|
<>
|
|
@@ -223,6 +245,14 @@ export default function App() {
|
|
|
223
245
|
modelsCount={filtered.length}
|
|
224
246
|
theme={theme}
|
|
225
247
|
onToast={addToast}
|
|
248
|
+
updateSlot={
|
|
249
|
+
<UpdateChip
|
|
250
|
+
updateAvailable={updateAvailable}
|
|
251
|
+
latestVersion={latestVersion}
|
|
252
|
+
onRunUpdate={runUpdate}
|
|
253
|
+
onOpenChangelog={openChangelogAt}
|
|
254
|
+
/>
|
|
255
|
+
}
|
|
226
256
|
/>
|
|
227
257
|
|
|
228
258
|
<div className="app-content">
|
|
@@ -262,13 +292,18 @@ export default function App() {
|
|
|
262
292
|
sortColumn={sortColumn}
|
|
263
293
|
sortDirection={sortDirection}
|
|
264
294
|
onSort={toggleSort}
|
|
295
|
+
toolMode={toolMode}
|
|
265
296
|
/>
|
|
266
297
|
</div>
|
|
267
298
|
)}
|
|
268
299
|
|
|
269
300
|
{currentView === 'settings' && (
|
|
270
301
|
<div className="view">
|
|
271
|
-
<SettingsView
|
|
302
|
+
<SettingsView
|
|
303
|
+
onToast={addToast}
|
|
304
|
+
onOpenChangelog={(version) => { setChangelogDefaultVersion(version); setChangelogOpen(true) }}
|
|
305
|
+
onCheckForUpdate={() => { checkNow(); addToast?.('Checking for updates…', 'info') }}
|
|
306
|
+
/>
|
|
272
307
|
</div>
|
|
273
308
|
)}
|
|
274
309
|
|
|
@@ -304,12 +339,26 @@ export default function App() {
|
|
|
304
339
|
onNavigate={handleNavigate}
|
|
305
340
|
onCycleTheme={cycleTheme}
|
|
306
341
|
onResetView={handleResetView}
|
|
342
|
+
onSetPingMode={handlePingModeChange}
|
|
343
|
+
onOpenHelp={() => setHelpOpen(true)}
|
|
344
|
+
onOpenChangelog={() => { setChangelogDefaultVersion(null); setChangelogOpen(true) }}
|
|
345
|
+
onExport={() => setExportOpen(true)}
|
|
346
|
+
onRunUpdate={runUpdate}
|
|
307
347
|
currentView={currentView}
|
|
308
348
|
theme={theme}
|
|
309
349
|
pingMode={pingMode}
|
|
310
|
-
|
|
350
|
+
models={models}
|
|
351
|
+
updateAvailable={updateAvailable}
|
|
352
|
+
latestVersion={latestVersion}
|
|
311
353
|
onToast={addToast}
|
|
312
|
-
|
|
354
|
+
/>
|
|
355
|
+
)}
|
|
356
|
+
|
|
357
|
+
{helpOpen && <HelpView onClose={() => setHelpOpen(false)} />}
|
|
358
|
+
{changelogOpen && (
|
|
359
|
+
<ChangelogView
|
|
360
|
+
onClose={() => setChangelogOpen(false)}
|
|
361
|
+
defaultVersion={changelogDefaultVersion}
|
|
313
362
|
/>
|
|
314
363
|
)}
|
|
315
364
|
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/changelog/ChangelogView.jsx
|
|
3
|
+
* @description Changelog modal — M2 parity with the TUI's Changelog overlay (N key / Settings link).
|
|
4
|
+
* 📖 Two-phase UI: index of versions on the left, details on the right. Same
|
|
5
|
+
* 📖 content as the TUI's renderChangelog (which uses src/core/changelog-loader.js
|
|
6
|
+
* 📖 — we hit the same data through `/api/changelog`).
|
|
7
|
+
*
|
|
8
|
+
* @functions
|
|
9
|
+
* → ChangelogView → main modal component
|
|
10
|
+
*/
|
|
11
|
+
import { useState } from 'react'
|
|
12
|
+
import { IconArrowLeft, IconX, IconCalendar } from '@tabler/icons-react'
|
|
13
|
+
import { useChangelog } from '../../hooks/useChangelog.js'
|
|
14
|
+
import styles from './ChangelogView.module.css'
|
|
15
|
+
|
|
16
|
+
// 📖 Section order matches the changelog files (`### Added` / `### Fixed` /
|
|
17
|
+
// 📖 `### Changed` / `### Updated`). The TUI uses the same order in
|
|
18
|
+
// 📖 formatChangelogForDisplay.
|
|
19
|
+
const SECTION_LABELS = [
|
|
20
|
+
{ key: 'added', label: '✨ Added', icon: '✨' },
|
|
21
|
+
{ key: 'fixed', label: '🐛 Fixed', icon: '🐛' },
|
|
22
|
+
{ key: 'changed', label: '🔄 Changed', icon: '🔄' },
|
|
23
|
+
{ key: 'updated', label: '📝 Updated', icon: '📝' },
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
export default function ChangelogView({ onClose, defaultVersion = null }) {
|
|
27
|
+
const { sortedVersions, getVersion, loading, error } = useChangelog()
|
|
28
|
+
// 📖 Two-phase navigation: 'index' (version list) or 'details' (one version).
|
|
29
|
+
// 📖 `selectedVersion` is null on the index and a version string on details.
|
|
30
|
+
const [phase, setPhase] = useState(defaultVersion ? 'details' : 'index')
|
|
31
|
+
const [selectedVersion, setSelectedVersion] = useState(defaultVersion)
|
|
32
|
+
|
|
33
|
+
const openDetails = (version) => {
|
|
34
|
+
setSelectedVersion(version)
|
|
35
|
+
setPhase('details')
|
|
36
|
+
}
|
|
37
|
+
const backToIndex = () => {
|
|
38
|
+
setSelectedVersion(null)
|
|
39
|
+
setPhase('index')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const details = selectedVersion ? getVersion(selectedVersion) : null
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className={styles.backdrop} onClick={onClose}>
|
|
46
|
+
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
|
|
47
|
+
<div className={styles.header}>
|
|
48
|
+
<div className={styles.titleRow}>
|
|
49
|
+
<h2 className={styles.title}>
|
|
50
|
+
{phase === 'index' ? '📋 Changelog' : `📋 v${selectedVersion}`}
|
|
51
|
+
</h2>
|
|
52
|
+
{phase === 'details' && (
|
|
53
|
+
<button className={styles.backBtn} onClick={backToIndex} title="Back to index (TUI: B)">
|
|
54
|
+
<IconArrowLeft size={14} stroke={1.5} /> Index
|
|
55
|
+
</button>
|
|
56
|
+
)}
|
|
57
|
+
<button className={styles.closeBtn} onClick={onClose} aria-label="Close changelog">
|
|
58
|
+
<IconX size={18} stroke={1.5} />
|
|
59
|
+
</button>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div className={styles.body}>
|
|
64
|
+
{loading && <div className={styles.empty}>Loading changelog…</div>}
|
|
65
|
+
{error && !loading && <div className={styles.empty}>Failed to load changelog: {error}</div>}
|
|
66
|
+
|
|
67
|
+
{!loading && !error && phase === 'index' && (
|
|
68
|
+
<div className={styles.indexWrap}>
|
|
69
|
+
<p className={styles.indexHint}>
|
|
70
|
+
{sortedVersions.length} versions. Click any to read the release notes.
|
|
71
|
+
</p>
|
|
72
|
+
<ul className={styles.versionList}>
|
|
73
|
+
{sortedVersions.map((version) => {
|
|
74
|
+
const changes = getVersion(version)
|
|
75
|
+
const summary = []
|
|
76
|
+
if (changes?.added?.length) summary.push(`${changes.added.length} added`)
|
|
77
|
+
if (changes?.fixed?.length) summary.push(`${changes.fixed.length} fixed`)
|
|
78
|
+
if (changes?.changed?.length) summary.push(`${changes.changed.length} changed`)
|
|
79
|
+
return (
|
|
80
|
+
<li key={version}>
|
|
81
|
+
<button
|
|
82
|
+
className={styles.versionBtn}
|
|
83
|
+
onClick={() => openDetails(version)}
|
|
84
|
+
>
|
|
85
|
+
<span className={styles.versionLabel}>v{version}</span>
|
|
86
|
+
<span className={styles.versionSummary}>
|
|
87
|
+
{summary.length > 0 ? summary.join(' · ') : '—'}
|
|
88
|
+
</span>
|
|
89
|
+
</button>
|
|
90
|
+
</li>
|
|
91
|
+
)
|
|
92
|
+
})}
|
|
93
|
+
</ul>
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
|
|
97
|
+
{!loading && !error && phase === 'details' && details && (
|
|
98
|
+
<div className={styles.details}>
|
|
99
|
+
{SECTION_LABELS.map(({ key, label }) => {
|
|
100
|
+
const items = details[key]
|
|
101
|
+
if (!items || items.length === 0) return null
|
|
102
|
+
return (
|
|
103
|
+
<section key={key} className={styles.section}>
|
|
104
|
+
<h3 className={styles.sectionTitle}>{label}</h3>
|
|
105
|
+
<ul className={styles.itemList}>
|
|
106
|
+
{items.map((item, idx) => (
|
|
107
|
+
<li key={idx} className={styles.item}>{formatItem(item)}</li>
|
|
108
|
+
))}
|
|
109
|
+
</ul>
|
|
110
|
+
</section>
|
|
111
|
+
)
|
|
112
|
+
})}
|
|
113
|
+
{SECTION_LABELS.every(({ key }) => !details[key]?.length) && (
|
|
114
|
+
<div className={styles.empty}>No release notes for v{selectedVersion}.</div>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
{!loading && !error && phase === 'details' && !details && (
|
|
120
|
+
<div className={styles.empty}>No notes for v{selectedVersion}.</div>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 📖 Strip **bold** and `code` markdown markers for the Web view. The TUI does
|
|
129
|
+
// 📖 the same in formatChangelogForDisplay, so the two surfaces stay in sync.
|
|
130
|
+
function formatItem(text) {
|
|
131
|
+
return text
|
|
132
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
133
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
134
|
+
.trim()
|
|
135
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/changelog/ChangelogView.module.css
|
|
3
|
+
* @description Two-phase changelog modal styles (TUI parity).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
.backdrop {
|
|
7
|
+
position: fixed;
|
|
8
|
+
inset: 0;
|
|
9
|
+
background: rgba(0, 0, 0, 0.55);
|
|
10
|
+
backdrop-filter: blur(4px);
|
|
11
|
+
-webkit-backdrop-filter: blur(4px);
|
|
12
|
+
z-index: 500;
|
|
13
|
+
display: flex;
|
|
14
|
+
align-items: center;
|
|
15
|
+
justify-content: center;
|
|
16
|
+
padding: 5vh 4vw;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.modal {
|
|
20
|
+
width: min(720px, 95vw);
|
|
21
|
+
max-height: 88vh;
|
|
22
|
+
background: var(--color-bg-elevated);
|
|
23
|
+
border: 1px solid var(--color-border-hover);
|
|
24
|
+
border-radius: 14px;
|
|
25
|
+
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
|
|
26
|
+
display: flex;
|
|
27
|
+
flex-direction: column;
|
|
28
|
+
overflow: hidden;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.header {
|
|
32
|
+
padding: 16px 20px;
|
|
33
|
+
border-bottom: 1px solid var(--color-border);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.titleRow {
|
|
37
|
+
display: flex;
|
|
38
|
+
align-items: center;
|
|
39
|
+
gap: 12px;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.title {
|
|
43
|
+
font-size: 16px;
|
|
44
|
+
font-weight: 700;
|
|
45
|
+
margin: 0;
|
|
46
|
+
color: var(--color-text);
|
|
47
|
+
flex: 1;
|
|
48
|
+
min-width: 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.backBtn {
|
|
52
|
+
display: inline-flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
gap: 4px;
|
|
55
|
+
padding: 4px 10px;
|
|
56
|
+
background: var(--color-surface);
|
|
57
|
+
color: var(--color-text);
|
|
58
|
+
border: 1px solid var(--color-border);
|
|
59
|
+
border-radius: 6px;
|
|
60
|
+
font-size: 11px;
|
|
61
|
+
font-weight: 600;
|
|
62
|
+
cursor: pointer;
|
|
63
|
+
font-family: var(--font-sans);
|
|
64
|
+
white-space: nowrap;
|
|
65
|
+
}
|
|
66
|
+
.backBtn:hover { background: var(--color-bg-hover); border-color: var(--color-text-muted); }
|
|
67
|
+
|
|
68
|
+
.closeBtn {
|
|
69
|
+
background: transparent;
|
|
70
|
+
border: none;
|
|
71
|
+
color: var(--color-text-muted);
|
|
72
|
+
cursor: pointer;
|
|
73
|
+
padding: 4px;
|
|
74
|
+
border-radius: 4px;
|
|
75
|
+
display: inline-flex;
|
|
76
|
+
align-items: center;
|
|
77
|
+
justify-content: center;
|
|
78
|
+
flex-shrink: 0;
|
|
79
|
+
}
|
|
80
|
+
.closeBtn:hover { color: var(--color-text); background: var(--color-bg-hover); }
|
|
81
|
+
|
|
82
|
+
.body {
|
|
83
|
+
flex: 1;
|
|
84
|
+
overflow-y: auto;
|
|
85
|
+
padding: 16px 20px 20px;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.indexWrap {}
|
|
89
|
+
.indexHint {
|
|
90
|
+
margin: 0 0 12px;
|
|
91
|
+
font-size: 11px;
|
|
92
|
+
color: var(--color-text-muted);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.versionList {
|
|
96
|
+
list-style: none;
|
|
97
|
+
margin: 0;
|
|
98
|
+
padding: 0;
|
|
99
|
+
display: flex;
|
|
100
|
+
flex-direction: column;
|
|
101
|
+
gap: 4px;
|
|
102
|
+
}
|
|
103
|
+
.versionBtn {
|
|
104
|
+
display: flex;
|
|
105
|
+
align-items: center;
|
|
106
|
+
justify-content: space-between;
|
|
107
|
+
gap: 12px;
|
|
108
|
+
width: 100%;
|
|
109
|
+
padding: 8px 12px;
|
|
110
|
+
background: var(--color-surface);
|
|
111
|
+
border: 1px solid var(--color-border);
|
|
112
|
+
border-radius: 6px;
|
|
113
|
+
cursor: pointer;
|
|
114
|
+
text-align: left;
|
|
115
|
+
transition: all 100ms;
|
|
116
|
+
font-family: var(--font-sans);
|
|
117
|
+
}
|
|
118
|
+
.versionBtn:hover { background: var(--color-bg-hover); border-color: var(--color-text-muted); }
|
|
119
|
+
.versionLabel {
|
|
120
|
+
font-family: var(--font-mono);
|
|
121
|
+
font-size: 13px;
|
|
122
|
+
font-weight: 700;
|
|
123
|
+
color: var(--color-text);
|
|
124
|
+
}
|
|
125
|
+
.versionSummary {
|
|
126
|
+
font-size: 11px;
|
|
127
|
+
color: var(--color-text-muted);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.details { display: flex; flex-direction: column; gap: 14px; }
|
|
131
|
+
.section {}
|
|
132
|
+
.sectionTitle {
|
|
133
|
+
font-size: 12px;
|
|
134
|
+
font-weight: 700;
|
|
135
|
+
text-transform: uppercase;
|
|
136
|
+
letter-spacing: 0.6px;
|
|
137
|
+
color: var(--color-text-muted);
|
|
138
|
+
margin: 0 0 6px;
|
|
139
|
+
}
|
|
140
|
+
.itemList {
|
|
141
|
+
list-style: disc;
|
|
142
|
+
padding-left: 20px;
|
|
143
|
+
margin: 0;
|
|
144
|
+
display: flex;
|
|
145
|
+
flex-direction: column;
|
|
146
|
+
gap: 4px;
|
|
147
|
+
}
|
|
148
|
+
.item {
|
|
149
|
+
font-size: 12px;
|
|
150
|
+
color: var(--color-text);
|
|
151
|
+
line-height: 1.55;
|
|
152
|
+
word-break: break-word;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.empty {
|
|
156
|
+
text-align: center;
|
|
157
|
+
padding: 40px 20px;
|
|
158
|
+
color: var(--color-text-dim);
|
|
159
|
+
font-size: 13px;
|
|
160
|
+
}
|