@korajs/cli 0.1.10 → 0.1.11

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 (48) hide show
  1. package/dist/bin.cjs +52 -8
  2. package/dist/bin.cjs.map +1 -1
  3. package/dist/bin.js +2 -2
  4. package/dist/chunk-N36PFOSA.js +0 -0
  5. package/dist/{chunk-AUDUNRYA.js → chunk-SYOFLJLB.js} +33 -4
  6. package/dist/chunk-SYOFLJLB.js.map +1 -0
  7. package/dist/{chunk-ZVB4HAB3.js → chunk-VGOOQ56H.js} +22 -7
  8. package/dist/chunk-VGOOQ56H.js.map +1 -0
  9. package/dist/create.cjs +52 -8
  10. package/dist/create.cjs.map +1 -1
  11. package/dist/create.js +2 -2
  12. package/dist/index.cjs +21 -6
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.js +1 -1
  17. package/package.json +13 -13
  18. package/templates/react-basic/src/App.tsx +129 -31
  19. package/templates/react-basic/src/index.css +281 -0
  20. package/templates/react-basic/src/main.tsx +2 -0
  21. package/templates/react-sync/kora.config.ts +1 -1
  22. package/templates/react-sync/server.ts +13 -2
  23. package/templates/react-sync/src/App.tsx +132 -32
  24. package/templates/react-sync/src/index.css +292 -0
  25. package/templates/react-sync/src/main.tsx +2 -0
  26. package/templates/react-tailwind/index.html.hbs +12 -0
  27. package/templates/react-tailwind/kora.config.ts +13 -0
  28. package/templates/react-tailwind/package.json.hbs +31 -0
  29. package/templates/react-tailwind/src/App.tsx +212 -0
  30. package/templates/react-tailwind/src/index.css +7 -0
  31. package/templates/react-tailwind/src/kora-worker.ts +3 -0
  32. package/templates/react-tailwind/src/main.tsx +23 -0
  33. package/templates/react-tailwind/src/schema.ts +15 -0
  34. package/templates/react-tailwind/tsconfig.json +14 -0
  35. package/templates/react-tailwind/vite.config.ts +40 -0
  36. package/templates/react-tailwind-sync/index.html.hbs +12 -0
  37. package/templates/react-tailwind-sync/kora.config.ts +17 -0
  38. package/templates/react-tailwind-sync/package.json.hbs +33 -0
  39. package/templates/react-tailwind-sync/server.ts +22 -0
  40. package/templates/react-tailwind-sync/src/App.tsx +241 -0
  41. package/templates/react-tailwind-sync/src/index.css +7 -0
  42. package/templates/react-tailwind-sync/src/kora-worker.ts +3 -0
  43. package/templates/react-tailwind-sync/src/main.tsx +29 -0
  44. package/templates/react-tailwind-sync/src/schema.ts +15 -0
  45. package/templates/react-tailwind-sync/tsconfig.json +14 -0
  46. package/templates/react-tailwind-sync/vite.config.ts +40 -0
  47. package/dist/chunk-AUDUNRYA.js.map +0 -1
  48. package/dist/chunk-ZVB4HAB3.js.map +0 -1
