45 Commits

Author SHA1 Message Date
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
28 changed files with 1928 additions and 303 deletions

View File

@@ -1,3 +1,5 @@
![nextcloud-notes-tauri.png](src/assets/nextcloud-notes-tauri.png)
# Tauri + React + Typescript # Tauri + React + Typescript
# Nextcloud Notes - Cross-Platform Desktop App # Nextcloud Notes - Cross-Platform Desktop App

View File

@@ -4,13 +4,9 @@
<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> <title>Nextcloud Notes</title>
<!-- Editor fonts (monospace) --> <!-- Local fonts for offline support -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="stylesheet" href="/fonts/fonts.css">
<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">
<!-- Preview fonts (serif) -->
<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">
</head> </head>
<body> <body>

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "nextcloud-notes-tauri", "name": "nextcloud-notes-tauri",
"version": "0.1.0", "version": "0.2.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nextcloud-notes-tauri", "name": "nextcloud-notes-tauri",
"version": "0.1.0", "version": "0.2.2",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-dialog": "^2.6.0",

View File

@@ -1,7 +1,7 @@
{ {
"name": "nextcloud-notes-tauri", "name": "nextcloud-notes-tauri",
"private": true, "private": true,
"version": "0.1.0", "version": "0.2.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

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

@@ -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",
@@ -15,7 +15,7 @@
"title": "Nextcloud Notes", "title": "Nextcloud Notes",
"width": 1200, "width": 1200,
"height": 800, "height": 800,
"minWidth": 800, "minWidth": 900,
"minHeight": 600, "minHeight": 600,
"devtools": true "devtools": true
} }

View File

