create-theokit 1.0.3 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -204,3 +204,93 @@ tr.done td { opacity: 0.45; text-decoration: line-through; }
|
|
|
204
204
|
@keyframes spin {
|
|
205
205
|
to { transform: rotate(360deg); }
|
|
206
206
|
}
|
|
207
|
+
|
|
208
|
+
/* ─── Hero ──────────────────────────────────────────── */
|
|
209
|
+
|
|
210
|
+
.hero {
|
|
211
|
+
text-align: center;
|
|
212
|
+
padding: 48px 24px 32px;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.hero img {
|
|
216
|
+
margin-bottom: 16px;
|
|
217
|
+
border-radius: 16px;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.hero h1 {
|
|
221
|
+
font-size: 2.5rem;
|
|
222
|
+
font-weight: 800;
|
|
223
|
+
letter-spacing: -0.03em;
|
|
224
|
+
margin-bottom: 4px;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.hero .tagline {
|
|
228
|
+
color: var(--text-secondary);
|
|
229
|
+
font-size: 1.1rem;
|
|
230
|
+
margin-bottom: 24px;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.hero-links {
|
|
234
|
+
display: flex;
|
|
235
|
+
justify-content: center;
|
|
236
|
+
gap: 12px;
|
|
237
|
+
margin-bottom: 24px;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.btn {
|
|
241
|
+
display: inline-flex;
|
|
242
|
+
align-items: center;
|
|
243
|
+
padding: 10px 24px;
|
|
244
|
+
border-radius: 99px;
|
|
245
|
+
font-weight: 600;
|
|
246
|
+
font-size: 0.9rem;
|
|
247
|
+
text-decoration: none;
|
|
248
|
+
transition: background 0.2s, border-color 0.2s;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.btn.primary {
|
|
252
|
+
background: var(--accent);
|
|
253
|
+
color: white;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.btn.secondary {
|
|
257
|
+
background: transparent;
|
|
258
|
+
border: 1px solid var(--border);
|
|
259
|
+
color: var(--text);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
@media (hover: hover) and (pointer: fine) {
|
|
263
|
+
.btn.primary:hover { background: var(--accent-hover); }
|
|
264
|
+
.btn.secondary:hover { background: var(--bg-secondary); }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.hero .hint {
|
|
268
|
+
color: var(--text-muted);
|
|
269
|
+
font-size: 0.8rem;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.hero .hint code {
|
|
273
|
+
font-family: var(--font-mono);
|
|
274
|
+
background: var(--bg-secondary);
|
|
275
|
+
padding: 2px 6px;
|
|
276
|
+
border-radius: 4px;
|
|
277
|
+
font-size: 0.75rem;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* ─── Footer ────────────────────────────────────────── */
|
|
281
|
+
|
|
282
|
+
.footer {
|
|
283
|
+
text-align: center;
|
|
284
|
+
padding: 32px 24px;
|
|
285
|
+
color: var(--text-muted);
|
|
286
|
+
font-size: 0.8rem;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.footer a {
|
|
290
|
+
color: var(--accent);
|
|
291
|
+
text-decoration: none;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.footer a:hover {
|
|
295
|
+
text-decoration: underline;
|
|
296
|
+
}
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback, useRef, type FormEvent } from 'react'
|
|
4
4
|
|
|
5
|
+
// ── Types ──
|
|
6
|
+
|
|
5
7
|
interface Task {
|
|
6
|
-
id:
|
|
8
|
+
id: number
|
|
7
9
|
title: string
|
|
8
10
|
priority: 'high' | 'medium' | 'low'
|
|
9
11
|
done: boolean
|
|
@@ -11,261 +13,147 @@ interface Task {
|
|
|
11
13
|
|
|
12
14
|
type Role = '' | 'user' | 'admin'
|
|
13
15
|
|
|
14
|
-
interface
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
interface ChatMsg {
|
|
17
|
+
role: 'user' | 'agent' | 'tool' | 'system' | 'error'
|
|
18
|
+
text: string
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
21
|
-
}
|
|
21
|
+
// ── Page ──
|
|
22
22
|
|
|
23
23
|
export default function Page() {
|
|
24
24
|
const [tasks, setTasks] = useState<Task[]>([])
|
|
25
25
|
const [role, setRole] = useState<Role>('user')
|
|
26
|
-
const [
|
|
27
|
-
const [
|
|
26
|
+
const [title, setTitle] = useState('')
|
|
27
|
+
const [priority, setPriority] = useState<Task['priority']>('medium')
|
|
28
28
|
const [formError, setFormError] = useState('')
|
|
29
|
-
|
|
30
|
-
const [messages, setMessages] = useState<ChatMessage[]>([
|
|
31
|
-
{ type: 'system', content: 'Ask me to list, create, or complete tasks...' },
|
|
32
|
-
])
|
|
29
|
+
const [chat, setChat] = useState<ChatMsg[]>([{ role: 'system', text: 'Ask me to list, create, or complete tasks...' }])
|
|
33
30
|
const [chatInput, setChatInput] = useState('')
|
|
34
|
-
const [
|
|
35
|
-
const
|
|
36
|
-
const chatBoxRef = useRef<HTMLDivElement>(null)
|
|
37
|
-
const sessionId = useRef(`session-${Date.now()}`)
|
|
31
|
+
const [chatBusy, setChatBusy] = useState(false)
|
|
32
|
+
const chatRef = useRef<HTMLDivElement>(null)
|
|
38
33
|
|
|
39
|
-
const headers = useCallback(() => {
|
|
34
|
+
const headers = useCallback((): Record<string, string> => {
|
|
40
35
|
const h: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
41
36
|
if (role) h['x-role'] = role
|
|
42
37
|
return h
|
|
43
38
|
}, [role])
|
|
44
39
|
|
|
40
|
+
// Fetch tasks on mount + when role changes
|
|
45
41
|
const loadTasks = useCallback(async () => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (res.ok) {
|
|
49
|
-
const data = await res.json()
|
|
50
|
-
setTasks(data)
|
|
51
|
-
}
|
|
52
|
-
} catch {
|
|
53
|
-
// Network error — silently ignore on initial load
|
|
54
|
-
}
|
|
42
|
+
const res = await fetch('/api/tasks')
|
|
43
|
+
if (res.ok) setTasks(await res.json())
|
|
55
44
|
}, [])
|
|
56
45
|
|
|
57
|
-
useEffect(() => {
|
|
58
|
-
loadTasks()
|
|
59
|
-
}, [loadTasks])
|
|
60
|
-
|
|
61
|
-
useEffect(() => {
|
|
62
|
-
if (chatBoxRef.current) {
|
|
63
|
-
chatBoxRef.current.scrollTop = chatBoxRef.current.scrollHeight
|
|
64
|
-
}
|
|
65
|
-
}, [messages])
|
|
46
|
+
useEffect(() => { loadTasks() }, [loadTasks])
|
|
66
47
|
|
|
67
|
-
|
|
48
|
+
// Create task
|
|
49
|
+
const createTask = async (e: FormEvent) => {
|
|
68
50
|
e.preventDefault()
|
|
69
51
|
setFormError('')
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
headers: headers(),
|
|
76
|
-
body: JSON.stringify({ title, priority: newPriority }),
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
if (res.status === 403) {
|
|
80
|
-
setFormError('403 — Need User role')
|
|
81
|
-
return
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (!res.ok) {
|
|
85
|
-
const data = await res.json()
|
|
86
|
-
setFormError(data.error?.issues?.[0]?.message ?? `Error ${res.status}`)
|
|
87
|
-
return
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
setNewTitle('')
|
|
52
|
+
if (!title.trim()) return
|
|
53
|
+
const res = await fetch('/api/tasks', { method: 'POST', headers: headers(), body: JSON.stringify({ title, priority }) })
|
|
54
|
+
if (res.status === 403) { setFormError('403 — Need User role'); return }
|
|
55
|
+
if (!res.ok) { const b = await res.json(); setFormError(b.error?.issues?.[0]?.message ?? `Error ${res.status}`); return }
|
|
56
|
+
setTitle('')
|
|
91
57
|
loadTasks()
|
|
92
58
|
}
|
|
93
59
|
|
|
94
|
-
|
|
60
|
+
// AI Chat
|
|
61
|
+
const sendChat = async () => {
|
|
95
62
|
const msg = chatInput.trim()
|
|
96
|
-
if (!msg ||
|
|
63
|
+
if (!msg || chatBusy) return
|
|
97
64
|
setChatInput('')
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
setMessages((prev) => [...prev, { type: 'user', content: `You: ${msg}` }])
|
|
65
|
+
setChat(c => [...c, { role: 'user', text: msg }])
|
|
66
|
+
setChatBusy(true)
|
|
101
67
|
|
|
102
68
|
try {
|
|
103
69
|
const res = await fetch('/api/agents/assistant/chat', {
|
|
104
|
-
method: 'POST',
|
|
105
|
-
|
|
106
|
-
body: JSON.stringify({ message: msg, sessionId: sessionId.current }),
|
|
70
|
+
method: 'POST', headers: headers(),
|
|
71
|
+
body: JSON.stringify({ message: msg, sessionId: 'session-' + Date.now() }),
|
|
107
72
|
})
|
|
108
|
-
|
|
109
|
-
if (res.status === 403) {
|
|
110
|
-
setMessages((prev) => [
|
|
111
|
-
...prev,
|
|
112
|
-
{ type: 'system', content: '403 — Need User role to chat' },
|
|
113
|
-
])
|
|
114
|
-
setChatSending(false)
|
|
115
|
-
return
|
|
116
|
-
}
|
|
73
|
+
if (res.status === 403) { setChat(c => [...c, { role: 'error', text: '403 — Need User role' }]); return }
|
|
117
74
|
|
|
118
75
|
const reader = res.body?.getReader()
|
|
119
|
-
if (!reader)
|
|
120
|
-
setChatSending(false)
|
|
121
|
-
return
|
|
122
|
-
}
|
|
123
|
-
|
|
76
|
+
if (!reader) return
|
|
124
77
|
const decoder = new TextDecoder()
|
|
125
|
-
let buf = ''
|
|
126
|
-
let agentText = ''
|
|
127
|
-
|
|
128
|
-
setMessages((prev) => [...prev, { type: 'agent', content: '' }])
|
|
78
|
+
let buf = '', agentText = ''
|
|
129
79
|
|
|
130
80
|
while (true) {
|
|
131
81
|
const { done, value } = await reader.read()
|
|
132
82
|
if (done) break
|
|
133
|
-
|
|
134
83
|
buf += decoder.decode(value, { stream: true })
|
|
135
|
-
const lines = buf.split('\n')
|
|
136
|
-
buf = lines.pop() ?? ''
|
|
137
|
-
|
|
84
|
+
const lines = buf.split('\n'); buf = lines.pop() ?? ''
|
|
138
85
|
for (const line of lines) {
|
|
139
86
|
if (!line.startsWith('data: ')) continue
|
|
140
87
|
try {
|
|
141
88
|
const ev = JSON.parse(line.slice(6))
|
|
142
|
-
if (ev.type === 'text_delta')
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return updated
|
|
148
|
-
})
|
|
149
|
-
} else if (ev.type === 'tool_call') {
|
|
150
|
-
setMessages((prev) => {
|
|
151
|
-
const inserted = [...prev]
|
|
152
|
-
inserted.splice(inserted.length - 1, 0, {
|
|
153
|
-
type: 'tool',
|
|
154
|
-
content: `\uD83D\uDD27 ${ev.toolName}`,
|
|
155
|
-
})
|
|
156
|
-
return inserted
|
|
157
|
-
})
|
|
158
|
-
} else if (ev.type === 'tool_result') {
|
|
159
|
-
setMessages((prev) => {
|
|
160
|
-
const inserted = [...prev]
|
|
161
|
-
inserted.splice(inserted.length - 1, 0, {
|
|
162
|
-
type: 'tool',
|
|
163
|
-
content: `\u2705 ${(ev.output ?? '').substring(0, 80)}`,
|
|
164
|
-
})
|
|
165
|
-
return inserted
|
|
166
|
-
})
|
|
167
|
-
} else if (ev.type === 'thinking') {
|
|
168
|
-
setMessages((prev) => {
|
|
169
|
-
const inserted = [...prev]
|
|
170
|
-
inserted.splice(inserted.length - 1, 0, {
|
|
171
|
-
type: 'system',
|
|
172
|
-
content: `\uD83D\uDCAD ${ev.content}`,
|
|
173
|
-
})
|
|
174
|
-
return inserted
|
|
175
|
-
})
|
|
176
|
-
} else if (ev.type === 'done') {
|
|
177
|
-
const tokens = ev.usage?.totalTokens ?? 0
|
|
178
|
-
const costStr = ev.cost ? ` \u00B7 $${ev.cost.toFixed(6)}` : ''
|
|
179
|
-
setChatCost(`${tokens} tokens \u00B7 ${ev.durationMs}ms${costStr}`)
|
|
180
|
-
} else if (ev.type === 'error') {
|
|
181
|
-
setMessages((prev) => [...prev, { type: 'error', content: ev.message }])
|
|
182
|
-
}
|
|
183
|
-
} catch {
|
|
184
|
-
/* partial JSON — skip */
|
|
185
|
-
}
|
|
89
|
+
if (ev.type === 'text_delta') agentText += ev.content
|
|
90
|
+
else if (ev.type === 'tool_call') setChat(c => [...c, { role: 'tool', text: `🔧 ${ev.toolName}` }])
|
|
91
|
+
else if (ev.type === 'tool_result') setChat(c => [...c, { role: 'tool', text: `✅ ${(ev.output ?? '').slice(0, 80)}` }])
|
|
92
|
+
else if (ev.type === 'error') setChat(c => [...c, { role: 'error', text: ev.message }])
|
|
93
|
+
} catch { /* partial JSON */ }
|
|
186
94
|
}
|
|
187
95
|
}
|
|
188
|
-
|
|
96
|
+
if (agentText) setChat(c => [...c, { role: 'agent', text: agentText }])
|
|
189
97
|
loadTasks()
|
|
190
98
|
} catch (err) {
|
|
191
|
-
|
|
192
|
-
|
|
99
|
+
setChat(c => [...c, { role: 'error', text: `Error: ${err instanceof Error ? err.message : String(err)}` }])
|
|
100
|
+
} finally {
|
|
101
|
+
setChatBusy(false)
|
|
193
102
|
}
|
|
194
|
-
|
|
195
|
-
setChatSending(false)
|
|
196
103
|
}
|
|
197
104
|
|
|
105
|
+
useEffect(() => { chatRef.current?.scrollTo(0, chatRef.current.scrollHeight) }, [chat])
|
|
106
|
+
|
|
198
107
|
return (
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
</h1>
|
|
204
|
-
<p className="
|
|
205
|
-
|
|
206
|
-
|
|
108
|
+
<>
|
|
109
|
+
{/* Hero */}
|
|
110
|
+
<header className="hero">
|
|
111
|
+
<img src="/logo.png" alt="TheoKit" width={80} height={80} />
|
|
112
|
+
<h1>TheoKit</h1>
|
|
113
|
+
<p className="tagline">Build the app your agent lives in.</p>
|
|
114
|
+
<nav className="hero-links">
|
|
115
|
+
<a href="https://usetheo.dev" target="_blank" rel="noopener noreferrer" className="btn primary">
|
|
116
|
+
usetheo.dev
|
|
117
|
+
</a>
|
|
118
|
+
<a href="https://github.com/usetheodev/theokit" target="_blank" rel="noopener noreferrer" className="btn secondary">
|
|
119
|
+
Documentation
|
|
120
|
+
</a>
|
|
121
|
+
</nav>
|
|
122
|
+
<p className="hint">
|
|
123
|
+
Edit <code>app/page.tsx</code> to get started. Changes hot-reload instantly.
|
|
207
124
|
</p>
|
|
208
|
-
<div className="role-bar">
|
|
209
|
-
<label htmlFor="role">Role:</label>
|
|
210
|
-
<select id="role" value={role} onChange={(e) => setRole(e.target.value as Role)}>
|
|
211
|
-
<option value="">None (public only)</option>
|
|
212
|
-
<option value="user">User</option>
|
|
213
|
-
<option value="admin">Admin</option>
|
|
214
|
-
</select>
|
|
215
|
-
</div>
|
|
216
125
|
</header>
|
|
217
126
|
|
|
127
|
+
{/* Role selector */}
|
|
128
|
+
<div className="role-bar">
|
|
129
|
+
<label htmlFor="role">Role:</label>
|
|
130
|
+
<select id="role" value={role} onChange={e => setRole(e.target.value as Role)}>
|
|
131
|
+
<option value="">None (public only)</option>
|
|
132
|
+
<option value="user">User</option>
|
|
133
|
+
<option value="admin">Admin</option>
|
|
134
|
+
</select>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
{/* Main grid */}
|
|
218
138
|
<main className="grid">
|
|
219
|
-
{/*
|
|
139
|
+
{/* Tasks CRUD */}
|
|
220
140
|
<section className="card">
|
|
221
|
-
<h2>
|
|
222
|
-
Tasks <span className="badge">@Controller</span>
|
|
223
|
-
</h2>
|
|
141
|
+
<h2>Tasks <span className="badge">@Controller</span></h2>
|
|
224
142
|
<table>
|
|
225
|
-
<thead>
|
|
226
|
-
<tr>
|
|
227
|
-
<th>Task</th>
|
|
228
|
-
<th>Priority</th>
|
|
229
|
-
<th>Status</th>
|
|
230
|
-
</tr>
|
|
231
|
-
</thead>
|
|
143
|
+
<thead><tr><th>Task</th><th>Priority</th><th>Status</th></tr></thead>
|
|
232
144
|
<tbody>
|
|
233
|
-
{tasks.map(
|
|
234
|
-
<tr key={
|
|
235
|
-
<td>
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
</td>
|
|
239
|
-
<td>
|
|
240
|
-
<span
|
|
241
|
-
className={`prio ${
|
|
242
|
-
task.priority === 'high'
|
|
243
|
-
? 'prio-high'
|
|
244
|
-
: task.priority === 'low'
|
|
245
|
-
? 'prio-low'
|
|
246
|
-
: 'prio-med'
|
|
247
|
-
}`}
|
|
248
|
-
>
|
|
249
|
-
{task.priority}
|
|
250
|
-
</span>
|
|
251
|
-
</td>
|
|
252
|
-
<td>{task.done ? 'Done' : 'To do'}</td>
|
|
145
|
+
{tasks.map(t => (
|
|
146
|
+
<tr key={t.id} className={t.done ? 'done' : ''}>
|
|
147
|
+
<td>{t.done ? '✅ ' : '○ '}{t.title}</td>
|
|
148
|
+
<td><span className={`prio prio-${t.priority}`}>{t.priority}</span></td>
|
|
149
|
+
<td>{t.done ? 'Done' : 'To do'}</td>
|
|
253
150
|
</tr>
|
|
254
151
|
))}
|
|
255
152
|
</tbody>
|
|
256
153
|
</table>
|
|
257
|
-
<form onSubmit={
|
|
258
|
-
<input
|
|
259
|
-
|
|
260
|
-
required
|
|
261
|
-
minLength={3}
|
|
262
|
-
value={newTitle}
|
|
263
|
-
onChange={(e) => setNewTitle(e.target.value)}
|
|
264
|
-
/>
|
|
265
|
-
<select
|
|
266
|
-
value={newPriority}
|
|
267
|
-
onChange={(e) => setNewPriority(e.target.value as 'high' | 'medium' | 'low')}
|
|
268
|
-
>
|
|
154
|
+
<form onSubmit={createTask} className="create-bar">
|
|
155
|
+
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="New task..." required minLength={3} />
|
|
156
|
+
<select value={priority} onChange={e => setPriority(e.target.value as Task['priority'])}>
|
|
269
157
|
<option value="medium">Medium</option>
|
|
270
158
|
<option value="high">High</option>
|
|
271
159
|
<option value="low">Low</option>
|
|
@@ -275,34 +163,38 @@ export default function Page() {
|
|
|
275
163
|
{formError && <p className="error">{formError}</p>}
|
|
276
164
|
</section>
|
|
277
165
|
|
|
278
|
-
{/*
|
|
166
|
+
{/* AI Chat */}
|
|
279
167
|
<section className="card">
|
|
280
|
-
<h2>
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
{messages.map((msg, i) => (
|
|
285
|
-
<div key={i} className={`msg ${msg.type}`}>
|
|
286
|
-
{msg.content}
|
|
287
|
-
</div>
|
|
168
|
+
<h2>AI Assistant <span className="badge badge-ai">@Agent + SSE</span></h2>
|
|
169
|
+
<div ref={chatRef} className="chat-box">
|
|
170
|
+
{chat.map((m, i) => (
|
|
171
|
+
<div key={i} className={`msg ${m.role}`}>{m.role === 'user' ? `You: ${m.text}` : m.text}</div>
|
|
288
172
|
))}
|
|
289
173
|
</div>
|
|
290
174
|
<div className="chat-bar">
|
|
291
175
|
<input
|
|
292
|
-
placeholder="Message the AI assistant..."
|
|
293
176
|
value={chatInput}
|
|
294
|
-
onChange={
|
|
295
|
-
onKeyDown={
|
|
296
|
-
|
|
297
|
-
}
|
|
177
|
+
onChange={e => setChatInput(e.target.value)}
|
|
178
|
+
onKeyDown={e => e.key === 'Enter' && sendChat()}
|
|
179
|
+
placeholder="Message the AI assistant..."
|
|
180
|
+
disabled={chatBusy}
|
|
298
181
|
/>
|
|
299
|
-
<button type="button" onClick={
|
|
300
|
-
Send
|
|
301
|
-
</button>
|
|
182
|
+
<button type="button" onClick={sendChat} disabled={chatBusy}>Send</button>
|
|
302
183
|
</div>
|
|
303
|
-
{chatCost && <p className="cost">{chatCost}</p>}
|
|
304
184
|
</section>
|
|
305
185
|
</main>
|
|
306
|
-
|
|
186
|
+
|
|
187
|
+
{/* Footer */}
|
|
188
|
+
<footer className="footer">
|
|
189
|
+
<p>
|
|
190
|
+
Powered by{' '}
|
|
191
|
+
<a href="https://usetheo.dev" target="_blank" rel="noopener noreferrer">TheoKit</a>
|
|
192
|
+
{' · '}
|
|
193
|
+
<a href="https://github.com/usetheodev/theokit" target="_blank" rel="noopener noreferrer">GitHub</a>
|
|
194
|
+
{' · '}
|
|
195
|
+
<a href="https://discord.usetheo.dev" target="_blank" rel="noopener noreferrer">Discord</a>
|
|
196
|
+
</p>
|
|
197
|
+
</footer>
|
|
198
|
+
</>
|
|
307
199
|
)
|
|
308
200
|
}
|
|
Binary file
|