tanuki-telemetry 1.4.1 → 1.4.3
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/frontend/src/App.tsx +124 -71
- package/frontend/src/components/DemosPage.tsx +297 -0
- package/package.json +1 -1
- package/skills/commands/demo.md +144 -0
- package/skills/commands/record.md +55 -0
- package/skills/scripts/record-browser.sh +100 -0
- package/src/dashboard.ts +317 -144
package/frontend/src/App.tsx
CHANGED
|
@@ -1,70 +1,77 @@
|
|
|
1
|
-
import { useState, useEffect, useRef } from "react"
|
|
2
|
-
import gsap from "gsap"
|
|
3
|
-
import { Header } from "@/components/Header"
|
|
4
|
-
import { SessionList } from "@/components/SessionList"
|
|
5
|
-
import { SessionDetail } from "@/components/SessionDetail"
|
|
6
|
-
import { KnowledgePage } from "@/components/KnowledgePage"
|
|
7
|
-
import { CoordinatorPage } from "@/components/CoordinatorPage"
|
|
8
|
-
import { LoginPage } from "@/components/LoginPage"
|
|
9
|
-
import { SettingsPage } from "@/components/SettingsPage"
|
|
10
|
-
import { WalkthroughPage } from "@/components/WalkthroughPage"
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
1
|
+
import { useState, useEffect, useRef } from "react";
|
|
2
|
+
import gsap from "gsap";
|
|
3
|
+
import { Header } from "@/components/Header";
|
|
4
|
+
import { SessionList } from "@/components/SessionList";
|
|
5
|
+
import { SessionDetail } from "@/components/SessionDetail";
|
|
6
|
+
import { KnowledgePage } from "@/components/KnowledgePage";
|
|
7
|
+
import { CoordinatorPage } from "@/components/CoordinatorPage";
|
|
8
|
+
import { LoginPage } from "@/components/LoginPage";
|
|
9
|
+
import { SettingsPage } from "@/components/SettingsPage";
|
|
10
|
+
import { WalkthroughPage } from "@/components/WalkthroughPage";
|
|
11
|
+
import { DemosPage } from "@/components/DemosPage";
|
|
12
|
+
import { useAuth } from "@/hooks/useAuth";
|
|
13
|
+
import { useStats, useSessions, useSessionDetail } from "@/hooks/useApi";
|
|
14
|
+
import { useWebSocket } from "@/hooks/useWebSocket";
|
|
15
|
+
import { cn } from "@/lib/utils";
|
|
16
|
+
|
|
17
|
+
type Tab =
|
|
18
|
+
| "sessions"
|
|
19
|
+
| "knowledge"
|
|
20
|
+
| "coordinator"
|
|
21
|
+
| "walkthroughs"
|
|
22
|
+
| "demos"
|
|
23
|
+
| "settings";
|
|
17
24
|
|
|
18
25
|
export default function App() {
|
|
19
|
-
const { user, loading: authLoading, authEnabled } = useAuth()
|
|
20
|
-
const [tab, setTab] = useState<Tab>("coordinator")
|
|
21
|
-
const [filter, setFilter] = useState("")
|
|
22
|
-
const [activeId, setActiveId] = useState<string | null>(null)
|
|
23
|
-
const [parentId, setParentId] = useState<string | null>(null)
|
|
26
|
+
const { user, loading: authLoading, authEnabled } = useAuth();
|
|
27
|
+
const [tab, setTab] = useState<Tab>("coordinator");
|
|
28
|
+
const [filter, setFilter] = useState("");
|
|
29
|
+
const [activeId, setActiveId] = useState<string | null>(null);
|
|
30
|
+
const [parentId, setParentId] = useState<string | null>(null);
|
|
24
31
|
|
|
25
|
-
const tabBarRef = useRef<HTMLDivElement>(null)
|
|
32
|
+
const tabBarRef = useRef<HTMLDivElement>(null);
|
|
26
33
|
|
|
27
|
-
const { stats, reload: reloadStats } = useStats()
|
|
28
|
-
const { sessions, reload: reloadSessions } = useSessions(filter)
|
|
29
|
-
const { detail, loading, reload: reloadDetail } = useSessionDetail(activeId)
|
|
30
|
-
const { status: wsStatus, messages } = useWebSocket()
|
|
34
|
+
const { stats, reload: reloadStats } = useStats();
|
|
35
|
+
const { sessions, reload: reloadSessions } = useSessions(filter);
|
|
36
|
+
const { detail, loading, reload: reloadDetail } = useSessionDetail(activeId);
|
|
37
|
+
const { status: wsStatus, messages } = useWebSocket();
|
|
31
38
|
|
|
32
39
|
// Staggered fade-in for tab bar
|
|
33
40
|
useEffect(() => {
|
|
34
|
-
if (!tabBarRef.current) return
|
|
41
|
+
if (!tabBarRef.current) return;
|
|
35
42
|
gsap.fromTo(
|
|
36
43
|
tabBarRef.current.children,
|
|
37
44
|
{ opacity: 0 },
|
|
38
|
-
{ opacity: 1, duration: 0.3, stagger: 0.06, ease: "power2.out" }
|
|
39
|
-
)
|
|
40
|
-
}, [])
|
|
45
|
+
{ opacity: 1, duration: 0.3, stagger: 0.06, ease: "power2.out" },
|
|
46
|
+
);
|
|
47
|
+
}, []);
|
|
41
48
|
|
|
42
49
|
// Navigate to a child session, remembering the parent
|
|
43
50
|
const handleSelectSession = (id: string) => {
|
|
44
51
|
if (detail?.session) {
|
|
45
|
-
setParentId(detail.session.id)
|
|
52
|
+
setParentId(detail.session.id);
|
|
46
53
|
}
|
|
47
|
-
setActiveId(id)
|
|
48
|
-
}
|
|
54
|
+
setActiveId(id);
|
|
55
|
+
};
|
|
49
56
|
|
|
50
57
|
// Navigate back to parent
|
|
51
58
|
const handleBackToParent = () => {
|
|
52
59
|
if (parentId) {
|
|
53
|
-
setActiveId(parentId)
|
|
54
|
-
setParentId(null)
|
|
60
|
+
setActiveId(parentId);
|
|
61
|
+
setParentId(null);
|
|
55
62
|
} else if (detail?.session?.parent_session_id) {
|
|
56
|
-
setActiveId(detail.session.parent_session_id)
|
|
63
|
+
setActiveId(detail.session.parent_session_id);
|
|
57
64
|
}
|
|
58
|
-
}
|
|
65
|
+
};
|
|
59
66
|
|
|
60
67
|
// React to WebSocket messages
|
|
61
68
|
useEffect(() => {
|
|
62
|
-
if (messages.length === 0) return
|
|
63
|
-
const latest = messages[messages.length - 1]
|
|
69
|
+
if (messages.length === 0) return;
|
|
70
|
+
const latest = messages[messages.length - 1];
|
|
64
71
|
|
|
65
72
|
if (latest.type === "session_new" || latest.type === "session_update") {
|
|
66
|
-
reloadSessions()
|
|
67
|
-
reloadStats()
|
|
73
|
+
reloadSessions();
|
|
74
|
+
reloadStats();
|
|
68
75
|
}
|
|
69
76
|
|
|
70
77
|
if (activeId) {
|
|
@@ -74,68 +81,104 @@ export default function App() {
|
|
|
74
81
|
(latest.type === "screenshot" && latest.data.session_id === activeId) ||
|
|
75
82
|
(latest.type === "artifact" && latest.data.session_id === activeId) ||
|
|
76
83
|
(latest.type === "insight" && latest.data.session_id === activeId) ||
|
|
77
|
-
(latest.type === "plan_step_new" &&
|
|
78
|
-
|
|
79
|
-
(latest.type === "
|
|
84
|
+
(latest.type === "plan_step_new" &&
|
|
85
|
+
latest.data.session_id === activeId) ||
|
|
86
|
+
(latest.type === "plan_step_update" &&
|
|
87
|
+
latest.data.session_id === activeId) ||
|
|
88
|
+
(latest.type === "session_update" && latest.data.id === activeId);
|
|
80
89
|
|
|
81
90
|
if (affectsActive) {
|
|
82
|
-
reloadDetail()
|
|
91
|
+
reloadDetail();
|
|
83
92
|
}
|
|
84
93
|
|
|
85
94
|
// If a child session of the active session updates, also refresh
|
|
86
95
|
if (detail?.child_sessions && detail.child_sessions.length > 0) {
|
|
87
|
-
const childIds = new Set(detail.child_sessions.map((c) => c.id))
|
|
96
|
+
const childIds = new Set(detail.child_sessions.map((c) => c.id));
|
|
88
97
|
const affectsChild =
|
|
89
98
|
(latest.type === "session_update" && childIds.has(latest.data.id)) ||
|
|
90
|
-
(latest.type === "session_new" &&
|
|
99
|
+
(latest.type === "session_new" &&
|
|
100
|
+
latest.data.parent_session_id === activeId);
|
|
91
101
|
if (affectsChild) {
|
|
92
|
-
reloadDetail()
|
|
102
|
+
reloadDetail();
|
|
93
103
|
}
|
|
94
104
|
}
|
|
95
105
|
}
|
|
96
|
-
}, [
|
|
106
|
+
}, [
|
|
107
|
+
messages,
|
|
108
|
+
activeId,
|
|
109
|
+
reloadSessions,
|
|
110
|
+
reloadStats,
|
|
111
|
+
reloadDetail,
|
|
112
|
+
detail?.child_sessions,
|
|
113
|
+
]);
|
|
97
114
|
|
|
98
115
|
// Auth loading state
|
|
99
116
|
if (authLoading) {
|
|
100
117
|
return (
|
|
101
118
|
<div className="h-screen flex items-center justify-center bg-bg">
|
|
102
|
-
<div className="text-accent text-xs font-mono animate-pulse">
|
|
119
|
+
<div className="text-accent text-xs font-mono animate-pulse">
|
|
120
|
+
INITIALIZING...
|
|
121
|
+
</div>
|
|
103
122
|
</div>
|
|
104
|
-
)
|
|
123
|
+
);
|
|
105
124
|
}
|
|
106
125
|
|
|
107
126
|
// Auth gate — if auth is enabled and user is not logged in, show login page
|
|
108
127
|
if (authEnabled && !user) {
|
|
109
|
-
return <LoginPage
|
|
128
|
+
return <LoginPage />;
|
|
110
129
|
}
|
|
111
130
|
|
|
112
131
|
return (
|
|
113
132
|
<div className="h-screen flex flex-col overflow-hidden bg-bg">
|
|
114
133
|
{/* Scanline overlay */}
|
|
115
134
|
<div className="pointer-events-none fixed inset-0 z-[100] opacity-[0.03]">
|
|
116
|
-
<div
|
|
117
|
-
|
|
118
|
-
|
|
135
|
+
<div
|
|
136
|
+
className="w-full h-px bg-accent"
|
|
137
|
+
style={{
|
|
138
|
+
animation: "scanline 8s linear infinite",
|
|
139
|
+
}}
|
|
140
|
+
/>
|
|
119
141
|
</div>
|
|
120
142
|
|
|
121
143
|
<Header stats={stats} />
|
|
122
144
|
|
|
123
145
|
{/* Tab bar */}
|
|
124
|
-
<div
|
|
125
|
-
|
|
146
|
+
<div
|
|
147
|
+
ref={tabBarRef}
|
|
148
|
+
className="border-b border-border px-5 flex items-center gap-1 bg-bg flex-shrink-0"
|
|
149
|
+
>
|
|
150
|
+
<TabButton
|
|
151
|
+
active={tab === "coordinator"}
|
|
152
|
+
onClick={() => setTab("coordinator")}
|
|
153
|
+
>
|
|
126
154
|
COORDINATOR
|
|
127
155
|
</TabButton>
|
|
128
|
-
<TabButton
|
|
156
|
+
<TabButton
|
|
157
|
+
active={tab === "sessions"}
|
|
158
|
+
onClick={() => setTab("sessions")}
|
|
159
|
+
>
|
|
129
160
|
SESSIONS
|
|
130
161
|
</TabButton>
|
|
131
|
-
<TabButton
|
|
162
|
+
<TabButton
|
|
163
|
+
active={tab === "knowledge"}
|
|
164
|
+
onClick={() => setTab("knowledge")}
|
|
165
|
+
>
|
|
132
166
|
KNOWLEDGE
|
|
133
167
|
</TabButton>
|
|
134
|
-
<TabButton
|
|
168
|
+
<TabButton
|
|
169
|
+
active={tab === "walkthroughs"}
|
|
170
|
+
onClick={() => setTab("walkthroughs")}
|
|
171
|
+
>
|
|
135
172
|
WALKTHROUGHS
|
|
136
173
|
</TabButton>
|
|
174
|
+
<TabButton active={tab === "demos"} onClick={() => setTab("demos")}>
|
|
175
|
+
DEMOS
|
|
176
|
+
</TabButton>
|
|
137
177
|
{authEnabled && (
|
|
138
|
-
<TabButton
|
|
178
|
+
<TabButton
|
|
179
|
+
active={tab === "settings"}
|
|
180
|
+
onClick={() => setTab("settings")}
|
|
181
|
+
>
|
|
139
182
|
SETTINGS
|
|
140
183
|
</TabButton>
|
|
141
184
|
)}
|
|
@@ -151,8 +194,8 @@ export default function App() {
|
|
|
151
194
|
activeId={activeId}
|
|
152
195
|
filter={filter}
|
|
153
196
|
onSelect={(id) => {
|
|
154
|
-
setParentId(null)
|
|
155
|
-
setActiveId(id)
|
|
197
|
+
setParentId(null);
|
|
198
|
+
setActiveId(id);
|
|
156
199
|
}}
|
|
157
200
|
onFilterChange={setFilter}
|
|
158
201
|
/>
|
|
@@ -179,6 +222,8 @@ export default function App() {
|
|
|
179
222
|
<KnowledgePage wsMessages={messages} />
|
|
180
223
|
) : tab === "walkthroughs" ? (
|
|
181
224
|
<WalkthroughPage />
|
|
225
|
+
) : tab === "demos" ? (
|
|
226
|
+
<DemosPage />
|
|
182
227
|
) : tab === "settings" ? (
|
|
183
228
|
<SettingsPage user={user} />
|
|
184
229
|
) : null}
|
|
@@ -196,14 +241,23 @@ export default function App() {
|
|
|
196
241
|
</>
|
|
197
242
|
)}
|
|
198
243
|
<span className="flex items-center gap-1.5">
|
|
199
|
-
<span
|
|
244
|
+
<span
|
|
245
|
+
className={cn(
|
|
246
|
+
"inline-block w-1.5 h-1.5",
|
|
247
|
+
wsStatus === "connected"
|
|
248
|
+
? "bg-accent"
|
|
249
|
+
: wsStatus === "connecting"
|
|
250
|
+
? "bg-warning"
|
|
251
|
+
: "bg-error",
|
|
252
|
+
)}
|
|
253
|
+
/>
|
|
200
254
|
</span>
|
|
201
255
|
<span className="text-border-bright">|</span>
|
|
202
256
|
{sessions.length} sessions
|
|
203
257
|
</span>
|
|
204
258
|
</div>
|
|
205
259
|
</div>
|
|
206
|
-
)
|
|
260
|
+
);
|
|
207
261
|
}
|
|
208
262
|
|
|
209
263
|
function TabButton({
|
|
@@ -211,9 +265,9 @@ function TabButton({
|
|
|
211
265
|
onClick,
|
|
212
266
|
children,
|
|
213
267
|
}: {
|
|
214
|
-
active: boolean
|
|
215
|
-
onClick: () => void
|
|
216
|
-
children: React.ReactNode
|
|
268
|
+
active: boolean;
|
|
269
|
+
onClick: () => void;
|
|
270
|
+
children: React.ReactNode;
|
|
217
271
|
}) {
|
|
218
272
|
return (
|
|
219
273
|
<button
|
|
@@ -222,11 +276,10 @@ function TabButton({
|
|
|
222
276
|
"px-3 py-2 text-[10px] font-bold uppercase tracking-widest border-b-2 transition-colors duration-200",
|
|
223
277
|
active
|
|
224
278
|
? "border-b-accent text-accent"
|
|
225
|
-
: "border-b-transparent text-text-dim hover:text-text-muted"
|
|
279
|
+
: "border-b-transparent text-text-dim hover:text-text-muted",
|
|
226
280
|
)}
|
|
227
281
|
>
|
|
228
282
|
{children}
|
|
229
283
|
</button>
|
|
230
|
-
)
|
|
284
|
+
);
|
|
231
285
|
}
|
|
232
|
-
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from "react";
|
|
2
|
+
import gsap from "gsap";
|
|
3
|
+
import { Play, Film, Clock, HardDrive, Calendar } from "lucide-react";
|
|
4
|
+
import type { Artifact } from "@/types";
|
|
5
|
+
import { cn, timeAgo } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
function formatSize(bytes: number | null): string {
|
|
8
|
+
if (bytes == null) return "";
|
|
9
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
10
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
11
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function artifactUrl(id: number): string {
|
|
15
|
+
return `/api/artifacts/by-id/${id}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isVideo(a: Artifact): boolean {
|
|
19
|
+
const mime = a.mime_type || "";
|
|
20
|
+
const ext = a.file_path?.split(".").pop()?.toLowerCase() || "";
|
|
21
|
+
return mime.startsWith("video/") || ["mp4", "webm", "mov"].includes(ext);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function DemosPage() {
|
|
25
|
+
const [demos, setDemos] = useState<Artifact[]>([]);
|
|
26
|
+
const [activeId, setActiveId] = useState<number | null>(null);
|
|
27
|
+
const [loading, setLoading] = useState(true);
|
|
28
|
+
const sidebarRef = useRef<HTMLDivElement>(null);
|
|
29
|
+
const sidebarAnimated = useRef(false);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
fetch("/api/artifacts?type=demo,recording")
|
|
33
|
+
.then((r) => r.json())
|
|
34
|
+
.then((data) => {
|
|
35
|
+
const items = data.artifacts || [];
|
|
36
|
+
setDemos(items);
|
|
37
|
+
if (items.length > 0) {
|
|
38
|
+
setActiveId(items[0].id);
|
|
39
|
+
}
|
|
40
|
+
setLoading(false);
|
|
41
|
+
})
|
|
42
|
+
.catch(() => setLoading(false));
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
// Animate sidebar entries on first load
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!sidebarRef.current || sidebarAnimated.current || demos.length === 0)
|
|
48
|
+
return;
|
|
49
|
+
sidebarAnimated.current = true;
|
|
50
|
+
const items = sidebarRef.current.querySelectorAll("[data-demo-item]");
|
|
51
|
+
gsap.fromTo(
|
|
52
|
+
items,
|
|
53
|
+
{ opacity: 0, x: -8 },
|
|
54
|
+
{ opacity: 1, x: 0, duration: 0.25, stagger: 0.03, ease: "power2.out" },
|
|
55
|
+
);
|
|
56
|
+
}, [demos.length > 0]);
|
|
57
|
+
|
|
58
|
+
const activeDemo = demos.find((d) => d.id === activeId) || null;
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="flex flex-1 overflow-hidden">
|
|
62
|
+
{/* Left sidebar — demo list */}
|
|
63
|
+
<div
|
|
64
|
+
ref={sidebarRef}
|
|
65
|
+
className="w-[300px] min-w-[260px] border-r border-border flex flex-col overflow-hidden flex-shrink-0 bg-bg"
|
|
66
|
+
>
|
|
67
|
+
<div className="px-4 h-9 border-b border-border flex items-center justify-between sticky top-0 bg-bg z-10 flex-shrink-0">
|
|
68
|
+
<span className="text-text-dim text-[10px]">
|
|
69
|
+
<span className="text-accent">$</span> demos/
|
|
70
|
+
</span>
|
|
71
|
+
<span className="text-[10px] text-text-dim">
|
|
72
|
+
{demos.length} recordings
|
|
73
|
+
</span>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div className="flex-1 overflow-y-auto">
|
|
77
|
+
{loading ? (
|
|
78
|
+
<div className="p-4 text-xs text-accent animate-pulse">
|
|
79
|
+
Loading...
|
|
80
|
+
</div>
|
|
81
|
+
) : demos.length === 0 ? (
|
|
82
|
+
<div className="p-4 text-xs text-text-dim">
|
|
83
|
+
No demos or recordings yet.
|
|
84
|
+
<div className="mt-2 text-[10px]">
|
|
85
|
+
Use <code className="text-accent">/demo</code> to record one, or{" "}
|
|
86
|
+
<code className="text-accent">/record</code> for manual
|
|
87
|
+
recording.
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
) : (
|
|
91
|
+
demos.map((demo) => (
|
|
92
|
+
<button
|
|
93
|
+
key={demo.id}
|
|
94
|
+
data-demo-item
|
|
95
|
+
onClick={() => setActiveId(demo.id)}
|
|
96
|
+
className={cn(
|
|
97
|
+
"w-full text-left px-4 py-2.5 border-b border-border transition-colors cursor-pointer",
|
|
98
|
+
activeId === demo.id
|
|
99
|
+
? "bg-bg-elevated border-l-2 border-l-accent"
|
|
100
|
+
: "hover:bg-bg-elevated border-l-2 border-l-transparent",
|
|
101
|
+
)}
|
|
102
|
+
>
|
|
103
|
+
<div className="flex items-center gap-2">
|
|
104
|
+
<span
|
|
105
|
+
className={cn(
|
|
106
|
+
"text-[10px] font-bold px-1.5 py-0.5 rounded",
|
|
107
|
+
demo.artifact_type === "demo"
|
|
108
|
+
? "bg-accent/15 text-accent"
|
|
109
|
+
: "bg-info/15 text-info",
|
|
110
|
+
)}
|
|
111
|
+
>
|
|
112
|
+
{demo.artifact_type === "demo" ? "DEMO" : "REC"}
|
|
113
|
+
</span>
|
|
114
|
+
<span className="text-xs text-text truncate font-mono">
|
|
115
|
+
{demo.description || `Recording #${demo.id}`}
|
|
116
|
+
</span>
|
|
117
|
+
</div>
|
|
118
|
+
<div className="flex items-center gap-2 mt-1 text-[10px] text-text-muted">
|
|
119
|
+
<span>{timeAgo(demo.timestamp)}</span>
|
|
120
|
+
{demo.file_size && (
|
|
121
|
+
<>
|
|
122
|
+
<span className="text-border-bright">|</span>
|
|
123
|
+
<span>{formatSize(demo.file_size)}</span>
|
|
124
|
+
</>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
</button>
|
|
128
|
+
))
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{/* Right panel — video player */}
|
|
134
|
+
<div className="flex-1 overflow-y-auto">
|
|
135
|
+
{activeDemo ? (
|
|
136
|
+
<DemoDetail demo={activeDemo} />
|
|
137
|
+
) : (
|
|
138
|
+
<div className="h-full flex items-center justify-center text-text-dim text-xs">
|
|
139
|
+
{loading ? (
|
|
140
|
+
<span className="text-accent animate-pulse">Loading...</span>
|
|
141
|
+
) : demos.length === 0 ? (
|
|
142
|
+
<div className="text-center">
|
|
143
|
+
<Film className="mx-auto mb-3 text-text-dim" size={32} />
|
|
144
|
+
<div>No demos recorded yet</div>
|
|
145
|
+
<div className="text-[10px] mt-1">
|
|
146
|
+
Run <code className="text-accent">/demo</code> to create one
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
) : (
|
|
150
|
+
"Select a demo"
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function DemoDetail({ demo }: { demo: Artifact }) {
|
|
160
|
+
const detailRef = useRef<HTMLDivElement>(null);
|
|
161
|
+
const video = isVideo(demo);
|
|
162
|
+
const metadata = parseMetadata(demo.metadata);
|
|
163
|
+
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
if (!detailRef.current) return;
|
|
166
|
+
gsap.fromTo(
|
|
167
|
+
detailRef.current.children,
|
|
168
|
+
{ opacity: 0, y: 8 },
|
|
169
|
+
{ opacity: 1, y: 0, duration: 0.3, stagger: 0.05, ease: "power2.out" },
|
|
170
|
+
);
|
|
171
|
+
}, [demo.id]);
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<div ref={detailRef} className="p-6">
|
|
175
|
+
{/* Header */}
|
|
176
|
+
<div className="mb-6">
|
|
177
|
+
<div className="flex items-center gap-3 mb-2">
|
|
178
|
+
<span
|
|
179
|
+
className={cn(
|
|
180
|
+
"text-[10px] font-bold px-1.5 py-0.5 rounded",
|
|
181
|
+
demo.artifact_type === "demo"
|
|
182
|
+
? "bg-accent/15 text-accent"
|
|
183
|
+
: "bg-info/15 text-info",
|
|
184
|
+
)}
|
|
185
|
+
>
|
|
186
|
+
{demo.artifact_type === "demo" ? "DEMO" : "RECORDING"}
|
|
187
|
+
</span>
|
|
188
|
+
<h2 className="text-lg font-bold text-text font-mono truncate">
|
|
189
|
+
{demo.description || `Recording #${demo.id}`}
|
|
190
|
+
</h2>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
{/* Meta row */}
|
|
194
|
+
<div className="flex items-center gap-4 text-xs text-text-muted mt-2">
|
|
195
|
+
<span className="flex items-center gap-1">
|
|
196
|
+
<Calendar size={10} />
|
|
197
|
+
{timeAgo(demo.timestamp)}
|
|
198
|
+
</span>
|
|
199
|
+
{demo.file_size && (
|
|
200
|
+
<span className="flex items-center gap-1">
|
|
201
|
+
<HardDrive size={10} />
|
|
202
|
+
{formatSize(demo.file_size)}
|
|
203
|
+
</span>
|
|
204
|
+
)}
|
|
205
|
+
{demo.mime_type && (
|
|
206
|
+
<span className="text-text-dim">{demo.mime_type}</span>
|
|
207
|
+
)}
|
|
208
|
+
{Boolean(metadata?.duration) && (
|
|
209
|
+
<span className="flex items-center gap-1">
|
|
210
|
+
<Clock size={10} />
|
|
211
|
+
{String(metadata!.duration)}
|
|
212
|
+
</span>
|
|
213
|
+
)}
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
{/* Session link */}
|
|
217
|
+
{demo.session_id && (
|
|
218
|
+
<div className="text-[10px] text-text-dim mt-1">
|
|
219
|
+
Session:{" "}
|
|
220
|
+
<span className="text-accent font-mono">
|
|
221
|
+
{demo.session_id.slice(0, 8)}
|
|
222
|
+
</span>
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
{/* Video player or download */}
|
|
228
|
+
{video ? (
|
|
229
|
+
<div className="mb-6">
|
|
230
|
+
<div className="rounded-lg overflow-hidden border border-border bg-black">
|
|
231
|
+
<video
|
|
232
|
+
key={demo.id}
|
|
233
|
+
controls
|
|
234
|
+
preload="metadata"
|
|
235
|
+
className="w-full max-h-[70vh]"
|
|
236
|
+
poster={undefined}
|
|
237
|
+
>
|
|
238
|
+
<source
|
|
239
|
+
src={artifactUrl(demo.id)}
|
|
240
|
+
type={demo.mime_type || "video/mp4"}
|
|
241
|
+
/>
|
|
242
|
+
Your browser does not support video playback.
|
|
243
|
+
</video>
|
|
244
|
+
</div>
|
|
245
|
+
<div className="flex items-center gap-3 mt-3">
|
|
246
|
+
<a
|
|
247
|
+
href={artifactUrl(demo.id)}
|
|
248
|
+
download
|
|
249
|
+
className="text-[10px] text-accent hover:underline flex items-center gap-1"
|
|
250
|
+
>
|
|
251
|
+
<Play size={10} /> Download video
|
|
252
|
+
</a>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
) : (
|
|
256
|
+
<div className="mb-6 p-4 bg-bg-elevated rounded border border-border">
|
|
257
|
+
<div className="text-xs text-text-dim mb-2">
|
|
258
|
+
This artifact is not a video file. You can download it directly:
|
|
259
|
+
</div>
|
|
260
|
+
<a
|
|
261
|
+
href={artifactUrl(demo.id)}
|
|
262
|
+
download
|
|
263
|
+
className="text-xs text-accent hover:underline"
|
|
264
|
+
>
|
|
265
|
+
Download {demo.file_path?.split("/").pop() || "file"}
|
|
266
|
+
</a>
|
|
267
|
+
</div>
|
|
268
|
+
)}
|
|
269
|
+
|
|
270
|
+
{/* Metadata */}
|
|
271
|
+
{metadata && Object.keys(metadata).length > 0 && (
|
|
272
|
+
<div>
|
|
273
|
+
<h3 className="text-[10px] text-text-dim uppercase tracking-wider font-bold mb-2">
|
|
274
|
+
Metadata
|
|
275
|
+
</h3>
|
|
276
|
+
<div className="bg-bg-elevated rounded p-3 border border-border text-xs font-mono">
|
|
277
|
+
{Object.entries(metadata).map(([key, value]) => (
|
|
278
|
+
<div key={key} className="flex gap-2">
|
|
279
|
+
<span className="text-accent">{key}:</span>
|
|
280
|
+
<span className="text-text">{String(value)}</span>
|
|
281
|
+
</div>
|
|
282
|
+
))}
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
)}
|
|
286
|
+
</div>
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function parseMetadata(raw: string | null): Record<string, unknown> | null {
|
|
291
|
+
if (!raw) return null;
|
|
292
|
+
try {
|
|
293
|
+
return JSON.parse(raw);
|
|
294
|
+
} catch {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
}
|