47 Commits

Author SHA1 Message Date
drelich
00e1f47511 Migrate to Electron and implement native PDF export
Major changes:
- Migrate from Tauri to Electron as primary desktop runtime
- Implement native print dialog for PDF export via Electron webview
- Add desktop runtime abstraction layer (supports both Electron and Tauri)
- Implement task list rendering in preview mode
- Add favorite notes sorting to display starred notes at top
- Add attachment upload functionality with file picker
- Improve sync reliability and Unicode filename support
- Add category color sync across devices via WebDAV
- Update documentation for Electron workflow

Technical improvements:
- Add Electron main process and preload bridge
- Create desktop service layer for runtime-agnostic operations
- Implement runtimeFetch for proxying network requests through Electron
- Add PrintView component for native print rendering
- Extract print/PDF utilities to shared module
- Update build configuration for Electron integration
2026-04-06 10:16:18 +02:00
drelich
12b50c2304 Fix note sync reliability and Unicode filename support
- Fixed critical bug where notes with empty category had malformed IDs (leading slash)
- Added URL encoding for Czech and Unicode characters in WebDAV paths
- Fixed filename sanitization to preserve Unicode characters (only remove filesystem-unsafe chars)
- Updated note preview to show first non-empty line after title instead of repeating title
- Adjusted default column widths for better proportions (increased window width to 1300px)
- Protection mechanism now works correctly with proper note ID matching
2026-03-31 09:55:44 +02:00
drelich
525413a08a Bump version to 0.2.2
- Fixed category rename to move notes between folders
- Added support for nested subdirectories (categories with slashes)
- Fixed attachment folder moving during category rename
- Fixed attachment folder sanitization to match upload logic
- Hybrid favorite sync working bidirectionally
2026-03-26 10:20:57 +01:00
drelich
1d15a39b4c Fix attachment folder sanitization to match upload logic
The sanitization regex was inconsistent between uploadAttachment and
moveNoteWebDAV. Upload uses /[^a-zA-Z0-9_-]/g which replaces spaces
with underscores, but move was using /[^\w\s-]/g which kept spaces.
This caused 404 errors when trying to move attachment folders.
2026-03-26 10:02:36 +01:00
drelich
b31f974411 Move attachment folders when moving notes between categories
When renaming categories, notes are moved to new folders but their
.attachments.{noteId} folders were left behind. Now moveNoteWebDAV
also moves the attachment folder to the new category location.
2026-03-26 09:51:43 +01:00
drelich
511ebca4ad Fix category rename to actually move notes between folders
- Changed handleRenameCategory to use moveNote instead of updateNote
- Fixed moveNoteWebDAV to create nested subdirectories for categories with slashes
- Now properly supports hierarchical category structures like 'Parent/Child'
2026-03-26 09:39:45 +01:00
drelich
17c79a3aa8 Bump version to 0.2.1 and increase minimum window width to 900px 2026-03-26 09:30:39 +01:00
drelich
0a5dba2a98 Fix favorite star not showing in editor toolbar after sync
Added note.favorite to useEffect dependencies so localFavorite state
updates when favorite status changes via background sync from mobile.
2026-03-26 09:23:36 +01:00
drelich
8bbd5f9262 Fix bidirectional favorite sync - use timestamp matching in syncFavoriteStatus
The syncFavoriteStatus method was using title matching which fails when
API and WebDAV titles differ. Now uses modified timestamp + category
matching (with title fallback) for reliable bidirectional sync.
2026-03-26 09:18:26 +01:00
drelich
244ba69eed Implement hybrid WebDAV + API favorite sync
- Keep WebDAV for reliable content sync
- Add REST API calls for favorite status only
- Match notes by modified timestamp + category (titles can differ)
- Sync favorites from API after WebDAV sync completes
- Update favorite via API when user toggles star
- Tested and working with mobile app sync
2026-03-26 09:14:42 +01:00
drelich
36733da434 Remove title field again after revert - keep auto-resize behavior
- Removed separate title input field (reverted back in previous commit)
- Title extracted from first line of content
- Favorite star moved to toolbar
- Fixed duplicate import in NotesList
- Auto-resize behavior preserved to avoid scroll jumping issue
2026-03-25 23:48:36 +01:00
drelich
f4ba8c9775 Merge branch 'main' into dev 2026-03-25 23:45:50 +01:00
drelich
dac08f1d2f Revert scroll position changes - restore original auto-resize behavior
The attempts to fix scroll jumping caused other issues. Reverting to original
working state with auto-resize, accepting the scroll-to-top behavior for now.
This can be revisited later with more time for proper testing.
2026-03-25 23:43:14 +01:00
drelich
0a6ecd25da Fix scroll position when typing in long notes
- Preserve textarea scroll position during auto-resize in onChange handler
- Prevents view from jumping to top when typing below the fold
2026-03-25 23:36:02 +01:00
drelich
cb7a8d8276 Major UX improvements: remove title field, auto-sync, fix image uploads
- Remove separate title input field - first line of content is now the title (standard Markdown behavior)
- Update note parsing to extract title from first line while keeping full content
- Move favorite star button to toolbar to save vertical space
- Fix image upload attachment directory path sanitization
- Add automatic background sync after save operations (create, update, move)
- Add rotating sync icon animation during sync operations
- Fix infinite sync loop by preventing sync complete callback from triggering another sync
- Bump IndexedDB version to 2 to clear old cached notes with stripped first lines
- Remove dialog permission errors in attachment upload (use console.log and alert instead)
- Add detailed debug logging for attachment upload troubleshooting
2026-03-25 23:31:27 +01:00
drelich
911662b214 Merge branch 'main' into dev 2026-03-25 20:20:04 +01:00
drelich
dfc0e644eb fix: remove duplicate import in NotesList.tsx 2026-03-25 20:16:27 +01:00
drelich
5a925dc50e feat: WebDAV file access and category color sync (v0.2.0)
Major Changes:
- Switch from Nextcloud Notes API to direct WebDAV file access
- Notes stored as .txt files with filename-based IDs for reliability
- Implement safer sync strategy without clearNotes() to prevent data loss
- Add ETag-based conflict detection for concurrent edits
- Add category color sync to .category-colors.json on server
- Show neutral gray badges for categories without assigned colors

