@korajs/cli 0.3.2 → 0.4.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.
Files changed (50) hide show
  1. package/dist/bin.cjs +385 -288
  2. package/dist/bin.cjs.map +1 -1
  3. package/dist/bin.js +25 -11
  4. package/dist/bin.js.map +1 -1
  5. package/dist/{chunk-KTSRAPSE.js → chunk-CMSX76KM.js} +108 -65
  6. package/dist/chunk-CMSX76KM.js.map +1 -0
  7. package/dist/{chunk-ZGYRDYXS.js → chunk-MVP5PDT4.js} +22 -4
  8. package/dist/chunk-MVP5PDT4.js.map +1 -0
  9. package/dist/{chunk-E7OCVRYL.js → chunk-YGVO4POI.js} +242 -215
  10. package/dist/chunk-YGVO4POI.js.map +1 -0
  11. package/dist/create.cjs +122 -66
  12. package/dist/create.cjs.map +1 -1
  13. package/dist/create.js +2 -2
  14. package/dist/index.cjs +348 -278
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +31 -26
  17. package/dist/index.d.ts +31 -26
  18. package/dist/index.js +2 -2
  19. package/package.json +2 -2
  20. package/templates/tauri-react/.env.example +7 -0
  21. package/templates/tauri-react/.github/workflows/release-desktop.yml +107 -0
  22. package/templates/tauri-react/README.md.hbs +148 -0
  23. package/templates/tauri-react/dev.ts +37 -0
  24. package/templates/tauri-react/index.html.hbs +16 -0
  25. package/templates/tauri-react/kora.config.ts +17 -0
  26. package/templates/tauri-react/package.json.hbs +44 -0
  27. package/templates/tauri-react/server.ts +23 -0
  28. package/templates/tauri-react/src/App.tsx +578 -0
  29. package/templates/tauri-react/src/AppShell.tsx +116 -0
  30. package/templates/tauri-react/src/SetupScreen.tsx +239 -0
  31. package/templates/tauri-react/src/main.tsx +9 -0
  32. package/templates/tauri-react/src/schema.ts +15 -0
  33. package/templates/tauri-react/src/sync-config.ts +103 -0
  34. package/templates/tauri-react/src/updater.ts +38 -0
  35. package/templates/tauri-react/src-tauri/Cargo.toml +19 -0
  36. package/templates/tauri-react/src-tauri/build.rs +3 -0
  37. package/templates/tauri-react/src-tauri/capabilities/default.json +12 -0
  38. package/templates/tauri-react/src-tauri/icons/128x128.png +0 -0
  39. package/templates/tauri-react/src-tauri/icons/128x128@2x.png +0 -0
  40. package/templates/tauri-react/src-tauri/icons/32x32.png +0 -0
  41. package/templates/tauri-react/src-tauri/icons/icon.icns +0 -0
  42. package/templates/tauri-react/src-tauri/icons/icon.ico +0 -0
  43. package/templates/tauri-react/src-tauri/src/lib.rs +8 -0
  44. package/templates/tauri-react/src-tauri/src/main.rs +6 -0
  45. package/templates/tauri-react/src-tauri/tauri.conf.json +44 -0
  46. package/templates/tauri-react/tsconfig.json +15 -0
  47. package/templates/tauri-react/vite.config.ts +15 -0
  48. package/dist/chunk-E7OCVRYL.js.map +0 -1
  49. package/dist/chunk-KTSRAPSE.js.map +0 -1
  50. package/dist/chunk-ZGYRDYXS.js.map +0 -1
