Initialize Nextcloud Notes desktop app
This commit is contained in:
commit
46aee2d313
13 changed files with 7632 additions and 0 deletions
68
.github/workflows/release.yml
vendored
Normal file
68
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: Linux
|
||||
os: ubuntu-latest
|
||||
command: npm run dist:linux -- --publish never
|
||||
artifacts: |
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
- name: Windows
|
||||
os: windows-latest
|
||||
command: npm run dist:win -- --publish never
|
||||
artifacts: |
|
||||
release/*.exe
|
||||
- name: macOS
|
||||
os: macos-latest
|
||||
command: npm run dist:mac -- --publish never
|
||||
artifacts: |
|
||||
release/*.dmg
|
||||
release/*.zip
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build package
|
||||
run: ${{ matrix.command }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Upload workflow artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nextcloud-notes-${{ matrix.name }}
|
||||
path: ${{ matrix.artifacts }}
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload to GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: ${{ matrix.artifacts }}
|
||||
fail_on_unmatched_files: true
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
.firecrawl/
|
||||
.DS_Store
|
||||
*.log
|
||||
61
README.md
Normal file
61
README.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Nextcloud Notes Desktop
|
||||
|
||||
A fast, cross-platform desktop client for the Nextcloud Notes app, inspired by `dgmid/nextcloud-notes-mac-client` but rebuilt with a modern Electron + React stack.
|
||||
|
||||
## Goals
|
||||
|
||||
- macOS-first desktop experience with Windows/Linux builds available.
|
||||
- Fast startup from a local IndexedDB cache.
|
||||
- Incremental sync using the Nextcloud Notes API `pruneBefore` and chunk cursor support.
|
||||
- Safe credential storage via Electron `safeStorage` when available; renderer code never receives the stored app password.
|
||||
- ETag-aware updates to avoid overwriting newer server changes.
|
||||
- Hardened Electron shell with context isolation, renderer sandboxing, blocked permission prompts, denied window popups, and a content security policy.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Use a Nextcloud app password for login.
|
||||
|
||||
## Packaging
|
||||
|
||||
```bash
|
||||
npm run dist:mac
|
||||
npm run dist:win
|
||||
npm run dist:linux
|
||||
```
|
||||
|
||||
Artifacts are written to `release/`.
|
||||
|
||||
macOS DMG builds must run on macOS because the DMG toolchain and `dmg-license` dependency are Darwin-only. The `dist:mac` script builds both Intel and Apple Silicon outputs when run on a Mac.
|
||||
|
||||
## CI Releases
|
||||
|
||||
GitHub Actions builds release artifacts on native runners for Linux, Windows, and macOS. Push a version tag to publish artifacts to a GitHub Release:
|
||||
|
||||
```bash
|
||||
git tag v0.1.0
|
||||
git push origin v0.1.0
|
||||
```
|
||||
|
||||
The workflow can also be run manually from the Actions tab; manual runs upload workflow artifacts but do not create a GitHub Release unless the run is for a `v*` tag.
|
||||
|
||||
Current Linux outputs:
|
||||
|
||||
- `release/Nextcloud Notes-0.1.0-linux-x86_64.AppImage`
|
||||
- `release/Nextcloud Notes-0.1.0-linux-amd64.deb`
|
||||
|
||||
## Notes API
|
||||
|
||||
The app uses:
|
||||
|
||||
- `GET /index.php/apps/notes/api/v1/notes?chunkSize=100&pruneBefore=<unix>`
|
||||
- `GET /index.php/apps/notes/api/v1/notes/:id`
|
||||
- `POST /index.php/apps/notes/api/v1/notes`
|
||||
- `PUT /index.php/apps/notes/api/v1/notes/:id` with `If-Match`
|
||||
- `DELETE /index.php/apps/notes/api/v1/notes/:id`
|
||||
|
||||
The sync path handles the API's final pruned ID-only chunk for deletion detection.
|
||||
14
build/entitlements.mac.plist
Normal file
14
build/entitlements.mac.plist
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
249
electron/main.cjs
Normal file
249
electron/main.cjs
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
const { app, BrowserWindow, ipcMain, safeStorage, shell } = require('electron')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const isDev = !app.isPackaged
|
||||
const credentialFileName = 'credentials.enc'
|
||||
|
||||
app.setName('Nextcloud Notes')
|
||||
|
||||
const gotLock = app.requestSingleInstanceLock()
|
||||
if (!gotLock) app.exit(0)
|
||||
|
||||
function userDataPath(fileName) {
|
||||
return path.join(app.getPath('userData'), fileName)
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(baseUrl) {
|
||||
const trimmed = String(baseUrl || '').trim().replace(/\/+$/, '')
|
||||
if (!trimmed) throw new Error('Nextcloud URL is required')
|
||||
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
|
||||
}
|
||||
|
||||
function notesApiPath(apiPath) {
|
||||
return `/index.php/apps/notes/api/v1${apiPath}`
|
||||
}
|
||||
|
||||
function authHeader(username, password) {
|
||||
return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`
|
||||
}
|
||||
|
||||
function readCredentials() {
|
||||
const filePath = userDataPath(credentialFileName)
|
||||
if (!fs.existsSync(filePath)) return null
|
||||
if (!safeStorage.isEncryptionAvailable()) return null
|
||||
try {
|
||||
const encrypted = fs.readFileSync(filePath)
|
||||
return JSON.parse(safeStorage.decryptString(encrypted))
|
||||
} catch {
|
||||
clearCredentials()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function publicAccount(credentials) {
|
||||
if (!credentials) return null
|
||||
return { baseUrl: credentials.baseUrl, username: credentials.username }
|
||||
}
|
||||
|
||||
function getStoredCredentials() {
|
||||
const credentials = readCredentials()
|
||||
if (!credentials) throw new Error('Sign in before syncing notes')
|
||||
return credentials
|
||||
}
|
||||
|
||||
function writeCredentials(credentials) {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
throw new Error('Secure credential storage is not available on this system')
|
||||
}
|
||||
const encrypted = safeStorage.encryptString(JSON.stringify(credentials))
|
||||
fs.writeFileSync(userDataPath(credentialFileName), encrypted)
|
||||
}
|
||||
|
||||
function clearCredentials() {
|
||||
const filePath = userDataPath(credentialFileName)
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath)
|
||||
}
|
||||
|
||||
async function request(credentials, method, apiPath, { query, body, etag } = {}) {
|
||||
const baseUrl = normalizeBaseUrl(credentials.baseUrl)
|
||||
const url = new URL(baseUrl + notesApiPath(apiPath))
|
||||
Object.entries(query || {}).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, value)
|
||||
})
|
||||
|
||||
const headers = {
|
||||
Authorization: authHeader(credentials.username, credentials.password),
|
||||
Accept: 'application/json',
|
||||
'OCS-APIRequest': 'true',
|
||||
}
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||
if (etag) headers['If-Match'] = `"${etag}"`
|
||||
|
||||
let response
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
})
|
||||
} catch (err) {
|
||||
if (err.name === 'TimeoutError') throw new Error('Nextcloud request timed out')
|
||||
throw err
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '')
|
||||
const error = new Error(text || `${response.status} ${response.statusText}`)
|
||||
error.status = response.status
|
||||
throw error
|
||||
}
|
||||
|
||||
const text = await response.text()
|
||||
return {
|
||||
data: text ? JSON.parse(text) : null,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
}
|
||||
}
|
||||
|
||||
function expectNotePayload(note) {
|
||||
if (!note || typeof note !== 'object') throw new Error('Note payload is required')
|
||||
return {
|
||||
title: String(note.title || ''),
|
||||
content: String(note.content || ''),
|
||||
category: String(note.category || ''),
|
||||
}
|
||||
}
|
||||
|
||||
function registerHandler(channel, handler) {
|
||||
ipcMain.handle(channel, async (_event, payload) => {
|
||||
try {
|
||||
return { ok: true, data: await handler(payload || {}) }
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
message: err.message || 'Request failed',
|
||||
status: err.status || null,
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function syncNotes(credentials, pruneBefore) {
|
||||
let cursor = ''
|
||||
const changed = []
|
||||
const serverIds = new Set()
|
||||
const seenChangedIds = new Set()
|
||||
|
||||
do {
|
||||
const query = { chunkSize: 100 }
|
||||
if (cursor) query.chunkCursor = cursor
|
||||
if (pruneBefore) query.pruneBefore = pruneBefore
|
||||
|
||||
const { data, headers } = await request(credentials, 'GET', '/notes', { query })
|
||||
for (const note of Array.isArray(data) ? data : []) {
|
||||
if (!note || note.id == null) continue
|
||||
serverIds.add(note.id)
|
||||
if (note.content === undefined || seenChangedIds.has(note.id)) continue
|
||||
seenChangedIds.add(note.id)
|
||||
changed.push(note)
|
||||
}
|
||||
cursor = headers['x-notes-chunk-cursor'] || ''
|
||||
} while (cursor)
|
||||
|
||||
return {
|
||||
changed,
|
||||
serverIds: Array.from(serverIds),
|
||||
syncedAt: Math.floor(Date.now() / 1000),
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const window = new BrowserWindow({
|
||||
width: 1180,
|
||||
height: 780,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
title: 'Nextcloud Notes',
|
||||
backgroundColor: '#0f172a',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
},
|
||||
})
|
||||
|
||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (/^https?:\/\//i.test(url)) shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
window.webContents.session.setPermissionRequestHandler((_webContents, _permission, callback) => {
|
||||
callback(false)
|
||||
})
|
||||
|
||||
window.webContents.on('will-navigate', event => {
|
||||
if (!isDev) event.preventDefault()
|
||||
})
|
||||
|
||||
if (isDev) {
|
||||
window.loadURL('http://127.0.0.1:5173')
|
||||
} else {
|
||||
window.loadFile(path.join(__dirname, '..', 'dist', 'index.html'))
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow()
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('second-instance', () => {
|
||||
const [window] = BrowserWindow.getAllWindows()
|
||||
if (!window) return
|
||||
if (window.isMinimized()) window.restore()
|
||||
window.focus()
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
|
||||
registerHandler('credentials:get', () => publicAccount(readCredentials()))
|
||||
registerHandler('credentials:save', async credentials => {
|
||||
const normalized = {
|
||||
baseUrl: normalizeBaseUrl(credentials.baseUrl),
|
||||
username: String(credentials.username || '').trim(),
|
||||
password: String(credentials.password || ''),
|
||||
}
|
||||
if (!normalized.username || !normalized.password) throw new Error('Username and app password are required')
|
||||
await request(normalized, 'GET', '/settings')
|
||||
writeCredentials(normalized)
|
||||
return publicAccount(normalized)
|
||||
})
|
||||
registerHandler('credentials:clear', () => {
|
||||
clearCredentials()
|
||||
return true
|
||||
})
|
||||
registerHandler('notes:sync', ({ pruneBefore }) => syncNotes(getStoredCredentials(), pruneBefore))
|
||||
registerHandler('notes:create', async ({ note }) => {
|
||||
const { data } = await request(getStoredCredentials(), 'POST', '/notes', { body: expectNotePayload(note) })
|
||||
return Array.isArray(data) ? data[0] : data
|
||||
})
|
||||
registerHandler('notes:update', async ({ id, etag, note }) => {
|
||||
if (!Number.isInteger(id)) throw new Error('Valid note ID is required')
|
||||
const { data } = await request(getStoredCredentials(), 'PUT', `/notes/${id}`, { body: expectNotePayload(note), etag })
|
||||
return Array.isArray(data) ? data[0] : data
|
||||
})
|
||||
registerHandler('notes:delete', async ({ id }) => {
|
||||
if (!Number.isInteger(id)) throw new Error('Valid note ID is required')
|
||||
const { data } = await request(getStoredCredentials(), 'DELETE', `/notes/${id}`)
|
||||
return data || { deleted: true }
|
||||
})
|
||||
19
electron/preload.cjs
Normal file
19
electron/preload.cjs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
|
||||
async function invoke(channel, payload) {
|
||||
const result = await ipcRenderer.invoke(channel, payload)
|
||||
if (result?.ok) return result.data
|
||||
const error = new Error(result?.error?.message || 'Request failed')
|
||||
error.status = result?.error?.status || null
|
||||
throw error
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('notesBridge', {
|
||||
getAccount: () => invoke('credentials:get'),
|
||||
saveCredentials: credentials => invoke('credentials:save', credentials),
|
||||
clearCredentials: () => invoke('credentials:clear'),
|
||||
syncNotes: payload => invoke('notes:sync', payload),
|
||||
createNote: payload => invoke('notes:create', payload),
|
||||
updateNote: payload => invoke('notes:update', payload),
|
||||
deleteNote: payload => invoke('notes:delete', payload),
|
||||
})
|
||||
13
index.html
Normal file
13
index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' http://127.0.0.1:5173 ws://127.0.0.1:5173; base-uri 'none'; form-action 'none'" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Nextcloud Notes</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6540
package-lock.json
generated
Normal file
6540
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
86
package.json
Normal file
86
package.json
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
{
|
||||
"name": "nextcloud-notes-desktop",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Fast cross-platform desktop client for Nextcloud Notes",
|
||||
"author": {
|
||||
"name": "Danvics",
|
||||
"email": "admin@danvics.com"
|
||||
},
|
||||
"homepage": "https://cloud.danvics.com",
|
||||
"main": "electron/main.cjs",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k \"vite --host 127.0.0.1 --strictPort\" \"wait-on tcp:5173 && electron .\"",
|
||||
"build": "vite build",
|
||||
"start": "electron .",
|
||||
"preview": "vite preview --host 127.0.0.1 --strictPort",
|
||||
"dist": "npm run build && electron-builder",
|
||||
"dist:mac": "npm run build && electron-builder --mac --x64 --arm64",
|
||||
"dist:win": "npm run build && electron-builder --win",
|
||||
"dist:linux": "npm run build && electron-builder --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"dmg-license": "^1.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"concurrently": "^9.2.1",
|
||||
"electron": "^42.0.1",
|
||||
"electron-builder": "^26.8.1",
|
||||
"vite": "^8.0.13",
|
||||
"wait-on": "^9.0.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.danvics.nextcloud-notes",
|
||||
"productName": "Nextcloud Notes",
|
||||
"asar": true,
|
||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"electron/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"mac": {
|
||||
"category": "public.app-category.productivity",
|
||||
"hardenedRuntime": true,
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": "build/entitlements.mac.plist",
|
||||
"entitlementsInherit": "build/entitlements.mac.plist",
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
"zip"
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb"
|
||||
],
|
||||
"category": "Office",
|
||||
"maintainer": "Danvics <admin@danvics.com>"
|
||||
}
|
||||
}
|
||||
}
|
||||
95
src/db.js
Normal file
95
src/db.js
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
const DB_NAME = 'nextcloud-notes-desktop'
|
||||
const DB_VERSION = 1
|
||||
|
||||
function openDatabase() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result
|
||||
if (!db.objectStoreNames.contains('notes')) {
|
||||
const notes = db.createObjectStore('notes', { keyPath: 'id' })
|
||||
notes.createIndex('modified', 'modified')
|
||||
notes.createIndex('category', 'category')
|
||||
}
|
||||
if (!db.objectStoreNames.contains('meta')) db.createObjectStore('meta', { keyPath: 'key' })
|
||||
}
|
||||
request.onsuccess = () => {
|
||||
request.result.onversionchange = () => request.result.close()
|
||||
resolve(request.result)
|
||||
}
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
async function transaction(storeName, mode, fn) {
|
||||
const db = await openDatabase()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, mode)
|
||||
const store = tx.objectStore(storeName)
|
||||
let result
|
||||
tx.oncomplete = () => resolve(result)
|
||||
tx.onerror = () => reject(tx.error)
|
||||
tx.onabort = () => reject(tx.error)
|
||||
result = fn(store)
|
||||
}).finally(() => db.close())
|
||||
}
|
||||
|
||||
function promisifyRequest(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
export async function getAllCachedNotes() {
|
||||
const notes = await transaction('notes', 'readonly', store => promisifyRequest(store.getAll()))
|
||||
return notes.sort((a, b) => (b.favorite === a.favorite ? b.modified - a.modified : Number(b.favorite) - Number(a.favorite)))
|
||||
}
|
||||
|
||||
export async function upsertCachedNotes(notes) {
|
||||
if (!notes.length) return
|
||||
await transaction('notes', 'readwrite', store => {
|
||||
notes.forEach(note => store.put({ ...note, deleted: false, cachedAt: Date.now() }))
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteCachedNote(id) {
|
||||
await transaction('notes', 'readwrite', store => store.delete(id))
|
||||
}
|
||||
|
||||
export async function pruneDeletedNotes(serverIds) {
|
||||
const serverSet = new Set(serverIds)
|
||||
const deletedIds = []
|
||||
await transaction('notes', 'readwrite', store => {
|
||||
const request = store.openCursor()
|
||||
request.onsuccess = event => {
|
||||
const cursor = event.target.result
|
||||
if (!cursor) return
|
||||
if (!serverSet.has(cursor.value.id)) {
|
||||
deletedIds.push(cursor.value.id)
|
||||
cursor.delete()
|
||||
}
|
||||
cursor.continue()
|
||||
}
|
||||
})
|
||||
return deletedIds
|
||||
}
|
||||
|
||||
export async function getMeta(key, fallback = null) {
|
||||
const row = await transaction('meta', 'readonly', store => promisifyRequest(store.get(key)))
|
||||
return row ? row.value : fallback
|
||||
}
|
||||
|
||||
export async function setMeta(key, value) {
|
||||
await transaction('meta', 'readwrite', store => store.put({ key, value }))
|
||||
}
|
||||
|
||||
export async function clearCache() {
|
||||
const db = await openDatabase()
|
||||
await Promise.all(['notes', 'meta'].map(storeName => new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, 'readwrite')
|
||||
tx.objectStore(storeName).clear()
|
||||
tx.oncomplete = resolve
|
||||
tx.onerror = () => reject(tx.error)
|
||||
}))).finally(() => db.close())
|
||||
}
|
||||
380
src/main.jsx
Normal file
380
src/main.jsx
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import {
|
||||
clearCache,
|
||||
deleteCachedNote,
|
||||
getAllCachedNotes,
|
||||
getMeta,
|
||||
pruneDeletedNotes,
|
||||
setMeta,
|
||||
upsertCachedNotes,
|
||||
} from './db.js'
|
||||
import './styles.css'
|
||||
|
||||
const NEW_NOTE_ID = 'new'
|
||||
|
||||
function nowSeconds() {
|
||||
return Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
function emptyNote() {
|
||||
return {
|
||||
id: NEW_NOTE_ID,
|
||||
title: 'Untitled note',
|
||||
content: '# Untitled note\n',
|
||||
category: '',
|
||||
modified: nowSeconds(),
|
||||
favorite: false,
|
||||
etag: '',
|
||||
readonly: false,
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(timestamp) {
|
||||
if (!timestamp) return 'Never'
|
||||
return new Date(timestamp * 1000).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })
|
||||
}
|
||||
|
||||
function deriveTitle(content) {
|
||||
const firstLine = content.split('\n').find(line => line.trim()) || 'Untitled note'
|
||||
return firstLine.replace(/^#+\s*/, '').trim().slice(0, 120) || 'Untitled note'
|
||||
}
|
||||
|
||||
function normalizeNote(note) {
|
||||
return {
|
||||
id: note.id,
|
||||
title: note.title || deriveTitle(note.content || ''),
|
||||
content: note.content || '',
|
||||
category: note.category || '',
|
||||
modified: Number(note.modified || nowSeconds()),
|
||||
favorite: Boolean(note.favorite),
|
||||
etag: note.etag || '',
|
||||
readonly: Boolean(note.readonly),
|
||||
}
|
||||
}
|
||||
|
||||
function Login({ onLogin }) {
|
||||
const [baseUrl, setBaseUrl] = React.useState('https://cloud.danvics.com')
|
||||
const [username, setUsername] = React.useState('')
|
||||
const [password, setPassword] = React.useState('')
|
||||
const [error, setError] = React.useState('')
|
||||
const [busy, setBusy] = React.useState(false)
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
const account = await window.notesBridge.saveCredentials({ baseUrl, username, password })
|
||||
setPassword('')
|
||||
onLogin(account)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not connect to Nextcloud Notes')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="login-shell">
|
||||
<form className="login-card" onSubmit={submit}>
|
||||
<div className="brand-mark">N</div>
|
||||
<h1>Nextcloud Notes</h1>
|
||||
<p>Connect with a Nextcloud app password. The password stays in the Electron main process after login.</p>
|
||||
<label>
|
||||
Server URL
|
||||
<input value={baseUrl} onChange={event => setBaseUrl(event.target.value)} placeholder="https://cloud.example.com" />
|
||||
</label>
|
||||
<label>
|
||||
Username
|
||||
<input value={username} onChange={event => setUsername(event.target.value)} autoComplete="username" />
|
||||
</label>
|
||||
<label>
|
||||
App password
|
||||
<input value={password} onChange={event => setPassword(event.target.value)} type="password" autoComplete="current-password" />
|
||||
</label>
|
||||
{error && <div className="error-box">{error}</div>}
|
||||
<button className="primary-button" disabled={busy}>{busy ? 'Connecting...' : 'Connect'}</button>
|
||||
</form>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [account, setAccount] = React.useState(null)
|
||||
const [notes, setNotes] = React.useState([])
|
||||
const [selectedId, setSelectedId] = React.useState(null)
|
||||
const [query, setQuery] = React.useState('')
|
||||
const [category, setCategory] = React.useState('all')
|
||||
const [mode, setMode] = React.useState('edit')
|
||||
const [syncStatus, setSyncStatus] = React.useState('Loading local cache...')
|
||||
const [error, setError] = React.useState('')
|
||||
const [draft, setDraft] = React.useState(null)
|
||||
const deferredQuery = React.useDeferredValue(query)
|
||||
const dirtyRef = React.useRef(false)
|
||||
const draftRef = React.useRef(null)
|
||||
const saveTimer = React.useRef(null)
|
||||
const saveVersion = React.useRef(0)
|
||||
const saveInFlight = React.useRef(false)
|
||||
const saveQueued = React.useRef(false)
|
||||
const syncing = React.useRef(false)
|
||||
|
||||
const selected = draft || notes.find(note => note.id === selectedId) || null
|
||||
const categories = React.useMemo(() => Array.from(new Set(notes.map(note => note.category || 'Uncategorized'))).sort(), [notes])
|
||||
const filteredNotes = React.useMemo(() => {
|
||||
const normalizedQuery = deferredQuery.trim().toLowerCase()
|
||||
return notes.filter(note => {
|
||||
const text = `${note.title}\n${note.content}\n${note.category}`.toLowerCase()
|
||||
const matchesQuery = !normalizedQuery || text.includes(normalizedQuery)
|
||||
const normalizedCategory = note.category || 'Uncategorized'
|
||||
const matchesCategory = category === 'all' || normalizedCategory === category
|
||||
return matchesQuery && matchesCategory
|
||||
})
|
||||
}, [notes, deferredQuery, category])
|
||||
|
||||
React.useEffect(() => {
|
||||
draftRef.current = draft
|
||||
}, [draft])
|
||||
|
||||
React.useEffect(() => () => clearTimeout(saveTimer.current), [])
|
||||
|
||||
React.useEffect(() => {
|
||||
async function boot() {
|
||||
const cached = await getAllCachedNotes()
|
||||
const savedAccount = await window.notesBridge.getAccount()
|
||||
setNotes(cached)
|
||||
setSelectedId(cached[0]?.id || null)
|
||||
if (savedAccount) setAccount(savedAccount)
|
||||
setSyncStatus(savedAccount ? 'Ready to sync' : 'Sign in to sync')
|
||||
}
|
||||
boot().catch(err => setError(err.message || 'Startup failed'))
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (account) syncNow(false)
|
||||
}, [account])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (dirtyRef.current) return
|
||||
setDraft(notes.find(note => note.id === selectedId) || null)
|
||||
}, [notes, selectedId])
|
||||
|
||||
async function refreshNotes() {
|
||||
const cached = await getAllCachedNotes()
|
||||
setNotes(cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
async function syncNow(forceFull = false) {
|
||||
if (!account || syncing.current) return
|
||||
await flushSave()
|
||||
syncing.current = true
|
||||
setError('')
|
||||
setSyncStatus('Syncing...')
|
||||
try {
|
||||
const pruneBefore = forceFull ? null : await getMeta('lastSync', null)
|
||||
const result = await window.notesBridge.syncNotes({ pruneBefore })
|
||||
const changed = result.changed.map(normalizeNote)
|
||||
await upsertCachedNotes(changed)
|
||||
const deletedIds = await pruneDeletedNotes(result.serverIds)
|
||||
const cached = await refreshNotes()
|
||||
await setMeta('lastSync', result.syncedAt)
|
||||
if (!selectedId || deletedIds.includes(selectedId)) setSelectedId(cached[0]?.id || null)
|
||||
setSyncStatus(`Synced ${formatDate(result.syncedAt)}`)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Sync failed')
|
||||
setSyncStatus('Sync failed')
|
||||
} finally {
|
||||
syncing.current = false
|
||||
}
|
||||
}
|
||||
|
||||
function updateDraft(patch, { autosave = true } = {}) {
|
||||
const next = { ...(draftRef.current || emptyNote()), ...patch, modified: nowSeconds() }
|
||||
dirtyRef.current = true
|
||||
saveVersion.current += 1
|
||||
setDraft(next)
|
||||
if (autosave) scheduleSave(next, saveVersion.current)
|
||||
}
|
||||
|
||||
async function saveDraft(note = draftRef.current, version = saveVersion.current) {
|
||||
if (!account || !note || note.readonly) return
|
||||
clearTimeout(saveTimer.current)
|
||||
if (saveInFlight.current) {
|
||||
saveQueued.current = true
|
||||
return
|
||||
}
|
||||
saveInFlight.current = true
|
||||
const payload = { title: note.title || deriveTitle(note.content || ''), content: note.content || '', category: note.category || '' }
|
||||
setError('')
|
||||
setSyncStatus('Saving...')
|
||||
try {
|
||||
const saved = note.id === NEW_NOTE_ID
|
||||
? await window.notesBridge.createNote({ note: payload })
|
||||
: await window.notesBridge.updateNote({ id: note.id, etag: note.etag, note: payload })
|
||||
const normalized = normalizeNote({ ...note, ...payload, ...saved })
|
||||
await upsertCachedNotes([normalized])
|
||||
await refreshNotes()
|
||||
if (version === saveVersion.current) {
|
||||
dirtyRef.current = false
|
||||
setSelectedId(normalized.id)
|
||||
setDraft(normalized)
|
||||
} else if (note.id === NEW_NOTE_ID && draftRef.current?.id === NEW_NOTE_ID) {
|
||||
const migratedDraft = { ...draftRef.current, id: normalized.id, etag: normalized.etag }
|
||||
setSelectedId(normalized.id)
|
||||
setDraft(migratedDraft)
|
||||
draftRef.current = migratedDraft
|
||||
}
|
||||
setSyncStatus(`Saved ${formatDate(normalized.modified)}`)
|
||||
} catch (err) {
|
||||
if (err.status === 412) setError('This note changed on the server. Run sync before saving again to avoid overwriting it.')
|
||||
else setError(err.message || 'Save failed')
|
||||
setSyncStatus('Save failed')
|
||||
} finally {
|
||||
saveInFlight.current = false
|
||||
if (saveQueued.current) {
|
||||
saveQueued.current = false
|
||||
await saveDraft(draftRef.current, saveVersion.current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSave(nextDraft, version) {
|
||||
clearTimeout(saveTimer.current)
|
||||
saveTimer.current = setTimeout(() => saveDraft(nextDraft, version), 700)
|
||||
}
|
||||
|
||||
async function flushSave() {
|
||||
clearTimeout(saveTimer.current)
|
||||
if (dirtyRef.current) await saveDraft(draftRef.current, saveVersion.current)
|
||||
}
|
||||
|
||||
async function selectNote(id) {
|
||||
await flushSave()
|
||||
dirtyRef.current = false
|
||||
setSelectedId(id)
|
||||
}
|
||||
|
||||
async function createNewNote() {
|
||||
await flushSave()
|
||||
const note = emptyNote()
|
||||
dirtyRef.current = true
|
||||
saveVersion.current += 1
|
||||
setSelectedId(NEW_NOTE_ID)
|
||||
setDraft(note)
|
||||
scheduleSave(note, saveVersion.current)
|
||||
}
|
||||
|
||||
async function deleteSelected() {
|
||||
if (!account || !selected || selected.id === NEW_NOTE_ID) return
|
||||
clearTimeout(saveTimer.current)
|
||||
setSyncStatus('Deleting...')
|
||||
try {
|
||||
await window.notesBridge.deleteNote({ id: selected.id })
|
||||
await deleteCachedNote(selected.id)
|
||||
dirtyRef.current = false
|
||||
const cached = await refreshNotes()
|
||||
setSelectedId(cached[0]?.id || null)
|
||||
setSyncStatus('Deleted')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Delete failed')
|
||||
setSyncStatus('Delete failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
clearTimeout(saveTimer.current)
|
||||
await window.notesBridge.clearCredentials()
|
||||
await clearCache()
|
||||
dirtyRef.current = false
|
||||
setAccount(null)
|
||||
setNotes([])
|
||||
setSelectedId(null)
|
||||
setDraft(null)
|
||||
setError('')
|
||||
setSyncStatus('Sign in to sync')
|
||||
}
|
||||
|
||||
if (!account) return <Login onLogin={setAccount} />
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<div>
|
||||
<h1>Notes</h1>
|
||||
<span>{syncStatus}</span>
|
||||
</div>
|
||||
<button onClick={createNewNote}>New</button>
|
||||
</div>
|
||||
<div className="account-line">{account.username} · {account.baseUrl}</div>
|
||||
<input className="search" value={query} onChange={event => setQuery(event.target.value)} placeholder="Search notes" />
|
||||
<div className="categories">
|
||||
<button className={category === 'all' ? 'active' : ''} onClick={() => setCategory('all')}>All</button>
|
||||
{categories.map(item => <button key={item} className={category === item ? 'active' : ''} onClick={() => setCategory(item)}>{item}</button>)}
|
||||
</div>
|
||||
<div className="note-list">
|
||||
{filteredNotes.map(note => (
|
||||
<button key={note.id} className={note.id === selectedId ? 'note-row active' : 'note-row'} onClick={() => selectNote(note.id)}>
|
||||
<strong>{note.favorite ? '★ ' : ''}{note.title}</strong>
|
||||
<span>{note.category || 'Uncategorized'} · {formatDate(note.modified)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
<section className="editor-pane">
|
||||
<header className="toolbar">
|
||||
<div>
|
||||
<input
|
||||
className="title-input"
|
||||
value={selected?.title || ''}
|
||||
onChange={event => updateDraft({ title: event.target.value })}
|
||||
onBlur={flushSave}
|
||||
placeholder="Select or create a note"
|
||||
disabled={!selected || selected.readonly}
|
||||
/>
|
||||
<input
|
||||
className="category-input"
|
||||
value={selected?.category || ''}
|
||||
onChange={event => updateDraft({ category: event.target.value })}
|
||||
onBlur={flushSave}
|
||||
placeholder="Category"
|
||||
disabled={!selected || selected.readonly}
|
||||
/>
|
||||
</div>
|
||||
<div className="toolbar-actions">
|
||||
<button onClick={() => syncNow(true)}>Full sync</button>
|
||||
<button onClick={() => syncNow(false)}>Sync</button>
|
||||
<button onClick={() => setMode(mode === 'edit' ? 'preview' : 'edit')}>{mode === 'edit' ? 'Preview' : 'Edit'}</button>
|
||||
<button onClick={deleteSelected} disabled={!selected || selected.id === NEW_NOTE_ID}>Delete</button>
|
||||
<button onClick={logout}>Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{!selected ? (
|
||||
<div className="empty-editor">Create a note or select one from the sidebar.</div>
|
||||
) : mode === 'edit' ? (
|
||||
<textarea
|
||||
className="note-editor"
|
||||
value={selected.content}
|
||||
onChange={event => updateDraft({ content: event.target.value, title: deriveTitle(event.target.value) })}
|
||||
spellCheck="true"
|
||||
disabled={selected.readonly}
|
||||
/>
|
||||
) : (
|
||||
<article className="markdown-preview">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{selected.content || ''}</ReactMarkdown>
|
||||
</article>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
91
src/styles.css
Normal file
91
src/styles.css
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
:root {
|
||||
color-scheme: light dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--bg: #f4f7fb;
|
||||
--panel: rgba(255,255,255,0.88);
|
||||
--panel-solid: #ffffff;
|
||||
--text: #111827;
|
||||
--muted: #6b7280;
|
||||
--subtle: #9ca3af;
|
||||
--border: #dbe3ef;
|
||||
--primary: #2563eb;
|
||||
--primary-strong: #1d4ed8;
|
||||
--danger: #dc2626;
|
||||
--shadow: 0 18px 60px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0b1120;
|
||||
--panel: rgba(15,23,42,0.86);
|
||||
--panel-solid: #111827;
|
||||
--text: #e5e7eb;
|
||||
--muted: #9ca3af;
|
||||
--subtle: #64748b;
|
||||
--border: #253247;
|
||||
--primary: #60a5fa;
|
||||
--primary-strong: #3b82f6;
|
||||
--shadow: 0 20px 70px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: radial-gradient(circle at top left, rgba(96,165,250,0.22), transparent 34%), var(--bg); color: var(--text); }
|
||||
button, input, textarea { font: inherit; }
|
||||
button { border: 0; border-radius: 10px; padding: 9px 13px; background: var(--panel-solid); color: var(--text); border: 1px solid var(--border); cursor: pointer; }
|
||||
button:hover:not(:disabled) { border-color: var(--primary); }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.45; }
|
||||
|
||||
.login-shell { min-height: 100vh; display: grid; place-items: center; padding: 28px; }
|
||||
.login-card { width: min(440px, 100%); display: grid; gap: 15px; padding: 30px; border: 1px solid var(--border); border-radius: 24px; background: var(--panel); box-shadow: var(--shadow); backdrop-filter: blur(18px); }
|
||||
.brand-mark { width: 54px; height: 54px; display: grid; place-items: center; border-radius: 16px; color: white; background: linear-gradient(135deg, #2563eb, #0ea5e9); font-weight: 900; font-size: 1.6rem; }
|
||||
.login-card h1 { margin: 0; font-size: 1.75rem; letter-spacing: -0.04em; }
|
||||
.login-card p { margin: -8px 0 4px; color: var(--muted); line-height: 1.5; }
|
||||
.login-card label { display: grid; gap: 6px; font-size: 0.82rem; color: var(--muted); font-weight: 650; }
|
||||
.login-card input, .search, .title-input, .category-input { width: 100%; border: 1px solid var(--border); border-radius: 12px; padding: 10px 12px; background: var(--panel-solid); color: var(--text); outline: none; }
|
||||
.login-card input:focus, .search:focus, .title-input:focus, .category-input:focus, .note-editor:focus { border-color: var(--primary); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); }
|
||||
.primary-button { background: var(--primary); color: white; border-color: var(--primary); font-weight: 750; }
|
||||
.primary-button:hover:not(:disabled) { background: var(--primary-strong); }
|
||||
|
||||
.app-shell { height: 100vh; display: grid; grid-template-columns: 340px 1fr; overflow: hidden; }
|
||||
.sidebar { display: flex; flex-direction: column; gap: 12px; padding: 16px; border-right: 1px solid var(--border); background: color-mix(in srgb, var(--panel-solid) 74%, transparent); backdrop-filter: blur(18px); min-width: 0; }
|
||||
.sidebar-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||
.sidebar-header h1 { margin: 0; font-size: 1.4rem; letter-spacing: -0.04em; }
|
||||
.sidebar-header span { display: block; color: var(--muted); font-size: 0.75rem; margin-top: 2px; }
|
||||
.account-line { color: var(--muted); font-size: 0.76rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.categories { display: flex; gap: 7px; overflow-x: auto; padding-bottom: 2px; }
|
||||
.categories button { white-space: nowrap; font-size: 0.78rem; padding: 6px 10px; color: var(--muted); }
|
||||
.categories button.active { background: color-mix(in srgb, var(--primary) 14%, var(--panel-solid)); border-color: var(--primary); color: var(--primary); }
|
||||
.note-list { flex: 1; overflow: auto; display: grid; align-content: start; gap: 8px; padding-right: 3px; }
|
||||
.note-row { width: 100%; text-align: left; display: grid; gap: 5px; padding: 12px; background: transparent; }
|
||||
.note-row strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.note-row span { color: var(--muted); font-size: 0.76rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.note-row.active { background: var(--panel-solid); border-color: var(--primary); box-shadow: 0 8px 24px rgba(37,99,235,0.10); }
|
||||
|
||||
.editor-pane { min-width: 0; display: flex; flex-direction: column; padding: 18px; gap: 12px; }
|
||||
.toolbar { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; padding: 14px; background: var(--panel); border: 1px solid var(--border); border-radius: 18px; box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06); backdrop-filter: blur(18px); }
|
||||
.toolbar > div:first-child { display: grid; gap: 8px; flex: 1; min-width: 0; }
|
||||
.title-input { font-size: 1.1rem; font-weight: 760; letter-spacing: -0.02em; }
|
||||
.category-input { max-width: 280px; font-size: 0.86rem; }
|
||||
.toolbar-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.toolbar-actions button { font-size: 0.82rem; }
|
||||
.toolbar-actions button:nth-last-child(2) { color: var(--danger); }
|
||||
.note-editor, .markdown-preview, .empty-editor { flex: 1; width: 100%; min-height: 0; border: 1px solid var(--border); border-radius: 20px; background: var(--panel-solid); color: var(--text); box-shadow: var(--shadow); }
|
||||
.note-editor { resize: none; padding: 24px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; line-height: 1.7; outline: none; }
|
||||
.note-editor:disabled { opacity: 0.75; cursor: not-allowed; }
|
||||
.markdown-preview { overflow: auto; padding: 24px 34px; line-height: 1.7; }
|
||||
.markdown-preview h1, .markdown-preview h2, .markdown-preview h3 { letter-spacing: -0.035em; }
|
||||
.markdown-preview pre { overflow: auto; padding: 14px; border-radius: 12px; background: #0f172a; color: #e5e7eb; }
|
||||
.markdown-preview code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
|
||||
.markdown-preview blockquote { margin-left: 0; padding-left: 16px; border-left: 4px solid var(--primary); color: var(--muted); }
|
||||
.markdown-preview table { border-collapse: collapse; width: 100%; }
|
||||
.markdown-preview th, .markdown-preview td { border: 1px solid var(--border); padding: 8px 10px; }
|
||||
.empty-editor { display: grid; place-items: center; color: var(--muted); font-size: 1rem; }
|
||||
.error-banner, .error-box { padding: 10px 12px; border-radius: 12px; background: color-mix(in srgb, var(--danger) 12%, var(--panel-solid)); color: var(--danger); border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent); font-size: 0.88rem; }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.app-shell { grid-template-columns: 1fr; grid-template-rows: 280px 1fr; }
|
||||
.sidebar { border-right: 0; border-bottom: 1px solid var(--border); }
|
||||
.toolbar { flex-direction: column; }
|
||||
.toolbar-actions { justify-content: flex-start; }
|
||||
}
|
||||
10
vite.config.js
Normal file
10
vite.config.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
})
|
||||
Loading…
Reference in a new issue