Technical Improvements:
- Replace numeric IDs with filename-based string IDs
- Update Note type to support both number and string IDs
- Implement WebDAV methods: fetchNotesWebDAV, createNoteWebDAV, updateNoteWebDAV, deleteNoteWebDAV
- Add CategoryColorsSync service for server synchronization
- Remove hash-based color fallback (only show colors when explicitly set)

Bug Fixes:
- Fix category badge rendering to show all categories
- Prevent note loss during sync operations
- Improve offline-first functionality with better merge strategy
2026-03-25 20:12:00 +01:00
drelich
70c38cb925 Merge feature/webdav-file-access: WebDAV implementation and improvements 2026-03-25 20:11:28 +01:00
drelich
4f13b0d57f fix: show neutral badge for categories without assigned colors
- Categories without colors now show gray badge instead of no badge
- Categories with colors show colored badge as before
- Bump version to 0.2.0
2026-03-25 20:08:47 +01:00
drelich
4dbf0233b7 fix: add category color sync and remove hash-based fallback
- Add categoryColorsSync service from dev branch
- Add missing fetchCategoryColors() method to NextcloudAPI
- Remove hash-based color fallback in NotesList (only show badges when color explicitly set)
- Initialize categoryColorsSync in App.tsx for server sync
- Category colors now sync to .category-colors.json on server
2026-03-25 19:58:48 +01:00
drelich
5de3cd3789 feat: switch from Notes API to WebDAV file access
- Replace API-based note operations with direct WebDAV file access
- Use filename-based IDs instead of numeric IDs for better reliability
- Implement safer merge strategy that doesn't clear local notes
- Add ETag-based conflict detection to prevent data loss
- Support string | number IDs throughout the codebase
- Notes are now stored as .txt files in /Notes/{category}/
- Eliminates race conditions and temporary ID conflicts
- More reliable sync with direct file system access
2026-03-25 19:47:00 +01:00
drelich
486579809f feat: add category colors sync to Nextcloud server
- Add categoryColorsSync service to sync colors to server
- Store category colors in .category-colors.json file in Notes directory
- Add fetchCategoryColors() and saveCategoryColors() methods to NextcloudAPI
- Initialize categoryColorsSync with API instance on login/logout
- Remove automatic hash-based color assignment for categories
- Only show category badges when colors are explicitly set by user
- Simplify color change event handling using category
2026-03-25 15:45:53 +01:00
drelich
f8b3cc8a9d Merge branch 'main' into dev 2026-03-23 16:11:50 +01:00
drelich
0b13a2df5b feat: add custom category color picker with visual improvements
- Add custom color picker for categories (10 pastel colors)
- Store category colors in localStorage
- Add real-time color updates across components using custom events
- Change folder icons to filled/solid style for better visibility
- Use vibrant darker shades for folder icon colors
- Add 'Remove Color' option to reset category to default
- Add color indicator dots (replaced with filled icons)
- Improve hash distribution using FNV-1a algorithm for auto-assigned colors
- Expand auto-assigned color palette from 10 to 20 colors
2026-03-23 16:08:36 +01:00
drelich
861eb1e103 feat: add custom category color picker with visual improvements
- Add custom color picker for categories (10 pastel colors)
- Store category colors in localStorage
- Add real-time color updates across components using custom events
- Change folder icons to filled/solid style for better visibility
- Use vibrant darker shades for folder icon colors
- Add 'Remove Color' option to reset category to default
- Add color indicator dots (replaced with filled icons)
- Improve hash distribution using FNV-1a algorithm for auto-assigned colors
- Expand auto-assigned color palette from 10 to 20 colors
2026-03-23 16:08:26 +01:00
drelich
edc65f2edd Merge dev: category renaming feature (v0.1.5) 2026-03-21 22:34:22 +01:00
drelich
4ef0814ccd feat: add category renaming functionality (v0.1.5)
- Add double-click to rename categories (deprecated in favor of pencil icon)
- Add pencil icon on hover for intuitive category renaming
- Click pencil icon to enter inline rename mode
- Show helpful hint (Enter to save, Esc to cancel)
- Update all notes with old category name to new name
- Sync category changes to server
- Update selected category if currently viewing renamed category
- Bump version to 0.1.5
2026-03-21 22:34:05 +01:00
drelich
c775661caa Merge dev: PDF export improvements and offline fonts (v0.1.4) 2026-03-21 22:14:32 +01:00
drelich
3e93cf2408 feat: improve PDF export styling and functionality
- Fix inline code padding to prevent overlap with line above
- Add proper heading styles (h1, h2, h3) with correct font sizes
- Add list styling (ul/ol) with proper bullets and numbering
- Embed images as data URLs in PDF export
- Fix list layout issues when images are present
- Add image styling to prevent layout interference
- Remove grey background from inline code for cleaner appearance
2026-03-21 22:14:16 +01:00
drelich
3e3d9ca7f1 feat: embed custom fonts in PDF exports using jsPDF addFont/setFont
- Load TTF font files as base64 from local fonts directory
- Use pdf.addFileToVFS() and pdf.addFont() to register custom fonts
- Use pdf.setFont() to explicitly set preview font before rendering
- Support all preview fonts: Merriweather, Crimson Pro, Roboto Serif, Average
- Include italic variants for proper markdown italic rendering
- Embed Source Code Pro for code blocks
- Maintains efficient file size (~120KB increase vs 18MB with html2canvas)
- Keeps proper margins, pagination, and page breaks
2026-03-21 21:49:50 +01:00
drelich
ed6dd69b32 Merge dev: fix TypeScript build error 2026-03-21 21:34:47 +01:00
drelich
bd6d2cd404 fix: restore hidden file input for InsertToolbar attachment upload
- Add back hidden file input element removed with Attach button
- InsertToolbar still uses file upload functionality
- Fixes TypeScript build error TS6133 (unused handleAttachmentUpload)
- Attachment upload still available via floating insert toolbar
2026-03-21 21:34:30 +01:00
drelich
e86e851b31 Merge dev: UI improvements and offline fonts (v0.1.4) 2026-03-21 21:30:54 +01:00
drelich
e9ba48d7d4 feat: implement offline fonts support (v0.1.4)
- Add local font files to public/fonts directory
- Replace Google Fonts CDN with local @font-face declarations
- Include both regular and italic variants for preview fonts
- Remove unused editor italic fonts to reduce bundle size
- App now fully functional offline without external dependencies
- Total font bundle: ~22.8 MB (10 font files)
2026-03-21 21:30:37 +01:00
drelich
c5c963200a refactor: remove Attach button from note editor header
- Remove confusing attachment upload button from header
- Simplify UI by keeping only category selector and preview toggle
- Attachment functionality removed from this section
2026-03-21 21:18:05 +01:00
drelich
013e7670f5 feat: add UI improvements to notes list
- Add color-coded category badges with consistent pastel colors
- Fix scroll jump issue when editing at bottom of note
- Add localStorage persistence for notes list width
- Prevent notes list from shrinking with flex-shrink-0
- Preserve cursor and scroll position during textarea resize
2026-03-21 21:12:58 +01:00
drelich
23ef338e47 Merge dev: offline-first functionality (v0.1.3) 2026-03-21 21:01:18 +01:00
drelich
1667c6cf13 chore: bump version to 0.1.3 2026-03-21 21:00:48 +01:00
drelich
6172abbe53 feat: implement offline-first functionality with local storage
- Add IndexedDB storage layer for notes (src/db/localDB.ts)
- Implement sync manager with queue and conflict resolution (src/services/syncManager.ts)
- Add online/offline detection hook (src/hooks/useOnlineStatus.ts)
- Load notes from local storage immediately on app startup
- Add sync status UI indicators (offline badge, pending count)
- Auto-sync every 5 minutes when online
- Queue operations when offline, sync when connection restored
- Fix note content update when synced from server while viewing
- Retry failed sync operations up to 5 times
- Temporary IDs for offline-created notes
2026-03-21 21:00:14 +01:00
drelich
4ddf2d15a9 chore: update Tauri config version to 0.1.2 2026-03-21 08:46:54 +01:00
drelich
472e6e3b2e Merge dev: collapsible settings and resizable notes list (v0.1.2) 2026-03-21 08:45:45 +01:00
drelich
e3a1d74413 feat: add collapsible settings panel and resizable notes list (v0.1.2)
- Settings panel in categories sidebar now collapses/expands with toggle button
- Settings collapsed by default to save space
- Notes list column now resizable with drag handle (240px-600px range)
- Improved UI flexibility and space management
2026-03-21 08:43:58 +01:00
drelich
c147890138 Fix preview mode stale content bug when switching notes (v0.1.1)
- Fixed race condition where image processing ran before localContent updated
- Added synchronization guard to prevent processing stale content
- Added comprehensive logging for debugging note switches
- Bumped version to 0.1.1
2026-03-18 17:42:39 +01:00
drelich
7d992d103c Add app screenshot to README header
- Display nextcloud-notes-tauri.png at top of README
- Provides visual branding before project title
2026-03-18 16:45:48 +01:00
drelich
2a3b733178 Add app screenshot to README header 2026-03-18 16:43:42 +01:00
drelich
c11e792062 Initial release: Nextcloud Notes Desktop App
A cross-platform desktop application for Nextcloud Notes built with Tauri, React, and TypeScript.

