tanuki-telemetry 1.1.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/Dockerfile +22 -0
- package/bin/tanuki.mjs +251 -0
- package/frontend/eslint.config.js +23 -0
- package/frontend/index.html +13 -0
- package/frontend/package.json +39 -0
- package/frontend/src/App.tsx +232 -0
- package/frontend/src/assets/hero.png +0 -0
- package/frontend/src/assets/react.svg +1 -0
- package/frontend/src/assets/vite.svg +1 -0
- package/frontend/src/components/ArtifactsPanel.tsx +429 -0
- package/frontend/src/components/ChildStreams.tsx +176 -0
- package/frontend/src/components/CoordinatorPage.tsx +317 -0
- package/frontend/src/components/Header.tsx +108 -0
- package/frontend/src/components/InsightsPanel.tsx +142 -0
- package/frontend/src/components/IterationsTable.tsx +98 -0
- package/frontend/src/components/KnowledgePage.tsx +308 -0
- package/frontend/src/components/LoginPage.tsx +55 -0
- package/frontend/src/components/PlanProgress.tsx +163 -0
- package/frontend/src/components/QualityReport.tsx +276 -0
- package/frontend/src/components/ScreenshotUpload.tsx +117 -0
- package/frontend/src/components/ScreenshotsGrid.tsx +266 -0
- package/frontend/src/components/SessionDetail.tsx +265 -0
- package/frontend/src/components/SessionList.tsx +234 -0
- package/frontend/src/components/SettingsPage.tsx +213 -0
- package/frontend/src/components/StreamComms.tsx +228 -0
- package/frontend/src/components/TanukiLogo.tsx +16 -0
- package/frontend/src/components/Timeline.tsx +416 -0
- package/frontend/src/components/WalkthroughPage.tsx +458 -0
- package/frontend/src/hooks/useApi.ts +81 -0
- package/frontend/src/hooks/useAuth.ts +54 -0
- package/frontend/src/hooks/useKnowledge.ts +33 -0
- package/frontend/src/hooks/useWebSocket.ts +95 -0
- package/frontend/src/index.css +66 -0
- package/frontend/src/lib/api.ts +15 -0
- package/frontend/src/lib/utils.ts +58 -0
- package/frontend/src/main.tsx +10 -0
- package/frontend/src/types.ts +181 -0
- package/frontend/tsconfig.app.json +32 -0
- package/frontend/tsconfig.json +7 -0
- package/frontend/vite.config.ts +25 -0
- package/install.sh +87 -0
- package/package.json +63 -0
- package/src/api-keys.ts +97 -0
- package/src/auth.ts +165 -0
- package/src/coordinator.ts +136 -0
- package/src/dashboard-server.ts +5 -0
- package/src/dashboard.ts +826 -0
- package/src/db.ts +1009 -0
- package/src/index.ts +20 -0
- package/src/middleware.ts +76 -0
- package/src/tools.ts +864 -0
- package/src/types-shim.d.ts +18 -0
- package/src/types.ts +171 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { useMemo, useRef, useState, useEffect } from "react"
|
|
2
|
+
import gsap from "gsap"
|
|
3
|
+
import {
|
|
4
|
+
RadarChart,
|
|
5
|
+
PolarGrid,
|
|
6
|
+
PolarAngleAxis,
|
|
7
|
+
Radar,
|
|
8
|
+
LineChart,
|
|
9
|
+
Line,
|
|
10
|
+
XAxis,
|
|
11
|
+
YAxis,
|
|
12
|
+
CartesianGrid,
|
|
13
|
+
Tooltip,
|
|
14
|
+
} from "recharts"
|
|
15
|
+
import type { Event } from "@/types"
|
|
16
|
+
import { cn } from "@/lib/utils"
|
|
17
|
+
|
|
18
|
+
// Stable chart container that measures width once and doesn't flicker on re-renders
|
|
19
|
+
function ChartContainer({ height, children }: { height: number; children: (width: number) => React.ReactNode }) {
|
|
20
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
21
|
+
const [width, setWidth] = useState(0)
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!containerRef.current) return
|
|
25
|
+
const w = containerRef.current.clientWidth
|
|
26
|
+
if (w > 0 && w !== width) setWidth(w)
|
|
27
|
+
|
|
28
|
+
const observer = new ResizeObserver((entries) => {
|
|
29
|
+
const newW = entries[0]?.contentRect.width ?? 0
|
|
30
|
+
if (newW > 0) setWidth(newW)
|
|
31
|
+
})
|
|
32
|
+
observer.observe(containerRef.current)
|
|
33
|
+
return () => observer.disconnect()
|
|
34
|
+
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div ref={containerRef} style={{ height }}>
|
|
38
|
+
{width > 0 && children(width)}
|
|
39
|
+
</div>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface QualityEvent {
|
|
44
|
+
overall_score: number
|
|
45
|
+
score_breakdown: Record<string, number>
|
|
46
|
+
version?: string
|
|
47
|
+
report_path?: string
|
|
48
|
+
timestamp: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface Props {
|
|
52
|
+
events: Event[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseQualityEvents(events: Event[]): QualityEvent[] {
|
|
56
|
+
const results: QualityEvent[] = []
|
|
57
|
+
for (const ev of events) {
|
|
58
|
+
if (!ev.metadata) continue
|
|
59
|
+
try {
|
|
60
|
+
const meta = typeof ev.metadata === "string" ? JSON.parse(ev.metadata) : ev.metadata
|
|
61
|
+
if (meta.overall_score !== undefined && typeof meta.overall_score === "number") {
|
|
62
|
+
results.push({
|
|
63
|
+
overall_score: meta.overall_score,
|
|
64
|
+
score_breakdown: meta.score_breakdown || {},
|
|
65
|
+
version: meta.version,
|
|
66
|
+
report_path: meta.report_path,
|
|
67
|
+
timestamp: ev.timestamp,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// ignore
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return results
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function scoreColor(score: number): string {
|
|
78
|
+
if (score >= 9) return "text-yellow-400"
|
|
79
|
+
if (score >= 8) return "text-accent"
|
|
80
|
+
if (score >= 7) return "text-warning"
|
|
81
|
+
return "text-error"
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function scoreBorder(score: number): string {
|
|
85
|
+
if (score >= 9) return "border-yellow-400/40"
|
|
86
|
+
if (score >= 8) return "border-accent/40"
|
|
87
|
+
if (score >= 7) return "border-warning/40"
|
|
88
|
+
return "border-error/40"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function scoreBg(score: number): string {
|
|
92
|
+
if (score >= 9) return "bg-yellow-400/5"
|
|
93
|
+
if (score >= 8) return "bg-accent/5"
|
|
94
|
+
if (score >= 7) return "bg-warning/5"
|
|
95
|
+
return "bg-error/5"
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function scoreHex(score: number): string {
|
|
99
|
+
if (score >= 9) return "#facc15"
|
|
100
|
+
if (score >= 8) return "#00ff88"
|
|
101
|
+
if (score >= 7) return "#ffaa00"
|
|
102
|
+
return "#ff4444"
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function QualityReport({ events }: Props) {
|
|
106
|
+
const qualityEvents = useMemo(() => parseQualityEvents(events), [events])
|
|
107
|
+
|
|
108
|
+
if (qualityEvents.length === 0) return null
|
|
109
|
+
|
|
110
|
+
const latest = qualityEvents[qualityEvents.length - 1]
|
|
111
|
+
const hasProgression = qualityEvents.length > 1
|
|
112
|
+
|
|
113
|
+
// Radar chart data from latest breakdown
|
|
114
|
+
const radarData = Object.entries(latest.score_breakdown).map(([key, value]) => ({
|
|
115
|
+
category: key,
|
|
116
|
+
score: value,
|
|
117
|
+
fullMark: 10,
|
|
118
|
+
}))
|
|
119
|
+
|
|
120
|
+
// Line chart data for progression
|
|
121
|
+
const progressionData = qualityEvents.map((qe, i) => ({
|
|
122
|
+
label: qe.version || `v${i + 1}`,
|
|
123
|
+
score: qe.overall_score,
|
|
124
|
+
...qe.score_breakdown,
|
|
125
|
+
}))
|
|
126
|
+
|
|
127
|
+
const qrRef = useRef<HTMLDivElement>(null)
|
|
128
|
+
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (!qrRef.current) return
|
|
131
|
+
gsap.fromTo(
|
|
132
|
+
qrRef.current.children,
|
|
133
|
+
{ opacity: 0, y: 8 },
|
|
134
|
+
{ opacity: 1, y: 0, duration: 0.3, stagger: 0.06, ease: "power2.out" }
|
|
135
|
+
)
|
|
136
|
+
}, [])
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<div ref={qrRef} className="space-y-3">
|
|
140
|
+
{/* Score badge */}
|
|
141
|
+
<div className="flex items-center gap-4">
|
|
142
|
+
<div
|
|
143
|
+
className={cn(
|
|
144
|
+
"border-2 rounded-lg px-4 py-3 flex flex-col items-center",
|
|
145
|
+
scoreBorder(latest.overall_score),
|
|
146
|
+
scoreBg(latest.overall_score)
|
|
147
|
+
)}
|
|
148
|
+
>
|
|
149
|
+
<span className={cn("text-3xl font-bold font-mono", scoreColor(latest.overall_score))}>
|
|
150
|
+
{latest.overall_score.toFixed(1)}
|
|
151
|
+
</span>
|
|
152
|
+
<span className="text-[9px] text-text-dim uppercase tracking-wider mt-0.5">
|
|
153
|
+
/ 10
|
|
154
|
+
</span>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
{/* Category scores */}
|
|
158
|
+
<div className="flex-1 grid grid-cols-2 gap-x-4 gap-y-1">
|
|
159
|
+
{Object.entries(latest.score_breakdown).map(([key, value]) => (
|
|
160
|
+
<div key={key} className="flex items-center gap-2">
|
|
161
|
+
<span className="text-[10px] text-text-dim uppercase w-16 truncate">{key}</span>
|
|
162
|
+
<div className="flex-1 h-1.5 bg-border rounded-full overflow-hidden">
|
|
163
|
+
<div
|
|
164
|
+
className="h-full rounded-full transition-all"
|
|
165
|
+
style={{
|
|
166
|
+
width: `${(value / 10) * 100}%`,
|
|
167
|
+
backgroundColor: scoreHex(value),
|
|
168
|
+
}}
|
|
169
|
+
/>
|
|
170
|
+
</div>
|
|
171
|
+
<span className={cn("text-[10px] font-bold font-mono w-6 text-right", scoreColor(value))}>
|
|
172
|
+
{value}
|
|
173
|
+
</span>
|
|
174
|
+
</div>
|
|
175
|
+
))}
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
{/* Radar chart if there are 3+ categories */}
|
|
180
|
+
{radarData.length >= 3 && (
|
|
181
|
+
<ChartContainer height={192}>
|
|
182
|
+
{(width) => (
|
|
183
|
+
<RadarChart data={radarData} cx="50%" cy="50%" outerRadius="70%" width={width} height={192}>
|
|
184
|
+
<PolarGrid stroke="#333" />
|
|
185
|
+
<PolarAngleAxis
|
|
186
|
+
dataKey="category"
|
|
187
|
+
tick={{ fill: "#666", fontSize: 10, fontFamily: "monospace" }}
|
|
188
|
+
/>
|
|
189
|
+
<Radar
|
|
190
|
+
dataKey="score"
|
|
191
|
+
stroke={scoreHex(latest.overall_score)}
|
|
192
|
+
fill={scoreHex(latest.overall_score)}
|
|
193
|
+
fillOpacity={0.15}
|
|
194
|
+
strokeWidth={2}
|
|
195
|
+
isAnimationActive={false}
|
|
196
|
+
/>
|
|
197
|
+
</RadarChart>
|
|
198
|
+
)}
|
|
199
|
+
</ChartContainer>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
{/* Progression line chart if multiple quality events */}
|
|
203
|
+
{hasProgression && (
|
|
204
|
+
<div>
|
|
205
|
+
<div className="text-[9px] text-text-dim uppercase tracking-wider mb-1">
|
|
206
|
+
SCORE PROGRESSION
|
|
207
|
+
</div>
|
|
208
|
+
<ChartContainer height={144}>
|
|
209
|
+
{(width) => (
|
|
210
|
+
<LineChart data={progressionData} width={width} height={144}>
|
|
211
|
+
<CartesianGrid strokeDasharray="3 3" stroke="#222" />
|
|
212
|
+
<XAxis
|
|
213
|
+
dataKey="label"
|
|
214
|
+
tick={{ fill: "#666", fontSize: 10, fontFamily: "monospace" }}
|
|
215
|
+
stroke="#333"
|
|
216
|
+
/>
|
|
217
|
+
<YAxis
|
|
218
|
+
domain={[0, 10]}
|
|
219
|
+
tick={{ fill: "#666", fontSize: 10, fontFamily: "monospace" }}
|
|
220
|
+
stroke="#333"
|
|
221
|
+
width={25}
|
|
222
|
+
/>
|
|
223
|
+
<Tooltip
|
|
224
|
+
contentStyle={{
|
|
225
|
+
background: "#111",
|
|
226
|
+
border: "1px solid #333",
|
|
227
|
+
borderRadius: 4,
|
|
228
|
+
fontSize: 11,
|
|
229
|
+
fontFamily: "monospace",
|
|
230
|
+
}}
|
|
231
|
+
itemStyle={{ color: "#c8c8c8" }}
|
|
232
|
+
/>
|
|
233
|
+
<Line
|
|
234
|
+
type="monotone"
|
|
235
|
+
dataKey="score"
|
|
236
|
+
stroke="#00ff88"
|
|
237
|
+
strokeWidth={2}
|
|
238
|
+
dot={{ fill: "#00ff88", r: 4 }}
|
|
239
|
+
name="overall"
|
|
240
|
+
isAnimationActive={false}
|
|
241
|
+
/>
|
|
242
|
+
{Object.keys(latest.score_breakdown).map((key, i) => {
|
|
243
|
+
const colors = ["#4488ff", "#ffaa00", "#aa66ff", "#ff4444", "#facc15", "#00ccff"]
|
|
244
|
+
return (
|
|
245
|
+
<Line
|
|
246
|
+
key={key}
|
|
247
|
+
type="monotone"
|
|
248
|
+
dataKey={key}
|
|
249
|
+
stroke={colors[i % colors.length]}
|
|
250
|
+
strokeWidth={1}
|
|
251
|
+
strokeDasharray="3 3"
|
|
252
|
+
dot={false}
|
|
253
|
+
name={key}
|
|
254
|
+
isAnimationActive={false}
|
|
255
|
+
/>
|
|
256
|
+
)
|
|
257
|
+
})}
|
|
258
|
+
</LineChart>
|
|
259
|
+
)}
|
|
260
|
+
</ChartContainer>
|
|
261
|
+
</div>
|
|
262
|
+
)}
|
|
263
|
+
|
|
264
|
+
{/* Version + report link */}
|
|
265
|
+
<div className="flex items-center gap-3 text-[10px] text-text-dim">
|
|
266
|
+
{latest.version && <span>version: <span className="text-text">{latest.version}</span></span>}
|
|
267
|
+
{latest.report_path && (
|
|
268
|
+
<span>report: <span className="text-text-muted font-mono">{latest.report_path.split("/").pop()}</span></span>
|
|
269
|
+
)}
|
|
270
|
+
{hasProgression && (
|
|
271
|
+
<span>{qualityEvents.length} measurements</span>
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
)
|
|
276
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, useEffect } from "react"
|
|
2
|
+
import gsap from "gsap"
|
|
3
|
+
import { cn } from "@/lib/utils"
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
sessionId: string
|
|
7
|
+
onUploaded: () => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function ScreenshotUpload({ sessionId, onUploaded }: Props) {
|
|
11
|
+
const [dragging, setDragging] = useState(false)
|
|
12
|
+
const [uploading, setUploading] = useState(false)
|
|
13
|
+
const [description, setDescription] = useState("")
|
|
14
|
+
const fileRef = useRef<HTMLInputElement>(null)
|
|
15
|
+
|
|
16
|
+
const handleUpload = useCallback(
|
|
17
|
+
async (file: File) => {
|
|
18
|
+
setUploading(true)
|
|
19
|
+
const formData = new FormData()
|
|
20
|
+
formData.append("file", file)
|
|
21
|
+
formData.append("description", description || file.name)
|
|
22
|
+
formData.append("phase", "verification")
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch(`/api/sessions/${sessionId}/screenshots`, {
|
|
26
|
+
method: "POST",
|
|
27
|
+
body: formData,
|
|
28
|
+
})
|
|
29
|
+
if (res.ok) {
|
|
30
|
+
setDescription("")
|
|
31
|
+
onUploaded()
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
// ignore
|
|
35
|
+
}
|
|
36
|
+
setUploading(false)
|
|
37
|
+
},
|
|
38
|
+
[sessionId, description, onUploaded]
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const handleDrop = useCallback(
|
|
42
|
+
(e: React.DragEvent) => {
|
|
43
|
+
e.preventDefault()
|
|
44
|
+
setDragging(false)
|
|
45
|
+
const file = e.dataTransfer.files[0]
|
|
46
|
+
if (file && file.type.startsWith("image/")) {
|
|
47
|
+
handleUpload(file)
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
[handleUpload]
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const handleFileSelect = useCallback(
|
|
54
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
55
|
+
const file = e.target.files?.[0]
|
|
56
|
+
if (file) {
|
|
57
|
+
handleUpload(file)
|
|
58
|
+
}
|
|
59
|
+
e.target.value = ""
|
|
60
|
+
},
|
|
61
|
+
[handleUpload]
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
const uploadRef = useRef<HTMLDivElement>(null)
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!uploadRef.current) return
|
|
68
|
+
gsap.fromTo(uploadRef.current, { opacity: 0, y: 6 }, { opacity: 1, y: 0, duration: 0.3, ease: "power2.out" })
|
|
69
|
+
}, [])
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div ref={uploadRef} className="mt-3">
|
|
73
|
+
<div className="flex gap-2 mb-2">
|
|
74
|
+
<input
|
|
75
|
+
type="text"
|
|
76
|
+
value={description}
|
|
77
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
78
|
+
placeholder="description (optional)"
|
|
79
|
+
className="flex-1 bg-bg border border-border rounded px-2 py-1 text-[11px] text-text font-mono placeholder:text-text-dim focus:outline-none focus:border-accent/50"
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
<div
|
|
83
|
+
onDragOver={(e) => {
|
|
84
|
+
e.preventDefault()
|
|
85
|
+
setDragging(true)
|
|
86
|
+
}}
|
|
87
|
+
onDragLeave={() => setDragging(false)}
|
|
88
|
+
onDrop={handleDrop}
|
|
89
|
+
onClick={() => fileRef.current?.click()}
|
|
90
|
+
className={cn(
|
|
91
|
+
"border border-dashed rounded p-4 text-center cursor-pointer transition-colors duration-200",
|
|
92
|
+
dragging
|
|
93
|
+
? "border-accent bg-accent/5 text-accent"
|
|
94
|
+
: "border-border-bright text-text-dim hover:border-accent/40 hover:text-text-muted"
|
|
95
|
+
)}
|
|
96
|
+
>
|
|
97
|
+
<input
|
|
98
|
+
ref={fileRef}
|
|
99
|
+
type="file"
|
|
100
|
+
accept="image/*"
|
|
101
|
+
onChange={handleFileSelect}
|
|
102
|
+
className="hidden"
|
|
103
|
+
/>
|
|
104
|
+
<div className="text-[10px] font-mono">
|
|
105
|
+
{uploading ? (
|
|
106
|
+
<span className="text-accent animate-pulse">uploading...</span>
|
|
107
|
+
) : (
|
|
108
|
+
<>
|
|
109
|
+
<span className="text-accent mr-1">+</span>
|
|
110
|
+
drop image or click to upload
|
|
111
|
+
</>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useCallback, useMemo } from "react"
|
|
2
|
+
import { createPortal } from "react-dom"
|
|
3
|
+
import gsap from "gsap"
|
|
4
|
+
import type { Screenshot } from "@/types"
|
|
5
|
+
import { screenshotUrl, cn } from "@/lib/utils"
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
screenshots: Screenshot[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ScreenshotGroup {
|
|
12
|
+
name: string
|
|
13
|
+
screenshots: Screenshot[]
|
|
14
|
+
indices: number[] // indices into the flat screenshots array
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Extract template/group name from file path or description
|
|
18
|
+
function extractGroup(sc: Screenshot): string {
|
|
19
|
+
const fp = sc.file_path.toLowerCase()
|
|
20
|
+
// Check for template folder pattern: .../generated/trinity/... or .../reference/trinity/...
|
|
21
|
+
const match = fp.match(/\/(generated|reference)\/([^/]+)\//)
|
|
22
|
+
if (match) return match[2]
|
|
23
|
+
// Check description for template names
|
|
24
|
+
const descMatch = sc.description.match(/template[:\s]+(\w+)/i)
|
|
25
|
+
if (descMatch) return descMatch[1].toLowerCase()
|
|
26
|
+
return "_default"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Extract score from description (e.g., "8.7/10", "score: 8", "Score 9.2")
|
|
30
|
+
function extractScore(description: string): number | null {
|
|
31
|
+
const patterns = [
|
|
32
|
+
/(\d+\.?\d*)\/10/,
|
|
33
|
+
/score[:\s]+(\d+\.?\d*)/i,
|
|
34
|
+
/(\d+\.?\d*)\s*pts?/i,
|
|
35
|
+
]
|
|
36
|
+
for (const p of patterns) {
|
|
37
|
+
const m = description.match(p)
|
|
38
|
+
if (m) return parseFloat(m[1])
|
|
39
|
+
}
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function scoreColor(score: number): string {
|
|
44
|
+
if (score >= 9) return "text-yellow-400"
|
|
45
|
+
if (score >= 8) return "text-accent"
|
|
46
|
+
if (score >= 7) return "text-warning"
|
|
47
|
+
return "text-error"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function ScreenshotsGrid({ screenshots }: Props) {
|
|
51
|
+
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
|
52
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
53
|
+
const prevCountRef = useRef(0)
|
|
54
|
+
|
|
55
|
+
const thumbUrls = screenshots.map((sc) => screenshotUrl(sc.file_path, sc.id, "thumb"))
|
|
56
|
+
const fullUrls = screenshots.map((sc) => screenshotUrl(sc.file_path, sc.id))
|
|
57
|
+
|
|
58
|
+
// Group screenshots by template
|
|
59
|
+
const groups = useMemo((): ScreenshotGroup[] => {
|
|
60
|
+
const map = new Map<string, { screenshots: Screenshot[]; indices: number[] }>()
|
|
61
|
+
screenshots.forEach((sc, i) => {
|
|
62
|
+
const group = extractGroup(sc)
|
|
63
|
+
const existing = map.get(group) || { screenshots: [], indices: [] }
|
|
64
|
+
existing.screenshots.push(sc)
|
|
65
|
+
existing.indices.push(i)
|
|
66
|
+
map.set(group, existing)
|
|
67
|
+
})
|
|
68
|
+
return Array.from(map.entries()).map(([name, data]) => ({
|
|
69
|
+
name,
|
|
70
|
+
screenshots: data.screenshots,
|
|
71
|
+
indices: data.indices,
|
|
72
|
+
}))
|
|
73
|
+
}, [screenshots])
|
|
74
|
+
|
|
75
|
+
const hasMultipleGroups = groups.length > 1 || (groups.length === 1 && groups[0].name !== "_default")
|
|
76
|
+
|
|
77
|
+
const goNext = useCallback(() => {
|
|
78
|
+
if (activeIndex === null) return
|
|
79
|
+
setActiveIndex((activeIndex + 1) % screenshots.length)
|
|
80
|
+
}, [activeIndex, screenshots.length])
|
|
81
|
+
|
|
82
|
+
const goPrev = useCallback(() => {
|
|
83
|
+
if (activeIndex === null) return
|
|
84
|
+
setActiveIndex((activeIndex - 1 + screenshots.length) % screenshots.length)
|
|
85
|
+
}, [activeIndex, screenshots.length])
|
|
86
|
+
|
|
87
|
+
const close = useCallback(() => setActiveIndex(null), [])
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (activeIndex === null) return
|
|
91
|
+
const handler = (e: KeyboardEvent) => {
|
|
92
|
+
if (e.key === "ArrowRight") goNext()
|
|
93
|
+
else if (e.key === "ArrowLeft") goPrev()
|
|
94
|
+
else if (e.key === "Escape") close()
|
|
95
|
+
}
|
|
96
|
+
window.addEventListener("keydown", handler)
|
|
97
|
+
return () => window.removeEventListener("keydown", handler)
|
|
98
|
+
}, [activeIndex, goNext, goPrev, close])
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!ref.current) return
|
|
102
|
+
if (screenshots.length === prevCountRef.current) return
|
|
103
|
+
const isInitial = prevCountRef.current === 0
|
|
104
|
+
prevCountRef.current = screenshots.length
|
|
105
|
+
if (isInitial) {
|
|
106
|
+
const cards = ref.current.querySelectorAll(".ss-card")
|
|
107
|
+
gsap.fromTo(
|
|
108
|
+
cards,
|
|
109
|
+
{ opacity: 0, scale: 0.95 },
|
|
110
|
+
{ opacity: 1, scale: 1, duration: 0.3, stagger: 0.06, ease: "power2.out" }
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
}, [screenshots])
|
|
114
|
+
|
|
115
|
+
const activeSc = activeIndex !== null ? screenshots[activeIndex] : null
|
|
116
|
+
const activeUrl = activeIndex !== null ? fullUrls[activeIndex] : null
|
|
117
|
+
|
|
118
|
+
const renderCard = (sc: Screenshot, flatIndex: number) => {
|
|
119
|
+
const score = extractScore(sc.description)
|
|
120
|
+
return (
|
|
121
|
+
<div
|
|
122
|
+
key={sc.id}
|
|
123
|
+
className="ss-card border border-border rounded overflow-hidden cursor-pointer hover:border-accent transition-colors duration-200 group"
|
|
124
|
+
onClick={() => setActiveIndex(flatIndex)}
|
|
125
|
+
>
|
|
126
|
+
<div className="relative">
|
|
127
|
+
<img
|
|
128
|
+
src={thumbUrls[flatIndex]}
|
|
129
|
+
alt={sc.description}
|
|
130
|
+
loading="lazy"
|
|
131
|
+
className="w-full h-32 object-cover block opacity-80 group-hover:opacity-100 transition-opacity"
|
|
132
|
+
onError={(e) => {
|
|
133
|
+
;(e.target as HTMLImageElement).style.display = "none"
|
|
134
|
+
}}
|
|
135
|
+
/>
|
|
136
|
+
{score !== null && (
|
|
137
|
+
<span
|
|
138
|
+
className={cn(
|
|
139
|
+
"absolute top-1 right-1 text-[10px] font-bold font-mono px-1.5 py-0.5 rounded bg-bg/80 border border-border",
|
|
140
|
+
scoreColor(score)
|
|
141
|
+
)}
|
|
142
|
+
>
|
|
143
|
+
{score.toFixed(1)}
|
|
144
|
+
</span>
|
|
145
|
+
)}
|
|
146
|
+
<span className="absolute bottom-1 left-1 text-[9px] px-1 py-0.5 rounded bg-bg/80 text-text-muted">
|
|
147
|
+
{sc.phase}
|
|
148
|
+
</span>
|
|
149
|
+
</div>
|
|
150
|
+
<div className="p-2 text-[10px] text-text-muted truncate">
|
|
151
|
+
<span className="text-accent mr-1">{"\u25B8"}</span>
|
|
152
|
+
{sc.description}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<>
|
|
160
|
+
<div ref={ref}>
|
|
161
|
+
{hasMultipleGroups ? (
|
|
162
|
+
// Grouped view
|
|
163
|
+
groups.map((group) => (
|
|
164
|
+
<div key={group.name} className="mb-4">
|
|
165
|
+
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-2 flex items-center gap-2">
|
|
166
|
+
<span className="text-accent">{"\u25B8"}</span>
|
|
167
|
+
{group.name === "_default" ? "screenshots" : group.name}
|
|
168
|
+
<span className="text-text-dim">({group.screenshots.length})</span>
|
|
169
|
+
</div>
|
|
170
|
+
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-3">
|
|
171
|
+
{group.screenshots.map((sc, gi) => renderCard(sc, group.indices[gi]))}
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
))
|
|
175
|
+
) : (
|
|
176
|
+
// Flat view
|
|
177
|
+
<div className="mt-2 grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-3">
|
|
178
|
+
{screenshots.map((sc, i) => renderCard(sc, i))}
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
{/* Modal */}
|
|
184
|
+
{activeIndex !== null && activeUrl && activeSc && createPortal(
|
|
185
|
+
<div
|
|
186
|
+
className="fixed inset-0 bg-black/80 z-[200] flex items-center justify-center"
|
|
187
|
+
onClick={close}
|
|
188
|
+
>
|
|
189
|
+
<div
|
|
190
|
+
className="bg-bg-elevated border border-border rounded-lg shadow-2xl w-[85vw] h-[85vh] flex flex-col overflow-hidden"
|
|
191
|
+
onClick={(e) => e.stopPropagation()}
|
|
192
|
+
>
|
|
193
|
+
{/* Header */}
|
|
194
|
+
<div className="flex items-center justify-between px-4 py-2 border-b border-border flex-shrink-0">
|
|
195
|
+
<div className="flex items-center gap-3">
|
|
196
|
+
<span className="text-[10px] text-text-muted font-mono">
|
|
197
|
+
{activeSc.description}
|
|
198
|
+
</span>
|
|
199
|
+
{(() => {
|
|
200
|
+
const score = extractScore(activeSc.description)
|
|
201
|
+
return score !== null ? (
|
|
202
|
+
<span className={cn("text-[11px] font-bold font-mono", scoreColor(score))}>
|
|
203
|
+
{score.toFixed(1)}/10
|
|
204
|
+
</span>
|
|
205
|
+
) : null
|
|
206
|
+
})()}
|
|
207
|
+
</div>
|
|
208
|
+
<div className="flex items-center gap-3">
|
|
209
|
+
<span className="text-[10px] text-text-dim font-mono">
|
|
210
|
+
{activeIndex + 1}/{screenshots.length}
|
|
211
|
+
</span>
|
|
212
|
+
<span className="text-[9px] text-text-dim">
|
|
213
|
+
{"\u2190"} {"\u2192"} navigate
|
|
214
|
+
</span>
|
|
215
|
+
<button onClick={close} className="text-text-dim hover:text-text text-sm">×</button>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
{/* Main image */}
|
|
220
|
+
<div className="flex-1 flex items-center justify-center p-6 overflow-hidden relative">
|
|
221
|
+
<button
|
|
222
|
+
onClick={goPrev}
|
|
223
|
+
className="absolute left-3 top-1/2 -translate-y-1/2 w-8 h-8 flex items-center justify-center rounded bg-bg/80 border border-border text-text-dim hover:text-accent hover:border-accent transition-colors"
|
|
224
|
+
>
|
|
225
|
+
{"\u2190"}
|
|
226
|
+
</button>
|
|
227
|
+
<img
|
|
228
|
+
src={activeUrl}
|
|
229
|
+
alt={activeSc.description}
|
|
230
|
+
className="max-w-full max-h-full object-contain rounded"
|
|
231
|
+
/>
|
|
232
|
+
<button
|
|
233
|
+
onClick={goNext}
|
|
234
|
+
className="absolute right-3 top-1/2 -translate-y-1/2 w-8 h-8 flex items-center justify-center rounded bg-bg/80 border border-border text-text-dim hover:text-accent hover:border-accent transition-colors"
|
|
235
|
+
>
|
|
236
|
+
{"\u2192"}
|
|
237
|
+
</button>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
{/* Gallery strip */}
|
|
241
|
+
<div className="border-t border-border px-4 py-2 flex gap-2 overflow-x-auto flex-shrink-0">
|
|
242
|
+
{screenshots.map((sc, i) => (
|
|
243
|
+
<img
|
|
244
|
+
key={sc.id}
|
|
245
|
+
src={thumbUrls[i]}
|
|
246
|
+
alt={sc.description}
|
|
247
|
+
onClick={() => setActiveIndex(i)}
|
|
248
|
+
className={cn(
|
|
249
|
+
"h-14 w-20 object-cover rounded cursor-pointer flex-shrink-0 border-2 transition-all",
|
|
250
|
+
i === activeIndex
|
|
251
|
+
? "border-accent opacity-100"
|
|
252
|
+
: "border-transparent opacity-50 hover:opacity-80"
|
|
253
|
+
)}
|
|
254
|
+
onError={(e) => {
|
|
255
|
+
;(e.target as HTMLImageElement).style.display = "none"
|
|
256
|
+
}}
|
|
257
|
+
/>
|
|
258
|
+
))}
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
</div>,
|
|
262
|
+
document.body
|
|
263
|
+
)}
|
|
264
|
+
</>
|
|
265
|
+
)
|
|
266
|
+
}
|