Compare commits
47 Commits
backup-bef
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00e1f47511 | ||
|
|
12b50c2304 | ||
|
|
525413a08a | ||
|
|
1d15a39b4c | ||
|
|
b31f974411 | ||
|
|
511ebca4ad | ||
|
|
17c79a3aa8 | ||
|
|
0a5dba2a98 | ||
|
|
8bbd5f9262 | ||
|
|
244ba69eed | ||
|
|
36733da434 | ||
|
|
f4ba8c9775 | ||
|
|
dac08f1d2f | ||
|
|
0a6ecd25da | ||
|
|
cb7a8d8276 | ||
|
|
911662b214 | ||
|
|
dfc0e644eb | ||
|
|
5a925dc50e | ||
|
|
70c38cb925 | ||
|
|
4f13b0d57f | ||
|
|
4dbf0233b7 | ||
|
|
5de3cd3789 | ||
|
|
486579809f | ||
|
|
f8b3cc8a9d | ||
|
|
0b13a2df5b | ||
|
|
861eb1e103 | ||
|
|
edc65f2edd | ||
|
|
4ef0814ccd | ||
|
|
c775661caa | ||
|
|
3e93cf2408 | ||
|
|
3e3d9ca7f1 | ||
|
|
ed6dd69b32 | ||
|
|
bd6d2cd404 | ||
|
|
e86e851b31 | ||
|
|
e9ba48d7d4 | ||
|
|
c5c963200a | ||
|
|
013e7670f5 | ||
|
|
23ef338e47 | ||
|
|
1667c6cf13 | ||
|
|
6172abbe53 | ||
|
|
4ddf2d15a9 | ||
|
|
472e6e3b2e | ||
|
|
e3a1d74413 | ||
|
|
c147890138 | ||
|
|
7d992d103c | ||
|
|
2a3b733178 | ||
|
|
c11e792062 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,6 +10,7 @@ lerna-debug.log*
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
release
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
|
|||||||
170
README.md
170
README.md
@@ -1,83 +1,143 @@
|
|||||||
# Tauri + React + Typescript
|