@@ -0,0 +1,212 @@
1
+ import { useState } from 'react'
2
+ import { useQuery, useMutation, useCollection } from '@korajs/react'
3
+ import {
4
+ CheckCircle2,
5
+ Circle,
6
+ ClipboardList,
7
+ Loader2,
8
+ Plus,
9
+ Trash2,
10
+ } from 'lucide-react'
11
+
12
+ type Filter = 'all' | 'active' | 'completed'
13
+
14
+ export function App() {
15
+ const todos = useCollection('todos')
16
+ const allTodos = useQuery(todos.orderBy('createdAt', 'desc'))
17
+ const { mutate: addTodo, isPending: isAdding } = useMutation(
18
+ (data: { title: string }) => todos.insert(data)
19
+ )
20
+ const { mutate: toggleTodo } = useMutation(
21
+ (id: string, data: { completed: boolean }) => todos.update(id, data)
22
+ )
23
+ const { mutate: deleteTodo } = useMutation(
24
+ (id: string) => todos.delete(id)
25
+ )
26
+
27
+ const [filter, setFilter] = useState<Filter>('all')
28
+ const [input, setInput] = useState('')
29
+
30
+ const activeTodos = allTodos.filter((t) => !t.completed)
31
+ const completedTodos = allTodos.filter((t) => !!t.completed)
32
+ const filteredTodos =
33
+ filter === 'active' ? activeTodos : filter === 'completed' ? completedTodos : allTodos
34
+
35
+ const handleSubmit = (e: React.FormEvent) => {
36
+ e.preventDefault()
37
+ const title = input.trim()
38
+ if (title) {
39
+ addTodo({ title })
40
+ setInput('')
41
+ }
42
+ }
43
+
44
+ const clearCompleted = () => {
45
+ for (const todo of completedTodos) {
46
+ deleteTodo(todo.id)
47
+ }
48
+ }
49
+
50
+ return (
51
+ <div className="min-h-screen bg-gray-950 text-gray-100">
52
+ <div className="mx-auto max-w-2xl px-4 py-12">
53
+ {/* Header */}
54
+ <div className="flex items-center justify-between mb-8">
55
+ <div className="flex items-center gap-3">
56
+ <ClipboardList className="h-8 w-8 text-indigo-400" />
57
+ <h1 className="text-2xl font-bold">My Tasks</h1>
58
+ </div>
59
+ <span className="rounded-full bg-gray-800 px-3 py-1.5 text-sm text-gray-400">
60
+ Local-first
61
+ </span>
62
+ </div>
63
+
64
+ {/* Stats */}
65
+ <div className="grid grid-cols-3 gap-4 mb-8">
66
+ <StatCard label="Total" value={allTodos.length} color="text-gray-300" />
67
+ <StatCard label="Remaining" value={activeTodos.length} color="text-amber-400" />
68
+ <StatCard label="Done" value={completedTodos.length} color="text-emerald-400" />
69
+ </div>
70
+
71
+ {/* Add form */}
72
+ <form onSubmit={handleSubmit} className="flex gap-3 mb-8">
73
+ <input
74
+ type="text"
75
+ value={input}
76
+ onChange={(e) => setInput(e.target.value)}
77
+ placeholder="What needs to be done?"
78
+ className="flex-1 rounded-lg border border-gray-700 bg-gray-900 px-4 py-3 text-gray-100 placeholder-gray-500 outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
79
+ />
80
+ <button
81
+ type="submit"
82
+ disabled={isAdding || !input.trim()}
83
+ className="flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-3 font-medium text-white transition hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
84
+ >
85
+ {isAdding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
86
+ Add
87
+ </button>
88
+ </form>
89
+
90
+ {/* Filter tabs */}
91
+ <div className="flex gap-2 mb-6">
92
+ {(['all', 'active', 'completed'] as const).map((f) => {
93
+ const count = f === 'all' ? allTodos.length : f === 'active' ? activeTodos.length : completedTodos.length
94
+ return (
95
+ <button
96
+ key={f}
97
+ onClick={() => setFilter(f)}
98
+ className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition ${
99
+ filter === f
100
+ ? 'bg-indigo-600 text-white'
101
+ : 'bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-gray-200'
102
+ }`}
103
+ >
104
+ {f.charAt(0).toUpperCase() + f.slice(1)}
105
+ <span
106
+ className={`rounded-full px-2 py-0.5 text-xs ${
107
+ filter === f ? 'bg-indigo-500 text-white' : 'bg-gray-700 text-gray-400'
108
+ }`}
109
+ >
110
+ {count}
111
+ </span>
112
+ </button>
113
+ )
114
+ })}
115
+ </div>
116
+
117
+ {/* Todo list */}
118
+ <div className="space-y-2">
119
+ {filteredTodos.length === 0 ? (
120
+ <EmptyState filter={filter} />
121
+ ) : (
122
+ filteredTodos.map((todo) => (
123
+ <div
124
+ key={todo.id}
125
+ className="group flex items-center gap-3 rounded-lg border border-gray-800 bg-gray-900 px-4 py-3 transition hover:border-gray-700"
126
+ >
127
+ <button
128
+ onClick={() => toggleTodo(todo.id, { completed: !todo.completed })}
129
+ className="shrink-0 text-gray-500 hover:text-indigo-400 transition"
130
+ >
131
+ {todo.completed ? (
132
+ <CheckCircle2 className="h-5 w-5 text-emerald-400" />
133
+ ) : (
134
+ <Circle className="h-5 w-5" />
135
+ )}
136
+ </button>
137
+ <span
138
+ className={`flex-1 ${
139
+ todo.completed ? 'text-gray-500 line-through' : 'text-gray-100'
140
+ }`}
141
+ >
142
+ {String(todo.title)}
143
+ </span>
144
+ {todo.createdAt && (
145
+ <span className="text-xs text-gray-600">
146
+ {formatTime(Number(todo.createdAt))}
147
+ </span>
148
+ )}
149
+ <button
150
+ onClick={() => deleteTodo(todo.id)}
151
+ className="shrink-0 text-gray-600 opacity-0 transition hover:text-red-400 group-hover:opacity-100"
152
+ >
153
+ <Trash2 className="h-4 w-4" />
154
+ </button>
155
+ </div>
156
+ ))
157
+ )}
158
+ </div>
159
+
160
+ {/* Footer */}
161
+ {allTodos.length > 0 && (
162
+ <div className="mt-6 flex items-center justify-between text-sm text-gray-500">
163
+ <span>{activeTodos.length} item{activeTodos.length !== 1 ? 's' : ''} left</span>
164
+ {completedTodos.length > 0 && (
165
+ <button
166
+ onClick={clearCompleted}
167
+ className="text-gray-500 transition hover:text-gray-300"
168
+ >
169
+ Clear completed
170
+ </button>
171
+ )}
172
+ </div>
173
+ )}
174
+
175
+ <p className="mt-12 text-center text-xs text-gray-700">
176
+ Powered by Kora &mdash; offline-first, real-time sync
177
+ </p>
178
+ </div>
179
+ </div>
180
+ )
181
+ }
182
+
183
+ function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
184
+ return (
185
+ <div className="rounded-lg border border-gray-800 bg-gray-900 p-4">
186
+ <p className="text-sm text-gray-500">{label}</p>
187
+ <p className={`text-2xl font-bold ${color}`}>{value}</p>
188
+ </div>
189
+ )
190
+ }
191
+
192
+ function EmptyState({ filter }: { filter: Filter }) {
193
+ const messages: Record<Filter, string> = {
194
+ all: 'No tasks yet. Add one above!',
195
+ active: 'All caught up! No active tasks.',
196
+ completed: 'No completed tasks yet.',
197
+ }
198
+ return (
199
+ <div className="rounded-lg border border-dashed border-gray-800 py-12 text-center text-gray-600">
200
+ {messages[filter]}
201
+ </div>
202
+ )
203
+ }
204
+
205
+ function formatTime(timestamp: number): string {
206
+ const date = new Date(timestamp)
207
+ const now = new Date()
208
+ if (date.toDateString() === now.toDateString()) {
209
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
210
+ }
211
+ return date.toLocaleDateString([], { month: 'short', day: 'numeric' })
212
+ }
@@ -0,0 +1,7 @@
1
+ @import "tailwindcss";
2
+
3
+ @layer base {
4
+ body {
5
+ @apply bg-gray-950 text-gray-100 antialiased;
6
+ }
7
+ }
@@ -0,0 +1,3 @@
1
+ // Web Worker entry point for SQLite WASM.
2
+ // Loaded automatically by the Kora store to run SQLite in a background thread.
3
+ import '@korajs/store/sqlite-wasm/worker'
@@ -0,0 +1,23 @@
1
+ import { createApp } from 'korajs'
2
+ import { KoraProvider } from '@korajs/react'
3
+ import { StrictMode } from 'react'
4
+ import { createRoot } from 'react-dom/client'
5
+ import schema from './schema'
6
+ import { App } from './App'
7
+ import './index.css'
8
+
9
+ const app = createApp({
10
+ schema,
11
+ store: {
12
+ workerUrl: new URL('./kora-worker.ts', import.meta.url),
13
+ },
14
+ devtools: true,
15
+ })
16
+
17
+ createRoot(document.getElementById('root')!).render(
18
+ <StrictMode>
19
+ <KoraProvider app={app} fallback={<div className="flex items-center justify-center h-screen bg-gray-950 text-gray-400">Loading...</div>}>
20
+ <App />
21
+ </KoraProvider>
22
+ </StrictMode>,
23
+ )
@@ -0,0 +1,15 @@
1
+ import { defineSchema, t } from 'korajs'
2
+
3
+ export default defineSchema({
4
+ version: 1,
5
+ collections: {
6
+ todos: {
7
+ fields: {
8
+ title: t.string(),
9
+ completed: t.boolean().default(false),
10
+ createdAt: t.timestamp().auto(),
11
+ },
12
+ indexes: ['completed'],
13
+ },
14
+ },
15
+ })
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "outDir": "dist"
12
+ },
13
+ "include": ["src"]
14
+ }
@@ -0,0 +1,40 @@
1
+ import tailwindcss from '@tailwindcss/vite'
2
+ import react from '@vitejs/plugin-react'
3
+ import type { Plugin } from 'vite'
4
+ import { defineConfig } from 'vite'
5
+
6
+ /**
7
+ * Adds Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers
8
+ * required for SharedArrayBuffer. Without these headers, OPFS SAH Pool VFS
9
+ * cannot be used and SQLite WASM falls back to in-memory storage (no persistence).
10
+ */
11
+ function crossOriginIsolation(): Plugin {
12
+ return {
13
+ name: 'cross-origin-isolation',
14
+ configureServer(server) {
15
+ server.middlewares.use((_req, res, next) => {
16
+ res.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
17
+ res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp')
18
+ next()
19
+ })
20
+ },
21
+ configurePreviewServer(server) {
22
+ server.middlewares.use((_req, res, next) => {
23
+ res.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
24
+ res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp')
25
+ next()
26
+ })
27
+ },
28
+ }
29
+ }
30
+
31
+ export default defineConfig({
32
+ plugins: [react(), tailwindcss(), crossOriginIsolation()],
33
+ optimizeDeps: {
34
+ exclude: ['@sqlite.org/sqlite-wasm', '@korajs/store'],
35
+ include: ['yjs'],
36
+ },
37
+ resolve: {
38
+ dedupe: ['yjs'],
39
+ },
40
+ })
@@ -0,0 +1,12 @@
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>{{projectName}}</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,17 @@
1
+ import { defineConfig } from 'korajs/config'
2
+
3
+ export default defineConfig({
4
+ schema: './src/schema.ts',
5
+ dev: {
6
+ port: 5173,
7
+ sync: {
8
+ enabled: true,
9
+ port: 3001,
10
+ store: 'sqlite',
11
+ },
12
+ watch: {
13
+ enabled: true,
14
+ debounceMs: 300,
15
+ },
16
+ },
17
+ })
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "{{projectName}}",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "kora dev",
8
+ "dev:server": "tsx server.ts",
9
+ "build": "tsc && vite build",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "korajs": "{{koraVersion}}",
14
+ "@korajs/react": "{{koraVersion}}",
15
+ "@korajs/store": "{{koraVersion}}",
16
+ "@sqlite.org/sqlite-wasm": ">=3.51.0-build1",
17
+ "lucide-react": "^0.468.0",
18
+ "react": "^19.0.0",
19
+ "react-dom": "^19.0.0"
20
+ },
21
+ "devDependencies": {
22
+ "@korajs/cli": "{{koraVersion}}",
23
+ "@korajs/server": "{{koraVersion}}",
24
+ "@tailwindcss/vite": "^4.0.0",
25
+ "@types/react": "^19.0.0",
26
+ "@types/react-dom": "^19.0.0",
27
+ "@vitejs/plugin-react": "^4.3.0",
28
+ "tailwindcss": "^4.0.0",
29
+ "tsx": "^4.19.0",
30
+ "typescript": "^5.7.0",
31
+ "vite": "^6.0.0"
32
+ }
33
+ }
@@ -0,0 +1,22 @@
1
+ import { createKoraServer, createSqliteServerStore } from '@korajs/server'
2
+
3
+ // SQLite persists data to disk — survives server restarts
4
+ const store = createSqliteServerStore({ filename: './kora-server.db' })
5
+
6
+ // To use PostgreSQL instead:
7
+ // 1. Install: pnpm add postgres
8
+ // 2. Replace the store above with:
9
+ //
10
+ // import { createPostgresServerStore } from '@korajs/server'
11
+ // const store = await createPostgresServerStore({
12
+ // connectionString: 'postgresql://user:password@localhost:5432/mydb',
13
+ // })
14
+
15
+ const server = createKoraServer({
16
+ store,
17
+ port: 3001,
18
+ })
19
+
20
+ server.start().then(() => {
21
+ console.log('Kora sync server running on ws://localhost:3001')
22
+ })
@@ -0,0 +1,241 @@
1
+ import { useState } from 'react'
2
+ import { useQuery, useMutation, useSyncStatus, useCollection } from '@korajs/react'
3
+ import {
4
+ CheckCircle2,
5
+ Circle,
6
+ ClipboardList,
7
+ Loader2,
8
+ Plus,
9
+ Trash2,
10
+ Wifi,
11
+ WifiOff,
12
+ AlertCircle,
13
+ } from 'lucide-react'
14
+
15
+ type Filter = 'all' | 'active' | 'completed'
16
+
17
+ export function App() {
18
+ const todos = useCollection('todos')
19
+ const allTodos = useQuery(todos.orderBy('createdAt', 'desc'))
20
+ const { mutate: addTodo, isPending: isAdding } = useMutation(
21
+ (data: { title: string }) => todos.insert(data)
22
+ )
23
+ const { mutate: toggleTodo } = useMutation(
24
+ (id: string, data: { completed: boolean }) => todos.update(id, data)
25
+ )
26
+ const { mutate: deleteTodo } = useMutation(
27
+ (id: string) => todos.delete(id)
28
+ )
29
+ const status = useSyncStatus()
30
+
31
+ const [filter, setFilter] = useState<Filter>('all')
32
+ const [input, setInput] = useState('')
33
+
34
+ const activeTodos = allTodos.filter((t) => !t.completed)
35
+ const completedTodos = allTodos.filter((t) => !!t.completed)
36
+ const filteredTodos =
37
+ filter === 'active' ? activeTodos : filter === 'completed' ? completedTodos : allTodos
38
+
39
+ const handleSubmit = (e: React.FormEvent) => {
40
+ e.preventDefault()
41
+ const title = input.trim()
42
+ if (title) {
43
+ addTodo({ title })
44
+ setInput('')
45
+ }
46
+ }
47
+
48
+ const clearCompleted = () => {
49
+ for (const todo of completedTodos) {
50
+ deleteTodo(todo.id)
51
+ }
52
+ }
53
+
54
+ return (
55
+ <div className="min-h-screen bg-gray-950 text-gray-100">
56
+ <div className="mx-auto max-w-2xl px-4 py-12">
57
+ {/* Header */}
58
+ <div className="flex items-center justify-between mb-8">
59
+ <div className="flex items-center gap-3">
60
+ <ClipboardList className="h-8 w-8 text-indigo-400" />
61
+ <h1 className="text-2xl font-bold">My Tasks</h1>
62
+ </div>
63
+ <SyncBadge status={status} />
64
+ </div>
65
+
66
+ {/* Stats */}
67
+ <div className="grid grid-cols-3 gap-4 mb-8">
68
+ <StatCard label="Total" value={allTodos.length} color="text-gray-300" />
69
+ <StatCard label="Remaining" value={activeTodos.length} color="text-amber-400" />
70
+ <StatCard label="Done" value={completedTodos.length} color="text-emerald-400" />
71
+ </div>
72
+
73
+ {/* Add form */}
74
+ <form onSubmit={handleSubmit} className="flex gap-3 mb-8">
75
+ <input
76
+ type="text"
77
+ value={input}
78
+ onChange={(e) => setInput(e.target.value)}
79
+ placeholder="What needs to be done?"
80
+ className="flex-1 rounded-lg border border-gray-700 bg-gray-900 px-4 py-3 text-gray-100 placeholder-gray-500 outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
81
+ />
82
+ <button
83
+ type="submit"
84
+ disabled={isAdding || !input.trim()}
85
+ className="flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-3 font-medium text-white transition hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
86
+ >
87
+ {isAdding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
88
+ Add
89
+ </button>
90
+ </form>
91
+
92
+ {/* Filter tabs */}
93
+ <div className="flex gap-2 mb-6">
94
+ {(['all', 'active', 'completed'] as const).map((f) => {
95
+ const count = f === 'all' ? allTodos.length : f === 'active' ? activeTodos.length : completedTodos.length
96
+ return (
97
+ <button
98
+ key={f}
99
+ onClick={() => setFilter(f)}
100
+ className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition ${
101
+ filter === f
102
+ ? 'bg-indigo-600 text-white'
103
+ : 'bg-gray-800 text-gray-400 hover:bg-gray-700 hover:text-gray-200'
104
+ }`}
105
+ >
106
+ {f.charAt(0).toUpperCase() + f.slice(1)}
107
+ <span
108
+ className={`rounded-full px-2 py-0.5 text-xs ${
109
+ filter === f ? 'bg-indigo-500 text-white' : 'bg-gray-700 text-gray-400'
110
+ }`}
111
+ >
112
+ {count}
113
+ </span>
114
+ </button>
115
+ )
116
+ })}
117
+ </div>
118
+
119
+ {/* Todo list */}
120
+ <div className="space-y-2">
121
+ {filteredTodos.length === 0 ? (
122
+ <EmptyState filter={filter} />
123
+ ) : (
124
+ filteredTodos.map((todo) => (
125
+ <div
126
+ key={todo.id}
127
+ className="group flex items-center gap-3 rounded-lg border border-gray-800 bg-gray-900 px-4 py-3 transition hover:border-gray-700"
128
+ >
129
+ <button
130
+ onClick={() => toggleTodo(todo.id, { completed: !todo.completed })}
131
+ className="shrink-0 text-gray-500 hover:text-indigo-400 transition"
132
+ >
133
+ {todo.completed ? (
134
+ <CheckCircle2 className="h-5 w-5 text-emerald-400" />
135
+ ) : (
136
+ <Circle className="h-5 w-5" />
137
+ )}
138
+ </button>
139
+ <span
140
+ className={`flex-1 ${
141
+ todo.completed ? 'text-gray-500 line-through' : 'text-gray-100'
142
+ }`}
143
+ >
144
+ {String(todo.title)}
145
+ </span>
146
+ {todo.createdAt && (
147
+ <span className="text-xs text-gray-600">
148
+ {formatTime(Number(todo.createdAt))}
149
+ </span>
150
+ )}
151
+ <button
152
+ onClick={() => deleteTodo(todo.id)}
153
+ className="shrink-0 text-gray-600 opacity-0 transition hover:text-red-400 group-hover:opacity-100"
154
+ >
155
+ <Trash2 className="h-4 w-4" />
156
+ </button>
157
+ </div>
158
+ ))
159
+ )}
160
+ </div>
161
+
162
+ {/* Footer */}
163
+ {allTodos.length > 0 && (
164
+ <div className="mt-6 flex items-center justify-between text-sm text-gray-500">
165
+ <span>{activeTodos.length} item{activeTodos.length !== 1 ? 's' : ''} left</span>
166
+ {completedTodos.length > 0 && (
167
+ <button
168
+ onClick={clearCompleted}
169
+ className="text-gray-500 transition hover:text-gray-300"
170
+ >
171
+ Clear completed
172
+ </button>
173
+ )}
174
+ </div>
175
+ )}
176
+
177
+ <p className="mt-12 text-center text-xs text-gray-700">
178
+ Powered by Kora &mdash; offline-first, real-time sync
179
+ </p>
180
+ </div>
181
+ </div>
182
+ )
183
+ }
184
+
185
+ function SyncBadge({ status }: { status: { status: string; pendingOperations?: number } }) {
186
+ const s = status.status
187
+ const pending = status.pendingOperations ?? 0
188
+
189
+ const config: Record<string, { icon: typeof Wifi; color: string; label: string }> = {
190
+ connected: { icon: Wifi, color: 'text-emerald-400', label: 'Connected' },
191
+ syncing: { icon: Wifi, color: 'text-amber-400', label: 'Syncing' },
192
+ synced: { icon: Wifi, color: 'text-emerald-400', label: 'Synced' },
193
+ offline: { icon: WifiOff, color: 'text-gray-500', label: 'Offline' },
194
+ error: { icon: AlertCircle, color: 'text-red-400', label: 'Error' },
195
+ }
196
+
197
+ const { icon: Icon, color, label } = config[s] ?? config.offline!
198
+
199
+ return (
200
+ <div className="flex items-center gap-2 rounded-full bg-gray-800 px-3 py-1.5 text-sm">
201
+ <Icon className={`h-4 w-4 ${color}`} />
202
+ <span className={color}>{label}</span>
203
+ {pending > 0 && (
204
+ <span className="rounded-full bg-gray-700 px-2 py-0.5 text-xs text-gray-400">
205
+ {pending} pending
206
+ </span>
207
+ )}
208
+ </div>
209
+ )
210
+ }
211
+
212
+ function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
213
+ return (
214
+ <div className="rounded-lg border border-gray-800 bg-gray-900 p-4">
215
+ <p className="text-sm text-gray-500">{label}</p>
216
+ <p className={`text-2xl font-bold ${color}`}>{value}</p>
217
+ </div>
218
+ )
219
+ }
220
+
221
+ function EmptyState({ filter }: { filter: Filter }) {
222
+ const messages: Record<Filter, string> = {
223
+ all: 'No tasks yet. Add one above!',
224
+ active: 'All caught up! No active tasks.',
225
+ completed: 'No completed tasks yet.',
226
+ }
227
+ return (
228
+ <div className="rounded-lg border border-dashed border-gray-800 py-12 text-center text-gray-600">
229
+ {messages[filter]}
230
+ </div>
231
+ )
232
+ }
233
+
234
+ function formatTime(timestamp: number): string {
235
+ const date = new Date(timestamp)
236
+ const now = new Date()
237
+ if (date.toDateString() === now.toDateString()) {
238
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
239
+ }
240
+ return date.toLocaleDateString([], { month: 'short', day: 'numeric' })
241
+ }
@@ -0,0 +1,7 @@
1
+ @import "tailwindcss";
2
+
3
+ @layer base {
4
+ body {
5
+ @apply bg-gray-950 text-gray-100 antialiased;
6
+ }
7
+ }
@@ -0,0 +1,3 @@
1
+ // Web Worker entry point for SQLite WASM.
2
+ // Loaded automatically by the Kora store to run SQLite in a background thread.
3
+ import '@korajs/store/sqlite-wasm/worker'