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