ghuser1 commited on
Commit
163c686
·
verified ·
1 Parent(s): 3866fee

Upload 11 files

Browse files
Files changed (11) hide show
  1. app.js +1069 -0
  2. auth.mjs +65 -0
  3. index.html +131 -0
  4. package.json +12 -0
  5. pnpm-lock.yaml +44 -0
  6. scripts/backup-proxy.mjs +252 -0
  7. scripts/backup-sqlite.mjs +133 -0
  8. scripts/docker-entrypoint.sh +64 -0
  9. scripts/seed-mock.mjs +43 -0
  10. server.mjs +230 -0
  11. styles.css +1497 -0
app.js ADDED
@@ -0,0 +1,1069 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const uiStorageKey = "memo-ui-state-v1";
2
+
3
+ if (new URLSearchParams(window.location.search).get("device") === "mobile") {
4
+ document.documentElement.classList.add("force-mobile");
5
+ }
6
+
7
+ const starterNotes = [
8
+ {
9
+ id: crypto.randomUUID(),
10
+ folder: "notes",
11
+ body: "欢迎使用备忘录\n\n左侧选择文件夹,中间选择笔记,右侧直接编辑。第一行会自动作为标题。",
12
+ createdAt: Date.now() - 1000 * 60 * 60 * 24,
13
+ updatedAt: Date.now() - 1000 * 60 * 12,
14
+ version: 1
15
+ },
16
+ {
17
+ id: crypto.randomUUID(),
18
+ folder: "notes",
19
+ body: "待办清单\n\n- 记录想法\n- 整理项目\n- 做一个真正好用的 Web 版笔记",
20
+ createdAt: Date.now() - 1000 * 60 * 60 * 3,
21
+ updatedAt: Date.now() - 1000 * 60 * 35,
22
+ version: 1
23
+ }
24
+ ];
25
+
26
+ const authStatus = await getAuthStatus();
27
+ const state = authStatus.authenticated
28
+ ? await loadState()
29
+ : hydrateState({ notes: [], folders: defaultFolders() });
30
+ let saveTimer = null;
31
+ let saveStatusTimer = null;
32
+ const saveDelay = 700;
33
+ const maxNoteBodyLength = 1024 * 1024;
34
+ const els = {
35
+ authGate: document.querySelector("#authGate"),
36
+ authForm: document.querySelector("#authForm"),
37
+ authTitle: document.querySelector("#authTitle"),
38
+ authPassword: document.querySelector("#authPassword"),
39
+ authSubmit: document.querySelector("#authSubmit"),
40
+ authMessage: document.querySelector("#authMessage"),
41
+ folderList: document.querySelector("#folderList"),
42
+ newFolder: document.querySelector("#newFolder"),
43
+ listTitle: document.querySelector("#listTitle"),
44
+ listCount: document.querySelector("#listCount"),
45
+ noteList: document.querySelector("#noteList"),
46
+ newNote: document.querySelector("#newNote"),
47
+ deleteNote: document.querySelector("#deleteNote"),
48
+ previewToggle: document.querySelector("#previewToggle"),
49
+ changePassword: document.querySelector("#changePassword"),
50
+ logout: document.querySelector("#logout"),
51
+ searchInput: document.querySelector("#searchInput"),
52
+ mobileBack: document.querySelector("#mobileBack"),
53
+ mobileBackLabel: document.querySelector("#mobileBackLabel"),
54
+ mobileTitle: document.querySelector("#mobileTitle"),
55
+ mobileSearchInput: document.querySelector("#mobileSearchInput"),
56
+ mobileSearchWrap: document.querySelector("#mobileSearchWrap"),
57
+ mobileEdit: document.querySelector("#mobileEdit"),
58
+ mobilePreviewToggle: document.querySelector("#mobilePreviewToggle"),
59
+ mobileSave: document.querySelector("#mobileSave"),
60
+ mobileMore: document.querySelector("#mobileMore"),
61
+ mobileActionMenu: document.querySelector("#mobileActionMenu"),
62
+ mobileMenuPreview: document.querySelector("#mobileMenuPreview"),
63
+ mobileMenuSave: document.querySelector("#mobileMenuSave"),
64
+ mobileMenuDelete: document.querySelector("#mobileMenuDelete"),
65
+ mobileNoteFolderSelect: document.querySelector("#mobileNoteFolderSelect"),
66
+ appShell: document.querySelector(".app-shell"),
67
+ editor: document.querySelector("#editor"),
68
+ noteFolderSelect: document.querySelector("#noteFolderSelect"),
69
+ editorPreviewToggle: document.querySelector("#editorPreviewToggle"),
70
+ saveNote: document.querySelector("#saveNote"),
71
+ saveStatus: document.querySelector("#saveStatus"),
72
+ editedAt: document.querySelector("#editedAt"),
73
+ markdownPreview: document.querySelector("#markdownPreview"),
74
+ editorPane: document.querySelector(".editor-pane")
75
+ };
76
+
77
+ render();
78
+ if (!authStatus.authenticated) showAuthGate(authStatus.configured);
79
+
80
+ window.addEventListener("beforeunload", flushPendingSave);
81
+
82
+ els.authForm.addEventListener("submit", async (event) => {
83
+ event.preventDefault();
84
+ const password = els.authPassword.value;
85
+ els.authSubmit.disabled = true;
86
+ els.authMessage.textContent = "";
87
+
88
+ try {
89
+ const path = authStatus.configured ? "/api/auth/login" : "/api/auth/setup";
90
+ const response = await fetch(path, {
91
+ method: "POST",
92
+ headers: {
93
+ "content-type": "application/json"
94
+ },
95
+ body: JSON.stringify({ password })
96
+ });
97
+ if (!response.ok) {
98
+ const message = response.status === 401 ? "密码不正确" : "密码至少需要 8 位";
99
+ els.authMessage.textContent = message;
100
+ return;
101
+ }
102
+ location.reload();
103
+ } finally {
104
+ els.authSubmit.disabled = false;
105
+ }
106
+ });
107
+
108
+ els.newNote.addEventListener("click", async () => {
109
+ const now = Date.now();
110
+ const note = {
111
+ id: crypto.randomUUID(),
112
+ folder: getWriteFolderId(),
113
+ body: "",
114
+ createdAt: now,
115
+ updatedAt: now,
116
+ version: 1
117
+ };
118
+
119
+ state.notes.unshift(note);
120
+ if (state.activeFolder === "recent") state.activeFolder = note.folder;
121
+ state.selectedId = note.id;
122
+ state.mobileView = "editor";
123
+ render();
124
+ els.editor.focus();
125
+
126
+ try {
127
+ setSaveStatus("保存中");
128
+ const saved = await createNote(note);
129
+ Object.assign(note, saved);
130
+ setSaveStatus("已保存", "ok");
131
+ renderListOnly();
132
+ renderEditor();
133
+ } catch (error) {
134
+ setSaveStatus("保存失败", "error");
135
+ console.error("Failed to create note.", error);
136
+ }
137
+ });
138
+
139
+ els.deleteNote.addEventListener("click", async () => {
140
+ const current = getSelectedNote();
141
+ if (!current) return;
142
+
143
+ const visible = getVisibleNotes();
144
+ const index = visible.findIndex((note) => note.id === current.id);
145
+ state.notes = state.notes.filter((note) => note.id !== current.id);
146
+ const next = visible[index + 1] || visible[index - 1];
147
+ state.selectedId = next && state.notes.some((note) => note.id === next.id) ? next.id : null;
148
+ render();
149
+
150
+ try {
151
+ await deleteNote(current.id);
152
+ setSaveStatus("已删除", "ok");
153
+ } catch (error) {
154
+ state.notes.unshift(current);
155
+ state.selectedId = current.id;
156
+ setSaveStatus("删除失败", "error");
157
+ render();
158
+ console.error("Failed to delete note.", error);
159
+ }
160
+ });
161
+
162
+ els.searchInput.addEventListener("input", (event) => {
163
+ state.query = event.target.value;
164
+ const visible = getVisibleNotes();
165
+ if (!visible.some((note) => note.id === state.selectedId)) {
166
+ state.selectedId = visible[0]?.id || null;
167
+ }
168
+ saveUiState();
169
+ render();
170
+ });
171
+
172
+ els.mobileSearchInput.addEventListener("input", (event) => {
173
+ if (state.mobileView === "folders") {
174
+ state.folderQuery = event.target.value;
175
+ } else {
176
+ state.query = event.target.value;
177
+ const visible = getVisibleNotes();
178
+ if (!visible.some((note) => note.id === state.selectedId)) {
179
+ state.selectedId = visible[0]?.id || null;
180
+ }
181
+ }
182
+ saveUiState();
183
+ render();
184
+ });
185
+
186
+ els.mobileBack.addEventListener("click", () => {
187
+ if (state.mobileView === "editor") {
188
+ state.mobileView = "list";
189
+ } else if (state.mobileView === "list") {
190
+ state.mobileView = "folders";
191
+ }
192
+ saveUiState();
193
+ render();
194
+ });
195
+
196
+ els.mobileEdit.addEventListener("click", () => {
197
+ els.newNote.click();
198
+ });
199
+
200
+ els.mobileSave.addEventListener("click", () => {
201
+ if (!getSelectedNote()) return;
202
+ saveSelectedNote({ immediate: true });
203
+ });
204
+
205
+ els.mobilePreviewToggle.addEventListener("click", togglePreviewMode);
206
+
207
+ els.mobileMore.addEventListener("click", (event) => {
208
+ event.stopPropagation();
209
+ els.mobileActionMenu.hidden = !els.mobileActionMenu.hidden;
210
+ });
211
+
212
+ els.mobileMenuPreview.addEventListener("click", () => {
213
+ els.mobileActionMenu.hidden = true;
214
+ togglePreviewMode();
215
+ });
216
+
217
+ els.mobileMenuSave.addEventListener("click", () => {
218
+ els.mobileActionMenu.hidden = true;
219
+ if (getSelectedNote()) saveSelectedNote({ immediate: true });
220
+ });
221
+
222
+ els.mobileMenuDelete.addEventListener("click", () => {
223
+ els.mobileActionMenu.hidden = true;
224
+ els.deleteNote.click();
225
+ });
226
+
227
+ document.addEventListener("click", (event) => {
228
+ if (els.mobileActionMenu.hidden) return;
229
+ if (event.target.closest("#mobileActions")) return;
230
+ els.mobileActionMenu.hidden = true;
231
+ });
232
+
233
+ els.newFolder.addEventListener("click", async () => {
234
+ const name = getUniqueFolderName("新建文件夹");
235
+ const folder = {
236
+ id: crypto.randomUUID(),
237
+ name,
238
+ createdAt: Date.now(),
239
+ updatedAt: Date.now(),
240
+ version: 1
241
+ };
242
+
243
+ state.folders.push(folder);
244
+ state.activeFolder = folder.id;
245
+ state.selectedId = null;
246
+ state.mobileView = "list";
247
+ render();
248
+
249
+ try {
250
+ const saved = await createFolder(folder);
251
+ Object.assign(folder, saved);
252
+ setSaveStatus("文件夹已创建", "ok");
253
+ renderFolders();
254
+ renderEditor();
255
+ } catch (error) {
256
+ state.folders = state.folders.filter((entry) => entry.id !== folder.id);
257
+ state.activeFolder = "all";
258
+ setSaveStatus("创建失败", "error");
259
+ render();
260
+ console.error("Failed to create folder.", error);
261
+ }
262
+ });
263
+
264
+ els.editor.addEventListener("input", (event) => {
265
+ const note = getSelectedNote();
266
+ if (!note) return;
267
+
268
+ if (event.target.value.length > maxNoteBodyLength) {
269
+ event.target.value = event.target.value.slice(0, maxNoteBodyLength);
270
+ setSaveStatus("内容已达上限", "error");
271
+ }
272
+
273
+ note.body = event.target.value;
274
+ note.updatedAt = Date.now();
275
+ saveSelectedNote();
276
+ renderListOnly();
277
+ updateEditorMeta(note);
278
+ renderMarkdownPreview(note);
279
+ });
280
+
281
+ els.noteFolderSelect.addEventListener("change", (event) => {
282
+ moveSelectedNoteToFolder(event.target.value);
283
+ });
284
+
285
+ els.saveNote.addEventListener("click", () => {
286
+ if (!getSelectedNote()) return;
287
+ saveSelectedNote({ immediate: true });
288
+ });
289
+
290
+ els.mobileNoteFolderSelect.addEventListener("change", (event) => {
291
+ els.mobileActionMenu.hidden = true;
292
+ moveSelectedNoteToFolder(event.target.value);
293
+ });
294
+
295
+ els.previewToggle.addEventListener("click", togglePreviewMode);
296
+ els.editorPreviewToggle.addEventListener("click", togglePreviewMode);
297
+
298
+ els.logout.addEventListener("click", async () => {
299
+ await fetch("/api/auth/logout", { method: "POST" });
300
+ location.reload();
301
+ });
302
+
303
+ els.changePassword.addEventListener("click", async () => {
304
+ const currentPassword = window.prompt("请输入当前密码");
305
+ if (!currentPassword) return;
306
+ const nextPassword = window.prompt("请输入新密码,至少 8 位");
307
+ if (!nextPassword) return;
308
+
309
+ try {
310
+ const response = await fetch("/api/auth/password", {
311
+ method: "POST",
312
+ headers: { "content-type": "application/json" },
313
+ body: JSON.stringify({ currentPassword, nextPassword })
314
+ });
315
+ if (!response.ok) {
316
+ const message = response.status === 401 ? "当前密码不正确" : "新密码至少需要 8 位";
317
+ window.alert(message);
318
+ return;
319
+ }
320
+ window.alert("密码已修改");
321
+ } catch (error) {
322
+ window.alert("修改失败,请稍后再试");
323
+ console.error("Failed to change password.", error);
324
+ }
325
+ });
326
+
327
+ function moveSelectedNoteToFolder(nextFolder) {
328
+ const note = getSelectedNote();
329
+ if (!note) return;
330
+
331
+ if (!state.folders.some((folder) => folder.id === nextFolder)) return;
332
+
333
+ note.folder = nextFolder;
334
+ note.updatedAt = Date.now();
335
+ state.activeFolder = nextFolder;
336
+ saveSelectedNote({ immediate: true });
337
+ render();
338
+ }
339
+
340
+ function togglePreviewMode() {
341
+ state.previewMode = !state.previewMode;
342
+ saveUiState();
343
+ renderEditor();
344
+ renderMobileChrome();
345
+ }
346
+
347
+ document.addEventListener("keydown", (event) => {
348
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "n") {
349
+ event.preventDefault();
350
+ els.newNote.click();
351
+ }
352
+
353
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f") {
354
+ event.preventDefault();
355
+ els.searchInput.focus();
356
+ els.searchInput.select();
357
+ }
358
+
359
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {
360
+ event.preventDefault();
361
+ if (getSelectedNote()) saveSelectedNote({ immediate: true });
362
+ }
363
+
364
+ if (event.key === "Delete" && document.activeElement !== els.editor) {
365
+ els.deleteNote.click();
366
+ }
367
+ });
368
+
369
+ async function loadState() {
370
+ try {
371
+ const response = await fetch("/api/state");
372
+ if (!response.ok) throw new Error(`State API returned ${response.status}`);
373
+ return hydrateState(await response.json());
374
+ } catch (error) {
375
+ console.warn("Falling back to starter notes because the API is unavailable.", error);
376
+ return hydrateState({ notes: starterNotes, folders: defaultFolders() });
377
+ }
378
+ }
379
+
380
+ async function getAuthStatus() {
381
+ try {
382
+ const response = await fetch("/api/auth/status");
383
+ if (!response.ok) throw new Error(`Auth API returned ${response.status}`);
384
+ return await response.json();
385
+ } catch (error) {
386
+ console.warn("Auth API unavailable.", error);
387
+ return { configured: false, authenticated: false };
388
+ }
389
+ }
390
+
391
+ function showAuthGate(configured) {
392
+ els.authTitle.textContent = configured ? "登录" : "设置密码";
393
+ els.authPassword.autocomplete = configured ? "current-password" : "new-password";
394
+ els.authPassword.placeholder = configured ? "密码" : "设置至少 8 位密码";
395
+ els.authSubmit.textContent = configured ? "登录" : "创建";
396
+ els.authGate.hidden = false;
397
+ els.authPassword.focus();
398
+ }
399
+
400
+ function saveSelectedNote({ immediate = false } = {}) {
401
+ saveUiState();
402
+ const note = getSelectedNote();
403
+ if (!note) return;
404
+ if (saveTimer) clearTimeout(saveTimer);
405
+ if (immediate) {
406
+ saveTimer = null;
407
+ sendNote(note.id);
408
+ return;
409
+ }
410
+ saveTimer = setTimeout(() => {
411
+ saveTimer = null;
412
+ sendNote(note.id);
413
+ }, saveDelay);
414
+ setSaveStatus("未保存");
415
+ }
416
+
417
+ function flushPendingSave() {
418
+ saveUiState();
419
+ if (!saveTimer) return;
420
+ clearTimeout(saveTimer);
421
+ saveTimer = null;
422
+ const note = getSelectedNote();
423
+ if (note) sendNote(note.id, { keepalive: true });
424
+ }
425
+
426
+ async function sendNote(noteId, { keepalive = false } = {}) {
427
+ const note = state.notes.find((entry) => entry.id === noteId);
428
+ if (!note) return;
429
+
430
+ setSaveStatus("保存中");
431
+ try {
432
+ const response = await fetch(`/api/notes/${encodeURIComponent(note.id)}`, {
433
+ method: "PATCH",
434
+ keepalive,
435
+ headers: {
436
+ "content-type": "application/json"
437
+ },
438
+ body: JSON.stringify({
439
+ folder: note.folder,
440
+ body: note.body,
441
+ updatedAt: note.updatedAt,
442
+ version: note.version
443
+ })
444
+ });
445
+ const payload = await response.json().catch(() => ({}));
446
+ if (response.status === 409 && payload.note) {
447
+ handleNoteConflict(note, payload.note);
448
+ return;
449
+ }
450
+ if (!response.ok) {
451
+ throw new Error(payload.error || `Save API returned ${response.status}`);
452
+ }
453
+ Object.assign(note, payload);
454
+ setSaveStatus("已保存", "ok");
455
+ renderListOnly();
456
+ renderEditor();
457
+ } catch (error) {
458
+ setSaveStatus("保存失败", "error");
459
+ console.error("Failed to save note.", error);
460
+ }
461
+ }
462
+
463
+ async function createNote(note) {
464
+ const response = await fetch("/api/notes", {
465
+ method: "POST",
466
+ headers: {
467
+ "content-type": "application/json"
468
+ },
469
+ body: JSON.stringify(note)
470
+ });
471
+ if (!response.ok) throw new Error(`Create note API returned ${response.status}`);
472
+ return await response.json();
473
+ }
474
+
475
+ async function deleteNote(noteId) {
476
+ const response = await fetch(`/api/notes/${encodeURIComponent(noteId)}`, { method: "DELETE" });
477
+ if (!response.ok) throw new Error(`Delete note API returned ${response.status}`);
478
+ }
479
+
480
+ async function createFolder(folder) {
481
+ const response = await fetch("/api/folders", {
482
+ method: "POST",
483
+ headers: {
484
+ "content-type": "application/json"
485
+ },
486
+ body: JSON.stringify(folder)
487
+ });
488
+ if (!response.ok) throw new Error(`Create folder API returned ${response.status}`);
489
+ return await response.json();
490
+ }
491
+
492
+ async function removeFolder(folderId) {
493
+ const response = await fetch(`/api/folders/${encodeURIComponent(folderId)}`, { method: "DELETE" });
494
+ if (!response.ok) throw new Error(`Delete folder API returned ${response.status}`);
495
+ return await response.json();
496
+ }
497
+
498
+ function handleNoteConflict(localNote, serverNote) {
499
+ setSaveStatus("有冲突", "conflict");
500
+ const useServer = window.confirm("这条笔记已在其他设备修改。确定载入服务器版本吗?取消则保留本机内容,稍后可再次保存。");
501
+ if (useServer) {
502
+ Object.assign(localNote, serverNote);
503
+ setSaveStatus("已载入服务器版本", "ok");
504
+ render();
505
+ return;
506
+ }
507
+ localNote.version = serverNote.version;
508
+ setSaveStatus("保留本机内容", "conflict");
509
+ }
510
+
511
+ function setSaveStatus(text, type = "") {
512
+ els.saveStatus.textContent = text;
513
+ els.saveStatus.className = `save-status${type ? ` is-${type}` : ""}`;
514
+ if (saveStatusTimer) clearTimeout(saveStatusTimer);
515
+ if (type === "ok") {
516
+ saveStatusTimer = setTimeout(() => {
517
+ els.saveStatus.textContent = "";
518
+ els.saveStatus.className = "save-status";
519
+ }, 1800);
520
+ }
521
+ }
522
+
523
+ function hydrateState(data) {
524
+ const folders = Array.isArray(data.folders) && data.folders.length ? data.folders : defaultFolders();
525
+ const notes = Array.isArray(data.notes)
526
+ ? data.notes.map((note) => ({ ...note, version: Number(note.version || 1) }))
527
+ : [];
528
+ const ui = loadUiState();
529
+ const selectedId = notes.some((note) => note.id === ui.selectedId) ? ui.selectedId : notes[0]?.id || null;
530
+ const selectedNote = notes.find((note) => note.id === selectedId);
531
+ const validFolders = new Set(["all", "recent", ...folders.map((folder) => folder.id)]);
532
+ let activeFolder = validFolders.has(ui.activeFolder)
533
+ ? ui.activeFolder
534
+ : selectedNote?.folder || "all";
535
+ if (selectedNote && activeFolder !== "all" && activeFolder !== "recent" && activeFolder !== selectedNote.folder) {
536
+ activeFolder = selectedNote.folder;
537
+ }
538
+ if (selectedNote && activeFolder === "recent" && !isRecentNote(selectedNote)) {
539
+ activeFolder = selectedNote.folder;
540
+ }
541
+ const mobileView = ["folders", "list", "editor"].includes(ui.mobileView)
542
+ ? ui.mobileView
543
+ : selectedId ? "editor" : "folders";
544
+
545
+ return {
546
+ notes,
547
+ folders,
548
+ activeFolder,
549
+ selectedId,
550
+ query: "",
551
+ folderQuery: "",
552
+ mobileView,
553
+ previewMode: Boolean(ui.previewMode)
554
+ };
555
+ }
556
+
557
+ function loadUiState() {
558
+ try {
559
+ return JSON.parse(localStorage.getItem(uiStorageKey)) || {};
560
+ } catch {
561
+ return {};
562
+ }
563
+ }
564
+
565
+ function saveUiState() {
566
+ localStorage.setItem(uiStorageKey, JSON.stringify({
567
+ activeFolder: state.activeFolder,
568
+ selectedId: state.selectedId,
569
+ mobileView: state.mobileView,
570
+ previewMode: state.previewMode
571
+ }));
572
+ }
573
+
574
+ function render() {
575
+ els.searchInput.value = state.query;
576
+ renderFolders();
577
+ renderListOnly();
578
+ renderEditor();
579
+ renderMobileChrome();
580
+ }
581
+
582
+ function renderFolders() {
583
+ const now = Date.now();
584
+ const recentCutoff = now - 1000 * 60 * 60 * 24 * 7;
585
+ const allFolder = { id: "all", name: "所有笔记", icon: "▣", count: state.notes.length };
586
+ const recentFolder = {
587
+ id: "recent",
588
+ name: "最近编辑",
589
+ icon: "clock-3",
590
+ count: state.notes.filter((note) => note.updatedAt >= recentCutoff).length
591
+ };
592
+ allFolder.icon = "file-text";
593
+ const folderQuery = state.mobileView === "folders" ? state.folderQuery.trim().toLowerCase() : "";
594
+ const userFolders = state.folders
595
+ .filter((folder) => !folderQuery || folder.name.toLowerCase().includes(folderQuery))
596
+ .map((folder) => ({
597
+ id: folder.id,
598
+ name: folder.name,
599
+ icon: "folder",
600
+ count: state.notes.filter((note) => note.folder === folder.id).length,
601
+ deletable: folder.id !== "notes"
602
+ }));
603
+
604
+ const smartFolders = [allFolder, recentFolder].filter((folder) => (
605
+ !folderQuery || folder.name.toLowerCase().includes(folderQuery)
606
+ ));
607
+
608
+ els.folderList.replaceChildren(...[...smartFolders, ...userFolders].map(renderFolderItem));
609
+ }
610
+
611
+ function renderListOnly() {
612
+ const visible = getVisibleNotes();
613
+ if (!visible.some((note) => note.id === state.selectedId)) {
614
+ state.selectedId = visible[0]?.id || null;
615
+ }
616
+
617
+ const labels = {
618
+ all: "所有笔记",
619
+ recent: "最近编辑"
620
+ };
621
+ const activeFolder = state.folders.find((folder) => folder.id === state.activeFolder);
622
+
623
+ els.listTitle.textContent = labels[state.activeFolder] || activeFolder?.name || "所有笔记";
624
+ els.listCount.textContent = `${visible.length} 个笔记`;
625
+ els.deleteNote.disabled = !state.selectedId;
626
+ els.noteList.replaceChildren(...renderGroupedNotes(visible));
627
+ renderFolders();
628
+ }
629
+
630
+ function renderGroupedNotes(notes) {
631
+ const nodes = [];
632
+ let currentGroup = "";
633
+
634
+ notes.forEach((note) => {
635
+ const group = formatSectionDate(note.updatedAt);
636
+ if (group !== currentGroup) {
637
+ currentGroup = group;
638
+ const heading = document.createElement("div");
639
+ heading.className = "note-section-title";
640
+ heading.textContent = group;
641
+ nodes.push(heading);
642
+ }
643
+ nodes.push(renderNoteButton(note));
644
+ });
645
+
646
+ return nodes;
647
+ }
648
+
649
+ function renderFolderItem(folder) {
650
+ const item = document.createElement("div");
651
+ item.className = `folder-item${folder.deletable ? " can-delete" : ""}`;
652
+
653
+ const button = document.createElement("button");
654
+ button.className = `folder-row${folder.id === state.activeFolder ? " active" : ""}`;
655
+ button.type = "button";
656
+ button.dataset.folder = folder.id;
657
+
658
+ const icon = document.createElement("i");
659
+ icon.className = "folder-icon";
660
+ icon.dataset.lucide = folder.icon;
661
+ icon.setAttribute("aria-hidden", "true");
662
+
663
+ const name = document.createElement("span");
664
+ name.textContent = folder.name;
665
+
666
+ const count = document.createElement("span");
667
+ count.className = "folder-count";
668
+ count.textContent = folder.count;
669
+
670
+ button.append(icon, name, count);
671
+ button.addEventListener("click", () => {
672
+ state.activeFolder = folder.id;
673
+ const visible = getVisibleNotes();
674
+ state.selectedId = visible[0]?.id || null;
675
+ state.mobileView = "list";
676
+ saveUiState();
677
+ render();
678
+ });
679
+
680
+ item.append(button);
681
+
682
+ if (folder.deletable) {
683
+ const deleteButton = document.createElement("button");
684
+ deleteButton.className = "folder-delete";
685
+ deleteButton.type = "button";
686
+ deleteButton.setAttribute("aria-label", `删除${folder.name}`);
687
+ deleteButton.title = "删除文件夹";
688
+ deleteButton.innerHTML = '<i data-lucide="trash-2" aria-hidden="true"></i>';
689
+ deleteButton.addEventListener("click", (event) => {
690
+ event.stopPropagation();
691
+ deleteFolder(folder.id);
692
+ });
693
+ item.append(deleteButton);
694
+ }
695
+
696
+ return item;
697
+ }
698
+
699
+ function renderNoteButton(note) {
700
+ const button = document.createElement("button");
701
+ button.type = "button";
702
+ button.className = `note-card${note.id === state.selectedId ? " selected" : ""}`;
703
+ button.dataset.noteId = note.id;
704
+ button.setAttribute("aria-label", getTitle(note));
705
+
706
+ const title = document.createElement("span");
707
+ title.className = "note-title";
708
+ title.textContent = getTitle(note);
709
+
710
+ const preview = document.createElement("span");
711
+ preview.className = "note-preview";
712
+ preview.innerHTML = `<span class="note-date">${formatListDate(note.updatedAt)}</span> ${getPreview(note)}`;
713
+
714
+ button.append(title, preview);
715
+ button.addEventListener("click", () => {
716
+ state.selectedId = note.id;
717
+ state.mobileView = "editor";
718
+ saveUiState();
719
+ render();
720
+ els.editor.focus();
721
+ });
722
+
723
+ return button;
724
+ }
725
+
726
+ function renderEditor() {
727
+ const note = getSelectedNote();
728
+ els.editorPane.classList.toggle("empty", !note);
729
+ els.editor.disabled = !note;
730
+ els.saveNote.disabled = !note;
731
+ els.mobileSave.disabled = !note;
732
+ els.editorPreviewToggle.disabled = !note;
733
+ els.mobilePreviewToggle.disabled = !note;
734
+ els.mobileMore.disabled = !note;
735
+ els.mobileMenuSave.disabled = !note;
736
+ els.mobileMenuPreview.disabled = !note;
737
+ els.mobileMenuDelete.disabled = !note;
738
+ els.previewToggle.classList.toggle("active", state.previewMode);
739
+ els.editorPreviewToggle.classList.toggle("active", state.previewMode);
740
+ els.mobilePreviewToggle.classList.toggle("active", state.previewMode);
741
+ els.mobileMenuPreview.classList.toggle("active", state.previewMode);
742
+
743
+ if (!note) {
744
+ els.mobileActionMenu.hidden = true;
745
+ els.editor.value = "";
746
+ els.editor.hidden = false;
747
+ els.editedAt.textContent = "";
748
+ els.markdownPreview.hidden = true;
749
+ els.noteFolderSelect.replaceChildren();
750
+ els.mobileNoteFolderSelect.replaceChildren();
751
+ els.noteFolderSelect.disabled = true;
752
+ els.mobileNoteFolderSelect.disabled = true;
753
+ return;
754
+ }
755
+
756
+ if (els.editor.value !== note.body) {
757
+ els.editor.value = note.body;
758
+ }
759
+ els.editor.hidden = state.previewMode;
760
+ els.markdownPreview.hidden = !state.previewMode;
761
+ renderMarkdownPreview(note);
762
+ renderNoteFolderSelect(note);
763
+ updateEditorMeta(note);
764
+ }
765
+
766
+ function renderNoteFolderSelect(note) {
767
+ const options = state.folders.map((folder) => {
768
+ const option = document.createElement("option");
769
+ option.value = folder.id;
770
+ option.textContent = folder.name;
771
+ option.selected = folder.id === note.folder;
772
+ return option;
773
+ });
774
+ const mobileOptions = options.map((option) => option.cloneNode(true));
775
+ els.noteFolderSelect.disabled = false;
776
+ els.mobileNoteFolderSelect.disabled = false;
777
+ els.noteFolderSelect.replaceChildren(...options);
778
+ els.mobileNoteFolderSelect.replaceChildren(...mobileOptions);
779
+ }
780
+
781
+ function renderMobileChrome() {
782
+ if (!["folders", "list", "editor"].includes(state.mobileView)) {
783
+ state.mobileView = "folders";
784
+ }
785
+
786
+ els.appShell.dataset.mobileView = state.mobileView;
787
+ const activeLabel = getActiveFolderLabel();
788
+ const selected = getSelectedNote();
789
+
790
+ if (state.mobileView === "folders") {
791
+ els.mobileTitle.textContent = "文件夹";
792
+ els.mobileBackLabel.textContent = "返回";
793
+ els.mobileBack.classList.add("is-hidden");
794
+ els.mobileSearchInput.value = state.folderQuery;
795
+ els.mobileSearchInput.placeholder = "搜索文件夹";
796
+ } else if (state.mobileView === "list") {
797
+ els.mobileTitle.textContent = activeLabel;
798
+ els.mobileBackLabel.textContent = "文件夹";
799
+ els.mobileBack.classList.remove("is-hidden");
800
+ els.mobileSearchInput.value = state.query;
801
+ els.mobileSearchInput.placeholder = "搜索";
802
+ } else {
803
+ els.mobileTitle.textContent = selected ? getTitle(selected) : "笔记";
804
+ els.mobileBackLabel.textContent = activeLabel;
805
+ els.mobileBack.classList.remove("is-hidden");
806
+ els.mobileSearchInput.value = state.query;
807
+ els.mobileSearchInput.placeholder = "搜索";
808
+ }
809
+ if (state.mobileView !== "editor") {
810
+ els.mobileActionMenu.hidden = true;
811
+ }
812
+ renderIcons();
813
+ }
814
+
815
+ function updateEditorMeta(note) {
816
+ els.editedAt.dateTime = new Date(note.updatedAt).toISOString();
817
+ els.editedAt.textContent = `编辑于 ${formatFullDate(note.updatedAt)}`;
818
+ }
819
+
820
+ function getVisibleNotes() {
821
+ const query = state.query.trim().toLowerCase();
822
+
823
+ return state.notes
824
+ .filter((note) => {
825
+ if (state.activeFolder === "recent") return isRecentNote(note);
826
+ if (state.activeFolder !== "all") return note.folder === state.activeFolder;
827
+ return true;
828
+ })
829
+ .filter((note) => {
830
+ if (!query) return true;
831
+ return `${getTitle(note)} ${note.body}`.toLowerCase().includes(query);
832
+ })
833
+ .sort((a, b) => b.updatedAt - a.updatedAt);
834
+ }
835
+
836
+ function isRecentNote(note) {
837
+ return note.updatedAt >= Date.now() - 1000 * 60 * 60 * 24 * 7;
838
+ }
839
+
840
+ function getSelectedNote() {
841
+ return state.notes.find((note) => note.id === state.selectedId) || null;
842
+ }
843
+
844
+ async function deleteFolder(folderId) {
845
+ const folder = state.folders.find((entry) => entry.id === folderId);
846
+ if (!folder || folder.id === "notes") return;
847
+
848
+ const shouldDelete = window.confirm(`删除“${folder.name}”?其中的笔记会移到“备忘录”。`);
849
+ if (!shouldDelete) return;
850
+
851
+ const fallbackFolder = state.folders.find((entry) => entry.id === "notes") || state.folders[0];
852
+ state.notes.forEach((note) => {
853
+ if (note.folder === folder.id) {
854
+ note.folder = fallbackFolder.id;
855
+ note.updatedAt = Date.now();
856
+ }
857
+ });
858
+
859
+ state.folders = state.folders.filter((entry) => entry.id !== folder.id);
860
+ if (state.activeFolder === folder.id) {
861
+ state.activeFolder = fallbackFolder.id;
862
+ const visible = getVisibleNotes();
863
+ state.selectedId = visible[0]?.id || null;
864
+ state.mobileView = "list";
865
+ }
866
+
867
+ render();
868
+
869
+ try {
870
+ await removeFolder(folder.id);
871
+ setSaveStatus("文件夹已删除", "ok");
872
+ const fresh = await loadState();
873
+ Object.assign(state, fresh);
874
+ render();
875
+ } catch (error) {
876
+ setSaveStatus("删除失败", "error");
877
+ console.error("Failed to delete folder.", error);
878
+ const fresh = await loadState();
879
+ Object.assign(state, fresh);
880
+ render();
881
+ }
882
+ }
883
+
884
+ function getActiveFolderLabel() {
885
+ if (state.activeFolder === "all") return "所有笔记";
886
+ if (state.activeFolder === "recent") return "最近编辑";
887
+ return state.folders.find((folder) => folder.id === state.activeFolder)?.name || "笔记";
888
+ }
889
+
890
+ function getWriteFolderId() {
891
+ if (state.folders.some((folder) => folder.id === state.activeFolder)) {
892
+ return state.activeFolder;
893
+ }
894
+ return state.folders[0]?.id || "notes";
895
+ }
896
+
897
+ function getUniqueFolderName(baseName) {
898
+ const existing = new Set(state.folders.map((folder) => folder.name));
899
+ if (!existing.has(baseName)) return baseName;
900
+
901
+ let index = 2;
902
+ while (existing.has(`${baseName} ${index}`)) {
903
+ index += 1;
904
+ }
905
+ return `${baseName} ${index}`;
906
+ }
907
+
908
+ function defaultFolders() {
909
+ return [{ id: "notes", name: "备忘录", version: 1 }];
910
+ }
911
+
912
+ function renderIcons() {
913
+ window.lucide?.createIcons({
914
+ attrs: {
915
+ "stroke-width": 2
916
+ }
917
+ });
918
+ }
919
+
920
+ function renderMarkdownPreview(note) {
921
+ if (!state.previewMode) return;
922
+ els.markdownPreview.innerHTML = markdownToHtml(note.body);
923
+ }
924
+
925
+ function markdownToHtml(markdown) {
926
+ const lines = String(markdown || "").split("\n");
927
+ const html = [];
928
+ let listType = "";
929
+ let inCode = false;
930
+ let codeLines = [];
931
+
932
+ const closeList = () => {
933
+ if (!listType) return;
934
+ html.push(`</${listType}>`);
935
+ listType = "";
936
+ };
937
+
938
+ lines.forEach((line) => {
939
+ if (line.trim().startsWith("```")) {
940
+ if (inCode) {
941
+ html.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
942
+ codeLines = [];
943
+ inCode = false;
944
+ } else {
945
+ closeList();
946
+ inCode = true;
947
+ }
948
+ return;
949
+ }
950
+
951
+ if (inCode) {
952
+ codeLines.push(line);
953
+ return;
954
+ }
955
+
956
+ const trimmed = line.trim();
957
+ if (!trimmed) {
958
+ closeList();
959
+ return;
960
+ }
961
+
962
+ const heading = trimmed.match(/^(#{1,3})\s+(.+)$/);
963
+ if (heading) {
964
+ closeList();
965
+ html.push(`<h${heading[1].length}>${renderInlineMarkdown(heading[2])}</h${heading[1].length}>`);
966
+ return;
967
+ }
968
+
969
+ const unordered = trimmed.match(/^[-*]\s+(.+)$/);
970
+ if (unordered) {
971
+ if (listType !== "ul") {
972
+ closeList();
973
+ listType = "ul";
974
+ html.push("<ul>");
975
+ }
976
+ html.push(`<li>${renderInlineMarkdown(unordered[1])}</li>`);
977
+ return;
978
+ }
979
+
980
+ const ordered = trimmed.match(/^\d+\.\s+(.+)$/);
981
+ if (ordered) {
982
+ if (listType !== "ol") {
983
+ closeList();
984
+ listType = "ol";
985
+ html.push("<ol>");
986
+ }
987
+ html.push(`<li>${renderInlineMarkdown(ordered[1])}</li>`);
988
+ return;
989
+ }
990
+
991
+ if (trimmed.startsWith(">")) {
992
+ closeList();
993
+ html.push(`<blockquote>${renderInlineMarkdown(trimmed.slice(1).trim())}</blockquote>`);
994
+ return;
995
+ }
996
+
997
+ closeList();
998
+ html.push(`<p>${renderInlineMarkdown(trimmed)}</p>`);
999
+ });
1000
+
1001
+ if (inCode) {
1002
+ html.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
1003
+ }
1004
+ closeList();
1005
+ return html.join("");
1006
+ }
1007
+
1008
+ function renderInlineMarkdown(value) {
1009
+ return escapeHtml(value)
1010
+ .replace(/`([^`]+)`/g, "<code>$1</code>")
1011
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
1012
+ .replace(/\*([^*]+)\*/g, "<em>$1</em>")
1013
+ .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
1014
+ }
1015
+
1016
+ function escapeHtml(value) {
1017
+ return String(value)
1018
+ .replaceAll("&", "&amp;")
1019
+ .replaceAll("<", "&lt;")
1020
+ .replaceAll(">", "&gt;")
1021
+ .replaceAll('"', "&quot;")
1022
+ .replaceAll("'", "&#039;");
1023
+ }
1024
+
1025
+ function getTitle(note) {
1026
+ const firstLine = note.body.split(/\n/).find((line) => line.trim());
1027
+ return firstLine ? firstLine.trim().slice(0, 80) : "新建笔记";
1028
+ }
1029
+
1030
+ function getPreview(note) {
1031
+ const lines = note.body.split(/\n/).map((line) => line.trim()).filter(Boolean);
1032
+ const text = lines.slice(1).join(" ") || "无附加文本";
1033
+ return text.slice(0, 140);
1034
+ }
1035
+
1036
+ function formatListDate(value) {
1037
+ const date = new Date(value);
1038
+ const today = new Date();
1039
+ const sameDay = date.toDateString() === today.toDateString();
1040
+ if (sameDay) {
1041
+ return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
1042
+ }
1043
+ return date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" });
1044
+ }
1045
+
1046
+ function formatFullDate(value) {
1047
+ return new Date(value).toLocaleString("zh-CN", {
1048
+ year: "numeric",
1049
+ month: "long",
1050
+ day: "numeric",
1051
+ hour: "2-digit",
1052
+ minute: "2-digit"
1053
+ });
1054
+ }
1055
+
1056
+ function formatSectionDate(value) {
1057
+ const date = new Date(value);
1058
+ const today = new Date();
1059
+ const yesterday = new Date();
1060
+ yesterday.setDate(today.getDate() - 1);
1061
+
1062
+ if (date.toDateString() === today.toDateString()) return "今天";
1063
+ if (date.toDateString() === yesterday.toDateString()) return "昨天";
1064
+
1065
+ const sameYear = date.getFullYear() === today.getFullYear();
1066
+ return date.toLocaleDateString("zh-CN", sameYear
1067
+ ? { month: "long", day: "numeric" }
1068
+ : { year: "numeric", month: "long", day: "numeric" });
1069
+ }
auth.mjs ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const encoder = new TextEncoder();
2
+ const iterations = 310000;
3
+
4
+ export async function hashPassword(password, salt = crypto.getRandomValues(new Uint8Array(16))) {
5
+ const key = await crypto.subtle.importKey(
6
+ "raw",
7
+ encoder.encode(password),
8
+ "PBKDF2",
9
+ false,
10
+ ["deriveBits"]
11
+ );
12
+ const bits = await crypto.subtle.deriveBits(
13
+ {
14
+ name: "PBKDF2",
15
+ hash: "SHA-256",
16
+ salt,
17
+ iterations
18
+ },
19
+ key,
20
+ 256
21
+ );
22
+
23
+ return `pbkdf2_sha256$${iterations}$${base64url(salt)}$${base64url(new Uint8Array(bits))}`;
24
+ }
25
+
26
+ export async function verifyPassword(password, storedHash) {
27
+ const [scheme, storedIterations, saltText, hashText] = String(storedHash || "").split("$");
28
+ if (scheme !== "pbkdf2_sha256" || Number(storedIterations) !== iterations) return false;
29
+
30
+ const candidate = await hashPassword(password, fromBase64url(saltText));
31
+ return timingSafeEqual(candidate, storedHash);
32
+ }
33
+
34
+ export async function hashSessionToken(token) {
35
+ const digest = await crypto.subtle.digest("SHA-256", encoder.encode(token));
36
+ return base64url(new Uint8Array(digest));
37
+ }
38
+
39
+ export function createSessionToken() {
40
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
41
+ return base64url(bytes);
42
+ }
43
+
44
+ function timingSafeEqual(a, b) {
45
+ const left = encoder.encode(a);
46
+ const right = encoder.encode(b);
47
+ if (left.length !== right.length) return false;
48
+
49
+ let result = 0;
50
+ for (let index = 0; index < left.length; index += 1) {
51
+ result |= left[index] ^ right[index];
52
+ }
53
+ return result === 0;
54
+ }
55
+
56
+ function base64url(bytes) {
57
+ const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join("");
58
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
59
+ }
60
+
61
+ function fromBase64url(value) {
62
+ const padded = value.replaceAll("-", "+").replaceAll("_", "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
63
+ const binary = atob(padded);
64
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
65
+ }
index.html ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>备忘录</title>
7
+ <link rel="stylesheet" href="styles.css">
8
+ </head>
9
+ <body>
10
+ <section class="auth-gate" id="authGate" hidden>
11
+ <form class="auth-panel" id="authForm">
12
+ <h1 id="authTitle">登录</h1>
13
+ <input id="authPassword" type="password" autocomplete="current-password" minlength="8" placeholder="密码" aria-label="密码">
14
+ <button id="authSubmit" type="submit">继续</button>
15
+ <p id="authMessage"></p>
16
+ </form>
17
+ </section>
18
+
19
+ <div class="app-shell">
20
+ <header class="mobile-nav" aria-label="移动端导航">
21
+ <button class="mobile-back" id="mobileBack" type="button" aria-label="返回">
22
+ <i data-lucide="chevron-left" aria-hidden="true"></i>
23
+ <span id="mobileBackLabel">返回</span>
24
+ </button>
25
+ <strong id="mobileTitle">文件夹</strong>
26
+ <div class="mobile-actions" id="mobileActions">
27
+ <button class="mobile-more-button" id="mobileMore" type="button" aria-label="更多操作" title="更多操作">
28
+ <i data-lucide="ellipsis" aria-hidden="true"></i>
29
+ </button>
30
+ <div class="mobile-action-menu" id="mobileActionMenu" hidden>
31
+ <label>
32
+ <span>移动到</span>
33
+ <select id="mobileNoteFolderSelect" class="mobile-folder-select" aria-label="移动笔记到文件夹"></select>
34
+ </label>
35
+ <button id="mobileMenuPreview" type="button">
36
+ <i data-lucide="eye" aria-hidden="true"></i>
37
+ <span>Markdown 预览</span>
38
+ </button>
39
+ <button id="mobileMenuSave" type="button">
40
+ <i data-lucide="save" aria-hidden="true"></i>
41
+ <span>保存</span>
42
+ </button>
43
+ <button id="mobileMenuDelete" type="button">
44
+ <i data-lucide="trash-2" aria-hidden="true"></i>
45
+ <span>删除</span>
46
+ </button>
47
+ </div>
48
+ </div>
49
+ </header>
50
+
51
+ <aside class="folders" aria-label="文件夹">
52
+ <div class="folder-scroll">
53
+ <div class="sidebar-title">iCloud</div>
54
+ <div id="folderList"></div>
55
+ </div>
56
+ <div class="folder-footer">
57
+ <button class="new-folder-button" id="newFolder" type="button" aria-label="新建文件夹" title="新建文件夹">
58
+ <i data-lucide="folder-plus" aria-hidden="true"></i>
59
+ <span>新建文件夹</span>
60
+ </button>
61
+ </div>
62
+ </aside>
63
+
64
+ <main class="notes-pane" aria-label="笔记列表">
65
+ <header class="toolbar">
66
+ <button class="icon-button" id="newNote" type="button" aria-label="新建笔记" title="新建笔记">
67
+ <i data-lucide="plus" aria-hidden="true"></i>
68
+ </button>
69
+ <button class="icon-button" id="deleteNote" type="button" aria-label="删除笔记" title="删除笔记">
70
+ <i data-lucide="trash-2" aria-hidden="true"></i>
71
+ </button>
72
+ <button class="icon-button" id="previewToggle" type="button" aria-label="Markdown 预览" title="Markdown 预览">
73
+ <i data-lucide="eye" aria-hidden="true"></i>
74
+ </button>
75
+ <button class="icon-button" id="changePassword" type="button" aria-label="修改密码" title="修改密码">
76
+ <i data-lucide="key-round" aria-hidden="true"></i>
77
+ </button>
78
+ <button class="icon-button" id="logout" type="button" aria-label="退出登录" title="退出登录">
79
+ <i data-lucide="log-out" aria-hidden="true"></i>
80
+ </button>
81
+ </header>
82
+ <div class="search-wrap">
83
+ <input id="searchInput" type="search" placeholder="搜索" autocomplete="off" aria-label="搜索笔记">
84
+ </div>
85
+ <div class="list-heading">
86
+ <strong id="listTitle">所有笔记</strong>
87
+ <span id="listCount">0 个笔记</span>
88
+ </div>
89
+ <section class="note-list" id="noteList" aria-label="笔记"></section>
90
+ </main>
91
+
92
+ <section class="editor-pane" aria-label="编辑器">
93
+ <div class="editor-meta">
94
+ <time id="editedAt"></time>
95
+ <span class="save-status" id="saveStatus" aria-live="polite"></span>
96
+ <select id="noteFolderSelect" aria-label="移动笔记到文件夹"></select>
97
+ <button class="editor-preview-button" id="editorPreviewToggle" type="button" aria-label="Markdown 预览" title="Markdown 预览">
98
+ <i data-lucide="eye" aria-hidden="true"></i>
99
+ </button>
100
+ <button class="editor-save-button" id="saveNote" type="button" aria-label="保存笔记" title="保存笔记">
101
+ <i data-lucide="save" aria-hidden="true"></i>
102
+ </button>
103
+ </div>
104
+ <textarea id="editor" spellcheck="true" aria-label="笔记正文"></textarea>
105
+ <article class="markdown-preview" id="markdownPreview" hidden></article>
106
+ <div class="empty-state" id="emptyState">
107
+ <strong>没有选择笔记</strong>
108
+ <span>新建或选择一条笔记开始编辑</span>
109
+ </div>
110
+ </section>
111
+
112
+ <footer class="mobile-bottom" aria-label="移动端操作">
113
+ <div class="mobile-search" id="mobileSearchWrap">
114
+ <input id="mobileSearchInput" type="search" placeholder="搜索" autocomplete="off" aria-label="移动端搜索">
115
+ </div>
116
+ <button class="mobile-edit-button" id="mobileEdit" type="button" aria-label="新建笔记" title="新建笔记">
117
+ <i data-lucide="square-pen" aria-hidden="true"></i>
118
+ </button>
119
+ <button class="mobile-preview-button" id="mobilePreviewToggle" type="button" aria-label="Markdown 预览" title="Markdown 预览">
120
+ <i data-lucide="eye" aria-hidden="true"></i>
121
+ </button>
122
+ <button class="mobile-save-button" id="mobileSave" type="button" aria-label="保存笔记" title="保存笔记">
123
+ <i data-lucide="save" aria-hidden="true"></i>
124
+ </button>
125
+ </footer>
126
+ </div>
127
+
128
+ <script src="/node_modules/lucide/dist/umd/lucide.js"></script>
129
+ <script type="module" src="app.js"></script>
130
+ </body>
131
+ </html>
package.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "module",
3
+ "scripts": {
4
+ "dev": "node server.mjs",
5
+ "seed:mock": "node scripts/seed-mock.mjs"
6
+ },
7
+ "dependencies": {
8
+ "@hono/node-server": "^2.0.4",
9
+ "hono": "^4.12.23",
10
+ "lucide": "^1.16.0"
11
+ }
12
+ }
pnpm-lock.yaml ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ lockfileVersion: '9.0'
2
+
3
+ settings:
4
+ autoInstallPeers: true
5
+ excludeLinksFromLockfile: false
6
+
7
+ importers:
8
+
9
+ .:
10
+ dependencies:
11
+ '@hono/node-server':
12
+ specifier: ^2.0.4
13
+ version: 2.0.4(hono@4.12.23)
14
+ hono:
15
+ specifier: ^4.12.23
16
+ version: 4.12.23
17
+ lucide:
18
+ specifier: ^1.16.0
19
+ version: 1.16.0
20
+
21
+ packages:
22
+
23
+ '@hono/node-server@2.0.4':
24
+ resolution: {integrity: sha512-Ut3y0dMMPWy6bZ2kVfx25EOVbZlm15dhF4mOsezMlhpNHy+4MkU1qN9Y6lnruYi4wPmFzimGX2X7LF/FwHli4A==}
25
+ engines: {node: '>=20'}
26
+ peerDependencies:
27
+ hono: ^4
28
+
29
+ hono@4.12.23:
30
+ resolution: {integrity: sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==}
31
+ engines: {node: '>=16.9.0'}
32
+
33
+ lucide@1.16.0:
34
+ resolution: {integrity: sha512-20QvduCJTB7e7K9WVvoLBuKPsYZ8d6ptwe9PIdTFiZmjVTPdFWQreBNyNCM9QQtGnqHR0PLiEQdxyhYsL1LdoA==}
35
+
36
+ snapshots:
37
+
38
+ '@hono/node-server@2.0.4(hono@4.12.23)':
39
+ dependencies:
40
+ hono: 4.12.23
41
+
42
+ hono@4.12.23: {}
43
+
44
+ lucide@1.16.0: {}
scripts/backup-proxy.mjs ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { spawn } from "node:child_process";
2
+ import { createServer } from "node:http";
3
+
4
+ const port = Number(process.env.PORT || 7860);
5
+ const appOrigin = new URL(process.env.APP_ORIGIN || "http://127.0.0.1:4173");
6
+ let backupRunning = false;
7
+
8
+ const server = createServer(async (req, res) => {
9
+ try {
10
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
11
+
12
+ if (url.pathname === "/backup-injected.js") {
13
+ sendText(res, 200, injectedScript(), "text/javascript; charset=utf-8");
14
+ return;
15
+ }
16
+
17
+ if (url.pathname === "/api/backup/run") {
18
+ await handleBackup(req, res);
19
+ return;
20
+ }
21
+
22
+ await proxyRequest(req, res, url);
23
+ } catch (error) {
24
+ console.error("[backup-proxy]", error);
25
+ sendJson(res, 500, { ok: false, error: "proxy_error" });
26
+ }
27
+ });
28
+
29
+ server.listen(port, "0.0.0.0", () => {
30
+ console.log(`Backup proxy running at http://0.0.0.0:${port}`);
31
+ console.log(`Proxying app origin: ${appOrigin.origin}`);
32
+ });
33
+
34
+ async function handleBackup(req, res) {
35
+ if (req.method !== "POST") {
36
+ sendJson(res, 405, { ok: false, error: "method_not_allowed" });
37
+ return;
38
+ }
39
+ if (!isSameOriginRequest(req)) {
40
+ sendJson(res, 403, { ok: false, error: "origin_forbidden" });
41
+ return;
42
+ }
43
+ if (!await isAuthenticated(req)) {
44
+ sendJson(res, 401, { ok: false, error: "unauthorized" });
45
+ return;
46
+ }
47
+ if (backupRunning) {
48
+ sendJson(res, 409, { ok: false, error: "backup_running" });
49
+ return;
50
+ }
51
+
52
+ backupRunning = true;
53
+ try {
54
+ const result = await runBackupScript();
55
+ sendJson(res, 200, result);
56
+ } catch (error) {
57
+ console.error("[backup]", error);
58
+ sendJson(res, 500, { ok: false, error: "backup_failed" });
59
+ } finally {
60
+ backupRunning = false;
61
+ }
62
+ }
63
+
64
+ async function isAuthenticated(req) {
65
+ const response = await fetch(new URL("/api/auth/status", appOrigin), {
66
+ headers: {
67
+ cookie: req.headers.cookie || ""
68
+ }
69
+ });
70
+ if (!response.ok) return false;
71
+ const status = await response.json().catch(() => ({}));
72
+ return status.authenticated === true;
73
+ }
74
+
75
+ function isSameOriginRequest(req) {
76
+ const origin = req.headers.origin;
77
+ if (!origin) return true;
78
+ const host = req.headers.host;
79
+ if (!host) return false;
80
+ try {
81
+ return new URL(origin).host === host;
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ function runBackupScript() {
88
+ return new Promise((resolve, reject) => {
89
+ const child = spawn(process.execPath, ["scripts/backup-sqlite.mjs", "--backup"], {
90
+ env: process.env,
91
+ stdio: ["ignore", "pipe", "pipe"]
92
+ });
93
+ let stdout = "";
94
+ let stderr = "";
95
+ child.stdout.on("data", (chunk) => {
96
+ stdout += chunk.toString();
97
+ });
98
+ child.stderr.on("data", (chunk) => {
99
+ stderr += chunk.toString();
100
+ });
101
+ child.on("error", reject);
102
+ child.on("close", (code) => {
103
+ if (code !== 0) {
104
+ reject(new Error(`backup_script_failed_${code}: ${stderr.slice(0, 500)}`));
105
+ return;
106
+ }
107
+ resolve(parseLastJson(stdout));
108
+ });
109
+ });
110
+ }
111
+
112
+ async function proxyRequest(req, res, url) {
113
+ const target = new URL(`${url.pathname}${url.search}`, appOrigin);
114
+ const headers = new Headers(req.headers);
115
+ headers.delete("host");
116
+ headers.delete("content-length");
117
+
118
+ const init = {
119
+ method: req.method,
120
+ headers,
121
+ redirect: "manual"
122
+ };
123
+ if (!["GET", "HEAD"].includes(req.method || "GET")) {
124
+ init.body = await readBody(req);
125
+ }
126
+
127
+ const response = await fetch(target, init);
128
+ const responseHeaders = new Headers(response.headers);
129
+ let body = Buffer.from(await response.arrayBuffer());
130
+
131
+ if (isHtmlResponse(responseHeaders)) {
132
+ const html = body.toString("utf8");
133
+ body = Buffer.from(injectBackupScript(html));
134
+ responseHeaders.set("content-length", String(body.byteLength));
135
+ }
136
+
137
+ res.writeHead(response.status, Object.fromEntries(responseHeaders.entries()));
138
+ res.end(body);
139
+ }
140
+
141
+ function injectBackupScript(html) {
142
+ if (html.includes("/backup-injected.js")) return html;
143
+ const tag = '<script src="/backup-injected.js" defer></script>';
144
+ return html.includes("</body>") ? html.replace("</body>", `${tag}</body>`) : `${html}${tag}`;
145
+ }
146
+
147
+ function injectedScript() {
148
+ return `
149
+ (() => {
150
+ const buttonHtml = '<i data-lucide="cloud-upload" aria-hidden="true"></i><span>备份</span>';
151
+
152
+ function makeButton(className) {
153
+ const button = document.createElement('button');
154
+ button.type = 'button';
155
+ button.className = className;
156
+ button.setAttribute('aria-label', '备份数据库');
157
+ button.title = '备份数据库';
158
+ button.innerHTML = buttonHtml;
159
+ button.addEventListener('click', runBackup);
160
+ return button;
161
+ }
162
+
163
+ async function runBackup(event) {
164
+ const button = event.currentTarget;
165
+ button.disabled = true;
166
+ const original = button.innerHTML;
167
+ button.textContent = '备份中';
168
+ try {
169
+ const response = await fetch('/api/backup/run', { method: 'POST' });
170
+ if (response.status === 401) {
171
+ alert('请先登录后再备份');
172
+ return;
173
+ }
174
+ if (response.status === 409) {
175
+ alert('备份正在进行中');
176
+ return;
177
+ }
178
+ if (!response.ok) throw new Error('backup_failed');
179
+ alert('备份完成');
180
+ } catch {
181
+ alert('备份失败,请检查 rclone 配置');
182
+ } finally {
183
+ button.disabled = false;
184
+ button.innerHTML = original;
185
+ window.lucide?.createIcons({ attrs: { 'stroke-width': 2 } });
186
+ }
187
+ }
188
+
189
+ function mount() {
190
+ if (!document.querySelector('[data-backup-button="desktop"]')) {
191
+ const toolbar = document.querySelector('.toolbar');
192
+ if (toolbar) {
193
+ const button = makeButton('icon-button');
194
+ button.dataset.backupButton = 'desktop';
195
+ button.innerHTML = '<i data-lucide="cloud-upload" aria-hidden="true"></i>';
196
+ toolbar.append(button);
197
+ }
198
+ }
199
+
200
+ if (!document.querySelector('[data-backup-button="mobile"]')) {
201
+ const menu = document.querySelector('#mobileActionMenu');
202
+ if (menu) {
203
+ const button = makeButton('');
204
+ button.dataset.backupButton = 'mobile';
205
+ menu.append(button);
206
+ }
207
+ }
208
+
209
+ window.lucide?.createIcons({ attrs: { 'stroke-width': 2 } });
210
+ }
211
+
212
+ mount();
213
+ new MutationObserver(mount).observe(document.documentElement, { childList: true, subtree: true });
214
+ })();
215
+ `;
216
+ }
217
+
218
+ function isHtmlResponse(headers) {
219
+ return (headers.get("content-type") || "").includes("text/html");
220
+ }
221
+
222
+ function readBody(req) {
223
+ return new Promise((resolve, reject) => {
224
+ const chunks = [];
225
+ req.on("data", (chunk) => chunks.push(chunk));
226
+ req.on("end", () => resolve(Buffer.concat(chunks)));
227
+ req.on("error", reject);
228
+ });
229
+ }
230
+
231
+ function parseLastJson(output) {
232
+ const lines = output.trim().split(/\r?\n/).filter(Boolean);
233
+ const last = lines.at(-1);
234
+ if (!last) return { ok: true };
235
+ try {
236
+ return JSON.parse(last);
237
+ } catch {
238
+ return { ok: true };
239
+ }
240
+ }
241
+
242
+ function sendJson(res, status, payload) {
243
+ sendText(res, status, JSON.stringify(payload), "application/json; charset=utf-8");
244
+ }
245
+
246
+ function sendText(res, status, body, contentType) {
247
+ res.writeHead(status, {
248
+ "content-type": contentType,
249
+ "content-length": Buffer.byteLength(body)
250
+ });
251
+ res.end(body);
252
+ }
scripts/backup-sqlite.mjs ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, renameSync, statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { DatabaseSync } from "node:sqlite";
6
+
7
+ const root = process.cwd();
8
+ const dataDir = join(root, "data");
9
+ const dbPath = join(dataDir, "notes.db");
10
+ const remoteFolder = sanitizeRemoteFolder(process.env.REMOTE_FOLDER || "huggingface:notes");
11
+ const backupFile = "notes.db";
12
+ const uploadFile = "notes.db.uploading";
13
+ const mode = process.argv.includes("--restore") ? "restore" : "backup";
14
+
15
+ try {
16
+ if (mode === "restore") {
17
+ await restoreBackup();
18
+ } else {
19
+ await uploadBackup();
20
+ }
21
+ } catch (error) {
22
+ console.error(JSON.stringify({
23
+ ok: false,
24
+ mode,
25
+ error: error.message
26
+ }));
27
+ process.exitCode = 1;
28
+ }
29
+
30
+ async function restoreBackup() {
31
+ mkdirSync(dataDir, { recursive: true });
32
+ const remote = remotePath(backupFile);
33
+ const exists = await remoteFileExists();
34
+ if (!exists) {
35
+ console.log(JSON.stringify({ ok: true, mode: "restore", restored: false, reason: "remote_backup_missing" }));
36
+ return;
37
+ }
38
+
39
+ const workDir = mkdtempSync(join(tmpdir(), "notes-restore-"));
40
+ const restorePath = join(workDir, "notes.db");
41
+ await rclone(["copyto", remote, restorePath]);
42
+ assertUsableSqlite(restorePath);
43
+ renameSync(restorePath, dbPath);
44
+ rmSync(workDir, { recursive: true, force: true });
45
+ console.log(JSON.stringify({ ok: true, mode: "restore", restored: true }));
46
+ }
47
+
48
+ async function uploadBackup() {
49
+ if (!existsSync(dbPath) || statSync(dbPath).size === 0) {
50
+ throw new Error("local_database_missing");
51
+ }
52
+
53
+ const workDir = mkdtempSync(join(tmpdir(), "notes-backup-"));
54
+ const snapshotPath = join(workDir, "notes.db");
55
+ createSnapshot(snapshotPath);
56
+ const bytes = statSync(snapshotPath).size;
57
+
58
+ const uploading = remotePath(uploadFile);
59
+ const target = remotePath(backupFile);
60
+ await rclone(["copyto", snapshotPath, uploading]);
61
+ await rclone(["moveto", uploading, target]);
62
+ rmSync(workDir, { recursive: true, force: true });
63
+ console.log(JSON.stringify({
64
+ ok: true,
65
+ mode: "backup",
66
+ remote: target,
67
+ bytes,
68
+ updatedAt: Date.now()
69
+ }));
70
+ }
71
+
72
+ async function remoteFileExists() {
73
+ const output = await rclone(["lsf", remoteFolder, "--files-only", "--include", backupFile], { allowFailure: true });
74
+ return output.split(/\r?\n/).some((line) => line.trim() === backupFile);
75
+ }
76
+
77
+ function createSnapshot(snapshotPath) {
78
+ const db = new DatabaseSync(dbPath);
79
+ try {
80
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
81
+ db.exec(`VACUUM INTO '${escapeSqlitePath(snapshotPath)}'`);
82
+ } finally {
83
+ db.close();
84
+ }
85
+ assertUsableSqlite(snapshotPath);
86
+ }
87
+
88
+ function assertUsableSqlite(path) {
89
+ const db = new DatabaseSync(path);
90
+ try {
91
+ db.prepare("SELECT name FROM sqlite_master LIMIT 1").all();
92
+ } finally {
93
+ db.close();
94
+ }
95
+ }
96
+
97
+ function rclone(args, { allowFailure = false } = {}) {
98
+ return new Promise((resolve, reject) => {
99
+ const child = spawn("rclone", args, {
100
+ env: process.env,
101
+ stdio: ["ignore", "pipe", "pipe"]
102
+ });
103
+ let stdout = "";
104
+ child.stdout.on("data", (chunk) => {
105
+ stdout += chunk.toString();
106
+ });
107
+ child.stderr.on("data", () => {});
108
+ child.on("error", reject);
109
+ child.on("close", (code) => {
110
+ if (code === 0 || allowFailure) {
111
+ resolve(stdout);
112
+ return;
113
+ }
114
+ reject(new Error(`rclone_failed_${code}`));
115
+ });
116
+ });
117
+ }
118
+
119
+ function remotePath(file) {
120
+ return `${remoteFolder.replace(/\/+$/, "")}/${file}`;
121
+ }
122
+
123
+ function sanitizeRemoteFolder(value) {
124
+ const trimmed = String(value || "").trim();
125
+ if (!trimmed) throw new Error("remote_folder_missing");
126
+ if (/[\0\r\n]/.test(trimmed)) throw new Error("remote_folder_invalid");
127
+ if (!trimmed.includes(":")) throw new Error("remote_folder_must_include_remote_name");
128
+ return trimmed;
129
+ }
130
+
131
+ function escapeSqlitePath(path) {
132
+ return path.replaceAll("'", "''");
133
+ }
scripts/docker-entrypoint.sh ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env sh
2
+ set -eu
3
+
4
+ APP_PORT="${APP_PORT:-4173}"
5
+ PUBLIC_PORT="${PORT:-7860}"
6
+ export APP_ORIGIN="${APP_ORIGIN:-http://127.0.0.1:${APP_PORT}}"
7
+ export REMOTE_FOLDER="${REMOTE_FOLDER:-huggingface:notes}"
8
+
9
+ APP_PID=""
10
+ PROXY_PID=""
11
+
12
+ log() {
13
+ printf '%s\n' "$*"
14
+ }
15
+
16
+ setup_rclone_config() {
17
+ if [ -n "${RCLONE_CONF:-}" ] || [ -n "${RCLONE_CONFIG_BASE64:-}" ] || [ -n "${RCLONE_CONFIG_CONTENT:-}" ]; then
18
+ mkdir -p /root/.config/rclone
19
+ umask 077
20
+ export RCLONE_CONFIG="${RCLONE_CONFIG:-/root/.config/rclone/rclone.conf}"
21
+
22
+ if [ -n "${RCLONE_CONF:-}" ]; then
23
+ printf '%s' "$RCLONE_CONF" > "$RCLONE_CONFIG"
24
+ log "rclone config loaded from RCLONE_CONF."
25
+ elif [ -n "${RCLONE_CONFIG_BASE64:-}" ]; then
26
+ printf '%s' "$RCLONE_CONFIG_BASE64" | base64 -d > "$RCLONE_CONFIG"
27
+ log "rclone config loaded from RCLONE_CONFIG_BASE64."
28
+ else
29
+ printf '%s' "$RCLONE_CONFIG_CONTENT" > "$RCLONE_CONFIG"
30
+ log "rclone config loaded from RCLONE_CONFIG_CONTENT."
31
+ fi
32
+
33
+ chmod 600 "$RCLONE_CONFIG"
34
+ return
35
+ fi
36
+
37
+ log "No rclone config file secret found; using rclone environment remote config if provided."
38
+ }
39
+
40
+ shutdown() {
41
+ log "Shutting down, running final backup..."
42
+ node scripts/backup-sqlite.mjs --backup >/tmp/final-backup.log 2>&1 || log "Final backup failed."
43
+ if [ -n "$PROXY_PID" ]; then kill "$PROXY_PID" 2>/dev/null || true; fi
44
+ if [ -n "$APP_PID" ]; then kill "$APP_PID" 2>/dev/null || true; fi
45
+ wait 2>/dev/null || true
46
+ }
47
+
48
+ trap shutdown INT TERM
49
+
50
+ mkdir -p data
51
+ setup_rclone_config
52
+
53
+ log "Restoring database from ${REMOTE_FOLDER}/notes.db if it exists..."
54
+ node scripts/backup-sqlite.mjs --restore || log "Restore skipped or failed; continuing with local initialization."
55
+
56
+ log "Starting app on ${APP_ORIGIN}..."
57
+ PORT="$APP_PORT" node server.mjs &
58
+ APP_PID="$!"
59
+
60
+ log "Starting backup proxy on port ${PUBLIC_PORT}..."
61
+ PORT="$PUBLIC_PORT" node scripts/backup-proxy.mjs &
62
+ PROXY_PID="$!"
63
+
64
+ wait "$PROXY_PID"
scripts/seed-mock.mjs ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { join } from "node:path";
2
+ import { createRepository } from "../data/repository.mjs";
3
+
4
+ const repository = createRepository({ dbPath: join(process.cwd(), "data", "notes.db") });
5
+ await repository.init();
6
+
7
+ const now = Date.now();
8
+ const folders = [
9
+ { id: "notes", name: "备忘录", createdAt: now - 1000 * 60 * 60 * 24 * 30, updatedAt: now },
10
+ ...Array.from({ length: 22 }, (_, index) => ({
11
+ id: `mock-folder-${index + 1}`,
12
+ name: `测试分类 ${String(index + 1).padStart(2, "0")}`,
13
+ createdAt: now - 1000 * 60 * 60 * (22 - index),
14
+ updatedAt: now - 1000 * 60 * index
15
+ }))
16
+ ];
17
+
18
+ const longParagraph = [
19
+ "这是一段用于测试内容滚动的长文本。",
20
+ "移动端需要在内容页保持顶部返回栏和底部编辑按钮稳定。",
21
+ "桌面端需要编辑区自己滚动,不应该把分类和列表一起带走。",
22
+ "这里故意写得比较长,用来观察换行、行高和滚动条表现。"
23
+ ].join("");
24
+
25
+ const notes = Array.from({ length: 96 }, (_, index) => {
26
+ const folder = index < 18 ? "notes" : `mock-folder-${(index % 22) + 1}`;
27
+ const lines = Array.from({ length: index % 9 === 0 ? 46 : 8 }, (__, lineIndex) => {
28
+ if (lineIndex === 0) return `测试笔记 ${String(index + 1).padStart(3, "0")}`;
29
+ if (lineIndex % 6 === 0) return `- 列表项 ${lineIndex}: 检查列表和正文滚动`;
30
+ return `${lineIndex}. ${longParagraph}`;
31
+ });
32
+
33
+ return {
34
+ id: `mock-note-${index + 1}`,
35
+ folder,
36
+ body: lines.join("\n\n"),
37
+ createdAt: now - 1000 * 60 * 60 * index,
38
+ updatedAt: now - 1000 * 60 * index
39
+ };
40
+ });
41
+
42
+ await repository.writeState({ folders, notes });
43
+ console.log(`Seeded ${folders.length} folders and ${notes.length} notes.`);
server.mjs ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { serve } from "@hono/node-server";
2
+ import { Hono } from "hono";
3
+ import { deleteCookie, getCookie, setCookie } from "hono/cookie";
4
+ import { readFile } from "node:fs/promises";
5
+ import { extname, join, normalize } from "node:path";
6
+ import { createSessionToken, hashPassword, hashSessionToken, verifyPassword } from "./auth.mjs";
7
+ import { createRepository } from "./data/repository.mjs";
8
+ import { maxNoteBodyLength } from "./data/state-utils.mjs";
9
+
10
+ const port = Number(process.env.PORT || 4173);
11
+ const root = process.cwd();
12
+ const app = new Hono();
13
+ const repository = createRepository({ dbPath: join(root, "data", "notes.db") });
14
+ const sessionCookie = "web_notes_session";
15
+ const sessionTtlMs = 1000 * 60 * 60 * 24 * 30;
16
+
17
+ await repository.init();
18
+
19
+ app.get("/api/health", (c) => c.json({ ok: true }));
20
+
21
+ app.get("/api/auth/status", async (c) => {
22
+ const user = await repository.getUser();
23
+ return c.json({
24
+ configured: Boolean(user),
25
+ authenticated: await isAuthenticated(c)
26
+ });
27
+ });
28
+
29
+ app.post("/api/auth/setup", async (c) => {
30
+ const existing = await repository.getUser();
31
+ if (existing) return c.json({ ok: false, error: "already_configured" }, 409);
32
+
33
+ const { password } = await c.req.json();
34
+ if (!isValidPassword(password)) {
35
+ return c.json({ ok: false, error: "password_too_short" }, 400);
36
+ }
37
+
38
+ await repository.createUser({ passwordHash: await hashPassword(password) });
39
+ await createSession(c);
40
+ return c.json({ ok: true });
41
+ });
42
+
43
+ app.post("/api/auth/login", async (c) => {
44
+ const user = await repository.getUser();
45
+ if (!user) return c.json({ ok: false, error: "not_configured" }, 400);
46
+
47
+ const { password } = await c.req.json();
48
+ if (!await verifyPassword(password, user.passwordHash)) {
49
+ return c.json({ ok: false, error: "invalid_password" }, 401);
50
+ }
51
+
52
+ await createSession(c);
53
+ return c.json({ ok: true });
54
+ });
55
+
56
+ app.post("/api/auth/logout", async (c) => {
57
+ const token = getCookie(c, sessionCookie);
58
+ if (token) {
59
+ await repository.deleteSession(await hashSessionToken(token));
60
+ }
61
+ deleteCookie(c, sessionCookie, { path: "/" });
62
+ return c.json({ ok: true });
63
+ });
64
+
65
+ app.post("/api/auth/password", async (c) => {
66
+ const user = await repository.getUser();
67
+ if (!user) return c.json({ ok: false, error: "not_configured" }, 400);
68
+ if (!await isAuthenticated(c)) return c.json({ ok: false, error: "unauthorized" }, 401);
69
+
70
+ const { currentPassword, nextPassword } = await c.req.json();
71
+ if (!await verifyPassword(currentPassword, user.passwordHash)) {
72
+ return c.json({ ok: false, error: "invalid_password" }, 401);
73
+ }
74
+ if (!isValidPassword(nextPassword)) {
75
+ return c.json({ ok: false, error: "password_too_short" }, 400);
76
+ }
77
+
78
+ await repository.updateUserPassword({ passwordHash: await hashPassword(nextPassword) });
79
+ await repository.deleteAllSessions();
80
+ await createSession(c);
81
+ return c.json({ ok: true });
82
+ });
83
+
84
+ app.use("/api/state", async (c, next) => {
85
+ const user = await repository.getUser();
86
+ if (!user) return c.json({ ok: false, error: "not_configured" }, 401);
87
+ if (!await isAuthenticated(c)) return c.json({ ok: false, error: "unauthorized" }, 401);
88
+ await next();
89
+ });
90
+
91
+ app.get("/api/state", async (c) => {
92
+ return c.json(await repository.readState());
93
+ });
94
+
95
+ app.put("/api/state", async (c) => {
96
+ const body = await c.req.json();
97
+ const folders = Array.isArray(body.folders) ? body.folders : [];
98
+ const notes = Array.isArray(body.notes) ? body.notes : [];
99
+ return c.json(await repository.writeState({ folders, notes }));
100
+ });
101
+
102
+ app.post("/api/notes", async (c) => {
103
+ const authError = await getAuthError(c);
104
+ if (authError) return authError;
105
+ const body = await c.req.json();
106
+ const lengthError = validateBodyLength(body.body);
107
+ if (lengthError) return c.json(lengthError, 413);
108
+ return c.json(await repository.createNote(body), 201);
109
+ });
110
+
111
+ app.patch("/api/notes/:id", async (c) => {
112
+ const authError = await getAuthError(c);
113
+ if (authError) return authError;
114
+ const body = await c.req.json();
115
+ const lengthError = validateBodyLength(body.body);
116
+ if (lengthError) return c.json(lengthError, 413);
117
+
118
+ const result = await repository.updateNote(c.req.param("id"), body);
119
+ if (result.status === "missing") return c.json({ ok: false, error: "not_found" }, 404);
120
+ if (result.status === "conflict") return c.json({ ok: false, error: "conflict", note: result.note }, 409);
121
+ return c.json(result.note);
122
+ });
123
+
124
+ app.delete("/api/notes/:id", async (c) => {
125
+ const authError = await getAuthError(c);
126
+ if (authError) return authError;
127
+ await repository.deleteNote(c.req.param("id"));
128
+ return c.json({ ok: true });
129
+ });
130
+
131
+ app.post("/api/folders", async (c) => {
132
+ const authError = await getAuthError(c);
133
+ if (authError) return authError;
134
+ const body = await c.req.json();
135
+ return c.json(await repository.createFolder(body), 201);
136
+ });
137
+
138
+ app.delete("/api/folders/:id", async (c) => {
139
+ const authError = await getAuthError(c);
140
+ if (authError) return authError;
141
+ const result = await repository.deleteFolder(c.req.param("id"));
142
+ if (result.status === "missing") return c.json({ ok: false, error: "not_found" }, 404);
143
+ return c.json(result);
144
+ });
145
+
146
+ app.get("*", async (c) => {
147
+ const url = new URL(c.req.url);
148
+ const requestPath = url.pathname === "/" ? "/index.html" : url.pathname;
149
+ const safePath = normalize(requestPath).replace(/^(\.\.[/\\])+/, "");
150
+ const filePath = join(root, safePath);
151
+
152
+ if (!filePath.startsWith(root)) {
153
+ return c.text("Not found", 404);
154
+ }
155
+
156
+ try {
157
+ const file = await readFile(filePath);
158
+ return new Response(file, {
159
+ headers: {
160
+ "content-type": mimeType(extname(filePath))
161
+ }
162
+ });
163
+ } catch {
164
+ const index = await readFile(join(root, "index.html"));
165
+ return new Response(index, {
166
+ headers: {
167
+ "content-type": "text/html; charset=utf-8"
168
+ }
169
+ });
170
+ }
171
+ });
172
+
173
+ serve({ fetch: app.fetch, port }, () => {
174
+ console.log(`备忘录 running at http://127.0.0.1:${port}`);
175
+ console.log(`Data repository runtime: ${repository.runtime}`);
176
+ });
177
+
178
+ function mimeType(ext) {
179
+ return {
180
+ ".html": "text/html; charset=utf-8",
181
+ ".css": "text/css; charset=utf-8",
182
+ ".js": "text/javascript; charset=utf-8",
183
+ ".json": "application/json; charset=utf-8",
184
+ ".png": "image/png",
185
+ ".svg": "image/svg+xml; charset=utf-8"
186
+ }[ext] || "application/octet-stream";
187
+ }
188
+
189
+ async function createSession(c) {
190
+ const token = createSessionToken();
191
+ const expiresAt = Date.now() + sessionTtlMs;
192
+ await repository.createSession({
193
+ tokenHash: await hashSessionToken(token),
194
+ expiresAt
195
+ });
196
+ setCookie(c, sessionCookie, token, {
197
+ path: "/",
198
+ httpOnly: true,
199
+ sameSite: "Lax",
200
+ maxAge: Math.floor(sessionTtlMs / 1000),
201
+ secure: new URL(c.req.url).protocol === "https:"
202
+ });
203
+ }
204
+
205
+ async function isAuthenticated(c) {
206
+ const token = getCookie(c, sessionCookie);
207
+ if (!token) return false;
208
+ return Boolean(await repository.getSession(await hashSessionToken(token)));
209
+ }
210
+
211
+ async function getAuthError(c) {
212
+ const user = await repository.getUser();
213
+ if (!user) return c.json({ ok: false, error: "not_configured" }, 401);
214
+ if (!await isAuthenticated(c)) return c.json({ ok: false, error: "unauthorized" }, 401);
215
+ return null;
216
+ }
217
+
218
+ function isValidPassword(password) {
219
+ return typeof password === "string" && password.length >= 8;
220
+ }
221
+
222
+ function validateBodyLength(body) {
223
+ if (body === undefined) return null;
224
+ if (String(body).length <= maxNoteBodyLength) return null;
225
+ return {
226
+ ok: false,
227
+ error: "note_too_large",
228
+ maxLength: maxNoteBodyLength
229
+ };
230
+ }
styles.css ADDED
@@ -0,0 +1,1497 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ color-scheme: light;
3
+ --window: #f5f5f3;
4
+ --sidebar: #ecebe7;
5
+ --list: #fbfaf7;
6
+ --editor: #fffefa;
7
+ --text: #1d1d1f;
8
+ --muted: #78736b;
9
+ --line: #d9d6ce;
10
+ --accent: #f2b84b;
11
+ --accent-strong: #d89621;
12
+ --selected: #ffe7ad;
13
+ --selected-soft: #fff3d5;
14
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Arial, sans-serif;
15
+ }
16
+
17
+ * {
18
+ box-sizing: border-box;
19
+ }
20
+
21
+ html,
22
+ body {
23
+ height: 100%;
24
+ }
25
+
26
+ body {
27
+ margin: 0;
28
+ background: var(--window);
29
+ color: var(--text);
30
+ overflow: hidden;
31
+ }
32
+
33
+ .auth-gate {
34
+ position: fixed;
35
+ z-index: 20;
36
+ inset: 0;
37
+ display: grid;
38
+ place-items: center;
39
+ background: rgba(245, 245, 243, 0.92);
40
+ backdrop-filter: blur(16px);
41
+ }
42
+
43
+ .auth-gate[hidden] {
44
+ display: none;
45
+ }
46
+
47
+ .auth-panel {
48
+ display: grid;
49
+ width: min(360px, calc(100vw - 32px));
50
+ gap: 12px;
51
+ padding: 24px;
52
+ border: 1px solid var(--line);
53
+ border-radius: 8px;
54
+ background: var(--editor);
55
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.12);
56
+ }
57
+
58
+ .auth-panel h1 {
59
+ margin: 0 0 4px;
60
+ font-size: 22px;
61
+ text-align: center;
62
+ }
63
+
64
+ .auth-panel input {
65
+ height: 40px;
66
+ padding: 0 12px;
67
+ border: 1px solid #d7d2c8;
68
+ border-radius: 7px;
69
+ outline: 0;
70
+ background: #f8f7f3;
71
+ font-size: 16px;
72
+ }
73
+
74
+ .auth-panel input:focus {
75
+ border-color: var(--accent);
76
+ background: white;
77
+ }
78
+
79
+ .auth-panel button {
80
+ height: 40px;
81
+ border: 0;
82
+ border-radius: 7px;
83
+ background: var(--accent-strong);
84
+ color: white;
85
+ font-weight: 700;
86
+ }
87
+
88
+ .auth-panel p {
89
+ min-height: 18px;
90
+ margin: 0;
91
+ color: #a24624;
92
+ font-size: 13px;
93
+ }
94
+
95
+ button,
96
+ input,
97
+ textarea {
98
+ font: inherit;
99
+ }
100
+
101
+ .app-shell {
102
+ display: grid;
103
+ grid-template-columns: 220px minmax(260px, 340px) minmax(360px, 1fr);
104
+ height: 100vh;
105
+ height: 100dvh;
106
+ min-height: 520px;
107
+ border: 1px solid var(--line);
108
+ background: var(--editor);
109
+ }
110
+
111
+ .app-shell > * {
112
+ min-height: 0;
113
+ }
114
+
115
+ .mobile-nav,
116
+ .mobile-bottom {
117
+ display: none;
118
+ }
119
+
120
+ .folders {
121
+ display: flex;
122
+ flex-direction: column;
123
+ background: var(--sidebar);
124
+ border-right: 1px solid var(--line);
125
+ padding: 18px 12px;
126
+ overflow: hidden;
127
+ }
128
+
129
+ .folder-scroll {
130
+ flex: 1;
131
+ min-height: 0;
132
+ overflow: auto;
133
+ }
134
+
135
+ .sidebar-title {
136
+ margin: 0 8px 8px;
137
+ color: var(--muted);
138
+ font-size: 12px;
139
+ font-weight: 700;
140
+ text-transform: uppercase;
141
+ }
142
+
143
+ .folder-row {
144
+ display: grid;
145
+ grid-template-columns: 22px 1fr auto;
146
+ column-gap: 2px;
147
+ align-items: center;
148
+ width: 100%;
149
+ min-height: 34px;
150
+ margin: 2px 0;
151
+ padding: 0 8px;
152
+ border: 0;
153
+ border-radius: 7px;
154
+ background: transparent;
155
+ color: var(--text);
156
+ text-align: left;
157
+ touch-action: manipulation;
158
+ cursor: default;
159
+ }
160
+
161
+ .folder-item {
162
+ display: grid;
163
+ grid-template-columns: 1fr;
164
+ align-items: center;
165
+ gap: 4px;
166
+ }
167
+
168
+ .folder-item.can-delete {
169
+ grid-template-columns: 1fr auto;
170
+ }
171
+
172
+ .folder-item .folder-row {
173
+ min-width: 0;
174
+ }
175
+
176
+ .folder-delete {
177
+ display: none;
178
+ place-items: center;
179
+ width: 30px;
180
+ height: 30px;
181
+ border: 0;
182
+ border-radius: 6px;
183
+ background: transparent;
184
+ color: var(--accent-strong);
185
+ touch-action: manipulation;
186
+ cursor: default;
187
+ }
188
+
189
+ .folder-item.can-delete:hover .folder-delete,
190
+ .folder-item.can-delete:focus-within .folder-delete {
191
+ display: grid;
192
+ }
193
+
194
+ .folder-delete:hover {
195
+ background: var(--selected-soft);
196
+ }
197
+
198
+ .folder-delete svg {
199
+ width: 17px;
200
+ height: 17px;
201
+ fill: none;
202
+ stroke: currentColor;
203
+ stroke-linecap: round;
204
+ stroke-linejoin: round;
205
+ stroke-width: 2;
206
+ }
207
+
208
+ .folder-row span {
209
+ white-space: nowrap;
210
+ }
211
+
212
+ .folder-row.active {
213
+ background: rgba(255, 255, 255, 0.72);
214
+ }
215
+
216
+ .folder-row:hover {
217
+ background: rgba(255, 255, 255, 0.52);
218
+ }
219
+
220
+ .folder-icon {
221
+ color: var(--accent-strong);
222
+ width: 17px;
223
+ height: 17px;
224
+ line-height: 1;
225
+ fill: none;
226
+ stroke: currentColor;
227
+ stroke-linecap: round;
228
+ stroke-linejoin: round;
229
+ }
230
+
231
+ .folder-count {
232
+ color: var(--muted);
233
+ font-size: 12px;
234
+ }
235
+
236
+ .folder-footer {
237
+ margin-top: auto;
238
+ padding-top: 12px;
239
+ }
240
+
241
+ .new-folder-button {
242
+ display: grid;
243
+ grid-template-columns: 22px 1fr;
244
+ column-gap: 5px;
245
+ align-items: center;
246
+ width: 100%;
247
+ min-height: 34px;
248
+ padding: 0 8px;
249
+ border: 0;
250
+ border-radius: 7px;
251
+ background: transparent;
252
+ color: var(--accent-strong);
253
+ text-align: left;
254
+ touch-action: manipulation;
255
+ cursor: default;
256
+ }
257
+
258
+ .new-folder-button:hover {
259
+ background: rgba(255, 255, 255, 0.52);
260
+ }
261
+
262
+ .new-folder-button svg {
263
+ width: 18px;
264
+ height: 18px;
265
+ fill: none;
266
+ stroke: currentColor;
267
+ stroke-linecap: round;
268
+ stroke-linejoin: round;
269
+ stroke-width: 2;
270
+ }
271
+
272
+ .new-folder-button span {
273
+ color: var(--text);
274
+ white-space: nowrap;
275
+ }
276
+
277
+ .notes-pane {
278
+ display: flex;
279
+ flex-direction: column;
280
+ min-width: 0;
281
+ min-height: 0;
282
+ overflow: hidden;
283
+ background: var(--list);
284
+ border-right: 1px solid var(--line);
285
+ }
286
+
287
+ .toolbar {
288
+ display: flex;
289
+ align-items: center;
290
+ gap: 8px;
291
+ flex-wrap: wrap;
292
+ min-height: 48px;
293
+ padding: 8px 12px;
294
+ border-bottom: 1px solid var(--line);
295
+ }
296
+
297
+ .icon-button {
298
+ display: inline-grid;
299
+ place-items: center;
300
+ width: 34px;
301
+ height: 34px;
302
+ border: 1px solid transparent;
303
+ border-radius: 6px;
304
+ background: transparent;
305
+ color: var(--accent-strong);
306
+ touch-action: manipulation;
307
+ cursor: default;
308
+ }
309
+
310
+ .icon-button svg {
311
+ width: 20px;
312
+ height: 20px;
313
+ fill: none;
314
+ stroke: currentColor;
315
+ stroke-linecap: round;
316
+ stroke-linejoin: round;
317
+ stroke-width: 2;
318
+ }
319
+
320
+ .icon-button:hover:not(:disabled) {
321
+ background: var(--selected-soft);
322
+ border-color: #eed08b;
323
+ }
324
+
325
+ .icon-button.active {
326
+ background: var(--selected);
327
+ border-color: #e3bd5d;
328
+ }
329
+
330
+ .icon-button:disabled {
331
+ color: #b9b4aa;
332
+ }
333
+
334
+ .search-wrap {
335
+ padding: 10px 12px 4px;
336
+ }
337
+
338
+ #searchInput {
339
+ width: 100%;
340
+ height: 30px;
341
+ padding: 0 12px;
342
+ border: 1px solid #d7d2c8;
343
+ border-radius: 7px;
344
+ outline: 0;
345
+ background: #f1f0ec;
346
+ font-size: 16px;
347
+ }
348
+
349
+ #searchInput:focus {
350
+ border-color: var(--accent);
351
+ background: white;
352
+ }
353
+
354
+ .list-heading {
355
+ display: flex;
356
+ align-items: baseline;
357
+ justify-content: space-between;
358
+ gap: 12px;
359
+ padding: 12px 14px 8px;
360
+ }
361
+
362
+ .list-heading strong {
363
+ font-size: 20px;
364
+ }
365
+
366
+ .list-heading span {
367
+ color: var(--muted);
368
+ font-size: 12px;
369
+ white-space: nowrap;
370
+ }
371
+
372
+ .note-list {
373
+ flex: 1;
374
+ overflow: auto;
375
+ padding: 0 8px 12px;
376
+ }
377
+
378
+ .note-section-title {
379
+ padding: 12px 8px 4px;
380
+ color: var(--muted);
381
+ font-size: 12px;
382
+ font-weight: 700;
383
+ }
384
+
385
+ .note-card {
386
+ display: block;
387
+ width: 100%;
388
+ min-height: 76px;
389
+ padding: 10px 12px;
390
+ border: 0;
391
+ border-radius: 8px;
392
+ background: transparent;
393
+ color: inherit;
394
+ text-align: left;
395
+ touch-action: manipulation;
396
+ cursor: default;
397
+ }
398
+
399
+ .note-card:hover {
400
+ background: #f3eee3;
401
+ }
402
+
403
+ .note-card.selected {
404
+ background: var(--selected);
405
+ }
406
+
407
+ .note-title {
408
+ display: block;
409
+ overflow: hidden;
410
+ margin-bottom: 4px;
411
+ font-weight: 700;
412
+ text-overflow: ellipsis;
413
+ white-space: nowrap;
414
+ }
415
+
416
+ .note-preview {
417
+ display: -webkit-box;
418
+ overflow: hidden;
419
+ color: var(--muted);
420
+ font-size: 13px;
421
+ line-height: 1.35;
422
+ -webkit-box-orient: vertical;
423
+ -webkit-line-clamp: 2;
424
+ }
425
+
426
+ .note-date {
427
+ color: #8a806f;
428
+ font-size: 12px;
429
+ font-weight: 600;
430
+ }
431
+
432
+ .editor-pane {
433
+ position: relative;
434
+ display: flex;
435
+ flex-direction: column;
436
+ min-width: 0;
437
+ min-height: 0;
438
+ overflow: hidden;
439
+ background: var(--editor);
440
+ }
441
+
442
+ .editor-meta {
443
+ display: grid;
444
+ grid-template-columns: 1fr auto auto auto auto;
445
+ align-items: center;
446
+ gap: 8px;
447
+ height: 48px;
448
+ padding: 8px 28px 0;
449
+ color: var(--muted);
450
+ font-size: 12px;
451
+ }
452
+
453
+ .editor-meta time {
454
+ justify-self: center;
455
+ padding-left: 120px;
456
+ }
457
+
458
+ .save-status {
459
+ min-width: 48px;
460
+ color: #8a806f;
461
+ font-size: 12px;
462
+ white-space: nowrap;
463
+ }
464
+
465
+ .save-status.is-error,
466
+ .save-status.is-conflict {
467
+ color: #b42318;
468
+ }
469
+
470
+ .save-status.is-ok {
471
+ color: #59823f;
472
+ }
473
+
474
+ #noteFolderSelect {
475
+ max-width: 160px;
476
+ height: 30px;
477
+ padding: 0 8px;
478
+ border: 1px solid #d7d2c8;
479
+ border-radius: 7px;
480
+ outline: 0;
481
+ background: #f8f7f3;
482
+ color: var(--text);
483
+ font-size: 13px;
484
+ }
485
+
486
+ .editor-save-button,
487
+ .editor-preview-button {
488
+ display: grid;
489
+ place-items: center;
490
+ width: 30px;
491
+ height: 30px;
492
+ border: 1px solid transparent;
493
+ border-radius: 6px;
494
+ background: transparent;
495
+ color: var(--accent-strong);
496
+ touch-action: manipulation;
497
+ }
498
+
499
+ .editor-save-button:hover:not(:disabled),
500
+ .editor-preview-button:hover:not(:disabled) {
501
+ background: var(--selected-soft);
502
+ border-color: #eed08b;
503
+ }
504
+
505
+ .editor-preview-button.active {
506
+ background: var(--selected);
507
+ border-color: #e3bd5d;
508
+ }
509
+
510
+ .editor-save-button:disabled,
511
+ .editor-preview-button:disabled {
512
+ color: #b9b4aa;
513
+ }
514
+
515
+ .editor-save-button svg,
516
+ .editor-preview-button svg {
517
+ width: 18px;
518
+ height: 18px;
519
+ fill: none;
520
+ stroke: currentColor;
521
+ stroke-linecap: round;
522
+ stroke-linejoin: round;
523
+ stroke-width: 2;
524
+ }
525
+
526
+ .mobile-folder-select,
527
+ .mobile-save-button,
528
+ .mobile-preview-button {
529
+ display: none;
530
+ }
531
+
532
+ .mobile-actions {
533
+ display: none;
534
+ }
535
+
536
+ #editor {
537
+ flex: 1;
538
+ width: 100%;
539
+ min-height: 0;
540
+ padding: 8px 56px 36px;
541
+ border: 0;
542
+ outline: 0;
543
+ resize: none;
544
+ background: transparent;
545
+ color: var(--text);
546
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Arial, sans-serif;
547
+ font-size: 18px;
548
+ line-height: 1.55;
549
+ }
550
+
551
+ .markdown-preview {
552
+ flex: 1;
553
+ min-height: 0;
554
+ overflow: auto;
555
+ padding: 8px 56px 36px;
556
+ color: var(--text);
557
+ font-size: 18px;
558
+ line-height: 1.55;
559
+ }
560
+
561
+ .markdown-preview h1,
562
+ .markdown-preview h2,
563
+ .markdown-preview h3 {
564
+ margin: 0.8em 0 0.35em;
565
+ line-height: 1.2;
566
+ }
567
+
568
+ .markdown-preview p,
569
+ .markdown-preview ul,
570
+ .markdown-preview ol,
571
+ .markdown-preview blockquote,
572
+ .markdown-preview pre {
573
+ margin: 0.55em 0;
574
+ }
575
+
576
+ .markdown-preview code,
577
+ .markdown-preview pre {
578
+ border-radius: 6px;
579
+ background: #f1f0ec;
580
+ }
581
+
582
+ .markdown-preview code {
583
+ padding: 2px 4px;
584
+ }
585
+
586
+ .markdown-preview pre {
587
+ overflow: auto;
588
+ padding: 10px;
589
+ }
590
+
591
+ .markdown-preview blockquote {
592
+ border-left: 3px solid #d7d2c8;
593
+ padding-left: 12px;
594
+ color: #6e675d;
595
+ }
596
+
597
+ .empty-state {
598
+ position: absolute;
599
+ inset: 48px 0 0;
600
+ display: none;
601
+ place-content: center;
602
+ gap: 8px;
603
+ background: var(--editor);
604
+ color: var(--muted);
605
+ text-align: center;
606
+ }
607
+
608
+ .empty-state strong {
609
+ color: #5d5850;
610
+ font-size: 18px;
611
+ }
612
+
613
+ .editor-pane.empty #editor,
614
+ .editor-pane.empty .markdown-preview,
615
+ .editor-pane.empty .editor-meta {
616
+ visibility: hidden;
617
+ }
618
+
619
+ .editor-pane.empty .empty-state {
620
+ display: grid;
621
+ }
622
+
623
+ @media (max-width: 900px) {
624
+ .app-shell {
625
+ grid-template-columns: 180px minmax(230px, 300px) minmax(320px, 1fr);
626
+ }
627
+
628
+ #editor {
629
+ padding-inline: 30px;
630
+ }
631
+ }
632
+
633
+ @media (max-width: 720px) {
634
+ body {
635
+ overflow: hidden;
636
+ }
637
+
638
+ .app-shell {
639
+ grid-template-columns: 1fr;
640
+ grid-template-rows: auto minmax(240px, 36dvh) minmax(360px, 1fr);
641
+ height: 100vh;
642
+ height: 100dvh;
643
+ min-height: 0;
644
+ }
645
+
646
+ .folders {
647
+ display: flex;
648
+ flex-direction: row;
649
+ gap: 8px;
650
+ border-right: 0;
651
+ border-bottom: 1px solid var(--line);
652
+ padding: 10px;
653
+ overflow: hidden;
654
+ }
655
+
656
+ .sidebar-title {
657
+ display: none;
658
+ }
659
+
660
+ .folder-scroll,
661
+ #folderList {
662
+ display: flex;
663
+ min-width: 0;
664
+ gap: 8px;
665
+ }
666
+
667
+ .folder-scroll {
668
+ flex: 1 1 auto;
669
+ overflow-x: auto;
670
+ overflow-y: hidden;
671
+ }
672
+
673
+ #folderList {
674
+ flex: 0 0 auto;
675
+ }
676
+
677
+ .folder-row {
678
+ grid-template-columns: 18px auto auto;
679
+ column-gap: 5px;
680
+ width: auto;
681
+ min-height: 38px;
682
+ min-width: 0;
683
+ flex: 0 0 auto;
684
+ }
685
+
686
+ .folder-footer {
687
+ margin-top: 0;
688
+ padding-top: 0;
689
+ flex: 0 0 auto;
690
+ }
691
+
692
+ .new-folder-button {
693
+ grid-template-columns: 22px;
694
+ width: 38px;
695
+ min-height: 38px;
696
+ place-items: center;
697
+ padding: 0;
698
+ }
699
+
700
+ .new-folder-button span {
701
+ display: none;
702
+ }
703
+
704
+ .notes-pane {
705
+ min-height: 0;
706
+ border-right: 0;
707
+ border-bottom: 1px solid var(--line);
708
+ }
709
+
710
+ .editor-pane {
711
+ min-height: 0;
712
+ }
713
+
714
+ .toolbar {
715
+ min-height: 46px;
716
+ }
717
+
718
+ .icon-button {
719
+ width: 38px;
720
+ height: 38px;
721
+ }
722
+
723
+ .note-card {
724
+ min-height: 70px;
725
+ }
726
+
727
+ #editor {
728
+ padding: 6px 20px 28px;
729
+ font-size: 17px;
730
+ }
731
+
732
+ .markdown-preview {
733
+ padding: 6px 20px 28px;
734
+ font-size: 17px;
735
+ }
736
+
737
+ .editor-meta {
738
+ grid-template-columns: 1fr;
739
+ height: 52px;
740
+ padding: 8px 16px 0;
741
+ }
742
+
743
+ .editor-meta time {
744
+ padding-left: 0;
745
+ }
746
+
747
+ .save-status {
748
+ display: none;
749
+ }
750
+
751
+ #noteFolderSelect,
752
+ .editor-preview-button,
753
+ .editor-save-button {
754
+ display: none;
755
+ }
756
+
757
+ .app-shell[data-mobile-view="editor"] .mobile-folder-select {
758
+ display: block;
759
+ width: 100%;
760
+ height: 32px;
761
+ padding: 0 6px;
762
+ border: 1px solid #d7d2c8;
763
+ border-radius: 7px;
764
+ outline: 0;
765
+ background: #f8f7f3;
766
+ color: var(--text);
767
+ font-size: 12px;
768
+ }
769
+ }
770
+
771
+ html.force-mobile body {
772
+ display: grid;
773
+ overflow: hidden;
774
+ min-height: 100vh;
775
+ place-items: center;
776
+ background: #2f3337;
777
+ }
778
+
779
+ html.force-mobile .app-shell {
780
+ grid-template-columns: 1fr;
781
+ grid-template-rows: auto minmax(240px, 36dvh) minmax(360px, 1fr);
782
+ width: min(390px, 100vw);
783
+ height: min(844px, 100vh);
784
+ height: min(844px, 100dvh);
785
+ min-height: 0;
786
+ overflow: hidden;
787
+ border: 10px solid #141517;
788
+ border-radius: 36px;
789
+ }
790
+
791
+ html.force-mobile .folders {
792
+ display: flex;
793
+ flex-direction: row;
794
+ gap: 8px;
795
+ overflow: hidden;
796
+ border-right: 0;
797
+ border-bottom: 1px solid var(--line);
798
+ padding: 10px;
799
+ }
800
+
801
+ html.force-mobile .sidebar-title {
802
+ display: none;
803
+ }
804
+
805
+ html.force-mobile .folder-scroll,
806
+ html.force-mobile #folderList {
807
+ display: flex;
808
+ min-width: 0;
809
+ gap: 8px;
810
+ }
811
+
812
+ html.force-mobile .folder-scroll {
813
+ flex: 1 1 auto;
814
+ overflow-x: auto;
815
+ overflow-y: hidden;
816
+ }
817
+
818
+ html.force-mobile #folderList {
819
+ flex: 0 0 auto;
820
+ }
821
+
822
+ html.force-mobile .folder-row {
823
+ grid-template-columns: 18px auto auto;
824
+ column-gap: 5px;
825
+ flex: 0 0 auto;
826
+ width: auto;
827
+ min-width: 0;
828
+ min-height: 38px;
829
+ }
830
+
831
+ html.force-mobile .folder-footer {
832
+ margin-top: 0;
833
+ padding-top: 0;
834
+ flex: 0 0 auto;
835
+ }
836
+
837
+ html.force-mobile .new-folder-button {
838
+ grid-template-columns: 22px;
839
+ width: 38px;
840
+ min-height: 38px;
841
+ place-items: center;
842
+ padding: 0;
843
+ }
844
+
845
+ html.force-mobile .new-folder-button span {
846
+ display: none;
847
+ }
848
+
849
+ html.force-mobile .notes-pane {
850
+ min-height: 0;
851
+ border-right: 0;
852
+ border-bottom: 1px solid var(--line);
853
+ }
854
+
855
+ html.force-mobile .editor-pane {
856
+ min-height: 0;
857
+ }
858
+
859
+ html.force-mobile .toolbar {
860
+ min-height: 46px;
861
+ }
862
+
863
+ html.force-mobile .icon-button {
864
+ width: 38px;
865
+ height: 38px;
866
+ }
867
+
868
+ html.force-mobile .note-card {
869
+ min-height: 70px;
870
+ }
871
+
872
+ html.force-mobile #editor {
873
+ padding: 6px 20px 28px;
874
+ font-size: 17px;
875
+ }
876
+
877
+ html.force-mobile .markdown-preview {
878
+ padding: 6px 20px 28px;
879
+ font-size: 17px;
880
+ }
881
+
882
+ html.force-mobile .editor-meta {
883
+ grid-template-columns: 1fr;
884
+ height: 52px;
885
+ padding: 8px 16px 0;
886
+ }
887
+
888
+ html.force-mobile .editor-meta time {
889
+ padding-left: 0;
890
+ }
891
+
892
+ html.force-mobile .save-status {
893
+ display: none;
894
+ }
895
+
896
+ html.force-mobile #noteFolderSelect,
897
+ html.force-mobile .editor-preview-button,
898
+ html.force-mobile .editor-save-button {
899
+ display: none;
900
+ }
901
+
902
+ html.force-mobile .app-shell[data-mobile-view="editor"] .mobile-folder-select {
903
+ display: block;
904
+ width: 100%;
905
+ height: 32px;
906
+ padding: 0 6px;
907
+ border: 1px solid #d7d2c8;
908
+ border-radius: 7px;
909
+ outline: 0;
910
+ background: #f8f7f3;
911
+ color: var(--text);
912
+ font-size: 12px;
913
+ }
914
+
915
+ @media (max-width: 720px) {
916
+ .app-shell {
917
+ grid-template-columns: 1fr;
918
+ grid-template-rows: 48px minmax(0, 1fr) auto;
919
+ }
920
+
921
+ .mobile-nav {
922
+ display: grid;
923
+ grid-template-columns: 118px 1fr 100px;
924
+ align-items: center;
925
+ grid-column: 1;
926
+ grid-row: 1;
927
+ min-height: 48px;
928
+ padding: 0 10px;
929
+ border-bottom: 1px solid var(--line);
930
+ background: var(--list);
931
+ }
932
+
933
+ .mobile-back {
934
+ display: inline-flex;
935
+ align-items: center;
936
+ gap: 2px;
937
+ min-width: 0;
938
+ height: 36px;
939
+ border: 0;
940
+ background: transparent;
941
+ color: var(--accent-strong);
942
+ touch-action: manipulation;
943
+ }
944
+
945
+ .mobile-back span {
946
+ overflow: hidden;
947
+ max-width: 92px;
948
+ text-overflow: ellipsis;
949
+ white-space: nowrap;
950
+ }
951
+
952
+ .mobile-back svg {
953
+ width: 22px;
954
+ height: 22px;
955
+ fill: none;
956
+ stroke: currentColor;
957
+ stroke-linecap: round;
958
+ stroke-linejoin: round;
959
+ stroke-width: 2;
960
+ }
961
+
962
+ .mobile-back.is-hidden {
963
+ visibility: hidden;
964
+ }
965
+
966
+ #mobileTitle {
967
+ overflow: hidden;
968
+ text-align: center;
969
+ text-overflow: ellipsis;
970
+ white-space: nowrap;
971
+ }
972
+
973
+ .mobile-actions {
974
+ position: relative;
975
+ display: none;
976
+ justify-self: end;
977
+ }
978
+
979
+ .app-shell[data-mobile-view="editor"] .mobile-actions {
980
+ display: block;
981
+ }
982
+
983
+ .mobile-more-button {
984
+ display: grid;
985
+ place-items: center;
986
+ width: 38px;
987
+ height: 38px;
988
+ border: 0;
989
+ border-radius: 8px;
990
+ background: transparent;
991
+ color: var(--accent-strong);
992
+ }
993
+
994
+ .mobile-action-menu {
995
+ position: absolute;
996
+ top: 42px;
997
+ right: 0;
998
+ z-index: 30;
999
+ display: grid;
1000
+ gap: 4px;
1001
+ width: 210px;
1002
+ padding: 8px;
1003
+ border: 1px solid var(--line);
1004
+ border-radius: 8px;
1005
+ background: rgba(248, 247, 243, 0.98);
1006
+ box-shadow: 0 12px 30px rgba(31, 29, 25, 0.18);
1007
+ }
1008
+
1009
+ .mobile-action-menu[hidden] {
1010
+ display: none;
1011
+ }
1012
+
1013
+ .mobile-action-menu label {
1014
+ display: grid;
1015
+ gap: 5px;
1016
+ padding: 6px;
1017
+ color: var(--muted);
1018
+ font-size: 12px;
1019
+ }
1020
+
1021
+ .mobile-action-menu button {
1022
+ display: grid;
1023
+ grid-template-columns: 20px 1fr;
1024
+ align-items: center;
1025
+ gap: 8px;
1026
+ min-height: 36px;
1027
+ border: 0;
1028
+ border-radius: 7px;
1029
+ background: transparent;
1030
+ color: var(--text);
1031
+ text-align: left;
1032
+ }
1033
+
1034
+ .mobile-action-menu button:active {
1035
+ background: var(--selected-soft);
1036
+ }
1037
+
1038
+ .mobile-more-button svg,
1039
+ .mobile-action-menu svg {
1040
+ width: 20px;
1041
+ height: 20px;
1042
+ fill: none;
1043
+ stroke: currentColor;
1044
+ stroke-linecap: round;
1045
+ stroke-linejoin: round;
1046
+ stroke-width: 2;
1047
+ }
1048
+
1049
+ .mobile-bottom {
1050
+ display: grid;
1051
+ grid-template-columns: minmax(0, 1fr) 44px;
1052
+ gap: 10px;
1053
+ align-items: center;
1054
+ grid-column: 1;
1055
+ grid-row: 3;
1056
+ min-height: 58px;
1057
+ padding: 8px 12px;
1058
+ border-top: 1px solid var(--line);
1059
+ background: var(--list);
1060
+ }
1061
+
1062
+ .mobile-search input {
1063
+ width: 100%;
1064
+ height: 36px;
1065
+ padding: 0 12px;
1066
+ border: 1px solid #d7d2c8;
1067
+ border-radius: 8px;
1068
+ outline: 0;
1069
+ background: #f1f0ec;
1070
+ font-size: 16px;
1071
+ }
1072
+
1073
+ .mobile-edit-button,
1074
+ .mobile-preview-button,
1075
+ .mobile-save-button {
1076
+ display: grid;
1077
+ place-items: center;
1078
+ width: 42px;
1079
+ height: 42px;
1080
+ border: 0;
1081
+ border-radius: 8px;
1082
+ background: transparent;
1083
+ color: var(--accent-strong);
1084
+ touch-action: manipulation;
1085
+ }
1086
+
1087
+ .mobile-save-button:disabled,
1088
+ .mobile-preview-button:disabled {
1089
+ color: #b9b4aa;
1090
+ }
1091
+
1092
+ .mobile-save-button,
1093
+ .mobile-preview-button {
1094
+ display: none;
1095
+ }
1096
+
1097
+ .mobile-preview-button.active {
1098
+ background: var(--selected);
1099
+ }
1100
+
1101
+ .mobile-edit-button svg,
1102
+ .mobile-preview-button svg,
1103
+ .mobile-save-button svg {
1104
+ width: 22px;
1105
+ height: 22px;
1106
+ fill: none;
1107
+ stroke: currentColor;
1108
+ stroke-linecap: round;
1109
+ stroke-linejoin: round;
1110
+ stroke-width: 2;
1111
+ }
1112
+
1113
+ .folders,
1114
+ .notes-pane,
1115
+ .editor-pane {
1116
+ display: none;
1117
+ grid-column: 1;
1118
+ grid-row: 2;
1119
+ min-height: 0;
1120
+ }
1121
+
1122
+ .app-shell[data-mobile-view="folders"] .folders,
1123
+ .app-shell[data-mobile-view="list"] .notes-pane,
1124
+ .app-shell[data-mobile-view="editor"] .editor-pane {
1125
+ display: flex;
1126
+ }
1127
+
1128
+ .app-shell[data-mobile-view="editor"] .mobile-bottom {
1129
+ grid-template-columns: 44px 44px 44px;
1130
+ justify-content: end;
1131
+ }
1132
+
1133
+ .app-shell[data-mobile-view="editor"] .mobile-search {
1134
+ display: none;
1135
+ }
1136
+
1137
+ .app-shell[data-mobile-view="editor"] .mobile-save-button {
1138
+ display: grid;
1139
+ }
1140
+
1141
+ .app-shell[data-mobile-view="editor"] .mobile-preview-button {
1142
+ display: grid;
1143
+ }
1144
+
1145
+ .folders {
1146
+ flex-direction: column;
1147
+ gap: 0;
1148
+ overflow: hidden;
1149
+ border-right: 0;
1150
+ border-bottom: 0;
1151
+ padding: 16px 12px;
1152
+ }
1153
+
1154
+ .folder-scroll {
1155
+ display: block;
1156
+ flex: 1 1 auto;
1157
+ overflow: auto;
1158
+ }
1159
+
1160
+ #folderList {
1161
+ display: block;
1162
+ }
1163
+
1164
+ .folder-row {
1165
+ grid-template-columns: 22px 1fr auto;
1166
+ width: 100%;
1167
+ min-height: 42px;
1168
+ }
1169
+
1170
+ .folder-item {
1171
+ grid-template-columns: 1fr;
1172
+ }
1173
+
1174
+ .folder-item.can-delete {
1175
+ grid-template-columns: 1fr 38px;
1176
+ }
1177
+
1178
+ .folder-item.can-delete .folder-delete {
1179
+ display: grid;
1180
+ width: 38px;
1181
+ height: 38px;
1182
+ }
1183
+
1184
+ .folder-footer {
1185
+ margin-top: 12px;
1186
+ padding-top: 8px;
1187
+ }
1188
+
1189
+ .new-folder-button {
1190
+ grid-template-columns: 22px 1fr;
1191
+ width: 100%;
1192
+ min-height: 42px;
1193
+ align-items: center;
1194
+ padding: 0 8px;
1195
+ }
1196
+
1197
+ .new-folder-button span {
1198
+ display: inline;
1199
+ }
1200
+
1201
+ .notes-pane {
1202
+ border-right: 0;
1203
+ border-bottom: 0;
1204
+ }
1205
+
1206
+ .toolbar,
1207
+ .search-wrap {
1208
+ display: none;
1209
+ }
1210
+
1211
+ .list-heading {
1212
+ padding-top: 18px;
1213
+ }
1214
+
1215
+ .editor-pane {
1216
+ border: 0;
1217
+ }
1218
+ }
1219
+
1220
+ html.force-mobile .app-shell {
1221
+ grid-template-columns: 1fr;
1222
+ grid-template-rows: 48px minmax(0, 1fr) auto;
1223
+ }
1224
+
1225
+ html.force-mobile .mobile-nav {
1226
+ display: grid;
1227
+ grid-template-columns: 118px 1fr 100px;
1228
+ align-items: center;
1229
+ grid-column: 1;
1230
+ grid-row: 1;
1231
+ min-height: 48px;
1232
+ padding: 0 10px;
1233
+ border-bottom: 1px solid var(--line);
1234
+ background: var(--list);
1235
+ }
1236
+
1237
+ html.force-mobile .mobile-bottom {
1238
+ display: grid;
1239
+ grid-template-columns: minmax(0, 1fr) 44px;
1240
+ gap: 10px;
1241
+ align-items: center;
1242
+ grid-column: 1;
1243
+ grid-row: 3;
1244
+ min-height: 58px;
1245
+ padding: 8px 12px;
1246
+ border-top: 1px solid var(--line);
1247
+ background: var(--list);
1248
+ }
1249
+
1250
+ html.force-mobile .mobile-back {
1251
+ display: inline-flex;
1252
+ align-items: center;
1253
+ gap: 2px;
1254
+ height: 36px;
1255
+ border: 0;
1256
+ background: transparent;
1257
+ color: var(--accent-strong);
1258
+ }
1259
+
1260
+ html.force-mobile .mobile-back span {
1261
+ overflow: hidden;
1262
+ max-width: 92px;
1263
+ text-overflow: ellipsis;
1264
+ white-space: nowrap;
1265
+ }
1266
+
1267
+ html.force-mobile .mobile-back svg,
1268
+ html.force-mobile .mobile-edit-button svg,
1269
+ html.force-mobile .mobile-preview-button svg,
1270
+ html.force-mobile .mobile-save-button svg {
1271
+ width: 22px;
1272
+ height: 22px;
1273
+ fill: none;
1274
+ stroke: currentColor;
1275
+ stroke-linecap: round;
1276
+ stroke-linejoin: round;
1277
+ stroke-width: 2;
1278
+ }
1279
+
1280
+ html.force-mobile .mobile-back.is-hidden {
1281
+ visibility: hidden;
1282
+ }
1283
+
1284
+ html.force-mobile #mobileTitle {
1285
+ overflow: hidden;
1286
+ text-align: center;
1287
+ text-overflow: ellipsis;
1288
+ white-space: nowrap;
1289
+ }
1290
+
1291
+ html.force-mobile .mobile-actions {
1292
+ position: relative;
1293
+ display: none;
1294
+ justify-self: end;
1295
+ }
1296
+
1297
+ html.force-mobile .app-shell[data-mobile-view="editor"] .mobile-actions {
1298
+ display: block;
1299
+ }
1300
+
1301
+ html.force-mobile .mobile-more-button {
1302
+ display: grid;
1303
+ place-items: center;
1304
+ width: 38px;
1305
+ height: 38px;
1306
+ border: 0;
1307
+ border-radius: 8px;
1308
+ background: transparent;
1309
+ color: var(--accent-strong);
1310
+ }
1311
+
1312
+ html.force-mobile .mobile-action-menu {
1313
+ position: absolute;
1314
+ top: 42px;
1315
+ right: 0;
1316
+ z-index: 30;
1317
+ display: grid;
1318
+ gap: 4px;
1319
+ width: 210px;
1320
+ padding: 8px;
1321
+ border: 1px solid var(--line);
1322
+ border-radius: 8px;
1323
+ background: rgba(248, 247, 243, 0.98);
1324
+ box-shadow: 0 12px 30px rgba(31, 29, 25, 0.18);
1325
+ }
1326
+
1327
+ html.force-mobile .mobile-action-menu[hidden] {
1328
+ display: none;
1329
+ }
1330
+
1331
+ html.force-mobile .mobile-action-menu label {
1332
+ display: grid;
1333
+ gap: 5px;
1334
+ padding: 6px;
1335
+ color: var(--muted);
1336
+ font-size: 12px;
1337
+ }
1338
+
1339
+ html.force-mobile .mobile-action-menu button {
1340
+ display: grid;
1341
+ grid-template-columns: 20px 1fr;
1342
+ align-items: center;
1343
+ gap: 8px;
1344
+ min-height: 36px;
1345
+ border: 0;
1346
+ border-radius: 7px;
1347
+ background: transparent;
1348
+ color: var(--text);
1349
+ text-align: left;
1350
+ }
1351
+
1352
+ html.force-mobile .mobile-more-button svg,
1353
+ html.force-mobile .mobile-action-menu svg {
1354
+ width: 20px;
1355
+ height: 20px;
1356
+ fill: none;
1357
+ stroke: currentColor;
1358
+ stroke-linecap: round;
1359
+ stroke-linejoin: round;
1360
+ stroke-width: 2;
1361
+ }
1362
+
1363
+ html.force-mobile .mobile-search input {
1364
+ width: 100%;
1365
+ height: 36px;
1366
+ padding: 0 12px;
1367
+ border: 1px solid #d7d2c8;
1368
+ border-radius: 8px;
1369
+ outline: 0;
1370
+ background: #f1f0ec;
1371
+ font-size: 16px;
1372
+ }
1373
+
1374
+ html.force-mobile .mobile-edit-button,
1375
+ html.force-mobile .mobile-preview-button,
1376
+ html.force-mobile .mobile-save-button {
1377
+ display: grid;
1378
+ place-items: center;
1379
+ width: 42px;
1380
+ height: 42px;
1381
+ border: 0;
1382
+ border-radius: 8px;
1383
+ background: transparent;
1384
+ color: var(--accent-strong);
1385
+ }
1386
+
1387
+ html.force-mobile .mobile-save-button:disabled,
1388
+ html.force-mobile .mobile-preview-button:disabled {
1389
+ color: #b9b4aa;
1390
+ }
1391
+
1392
+ html.force-mobile .mobile-save-button,
1393
+ html.force-mobile .mobile-preview-button {
1394
+ display: none;
1395
+ }
1396
+
1397
+ html.force-mobile .mobile-preview-button.active {
1398
+ background: var(--selected);
1399
+ }
1400
+
1401
+ html.force-mobile .folders,
1402
+ html.force-mobile .notes-pane,
1403
+ html.force-mobile .editor-pane {
1404
+ display: none;
1405
+ grid-column: 1;
1406
+ grid-row: 2;
1407
+ min-height: 0;
1408
+ }
1409
+
1410
+ html.force-mobile .app-shell[data-mobile-view="folders"] .folders,
1411
+ html.force-mobile .app-shell[data-mobile-view="list"] .notes-pane,
1412
+ html.force-mobile .app-shell[data-mobile-view="editor"] .editor-pane {
1413
+ display: flex;
1414
+ }
1415
+
1416
+ html.force-mobile .app-shell[data-mobile-view="editor"] .mobile-bottom {
1417
+ grid-template-columns: 44px 44px 44px;
1418
+ justify-content: end;
1419
+ }
1420
+
1421
+ html.force-mobile .app-shell[data-mobile-view="editor"] .mobile-search {
1422
+ display: none;
1423
+ }
1424
+
1425
+ html.force-mobile .app-shell[data-mobile-view="editor"] .mobile-save-button {
1426
+ display: grid;
1427
+ }
1428
+
1429
+ html.force-mobile .app-shell[data-mobile-view="editor"] .mobile-preview-button {
1430
+ display: grid;
1431
+ }
1432
+
1433
+ html.force-mobile .folders {
1434
+ flex-direction: column;
1435
+ gap: 0;
1436
+ overflow: hidden;
1437
+ border-right: 0;
1438
+ border-bottom: 0;
1439
+ padding: 16px 12px;
1440
+ }
1441
+
1442
+ html.force-mobile .folder-scroll {
1443
+ display: block;
1444
+ flex: 1 1 auto;
1445
+ overflow: auto;
1446
+ }
1447
+
1448
+ html.force-mobile #folderList {
1449
+ display: block;
1450
+ }
1451
+
1452
+ html.force-mobile .folder-row {
1453
+ grid-template-columns: 22px 1fr auto;
1454
+ width: 100%;
1455
+ min-height: 42px;
1456
+ }
1457
+
1458
+ html.force-mobile .folder-item {
1459
+ grid-template-columns: 1fr;
1460
+ }
1461
+
1462
+ html.force-mobile .folder-item.can-delete {
1463
+ grid-template-columns: 1fr 38px;
1464
+ }
1465
+
1466
+ html.force-mobile .folder-item.can-delete .folder-delete {
1467
+ display: grid;
1468
+ width: 38px;
1469
+ height: 38px;
1470
+ }
1471
+
1472
+ html.force-mobile .folder-footer {
1473
+ margin-top: 12px;
1474
+ padding-top: 8px;
1475
+ }
1476
+
1477
+ html.force-mobile .new-folder-button {
1478
+ grid-template-columns: 22px 1fr;
1479
+ width: 100%;
1480
+ min-height: 42px;
1481
+ align-items: center;
1482
+ padding: 0 8px;
1483
+ }
1484
+
1485
+ html.force-mobile .new-folder-button span {
1486
+ display: inline;
1487
+ }
1488
+
1489
+ html.force-mobile .toolbar,
1490
+ html.force-mobile .search-wrap {
1491
+ display: none;
1492
+ }
1493
+
1494
+ html.force-mobile .notes-pane,
1495
+ html.force-mobile .editor-pane {
1496
+ border: 0;
1497
+ }