Initialize Nextcloud Notes desktop app
Some checks failed
Release / Build Linux (push) Has been cancelled
Release / Build macOS (push) Has been cancelled
Release / Build Windows (push) Has been cancelled

This commit is contained in:
Daniel 2026-05-15 16:24:17 +02:00
commit 46aee2d313
13 changed files with 7632 additions and 0 deletions

68
.github/workflows/release.yml vendored Normal file
View 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
View file

@ -0,0 +1,6 @@
node_modules/
dist/
release/
.firecrawl/
.DS_Store
*.log

61
README.md Normal file
View 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.

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

86
package.json Normal file
View 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
View 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
View 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
View 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
View 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,
},
})