@upend/cli 0.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/README.md +231 -0
- package/bin/cli.ts +48 -0
- package/package.json +26 -0
- package/src/commands/deploy.ts +67 -0
- package/src/commands/dev.ts +96 -0
- package/src/commands/infra.ts +227 -0
- package/src/commands/init.ts +323 -0
- package/src/commands/migrate.ts +64 -0
- package/src/config.ts +18 -0
- package/src/index.ts +2 -0
- package/src/lib/auth.ts +89 -0
- package/src/lib/db.ts +14 -0
- package/src/lib/exec.ts +38 -0
- package/src/lib/log.ts +16 -0
- package/src/lib/middleware.ts +51 -0
- package/src/services/claude/index.ts +507 -0
- package/src/services/claude/snapshots.ts +142 -0
- package/src/services/claude/worktree.ts +151 -0
- package/src/services/dashboard/public/index.html +888 -0
- package/src/services/gateway/auth-routes.ts +203 -0
- package/src/services/gateway/index.ts +64 -0
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>upend</title>
|
|
7
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
8
|
+
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
|
|
9
|
+
<script src="https://unpkg.com/lucide@latest"></script>
|
|
10
|
+
<script>
|
|
11
|
+
tailwind.config = {
|
|
12
|
+
theme: {
|
|
13
|
+
extend: {
|
|
14
|
+
colors: {
|
|
15
|
+
bg: '#0a0a0a',
|
|
16
|
+
surface: '#141414',
|
|
17
|
+
border: '#262626',
|
|
18
|
+
muted: '#737373',
|
|
19
|
+
accent: '#f97316',
|
|
20
|
+
'accent-dim': '#431407',
|
|
21
|
+
},
|
|
22
|
+
fontFamily: {
|
|
23
|
+
mono: ['SF Mono', 'Fira Code', 'Cascadia Code', 'monospace'],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
</script>
|
|
29
|
+
<style>
|
|
30
|
+
[x-cloak] { display: none !important; }
|
|
31
|
+
@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
32
|
+
.dot-pulse { animation: pulse-dot 1s infinite; }
|
|
33
|
+
/* hide scrollbar but allow scroll */
|
|
34
|
+
.no-scrollbar::-webkit-scrollbar { display: none; }
|
|
35
|
+
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
|
36
|
+
</style>
|
|
37
|
+
</head>
|
|
38
|
+
<body class="bg-bg text-gray-200 font-mono h-screen flex flex-col overflow-hidden" x-data="dashboard()" x-cloak>
|
|
39
|
+
|
|
40
|
+
<!-- login overlay -->
|
|
41
|
+
<div x-show="!token" class="fixed inset-0 bg-bg flex items-center justify-center z-50">
|
|
42
|
+
<form @submit.prevent="login" class="bg-surface border border-border rounded-xl p-8 w-80 flex flex-col gap-3">
|
|
43
|
+
<h2 class="text-accent font-bold" x-text="isSignup ? 'sign up' : 'log in'"></h2>
|
|
44
|
+
<input x-model="email" type="email" placeholder="email" required
|
|
45
|
+
class="bg-bg border border-border rounded-md px-3 py-2 text-sm text-gray-200 font-mono outline-none focus:border-accent">
|
|
46
|
+
<input x-model="password" type="password" placeholder="password" required
|
|
47
|
+
class="bg-bg border border-border rounded-md px-3 py-2 text-sm text-gray-200 font-mono outline-none focus:border-accent">
|
|
48
|
+
<p x-show="authError" x-text="authError" class="text-red-500 text-xs"></p>
|
|
49
|
+
<button type="submit" class="bg-accent text-black rounded-md py-2 text-sm font-bold cursor-pointer"
|
|
50
|
+
x-text="isSignup ? 'sign up' : 'log in'"></button>
|
|
51
|
+
<p class="text-muted text-xs text-center cursor-pointer hover:text-gray-200"
|
|
52
|
+
@click="isSignup = !isSignup" x-text="isSignup ? 'already have an account? log in' : 'need an account? sign up'"></p>
|
|
53
|
+
</form>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<!-- top bar -->
|
|
57
|
+
<div x-show="token" class="h-10 bg-surface border-b border-border flex items-center justify-between px-3 shrink-0 z-40">
|
|
58
|
+
<div class="flex items-center gap-3">
|
|
59
|
+
<button @click="drawerOpen = !drawerOpen; if(drawerOpen) loadSessions()"
|
|
60
|
+
class="text-lg px-2 py-1 rounded text-muted hover:text-gray-200 hover:bg-border cursor-pointer">☰</button>
|
|
61
|
+
<span class="text-accent font-bold text-sm cursor-pointer" @click="rightPanel = 'home'">upend</span>
|
|
62
|
+
<div class="flex gap-0.5">
|
|
63
|
+
<button @click="rightPanel = 'data'"
|
|
64
|
+
:class="rightPanel === 'data' ? 'text-accent bg-accent-dim' : 'text-muted hover:text-gray-200 hover:bg-border'"
|
|
65
|
+
class="px-3 py-1 text-xs rounded cursor-pointer font-mono">data</button>
|
|
66
|
+
<div class="relative" @click.away="appsOpen = false">
|
|
67
|
+
<button @click="appsOpen = !appsOpen; loadApps()"
|
|
68
|
+
:class="rightPanel !== 'data' && rightPanel !== 'home' ? 'text-accent bg-accent-dim' : 'text-muted hover:text-gray-200 hover:bg-border'"
|
|
69
|
+
class="px-3 py-1 text-xs rounded cursor-pointer font-mono">apps ▾</button>
|
|
70
|
+
<div x-show="appsOpen" x-transition
|
|
71
|
+
class="absolute top-full left-0 mt-1 bg-surface border border-border rounded-md min-w-[180px] p-1 z-50 shadow-xl">
|
|
72
|
+
<template x-for="app in apps" :key="app.name">
|
|
73
|
+
<div class="flex items-center justify-between px-2 py-1.5 rounded cursor-pointer text-xs hover:bg-border"
|
|
74
|
+
:class="rightPanel === app.name ? 'text-accent bg-accent-dim' : 'text-gray-200'"
|
|
75
|
+
@click="openApp(app.name)">
|
|
76
|
+
<span x-text="app.name"></span>
|
|
77
|
+
<a :href="'/apps/' + app.name + '/'" target="_blank" @click.stop
|
|
78
|
+
class="text-muted text-[10px] px-1 hover:text-gray-200">↗</a>
|
|
79
|
+
</div>
|
|
80
|
+
</template>
|
|
81
|
+
<p x-show="apps.length === 0" class="text-muted text-xs text-center py-3">no apps yet</p>
|
|
82
|
+
<div class="border-t border-border mt-1 pt-1">
|
|
83
|
+
<div class="px-2 py-1.5 rounded cursor-pointer text-xs text-accent hover:bg-border"
|
|
84
|
+
@click="createNewApp()">+ new app</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="flex items-center gap-3">
|
|
91
|
+
<span class="text-muted text-xs truncate max-w-[200px]" x-text="sessionLabel"></span>
|
|
92
|
+
<span class="flex items-center gap-1">
|
|
93
|
+
<span class="w-1.5 h-1.5 rounded-full"
|
|
94
|
+
:class="{ 'bg-muted': status === 'ready', 'bg-green-500': status === 'active' || status === 'done',
|
|
95
|
+
'bg-accent dot-pulse': status === 'running', 'bg-red-500': status === 'error' }"></span>
|
|
96
|
+
<span class="text-muted text-xs" x-text="statusText"></span>
|
|
97
|
+
</span>
|
|
98
|
+
<button x-show="currentSessionId" @click="showCloseModal = true"
|
|
99
|
+
class="text-muted px-1.5 py-0.5 rounded text-xs cursor-pointer hover:text-red-500" title="close session">
|
|
100
|
+
<i data-lucide="trash-2" class="w-3.5 h-3.5"></i>
|
|
101
|
+
</button>
|
|
102
|
+
<button x-show="currentSessionId" @click="commitSession()"
|
|
103
|
+
class="flex items-center gap-1 text-green-500 border border-green-500 px-2 py-0.5 rounded text-xs cursor-pointer hover:bg-green-500/10 font-mono">
|
|
104
|
+
<i data-lucide="rocket" class="w-3 h-3"></i> publish
|
|
105
|
+
</button>
|
|
106
|
+
<span class="text-muted text-xs" x-text="userEmail"></span>
|
|
107
|
+
<button @click="logout()"
|
|
108
|
+
class="text-muted border border-border px-2 py-0.5 rounded text-xs cursor-pointer hover:text-gray-200 hover:border-muted font-mono">logout</button>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<!-- sessions drawer -->
|
|
113
|
+
<div x-show="drawerOpen" x-transition:enter="transition ease-out duration-200"
|
|
114
|
+
x-transition:enter-start="-translate-x-full" x-transition:enter-end="translate-x-0"
|
|
115
|
+
x-transition:leave="transition ease-in duration-150"
|
|
116
|
+
x-transition:leave-start="translate-x-0" x-transition:leave-end="-translate-x-full"
|
|
117
|
+
@click.away="drawerOpen = false"
|
|
118
|
+
class="fixed left-0 top-10 bottom-0 w-64 bg-surface border-r border-border z-30 flex flex-col">
|
|
119
|
+
<div class="px-4 py-3 flex justify-between items-center">
|
|
120
|
+
<span class="text-muted text-xs uppercase tracking-wide">sessions</span>
|
|
121
|
+
<button @click="newSession()" class="bg-accent text-white text-xs px-3 py-1 rounded cursor-pointer font-mono">+ new</button>
|
|
122
|
+
</div>
|
|
123
|
+
<div class="flex-1 overflow-y-auto px-2 no-scrollbar">
|
|
124
|
+
<template x-for="s in sessions.filter(s => s.status === 'active')" :key="s.id">
|
|
125
|
+
<div @click="openSession(s.id)"
|
|
126
|
+
:class="s.id == currentSessionId ? 'bg-accent-dim text-accent' : 'text-muted hover:bg-border hover:text-gray-200'"
|
|
127
|
+
class="px-3 py-2 rounded-md cursor-pointer text-xs truncate mb-0.5"
|
|
128
|
+
x-text="s.title || s.prompt.slice(0, 50)"></div>
|
|
129
|
+
</template>
|
|
130
|
+
<p x-show="sessions.filter(s => s.status === 'active').length === 0"
|
|
131
|
+
class="text-muted text-xs text-center py-6">no active sessions</p>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<!-- new session modal -->
|
|
136
|
+
<div x-show="showSessionModal" x-transition class="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
|
137
|
+
<form @submit.prevent="showSessionModal = false; createSession(_pendingPrompt, sessionTitle)"
|
|
138
|
+
@keydown.escape="showSessionModal = false; messages.pop()"
|
|
139
|
+
class="bg-surface border border-border rounded-xl p-6 w-96 flex flex-col gap-4">
|
|
140
|
+
<h3 class="text-accent font-bold text-sm">new session</h3>
|
|
141
|
+
<p class="text-muted text-xs">what are you working on?</p>
|
|
142
|
+
<input x-model="sessionTitle" x-ref="sessionTitleInput" type="text" placeholder="e.g. add products table and CRUD app"
|
|
143
|
+
class="bg-bg border border-border rounded-md px-3 py-2 text-sm text-gray-200 font-mono outline-none focus:border-accent">
|
|
144
|
+
<div class="flex gap-2 justify-end">
|
|
145
|
+
<button type="button" @click="showSessionModal = false; messages.pop()"
|
|
146
|
+
class="text-muted text-xs px-3 py-1.5 rounded border border-border cursor-pointer hover:text-gray-200 font-mono">cancel</button>
|
|
147
|
+
<button type="submit" :disabled="!sessionTitle.trim()"
|
|
148
|
+
class="bg-accent text-black text-xs px-4 py-1.5 rounded font-bold cursor-pointer disabled:opacity-40 font-mono">start session</button>
|
|
149
|
+
</div>
|
|
150
|
+
</form>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<!-- close session modal -->
|
|
154
|
+
<div x-show="showCloseModal" x-transition class="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
|
155
|
+
<div @keydown.escape="showCloseModal = false"
|
|
156
|
+
class="bg-surface border border-border rounded-xl p-6 w-96 flex flex-col gap-4">
|
|
157
|
+
<h3 class="text-red-500 font-bold text-sm flex items-center gap-2">
|
|
158
|
+
<i data-lucide="alert-triangle" class="w-4 h-4"></i>
|
|
159
|
+
close session
|
|
160
|
+
</h3>
|
|
161
|
+
<p class="text-muted text-xs leading-relaxed">This will close the session without publishing changes to live. The session and its changes will be preserved but not applied.</p>
|
|
162
|
+
<p class="text-gray-200 text-xs">You can reopen it later from the sessions drawer.</p>
|
|
163
|
+
<div class="flex gap-2 justify-end">
|
|
164
|
+
<button @click="showCloseModal = false"
|
|
165
|
+
class="text-muted text-xs px-3 py-1.5 rounded border border-border cursor-pointer hover:text-gray-200 font-mono">cancel</button>
|
|
166
|
+
<button @click="closeSession(); showCloseModal = false"
|
|
167
|
+
class="bg-red-500/20 text-red-500 border border-red-500/50 text-xs px-4 py-1.5 rounded font-bold cursor-pointer hover:bg-red-500/30 font-mono">close session</button>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<!-- publish modal -->
|
|
173
|
+
<div x-show="showPublishModal" x-transition class="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
|
174
|
+
<div @keydown.escape="showPublishModal = false"
|
|
175
|
+
class="bg-surface border border-border rounded-xl p-6 w-96 flex flex-col gap-4">
|
|
176
|
+
<h3 class="text-green-500 font-bold text-sm flex items-center gap-2">
|
|
177
|
+
<i data-lucide="rocket" class="w-4 h-4"></i>
|
|
178
|
+
publish to live
|
|
179
|
+
</h3>
|
|
180
|
+
<p class="text-muted text-xs leading-relaxed">This will merge all changes from this session into the live environment. Services will restart automatically.</p>
|
|
181
|
+
<p class="text-gray-200 text-xs font-mono" x-text="sessionLabel"></p>
|
|
182
|
+
<div class="flex gap-2 justify-end">
|
|
183
|
+
<button @click="showPublishModal = false"
|
|
184
|
+
class="text-muted text-xs px-3 py-1.5 rounded border border-border cursor-pointer hover:text-gray-200 font-mono">cancel</button>
|
|
185
|
+
<button @click="doPublish()"
|
|
186
|
+
class="bg-green-500/20 text-green-500 border border-green-500/50 text-xs px-4 py-1.5 rounded font-bold cursor-pointer hover:bg-green-500/30 font-mono flex items-center gap-1">
|
|
187
|
+
<i data-lucide="rocket" class="w-3 h-3"></i> publish
|
|
188
|
+
</button>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<!-- main panels -->
|
|
194
|
+
<div x-show="token" class="flex-1 flex min-h-0">
|
|
195
|
+
<!-- left: chat (native DOM) -->
|
|
196
|
+
<div class="flex-1 flex flex-col min-h-0 min-w-0" id="panel-left">
|
|
197
|
+
<!-- messages -->
|
|
198
|
+
<div class="flex-1 overflow-y-auto px-5 py-4 space-y-4 no-scrollbar" x-ref="messages">
|
|
199
|
+
<template x-for="(msg, i) in messages" :key="i">
|
|
200
|
+
<div class="max-w-3xl mx-auto">
|
|
201
|
+
<div class="text-xs font-bold uppercase tracking-wide mb-1"
|
|
202
|
+
:class="{ 'text-blue-500': msg.role === 'user', 'text-accent': msg.role === 'assistant', 'text-muted': msg.role === 'system' }"
|
|
203
|
+
x-text="msg.role"></div>
|
|
204
|
+
<template x-if="msg.role === 'tool_use'">
|
|
205
|
+
<div class="bg-surface border border-border rounded-md px-3 py-2">
|
|
206
|
+
<div class="text-green-500 text-xs font-bold" x-text="'▶ ' + msg.name"></div>
|
|
207
|
+
<pre class="text-muted text-xs mt-1 overflow-x-auto" x-text="typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content, null, 2)"></pre>
|
|
208
|
+
</div>
|
|
209
|
+
</template>
|
|
210
|
+
<template x-if="msg.role !== 'tool_use'">
|
|
211
|
+
<div class="text-sm leading-relaxed whitespace-pre-wrap break-words" x-text="msg.content"></div>
|
|
212
|
+
</template>
|
|
213
|
+
</div>
|
|
214
|
+
</template>
|
|
215
|
+
<div x-show="messages.length === 0" class="max-w-3xl mx-auto">
|
|
216
|
+
<div class="text-xs font-bold uppercase tracking-wide mb-1 text-muted">system</div>
|
|
217
|
+
<div class="text-sm text-muted">start a new session or tell claude what to build.</div>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
<!-- input -->
|
|
221
|
+
<div class="border-t border-border px-5 py-3 shrink-0">
|
|
222
|
+
<form @submit.prevent="sendPrompt()" class="max-w-3xl mx-auto flex gap-2">
|
|
223
|
+
<textarea x-model="prompt" x-ref="chatInput" rows="1" placeholder="tell claude what to do..."
|
|
224
|
+
@keydown.enter.prevent="if(!$event.shiftKey) sendPrompt()"
|
|
225
|
+
@input="$el.style.height = 'auto'; $el.style.height = Math.min($el.scrollHeight, 200) + 'px'"
|
|
226
|
+
class="flex-1 bg-surface border border-border rounded-lg px-3 py-2 text-sm text-gray-200 font-mono resize-none outline-none focus:border-accent min-h-[40px] max-h-[200px]"></textarea>
|
|
227
|
+
<button type="submit" :disabled="isRunning || !prompt.trim()"
|
|
228
|
+
class="bg-accent text-black px-5 py-2 rounded-lg text-sm font-bold cursor-pointer shrink-0 disabled:opacity-40 disabled:cursor-not-allowed font-mono">send</button>
|
|
229
|
+
</form>
|
|
230
|
+
<p class="max-w-3xl mx-auto text-muted text-xs mt-2">shift+enter for newline</p>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<!-- resizer -->
|
|
235
|
+
<div class="w-1 bg-border cursor-col-resize shrink-0 hover:bg-accent"
|
|
236
|
+
@mousedown="startResize($event)"></div>
|
|
237
|
+
|
|
238
|
+
<!-- right panel -->
|
|
239
|
+
<div class="flex-1 min-h-0 min-w-0 overflow-y-auto" id="panel-right">
|
|
240
|
+
<!-- app iframe -->
|
|
241
|
+
<iframe x-show="rightPanel !== 'data' && rightPanel !== 'home'" x-ref="rightIframe" class="w-full h-full border-none"
|
|
242
|
+
:src="token && rightPanel !== 'data' && rightPanel !== 'home' ? appUrl(rightPanel) : 'about:blank'"></iframe>
|
|
243
|
+
|
|
244
|
+
<!-- home panel -->
|
|
245
|
+
<div x-show="rightPanel === 'home'" class="p-8 max-w-2xl mx-auto">
|
|
246
|
+
<h2 class="text-accent font-bold text-lg mb-6">what you can do with upend</h2>
|
|
247
|
+
<div class="space-y-4">
|
|
248
|
+
<div class="bg-surface border border-border rounded-lg p-4">
|
|
249
|
+
<h3 class="text-sm font-bold text-gray-200 mb-2">talk to claude</h3>
|
|
250
|
+
<p class="text-xs text-muted leading-relaxed">Tell claude to create tables, add columns, build apps, write migrations — anything.</p>
|
|
251
|
+
<div class="mt-3 space-y-1">
|
|
252
|
+
<p class="text-xs text-muted italic cursor-pointer hover:text-accent" @click="prompt = 'create a products table with name, price, description, and an image_url'; $refs.chatInput.focus()">"create a products table with name, price, description, and image_url"</p>
|
|
253
|
+
<p class="text-xs text-muted italic cursor-pointer hover:text-accent" @click="prompt = 'build me a CRUD app for managing products at apps/products'; $refs.chatInput.focus()">"build me a CRUD app for managing products"</p>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
<div class="bg-surface border border-border rounded-lg p-4">
|
|
257
|
+
<h3 class="text-sm font-bold text-gray-200 mb-2">apps</h3>
|
|
258
|
+
<template x-for="app in apps" :key="app.name">
|
|
259
|
+
<div class="flex items-center justify-between text-xs mb-1">
|
|
260
|
+
<span class="text-gray-200 font-mono cursor-pointer hover:text-accent" x-text="app.name" @click="openApp(app.name)"></span>
|
|
261
|
+
<a :href="appUrl(app.name)" target="_blank" class="text-muted hover:text-accent cursor-pointer text-[10px]">↗</a>
|
|
262
|
+
</div>
|
|
263
|
+
</template>
|
|
264
|
+
<p x-show="apps.length === 0" class="text-muted text-xs">no apps yet — tell claude to build one</p>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
<!-- data panel -->
|
|
270
|
+
<div x-show="rightPanel === 'data'" class="flex flex-col h-full" x-init="$watch('rightPanel', v => { if (v === 'data') loadTables() })">
|
|
271
|
+
<!-- table list sidebar + detail -->
|
|
272
|
+
<div class="flex flex-1 min-h-0">
|
|
273
|
+
<!-- table list -->
|
|
274
|
+
<div class="w-48 border-r border-border overflow-y-auto shrink-0 no-scrollbar">
|
|
275
|
+
<div class="p-3 border-b border-border">
|
|
276
|
+
<span class="text-xs text-muted uppercase tracking-wide">tables</span>
|
|
277
|
+
</div>
|
|
278
|
+
<template x-for="t in tables" :key="t">
|
|
279
|
+
<div @click="selectTable(t)"
|
|
280
|
+
:class="selectedTable === t ? 'bg-accent-dim text-accent' : 'text-muted hover:bg-border hover:text-gray-200'"
|
|
281
|
+
class="px-3 py-2 text-xs font-mono cursor-pointer truncate" x-text="t"></div>
|
|
282
|
+
</template>
|
|
283
|
+
<p x-show="tables.length === 0" class="text-muted text-xs p-3">no tables</p>
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
<!-- table detail -->
|
|
287
|
+
<div class="flex-1 overflow-y-auto no-scrollbar">
|
|
288
|
+
<!-- no table selected -->
|
|
289
|
+
<div x-show="!selectedTable" class="p-8 text-center text-muted text-sm">
|
|
290
|
+
select a table to view its schema
|
|
291
|
+
</div>
|
|
292
|
+
|
|
293
|
+
<!-- table schema -->
|
|
294
|
+
<div x-show="selectedTable">
|
|
295
|
+
<div class="flex items-center justify-between px-4 py-3 border-b border-border">
|
|
296
|
+
<div class="flex items-center gap-3">
|
|
297
|
+
<span class="text-sm font-bold font-mono text-gray-200" x-text="selectedTable"></span>
|
|
298
|
+
<span class="text-xs text-muted" x-text="tableColumns.length + ' columns'"></span>
|
|
299
|
+
</div>
|
|
300
|
+
<div class="flex gap-2">
|
|
301
|
+
<!-- TODO: CSV upload (needs proper parsing)
|
|
302
|
+
<label class="bg-accent text-black text-xs px-3 py-1 rounded cursor-pointer font-mono">
|
|
303
|
+
upload CSV
|
|
304
|
+
<input type="file" accept=".csv" class="hidden" @change="uploadCSV($event)">
|
|
305
|
+
</label>
|
|
306
|
+
-->
|
|
307
|
+
<button @click="prompt = 'build an admin CRUD app for the ' + selectedTable + ' table at apps/' + selectedTable; $refs.chatInput.focus()"
|
|
308
|
+
class="text-xs text-muted border border-border px-3 py-1 rounded cursor-pointer hover:text-accent hover:border-accent font-mono">
|
|
309
|
+
generate CRUD app
|
|
310
|
+
</button>
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
|
|
314
|
+
<!-- columns -->
|
|
315
|
+
<table class="w-full text-xs">
|
|
316
|
+
<thead>
|
|
317
|
+
<tr class="border-b border-border text-muted">
|
|
318
|
+
<th class="text-left px-4 py-2 font-normal">column</th>
|
|
319
|
+
<th class="text-left px-4 py-2 font-normal">type</th>
|
|
320
|
+
<th class="text-left px-4 py-2 font-normal">nullable</th>
|
|
321
|
+
<th class="text-left px-4 py-2 font-normal">default</th>
|
|
322
|
+
</tr>
|
|
323
|
+
</thead>
|
|
324
|
+
<tbody>
|
|
325
|
+
<template x-for="col in tableColumns" :key="col.columnName">
|
|
326
|
+
<tr class="border-b border-border/50 hover:bg-surface">
|
|
327
|
+
<td class="px-4 py-2 font-mono text-gray-200" x-text="col.columnName"></td>
|
|
328
|
+
<td class="px-4 py-2 text-accent font-mono" x-text="col.dataType"></td>
|
|
329
|
+
<td class="px-4 py-2 text-muted" x-text="col.isNullable"></td>
|
|
330
|
+
<td class="px-4 py-2 text-muted font-mono truncate max-w-[200px]" x-text="col.columnDefault || '—'"></td>
|
|
331
|
+
</tr>
|
|
332
|
+
</template>
|
|
333
|
+
</tbody>
|
|
334
|
+
</table>
|
|
335
|
+
|
|
336
|
+
<!-- sample rows -->
|
|
337
|
+
<div x-show="sampleRows.length > 0" class="border-t border-border">
|
|
338
|
+
<div class="px-4 py-2 text-xs text-muted border-b border-border">
|
|
339
|
+
sample rows <span class="text-muted" x-text="'(' + sampleRows.length + ')'"></span>
|
|
340
|
+
</div>
|
|
341
|
+
<div class="overflow-x-auto">
|
|
342
|
+
<table class="w-full text-xs">
|
|
343
|
+
<thead>
|
|
344
|
+
<tr class="border-b border-border text-muted">
|
|
345
|
+
<template x-for="key in sampleRowKeys" :key="key">
|
|
346
|
+
<th class="text-left px-3 py-2 font-normal font-mono whitespace-nowrap" x-text="key"></th>
|
|
347
|
+
</template>
|
|
348
|
+
</tr>
|
|
349
|
+
</thead>
|
|
350
|
+
<tbody>
|
|
351
|
+
<template x-for="(row, i) in sampleRows" :key="i">
|
|
352
|
+
<tr class="border-b border-border/50 hover:bg-surface">
|
|
353
|
+
<template x-for="key in sampleRowKeys" :key="key">
|
|
354
|
+
<td class="px-3 py-2 text-gray-200 font-mono whitespace-nowrap max-w-[200px] truncate"
|
|
355
|
+
x-text="typeof row[key] === 'object' ? JSON.stringify(row[key]) : row[key]"></td>
|
|
356
|
+
</template>
|
|
357
|
+
</tr>
|
|
358
|
+
</template>
|
|
359
|
+
</tbody>
|
|
360
|
+
</table>
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
|
|
370
|
+
<script>
|
|
371
|
+
function dashboard() {
|
|
372
|
+
return {
|
|
373
|
+
// auth
|
|
374
|
+
token: localStorage.getItem('upend_token'),
|
|
375
|
+
userEmail: localStorage.getItem('upend_email') || '',
|
|
376
|
+
email: '', password: '', isSignup: false, authError: '',
|
|
377
|
+
|
|
378
|
+
// sessions
|
|
379
|
+
sessions: [],
|
|
380
|
+
currentSessionId: localStorage.getItem('upend_dashboard_session') ? Number(localStorage.getItem('upend_dashboard_session')) : null,
|
|
381
|
+
currentWorktree: null,
|
|
382
|
+
drawerOpen: false,
|
|
383
|
+
|
|
384
|
+
// modals
|
|
385
|
+
showSessionModal: false,
|
|
386
|
+
showCloseModal: false,
|
|
387
|
+
showPublishModal: false,
|
|
388
|
+
sessionTitle: '',
|
|
389
|
+
_pendingPrompt: '',
|
|
390
|
+
|
|
391
|
+
// chat
|
|
392
|
+
messages: [],
|
|
393
|
+
prompt: '',
|
|
394
|
+
isRunning: false,
|
|
395
|
+
status: 'ready',
|
|
396
|
+
statusText: '',
|
|
397
|
+
ws: null,
|
|
398
|
+
assistantBuffer: '',
|
|
399
|
+
|
|
400
|
+
// right panel
|
|
401
|
+
tables: [],
|
|
402
|
+
selectedTable: null,
|
|
403
|
+
tableColumns: [],
|
|
404
|
+
sampleRows: [],
|
|
405
|
+
sampleRowKeys: [],
|
|
406
|
+
rightPanel: 'home',
|
|
407
|
+
apps: [],
|
|
408
|
+
appsOpen: false,
|
|
409
|
+
|
|
410
|
+
// resizer state
|
|
411
|
+
_resizing: false,
|
|
412
|
+
|
|
413
|
+
get sessionLabel() {
|
|
414
|
+
if (!this.currentSessionId) return 'no session';
|
|
415
|
+
const s = this.sessions.find(s => s.id == this.currentSessionId);
|
|
416
|
+
return s?.title || s?.prompt?.slice(0, 40) || `#${this.currentSessionId}`;
|
|
417
|
+
},
|
|
418
|
+
|
|
419
|
+
// ---------- init ----------
|
|
420
|
+
|
|
421
|
+
init() {
|
|
422
|
+
// init icons + re-render on state changes
|
|
423
|
+
this.$nextTick(() => { if (window.lucide) lucide.createIcons(); });
|
|
424
|
+
this.$watch('currentSessionId', () => this.$nextTick(() => lucide?.createIcons()));
|
|
425
|
+
this.$watch('showCloseModal', () => this.$nextTick(() => lucide?.createIcons()));
|
|
426
|
+
this.$watch('showPublishModal', () => this.$nextTick(() => lucide?.createIcons()));
|
|
427
|
+
|
|
428
|
+
if (this.token) {
|
|
429
|
+
this.loadApps();
|
|
430
|
+
this.loadTables();
|
|
431
|
+
if (this.currentSessionId) {
|
|
432
|
+
this.openSession(this.currentSessionId);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
document.addEventListener('mousemove', (e) => this.onResize(e));
|
|
436
|
+
document.addEventListener('mouseup', () => this.stopResize());
|
|
437
|
+
},
|
|
438
|
+
|
|
439
|
+
// ---------- auth ----------
|
|
440
|
+
|
|
441
|
+
async login() {
|
|
442
|
+
this.authError = '';
|
|
443
|
+
try {
|
|
444
|
+
const res = await fetch('/api' + (this.isSignup ? '/auth/signup' : '/auth/login'), {
|
|
445
|
+
method: 'POST',
|
|
446
|
+
headers: { 'Content-Type': 'application/json' },
|
|
447
|
+
body: JSON.stringify({ email: this.email, password: this.password }),
|
|
448
|
+
});
|
|
449
|
+
const data = await res.json();
|
|
450
|
+
if (!res.ok) throw new Error(data.error);
|
|
451
|
+
this.token = data.token;
|
|
452
|
+
this.userEmail = data.user.email;
|
|
453
|
+
localStorage.setItem('upend_token', this.token);
|
|
454
|
+
localStorage.setItem('upend_email', this.userEmail);
|
|
455
|
+
this.loadApps();
|
|
456
|
+
} catch (err) {
|
|
457
|
+
this.authError = err.message;
|
|
458
|
+
}
|
|
459
|
+
},
|
|
460
|
+
|
|
461
|
+
logout() {
|
|
462
|
+
this.token = null;
|
|
463
|
+
this.userEmail = '';
|
|
464
|
+
localStorage.removeItem('upend_token');
|
|
465
|
+
localStorage.removeItem('upend_email');
|
|
466
|
+
localStorage.removeItem('upend_dashboard_session');
|
|
467
|
+
if (this.ws) { this.ws.close(); this.ws = null; }
|
|
468
|
+
this.messages = [];
|
|
469
|
+
this.currentSessionId = null;
|
|
470
|
+
},
|
|
471
|
+
|
|
472
|
+
authHeaders() {
|
|
473
|
+
return { 'Authorization': `Bearer ${this.token}`, 'Content-Type': 'application/json' };
|
|
474
|
+
},
|
|
475
|
+
|
|
476
|
+
async authFetch(url, opts = {}) {
|
|
477
|
+
opts.headers = { ...this.authHeaders(), ...opts.headers };
|
|
478
|
+
const res = await fetch(url, opts);
|
|
479
|
+
if (res.status === 401) {
|
|
480
|
+
console.warn('[auth] token expired, logging out');
|
|
481
|
+
this.logout();
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
return res;
|
|
485
|
+
},
|
|
486
|
+
|
|
487
|
+
// ---------- sessions ----------
|
|
488
|
+
|
|
489
|
+
async loadSessions() {
|
|
490
|
+
try {
|
|
491
|
+
const res = await this.authFetch('/claude/sessions');
|
|
492
|
+
if (res.ok) this.sessions = await res.json();
|
|
493
|
+
} catch (err) {
|
|
494
|
+
console.error('[sessions]', err);
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
|
|
498
|
+
async openSession(id) {
|
|
499
|
+
try {
|
|
500
|
+
const res = await this.authFetch(`/claude/sessions/${id}`);
|
|
501
|
+
if (!res.ok) throw new Error('session not found');
|
|
502
|
+
const session = await res.json();
|
|
503
|
+
|
|
504
|
+
this.currentSessionId = Number(id);
|
|
505
|
+
const ctx = typeof session.context === 'string' ? JSON.parse(session.context) : session.context;
|
|
506
|
+
this.currentWorktree = ctx?.worktree || null;
|
|
507
|
+
localStorage.setItem('upend_dashboard_session', id);
|
|
508
|
+
this.drawerOpen = false;
|
|
509
|
+
|
|
510
|
+
// render messages
|
|
511
|
+
this.messages = [];
|
|
512
|
+
if (session.messages) {
|
|
513
|
+
for (const msg of session.messages) {
|
|
514
|
+
this.messages.push({ role: 'user', content: msg.content });
|
|
515
|
+
if (msg.result) {
|
|
516
|
+
this.messages.push({
|
|
517
|
+
role: msg.status === 'error' ? 'system' : 'assistant',
|
|
518
|
+
content: msg.result,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
this.setStatus(session.status === 'active' ? 'active' : session.status, session.status);
|
|
525
|
+
this.scrollToBottom();
|
|
526
|
+
|
|
527
|
+
// connect websocket if active
|
|
528
|
+
if (session.status === 'active') {
|
|
529
|
+
this.connectWS(id);
|
|
530
|
+
// check if last message is already complete (not waiting on claude)
|
|
531
|
+
const lastMsg = session.messages?.[session.messages.length - 1];
|
|
532
|
+
if (!lastMsg || lastMsg.status === 'complete' || lastMsg.status === 'error') {
|
|
533
|
+
this.isRunning = false;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
} catch (err) {
|
|
537
|
+
console.error('[openSession]', err);
|
|
538
|
+
this.messages.push({ role: 'system', content: `could not load session ${id}: ${err.message}` });
|
|
539
|
+
this.currentSessionId = null;
|
|
540
|
+
localStorage.removeItem('upend_dashboard_session');
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
|
|
544
|
+
newSession() {
|
|
545
|
+
this.currentSessionId = null;
|
|
546
|
+
this.currentWorktree = null;
|
|
547
|
+
localStorage.removeItem('upend_dashboard_session');
|
|
548
|
+
this.messages = [];
|
|
549
|
+
this.drawerOpen = false;
|
|
550
|
+
if (this.ws) { this.ws.close(); this.ws = null; }
|
|
551
|
+
this.setStatus('ready', 'ready');
|
|
552
|
+
this.$nextTick(() => this.$refs.chatInput?.focus());
|
|
553
|
+
},
|
|
554
|
+
|
|
555
|
+
async closeSession() {
|
|
556
|
+
if (!this.currentSessionId) return;
|
|
557
|
+
try {
|
|
558
|
+
await this.authFetch(`/claude/sessions/${this.currentSessionId}/end`, { method: 'POST' });
|
|
559
|
+
} catch {}
|
|
560
|
+
this.messages.push({ role: 'system', content: 'session closed. changes preserved but not published.' });
|
|
561
|
+
this.setStatus('ready', 'closed');
|
|
562
|
+
this.currentSessionId = null;
|
|
563
|
+
this.currentWorktree = null;
|
|
564
|
+
localStorage.removeItem('upend_dashboard_session');
|
|
565
|
+
if (this.ws) { this.ws.close(); this.ws = null; }
|
|
566
|
+
this.loadSessions();
|
|
567
|
+
this.loadApps();
|
|
568
|
+
},
|
|
569
|
+
|
|
570
|
+
async commitSession() {
|
|
571
|
+
if (!this.currentSessionId) return;
|
|
572
|
+
|
|
573
|
+
// check mergability first
|
|
574
|
+
this.messages.push({ role: 'system', content: 'checking merge...' });
|
|
575
|
+
try {
|
|
576
|
+
const checkRes = await this.authFetch(`/claude/sessions/${this.currentSessionId}/mergeable`);
|
|
577
|
+
const check = await checkRes.json();
|
|
578
|
+
|
|
579
|
+
if (!check.mergeable) {
|
|
580
|
+
this.messages.push({ role: 'system', content: `merge has conflicts:\n${check.conflicts.join('\n')}\n\ntell claude to resolve them, then try again.` });
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
} catch {}
|
|
584
|
+
|
|
585
|
+
this.showPublishModal = true;
|
|
586
|
+
return;
|
|
587
|
+
},
|
|
588
|
+
|
|
589
|
+
async doPublish() {
|
|
590
|
+
this.showPublishModal = false;
|
|
591
|
+
|
|
592
|
+
try {
|
|
593
|
+
const res = await this.authFetch(`/claude/sessions/${this.currentSessionId}/commit`, { method: 'POST' });
|
|
594
|
+
const data = await res.json();
|
|
595
|
+
|
|
596
|
+
if (!res.ok) {
|
|
597
|
+
this.messages.push({ role: 'system', content: `commit failed: ${data.message || data.error}\n\ntell claude to resolve any conflicts.` });
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
this.messages.push({ role: 'system', content: 'published to live. services restarting.' });
|
|
602
|
+
this.setStatus('done', 'committed');
|
|
603
|
+
this.currentSessionId = null;
|
|
604
|
+
localStorage.removeItem('upend_dashboard_session');
|
|
605
|
+
if (this.ws) { this.ws.close(); this.ws = null; }
|
|
606
|
+
this.loadSessions();
|
|
607
|
+
this.refreshRightPanel();
|
|
608
|
+
} catch (err) {
|
|
609
|
+
this.messages.push({ role: 'system', content: `commit failed: ${err.message}` });
|
|
610
|
+
}
|
|
611
|
+
},
|
|
612
|
+
|
|
613
|
+
// ---------- chat ----------
|
|
614
|
+
|
|
615
|
+
async sendPrompt() {
|
|
616
|
+
const text = this.prompt.trim();
|
|
617
|
+
if (!text || this.isRunning) return;
|
|
618
|
+
this.prompt = '';
|
|
619
|
+
this.messages.push({ role: 'user', content: text });
|
|
620
|
+
this.scrollToBottom();
|
|
621
|
+
|
|
622
|
+
if (!this.currentSessionId) {
|
|
623
|
+
this._pendingPrompt = text;
|
|
624
|
+
this.showSessionModal = true;
|
|
625
|
+
this.sessionTitle = '';
|
|
626
|
+
this.$nextTick(() => this.$refs.sessionTitleInput?.focus());
|
|
627
|
+
return;
|
|
628
|
+
} else {
|
|
629
|
+
await this.sendMessage(text);
|
|
630
|
+
}
|
|
631
|
+
},
|
|
632
|
+
|
|
633
|
+
async createSession(text, title) {
|
|
634
|
+
this.setStatus('running', 'creating session...');
|
|
635
|
+
try {
|
|
636
|
+
const res = await this.authFetch('/claude/sessions', {
|
|
637
|
+
method: 'POST',
|
|
638
|
+
body: JSON.stringify({ prompt: text, force: true, title }),
|
|
639
|
+
});
|
|
640
|
+
const data = await res.json();
|
|
641
|
+
if (!res.ok) throw new Error(data.error || data.message);
|
|
642
|
+
|
|
643
|
+
this.currentSessionId = Number(data.session.id);
|
|
644
|
+
this.currentWorktree = data.worktree;
|
|
645
|
+
localStorage.setItem('upend_dashboard_session', this.currentSessionId);
|
|
646
|
+
this.connectWS(this.currentSessionId);
|
|
647
|
+
this.loadSessions();
|
|
648
|
+
this.loadApps();
|
|
649
|
+
} catch (err) {
|
|
650
|
+
this.messages.push({ role: 'system', content: `failed to create session: ${err.message}` });
|
|
651
|
+
this.setStatus('error', 'failed');
|
|
652
|
+
}
|
|
653
|
+
},
|
|
654
|
+
|
|
655
|
+
async sendMessage(text) {
|
|
656
|
+
this.setStatus('running', 'sending...');
|
|
657
|
+
try {
|
|
658
|
+
const res = await this.authFetch(`/claude/sessions/${this.currentSessionId}/messages`, {
|
|
659
|
+
method: 'POST',
|
|
660
|
+
body: JSON.stringify({ prompt: text }),
|
|
661
|
+
});
|
|
662
|
+
const data = await res.json();
|
|
663
|
+
if (!res.ok) throw new Error(data.error);
|
|
664
|
+
} catch (err) {
|
|
665
|
+
this.messages.push({ role: 'system', content: `failed: ${err.message}\n\ntry again or start a new session.` });
|
|
666
|
+
this.setStatus('error', 'failed');
|
|
667
|
+
}
|
|
668
|
+
},
|
|
669
|
+
|
|
670
|
+
// ---------- websocket ----------
|
|
671
|
+
|
|
672
|
+
connectWS(sessionId) {
|
|
673
|
+
if (this.ws) { this.ws.close(); this.ws = null; }
|
|
674
|
+
|
|
675
|
+
const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
676
|
+
const wsUrl = `${wsProto}//${location.host}/claude/ws/${sessionId}?token=${encodeURIComponent(this.token)}`;
|
|
677
|
+
console.log('[ws] connecting:', wsUrl);
|
|
678
|
+
this.ws = new WebSocket(wsUrl);
|
|
679
|
+
this.isRunning = true;
|
|
680
|
+
this.assistantBuffer = '';
|
|
681
|
+
|
|
682
|
+
this.ws.onopen = () => {
|
|
683
|
+
console.log('[ws] connected');
|
|
684
|
+
this.setStatus('running', 'connected');
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
this.ws.onmessage = (e) => {
|
|
688
|
+
const data = JSON.parse(e.data);
|
|
689
|
+
|
|
690
|
+
if (data.type === 'text') {
|
|
691
|
+
this.assistantBuffer += data.text;
|
|
692
|
+
// update or create the assistant message
|
|
693
|
+
const last = this.messages[this.messages.length - 1];
|
|
694
|
+
if (last?.role === 'assistant' && last._streaming) {
|
|
695
|
+
last.content = this.assistantBuffer;
|
|
696
|
+
} else {
|
|
697
|
+
this.messages.push({ role: 'assistant', content: this.assistantBuffer, _streaming: true });
|
|
698
|
+
}
|
|
699
|
+
this.scrollToBottom();
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
else if (data.type === 'tool_use') {
|
|
703
|
+
this.messages.push({ role: 'tool_use', name: data.name, content: data.input });
|
|
704
|
+
this.scrollToBottom();
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
else if (data.type === 'status') {
|
|
708
|
+
if (data.status === 'complete') {
|
|
709
|
+
this.assistantBuffer = '';
|
|
710
|
+
this.isRunning = false;
|
|
711
|
+
this.setStatus('done', 'done');
|
|
712
|
+
// mark streaming message as final
|
|
713
|
+
const last = this.messages[this.messages.length - 1];
|
|
714
|
+
if (last?._streaming) delete last._streaming;
|
|
715
|
+
// refresh right panel
|
|
716
|
+
this.refreshRightPanel();
|
|
717
|
+
this.loadApps();
|
|
718
|
+
} else if (data.status === 'error') {
|
|
719
|
+
this.assistantBuffer = '';
|
|
720
|
+
this.isRunning = false;
|
|
721
|
+
this.setStatus('error', 'error');
|
|
722
|
+
this.messages.push({ role: 'system', content: `claude error: ${data.error || 'unknown'}\n\nsend another message to retry, or start a new session.` });
|
|
723
|
+
this.scrollToBottom();
|
|
724
|
+
} else if (data.status === 'running') {
|
|
725
|
+
this.assistantBuffer = '';
|
|
726
|
+
this.isRunning = true;
|
|
727
|
+
this.setStatus('running', 'claude is working...');
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
this.ws.onclose = () => {
|
|
733
|
+
console.log('[ws] disconnected');
|
|
734
|
+
if (this.isRunning) this.setStatus('error', 'disconnected');
|
|
735
|
+
};
|
|
736
|
+
},
|
|
737
|
+
|
|
738
|
+
// ---------- right panel ----------
|
|
739
|
+
|
|
740
|
+
async loadTables() {
|
|
741
|
+
try {
|
|
742
|
+
const res = await this.authFetch('/api/tables');
|
|
743
|
+
if (res.ok) this.tables = (await res.json()).map(t => t.name);
|
|
744
|
+
} catch {}
|
|
745
|
+
},
|
|
746
|
+
|
|
747
|
+
async selectTable(name) {
|
|
748
|
+
this.selectedTable = name;
|
|
749
|
+
this.tableColumns = [];
|
|
750
|
+
this.sampleRows = [];
|
|
751
|
+
this.sampleRowKeys = [];
|
|
752
|
+
try {
|
|
753
|
+
// fetch columns
|
|
754
|
+
const colRes = await this.authFetch(`/api/tables/${name}`);
|
|
755
|
+
if (colRes.ok) this.tableColumns = await colRes.json();
|
|
756
|
+
|
|
757
|
+
// fetch sample rows via Neon Data API
|
|
758
|
+
const dataRes = await this.authFetch(`/api/data/${name}?limit=5&order=id.desc`);
|
|
759
|
+
if (dataRes.ok) {
|
|
760
|
+
const rows = await dataRes.json();
|
|
761
|
+
this.sampleRows = rows;
|
|
762
|
+
this.sampleRowKeys = rows.length > 0 ? Object.keys(rows[0]) : [];
|
|
763
|
+
}
|
|
764
|
+
} catch (err) {
|
|
765
|
+
console.error('[selectTable]', err);
|
|
766
|
+
}
|
|
767
|
+
},
|
|
768
|
+
|
|
769
|
+
async uploadCSV(event) {
|
|
770
|
+
const file = event.target.files[0];
|
|
771
|
+
if (!file || !this.selectedTable) return;
|
|
772
|
+
const text = await file.text();
|
|
773
|
+
const lines = text.trim().split('\n');
|
|
774
|
+
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
|
|
775
|
+
|
|
776
|
+
const rows = [];
|
|
777
|
+
for (let i = 1; i < lines.length; i++) {
|
|
778
|
+
const values = lines[i].split(',').map(v => v.trim().replace(/"/g, ''));
|
|
779
|
+
const row = {};
|
|
780
|
+
headers.forEach((h, j) => { row[h] = values[j] || null; });
|
|
781
|
+
rows.push(row);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// insert via Neon Data API
|
|
785
|
+
try {
|
|
786
|
+
const res = await this.authFetch(`/api/data/${this.selectedTable}`, {
|
|
787
|
+
method: 'POST',
|
|
788
|
+
headers: { 'Prefer': 'return=representation' },
|
|
789
|
+
body: JSON.stringify(rows),
|
|
790
|
+
});
|
|
791
|
+
if (res.ok) {
|
|
792
|
+
const inserted = await res.json();
|
|
793
|
+
this.messages.push({ role: 'system', content: `uploaded ${inserted.length} rows to ${this.selectedTable}` });
|
|
794
|
+
this.selectTable(this.selectedTable); // refresh
|
|
795
|
+
} else {
|
|
796
|
+
const err = await res.json();
|
|
797
|
+
this.messages.push({ role: 'system', content: `CSV upload failed: ${err.message || JSON.stringify(err)}` });
|
|
798
|
+
}
|
|
799
|
+
} catch (err) {
|
|
800
|
+
this.messages.push({ role: 'system', content: `CSV upload failed: ${err.message}` });
|
|
801
|
+
}
|
|
802
|
+
event.target.value = ''; // reset file input
|
|
803
|
+
},
|
|
804
|
+
|
|
805
|
+
async loadApps() {
|
|
806
|
+
try {
|
|
807
|
+
const url = this.currentWorktree
|
|
808
|
+
? `/claude/apps?session=${this.currentWorktree}`
|
|
809
|
+
: '/claude/apps';
|
|
810
|
+
const res = await this.authFetch(url);
|
|
811
|
+
if (res?.ok) this.apps = await res.json();
|
|
812
|
+
} catch {}
|
|
813
|
+
},
|
|
814
|
+
|
|
815
|
+
openApp(name) {
|
|
816
|
+
this.rightPanel = name;
|
|
817
|
+
this.appsOpen = false;
|
|
818
|
+
},
|
|
819
|
+
|
|
820
|
+
appUrl(name) {
|
|
821
|
+
if (this.currentWorktree) {
|
|
822
|
+
return `/claude/preview/${this.currentWorktree}/apps/${name}/`;
|
|
823
|
+
}
|
|
824
|
+
return `/apps/${name}/`;
|
|
825
|
+
},
|
|
826
|
+
|
|
827
|
+
createNewApp() {
|
|
828
|
+
this.appsOpen = false;
|
|
829
|
+
const name = window.prompt('app name (lowercase, no spaces):');
|
|
830
|
+
if (!name) return;
|
|
831
|
+
const desc = window.prompt('what should this app do?');
|
|
832
|
+
if (!desc) return;
|
|
833
|
+
this.prompt = `create an app called "${name}" in apps/${name}/. ${desc}`;
|
|
834
|
+
this.sendPrompt();
|
|
835
|
+
},
|
|
836
|
+
|
|
837
|
+
refreshRightPanel() {
|
|
838
|
+
// refresh app iframe if showing
|
|
839
|
+
const iframe = this.$refs.rightIframe;
|
|
840
|
+
if (iframe?.src && iframe.src !== 'about:blank') iframe.src = iframe.src;
|
|
841
|
+
// refresh data panel if showing
|
|
842
|
+
this.loadTables();
|
|
843
|
+
if (this.selectedTable) this.selectTable(this.selectedTable);
|
|
844
|
+
},
|
|
845
|
+
|
|
846
|
+
// ---------- resizer ----------
|
|
847
|
+
|
|
848
|
+
startResize(e) {
|
|
849
|
+
this._resizing = true;
|
|
850
|
+
document.body.style.cursor = 'col-resize';
|
|
851
|
+
document.querySelectorAll('iframe').forEach(f => f.style.pointerEvents = 'none');
|
|
852
|
+
},
|
|
853
|
+
|
|
854
|
+
onResize(e) {
|
|
855
|
+
if (!this._resizing) return;
|
|
856
|
+
const panels = document.querySelector('.flex-1.flex.min-h-0');
|
|
857
|
+
if (!panels) return;
|
|
858
|
+
const rect = panels.getBoundingClientRect();
|
|
859
|
+
const pct = Math.max(20, Math.min(80, ((e.clientX - rect.left) / rect.width) * 100));
|
|
860
|
+
document.getElementById('panel-left').style.flex = `0 0 ${pct}%`;
|
|
861
|
+
document.getElementById('panel-right').style.flex = `0 0 ${100 - pct}%`;
|
|
862
|
+
},
|
|
863
|
+
|
|
864
|
+
stopResize() {
|
|
865
|
+
if (!this._resizing) return;
|
|
866
|
+
this._resizing = false;
|
|
867
|
+
document.body.style.cursor = '';
|
|
868
|
+
document.querySelectorAll('iframe').forEach(f => f.style.pointerEvents = '');
|
|
869
|
+
},
|
|
870
|
+
|
|
871
|
+
// ---------- helpers ----------
|
|
872
|
+
|
|
873
|
+
setStatus(s, text) {
|
|
874
|
+
this.status = s;
|
|
875
|
+
this.statusText = text;
|
|
876
|
+
},
|
|
877
|
+
|
|
878
|
+
scrollToBottom() {
|
|
879
|
+
this.$nextTick(() => {
|
|
880
|
+
const el = this.$refs.messages;
|
|
881
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
882
|
+
});
|
|
883
|
+
},
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
</script>
|
|
887
|
+
</body>
|
|
888
|
+
</html>
|