tablinum 0.0.1
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/.changeset/README.md +8 -0
- package/.changeset/config.json +11 -0
- package/.context/attachments/pasted_text_2026-03-07_14-02-40.txt +571 -0
- package/.context/attachments/pasted_text_2026-03-07_15-48-27.txt +498 -0
- package/.context/notes.md +0 -0
- package/.context/plans/add-changesets-to-douala-v4.md +48 -0
- package/.context/plans/dexie-js-style-query-language-for-localstr.md +115 -0
- package/.context/plans/dexie-js-style-query-language-with-per-collection-.md +336 -0
- package/.context/plans/implementation-plan-localstr-v0-2.md +263 -0
- package/.context/plans/project-init-effect-v4-bun-oxlint-oxfmt-vitest.md +71 -0
- package/.context/plans/revise-localstr-prd-v0-2.md +132 -0
- package/.context/plans/svelte-5-runes-bindings-for-localstr.md +233 -0
- package/.context/todos.md +0 -0
- package/.github/workflows/release.yml +36 -0
- package/.oxlintrc.json +8 -0
- package/README.md +1 -0
- package/bun.lock +705 -0
- package/examples/svelte/bun.lock +261 -0
- package/examples/svelte/package.json +21 -0
- package/examples/svelte/src/app.html +11 -0
- package/examples/svelte/src/lib/db.ts +44 -0
- package/examples/svelte/src/routes/+page.svelte +322 -0
- package/examples/svelte/svelte.config.js +16 -0
- package/examples/svelte/tsconfig.json +6 -0
- package/examples/svelte/vite.config.ts +6 -0
- package/examples/vanilla/app.ts +219 -0
- package/examples/vanilla/index.html +144 -0
- package/examples/vanilla/serve.ts +42 -0
- package/package.json +46 -0
- package/prds/localstr-v0.2.md +221 -0
- package/prek.toml +10 -0
- package/scripts/validate.ts +392 -0
- package/src/crud/collection-handle.ts +189 -0
- package/src/crud/query-builder.ts +414 -0
- package/src/crud/watch.ts +78 -0
- package/src/db/create-localstr.ts +217 -0
- package/src/db/database-handle.ts +16 -0
- package/src/db/identity.ts +49 -0
- package/src/errors.ts +37 -0
- package/src/index.ts +32 -0
- package/src/main.ts +10 -0
- package/src/schema/collection.ts +53 -0
- package/src/schema/field.ts +25 -0
- package/src/schema/types.ts +19 -0
- package/src/schema/validate.ts +111 -0
- package/src/storage/events-store.ts +24 -0
- package/src/storage/giftwraps-store.ts +23 -0
- package/src/storage/idb.ts +244 -0
- package/src/storage/lww.ts +17 -0
- package/src/storage/records-store.ts +76 -0
- package/src/svelte/collection.svelte.ts +87 -0
- package/src/svelte/database.svelte.ts +83 -0
- package/src/svelte/index.svelte.ts +52 -0
- package/src/svelte/live-query.svelte.ts +29 -0
- package/src/svelte/query.svelte.ts +101 -0
- package/src/sync/gift-wrap.ts +33 -0
- package/src/sync/negentropy.ts +83 -0
- package/src/sync/publish-queue.ts +61 -0
- package/src/sync/relay.ts +239 -0
- package/src/sync/sync-service.ts +183 -0
- package/src/sync/sync-status.ts +17 -0
- package/src/utils/uuid.ts +22 -0
- package/src/vendor/negentropy.js +616 -0
- package/tests/db/create-localstr.test.ts +174 -0
- package/tests/db/identity.test.ts +33 -0
- package/tests/main.test.ts +9 -0
- package/tests/schema/collection.test.ts +27 -0
- package/tests/schema/field.test.ts +41 -0
- package/tests/schema/validate.test.ts +85 -0
- package/tests/setup.ts +1 -0
- package/tests/storage/idb.test.ts +144 -0
- package/tests/storage/lww.test.ts +33 -0
- package/tests/sync/gift-wrap.test.ts +56 -0
- package/tsconfig.json +18 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onDestroy } from "svelte";
|
|
3
|
+
import { initDb, type AppSchema, type TodoRecord } from "$lib/db";
|
|
4
|
+
import type { Database, Collection, LiveQuery } from "localstr/svelte";
|
|
5
|
+
|
|
6
|
+
let db: Database<AppSchema> | null = $state(null);
|
|
7
|
+
let todos: Collection<AppSchema["todos"]> | null = $state(null);
|
|
8
|
+
let incomplete: LiveQuery<TodoRecord> | null = $state(null);
|
|
9
|
+
let done: LiveQuery<TodoRecord> | null = $state(null);
|
|
10
|
+
|
|
11
|
+
let title = $state("");
|
|
12
|
+
let importKeyHex = $state("");
|
|
13
|
+
let loading = $state(true);
|
|
14
|
+
let initError = $state<string | null>(null);
|
|
15
|
+
let imported = $state(false);
|
|
16
|
+
let keyCopied = $state(false);
|
|
17
|
+
|
|
18
|
+
async function init() {
|
|
19
|
+
try {
|
|
20
|
+
const result = await initDb();
|
|
21
|
+
db = result.db;
|
|
22
|
+
imported = result.imported;
|
|
23
|
+
todos = db.collection("todos");
|
|
24
|
+
incomplete = todos.where("done").equals(false).live();
|
|
25
|
+
done = todos.where("done").equals(true).live();
|
|
26
|
+
loading = false;
|
|
27
|
+
} catch (e: unknown) {
|
|
28
|
+
initError = e instanceof Error ? e.message : String(e);
|
|
29
|
+
loading = false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
init();
|
|
34
|
+
|
|
35
|
+
async function addTodo(e: SubmitEvent) {
|
|
36
|
+
e.preventDefault();
|
|
37
|
+
if (!todos || !title.trim()) return;
|
|
38
|
+
await todos.add({ title: title.trim(), done: false, priority: 1 });
|
|
39
|
+
title = "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function toggle(id: string, currentDone: boolean) {
|
|
43
|
+
if (!todos) return;
|
|
44
|
+
await todos.update(id, { done: !currentDone });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function remove(id: string) {
|
|
48
|
+
if (!todos) return;
|
|
49
|
+
await todos.delete(id);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function sync() {
|
|
53
|
+
if (!db) return;
|
|
54
|
+
await db.sync();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function rebuild() {
|
|
58
|
+
if (!db) return;
|
|
59
|
+
await db.rebuild();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function copyKey() {
|
|
63
|
+
if (!db) return;
|
|
64
|
+
navigator.clipboard.writeText(db.exportKey()).then(
|
|
65
|
+
() => {
|
|
66
|
+
keyCopied = true;
|
|
67
|
+
setTimeout(() => (keyCopied = false), 2000);
|
|
68
|
+
},
|
|
69
|
+
() => {},
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function importKey() {
|
|
74
|
+
const hex = importKeyHex.trim().toLowerCase();
|
|
75
|
+
if (hex.length !== 64) return;
|
|
76
|
+
if (!/^[0-9a-f]+$/.test(hex)) return;
|
|
77
|
+
window.location.search = `?key=${hex}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
onDestroy(() => {
|
|
81
|
+
incomplete?.destroy();
|
|
82
|
+
done?.destroy();
|
|
83
|
+
db?.close();
|
|
84
|
+
});
|
|
85
|
+
</script>
|
|
86
|
+
|
|
87
|
+
<svelte:head>
|
|
88
|
+
<title>localstr Svelte Demo</title>
|
|
89
|
+
</svelte:head>
|
|
90
|
+
|
|
91
|
+
<main>
|
|
92
|
+
<h1>localstr Svelte Demo</h1>
|
|
93
|
+
|
|
94
|
+
{#if loading}
|
|
95
|
+
<p>Initializing database...</p>
|
|
96
|
+
{:else if initError}
|
|
97
|
+
<p class="error">Failed to initialize: {initError}</p>
|
|
98
|
+
{:else if todos && db}
|
|
99
|
+
<div class="key-section">
|
|
100
|
+
<label>Current key:</label>
|
|
101
|
+
<div class="key-display">{db.exportKey()}</div>
|
|
102
|
+
{#if imported}
|
|
103
|
+
<p class="imported-badge">Imported from URL</p>
|
|
104
|
+
{/if}
|
|
105
|
+
<div class="key-import">
|
|
106
|
+
<input
|
|
107
|
+
bind:value={importKeyHex}
|
|
108
|
+
placeholder="Paste hex private key to import..."
|
|
109
|
+
/>
|
|
110
|
+
<button onclick={importKey}>Import Key</button>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
{#if db.status === "syncing"}
|
|
115
|
+
<p class="sync-status">Syncing...</p>
|
|
116
|
+
{/if}
|
|
117
|
+
|
|
118
|
+
{#if todos.error}
|
|
119
|
+
<p class="error">{todos.error.message}</p>
|
|
120
|
+
{/if}
|
|
121
|
+
|
|
122
|
+
<form onsubmit={addTodo}>
|
|
123
|
+
<input bind:value={title} placeholder="Add a todo..." autofocus />
|
|
124
|
+
<button type="submit">Add</button>
|
|
125
|
+
</form>
|
|
126
|
+
|
|
127
|
+
<p class="count">{todos.items.length} total</p>
|
|
128
|
+
|
|
129
|
+
<h2>Todo ({incomplete?.items.length ?? 0})</h2>
|
|
130
|
+
<ul>
|
|
131
|
+
{#each incomplete?.items ?? [] as todo}
|
|
132
|
+
<li>
|
|
133
|
+
<label>
|
|
134
|
+
<input
|
|
135
|
+
type="checkbox"
|
|
136
|
+
checked={false}
|
|
137
|
+
onchange={() => toggle(todo.id, todo.done)}
|
|
138
|
+
/>
|
|
139
|
+
<span>{todo.title}</span>
|
|
140
|
+
</label>
|
|
141
|
+
<button class="delete" onclick={() => remove(todo.id)}
|
|
142
|
+
>✕</button
|
|
143
|
+
>
|
|
144
|
+
</li>
|
|
145
|
+
{/each}
|
|
146
|
+
</ul>
|
|
147
|
+
|
|
148
|
+
<h2>Done ({done?.items.length ?? 0})</h2>
|
|
149
|
+
<ul>
|
|
150
|
+
{#each done?.items ?? [] as todo}
|
|
151
|
+
<li class="done">
|
|
152
|
+
<label>
|
|
153
|
+
<input
|
|
154
|
+
type="checkbox"
|
|
155
|
+
checked={true}
|
|
156
|
+
onchange={() => toggle(todo.id, todo.done)}
|
|
157
|
+
/>
|
|
158
|
+
<span>{todo.title}</span>
|
|
159
|
+
</label>
|
|
160
|
+
<button class="delete" onclick={() => remove(todo.id)}
|
|
161
|
+
>✕</button
|
|
162
|
+
>
|
|
163
|
+
</li>
|
|
164
|
+
{/each}
|
|
165
|
+
</ul>
|
|
166
|
+
|
|
167
|
+
<div class="actions">
|
|
168
|
+
<button onclick={sync}>Sync</button>
|
|
169
|
+
<button onclick={rebuild}>Rebuild</button>
|
|
170
|
+
<button onclick={copyKey}
|
|
171
|
+
>{keyCopied ? "Copied!" : "Copy Key"}</button
|
|
172
|
+
>
|
|
173
|
+
</div>
|
|
174
|
+
{/if}
|
|
175
|
+
</main>
|
|
176
|
+
|
|
177
|
+
<style>
|
|
178
|
+
* {
|
|
179
|
+
box-sizing: border-box;
|
|
180
|
+
margin: 0;
|
|
181
|
+
padding: 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
main {
|
|
185
|
+
font-family: system-ui, sans-serif;
|
|
186
|
+
max-width: 600px;
|
|
187
|
+
margin: 2rem auto;
|
|
188
|
+
padding: 0 1rem;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
h1 {
|
|
192
|
+
margin-bottom: 1rem;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
h2 {
|
|
196
|
+
margin: 1rem 0 0.5rem;
|
|
197
|
+
font-size: 1rem;
|
|
198
|
+
color: #666;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
form {
|
|
202
|
+
display: flex;
|
|
203
|
+
gap: 0.5rem;
|
|
204
|
+
margin-bottom: 1rem;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
input[type="text"] {
|
|
208
|
+
flex: 1;
|
|
209
|
+
padding: 0.5rem;
|
|
210
|
+
font-size: 1rem;
|
|
211
|
+
border: 1px solid #ccc;
|
|
212
|
+
border-radius: 4px;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
input[type="checkbox"] {
|
|
216
|
+
flex: none;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
button {
|
|
220
|
+
padding: 0.5rem 1rem;
|
|
221
|
+
font-size: 1rem;
|
|
222
|
+
cursor: pointer;
|
|
223
|
+
border: 1px solid #ccc;
|
|
224
|
+
border-radius: 4px;
|
|
225
|
+
background: #f5f5f5;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
button:hover {
|
|
229
|
+
background: #e0e0e0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
ul {
|
|
233
|
+
list-style: none;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
li {
|
|
237
|
+
display: flex;
|
|
238
|
+
align-items: center;
|
|
239
|
+
gap: 0.5rem;
|
|
240
|
+
padding: 0.5rem 0;
|
|
241
|
+
border-bottom: 1px solid #eee;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
li label {
|
|
245
|
+
flex: 1;
|
|
246
|
+
display: flex;
|
|
247
|
+
align-items: center;
|
|
248
|
+
gap: 0.5rem;
|
|
249
|
+
cursor: pointer;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
li.done span {
|
|
253
|
+
opacity: 0.5;
|
|
254
|
+
text-decoration: line-through;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.delete {
|
|
258
|
+
padding: 0.25rem 0.5rem;
|
|
259
|
+
font-size: 0.875rem;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.count {
|
|
263
|
+
color: #666;
|
|
264
|
+
margin-bottom: 0.5rem;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.actions {
|
|
268
|
+
display: flex;
|
|
269
|
+
gap: 0.5rem;
|
|
270
|
+
margin-top: 1.5rem;
|
|
271
|
+
flex-wrap: wrap;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.error {
|
|
275
|
+
color: #c00;
|
|
276
|
+
margin-bottom: 1rem;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.sync-status {
|
|
280
|
+
color: #06c;
|
|
281
|
+
margin-bottom: 0.5rem;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.key-section {
|
|
285
|
+
margin-bottom: 1rem;
|
|
286
|
+
padding: 0.75rem;
|
|
287
|
+
background: #f9f9f9;
|
|
288
|
+
border: 1px solid #ddd;
|
|
289
|
+
border-radius: 4px;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.key-section label {
|
|
293
|
+
display: block;
|
|
294
|
+
font-size: 0.85rem;
|
|
295
|
+
color: #666;
|
|
296
|
+
margin-bottom: 0.25rem;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.key-display {
|
|
300
|
+
font-family: monospace;
|
|
301
|
+
font-size: 0.8rem;
|
|
302
|
+
word-break: break-all;
|
|
303
|
+
color: #333;
|
|
304
|
+
margin-bottom: 0.5rem;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.key-import {
|
|
308
|
+
display: flex;
|
|
309
|
+
gap: 0.5rem;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
.key-import input {
|
|
313
|
+
font-family: monospace;
|
|
314
|
+
font-size: 0.8rem;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.imported-badge {
|
|
318
|
+
font-size: 0.8rem;
|
|
319
|
+
color: #06c;
|
|
320
|
+
margin-bottom: 0.5rem;
|
|
321
|
+
}
|
|
322
|
+
</style>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import adapter from "@sveltejs/adapter-static";
|
|
2
|
+
|
|
3
|
+
/** @type {import('@sveltejs/kit').Config} */
|
|
4
|
+
const config = {
|
|
5
|
+
kit: {
|
|
6
|
+
adapter: adapter({
|
|
7
|
+
fallback: "index.html",
|
|
8
|
+
}),
|
|
9
|
+
alias: {
|
|
10
|
+
"localstr/svelte": "../../src/svelte/index.svelte.ts",
|
|
11
|
+
localstr: "../../src/index.ts",
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default config;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { Effect, Stream } from "effect";
|
|
2
|
+
import { field, collection, createLocalstr } from "../../src/index.ts";
|
|
3
|
+
|
|
4
|
+
// Read key from URL if present: ?key=<hex>
|
|
5
|
+
function getKeyFromUrl(): Uint8Array | undefined {
|
|
6
|
+
const params = new URLSearchParams(window.location.search);
|
|
7
|
+
const hex = params.get("key");
|
|
8
|
+
if (!hex || hex.length !== 64) return undefined;
|
|
9
|
+
const bytes = new Uint8Array(32);
|
|
10
|
+
for (let i = 0; i < 32; i++) {
|
|
11
|
+
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
12
|
+
}
|
|
13
|
+
return bytes;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const log = (msg: string) => {
|
|
17
|
+
const el = document.getElementById("log")!;
|
|
18
|
+
el.textContent += msg + "\n";
|
|
19
|
+
el.scrollTop = el.scrollHeight;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const app = Effect.gen(function* () {
|
|
23
|
+
const todos = collection(
|
|
24
|
+
"todos",
|
|
25
|
+
{
|
|
26
|
+
title: field.string(),
|
|
27
|
+
done: field.boolean(),
|
|
28
|
+
priority: field.number(),
|
|
29
|
+
},
|
|
30
|
+
{ indices: ["done", "priority"] },
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const importedKey = getKeyFromUrl();
|
|
34
|
+
// Use a unique DB name per key so imported keys get a fresh DB
|
|
35
|
+
const dbSuffix = importedKey ? "-imported" : "";
|
|
36
|
+
|
|
37
|
+
const db = yield* createLocalstr({
|
|
38
|
+
schema: { todos },
|
|
39
|
+
relays: ["wss://relay.nostr.place"],
|
|
40
|
+
dbName: `localstr-demo${dbSuffix}`,
|
|
41
|
+
privateKey: importedKey,
|
|
42
|
+
onSyncError: (err) => {
|
|
43
|
+
log(`Sync error: ${err.message}`);
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const col = db.collection("todos");
|
|
48
|
+
const fullKey = db.exportKey();
|
|
49
|
+
|
|
50
|
+
// Show key in UI
|
|
51
|
+
document.getElementById("key-display")!.textContent = fullKey;
|
|
52
|
+
if (importedKey) {
|
|
53
|
+
log("Imported key from URL");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Wire up UI
|
|
57
|
+
const form = document.getElementById("add-form") as HTMLFormElement;
|
|
58
|
+
form.addEventListener("submit", (e) => {
|
|
59
|
+
e.preventDefault();
|
|
60
|
+
const titleInput = document.getElementById("title") as HTMLInputElement;
|
|
61
|
+
const title = titleInput.value.trim();
|
|
62
|
+
if (!title) return;
|
|
63
|
+
|
|
64
|
+
Effect.runPromise(
|
|
65
|
+
Effect.gen(function* () {
|
|
66
|
+
const id = yield* col.add({ title, done: false, priority: 1 } as any);
|
|
67
|
+
log(`Added: "${title}" (${id})`);
|
|
68
|
+
titleInput.value = "";
|
|
69
|
+
yield* renderTodos();
|
|
70
|
+
}),
|
|
71
|
+
).catch((err) => {
|
|
72
|
+
log(`Error: ${err}`);
|
|
73
|
+
if (err?.cause) log(`Cause: ${err.cause}`);
|
|
74
|
+
console.error(err);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const renderTodos = () =>
|
|
79
|
+
Effect.gen(function* () {
|
|
80
|
+
const all = yield* col.where("done").equals(false).sortBy("priority").get();
|
|
81
|
+
const done = yield* col.where("done").equals(true).get();
|
|
82
|
+
const count = yield* col.count();
|
|
83
|
+
|
|
84
|
+
const list = document.getElementById("todos")!;
|
|
85
|
+
list.innerHTML = "";
|
|
86
|
+
|
|
87
|
+
for (const todo of all) {
|
|
88
|
+
const li = document.createElement("li");
|
|
89
|
+
li.innerHTML = `
|
|
90
|
+
<span>${todo.title}</span>
|
|
91
|
+
<button data-id="${todo.id}" data-action="done">✓</button>
|
|
92
|
+
<button data-id="${todo.id}" data-action="delete">✕</button>
|
|
93
|
+
`;
|
|
94
|
+
list.appendChild(li);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const todo of done) {
|
|
98
|
+
const li = document.createElement("li");
|
|
99
|
+
li.style.opacity = "0.5";
|
|
100
|
+
li.style.textDecoration = "line-through";
|
|
101
|
+
li.innerHTML = `
|
|
102
|
+
<span>${todo.title}</span>
|
|
103
|
+
<button data-id="${todo.id}" data-action="delete">✕</button>
|
|
104
|
+
`;
|
|
105
|
+
list.appendChild(li);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
document.getElementById("count")!.textContent = `${count} total`;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Handle todo actions (done/delete)
|
|
112
|
+
document.getElementById("todos")!.addEventListener("click", (e) => {
|
|
113
|
+
const btn = (e.target as HTMLElement).closest("button");
|
|
114
|
+
if (!btn) return;
|
|
115
|
+
const id = btn.dataset.id!;
|
|
116
|
+
const action = btn.dataset.action!;
|
|
117
|
+
|
|
118
|
+
const effect =
|
|
119
|
+
action === "done"
|
|
120
|
+
? Effect.gen(function* () {
|
|
121
|
+
yield* col.update(id, { done: true } as any);
|
|
122
|
+
log(`Completed: ${id}`);
|
|
123
|
+
yield* renderTodos();
|
|
124
|
+
})
|
|
125
|
+
: Effect.gen(function* () {
|
|
126
|
+
yield* col.delete(id);
|
|
127
|
+
log(`Deleted: ${id}`);
|
|
128
|
+
yield* renderTodos();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
Effect.runPromise(effect).catch((err) => {
|
|
132
|
+
log(`Error: ${err}`);
|
|
133
|
+
if (err?.cause) log(`Cause: ${err.cause}`);
|
|
134
|
+
console.error(err);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Sync button
|
|
139
|
+
document.getElementById("sync-btn")!.addEventListener("click", () => {
|
|
140
|
+
log("Syncing...");
|
|
141
|
+
Effect.runPromise(
|
|
142
|
+
Effect.gen(function* () {
|
|
143
|
+
yield* db.sync();
|
|
144
|
+
log("Sync complete");
|
|
145
|
+
yield* renderTodos();
|
|
146
|
+
}),
|
|
147
|
+
).catch((err) => {
|
|
148
|
+
log(`Sync error: ${err}`);
|
|
149
|
+
if (err?.cause) log(`Cause: ${err.cause}`);
|
|
150
|
+
console.error(err);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Rebuild button
|
|
155
|
+
document.getElementById("rebuild-btn")!.addEventListener("click", () => {
|
|
156
|
+
Effect.runPromise(
|
|
157
|
+
Effect.gen(function* () {
|
|
158
|
+
yield* db.rebuild();
|
|
159
|
+
log("Rebuilt records from events");
|
|
160
|
+
yield* renderTodos();
|
|
161
|
+
}),
|
|
162
|
+
).catch((err) => {
|
|
163
|
+
log(`Error: ${err}`);
|
|
164
|
+
if (err?.cause) log(`Cause: ${err.cause}`);
|
|
165
|
+
console.error(err);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Copy key button
|
|
170
|
+
document.getElementById("copy-btn")!.addEventListener("click", () => {
|
|
171
|
+
navigator.clipboard.writeText(fullKey).then(
|
|
172
|
+
() => log("Key copied to clipboard"),
|
|
173
|
+
() => log(`Key: ${fullKey}`),
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Import key button — reload page with key in URL
|
|
178
|
+
document.getElementById("import-btn")!.addEventListener("click", () => {
|
|
179
|
+
const input = document.getElementById("import-key") as HTMLInputElement;
|
|
180
|
+
const hex = input.value.trim();
|
|
181
|
+
if (hex.length !== 64) {
|
|
182
|
+
log("Error: key must be 64 hex characters");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (!/^[0-9a-f]+$/i.test(hex)) {
|
|
186
|
+
log("Error: key must be valid hex");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
window.location.search = `?key=${hex.toLowerCase()}`;
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
log("localstr initialized");
|
|
193
|
+
log(`Key: ${fullKey.slice(0, 16)}...`);
|
|
194
|
+
|
|
195
|
+
yield* renderTodos();
|
|
196
|
+
|
|
197
|
+
// Watch for changes (local + remote) and re-render automatically
|
|
198
|
+
yield* col.watch().pipe(
|
|
199
|
+
Stream.runForEach((todos) =>
|
|
200
|
+
Effect.gen(function* () {
|
|
201
|
+
console.log("[localstr:watch] todos changed:", todos);
|
|
202
|
+
yield* renderTodos();
|
|
203
|
+
}).pipe(
|
|
204
|
+
Effect.catch((_e) =>
|
|
205
|
+
Effect.sync(() => console.error("[localstr:watch] render error:", _e)),
|
|
206
|
+
),
|
|
207
|
+
),
|
|
208
|
+
),
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
Effect.runPromise(Effect.scoped(app)).catch((err) => {
|
|
213
|
+
document.getElementById("log")!.textContent += `Fatal: ${err}\n`;
|
|
214
|
+
if (err?.cause) {
|
|
215
|
+
document.getElementById("log")!.textContent += `Cause: ${err.cause}\n`;
|
|
216
|
+
console.error("Cause:", err.cause);
|
|
217
|
+
}
|
|
218
|
+
console.error(err);
|
|
219
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
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>localstr demo</title>
|
|
7
|
+
<style>
|
|
8
|
+
* {
|
|
9
|
+
box-sizing: border-box;
|
|
10
|
+
margin: 0;
|
|
11
|
+
padding: 0;
|
|
12
|
+
}
|
|
13
|
+
body {
|
|
14
|
+
font-family: system-ui, sans-serif;
|
|
15
|
+
max-width: 600px;
|
|
16
|
+
margin: 2rem auto;
|
|
17
|
+
padding: 0 1rem;
|
|
18
|
+
}
|
|
19
|
+
h1 {
|
|
20
|
+
margin-bottom: 1rem;
|
|
21
|
+
}
|
|
22
|
+
form {
|
|
23
|
+
display: flex;
|
|
24
|
+
gap: 0.5rem;
|
|
25
|
+
margin-bottom: 1rem;
|
|
26
|
+
}
|
|
27
|
+
input {
|
|
28
|
+
flex: 1;
|
|
29
|
+
padding: 0.5rem;
|
|
30
|
+
font-size: 1rem;
|
|
31
|
+
border: 1px solid #ccc;
|
|
32
|
+
border-radius: 4px;
|
|
33
|
+
}
|
|
34
|
+
button {
|
|
35
|
+
padding: 0.5rem 1rem;
|
|
36
|
+
font-size: 1rem;
|
|
37
|
+
cursor: pointer;
|
|
38
|
+
border: 1px solid #ccc;
|
|
39
|
+
border-radius: 4px;
|
|
40
|
+
background: #f5f5f5;
|
|
41
|
+
}
|
|
42
|
+
button:hover {
|
|
43
|
+
background: #e0e0e0;
|
|
44
|
+
}
|
|
45
|
+
ul {
|
|
46
|
+
list-style: none;
|
|
47
|
+
margin-bottom: 1rem;
|
|
48
|
+
}
|
|
49
|
+
li {
|
|
50
|
+
display: flex;
|
|
51
|
+
align-items: center;
|
|
52
|
+
gap: 0.5rem;
|
|
53
|
+
padding: 0.5rem 0;
|
|
54
|
+
border-bottom: 1px solid #eee;
|
|
55
|
+
}
|
|
56
|
+
li span {
|
|
57
|
+
flex: 1;
|
|
58
|
+
}
|
|
59
|
+
li button {
|
|
60
|
+
padding: 0.25rem 0.5rem;
|
|
61
|
+
font-size: 0.875rem;
|
|
62
|
+
}
|
|
63
|
+
#count {
|
|
64
|
+
color: #666;
|
|
65
|
+
margin-bottom: 1rem;
|
|
66
|
+
}
|
|
67
|
+
.actions {
|
|
68
|
+
display: flex;
|
|
69
|
+
gap: 0.5rem;
|
|
70
|
+
margin-bottom: 1rem;
|
|
71
|
+
flex-wrap: wrap;
|
|
72
|
+
}
|
|
73
|
+
#log {
|
|
74
|
+
background: #1a1a1a;
|
|
75
|
+
color: #0f0;
|
|
76
|
+
padding: 1rem;
|
|
77
|
+
border-radius: 4px;
|
|
78
|
+
font-family: monospace;
|
|
79
|
+
font-size: 0.8rem;
|
|
80
|
+
white-space: pre-wrap;
|
|
81
|
+
max-height: 200px;
|
|
82
|
+
overflow-y: auto;
|
|
83
|
+
}
|
|
84
|
+
.key-section {
|
|
85
|
+
margin-bottom: 1rem;
|
|
86
|
+
padding: 0.75rem;
|
|
87
|
+
background: #f9f9f9;
|
|
88
|
+
border: 1px solid #ddd;
|
|
89
|
+
border-radius: 4px;
|
|
90
|
+
}
|
|
91
|
+
.key-section label {
|
|
92
|
+
display: block;
|
|
93
|
+
font-size: 0.85rem;
|
|
94
|
+
color: #666;
|
|
95
|
+
margin-bottom: 0.25rem;
|
|
96
|
+
}
|
|
97
|
+
.key-display {
|
|
98
|
+
font-family: monospace;
|
|
99
|
+
font-size: 0.8rem;
|
|
100
|
+
word-break: break-all;
|
|
101
|
+
color: #333;
|
|
102
|
+
margin-bottom: 0.5rem;
|
|
103
|
+
}
|
|
104
|
+
.key-import {
|
|
105
|
+
display: flex;
|
|
106
|
+
gap: 0.5rem;
|
|
107
|
+
}
|
|
108
|
+
.key-import input {
|
|
109
|
+
font-family: monospace;
|
|
110
|
+
font-size: 0.8rem;
|
|
111
|
+
}
|
|
112
|
+
</style>
|
|
113
|
+
</head>
|
|
114
|
+
<body>
|
|
115
|
+
<h1>localstr demo</h1>
|
|
116
|
+
|
|
117
|
+
<div class="key-section">
|
|
118
|
+
<label>Current key:</label>
|
|
119
|
+
<div class="key-display" id="key-display">loading...</div>
|
|
120
|
+
<div class="key-import">
|
|
121
|
+
<input id="import-key" type="text" placeholder="Paste hex private key to import..." />
|
|
122
|
+
<button id="import-btn">Import Key</button>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<form id="add-form">
|
|
127
|
+
<input id="title" type="text" placeholder="Add a todo..." autofocus />
|
|
128
|
+
<button type="submit">Add</button>
|
|
129
|
+
</form>
|
|
130
|
+
|
|
131
|
+
<div id="count"></div>
|
|
132
|
+
<ul id="todos"></ul>
|
|
133
|
+
|
|
134
|
+
<div class="actions">
|
|
135
|
+
<button id="sync-btn">Sync</button>
|
|
136
|
+
<button id="rebuild-btn">Rebuild</button>
|
|
137
|
+
<button id="copy-btn">Copy Key</button>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<div id="log"></div>
|
|
141
|
+
|
|
142
|
+
<script type="module" src="./app.js"></script>
|
|
143
|
+
</body>
|
|
144
|
+
</html>
|