Upload 11 files
Browse files- app.js +1069 -0
- auth.mjs +65 -0
- index.html +131 -0
- package.json +12 -0
- pnpm-lock.yaml +44 -0
- scripts/backup-proxy.mjs +252 -0
- scripts/backup-sqlite.mjs +133 -0
- scripts/docker-entrypoint.sh +64 -0
- scripts/seed-mock.mjs +43 -0
- server.mjs +230 -0
- 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("&", "&")
|
| 1019 |
+
.replaceAll("<", "<")
|
| 1020 |
+
.replaceAll(">", ">")
|
| 1021 |
+
.replaceAll('"', """)
|
| 1022 |
+
.replaceAll("'", "'");
|
| 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 |
+
}
|