@kolkrabbi/kol-component 0.6.0 → 0.7.0
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/package.json +4 -2
- package/src/chess/ChessPiece.jsx +191 -0
- package/src/chess/apparatus/AlternativeControlsMock.jsx +460 -0
- package/src/chess/apparatus/ChessAnalysisLayout.jsx +24 -0
- package/src/chess/apparatus/ChessBoard.jsx +210 -0
- package/src/chess/apparatus/ChessBoardFullscreen.jsx +49 -0
- package/src/chess/apparatus/ChessBoardWithControls.jsx +41 -0
- package/src/chess/apparatus/ChessBoardWithSidebar.jsx +122 -0
- package/src/chess/apparatus/ChessSidebar.jsx +131 -0
- package/src/chess/apparatus/GameArchiveTable.jsx +493 -0
- package/src/chess/apparatus/GameSelector.jsx +47 -0
- package/src/chess/apparatus/NotationPanel.jsx +70 -0
- package/src/chess/apparatus/PlaybackControls.jsx +52 -0
- package/src/chess/apparatus/VariationTree.jsx +137 -0
- package/src/chess/assets/chess-extra-set/set-1/bishop-1.svg +6 -0
- package/src/chess/assets/chess-extra-set/set-1/bishop.svg +6 -0
- package/src/chess/assets/chess-extra-set/set-1/king-1.svg +9 -0
- package/src/chess/assets/chess-extra-set/set-1/king.svg +7 -0
- package/src/chess/assets/chess-extra-set/set-1/knight-1.svg +8 -0
- package/src/chess/assets/chess-extra-set/set-1/knight.svg +7 -0
- package/src/chess/assets/chess-extra-set/set-1/pawn-1.svg +3 -0
- package/src/chess/assets/chess-extra-set/set-1/pawn.svg +3 -0
- package/src/chess/assets/chess-extra-set/set-1/queen-1.svg +11 -0
- package/src/chess/assets/chess-extra-set/set-1/queen.svg +6 -0
- package/src/chess/assets/chess-extra-set/set-1/rook-1.svg +7 -0
- package/src/chess/assets/chess-extra-set/set-1/rook.svg +11 -0
- package/src/chess/assets/chess-extra-set/set-2/bishop-1.svg +4 -0
- package/src/chess/assets/chess-extra-set/set-2/bishop.svg +4 -0
- package/src/chess/assets/chess-extra-set/set-2/king-1.svg +4 -0
- package/src/chess/assets/chess-extra-set/set-2/king.svg +4 -0
- package/src/chess/assets/chess-extra-set/set-2/knight-1.svg +5 -0
- package/src/chess/assets/chess-extra-set/set-2/knight.svg +5 -0
- package/src/chess/assets/chess-extra-set/set-2/pawn-1.svg +4 -0
- package/src/chess/assets/chess-extra-set/set-2/pawn.svg +4 -0
- package/src/chess/assets/chess-extra-set/set-2/queen-1.svg +4 -0
- package/src/chess/assets/chess-extra-set/set-2/queen.svg +4 -0
- package/src/chess/assets/chess-extra-set/set-2/rook-1.svg +5 -0
- package/src/chess/assets/chess-extra-set/set-2/rook.svg +5 -0
- package/src/chess/assets/chess-extra-set/set-3/bishop-1.svg +10 -0
- package/src/chess/assets/chess-extra-set/set-3/bishop.svg +3 -0
- package/src/chess/assets/chess-extra-set/set-3/king-1.svg +12 -0
- package/src/chess/assets/chess-extra-set/set-3/king.svg +3 -0
- package/src/chess/assets/chess-extra-set/set-3/knight-1.svg +3 -0
- package/src/chess/assets/chess-extra-set/set-3/knight.svg +10 -0
- package/src/chess/assets/chess-extra-set/set-3/pawn-1.svg +10 -0
- package/src/chess/assets/chess-extra-set/set-3/pawn.svg +10 -0
- package/src/chess/assets/chess-extra-set/set-3/queen-1.svg +10 -0
- package/src/chess/assets/chess-extra-set/set-3/queen.svg +3 -0
- package/src/chess/assets/chess-extra-set/set-3/rook-1.svg +3 -0
- package/src/chess/assets/chess-extra-set/set-3/rook.svg +11 -0
- package/src/chess/assets/chess-vector-set/ABoard.svg +164 -0
- package/src/chess/assets/chess-vector-set/BBiskup.svg +12 -0
- package/src/chess/assets/chess-vector-set/BKing.svg +15 -0
- package/src/chess/assets/chess-vector-set/BKnight.svg +13 -0
- package/src/chess/assets/chess-vector-set/BPawn.svg +7 -0
- package/src/chess/assets/chess-vector-set/BQueen.svg +6 -0
- package/src/chess/assets/chess-vector-set/BRook.svg +12 -0
- package/src/chess/assets/chess-vector-set/WBiskup.svg +14 -0
- package/src/chess/assets/chess-vector-set/WKing.svg +13 -0
- package/src/chess/assets/chess-vector-set/WKnight.svg +10 -0
- package/src/chess/assets/chess-vector-set/WPawn.svg +8 -0
- package/src/chess/assets/chess-vector-set/WQueen.svg +17 -0
- package/src/chess/assets/chess-vector-set/WRook.svg +13 -0
- package/src/chess/context/ChessControlsContext.jsx +379 -0
- package/src/chess/dashboards/ChessHero.jsx +303 -0
- package/src/chess/index.js +32 -0
- package/src/chess/utils/parsePgnTree.js +97 -0
- package/src/dashboards/cards/DashAlertCard.jsx +44 -0
- package/src/dashboards/cards/DashChartCard.jsx +44 -0
- package/src/dashboards/cards/DashFeaturedCard.jsx +42 -0
- package/src/dashboards/cards/DashListCard.jsx +87 -0
- package/src/dashboards/cards/DashMetricCard.jsx +59 -0
- package/src/dashboards/cards/DashSlotCard.jsx +45 -0
- package/src/dashboards/cards/DashStackedBarCard.jsx +70 -0
- package/src/dashboards/cards/DashTableCard.jsx +31 -0
- package/src/dashboards/cards/_shared/CardHeader.jsx +15 -0
- package/src/dashboards/cards/_shared/LegendRow.jsx +25 -0
- package/src/dashboards/charts/Candlestick.jsx +79 -0
- package/src/dashboards/charts/DonutChart.jsx +87 -0
- package/src/dashboards/charts/Heatmap.jsx +79 -0
- package/src/dashboards/charts/Histogram.jsx +56 -0
- package/src/dashboards/charts/LineChart.jsx +157 -0
- package/src/dashboards/charts/ScatterPlot.jsx +93 -0
- package/src/dashboards/charts/Sparkline.jsx +63 -0
- package/src/dashboards/index.js +28 -0
- package/src/dashboards/layout/DashboardGrid.jsx +30 -0
- package/src/dashboards/layout/GridCard.jsx +44 -0
- package/src/dashboards/shared/DashTooltip.jsx +6 -0
- package/src/dashboards/shared/formatMetric.js +26 -0
- package/src/dashboards/shared/useChartTooltip.js +25 -0
- package/src/dashboards/shared/useCountUp.js +107 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
// Data is consumer-provided via the `chessData` prop (no bundled dataset).
|
|
2
|
+
// Required chessData functions: loadMonthGames(month), getMonthlySummary(),
|
|
3
|
+
// getRandomMonth(), getGamePgnByIdAsync(id, month).
|
|
4
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
5
|
+
import Dropdown from '../../molecules/Dropdown.jsx'
|
|
6
|
+
import Input from '../../atoms/Input.jsx'
|
|
7
|
+
import Table from '../../organisms/Table.jsx'
|
|
8
|
+
import Tag from '../../atoms/Tag.jsx'
|
|
9
|
+
import Pill from '../../atoms/Pill.jsx'
|
|
10
|
+
|
|
11
|
+
const MAX_VISIBLE_GAMES = 5
|
|
12
|
+
|
|
13
|
+
const GameArchiveTable = ({ chessData, onGameLoad }) => {
|
|
14
|
+
const {
|
|
15
|
+
loadMonthGames = async () => [],
|
|
16
|
+
getMonthlySummary = () => [],
|
|
17
|
+
getRandomMonth = () => null,
|
|
18
|
+
getGamePgnByIdAsync = async () => null
|
|
19
|
+
} = chessData ?? {}
|
|
20
|
+
|
|
21
|
+
const monthFormatter = useMemo(
|
|
22
|
+
() =>
|
|
23
|
+
new Intl.DateTimeFormat('en', {
|
|
24
|
+
month: 'short',
|
|
25
|
+
year: 'numeric'
|
|
26
|
+
}),
|
|
27
|
+
[]
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
// Lightweight data - always loaded
|
|
31
|
+
const monthlySummary = useMemo(() => getMonthlySummary(), [])
|
|
32
|
+
|
|
33
|
+
// Heavy data - loaded on demand
|
|
34
|
+
const [loadedMonths, setLoadedMonths] = useState(new Set())
|
|
35
|
+
const [monthlyGames, setMonthlyGames] = useState({}) // { "2024-08": [...games] }
|
|
36
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
37
|
+
const [selectedMonth, setSelectedMonth] = useState(null)
|
|
38
|
+
const [selectedTimeClass, setSelectedTimeClass] = useState('all')
|
|
39
|
+
const [selectedResult, setSelectedResult] = useState('all')
|
|
40
|
+
const [searchTerm, setSearchTerm] = useState('')
|
|
41
|
+
const [showAllGames, setShowAllGames] = useState(false)
|
|
42
|
+
|
|
43
|
+
// Month options (from lightweight summary)
|
|
44
|
+
const monthOptions = useMemo(() => {
|
|
45
|
+
const formatted = (monthlySummary || [])
|
|
46
|
+
.filter((entry) => entry?.month && entry.month !== 'unknown')
|
|
47
|
+
.map((entry) => ({
|
|
48
|
+
label: monthFormatter.format(new Date(`${entry.month}-01T00:00:00Z`)),
|
|
49
|
+
value: entry.month,
|
|
50
|
+
count: entry.total
|
|
51
|
+
}))
|
|
52
|
+
.reverse()
|
|
53
|
+
return formatted
|
|
54
|
+
}, [monthlySummary, monthFormatter])
|
|
55
|
+
|
|
56
|
+
// Load random month on mount
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const loadInitialMonth = async () => {
|
|
59
|
+
const randomMonth = getRandomMonth()
|
|
60
|
+
setIsLoading(true)
|
|
61
|
+
const games = await loadMonthGames(randomMonth)
|
|
62
|
+
setMonthlyGames({ [randomMonth]: games.slice(0, 5) }) // Only first 5 games
|
|
63
|
+
setLoadedMonths(new Set([randomMonth]))
|
|
64
|
+
setSelectedMonth(randomMonth)
|
|
65
|
+
setIsLoading(false)
|
|
66
|
+
}
|
|
67
|
+
loadInitialMonth()
|
|
68
|
+
}, [])
|
|
69
|
+
|
|
70
|
+
// Load month data when explicitly requested
|
|
71
|
+
const handleLoadMonth = useCallback(async (month) => {
|
|
72
|
+
if (loadedMonths.has(month)) {
|
|
73
|
+
// Already loaded, just switch to it
|
|
74
|
+
setSelectedMonth(month)
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setIsLoading(true)
|
|
79
|
+
const games = await loadMonthGames(month)
|
|
80
|
+
setMonthlyGames(prev => ({ ...prev, [month]: games }))
|
|
81
|
+
setLoadedMonths(prev => new Set([...prev, month]))
|
|
82
|
+
setSelectedMonth(month)
|
|
83
|
+
setIsLoading(false)
|
|
84
|
+
}, [loadedMonths])
|
|
85
|
+
|
|
86
|
+
// Get current month's games
|
|
87
|
+
const currentMonthGames = useMemo(() => {
|
|
88
|
+
if (!selectedMonth) return []
|
|
89
|
+
return monthlyGames[selectedMonth] || []
|
|
90
|
+
}, [selectedMonth, monthlyGames])
|
|
91
|
+
|
|
92
|
+
// Time class options (from current month only)
|
|
93
|
+
const timeClassOptions = useMemo(() => {
|
|
94
|
+
const unique = Array.from(
|
|
95
|
+
new Set(currentMonthGames.map((game) => game.timeClass).filter(Boolean))
|
|
96
|
+
)
|
|
97
|
+
return [
|
|
98
|
+
{ label: 'All Time Classes', value: 'all' },
|
|
99
|
+
...unique.map((timeClass) => ({
|
|
100
|
+
label: timeClass.charAt(0).toUpperCase() + timeClass.slice(1),
|
|
101
|
+
value: timeClass
|
|
102
|
+
}))
|
|
103
|
+
]
|
|
104
|
+
}, [currentMonthGames])
|
|
105
|
+
|
|
106
|
+
// Result options (from current month only)
|
|
107
|
+
const resultOptions = useMemo(() => {
|
|
108
|
+
const unique = Array.from(
|
|
109
|
+
new Set(currentMonthGames.map((game) => game.playerResult).filter(Boolean))
|
|
110
|
+
)
|
|
111
|
+
return [
|
|
112
|
+
{ label: 'All Results', value: 'all' },
|
|
113
|
+
...unique.map((result) => ({
|
|
114
|
+
label: result.charAt(0).toUpperCase() + result.slice(1),
|
|
115
|
+
value: result
|
|
116
|
+
}))
|
|
117
|
+
]
|
|
118
|
+
}, [currentMonthGames])
|
|
119
|
+
|
|
120
|
+
// Reset filters when month changes
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
setShowAllGames(false)
|
|
123
|
+
setSelectedTimeClass('all')
|
|
124
|
+
setSelectedResult('all')
|
|
125
|
+
setSearchTerm('')
|
|
126
|
+
}, [selectedMonth])
|
|
127
|
+
|
|
128
|
+
// Filter games
|
|
129
|
+
const filteredGames = useMemo(() => {
|
|
130
|
+
if (!currentMonthGames.length) return []
|
|
131
|
+
|
|
132
|
+
const term = searchTerm.trim().toLowerCase()
|
|
133
|
+
|
|
134
|
+
return currentMonthGames
|
|
135
|
+
.filter((game) => {
|
|
136
|
+
if (selectedTimeClass !== 'all' && game.timeClass !== selectedTimeClass) return false
|
|
137
|
+
if (selectedResult !== 'all' && game.playerResult !== selectedResult) return false
|
|
138
|
+
|
|
139
|
+
if (!term) return true
|
|
140
|
+
|
|
141
|
+
const haystack = [
|
|
142
|
+
game.player?.username,
|
|
143
|
+
game.opponent?.username,
|
|
144
|
+
game.timeControl,
|
|
145
|
+
game.eco
|
|
146
|
+
]
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
.join(' ')
|
|
149
|
+
.toLowerCase()
|
|
150
|
+
|
|
151
|
+
return haystack.includes(term)
|
|
152
|
+
})
|
|
153
|
+
.sort((a, b) => (b.endTime ?? 0) - (a.endTime ?? 0))
|
|
154
|
+
}, [currentMonthGames, selectedTimeClass, selectedResult, searchTerm])
|
|
155
|
+
|
|
156
|
+
const tableRows = useMemo(() => {
|
|
157
|
+
if (showAllGames || filteredGames.length <= MAX_VISIBLE_GAMES) {
|
|
158
|
+
return filteredGames
|
|
159
|
+
}
|
|
160
|
+
return filteredGames.slice(0, MAX_VISIBLE_GAMES)
|
|
161
|
+
}, [filteredGames, showAllGames])
|
|
162
|
+
|
|
163
|
+
const canShowAll = filteredGames.length > MAX_VISIBLE_GAMES
|
|
164
|
+
|
|
165
|
+
const monthLabel = useMemo(() => {
|
|
166
|
+
if (!selectedMonth) return 'No month selected'
|
|
167
|
+
try {
|
|
168
|
+
return monthFormatter.format(new Date(`${selectedMonth}-01T00:00:00Z`))
|
|
169
|
+
} catch {
|
|
170
|
+
return selectedMonth
|
|
171
|
+
}
|
|
172
|
+
}, [monthFormatter, selectedMonth])
|
|
173
|
+
|
|
174
|
+
const formatDate = useCallback((timestamp) => {
|
|
175
|
+
if (!timestamp) return '—'
|
|
176
|
+
const date = new Date(timestamp * 1000)
|
|
177
|
+
return date.toLocaleDateString('en-US', {
|
|
178
|
+
month: 'short',
|
|
179
|
+
day: 'numeric',
|
|
180
|
+
year: 'numeric'
|
|
181
|
+
})
|
|
182
|
+
}, [])
|
|
183
|
+
|
|
184
|
+
const formatTime = useCallback((timestamp) => {
|
|
185
|
+
if (!timestamp) return ''
|
|
186
|
+
const date = new Date(timestamp * 1000)
|
|
187
|
+
return date.toLocaleTimeString('en-US', {
|
|
188
|
+
hour: '2-digit',
|
|
189
|
+
minute: '2-digit'
|
|
190
|
+
})
|
|
191
|
+
}, [])
|
|
192
|
+
|
|
193
|
+
const formatResult = useCallback((result) => {
|
|
194
|
+
if (!result) return 'Pending'
|
|
195
|
+
switch (result) {
|
|
196
|
+
case 'win':
|
|
197
|
+
return 'Win'
|
|
198
|
+
case 'draw':
|
|
199
|
+
return 'Draw'
|
|
200
|
+
case 'stalemate':
|
|
201
|
+
return 'Stalemate'
|
|
202
|
+
case 'resigned':
|
|
203
|
+
return 'Resigned'
|
|
204
|
+
default:
|
|
205
|
+
return result.charAt(0).toUpperCase() + result.slice(1)
|
|
206
|
+
}
|
|
207
|
+
}, [])
|
|
208
|
+
|
|
209
|
+
const handleLoadGame = useCallback(async (game) => {
|
|
210
|
+
// Pass month directly to avoid fetching full dataset
|
|
211
|
+
const pgn = await getGamePgnByIdAsync(game.id, game.month)
|
|
212
|
+
if (!pgn) {
|
|
213
|
+
console.error('[GameArchiveTable] No PGN found for game:', game.id, 'month:', game.month)
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
const loadedGame = {
|
|
217
|
+
...game,
|
|
218
|
+
pgn
|
|
219
|
+
}
|
|
220
|
+
if (onGameLoad) {
|
|
221
|
+
onGameLoad(loadedGame)
|
|
222
|
+
}
|
|
223
|
+
if (typeof window !== 'undefined') {
|
|
224
|
+
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
225
|
+
}
|
|
226
|
+
}, [onGameLoad])
|
|
227
|
+
|
|
228
|
+
const columns = useMemo(
|
|
229
|
+
() => [
|
|
230
|
+
{
|
|
231
|
+
header: 'Date',
|
|
232
|
+
accessor: 'date',
|
|
233
|
+
className: 'kol-table-cell-text analysis-table__cell',
|
|
234
|
+
render: (game) => (
|
|
235
|
+
<div className="flex items-center gap-3">
|
|
236
|
+
<span>{formatDate(game.endTime)}</span>
|
|
237
|
+
{game.endTime ? (
|
|
238
|
+
<span className="kol-table-token bg-fg-08 hidden md:inline-flex">{formatTime(game.endTime)}</span>
|
|
239
|
+
) : null}
|
|
240
|
+
</div>
|
|
241
|
+
)
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
header: 'Opponent',
|
|
245
|
+
accessor: 'opponent',
|
|
246
|
+
className: 'kol-table-cell-text analysis-table__cell',
|
|
247
|
+
render: (game) => <span>{game.opponent?.username ?? 'Opponent'}</span>
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
header: 'Result',
|
|
251
|
+
accessor: 'result',
|
|
252
|
+
className: 'kol-table-cell-text analysis-table__cell',
|
|
253
|
+
render: (game) => {
|
|
254
|
+
const label = game.terminationCategory
|
|
255
|
+
? game.terminationCategory.replace('win-', 'win by ').replace(/-/g, ' ')
|
|
256
|
+
: formatResult(game.playerResult)
|
|
257
|
+
return <span className="kol-table-token bg-fg-08">{label}</span>
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
header: 'Color',
|
|
262
|
+
accessor: 'color',
|
|
263
|
+
className: 'kol-table-cell-text analysis-table__cell hidden lg:table-cell',
|
|
264
|
+
headerClassName: 'kol-table-cell-title hidden lg:table-cell',
|
|
265
|
+
render: (game) => (
|
|
266
|
+
<span
|
|
267
|
+
className={`kol-table-pill ${
|
|
268
|
+
game.playerColor === 'white' ? 'kol-table-pill-light' : 'kol-table-pill-dark'
|
|
269
|
+
}`}
|
|
270
|
+
>
|
|
271
|
+
{game.playerColor === 'white' ? 'White' : 'Black'}
|
|
272
|
+
</span>
|
|
273
|
+
)
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
header: 'Time Control',
|
|
277
|
+
accessor: 'timeControl',
|
|
278
|
+
className: 'kol-table-cell-text analysis-table__cell hidden md:table-cell',
|
|
279
|
+
headerClassName: 'kol-table-cell-title hidden md:table-cell',
|
|
280
|
+
render: (game) => {
|
|
281
|
+
const label = game.timeClass
|
|
282
|
+
? game.timeClass.charAt(0).toUpperCase() + game.timeClass.slice(1)
|
|
283
|
+
: '—'
|
|
284
|
+
return <span>{label}</span>
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
header: 'Rating',
|
|
289
|
+
accessor: 'ratings',
|
|
290
|
+
className: 'kol-table-cell-text analysis-table__cell hidden lg:table-cell',
|
|
291
|
+
headerClassName: 'kol-table-cell-title hidden lg:table-cell',
|
|
292
|
+
render: (game) => (
|
|
293
|
+
<div className="flex items-center gap-3">
|
|
294
|
+
<span className="kol-table-pill kol-table-pill-dark">
|
|
295
|
+
{game.player?.rating ?? '—'}
|
|
296
|
+
</span>
|
|
297
|
+
<span>{game.opponent?.rating ?? '—'}</span>
|
|
298
|
+
</div>
|
|
299
|
+
)
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
header: 'Link',
|
|
303
|
+
accessor: 'url',
|
|
304
|
+
className: 'kol-table-cell-text analysis-table__actions-cell',
|
|
305
|
+
headerClassName: 'kol-table-cell-title analysis-table__actions-header',
|
|
306
|
+
render: (game) => (
|
|
307
|
+
<div className="analysis-table__actions">
|
|
308
|
+
<button
|
|
309
|
+
type="button"
|
|
310
|
+
className="analysis-table__link analysis-table__link-button"
|
|
311
|
+
onClick={(e) => {
|
|
312
|
+
e.preventDefault()
|
|
313
|
+
e.stopPropagation()
|
|
314
|
+
handleLoadGame(game)
|
|
315
|
+
}}
|
|
316
|
+
>
|
|
317
|
+
<span className="hidden sm:inline">Load here</span>
|
|
318
|
+
<span className="inline sm:hidden">Load</span>
|
|
319
|
+
</button>
|
|
320
|
+
{game.url ? (
|
|
321
|
+
<a
|
|
322
|
+
href={game.url}
|
|
323
|
+
className="analysis-table__link"
|
|
324
|
+
target="_blank"
|
|
325
|
+
rel="noreferrer"
|
|
326
|
+
>
|
|
327
|
+
Chess.com →
|
|
328
|
+
</a>
|
|
329
|
+
) : (
|
|
330
|
+
<span className="analysis-table__meta">No link</span>
|
|
331
|
+
)}
|
|
332
|
+
</div>
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
],
|
|
336
|
+
[formatDate, formatTime, formatResult, handleLoadGame]
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
// Calculate memory usage
|
|
340
|
+
const loadedGamesCount = useMemo(() => {
|
|
341
|
+
return Object.values(monthlyGames).reduce((sum, games) => sum + games.length, 0)
|
|
342
|
+
}, [monthlyGames])
|
|
343
|
+
|
|
344
|
+
const selectedMonthInfo = useMemo(() => {
|
|
345
|
+
return monthlySummary.find(entry => entry.month === selectedMonth)
|
|
346
|
+
}, [monthlySummary, selectedMonth])
|
|
347
|
+
|
|
348
|
+
const isMonthFullyLoaded = useMemo(() => {
|
|
349
|
+
if (!selectedMonth || !selectedMonthInfo) return false
|
|
350
|
+
const loadedCount = monthlyGames[selectedMonth]?.length || 0
|
|
351
|
+
return loadedCount === selectedMonthInfo.total
|
|
352
|
+
}, [selectedMonth, selectedMonthInfo, monthlyGames])
|
|
353
|
+
|
|
354
|
+
return (
|
|
355
|
+
<section className="space-y-6">
|
|
356
|
+
{/* Memory Indicator */}
|
|
357
|
+
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-4 bg-surface-secondary border border-fg-08 rounded">
|
|
358
|
+
<div className="space-y-1">
|
|
359
|
+
<div className="flex items-center gap-2">
|
|
360
|
+
<h3 className="kol-label-mono-xs uppercase">Game Archive Status</h3>
|
|
361
|
+
{isLoading && <Pill variant="subtle" size="sm">Loading...</Pill>}
|
|
362
|
+
</div>
|
|
363
|
+
<p className="kol-mono-xs text-fg-64">
|
|
364
|
+
{loadedMonths.size} of {monthOptions.length} months loaded · {loadedGamesCount.toLocaleString()} games in memory
|
|
365
|
+
</p>
|
|
366
|
+
</div>
|
|
367
|
+
<div className="flex items-center gap-2">
|
|
368
|
+
<Pill variant="subtle" size="sm">
|
|
369
|
+
{monthLabel}
|
|
370
|
+
</Pill>
|
|
371
|
+
{selectedMonthInfo && (
|
|
372
|
+
<Pill variant="subtle" size="sm">
|
|
373
|
+
{selectedMonthInfo.total} total
|
|
374
|
+
</Pill>
|
|
375
|
+
)}
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
|
|
379
|
+
<div className="flex flex-col gap-4">
|
|
380
|
+
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
381
|
+
<h3 className="kol-heading-subsection">Browse Games</h3>
|
|
382
|
+
<Tag>
|
|
383
|
+
{`${tableRows.length} shown · ${filteredGames.length.toLocaleString()} filtered`}
|
|
384
|
+
</Tag>
|
|
385
|
+
</div>
|
|
386
|
+
<p className="kol-text-md text-auto/70 leading-relaxed">
|
|
387
|
+
Select a month to load games. Initially showing a random sample of 5 games.
|
|
388
|
+
Use filters to narrow your search or load the full month.
|
|
389
|
+
</p>
|
|
390
|
+
</div>
|
|
391
|
+
|
|
392
|
+
{/* Month Selector with Load Button */}
|
|
393
|
+
<div className="flex flex-col gap-4">
|
|
394
|
+
<div className="flex flex-col sm:flex-row gap-3 flex-wrap">
|
|
395
|
+
<Dropdown
|
|
396
|
+
options={monthOptions}
|
|
397
|
+
value={selectedMonth}
|
|
398
|
+
onChange={setSelectedMonth}
|
|
399
|
+
className="analysis-control w-full sm:w-auto"
|
|
400
|
+
placeholder="Select month..."
|
|
401
|
+
/>
|
|
402
|
+
|
|
403
|
+
{selectedMonth && !loadedMonths.has(selectedMonth) && (
|
|
404
|
+
<button
|
|
405
|
+
onClick={() => handleLoadMonth(selectedMonth)}
|
|
406
|
+
disabled={isLoading}
|
|
407
|
+
className="px-4 py-2 bg-accent-primary text-surface-primary rounded kol-mono-sm hover:bg-accent-80 transition-colors disabled:opacity-50"
|
|
408
|
+
>
|
|
409
|
+
{isLoading ? 'Loading...' : `Load ${selectedMonthInfo?.total || 0} games from ${monthLabel}`}
|
|
410
|
+
</button>
|
|
411
|
+
)}
|
|
412
|
+
|
|
413
|
+
{selectedMonth && loadedMonths.has(selectedMonth) && !isMonthFullyLoaded && (
|
|
414
|
+
<button
|
|
415
|
+
onClick={() => handleLoadMonth(selectedMonth)}
|
|
416
|
+
disabled={isLoading}
|
|
417
|
+
className="px-4 py-2 bg-surface-tertiary text-auto rounded kol-mono-sm border border-fg-08 hover:bg-surface-secondary transition-colors"
|
|
418
|
+
>
|
|
419
|
+
Load full month ({selectedMonthInfo?.total || 0} games)
|
|
420
|
+
</button>
|
|
421
|
+
)}
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
|
|
425
|
+
{/* Filters - only show when month is loaded */}
|
|
426
|
+
{loadedMonths.has(selectedMonth) && (
|
|
427
|
+
<div className="flex flex-col gap-4 md:gap-0 md:flex-row md:items-center md:justify-between">
|
|
428
|
+
<div className="flex flex-col sm:flex-row gap-3 flex-wrap">
|
|
429
|
+
<Dropdown
|
|
430
|
+
options={timeClassOptions}
|
|
431
|
+
value={selectedTimeClass}
|
|
432
|
+
onChange={setSelectedTimeClass}
|
|
433
|
+
className="analysis-control w-full sm:w-auto"
|
|
434
|
+
/>
|
|
435
|
+
<Dropdown
|
|
436
|
+
options={resultOptions}
|
|
437
|
+
value={selectedResult}
|
|
438
|
+
onChange={setSelectedResult}
|
|
439
|
+
className="analysis-control w-full sm:w-auto"
|
|
440
|
+
/>
|
|
441
|
+
</div>
|
|
442
|
+
<div className="w-full md:w-auto md:min-w-[280px]">
|
|
443
|
+
<Input
|
|
444
|
+
placeholder="Search opponent, ECO, or control…"
|
|
445
|
+
value={searchTerm}
|
|
446
|
+
onChange={(event) => setSearchTerm(event.target.value)}
|
|
447
|
+
iconLeft="search-16"
|
|
448
|
+
/>
|
|
449
|
+
</div>
|
|
450
|
+
</div>
|
|
451
|
+
)}
|
|
452
|
+
|
|
453
|
+
{/* Table */}
|
|
454
|
+
<div className="analysis-table-wrapper">
|
|
455
|
+
{!selectedMonth || !loadedMonths.has(selectedMonth) ? (
|
|
456
|
+
<div className="analysis-table-empty">
|
|
457
|
+
<p className="kol-text-md text-auto/70">
|
|
458
|
+
{selectedMonth
|
|
459
|
+
? 'Click "Load games" to view this month\'s games'
|
|
460
|
+
: 'Select a month from the dropdown above to get started'}
|
|
461
|
+
</p>
|
|
462
|
+
</div>
|
|
463
|
+
) : tableRows.length === 0 ? (
|
|
464
|
+
<div className="analysis-table-empty">
|
|
465
|
+
<p className="kol-text-md text-auto/70">
|
|
466
|
+
No games match the current filters. Try expanding your search criteria.
|
|
467
|
+
</p>
|
|
468
|
+
</div>
|
|
469
|
+
) : (
|
|
470
|
+
<Table
|
|
471
|
+
caption="Archive of chess games matching current filters"
|
|
472
|
+
columns={columns}
|
|
473
|
+
rows={tableRows}
|
|
474
|
+
/>
|
|
475
|
+
)}
|
|
476
|
+
|
|
477
|
+
{canShowAll && (
|
|
478
|
+
<button
|
|
479
|
+
type="button"
|
|
480
|
+
className="analysis-table__toggle"
|
|
481
|
+
onClick={() => setShowAllGames((value) => !value)}
|
|
482
|
+
>
|
|
483
|
+
{showAllGames
|
|
484
|
+
? 'Show fewer games'
|
|
485
|
+
: `Show all ${filteredGames.length.toLocaleString()} games`}
|
|
486
|
+
</button>
|
|
487
|
+
)}
|
|
488
|
+
</div>
|
|
489
|
+
</section>
|
|
490
|
+
)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export default GameArchiveTable
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
|
+
import { useChessControls } from '../context/ChessControlsContext'
|
|
4
|
+
|
|
5
|
+
const GameSelector = () => {
|
|
6
|
+
const { filteredGames, selectedGame, setSelectedGameId } = useChessControls()
|
|
7
|
+
const [showGameSelector, setShowGameSelector] = useState(false)
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<div className="flex items-center gap-2 flex-1 relative">
|
|
11
|
+
<div
|
|
12
|
+
className="flex-1 rounded bg-oq-04 px-3 py-3 flex flex-col gap-1 cursor-pointer"
|
|
13
|
+
onClick={() => setShowGameSelector(!showGameSelector)}
|
|
14
|
+
>
|
|
15
|
+
<div className="flex items-center justify-between">
|
|
16
|
+
<span className="kol-mono-xs text-fg-80 uppercase tracking-[0.2em]">
|
|
17
|
+
{selectedGame?.player?.username || 'Player'} vs {selectedGame?.opponent?.username || 'Opponent'}
|
|
18
|
+
</span>
|
|
19
|
+
<Icon name="chevron-down" size={16} className="text-fg-80" />
|
|
20
|
+
</div>
|
|
21
|
+
{selectedGame?.opening?.name && (
|
|
22
|
+
<span className="kol-mono-xxs text-fg-64">
|
|
23
|
+
{selectedGame.opening.name}
|
|
24
|
+
</span>
|
|
25
|
+
)}
|
|
26
|
+
</div>
|
|
27
|
+
{showGameSelector && (
|
|
28
|
+
<div className="absolute top-full left-0 right-0 mt-1 bg-oq-08 border border-oq-16 rounded max-h-60 overflow-auto z-10">
|
|
29
|
+
{filteredGames.map((game) => (
|
|
30
|
+
<div
|
|
31
|
+
key={game.id}
|
|
32
|
+
className="px-3 py-2 kol-mono-xs text-fg-80 hover:bg-oq-16 cursor-pointer"
|
|
33
|
+
onClick={() => {
|
|
34
|
+
setSelectedGameId(game.id)
|
|
35
|
+
setShowGameSelector(false)
|
|
36
|
+
}}
|
|
37
|
+
>
|
|
38
|
+
{game.player?.username} vs {game.opponent?.username}
|
|
39
|
+
</div>
|
|
40
|
+
))}
|
|
41
|
+
</div>
|
|
42
|
+
)}
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default GameSelector
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
const buttonClass = (isActive, hasMove) => {
|
|
4
|
+
let base = 'text-left kol-mono-xs transition-colors'
|
|
5
|
+
if (!hasMove) {
|
|
6
|
+
return `${base} text-fg-40 cursor-not-allowed`
|
|
7
|
+
}
|
|
8
|
+
if (isActive) {
|
|
9
|
+
return `${base} text-accent-primary font-semibold`
|
|
10
|
+
}
|
|
11
|
+
return `${base} text-fg-80 hover:text-fg-88`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const NotationPanel = ({ notationPairs = [], activePly = 0, onSelectPly = () => {}, isLoading = false }) => {
|
|
15
|
+
const activeRef = useRef(null)
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (activeRef.current) {
|
|
19
|
+
activeRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
20
|
+
}
|
|
21
|
+
}, [activePly])
|
|
22
|
+
if (isLoading) {
|
|
23
|
+
return (
|
|
24
|
+
<div className="flex flex-col gap-2 text-fg-48">
|
|
25
|
+
<div className="h-3 bg-oq-04 rounded animate-pulse" />
|
|
26
|
+
<div className="h-3 bg-oq-04 rounded animate-pulse" />
|
|
27
|
+
<div className="h-3 bg-oq-04 rounded animate-pulse" />
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!notationPairs.length) {
|
|
33
|
+
return <p className="kol-mono-xs text-fg-64">No notation available for this game.</p>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="flex flex-col gap-2">
|
|
38
|
+
{notationPairs.map((pair) => {
|
|
39
|
+
const isWhiteActive = pair.white?.ply === activePly
|
|
40
|
+
const isBlackActive = pair.black?.ply === activePly
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div key={pair.moveNumber} className="flex items-start gap-4" ref={isWhiteActive || isBlackActive ? activeRef : null}>
|
|
44
|
+
<span className="kol-mono-xs text-fg-64 w-6 text-right">{pair.moveNumber}.</span>
|
|
45
|
+
<div className="flex flex-1 gap-8">
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
className={buttonClass(isWhiteActive, Boolean(pair.white))}
|
|
49
|
+
disabled={!pair.white}
|
|
50
|
+
onClick={() => pair.white && onSelectPly(pair.white.ply)}
|
|
51
|
+
>
|
|
52
|
+
{pair.white?.san ?? '—'}
|
|
53
|
+
</button>
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
className={buttonClass(isBlackActive, Boolean(pair.black))}
|
|
57
|
+
disabled={!pair.black}
|
|
58
|
+
onClick={() => pair.black && onSelectPly(pair.black.ply)}
|
|
59
|
+
>
|
|
60
|
+
{pair.black?.san ?? '—'}
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
})}
|
|
66
|
+
</div>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default NotationPanel
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
2
|
+
import { useChessControls } from '../context/ChessControlsContext'
|
|
3
|
+
|
|
4
|
+
const PlaybackControls = () => {
|
|
5
|
+
const {
|
|
6
|
+
goToStart,
|
|
7
|
+
goToEnd,
|
|
8
|
+
stepBackward,
|
|
9
|
+
stepForward,
|
|
10
|
+
togglePlayback,
|
|
11
|
+
isPlaying,
|
|
12
|
+
moveIndex,
|
|
13
|
+
notationPairs
|
|
14
|
+
} = useChessControls()
|
|
15
|
+
|
|
16
|
+
const playbackButtons = [
|
|
17
|
+
{ icon: 'play-arrow-start', label: 'Jump to start', action: goToStart },
|
|
18
|
+
{ icon: 'play-arrow-back', label: 'Step backward', action: stepBackward },
|
|
19
|
+
{
|
|
20
|
+
icon: isPlaying ? 'play-pause' : 'play-Play',
|
|
21
|
+
label: isPlaying ? 'Pause playback' : 'Play moves',
|
|
22
|
+
action: togglePlayback
|
|
23
|
+
},
|
|
24
|
+
{ icon: 'play-arrow-forward', label: 'Step forward', action: stepForward },
|
|
25
|
+
{ icon: 'play-arrow-end', label: 'Jump to end', action: goToEnd }
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="flex flex-col gap-3">
|
|
30
|
+
<div className="flex items-center justify-center">
|
|
31
|
+
<span className="kol-mono-xs text-fg-64 uppercase tracking-[0.2em]">
|
|
32
|
+
Move {moveIndex}/{notationPairs.length || 0}
|
|
33
|
+
</span>
|
|
34
|
+
</div>
|
|
35
|
+
<div className="grid grid-cols-5 gap-3">
|
|
36
|
+
{playbackButtons.map(({ icon, label, action }) => (
|
|
37
|
+
<button
|
|
38
|
+
key={icon}
|
|
39
|
+
type="button"
|
|
40
|
+
aria-label={label}
|
|
41
|
+
className="h-12 rounded bg-oq-02 border border-fg-08 text-fg-80 flex items-center justify-center hover:bg-oq-04 transition-colors"
|
|
42
|
+
onClick={action}
|
|
43
|
+
>
|
|
44
|
+
<Icon name={icon} size={18} className="text-fg-80" />
|
|
45
|
+
</button>
|
|
46
|
+
))}
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default PlaybackControls
|