nostr-git-client 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/demo.html +836 -0
- package/package.json +1 -1
- package/src/utils.js +17 -1
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@ Browser-based git sync via Nostr with optional Bitcoin anchoring.
|
|
|
4
4
|
|
|
5
5
|
Subscribe to NIP-34 repo state events (kind 30618) and sync git repositories to browser IndexedDB using isomorphic-git.
|
|
6
6
|
|
|
7
|
+
**[Try the Demo](https://javascriptsolidserver.github.io/nostr-git-client/demo.html)**
|
|
8
|
+
|
|
7
9
|
## Installation
|
|
8
10
|
|
|
9
11
|
```bash
|
package/demo.html
ADDED
|
@@ -0,0 +1,836 @@
|
|
|
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>Live Notes - Nostr Git Demo</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { box-sizing: border-box; }
|
|
9
|
+
body {
|
|
10
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
11
|
+
margin: 0;
|
|
12
|
+
background: #0f0f1a;
|
|
13
|
+
color: #e0e0e0;
|
|
14
|
+
min-height: 100vh;
|
|
15
|
+
}
|
|
16
|
+
.container {
|
|
17
|
+
max-width: 900px;
|
|
18
|
+
margin: 0 auto;
|
|
19
|
+
padding: 20px;
|
|
20
|
+
}
|
|
21
|
+
header {
|
|
22
|
+
text-align: center;
|
|
23
|
+
padding: 40px 20px;
|
|
24
|
+
border-bottom: 1px solid #2a2a3a;
|
|
25
|
+
margin-bottom: 30px;
|
|
26
|
+
}
|
|
27
|
+
h1 {
|
|
28
|
+
color: #a78bfa;
|
|
29
|
+
margin: 0 0 10px 0;
|
|
30
|
+
font-size: 2.5rem;
|
|
31
|
+
}
|
|
32
|
+
.tagline {
|
|
33
|
+
color: #888;
|
|
34
|
+
font-size: 1.1rem;
|
|
35
|
+
}
|
|
36
|
+
.status-row {
|
|
37
|
+
display: flex;
|
|
38
|
+
justify-content: center;
|
|
39
|
+
align-items: center;
|
|
40
|
+
gap: 15px;
|
|
41
|
+
margin-top: 15px;
|
|
42
|
+
flex-wrap: wrap;
|
|
43
|
+
}
|
|
44
|
+
.status {
|
|
45
|
+
display: inline-flex;
|
|
46
|
+
align-items: center;
|
|
47
|
+
gap: 8px;
|
|
48
|
+
padding: 6px 14px;
|
|
49
|
+
background: #1a1a2e;
|
|
50
|
+
border-radius: 20px;
|
|
51
|
+
font-size: 13px;
|
|
52
|
+
}
|
|
53
|
+
.dot {
|
|
54
|
+
width: 8px;
|
|
55
|
+
height: 8px;
|
|
56
|
+
border-radius: 50%;
|
|
57
|
+
background: #666;
|
|
58
|
+
}
|
|
59
|
+
.dot.syncing { background: #f59e0b; animation: pulse 1s infinite; }
|
|
60
|
+
.dot.synced { background: #10b981; }
|
|
61
|
+
.dot.error { background: #ef4444; }
|
|
62
|
+
@keyframes pulse { 50% { opacity: 0.5; } }
|
|
63
|
+
|
|
64
|
+
.edit-mode-toggle {
|
|
65
|
+
display: inline-flex;
|
|
66
|
+
align-items: center;
|
|
67
|
+
gap: 8px;
|
|
68
|
+
padding: 6px 14px;
|
|
69
|
+
background: #1a1a2e;
|
|
70
|
+
border-radius: 20px;
|
|
71
|
+
font-size: 13px;
|
|
72
|
+
cursor: pointer;
|
|
73
|
+
border: 1px solid #2a2a3a;
|
|
74
|
+
}
|
|
75
|
+
.edit-mode-toggle:hover { border-color: #a78bfa; }
|
|
76
|
+
.edit-mode-toggle.active {
|
|
77
|
+
background: #7c3aed;
|
|
78
|
+
border-color: #7c3aed;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.key-panel {
|
|
82
|
+
display: none;
|
|
83
|
+
background: #1a1a2e;
|
|
84
|
+
border-radius: 12px;
|
|
85
|
+
padding: 20px;
|
|
86
|
+
margin-bottom: 20px;
|
|
87
|
+
border: 1px solid #2a2a3a;
|
|
88
|
+
}
|
|
89
|
+
.key-panel.show { display: block; }
|
|
90
|
+
.key-panel label {
|
|
91
|
+
display: block;
|
|
92
|
+
color: #888;
|
|
93
|
+
font-size: 13px;
|
|
94
|
+
margin-bottom: 8px;
|
|
95
|
+
}
|
|
96
|
+
.key-panel input {
|
|
97
|
+
width: 100%;
|
|
98
|
+
padding: 10px;
|
|
99
|
+
background: #0f0f1a;
|
|
100
|
+
border: 1px solid #2a2a3a;
|
|
101
|
+
border-radius: 6px;
|
|
102
|
+
color: #fff;
|
|
103
|
+
font-family: monospace;
|
|
104
|
+
font-size: 13px;
|
|
105
|
+
}
|
|
106
|
+
.key-panel input:focus {
|
|
107
|
+
outline: none;
|
|
108
|
+
border-color: #7c3aed;
|
|
109
|
+
}
|
|
110
|
+
.key-panel .hint {
|
|
111
|
+
color: #666;
|
|
112
|
+
font-size: 12px;
|
|
113
|
+
margin-top: 8px;
|
|
114
|
+
}
|
|
115
|
+
.key-panel .pubkey {
|
|
116
|
+
color: #10b981;
|
|
117
|
+
font-family: monospace;
|
|
118
|
+
font-size: 12px;
|
|
119
|
+
margin-top: 8px;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.notes-list {
|
|
123
|
+
display: grid;
|
|
124
|
+
gap: 15px;
|
|
125
|
+
margin-bottom: 30px;
|
|
126
|
+
}
|
|
127
|
+
.note-card {
|
|
128
|
+
background: #1a1a2e;
|
|
129
|
+
border-radius: 12px;
|
|
130
|
+
padding: 20px;
|
|
131
|
+
cursor: pointer;
|
|
132
|
+
border: 1px solid #2a2a3a;
|
|
133
|
+
transition: all 0.2s;
|
|
134
|
+
}
|
|
135
|
+
.note-card:hover {
|
|
136
|
+
border-color: #a78bfa;
|
|
137
|
+
transform: translateY(-2px);
|
|
138
|
+
}
|
|
139
|
+
.note-card h3 {
|
|
140
|
+
margin: 0 0 8px 0;
|
|
141
|
+
color: #fff;
|
|
142
|
+
}
|
|
143
|
+
.note-card p {
|
|
144
|
+
margin: 0;
|
|
145
|
+
color: #888;
|
|
146
|
+
font-size: 14px;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.new-note-btn {
|
|
150
|
+
background: #7c3aed;
|
|
151
|
+
color: white;
|
|
152
|
+
border: none;
|
|
153
|
+
padding: 15px 20px;
|
|
154
|
+
border-radius: 12px;
|
|
155
|
+
cursor: pointer;
|
|
156
|
+
font-size: 14px;
|
|
157
|
+
width: 100%;
|
|
158
|
+
margin-bottom: 15px;
|
|
159
|
+
display: none;
|
|
160
|
+
}
|
|
161
|
+
.new-note-btn:hover { background: #6d28d9; }
|
|
162
|
+
.new-note-btn.show { display: block; }
|
|
163
|
+
|
|
164
|
+
.note-view {
|
|
165
|
+
display: none;
|
|
166
|
+
background: #1a1a2e;
|
|
167
|
+
border-radius: 12px;
|
|
168
|
+
padding: 30px;
|
|
169
|
+
border: 1px solid #2a2a3a;
|
|
170
|
+
}
|
|
171
|
+
.note-view.active { display: block; }
|
|
172
|
+
|
|
173
|
+
.note-header {
|
|
174
|
+
display: flex;
|
|
175
|
+
justify-content: space-between;
|
|
176
|
+
align-items: center;
|
|
177
|
+
margin-bottom: 20px;
|
|
178
|
+
}
|
|
179
|
+
.back-btn, .edit-btn, .save-btn, .cancel-btn {
|
|
180
|
+
background: none;
|
|
181
|
+
border: 1px solid #444;
|
|
182
|
+
color: #888;
|
|
183
|
+
padding: 8px 16px;
|
|
184
|
+
border-radius: 6px;
|
|
185
|
+
cursor: pointer;
|
|
186
|
+
font-size: 14px;
|
|
187
|
+
}
|
|
188
|
+
.back-btn:hover, .edit-btn:hover, .cancel-btn:hover {
|
|
189
|
+
border-color: #a78bfa;
|
|
190
|
+
color: #a78bfa;
|
|
191
|
+
}
|
|
192
|
+
.save-btn {
|
|
193
|
+
background: #7c3aed;
|
|
194
|
+
border-color: #7c3aed;
|
|
195
|
+
color: white;
|
|
196
|
+
}
|
|
197
|
+
.save-btn:hover { background: #6d28d9; }
|
|
198
|
+
.save-btn:disabled {
|
|
199
|
+
background: #444;
|
|
200
|
+
border-color: #444;
|
|
201
|
+
cursor: not-allowed;
|
|
202
|
+
}
|
|
203
|
+
.edit-btn { display: none; }
|
|
204
|
+
.edit-btn.show { display: inline-block; }
|
|
205
|
+
.btn-group { display: flex; gap: 10px; }
|
|
206
|
+
|
|
207
|
+
.note-editor {
|
|
208
|
+
display: none;
|
|
209
|
+
width: 100%;
|
|
210
|
+
min-height: 400px;
|
|
211
|
+
padding: 15px;
|
|
212
|
+
background: #0f0f1a;
|
|
213
|
+
border: 1px solid #2a2a3a;
|
|
214
|
+
border-radius: 8px;
|
|
215
|
+
color: #e0e0e0;
|
|
216
|
+
font-family: 'Monaco', 'Menlo', monospace;
|
|
217
|
+
font-size: 14px;
|
|
218
|
+
line-height: 1.6;
|
|
219
|
+
resize: vertical;
|
|
220
|
+
}
|
|
221
|
+
.note-editor:focus {
|
|
222
|
+
outline: none;
|
|
223
|
+
border-color: #7c3aed;
|
|
224
|
+
}
|
|
225
|
+
.note-editor.active { display: block; }
|
|
226
|
+
|
|
227
|
+
.filename-input {
|
|
228
|
+
width: 100%;
|
|
229
|
+
padding: 10px;
|
|
230
|
+
background: #0f0f1a;
|
|
231
|
+
border: 1px solid #2a2a3a;
|
|
232
|
+
border-radius: 6px;
|
|
233
|
+
color: #fff;
|
|
234
|
+
font-size: 14px;
|
|
235
|
+
margin-bottom: 15px;
|
|
236
|
+
}
|
|
237
|
+
.filename-input:focus {
|
|
238
|
+
outline: none;
|
|
239
|
+
border-color: #7c3aed;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* Markdown styles */
|
|
243
|
+
.markdown h1 { color: #a78bfa; border-bottom: 1px solid #2a2a3a; padding-bottom: 10px; }
|
|
244
|
+
.markdown h2 { color: #8b5cf6; margin-top: 30px; }
|
|
245
|
+
.markdown h3 { color: #7c3aed; }
|
|
246
|
+
.markdown p { line-height: 1.7; }
|
|
247
|
+
.markdown a { color: #60a5fa; }
|
|
248
|
+
.markdown code {
|
|
249
|
+
background: #0f0f1a;
|
|
250
|
+
padding: 2px 6px;
|
|
251
|
+
border-radius: 4px;
|
|
252
|
+
font-family: 'Monaco', monospace;
|
|
253
|
+
font-size: 0.9em;
|
|
254
|
+
}
|
|
255
|
+
.markdown pre {
|
|
256
|
+
background: #0f0f1a;
|
|
257
|
+
padding: 15px;
|
|
258
|
+
border-radius: 8px;
|
|
259
|
+
overflow-x: auto;
|
|
260
|
+
}
|
|
261
|
+
.markdown pre code { background: none; padding: 0; }
|
|
262
|
+
.markdown blockquote {
|
|
263
|
+
border-left: 3px solid #a78bfa;
|
|
264
|
+
margin: 20px 0;
|
|
265
|
+
padding-left: 20px;
|
|
266
|
+
color: #aaa;
|
|
267
|
+
font-style: italic;
|
|
268
|
+
}
|
|
269
|
+
.markdown ul, .markdown ol {
|
|
270
|
+
padding-left: 25px;
|
|
271
|
+
line-height: 1.8;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.update-toast {
|
|
275
|
+
position: fixed;
|
|
276
|
+
bottom: 20px;
|
|
277
|
+
right: 20px;
|
|
278
|
+
background: #7c3aed;
|
|
279
|
+
color: white;
|
|
280
|
+
padding: 12px 20px;
|
|
281
|
+
border-radius: 8px;
|
|
282
|
+
display: none;
|
|
283
|
+
animation: slideIn 0.3s;
|
|
284
|
+
}
|
|
285
|
+
.update-toast.show { display: block; }
|
|
286
|
+
@keyframes slideIn {
|
|
287
|
+
from { transform: translateY(100px); opacity: 0; }
|
|
288
|
+
to { transform: translateY(0); opacity: 1; }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.last-updated {
|
|
292
|
+
color: #666;
|
|
293
|
+
font-size: 12px;
|
|
294
|
+
margin-top: 10px;
|
|
295
|
+
text-align: center;
|
|
296
|
+
}
|
|
297
|
+
.last-updated .time { color: #10b981; font-weight: 500; }
|
|
298
|
+
|
|
299
|
+
.footer {
|
|
300
|
+
text-align: center;
|
|
301
|
+
padding: 30px;
|
|
302
|
+
color: #555;
|
|
303
|
+
font-size: 13px;
|
|
304
|
+
}
|
|
305
|
+
.footer a { color: #a78bfa; }
|
|
306
|
+
|
|
307
|
+
.saving-overlay {
|
|
308
|
+
position: fixed;
|
|
309
|
+
top: 0; left: 0; right: 0; bottom: 0;
|
|
310
|
+
background: rgba(0,0,0,0.7);
|
|
311
|
+
display: none;
|
|
312
|
+
justify-content: center;
|
|
313
|
+
align-items: center;
|
|
314
|
+
z-index: 100;
|
|
315
|
+
}
|
|
316
|
+
.saving-overlay.show { display: flex; }
|
|
317
|
+
.saving-box {
|
|
318
|
+
background: #1a1a2e;
|
|
319
|
+
padding: 30px 50px;
|
|
320
|
+
border-radius: 12px;
|
|
321
|
+
text-align: center;
|
|
322
|
+
}
|
|
323
|
+
.saving-box .spinner {
|
|
324
|
+
width: 30px;
|
|
325
|
+
height: 30px;
|
|
326
|
+
border: 3px solid #333;
|
|
327
|
+
border-top-color: #7c3aed;
|
|
328
|
+
border-radius: 50%;
|
|
329
|
+
animation: spin 1s linear infinite;
|
|
330
|
+
margin: 0 auto 15px;
|
|
331
|
+
}
|
|
332
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
333
|
+
</style>
|
|
334
|
+
</head>
|
|
335
|
+
<body>
|
|
336
|
+
<div class="container">
|
|
337
|
+
<header>
|
|
338
|
+
<h1>📝 Live Notes</h1>
|
|
339
|
+
<p class="tagline">Git-synced notes via Nostr - updates appear automatically</p>
|
|
340
|
+
<div class="status-row">
|
|
341
|
+
<div class="status">
|
|
342
|
+
<span class="dot" id="statusDot"></span>
|
|
343
|
+
<span id="statusText">Connecting...</span>
|
|
344
|
+
</div>
|
|
345
|
+
<div class="edit-mode-toggle" id="editToggle" onclick="toggleEditMode()">
|
|
346
|
+
✏️ Edit Mode
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
<div id="lastUpdated" class="last-updated"></div>
|
|
350
|
+
</header>
|
|
351
|
+
|
|
352
|
+
<div id="keyPanel" class="key-panel">
|
|
353
|
+
<label>Nostr Private Key (hex)</label>
|
|
354
|
+
<input type="password" id="privkeyInput" placeholder="Enter your private key to enable editing..." oninput="onPrivkeyChange()">
|
|
355
|
+
<div class="hint">Your key is stored locally and used to sign commits and Nostr events.<br>
|
|
356
|
+
For testing: <code style="font-size:11px;color:#a78bfa;">07f689807708ca937e1fdbea750ea70802fdec379f890306f6dc4716d0798948</code></div>
|
|
357
|
+
<div id="pubkeyDisplay" class="pubkey"></div>
|
|
358
|
+
</div>
|
|
359
|
+
|
|
360
|
+
<button id="newNoteBtn" class="new-note-btn" onclick="newNote()">+ New Note</button>
|
|
361
|
+
|
|
362
|
+
<div id="notesList" class="notes-list"></div>
|
|
363
|
+
|
|
364
|
+
<div id="noteView" class="note-view">
|
|
365
|
+
<div class="note-header">
|
|
366
|
+
<button class="back-btn" onclick="showList()">← Back</button>
|
|
367
|
+
<div class="btn-group">
|
|
368
|
+
<button id="editBtn" class="edit-btn" onclick="startEdit()">✏️ Edit</button>
|
|
369
|
+
<button id="cancelBtn" class="cancel-btn" style="display:none" onclick="cancelEdit()">Cancel</button>
|
|
370
|
+
<button id="saveBtn" class="save-btn" style="display:none" onclick="saveNote()">Save & Push</button>
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
<input type="text" id="filenameInput" class="filename-input" style="display:none" placeholder="filename.md">
|
|
374
|
+
<textarea id="noteEditor" class="note-editor"></textarea>
|
|
375
|
+
<div id="noteContent" class="markdown"></div>
|
|
376
|
+
</div>
|
|
377
|
+
|
|
378
|
+
<div class="footer">
|
|
379
|
+
Powered by <a href="https://github.com/JavaScriptSolidServer/nostr-git-client">nostr-git-client</a>
|
|
380
|
+
• Syncing from <a href="https://solid.social/mel/public/notes">solid.social</a>
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
|
|
384
|
+
<div id="updateToast" class="update-toast">✨ Notes updated!</div>
|
|
385
|
+
|
|
386
|
+
<div id="savingOverlay" class="saving-overlay">
|
|
387
|
+
<div class="saving-box">
|
|
388
|
+
<div class="spinner"></div>
|
|
389
|
+
<div id="savingText">Saving...</div>
|
|
390
|
+
</div>
|
|
391
|
+
</div>
|
|
392
|
+
|
|
393
|
+
<script type="importmap">
|
|
394
|
+
{
|
|
395
|
+
"imports": {
|
|
396
|
+
"nostr-git-client": "https://esm.sh/nostr-git-client@0.0.1",
|
|
397
|
+
"marked": "https://esm.sh/marked@9.0.0",
|
|
398
|
+
"isomorphic-git": "https://esm.sh/isomorphic-git@1.27.1",
|
|
399
|
+
"nostr-tools": "https://esm.sh/nostr-tools@2.10.0"
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
</script>
|
|
403
|
+
|
|
404
|
+
<script type="module">
|
|
405
|
+
import { NostrGitClient, createFS, createHttpClient } from 'nostr-git-client';
|
|
406
|
+
import { marked } from 'marked';
|
|
407
|
+
import git from 'isomorphic-git';
|
|
408
|
+
import * as nostrTools from 'nostr-tools';
|
|
409
|
+
|
|
410
|
+
// Helper to convert hex string to Uint8Array
|
|
411
|
+
function hexToBytes(hex) {
|
|
412
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
413
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
414
|
+
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
|
|
415
|
+
}
|
|
416
|
+
return bytes;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const REPO = 'https://solid.social/mel/public/notes';
|
|
420
|
+
const REPO_ID = 'notes';
|
|
421
|
+
const RELAYS = ['wss://relay.damus.io', 'wss://nos.lol'];
|
|
422
|
+
|
|
423
|
+
let client;
|
|
424
|
+
let notes = [];
|
|
425
|
+
let currentNote = null;
|
|
426
|
+
let currentContent = '';
|
|
427
|
+
let editMode = false;
|
|
428
|
+
let isEditing = false;
|
|
429
|
+
let isNewNote = false;
|
|
430
|
+
let privkey = localStorage.getItem('nostr-privkey') || '';
|
|
431
|
+
|
|
432
|
+
function renderMarkdown(text) {
|
|
433
|
+
return marked.parse(text);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function setStatus(state, text) {
|
|
437
|
+
document.getElementById('statusDot').className = `dot ${state}`;
|
|
438
|
+
document.getElementById('statusText').textContent = text;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function updateTimestamp() {
|
|
442
|
+
const now = new Date();
|
|
443
|
+
const time = now.toLocaleTimeString();
|
|
444
|
+
document.getElementById('lastUpdated').innerHTML = `Last synced: <span class="time">${time}</span>`;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function showToast(msg = '✨ Notes updated!') {
|
|
448
|
+
const toast = document.getElementById('updateToast');
|
|
449
|
+
toast.textContent = msg;
|
|
450
|
+
toast.classList.add('show');
|
|
451
|
+
setTimeout(() => toast.classList.remove('show'), 3000);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function showSaving(text) {
|
|
455
|
+
document.getElementById('savingText').textContent = text;
|
|
456
|
+
document.getElementById('savingOverlay').classList.add('show');
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function hideSaving() {
|
|
460
|
+
document.getElementById('savingOverlay').classList.remove('show');
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
window.toggleEditMode = function() {
|
|
464
|
+
editMode = !editMode;
|
|
465
|
+
document.getElementById('editToggle').classList.toggle('active', editMode);
|
|
466
|
+
document.getElementById('keyPanel').classList.toggle('show', editMode);
|
|
467
|
+
document.getElementById('newNoteBtn').classList.toggle('show', editMode && privkey);
|
|
468
|
+
document.getElementById('editBtn').classList.toggle('show', editMode && privkey && currentNote);
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
window.onPrivkeyChange = function() {
|
|
472
|
+
privkey = document.getElementById('privkeyInput').value.trim();
|
|
473
|
+
localStorage.setItem('nostr-privkey', privkey);
|
|
474
|
+
|
|
475
|
+
const pubkeyDisplay = document.getElementById('pubkeyDisplay');
|
|
476
|
+
if (privkey && privkey.length === 64) {
|
|
477
|
+
try {
|
|
478
|
+
const pubkey = nostrTools.getPublicKey(privkey);
|
|
479
|
+
pubkeyDisplay.textContent = `Pubkey: ${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`;
|
|
480
|
+
document.getElementById('newNoteBtn').classList.toggle('show', editMode);
|
|
481
|
+
document.getElementById('editBtn').classList.toggle('show', editMode && currentNote);
|
|
482
|
+
} catch {
|
|
483
|
+
pubkeyDisplay.textContent = 'Invalid key';
|
|
484
|
+
}
|
|
485
|
+
} else {
|
|
486
|
+
pubkeyDisplay.textContent = '';
|
|
487
|
+
document.getElementById('newNoteBtn').classList.remove('show');
|
|
488
|
+
document.getElementById('editBtn').classList.remove('show');
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
// Initialize privkey input from localStorage
|
|
493
|
+
if (privkey) {
|
|
494
|
+
document.getElementById('privkeyInput').value = privkey;
|
|
495
|
+
window.onPrivkeyChange();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function loadNotes() {
|
|
499
|
+
const files = await client.listFiles();
|
|
500
|
+
notes = files.filter(f => f.name.endsWith('.md'));
|
|
501
|
+
renderList();
|
|
502
|
+
|
|
503
|
+
if (currentNote && !isEditing) {
|
|
504
|
+
await showNote(currentNote);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function renderList() {
|
|
509
|
+
const list = document.getElementById('notesList');
|
|
510
|
+
|
|
511
|
+
if (notes.length === 0) {
|
|
512
|
+
list.innerHTML = '<p style="text-align:center;color:#666;">No notes yet</p>';
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
list.innerHTML = notes.map(note => {
|
|
517
|
+
const title = note.name.replace('.md', '').replace(/-/g, ' ');
|
|
518
|
+
const titleCased = title.charAt(0).toUpperCase() + title.slice(1);
|
|
519
|
+
return `
|
|
520
|
+
<div class="note-card" onclick="window.showNote('${note.path}')">
|
|
521
|
+
<h3>${titleCased}</h3>
|
|
522
|
+
<p>${note.path}</p>
|
|
523
|
+
</div>
|
|
524
|
+
`;
|
|
525
|
+
}).join('');
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
window.showNote = async function(path) {
|
|
529
|
+
currentNote = path;
|
|
530
|
+
isNewNote = false;
|
|
531
|
+
currentContent = await client.readFile(path);
|
|
532
|
+
|
|
533
|
+
document.getElementById('noteContent').innerHTML = renderMarkdown(currentContent);
|
|
534
|
+
document.getElementById('noteContent').style.display = 'block';
|
|
535
|
+
document.getElementById('noteEditor').style.display = 'none';
|
|
536
|
+
document.getElementById('filenameInput').style.display = 'none';
|
|
537
|
+
document.getElementById('editBtn').classList.toggle('show', editMode && privkey);
|
|
538
|
+
document.getElementById('cancelBtn').style.display = 'none';
|
|
539
|
+
document.getElementById('saveBtn').style.display = 'none';
|
|
540
|
+
|
|
541
|
+
document.getElementById('notesList').style.display = 'none';
|
|
542
|
+
document.getElementById('newNoteBtn').style.display = 'none';
|
|
543
|
+
document.getElementById('noteView').classList.add('active');
|
|
544
|
+
isEditing = false;
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
window.showList = function() {
|
|
548
|
+
currentNote = null;
|
|
549
|
+
isEditing = false;
|
|
550
|
+
isNewNote = false;
|
|
551
|
+
document.getElementById('notesList').style.display = 'grid';
|
|
552
|
+
document.getElementById('newNoteBtn').classList.toggle('show', editMode && privkey);
|
|
553
|
+
document.getElementById('noteView').classList.remove('active');
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
window.newNote = function() {
|
|
557
|
+
currentNote = null;
|
|
558
|
+
isNewNote = true;
|
|
559
|
+
currentContent = '# New Note\n\nStart writing...';
|
|
560
|
+
|
|
561
|
+
document.getElementById('noteContent').style.display = 'none';
|
|
562
|
+
document.getElementById('noteEditor').value = currentContent;
|
|
563
|
+
document.getElementById('noteEditor').style.display = 'block';
|
|
564
|
+
document.getElementById('filenameInput').value = '';
|
|
565
|
+
document.getElementById('filenameInput').style.display = 'block';
|
|
566
|
+
document.getElementById('filenameInput').placeholder = 'filename.md';
|
|
567
|
+
document.getElementById('editBtn').style.display = 'none';
|
|
568
|
+
document.getElementById('cancelBtn').style.display = 'inline-block';
|
|
569
|
+
document.getElementById('saveBtn').style.display = 'inline-block';
|
|
570
|
+
|
|
571
|
+
document.getElementById('notesList').style.display = 'none';
|
|
572
|
+
document.getElementById('newNoteBtn').style.display = 'none';
|
|
573
|
+
document.getElementById('noteView').classList.add('active');
|
|
574
|
+
isEditing = true;
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
window.startEdit = function() {
|
|
578
|
+
isEditing = true;
|
|
579
|
+
document.getElementById('noteContent').style.display = 'none';
|
|
580
|
+
document.getElementById('noteEditor').value = currentContent;
|
|
581
|
+
document.getElementById('noteEditor').style.display = 'block';
|
|
582
|
+
document.getElementById('editBtn').style.display = 'none';
|
|
583
|
+
document.getElementById('cancelBtn').style.display = 'inline-block';
|
|
584
|
+
document.getElementById('saveBtn').style.display = 'inline-block';
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
window.cancelEdit = function() {
|
|
588
|
+
if (isNewNote) {
|
|
589
|
+
showList();
|
|
590
|
+
} else {
|
|
591
|
+
isEditing = false;
|
|
592
|
+
document.getElementById('noteContent').style.display = 'block';
|
|
593
|
+
document.getElementById('noteEditor').style.display = 'none';
|
|
594
|
+
document.getElementById('editBtn').classList.toggle('show', editMode && privkey);
|
|
595
|
+
document.getElementById('cancelBtn').style.display = 'none';
|
|
596
|
+
document.getElementById('saveBtn').style.display = 'none';
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
window.saveNote = async function() {
|
|
601
|
+
if (!privkey) {
|
|
602
|
+
alert('Please enter your private key first');
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const content = document.getElementById('noteEditor').value;
|
|
607
|
+
let filepath = currentNote;
|
|
608
|
+
|
|
609
|
+
if (isNewNote) {
|
|
610
|
+
filepath = document.getElementById('filenameInput').value.trim();
|
|
611
|
+
if (!filepath) {
|
|
612
|
+
alert('Please enter a filename');
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
if (!filepath.endsWith('.md')) {
|
|
616
|
+
filepath += '.md';
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
try {
|
|
621
|
+
showSaving('Syncing fresh copy...');
|
|
622
|
+
|
|
623
|
+
// Clear and re-clone to ensure clean state
|
|
624
|
+
const fs = client.fs;
|
|
625
|
+
const dir = client.dir;
|
|
626
|
+
const http = createNip98HttpClient(privkey);
|
|
627
|
+
|
|
628
|
+
// Remove old repo and recreate
|
|
629
|
+
try {
|
|
630
|
+
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
631
|
+
} catch (e) { /* ignore */ }
|
|
632
|
+
try {
|
|
633
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
634
|
+
} catch (e) { /* ignore if exists */ }
|
|
635
|
+
|
|
636
|
+
// Fresh clone
|
|
637
|
+
await git.clone({
|
|
638
|
+
fs,
|
|
639
|
+
http,
|
|
640
|
+
dir,
|
|
641
|
+
url: REPO,
|
|
642
|
+
ref: 'main',
|
|
643
|
+
singleBranch: true,
|
|
644
|
+
depth: 1
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
showSaving('Writing file...');
|
|
648
|
+
|
|
649
|
+
await fs.promises.writeFile(`${dir}/${filepath}`, content, 'utf8');
|
|
650
|
+
|
|
651
|
+
showSaving('Committing...');
|
|
652
|
+
|
|
653
|
+
// Git add and commit
|
|
654
|
+
await git.add({ fs, dir, filepath });
|
|
655
|
+
await git.commit({
|
|
656
|
+
fs,
|
|
657
|
+
dir,
|
|
658
|
+
message: isNewNote ? `Add ${filepath}` : `Update ${filepath}`,
|
|
659
|
+
author: { name: 'Live Notes', email: 'notes@example.com' }
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
showSaving('Pushing to server...');
|
|
663
|
+
|
|
664
|
+
await git.push({
|
|
665
|
+
fs,
|
|
666
|
+
http,
|
|
667
|
+
dir,
|
|
668
|
+
url: REPO,
|
|
669
|
+
ref: 'main',
|
|
670
|
+
force: true
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
showSaving('Publishing to Nostr...');
|
|
674
|
+
|
|
675
|
+
// Get new commit hash
|
|
676
|
+
const commits = await git.log({ fs, dir, depth: 1 });
|
|
677
|
+
const commit = commits[0].oid;
|
|
678
|
+
|
|
679
|
+
// Publish Nostr event
|
|
680
|
+
await publishNostrEvent(commit);
|
|
681
|
+
|
|
682
|
+
hideSaving();
|
|
683
|
+
showToast('✅ Saved and published!');
|
|
684
|
+
|
|
685
|
+
// Refresh
|
|
686
|
+
currentNote = filepath;
|
|
687
|
+
isNewNote = false;
|
|
688
|
+
isEditing = false;
|
|
689
|
+
currentContent = content;
|
|
690
|
+
|
|
691
|
+
document.getElementById('noteContent').innerHTML = renderMarkdown(content);
|
|
692
|
+
document.getElementById('noteContent').style.display = 'block';
|
|
693
|
+
document.getElementById('noteEditor').style.display = 'none';
|
|
694
|
+
document.getElementById('filenameInput').style.display = 'none';
|
|
695
|
+
document.getElementById('editBtn').classList.toggle('show', editMode && privkey);
|
|
696
|
+
document.getElementById('cancelBtn').style.display = 'none';
|
|
697
|
+
document.getElementById('saveBtn').style.display = 'none';
|
|
698
|
+
|
|
699
|
+
updateTimestamp();
|
|
700
|
+
await loadNotes();
|
|
701
|
+
|
|
702
|
+
} catch (err) {
|
|
703
|
+
hideSaving();
|
|
704
|
+
console.error('Save failed:', err);
|
|
705
|
+
alert('Save failed: ' + err.message);
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
// Concatenate array body (isomorphic-git sends body as array of Uint8Arrays)
|
|
710
|
+
function concatBody(body) {
|
|
711
|
+
if (!Array.isArray(body)) return body;
|
|
712
|
+
const chunks = body.map(c => c instanceof Uint8Array ? c : new Uint8Array(c));
|
|
713
|
+
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
714
|
+
const result = new Uint8Array(totalLength);
|
|
715
|
+
let offset = 0;
|
|
716
|
+
for (const chunk of chunks) {
|
|
717
|
+
result.set(chunk, offset);
|
|
718
|
+
offset += chunk.length;
|
|
719
|
+
}
|
|
720
|
+
return result;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Create HTTP client with NIP-98 auth for git operations
|
|
724
|
+
function createNip98HttpClient(privkey) {
|
|
725
|
+
return {
|
|
726
|
+
async request({ url, method, headers, body }) {
|
|
727
|
+
// Create NIP-98 auth event
|
|
728
|
+
const pubkey = nostrTools.getPublicKey(privkey);
|
|
729
|
+
|
|
730
|
+
const authEvent = {
|
|
731
|
+
kind: 27235,
|
|
732
|
+
pubkey,
|
|
733
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
734
|
+
tags: [
|
|
735
|
+
['u', url],
|
|
736
|
+
['method', method || 'GET']
|
|
737
|
+
],
|
|
738
|
+
content: ''
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
// Use finalizeEvent which hashes and signs
|
|
742
|
+
const signedEvent = nostrTools.finalizeEvent(authEvent, hexToBytes(privkey));
|
|
743
|
+
|
|
744
|
+
// Base64 encode the event
|
|
745
|
+
const authHeader = 'Nostr ' + btoa(JSON.stringify(signedEvent));
|
|
746
|
+
|
|
747
|
+
// Concatenate body if array and make request
|
|
748
|
+
const res = await fetch(url, {
|
|
749
|
+
method,
|
|
750
|
+
headers: { ...headers, 'Authorization': authHeader },
|
|
751
|
+
body: concatBody(body)
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
return {
|
|
755
|
+
url: res.url,
|
|
756
|
+
method,
|
|
757
|
+
statusCode: res.status,
|
|
758
|
+
statusMessage: res.statusText,
|
|
759
|
+
headers: Object.fromEntries(res.headers.entries()),
|
|
760
|
+
body: [new Uint8Array(await res.arrayBuffer())]
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
async function publishNostrEvent(commit) {
|
|
767
|
+
const secretKey = hexToBytes(privkey);
|
|
768
|
+
|
|
769
|
+
const eventTemplate = {
|
|
770
|
+
kind: 30618,
|
|
771
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
772
|
+
tags: [
|
|
773
|
+
['d', REPO_ID],
|
|
774
|
+
['refs/heads/main', commit]
|
|
775
|
+
],
|
|
776
|
+
content: ''
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
const signedEvent = nostrTools.finalizeEvent(eventTemplate, secretKey);
|
|
780
|
+
|
|
781
|
+
// Publish to relays
|
|
782
|
+
for (const url of RELAYS) {
|
|
783
|
+
try {
|
|
784
|
+
const ws = new WebSocket(url);
|
|
785
|
+
await new Promise((resolve, reject) => {
|
|
786
|
+
ws.onopen = () => {
|
|
787
|
+
ws.send(JSON.stringify(['EVENT', signedEvent]));
|
|
788
|
+
setTimeout(() => { ws.close(); resolve(); }, 1000);
|
|
789
|
+
};
|
|
790
|
+
ws.onerror = reject;
|
|
791
|
+
});
|
|
792
|
+
} catch (e) {
|
|
793
|
+
console.warn('Failed to publish to', url, e);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Initialize
|
|
799
|
+
async function init() {
|
|
800
|
+
client = new NostrGitClient({
|
|
801
|
+
repo: REPO,
|
|
802
|
+
branch: 'main',
|
|
803
|
+
relays: RELAYS
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
client.on('connect', () => {
|
|
807
|
+
setStatus('syncing', 'Connected, syncing...');
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
client.on('sync', async (event) => {
|
|
811
|
+
if (event.status === 'complete') {
|
|
812
|
+
setStatus('synced', `Synced • ${event.commit?.slice(0, 7)}`);
|
|
813
|
+
updateTimestamp();
|
|
814
|
+
await loadNotes();
|
|
815
|
+
} else if (event.status === 'error') {
|
|
816
|
+
setStatus('error', 'Sync failed');
|
|
817
|
+
}
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
client.on('event', async (event) => {
|
|
821
|
+
if (event.type === 'repo' && event.commit !== client.currentCommit) {
|
|
822
|
+
setStatus('syncing', 'Updating...');
|
|
823
|
+
await client.sync(event.commit);
|
|
824
|
+
showToast();
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
setStatus('syncing', 'Connecting...');
|
|
829
|
+
client.connect();
|
|
830
|
+
await client.sync();
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
init();
|
|
834
|
+
</script>
|
|
835
|
+
</body>
|
|
836
|
+
</html>
|
package/package.json
CHANGED
package/src/utils.js
CHANGED
|
@@ -29,10 +29,26 @@ export function createHttpClient(options = {}) {
|
|
|
29
29
|
// Apply CORS proxy if specified
|
|
30
30
|
const finalUrl = corsProxy ? `${corsProxy}${encodeURIComponent(url)}` : url;
|
|
31
31
|
|
|
32
|
+
// isomorphic-git may pass body as array of Uint8Arrays - concatenate them
|
|
33
|
+
let requestBody = body;
|
|
34
|
+
if (Array.isArray(body)) {
|
|
35
|
+
const chunks = [];
|
|
36
|
+
for (const chunk of body) {
|
|
37
|
+
chunks.push(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk));
|
|
38
|
+
}
|
|
39
|
+
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
40
|
+
requestBody = new Uint8Array(totalLength);
|
|
41
|
+
let offset = 0;
|
|
42
|
+
for (const chunk of chunks) {
|
|
43
|
+
requestBody.set(chunk, offset);
|
|
44
|
+
offset += chunk.length;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
const res = await fetch(finalUrl, {
|
|
33
49
|
method,
|
|
34
50
|
headers: { ...reqHeaders, ...headers },
|
|
35
|
-
body
|
|
51
|
+
body: requestBody
|
|
36
52
|
});
|
|
37
53
|
|
|
38
54
|
return {
|