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,213 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
|
+
import gsap from "gsap";
|
|
3
|
+
import type { AuthUser } from "@/hooks/useAuth";
|
|
4
|
+
import { apiFetch } from "@/lib/api";
|
|
5
|
+
|
|
6
|
+
interface ApiKeyItem {
|
|
7
|
+
id: string;
|
|
8
|
+
label: string;
|
|
9
|
+
last_used_at: string | null;
|
|
10
|
+
created_at: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function SettingsPage({ user }: { user: AuthUser | null }) {
|
|
14
|
+
const [keys, setKeys] = useState<ApiKeyItem[]>([]);
|
|
15
|
+
const [newKeyLabel, setNewKeyLabel] = useState("");
|
|
16
|
+
const [generatedKey, setGeneratedKey] = useState<string | null>(null);
|
|
17
|
+
const [copied, setCopied] = useState(false);
|
|
18
|
+
const [loading, setLoading] = useState(true);
|
|
19
|
+
|
|
20
|
+
const loadKeys = useCallback(() => {
|
|
21
|
+
apiFetch("/api/keys")
|
|
22
|
+
.then((r) => r.json())
|
|
23
|
+
.then((data) => {
|
|
24
|
+
setKeys(data);
|
|
25
|
+
setLoading(false);
|
|
26
|
+
})
|
|
27
|
+
.catch(() => setLoading(false));
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
loadKeys();
|
|
32
|
+
}, [loadKeys]);
|
|
33
|
+
|
|
34
|
+
const handleGenerate = async () => {
|
|
35
|
+
const label = newKeyLabel.trim() || "default";
|
|
36
|
+
try {
|
|
37
|
+
const res = await apiFetch("/api/keys", {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: { "Content-Type": "application/json" },
|
|
40
|
+
body: JSON.stringify({ label }),
|
|
41
|
+
});
|
|
42
|
+
const data = await res.json();
|
|
43
|
+
setGeneratedKey(data.key);
|
|
44
|
+
setNewKeyLabel("");
|
|
45
|
+
setCopied(false);
|
|
46
|
+
loadKeys();
|
|
47
|
+
} catch {
|
|
48
|
+
// handled by apiFetch
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const handleRevoke = async (keyId: string) => {
|
|
53
|
+
try {
|
|
54
|
+
await apiFetch(`/api/keys/${keyId}`, { method: "DELETE" });
|
|
55
|
+
loadKeys();
|
|
56
|
+
if (generatedKey) setGeneratedKey(null);
|
|
57
|
+
} catch {
|
|
58
|
+
// handled by apiFetch
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const handleCopy = () => {
|
|
63
|
+
if (generatedKey) {
|
|
64
|
+
navigator.clipboard.writeText(generatedKey);
|
|
65
|
+
setCopied(true);
|
|
66
|
+
setTimeout(() => setCopied(false), 2000);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const pageRef = useRef<HTMLDivElement>(null);
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!pageRef.current) return;
|
|
74
|
+
gsap.fromTo(
|
|
75
|
+
pageRef.current.children,
|
|
76
|
+
{ opacity: 0, y: 10 },
|
|
77
|
+
{ opacity: 1, y: 0, duration: 0.3, stagger: 0.08, ease: "power2.out" }
|
|
78
|
+
);
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div ref={pageRef} className="flex-1 overflow-y-auto p-6 max-w-2xl mx-auto w-full">
|
|
83
|
+
{/* User info */}
|
|
84
|
+
{user && (
|
|
85
|
+
<div className="mb-8 border border-border p-4">
|
|
86
|
+
<div className="text-[10px] text-accent font-bold tracking-widest mb-3">
|
|
87
|
+
ACCOUNT
|
|
88
|
+
</div>
|
|
89
|
+
<div className="flex items-center gap-3">
|
|
90
|
+
{user.avatar_url && (
|
|
91
|
+
<img
|
|
92
|
+
src={user.avatar_url}
|
|
93
|
+
alt=""
|
|
94
|
+
className="w-8 h-8 rounded-full"
|
|
95
|
+
/>
|
|
96
|
+
)}
|
|
97
|
+
<div>
|
|
98
|
+
<div className="text-text text-xs font-mono">{user.name}</div>
|
|
99
|
+
<div className="text-text-dim text-[10px] font-mono">
|
|
100
|
+
{user.email}
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
<a
|
|
104
|
+
href="/auth/logout"
|
|
105
|
+
className="ml-auto text-[10px] text-error hover:text-text font-mono transition-colors"
|
|
106
|
+
>
|
|
107
|
+
LOGOUT
|
|
108
|
+
</a>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
{/* API Keys */}
|
|
114
|
+
<div className="border border-border p-4">
|
|
115
|
+
<div className="text-[10px] text-accent font-bold tracking-widest mb-3">
|
|
116
|
+
API KEYS
|
|
117
|
+
</div>
|
|
118
|
+
<p className="text-text-dim text-[10px] font-mono mb-4">
|
|
119
|
+
Use API keys to connect Claude Code MCP clients to this Tanuki
|
|
120
|
+
server.
|
|
121
|
+
</p>
|
|
122
|
+
|
|
123
|
+
{/* Generate new key */}
|
|
124
|
+
<div className="flex gap-2 mb-4">
|
|
125
|
+
<input
|
|
126
|
+
type="text"
|
|
127
|
+
placeholder="label (optional)"
|
|
128
|
+
value={newKeyLabel}
|
|
129
|
+
onChange={(e) => setNewKeyLabel(e.target.value)}
|
|
130
|
+
className="flex-1 bg-bg border border-border px-3 py-1.5 text-xs font-mono text-text placeholder:text-text-dim focus:outline-none focus:border-accent"
|
|
131
|
+
onKeyDown={(e) => {
|
|
132
|
+
if (e.key === "Enter") handleGenerate();
|
|
133
|
+
}}
|
|
134
|
+
/>
|
|
135
|
+
<button
|
|
136
|
+
onClick={handleGenerate}
|
|
137
|
+
className="px-4 py-1.5 border border-accent text-accent hover:bg-accent hover:text-bg text-[10px] font-mono font-bold tracking-wider transition-colors"
|
|
138
|
+
>
|
|
139
|
+
GENERATE
|
|
140
|
+
</button>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
{/* Show generated key (one-time) */}
|
|
144
|
+
{generatedKey && (
|
|
145
|
+
<div className="mb-4 border border-accent bg-bg p-3">
|
|
146
|
+
<div className="text-[10px] text-accent font-bold mb-2">
|
|
147
|
+
NEW API KEY — COPY NOW (shown once)
|
|
148
|
+
</div>
|
|
149
|
+
<div className="flex items-center gap-2">
|
|
150
|
+
<code className="flex-1 text-xs font-mono text-text bg-bg-elevated px-2 py-1 overflow-x-auto select-all">
|
|
151
|
+
{generatedKey}
|
|
152
|
+
</code>
|
|
153
|
+
<button
|
|
154
|
+
onClick={handleCopy}
|
|
155
|
+
className="px-3 py-1 border border-border text-[10px] font-mono hover:border-accent hover:text-accent transition-colors"
|
|
156
|
+
>
|
|
157
|
+
{copied ? "COPIED" : "COPY"}
|
|
158
|
+
</button>
|
|
159
|
+
</div>
|
|
160
|
+
<div className="text-[10px] text-text-dim font-mono mt-2">
|
|
161
|
+
Add to your <code>.claude.json</code>:
|
|
162
|
+
</div>
|
|
163
|
+
<pre className="text-[10px] text-text-muted font-mono mt-1 overflow-x-auto">
|
|
164
|
+
{`"telemetry": {
|
|
165
|
+
"type": "sse",
|
|
166
|
+
"url": "${window.location.origin}/mcp/sse",
|
|
167
|
+
"headers": { "Authorization": "Bearer ${generatedKey}" }
|
|
168
|
+
}`}
|
|
169
|
+
</pre>
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
|
|
173
|
+
{/* Key list */}
|
|
174
|
+
{loading ? (
|
|
175
|
+
<div className="text-text-dim text-[10px] font-mono">Loading...</div>
|
|
176
|
+
) : keys.length === 0 ? (
|
|
177
|
+
<div className="text-text-dim text-[10px] font-mono">
|
|
178
|
+
No API keys yet. Generate one to connect Claude Code.
|
|
179
|
+
</div>
|
|
180
|
+
) : (
|
|
181
|
+
<div className="space-y-1">
|
|
182
|
+
{keys.map((k) => (
|
|
183
|
+
<div
|
|
184
|
+
key={k.id}
|
|
185
|
+
className="flex items-center justify-between border border-border px-3 py-2"
|
|
186
|
+
>
|
|
187
|
+
<div className="flex items-center gap-3">
|
|
188
|
+
<span className="text-xs font-mono text-text">{k.label}</span>
|
|
189
|
+
<span className="text-[10px] font-mono text-text-dim">
|
|
190
|
+
created{" "}
|
|
191
|
+
{new Date(k.created_at + "Z").toLocaleDateString()}
|
|
192
|
+
</span>
|
|
193
|
+
{k.last_used_at && (
|
|
194
|
+
<span className="text-[10px] font-mono text-text-dim">
|
|
195
|
+
last used{" "}
|
|
196
|
+
{new Date(k.last_used_at + "Z").toLocaleDateString()}
|
|
197
|
+
</span>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
<button
|
|
201
|
+
onClick={() => handleRevoke(k.id)}
|
|
202
|
+
className="text-[10px] font-mono text-error hover:text-text transition-colors"
|
|
203
|
+
>
|
|
204
|
+
REVOKE
|
|
205
|
+
</button>
|
|
206
|
+
</div>
|
|
207
|
+
))}
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { useRef, useEffect, useState, useMemo } from "react"
|
|
2
|
+
import gsap from "gsap"
|
|
3
|
+
import type { Session, Event } from "@/types"
|
|
4
|
+
import { cn } from "@/lib/utils"
|
|
5
|
+
|
|
6
|
+
type MsgType = "broadcast" | "finding" | "blocker" | "done" | "directive"
|
|
7
|
+
|
|
8
|
+
interface StreamMessage {
|
|
9
|
+
stream_name: string
|
|
10
|
+
msg_type: MsgType
|
|
11
|
+
message: string
|
|
12
|
+
timestamp: string
|
|
13
|
+
for_streams?: string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
childSessions: Session[]
|
|
18
|
+
parentSessionId?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MSG_TYPE_STYLES: Record<MsgType, { border: string; text: string; icon: string }> = {
|
|
22
|
+
broadcast: {
|
|
23
|
+
border: "border-l-info",
|
|
24
|
+
text: "text-info",
|
|
25
|
+
icon: "\u2139",
|
|
26
|
+
},
|
|
27
|
+
finding: {
|
|
28
|
+
border: "border-l-warning",
|
|
29
|
+
text: "text-warning",
|
|
30
|
+
icon: "\uD83D\uDCA1",
|
|
31
|
+
},
|
|
32
|
+
blocker: {
|
|
33
|
+
border: "border-l-error",
|
|
34
|
+
text: "text-error",
|
|
35
|
+
icon: "\u26D4",
|
|
36
|
+
},
|
|
37
|
+
done: {
|
|
38
|
+
border: "border-l-accent",
|
|
39
|
+
text: "text-accent",
|
|
40
|
+
icon: "\u2705",
|
|
41
|
+
},
|
|
42
|
+
directive: {
|
|
43
|
+
border: "border-l-purple",
|
|
44
|
+
text: "text-purple",
|
|
45
|
+
icon: "\u27A1",
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function StreamComms({ childSessions, parentSessionId }: Props) {
|
|
50
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
51
|
+
const bottomRef = useRef<HTMLDivElement>(null)
|
|
52
|
+
const [messages, setMessages] = useState<StreamMessage[]>([])
|
|
53
|
+
const [loading, setLoading] = useState(true)
|
|
54
|
+
|
|
55
|
+
// Build a map of session_id -> worktree_name
|
|
56
|
+
const sessionNameMap = useMemo(() => {
|
|
57
|
+
const map = new Map<string, string>()
|
|
58
|
+
for (const s of childSessions) {
|
|
59
|
+
map.set(s.id, s.worktree_name)
|
|
60
|
+
}
|
|
61
|
+
return map
|
|
62
|
+
}, [childSessions])
|
|
63
|
+
|
|
64
|
+
// Fetch events from all child sessions + parent coordinator events
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (childSessions.length === 0) {
|
|
67
|
+
setMessages([])
|
|
68
|
+
setLoading(false)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const fetchAll = async () => {
|
|
73
|
+
const allMessages: StreamMessage[] = []
|
|
74
|
+
|
|
75
|
+
// Fetch child session events
|
|
76
|
+
await Promise.all(
|
|
77
|
+
childSessions.map(async (child) => {
|
|
78
|
+
try {
|
|
79
|
+
const res = await fetch(`/api/sessions/${child.id}`)
|
|
80
|
+
const data = await res.json()
|
|
81
|
+
const events: Event[] = data.events || []
|
|
82
|
+
|
|
83
|
+
for (const ev of events) {
|
|
84
|
+
if (!ev.metadata) continue
|
|
85
|
+
try {
|
|
86
|
+
const meta = JSON.parse(ev.metadata)
|
|
87
|
+
if (meta.msg_type && isValidMsgType(meta.msg_type)) {
|
|
88
|
+
allMessages.push({
|
|
89
|
+
stream_name: sessionNameMap.get(child.id) || child.id.slice(0, 8),
|
|
90
|
+
msg_type: meta.msg_type as MsgType,
|
|
91
|
+
message: ev.message,
|
|
92
|
+
timestamp: ev.timestamp,
|
|
93
|
+
for_streams: meta.for_streams,
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// ignore parse errors
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// ignore fetch errors
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
// Fetch coordinator events from parent session
|
|
107
|
+
if (parentSessionId) {
|
|
108
|
+
try {
|
|
109
|
+
const res = await fetch(`/api/sessions/${parentSessionId}`)
|
|
110
|
+
const data = await res.json()
|
|
111
|
+
const events: Event[] = data.events || []
|
|
112
|
+
|
|
113
|
+
for (const ev of events) {
|
|
114
|
+
if (!ev.metadata) continue
|
|
115
|
+
try {
|
|
116
|
+
const meta = JSON.parse(ev.metadata)
|
|
117
|
+
if (meta.stream === "coordinator" && meta.msg_type) {
|
|
118
|
+
allMessages.push({
|
|
119
|
+
stream_name: "coordinator",
|
|
120
|
+
msg_type: (meta.msg_type === "directive" ? "directive" : meta.msg_type) as MsgType,
|
|
121
|
+
message: ev.message,
|
|
122
|
+
timestamp: ev.timestamp,
|
|
123
|
+
for_streams: meta.for_streams,
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
// ignore parse errors
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// ignore fetch errors
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Sort by timestamp
|
|
136
|
+
allMessages.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
|
|
137
|
+
setMessages(allMessages)
|
|
138
|
+
setLoading(false)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
fetchAll()
|
|
142
|
+
}, [childSessions, sessionNameMap, parentSessionId])
|
|
143
|
+
|
|
144
|
+
// Animate new messages
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
if (!ref.current || messages.length === 0) return
|
|
147
|
+
const items = ref.current.querySelectorAll(".comm-msg")
|
|
148
|
+
if (items.length > 0) {
|
|
149
|
+
gsap.fromTo(
|
|
150
|
+
items,
|
|
151
|
+
{ opacity: 0, x: -8 },
|
|
152
|
+
{ opacity: 1, x: 0, duration: 0.3, stagger: 0.03, ease: "power2.out" }
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
bottomRef.current?.scrollIntoView({ behavior: "smooth" })
|
|
156
|
+
}, [messages])
|
|
157
|
+
|
|
158
|
+
if (loading) {
|
|
159
|
+
return (
|
|
160
|
+
<div className="text-text-dim text-[10px] py-2">
|
|
161
|
+
loading stream comms...
|
|
162
|
+
</div>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (messages.length === 0) {
|
|
167
|
+
return (
|
|
168
|
+
<div className="text-text-dim text-[10px] py-2">
|
|
169
|
+
no inter-stream messages yet
|
|
170
|
+
</div>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<div ref={ref} className="max-h-80 overflow-y-auto space-y-0.5">
|
|
176
|
+
{messages.map((msg, i) => {
|
|
177
|
+
const style = MSG_TYPE_STYLES[msg.msg_type] || MSG_TYPE_STYLES.broadcast
|
|
178
|
+
const time = formatTime(msg.timestamp)
|
|
179
|
+
const isCoordinator = msg.stream_name === "coordinator"
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div
|
|
183
|
+
key={i}
|
|
184
|
+
className={cn(
|
|
185
|
+
"comm-msg flex items-start gap-2 px-2 py-1.5 border-l-2 text-[11px] font-mono",
|
|
186
|
+
style.border,
|
|
187
|
+
isCoordinator ? "bg-purple/[0.04]" : "bg-bg/50"
|
|
188
|
+
)}
|
|
189
|
+
>
|
|
190
|
+
<span className="flex-shrink-0 text-text-dim w-11 tabular-nums">
|
|
191
|
+
{time}
|
|
192
|
+
</span>
|
|
193
|
+
<span className="flex-shrink-0">{style.icon}</span>
|
|
194
|
+
<span className={cn("flex-shrink-0 font-bold", style.text)}>
|
|
195
|
+
[{msg.stream_name}]
|
|
196
|
+
</span>
|
|
197
|
+
{isCoordinator && msg.for_streams && msg.for_streams.length > 0 && (
|
|
198
|
+
<span className="flex-shrink-0 text-text-dim">
|
|
199
|
+
{"\u2192"} {msg.for_streams.join(", ")}:
|
|
200
|
+
</span>
|
|
201
|
+
)}
|
|
202
|
+
<span className={cn("flex-1", isCoordinator ? "text-purple/80" : "text-text")}>
|
|
203
|
+
{msg.message}
|
|
204
|
+
</span>
|
|
205
|
+
</div>
|
|
206
|
+
)
|
|
207
|
+
})}
|
|
208
|
+
<div ref={bottomRef} />
|
|
209
|
+
</div>
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function isValidMsgType(t: string): t is MsgType {
|
|
214
|
+
return ["broadcast", "finding", "blocker", "done", "directive"].includes(t)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function formatTime(ts: string): string {
|
|
218
|
+
try {
|
|
219
|
+
const d = new Date(ts + "Z")
|
|
220
|
+
return d.toLocaleTimeString("en-US", {
|
|
221
|
+
hour: "2-digit",
|
|
222
|
+
minute: "2-digit",
|
|
223
|
+
hour12: false,
|
|
224
|
+
})
|
|
225
|
+
} catch {
|
|
226
|
+
return "--:--"
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function TanukiLogo({ className = "" }: { className?: string }) {
|
|
2
|
+
return (
|
|
3
|
+
<svg
|
|
4
|
+
viewBox="0 0 168 128"
|
|
5
|
+
className={className}
|
|
6
|
+
fill="currentColor"
|
|
7
|
+
>
|
|
8
|
+
<g transform="translate(0,128) scale(0.1,-0.1)" stroke="none">
|
|
9
|
+
<path d="M113 1183 c-18 -37 -17 -212 1 -235 25 -34 48 -22 150 77 12 12 42 35 68 53 27 18 48 37 48 42 0 12 -55 50 -73 50 -9 0 -19 5 -22 10 -3 6 -17 10 -30 10 -13 0 -27 5 -30 10 -3 6 -27 10 -53 10 -41 0 -49 -3 -59 -27z m117 -30 c49 -18 70 -31 70 -44 0 -5 -26 -27 -57 -51 -32 -23 -63 -49 -70 -57 -9 -12 -14 -13 -24 -4 -15 15 -8 135 10 157 17 20 15 20 71 -1z" />
|
|
10
|
+
<path d="M1455 1200 c-3 -5 -17 -10 -30 -10 -13 0 -27 -4 -30 -10 -3 -5 -13 -10 -22 -10 -18 0 -73 -38 -73 -50 0 -5 21 -23 46 -41 25 -17 62 -46 82 -64 101 -93 114 -99 138 -67 18 23 19 198 2 235 -11 24 -19 27 -60 27 -26 0 -50 -4 -53 -10z m76 -67 c11 -30 12 -130 1 -137 -11 -6 -92 52 -92 65 0 5 -6 9 -13 9 -21 0 -48 35 -41 53 3 9 25 22 48 27 22 6 46 12 51 14 19 6 36 -7 46 -31z" />
|
|
11
|
+
<path d="M490 1002 c0 -4 -12 -13 -27 -19 -62 -27 -268 -227 -341 -330 -129 -182 -108 -278 103 -457 17 -14 36 -26 43 -26 6 0 12 -4 12 -8 0 -5 16 -14 35 -22 19 -8 35 -17 35 -22 0 -4 7 -8 15 -8 8 0 23 -4 33 -9 9 -5 31 -14 47 -21 17 -7 38 -16 47 -21 10 -5 29 -9 43 -9 14 0 33 -4 43 -9 20 -10 195 -31 262 -31 67 0 242 21 262 31 10 5 27 9 37 9 11 0 33 5 48 11 15 6 37 15 48 19 43 16 160 75 163 82 2 5 9 8 15 8 12 0 39 21 102 78 78 72 125 152 125 217 0 43 -33 120 -82 188 -71 99 -282 304 -340 330 -15 6 -28 15 -28 19 0 5 -27 8 -60 8 -60 0 -100 -16 -164 -65 -23 -18 -26 -27 -26 -83 0 -53 10 -93 30 -112 3 -3 17 -21 31 -40 15 -19 39 -48 53 -65 14 -16 32 -42 39 -57 6 -16 15 -28 19 -28 5 0 8 -9 8 -19 0 -11 5 -23 10 -26 14 -8 13 -71 -1 -97 -23 -43 -53 -65 -131 -97 -46 -19 -146 -26 -233 -16 -56 6 -72 11 -135 39 -28 13 -59 42 -77 74 -15 26 -17 88 -3 97 6 3 10 13 10 21 0 13 52 99 69 114 4 3 18 21 33 42 15 20 33 42 40 50 28 30 38 60 38 117 0 48 -4 62 -24 82 -45 45 -103 69 -167 69 -32 0 -59 -3 -59 -8z m121 -43 c57 -19 89 -56 89 -103 -1 -23 -6 -50 -13 -62 -19 -32 -71 -94 -79 -94 -5 0 -8 20 -8 44 0 76 -67 146 -127 131 -32 -8 -80 -61 -89 -98 -10 -46 4 -93 38 -126 17 -17 35 -28 39 -25 4 2 10 0 13 -5 4 -5 22 -7 41 -4 28 4 35 2 35 -11 0 -9 -4 -16 -10 -16 -5 0 -10 -8 -10 -18 0 -10 -4 -22 -10 -28 -5 -5 -12 -30 -16 -54 -4 -35 0 -55 18 -89 22 -43 81 -99 109 -105 8 -2 28 -9 44 -16 50 -22 257 -19 335 5 76 23 153 90 165 144 8 36 -2 108 -16 117 -5 3 -9 12 -9 21 0 9 -7 25 -17 35 -22 25 -10 33 27 18 61 -25 122 25 135 111 8 57 -11 101 -58 129 -46 29 -74 25 -113 -15 -38 -40 -48 -68 -42 -125 l3 -39 -28 27 c-46 44 -78 111 -75 156 3 34 10 45 44 71 61 47 119 47 190 0 76 -51 260 -238 315 -320 69 -104 77 -133 55 -199 -21 -62 -109 -159 -182 -202 -24 -14 -44 -30 -44 -35 0 -5 -6 -9 -12 -9 -7 0 -38 -12 -68 -26 -348 -164 -823 -106 -1080 133 -81 76 -120 136 -120 188 0 85 130 254 325 421 99 85 133 97 206 73z m-65 -147 c23 -29 17 -103 -10 -134 -29 -35 -56 -35 -90 -2 -20 21 -26 37 -26 70 0 24 5 44 10 44 6 0 10 7 10 15 0 37 79 43 106 7z m696 -5 c28 -35 23 -99 -8 -131 -34 -33 -61 -33 -90 2 -37 42 -26 144 16 156 27 8 65 -5 82 -27z" />
|
|
12
|
+
<path d="M725 557 c-31 -19 -45 -35 -45 -51 0 -18 48 -67 83 -83 15 -7 38 -18 52 -25 19 -9 31 -9 50 0 14 7 37 18 52 25 35 16 83 65 83 83 0 17 -15 33 -48 52 -28 16 -200 15 -227 -1z m184 -33 c36 -7 39 -17 12 -41 -40 -37 -51 -43 -71 -43 -33 0 -97 37 -103 59 -4 16 1 21 27 24 55 7 103 7 135 1z" />
|
|
13
|
+
</g>
|
|
14
|
+
</svg>
|
|
15
|
+
)
|
|
16
|
+
}
|