Compare commits
No commits in common. "main" and "v0.1.0" have entirely different histories.
8 changed files with 333 additions and 307 deletions
|
|
@ -1,79 +0,0 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: Linux
|
||||
runner: ubuntu-latest
|
||||
command: npm run dist:linux -- --publish never
|
||||
artifacts: "release/*.AppImage release/*.deb"
|
||||
- name: macOS
|
||||
runner: 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 packages
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
- name: Create or find Forgejo release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
FORGEJO_API: https://git.danvics.com/api/v1
|
||||
REPO: ${{ github.repository }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -eu
|
||||
response=$(curl -sS "$FORGEJO_API/repos/$REPO/releases/tags/$TAG" \
|
||||
-H "Authorization: token $FORGEJO_TOKEN")
|
||||
release_id=$(node -e "const data = JSON.parse(process.argv[1]); console.log(data.id || '')" "$response")
|
||||
if [ -z "$release_id" ]; then
|
||||
response=$(curl -sS -X POST "$FORGEJO_API/repos/$REPO/releases" \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"$TAG\",\"target_commitish\":\"main\",\"name\":\"$TAG\",\"body\":\"Automated desktop builds from Forgejo runners.\",\"draft\":false,\"prerelease\":false}")
|
||||
release_id=$(node -e "const data = JSON.parse(process.argv[1]); if (!data.id) throw new Error(JSON.stringify(data)); console.log(data.id)" "$response")
|
||||
fi
|
||||
echo "release_id=$release_id" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Upload release assets
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ github.token }}
|
||||
FORGEJO_API: https://git.danvics.com/api/v1
|
||||
REPO: ${{ github.repository }}
|
||||
ARTIFACTS: ${{ matrix.artifacts }}
|
||||
run: |
|
||||
set -eu
|
||||
for artifact in $ARTIFACTS; do
|
||||
[ -f "$artifact" ] || continue
|
||||
name=$(basename "$artifact")
|
||||
curl -sS -X POST "$FORGEJO_API/repos/$REPO/releases/$release_id/assets?name=$name" \
|
||||
-H "Authorization: token $FORGEJO_TOKEN" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@$artifact"
|
||||
done
|
||||
|
|
@ -7,8 +7,9 @@ A fast, cross-platform desktop client for the Nextcloud Notes app, inspired by `
|
|||
- 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.
|
||||
- No credential persistence in the recovery build: app passwords stay in memory only and must be entered each launch.
|
||||
- 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
|
||||
|
||||
|
|
@ -33,7 +34,7 @@ macOS DMG builds must run on macOS because the DMG toolchain and `dmg-license` d
|
|||
|
||||
## CI Releases
|
||||
|
||||
Forgejo Actions builds release artifacts on registered runners. The Linux job uses the existing `ubuntu-latest` runner; the macOS job expects a Forgejo runner registered with the `macos-latest` label so it can build DMG/zip artifacts natively. Push a version tag to publish artifacts to a Forgejo Release:
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,18 @@
|
|||
const { app, BrowserWindow, ipcMain } = require('electron')
|
||||
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(/\/+$/, '')
|
||||
|
|
@ -17,6 +28,43 @@ 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))
|
||||
|
|
@ -32,11 +80,18 @@ async function request(credentials, method, apiPath, { query, body, etag } = {})
|
|||
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||
if (etag) headers['If-Match'] = `"${etag}"`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
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(() => '')
|
||||
|
|
@ -52,6 +107,31 @@ async function request(credentials, method, apiPath, { query, body, etag } = {})
|
|||
}
|
||||
}
|
||||
|
||||
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 = []
|
||||
|
|
@ -93,15 +173,22 @@ function createWindow() {
|
|||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
},
|
||||
})
|
||||
|
||||
window.webContents.on('render-process-gone', (_event, details) => {
|
||||
console.error('Renderer process gone:', details.reason, details.exitCode)
|
||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (/^https?:\/\//i.test(url)) shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
window.webContents.on('console-message', (_event, level, message, line, sourceId) => {
|
||||
console.log(`[renderer:${level}] ${message} (${sourceId}:${line})`)
|
||||
window.webContents.session.setPermissionRequestHandler((_webContents, _permission, callback) => {
|
||||
callback(false)
|
||||
})
|
||||
|
||||
window.webContents.on('will-navigate', event => {
|
||||
if (!isDev) event.preventDefault()
|
||||
})
|
||||
|
||||
if (isDev) {
|
||||
|
|
@ -118,12 +205,19 @@ app.whenReady().then(() => {
|
|||
})
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
ipcMain.handle('credentials:get', () => null)
|
||||
ipcMain.handle('credentials:save', async (_event, credentials) => {
|
||||
registerHandler('credentials:get', () => publicAccount(readCredentials()))
|
||||
registerHandler('credentials:save', async credentials => {
|
||||
const normalized = {
|
||||
baseUrl: normalizeBaseUrl(credentials.baseUrl),
|
||||
username: String(credentials.username || '').trim(),
|
||||
|
|
@ -131,21 +225,25 @@ ipcMain.handle('credentials:save', async (_event, credentials) => {
|
|||
}
|
||||
if (!normalized.username || !normalized.password) throw new Error('Username and app password are required')
|
||||
await request(normalized, 'GET', '/settings')
|
||||
return { baseUrl: normalized.baseUrl, username: normalized.username }
|
||||
writeCredentials(normalized)
|
||||
return publicAccount(normalized)
|
||||
})
|
||||
ipcMain.handle('credentials:clear', () => {
|
||||
registerHandler('credentials:clear', () => {
|
||||
clearCredentials()
|
||||
return true
|
||||
})
|
||||
ipcMain.handle('notes:sync', (_event, { credentials, pruneBefore }) => syncNotes(credentials, pruneBefore))
|
||||
ipcMain.handle('notes:create', async (_event, { credentials, note }) => {
|
||||
const { data } = await request(credentials, 'POST', '/notes', { body: note })
|
||||
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
|
||||
})
|
||||
ipcMain.handle('notes:update', async (_event, { credentials, id, etag, note }) => {
|
||||
const { data } = await request(credentials, 'PUT', `/notes/${id}`, { body: note, etag })
|
||||
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
|
||||
})
|
||||
ipcMain.handle('notes:delete', async (_event, { credentials, id }) => {
|
||||
const { data } = await request(credentials, 'DELETE', `/notes/${id}`)
|
||||
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 }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,11 +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', {
|
||||
getCredentials: () => ipcRenderer.invoke('credentials:get'),
|
||||
saveCredentials: credentials => ipcRenderer.invoke('credentials:save', credentials),
|
||||
clearCredentials: () => ipcRenderer.invoke('credentials:clear'),
|
||||
syncNotes: payload => ipcRenderer.invoke('notes:sync', payload),
|
||||
createNote: payload => ipcRenderer.invoke('notes:create', payload),
|
||||
updateNote: payload => ipcRenderer.invoke('notes:update', payload),
|
||||
deleteNote: payload => ipcRenderer.invoke('notes:delete', payload),
|
||||
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),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
<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>
|
||||
|
|
|
|||
152
package-lock.json
generated
152
package-lock.json
generated
|
|
@ -1,22 +1,22 @@
|
|||
{
|
||||
"name": "nextcloud-notes-desktop",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nextcloud-notes-desktop",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"concurrently": "^9.2.1",
|
||||
"electron": "^39.8.10",
|
||||
"electron": "^42.0.1",
|
||||
"electron-builder": "^26.8.1",
|
||||
"vite": "^8.0.13",
|
||||
"wait-on": "^9.0.10"
|
||||
|
|
@ -150,35 +150,48 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@electron/get": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
|
||||
"integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz",
|
||||
"integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"env-paths": "^2.2.0",
|
||||
"fs-extra": "^8.1.0",
|
||||
"got": "^11.8.5",
|
||||
"env-paths": "^3.0.0",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"progress": "^2.0.3",
|
||||
"semver": "^6.2.0",
|
||||
"semver": "^7.6.3",
|
||||
"sumchecker": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"global-agent": "^3.0.0"
|
||||
"undici": "^7.24.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"node_modules/@electron/get/node_modules/env-paths": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
|
||||
"integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get/node_modules/undici": {
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
||||
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/notarize": {
|
||||
|
|
@ -2440,22 +2453,22 @@
|
|||
}
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "39.8.10",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-39.8.10.tgz",
|
||||
"integrity": "sha512-zbYtGPYUI7PzqLAzkk21Rk6j67WN0hxn0Mq/njErZo1d0HSf33is4f8ICI5fMLy5vYe0JtCtM5sYunNOaochSQ==",
|
||||
"version": "42.0.1",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-42.0.1.tgz",
|
||||
"integrity": "sha512-d8HnycE970DGESe91Nj30eonFBUcAI9EZ1TwUGJVzSAnJZdh0BkFEinAXjdklvDYst+bVDc8HsksCuqVLrnqdg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@electron/get": "^2.0.0",
|
||||
"@types/node": "^22.7.7",
|
||||
"@electron/get": "^5.0.0",
|
||||
"@types/node": "^24.9.0",
|
||||
"extract-zip": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"electron": "cli.js"
|
||||
"electron": "cli.js",
|
||||
"install-electron": "install.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.20.55"
|
||||
"node": ">= 22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-builder": {
|
||||
|
|
@ -2628,23 +2641,6 @@
|
|||
"node": ">=6 <7 || >=8"
|
||||
}
|
||||
},
|
||||
"node_modules/electron/node_modules/@types/node": {
|
||||
"version": "22.19.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
|
||||
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron/node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
|
|
@ -2934,21 +2930,6 @@
|
|||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
|
||||
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^4.0.0",
|
||||
"universalify": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6 <7 || >=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
|
|
@ -3572,12 +3553,6 @@
|
|||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
|
|
@ -3931,18 +3906,6 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lowercase-keys": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
|
||||
|
|
@ -5398,28 +5361,24 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
|
||||
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
"react": "^19.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/react-markdown": {
|
||||
|
|
@ -5692,13 +5651,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
|
|
|
|||
12
package.json
12
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "nextcloud-notes-desktop",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Fast cross-platform desktop client for Nextcloud Notes",
|
||||
"author": {
|
||||
|
|
@ -20,8 +20,8 @@
|
|||
"dist:linux": "npm run build && electron-builder --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
|
|
@ -31,7 +31,7 @@
|
|||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"concurrently": "^9.2.1",
|
||||
"electron": "^39.8.10",
|
||||
"electron": "^42.0.1",
|
||||
"electron-builder": "^26.8.1",
|
||||
"vite": "^8.0.13",
|
||||
"wait-on": "^9.0.10"
|
||||
|
|
@ -54,6 +54,10 @@
|
|||
},
|
||||
"mac": {
|
||||
"category": "public.app-category.productivity",
|
||||
"hardenedRuntime": true,
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": "build/entitlements.mac.plist",
|
||||
"entitlementsInherit": "build/entitlements.mac.plist",
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
|
|
|
|||
229
src/main.jsx
229
src/main.jsx
|
|
@ -13,14 +13,23 @@ import {
|
|||
} from './db.js'
|
||||
import './styles.css'
|
||||
|
||||
const NEW_NOTE = {
|
||||
id: 'new',
|
||||
title: 'Untitled note',
|
||||
content: '',
|
||||
category: '',
|
||||
modified: 0,
|
||||
favorite: false,
|
||||
etag: '',
|
||||
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) {
|
||||
|
|
@ -39,44 +48,13 @@ function normalizeNote(note) {
|
|||
title: note.title || deriveTitle(note.content || ''),
|
||||
content: note.content || '',
|
||||
category: note.category || '',
|
||||
modified: Number(note.modified || Math.floor(Date.now() / 1000)),
|
||||
modified: Number(note.modified || nowSeconds()),
|
||||
favorite: Boolean(note.favorite),
|
||||
etag: note.etag || '',
|
||||
readonly: Boolean(note.readonly),
|
||||
}
|
||||
}
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = { error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error('Renderer error:', error, errorInfo)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<main className="login-shell">
|
||||
<section className="login-card">
|
||||
<div className="brand-mark">!</div>
|
||||
<h1>Nextcloud Notes hit an error</h1>
|
||||
<p>The app did not start cleanly. This replaces the blank screen so the issue can be diagnosed.</p>
|
||||
<div className="error-box">{this.state.error.message || String(this.state.error)}</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
function Login({ onLogin }) {
|
||||
const [baseUrl, setBaseUrl] = React.useState('https://cloud.danvics.com')
|
||||
const [username, setUsername] = React.useState('')
|
||||
|
|
@ -89,8 +67,9 @@ function Login({ onLogin }) {
|
|||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
const saved = await window.notesBridge.saveCredentials({ baseUrl, username, password })
|
||||
onLogin({ ...saved, password })
|
||||
const account = await window.notesBridge.saveCredentials({ baseUrl, username, password })
|
||||
setPassword('')
|
||||
onLogin(account)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not connect to Nextcloud Notes')
|
||||
} finally {
|
||||
|
|
@ -103,7 +82,7 @@ function Login({ onLogin }) {
|
|||
<form className="login-card" onSubmit={submit}>
|
||||
<div className="brand-mark">N</div>
|
||||
<h1>Nextcloud Notes</h1>
|
||||
<p>Connect with a Nextcloud app password. Notes load locally first, then sync incrementally.</p>
|
||||
<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" />
|
||||
|
|
@ -124,7 +103,7 @@ function Login({ onLogin }) {
|
|||
}
|
||||
|
||||
function App() {
|
||||
const [credentials, setCredentials] = React.useState(null)
|
||||
const [account, setAccount] = React.useState(null)
|
||||
const [notes, setNotes] = React.useState([])
|
||||
const [selectedId, setSelectedId] = React.useState(null)
|
||||
const [query, setQuery] = React.useState('')
|
||||
|
|
@ -133,56 +112,75 @@ function App() {
|
|||
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 = notes.filter(note => {
|
||||
const text = `${note.title}\n${note.content}\n${note.category}`.toLowerCase()
|
||||
const matchesQuery = !query || text.includes(query.toLowerCase())
|
||||
const normalizedCategory = note.category || 'Uncategorized'
|
||||
const matchesCategory = category === 'all' || normalizedCategory === category
|
||||
return matchesQuery && matchesCategory
|
||||
})
|
||||
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)
|
||||
const savedCredentials = await window.notesBridge.getCredentials()
|
||||
if (savedCredentials) setCredentials(savedCredentials)
|
||||
setSyncStatus(savedCredentials ? 'Ready to sync' : 'Sign in to sync')
|
||||
if (savedAccount) setAccount(savedAccount)
|
||||
setSyncStatus(savedAccount ? 'Ready to sync' : 'Sign in to sync')
|
||||
}
|
||||
boot().catch(err => setError(err.message || 'Startup failed'))
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (credentials) syncNow(false)
|
||||
}, [credentials])
|
||||
if (account) syncNow(false)
|
||||
}, [account])
|
||||
|
||||
React.useEffect(() => {
|
||||
const next = notes.find(note => note.id === selectedId) || null
|
||||
setDraft(next)
|
||||
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 (!credentials || syncing.current) return
|
||||
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({ credentials, pruneBefore })
|
||||
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 getAllCachedNotes()
|
||||
const cached = await refreshNotes()
|
||||
await setMeta('lastSync', result.syncedAt)
|
||||
setNotes(cached)
|
||||
if (!selectedId || deletedIds.includes(selectedId)) setSelectedId(cached[0]?.id || null)
|
||||
setSyncStatus(`Synced ${formatDate(result.syncedAt)}`)
|
||||
} catch (err) {
|
||||
|
|
@ -193,57 +191,91 @@ function App() {
|
|||
}
|
||||
}
|
||||
|
||||
function updateDraft(patch) {
|
||||
setDraft(current => ({ ...(current || NEW_NOTE), ...patch, modified: Math.floor(Date.now() / 1000) }))
|
||||
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 = draft) {
|
||||
if (!credentials || !note) return
|
||||
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'
|
||||
? await window.notesBridge.createNote({ credentials, note: payload })
|
||||
: await window.notesBridge.updateNote({ credentials, id: note.id, etag: note.etag, note: payload })
|
||||
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])
|
||||
setNotes(await getAllCachedNotes())
|
||||
setSelectedId(normalized.id)
|
||||
setDraft(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. Sync before saving again to avoid overwriting it.')
|
||||
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) {
|
||||
function scheduleSave(nextDraft, version) {
|
||||
clearTimeout(saveTimer.current)
|
||||
saveTimer.current = setTimeout(() => saveDraft(nextDraft), 650)
|
||||
saveTimer.current = setTimeout(() => saveDraft(nextDraft, version), 700)
|
||||
}
|
||||
|
||||
function handleContentChange(content) {
|
||||
const nextDraft = { ...(draft || NEW_NOTE), content, title: deriveTitle(content), modified: Math.floor(Date.now() / 1000) }
|
||||
setDraft(nextDraft)
|
||||
scheduleSave(nextDraft)
|
||||
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() {
|
||||
const note = { ...NEW_NOTE, id: 'new', content: '# Untitled note\n', modified: Math.floor(Date.now() / 1000) }
|
||||
setSelectedId('new')
|
||||
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 (!credentials || !selected || selected.id === 'new') return
|
||||
if (!account || !selected || selected.id === NEW_NOTE_ID) return
|
||||
clearTimeout(saveTimer.current)
|
||||
setSyncStatus('Deleting...')
|
||||
try {
|
||||
await window.notesBridge.deleteNote({ credentials, id: selected.id })
|
||||
await window.notesBridge.deleteNote({ id: selected.id })
|
||||
await deleteCachedNote(selected.id)
|
||||
const cached = await getAllCachedNotes()
|
||||
setNotes(cached)
|
||||
dirtyRef.current = false
|
||||
const cached = await refreshNotes()
|
||||
setSelectedId(cached[0]?.id || null)
|
||||
setSyncStatus('Deleted')
|
||||
} catch (err) {
|
||||
|
|
@ -256,13 +288,16 @@ function App() {
|
|||
clearTimeout(saveTimer.current)
|
||||
await window.notesBridge.clearCredentials()
|
||||
await clearCache()
|
||||
setCredentials(null)
|
||||
dirtyRef.current = false
|
||||
setAccount(null)
|
||||
setNotes([])
|
||||
setSelectedId(null)
|
||||
setDraft(null)
|
||||
setError('')
|
||||
setSyncStatus('Sign in to sync')
|
||||
}
|
||||
|
||||
if (!credentials) return <Login onLogin={setCredentials} />
|
||||
if (!account) return <Login onLogin={setAccount} />
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
|
|
@ -274,6 +309,7 @@ function App() {
|
|||
</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>
|
||||
|
|
@ -281,7 +317,7 @@ function App() {
|
|||
</div>
|
||||
<div className="note-list">
|
||||
{filteredNotes.map(note => (
|
||||
<button key={note.id} className={note.id === selectedId ? 'note-row active' : 'note-row'} onClick={() => setSelectedId(note.id)}>
|
||||
<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>
|
||||
|
|
@ -295,24 +331,24 @@ function App() {
|
|||
className="title-input"
|
||||
value={selected?.title || ''}
|
||||
onChange={event => updateDraft({ title: event.target.value })}
|
||||
onBlur={() => saveDraft()}
|
||||
onBlur={flushSave}
|
||||
placeholder="Select or create a note"
|
||||
disabled={!selected}
|
||||
disabled={!selected || selected.readonly}
|
||||
/>
|
||||
<input
|
||||
className="category-input"
|
||||
value={selected?.category || ''}
|
||||
onChange={event => updateDraft({ category: event.target.value })}
|
||||
onBlur={() => saveDraft()}
|
||||
onBlur={flushSave}
|
||||
placeholder="Category"
|
||||
disabled={!selected}
|
||||
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'}>Delete</button>
|
||||
<button onClick={deleteSelected} disabled={!selected || selected.id === NEW_NOTE_ID}>Delete</button>
|
||||
<button onClick={logout}>Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -323,8 +359,9 @@ function App() {
|
|||
<textarea
|
||||
className="note-editor"
|
||||
value={selected.content}
|
||||
onChange={event => handleContentChange(event.target.value)}
|
||||
onChange={event => updateDraft({ content: event.target.value, title: deriveTitle(event.target.value) })}
|
||||
spellCheck="true"
|
||||
disabled={selected.readonly}
|
||||
/>
|
||||
) : (
|
||||
<article className="markdown-preview">
|
||||
|
|
@ -337,7 +374,7 @@ function App() {
|
|||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<ErrorBoundary>
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue