Compare commits
2 Commits
backup-bef
...
backup-ui-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a3b733178 | ||
|
|
c11e792062 |
@@ -1,3 +1,5 @@
|
|||||||
|

|
||||||
|
|
||||||
# Tauri + React + Typescript
|
# Tauri + React + Typescript
|
||||||
|
|
||||||
# Nextcloud Notes - Cross-Platform Desktop App
|
# Nextcloud Notes - Cross-Platform Desktop App
|
||||||
|
|||||||
86
TODO.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# TODO - Future Improvements
|
||||||
|
|
||||||
|
## High Priority
|
||||||
|
|
||||||
|
### Unsaved Note Switching
|
||||||
|
**Current Behavior:** When a note has unsaved changes, switching to another note is completely blocked. User must either save or discard changes first.
|
||||||
|
|
||||||
|
**Proposed Improvement:** Implement local session storage for unsaved changes:
|
||||||
|
- Store unsaved note content in browser's sessionStorage/localStorage
|
||||||
|
- Allow switching between notes without losing unsaved changes
|
||||||
|
- Each note maintains its own unsaved state independently
|
||||||
|
- Unsaved changes persist across note switches but don't trigger server sync
|
||||||
|
- Visual indicator shows which notes have unsaved local changes
|
||||||
|
- Only sync with server when user explicitly saves
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- More flexible editing workflow
|
||||||
|
- Can work on multiple notes simultaneously
|
||||||
|
- No data loss when switching notes
|
||||||
|
- Better matches user expectations from modern editors
|
||||||
|
|
||||||
|
**Technical Approach:**
|
||||||
|
- Use Map/Object to store unsaved changes per note ID
|
||||||
|
- Key: note ID, Value: { title, content, timestamp }
|
||||||
|
- Load from local storage on note switch
|
||||||
|
- Clear local storage on explicit save or discard
|
||||||
|
- Add visual indicator (dot/asterisk) on notes with local changes
|
||||||
|
|
||||||
|
### PDF Export Styling
|
||||||
|
**Current Issue:** With custom Google Fonts in place, PDF export produces broken layout and styling. The jsPDF html() method doesn't properly handle web fonts and complex CSS.
|
||||||
|
|
||||||
|
**Needs Investigation:**
|
||||||
|
- jsPDF may not support external web fonts properly
|
||||||
|
- May need to embed fonts or use fallback system fonts for PDF
|
||||||
|
- Consider alternative approaches: html2canvas, puppeteer, or server-side PDF generation
|
||||||
|
- Ensure proper markdown rendering with headings, lists, code blocks, etc.
|
||||||
|
- Maintain consistent styling between preview and PDF output
|
||||||
|
- Consider bundling Google Fonts locally for offline support and better PDF rendering
|
||||||
|
|
||||||
|
### Offline Mode
|
||||||
|
**Current Issue:** App fails when internet connection is unavailable. No local caching, no change queuing, no sync on reconnect.
|
||||||
|
|
||||||
|
**Required Features:**
|
||||||
|
- Local-first storage of all notes (IndexedDB or localStorage)
|
||||||
|
- Work offline seamlessly - create, edit, delete notes
|
||||||
|
- Queue changes when offline for later sync
|
||||||
|
- Detect connection restore and push queued changes
|
||||||
|
- Conflict resolution when note changed both locally and on server
|
||||||
|
- Visual indicator showing online/offline status
|
||||||
|
- Show which notes have pending sync
|
||||||
|
|
||||||
|
**Technical Approach:**
|
||||||
|
- Cache all notes locally on successful fetch
|
||||||
|
- Intercept all API calls - if offline, work with local cache
|
||||||
|
- Maintain a sync queue: { noteId, action, timestamp, data }
|
||||||
|
- Use navigator.onLine and 'online'/'offline' events for detection
|
||||||
|
- On reconnect: process queue in order, handle conflicts
|
||||||
|
- Conflict strategy: last-write-wins or prompt user
|
||||||
|
|
||||||
|
**Synergy with Other Features:**
|
||||||
|
- Pairs well with "Unsaved Note Switching" (both need local storage)
|
||||||
|
- Bundled fonts ensure app works fully offline
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Medium Priority
|
||||||
|
|
||||||
|
### Other Improvements
|
||||||
|
- Add keyboard shortcuts (Cmd+S for save, Cmd+N for new note, etc.)
|
||||||
|
- Implement note search within content (not just titles)
|
||||||
|
- Add tags/labels system as alternative to categories
|
||||||
|
- Export multiple notes at once
|
||||||
|
- Import notes from other formats (Markdown files, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Low Priority
|
||||||
|
|
||||||
|
### Nice to Have
|
||||||
|
- Note templates
|
||||||
|
- Rich text paste handling
|
||||||
|
- ~~Image upload/embedding support~~ ✅ (viewing attachments works, upload TBD)
|
||||||
|
- Note linking (wiki-style)
|
||||||
|
- Version history/undo for saved notes
|
||||||
|
- Customizable editor themes
|
||||||
|
- Font size adjustment
|
||||||
@@ -5,6 +5,12 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Tauri + React + Typescript</title>
|
<title>Tauri + React + Typescript</title>
|
||||||
|
<!-- Editor fonts (monospace) -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inconsolata:wght@200..900&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&display=swap" rel="stylesheet">
|
||||||
|
<!-- Preview fonts (serif) -->
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Average&family=Crimson+Pro:ital,wght@0,200..900;1,200..900&family=Merriweather:ital,opsz,wght@0,18..144,300..900;1,18..144,300..900&family=Roboto+Serif:ital,opsz,wght@0,8..144,100..900;1,8..144,100..900&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
250
package-lock.json
generated
@@ -9,12 +9,16 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
|
"@tauri-apps/plugin-http": "^2.5.7",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@tiptap/extension-strike": "^2.27.2",
|
"@tiptap/extension-strike": "^2.27.2",
|
||||||
"@tiptap/extension-underline": "^2.27.2",
|
"@tiptap/extension-underline": "^2.27.2",
|
||||||
"@tiptap/pm": "^2.27.2",
|
"@tiptap/pm": "^2.27.2",
|
||||||
"@tiptap/react": "^2.27.2",
|
"@tiptap/react": "^2.27.2",
|
||||||
"@tiptap/starter-kit": "^2.27.2",
|
"@tiptap/starter-kit": "^2.27.2",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
|
"jspdf": "^4.2.0",
|
||||||
"marked": "^17.0.4",
|
"marked": "^17.0.4",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
@@ -25,6 +29,7 @@
|
|||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@types/turndown": "^5.0.6",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
"autoprefixer": "^10.4.27",
|
"autoprefixer": "^10.4.27",
|
||||||
"postcss": "^8.5.8",
|
"postcss": "^8.5.8",
|
||||||
@@ -280,6 +285,15 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@babel/core": "^7.0.0-0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.29.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||||
|
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||||
@@ -1477,6 +1491,24 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-dialog": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-http": {
|
||||||
|
"version": "2.5.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.7.tgz",
|
||||||
|
"integrity": "sha512-+F2lEH/c9b0zSsOXKq+5hZNcd9F4IIKCK1T17RqMwpCmVnx2aoqY8yIBccCd25HTYUb3j6NPVbRax/m00hKG8A==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.10.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tauri-apps/plugin-opener": {
|
"node_modules/@tauri-apps/plugin-opener": {
|
||||||
"version": "2.5.3",
|
"version": "2.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
|
||||||
@@ -1958,6 +1990,19 @@
|
|||||||
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pako": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/raf": {
|
||||||
|
"version": "3.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||||
|
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
@@ -1978,6 +2023,20 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/trusted-types": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/@types/turndown": {
|
||||||
|
"version": "5.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz",
|
||||||
|
"integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/use-sync-external-store": {
|
"node_modules/@types/use-sync-external-store": {
|
||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
@@ -2076,6 +2135,15 @@
|
|||||||
"postcss": "^8.1.0"
|
"postcss": "^8.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-arraybuffer": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.8",
|
"version": "2.10.8",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz",
|
||||||
@@ -2180,6 +2248,26 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/canvg": {
|
||||||
|
"version": "3.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
|
||||||
|
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.12.5",
|
||||||
|
"@types/raf": "^3.4.0",
|
||||||
|
"core-js": "^3.8.3",
|
||||||
|
"raf": "^3.4.1",
|
||||||
|
"regenerator-runtime": "^0.13.7",
|
||||||
|
"rgbcolor": "^1.0.1",
|
||||||
|
"stackblur-canvas": "^2.0.0",
|
||||||
|
"svg-pathdata": "^6.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
@@ -2235,12 +2323,33 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/core-js": {
|
||||||
|
"version": "3.49.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
|
||||||
|
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/core-js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/crelt": {
|
"node_modules/crelt": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/css-line-break": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"utrie": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cssesc": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
@@ -2293,6 +2402,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dompurify": {
|
||||||
|
"version": "3.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||||
|
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||||
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"optional": true,
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.313",
|
"version": "1.5.313",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz",
|
||||||
@@ -2412,6 +2531,17 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-png": {
|
||||||
|
"version": "6.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
|
||||||
|
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/pako": "^2.0.3",
|
||||||
|
"iobuffer": "^5.3.2",
|
||||||
|
"pako": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fastq": {
|
"node_modules/fastq": {
|
||||||
"version": "1.20.1",
|
"version": "1.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||||
@@ -2422,6 +2552,12 @@
|
|||||||
"reusify": "^1.0.4"
|
"reusify": "^1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||||
|
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@@ -2510,6 +2646,25 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html2canvas": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"css-line-break": "^2.1.0",
|
||||||
|
"text-segmentation": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/iobuffer": {
|
||||||
|
"version": "5.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||||
|
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-binary-path": {
|
"node_modules/is-binary-path": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
@@ -2615,6 +2770,23 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jspdf": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.6",
|
||||||
|
"fast-png": "^6.2.0",
|
||||||
|
"fflate": "^0.8.1"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"canvg": "^3.0.11",
|
||||||
|
"core-js": "^3.6.0",
|
||||||
|
"dompurify": "^3.3.1",
|
||||||
|
"html2canvas": "^1.0.0-rc.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lilconfig": {
|
"node_modules/lilconfig": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||||
@@ -2794,6 +2966,12 @@
|
|||||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/path-parse": {
|
"node_modules/path-parse": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||||
@@ -2801,6 +2979,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/performance-now": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -3243,6 +3428,16 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/raf": {
|
||||||
|
"version": "3.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||||
|
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"performance-now": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
@@ -3297,6 +3492,13 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/regenerator-runtime": {
|
||||||
|
"version": "0.13.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||||
|
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -3329,6 +3531,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rgbcolor": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
|
||||||
|
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.59.0",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||||
@@ -3430,6 +3642,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stackblur-canvas": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.1.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sucrase": {
|
"node_modules/sucrase": {
|
||||||
"version": "3.35.1",
|
"version": "3.35.1",
|
||||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||||
@@ -3466,6 +3688,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/svg-pathdata": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "3.4.19",
|
"version": "3.4.19",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||||
@@ -3518,6 +3750,15 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/text-segmentation": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"utrie": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/thenify": {
|
"node_modules/thenify": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||||
@@ -3694,6 +3935,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/utrie": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-arraybuffer": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||||
|
|||||||
@@ -11,12 +11,16 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
|
"@tauri-apps/plugin-http": "^2.5.7",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@tiptap/extension-strike": "^2.27.2",
|
"@tiptap/extension-strike": "^2.27.2",
|
||||||
"@tiptap/extension-underline": "^2.27.2",
|
"@tiptap/extension-underline": "^2.27.2",
|
||||||
"@tiptap/pm": "^2.27.2",
|
"@tiptap/pm": "^2.27.2",
|
||||||
"@tiptap/react": "^2.27.2",
|
"@tiptap/react": "^2.27.2",
|
||||||
"@tiptap/starter-kit": "^2.27.2",
|
"@tiptap/starter-kit": "^2.27.2",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
|
"jspdf": "^4.2.0",
|
||||||
"marked": "^17.0.4",
|
"marked": "^17.0.4",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
@@ -27,6 +31,7 @@
|
|||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@types/turndown": "^5.0.6",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
"autoprefixer": "^10.4.27",
|
"autoprefixer": "^10.4.27",
|
||||||
"postcss": "^8.5.8",
|
"postcss": "^8.5.8",
|
||||||
|
|||||||
@@ -22,4 +22,6 @@ tauri = { version = "2", features = [] }
|
|||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
tauri-plugin-dialog = "2.6.0"
|
||||||
|
tauri-plugin-http = "2.5.7"
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,14 @@
|
|||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"opener:default"
|
"opener:default",
|
||||||
|
"http:default",
|
||||||
|
{
|
||||||
|
"identifier": "http:default",
|
||||||
|
"allow": [
|
||||||
|
{ "url": "https://*" },
|
||||||
|
{ "url": "http://*" }
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 2.1 KiB |
@@ -8,6 +8,8 @@ fn greet(name: &str) -> String {
|
|||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_http::init())
|
||||||
.invoke_handler(tauri::generate_handler![greet])
|
.invoke_handler(tauri::generate_handler![greet])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "nextcloud-notes-tauri",
|
"productName": "Nextcloud Notes",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"identifier": "com.davidrelich.nextcloud-notes-tauri",
|
"identifier": "com.davidrelich.nextcloud-notes",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
"devUrl": "http://localhost:1420",
|
"devUrl": "http://localhost:1420",
|
||||||
|
|||||||
145
src/App.tsx
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { LoginView } from './components/LoginView';
|
import { LoginView } from './components/LoginView';
|
||||||
import { NotesList } from './components/NotesList';
|
import { NotesList } from './components/NotesList';
|
||||||
import { NoteEditor } from './components/NoteEditor';
|
import { NoteEditor } from './components/NoteEditor';
|
||||||
|
import { CategoriesSidebar } from './components/CategoriesSidebar';
|
||||||
import { NextcloudAPI } from './api/nextcloud';
|
import { NextcloudAPI } from './api/nextcloud';
|
||||||
import { Note } from './types';
|
import { Note } from './types';
|
||||||
|
|
||||||
@@ -12,12 +13,44 @@ function App() {
|
|||||||
const [selectedNoteId, setSelectedNoteId] = useState<number | null>(null);
|
const [selectedNoteId, setSelectedNoteId] = useState<number | null>(null);
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
|
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
|
||||||
const [fontSize] = useState(14);
|
const [selectedCategory, setSelectedCategory] = useState('');
|
||||||
|
const [manualCategories, setManualCategories] = useState<string[]>([]);
|
||||||
|
const [isCategoriesCollapsed, setIsCategoriesCollapsed] = useState(false);
|
||||||
|
const [isFocusMode, setIsFocusMode] = useState(false);
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('system');
|
||||||
|
const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light');
|
||||||
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
|
const [editorFont, setEditorFont] = useState('Source Code Pro');
|
||||||
|
const [editorFontSize, setEditorFontSize] = useState(14);
|
||||||
|
const [previewFont, setPreviewFont] = useState('Merriweather');
|
||||||
|
const [previewFontSize, setPreviewFontSize] = useState(16);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedServer = localStorage.getItem('serverURL');
|
const savedServer = localStorage.getItem('serverURL');
|
||||||
const savedUsername = localStorage.getItem('username');
|
const savedUsername = localStorage.getItem('username');
|
||||||
const savedPassword = localStorage.getItem('password');
|
const savedPassword = localStorage.getItem('password');
|
||||||
|
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | 'system' | null;
|
||||||
|
const savedEditorFont = localStorage.getItem('editorFont');
|
||||||
|
const savedPreviewFont = localStorage.getItem('previewFont');
|
||||||
|
|
||||||
|
if (savedTheme) {
|
||||||
|
setTheme(savedTheme);
|
||||||
|
}
|
||||||
|
if (savedEditorFont) {
|
||||||
|
setEditorFont(savedEditorFont);
|
||||||
|
}
|
||||||
|
if (savedPreviewFont) {
|
||||||
|
setPreviewFont(savedPreviewFont);
|
||||||
|
}
|
||||||
|
const savedEditorFontSize = localStorage.getItem('editorFontSize');
|
||||||
|
const savedPreviewFontSize = localStorage.getItem('previewFontSize');
|
||||||
|
if (savedEditorFontSize) {
|
||||||
|
setEditorFontSize(parseInt(savedEditorFontSize, 10));
|
||||||
|
}
|
||||||
|
if (savedPreviewFontSize) {
|
||||||
|
setPreviewFontSize(parseInt(savedPreviewFontSize, 10));
|
||||||
|
}
|
||||||
|
|
||||||
if (savedServer && savedUsername && savedPassword) {
|
if (savedServer && savedUsername && savedPassword) {
|
||||||
const apiInstance = new NextcloudAPI({
|
const apiInstance = new NextcloudAPI({
|
||||||
@@ -26,10 +59,35 @@ function App() {
|
|||||||
password: savedPassword,
|
password: savedPassword,
|
||||||
});
|
});
|
||||||
setApi(apiInstance);
|
setApi(apiInstance);
|
||||||
|
setUsername(savedUsername);
|
||||||
setIsLoggedIn(true);
|
setIsLoggedIn(true);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateEffectiveTheme = () => {
|
||||||
|
if (theme === 'system') {
|
||||||
|
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
setEffectiveTheme(isDark ? 'dark' : 'light');
|
||||||
|
} else {
|
||||||
|
setEffectiveTheme(theme);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateEffectiveTheme();
|
||||||
|
|
||||||
|
if (theme === 'system') {
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const handler = () => updateEffectiveTheme();
|
||||||
|
mediaQuery.addEventListener('change', handler);
|
||||||
|
return () => mediaQuery.removeEventListener('change', handler);
|
||||||
|
}
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.toggle('dark', effectiveTheme === 'dark');
|
||||||
|
}, [effectiveTheme]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (api && isLoggedIn) {
|
if (api && isLoggedIn) {
|
||||||
syncNotes();
|
syncNotes();
|
||||||
@@ -58,9 +116,46 @@ function App() {
|
|||||||
|
|
||||||
const apiInstance = new NextcloudAPI({ serverURL, username, password });
|
const apiInstance = new NextcloudAPI({ serverURL, username, password });
|
||||||
setApi(apiInstance);
|
setApi(apiInstance);
|
||||||
|
setUsername(username);
|
||||||
setIsLoggedIn(true);
|
setIsLoggedIn(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('serverURL');
|
||||||
|
localStorage.removeItem('username');
|
||||||
|
localStorage.removeItem('password');
|
||||||
|
setApi(null);
|
||||||
|
setUsername('');
|
||||||
|
setNotes([]);
|
||||||
|
setSelectedNoteId(null);
|
||||||
|
setIsLoggedIn(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleThemeChange = (newTheme: 'light' | 'dark' | 'system') => {
|
||||||
|
setTheme(newTheme);
|
||||||
|
localStorage.setItem('theme', newTheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditorFontChange = (font: string) => {
|
||||||
|
setEditorFont(font);
|
||||||
|
localStorage.setItem('editorFont', font);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePreviewFontChange = (font: string) => {
|
||||||
|
setPreviewFont(font);
|
||||||
|
localStorage.setItem('previewFont', font);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditorFontSizeChange = (size: number) => {
|
||||||
|
setEditorFontSize(size);
|
||||||
|
localStorage.setItem('editorFontSize', size.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePreviewFontSizeChange = (size: number) => {
|
||||||
|
setPreviewFontSize(size);
|
||||||
|
localStorage.setItem('previewFontSize', size.toString());
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateNote = async () => {
|
const handleCreateNote = async () => {
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
try {
|
try {
|
||||||
@@ -73,7 +168,7 @@ function App() {
|
|||||||
hour12: false,
|
hour12: false,
|
||||||
}).replace(/[/:]/g, '-').replace(', ', ' ');
|
}).replace(/[/:]/g, '-').replace(', ', ' ');
|
||||||
|
|
||||||
const note = await api.createNote(`New Note ${timestamp}`, '', '');
|
const note = await api.createNote(`New Note ${timestamp}`, '', selectedCategory);
|
||||||
setNotes([note, ...notes]);
|
setNotes([note, ...notes]);
|
||||||
setSelectedNoteId(note.id);
|
setSelectedNoteId(note.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -81,6 +176,12 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreateCategory = (name: string) => {
|
||||||
|
if (!manualCategories.includes(name)) {
|
||||||
|
setManualCategories([...manualCategories, name]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleUpdateNote = async (updatedNote: Note) => {
|
const handleUpdateNote = async (updatedNote: Note) => {
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
try {
|
try {
|
||||||
@@ -98,7 +199,6 @@ function App() {
|
|||||||
|
|
||||||
const handleDeleteNote = async (note: Note) => {
|
const handleDeleteNote = async (note: Note) => {
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
if (!confirm(`Delete "${note.title}"?`)) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.deleteNote(note.id);
|
await api.deleteNote(note.id);
|
||||||
@@ -111,7 +211,11 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const categoriesFromNotes = Array.from(new Set(notes.map(n => n.category).filter(c => c)));
|
||||||
|
const categories = Array.from(new Set([...categoriesFromNotes, ...manualCategories])).sort();
|
||||||
|
|
||||||
const filteredNotes = notes.filter(note => {
|
const filteredNotes = notes.filter(note => {
|
||||||
|
if (selectedCategory && note.category !== selectedCategory) return false;
|
||||||
if (showFavoritesOnly && !note.favorite) return false;
|
if (showFavoritesOnly && !note.favorite) return false;
|
||||||
if (searchText) {
|
if (searchText) {
|
||||||
const search = searchText.toLowerCase();
|
const search = searchText.toLowerCase();
|
||||||
@@ -129,6 +233,28 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen">
|
<div className="flex h-screen">
|
||||||
|
{!isFocusMode && (
|
||||||
|
<>
|
||||||
|
<CategoriesSidebar
|
||||||
|
categories={categories}
|
||||||
|
selectedCategory={selectedCategory}
|
||||||
|
onSelectCategory={setSelectedCategory}
|
||||||
|
onCreateCategory={handleCreateCategory}
|
||||||
|
isCollapsed={isCategoriesCollapsed}
|
||||||
|
onToggleCollapse={() => setIsCategoriesCollapsed(!isCategoriesCollapsed)}
|
||||||
|
username={username}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
theme={theme}
|
||||||
|
onThemeChange={handleThemeChange}
|
||||||
|
editorFont={editorFont}
|
||||||
|
onEditorFontChange={handleEditorFontChange}
|
||||||
|
editorFontSize={editorFontSize}
|
||||||
|
onEditorFontSizeChange={handleEditorFontSizeChange}
|
||||||
|
previewFont={previewFont}
|
||||||
|
onPreviewFontChange={handlePreviewFontChange}
|
||||||
|
previewFontSize={previewFontSize}
|
||||||
|
onPreviewFontSizeChange={handlePreviewFontSizeChange}
|
||||||
|
/>
|
||||||
<NotesList
|
<NotesList
|
||||||
notes={filteredNotes}
|
notes={filteredNotes}
|
||||||
selectedNoteId={selectedNoteId}
|
selectedNoteId={selectedNoteId}
|
||||||
@@ -140,11 +266,22 @@ function App() {
|
|||||||
onSearchChange={setSearchText}
|
onSearchChange={setSearchText}
|
||||||
showFavoritesOnly={showFavoritesOnly}
|
showFavoritesOnly={showFavoritesOnly}
|
||||||
onToggleFavorites={() => setShowFavoritesOnly(!showFavoritesOnly)}
|
onToggleFavorites={() => setShowFavoritesOnly(!showFavoritesOnly)}
|
||||||
|
hasUnsavedChanges={hasUnsavedChanges}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<NoteEditor
|
<NoteEditor
|
||||||
note={selectedNote}
|
note={selectedNote}
|
||||||
onUpdateNote={handleUpdateNote}
|
onUpdateNote={handleUpdateNote}
|
||||||
fontSize={fontSize}
|
onUnsavedChanges={setHasUnsavedChanges}
|
||||||
|
categories={categories}
|
||||||
|
isFocusMode={isFocusMode}
|
||||||
|
onToggleFocusMode={() => setIsFocusMode(!isFocusMode)}
|
||||||
|
editorFont={editorFont}
|
||||||
|
editorFontSize={editorFontSize}
|
||||||
|
previewFont={previewFont}
|
||||||
|
previewFontSize={previewFontSize}
|
||||||
|
api={api}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
|
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
|
||||||
import { Note, APIConfig } from '../types';
|
import { Note, APIConfig } from '../types';
|
||||||
|
|
||||||
export class NextcloudAPI {
|
export class NextcloudAPI {
|
||||||
private baseURL: string;
|
private baseURL: string;
|
||||||
|
private serverURL: string;
|
||||||
private authHeader: string;
|
private authHeader: string;
|
||||||
|
private username: string;
|
||||||
|
|
||||||
constructor(config: APIConfig) {
|
constructor(config: APIConfig) {
|
||||||
const url = config.serverURL.replace(/\/$/, '');
|
const url = config.serverURL.replace(/\/$/, '');
|
||||||
|
this.serverURL = url;
|
||||||
this.baseURL = `${url}/index.php/apps/notes/api/v1`;
|
this.baseURL = `${url}/index.php/apps/notes/api/v1`;
|
||||||
this.authHeader = 'Basic ' + btoa(`${config.username}:${config.password}`);
|
this.authHeader = 'Basic ' + btoa(`${config.username}:${config.password}`);
|
||||||
|
this.username = config.username;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
@@ -55,4 +60,96 @@ export class NextcloudAPI {
|
|||||||
async deleteNote(id: number): Promise<void> {
|
async deleteNote(id: number): Promise<void> {
|
||||||
await this.request<void>(`/notes/${id}`, { method: 'DELETE' });
|
await this.request<void>(`/notes/${id}`, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchAttachment(_noteId: number, path: string, noteCategory?: string): Promise<string> {
|
||||||
|
// Build WebDAV path: /remote.php/dav/files/{username}/Notes/{category}/.attachments.{noteId}/{filename}
|
||||||
|
// The path from markdown is like: .attachments.38479/Screenshot.png
|
||||||
|
// We need to construct the full WebDAV URL
|
||||||
|
|
||||||
|
let webdavPath = `/remote.php/dav/files/${this.username}/Notes`;
|
||||||
|
|
||||||
|
// Add category subfolder if present
|
||||||
|
if (noteCategory) {
|
||||||
|
webdavPath += `/${noteCategory}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the attachment path (already includes .attachments.{id}/filename)
|
||||||
|
webdavPath += `/${path}`;
|
||||||
|
|
||||||
|
const url = `${this.serverURL}${webdavPath}`;
|
||||||
|
console.log('Fetching attachment via WebDAV:', url);
|
||||||
|
|
||||||
|
const response = await tauriFetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': this.authHeader,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch attachment: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getServerURL(): string {
|
||||||
|
return this.serverURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadAttachment(noteId: number, file: File, noteCategory?: string): Promise<string> {
|
||||||
|
// Create .attachments.{noteId} directory path and upload file via WebDAV PUT
|
||||||
|
// Returns the relative path to insert into markdown
|
||||||
|
|
||||||
|
let webdavPath = `/remote.php/dav/files/${this.username}/Notes`;
|
||||||
|
|
||||||
|
if (noteCategory) {
|
||||||
|
webdavPath += `/${noteCategory}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentDir = `.attachments.${noteId}`;
|
||||||
|
const fileName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); // Sanitize filename
|
||||||
|
const fullPath = `${webdavPath}/${attachmentDir}/${fileName}`;
|
||||||
|
|
||||||
|
const url = `${this.serverURL}${fullPath}`;
|
||||||
|
console.log('Uploading attachment via WebDAV:', url);
|
||||||
|
|
||||||
|
// First, try to create the attachments directory (MKCOL)
|
||||||
|
// This may fail if it already exists, which is fine
|
||||||
|
try {
|
||||||
|
await tauriFetch(`${this.serverURL}${webdavPath}/${attachmentDir}`, {
|
||||||
|
method: 'MKCOL',
|
||||||
|
headers: {
|
||||||
|
'Authorization': this.authHeader,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Directory might already exist, continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file as ArrayBuffer
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
|
||||||
|
// Upload the file via PUT
|
||||||
|
const response = await tauriFetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Authorization': this.authHeader,
|
||||||
|
'Content-Type': file.type || 'application/octet-stream',
|
||||||
|
},
|
||||||
|
body: arrayBuffer,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok && response.status !== 201 && response.status !== 204) {
|
||||||
|
throw new Error(`Failed to upload attachment: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the relative path for markdown
|
||||||
|
return `${attachmentDir}/${fileName}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
src/assets/nextcloud-notes-tauri.png
Normal file
|
After Width: | Height: | Size: 208 KiB |
327
src/components/CategoriesSidebar.tsx
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
const EDITOR_FONTS = [
|
||||||
|
{ name: 'Source Code Pro', value: 'Source Code Pro' },
|
||||||
|
{ name: 'Roboto Mono', value: 'Roboto Mono' },
|
||||||
|
{ name: 'Inconsolata', value: 'Inconsolata' },
|
||||||
|
{ name: 'System Mono', value: 'ui-monospace, monospace' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PREVIEW_FONTS = [
|
||||||
|
{ name: 'Merriweather', value: 'Merriweather' },
|
||||||
|
{ name: 'Crimson Pro', value: 'Crimson Pro' },
|
||||||
|
{ name: 'Roboto Serif', value: 'Roboto Serif' },
|
||||||
|
{ name: 'Average', value: 'Average' },
|
||||||
|
{ name: 'System Serif', value: 'ui-serif, Georgia, serif' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface CategoriesSidebarProps {
|
||||||
|
categories: string[];
|
||||||
|
selectedCategory: string;
|
||||||
|
onSelectCategory: (category: string) => void;
|
||||||
|
onCreateCategory: (name: string) => void;
|
||||||
|
isCollapsed: boolean;
|
||||||
|
onToggleCollapse: () => void;
|
||||||
|
username: string;
|
||||||
|
onLogout: () => void;
|
||||||
|
theme: 'light' | 'dark' | 'system';
|
||||||
|
onThemeChange: (theme: 'light' | 'dark' | 'system') => void;
|
||||||
|
editorFont: string;
|
||||||
|
onEditorFontChange: (font: string) => void;
|
||||||
|
editorFontSize: number;
|
||||||
|
onEditorFontSizeChange: (size: number) => void;
|
||||||
|
previewFont: string;
|
||||||
|
onPreviewFontChange: (font: string) => void;
|
||||||
|
previewFontSize: number;
|
||||||
|
onPreviewFontSizeChange: (size: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoriesSidebar({
|
||||||
|
categories,
|
||||||
|
selectedCategory,
|
||||||
|
onSelectCategory,
|
||||||
|
onCreateCategory,
|
||||||
|
isCollapsed,
|
||||||
|
onToggleCollapse,
|
||||||
|
username,
|
||||||
|
onLogout,
|
||||||
|
theme,
|
||||||
|
onThemeChange,
|
||||||
|
editorFont,
|
||||||
|
onEditorFontChange,
|
||||||
|
editorFontSize,
|
||||||
|
onEditorFontSizeChange,
|
||||||
|
previewFont,
|
||||||
|
onPreviewFontChange,
|
||||||
|
previewFontSize,
|
||||||
|
onPreviewFontSizeChange,
|
||||||
|
}: CategoriesSidebarProps) {
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [newCategoryName, setNewCategoryName] = useState('');
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCreating && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [isCreating]);
|
||||||
|
|
||||||
|
const handleCreateCategory = () => {
|
||||||
|
if (newCategoryName.trim()) {
|
||||||
|
onCreateCategory(newCategoryName.trim());
|
||||||
|
setNewCategoryName('');
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleCreateCategory();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setIsCreating(false);
|
||||||
|
setNewCategoryName('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isCollapsed) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
className="w-6 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 border-r border-gray-300 dark:border-gray-600 flex items-center justify-center transition-colors group relative"
|
||||||
|
title="Show Categories"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-y-0 left-0 w-1 bg-blue-500 opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||||
|
<svg className="w-4 h-4 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-64 bg-gray-100 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col">
|
||||||
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Categories</h2>
|
||||||
|
<button
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
|
||||||
|
title="Collapse"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCreating(true)}
|
||||||
|
className="w-full px-3 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors flex items-center justify-center gap-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
New Category
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<button
|
||||||
|
onClick={() => onSelectCategory('')}
|
||||||
|
className={`w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center ${
|
||||||
|
selectedCategory === ''
|
||||||
|
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||||
|
: 'hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium">All Notes</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{categories.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category}
|
||||||
|
onClick={() => onSelectCategory(category)}
|
||||||
|
className={`w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center ${
|
||||||
|
selectedCategory === category
|
||||||
|
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||||
|
: 'hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm truncate">{category}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isCreating && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 bg-white dark:bg-gray-700 rounded-lg border border-gray-300 dark:border-gray-600">
|
||||||
|
<svg className="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={newCategoryName}
|
||||||
|
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={() => {
|
||||||
|
if (newCategoryName.trim()) {
|
||||||
|
handleCreateCategory();
|
||||||
|
} else {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Category name..."
|
||||||
|
className="flex-1 text-sm px-0 py-0 border-none bg-transparent text-gray-900 dark:text-gray-100 focus:ring-0 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Info and Settings */}
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-white dark:bg-gray-900">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center space-x-2 min-w-0">
|
||||||
|
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white font-semibold flex-shrink-0">
|
||||||
|
{username.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-200 truncate font-medium">{username}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onLogout}
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors flex-shrink-0"
|
||||||
|
title="Logout"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Theme Toggle */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">Theme</span>
|
||||||
|
<div className="flex items-center space-x-1 bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => onThemeChange('light')}
|
||||||
|
className={`p-1.5 rounded transition-colors ${
|
||||||
|
theme === 'light'
|
||||||
|
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
|
||||||
|
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
title="Light mode"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onThemeChange('dark')}
|
||||||
|
className={`p-1.5 rounded transition-colors ${
|
||||||
|
theme === 'dark'
|
||||||
|
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
|
||||||
|
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
title="Dark mode"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onThemeChange('system')}
|
||||||
|
className={`p-1.5 rounded transition-colors ${
|
||||||
|
theme === 'system'
|
||||||
|
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
|
||||||
|
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
title="System theme"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Font Settings */}
|
||||||
|
<div className="mt-4 pt-3 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
||||||
|
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Fonts</span>
|
||||||
|
|
||||||
|
{/* Editor Font */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<svg className="w-4 h-4 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">Editor</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={editorFont}
|
||||||
|
onChange={(e) => onEditorFontChange(e.target.value)}
|
||||||
|
className="flex-1 min-w-0 text-sm px-3 py-1.5 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-200 focus:ring-2 focus:ring-blue-500 focus:border-transparent cursor-pointer"
|
||||||
|
style={{ fontFamily: editorFont }}
|
||||||
|
>
|
||||||
|
{EDITOR_FONTS.map((font) => (
|
||||||
|
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>
|
||||||
|
{font.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={editorFontSize}
|
||||||
|
onChange={(e) => onEditorFontSizeChange(parseInt(e.target.value, 10))}
|
||||||
|
className="w-16 flex-shrink-0 text-sm px-2 py-1.5 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-200 focus:ring-2 focus:ring-blue-500 focus:border-transparent cursor-pointer text-center"
|
||||||
|
>
|
||||||
|
{[12, 13, 14, 15, 16, 17, 18, 20, 22, 24].map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview Font */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<svg className="w-4 h-4 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">Preview</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={previewFont}
|
||||||
|
onChange={(e) => onPreviewFontChange(e.target.value)}
|
||||||
|
className="flex-1 min-w-0 text-sm px-3 py-1.5 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-200 focus:ring-2 focus:ring-blue-500 focus:border-transparent cursor-pointer"
|
||||||
|
style={{ fontFamily: previewFont }}
|
||||||
|
>
|
||||||
|
{PREVIEW_FONTS.map((font) => (
|
||||||
|
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}>
|
||||||
|
{font.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={previewFontSize}
|
||||||
|
onChange={(e) => onPreviewFontSizeChange(parseInt(e.target.value, 10))}
|
||||||
|
className="w-16 flex-shrink-0 text-sm px-2 py-1.5 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-200 focus:ring-2 focus:ring-blue-500 focus:border-transparent cursor-pointer text-center"
|
||||||
|
>
|
||||||
|
{[12, 13, 14, 15, 16, 17, 18, 20, 22, 24].map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/components/CategorySelector.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
interface CategorySelectorProps {
|
||||||
|
categories: string[];
|
||||||
|
selectedCategory: string;
|
||||||
|
onSelectCategory: (category: string) => void;
|
||||||
|
onCreateCategory: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategorySelector({
|
||||||
|
categories,
|
||||||
|
selectedCategory,
|
||||||
|
onSelectCategory,
|
||||||
|
onCreateCategory
|
||||||
|
}: CategorySelectorProps) {
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [newCategoryName, setNewCategoryName] = useState('');
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCreating && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [isCreating]);
|
||||||
|
|
||||||
|
const handleCreateCategory = () => {
|
||||||
|
if (newCategoryName.trim()) {
|
||||||
|
onCreateCategory(newCategoryName.trim());
|
||||||
|
setNewCategoryName('');
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleCreateCategory();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setIsCreating(false);
|
||||||
|
setNewCategoryName('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-gray-200 dark:border-gray-700 p-3">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
Categories
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCreating(true)}
|
||||||
|
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
|
||||||
|
title="New Category"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<button
|
||||||
|
onClick={() => onSelectCategory('')}
|
||||||
|
className={`w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center ${
|
||||||
|
selectedCategory === ''
|
||||||
|
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||||
|
: 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm">All Notes</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{categories.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category}
|
||||||
|
onClick={() => onSelectCategory(category)}
|
||||||
|
className={`w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center ${
|
||||||
|
selectedCategory === category
|
||||||
|
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||||
|
: 'hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm truncate">{category}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isCreating && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={newCategoryName}
|
||||||
|
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={() => {
|
||||||
|
if (!newCategoryName.trim()) {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Category name..."
|
||||||
|
className="flex-1 text-sm px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
222
src/components/FloatingToolbar.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { useEffect, useState, useRef, RefObject } from 'react';
|
||||||
|
|
||||||
|
type FormatType = 'bold' | 'italic' | 'strikethrough' | 'code' | 'codeblock' | 'quote' | 'ul' | 'ol' | 'link' | 'h1' | 'h2' | 'h3';
|
||||||
|
|
||||||
|
interface FloatingToolbarProps {
|
||||||
|
onFormat: (format: FormatType) => void;
|
||||||
|
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FloatingToolbar({ onFormat, textareaRef }: FloatingToolbarProps) {
|
||||||
|
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [activeFormats, setActiveFormats] = useState<Set<FormatType>>(new Set());
|
||||||
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const detectActiveFormats = (text: string, fullContent: string, selectionStart: number): Set<FormatType> => {
|
||||||
|
const formats = new Set<FormatType>();
|
||||||
|
|
||||||
|
// Check inline formats
|
||||||
|
if (/\*\*[^*]+\*\*/.test(text) || /__[^_]+__/.test(text)) formats.add('bold');
|
||||||
|
if (/(?<!\*)\*[^*]+\*(?!\*)/.test(text) || /(?<!_)_[^_]+_(?!_)/.test(text)) formats.add('italic');
|
||||||
|
if (/~~[^~]+~~/.test(text)) formats.add('strikethrough');
|
||||||
|
if (/`[^`]+`/.test(text)) formats.add('code');
|
||||||
|
if (/```[\s\S]*```/.test(text)) formats.add('codeblock');
|
||||||
|
|
||||||
|
// Check line-based formats by looking at the line containing the selection
|
||||||
|
const textBeforeSelection = fullContent.substring(0, selectionStart);
|
||||||
|
const lineStart = textBeforeSelection.lastIndexOf('\n') + 1;
|
||||||
|
const lineEnd = fullContent.indexOf('\n', selectionStart);
|
||||||
|
const currentLine = fullContent.substring(lineStart, lineEnd === -1 ? fullContent.length : lineEnd);
|
||||||
|
|
||||||
|
if (/^#{1}\s/.test(currentLine)) formats.add('h1');
|
||||||
|
if (/^#{2}\s/.test(currentLine)) formats.add('h2');
|
||||||
|
if (/^#{3}\s/.test(currentLine)) formats.add('h3');
|
||||||
|
if (/^>\s/.test(currentLine)) formats.add('quote');
|
||||||
|
if (/^[-*+]\s/.test(currentLine)) formats.add('ul');
|
||||||
|
if (/^\d+\.\s/.test(currentLine)) formats.add('ol');
|
||||||
|
if (/\[.+\]\(.+\)/.test(text)) formats.add('link');
|
||||||
|
|
||||||
|
return formats;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const handleSelectionChange = () => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
|
||||||
|
if (start === end) {
|
||||||
|
setIsVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get textarea position and calculate approximate selection position
|
||||||
|
const textareaRect = textarea.getBoundingClientRect();
|
||||||
|
const computedStyle = window.getComputedStyle(textarea);
|
||||||
|
const lineHeight = parseFloat(computedStyle.lineHeight) || 24;
|
||||||
|
const paddingTop = parseFloat(computedStyle.paddingTop) || 0;
|
||||||
|
const paddingLeft = parseFloat(computedStyle.paddingLeft) || 0;
|
||||||
|
const fontSize = parseFloat(computedStyle.fontSize) || 16;
|
||||||
|
|
||||||
|
// Get text before selection to calculate position
|
||||||
|
const textBeforeSelection = textarea.value.substring(0, start);
|
||||||
|
const lines = textBeforeSelection.split('\n');
|
||||||
|
const currentLineIndex = lines.length - 1;
|
||||||
|
const currentLineText = lines[currentLineIndex];
|
||||||
|
|
||||||
|
// Approximate character width (monospace assumption)
|
||||||
|
const charWidth = fontSize * 0.6;
|
||||||
|
|
||||||
|
// Calculate position
|
||||||
|
const scrollTop = textarea.scrollTop;
|
||||||
|
const top = textareaRect.top + paddingTop + (currentLineIndex * lineHeight) - scrollTop - 56;
|
||||||
|
const left = textareaRect.left + paddingLeft + (currentLineText.length * charWidth);
|
||||||
|
|
||||||
|
const toolbarWidth = 320;
|
||||||
|
let adjustedLeft = Math.max(10, Math.min(left - toolbarWidth / 2, window.innerWidth - toolbarWidth - 10));
|
||||||
|
let adjustedTop = top;
|
||||||
|
|
||||||
|
if (adjustedTop < 10) {
|
||||||
|
adjustedTop = textareaRect.top + paddingTop + ((currentLineIndex + 1) * lineHeight) - scrollTop + 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPosition({ top: adjustedTop, left: adjustedLeft });
|
||||||
|
setIsVisible(true);
|
||||||
|
|
||||||
|
// Detect active formats
|
||||||
|
const selectedText = textarea.value.substring(start, end);
|
||||||
|
const formats = detectActiveFormats(selectedText, textarea.value, start);
|
||||||
|
setActiveFormats(formats);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setTimeout(handleSelectionChange, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyUp = (e: KeyboardEvent) => {
|
||||||
|
if (e.shiftKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
||||||
|
handleSelectionChange();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
// Delay hiding to allow button clicks to register
|
||||||
|
setTimeout(() => {
|
||||||
|
if (document.activeElement !== textarea && !toolbarRef.current?.contains(document.activeElement)) {
|
||||||
|
setIsVisible(false);
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
textarea.addEventListener('mouseup', handleMouseUp);
|
||||||
|
textarea.addEventListener('keyup', handleKeyUp);
|
||||||
|
textarea.addEventListener('blur', handleBlur);
|
||||||
|
textarea.addEventListener('select', handleSelectionChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
textarea.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
textarea.removeEventListener('keyup', handleKeyUp);
|
||||||
|
textarea.removeEventListener('blur', handleBlur);
|
||||||
|
textarea.removeEventListener('select', handleSelectionChange);
|
||||||
|
};
|
||||||
|
}, [textareaRef]);
|
||||||
|
|
||||||
|
if (!isVisible || !position) return null;
|
||||||
|
|
||||||
|
const buttonClass = (format: FormatType) => `p-2 rounded transition-colors ${
|
||||||
|
activeFormats.has(format)
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'hover:bg-gray-700 dark:hover:bg-gray-600 text-white'
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const headingButtonClass = (format: FormatType) => `px-2 py-1 rounded font-bold text-xs transition-colors ${
|
||||||
|
activeFormats.has(format)
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'hover:bg-gray-700 dark:hover:bg-gray-600 text-white'
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={toolbarRef}
|
||||||
|
className="fixed z-50 bg-gray-800 dark:bg-gray-700 rounded-lg shadow-xl px-2 py-2 flex items-center gap-0.5"
|
||||||
|
style={{ top: `${position.top}px`, left: `${position.left}px` }}
|
||||||
|
>
|
||||||
|
{/* Text Formatting */}
|
||||||
|
<button onClick={() => onFormat('bold')} className={buttonClass('bold')} title="Bold (⌘B)">
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M13.5,15.5H10V12.5H13.5A1.5,1.5 0 0,1 15,14A1.5,1.5 0 0,1 13.5,15.5M10,6.5H13A1.5,1.5 0 0,1 14.5,8A1.5,1.5 0 0,1 13,9.5H10M15.6,10.79C16.57,10.11 17.25,9 17.25,8C17.25,5.74 15.5,4 13.25,4H7V18H14.04C16.14,18 17.75,16.3 17.75,14.21C17.75,12.69 16.89,11.39 15.6,10.79Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => onFormat('italic')} className={buttonClass('italic')} title="Italic (⌘I)">
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M10,4V7H12.21L8.79,15H6V18H14V15H11.79L15.21,7H18V4H10Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => onFormat('strikethrough')} className={buttonClass('strikethrough')} title="Strikethrough">
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M3,14H21V12H3M5,4V7H10V10H14V7H19V4M10,19H14V16H10V19Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-gray-600 mx-1"></div>
|
||||||
|
|
||||||
|
{/* Code */}
|
||||||
|
<button onClick={() => onFormat('code')} className={buttonClass('code')} title="Inline Code">
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M8.5,18L3.5,13L8.5,8L9.91,9.41L6.33,13L9.91,16.59L8.5,18M15.5,18L14.09,16.59L17.67,13L14.09,9.41L15.5,8L20.5,13L15.5,18Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => onFormat('codeblock')} className={buttonClass('codeblock')} title="Code Block">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-gray-600 mx-1"></div>
|
||||||
|
|
||||||
|
{/* Quote & Lists */}
|
||||||
|
<button onClick={() => onFormat('quote')} className={buttonClass('quote')} title="Quote">
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M6,17H9L11,13V7H5V13H8L6,17M14,17H17L19,13V7H13V13H16L14,17Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => onFormat('ul')} className={buttonClass('ul')} title="Bullet List">
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M7,5H21V7H7V5M7,13V11H21V13H7M4,4.5A1.5,1.5 0 0,1 5.5,6A1.5,1.5 0 0,1 4,7.5A1.5,1.5 0 0,1 2.5,6A1.5,1.5 0 0,1 4,4.5M4,10.5A1.5,1.5 0 0,1 5.5,12A1.5,1.5 0 0,1 4,13.5A1.5,1.5 0 0,1 2.5,12A1.5,1.5 0 0,1 4,10.5M7,19V17H21V19H7M4,16.5A1.5,1.5 0 0,1 5.5,18A1.5,1.5 0 0,1 4,19.5A1.5,1.5 0 0,1 2.5,18A1.5,1.5 0 0,1 4,16.5Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => onFormat('ol')} className={buttonClass('ol')} title="Numbered List">
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M7,13V11H21V13H7M7,19V17H21V19H7M7,7V5H21V7H7M3,8V5H2V4H4V8H3M2,17V16H5V20H2V19H4V18.5H3V17.5H4V17H2M4.25,10A0.75,0.75 0 0,1 5,10.75C5,10.95 4.92,11.14 4.79,11.27L3.12,13H5V14H2V13.08L4,11H2V10H4.25Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-gray-600 mx-1"></div>
|
||||||
|
|
||||||
|
{/* Link */}
|
||||||
|
<button onClick={() => onFormat('link')} className={buttonClass('link')} title="Link">
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-gray-600 mx-1"></div>
|
||||||
|
|
||||||
|
{/* Headings */}
|
||||||
|
<button onClick={() => onFormat('h1')} className={headingButtonClass('h1')} title="Heading 1">H1</button>
|
||||||
|
<button onClick={() => onFormat('h2')} className={headingButtonClass('h2')} title="Heading 2">H2</button>
|
||||||
|
<button onClick={() => onFormat('h3')} className={headingButtonClass('h3')} title="Heading 3">H3</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
244
src/components/InsertToolbar.tsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { useEffect, useState, useRef, RefObject } from 'react';
|
||||||
|
|
||||||
|
interface InsertToolbarProps {
|
||||||
|
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||||
|
onInsertLink: (text: string, url: string) => void;
|
||||||
|
onInsertFile: () => void;
|
||||||
|
isUploading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkModalState {
|
||||||
|
isOpen: boolean;
|
||||||
|
text: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InsertToolbar({ textareaRef, onInsertLink, onInsertFile, isUploading }: InsertToolbarProps) {
|
||||||
|
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [linkModal, setLinkModal] = useState<LinkModalState>({ isOpen: false, text: '', url: '' });
|
||||||
|
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const urlInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const updatePosition = () => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea || linkModal.isOpen) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
|
||||||
|
// Only show when cursor is placed (no selection)
|
||||||
|
if (start !== end) {
|
||||||
|
setIsVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textareaRect = textarea.getBoundingClientRect();
|
||||||
|
const computedStyle = window.getComputedStyle(textarea);
|
||||||
|
const lineHeight = parseFloat(computedStyle.lineHeight) || 24;
|
||||||
|
const paddingTop = parseFloat(computedStyle.paddingTop) || 0;
|
||||||
|
const paddingLeft = parseFloat(computedStyle.paddingLeft) || 0;
|
||||||
|
const fontSize = parseFloat(computedStyle.fontSize) || 16;
|
||||||
|
|
||||||
|
const textBeforeCursor = textarea.value.substring(0, start);
|
||||||
|
const lines = textBeforeCursor.split('\n');
|
||||||
|
const currentLineIndex = lines.length - 1;
|
||||||
|
const currentLineText = lines[currentLineIndex];
|
||||||
|
|
||||||
|
const charWidth = fontSize * 0.6;
|
||||||
|
const scrollTop = textarea.scrollTop;
|
||||||
|
|
||||||
|
// Position to the right of cursor
|
||||||
|
const top = textareaRect.top + paddingTop + (currentLineIndex * lineHeight) - scrollTop + lineHeight / 2;
|
||||||
|
const left = textareaRect.left + paddingLeft + (currentLineText.length * charWidth) + 20;
|
||||||
|
|
||||||
|
// Keep toolbar within viewport
|
||||||
|
const toolbarWidth = 100;
|
||||||
|
const adjustedLeft = Math.min(left, window.innerWidth - toolbarWidth - 20);
|
||||||
|
let adjustedTop = top - 16; // Center vertically with cursor line
|
||||||
|
|
||||||
|
if (adjustedTop < 10) {
|
||||||
|
adjustedTop = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPosition({ top: adjustedTop, left: adjustedLeft });
|
||||||
|
setIsVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
setTimeout(updatePosition, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyUp = (e: KeyboardEvent) => {
|
||||||
|
// Update on arrow keys or other navigation
|
||||||
|
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key)) {
|
||||||
|
updatePosition();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInput = () => {
|
||||||
|
// Hide briefly during typing, show after a pause
|
||||||
|
setIsVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
// Don't hide if clicking on toolbar or modal
|
||||||
|
setTimeout(() => {
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
if (
|
||||||
|
activeElement !== textarea &&
|
||||||
|
!toolbarRef.current?.contains(activeElement) &&
|
||||||
|
!modalRef.current?.contains(activeElement)
|
||||||
|
) {
|
||||||
|
setIsVisible(false);
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
textarea.addEventListener('click', handleClick);
|
||||||
|
textarea.addEventListener('keyup', handleKeyUp);
|
||||||
|
textarea.addEventListener('input', handleInput);
|
||||||
|
textarea.addEventListener('blur', handleBlur);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
textarea.removeEventListener('click', handleClick);
|
||||||
|
textarea.removeEventListener('keyup', handleKeyUp);
|
||||||
|
textarea.removeEventListener('input', handleInput);
|
||||||
|
textarea.removeEventListener('blur', handleBlur);
|
||||||
|
};
|
||||||
|
}, [textareaRef, linkModal.isOpen]);
|
||||||
|
|
||||||
|
const handleLinkClick = () => {
|
||||||
|
setLinkModal({ isOpen: true, text: '', url: '' });
|
||||||
|
setTimeout(() => urlInputRef.current?.focus(), 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLinkSubmit = () => {
|
||||||
|
if (linkModal.url) {
|
||||||
|
onInsertLink(linkModal.text || linkModal.url, linkModal.url);
|
||||||
|
setLinkModal({ isOpen: false, text: '', url: '' });
|
||||||
|
setIsVisible(false);
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLinkCancel = () => {
|
||||||
|
setLinkModal({ isOpen: false, text: '', url: '' });
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileClick = () => {
|
||||||
|
onInsertFile();
|
||||||
|
setIsVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isVisible || !position) return null;
|
||||||
|
|
||||||
|
// Link Modal
|
||||||
|
if (linkModal.isOpen) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
className="fixed z-50 bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 p-4 w-72"
|
||||||
|
style={{ top: `${position.top}px`, left: `${position.left}px` }}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Insert Link</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">URL</label>
|
||||||
|
<input
|
||||||
|
ref={urlInputRef}
|
||||||
|
type="url"
|
||||||
|
value={linkModal.url}
|
||||||
|
onChange={(e) => setLinkModal({ ...linkModal, url: e.target.value })}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleLinkSubmit();
|
||||||
|
if (e.key === 'Escape') handleLinkCancel();
|
||||||
|
}}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
className="w-full px-3 py-1.5 text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 dark:text-gray-400 mb-1">Text (optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={linkModal.text}
|
||||||
|
onChange={(e) => setLinkModal({ ...linkModal, text: e.target.value })}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleLinkSubmit();
|
||||||
|
if (e.key === 'Escape') handleLinkCancel();
|
||||||
|
}}
|
||||||
|
placeholder="Link text"
|
||||||
|
className="w-full px-3 py-1.5 text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
onClick={handleLinkCancel}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleLinkSubmit}
|
||||||
|
disabled={!linkModal.url}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Insert
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert Toolbar
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={toolbarRef}
|
||||||
|
className="fixed z-50 bg-gray-800 dark:bg-gray-700 rounded-lg shadow-xl px-1 py-1 flex items-center gap-0.5"
|
||||||
|
style={{ top: `${position.top}px`, left: `${position.left}px` }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={handleLinkClick}
|
||||||
|
className="p-2 rounded hover:bg-gray-700 dark:hover:bg-gray-600 text-white transition-colors"
|
||||||
|
title="Insert Link"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleFileClick}
|
||||||
|
disabled={isUploading}
|
||||||
|
className={`p-2 rounded transition-colors ${
|
||||||
|
isUploading
|
||||||
|
? 'text-gray-500 cursor-not-allowed'
|
||||||
|
: 'hover:bg-gray-700 dark:hover:bg-gray-600 text-white'
|
||||||
|
}`}
|
||||||
|
title="Insert Image/File"
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ interface NotesListProps {
|
|||||||
onSearchChange: (text: string) => void;
|
onSearchChange: (text: string) => void;
|
||||||
showFavoritesOnly: boolean;
|
showFavoritesOnly: boolean;
|
||||||
onToggleFavorites: () => void;
|
onToggleFavorites: () => void;
|
||||||
|
hasUnsavedChanges: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NotesList({
|
export function NotesList({
|
||||||
@@ -25,14 +26,36 @@ export function NotesList({
|
|||||||
onSearchChange,
|
onSearchChange,
|
||||||
showFavoritesOnly,
|
showFavoritesOnly,
|
||||||
onToggleFavorites,
|
onToggleFavorites,
|
||||||
|
hasUnsavedChanges,
|
||||||
}: NotesListProps) {
|
}: NotesListProps) {
|
||||||
const [isSyncing, setIsSyncing] = React.useState(false);
|
const [isSyncing, setIsSyncing] = React.useState(false);
|
||||||
|
const [deleteClickedId, setDeleteClickedId] = React.useState<number | null>(null);
|
||||||
|
|
||||||
const handleSync = async () => {
|
const handleSync = async () => {
|
||||||
setIsSyncing(true);
|
setIsSyncing(true);
|
||||||
await onSync();
|
await onSync();
|
||||||
setTimeout(() => setIsSyncing(false), 500);
|
setTimeout(() => setIsSyncing(false), 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (note: Note, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Prevent deletion if there are unsaved changes on a different note
|
||||||
|
if (hasUnsavedChanges && note.id !== selectedNoteId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteClickedId === note.id) {
|
||||||
|
// Second click - actually delete
|
||||||
|
onDeleteNote(note);
|
||||||
|
setDeleteClickedId(null);
|
||||||
|
} else {
|
||||||
|
// First click - show confirmation state
|
||||||
|
setDeleteClickedId(note.id);
|
||||||
|
// Reset after 3 seconds
|
||||||
|
setTimeout(() => setDeleteClickedId(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
const formatDate = (timestamp: number) => {
|
const formatDate = (timestamp: number) => {
|
||||||
const date = new Date(timestamp * 1000);
|
const date = new Date(timestamp * 1000);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -49,24 +72,26 @@ export function NotesList({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getPreview = (content: string) => {
|
const getPreview = (content: string) => {
|
||||||
const lines = content.split('\n').filter(l => l.trim());
|
// grab first 100 characters of note's content, remove markdown syntax from the preview
|
||||||
return lines.slice(1, 3).join(' ').substring(0, 100);
|
const previewContent = content.substring(0, 100);
|
||||||
|
const cleanedPreview = previewContent.replace(/[#*`]/g, '');
|
||||||
|
return cleanedPreview;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-80 bg-gray-50 border-r border-gray-200 flex flex-col">
|
<div className="w-80 bg-gray-50 dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 flex flex-col">
|
||||||
<div className="p-4 border-b border-gray-200">
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Notes</h2>
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Notes</h2>
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<button
|
<button
|
||||||
onClick={handleSync}
|
onClick={handleSync}
|
||||||
disabled={isSyncing}
|
disabled={isSyncing}
|
||||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
|
className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
|
||||||
title="Sync with Server"
|
title="Sync with Server"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className={`w-5 h-5 ${isSyncing ? 'animate-spin' : ''}`}
|
className={`w-5 h-5 text-gray-700 dark:text-gray-300 ${isSyncing ? 'animate-spin' : ''}`}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -76,10 +101,10 @@ export function NotesList({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onCreateNote}
|
onClick={onCreateNote}
|
||||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
title="New Note"
|
title="New Note"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 text-gray-700 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -91,26 +116,26 @@ export function NotesList({
|
|||||||
value={searchText}
|
value={searchText}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
placeholder="Search notes..."
|
placeholder="Search notes..."
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center justify-between mt-3">
|
<div className="flex items-center justify-between mt-3">
|
||||||
<button
|
<button
|
||||||
onClick={onToggleFavorites}
|
onClick={onToggleFavorites}
|
||||||
className="text-xs text-gray-600 hover:text-gray-900 flex items-center"
|
className="text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 flex items-center"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 mr-1" fill={showFavoritesOnly ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 mr-1" fill={showFavoritesOnly ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||||
</svg>
|
</svg>
|
||||||
{showFavoritesOnly ? 'All Notes' : 'Favorites'}
|
{showFavoritesOnly ? 'All Notes' : 'Favorites'}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs text-gray-500">{notes.length} notes</span>
|
<span className="text-xs text-gray-500 dark:text-gray-400">{notes.length} notes</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{notes.length === 0 ? (
|
{notes.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full text-gray-400 p-8">
|
<div className="flex flex-col items-center justify-center h-full text-gray-400 dark:text-gray-500 p-8">
|
||||||
<svg className="w-16 h-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-16 h-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -120,10 +145,21 @@ export function NotesList({
|
|||||||
notes.map((note) => (
|
notes.map((note) => (
|
||||||
<div
|
<div
|
||||||
key={note.id}
|
key={note.id}
|
||||||
onClick={() => onSelectNote(note.id)}
|
onClick={() => {
|
||||||
className={`p-4 border-b border-gray-200 cursor-pointer hover:bg-gray-100 transition-colors ${
|
// Prevent switching if current note has unsaved changes
|
||||||
selectedNoteId === note.id ? 'bg-blue-50 hover:bg-blue-50' : ''
|
if (hasUnsavedChanges && note.id !== selectedNoteId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSelectNote(note.id);
|
||||||
|
}}
|
||||||
|
className={`p-3 border-b border-gray-200 dark:border-gray-700 transition-colors group ${
|
||||||
|
note.id === selectedNoteId
|
||||||
|
? 'bg-blue-50 dark:bg-gray-800 border-l-4 border-l-blue-500'
|
||||||
|
: hasUnsavedChanges
|
||||||
|
? 'cursor-not-allowed opacity-50'
|
||||||
|
: 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
|
title={hasUnsavedChanges && note.id !== selectedNoteId ? 'Save current note before switching' : ''}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-1">
|
<div className="flex items-start justify-between mb-1">
|
||||||
<div className="flex items-center flex-1 min-w-0">
|
<div className="flex items-center flex-1 min-w-0">
|
||||||
@@ -132,35 +168,38 @@ export function NotesList({
|
|||||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
<h3 className="font-medium text-gray-900 truncate">
|
<h3 className="font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
{note.title || 'Untitled'}
|
{note.title || 'Untitled'}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{deleteClickedId === note.id && (
|
||||||
|
<span className="text-xs text-red-600 dark:text-red-400 font-medium whitespace-nowrap">
|
||||||
|
Click again to delete
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => handleDeleteClick(note, e)}
|
||||||
e.stopPropagation();
|
className={`p-1 rounded transition-all opacity-0 group-hover:opacity-100 ${
|
||||||
onDeleteNote(note);
|
deleteClickedId === note.id
|
||||||
}}
|
? 'bg-red-600 text-white opacity-100'
|
||||||
className="ml-2 p-1 hover:bg-red-100 rounded text-red-600 opacity-0 group-hover:opacity-100 transition-opacity"
|
: 'hover:bg-red-100 dark:hover:bg-red-900/30 text-red-600 dark:text-red-400'
|
||||||
title="Delete"
|
}`}
|
||||||
|
title={deleteClickedId === note.id ? "Click again to confirm deletion" : "Delete"}
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center text-xs text-gray-500 mb-2">
|
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||||
{note.category && (
|
|
||||||
<span className="bg-gray-200 px-2 py-0.5 rounded-full mr-2">
|
|
||||||
{note.category}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span>{formatDate(note.modified)}</span>
|
<span>{formatDate(note.modified)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{getPreview(note.content) && (
|
{getPreview(note.content) && (
|
||||||
<p className="text-sm text-gray-600 line-clamp-2">
|
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||||
{getPreview(note.content)}
|
{getPreview(note.content)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ code {
|
|||||||
/* TipTap Editor Styles */
|
/* TipTap Editor Styles */
|
||||||
.ProseMirror {
|
.ProseMirror {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ProseMirror {
|
||||||
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror:focus {
|
.ProseMirror:focus {
|
||||||
@@ -30,6 +35,11 @@ code {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-top: 0.5em;
|
margin-top: 0.5em;
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 0.5em;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ProseMirror h1 {
|
||||||
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror h2 {
|
.ProseMirror h2 {
|
||||||
@@ -37,6 +47,11 @@ code {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-top: 0.5em;
|
margin-top: 0.5em;
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 0.5em;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ProseMirror h2 {
|
||||||
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror h3 {
|
.ProseMirror h3 {
|
||||||
@@ -44,26 +59,56 @@ code {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-top: 0.5em;
|
margin-top: 0.5em;
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 0.5em;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ProseMirror h3 {
|
||||||
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror p {
|
.ProseMirror p {
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 0.5em;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ProseMirror p {
|
||||||
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror strong {
|
.ProseMirror strong {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ProseMirror strong {
|
||||||
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror em {
|
.ProseMirror em {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ProseMirror em {
|
||||||
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror s {
|
.ProseMirror s {
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ProseMirror s {
|
||||||
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror u {
|
.ProseMirror u {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ProseMirror u {
|
||||||
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror code {
|
.ProseMirror code {
|
||||||
@@ -71,7 +116,13 @@ code {
|
|||||||
padding: 0.125rem 0.25rem;
|
padding: 0.125rem 0.25rem;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
font-size: 0.9em;
|
font-size: 1.1em;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ProseMirror code {
|
||||||
|
background-color: #374151;
|
||||||
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror pre {
|
.ProseMirror pre {
|
||||||
@@ -83,6 +134,10 @@ code {
|
|||||||
margin: 1em 0;
|
margin: 1em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark .ProseMirror pre {
|
||||||
|
background-color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
.ProseMirror pre code {
|
.ProseMirror pre code {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -93,6 +148,12 @@ code {
|
|||||||
.ProseMirror ol {
|
.ProseMirror ol {
|
||||||
padding-left: 1.5rem;
|
padding-left: 1.5rem;
|
||||||
margin: 0.5em 0;
|
margin: 0.5em 0;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ProseMirror ul,
|
||||||
|
.dark .ProseMirror ol {
|
||||||
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror ul {
|
.ProseMirror ul {
|
||||||
@@ -105,6 +166,15 @@ code {
|
|||||||
|
|
||||||
.ProseMirror li {
|
.ProseMirror li {
|
||||||
margin: 0.25em 0;
|
margin: 0.25em 0;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror li p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ProseMirror li {
|
||||||
|
color: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror blockquote {
|
.ProseMirror blockquote {
|
||||||
@@ -114,8 +184,17 @@ code {
|
|||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark .ProseMirror blockquote {
|
||||||
|
border-left-color: #4b5563;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
.ProseMirror hr {
|
.ProseMirror hr {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 2px solid #e5e7eb;
|
border-top: 2px solid #e5e7eb;
|
||||||
margin: 2em 0;
|
margin: 2em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark .ProseMirror hr {
|
||||||
|
border-top-color: #374151;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
|
darkMode: 'class',
|
||||||
content: [
|
content: [
|
||||||
"./index.html",
|
"./index.html",
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
|||||||