@korajs/cli 0.3.1 → 0.3.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/dist/bin.cjs +120 -67
- package/dist/bin.cjs.map +1 -1
- package/dist/bin.js +3 -3
- package/dist/{chunk-KTSRAPSE.js → chunk-HNVCNPER.js} +105 -65
- package/dist/chunk-HNVCNPER.js.map +1 -0
- package/dist/{chunk-E7OCVRYL.js → chunk-MIMLCXSX.js} +2 -2
- package/dist/{chunk-ZGYRDYXS.js → chunk-ULTO5NFA.js} +22 -4
- package/dist/chunk-ULTO5NFA.js.map +1 -0
- package/dist/create.cjs +119 -66
- package/dist/create.cjs.map +1 -1
- package/dist/create.js +2 -2
- package/dist/index.cjs +105 -65
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +2 -2
- package/package.json +2 -2
- package/templates/tauri-react/.env.example +7 -0
- package/templates/tauri-react/.github/workflows/release-desktop.yml +107 -0
- package/templates/tauri-react/README.md.hbs +148 -0
- package/templates/tauri-react/dev.ts +37 -0
- package/templates/tauri-react/index.html.hbs +16 -0
- package/templates/tauri-react/kora.config.ts +17 -0
- package/templates/tauri-react/package.json.hbs +44 -0
- package/templates/tauri-react/server.ts +23 -0
- package/templates/tauri-react/src/App.tsx +578 -0
- package/templates/tauri-react/src/AppShell.tsx +116 -0
- package/templates/tauri-react/src/SetupScreen.tsx +239 -0
- package/templates/tauri-react/src/main.tsx +9 -0
- package/templates/tauri-react/src/schema.ts +15 -0
- package/templates/tauri-react/src/sync-config.ts +103 -0
- package/templates/tauri-react/src/updater.ts +38 -0
- package/templates/tauri-react/src-tauri/Cargo.toml +19 -0
- package/templates/tauri-react/src-tauri/build.rs +3 -0
- package/templates/tauri-react/src-tauri/capabilities/default.json +12 -0
- package/templates/tauri-react/src-tauri/icons/128x128.png +0 -0
- package/templates/tauri-react/src-tauri/icons/128x128@2x.png +0 -0
- package/templates/tauri-react/src-tauri/icons/32x32.png +0 -0
- package/templates/tauri-react/src-tauri/icons/icon.icns +0 -0
- package/templates/tauri-react/src-tauri/icons/icon.ico +0 -0
- package/templates/tauri-react/src-tauri/src/lib.rs +8 -0
- package/templates/tauri-react/src-tauri/src/main.rs +6 -0
- package/templates/tauri-react/src-tauri/tauri.conf.json +44 -0
- package/templates/tauri-react/tsconfig.json +15 -0
- package/templates/tauri-react/vite.config.ts +15 -0
- package/dist/chunk-KTSRAPSE.js.map +0 -1
- package/dist/chunk-ZGYRDYXS.js.map +0 -1
- /package/dist/{chunk-E7OCVRYL.js.map → chunk-MIMLCXSX.js.map} +0 -0
|
@@ -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 — 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
|
+
}
|