@@ -5,12 +5,16 @@ 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';
function App() { function App() {
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 [selectedNoteId, setSelectedNoteId] = useState<number | 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('');
@@ -25,43 +29,54 @@ function App() {
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();
useEffect(() => { useEffect(() => {
const savedServer = localStorage.getItem('serverURL'); const initApp = async () => {
const savedUsername = localStorage.getItem('username'); await localDB.init();
const savedPassword = localStorage.getItem('password');
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | 'system' | null;
const savedEditorFont = localStorage.getItem('editorFont');
const savedPreviewFont = localStorage.getItem('previewFont');
if (savedTheme) { const savedServer = localStorage.getItem('serverURL');
setTheme(savedTheme); const savedUsername = localStorage.getItem('username');
} const savedPassword = localStorage.getItem('password');
if (savedEditorFont) { const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | 'system' | null;
setEditorFont(savedEditorFont); const savedEditorFont = localStorage.getItem('editorFont');
} const savedPreviewFont = localStorage.getItem('previewFont');
if (savedPreviewFont) {
setPreviewFont(savedPreviewFont);
}
const savedEditorFontSize = localStorage.getItem('editorFontSize');
const savedPreviewFontSize = localStorage.getItem('previewFontSize');
if (savedEditorFontSize) {
setEditorFontSize(parseInt(savedEditorFontSize, 10));
}
if (savedPreviewFontSize) {
setPreviewFontSize(parseInt(savedPreviewFontSize, 10));
}
if (savedServer && savedUsername && savedPassword) { if (savedTheme) {
const apiInstance = new NextcloudAPI({ setTheme(savedTheme);
serverURL: savedServer, }
username: savedUsername, if (savedEditorFont) {
password: savedPassword, setEditorFont(savedEditorFont);
}); }
setApi(apiInstance); if (savedPreviewFont) {
setUsername(savedUsername); setPreviewFont(savedPreviewFont);
setIsLoggedIn(true); }
} const savedEditorFontSize = localStorage.getItem('editorFontSize');
const savedPreviewFontSize = localStorage.getItem('previewFontSize');
if (savedEditorFontSize) {
setEditorFontSize(parseInt(savedEditorFontSize, 10));
}
if (savedPreviewFontSize) {
setPreviewFontSize(parseInt(savedPreviewFontSize, 10));
}
if (savedServer && savedUsername && savedPassword) {
const apiInstance = new NextcloudAPI({
serverURL: savedServer,
username: savedUsername,
password: savedPassword,
});
setApi(apiInstance);
syncManager.setAPI(apiInstance);
categoryColorsSync.setAPI(apiInstance);
setUsername(savedUsername);
setIsLoggedIn(true);
}
};
initApp();
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -88,43 +103,70 @@ 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();
setNotes(cachedNotes.sort((a, b) => b.modified - a.modified));
});
}, []);
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 () => { const loadNotes = async () => {
if (!api) return;
try { try {
const fetched = await api.fetchNotes(); const loadedNotes = await syncManager.loadNotes();
setNotes(fetched.sort((a, b) => b.modified - a.modified)); setNotes(loadedNotes.sort((a, b) => b.modified - a.modified));
if (!selectedNoteId && fetched.length > 0) { if (!selectedNoteId && loadedNotes.length > 0) {
setSelectedNoteId(fetched[0].id); setSelectedNoteId(loadedNotes[0].id);
} }
} catch (error) {
console.error('Failed to load notes:', error);
}
};
const syncNotes = async () => {
try {
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); setSelectedNoteId(null);
@@ -156,8 +198,19 @@ function App() {
localStorage.setItem('previewFontSize', size.toString()); localStorage.setItem('previewFontSize', size.toString());
}; };
const handleToggleFavorite = async (note: Note, favorite: boolean) => {
try {
await syncManager.updateFavoriteStatus(note, favorite);
// Update local state
setNotes(prevNotes =>
prevNotes.map(n => n.id === note.id ? { ...n, favorite } : n)
);
} catch (error) {
console.error('Toggle favorite failed:', error);
}
};
const handleCreateNote = async () => { const handleCreateNote = async () => {
if (!api) return;
try { try {
const timestamp = new Date().toLocaleString('en-US', { const timestamp = new Date().toLocaleString('en-US', {
year: 'numeric', year: 'numeric',
@@ -168,7 +221,7 @@ function App() {
hour12: false, hour12: false,
}).replace(/[/:]/g, '-').replace(', ', ' '); }).replace(/[/:]/g, '-').replace(', ', ' ');
const note = await api.createNote(`New Note ${timestamp}`, '', selectedCategory); const note = await syncManager.createNote(`New Note ${timestamp}`, '', selectedCategory);
setNotes([note, ...notes]); setNotes([note, ...notes]);
setSelectedNoteId(note.id); setSelectedNoteId(note.id);
} catch (error) { } catch (error) {
@@ -182,29 +235,65 @@ function App() {
} }
}; };
const handleRenameCategory = async (oldName: string, newName: string) => {
// Move all notes from old category to new category
const notesToMove = notes.filter(note => note.category === oldName);
for (const note of notesToMove) {
try {
const movedNote = await syncManager.moveNote(note, newName);
setNotes(prevNotes => prevNotes.map(n => n.id === note.id ? movedNote : n));
} catch (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 handleUpdateNote = async (updatedNote: Note) => { const handleUpdateNote = async (updatedNote: Note) => {
if (!api) return;
try { try {
console.log('Sending to API - content length:', updatedNote.content.length); const originalNote = notes.find(n => n.id === updatedNote.id);
console.log('Sending to API - last 50 chars:', updatedNote.content.slice(-50));
const result = await api.updateNote(updatedNote); // If category changed, use moveNote instead of updateNote
console.log('Received from API - content length:', result.content.length); if (originalNote && originalNote.category !== updatedNote.category) {
console.log('Received from API - last 50 chars:', result.content.slice(-50)); const movedNote = await syncManager.moveNote(originalNote, updatedNote.category);
// Update notes array with server response now that we have manual save // If content/title also changed, update the moved note
setNotes(notes.map(n => n.id === result.id ? result : n)); if (originalNote.content !== updatedNote.content || originalNote.title !== updatedNote.title || originalNote.favorite !== updatedNote.favorite) {
const finalNote = await syncManager.updateNote({
...movedNote,
title: updatedNote.title,
content: updatedNote.content,
favorite: updatedNote.favorite,
});
setNotes(notes.map(n => n.id === originalNote.id ? finalNote : n.id === movedNote.id ? finalNote : n));
} else {
setNotes(notes.map(n => n.id === originalNote.id ? movedNote : n));
}
} else {
const updated = await syncManager.updateNote(updatedNote);
setNotes(notes.map(n => n.id === updatedNote.id ? updated : n));
}
} catch (error) { } catch (error) {
console.error('Update note failed:', error); console.error('Update note failed:', error);
} }
}; };
const handleDeleteNote = async (note: Note) => { const handleDeleteNote = async (note: Note) => {
if (!api) return;
try { try {
await api.deleteNote(note.id); await syncManager.deleteNote(note);
setNotes(notes.filter(n => n.id !== note.id)); const remainingNotes = notes.filter(n => n.id !== note.id);
setNotes(remainingNotes);
if (selectedNoteId === note.id) { if (selectedNoteId === note.id) {
setSelectedNoteId(notes[0]?.id || null); setSelectedNoteId(remainingNotes[0]?.id || null);
} }
} catch (error) { } catch (error) {
console.error('Delete note failed:', error); console.error('Delete note failed:', error);
@@ -240,6 +329,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}
@@ -267,12 +357,16 @@ 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} onUpdateNote={handleUpdateNote}
onToggleFavorite={handleToggleFavorite}
onUnsavedChanges={setHasUnsavedChanges} onUnsavedChanges={setHasUnsavedChanges}
categories={categories} categories={categories}
isFocusMode={isFocusMode} isFocusMode={isFocusMode}

View File

@@ -61,7 +61,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,7 +122,7 @@ 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 tauriFetch(url, {
headers: { headers: {
@@ -102,7 +147,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 +157,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}`;
@@ -152,4 +204,350 @@ 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 tauriFetch(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 tauriFetch(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}/${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;
}
async fetchNotesWebDAV(): Promise<Note[]> {
const webdavPath = `/remote.php/dav/files/${this.username}/Notes`;
const url = `${this.serverURL}${webdavPath}`;
const response = await tauriFetch(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}/${filename}`;
const url = `${this.serverURL}${webdavPath}`;
const response = await tauriFetch(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(/[^a-zA-Z0-9\s-]/g, '').replace(/\s+/g, ' ').trim()}.md`;
const categoryPath = category ? `/${category}` : '';
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${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 tauriFetch(categoryUrl, {
method: 'MKCOL',
headers: { 'Authorization': this.authHeader },
});
} catch (e) {
// Directory might already exist
}
}
const noteContent = `${title}\n${content}`;
const response = await tauriFetch(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}/${filename}`,
filename,
path: category ? `${category}/${filename}` : filename,
etag,
readonly: false,
content,
title,
category,
favorite: false,
modified,
};
}
async updateNoteWebDAV(note: Note): Promise<Note> {
const categoryPath = note.category ? `/${note.category}` : '';
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${note.filename}`;
const url = `${this.serverURL}${webdavPath}`;
const noteContent = this.formatNoteContent(note);
const response = await tauriFetch(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 new Error('Note was modified by another client. Please refresh.');
}
throw new Error(`Failed to update note: ${response.status}`);
}
const etag = response.headers.get('etag') || note.etag;
return {
...note,
etag,
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}/${note.filename}`;
const url = `${this.serverURL}${webdavPath}`;
const response = await tauriFetch(url, {
method: 'DELETE',
headers: { 'Authorization': this.authHeader },
});
if (!response.ok && response.status !== 204) {
throw new Error(`Failed to delete note: ${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}/${note.filename}`;
const newPath = `/remote.php/dav/files/${this.username}/Notes${newCategoryPath}/${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 tauriFetch(categoryUrl, {
method: 'MKCOL',
headers: { 'Authorization': this.authHeader },
});
} catch (e) {
// Directory might already exist, continue
}
}
}
const response = await tauriFetch(`${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 tauriFetch(`${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}/${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">
selectedCategory === category <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" />
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300' </svg>
: 'hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300' <input
}`} ref={renameInputRef}
> type="text"
<svg className="w-4 h-4 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> value={renameCategoryValue}
<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" /> onChange={(e) => setRenameCategoryValue(e.target.value)}
</svg> onKeyDown={handleRenameKeyDown}
<span className="text-sm truncate">{category}</span> onBlur={handleRenameCategory}
</button> 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
? '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'
}`}
>
<button
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>
);
})()}
<span className="text-sm truncate">{category}</span>
</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,27 +338,40 @@ 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>
<button <div className="flex items-center gap-1">
onClick={onLogout} <button
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors flex-shrink-0" onClick={() => setIsSettingsCollapsed(!isSettingsCollapsed)}
title="Logout" 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-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /> <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">
</svg> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</button> </svg>
</button>
<button
onClick={onLogout}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors flex-shrink-0"
title="Logout"
>
<svg className="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
</div> </div>
{/* Theme Toggle */} {!isSettingsCollapsed && (
<div className="flex items-center justify-between mb-3"> <div className="px-4 pb-4">
{/* Theme Toggle */}
<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>
<div className="flex items-center space-x-1 bg-gray-100 dark:bg-gray-700 rounded-lg p-1"> <div className="flex items-center space-x-1 bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
<button <button
@@ -250,77 +416,79 @@ export function CategoriesSidebar({
</div> </div>
</div> </div>
{/* Font Settings */} {/* Font Settings */}
<div className="mt-4 pt-3 border-t border-gray-200 dark:border-gray-700 space-y-3"> <div className="mt-4 pt-3 border-t border-gray-200 dark:border-gray-700 space-y-3">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Fonts</span> <span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Fonts</span>
{/* Editor Font */} {/* Editor Font */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3"> <div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<svg className="w-4 h-4 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg> </svg>
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">Editor</span> <span className="text-xs font-medium text-gray-600 dark:text-gray-300">Editor</span>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<select <select
value={editorFont} value={editorFont}
onChange={(e) => onEditorFontChange(e.target.value)} onChange={(e) => onEditorFontChange(e.target.value)}
className="flex-1 min-w-0 text-sm px-3 py-1.5 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-200 focus:ring-2 focus:ring-blue-500 focus:border-transparent cursor-pointer" className="flex-1 min-w-0 text-sm px-3 py-1.5 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-200 focus:ring-2 focus:ring-blue-500 focus:border-transparent cursor-pointer"
style={{ fontFamily: editorFont }} style={{ fontFamily: editorFont }}
> >
{EDITOR_FONTS.map((font) => ( {EDITOR_FONTS.map((font) => (
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}> <option key={font.value} value={font.value} style={{ fontFamily: font.value }}>
{font.name} {font.name}
</option> </option>
))} ))}
</select> </select>
<select <select
value={editorFontSize} value={editorFontSize}
onChange={(e) => onEditorFontSizeChange(parseInt(e.target.value, 10))} onChange={(e) => onEditorFontSizeChange(parseInt(e.target.value, 10))}
className="w-16 flex-shrink-0 text-sm px-2 py-1.5 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-200 focus:ring-2 focus:ring-blue-500 focus:border-transparent cursor-pointer text-center" className="w-16 flex-shrink-0 text-sm px-2 py-1.5 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-200 focus:ring-2 focus:ring-blue-500 focus:border-transparent cursor-pointer text-center"
> >
{[12, 13, 14, 15, 16, 17, 18, 20, 22, 24].map((size) => ( {[12, 13, 14, 15, 16, 17, 18, 20, 22, 24].map((size) => (
<option key={size} value={size}>{size}</option> <option key={size} value={size}>{size}</option>
))} ))}
</select> </select>
</div> </div>
</div> </div>
{/* Preview Font */} {/* Preview Font */}
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3"> <div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<svg className="w-4 h-4 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg> </svg>
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">Preview</span> <span className="text-xs font-medium text-gray-600 dark:text-gray-300">Preview</span>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<select <select
value={previewFont} value={previewFont}
onChange={(e) => onPreviewFontChange(e.target.value)} onChange={(e) => onPreviewFontChange(e.target.value)}
className="flex-1 min-w-0 text-sm px-3 py-1.5 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-200 focus:ring-2 focus:ring-blue-500 focus:border-transparent cursor-pointer" className="flex-1 min-w-0 text-sm px-3 py-1.5 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-200 focus:ring-2 focus:ring-blue-500 focus:border-transparent cursor-pointer"
style={{ fontFamily: previewFont }} style={{ fontFamily: previewFont }}
> >
{PREVIEW_FONTS.map((font) => ( {PREVIEW_FONTS.map((font) => (
<option key={font.value} value={font.value} style={{ fontFamily: font.value }}> <option key={font.value} value={font.value} style={{ fontFamily: font.value }}>
{font.name} {font.name}
</option> </option>
))} ))}
</select> </select>
<select <select
value={previewFontSize} value={previewFontSize}
onChange={(e) => onPreviewFontSizeChange(parseInt(e.target.value, 10))} onChange={(e) => onPreviewFontSizeChange(parseInt(e.target.value, 10))}
className="w-16 flex-shrink-0 text-sm px-2 py-1.5 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-200 focus:ring-2 focus:ring-blue-500 focus:border-transparent cursor-pointer text-center" className="w-16 flex-shrink-0 text-sm px-2 py-1.5 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-200 focus:ring-2 focus:ring-blue-500 focus:border-transparent cursor-pointer text-center"
> >
{[12, 13, 14, 15, 16, 17, 18, 20, 22, 24].map((size) => ( {[12, 13, 14, 15, 16, 17, 18, 20, 22, 24].map((size) => (
<option key={size} value={size}>{size}</option> <option key={size} value={size}>{size}</option>
))} ))}
</select> </select>
</div>
</div>
</div> </div>
</div> </div>
</div> )}
</div> </div>
</div> </div>
); );

View File

@@ -10,6 +10,7 @@ import { InsertToolbar } from './InsertToolbar';
interface NoteEditorProps { interface NoteEditorProps {
note: Note | null; note: Note | null;
onUpdateNote: (note: Note) => void; onUpdateNote: (note: Note) => void;
onToggleFavorite?: (note: Note, favorite: boolean) => void;
onUnsavedChanges?: (hasChanges: boolean) => void; onUnsavedChanges?: (hasChanges: boolean) => void;
categories: string[]; categories: string[];
isFocusMode?: boolean; isFocusMode?: boolean;
@@ -24,20 +25,19 @@ interface NoteEditorProps {
const imageCache = new Map<string, string>(); const imageCache = new Map<string, string>();
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, onUpdateNote, onToggleFavorite, onUnsavedChanges, 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 [isSaving, setIsSaving] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = 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 previousNoteIdRef = useRef<number | string | null>(null);
const previousNoteContentRef = useRef<string>('');
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -63,8 +63,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 +85,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 (previousNoteIdRef.current !== note.id) {
console.log(`[Note ${note.id}] Skipping image processing - waiting for content to sync (previousNoteIdRef: ${previousNoteIdRef.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;
@@ -121,29 +139,39 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
useEffect(() => { useEffect(() => {
const loadNewNote = () => { const loadNewNote = () => {
if (note) { if (note) {
setLocalTitle(note.title);
setLocalContent(note.content); setLocalContent(note.content);
setLocalCategory(note.category || ''); setLocalCategory(note.category || '');
setLocalFavorite(note.favorite); setLocalFavorite(note.favorite);
setHasUnsavedChanges(false); 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; previousNoteIdRef.current = note.id;
previousNoteContentRef.current = note.content;
} }
}; };
// Switching to a different note
if (previousNoteIdRef.current !== null && previousNoteIdRef.current !== note?.id) { if (previousNoteIdRef.current !== null && previousNoteIdRef.current !== note?.id) {
console.log(`Switching from note ${previousNoteIdRef.current} to note ${note?.id}`);
setProcessedContent('');
if (hasUnsavedChanges) { if (hasUnsavedChanges) {
handleSave(); handleSave();
} }
setTimeout(loadNewNote, 100);
} else {
loadNewNote(); loadNewNote();
} }
}, [note?.id]); // Same note but content changed from server (and no unsaved local changes)
else if (note && previousNoteIdRef.current === note.id && !hasUnsavedChanges && previousNoteContentRef.current !== note.content) {
console.log(`Note ${note.id} content changed from server (prev: ${previousNoteContentRef.current.length} chars, new: ${note.content.length} chars)`);
loadNewNote();
}
// Initial load
else if (!note || previousNoteIdRef.current === null) {
loadNewNote();
}
// Favorite status changed (e.g., from sync)
else if (note && note.favorite !== localFavorite) {
setLocalFavorite(note.favorite);
}
}, [note?.id, note?.content, note?.modified, note?.favorite]);
const handleSave = () => { const handleSave = () => {
if (!note || !hasUnsavedChanges) return; if (!note || !hasUnsavedChanges) return;
@@ -152,9 +180,13 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
console.log('Last 50 chars:', localContent.slice(-50)); console.log('Last 50 chars:', localContent.slice(-50));
setIsSaving(true); setIsSaving(true);
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
const title = firstLine || 'Untitled';
onUpdateNote({ onUpdateNote({
...note, ...note,
title: localTitle, title,
content: localContent, content: localContent,
category: localCategory, category: localCategory,
favorite: localFavorite, favorite: localFavorite,
@@ -162,36 +194,33 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
setTimeout(() => setIsSaving(false), 500); 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); setHasUnsavedChanges(true);
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) return;
setLocalTitle(note.title);
setLocalContent(note.content); setLocalContent(note.content);
setLocalCategory(note.category || ''); setLocalCategory(note.category || '');
setLocalFavorite(note.favorite); setLocalFavorite(note.favorite);
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
};
const firstLine = note.content.split('\n')[0].replace(/^#+\s*/, '').trim(); const loadFontAsBase64 = async (fontPath: string): Promise<string> => {
const titleMatchesFirstLine = note.title === firstLine || note.title === firstLine.substring(0, 50); const response = await fetch(fontPath);
setTitleManuallyEdited(!titleMatchesFirstLine); const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const base64 = reader.result as string;
// Remove data URL prefix to get just the base64 string
resolve(base64.split(',')[1]);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}; };
const handleExportPDF = async () => { const handleExportPDF = async () => {
@@ -200,14 +229,105 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
setIsExportingPDF(true); setIsExportingPDF(true);
try { try {
// Create PDF
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
});
// Load and add custom fonts based on preview font selection
const fontMap: { [key: string]: { regular: string; italic: string; name: string } } = {
'Merriweather': {
regular: '/fonts/Merriweather-VariableFont_opsz,wdth,wght.ttf',
italic: '/fonts/Merriweather-Italic-VariableFont_opsz,wdth,wght.ttf',
name: 'Merriweather'
},
'Crimson Pro': {
regular: '/fonts/CrimsonPro-VariableFont_wght.ttf',
italic: '/fonts/CrimsonPro-Italic-VariableFont_wght.ttf',
name: 'CrimsonPro'
},
'Roboto Serif': {
regular: '/fonts/RobotoSerif-VariableFont_GRAD,opsz,wdth,wght.ttf',
italic: '/fonts/RobotoSerif-Italic-VariableFont_GRAD,opsz,wdth,wght.ttf',
name: 'RobotoSerif'
},
'Average': {
regular: '/fonts/Average-Regular.ttf',
italic: '/fonts/Average-Regular.ttf', // No italic variant
name: 'Average'
}
};
const selectedFont = fontMap[previewFont];
if (selectedFont) {
try {
const regularBase64 = await loadFontAsBase64(selectedFont.regular);
pdf.addFileToVFS(`${selectedFont.name}-normal.ttf`, regularBase64);
pdf.addFont(`${selectedFont.name}-normal.ttf`, selectedFont.name, 'normal');
const italicBase64 = await loadFontAsBase64(selectedFont.italic);
pdf.addFileToVFS(`${selectedFont.name}-italic.ttf`, italicBase64);
pdf.addFont(`${selectedFont.name}-italic.ttf`, selectedFont.name, 'italic');
// Set the custom font as default
pdf.setFont(selectedFont.name, 'normal');
} catch (fontError) {
console.error('Failed to load custom font, using default:', fontError);
}
}
// Add Source Code Pro for code blocks
try {
const codeFont = await loadFontAsBase64('/fonts/SourceCodePro-VariableFont_wght.ttf');
pdf.addFileToVFS('SourceCodePro-normal.ttf', codeFont);
pdf.addFont('SourceCodePro-normal.ttf', 'SourceCodePro', 'normal');
} catch (codeFontError) {
console.error('Failed to load code font:', codeFontError);
}
// Process images to embed them as data URLs
let contentForPDF = localContent;
if (api) {
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
const matches = [...localContent.matchAll(imageRegex)];
for (const match of matches) {
const [fullMatch, alt, imagePath] = match;
// Skip external URLs
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
continue;
}
// Check cache first
const cacheKey = `${note.id}:${imagePath}`;
if (imageCache.has(cacheKey)) {
const dataUrl = imageCache.get(cacheKey)!;
contentForPDF = contentForPDF.replace(fullMatch, `![${alt}](${dataUrl})`);
continue;
}
try {
const dataUrl = await api.fetchAttachment(note.id, imagePath, note.category);
imageCache.set(cacheKey, dataUrl);
contentForPDF = contentForPDF.replace(fullMatch, `![${alt}](${dataUrl})`);
} catch (error) {
console.error(`Failed to fetch attachment for PDF: ${imagePath}`, error);
}
}
}
const container = document.createElement('div'); const container = document.createElement('div');
container.style.fontFamily = `"${previewFont}", Georgia, serif`; container.style.fontFamily = `"${previewFont}", Georgia, serif`;
container.style.fontSize = '12px'; container.style.fontSize = `${previewFontSize}px`;
container.style.lineHeight = '1.6'; container.style.lineHeight = '1.6';
container.style.color = '#000000'; container.style.color = '#000000';
const titleElement = document.createElement('h1'); const titleElement = document.createElement('h1');
titleElement.textContent = localTitle || 'Untitled'; const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
titleElement.textContent = firstLine || 'Untitled';
titleElement.style.marginTop = '0'; titleElement.style.marginTop = '0';
titleElement.style.marginBottom = '20px'; titleElement.style.marginBottom = '20px';
titleElement.style.fontSize = '24px'; titleElement.style.fontSize = '24px';
@@ -218,37 +338,40 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
container.appendChild(titleElement); container.appendChild(titleElement);
const contentElement = document.createElement('div'); const contentElement = document.createElement('div');
const html = marked.parse(localContent || '', { async: false }) as string; const html = marked.parse(contentForPDF || '', { async: false }) as string;
contentElement.innerHTML = html; contentElement.innerHTML = html;
contentElement.style.fontSize = '12px'; contentElement.style.fontSize = `${previewFontSize}px`;
contentElement.style.lineHeight = '1.6'; contentElement.style.lineHeight = '1.6';
contentElement.style.color = '#000000'; contentElement.style.color = '#000000';
container.appendChild(contentElement); container.appendChild(contentElement);
// Apply monospace font to code elements
const style = document.createElement('style'); const style = document.createElement('style');
style.textContent = ` style.textContent = `
code, pre { font-family: "Source Code Pro", ui-monospace, monospace !important; } body, p, h1, h2, h3, div { font-family: "${previewFont}", Georgia, serif !important; }
code, pre, pre * { font-family: "Source Code Pro", "Courier New", monospace !important; }
pre { background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto; } pre { background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto; }
code { background: #f0f0f0; padding: 2px 4px; border-radius: 2px; } code { padding: 0; }
h1 { font-size: 2em; font-weight: bold; margin-top: 0.67em; margin-bottom: 0.67em; }
h2 { font-size: 1.5em; font-weight: bold; margin-top: 0.83em; margin-bottom: 0.83em; }
h3 { font-size: 1.17em; font-weight: bold; margin-top: 1em; margin-bottom: 1em; }
p { margin: 0.5em 0; }
ul, ol { margin: 0.5em 0; padding-left: 2em; list-style-position: outside; font-family: "${previewFont}", Georgia, serif !important; }
ul { list-style-type: disc; }
ol { list-style-type: decimal; }
li { margin: 0.25em 0; display: list-item; font-family: "${previewFont}", Georgia, serif !important; }
em { font-style: italic; vertical-align: baseline; }
strong { font-weight: bold; vertical-align: baseline; line-height: inherit; }
img { max-width: 100%; height: auto; display: block; margin: 1em 0; }
`; `;
container.appendChild(style); container.appendChild(style);
// Create PDF using jsPDF's html() method (like dompdf) // Use jsPDF's html() method with custom font set
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
});
// Use jsPDF's html() method which handles pagination automatically
await pdf.html(container, { await pdf.html(container, {
callback: async (doc) => { callback: async (doc) => {
// Save the PDF const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
const fileName = `${localTitle || 'note'}.pdf`; const fileName = `${firstLine || 'note'}.pdf`;
doc.save(fileName); doc.save(fileName);
// Show success message using Tauri dialog
setTimeout(async () => { setTimeout(async () => {
try { try {
await message(`PDF exported successfully!\n\nFile: ${fileName}\nLocation: Downloads folder`, { await message(`PDF exported successfully!\n\nFile: ${fileName}\nLocation: Downloads folder`, {
@@ -261,10 +384,10 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
setIsExportingPDF(false); setIsExportingPDF(false);
}, 500); }, 500);
}, },
margin: [20, 20, 20, 20], // top, right, bottom, left margins in mm margin: [20, 20, 20, 20],
autoPaging: 'text', // Enable automatic page breaks autoPaging: 'text',
width: 170, // Content width in mm (A4 width 210mm - 40mm margins) width: 170,
windowWidth: 650, // Rendering width in pixels (matches content width ratio) windowWidth: 650,
}); });
} catch (error) { } catch (error) {
console.error('PDF export failed:', error); console.error('PDF export failed:', error);
@@ -281,14 +404,23 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
}; };
const handleFavoriteToggle = () => { const handleFavoriteToggle = () => {
setLocalFavorite(!localFavorite); const newFavorite = !localFavorite;
if (note) { setLocalFavorite(newFavorite);
if (note && onToggleFavorite) {
// Use dedicated favorite toggle callback if provided
onToggleFavorite(note, newFavorite);
} else if (note) {
// Fallback to full update if no callback provided
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
const title = firstLine || 'Untitled';
onUpdateNote({ onUpdateNote({
...note, ...note,
title: localTitle, title,
content: localContent, content: localContent,
category: localCategory, category: localCategory,
favorite: !localFavorite, favorite: newFavorite,
}); });
} }
}; };
@@ -528,36 +660,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 +686,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)}
@@ -663,6 +729,21 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
{/* 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
onClick={handleFavoriteToggle}
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 <button
onClick={handleSave} onClick={handleSave}
disabled={!hasUnsavedChanges || isSaving} disabled={!hasUnsavedChanges || isSaving}
@@ -756,7 +837,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 +853,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; selectedNoteId: number | string | null;
onSelectNote: (id: number) => void; onSelectNote: (id: number | string) => void;
onCreateNote: () => void; onCreateNote: () => void;
onDeleteNote: (note: Note) => void; onDeleteNote: (note: Note) => void;
onSync: () => void; onSync: () => void;
@@ -13,6 +15,9 @@ 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({
@@ -27,16 +32,64 @@ 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();
@@ -78,11 +131,55 @@ export function NotesList({
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: '240px', 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">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Notes</h2> <div className="flex items-center gap-2">
<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}
@@ -194,8 +291,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 +320,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>
); );
} }

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 {

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

375
src/services/syncManager.ts Normal file
View File

@@ -0,0 +1,375 @@
import { Note } from '../types';
import { NextcloudAPI } from '../api/nextcloud';
import { localDB } from '../db/localDB';
export type SyncStatus = 'idle' | 'syncing' | 'error' | 'offline';
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;
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 localDB.getAllNotes();
// 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);
const notes = await this.fetchAndCacheNotes();
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 localDB.getAllNotes();
// 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 || cached.etag !== serverNote.etag) {
notesToFetch.push(serverNote);
}
}
// Fetch content for changed notes
for (const note of notesToFetch) {
try {
const fullNote = await this.api.fetchNoteContentWebDAV(note);
await localDB.saveNote(fullNote);
} catch (error) {
console.error(`Failed to fetch note ${note.id}:`, error);
}
}
// Remove deleted notes from cache
for (const cachedNote of cachedNotes) {
if (!serverMap.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 localDB.getAllNotes();
// 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) {
// 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(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 serverNotes = await this.api.fetchNotesWebDAV();
const notesWithContent: Note[] = [];
for (const note of serverNotes) {
try {
const fullNote = await this.api.fetchNoteContentWebDAV(note);
notesWithContent.push(fullNote);
await localDB.saveNote(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 = await this.api.fetchNoteContentWebDAV(note);
await localDB.saveNote(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 = await this.api.createNoteWebDAV(title, content, category);
await localDB.saveNote(note);
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(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(note);
} catch (error) {
console.error('Failed to update favorite status:', error);
// Still update locally
note.favorite = favorite;
await localDB.saveNote(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 updatedNote = await this.api.updateNoteWebDAV(note);
await localDB.saveNote(updatedNote);
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 = await this.api.moveNoteWebDAV(note, newCategory);
await localDB.deleteNote(note.id);
await localDB.saveNote(movedNote);
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;
}
}
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,8 @@ 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
} }
export interface APIConfig { export interface APIConfig {