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,416 @@
|
|
|
1
|
+
import { useRef, useEffect, useState, useMemo, type ReactNode } from "react"
|
|
2
|
+
import { createPortal } from "react-dom"
|
|
3
|
+
import gsap from "gsap"
|
|
4
|
+
import { HelpCircle, Play, AlertTriangle, Wrench, Minus, Hash, CheckCircle2, XCircle, RotateCw, ArrowRight, DollarSign, Layers, Star } from "lucide-react"
|
|
5
|
+
import type { Event, Screenshot } from "@/types"
|
|
6
|
+
import { relativeTime, screenshotUrl, cn } from "@/lib/utils"
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
events: Event[]
|
|
10
|
+
sessionStart: string
|
|
11
|
+
screenshots?: Screenshot[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function Timeline({ events, sessionStart, screenshots = [] }: Props) {
|
|
15
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
16
|
+
const prevCountRef = useRef(0)
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (!ref.current) return
|
|
20
|
+
// Only animate new items, not on every refresh
|
|
21
|
+
const currentCount = events.length
|
|
22
|
+
if (currentCount === prevCountRef.current) return
|
|
23
|
+
const isInitial = prevCountRef.current === 0
|
|
24
|
+
prevCountRef.current = currentCount
|
|
25
|
+
|
|
26
|
+
if (isInitial) {
|
|
27
|
+
const items = ref.current.querySelectorAll(".tl-item")
|
|
28
|
+
gsap.fromTo(
|
|
29
|
+
items,
|
|
30
|
+
{ opacity: 0, x: -8 },
|
|
31
|
+
{ opacity: 1, x: 0, duration: 0.25, stagger: 0.03, ease: "power2.out" }
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
}, [events])
|
|
35
|
+
|
|
36
|
+
// Build map of event_id → linked screenshots
|
|
37
|
+
const eventScreenshots = useMemo(() => {
|
|
38
|
+
const map = new Map<number, Screenshot[]>()
|
|
39
|
+
for (const sc of screenshots) {
|
|
40
|
+
if (sc.event_id) {
|
|
41
|
+
const existing = map.get(sc.event_id) || []
|
|
42
|
+
existing.push(sc)
|
|
43
|
+
map.set(sc.event_id, existing)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return map
|
|
47
|
+
}, [screenshots])
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div ref={ref} className="mt-2 space-y-0">
|
|
51
|
+
{events.map((ev) => (
|
|
52
|
+
<TimelineItem key={ev.id} event={ev} sessionStart={sessionStart} linkedScreenshots={eventScreenshots.get(ev.id)} />
|
|
53
|
+
))}
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function TimelineItem({
|
|
59
|
+
event,
|
|
60
|
+
sessionStart,
|
|
61
|
+
linkedScreenshots,
|
|
62
|
+
}: {
|
|
63
|
+
event: Event
|
|
64
|
+
sessionStart: string
|
|
65
|
+
linkedScreenshots?: Screenshot[]
|
|
66
|
+
}) {
|
|
67
|
+
const [expanded, setExpanded] = useState(false)
|
|
68
|
+
const hasMeta = event.metadata && event.metadata !== "null"
|
|
69
|
+
const detailRef = useRef<HTMLDivElement>(null)
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!detailRef.current) return
|
|
73
|
+
if (expanded) {
|
|
74
|
+
gsap.fromTo(
|
|
75
|
+
detailRef.current,
|
|
76
|
+
{ height: 0, opacity: 0 },
|
|
77
|
+
{ height: "auto", opacity: 1, duration: 0.25, ease: "power2.out" }
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
}, [expanded])
|
|
81
|
+
|
|
82
|
+
const parsedMeta: object | string | null = hasMeta ? (safeParse(event.metadata!) as object | string) : null
|
|
83
|
+
const tokensUsed = event.tokens_used
|
|
84
|
+
const hasMetaScreenshot = parsedMeta && typeof parsedMeta === "object" && !Array.isArray(parsedMeta) &&
|
|
85
|
+
["screenshot", "screenshot_path", "file_path", "image", "image_path"].some((k) => {
|
|
86
|
+
const v = (parsedMeta as Record<string, unknown>)[k]
|
|
87
|
+
return typeof v === "string" && /\.(png|jpg|jpeg|gif|webp)$/i.test(v)
|
|
88
|
+
})
|
|
89
|
+
const hasLinkedScreenshots = linkedScreenshots && linkedScreenshots.length > 0
|
|
90
|
+
const hasScreenshot = hasMetaScreenshot || hasLinkedScreenshots
|
|
91
|
+
|
|
92
|
+
const phaseColors: Record<string, string> = {
|
|
93
|
+
setup: "text-info",
|
|
94
|
+
scope: "text-purple",
|
|
95
|
+
implementation: "text-warning",
|
|
96
|
+
verification: "text-accent",
|
|
97
|
+
deliverables: "text-accent",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const eventColors: Record<string, string> = {
|
|
101
|
+
decision: "text-info",
|
|
102
|
+
action: "text-accent",
|
|
103
|
+
error: "text-error",
|
|
104
|
+
fix: "text-warning",
|
|
105
|
+
info: "text-text-muted",
|
|
106
|
+
phase_change: "text-purple",
|
|
107
|
+
review_pass: "text-accent",
|
|
108
|
+
review_flag: "text-error",
|
|
109
|
+
review_dispatch: "text-info",
|
|
110
|
+
error_resolve: "text-accent",
|
|
111
|
+
cost_checkpoint: "text-text-dim",
|
|
112
|
+
pattern_detect: "text-purple",
|
|
113
|
+
knowledge_extract: "text-warning",
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const eventIcons: Record<string, ReactNode> = {
|
|
117
|
+
decision: <HelpCircle size={10} />,
|
|
118
|
+
action: <Play size={10} />,
|
|
119
|
+
error: <AlertTriangle size={10} />,
|
|
120
|
+
fix: <Wrench size={10} />,
|
|
121
|
+
info: <Minus size={10} />,
|
|
122
|
+
phase_change: <Hash size={10} />,
|
|
123
|
+
review_pass: <CheckCircle2 size={10} />,
|
|
124
|
+
review_flag: <XCircle size={10} />,
|
|
125
|
+
review_dispatch: <RotateCw size={10} />,
|
|
126
|
+
error_resolve: <ArrowRight size={10} />,
|
|
127
|
+
cost_checkpoint: <DollarSign size={10} />,
|
|
128
|
+
pattern_detect: <Layers size={10} />,
|
|
129
|
+
knowledge_extract: <Star size={10} />,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const isExpandable = hasMeta || tokensUsed > 0
|
|
133
|
+
const isError = event.event_type === "error" || event.event_type === "review_flag"
|
|
134
|
+
const isPhaseChange = event.event_type === "phase_change"
|
|
135
|
+
return (
|
|
136
|
+
<div
|
|
137
|
+
className={cn(
|
|
138
|
+
"tl-item border-l-2 ml-1 transition-colors duration-150",
|
|
139
|
+
event.event_type === "review_flag"
|
|
140
|
+
? "border-l-error/40 bg-error/[0.03]"
|
|
141
|
+
: event.event_type === "review_pass"
|
|
142
|
+
? "border-l-accent/40 bg-accent/[0.03]"
|
|
143
|
+
: event.event_type === "review_dispatch"
|
|
144
|
+
? "border-l-info/30 bg-info/[0.02]"
|
|
145
|
+
: event.event_type === "error_resolve"
|
|
146
|
+
? "border-l-accent/40 bg-accent/[0.02]"
|
|
147
|
+
: event.event_type === "knowledge_extract"
|
|
148
|
+
? "border-l-warning/40 bg-warning/[0.03]"
|
|
149
|
+
: event.event_type === "pattern_detect"
|
|
150
|
+
? "border-l-purple/40 bg-purple/[0.03]"
|
|
151
|
+
: isError
|
|
152
|
+
? "border-l-error/40 bg-error/[0.03]"
|
|
153
|
+
: isPhaseChange
|
|
154
|
+
? "border-l-purple/60"
|
|
155
|
+
: "border-l-border-bright",
|
|
156
|
+
isExpandable && "cursor-pointer",
|
|
157
|
+
expanded && "bg-bg-hover"
|
|
158
|
+
)}
|
|
159
|
+
onClick={() => isExpandable && setExpanded(!expanded)}
|
|
160
|
+
>
|
|
161
|
+
{/* Main row */}
|
|
162
|
+
<div className="flex gap-2 pt-2 pb-1.5 px-2 hover:bg-bg-hover transition-colors duration-150 group items-baseline">
|
|
163
|
+
{/* Timestamp */}
|
|
164
|
+
<span className="text-[10px] text-text-dim w-11 flex-shrink-0 font-mono tabular-nums leading-none">
|
|
165
|
+
{relativeTime(event.timestamp, sessionStart)}
|
|
166
|
+
</span>
|
|
167
|
+
|
|
168
|
+
{/* Icon */}
|
|
169
|
+
<span
|
|
170
|
+
className={cn(
|
|
171
|
+
"text-[10px] w-3 flex-shrink-0 font-bold text-center",
|
|
172
|
+
eventColors[event.event_type] || "text-text-muted"
|
|
173
|
+
)}
|
|
174
|
+
>
|
|
175
|
+
{eventIcons[event.event_type] || "-"}
|
|
176
|
+
</span>
|
|
177
|
+
|
|
178
|
+
{/* Phase tag */}
|
|
179
|
+
<span
|
|
180
|
+
className={cn(
|
|
181
|
+
"text-[10px] w-14 flex-shrink-0 uppercase tracking-wider truncate leading-none",
|
|
182
|
+
phaseColors[event.phase.toLowerCase()] || "text-text-muted"
|
|
183
|
+
)}
|
|
184
|
+
>
|
|
185
|
+
{event.phase.slice(0, 8)}
|
|
186
|
+
</span>
|
|
187
|
+
|
|
188
|
+
{/* Event type tag */}
|
|
189
|
+
<span
|
|
190
|
+
className={cn(
|
|
191
|
+
"text-[9px] px-1 py-px border rounded flex-shrink-0 uppercase tracking-wider",
|
|
192
|
+
event.event_type === "error"
|
|
193
|
+
? "border-error/30 text-error"
|
|
194
|
+
: event.event_type === "fix"
|
|
195
|
+
? "border-warning/30 text-warning"
|
|
196
|
+
: event.event_type === "action"
|
|
197
|
+
? "border-accent/20 text-accent/70"
|
|
198
|
+
: event.event_type === "decision"
|
|
199
|
+
? "border-info/30 text-info"
|
|
200
|
+
: event.event_type === "phase_change"
|
|
201
|
+
? "border-purple/30 text-purple"
|
|
202
|
+
: event.event_type === "review_pass"
|
|
203
|
+
? "border-accent/40 text-accent bg-accent/5"
|
|
204
|
+
: event.event_type === "review_flag"
|
|
205
|
+
? "border-error/40 text-error bg-error/5"
|
|
206
|
+
: event.event_type === "review_dispatch"
|
|
207
|
+
? "border-info/30 text-info bg-info/5"
|
|
208
|
+
: event.event_type === "error_resolve"
|
|
209
|
+
? "border-accent/30 text-accent bg-accent/5"
|
|
210
|
+
: event.event_type === "cost_checkpoint"
|
|
211
|
+
? "border-border text-text-dim"
|
|
212
|
+
: event.event_type === "pattern_detect"
|
|
213
|
+
? "border-purple/30 text-purple bg-purple/5"
|
|
214
|
+
: event.event_type === "knowledge_extract"
|
|
215
|
+
? "border-warning/30 text-warning bg-warning/5"
|
|
216
|
+
: "border-border text-text-dim"
|
|
217
|
+
)}
|
|
218
|
+
>
|
|
219
|
+
{event.event_type.replace("_", " ")}
|
|
220
|
+
</span>
|
|
221
|
+
|
|
222
|
+
{/* Message */}
|
|
223
|
+
<div className="flex-1 min-w-0">
|
|
224
|
+
<span
|
|
225
|
+
className={cn(
|
|
226
|
+
"text-xs break-words",
|
|
227
|
+
isError ? "text-error/90" : "text-text"
|
|
228
|
+
)}
|
|
229
|
+
>
|
|
230
|
+
{event.message}
|
|
231
|
+
</span>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
{/* Screenshot + expand indicator */}
|
|
235
|
+
<span className="flex items-center gap-1 flex-shrink-0">
|
|
236
|
+
{hasScreenshot && (
|
|
237
|
+
<span className="text-[10px] text-accent" title="has screenshot">
|
|
238
|
+
{"\uD83D\uDDBC"}
|
|
239
|
+
</span>
|
|
240
|
+
)}
|
|
241
|
+
{isExpandable && (
|
|
242
|
+
<span className="text-[10px] text-text-dim opacity-0 group-hover:opacity-100 transition-opacity">
|
|
243
|
+
{expanded ? "\u25BE" : "\u25B8"}
|
|
244
|
+
</span>
|
|
245
|
+
)}
|
|
246
|
+
</span>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
{/* Expanded detail panel */}
|
|
250
|
+
{expanded && isExpandable && (
|
|
251
|
+
<div
|
|
252
|
+
ref={detailRef}
|
|
253
|
+
data-expand
|
|
254
|
+
className="overflow-hidden"
|
|
255
|
+
>
|
|
256
|
+
<div className="mx-2 mb-2 ml-8 border border-border rounded bg-bg">
|
|
257
|
+
{/* Detail header */}
|
|
258
|
+
<div className="flex items-center gap-3 px-3 py-1.5 border-b border-border text-[10px] text-text-dim">
|
|
259
|
+
<span>
|
|
260
|
+
EVENT #{event.id}
|
|
261
|
+
</span>
|
|
262
|
+
<span className="text-border-bright">|</span>
|
|
263
|
+
<span>
|
|
264
|
+
phase: <span className={phaseColors[event.phase.toLowerCase()] || "text-text-muted"}>{event.phase}</span>
|
|
265
|
+
</span>
|
|
266
|
+
<span className="text-border-bright">|</span>
|
|
267
|
+
<span>
|
|
268
|
+
type: <span className={eventColors[event.event_type] || "text-text-muted"}>{event.event_type}</span>
|
|
269
|
+
</span>
|
|
270
|
+
{tokensUsed > 0 && (
|
|
271
|
+
<>
|
|
272
|
+
<span className="text-border-bright">|</span>
|
|
273
|
+
<span>
|
|
274
|
+
tokens: <span className="text-text">{tokensUsed.toLocaleString()}</span>
|
|
275
|
+
</span>
|
|
276
|
+
</>
|
|
277
|
+
)}
|
|
278
|
+
</div>
|
|
279
|
+
|
|
280
|
+
{/* Metadata */}
|
|
281
|
+
{parsedMeta && (
|
|
282
|
+
<div className="px-3 py-2">
|
|
283
|
+
{typeof parsedMeta === "object" && parsedMeta !== null && !Array.isArray(parsedMeta) ? (
|
|
284
|
+
<MetadataTable data={parsedMeta as Record<string, unknown>} />
|
|
285
|
+
) : (
|
|
286
|
+
<pre className="text-[10px] text-text-muted whitespace-pre-wrap overflow-auto max-h-64">
|
|
287
|
+
{JSON.stringify(parsedMeta, null, 2)}
|
|
288
|
+
</pre>
|
|
289
|
+
)}
|
|
290
|
+
</div>
|
|
291
|
+
)}
|
|
292
|
+
|
|
293
|
+
{/* No metadata but has tokens */}
|
|
294
|
+
{!parsedMeta && tokensUsed > 0 && (
|
|
295
|
+
<div className="px-3 py-2 text-[10px] text-text-dim">
|
|
296
|
+
<span className="text-text-muted">tokens_used:</span>{" "}
|
|
297
|
+
<span className="text-text">{tokensUsed.toLocaleString()}</span>
|
|
298
|
+
</div>
|
|
299
|
+
)}
|
|
300
|
+
|
|
301
|
+
{/* Inline screenshot if metadata contains screenshot/image path */}
|
|
302
|
+
{parsedMeta && <InlineScreenshot meta={parsedMeta as Record<string, unknown>} />}
|
|
303
|
+
|
|
304
|
+
{/* Linked screenshots via event_id */}
|
|
305
|
+
{hasLinkedScreenshots && (
|
|
306
|
+
<div className="px-3 py-2 border-t border-border">
|
|
307
|
+
<div className="text-[9px] text-text-dim uppercase tracking-wider mb-1">LINKED SCREENSHOTS</div>
|
|
308
|
+
<div className="flex gap-2 overflow-x-auto">
|
|
309
|
+
{linkedScreenshots!.map((sc) => (
|
|
310
|
+
<img
|
|
311
|
+
key={sc.id}
|
|
312
|
+
src={screenshotUrl(sc.file_path, sc.id, "thumb")}
|
|
313
|
+
alt={sc.description}
|
|
314
|
+
title={sc.description}
|
|
315
|
+
className="h-20 w-auto rounded border border-border cursor-pointer hover:border-accent transition-colors flex-shrink-0"
|
|
316
|
+
onError={(e) => { (e.target as HTMLImageElement).style.display = "none" }}
|
|
317
|
+
/>
|
|
318
|
+
))}
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
)}
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
)}
|
|
325
|
+
</div>
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function MetadataTable({ data }: { data: Record<string, unknown> }) {
|
|
330
|
+
return (
|
|
331
|
+
<div className="space-y-1">
|
|
332
|
+
{Object.entries(data).map(([key, value]) => (
|
|
333
|
+
<div key={key} className="flex gap-2 text-[10px]">
|
|
334
|
+
<span className="text-accent/60 flex-shrink-0 min-w-[100px]">{key}:</span>
|
|
335
|
+
<span className="text-text-muted break-all">
|
|
336
|
+
{renderValue(value)}
|
|
337
|
+
</span>
|
|
338
|
+
</div>
|
|
339
|
+
))}
|
|
340
|
+
</div>
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function renderValue(value: unknown): string {
|
|
345
|
+
if (value === null || value === undefined) return "null"
|
|
346
|
+
if (typeof value === "string") return value
|
|
347
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
|
348
|
+
if (Array.isArray(value)) {
|
|
349
|
+
if (value.length === 0) return "[]"
|
|
350
|
+
if (value.every((v) => typeof v === "string")) return value.join(", ")
|
|
351
|
+
return JSON.stringify(value, null, 2)
|
|
352
|
+
}
|
|
353
|
+
return JSON.stringify(value, null, 2)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function InlineScreenshot({ meta }: { meta: Record<string, unknown> }) {
|
|
357
|
+
// Look for screenshot paths in metadata
|
|
358
|
+
const screenshotKeys = ["screenshot", "screenshot_path", "file_path", "image", "image_path"]
|
|
359
|
+
let imgPath: string | null = null
|
|
360
|
+
|
|
361
|
+
for (const key of screenshotKeys) {
|
|
362
|
+
const val = meta[key]
|
|
363
|
+
if (typeof val === "string" && (val.endsWith(".png") || val.endsWith(".jpg") || val.endsWith(".jpeg") || val.endsWith(".gif") || val.endsWith(".webp"))) {
|
|
364
|
+
imgPath = val
|
|
365
|
+
break
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (!imgPath) return null
|
|
370
|
+
|
|
371
|
+
const url = screenshotUrl(imgPath)
|
|
372
|
+
const [lightbox, setLightbox] = useState(false)
|
|
373
|
+
|
|
374
|
+
return (
|
|
375
|
+
<>
|
|
376
|
+
<div className="px-3 py-2 border-t border-border">
|
|
377
|
+
<div className="text-[9px] text-text-dim uppercase tracking-wider mb-1">SCREENSHOT</div>
|
|
378
|
+
<img
|
|
379
|
+
src={url}
|
|
380
|
+
alt=""
|
|
381
|
+
className="max-w-full max-h-48 rounded border border-border cursor-pointer hover:border-accent transition-colors"
|
|
382
|
+
onClick={(e) => { e.stopPropagation(); setLightbox(true) }}
|
|
383
|
+
onError={(e) => { (e.target as HTMLImageElement).style.display = "none" }}
|
|
384
|
+
/>
|
|
385
|
+
</div>
|
|
386
|
+
{lightbox && createPortal(
|
|
387
|
+
<div
|
|
388
|
+
className="fixed inset-0 bg-black/80 z-[200] flex items-center justify-center"
|
|
389
|
+
onClick={(e) => { e.stopPropagation(); setLightbox(false) }}
|
|
390
|
+
>
|
|
391
|
+
<div
|
|
392
|
+
className="bg-bg-elevated border border-border rounded-lg shadow-2xl w-[85vw] h-[85vh] flex flex-col overflow-hidden"
|
|
393
|
+
onClick={(e) => e.stopPropagation()}
|
|
394
|
+
>
|
|
395
|
+
<div className="flex items-center justify-between px-4 py-2 border-b border-border flex-shrink-0">
|
|
396
|
+
<span className="text-[10px] text-text-muted font-mono">screenshot preview</span>
|
|
397
|
+
<button onClick={(e) => { e.stopPropagation(); setLightbox(false) }} className="text-text-dim hover:text-text text-sm">×</button>
|
|
398
|
+
</div>
|
|
399
|
+
<div className="flex-1 flex items-center justify-center p-6 overflow-hidden">
|
|
400
|
+
<img src={url} alt="" className="max-w-full max-h-full object-contain rounded" />
|
|
401
|
+
</div>
|
|
402
|
+
</div>
|
|
403
|
+
</div>,
|
|
404
|
+
document.body
|
|
405
|
+
)}
|
|
406
|
+
</>
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function safeParse(str: string): unknown {
|
|
411
|
+
try {
|
|
412
|
+
return JSON.parse(str)
|
|
413
|
+
} catch {
|
|
414
|
+
return str
|
|
415
|
+
}
|
|
416
|
+
}
|