|
||||||
|
|
||||||
# Nextcloud Notes - Cross-Platform Desktop App
|
# Nextcloud Notes Desktop
|
||||||
|
|
||||||
A modern, cross-platform desktop application for [Nextcloud Notes](https://apps.nextcloud.com/apps/notes) built with Tauri + React + TypeScript.
|
A desktop client for [Nextcloud Notes](https://apps.nextcloud.com/apps/notes) built with React, TypeScript, Vite, and Electron.
|
||||||
|
|
||||||
## Features
|
This project started life as a Tauri app and has now been migrated to Electron for desktop runtime support, PDF export, and simpler cross-platform desktop behavior during development.
|
||||||
|
|
||||||
- ✅ **Cross-platform**: macOS, Linux, Windows
|
## What It Does
|
||||||
- ✅ **Lightweight**: ~600KB binary (vs 150MB+ Electron)
|
|
||||||
- ✅ **Modern UI**: React + TailwindCSS
|
|
||||||
- ✅ **Full sync**: Create, edit, delete, favorite notes
|
|
||||||
- ✅ **Search & filter**: Find notes quickly, filter by favorites
|
|
||||||
- ✅ **Auto-save**: Changes save automatically after 1.5s
|
|
||||||
- ✅ **Secure**: Credentials stored in system keychain (localStorage for now)
|
|
||||||
- ✅ **Background sync**: Auto-sync every 5 minutes
|
|
||||||
|
|
||||||
## Prerequisites
|
- Sign in to a Nextcloud server with Notes enabled
|
||||||
|
- Sync notes from WebDAV and favorite state from the Notes API
|
||||||
|
- Create, edit, move, rename, and delete notes
|
||||||
|
- Organize notes into categories, including nested categories
|
||||||
|
- Mark notes as favorites
|
||||||
|
- Cache notes locally for faster startup and offline viewing
|
||||||
|
- Upload and render note attachments
|
||||||
|
- Preview Markdown while editing
|
||||||
|
- Export notes to PDF
|
||||||
|
- Use a focus mode for distraction-free editing
|
||||||
|
|
||||||
- **Rust**: Install from https://rustup.rs/
|
## Current Runtime
|
||||||
- **Node.js**: v18+ recommended
|
|
||||||
- **Nextcloud instance** with Notes app enabled
|
- Primary desktop runtime: Electron
|
||||||
|
- Frontend: React 19 + TypeScript + Vite
|
||||||
|
- Styling: Tailwind CSS
|
||||||
|
- Local cache: IndexedDB
|
||||||
|
- Nextcloud integration:
|
||||||
|
- WebDAV for note files, folders, attachments, and category color storage
|
||||||
|
- Notes API for favorite metadata
|
||||||
|
|
||||||
|
Some Tauri-related code and dependencies are still present in the repository, mainly because parts of the app were built during the earlier Tauri phase. The Electron path is the actively used desktop runtime.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Node.js 18 or newer
|
||||||
|
- npm
|
||||||
|
- A Nextcloud instance with the Notes app enabled
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
Run the Electron app with the Vite dev server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
npm run dev:desktop
|
||||||
npm install
|
```
|
||||||
|
|
||||||
# Run in development mode
|
Useful scripts:
|
||||||
npm run tauri dev
|
|
||||||
|
|
||||||
# Build for production
|
```bash
|
||||||
npm run tauri build
|
npm run dev:renderer # Vite frontend only
|
||||||
|
npm run dev:electron # Electron only, expects renderer on port 1420
|
||||||
|
npm run build # TypeScript + Vite production build
|
||||||
|
npm run desktop # Run Electron against the built dist/
|
||||||
|
npm run dist:dir # Build an unpacked Electron app in release/
|
||||||
|
npm run dist:mac # Build macOS .dmg and .zip packages in release/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production-Like Local Run
|
||||||
|
|
||||||
|
Build the frontend, then start Electron against the generated `dist/` files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run desktop
|
||||||
```
|
```
|
||||||
|
|
||||||
## First Launch
|
## First Launch
|
||||||
|
|
||||||
1. Enter your Nextcloud server URL (e.g., `https://cloud.example.com`)
|
1. Enter your Nextcloud server URL, for example `https://cloud.example.com`
|
||||||
2. Enter your username
|
2. Enter your username
|
||||||
3. Enter your password or **App Password** (recommended)
|
3. Enter your password or, preferably, a Nextcloud app password
|
||||||
- Generate at: Settings → Security → Devices & Sessions in Nextcloud
|
4. Wait for the initial sync to finish
|
||||||
4. Click **Connect**
|
|
||||||
|
|
||||||
## Building for Distribution
|
Using an app password is strongly recommended.
|
||||||
|
|
||||||
### macOS
|
## Notable Behavior
|
||||||
```bash
|
|
||||||
npm run tauri build
|
### Sync model
|
||||||
# Output: src-tauri/target/release/bundle/macos/
|
|
||||||
|
- Note files are synced through WebDAV
|
||||||
|
- Favorite status is synced through the Nextcloud Notes API
|
||||||
|
- Notes are cached locally and can still be viewed when offline
|
||||||
|
- Background sync runs periodically while the app is open
|
||||||
|
|
||||||
|
### PDF export
|
||||||
|
|
||||||
|
- In Electron, the toolbar export action saves a PDF directly to disk
|
||||||
|
- Embedded note images are resolved before export when possible
|
||||||
|
|
||||||
|
### Category colors
|
||||||
|
|
||||||
|
- Category color preferences are stored in `.category-colors.json` inside your Nextcloud Notes WebDAV folder
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
electron/ Electron main process and preload bridge
|
||||||
|
src/api/ Nextcloud API and WebDAV client logic
|
||||||
|
src/components/ React UI
|
||||||
|
src/db/ Local IndexedDB cache
|
||||||
|
src/services/ Desktop runtime helpers and sync logic
|
||||||
|
src/printExport.ts Shared print/PDF document generation
|
||||||
```
|
```
|
||||||
|
|
||||||
### Linux
|
## Security Notes
|
||||||
```bash
|
|
||||||
npm run tauri build
|
|
||||||
# Output: src-tauri/target/release/bundle/appimage/ or .deb
|
|
||||||
```
|
|
||||||
|
|
||||||
### Windows
|
- Electron runs with `contextIsolation: true`
|
||||||
```bash
|
- `nodeIntegration` is disabled in renderer windows
|
||||||
npm run tauri build
|
- Network requests that need desktop privileges are routed through Electron IPC instead of renderer-side browser fetch
|
||||||
# Output: src-tauri/target/release/bundle/msi/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tech Stack
|
Current limitation:
|
||||||
|
|
||||||
- **Tauri**: Rust-based native wrapper (~600KB)
|
- Login credentials are still persisted in `localStorage`
|
||||||
- **React 18**: UI framework
|
|
||||||
- **TypeScript**: Type safety
|
|
||||||
- **TailwindCSS**: Utility-first styling
|
|
||||||
- **Vite**: Fast build tool
|
|
||||||
|
|
||||||
## Advantages over Native Swift App
|
That is convenient for development, but it is not the right long-term storage mechanism for a production desktop app. A future improvement should move credentials into the OS keychain or another secure secret store.
|
||||||
|
|
||||||
- ✅ **Cross-platform**: One codebase for macOS, Linux, Windows
|
## Packaging
|
||||||
- ✅ **No SwiftUI state issues**: React's state management is mature
|
|
||||||
- ✅ **Smaller binary**: Tauri is much lighter than Electron
|
|
||||||
- ✅ **Easier to maintain**: Web technologies vs platform-specific code
|
|
||||||
- ✅ **No Xcode required**: Build on any platform
|
|
||||||
|
|
||||||
## Recommended IDE Setup
|
Electron packaging is set up with `electron-builder`.
|
||||||
|
|
||||||
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
Current packaging commands:
|
||||||
|
|
||||||
|
- `npm run dist:dir` creates an unpacked app bundle in `release/`
|
||||||
|
- `npm run dist:mac` creates macOS `.dmg` and `.zip` artifacts in `release/`
|
||||||
|
|
||||||
|
The current mac build is unsigned and not notarized, which is fine for local use and testing but not enough for friction-free public distribution through Gatekeeper.
|
||||||
|
|
||||||
|
Windows and Linux targets are also configured in `package.json`, but they have not been validated in this repository yet.
|
||||||
|
|
||||||
|
## Legacy Tauri Script
|
||||||
|
|
||||||
|
There is still a `npm run tauri` script in `package.json`, but the README and current workflow are centered on Electron.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
No license file is currently included in this repository.
|
||||||
|
|||||||
164
electron/main.cjs
Normal file
164
electron/main.cjs
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
const fs = require('node:fs/promises');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { app, BrowserWindow, dialog, ipcMain } = require('electron');
|
||||||
|
|
||||||
|
const rendererUrl = process.env.ELECTRON_RENDERER_URL;
|
||||||
|
const isDev = Boolean(rendererUrl);
|
||||||
|
let mainWindow = null;
|
||||||
|
|
||||||
|
const waitForPrintDocument = () => `
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const pendingImages = Array.from(document.images).filter((image) => !image.complete);
|
||||||
|
const waitForImages = Promise.all(
|
||||||
|
pendingImages.map(
|
||||||
|
(image) =>
|
||||||
|
new Promise((done) => {
|
||||||
|
image.addEventListener('load', () => done(), { once: true });
|
||||||
|
image.addEventListener('error', () => done(), { once: true });
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const waitForFonts = document.fonts ? document.fonts.ready.catch(() => undefined) : Promise.resolve();
|
||||||
|
|
||||||
|
Promise.all([waitForImages, waitForFonts]).then(() => {
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
|
||||||
|
function createMainWindow() {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1300,
|
||||||
|
height: 800,
|
||||||
|
minWidth: 800,
|
||||||
|
minHeight: 600,
|
||||||
|
show: false,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.cjs'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.once('ready-to-show', () => {
|
||||||
|
mainWindow.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
void mainWindow.loadURL(rendererUrl);
|
||||||
|
mainWindow.webContents.openDevTools({ mode: 'detach' });
|
||||||
|
} else {
|
||||||
|
void mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
createMainWindow();
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
createMainWindow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('desktop:show-message', async (event, options) => {
|
||||||
|
const ownerWindow = BrowserWindow.fromWebContents(event.sender) ?? mainWindow;
|
||||||
|
const typeMap = {
|
||||||
|
info: 'info',
|
||||||
|
warning: 'warning',
|
||||||
|
error: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
await dialog.showMessageBox(ownerWindow, {
|
||||||
|
type: typeMap[options.kind] || 'info',
|
||||||
|
title: options.title || app.name,
|
||||||
|
message: options.message,
|
||||||
|
buttons: ['OK'],
|
||||||
|
defaultId: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('desktop:http-request', async (_event, payload) => {
|
||||||
|
const body =
|
||||||
|
payload.bodyBase64 != null
|
||||||
|
? Buffer.from(payload.bodyBase64, 'base64')
|
||||||
|
: payload.bodyText;
|
||||||
|
|
||||||
|
const response = await fetch(payload.url, {
|
||||||
|
method: payload.method || 'GET',
|
||||||
|
headers: payload.headers,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: response.ok,
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: Object.fromEntries(response.headers.entries()),
|
||||||
|
bodyBase64: buffer.toString('base64'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('desktop:export-pdf', async (event, payload) => {
|
||||||
|
const ownerWindow = BrowserWindow.fromWebContents(event.sender) ?? mainWindow;
|
||||||
|
const saveResult = await dialog.showSaveDialog(ownerWindow, {
|
||||||
|
title: 'Export PDF',
|
||||||
|
defaultPath: payload.fileName,
|
||||||
|
filters: [{ name: 'PDF Document', extensions: ['pdf'] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (saveResult.canceled || !saveResult.filePath) {
|
||||||
|
return { canceled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempHtmlPath = path.join(
|
||||||
|
app.getPath('temp'),
|
||||||
|
`nextcloud-notes-export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.html`
|
||||||
|
);
|
||||||
|
|
||||||
|
const pdfWindow = new BrowserWindow({
|
||||||
|
width: 960,
|
||||||
|
height: 1100,
|
||||||
|
show: false,
|
||||||
|
parent: ownerWindow ?? undefined,
|
||||||
|
webPreferences: {
|
||||||
|
sandbox: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
spellcheck: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.writeFile(tempHtmlPath, payload.documentHtml, 'utf8');
|
||||||
|
await pdfWindow.loadFile(tempHtmlPath);
|
||||||
|
await pdfWindow.webContents.executeJavaScript(waitForPrintDocument(), true);
|
||||||
|
|
||||||
|
const pdfData = await pdfWindow.webContents.printToPDF({
|
||||||
|
printBackground: true,
|
||||||
|
preferCSSPageSize: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await fs.writeFile(saveResult.filePath, pdfData);
|
||||||
|
|
||||||
|
return {
|
||||||
|
canceled: false,
|
||||||
|
filePath: saveResult.filePath,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await fs.unlink(tempHtmlPath).catch(() => undefined);
|
||||||
|
if (!pdfWindow.isDestroyed()) {
|
||||||
|
pdfWindow.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
8
electron/preload.cjs
Normal file
8
electron/preload.cjs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('electronDesktop', {
|
||||||
|
showMessage: (options) => ipcRenderer.invoke('desktop:show-message', options),
|
||||||
|
exportPdf: (payload) => ipcRenderer.invoke('desktop:export-pdf', payload),
|
||||||
|
httpRequest: (payload) => ipcRenderer.invoke('desktop:http-request', payload),
|
||||||
|
getRuntime: () => 'electron',
|
||||||
|
});
|
||||||
18
index.html
18
index.html
@@ -2,19 +2,19 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Tauri + React + Typescript</title>
|
<meta
|
||||||
<!-- Editor fonts (monospace) -->
|
http-equiv="Content-Security-Policy"
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; font-src 'self' data:; connect-src 'self' https: http: ws: wss:; object-src 'none'; base-uri 'self'"
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
/>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inconsolata:wght@200..900&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&display=swap" rel="stylesheet">
|
<title>Nextcloud Notes</title>
|
||||||
<!-- Preview fonts (serif) -->
|
<!-- Local fonts for offline support -->
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Average&family=Crimson+Pro:ital,wght@0,200..900;1,200..900&family=Merriweather:ital,opsz,wght@0,18..144,300..900;1,18..144,300..900&family=Roboto+Serif:ital,opsz,wght@0,8..144,100..900;1,8..144,100..900&display=swap" rel="stylesheet">
|
<link rel="stylesheet" href="./fonts/fonts.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="./src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
5015
package-lock.json
generated
5015
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
53
package.json
53
package.json
@@ -1,11 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "nextcloud-notes-tauri",
|
"name": "nextcloud-notes-tauri",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.2.2",
|
||||||
|
"description": "Desktop client for Nextcloud Notes built with Electron, React, and TypeScript.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"main": "electron/main.cjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"dev:renderer": "vite",
|
||||||
|
"dev:electron": "wait-on tcp:1420 && cross-env ELECTRON_RENDERER_URL=http://localhost:1420 electron .",
|
||||||
|
"dev:desktop": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
|
"desktop": "electron .",
|
||||||
|
"dist:dir": "npm run build && electron-builder --dir",
|
||||||
|
"dist:mac": "npm run build && electron-builder --mac dmg zip",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
@@ -34,9 +42,50 @@
|
|||||||
"@types/turndown": "^5.0.6",
|
"@types/turndown": "^5.0.6",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
"autoprefixer": "^10.4.27",
|
"autoprefixer": "^10.4.27",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
|
"electron": "^37.3.1",
|
||||||
|
"electron-builder": "^26.8.1",
|
||||||
"postcss": "^8.5.8",
|
"postcss": "^8.5.8",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.0.4",
|
||||||
|
"wait-on": "^8.0.5"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "cz.davidrelich.nextcloud-notes-desktop",
|
||||||
|
"productName": "Nextcloud Notes Desktop",
|
||||||
|
"directories": {
|
||||||
|
"output": "release"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist/**/*",
|
||||||
|
"electron/**/*",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"asar": true,
|
||||||
|
"npmRebuild": false,
|
||||||
|
"mac": {
|
||||||
|
"category": "public.app-category.productivity",
|
||||||
|
"icon": "src-tauri/icons/icon.icns",
|
||||||
|
"target": [
|
||||||
|
"dmg",
|
||||||
|
"zip"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"icon": "src-tauri/icons/icon.ico",
|
||||||
|
"target": [
|
||||||
|
"nsis",
|
||||||
|
"zip"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"icon": "src-tauri/icons/icon.png",
|
||||||
|
"target": [
|
||||||
|
"AppImage",
|
||||||
|
"deb"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/fonts/Average-Regular.ttf
Normal file
BIN
public/fonts/Average-Regular.ttf
Normal file
Binary file not shown.
BIN
public/fonts/CrimsonPro-Italic-VariableFont_wght.ttf
Normal file
BIN
public/fonts/CrimsonPro-Italic-VariableFont_wght.ttf
Normal file
Binary file not shown.
BIN
public/fonts/CrimsonPro-VariableFont_wght.ttf
Normal file
BIN
public/fonts/CrimsonPro-VariableFont_wght.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Inconsolata-VariableFont_wdth,wght.ttf
Normal file
BIN
public/fonts/Inconsolata-VariableFont_wdth,wght.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Merriweather-Italic-VariableFont_opsz,wdth,wght.ttf
Normal file
BIN
public/fonts/Merriweather-Italic-VariableFont_opsz,wdth,wght.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Merriweather-VariableFont_opsz,wdth,wght.ttf
Normal file
BIN
public/fonts/Merriweather-VariableFont_opsz,wdth,wght.ttf
Normal file
Binary file not shown.
BIN
public/fonts/RobotoMono-VariableFont_wght.ttf
Normal file
BIN
public/fonts/RobotoMono-VariableFont_wght.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/fonts/RobotoSerif-VariableFont_GRAD,opsz,wdth,wght.ttf
Normal file
BIN
public/fonts/RobotoSerif-VariableFont_GRAD,opsz,wdth,wght.ttf
Normal file
Binary file not shown.
BIN
public/fonts/SourceCodePro-VariableFont_wght.ttf
Normal file
BIN
public/fonts/SourceCodePro-VariableFont_wght.ttf
Normal file
Binary file not shown.
90
public/fonts/fonts.css
Normal file
90
public/fonts/fonts.css
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/* Editor Fonts (Monospace) */
|
||||||
|
|
||||||
|
/* Source Code Pro */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Source Code Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 200 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('./SourceCodePro-VariableFont_wght.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Roboto Mono */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('./RobotoMono-VariableFont_wght.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inconsolata */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inconsolata';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 200 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('./Inconsolata-VariableFont_wdth,wght.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preview Fonts (Serif) */
|
||||||
|
|
||||||
|
/* Merriweather */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Merriweather';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('./Merriweather-VariableFont_opsz,wdth,wght.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Merriweather';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 300 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('./Merriweather-Italic-VariableFont_opsz,wdth,wght.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Crimson Pro */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Crimson Pro';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 200 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('./CrimsonPro-VariableFont_wght.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Crimson Pro';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 200 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('./CrimsonPro-Italic-VariableFont_wght.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Roboto Serif */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto Serif';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('./RobotoSerif-VariableFont_GRAD,opsz,wdth,wght.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Roboto Serif';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('./RobotoSerif-Italic-VariableFont_GRAD,opsz,wdth,wght.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Average */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Average';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('./Average-Regular.ttf') format('truetype');
|
||||||
|
}
|
||||||
@@ -2,9 +2,11 @@
|
|||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capability for the main window",
|
"description": "Capability for the main window",
|
||||||
"windows": ["main"],
|
"windows": ["main", "print-export-*"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
|
"core:webview:allow-create-webview-window",
|
||||||
|
"core:webview:allow-print",
|
||||||
"opener:default",
|
"opener:default",
|
||||||
"http:default",
|
"http:default",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Nextcloud Notes",
|
"productName": "Nextcloud Notes",
|
||||||
"version": "0.1.0",
|
"version": "0.2.2",
|
||||||
"identifier": "com.davidrelich.nextcloud-notes",
|
"identifier": "com.davidrelich.nextcloud-notes",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "Nextcloud Notes",
|
"title": "Nextcloud Notes",
|
||||||
"width": 1200,
|
"width": 1300,
|
||||||
"height": 800,
|
"height": 800,
|
||||||
"minWidth": 800,
|
"minWidth": 800,
|
||||||
"minHeight": 600,
|
"minHeight": 600,
|
||||||
|
|||||||
699
src/App.tsx
699
src/App.tsx
@@ -1,16 +1,97 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
|
||||||
import { LoginView } from './components/LoginView';
|
import { LoginView } from './components/LoginView';
|
||||||
import { NotesList } from './components/NotesList';
|
import { NotesList } from './components/NotesList';
|
||||||
import { NoteEditor } from './components/NoteEditor';
|
import { NoteEditor } from './components/NoteEditor';
|
||||||
import { CategoriesSidebar } from './components/CategoriesSidebar';
|
import { CategoriesSidebar } from './components/CategoriesSidebar';
|
||||||
import { NextcloudAPI } from './api/nextcloud';
|
import { NextcloudAPI } from './api/nextcloud';
|
||||||
import { Note } from './types';
|
import { Note } from './types';
|
||||||
|
import { syncManager, SyncStatus } from './services/syncManager';
|
||||||
|
import { localDB } from './db/localDB';
|
||||||
|
import { useOnlineStatus } from './hooks/useOnlineStatus';
|
||||||
|
import { categoryColorsSync } from './services/categoryColorsSync';
|
||||||
|
import { PRINT_EXPORT_QUERY_PARAM } from './printExport';
|
||||||
|
|
||||||
function App() {
|
const LazyPrintView = lazy(async () => {
|
||||||
|
const module = await import('./components/PrintView');
|
||||||
|
return { default: module.PrintView };
|
||||||
|
});
|
||||||
|
|
||||||
|
const AUTOSAVE_DELAY_MS = 1000;
|
||||||
|
|
||||||
|
interface SaveController {
|
||||||
|
timerId: number | null;
|
||||||
|
revision: number;
|
||||||
|
inFlight: Promise<void> | null;
|
||||||
|
inFlightRevision: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlushSaveOptions {
|
||||||
|
force?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortNotes = (notes: Note[]) =>
|
||||||
|
[...notes].sort((a, b) => b.modified - a.modified);
|
||||||
|
|
||||||
|
const toStoredNote = (note: Note): Note => ({
|
||||||
|
...note,
|
||||||
|
isSaving: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getNoteDraftId = (note: Note | null | undefined) => note?.draftId ?? null;
|
||||||
|
|
||||||
|
const createDraftId = () => {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRemoteCategory = (note: Note) => {
|
||||||
|
if (note.path) {
|
||||||
|
const pathParts = note.path.split('/');
|
||||||
|
return pathParts.length > 1 ? pathParts.slice(0, -1).join('/') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof note.id === 'string') {
|
||||||
|
const idParts = note.id.split('/');
|
||||||
|
return idParts.length > 1 ? idParts.slice(0, -1).join('/') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return note.category;
|
||||||
|
};
|
||||||
|
|
||||||
|
const splitNoteContent = (content: string) => {
|
||||||
|
const [firstLine = '', ...rest] = content.split('\n');
|
||||||
|
return {
|
||||||
|
title: firstLine.replace(/^#+\s*/, '').trim(),
|
||||||
|
body: rest.join('\n'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const canAutosaveLocalNote = (note: Note) => {
|
||||||
|
if (!note.localOnly) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title } = splitNoteContent(note.content);
|
||||||
|
return title.length > 0 && note.content.includes('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const canForceSaveLocalNote = (note: Note) => {
|
||||||
|
if (!note.localOnly) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title } = splitNoteContent(note.content);
|
||||||
|
return title.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
function MainApp() {
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
const [api, setApi] = useState<NextcloudAPI | null>(null);
|
const [api, setApi] = useState<NextcloudAPI | null>(null);
|
||||||
const [notes, setNotes] = useState<Note[]>([]);
|
const [notes, setNotes] = useState<Note[]>([]);
|
||||||
const [selectedNoteId, setSelectedNoteId] = useState<number | null>(null);
|
const [selectedNoteDraftId, setSelectedNoteDraftId] = useState<string | null>(null);
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
|
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
|
||||||
const [selectedCategory, setSelectedCategory] = useState('');
|
const [selectedCategory, setSelectedCategory] = useState('');
|
||||||
@@ -20,13 +101,101 @@ function App() {
|
|||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('system');
|
const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('system');
|
||||||
const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light');
|
const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light');
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
||||||
const [editorFont, setEditorFont] = useState('Source Code Pro');
|
const [editorFont, setEditorFont] = useState('Source Code Pro');
|
||||||
const [editorFontSize, setEditorFontSize] = useState(14);
|
const [editorFontSize, setEditorFontSize] = useState(14);
|
||||||
const [previewFont, setPreviewFont] = useState('Merriweather');
|
const [previewFont, setPreviewFont] = useState('Merriweather');
|
||||||
const [previewFontSize, setPreviewFontSize] = useState(16);
|
const [previewFontSize, setPreviewFontSize] = useState(16);
|
||||||
|
const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle');
|
||||||
|
const [pendingSyncCount, setPendingSyncCount] = useState(0);
|
||||||
|
const isOnline = useOnlineStatus();
|
||||||
|
const notesRef = useRef<Note[]>([]);
|
||||||
|
const saveControllersRef = useRef<Map<string, SaveController>>(new Map());
|
||||||
|
const savedSnapshotsRef = useRef<Map<string, Note>>(new Map());
|
||||||
|
|
||||||
|
const setSortedNotes = (updater: Note[] | ((previous: Note[]) => Note[])) => {
|
||||||
|
setNotes((previous) => {
|
||||||
|
const nextNotes = typeof updater === 'function'
|
||||||
|
? (updater as (previous: Note[]) => Note[])(previous)
|
||||||
|
: updater;
|
||||||
|
const sortedNotes = sortNotes(nextNotes);
|
||||||
|
notesRef.current = sortedNotes;
|
||||||
|
return sortedNotes;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNoteByDraftId = (draftId: string | null) =>
|
||||||
|
draftId ? notesRef.current.find(note => note.draftId === draftId) ?? null : null;
|
||||||
|
|
||||||
|
const persistNoteToCache = (note: Note) => {
|
||||||
|
void localDB.saveNote(toStoredNote(note)).catch((error) => {
|
||||||
|
console.error('Failed to persist note locally:', error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureSaveController = (draftId: string) => {
|
||||||
|
let controller = saveControllersRef.current.get(draftId);
|
||||||
|
if (!controller) {
|
||||||
|
controller = {
|
||||||
|
timerId: null,
|
||||||
|
revision: 0,
|
||||||
|
inFlight: null,
|
||||||
|
inFlightRevision: 0,
|
||||||
|
};
|
||||||
|
saveControllersRef.current.set(draftId, controller);
|
||||||
|
}
|
||||||
|
|
||||||
|
return controller;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSaveTimer = (draftId: string) => {
|
||||||
|
const controller = saveControllersRef.current.get(draftId);
|
||||||
|
if (controller?.timerId) {
|
||||||
|
window.clearTimeout(controller.timerId);
|
||||||
|
controller.timerId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyLoadedNotes = (loadedNotes: Note[]) => {
|
||||||
|
const normalizedNotes = sortNotes(loadedNotes);
|
||||||
|
const incomingDraftIds = new Set<string>();
|
||||||
|
|
||||||
|
normalizedNotes.forEach((note) => {
|
||||||
|
if (!note.draftId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
incomingDraftIds.add(note.draftId);
|
||||||
|
if (!note.pendingSave) {
|
||||||
|
savedSnapshotsRef.current.set(note.draftId, {
|
||||||
|
...note,
|
||||||
|
pendingSave: false,
|
||||||
|
isSaving: false,
|
||||||
|
saveError: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const draftId of Array.from(savedSnapshotsRef.current.keys())) {
|
||||||
|
if (!incomingDraftIds.has(draftId)) {
|
||||||
|
savedSnapshotsRef.current.delete(draftId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notesRef.current = normalizedNotes;
|
||||||
|
setNotes(normalizedNotes);
|
||||||
|
setSelectedNoteDraftId((current) => {
|
||||||
|
if (current && normalizedNotes.some(note => note.draftId === current)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getNoteDraftId(normalizedNotes[0]);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const initApp = async () => {
|
||||||
|
await localDB.init();
|
||||||
|
|
||||||
const savedServer = localStorage.getItem('serverURL');
|
const savedServer = localStorage.getItem('serverURL');
|
||||||
const savedUsername = localStorage.getItem('username');
|
const savedUsername = localStorage.getItem('username');
|
||||||
const savedPassword = localStorage.getItem('password');
|
const savedPassword = localStorage.getItem('password');
|
||||||
@@ -59,9 +228,14 @@ function App() {
|
|||||||
password: savedPassword,
|
password: savedPassword,
|
||||||
});
|
});
|
||||||
setApi(apiInstance);
|
setApi(apiInstance);
|
||||||
|
syncManager.setAPI(apiInstance);
|
||||||
|
categoryColorsSync.setAPI(apiInstance);
|
||||||
setUsername(savedUsername);
|
setUsername(savedUsername);
|
||||||
setIsLoggedIn(true);
|
setIsLoggedIn(true);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initApp();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -88,47 +262,108 @@ function App() {
|
|||||||
document.documentElement.classList.toggle('dark', effectiveTheme === 'dark');
|
document.documentElement.classList.toggle('dark', effectiveTheme === 'dark');
|
||||||
}, [effectiveTheme]);
|
}, [effectiveTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
syncManager.setStatusCallback((status, count) => {
|
||||||
|
setSyncStatus(status);
|
||||||
|
setPendingSyncCount(count);
|
||||||
|
});
|
||||||
|
|
||||||
|
syncManager.setSyncCompleteCallback(async () => {
|
||||||
|
// Reload notes from cache after background sync completes
|
||||||
|
// Don't call loadNotes() as it triggers another sync - just reload from cache
|
||||||
|
const cachedNotes = await localDB.getAllNotes();
|
||||||
|
applyLoadedNotes(cachedNotes);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (api && isLoggedIn) {
|
if (api && isLoggedIn) {
|
||||||
syncNotes();
|
loadNotes();
|
||||||
const interval = setInterval(syncNotes, 300000);
|
const interval = setInterval(() => syncNotes(), 300000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}
|
}
|
||||||
}, [api, isLoggedIn]);
|
}, [api, isLoggedIn]);
|
||||||
|
|
||||||
const syncNotes = async () => {
|
useEffect(() => {
|
||||||
if (!api) return;
|
if (!isLoggedIn || !isOnline) {
|
||||||
try {
|
return;
|
||||||
const fetched = await api.fetchNotes();
|
|
||||||
setNotes(fetched.sort((a, b) => b.modified - a.modified));
|
|
||||||
if (!selectedNoteId && fetched.length > 0) {
|
|
||||||
setSelectedNoteId(fetched[0].id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notesRef.current
|
||||||
|
.filter(note => note.pendingSave && note.draftId)
|
||||||
|
.forEach((note) => {
|
||||||
|
const draftId = note.draftId as string;
|
||||||
|
const controller = ensureSaveController(draftId);
|
||||||
|
if (!controller.inFlight && !controller.timerId) {
|
||||||
|
controller.timerId = window.setTimeout(() => {
|
||||||
|
controller.timerId = null;
|
||||||
|
void flushNoteSave(draftId);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [isLoggedIn, isOnline]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeUnload = () => {
|
||||||
|
notesRef.current
|
||||||
|
.filter(note => note.pendingSave && note.draftId)
|
||||||
|
.forEach((note) => {
|
||||||
|
clearSaveTimer(note.draftId!);
|
||||||
|
void flushNoteSave(note.draftId!, { force: true });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadNotes = async () => {
|
||||||
|
try {
|
||||||
|
const loadedNotes = await syncManager.loadNotes();
|
||||||
|
applyLoadedNotes(loadedNotes);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load notes:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncNotes = async () => {
|
||||||
|
try {
|
||||||
|
await flushAllPendingSaves();
|
||||||
|
await syncManager.syncWithServer();
|
||||||
|
await loadNotes();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Sync failed:', error);
|
console.error('Sync failed:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogin = (serverURL: string, username: string, password: string) => {
|
const handleLogin = async (serverURL: string, username: string, password: string) => {
|
||||||
localStorage.setItem('serverURL', serverURL);
|
localStorage.setItem('serverURL', serverURL);
|
||||||
localStorage.setItem('username', username);
|
localStorage.setItem('username', username);
|
||||||
localStorage.setItem('password', password);
|
localStorage.setItem('password', password);
|
||||||
|
|
||||||
const apiInstance = new NextcloudAPI({ serverURL, username, password });
|
const apiInstance = new NextcloudAPI({ serverURL, username, password });
|
||||||
setApi(apiInstance);
|
setApi(apiInstance);
|
||||||
|
syncManager.setAPI(apiInstance);
|
||||||
|
categoryColorsSync.setAPI(apiInstance);
|
||||||
setUsername(username);
|
setUsername(username);
|
||||||
setIsLoggedIn(true);
|
setIsLoggedIn(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = async () => {
|
||||||
localStorage.removeItem('serverURL');
|
localStorage.removeItem('serverURL');
|
||||||
localStorage.removeItem('username');
|
localStorage.removeItem('username');
|
||||||
localStorage.removeItem('password');
|
localStorage.removeItem('password');
|
||||||
|
await localDB.clearNotes();
|
||||||
setApi(null);
|
setApi(null);
|
||||||
|
syncManager.setAPI(null);
|
||||||
|
categoryColorsSync.setAPI(null);
|
||||||
setUsername('');
|
setUsername('');
|
||||||
setNotes([]);
|
setNotes([]);
|
||||||
setSelectedNoteId(null);
|
notesRef.current = [];
|
||||||
|
setSelectedNoteDraftId(null);
|
||||||
setIsLoggedIn(false);
|
setIsLoggedIn(false);
|
||||||
|
saveControllersRef.current.clear();
|
||||||
|
savedSnapshotsRef.current.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleThemeChange = (newTheme: 'light' | 'dark' | 'system') => {
|
const handleThemeChange = (newTheme: 'light' | 'dark' | 'system') => {
|
||||||
@@ -156,24 +391,67 @@ function App() {
|
|||||||
localStorage.setItem('previewFontSize', size.toString());
|
localStorage.setItem('previewFontSize', size.toString());
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateNote = async () => {
|
const handleToggleFavorite = async (note: Note, favorite: boolean) => {
|
||||||
if (!api) return;
|
const draftId = note.draftId;
|
||||||
try {
|
if (!draftId) {
|
||||||
const timestamp = new Date().toLocaleString('en-US', {
|
return;
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false,
|
|
||||||
}).replace(/[/:]/g, '-').replace(', ', ' ');
|
|
||||||
|
|
||||||
const note = await api.createNote(`New Note ${timestamp}`, '', selectedCategory);
|
|
||||||
setNotes([note, ...notes]);
|
|
||||||
setSelectedNoteId(note.id);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Create note failed:', error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const optimisticNote = {
|
||||||
|
...note,
|
||||||
|
favorite,
|
||||||
|
saveError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
setSortedNotes(previousNotes =>
|
||||||
|
previousNotes.map(currentNote =>
|
||||||
|
currentNote.draftId === draftId ? optimisticNote : currentNote
|
||||||
|
)
|
||||||
|
);
|
||||||
|
persistNoteToCache(optimisticNote);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await syncManager.updateFavoriteStatus(optimisticNote, favorite);
|
||||||
|
const snapshot = savedSnapshotsRef.current.get(draftId);
|
||||||
|
if (snapshot) {
|
||||||
|
savedSnapshotsRef.current.set(draftId, {
|
||||||
|
...snapshot,
|
||||||
|
favorite,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Toggle favorite failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateNote = async () => {
|
||||||
|
const draftId = createDraftId();
|
||||||
|
const note: Note = {
|
||||||
|
id: `local:${draftId}`,
|
||||||
|
etag: '',
|
||||||
|
readonly: false,
|
||||||
|
content: '',
|
||||||
|
title: 'Untitled',
|
||||||
|
category: selectedCategory,
|
||||||
|
favorite: false,
|
||||||
|
modified: Math.floor(Date.now() / 1000),
|
||||||
|
draftId,
|
||||||
|
localOnly: true,
|
||||||
|
pendingSave: false,
|
||||||
|
isSaving: false,
|
||||||
|
saveError: null,
|
||||||
|
lastSavedAt: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
savedSnapshotsRef.current.set(draftId, {
|
||||||
|
...note,
|
||||||
|
pendingSave: false,
|
||||||
|
isSaving: false,
|
||||||
|
saveError: null,
|
||||||
|
});
|
||||||
|
setSortedNotes(previousNotes => [note, ...previousNotes]);
|
||||||
|
persistNoteToCache(note);
|
||||||
|
setSelectedNoteDraftId(draftId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateCategory = (name: string) => {
|
const handleCreateCategory = (name: string) => {
|
||||||
@@ -182,29 +460,316 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateNote = async (updatedNote: Note) => {
|
const handleRenameCategory = async (oldName: string, newName: string) => {
|
||||||
if (!api) return;
|
// Move all notes from old category to new category
|
||||||
|
const notesToMove = notes.filter(note => note.category === oldName);
|
||||||
|
|
||||||
|
for (const note of notesToMove) {
|
||||||
try {
|
try {
|
||||||
console.log('Sending to API - content length:', updatedNote.content.length);
|
const movedNote = await syncManager.moveNote(note, newName);
|
||||||
console.log('Sending to API - last 50 chars:', updatedNote.content.slice(-50));
|
if (movedNote.draftId) {
|
||||||
const result = await api.updateNote(updatedNote);
|
savedSnapshotsRef.current.set(movedNote.draftId, {
|
||||||
console.log('Received from API - content length:', result.content.length);
|
...movedNote,
|
||||||
console.log('Received from API - last 50 chars:', result.content.slice(-50));
|
pendingSave: false,
|
||||||
// Update notes array with server response now that we have manual save
|
isSaving: false,
|
||||||
setNotes(notes.map(n => n.id === result.id ? result : n));
|
saveError: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setSortedNotes(previousNotes =>
|
||||||
|
previousNotes.map(currentNote =>
|
||||||
|
currentNote.draftId === note.draftId ? movedNote : currentNote
|
||||||
|
)
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update note failed:', error);
|
console.error(`Failed to move note ${note.id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update manual categories list
|
||||||
|
setManualCategories(prev =>
|
||||||
|
prev.map(cat => cat === oldName ? newName : cat)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update selected category if it was the renamed one
|
||||||
|
if (selectedCategory === oldName) {
|
||||||
|
setSelectedCategory(newName);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const persistNoteToServer = async (note: Note) => {
|
||||||
|
if (note.localOnly) {
|
||||||
|
const { title, body } = splitNoteContent(note.content);
|
||||||
|
const createdNote = await syncManager.createNote(title, body, note.category);
|
||||||
|
return {
|
||||||
|
...createdNote,
|
||||||
|
content: note.content,
|
||||||
|
title,
|
||||||
|
favorite: note.favorite,
|
||||||
|
draftId: note.draftId,
|
||||||
|
localOnly: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteCategory = getRemoteCategory(note);
|
||||||
|
|
||||||
|
if (remoteCategory !== note.category) {
|
||||||
|
const movedNote = await syncManager.moveNote(note, note.category);
|
||||||
|
return syncManager.updateNote({
|
||||||
|
...movedNote,
|
||||||
|
draftId: note.draftId,
|
||||||
|
title: note.title,
|
||||||
|
content: note.content,
|
||||||
|
favorite: note.favorite,
|
||||||
|
localOnly: false,
|
||||||
|
pendingSave: false,
|
||||||
|
isSaving: false,
|
||||||
|
saveError: null,
|
||||||
|
lastSavedAt: note.lastSavedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return syncManager.updateNote(note);
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushNoteSave = async (draftId: string, options: FlushSaveOptions = {}): Promise<void> => {
|
||||||
|
const controller = ensureSaveController(draftId);
|
||||||
|
clearSaveTimer(draftId);
|
||||||
|
|
||||||
|
const currentNote = getNoteByDraftId(draftId);
|
||||||
|
if (!currentNote?.pendingSave) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canPersist = options.force ? canForceSaveLocalNote(currentNote) : canAutosaveLocalNote(currentNote);
|
||||||
|
if (!canPersist) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controller.inFlight) {
|
||||||
|
await controller.inFlight;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.inFlightRevision = controller.revision;
|
||||||
|
|
||||||
|
setSortedNotes(previousNotes =>
|
||||||
|
previousNotes.map(note =>
|
||||||
|
note.draftId === draftId
|
||||||
|
? {
|
||||||
|
...note,
|
||||||
|
isSaving: true,
|
||||||
|
saveError: null,
|
||||||
|
}
|
||||||
|
: note
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const savePromise = (async () => {
|
||||||
|
try {
|
||||||
|
const noteToPersist = getNoteByDraftId(draftId);
|
||||||
|
if (!noteToPersist?.pendingSave) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canPersistLatest = options.force ? canForceSaveLocalNote(noteToPersist) : canAutosaveLocalNote(noteToPersist);
|
||||||
|
if (!canPersistLatest) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedNote = {
|
||||||
|
...(await persistNoteToServer(noteToPersist)),
|
||||||
|
draftId,
|
||||||
|
localOnly: false,
|
||||||
|
pendingSave: false,
|
||||||
|
isSaving: false,
|
||||||
|
saveError: null,
|
||||||
|
lastSavedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (noteToPersist.id !== savedNote.id) {
|
||||||
|
void localDB.deleteNote(noteToPersist.id).catch((error) => {
|
||||||
|
console.error('Failed to remove stale local note cache entry:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
savedSnapshotsRef.current.set(draftId, {
|
||||||
|
...savedNote,
|
||||||
|
pendingSave: false,
|
||||||
|
isSaving: false,
|
||||||
|
saveError: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const latestNote = getNoteByDraftId(draftId);
|
||||||
|
const hasNewerChanges = controller.revision > controller.inFlightRevision && latestNote;
|
||||||
|
|
||||||
|
if (latestNote && hasNewerChanges) {
|
||||||
|
const mergedPendingNote: Note = {
|
||||||
|
...savedNote,
|
||||||
|
content: latestNote.content,
|
||||||
|
title: latestNote.title,
|
||||||
|
category: latestNote.category,
|
||||||
|
favorite: latestNote.favorite,
|
||||||
|
modified: latestNote.modified,
|
||||||
|
localOnly: false,
|
||||||
|
pendingSave: true,
|
||||||
|
isSaving: false,
|
||||||
|
saveError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
setSortedNotes(previousNotes =>
|
||||||
|
previousNotes.map(note =>
|
||||||
|
note.draftId === draftId ? mergedPendingNote : note
|
||||||
|
)
|
||||||
|
);
|
||||||
|
persistNoteToCache(mergedPendingNote);
|
||||||
|
scheduleNoteSave(draftId, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSortedNotes(previousNotes =>
|
||||||
|
previousNotes.map(note =>
|
||||||
|
note.draftId === draftId ? savedNote : note
|
||||||
|
)
|
||||||
|
);
|
||||||
|
persistNoteToCache(savedNote);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update note failed:', error);
|
||||||
|
|
||||||
|
const failedNote = getNoteByDraftId(draftId);
|
||||||
|
if (!failedNote) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const erroredNote = {
|
||||||
|
...failedNote,
|
||||||
|
pendingSave: true,
|
||||||
|
isSaving: false,
|
||||||
|
saveError: error instanceof Error ? error.message : 'Failed to save note.',
|
||||||
|
};
|
||||||
|
|
||||||
|
setSortedNotes(previousNotes =>
|
||||||
|
previousNotes.map(note =>
|
||||||
|
note.draftId === draftId ? erroredNote : note
|
||||||
|
)
|
||||||
|
);
|
||||||
|
persistNoteToCache(erroredNote);
|
||||||
|
} finally {
|
||||||
|
controller.inFlight = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
controller.inFlight = savePromise;
|
||||||
|
await savePromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleNoteSave = (draftId: string, delayMs = AUTOSAVE_DELAY_MS) => {
|
||||||
|
const controller = ensureSaveController(draftId);
|
||||||
|
clearSaveTimer(draftId);
|
||||||
|
|
||||||
|
controller.timerId = window.setTimeout(() => {
|
||||||
|
controller.timerId = null;
|
||||||
|
void flushNoteSave(draftId);
|
||||||
|
}, delayMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushAllPendingSaves = async () => {
|
||||||
|
const pendingDraftIds = notesRef.current
|
||||||
|
.filter(note => note.pendingSave && note.draftId)
|
||||||
|
.map(note => note.draftId as string);
|
||||||
|
|
||||||
|
await Promise.all(pendingDraftIds.map(draftId => flushNoteSave(draftId, { force: true })));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDraftChange = (updatedNote: Note) => {
|
||||||
|
const draftId = updatedNote.draftId;
|
||||||
|
if (!draftId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localNote = {
|
||||||
|
...updatedNote,
|
||||||
|
modified: Math.floor(Date.now() / 1000),
|
||||||
|
pendingSave: true,
|
||||||
|
isSaving: false,
|
||||||
|
saveError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const controller = ensureSaveController(draftId);
|
||||||
|
controller.revision += 1;
|
||||||
|
|
||||||
|
setSortedNotes(previousNotes =>
|
||||||
|
previousNotes.map(note =>
|
||||||
|
note.draftId === draftId ? localNote : note
|
||||||
|
)
|
||||||
|
);
|
||||||
|
persistNoteToCache(localNote);
|
||||||
|
scheduleNoteSave(draftId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManualSave = async (draftId: string) => {
|
||||||
|
await flushNoteSave(draftId, { force: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDiscardNote = (draftId: string) => {
|
||||||
|
const snapshot = savedSnapshotsRef.current.get(draftId);
|
||||||
|
if (!snapshot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSaveTimer(draftId);
|
||||||
|
const controller = ensureSaveController(draftId);
|
||||||
|
controller.revision += 1;
|
||||||
|
|
||||||
|
const cleanSnapshot = {
|
||||||
|
...snapshot,
|
||||||
|
pendingSave: false,
|
||||||
|
isSaving: false,
|
||||||
|
saveError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
setSortedNotes(previousNotes =>
|
||||||
|
previousNotes.map(note =>
|
||||||
|
note.draftId === draftId ? cleanSnapshot : note
|
||||||
|
)
|
||||||
|
);
|
||||||
|
persistNoteToCache(cleanSnapshot);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectNote = async (draftId: string) => {
|
||||||
|
if (draftId === selectedNoteDraftId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedNoteDraftId) {
|
||||||
|
await flushNoteSave(selectedNoteDraftId, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedNoteDraftId(draftId);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteNote = async (note: Note) => {
|
const handleDeleteNote = async (note: Note) => {
|
||||||
if (!api) return;
|
const draftId = note.draftId;
|
||||||
|
if (!draftId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.deleteNote(note.id);
|
clearSaveTimer(draftId);
|
||||||
setNotes(notes.filter(n => n.id !== note.id));
|
if (!note.localOnly) {
|
||||||
if (selectedNoteId === note.id) {
|
await syncManager.deleteNote(note);
|
||||||
setSelectedNoteId(notes[0]?.id || null);
|
} else {
|
||||||
|
await localDB.deleteNote(note.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveControllersRef.current.delete(draftId);
|
||||||
|
savedSnapshotsRef.current.delete(draftId);
|
||||||
|
|
||||||
|
const remainingNotes = notesRef.current.filter(currentNote => currentNote.draftId !== draftId);
|
||||||
|
notesRef.current = sortNotes(remainingNotes);
|
||||||
|
setNotes(notesRef.current);
|
||||||
|
|
||||||
|
if (selectedNoteDraftId === draftId) {
|
||||||
|
setSelectedNoteDraftId(getNoteDraftId(notesRef.current[0]));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete note failed:', error);
|
console.error('Delete note failed:', error);
|
||||||
@@ -223,9 +788,16 @@ function App() {
|
|||||||
note.content.toLowerCase().includes(search);
|
note.content.toLowerCase().includes(search);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
}).sort((a, b) => {
|
||||||
|
// Sort favorites first, then by modified date (newest first)
|
||||||
|
if (a.favorite !== b.favorite) {
|
||||||
|
return a.favorite ? -1 : 1;
|
||||||
|
}
|
||||||
|
return b.modified - a.modified;
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedNote = notes.find(n => n.id === selectedNoteId) || null;
|
const selectedNote = notes.find(n => n.draftId === selectedNoteDraftId) || null;
|
||||||
|
const hasUnsavedChanges = Boolean(selectedNote?.pendingSave);
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
return <LoginView onLogin={handleLogin} />;
|
return <LoginView onLogin={handleLogin} />;
|
||||||
@@ -240,6 +812,7 @@ function App() {
|
|||||||
selectedCategory={selectedCategory}
|
selectedCategory={selectedCategory}
|
||||||
onSelectCategory={setSelectedCategory}
|
onSelectCategory={setSelectedCategory}
|
||||||
onCreateCategory={handleCreateCategory}
|
onCreateCategory={handleCreateCategory}
|
||||||
|
onRenameCategory={handleRenameCategory}
|
||||||
isCollapsed={isCategoriesCollapsed}
|
isCollapsed={isCategoriesCollapsed}
|
||||||
onToggleCollapse={() => setIsCategoriesCollapsed(!isCategoriesCollapsed)}
|
onToggleCollapse={() => setIsCategoriesCollapsed(!isCategoriesCollapsed)}
|
||||||
username={username}
|
username={username}
|
||||||
@@ -257,8 +830,8 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
<NotesList
|
<NotesList
|
||||||
notes={filteredNotes}
|
notes={filteredNotes}
|
||||||
selectedNoteId={selectedNoteId}
|
selectedNoteDraftId={selectedNoteDraftId}
|
||||||
onSelectNote={setSelectedNoteId}
|
onSelectNote={handleSelectNote}
|
||||||
onCreateNote={handleCreateNote}
|
onCreateNote={handleCreateNote}
|
||||||
onDeleteNote={handleDeleteNote}
|
onDeleteNote={handleDeleteNote}
|
||||||
onSync={syncNotes}
|
onSync={syncNotes}
|
||||||
@@ -267,13 +840,18 @@ function App() {
|
|||||||
showFavoritesOnly={showFavoritesOnly}
|
showFavoritesOnly={showFavoritesOnly}
|
||||||
onToggleFavorites={() => setShowFavoritesOnly(!showFavoritesOnly)}
|
onToggleFavorites={() => setShowFavoritesOnly(!showFavoritesOnly)}
|
||||||
hasUnsavedChanges={hasUnsavedChanges}
|
hasUnsavedChanges={hasUnsavedChanges}
|
||||||
|
syncStatus={syncStatus}
|
||||||
|
pendingSyncCount={pendingSyncCount}
|
||||||
|
isOnline={isOnline}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<NoteEditor
|
<NoteEditor
|
||||||
note={selectedNote}
|
note={selectedNote}
|
||||||
onUpdateNote={handleUpdateNote}
|
onChangeNote={handleDraftChange}
|
||||||
onUnsavedChanges={setHasUnsavedChanges}
|
onSaveNote={handleManualSave}
|
||||||
|
onDiscardNote={handleDiscardNote}
|
||||||
|
onToggleFavorite={handleToggleFavorite}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
isFocusMode={isFocusMode}
|
isFocusMode={isFocusMode}
|
||||||
onToggleFocusMode={() => setIsFocusMode(!isFocusMode)}
|
onToggleFocusMode={() => setIsFocusMode(!isFocusMode)}
|
||||||
@@ -287,4 +865,19 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const printJobId = params.get(PRINT_EXPORT_QUERY_PARAM);
|
||||||
|
|
||||||
|
if (printJobId) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="min-h-screen bg-gray-100 px-8 py-12 text-gray-900">Preparing print view...</div>}>
|
||||||
|
<LazyPrintView jobId={printJobId} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MainApp />;
|
||||||
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
|
|
||||||
import { Note, APIConfig } from '../types';
|
import { Note, APIConfig } from '../types';
|
||||||
|
import { runtimeFetch } from '../services/runtimeFetch';
|
||||||
|
|
||||||
|
type HttpStatusError = Error & { status?: number };
|
||||||
|
|
||||||
|
const createHttpStatusError = (message: string, status: number): HttpStatusError => {
|
||||||
|
const error = new Error(message) as HttpStatusError;
|
||||||
|
error.status = status;
|
||||||
|
return error;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getHttpStatus = (error: unknown): number | null => {
|
||||||
|
if (typeof error !== 'object' || error === null || !('status' in error)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = (error as { status?: unknown }).status;
|
||||||
|
return typeof status === 'number' ? status : null;
|
||||||
|
};
|
||||||
|
|
||||||
export class NextcloudAPI {
|
export class NextcloudAPI {
|
||||||
private baseURL: string;
|
private baseURL: string;
|
||||||
@@ -16,7 +33,7 @@ export class NextcloudAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
const response = await fetch(`${this.baseURL}${path}`, {
|
const response = await runtimeFetch(`${this.baseURL}${path}`, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -61,7 +78,52 @@ export class NextcloudAPI {
|
|||||||
await this.request<void>(`/notes/${id}`, { method: 'DELETE' });
|
await this.request<void>(`/notes/${id}`, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchAttachment(_noteId: number, path: string, noteCategory?: string): Promise<string> {
|
// Fetch lightweight note list with IDs and favorites for hybrid sync
|
||||||
|
async fetchNotesMetadata(): Promise<Array<{id: number, title: string, category: string, favorite: boolean, modified: number}>> {
|
||||||
|
const notes = await this.request<Note[]>('/notes');
|
||||||
|
return notes.map(note => ({
|
||||||
|
id: note.id as number,
|
||||||
|
title: note.title,
|
||||||
|
category: note.category,
|
||||||
|
favorite: note.favorite,
|
||||||
|
modified: note.modified,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update only favorite status via API
|
||||||
|
async updateFavoriteStatus(noteId: number, favorite: boolean): Promise<void> {
|
||||||
|
await this.request<Note>(`/notes/${noteId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ favorite }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map WebDAV note to API ID by matching modified timestamp and category
|
||||||
|
// We can't use title because API title and WebDAV first-line title can differ
|
||||||
|
async findApiIdForNote(title: string, category: string, modified: number): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
const metadata = await this.fetchNotesMetadata();
|
||||||
|
|
||||||
|
// First try exact title + category match
|
||||||
|
let match = metadata.find(note =>
|
||||||
|
note.title === title && note.category === category
|
||||||
|
);
|
||||||
|
|
||||||
|
// If no title match, try modified timestamp + category (more reliable)
|
||||||
|
if (!match) {
|
||||||
|
match = metadata.find(note =>
|
||||||
|
note.modified === modified && note.category === category
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ? match.id : null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to find API ID for note:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchAttachment(_noteId: number | string, path: string, noteCategory?: string): Promise<string> {
|
||||||
// Build WebDAV path: /remote.php/dav/files/{username}/Notes/{category}/.attachments.{noteId}/{filename}
|
// Build WebDAV path: /remote.php/dav/files/{username}/Notes/{category}/.attachments.{noteId}/{filename}
|
||||||
// The path from markdown is like: .attachments.38479/Screenshot.png
|
// The path from markdown is like: .attachments.38479/Screenshot.png
|
||||||
// We need to construct the full WebDAV URL
|
// We need to construct the full WebDAV URL
|
||||||
@@ -77,9 +139,9 @@ export class NextcloudAPI {
|
|||||||
webdavPath += `/${path}`;
|
webdavPath += `/${path}`;
|
||||||
|
|
||||||
const url = `${this.serverURL}${webdavPath}`;
|
const url = `${this.serverURL}${webdavPath}`;
|
||||||
console.log('Fetching attachment via WebDAV:', url);
|
console.log(`[Note ${_noteId}] Fetching attachment via WebDAV:`, url);
|
||||||
|
|
||||||
const response = await tauriFetch(url, {
|
const response = await runtimeFetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': this.authHeader,
|
'Authorization': this.authHeader,
|
||||||
},
|
},
|
||||||
@@ -102,7 +164,7 @@ export class NextcloudAPI {
|
|||||||
return this.serverURL;
|
return this.serverURL;
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadAttachment(noteId: number, file: File, noteCategory?: string): Promise<string> {
|
async uploadAttachment(noteId: number | string, file: File, noteCategory?: string): Promise<string> {
|
||||||
// Create .attachments.{noteId} directory path and upload file via WebDAV PUT
|
// Create .attachments.{noteId} directory path and upload file via WebDAV PUT
|
||||||
// Returns the relative path to insert into markdown
|
// Returns the relative path to insert into markdown
|
||||||
|
|
||||||
@@ -112,7 +174,14 @@ export class NextcloudAPI {
|
|||||||
webdavPath += `/${noteCategory}`;
|
webdavPath += `/${noteCategory}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachmentDir = `.attachments.${noteId}`;
|
// Sanitize note ID: extract just the filename without extension and remove invalid chars
|
||||||
|
// noteId might be "category/filename.md" or just "filename.md"
|
||||||
|
const noteIdStr = String(noteId);
|
||||||
|
const justFilename = noteIdStr.split('/').pop() || noteIdStr;
|
||||||
|
const filenameWithoutExt = justFilename.replace(/\.(md|txt)$/, '');
|
||||||
|
const sanitizedNoteId = filenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
|
|
||||||
|
const attachmentDir = `.attachments.${sanitizedNoteId}`;
|
||||||
const fileName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); // Sanitize filename
|
const fileName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); // Sanitize filename
|
||||||
const fullPath = `${webdavPath}/${attachmentDir}/${fileName}`;
|
const fullPath = `${webdavPath}/${attachmentDir}/${fileName}`;
|
||||||
|
|
||||||
@@ -122,7 +191,7 @@ export class NextcloudAPI {
|
|||||||
// First, try to create the attachments directory (MKCOL)
|
// First, try to create the attachments directory (MKCOL)
|
||||||
// This may fail if it already exists, which is fine
|
// This may fail if it already exists, which is fine
|
||||||
try {
|
try {
|
||||||
await tauriFetch(`${this.serverURL}${webdavPath}/${attachmentDir}`, {
|
await runtimeFetch(`${this.serverURL}${webdavPath}/${attachmentDir}`, {
|
||||||
method: 'MKCOL',
|
method: 'MKCOL',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': this.authHeader,
|
'Authorization': this.authHeader,
|
||||||
@@ -136,7 +205,7 @@ export class NextcloudAPI {
|
|||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
|
||||||
// Upload the file via PUT
|
// Upload the file via PUT
|
||||||
const response = await tauriFetch(url, {
|
const response = await runtimeFetch(url, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': this.authHeader,
|
'Authorization': this.authHeader,
|
||||||
@@ -152,4 +221,532 @@ export class NextcloudAPI {
|
|||||||
// Return the relative path for markdown
|
// Return the relative path for markdown
|
||||||
return `${attachmentDir}/${fileName}`;
|
return `${attachmentDir}/${fileName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchCategoryColors(): Promise<Record<string, number>> {
|
||||||
|
const webdavPath = `/remote.php/dav/files/${this.username}/Notes/.category-colors.json`;
|
||||||
|
const url = `${this.serverURL}${webdavPath}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await runtimeFetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': this.authHeader,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
// File doesn't exist yet, return empty object
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to fetch category colors: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not fetch category colors, using empty:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveCategoryColors(colors: Record<string, number>): Promise<void> {
|
||||||
|
const webdavPath = `/remote.php/dav/files/${this.username}/Notes/.category-colors.json`;
|
||||||
|
const url = `${this.serverURL}${webdavPath}`;
|
||||||
|
|
||||||
|
const content = JSON.stringify(colors, null, 2);
|
||||||
|
|
||||||
|
const response = await runtimeFetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Authorization': this.authHeader,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: content,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok && response.status !== 201 && response.status !== 204) {
|
||||||
|
throw new Error(`Failed to save category colors: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebDAV-based note operations
|
||||||
|
private parseNoteFromContent(content: string, filename: string, category: string, etag: string, modified: number): Note {
|
||||||
|
// Extract title from first line
|
||||||
|
const firstLine = content.split('\n')[0].replace(/^#+\s*/, '').trim();
|
||||||
|
const title = firstLine || filename.replace(/\.(md|txt)$/, '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: category ? `${category}/${filename}` : filename,
|
||||||
|
filename,
|
||||||
|
path: category ? `${category}/${filename}` : filename,
|
||||||
|
etag,
|
||||||
|
readonly: false,
|
||||||
|
content, // Store full content including first line
|
||||||
|
title,
|
||||||
|
category,
|
||||||
|
favorite: false,
|
||||||
|
modified,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatNoteContent(note: Note): string {
|
||||||
|
// Content already includes the title as first line
|
||||||
|
return note.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildNoteWebDAVPath(category: string, filename: string): string {
|
||||||
|
const categoryPath = category ? `/${category}` : '';
|
||||||
|
return `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(filename)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async delay(ms: number): Promise<void> {
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchNoteMetadataWebDAV(category: string, filename: string): Promise<{ etag: string; modified: number }> {
|
||||||
|
const webdavPath = this.buildNoteWebDAVPath(category, filename);
|
||||||
|
const response = await runtimeFetch(`${this.serverURL}${webdavPath}`, {
|
||||||
|
method: 'PROPFIND',
|
||||||
|
headers: {
|
||||||
|
'Authorization': this.authHeader,
|
||||||
|
'Depth': '0',
|
||||||
|
'Content-Type': 'application/xml',
|
||||||
|
},
|
||||||
|
body: `<?xml version="1.0"?>
|
||||||
|
<d:propfind xmlns:d="DAV:">
|
||||||
|
<d:prop>
|
||||||
|
<d:getlastmodified/>
|
||||||
|
<d:getetag/>
|
||||||
|
</d:prop>
|
||||||
|
</d:propfind>`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw createHttpStatusError(`Failed to fetch note metadata: ${response.status}`, response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const xmlText = await response.text();
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const xmlDoc = parser.parseFromString(xmlText, 'text/xml');
|
||||||
|
const responseNode = xmlDoc.getElementsByTagNameNS('DAV:', 'response')[0];
|
||||||
|
const propstat = responseNode?.getElementsByTagNameNS('DAV:', 'propstat')[0];
|
||||||
|
const prop = propstat?.getElementsByTagNameNS('DAV:', 'prop')[0];
|
||||||
|
const etag = prop?.getElementsByTagNameNS('DAV:', 'getetag')[0]?.textContent || '';
|
||||||
|
const lastModified = prop?.getElementsByTagNameNS('DAV:', 'getlastmodified')[0]?.textContent || '';
|
||||||
|
const modified = lastModified ? Math.floor(new Date(lastModified).getTime() / 1000) : Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
return { etag, modified };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryFetchNoteMetadataWebDAV(category: string, filename: string): Promise<{ etag: string; modified: number } | null> {
|
||||||
|
try {
|
||||||
|
return await this.fetchNoteMetadataWebDAV(category, filename);
|
||||||
|
} catch (error) {
|
||||||
|
const status = getHttpStatus(error);
|
||||||
|
if (status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshNoteWebDAVMetadata(note: Note): Promise<Note> {
|
||||||
|
const metadata = await this.fetchNoteMetadataWebDAV(note.category, note.filename!);
|
||||||
|
return {
|
||||||
|
...note,
|
||||||
|
etag: metadata.etag || note.etag,
|
||||||
|
modified: metadata.modified || note.modified,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchNotesWebDAV(): Promise<Note[]> {
|
||||||
|
const webdavPath = `/remote.php/dav/files/${this.username}/Notes`;
|
||||||
|
const url = `${this.serverURL}${webdavPath}`;
|
||||||
|
|
||||||
|
const response = await runtimeFetch(url, {
|
||||||
|
method: 'PROPFIND',
|
||||||
|
headers: {
|
||||||
|
'Authorization': this.authHeader,
|
||||||
|
'Depth': 'infinity',
|
||||||
|
'Content-Type': 'application/xml',
|
||||||
|
},
|
||||||
|
body: `<?xml version="1.0"?>
|
||||||
|
<d:propfind xmlns:d="DAV:">
|
||||||
|
<d:prop>
|
||||||
|
<d:getlastmodified/>
|
||||||
|
<d:getetag/>
|
||||||
|
<d:getcontenttype/>
|
||||||
|
<d:resourcetype/>
|
||||||
|
</d:prop>
|
||||||
|
</d:propfind>`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to list notes: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const xmlText = await response.text();
|
||||||
|
const notes: Note[] = [];
|
||||||
|
|
||||||
|
// Parse XML response
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const xmlDoc = parser.parseFromString(xmlText, 'text/xml');
|
||||||
|
const responses = xmlDoc.getElementsByTagNameNS('DAV:', 'response');
|
||||||
|
|
||||||
|
for (let i = 0; i < responses.length; i++) {
|
||||||
|
const responseNode = responses[i];
|
||||||
|
const href = responseNode.getElementsByTagNameNS('DAV:', 'href')[0]?.textContent || '';
|
||||||
|
|
||||||
|
// Skip if not a .md or .txt file
|
||||||
|
if (!href.endsWith('.md') && !href.endsWith('.txt')) continue;
|
||||||
|
|
||||||
|
// Skip hidden files
|
||||||
|
const filename = decodeURIComponent(href.split('/').pop() || '');
|
||||||
|
if (filename.startsWith('.')) continue;
|
||||||
|
|
||||||
|
const propstat = responseNode.getElementsByTagNameNS('DAV:', 'propstat')[0];
|
||||||
|
const prop = propstat?.getElementsByTagNameNS('DAV:', 'prop')[0];
|
||||||
|
|
||||||
|
const etag = prop?.getElementsByTagNameNS('DAV:', 'getetag')[0]?.textContent || '';
|
||||||
|
const lastModified = prop?.getElementsByTagNameNS('DAV:', 'getlastmodified')[0]?.textContent || '';
|
||||||
|
const modified = lastModified ? Math.floor(new Date(lastModified).getTime() / 1000) : 0;
|
||||||
|
|
||||||
|
// Extract category from path and decode URL encoding
|
||||||
|
const pathParts = href.split('/Notes/')[1]?.split('/');
|
||||||
|
const category = pathParts && pathParts.length > 1
|
||||||
|
? pathParts.slice(0, -1).map(part => decodeURIComponent(part)).join('/')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Create note with empty content - will be loaded on-demand
|
||||||
|
const title = filename.replace(/\.(md|txt)$/, '');
|
||||||
|
const note: Note = {
|
||||||
|
id: category ? `${category}/${filename}` : filename,
|
||||||
|
filename,
|
||||||
|
path: category ? `${category}/${filename}` : filename,
|
||||||
|
etag,
|
||||||
|
readonly: false,
|
||||||
|
content: '', // Empty - load on demand
|
||||||
|
title,
|
||||||
|
category,
|
||||||
|
favorite: false,
|
||||||
|
modified,
|
||||||
|
};
|
||||||
|
notes.push(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchNoteContentWebDAV(note: Note): Promise<Note> {
|
||||||
|
const categoryPath = note.category ? `/${note.category}` : '';
|
||||||
|
const filename = note.filename || String(note.id).split('/').pop() || 'note.md';
|
||||||
|
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(filename)}`;
|
||||||
|
const url = `${this.serverURL}${webdavPath}`;
|
||||||
|
|
||||||
|
const response = await runtimeFetch(url, {
|
||||||
|
headers: { 'Authorization': this.authHeader },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch note content: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await response.text();
|
||||||
|
return this.parseNoteFromContent(content, filename, note.category, note.etag, note.modified);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createNoteWebDAV(title: string, content: string, category: string): Promise<Note> {
|
||||||
|
const filename = `${title.replace(/[\/:\*?"<>|]/g, '').replace(/\s+/g, ' ').trim()}.md`;
|
||||||
|
const categoryPath = category ? `/${category}` : '';
|
||||||
|
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(filename)}`;
|
||||||
|
const url = `${this.serverURL}${webdavPath}`;
|
||||||
|
|
||||||
|
// Ensure category directory exists
|
||||||
|
if (category) {
|
||||||
|
try {
|
||||||
|
const categoryUrl = `${this.serverURL}/remote.php/dav/files/${this.username}/Notes/${category}`;
|
||||||
|
await runtimeFetch(categoryUrl, {
|
||||||
|
method: 'MKCOL',
|
||||||
|
headers: { 'Authorization': this.authHeader },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Directory might already exist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const noteContent = content ? `${title}\n${content}` : title;
|
||||||
|
|
||||||
|
const response = await runtimeFetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Authorization': this.authHeader,
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
},
|
||||||
|
body: noteContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok && response.status !== 201 && response.status !== 204) {
|
||||||
|
throw new Error(`Failed to create note: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const etag = response.headers.get('etag') || '';
|
||||||
|
const modified = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: category ? `${category}/${filename}` : filename,
|
||||||
|
filename,
|
||||||
|
path: category ? `${category}/${filename}` : filename,
|
||||||
|
etag,
|
||||||
|
readonly: false,
|
||||||
|
content: noteContent,
|
||||||
|
title,
|
||||||
|
category,
|
||||||
|
favorite: false,
|
||||||
|
modified,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateNoteWebDAV(note: Note): Promise<Note> {
|
||||||
|
// Extract new title from first line of content
|
||||||
|
const firstLine = note.content.split('\n')[0].replace(/^#+\s*/, '').trim();
|
||||||
|
const newTitle = firstLine || 'New Note';
|
||||||
|
const newFilename = `${newTitle.replace(/[\/:\*?"<>|]/g, '').replace(/\s+/g, ' ').trim()}.md`;
|
||||||
|
|
||||||
|
// Check if filename needs to change
|
||||||
|
const needsRename = note.filename !== newFilename;
|
||||||
|
|
||||||
|
if (needsRename) {
|
||||||
|
// Rename the file first, then update content
|
||||||
|
const renamedNote = await this.renameNoteWebDAV(note, newFilename);
|
||||||
|
// Now update the content of the renamed file
|
||||||
|
return this.updateNoteContentWithRetryWebDAV(await this.refreshNoteWebDAVMetadata(renamedNote));
|
||||||
|
} else {
|
||||||
|
// Just update content
|
||||||
|
return this.updateNoteContentWithRetryWebDAV(note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateNoteContentWithRetryWebDAV(note: Note, maxRetries = 2): Promise<Note> {
|
||||||
|
let currentNote = note;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
||||||
|
try {
|
||||||
|
return await this.updateNoteContentWebDAV(currentNote);
|
||||||
|
} catch (error) {
|
||||||
|
const status = getHttpStatus(error);
|
||||||
|
const canRetry = status === 412 || status === 423;
|
||||||
|
|
||||||
|
if (!canRetry || attempt === maxRetries) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.delay(150 * (attempt + 1));
|
||||||
|
currentNote = await this.refreshNoteWebDAVMetadata(currentNote);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.updateNoteContentWebDAV(currentNote);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateNoteContentWebDAV(note: Note): Promise<Note> {
|
||||||
|
const webdavPath = this.buildNoteWebDAVPath(note.category, note.filename!);
|
||||||
|
const url = `${this.serverURL}${webdavPath}`;
|
||||||
|
|
||||||
|
const noteContent = this.formatNoteContent(note);
|
||||||
|
|
||||||
|
const response = await runtimeFetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Authorization': this.authHeader,
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
'If-Match': note.etag, // Prevent overwriting if file changed
|
||||||
|
},
|
||||||
|
body: noteContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok && response.status !== 204) {
|
||||||
|
if (response.status === 412) {
|
||||||
|
throw createHttpStatusError('Note was modified by another client. Please refresh.', response.status);
|
||||||
|
}
|
||||||
|
if (response.status === 423) {
|
||||||
|
throw createHttpStatusError('Note is temporarily locked. Retrying...', response.status);
|
||||||
|
}
|
||||||
|
throw createHttpStatusError(`Failed to update note: ${response.status}`, response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const etag = response.headers.get('etag') || note.etag;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...note,
|
||||||
|
etag,
|
||||||
|
modified: Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async renameNoteWebDAV(note: Note, newFilename: string): Promise<Note> {
|
||||||
|
const oldPath = this.buildNoteWebDAVPath(note.category, note.filename!);
|
||||||
|
const newPath = this.buildNoteWebDAVPath(note.category, newFilename);
|
||||||
|
|
||||||
|
const response = await runtimeFetch(`${this.serverURL}${oldPath}`, {
|
||||||
|
method: 'MOVE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': this.authHeader,
|
||||||
|
'Destination': `${this.serverURL}${newPath}`,
|
||||||
|
'If-Match': note.etag,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok && response.status !== 201 && response.status !== 204) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
const existingMetadata = await this.tryFetchNoteMetadataWebDAV(note.category, newFilename);
|
||||||
|
if (existingMetadata) {
|
||||||
|
const newId = note.category ? `${note.category}/${newFilename}` : newFilename;
|
||||||
|
return {
|
||||||
|
...note,
|
||||||
|
id: newId,
|
||||||
|
filename: newFilename,
|
||||||
|
path: note.category ? `${note.category}/${newFilename}` : newFilename,
|
||||||
|
etag: existingMetadata.etag || note.etag,
|
||||||
|
modified: existingMetadata.modified || Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createHttpStatusError(`Failed to rename note: ${response.status}`, response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also rename attachment folder if it exists
|
||||||
|
const categoryPath = note.category ? `/${note.category}` : '';
|
||||||
|
const oldNoteIdStr = String(note.id);
|
||||||
|
const oldJustFilename = oldNoteIdStr.split('/').pop() || oldNoteIdStr;
|
||||||
|
const oldFilenameWithoutExt = oldJustFilename.replace(/\.(md|txt)$/, '');
|
||||||
|
const oldSanitizedNoteId = oldFilenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
|
const oldAttachmentFolder = `.attachments.${oldSanitizedNoteId}`;
|
||||||
|
|
||||||
|
const newFilenameWithoutExt = newFilename.replace(/\.(md|txt)$/, '');
|
||||||
|
const newSanitizedNoteId = newFilenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
|
const newAttachmentFolder = `.attachments.${newSanitizedNoteId}`;
|
||||||
|
|
||||||
|
const oldAttachmentPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${oldAttachmentFolder}`;
|
||||||
|
const newAttachmentPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${newAttachmentFolder}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runtimeFetch(`${this.serverURL}${oldAttachmentPath}`, {
|
||||||
|
method: 'MOVE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': this.authHeader,
|
||||||
|
'Destination': `${this.serverURL}${newAttachmentPath}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Attachment folder might not exist, that's ok
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshedMetadata = await this.tryFetchNoteMetadataWebDAV(note.category, newFilename);
|
||||||
|
const newId = note.category ? `${note.category}/${newFilename}` : newFilename;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...note,
|
||||||
|
id: newId,
|
||||||
|
filename: newFilename,
|
||||||
|
path: note.category ? `${note.category}/${newFilename}` : newFilename,
|
||||||
|
etag: refreshedMetadata?.etag || note.etag,
|
||||||
|
modified: refreshedMetadata?.modified || Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteNoteWebDAV(note: Note): Promise<void> {
|
||||||
|
const categoryPath = note.category ? `/${note.category}` : '';
|
||||||
|
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(note.filename!)}`;
|
||||||
|
const url = `${this.serverURL}${webdavPath}`;
|
||||||
|
|
||||||
|
const response = await runtimeFetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': this.authHeader },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok && response.status !== 204 && response.status !== 404) {
|
||||||
|
throw createHttpStatusError(`Failed to delete note: ${response.status}`, response.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveNoteWebDAV(note: Note, newCategory: string): Promise<Note> {
|
||||||
|
const oldCategoryPath = note.category ? `/${note.category}` : '';
|
||||||
|
const newCategoryPath = newCategory ? `/${newCategory}` : '';
|
||||||
|
const oldPath = `/remote.php/dav/files/${this.username}/Notes${oldCategoryPath}/${encodeURIComponent(note.filename!)}`;
|
||||||
|
const newPath = `/remote.php/dav/files/${this.username}/Notes${newCategoryPath}/${encodeURIComponent(note.filename!)}`;
|
||||||
|
|
||||||
|
// Ensure new category directory exists (including nested subdirectories)
|
||||||
|
if (newCategory) {
|
||||||
|
const parts = newCategory.split('/');
|
||||||
|
let currentPath = '';
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
currentPath += (currentPath ? '/' : '') + part;
|
||||||
|
try {
|
||||||
|
const categoryUrl = `${this.serverURL}/remote.php/dav/files/${this.username}/Notes/${currentPath}`;
|
||||||
|
await runtimeFetch(categoryUrl, {
|
||||||
|
method: 'MKCOL',
|
||||||
|
headers: { 'Authorization': this.authHeader },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Directory might already exist, continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await runtimeFetch(`${this.serverURL}${oldPath}`, {
|
||||||
|
method: 'MOVE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': this.authHeader,
|
||||||
|
'Destination': `${this.serverURL}${newPath}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok && response.status !== 201 && response.status !== 204) {
|
||||||
|
throw new Error(`Failed to move note: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move attachment folder if it exists
|
||||||
|
const noteIdStr = String(note.id);
|
||||||
|
const justFilename = noteIdStr.split('/').pop() || noteIdStr;
|
||||||
|
const filenameWithoutExt = justFilename.replace(/\.(md|txt)$/, '');
|
||||||
|
const sanitizedNoteId = filenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
|
const attachmentFolder = `.attachments.${sanitizedNoteId}`;
|
||||||
|
|
||||||
|
const oldAttachmentPath = `/remote.php/dav/files/${this.username}/Notes${oldCategoryPath}/${attachmentFolder}`;
|
||||||
|
const newAttachmentPath = `/remote.php/dav/files/${this.username}/Notes${newCategoryPath}/${attachmentFolder}`;
|
||||||
|
|
||||||
|
console.log(`Attempting to move attachment folder:`);
|
||||||
|
console.log(` From: ${oldAttachmentPath}`);
|
||||||
|
console.log(` To: ${newAttachmentPath}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const attachmentResponse = await runtimeFetch(`${this.serverURL}${oldAttachmentPath}`, {
|
||||||
|
method: 'MOVE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': this.authHeader,
|
||||||
|
'Destination': `${this.serverURL}${newAttachmentPath}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Attachment folder MOVE response status: ${attachmentResponse.status}`);
|
||||||
|
|
||||||
|
if (attachmentResponse.ok || attachmentResponse.status === 201 || attachmentResponse.status === 204) {
|
||||||
|
console.log(`✓ Successfully moved attachment folder: ${attachmentFolder}`);
|
||||||
|
} else {
|
||||||
|
console.log(`✗ Failed to move attachment folder (status ${attachmentResponse.status})`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`✗ Error moving attachment folder:`, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...note,
|
||||||
|
category: newCategory,
|
||||||
|
path: newCategory ? `${newCategory}/${note.filename}` : note.filename || '',
|
||||||
|
id: newCategory ? `${newCategory}/${note.filename}` : (note.filename || ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
src/assets/nextcloud-notes-tauri.png
Normal file
BIN
src/assets/nextcloud-notes-tauri.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 208 KiB |
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { categoryColorsSync } from '../services/categoryColorsSync';
|
||||||
|
|
||||||
const EDITOR_FONTS = [
|
const EDITOR_FONTS = [
|
||||||
{ name: 'Source Code Pro', value: 'Source Code Pro' },
|
{ name: 'Source Code Pro', value: 'Source Code Pro' },
|
||||||
@@ -20,6 +21,7 @@ interface CategoriesSidebarProps {
|
|||||||
selectedCategory: string;
|
selectedCategory: string;
|
||||||
onSelectCategory: (category: string) => void;
|
onSelectCategory: (category: string) => void;
|
||||||
onCreateCategory: (name: string) => void;
|
onCreateCategory: (name: string) => void;
|
||||||
|
onRenameCategory: (oldName: string, newName: string) => void;
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
onToggleCollapse: () => void;
|
onToggleCollapse: () => void;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -36,11 +38,25 @@ interface CategoriesSidebarProps {
|
|||||||
onPreviewFontSizeChange: (size: number) => void;
|
onPreviewFontSizeChange: (size: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CATEGORY_COLORS = [
|
||||||
|
{ name: 'Blue', bg: 'bg-blue-100 dark:bg-blue-900/30', text: 'text-blue-700 dark:text-blue-300', preview: '#dbeafe', dot: '#3b82f6' },
|
||||||
|
{ name: 'Green', bg: 'bg-green-100 dark:bg-green-900/30', text: 'text-green-700 dark:text-green-300', preview: '#dcfce7', dot: '#22c55e' },
|
||||||
|
{ name: 'Purple', bg: 'bg-purple-100 dark:bg-purple-900/30', text: 'text-purple-700 dark:text-purple-300', preview: '#f3e8ff', dot: '#a855f7' },
|
||||||
|
{ name: 'Pink', bg: 'bg-pink-100 dark:bg-pink-900/30', text: 'text-pink-700 dark:text-pink-300', preview: '#fce7f3', dot: '#ec4899' },
|
||||||
|
{ name: 'Yellow', bg: 'bg-yellow-100 dark:bg-yellow-900/30', text: 'text-yellow-700 dark:text-yellow-300', preview: '#fef9c3', dot: '#eab308' },
|
||||||
|
{ name: 'Red', bg: 'bg-red-100 dark:bg-red-900/30', text: 'text-red-700 dark:text-red-300', preview: '#fee2e2', dot: '#ef4444' },
|
||||||
|
{ name: 'Orange', bg: 'bg-orange-100 dark:bg-orange-900/30', text: 'text-orange-700 dark:text-orange-300', preview: '#ffedd5', dot: '#f97316' },
|
||||||
|
{ name: 'Teal', bg: 'bg-teal-100 dark:bg-teal-900/30', text: 'text-teal-700 dark:text-teal-300', preview: '#ccfbf1', dot: '#14b8a6' },
|
||||||
|
{ name: 'Indigo', bg: 'bg-indigo-100 dark:bg-indigo-900/30', text: 'text-indigo-700 dark:text-indigo-300', preview: '#e0e7ff', dot: '#6366f1' },
|
||||||
|
{ name: 'Cyan', bg: 'bg-cyan-100 dark:bg-cyan-900/30', text: 'text-cyan-700 dark:text-cyan-300', preview: '#cffafe', dot: '#06b6d4' },
|
||||||
|
];
|
||||||
|
|
||||||
export function CategoriesSidebar({
|
export function CategoriesSidebar({
|
||||||
categories,
|
categories,
|
||||||
selectedCategory,
|
selectedCategory,
|
||||||
onSelectCategory,
|
onSelectCategory,
|
||||||
onCreateCategory,
|
onCreateCategory,
|
||||||
|
onRenameCategory,
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
username,
|
username,
|
||||||
@@ -58,7 +74,31 @@ export function CategoriesSidebar({
|
|||||||
}: CategoriesSidebarProps) {
|
}: CategoriesSidebarProps) {
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
const [newCategoryName, setNewCategoryName] = useState('');
|
const [newCategoryName, setNewCategoryName] = useState('');
|
||||||
|
const [renamingCategory, setRenamingCategory] = useState<string | null>(null);
|
||||||
|
const [renameCategoryValue, setRenameCategoryValue] = useState('');
|
||||||
|
const [categoryColors, setCategoryColors] = useState<Record<string, number>>(() => categoryColorsSync.getAllColors());
|
||||||
|
const [colorPickerCategory, setColorPickerCategory] = useState<string | null>(null);
|
||||||
|
const [isSettingsCollapsed, setIsSettingsCollapsed] = useState(true);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleColorChange = () => {
|
||||||
|
setCategoryColors(categoryColorsSync.getAllColors());
|
||||||
|
};
|
||||||
|
|
||||||
|
categoryColorsSync.setChangeCallback(handleColorChange);
|
||||||
|
window.addEventListener('categoryColorChanged', handleColorChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('categoryColorChanged', handleColorChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setCategoryColor = async (category: string, colorIndex: number | null) => {
|
||||||
|
await categoryColorsSync.setColor(category, colorIndex);
|
||||||
|
setColorPickerCategory(null);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCreating && inputRef.current) {
|
if (isCreating && inputRef.current) {
|
||||||
@@ -66,6 +106,13 @@ export function CategoriesSidebar({
|
|||||||
}
|
}
|
||||||
}, [isCreating]);
|
}, [isCreating]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (renamingCategory && renameInputRef.current) {
|
||||||
|
renameInputRef.current.focus();
|
||||||
|
renameInputRef.current.select();
|
||||||
|
}
|
||||||
|
}, [renamingCategory]);
|
||||||
|
|
||||||
const handleCreateCategory = () => {
|
const handleCreateCategory = () => {
|
||||||
if (newCategoryName.trim()) {
|
if (newCategoryName.trim()) {
|
||||||
onCreateCategory(newCategoryName.trim());
|
onCreateCategory(newCategoryName.trim());
|
||||||
@@ -74,6 +121,19 @@ export function CategoriesSidebar({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRenameCategory = () => {
|
||||||
|
if (renameCategoryValue.trim() && renamingCategory && renameCategoryValue.trim() !== renamingCategory) {
|
||||||
|
onRenameCategory(renamingCategory, renameCategoryValue.trim());
|
||||||
|
}
|
||||||
|
setRenamingCategory(null);
|
||||||
|
setRenameCategoryValue('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRenaming = (category: string) => {
|
||||||
|
setRenamingCategory(category);
|
||||||
|
setRenameCategoryValue(category);
|
||||||
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
handleCreateCategory();
|
handleCreateCategory();
|
||||||
@@ -83,6 +143,15 @@ export function CategoriesSidebar({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRenameKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleRenameCategory();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setRenamingCategory(null);
|
||||||
|
setRenameCategoryValue('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -142,20 +211,104 @@ export function CategoriesSidebar({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<button
|
renamingCategory === category ? (
|
||||||
key={category}
|
<div key={category} className="space-y-1">
|
||||||
onClick={() => onSelectCategory(category)}
|
<div className="flex items-center gap-2 px-3 py-2 bg-white dark:bg-gray-700 rounded-lg border border-blue-500">
|
||||||
className={`w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center ${
|
<svg className="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
ref={renameInputRef}
|
||||||
|
type="text"
|
||||||
|
value={renameCategoryValue}
|
||||||
|
onChange={(e) => setRenameCategoryValue(e.target.value)}
|
||||||
|
onKeyDown={handleRenameKeyDown}
|
||||||
|
onBlur={handleRenameCategory}
|
||||||
|
className="flex-1 text-sm px-2 py-1 border-none bg-transparent text-gray-900 dark:text-gray-100 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Press <kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">Enter</kbd> to save, <kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">Esc</kbd> to cancel
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div key={category} className="relative">
|
||||||
|
<div
|
||||||
|
className={`group w-full px-3 py-2 rounded-lg transition-colors flex items-center ${
|
||||||
selectedCategory === category
|
selectedCategory === category
|
||||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||||
: 'hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
|
: 'hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<button
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
onClick={() => onSelectCategory(category)}
|
||||||
|
className="flex items-center flex-1 min-w-0 text-left"
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const colorIndex = categoryColors[category];
|
||||||
|
const color = colorIndex !== undefined ? CATEGORY_COLORS[colorIndex] : null;
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mr-2 flex-shrink-0"
|
||||||
|
fill={color ? color.dot : "currentColor"}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path d="M3 7c0-1.1.9-2 2-2h4l2 2h6c1.1 0 2 .9 2 2v10c0 1.1-.9 2-2 2H5c-1.1 0-2-.9-2-2V7z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<span className="text-sm truncate">{category}</span>
|
<span className="text-sm truncate">{category}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setColorPickerCategory(colorPickerCategory === category ? null : category);
|
||||||
|
}}
|
||||||
|
className="p-1 hover:bg-gray-300 dark:hover:bg-gray-600 rounded flex-shrink-0"
|
||||||
|
title="Change color"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
startRenaming(category);
|
||||||
|
}}
|
||||||
|
className="p-1 hover:bg-gray-300 dark:hover:bg-gray-600 rounded flex-shrink-0"
|
||||||
|
title="Rename category"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{colorPickerCategory === category && (
|
||||||
|
<div className="absolute left-0 right-0 top-full mt-1 bg-white dark:bg-gray-700 rounded-lg shadow-lg border border-gray-200 dark:border-gray-600 p-2 z-10">
|
||||||
|
<div className="grid grid-cols-5 gap-1.5 mb-2">
|
||||||
|
{CATEGORY_COLORS.map((color, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
onClick={() => setCategoryColor(category, idx)}
|
||||||
|
className="w-7 h-7 rounded hover:scale-110 transition-transform border-2 border-gray-300 dark:border-gray-600"
|
||||||
|
style={{ backgroundColor: color.preview }}
|
||||||
|
title={color.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setCategoryColor(category, null)}
|
||||||
|
className="w-full text-xs py-1.5 px-2 bg-gray-100 dark:bg-gray-600 hover:bg-gray-200 dark:hover:bg-gray-500 rounded text-gray-700 dark:text-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
Remove Color
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{isCreating && (
|
{isCreating && (
|
||||||
@@ -185,14 +338,24 @@ export function CategoriesSidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Info and Settings */}
|
{/* User Info and Settings */}
|
||||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-white dark:bg-gray-900">
|
<div className="border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between p-4 pb-3">
|
||||||
<div className="flex items-center space-x-2 min-w-0">
|
<div className="flex items-center space-x-2 min-w-0">
|
||||||
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white font-semibold flex-shrink-0">
|
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white font-semibold flex-shrink-0">
|
||||||
{username.charAt(0).toUpperCase()}
|
{username.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-gray-700 dark:text-gray-200 truncate font-medium">{username}</span>
|
<span className="text-sm text-gray-700 dark:text-gray-200 truncate font-medium">{username}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsSettingsCollapsed(!isSettingsCollapsed)}
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors flex-shrink-0"
|
||||||
|
title={isSettingsCollapsed ? "Show Settings" : "Hide Settings"}
|
||||||
|
>
|
||||||
|
<svg className={`w-4 h-4 text-gray-600 dark:text-gray-300 transition-transform ${isSettingsCollapsed ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onLogout}
|
onClick={onLogout}
|
||||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors flex-shrink-0"
|
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors flex-shrink-0"
|
||||||
@@ -203,7 +366,10 @@ export function CategoriesSidebar({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isSettingsCollapsed && (
|
||||||
|
<div className="px-4 pb-4">
|
||||||
{/* Theme Toggle */}
|
{/* Theme Toggle */}
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">Theme</span>
|
<span className="text-xs text-gray-500 dark:text-gray-400">Theme</span>
|
||||||
@@ -322,6 +488,8 @@ export function CategoriesSidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import jsPDF from 'jspdf';
|
|
||||||
import { message } from '@tauri-apps/plugin-dialog';
|
|
||||||
import { Note } from '../types';
|
import { Note } from '../types';
|
||||||
import { NextcloudAPI } from '../api/nextcloud';
|
import { NextcloudAPI } from '../api/nextcloud';
|
||||||
import { FloatingToolbar } from './FloatingToolbar';
|
import { FloatingToolbar } from './FloatingToolbar';
|
||||||
import { InsertToolbar } from './InsertToolbar';
|
import { InsertToolbar } from './InsertToolbar';
|
||||||
|
import {
|
||||||
|
exportPdfDocument,
|
||||||
|
getDesktopRuntime,
|
||||||
|
showDesktopMessage,
|
||||||
|
} from '../services/desktop';
|
||||||
|
import {
|
||||||
|
getNoteTitleFromContent,
|
||||||
|
loadPrintFontFaceCss,
|
||||||
|
PrintExportPayload,
|
||||||
|
sanitizeFileName,
|
||||||
|
} from '../printExport';
|
||||||
|
|
||||||
interface NoteEditorProps {
|
interface NoteEditorProps {
|
||||||
note: Note | null;
|
note: Note | null;
|
||||||
onUpdateNote: (note: Note) => void;
|
onChangeNote: (note: Note) => void;
|
||||||
onUnsavedChanges?: (hasChanges: boolean) => void;
|
onSaveNote: (draftId: string) => void | Promise<void>;
|
||||||
|
onDiscardNote: (draftId: string) => void;
|
||||||
|
onToggleFavorite?: (note: Note, favorite: boolean) => void;
|
||||||
categories: string[];
|
categories: string[];
|
||||||
isFocusMode?: boolean;
|
isFocusMode?: boolean;
|
||||||
onToggleFocusMode?: () => void;
|
onToggleFocusMode?: () => void;
|
||||||
@@ -23,27 +34,30 @@ interface NoteEditorProps {
|
|||||||
|
|
||||||
const imageCache = new Map<string, string>();
|
const imageCache = new Map<string, string>();
|
||||||
|
|
||||||
|
// Configure marked to support task lists
|
||||||
|
marked.use({
|
||||||
|
gfm: true,
|
||||||
|
breaks: true,
|
||||||
|
});
|
||||||
|
|
||||||
export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, isFocusMode, onToggleFocusMode, editorFont = 'Source Code Pro', editorFontSize = 14, previewFont = 'Merriweather', previewFontSize = 16, api }: NoteEditorProps) {
|
export function NoteEditor({ note, onChangeNote, onSaveNote, onDiscardNote, onToggleFavorite, categories, isFocusMode, onToggleFocusMode, editorFont = 'Source Code Pro', editorFontSize = 14, previewFont = 'Merriweather', previewFontSize = 16, api }: NoteEditorProps) {
|
||||||
const [localTitle, setLocalTitle] = useState('');
|
|
||||||
const [localContent, setLocalContent] = useState('');
|
const [localContent, setLocalContent] = useState('');
|
||||||
const [localCategory, setLocalCategory] = useState('');
|
const [localCategory, setLocalCategory] = useState('');
|
||||||
const [localFavorite, setLocalFavorite] = useState(false);
|
const [localFavorite, setLocalFavorite] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
||||||
const [titleManuallyEdited, setTitleManuallyEdited] = useState(false);
|
|
||||||
const [isExportingPDF, setIsExportingPDF] = useState(false);
|
const [isExportingPDF, setIsExportingPDF] = useState(false);
|
||||||
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
||||||
const [processedContent, setProcessedContent] = useState('');
|
const [processedContent, setProcessedContent] = useState('');
|
||||||
const [isLoadingImages, setIsLoadingImages] = useState(false);
|
const [isLoadingImages, setIsLoadingImages] = useState(false);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const previousNoteIdRef = useRef<number | null>(null);
|
const previousDraftIdRef = useRef<string | null>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const desktopRuntime = getDesktopRuntime();
|
||||||
useEffect(() => {
|
const exportActionLabel = desktopRuntime === 'electron' ? 'Export PDF' : 'Print Note';
|
||||||
onUnsavedChanges?.(hasUnsavedChanges);
|
const hasUnsavedChanges = Boolean(note?.pendingSave);
|
||||||
}, [hasUnsavedChanges, onUnsavedChanges]);
|
const isSaving = Boolean(note?.isSaving);
|
||||||
|
const saveError = note?.saveError;
|
||||||
|
const hasSavedState = Boolean(note?.lastSavedAt) && !hasUnsavedChanges && !isSaving && !saveError;
|
||||||
|
|
||||||
// Handle Escape key to exit focus mode
|
// Handle Escape key to exit focus mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -63,8 +77,16 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
|||||||
// Use setTimeout to ensure DOM has updated
|
// Use setTimeout to ensure DOM has updated
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
|
// Save cursor position and scroll position
|
||||||
|
const cursorPosition = textareaRef.current.selectionStart;
|
||||||
|
const scrollTop = textareaRef.current.scrollTop;
|
||||||
|
|
||||||
textareaRef.current.style.height = 'auto';
|
textareaRef.current.style.height = 'auto';
|
||||||
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
|
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
|
||||||
|
|
||||||
|
// Restore cursor position and scroll position
|
||||||
|
textareaRef.current.setSelectionRange(cursorPosition, cursorPosition);
|
||||||
|
textareaRef.current.scrollTop = scrollTop;
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
@@ -77,13 +99,23 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guard: Only process if localContent has been updated for the current note
|
||||||
|
// This prevents processing stale content from the previous note
|
||||||
|
if (previousDraftIdRef.current !== note.draftId) {
|
||||||
|
console.log(`[Note ${note.id}] Skipping image processing - waiting for content to sync (previousDraftIdRef: ${previousDraftIdRef.current})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const processImages = async () => {
|
const processImages = async () => {
|
||||||
|
console.log(`[Note ${note.id}] Processing images in preview mode. Content length: ${localContent.length}`);
|
||||||
setIsLoadingImages(true);
|
setIsLoadingImages(true);
|
||||||
|
setProcessedContent(''); // Clear old content immediately
|
||||||
|
|
||||||
// Find all image references in markdown: 
|
// Find all image references in markdown: 
|
||||||
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||||
let content = localContent;
|
let content = localContent;
|
||||||
const matches = [...localContent.matchAll(imageRegex)];
|
const matches = [...localContent.matchAll(imageRegex)];
|
||||||
|
console.log(`[Note ${note.id}] Found ${matches.length} images to process`);
|
||||||
|
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
const [fullMatch, alt, imagePath] = match;
|
const [fullMatch, alt, imagePath] = match;
|
||||||
@@ -116,82 +148,56 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
|||||||
};
|
};
|
||||||
|
|
||||||
processImages();
|
processImages();
|
||||||
}, [isPreviewMode, localContent, note?.id, api]);
|
}, [isPreviewMode, localContent, note?.draftId, note?.id, api]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadNewNote = () => {
|
if (!note) {
|
||||||
if (note) {
|
setLocalContent('');
|
||||||
setLocalTitle(note.title);
|
setLocalCategory('');
|
||||||
|
setLocalFavorite(false);
|
||||||
|
previousDraftIdRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousDraftIdRef.current !== note.draftId) {
|
||||||
|
setProcessedContent('');
|
||||||
|
previousDraftIdRef.current = note.draftId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note.content !== localContent) {
|
||||||
setLocalContent(note.content);
|
setLocalContent(note.content);
|
||||||
|
}
|
||||||
|
if ((note.category || '') !== localCategory) {
|
||||||
setLocalCategory(note.category || '');
|
setLocalCategory(note.category || '');
|
||||||
|
}
|
||||||
|
if (note.favorite !== localFavorite) {
|
||||||
setLocalFavorite(note.favorite);
|
setLocalFavorite(note.favorite);
|
||||||
setHasUnsavedChanges(false);
|
|
||||||
|
|
||||||
const firstLine = note.content.split('\n')[0].replace(/^#+\s*/, '').trim();
|
|
||||||
const titleMatchesFirstLine = note.title === firstLine || note.title === firstLine.substring(0, 50);
|
|
||||||
setTitleManuallyEdited(!titleMatchesFirstLine);
|
|
||||||
|
|
||||||
previousNoteIdRef.current = note.id;
|
|
||||||
}
|
}
|
||||||
};
|
}, [note?.draftId, note?.content, note?.category, note?.favorite, localCategory, localContent, localFavorite]);
|
||||||
|
|
||||||
if (previousNoteIdRef.current !== null && previousNoteIdRef.current !== note?.id) {
|
const emitNoteChange = (content: string, category: string, favorite: boolean) => {
|
||||||
if (hasUnsavedChanges) {
|
if (!note) {
|
||||||
handleSave();
|
return;
|
||||||
}
|
}
|
||||||
setTimeout(loadNewNote, 100);
|
|
||||||
} else {
|
|
||||||
loadNewNote();
|
|
||||||
}
|
|
||||||
}, [note?.id]);
|
|
||||||
|
|
||||||
const handleSave = () => {
|
onChangeNote({
|
||||||
if (!note || !hasUnsavedChanges) return;
|
|
||||||
|
|
||||||
console.log('Saving note content length:', localContent.length);
|
|
||||||
console.log('Last 50 chars:', localContent.slice(-50));
|
|
||||||
setIsSaving(true);
|
|
||||||
setHasUnsavedChanges(false);
|
|
||||||
onUpdateNote({
|
|
||||||
...note,
|
...note,
|
||||||
title: localTitle,
|
title: getNoteTitleFromContent(content),
|
||||||
content: localContent,
|
content,
|
||||||
category: localCategory,
|
category,
|
||||||
favorite: localFavorite,
|
favorite,
|
||||||
});
|
});
|
||||||
setTimeout(() => setIsSaving(false), 500);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTitleChange = (value: string) => {
|
|
||||||
setLocalTitle(value);
|
|
||||||
setTitleManuallyEdited(true);
|
|
||||||
setHasUnsavedChanges(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleContentChange = (value: string) => {
|
const handleContentChange = (value: string) => {
|
||||||
setLocalContent(value);
|
setLocalContent(value);
|
||||||
setHasUnsavedChanges(true);
|
emitNoteChange(value, localCategory, localFavorite);
|
||||||
|
|
||||||
if (!titleManuallyEdited) {
|
|
||||||
const firstLine = value.split('\n')[0].replace(/^#+\s*/, '').trim();
|
|
||||||
if (firstLine) {
|
|
||||||
setLocalTitle(firstLine.substring(0, 50));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDiscard = () => {
|
const handleDiscard = () => {
|
||||||
if (!note) return;
|
if (!note?.draftId) return;
|
||||||
|
|
||||||
setLocalTitle(note.title);
|
onDiscardNote(note.draftId);
|
||||||
setLocalContent(note.content);
|
|
||||||
setLocalCategory(note.category || '');
|
|
||||||
setLocalFavorite(note.favorite);
|
|
||||||
setHasUnsavedChanges(false);
|
|
||||||
|
|
||||||
const firstLine = note.content.split('\n')[0].replace(/^#+\s*/, '').trim();
|
|
||||||
const titleMatchesFirstLine = note.title === firstLine || note.title === firstLine.substring(0, 50);
|
|
||||||
setTitleManuallyEdited(!titleMatchesFirstLine);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExportPDF = async () => {
|
const handleExportPDF = async () => {
|
||||||
@@ -200,102 +206,79 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
|||||||
setIsExportingPDF(true);
|
setIsExportingPDF(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const container = document.createElement('div');
|
let contentForPrint = localContent;
|
||||||
container.style.fontFamily = `"${previewFont}", Georgia, serif`;
|
if (api) {
|
||||||
container.style.fontSize = '12px';
|
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||||
container.style.lineHeight = '1.6';
|
const matches = [...localContent.matchAll(imageRegex)];
|
||||||
container.style.color = '#000000';
|
|
||||||
|
|
||||||
const titleElement = document.createElement('h1');
|
for (const match of matches) {
|
||||||
titleElement.textContent = localTitle || 'Untitled';
|
const [fullMatch, alt, imagePath] = match;
|
||||||
titleElement.style.marginTop = '0';
|
|
||||||
titleElement.style.marginBottom = '20px';
|
|
||||||
titleElement.style.fontSize = '24px';
|
|
||||||
titleElement.style.fontWeight = 'bold';
|
|
||||||
titleElement.style.color = '#000000';
|
|
||||||
titleElement.style.textAlign = 'center';
|
|
||||||
titleElement.style.fontFamily = `"${previewFont}", Georgia, serif`;
|
|
||||||
container.appendChild(titleElement);
|
|
||||||
|
|
||||||
const contentElement = document.createElement('div');
|
// Skip external URLs
|
||||||
const html = marked.parse(localContent || '', { async: false }) as string;
|
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
|
||||||
contentElement.innerHTML = html;
|
continue;
|
||||||
contentElement.style.fontSize = '12px';
|
|
||||||
contentElement.style.lineHeight = '1.6';
|
|
||||||
contentElement.style.color = '#000000';
|
|
||||||
container.appendChild(contentElement);
|
|
||||||
|
|
||||||
// Apply monospace font to code elements
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.textContent = `
|
|
||||||
code, pre { font-family: "Source Code Pro", ui-monospace, monospace !important; }
|
|
||||||
pre { background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto; }
|
|
||||||
code { background: #f0f0f0; padding: 2px 4px; border-radius: 2px; }
|
|
||||||
`;
|
|
||||||
container.appendChild(style);
|
|
||||||
|
|
||||||
// Create PDF using jsPDF's html() method (like dompdf)
|
|
||||||
const pdf = new jsPDF({
|
|
||||||
orientation: 'portrait',
|
|
||||||
unit: 'mm',
|
|
||||||
format: 'a4',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use jsPDF's html() method which handles pagination automatically
|
|
||||||
await pdf.html(container, {
|
|
||||||
callback: async (doc) => {
|
|
||||||
// Save the PDF
|
|
||||||
const fileName = `${localTitle || 'note'}.pdf`;
|
|
||||||
doc.save(fileName);
|
|
||||||
|
|
||||||
// Show success message using Tauri dialog
|
|
||||||
setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
await message(`PDF exported successfully!\n\nFile: ${fileName}\nLocation: Downloads folder`, {
|
|
||||||
title: 'Export Complete',
|
|
||||||
kind: 'info',
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Dialog shown successfully or not available');
|
|
||||||
}
|
}
|
||||||
setIsExportingPDF(false);
|
|
||||||
}, 500);
|
// Check cache first
|
||||||
},
|
const cacheKey = `${note.id}:${imagePath}`;
|
||||||
margin: [20, 20, 20, 20], // top, right, bottom, left margins in mm
|
if (imageCache.has(cacheKey)) {
|
||||||
autoPaging: 'text', // Enable automatic page breaks
|
const dataUrl = imageCache.get(cacheKey)!;
|
||||||
width: 170, // Content width in mm (A4 width 210mm - 40mm margins)
|
contentForPrint = contentForPrint.replace(fullMatch, ``);
|
||||||
windowWidth: 650, // Rendering width in pixels (matches content width ratio)
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dataUrl = await api.fetchAttachment(note.id, imagePath, note.category);
|
||||||
|
imageCache.set(cacheKey, dataUrl);
|
||||||
|
contentForPrint = contentForPrint.replace(fullMatch, ``);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch attachment for PDF: ${imagePath}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = getNoteTitleFromContent(localContent);
|
||||||
|
const fileName = `${sanitizeFileName(title)}.pdf`;
|
||||||
|
const noteHtml = marked.parse(contentForPrint || '', { async: false }) as string;
|
||||||
|
const payload: PrintExportPayload = {
|
||||||
|
fileName,
|
||||||
|
title,
|
||||||
|
html: noteHtml,
|
||||||
|
previewFont,
|
||||||
|
previewFontSize,
|
||||||
|
previewFontFaceCss: await loadPrintFontFaceCss(previewFont),
|
||||||
|
};
|
||||||
|
|
||||||
|
await exportPdfDocument({
|
||||||
|
...payload,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('PDF export failed:', error);
|
console.error('PDF export failed:', error);
|
||||||
try {
|
try {
|
||||||
await message('Failed to export PDF. Please try again.', {
|
await showDesktopMessage('Failed to export the PDF. Please try again.', {
|
||||||
title: 'Export Failed',
|
title: 'Export Failed',
|
||||||
kind: 'error',
|
kind: 'error',
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Could not show error dialog');
|
console.error('Could not show error dialog');
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
setIsExportingPDF(false);
|
setIsExportingPDF(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFavoriteToggle = () => {
|
const handleFavoriteToggle = () => {
|
||||||
setLocalFavorite(!localFavorite);
|
const newFavorite = !localFavorite;
|
||||||
if (note) {
|
setLocalFavorite(newFavorite);
|
||||||
onUpdateNote({
|
|
||||||
...note,
|
if (note && onToggleFavorite) {
|
||||||
title: localTitle,
|
onToggleFavorite(note, newFavorite);
|
||||||
content: localContent,
|
|
||||||
category: localCategory,
|
|
||||||
favorite: !localFavorite,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCategoryChange = (category: string) => {
|
const handleCategoryChange = (category: string) => {
|
||||||
setLocalCategory(category);
|
setLocalCategory(category);
|
||||||
setHasUnsavedChanges(true);
|
emitNoteChange(localContent, category, localFavorite);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAttachmentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAttachmentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -318,7 +301,7 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
|||||||
const cursorPos = textarea.selectionStart;
|
const cursorPos = textarea.selectionStart;
|
||||||
const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos);
|
const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos);
|
||||||
setLocalContent(newContent);
|
setLocalContent(newContent);
|
||||||
setHasUnsavedChanges(true);
|
emitNoteChange(newContent, localCategory, localFavorite);
|
||||||
|
|
||||||
// Move cursor after inserted text
|
// Move cursor after inserted text
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -328,17 +311,18 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
|||||||
}, 0);
|
}, 0);
|
||||||
} else {
|
} else {
|
||||||
// Append to end
|
// Append to end
|
||||||
setLocalContent(localContent + '\n' + markdownLink);
|
const newContent = `${localContent}\n${markdownLink}`;
|
||||||
setHasUnsavedChanges(true);
|
setLocalContent(newContent);
|
||||||
|
emitNoteChange(newContent, localCategory, localFavorite);
|
||||||
}
|
}
|
||||||
|
|
||||||
await message(`Attachment uploaded successfully!`, {
|
await showDesktopMessage('Attachment uploaded successfully!', {
|
||||||
title: 'Upload Complete',
|
title: 'Upload Complete',
|
||||||
kind: 'info',
|
kind: 'info',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload failed:', error);
|
console.error('Upload failed:', error);
|
||||||
await message(`Failed to upload attachment: ${error}`, {
|
await showDesktopMessage(`Failed to upload attachment: ${error}`, {
|
||||||
title: 'Upload Failed',
|
title: 'Upload Failed',
|
||||||
kind: 'error',
|
kind: 'error',
|
||||||
});
|
});
|
||||||
@@ -359,7 +343,7 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
|||||||
const markdownLink = `[${text}](${url})`;
|
const markdownLink = `[${text}](${url})`;
|
||||||
const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos);
|
const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos);
|
||||||
setLocalContent(newContent);
|
setLocalContent(newContent);
|
||||||
setHasUnsavedChanges(true);
|
emitNoteChange(newContent, localCategory, localFavorite);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
@@ -499,7 +483,7 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
|||||||
|
|
||||||
const newContent = localContent.substring(0, start) + formattedText + localContent.substring(end);
|
const newContent = localContent.substring(0, start) + formattedText + localContent.substring(end);
|
||||||
setLocalContent(newContent);
|
setLocalContent(newContent);
|
||||||
setHasUnsavedChanges(true);
|
emitNoteChange(newContent, localCategory, localFavorite);
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
@@ -528,36 +512,6 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col bg-white dark:bg-gray-900">
|
<div className="flex-1 flex flex-col bg-white dark:bg-gray-900">
|
||||||
{/* Header */}
|
|
||||||
<div className="border-b border-gray-200 dark:border-gray-700 px-6 py-4">
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={localTitle}
|
|
||||||
onChange={(e) => handleTitleChange(e.target.value)}
|
|
||||||
placeholder="Note Title"
|
|
||||||
className="w-full text-2xl font-semibold border-none outline-none focus:ring-0 bg-transparent text-gray-900 dark:text-gray-100 placeholder-gray-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleFavoriteToggle}
|
|
||||||
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors flex-shrink-0"
|
|
||||||
title={localFavorite ? "Remove from Favorites" : "Add to Favorites"}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className={`w-5 h-5 ${localFavorite ? 'text-yellow-500 fill-current' : 'text-gray-400 dark:text-gray-500'}`}
|
|
||||||
fill={localFavorite ? "currentColor" : "none"}
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="border-b border-gray-200 dark:border-gray-700 px-6 py-3 bg-gray-50 dark:bg-gray-800/50">
|
<div className="border-b border-gray-200 dark:border-gray-700 px-6 py-3 bg-gray-50 dark:bg-gray-800/50">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -584,42 +538,6 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Attachment Upload */}
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
onChange={handleAttachmentUpload}
|
|
||||||
className="hidden"
|
|
||||||
accept="image/*,.pdf,.doc,.docx,.txt,.md"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
disabled={isUploading || isPreviewMode}
|
|
||||||
className={`px-3 py-1.5 rounded-full transition-colors flex items-center gap-1.5 text-sm ${
|
|
||||||
isUploading || isPreviewMode
|
|
||||||
? 'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
|
|
||||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
|
||||||
}`}
|
|
||||||
title={isPreviewMode ? "Switch to Edit mode to upload" : "Upload Image/Attachment"}
|
|
||||||
>
|
|
||||||
{isUploading ? (
|
|
||||||
<>
|
|
||||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
<span>Uploading...</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
<span>Attach</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Preview Toggle */}
|
{/* Preview Toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsPreviewMode(!isPreviewMode)}
|
onClick={() => setIsPreviewMode(!isPreviewMode)}
|
||||||
@@ -651,21 +569,44 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
|||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
{(hasUnsavedChanges || isSaving) && (
|
{(hasUnsavedChanges || isSaving || saveError || hasSavedState) && (
|
||||||
<span className={`text-xs px-2 py-1 rounded-full ${
|
<span className={`text-xs px-2 py-1 rounded-full ${
|
||||||
isSaving
|
saveError
|
||||||
|
? 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400'
|
||||||
|
: isSaving
|
||||||
? 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
|
? 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
|
||||||
: 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400'
|
: hasUnsavedChanges
|
||||||
|
? 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400'
|
||||||
|
: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400'
|
||||||
}`}>
|
}`}>
|
||||||
{isSaving ? 'Saving...' : 'Unsaved'}
|
{saveError ? 'Save failed' : isSaving ? 'Saving...' : hasUnsavedChanges ? 'Unsaved' : 'Saved'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex items-center gap-1 pl-2 border-l border-gray-200 dark:border-gray-700">
|
<div className="flex items-center gap-1 pl-2 border-l border-gray-200 dark:border-gray-700">
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleFavoriteToggle}
|
||||||
disabled={!hasUnsavedChanges || isSaving}
|
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
title={localFavorite ? "Remove from Favorites" : "Add to Favorites"}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`w-5 h-5 ${localFavorite ? 'text-yellow-500 fill-current' : 'text-gray-600 dark:text-gray-400'}`}
|
||||||
|
fill={localFavorite ? "currentColor" : "none"}
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (note?.draftId) {
|
||||||
|
void onSaveNote(note.draftId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!hasUnsavedChanges || isSaving || !note?.draftId}
|
||||||
className={`p-1.5 rounded-lg transition-colors ${
|
className={`p-1.5 rounded-lg transition-colors ${
|
||||||
hasUnsavedChanges && !isSaving
|
hasUnsavedChanges && !isSaving
|
||||||
? 'text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30'
|
? 'text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30'
|
||||||
@@ -701,16 +642,26 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
|||||||
? 'text-blue-500 cursor-wait'
|
? 'text-blue-500 cursor-wait'
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
}`}
|
}`}
|
||||||
title={isExportingPDF ? "Generating PDF..." : "Export as PDF"}
|
title={isExportingPDF ? `${exportActionLabel} in progress...` : exportActionLabel}
|
||||||
>
|
>
|
||||||
{isExportingPDF ? (
|
{isExportingPDF ? (
|
||||||
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
|
) : desktopRuntime === 'electron' ? (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 16V4" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12l4 4 4-4" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 20h14" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 20v-2a1 1 0 011-1h8a1 1 0 011 1v2" />
|
||||||
|
</svg>
|
||||||
) : (
|
) : (
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 9V4a1 1 0 011-1h10a1 1 0 011 1v5" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18H5a2 2 0 01-2-2v-5a2 2 0 012-2h14a2 2 0 012 2v5a2 2 0 01-2 2h-1" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 14h8v7H8z" />
|
||||||
|
<circle cx="17" cy="11.5" r="0.75" fill="currentColor" stroke="none" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -756,7 +707,7 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={`prose prose-slate dark:prose-invert p-8 ${isFocusMode ? '' : 'max-w-none'} [&_code]:font-mono [&_pre]:font-mono [&_img]:max-w-full [&_img]:rounded-lg [&_img]:shadow-md`}
|
className={`prose prose-slate dark:prose-invert p-8 ${isFocusMode ? '' : 'max-w-none'} [&_code]:font-mono [&_pre]:font-mono [&_code]:py-0 [&_code]:px-1 [&_code]:align-baseline [&_code]:leading-none [&_img]:max-w-full [&_img]:rounded-lg [&_img]:shadow-md`}
|
||||||
style={{ fontSize: `${previewFontSize}px`, fontFamily: previewFont }}
|
style={{ fontSize: `${previewFontSize}px`, fontFamily: previewFont }}
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: marked.parse(processedContent || '', { async: false }) as string
|
__html: marked.parse(processedContent || '', { async: false }) as string
|
||||||
@@ -772,6 +723,13 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
|||||||
onInsertFile={handleInsertFile}
|
onInsertFile={handleInsertFile}
|
||||||
isUploading={isUploading}
|
isUploading={isUploading}
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
onChange={handleAttachmentUpload}
|
||||||
|
className="hidden"
|
||||||
|
accept="image/*,.pdf,.doc,.docx,.txt,.md"
|
||||||
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={localContent}
|
value={localContent}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Note } from '../types';
|
import { Note } from '../types';
|
||||||
|
import { SyncStatus } from '../services/syncManager';
|
||||||
|
import { categoryColorsSync } from '../services/categoryColorsSync';
|
||||||
|
|
||||||
interface NotesListProps {
|
interface NotesListProps {
|
||||||
notes: Note[];
|
notes: Note[];
|
||||||
selectedNoteId: number | null;
|
selectedNoteDraftId: string | null;
|
||||||
onSelectNote: (id: number) => void;
|
onSelectNote: (draftId: string) => void | Promise<void>;
|
||||||
onCreateNote: () => void;
|
onCreateNote: () => void;
|
||||||
onDeleteNote: (note: Note) => void;
|
onDeleteNote: (note: Note) => void;
|
||||||
onSync: () => void;
|
onSync: () => void;
|
||||||
@@ -13,11 +15,14 @@ interface NotesListProps {
|
|||||||
showFavoritesOnly: boolean;
|
showFavoritesOnly: boolean;
|
||||||
onToggleFavorites: () => void;
|
onToggleFavorites: () => void;
|
||||||
hasUnsavedChanges: boolean;
|
hasUnsavedChanges: boolean;
|
||||||
|
syncStatus: SyncStatus;
|
||||||
|
pendingSyncCount: number;
|
||||||
|
isOnline: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NotesList({
|
export function NotesList({
|
||||||
notes,
|
notes,
|
||||||
selectedNoteId,
|
selectedNoteDraftId,
|
||||||
onSelectNote,
|
onSelectNote,
|
||||||
onCreateNote,
|
onCreateNote,
|
||||||
onDeleteNote,
|
onDeleteNote,
|
||||||
@@ -27,24 +32,67 @@ export function NotesList({
|
|||||||
showFavoritesOnly,
|
showFavoritesOnly,
|
||||||
onToggleFavorites,
|
onToggleFavorites,
|
||||||
hasUnsavedChanges,
|
hasUnsavedChanges,
|
||||||
|
syncStatus,
|
||||||
|
pendingSyncCount,
|
||||||
|
isOnline,
|
||||||
}: NotesListProps) {
|
}: NotesListProps) {
|
||||||
const [isSyncing, setIsSyncing] = React.useState(false);
|
const [deleteClickedId, setDeleteClickedId] = React.useState<number | string | null>(null);
|
||||||
const [deleteClickedId, setDeleteClickedId] = React.useState<number | null>(null);
|
const [width, setWidth] = React.useState(() => {
|
||||||
|
const saved = localStorage.getItem('notesListWidth');
|
||||||
|
return saved ? parseInt(saved) : 300;
|
||||||
|
});
|
||||||
|
const [isResizing, setIsResizing] = React.useState(false);
|
||||||
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const isSyncing = syncStatus === 'syncing';
|
||||||
|
const [, forceUpdate] = React.useReducer(x => x + 1, 0);
|
||||||
|
|
||||||
const handleSync = async () => {
|
const handleSync = async () => {
|
||||||
setIsSyncing(true);
|
|
||||||
await onSync();
|
await onSync();
|
||||||
setTimeout(() => setIsSyncing(false), 500);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Listen for category color changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleCustomEvent = () => forceUpdate();
|
||||||
|
window.addEventListener('categoryColorChanged', handleCustomEvent);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('categoryColorChanged', handleCustomEvent);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!isResizing) return;
|
||||||
|
const newWidth = e.clientX - (containerRef.current?.getBoundingClientRect().left || 0);
|
||||||
|
if (newWidth >= 240 && newWidth <= 600) {
|
||||||
|
setWidth(newWidth);
|
||||||
|
localStorage.setItem('notesListWidth', newWidth.toString());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsResizing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isResizing) {
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
document.body.style.cursor = 'ew-resize';
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
document.body.style.cursor = '';
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
};
|
||||||
|
}, [isResizing]);
|
||||||
|
|
||||||
const handleDeleteClick = (note: Note, e: React.MouseEvent) => {
|
const handleDeleteClick = (note: Note, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
// Prevent deletion if there are unsaved changes on a different note
|
|
||||||
if (hasUnsavedChanges && note.id !== selectedNoteId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deleteClickedId === note.id) {
|
if (deleteClickedId === note.id) {
|
||||||
// Second click - actually delete
|
// Second click - actually delete
|
||||||
onDeleteNote(note);
|
onDeleteNote(note);
|
||||||
@@ -72,17 +120,69 @@ export function NotesList({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getPreview = (content: string) => {
|
const getPreview = (content: string) => {
|
||||||
// grab first 100 characters of note's content, remove markdown syntax from the preview
|
// Skip first line (title) and find first non-empty line
|
||||||
const previewContent = content.substring(0, 100);
|
const lines = content.split('\n');
|
||||||
const cleanedPreview = previewContent.replace(/[#*`]/g, '');
|
const contentLines = lines.slice(1); // Skip first line
|
||||||
|
|
||||||
|
// Find first non-empty line
|
||||||
|
const firstContentLine = contentLines.find(line => line.trim().length > 0);
|
||||||
|
if (!firstContentLine) return '';
|
||||||
|
|
||||||
|
// Take up to 100 characters from the content lines
|
||||||
|
const previewContent = contentLines.join(' ').substring(0, 100);
|
||||||
|
const cleanedPreview = previewContent.replace(/[#*`]/g, '').trim();
|
||||||
return cleanedPreview;
|
return cleanedPreview;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getCategoryColor = (category: string) => {
|
||||||
|
// Color palette matching CategoriesSidebar
|
||||||
|
const colors = [
|
||||||
|
{ bg: 'bg-blue-100 dark:bg-blue-900/30', text: 'text-blue-700 dark:text-blue-300' },
|
||||||
|
{ bg: 'bg-green-100 dark:bg-green-900/30', text: 'text-green-700 dark:text-green-300' },
|
||||||
|
{ bg: 'bg-purple-100 dark:bg-purple-900/30', text: 'text-purple-700 dark:text-purple-300' },
|
||||||
|
{ bg: 'bg-pink-100 dark:bg-pink-900/30', text: 'text-pink-700 dark:text-pink-300' },
|
||||||
|
{ bg: 'bg-yellow-100 dark:bg-yellow-900/30', text: 'text-yellow-700 dark:text-yellow-300' },
|
||||||
|
{ bg: 'bg-red-100 dark:bg-red-900/30', text: 'text-red-700 dark:text-red-300' },
|
||||||
|
{ bg: 'bg-orange-100 dark:bg-orange-900/30', text: 'text-orange-700 dark:text-orange-300' },
|
||||||
|
{ bg: 'bg-teal-100 dark:bg-teal-900/30', text: 'text-teal-700 dark:text-teal-300' },
|
||||||
|
{ bg: 'bg-indigo-100 dark:bg-indigo-900/30', text: 'text-indigo-700 dark:text-indigo-300' },
|
||||||
|
{ bg: 'bg-cyan-100 dark:bg-cyan-900/30', text: 'text-cyan-700 dark:text-cyan-300' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Only return color if explicitly set by user
|
||||||
|
const colorIndex = categoryColorsSync.getColor(category);
|
||||||
|
if (colorIndex !== undefined) {
|
||||||
|
return colors[colorIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
// No color set - return null to indicate no badge should be shown
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-80 bg-gray-50 dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 flex flex-col">
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="bg-gray-50 dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 flex flex-col relative flex-shrink-0"
|
||||||
|
style={{ width: `${width}px`, minWidth: '340px', maxWidth: '600px' }}
|
||||||
|
>
|
||||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Notes</h2>
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Notes</h2>
|
||||||
|
{!isOnline && (
|
||||||
|
<span className="px-2 py-0.5 text-xs font-medium bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-300 rounded-full flex items-center gap-1">
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3" />
|
||||||
|
</svg>
|
||||||
|
Offline
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{pendingSyncCount > 0 && (
|
||||||
|
<span className="px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded-full">
|
||||||
|
{pendingSyncCount} pending
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<button
|
<button
|
||||||
onClick={handleSync}
|
onClick={handleSync}
|
||||||
@@ -144,22 +244,18 @@ export function NotesList({
|
|||||||
) : (
|
) : (
|
||||||
notes.map((note) => (
|
notes.map((note) => (
|
||||||
<div
|
<div
|
||||||
key={note.id}
|
key={note.draftId ?? note.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Prevent switching if current note has unsaved changes
|
if (note.draftId) {
|
||||||
if (hasUnsavedChanges && note.id !== selectedNoteId) {
|
void onSelectNote(note.draftId);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
onSelectNote(note.id);
|
|
||||||
}}
|
}}
|
||||||
className={`p-3 border-b border-gray-200 dark:border-gray-700 transition-colors group ${
|
className={`p-3 border-b border-gray-200 dark:border-gray-700 transition-colors group ${
|
||||||
note.id === selectedNoteId
|
note.draftId === selectedNoteDraftId
|
||||||
? 'bg-blue-50 dark:bg-gray-800 border-l-4 border-l-blue-500'
|
? 'bg-blue-50 dark:bg-gray-800 border-l-4 border-l-blue-500'
|
||||||
: hasUnsavedChanges
|
|
||||||
? 'cursor-not-allowed opacity-50'
|
|
||||||
: 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800'
|
: 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
title={hasUnsavedChanges && note.id !== selectedNoteId ? 'Save current note before switching' : ''}
|
title={hasUnsavedChanges && note.draftId !== selectedNoteDraftId ? 'Saving current note before switching' : ''}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-1">
|
<div className="flex items-start justify-between mb-1">
|
||||||
<div className="flex items-center flex-1 min-w-0">
|
<div className="flex items-center flex-1 min-w-0">
|
||||||
@@ -194,8 +290,24 @@ export function NotesList({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 mb-2">
|
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||||
<span>{formatDate(note.modified)}</span>
|
<span>{formatDate(note.modified)}</span>
|
||||||
|
{note.category && (() => {
|
||||||
|
const colors = getCategoryColor(note.category);
|
||||||
|
if (colors) {
|
||||||
|
return (
|
||||||
|
<span className={`px-2 py-0.5 ${colors.bg} ${colors.text} rounded-full text-xs font-medium`}>
|
||||||
|
{note.category}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Show neutral badge when no color is set
|
||||||
|
return (
|
||||||
|
<span className="px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-full text-xs font-medium">
|
||||||
|
{note.category}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{getPreview(note.content) && (
|
{getPreview(note.content) && (
|
||||||
@@ -207,6 +319,17 @@ export function NotesList({
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Resize Handle */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 right-0 w-1 h-full cursor-ew-resize hover:bg-blue-500 transition-colors group"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsResizing(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-y-0 -right-1 w-3" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
366
src/components/PrintView.tsx
Normal file
366
src/components/PrintView.tsx
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { emit, listen } from '@tauri-apps/api/event';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { getCurrentWindow, LogicalSize } from '@tauri-apps/api/window';
|
||||||
|
import {
|
||||||
|
PrintExportPayload,
|
||||||
|
} from '../printExport';
|
||||||
|
|
||||||
|
interface PrintViewProps {
|
||||||
|
jobId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitForImages = async () => {
|
||||||
|
const images = Array.from(document.images).filter((image) => !image.complete);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
images.map(
|
||||||
|
(image) =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
image.addEventListener('load', () => resolve(), { once: true });
|
||||||
|
image.addEventListener('error', () => resolve(), { once: true });
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PrintView({ jobId }: PrintViewProps) {
|
||||||
|
const [payload, setPayload] = useState<PrintExportPayload | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentWindow = getCurrentWindow();
|
||||||
|
let timeoutId = 0;
|
||||||
|
let cleanup = () => {};
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
cleanup = await listen<PrintExportPayload>(
|
||||||
|
'print-export-payload',
|
||||||
|
(event) => {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
setPayload(event.payload);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: { kind: 'WebviewWindow', label: currentWindow.label },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await emit('print-export-ready', { jobId });
|
||||||
|
|
||||||
|
timeoutId = window.setTimeout(() => {
|
||||||
|
setError('Print data was not received. Please close this window and try exporting again.');
|
||||||
|
}, 5000);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
}, [jobId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!payload) return;
|
||||||
|
|
||||||
|
const currentWindow = getCurrentWindow();
|
||||||
|
let cancelled = false;
|
||||||
|
let printFlowStarted = false;
|
||||||
|
let lostFocusDuringPrint = false;
|
||||||
|
let closeTimerId = 0;
|
||||||
|
let destroyIntervalId = 0;
|
||||||
|
let removeFocusListener = () => {};
|
||||||
|
|
||||||
|
document.title = payload.fileName;
|
||||||
|
|
||||||
|
const closePrintWindow = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (destroyIntervalId) return;
|
||||||
|
|
||||||
|
window.clearTimeout(closeTimerId);
|
||||||
|
closeTimerId = window.setTimeout(() => {
|
||||||
|
destroyIntervalId = window.setInterval(() => {
|
||||||
|
void currentWindow.destroy().catch(() => undefined);
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
void currentWindow.destroy().catch(() => undefined);
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAfterPrint = () => {
|
||||||
|
closePrintWindow();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('afterprint', handleAfterPrint);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
removeFocusListener = await currentWindow.onFocusChanged(({ payload: focused }) => {
|
||||||
|
if (!printFlowStarted) return;
|
||||||
|
|
||||||
|
if (!focused) {
|
||||||
|
lostFocusDuringPrint = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lostFocusDuringPrint) {
|
||||||
|
closePrintWindow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('fonts' in document) {
|
||||||
|
await document.fonts.ready;
|
||||||
|
}
|
||||||
|
await waitForImages();
|
||||||
|
await new Promise<void>((resolve) =>
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
await currentWindow.show().catch(() => undefined);
|
||||||
|
await currentWindow.setFocus().catch(() => undefined);
|
||||||
|
|
||||||
|
window.setTimeout(async () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
printFlowStarted = true;
|
||||||
|
await invoke('plugin:webview|print', {
|
||||||
|
label: currentWindow.label,
|
||||||
|
});
|
||||||
|
await currentWindow
|
||||||
|
.setSize(new LogicalSize(520, 260))
|
||||||
|
.catch(() => undefined);
|
||||||
|
closePrintWindow();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Native webview print failed, falling back to window.print():', err);
|
||||||
|
printFlowStarted = true;
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
}, 120);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to initialize print view:', err);
|
||||||
|
setError('The print view could not be prepared. Please close this window and try again.');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(closeTimerId);
|
||||||
|
window.clearInterval(destroyIntervalId);
|
||||||
|
removeFocusListener();
|
||||||
|
window.removeEventListener('afterprint', handleAfterPrint);
|
||||||
|
};
|
||||||
|
}, [jobId, payload]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100 px-8 py-12 text-gray-900">
|
||||||
|
<div className="mx-auto max-w-2xl rounded-2xl bg-white p-8 shadow-sm">
|
||||||
|
<h1 className="mb-3 text-2xl font-semibold">Print Export Failed</h1>
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100 px-8 py-12 text-gray-900">
|
||||||
|
<div className="mx-auto max-w-2xl rounded-2xl bg-white p-8 shadow-sm">
|
||||||
|
Preparing print view...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{`
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 18mm 16mm 18mm 16mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-shell {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-status {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<div className="print-status min-h-screen bg-slate-100 px-6 py-6 text-slate-900">
|
||||||
|
<div className="mx-auto flex max-w-md items-center gap-4 rounded-2xl bg-white px-5 py-4 shadow-lg">
|
||||||
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-slate-200 border-t-slate-600" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold">Opening system print dialog...</div>
|
||||||
|
<div className="text-sm text-slate-500">{payload.fileName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="print-shell absolute left-[-200vw] top-0 min-h-screen w-[820px] bg-gray-200 px-6 py-8 print:static print:min-h-0 print:w-auto print:bg-white print:px-0 print:py-0">
|
||||||
|
<article
|
||||||
|
className="mx-auto min-h-[calc(100vh-4rem)] max-w-[820px] rounded-[20px] bg-white px-14 py-12 text-slate-900 shadow-xl print:min-h-0 print:max-w-none print:rounded-none print:px-0 print:py-0 print:shadow-none"
|
||||||
|
style={{
|
||||||
|
fontFamily: `"${payload.previewFont}", Georgia, serif`,
|
||||||
|
fontSize: `${payload.previewFontSize}px`,
|
||||||
|
lineHeight: 1.7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<style>{`
|
||||||
|
.print-note {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note h1,
|
||||||
|
.print-note h2,
|
||||||
|
.print-note h3 {
|
||||||
|
color: #020617;
|
||||||
|
font-weight: 700;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin: 0 0 1.35em;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note h2 {
|
||||||
|
font-size: 1.55em;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 1.25em 0 0.45em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note h3 {
|
||||||
|
font-size: 1.25em;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin: 1.1em 0 0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note p {
|
||||||
|
margin: 0 0 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note ul,
|
||||||
|
.print-note ol {
|
||||||
|
margin: 0.75em 0 1em;
|
||||||
|
padding-left: 1.7em;
|
||||||
|
list-style-position: outside;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note ul {
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note ol {
|
||||||
|
list-style-type: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note li {
|
||||||
|
margin: 0.28em 0;
|
||||||
|
padding-left: 0.18em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note li > p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note li::marker {
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note blockquote {
|
||||||
|
margin: 1.15em 0;
|
||||||
|
padding-left: 1em;
|
||||||
|
border-left: 3px solid #cbd5e1;
|
||||||
|
color: #475569;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note blockquote > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note blockquote > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note code {
|
||||||
|
font-family: "Source Code Pro", "Courier New", monospace;
|
||||||
|
font-size: 0.92em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note :not(pre) > code {
|
||||||
|
padding: 0.08em 0.28em;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #dbe4f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note pre {
|
||||||
|
margin: 1em 0 1.15em;
|
||||||
|
padding: 0.9em 1em;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note pre code {
|
||||||
|
font-size: 0.92em;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note a {
|
||||||
|
color: #1d4ed8;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin: 1.25em auto;
|
||||||
|
border-radius: 10px;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note hr {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid #cbd5e1;
|
||||||
|
margin: 1.6em 0;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<div
|
||||||
|
className="print-note"
|
||||||
|
dangerouslySetInnerHTML={{ __html: payload.html }}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
src/db/localDB.ts
Normal file
155
src/db/localDB.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { Note } from '../types';
|
||||||
|
|
||||||
|
const DB_NAME = 'nextcloud-notes-db';
|
||||||
|
const DB_VERSION = 2; // Bumped to clear old cache with URL-encoded categories
|
||||||
|
const NOTES_STORE = 'notes';
|
||||||
|
const SYNC_QUEUE_STORE = 'syncQueue';
|
||||||
|
|
||||||
|
export interface SyncOperation {
|
||||||
|
id: string;
|
||||||
|
type: 'create' | 'update' | 'delete';
|
||||||
|
noteId: number | string;
|
||||||
|
note?: Note;
|
||||||
|
timestamp: number;
|
||||||
|
retryCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LocalDB {
|
||||||
|
private db: IDBDatabase | null = null;
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
this.db = request.result;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = (event.target as IDBOpenDBRequest).result;
|
||||||
|
const oldVersion = event.oldVersion;
|
||||||
|
|
||||||
|
if (!db.objectStoreNames.contains(NOTES_STORE)) {
|
||||||
|
const notesStore = db.createObjectStore(NOTES_STORE, { keyPath: 'id' });
|
||||||
|
notesStore.createIndex('modified', 'modified', { unique: false });
|
||||||
|
notesStore.createIndex('category', 'category', { unique: false });
|
||||||
|
} else if (oldVersion < 2) {
|
||||||
|
// Clear notes store when upgrading to v2 to remove old cached notes
|
||||||
|
// with stripped first lines
|
||||||
|
const transaction = (event.target as IDBOpenDBRequest).transaction!;
|
||||||
|
const notesStore = transaction.objectStore(NOTES_STORE);
|
||||||
|
notesStore.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db.objectStoreNames.contains(SYNC_QUEUE_STORE)) {
|
||||||
|
db.createObjectStore(SYNC_QUEUE_STORE, { keyPath: 'id' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStore(storeName: string, mode: IDBTransactionMode = 'readonly'): IDBObjectStore {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
const transaction = this.db.transaction(storeName, mode);
|
||||||
|
return transaction.objectStore(storeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notes operations
|
||||||
|
async getAllNotes(): Promise<Note[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const store = this.getStore(NOTES_STORE);
|
||||||
|
const request = store.getAll();
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNote(id: number | string): Promise<Note | undefined> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const store = this.getStore(NOTES_STORE);
|
||||||
|
const request = store.get(id);
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveNote(note: Note): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const store = this.getStore(NOTES_STORE, 'readwrite');
|
||||||
|
const request = store.put(note);
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveNotes(notes: Note[]): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const store = this.getStore(NOTES_STORE, 'readwrite');
|
||||||
|
const transaction = store.transaction;
|
||||||
|
|
||||||
|
notes.forEach(note => store.put(note));
|
||||||
|
|
||||||
|
transaction.oncomplete = () => resolve();
|
||||||
|
transaction.onerror = () => reject(transaction.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteNote(id: number | string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const store = this.getStore(NOTES_STORE, 'readwrite');
|
||||||
|
const request = store.delete(id);
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearNotes(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const store = this.getStore(NOTES_STORE, 'readwrite');
|
||||||
|
const request = store.clear();
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync queue operations
|
||||||
|
async addToSyncQueue(operation: SyncOperation): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const store = this.getStore(SYNC_QUEUE_STORE, 'readwrite');
|
||||||
|
const request = store.put(operation);
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSyncQueue(): Promise<SyncOperation[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const store = this.getStore(SYNC_QUEUE_STORE);
|
||||||
|
const request = store.getAll();
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeFromSyncQueue(id: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const store = this.getStore(SYNC_QUEUE_STORE, 'readwrite');
|
||||||
|
const request = store.delete(id);
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearSyncQueue(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const store = this.getStore(SYNC_QUEUE_STORE, 'readwrite');
|
||||||
|
const request = store.clear();
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const localDB = new LocalDB();
|
||||||
20
src/hooks/useOnlineStatus.ts
Normal file
20
src/hooks/useOnlineStatus.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useOnlineStatus() {
|
||||||
|
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnline = () => setIsOnline(true);
|
||||||
|
const handleOffline = () => setIsOnline(false);
|
||||||
|
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
window.addEventListener('offline', handleOffline);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline);
|
||||||
|
window.removeEventListener('offline', handleOffline);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isOnline;
|
||||||
|
}
|
||||||
@@ -16,6 +16,19 @@ code {
|
|||||||
monospace;
|
monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Override Tailwind prose inline code styling to prevent overlap */
|
||||||
|
.prose code {
|
||||||
|
padding-top: 0 !important;
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
vertical-align: baseline !important;
|
||||||
|
line-height: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose code::before,
|
||||||
|
.prose code::after {
|
||||||
|
content: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* TipTap Editor Styles */
|
/* TipTap Editor Styles */
|
||||||
.ProseMirror {
|
.ProseMirror {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
@@ -113,11 +126,13 @@ code {
|
|||||||
|
|
||||||
.ProseMirror code {
|
.ProseMirror code {
|
||||||
background-color: #f3f4f6;
|
background-color: #f3f4f6;
|
||||||
padding: 0.125rem 0.25rem;
|
padding: 0.05rem 0.25rem;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
|
vertical-align: baseline;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .ProseMirror code {
|
.dark .ProseMirror code {
|
||||||
@@ -198,3 +213,29 @@ code {
|
|||||||
.dark .ProseMirror hr {
|
.dark .ProseMirror hr {
|
||||||
border-top-color: #374151;
|
border-top-color: #374151;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Task list styling for preview mode */
|
||||||
|
.prose ul li:has(> input[type="checkbox"]) {
|
||||||
|
list-style: none;
|
||||||
|
margin-left: -1.5em;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose ul:has(li > input[type="checkbox"]) {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose input[type="checkbox"] {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
margin-top: 0.32rem;
|
||||||
|
cursor: default;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose input[type="checkbox"]:checked {
|
||||||
|
accent-color: #16a34a;
|
||||||
|
}
|
||||||
|
|||||||
369
src/printExport.ts
Normal file
369
src/printExport.ts
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
export interface PrintExportPayload {
|
||||||
|
fileName: string;
|
||||||
|
title: string;
|
||||||
|
html: string;
|
||||||
|
previewFont: string;
|
||||||
|
previewFontSize: number;
|
||||||
|
previewFontFaceCss?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRINT_EXPORT_QUERY_PARAM = 'printJob';
|
||||||
|
|
||||||
|
const PRINT_DOCUMENT_CSP = [
|
||||||
|
"default-src 'none'",
|
||||||
|
"style-src 'unsafe-inline'",
|
||||||
|
"img-src data: blob: https: http:",
|
||||||
|
"font-src data:",
|
||||||
|
"object-src 'none'",
|
||||||
|
"base-uri 'none'",
|
||||||
|
].join('; ');
|
||||||
|
|
||||||
|
export const getNoteTitleFromContent = (content: string) => {
|
||||||
|
const firstLine = content
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find((line) => line.length > 0);
|
||||||
|
|
||||||
|
return (firstLine || 'Untitled').replace(/^#+\s*/, '').trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sanitizeFileName = (name: string) =>
|
||||||
|
name
|
||||||
|
.replace(/[\\/:*?"<>|]/g, '-')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim() || 'note';
|
||||||
|
|
||||||
|
const escapeHtml = (value: string) =>
|
||||||
|
value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
|
||||||
|
const escapeFontFamily = (value: string) =>
|
||||||
|
value.replace(/["\\]/g, '\\$&');
|
||||||
|
|
||||||
|
interface PrintFontAsset {
|
||||||
|
fileName: string;
|
||||||
|
fontStyle: 'normal' | 'italic';
|
||||||
|
fontWeight: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRINT_FONT_ASSETS: Record<string, PrintFontAsset[]> = {
|
||||||
|
Merriweather: [
|
||||||
|
{
|
||||||
|
fileName: 'Merriweather-VariableFont_opsz,wdth,wght.ttf',
|
||||||
|
fontStyle: 'normal',
|
||||||
|
fontWeight: '300 900',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileName: 'Merriweather-Italic-VariableFont_opsz,wdth,wght.ttf',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
fontWeight: '300 900',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'Crimson Pro': [
|
||||||
|
{
|
||||||
|
fileName: 'CrimsonPro-VariableFont_wght.ttf',
|
||||||
|
fontStyle: 'normal',
|
||||||
|
fontWeight: '200 900',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileName: 'CrimsonPro-Italic-VariableFont_wght.ttf',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
fontWeight: '200 900',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'Roboto Serif': [
|
||||||
|
{
|
||||||
|
fileName: 'RobotoSerif-VariableFont_GRAD,opsz,wdth,wght.ttf',
|
||||||
|
fontStyle: 'normal',
|
||||||
|
fontWeight: '100 900',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileName: 'RobotoSerif-Italic-VariableFont_GRAD,opsz,wdth,wght.ttf',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
fontWeight: '100 900',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Average: [
|
||||||
|
{
|
||||||
|
fileName: 'Average-Regular.ttf',
|
||||||
|
fontStyle: 'normal',
|
||||||
|
fontWeight: '400',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const fontDataUrlCache = new Map<string, Promise<string>>();
|
||||||
|
|
||||||
|
const blobToDataUrl = (blob: Blob) =>
|
||||||
|
new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = () => reject(reader.error ?? new Error('Failed to read font file.'));
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
|
||||||
|
const getBundledFontUrl = (fileName: string) =>
|
||||||
|
new URL(`./fonts/${fileName}`, window.location.href).toString();
|
||||||
|
|
||||||
|
const loadBundledFontDataUrl = async (fileName: string) => {
|
||||||
|
const cached = fontDataUrlCache.get(fileName);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = (async () => {
|
||||||
|
const response = await fetch(getBundledFontUrl(fileName));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load bundled font ${fileName}: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return blobToDataUrl(await response.blob());
|
||||||
|
})();
|
||||||
|
|
||||||
|
fontDataUrlCache.set(fileName, pending);
|
||||||
|
return pending;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadPrintFontFaceCss = async (fontFamily: string) => {
|
||||||
|
const fontAssets = PRINT_FONT_ASSETS[fontFamily];
|
||||||
|
if (!fontAssets) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = await Promise.all(
|
||||||
|
fontAssets.map(async ({ fileName, fontStyle, fontWeight }) => {
|
||||||
|
try {
|
||||||
|
const dataUrl = await loadBundledFontDataUrl(fileName);
|
||||||
|
return `@font-face {
|
||||||
|
font-family: "${escapeFontFamily(fontFamily)}";
|
||||||
|
font-style: ${fontStyle};
|
||||||
|
font-weight: ${fontWeight};
|
||||||
|
font-display: swap;
|
||||||
|
src: url("${dataUrl}") format("truetype");
|
||||||
|
}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to embed preview font "${fontFamily}" from ${fileName}:`, error);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return rules.filter(Boolean).join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildPrintDocument = (payload: PrintExportPayload) => {
|
||||||
|
const fontFamily = `"${escapeFontFamily(payload.previewFont)}", Georgia, serif`;
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="${PRINT_DOCUMENT_CSP}" />
|
||||||
|
<title>${escapeHtml(payload.fileName)}</title>
|
||||||
|
<style>
|
||||||
|
${payload.previewFontFaceCss ?? ''}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: ${fontFamily};
|
||||||
|
font-size: ${payload.previewFontSize}px;
|
||||||
|
line-height: 1.7;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 18mm 16mm 18mm 16mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
article {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note h1,
|
||||||
|
.print-note h2,
|
||||||
|
.print-note h3 {
|
||||||
|
color: #020617;
|
||||||
|
font-weight: 700;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin: 0 0 1.35em;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note h2 {
|
||||||
|
font-size: 1.55em;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 1.25em 0 0.45em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note h3 {
|
||||||
|
font-size: 1.25em;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin: 1.1em 0 0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note p {
|
||||||
|
margin: 0 0 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note ul,
|
||||||
|
.print-note ol {
|
||||||
|
margin: 0.75em 0 1em;
|
||||||
|
padding-left: 1.7em;
|
||||||
|
list-style-position: outside;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note ul {
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note ol {
|
||||||
|
list-style-type: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note li {
|
||||||
|
margin: 0.28em 0;
|
||||||
|
padding-left: 0.18em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note li > p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note li::marker {
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note blockquote {
|
||||||
|
margin: 1.15em 0;
|
||||||
|
padding-left: 1em;
|
||||||
|
border-left: 3px solid #cbd5e1;
|
||||||
|
color: #475569;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note blockquote > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note blockquote > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note pre {
|
||||||
|
margin: 1.15em 0 1.3em;
|
||||||
|
padding: 0.95em 1.05em;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dbe4f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f5f7fb;
|
||||||
|
color: #0f172a;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note pre code {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
font-size: 0.92em;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note code {
|
||||||
|
font-family: "SFMono-Regular", "SF Mono", "JetBrains Mono", "Fira Code", "Source Code Pro", Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.92em;
|
||||||
|
padding: 0.08em 0.38em;
|
||||||
|
border: 1px solid #dbe4f0;
|
||||||
|
border-radius: 0.42em;
|
||||||
|
background: #f5f7fb;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note a {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-thickness: 0.08em;
|
||||||
|
text-underline-offset: 0.14em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note em {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note del {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note hr {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid #cbd5e1;
|
||||||
|
margin: 1.6em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin: 1.2em auto;
|
||||||
|
border-radius: 12px;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1em 0 1.2em;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note th,
|
||||||
|
.print-note td {
|
||||||
|
border: 1px solid #dbe4f0;
|
||||||
|
padding: 0.5em 0.65em;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-note th {
|
||||||
|
background: #f8fafc;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<article class="print-note">${payload.html}</article>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
};
|
||||||
98
src/services/categoryColorsSync.ts
Normal file
98
src/services/categoryColorsSync.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { NextcloudAPI } from '../api/nextcloud';
|
||||||
|
|
||||||
|
export class CategoryColorsSync {
|
||||||
|
private api: NextcloudAPI | null = null;
|
||||||
|
private colors: Record<string, number> = {};
|
||||||
|
private syncInProgress: boolean = false;
|
||||||
|
private changeCallback: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.loadFromLocalStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
setAPI(api: NextcloudAPI | null) {
|
||||||
|
this.api = api;
|
||||||
|
if (api) {
|
||||||
|
this.syncFromServer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setChangeCallback(callback: () => void) {
|
||||||
|
this.changeCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadFromLocalStorage() {
|
||||||
|
const saved = localStorage.getItem('categoryColors');
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
this.colors = JSON.parse(saved);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse category colors from localStorage:', e);
|
||||||
|
this.colors = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveToLocalStorage() {
|
||||||
|
localStorage.setItem('categoryColors', JSON.stringify(this.colors));
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyChange() {
|
||||||
|
if (this.changeCallback) {
|
||||||
|
this.changeCallback();
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new Event('categoryColorChanged'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncFromServer(): Promise<void> {
|
||||||
|
if (!this.api || this.syncInProgress) return;
|
||||||
|
|
||||||
|
this.syncInProgress = true;
|
||||||
|
try {
|
||||||
|
const serverColors = await this.api.fetchCategoryColors();
|
||||||
|
|
||||||
|
// Merge: server wins for conflicts
|
||||||
|
const hasChanges = JSON.stringify(this.colors) !== JSON.stringify(serverColors);
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
this.colors = serverColors;
|
||||||
|
this.saveToLocalStorage();
|
||||||
|
this.notifyChange();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to sync category colors from server:', error);
|
||||||
|
} finally {
|
||||||
|
this.syncInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setColor(category: string, colorIndex: number | null): Promise<void> {
|
||||||
|
if (colorIndex === null) {
|
||||||
|
delete this.colors[category];
|
||||||
|
} else {
|
||||||
|
this.colors[category] = colorIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveToLocalStorage();
|
||||||
|
this.notifyChange();
|
||||||
|
|
||||||
|
// Sync to server if online
|
||||||
|
if (this.api) {
|
||||||
|
try {
|
||||||
|
await this.api.saveCategoryColors(this.colors);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save category colors to server:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getColor(category: string): number | undefined {
|
||||||
|
return this.colors[category];
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllColors(): Record<string, number> {
|
||||||
|
return { ...this.colors };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const categoryColorsSync = new CategoryColorsSync();
|
||||||
139
src/services/desktop.ts
Normal file
139
src/services/desktop.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import {
|
||||||
|
buildPrintDocument,
|
||||||
|
PrintExportPayload,
|
||||||
|
PRINT_EXPORT_QUERY_PARAM,
|
||||||
|
} from '../printExport';
|
||||||
|
|
||||||
|
export interface DesktopMessageOptions {
|
||||||
|
title?: string;
|
||||||
|
kind?: 'info' | 'warning' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportPdfResult {
|
||||||
|
canceled: boolean;
|
||||||
|
filePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isElectronRuntime = () =>
|
||||||
|
typeof window !== 'undefined' && typeof window.electronDesktop !== 'undefined';
|
||||||
|
|
||||||
|
const isTauriRuntime = () =>
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
(
|
||||||
|
typeof (window as Window & { __TAURI__?: unknown }).__TAURI__ !== 'undefined' ||
|
||||||
|
typeof (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ !== 'undefined'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getDesktopRuntime = () => {
|
||||||
|
if (isElectronRuntime()) return 'electron';
|
||||||
|
if (isTauriRuntime()) return 'tauri';
|
||||||
|
return 'browser';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const showDesktopMessage = async (
|
||||||
|
messageText: string,
|
||||||
|
options: DesktopMessageOptions = {}
|
||||||
|
) => {
|
||||||
|
if (window.electronDesktop) {
|
||||||
|
await window.electronDesktop.showMessage({
|
||||||
|
message: messageText,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTauriRuntime()) {
|
||||||
|
const { message } = await import('@tauri-apps/plugin-dialog');
|
||||||
|
await message(messageText, options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.alert(messageText);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const exportPdfDocument = async (
|
||||||
|
payload: PrintExportPayload
|
||||||
|
): Promise<ExportPdfResult> => {
|
||||||
|
if (window.electronDesktop) {
|
||||||
|
return window.electronDesktop.exportPdf({
|
||||||
|
...payload,
|
||||||
|
documentHtml: buildPrintDocument(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTauriRuntime()) {
|
||||||
|
await exportPdfDocumentWithTauri(payload);
|
||||||
|
return { canceled: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('PDF export is only available in a desktop runtime.');
|
||||||
|
};
|
||||||
|
|
||||||
|
async function exportPdfDocumentWithTauri(payload: PrintExportPayload) {
|
||||||
|
const [{ emitTo, listen }, { WebviewWindow }] = await Promise.all([
|
||||||
|
import('@tauri-apps/api/event'),
|
||||||
|
import('@tauri-apps/api/webviewWindow'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const jobId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const windowLabel = `print-export-${jobId}`;
|
||||||
|
let printWindow: any = null;
|
||||||
|
let unlistenReady: any = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
unlistenReady?.();
|
||||||
|
reject(new Error('Print view initialization timed out.'));
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
listen<{ jobId: string }>('print-export-ready', (event) => {
|
||||||
|
if (settled || event.payload.jobId !== jobId) return;
|
||||||
|
|
||||||
|
settled = true;
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
unlistenReady?.();
|
||||||
|
|
||||||
|
void emitTo(windowLabel, 'print-export-payload', payload)
|
||||||
|
.then(() => resolve())
|
||||||
|
.catch(reject);
|
||||||
|
})
|
||||||
|
.then((unlisten) => {
|
||||||
|
unlistenReady = unlisten;
|
||||||
|
printWindow = new WebviewWindow(windowLabel, {
|
||||||
|
url: `/?${PRINT_EXPORT_QUERY_PARAM}=${encodeURIComponent(jobId)}`,
|
||||||
|
title: 'Opening Print Dialog',
|
||||||
|
width: 960,
|
||||||
|
height: 1100,
|
||||||
|
center: true,
|
||||||
|
visible: true,
|
||||||
|
focus: true,
|
||||||
|
skipTaskbar: true,
|
||||||
|
resizable: false,
|
||||||
|
parent: 'main',
|
||||||
|
});
|
||||||
|
|
||||||
|
void printWindow.once('tauri://error', (event: { payload?: unknown }) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
unlistenReady?.();
|
||||||
|
reject(new Error(String(event.payload ?? 'Failed to create print window.')));
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (printWindow) {
|
||||||
|
void printWindow.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (typeof unlistenReady === 'function') {
|
||||||
|
unlistenReady();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
155
src/services/runtimeFetch.ts
Normal file
155
src/services/runtimeFetch.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
interface ElectronHttpRequest {
|
||||||
|
url: string;
|
||||||
|
method?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
bodyText?: string;
|
||||||
|
bodyBase64?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElectronHttpResponse {
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
statusText: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
bodyBase64: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuntimeResponse {
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
statusText: string;
|
||||||
|
headers: {
|
||||||
|
get(name: string): string | null;
|
||||||
|
};
|
||||||
|
text(): Promise<string>;
|
||||||
|
json<T>(): Promise<T>;
|
||||||
|
arrayBuffer(): Promise<ArrayBuffer>;
|
||||||
|
blob(): Promise<Blob>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textDecoder = new TextDecoder();
|
||||||
|
|
||||||
|
const bytesToBase64 = (bytes: Uint8Array) => {
|
||||||
|
let binary = '';
|
||||||
|
|
||||||
|
for (let index = 0; index < bytes.length; index += 0x8000) {
|
||||||
|
const chunk = bytes.subarray(index, index + 0x8000);
|
||||||
|
binary += String.fromCharCode(...chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
return btoa(binary);
|
||||||
|
};
|
||||||
|
|
||||||
|
const base64ToBytes = (value: string) => {
|
||||||
|
if (!value) {
|
||||||
|
return new Uint8Array(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const binary = atob(value);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
|
||||||
|
for (let index = 0; index < binary.length; index += 1) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeHeaders = (headers: Record<string, string>) => {
|
||||||
|
const normalized = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
normalized.set(key.toLowerCase(), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRuntimeResponse = (response: ElectronHttpResponse): RuntimeResponse => {
|
||||||
|
const headers = normalizeHeaders(response.headers);
|
||||||
|
const bytes = base64ToBytes(response.bodyBase64);
|
||||||
|
const contentType = headers.get('content-type') || '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: response.ok,
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: {
|
||||||
|
get(name: string) {
|
||||||
|
return headers.get(name.toLowerCase()) ?? null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async text() {
|
||||||
|
return textDecoder.decode(bytes);
|
||||||
|
},
|
||||||
|
async json<T>() {
|
||||||
|
return JSON.parse(textDecoder.decode(bytes)) as T;
|
||||||
|
},
|
||||||
|
async arrayBuffer() {
|
||||||
|
return bytes.buffer.slice(
|
||||||
|
bytes.byteOffset,
|
||||||
|
bytes.byteOffset + bytes.byteLength,
|
||||||
|
) as ArrayBuffer;
|
||||||
|
},
|
||||||
|
async blob() {
|
||||||
|
return new Blob([bytes], { type: contentType });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const headersToObject = (headers?: HeadersInit) => {
|
||||||
|
if (!headers) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(new Headers(headers).entries());
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializeBody = async (body?: BodyInit | null) => {
|
||||||
|
if (body == null) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body === 'string') {
|
||||||
|
return { bodyText: body };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body instanceof ArrayBuffer) {
|
||||||
|
return { bodyBase64: bytesToBase64(new Uint8Array(body)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ArrayBuffer.isView(body)) {
|
||||||
|
return {
|
||||||
|
bodyBase64: bytesToBase64(
|
||||||
|
new Uint8Array(body.buffer, body.byteOffset, body.byteLength),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body instanceof Blob) {
|
||||||
|
return {
|
||||||
|
bodyBase64: bytesToBase64(new Uint8Array(await body.arrayBuffer())),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unsupported request body for Electron runtime.');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runtimeFetch = async (
|
||||||
|
url: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
): Promise<Response | RuntimeResponse> => {
|
||||||
|
if (!window.electronDesktop?.httpRequest) {
|
||||||
|
return fetch(url, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: ElectronHttpRequest = {
|
||||||
|
url,
|
||||||
|
method: init.method,
|
||||||
|
headers: headersToObject(init.headers),
|
||||||
|
...(await serializeBody(init.body)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await window.electronDesktop.httpRequest(payload);
|
||||||
|
return createRuntimeResponse(response);
|
||||||
|
};
|
||||||
464
src/services/syncManager.ts
Normal file
464
src/services/syncManager.ts
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
import { Note } from '../types';
|
||||||
|
import { NextcloudAPI } from '../api/nextcloud';
|
||||||
|
import { localDB } from '../db/localDB';
|
||||||
|
|
||||||
|
export type SyncStatus = 'idle' | 'syncing' | 'error' | 'offline';
|
||||||
|
|
||||||
|
const createDraftId = () => {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const withLocalNoteFields = (note: Note, existing?: Note): Note => ({
|
||||||
|
...note,
|
||||||
|
draftId: note.draftId ?? existing?.draftId ?? createDraftId(),
|
||||||
|
localOnly: note.localOnly ?? existing?.localOnly ?? false,
|
||||||
|
pendingSave: note.pendingSave ?? existing?.pendingSave ?? false,
|
||||||
|
isSaving: false,
|
||||||
|
saveError: note.saveError ?? existing?.saveError ?? null,
|
||||||
|
lastSavedAt: note.lastSavedAt ?? existing?.lastSavedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toStoredNote = (note: Note): Note => ({
|
||||||
|
...note,
|
||||||
|
isSaving: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getCachedNotes = async (): Promise<Note[]> => {
|
||||||
|
const rawNotes = await localDB.getAllNotes();
|
||||||
|
const normalizedNotes = rawNotes.map(note => withLocalNoteFields(note));
|
||||||
|
const needsNormalization = rawNotes.some((note) => !note.draftId || note.isSaving);
|
||||||
|
|
||||||
|
if (needsNormalization) {
|
||||||
|
await localDB.saveNotes(normalizedNotes.map(toStoredNote));
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedNotes;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SyncManager {
|
||||||
|
private api: NextcloudAPI | null = null;
|
||||||
|
private isOnline: boolean = navigator.onLine;
|
||||||
|
private syncInProgress: boolean = false;
|
||||||
|
private statusCallback: ((status: SyncStatus, pendingCount: number) => void) | null = null;
|
||||||
|
private syncCompleteCallback: (() => void) | null = null;
|
||||||
|
private recentlyModifiedNotes: Set<number | string> = new Set();
|
||||||
|
private readonly PROTECTION_WINDOW_MS = 10000;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
window.addEventListener('online', () => {
|
||||||
|
this.isOnline = true;
|
||||||
|
this.notifyStatus('idle', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('offline', () => {
|
||||||
|
this.isOnline = false;
|
||||||
|
this.notifyStatus('offline', 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setAPI(api: NextcloudAPI | null) {
|
||||||
|
this.api = api;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatusCallback(callback: (status: SyncStatus, pendingCount: number) => void) {
|
||||||
|
this.statusCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSyncCompleteCallback(callback: () => void) {
|
||||||
|
this.syncCompleteCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyStatus(status: SyncStatus, pendingCount: number) {
|
||||||
|
if (this.statusCallback) {
|
||||||
|
this.statusCallback(status, pendingCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load notes: cache-first, then sync in background
|
||||||
|
async loadNotes(): Promise<Note[]> {
|
||||||
|
// Try to load from cache first (instant)
|
||||||
|
const cachedNotes = await getCachedNotes();
|
||||||
|
|
||||||
|
// If we have cached notes and we're offline, return them
|
||||||
|
if (!this.isOnline) {
|
||||||
|
this.notifyStatus('offline', 0);
|
||||||
|
return cachedNotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have cached notes, return them immediately
|
||||||
|
// Then sync in background
|
||||||
|
if (cachedNotes.length > 0) {
|
||||||
|
this.syncInBackground();
|
||||||
|
return cachedNotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No cache - must fetch from server
|
||||||
|
if (!this.api) {
|
||||||
|
throw new Error('API not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.notifyStatus('syncing', 0);
|
||||||
|
await this.fetchAndCacheNotes();
|
||||||
|
await this.syncFavoriteStatus();
|
||||||
|
const notes = await getCachedNotes();
|
||||||
|
this.notifyStatus('idle', 0);
|
||||||
|
return notes;
|
||||||
|
} catch (error) {
|
||||||
|
this.notifyStatus('error', 0);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background sync: compare etags and only fetch changed content
|
||||||
|
private async syncInBackground(): Promise<void> {
|
||||||
|
if (!this.api || this.syncInProgress) return;
|
||||||
|
|
||||||
|
this.syncInProgress = true;
|
||||||
|
try {
|
||||||
|
this.notifyStatus('syncing', 0);
|
||||||
|
|
||||||
|
// Get metadata for all notes (fast - no content)
|
||||||
|
const serverNotes = await this.api.fetchNotesWebDAV();
|
||||||
|
const cachedNotes = await getCachedNotes();
|
||||||
|
|
||||||
|
// Build maps for comparison
|
||||||
|
const serverMap = new Map(serverNotes.map(n => [n.id, n]));
|
||||||
|
const cachedMap = new Map(cachedNotes.map(n => [n.id, n]));
|
||||||
|
|
||||||
|
// Find notes that need content fetched (new or changed etag)
|
||||||
|
const notesToFetch: Note[] = [];
|
||||||
|
for (const serverNote of serverNotes) {
|
||||||
|
const cached = cachedMap.get(serverNote.id);
|
||||||
|
if (cached?.pendingSave) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cached || cached.etag !== serverNote.etag) {
|
||||||
|
notesToFetch.push(withLocalNoteFields(serverNote, cached));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch content for changed notes
|
||||||
|
for (const note of notesToFetch) {
|
||||||
|
try {
|
||||||
|
const fullNote = withLocalNoteFields(
|
||||||
|
await this.api.fetchNoteContentWebDAV(note),
|
||||||
|
cachedMap.get(note.id)
|
||||||
|
);
|
||||||
|
await localDB.saveNote(toStoredNote(fullNote));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch note ${note.id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove deleted notes from cache (but protect recently modified notes)
|
||||||
|
for (const cachedNote of cachedNotes) {
|
||||||
|
if (cachedNote.localOnly) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!serverMap.has(cachedNote.id)) {
|
||||||
|
// Don't delete notes that were recently created/updated (race condition protection)
|
||||||
|
if (!cachedNote.pendingSave && !this.recentlyModifiedNotes.has(cachedNote.id)) {
|
||||||
|
await localDB.deleteNote(cachedNote.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync favorite status from API
|
||||||
|
await this.syncFavoriteStatus();
|
||||||
|
|
||||||
|
this.notifyStatus('idle', 0);
|
||||||
|
|
||||||
|
// Notify that sync is complete so UI can reload
|
||||||
|
if (this.syncCompleteCallback) {
|
||||||
|
this.syncCompleteCallback();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Background sync failed:', error);
|
||||||
|
this.notifyStatus('error', 0);
|
||||||
|
} finally {
|
||||||
|
this.syncInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync favorite status from API to local cache
|
||||||
|
private async syncFavoriteStatus(): Promise<void> {
|
||||||
|
if (!this.api) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Syncing favorite status from API...');
|
||||||
|
const apiMetadata = await this.api.fetchNotesMetadata();
|
||||||
|
const cachedNotes = await getCachedNotes();
|
||||||
|
|
||||||
|
// Map API notes by modified timestamp + category for reliable matching
|
||||||
|
// (titles can differ between API and WebDAV)
|
||||||
|
const apiByTimestamp = new Map<string, {id: number, title: string, favorite: boolean}>();
|
||||||
|
const apiByTitle = new Map<string, {id: number, title: string, favorite: boolean}>();
|
||||||
|
|
||||||
|
for (const apiNote of apiMetadata) {
|
||||||
|
const timestampKey = `${apiNote.modified}:${apiNote.category}`;
|
||||||
|
const titleKey = `${apiNote.category}/${apiNote.title}`;
|
||||||
|
const noteData = { id: apiNote.id, title: apiNote.title, favorite: apiNote.favorite };
|
||||||
|
apiByTimestamp.set(timestampKey, noteData);
|
||||||
|
apiByTitle.set(titleKey, noteData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update favorite status in cache for matching notes
|
||||||
|
for (const cachedNote of cachedNotes) {
|
||||||
|
if (cachedNote.localOnly) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try timestamp match first (most reliable)
|
||||||
|
const timestampKey = `${cachedNote.modified}:${cachedNote.category}`;
|
||||||
|
let apiData = apiByTimestamp.get(timestampKey);
|
||||||
|
|
||||||
|
// Fallback to title match if timestamp doesn't work
|
||||||
|
if (!apiData) {
|
||||||
|
const titleKey = `${cachedNote.category}/${cachedNote.title}`;
|
||||||
|
apiData = apiByTitle.get(titleKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiData && cachedNote.favorite !== apiData.favorite) {
|
||||||
|
console.log(`Updating favorite status for "${cachedNote.title}": ${cachedNote.favorite} -> ${apiData.favorite}`);
|
||||||
|
cachedNote.favorite = apiData.favorite;
|
||||||
|
await localDB.saveNote(toStoredNote(cachedNote));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Favorite status sync complete');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to sync favorite status:', error);
|
||||||
|
// Don't throw - favorite sync is non-critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all notes and cache them
|
||||||
|
private async fetchAndCacheNotes(): Promise<Note[]> {
|
||||||
|
if (!this.api) throw new Error('API not initialized');
|
||||||
|
|
||||||
|
const cachedNotes = await getCachedNotes();
|
||||||
|
const cachedMap = new Map(cachedNotes.map(note => [note.id, note]));
|
||||||
|
const serverNotes = await this.api.fetchNotesWebDAV();
|
||||||
|
const notesWithContent: Note[] = [];
|
||||||
|
|
||||||
|
for (const note of serverNotes) {
|
||||||
|
try {
|
||||||
|
const fullNote = withLocalNoteFields(
|
||||||
|
await this.api.fetchNoteContentWebDAV(note),
|
||||||
|
cachedMap.get(note.id)
|
||||||
|
);
|
||||||
|
notesWithContent.push(fullNote);
|
||||||
|
await localDB.saveNote(toStoredNote(fullNote));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch note ${note.id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return notesWithContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch content for a specific note on-demand
|
||||||
|
async fetchNoteContent(note: Note): Promise<Note> {
|
||||||
|
if (!this.api) {
|
||||||
|
throw new Error('API not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isOnline) {
|
||||||
|
throw new Error('Cannot fetch note content while offline');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fullNote = withLocalNoteFields(await this.api.fetchNoteContentWebDAV(note), note);
|
||||||
|
await localDB.saveNote(toStoredNote(fullNote));
|
||||||
|
return fullNote;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create note on server and cache
|
||||||
|
async createNote(title: string, content: string, category: string): Promise<Note> {
|
||||||
|
if (!this.api) {
|
||||||
|
throw new Error('API not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isOnline) {
|
||||||
|
this.notifyStatus('offline', 0);
|
||||||
|
throw new Error('Cannot create note while offline');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.notifyStatus('syncing', 0);
|
||||||
|
const note = withLocalNoteFields(await this.api.createNoteWebDAV(title, content, category));
|
||||||
|
await localDB.saveNote(toStoredNote(note));
|
||||||
|
|
||||||
|
// Protect this note from being deleted by background sync for a short window
|
||||||
|
this.protectNote(note.id);
|
||||||
|
|
||||||
|
this.notifyStatus('idle', 0);
|
||||||
|
|
||||||
|
// Trigger background sync to fetch any other changes
|
||||||
|
this.syncInBackground().catch(err => console.error('Background sync failed:', err));
|
||||||
|
|
||||||
|
return note;
|
||||||
|
} catch (error) {
|
||||||
|
this.notifyStatus('error', 0);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update favorite status via API
|
||||||
|
async updateFavoriteStatus(note: Note, favorite: boolean): Promise<void> {
|
||||||
|
if (!this.api) {
|
||||||
|
throw new Error('API not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isOnline) {
|
||||||
|
// Update locally, will sync when back online
|
||||||
|
note.favorite = favorite;
|
||||||
|
await localDB.saveNote(toStoredNote(note));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find API ID for this note
|
||||||
|
const apiId = await this.api.findApiIdForNote(note.title, note.category, note.modified);
|
||||||
|
|
||||||
|
if (apiId) {
|
||||||
|
// Update via API
|
||||||
|
await this.api.updateFavoriteStatus(apiId, favorite);
|
||||||
|
console.log(`Updated favorite status for "${note.title}" (API ID: ${apiId})`);
|
||||||
|
} else {
|
||||||
|
console.warn(`Could not find API ID for note: "${note.title}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local cache
|
||||||
|
note.favorite = favorite;
|
||||||
|
await localDB.saveNote(toStoredNote(note));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update favorite status:', error);
|
||||||
|
// Still update locally
|
||||||
|
note.favorite = favorite;
|
||||||
|
await localDB.saveNote(toStoredNote(note));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update note on server and cache
|
||||||
|
async updateNote(note: Note): Promise<Note> {
|
||||||
|
if (!this.api) {
|
||||||
|
throw new Error('API not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isOnline) {
|
||||||
|
this.notifyStatus('offline', 0);
|
||||||
|
throw new Error('Cannot update note while offline');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.notifyStatus('syncing', 0);
|
||||||
|
const oldId = note.id;
|
||||||
|
const updatedNote = withLocalNoteFields(await this.api.updateNoteWebDAV(note), note);
|
||||||
|
|
||||||
|
// If the note ID changed (due to filename change), delete the old cache entry
|
||||||
|
if (oldId !== updatedNote.id) {
|
||||||
|
await localDB.deleteNote(oldId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await localDB.saveNote(toStoredNote(updatedNote));
|
||||||
|
|
||||||
|
// Protect this note from being deleted by background sync for a short window
|
||||||
|
this.protectNote(updatedNote.id);
|
||||||
|
|
||||||
|
this.notifyStatus('idle', 0);
|
||||||
|
|
||||||
|
// Trigger background sync to fetch any other changes
|
||||||
|
this.syncInBackground().catch(err => console.error('Background sync failed:', err));
|
||||||
|
|
||||||
|
return updatedNote;
|
||||||
|
} catch (error) {
|
||||||
|
this.notifyStatus('error', 0);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete note from server and cache
|
||||||
|
async deleteNote(note: Note): Promise<void> {
|
||||||
|
if (!this.api) {
|
||||||
|
throw new Error('API not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isOnline) {
|
||||||
|
this.notifyStatus('offline', 0);
|
||||||
|
throw new Error('Cannot delete note while offline');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.notifyStatus('syncing', 0);
|
||||||
|
await this.api.deleteNoteWebDAV(note);
|
||||||
|
await localDB.deleteNote(note.id);
|
||||||
|
this.notifyStatus('idle', 0);
|
||||||
|
} catch (error) {
|
||||||
|
this.notifyStatus('error', 0);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move note to different category on server and cache
|
||||||
|
async moveNote(note: Note, newCategory: string): Promise<Note> {
|
||||||
|
if (!this.api) {
|
||||||
|
throw new Error('API not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isOnline) {
|
||||||
|
this.notifyStatus('offline', 0);
|
||||||
|
throw new Error('Cannot move note while offline');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.notifyStatus('syncing', 0);
|
||||||
|
const movedNote = withLocalNoteFields(await this.api.moveNoteWebDAV(note, newCategory), note);
|
||||||
|
await localDB.deleteNote(note.id);
|
||||||
|
await localDB.saveNote(toStoredNote(movedNote));
|
||||||
|
|
||||||
|
// Protect the moved note from being deleted by background sync
|
||||||
|
this.protectNote(movedNote.id);
|
||||||
|
|
||||||
|
this.notifyStatus('idle', 0);
|
||||||
|
|
||||||
|
// Trigger background sync to fetch any other changes
|
||||||
|
this.syncInBackground().catch(err => console.error('Background sync failed:', err));
|
||||||
|
|
||||||
|
return movedNote;
|
||||||
|
} catch (error) {
|
||||||
|
this.notifyStatus('error', 0);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual sync with server
|
||||||
|
async syncWithServer(): Promise<void> {
|
||||||
|
if (!this.api || !this.isOnline || this.syncInProgress) return;
|
||||||
|
await this.syncInBackground();
|
||||||
|
}
|
||||||
|
|
||||||
|
getOnlineStatus(): boolean {
|
||||||
|
return this.isOnline;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protect a note from being deleted during background sync for a short window
|
||||||
|
private protectNote(noteId: number | string): void {
|
||||||
|
this.recentlyModifiedNotes.add(noteId);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.recentlyModifiedNotes.delete(noteId);
|
||||||
|
}, this.PROTECTION_WINDOW_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const syncManager = new SyncManager();
|
||||||
10
src/types.ts
10
src/types.ts
@@ -1,5 +1,5 @@
|
|||||||
export interface Note {
|
export interface Note {
|
||||||
id: number;
|
id: number | string; // number for API, string (filename) for WebDAV
|
||||||
etag: string;
|
etag: string;
|
||||||
readonly: boolean;
|
readonly: boolean;
|
||||||
content: string;
|
content: string;
|
||||||
@@ -7,6 +7,14 @@ export interface Note {
|
|||||||
category: string;
|
category: string;
|
||||||
favorite: boolean;
|
favorite: boolean;
|
||||||
modified: number;
|
modified: number;
|
||||||
|
filename?: string; // WebDAV: actual filename on server
|
||||||
|
path?: string; // WebDAV: full path including category
|
||||||
|
draftId?: string; // stable client-side identity across renames/moves
|
||||||
|
localOnly?: boolean; // exists only in local cache until first successful server create
|
||||||
|
pendingSave?: boolean; // local-first dirty flag
|
||||||
|
isSaving?: boolean; // local transient UI state
|
||||||
|
saveError?: string | null; // last save error, if any
|
||||||
|
lastSavedAt?: number; // local timestamp for "Saved" feedback
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface APIConfig {
|
export interface APIConfig {
|
||||||
|
|||||||
45
src/vite-env.d.ts
vendored
45
src/vite-env.d.ts
vendored
@@ -1 +1,46 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ElectronDesktopMessageOptions {
|
||||||
|
message: string;
|
||||||
|
title?: string;
|
||||||
|
kind?: 'info' | 'warning' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElectronDesktopExportPayload {
|
||||||
|
fileName: string;
|
||||||
|
title: string;
|
||||||
|
html: string;
|
||||||
|
previewFont: string;
|
||||||
|
previewFontSize: number;
|
||||||
|
documentHtml: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElectronDesktopExportResult {
|
||||||
|
canceled: boolean;
|
||||||
|
filePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElectronDesktopHttpRequest {
|
||||||
|
url: string;
|
||||||
|
method?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
bodyText?: string;
|
||||||
|
bodyBase64?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElectronDesktopHttpResponse {
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
statusText: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
bodyBase64: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
electronDesktop?: {
|
||||||
|
showMessage: (options: ElectronDesktopMessageOptions) => Promise<void>;
|
||||||
|
exportPdf: (payload: ElectronDesktopExportPayload) => Promise<ElectronDesktopExportResult>;
|
||||||
|
httpRequest: (payload: ElectronDesktopHttpRequest) => Promise<ElectronDesktopHttpResponse>;
|
||||||
|
getRuntime: () => 'electron';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import react from "@vitejs/plugin-react";
|
|||||||
const host = process.env.TAURI_DEV_HOST;
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig(async () => ({
|
export default defineConfig(async ({ command }) => ({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
base: command === "build" ? "./" : "/",
|
||||||
|
|
||||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||||
//
|
//
|
||||||
@@ -17,6 +18,10 @@ export default defineConfig(async () => ({
|
|||||||
port: 1420,
|
port: 1420,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
host: host || false,
|
host: host || false,
|
||||||
|
headers: {
|
||||||
|
"Content-Security-Policy":
|
||||||
|
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; font-src 'self' data:; connect-src 'self' https: http: ws: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'",
|
||||||
|
},
|
||||||
hmr: host
|
hmr: host
|
||||||
? {
|
? {
|
||||||
protocol: "ws",
|
protocol: "ws",
|
||||||
|
|||||||
Reference in New Issue
Block a user