@@ -0,0 +1,578 @@
1
+ import { useState } from 'react'
2
+ import { useQuery, useMutation, useCollection } from '@korajs/react'
3
+ import {
4
+ CheckCircle2,
5
+ Circle,
6
+ Monitor,
7
+ Loader2,
8
+ Plus,
9
+ Trash2,
10
+ Wifi,
11
+ WifiOff,
12
+ Settings,
13
+ AlertTriangle,
14
+ } from 'lucide-react'
15
+ import { testConnection } from './sync-config'
16
+
17
+ type Filter = 'all' | 'active' | 'completed'
18
+
19
+ interface AppProps {
20
+ syncUrl: string | null
21
+ onChangeServer: (newUrl: string | null) => void
22
+ onFactoryReset: () => void
23
+ }
24
+
25
+ export function App({ syncUrl, onChangeServer, onFactoryReset }: AppProps) {
26
+ const todos = useCollection('todos')
27
+ const allTodos = useQuery(todos.where({}).orderBy('createdAt', 'desc'))
28
+ const { mutate: addTodo, isLoading: isAdding } = useMutation(
29
+ (data: { title: string }) => todos.insert(data)
30
+ )
31
+ const { mutate: toggleTodo } = useMutation(
32
+ (id: string, data: { completed: boolean }) => todos.update(id, data)
33
+ )
34
+ const { mutate: deleteTodo } = useMutation(
35
+ (id: string) => todos.delete(id)
36
+ )
37
+
38
+ const [filter, setFilter] = useState<Filter>('all')
39
+ const [input, setInput] = useState('')
40
+ const [showSettings, setShowSettings] = useState(false)
41
+
42
+ const activeTodos = allTodos.filter((t) => !t.completed)
43
+ const completedTodos = allTodos.filter((t) => !!t.completed)
44
+ const filteredTodos =
45
+ filter === 'active' ? activeTodos : filter === 'completed' ? completedTodos : allTodos
46
+
47
+ const handleSubmit = (e: React.FormEvent) => {
48
+ e.preventDefault()
49
+ const title = input.trim()
50
+ if (title) {
51
+ addTodo({ title })
52
+ setInput('')
53
+ }
54
+ }
55
+
56
+ const clearCompleted = () => {
57
+ for (const todo of completedTodos) {
58
+ deleteTodo(todo.id)
59
+ }
60
+ }
61
+
62
+ return (
63
+ <div style={{ minHeight: '100vh', background: '#0a0a0a', color: '#f3f4f6' }}>
64
+ <div style={{ maxWidth: '640px', margin: '0 auto', padding: '48px 16px' }}>
65
+ {/* Header */}
66
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '32px' }}>
67
+ <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
68
+ <Monitor style={{ width: '32px', height: '32px', color: '#818cf8' }} />
69
+ <h1 style={{ fontSize: '24px', fontWeight: 'bold' }}>Desktop Tasks</h1>
70
+ </div>
71
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
72
+ <span style={{
73
+ display: 'flex',
74
+ alignItems: 'center',
75
+ gap: '6px',
76
+ fontSize: '12px',
77
+ color: syncUrl ? '#34d399' : '#6b7280',
78
+ background: '#1f2937',
79
+ padding: '4px 12px',
80
+ borderRadius: '9999px',
81
+ }}>
82
+ {syncUrl ? (
83
+ <Wifi style={{ width: '12px', height: '12px' }} />
84
+ ) : (
85
+ <WifiOff style={{ width: '12px', height: '12px' }} />
86
+ )}
87
+ {syncUrl ? 'Syncing' : 'Local only'}
88
+ </span>
89
+ <button
90
+ onClick={() => setShowSettings(!showSettings)}
91
+ style={{
92
+ background: showSettings ? '#374151' : '#1f2937',
93
+ border: 'none',
94
+ borderRadius: '9999px',
95
+ padding: '6px',
96
+ cursor: 'pointer',
97
+ color: showSettings ? '#f3f4f6' : '#6b7280',
98
+ display: 'flex',
99
+ }}
100
+ >
101
+ <Settings style={{ width: '14px', height: '14px' }} />
102
+ </button>
103
+ </div>
104
+ </div>
105
+
106
+ {/* Settings panel */}
107
+ {showSettings && (
108
+ <SettingsPanel
109
+ syncUrl={syncUrl}
110
+ onChangeServer={onChangeServer}
111
+ onFactoryReset={onFactoryReset}
112
+ onClose={() => setShowSettings(false)}
113
+ />
114
+ )}
115
+
116
+ {/* Stats */}
117
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '16px', marginBottom: '32px' }}>
118
+ <StatCard label="Total" value={allTodos.length} color="#d1d5db" />
119
+ <StatCard label="Remaining" value={activeTodos.length} color="#fbbf24" />
120
+ <StatCard label="Done" value={completedTodos.length} color="#34d399" />
121
+ </div>
122
+
123
+ {/* Add form */}
124
+ <form onSubmit={handleSubmit} style={{ display: 'flex', gap: '12px', marginBottom: '32px' }}>
125
+ <input
126
+ type="text"
127
+ value={input}
128
+ onChange={(e) => setInput(e.target.value)}
129
+ placeholder="What needs to be done?"
130
+ style={{
131
+ flex: 1,
132
+ borderRadius: '8px',
133
+ border: '1px solid #374151',
134
+ background: '#111827',
135
+ padding: '12px 16px',
136
+ color: '#f3f4f6',
137
+ outline: 'none',
138
+ fontSize: '14px',
139
+ }}
140
+ />
141
+ <button
142
+ type="submit"
143
+ disabled={isAdding || !input.trim()}
144
+ style={{
145
+ display: 'flex',
146
+ alignItems: 'center',
147
+ gap: '8px',
148
+ borderRadius: '8px',
149
+ background: '#4f46e5',
150
+ padding: '12px 20px',
151
+ fontWeight: '500',
152
+ color: 'white',
153
+ border: 'none',
154
+ cursor: 'pointer',
155
+ opacity: isAdding || !input.trim() ? 0.5 : 1,
156
+ fontSize: '14px',
157
+ }}
158
+ >
159
+ {isAdding ? <Loader2 style={{ width: '16px', height: '16px', animation: 'spin 1s linear infinite' }} /> : <Plus style={{ width: '16px', height: '16px' }} />}
160
+ Add
161
+ </button>
162
+ </form>
163
+
164
+ {/* Filter tabs */}
165
+ <div style={{ display: 'flex', gap: '8px', marginBottom: '24px' }}>
166
+ {(['all', 'active', 'completed'] as const).map((f) => {
167
+ const count = f === 'all' ? allTodos.length : f === 'active' ? activeTodos.length : completedTodos.length
168
+ const isActive = filter === f
169
+ return (
170
+ <button
171
+ key={f}
172
+ onClick={() => setFilter(f)}
173
+ style={{
174
+ display: 'flex',
175
+ alignItems: 'center',
176
+ gap: '8px',
177
+ borderRadius: '8px',
178
+ padding: '8px 16px',
179
+ fontSize: '14px',
180
+ fontWeight: '500',
181
+ border: 'none',
182
+ cursor: 'pointer',
183
+ background: isActive ? '#4f46e5' : '#1f2937',
184
+ color: isActive ? 'white' : '#9ca3af',
185
+ }}
186
+ >
187
+ {f.charAt(0).toUpperCase() + f.slice(1)}
188
+ <span style={{
189
+ borderRadius: '9999px',
190
+ padding: '2px 8px',
191
+ fontSize: '12px',
192
+ background: isActive ? '#4338ca' : '#374151',
193
+ color: isActive ? 'white' : '#9ca3af',
194
+ }}>
195
+ {count}
196
+ </span>
197
+ </button>
198
+ )
199
+ })}
200
+ </div>
201
+
202
+ {/* Todo list */}
203
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
204
+ {filteredTodos.length === 0 ? (
205
+ <div style={{ borderRadius: '8px', border: '1px dashed #1f2937', padding: '48px 0', textAlign: 'center', color: '#4b5563' }}>
206
+ {filter === 'all' ? 'No tasks yet. Add one above!' : filter === 'active' ? 'All caught up!' : 'No completed tasks yet.'}
207
+ </div>
208
+ ) : (
209
+ filteredTodos.map((todo) => (
210
+ <div
211
+ key={todo.id}
212
+ style={{
213
+ display: 'flex',
214
+ alignItems: 'center',
215
+ gap: '12px',
216
+ borderRadius: '8px',
217
+ border: '1px solid #1f2937',
218
+ background: '#111827',
219
+ padding: '12px 16px',
220
+ }}
221
+ >
222
+ <button
223
+ onClick={() => toggleTodo(todo.id, { completed: !todo.completed })}
224
+ style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}
225
+ >
226
+ {todo.completed ? (
227
+ <CheckCircle2 style={{ width: '20px', height: '20px', color: '#34d399' }} />
228
+ ) : (
229
+ <Circle style={{ width: '20px', height: '20px', color: '#6b7280' }} />
230
+ )}
231
+ </button>
232
+ <span style={{
233
+ flex: 1,
234
+ color: todo.completed ? '#6b7280' : '#f3f4f6',
235
+ textDecoration: todo.completed ? 'line-through' : 'none',
236
+ }}>
237
+ {String(todo.title)}
238
+ </span>
239
+ {todo.createdAt && (
240
+ <span style={{ fontSize: '12px', color: '#374151' }}>
241
+ {formatTime(Number(todo.createdAt))}
242
+ </span>
243
+ )}
244
+ <button
245
+ onClick={() => deleteTodo(todo.id)}
246
+ style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: '#4b5563' }}
247
+ >
248
+ <Trash2 style={{ width: '16px', height: '16px' }} />
249
+ </button>
250
+ </div>
251
+ ))
252
+ )}
253
+ </div>
254
+
255
+ {/* Footer */}
256
+ {allTodos.length > 0 && (
257
+ <div style={{ marginTop: '24px', display: 'flex', justifyContent: 'space-between', fontSize: '14px', color: '#6b7280' }}>
258
+ <span>{activeTodos.length} item{activeTodos.length !== 1 ? 's' : ''} left</span>
259
+ {completedTodos.length > 0 && (
260
+ <button
261
+ onClick={clearCompleted}
262
+ style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#6b7280', fontSize: '14px' }}
263
+ >
264
+ Clear completed
265
+ </button>
266
+ )}
267
+ </div>
268
+ )}
269
+
270
+ <p style={{ marginTop: '48px', textAlign: 'center', fontSize: '12px', color: '#374151' }}>
271
+ Powered by Kora &mdash; native SQLite, offline-first
272
+ </p>
273
+ </div>
274
+ </div>
275
+ )
276
+ }
277
+
278
+ // ---------------------------------------------------------------------------
279
+ // Settings Panel
280
+ // ---------------------------------------------------------------------------
281
+
282
+ interface SettingsPanelProps {
283
+ syncUrl: string | null
284
+ onChangeServer: (newUrl: string | null) => void
285
+ onFactoryReset: () => void
286
+ onClose: () => void
287
+ }
288
+
289
+ function SettingsPanel({ syncUrl, onChangeServer, onFactoryReset, onClose }: SettingsPanelProps) {
290
+ const [editing, setEditing] = useState(false)
291
+ const [newUrl, setNewUrl] = useState(syncUrl ?? '')
292
+ const [testing, setTesting] = useState(false)
293
+ const [testResult, setTestResult] = useState<'success' | 'failure' | null>(null)
294
+ const [showResetConfirm, setShowResetConfirm] = useState(false)
295
+ const [showChangeConfirm, setShowChangeConfirm] = useState(false)
296
+
297
+ const handleTestConnection = async () => {
298
+ const url = editing ? newUrl.trim() : syncUrl
299
+ if (!url) return
300
+ setTesting(true)
301
+ setTestResult(null)
302
+ const ok = await testConnection(url)
303
+ setTestResult(ok ? 'success' : 'failure')
304
+ setTesting(false)
305
+ }
306
+
307
+ const handleSaveUrl = () => {
308
+ const trimmed = newUrl.trim()
309
+ if (trimmed === syncUrl) {
310
+ setEditing(false)
311
+ return
312
+ }
313
+ // If there's existing data and the URL is changing, warn about data isolation
314
+ setShowChangeConfirm(true)
315
+ }
316
+
317
+ const confirmChangeServer = (keepData: boolean) => {
318
+ setShowChangeConfirm(false)
319
+ if (keepData) {
320
+ onChangeServer(newUrl.trim() || null)
321
+ } else {
322
+ // Factory reset + change server
323
+ // Store the new URL so it's picked up after reload
324
+ if (newUrl.trim()) {
325
+ localStorage.setItem('kora-sync-url', newUrl.trim())
326
+ localStorage.setItem('kora-sync-configured', 'true')
327
+ }
328
+ onFactoryReset()
329
+ }
330
+ }
331
+
332
+ const handleDisconnect = () => {
333
+ onChangeServer(null)
334
+ }
335
+
336
+ const handleConnect = () => {
337
+ setEditing(true)
338
+ setNewUrl('')
339
+ }
340
+
341
+ return (
342
+ <div style={{
343
+ marginBottom: '24px',
344
+ borderRadius: '8px',
345
+ border: '1px solid #1f2937',
346
+ background: '#111827',
347
+ overflow: 'hidden',
348
+ }}>
349
+ {/* Server URL section */}
350
+ <div style={{ padding: '16px' }}>
351
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '12px' }}>
352
+ <p style={{ fontSize: '14px', fontWeight: '500', color: '#d1d5db' }}>Sync Server</p>
353
+ <button
354
+ onClick={onClose}
355
+ style={{ fontSize: '12px', color: '#6b7280', background: 'none', border: 'none', cursor: 'pointer' }}
356
+ >
357
+ Close
358
+ </button>
359
+ </div>
360
+
361
+ {editing ? (
362
+ <div>
363
+ <input
364
+ type="text"
365
+ value={newUrl}
366
+ onChange={(e) => { setNewUrl(e.target.value); setTestResult(null) }}
367
+ placeholder="wss://your-server.example.com/kora-sync"
368
+ autoFocus
369
+ style={{
370
+ width: '100%',
371
+ borderRadius: '6px',
372
+ border: '1px solid #374151',
373
+ background: '#0a0a0a',
374
+ padding: '8px 12px',
375
+ color: '#f3f4f6',
376
+ outline: 'none',
377
+ fontSize: '13px',
378
+ boxSizing: 'border-box',
379
+ marginBottom: '8px',
380
+ }}
381
+ />
382
+ <div style={{ display: 'flex', gap: '8px' }}>
383
+ <button onClick={handleSaveUrl} disabled={!newUrl.trim()} style={btnStyle('#4f46e5', !newUrl.trim())}>
384
+ Save
385
+ </button>
386
+ <button onClick={handleTestConnection} disabled={testing || !newUrl.trim()} style={btnStyle('#374151', testing || !newUrl.trim())}>
387
+ {testing ? 'Testing...' : 'Test'}
388
+ </button>
389
+ <button onClick={() => { setEditing(false); setTestResult(null) }} style={btnStyle('#374151', false)}>
390
+ Cancel
391
+ </button>
392
+ </div>
393
+ </div>
394
+ ) : (
395
+ <div>
396
+ <p style={{ fontSize: '13px', color: '#9ca3af', marginBottom: '8px', wordBreak: 'break-all' }}>
397
+ {syncUrl ?? 'Not connected — running in local-only mode'}
398
+ </p>
399
+ <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
400
+ {syncUrl ? (
401
+ <>
402
+ <button onClick={() => { setEditing(true); setNewUrl(syncUrl) }} style={btnStyle('#374151', false)}>
403
+ Change URL
404
+ </button>
405
+ <button onClick={handleTestConnection} disabled={testing} style={btnStyle('#374151', testing)}>
406
+ {testing ? 'Testing...' : 'Test Connection'}
407
+ </button>
408
+ <button onClick={handleDisconnect} style={btnStyle('#7f1d1d', false, '#ef4444')}>
409
+ Disconnect
410
+ </button>
411
+ </>
412
+ ) : (
413
+ <button onClick={handleConnect} style={btnStyle('#4f46e5', false)}>
414
+ Connect to Server
415
+ </button>
416
+ )}
417
+ </div>
418
+ </div>
419
+ )}
420
+
421
+ {/* Test result */}
422
+ {testResult && (
423
+ <p style={{
424
+ fontSize: '12px',
425
+ marginTop: '8px',
426
+ color: testResult === 'success' ? '#34d399' : '#fbbf24',
427
+ }}>
428
+ {testResult === 'success' ? 'Connection successful' : 'Server unreachable — it may be down temporarily'}
429
+ </p>
430
+ )}
431
+ </div>
432
+
433
+ {/* Danger zone */}
434
+ <div style={{ padding: '12px 16px', borderTop: '1px solid #1f2937', background: '#0a0a0a' }}>
435
+ {showResetConfirm ? (
436
+ <div>
437
+ <div style={{ display: 'flex', gap: '8px', alignItems: 'flex-start', marginBottom: '8px' }}>
438
+ <AlertTriangle style={{ width: '16px', height: '16px', color: '#ef4444', flexShrink: 0, marginTop: '1px' }} />
439
+ <p style={{ fontSize: '13px', color: '#fca5a5' }}>
440
+ This will delete all local data and reset the app to its initial state. This cannot be undone.
441
+ </p>
442
+ </div>
443
+ <div style={{ display: 'flex', gap: '8px' }}>
444
+ <button onClick={onFactoryReset} style={btnStyle('#7f1d1d', false, '#ef4444')}>
445
+ Confirm Reset
446
+ </button>
447
+ <button onClick={() => setShowResetConfirm(false)} style={btnStyle('#374151', false)}>
448
+ Cancel
449
+ </button>
450
+ </div>
451
+ </div>
452
+ ) : (
453
+ <button onClick={() => setShowResetConfirm(true)} style={{ fontSize: '13px', color: '#6b7280', background: 'none', border: 'none', cursor: 'pointer' }}>
454
+ Reset app (clear all local data)
455
+ </button>
456
+ )}
457
+ </div>
458
+
459
+ {/* Server change confirmation dialog */}
460
+ {showChangeConfirm && (
461
+ <div style={{
462
+ position: 'fixed',
463
+ inset: 0,
464
+ background: 'rgba(0,0,0,0.7)',
465
+ display: 'flex',
466
+ alignItems: 'center',
467
+ justifyContent: 'center',
468
+ zIndex: 100,
469
+ }}>
470
+ <div style={{
471
+ maxWidth: '420px',
472
+ width: '100%',
473
+ margin: '0 16px',
474
+ borderRadius: '12px',
475
+ border: '1px solid #374151',
476
+ background: '#111827',
477
+ padding: '24px',
478
+ }}>
479
+ <div style={{ display: 'flex', gap: '12px', alignItems: 'flex-start', marginBottom: '16px' }}>
480
+ <AlertTriangle style={{ width: '20px', height: '20px', color: '#fbbf24', flexShrink: 0, marginTop: '2px' }} />
481
+ <div>
482
+ <p style={{ fontSize: '15px', fontWeight: '500', color: '#f3f4f6', marginBottom: '8px' }}>
483
+ Changing sync servers
484
+ </p>
485
+ <p style={{ fontSize: '13px', color: '#9ca3af', lineHeight: '1.6' }}>
486
+ Existing local data was created for the current server. Keeping it and connecting to a different server may sync this data to the wrong place.
487
+ </p>
488
+ </div>
489
+ </div>
490
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
491
+ <button
492
+ onClick={() => confirmChangeServer(false)}
493
+ style={{
494
+ width: '100%',
495
+ padding: '10px',
496
+ borderRadius: '8px',
497
+ border: '1px solid #374151',
498
+ background: '#4f46e5',
499
+ color: 'white',
500
+ fontSize: '14px',
501
+ cursor: 'pointer',
502
+ fontWeight: '500',
503
+ }}
504
+ >
505
+ Clear data and switch (recommended)
506
+ </button>
507
+ <button
508
+ onClick={() => confirmChangeServer(true)}
509
+ style={{
510
+ width: '100%',
511
+ padding: '10px',
512
+ borderRadius: '8px',
513
+ border: '1px solid #374151',
514
+ background: '#1f2937',
515
+ color: '#9ca3af',
516
+ fontSize: '14px',
517
+ cursor: 'pointer',
518
+ }}
519
+ >
520
+ Keep data and switch
521
+ </button>
522
+ <button
523
+ onClick={() => setShowChangeConfirm(false)}
524
+ style={{
525
+ width: '100%',
526
+ padding: '10px',
527
+ borderRadius: '8px',
528
+ border: 'none',
529
+ background: 'transparent',
530
+ color: '#6b7280',
531
+ fontSize: '14px',
532
+ cursor: 'pointer',
533
+ }}
534
+ >
535
+ Cancel
536
+ </button>
537
+ </div>
538
+ </div>
539
+ </div>
540
+ )}
541
+ </div>
542
+ )
543
+ }
544
+
545
+ function btnStyle(bg: string, disabled: boolean, color = '#d1d5db'): React.CSSProperties {
546
+ return {
547
+ fontSize: '13px',
548
+ color,
549
+ background: bg,
550
+ border: 'none',
551
+ borderRadius: '6px',
552
+ padding: '6px 12px',
553
+ cursor: disabled ? 'default' : 'pointer',
554
+ opacity: disabled ? 0.5 : 1,
555
+ }
556
+ }
557
+
558
+ // ---------------------------------------------------------------------------
559
+ // Shared components
560
+ // ---------------------------------------------------------------------------
561
+
562
+ function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
563
+ return (
564
+ <div style={{ borderRadius: '8px', border: '1px solid #1f2937', background: '#111827', padding: '16px' }}>
565
+ <p style={{ fontSize: '14px', color: '#6b7280' }}>{label}</p>
566
+ <p style={{ fontSize: '24px', fontWeight: 'bold', color }}>{value}</p>
567
+ </div>
568
+ )
569
+ }
570
+
571
+ function formatTime(timestamp: number): string {
572
+ const date = new Date(timestamp)
573
+ const now = new Date()
574
+ if (date.toDateString() === now.toDateString()) {
575
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
576
+ }
577
+ return date.toLocaleDateString([], { month: 'short', day: 'numeric' })
578
+ }
@@ -0,0 +1,116 @@
1
+ import { useState, useCallback, useEffect } from 'react'
2
+ import { createApp } from 'korajs'
3
+ import { KoraProvider } from '@korajs/react'
4
+ import schema from './schema'
5
+ import { App } from './App'
6
+ import { SetupScreen } from './SetupScreen'
7
+ import { getSyncUrl, setSyncUrl, clearSyncUrl, hasCompletedSetup, markSetupComplete, factoryReset } from './sync-config'
8
+ import { checkForUpdates } from './updater'
9
+
10
+ type AppState =
11
+ | { phase: 'setup' }
12
+ | { phase: 'running'; syncUrl: string | null }
13
+
14
+ /**
15
+ * Root component that handles the first-launch setup flow.
16
+ *
17
+ * - If setup not completed, shows the setup screen.
18
+ * - If completed, initializes Kora and renders the app.
19
+ * - Handles server changes with proper data isolation warnings.
20
+ *
21
+ * Changing the sync server requires an app restart (to re-create the
22
+ * Kora app instance with the new URL). This is intentional — it prevents
23
+ * partial state from one server leaking to another.
24
+ */
25
+ export function AppShell() {
26
+ const [state, setState] = useState<AppState>(() => {
27
+ if (hasCompletedSetup()) {
28
+ return { phase: 'running', syncUrl: getSyncUrl() }
29
+ }
30
+ return { phase: 'setup' }
31
+ })
32
+
33
+ const handleConnect = useCallback((url: string) => {
34
+ setSyncUrl(url)
35
+ setState({ phase: 'running', syncUrl: url })
36
+ }, [])
37
+
38
+ const handleSkip = useCallback(() => {
39
+ markSetupComplete()
40
+ setState({ phase: 'running', syncUrl: null })
41
+ }, [])
42
+
43
+ // Change to a different server URL.
44
+ // This requires restarting the app to create a fresh Kora instance.
45
+ const handleChangeServer = useCallback((newUrl: string | null) => {
46
+ if (newUrl) {
47
+ setSyncUrl(newUrl)
48
+ } else {
49
+ clearSyncUrl()
50
+ markSetupComplete() // Still completed — just disconnected
51
+ }
52
+ // Reload to re-initialize Kora with the new URL.
53
+ // This is cleaner than trying to hot-swap the sync connection,
54
+ // and prevents data from one server context leaking to another.
55
+ window.location.reload()
56
+ }, [])
57
+
58
+ const handleFactoryReset = useCallback(() => {
59
+ factoryReset() // Clears all data and reloads
60
+ }, [])
61
+
62
+ if (state.phase === 'setup') {
63
+ return <SetupScreen onConnect={handleConnect} onSkip={handleSkip} />
64
+ }
65
+
66
+ return (
67
+ <ConnectedApp
68
+ syncUrl={state.syncUrl}
69
+ onChangeServer={handleChangeServer}
70
+ onFactoryReset={handleFactoryReset}
71
+ />
72
+ )
73
+ }
74
+
75
+ interface ConnectedAppProps {
76
+ syncUrl: string | null
77
+ onChangeServer: (newUrl: string | null) => void
78
+ onFactoryReset: () => void
79
+ }
80
+
81
+ function ConnectedApp({ syncUrl, onChangeServer, onFactoryReset }: ConnectedAppProps) {
82
+ // Create the Kora app instance with optional sync.
83
+ // This only runs once — the app instance is stable for the lifetime of this component.
84
+ const [app] = useState(() =>
85
+ createApp({
86
+ schema,
87
+ ...(syncUrl ? { sync: { url: syncUrl } } : {}),
88
+ devtools: true,
89
+ })
90
+ )
91
+
92
+ // Connect to sync server once ready, and check for updates
93
+ useEffect(() => {
94
+ if (syncUrl) {
95
+ app.ready.then(() => app.sync?.connect())
96
+ }
97
+ checkForUpdates()
98
+ }, [])
99
+
100
+ return (
101
+ <KoraProvider
102
+ app={app}
103
+ fallback={
104
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', background: '#0a0a0a', color: '#6b7280' }}>
105
+ Loading...
106
+ </div>
107
+ }
108
+ >
109
+ <App
110
+ syncUrl={syncUrl}
111
+ onChangeServer={onChangeServer}
112
+ onFactoryReset={onFactoryReset}
113
+ />
114
+ </KoraProvider>
115
+ )
116
+ }