Features:
- Full Nextcloud Notes integration with real-time sync
- Rich markdown editor with live preview
- Category management and organization
- Image and attachment support
- Customizable fonts and UI themes
- Focus mode for distraction-free writing
- Floating toolbar for quick formatting
- PDF export functionality
- Offline mode support

Tech Stack:
- Tauri (Rust backend)
- React + TypeScript
- TailwindCSS for styling
- Vite for build tooling
- Markdown-it for rendering
2026-03-18 16:03:53 +01:00
38 changed files with 9216 additions and 529 deletions

1
.gitignore vendored
View File

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

@@ -1,83 +1,143 @@
# Tauri + React + Typescript ![nextcloud-notes-tauri.png](src/assets/nextcloud-notes-tauri.png)
# 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
View 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
View 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',
});

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
]
}
} }
} }

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

90
public/fonts/fonts.css Normal file
View 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');
}

View File

@@ -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",
{ {

View File

@@ -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,

View File

@@ -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;

View File

@@ -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 || ''),
};
}
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

View File

@@ -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>
); );
} }

View File

@@ -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: ![alt](path) // Find all image references in markdown: ![alt](path)
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, `![${alt}](${dataUrl})`);
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, `![${alt}](${dataUrl})`);
} 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}

View File

@@ -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>
); );
} }

View 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
View 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();

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

View File

@@ -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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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>`;
};

View 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
View 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();
}
}
}

View 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
View 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();

View File

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

@@ -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';
};
}

View File

@@ -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",