76 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
ba4600773a Add attachment upload and InsertToolbar for quick link/file insertion
- Add uploadAttachment method to NextcloudAPI (WebDAV PUT to .attachments directory)
- Add InsertToolbar component that appears on cursor placement in editor
- InsertToolbar provides quick access to insert links (with modal) and files
- Add Attach button to main toolbar as alternative upload method
- Insert markdown references at cursor position after upload
2026-03-18 14:42:36 +01:00
drelich
5ff3427848 Update TODO: mark image viewing support as completed 2026-03-18 14:26:32 +01:00
drelich
7fd765ceb6 Add image/attachment support in preview mode
- Fetch attachments via WebDAV using Tauri HTTP plugin (bypasses CORS)
- Parse markdown for image references and convert to base64 data URLs
- In-memory cache to avoid re-fetching images
- Loading indicator while images load
- Register tauri-plugin-http in Rust builder
- Add HTTP permissions in capabilities
2026-03-18 14:25:03 +01:00
drelich
7611f8e82e fix: Remove unused fontSize prop (replaced by editorFontSize/previewFontSize) 2026-03-17 20:58:52 +01:00
drelich
75c3cd4796 docs: Update TODO with high priority items - PDF export fix and offline mode 2026-03-17 20:46:08 +01:00
drelich
f06fc640b6 feat: Add customizable fonts for editor and preview
- Added Google Fonts: Source Code Pro, Roboto Mono, Inconsolata (editor)
  and Merriweather, Crimson Pro, Roboto Serif, Average (preview)
- Font face and size selectors in Categories sidebar with polished UI
- Editor font applied to markdown textarea
- Preview font applied to preview mode and PDF export
- Code blocks always render in monospace
- Settings persist in localStorage
- Fixed textarea height recalculation when switching from preview to edit
2026-03-17 20:39:33 +01:00
drelich
28914207f6 feat: Major UI improvements - categories sidebar, floating toolbar, focus mode
Categories sidebar:
- Collapsible first column with category management
- Create new categories directly from sidebar
- Thin tab when collapsed
- Moved user info, logout, and theme selector here

Note editor redesign:
- Clean toolbar with pill-style buttons
- Category dropdown with folder icon
- Preview toggle in toolbar
- Streamlined action buttons

Floating formatting toolbar:
- Appears on text selection
- Bold, italic, strikethrough, code, code block, quote, lists, link, headings
- Active state highlighting for applied formats
- Toggle behavior removes formatting if already applied

Focus mode:
- Hides sidebars for distraction-free writing
- Content centered with max-width
- Escape key to exit
- Scrolling works from anywhere in viewport
2026-03-17 20:13:44 +01:00
drelich
e94e201ec8 Improve note preview and editor padding
- Increased editor padding from 6 to 8 for better spacing
- Simplified preview generation to show first 100 characters
- Remove markdown syntax (#, *, `) from note previews for cleaner display
- Replaced multi-line filtering logic with direct substring approach
2026-03-17 18:41:19 +01:00
drelich
db7daa81a3 Center PDF title and improve code/list formatting
- Centered note title in PDF exports for better visual balance
- Increased inline code font size from 0.9em to 1.1em for better readability
- Removed paragraph margins inside list items to fix spacing issues
2026-03-17 15:54:55 +01:00
drelich
89c161f2f3 Remove unused html2canvas import to fix production build
- Removed html2canvas import that was causing TypeScript error
- html2canvas is no longer needed since we switched to jsPDF's html() method
- Fixes build error: TS6133 'html2canvas' is declared but never read
2026-03-17 11:34:41 +01:00
drelich
55f50f3a77 Adjust PDF margins and content width for balanced layout
- Reordered jsPDF html() options for clarity
- Added autoPaging: 'text' for better page break handling
- Adjusted windowWidth to 650px for better content scaling
- Maintains 20mm margins on all sides
- Content width set to 170mm (210mm A4 - 40mm margins)
- Should produce more balanced left/right margins
2026-03-17 10:27:08 +01:00
drelich
19a42a1190 Simplify PDF export using jsPDF's html() method for automatic pagination
- Removed complex manual page-by-page rendering logic
- Using jsPDF's built-in html() method instead (like dompdf does)
- Handles pagination automatically with proper page breaks
- Set 20mm margins on all sides via margin parameter
- Content width set to 170mm (210mm - 40mm margins)
- Much simpler and more reliable approach
- Leverages jsPDF's native HTML rendering engine
2026-03-17 10:23:10 +01:00
drelich
e018b9e1e9 Fix PDF page breaks to properly respect margins on all pages
- Rewrote PDF export to render content page-by-page instead of as one image
- Calculate total content height and number of pages needed
- Create separate canvas for each page with proper dimensions
- Use overflow:hidden wrapper to show only one page's worth of content at a time
- Position content with negative top offset for each subsequent page
- Each page gets proper 20mm margins on all sides
- Fixes issue where content flowed continuously across pages
- Bottom margins now properly respected on every page
2026-03-17 10:20:36 +01:00
drelich
12579d6198 Fix PDF export with Tauri native dialogs and proper margins
- Installed @tauri-apps/plugin-dialog for native dialogs
- Added tauri-plugin-dialog to Rust dependencies
- Registered dialog plugin in Tauri app initialization
- Replaced web alert() with Tauri message() dialog
- Success dialog shows filename and download location
- Error dialog shows if export fails
- Added 20mm margins on all sides of PDF pages
- Content width adjusted to 170mm (210mm - 40mm margins)
- Multi-page support respects margins on all pages
- Native dialogs work properly in Tauri app
2026-03-17 10:15:20 +01:00
drelich
9d3c8b5e3c Improve PDF export with multi-page support and better UX
- Fixed multi-page PDF generation - now properly splits long documents across pages
- Added isExportingPDF state for loading feedback
- Replaced generic download icon with document/PDF icon
- Button shows spinning loader during PDF generation
- Button changes to blue background while exporting
- Added success alert with filename and download location
- Button disabled during export to prevent multiple clicks
- Improved visual feedback throughout export process
2026-03-17 09:42:27 +01:00
drelich
d53a454d7b Add PDF export feature to notes
- Installed jsPDF and html2canvas libraries
- Implemented handleExportPDF function to convert note to PDF
- Creates temporary styled container with title and content
- Uses html2canvas to render content as image
- Generates A4 format PDF with proper formatting
- Added export button (download icon) in note header
- Button positioned between discard and favorite buttons
- PDF filename uses note title
- Includes error handling with user feedback
2026-03-17 09:34:27 +01:00
drelich
ef7ec39fed Add TODO.md with future improvements and unsaved note switching proposal 2026-03-17 09:33:06 +01:00
drelich
93e2a87fa6 Fix delete button to respect unsaved changes lock
- Prevent delete button from working when there are unsaved changes on a different note
- Delete button now checks hasUnsavedChanges before allowing deletion
- Only the currently selected note can be deleted when it has unsaved changes
- Prevents accidental deletion of other notes when locked
2026-03-17 09:29:53 +01:00
drelich
2d1cc4baf0 Add Discard Changes button to revert unsaved edits
- Added handleDiscard function to reload original note content
- New discard button appears next to save button when there are unsaved changes
- Button uses X icon and gray styling to distinguish from save
- Clicking discard reloads the original note title and content
- Resets hasUnsavedChanges to false, allowing note switching again
- Provides way to abandon changes without saving
2026-03-17 09:27:46 +01:00
drelich
9a229dcc00 Prevent note switching when there are unsaved changes
- Added hasUnsavedChanges state tracking in App.tsx
- NoteEditor now notifies parent via onUnsavedChanges callback
- NotesList receives hasUnsavedChanges prop
- Clicking on other notes is prevented when current note has unsaved changes
- Visual feedback: other notes become semi-transparent with cursor-not-allowed
- Tooltip shows 'Save current note before switching' on disabled notes
- Much simpler and more reliable than trying to auto-save on switch
2026-03-17 09:24:30 +01:00
drelich
e6ecab13fa Fix note switching to save unsaved changes before loading new note
- Added loadNewNote helper function to encapsulate note loading logic
- Check for unsaved changes when switching notes
- Save previous note if it has unsaved changes
- Add 100ms delay before loading new note to ensure save completes
- Prevents data loss when switching from unsaved note to another note
- Fixes bug where new note content would overwrite unsaved changes
2026-03-17 09:19:03 +01:00
drelich
489f2f847d Update app name to 'Nextcloud Notes'
- Changed productName from 'nextcloud-notes-tauri' to 'Nextcloud Notes'
- Updated identifier to 'com.davidrelich.nextcloud-notes'
- App will now show as 'Nextcloud Notes' in macOS Applications
- DMG and .app bundle will use proper name
2026-03-17 00:29:39 +01:00
drelich
c7f314d632 Fix disabled save button styling in dark mode
- Changed disabled save button background from light grey to dark grey in dark mode
- Changed disabled save button text from grey-400 to grey-500 in dark mode
- Button now blends better with dark mode design
- No longer draws unwanted attention when disabled
2026-03-17 00:27:01 +01:00
drelich
a2c717c2e2 Add visual hint for delete confirmation
- Shows 'Click again to delete' text when delete button is clicked once
- Text appears next to the red delete button in confirmation state
- Makes the double-click deletion flow much more intuitive
- Button also stays visible (opacity-100) during confirmation state
- Text styled in red to match the delete action
2026-03-17 00:24:47 +01:00
drelich
9b0a289cc8 Improve note deletion UX with double-click confirmation
- Replaced confusing browser confirm() dialog with double-click to delete
- First click highlights delete button in red (confirmation state)
- Second click within 3 seconds actually deletes the note
- Visual feedback with red background on confirmation state
- Tooltip changes to 'Click again to confirm deletion'
- Removed debug logging from delete handlers
- Much clearer and more intuitive deletion flow
2026-03-17 00:22:29 +01:00
drelich
42cc1ffcd9 Add debug logging for note deletion 2026-03-17 00:19:45 +01:00
drelich
d09920850d Remove category feature from UI
- Removed category input field from note editor
- Removed category display from notes list
- Removed category state management from NoteEditor component
- Category field now always saved as empty string
- Simplified UI to focus on core note-taking features
- Fixed favorite star icon dark mode styling
2026-03-17 00:18:18 +01:00
drelich
1c9efe6007 Fix TipTap editor content colors for dark mode
- Added proper text colors for all editor content in dark mode
- Fixed headings (H1, H2, H3) to use light text in dark mode
- Fixed bold, italic, strikethrough, underline text colors
- Fixed list items (ul, ol, li) text colors
- Fixed blockquote colors and borders for dark mode
- Fixed horizontal rule colors
- All formatted content now properly visible in dark mode
2026-03-17 00:12:06 +01:00
drelich
81cc72b444 Fix dark mode visibility for all toolbar buttons and icons
- Fixed all formatting toolbar buttons to be visible in dark mode
- Added proper text colors (gray-700/gray-300) to all toolbar icons
- Fixed sync and new note button icons in sidebar
- Fixed divider colors between button groups
- All UI elements now properly visible in both light and dark modes
2026-03-17 00:10:07 +01:00
drelich
f3096c16ca Add light/dark mode theme support with OS sync
- Added theme state management (light/dark/system)
- Implemented OS theme detection and automatic sync
- Added theme toggle UI in sidebar with 3 buttons
- Applied dark mode styles to all components:
  - Sidebar with dark backgrounds and borders
  - Note editor with dark text and inputs
  - Toolbar buttons with dark hover states
  - TipTap editor with dark mode text and code blocks
- Theme preference saved to localStorage
- Enabled Tailwind dark mode with class strategy
- Smooth transitions between themes
2026-03-17 00:07:23 +01:00
drelich
f4b324b702 Add TypeScript types for turndown library
- Added @types/turndown dev dependency for production build
- Fixes TypeScript compilation errors during build process
2026-03-17 00:03:31 +01:00
drelich
835643d690 Update app icon to official Nextcloud Notes icon
- Downloaded official Nextcloud Notes favicon
- Generated all required icon sizes for Tauri (macOS, Windows, Store)
- Replaced placeholder icons with branded Nextcloud Notes icon
- Icon sizes: 32x32, 128x128, 256x256, .icns, .ico, and Windows Store logos
2026-03-17 00:01:12 +01:00
drelich
074f695b3e Add logout functionality and username display
- Added logout button at bottom of sidebar
- Display username with avatar (first letter)
- Clear all credentials and state on logout
- Username persists from saved credentials
- Clean logout flow returns to login screen
2026-03-16 23:56:41 +01:00
26 changed files with 1926 additions and 303 deletions

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,8 +29,14 @@ 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 initApp = async () => {
await localDB.init();
const savedServer = localStorage.getItem('serverURL'); const savedServer = localStorage.getItem('serverURL');
const savedUsername = localStorage.getItem('username'); const savedUsername = localStorage.getItem('username');
const savedPassword = localStorage.getItem('password'); const savedPassword = localStorage.getItem('password');
@@ -59,9 +69,14 @@ function App() {
password: savedPassword, password: savedPassword,
}); });
setApi(apiInstance); setApi(apiInstance);
syncManager.setAPI(apiInstance);
categoryColorsSync.setAPI(apiInstance);
setUsername(savedUsername); setUsername(savedUsername);
setIsLoggedIn(true); setIsLoggedIn(true);
} }
};
initApp();
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -88,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 handleUpdateNote = async (updatedNote: Note) => { const handleRenameCategory = async (oldName: string, newName: string) => {
if (!api) return; // Move all notes from old category to new category
const notesToMove = notes.filter(note => note.category === oldName);
for (const note of notesToMove) {
try { try {
console.log('Sending to API - content length:', updatedNote.content.length); const movedNote = await syncManager.moveNote(note, newName);
console.log('Sending to API - last 50 chars:', updatedNote.content.slice(-50)); setNotes(prevNotes => prevNotes.map(n => n.id === note.id ? movedNote : n));
const result = await api.updateNote(updatedNote); } catch (error) {
console.log('Received from API - content length:', result.content.length); console.error(`Failed to move note ${note.id}:`, error);
console.log('Received from API - last 50 chars:', result.content.slice(-50)); }
// Update notes array with server response now that we have manual save }
setNotes(notes.map(n => n.id === result.id ? result : n));
// 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) => {
try {
const originalNote = notes.find(n => n.id === updatedNote.id);
// If category changed, use moveNote instead of updateNote
if (originalNote && originalNote.category !== updatedNote.category) {
const movedNote = await syncManager.moveNote(originalNote, updatedNote.category);
// If content/title also changed, update the moved note
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}`,
};
}
} }

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { categoryColorsSync } from '../services/categoryColorsSync';
const EDITOR_FONTS = [ const EDITOR_FONTS = [
{ name: 'Source Code Pro', value: 'Source Code Pro' }, { name: 'Source Code Pro', value: 'Source Code Pro' },
@@ -20,6 +21,7 @@ interface CategoriesSidebarProps {
selectedCategory: string; selectedCategory: string;
onSelectCategory: (category: string) => void; onSelectCategory: (category: string) => void;
onCreateCategory: (name: string) => void; onCreateCategory: (name: string) => void;
onRenameCategory: (oldName: string, newName: string) => void;
isCollapsed: boolean; isCollapsed: boolean;
onToggleCollapse: () => void; onToggleCollapse: () => void;
username: string; username: string;
@@ -36,11 +38,25 @@ interface CategoriesSidebarProps {
onPreviewFontSizeChange: (size: number) => void; onPreviewFontSizeChange: (size: number) => void;
} }
const CATEGORY_COLORS = [
{ name: 'Blue', bg: 'bg-blue-100 dark:bg-blue-900/30', text: 'text-blue-700 dark:text-blue-300', preview: '#dbeafe', dot: '#3b82f6' },
{ name: 'Green', bg: 'bg-green-100 dark:bg-green-900/30', text: 'text-green-700 dark:text-green-300', preview: '#dcfce7', dot: '#22c55e' },
{ name: 'Purple', bg: 'bg-purple-100 dark:bg-purple-900/30', text: 'text-purple-700 dark:text-purple-300', preview: '#f3e8ff', dot: '#a855f7' },
{ name: 'Pink', bg: 'bg-pink-100 dark:bg-pink-900/30', text: 'text-pink-700 dark:text-pink-300', preview: '#fce7f3', dot: '#ec4899' },
{ name: 'Yellow', bg: 'bg-yellow-100 dark:bg-yellow-900/30', text: 'text-yellow-700 dark:text-yellow-300', preview: '#fef9c3', dot: '#eab308' },
{ name: 'Red', bg: 'bg-red-100 dark:bg-red-900/30', text: 'text-red-700 dark:text-red-300', preview: '#fee2e2', dot: '#ef4444' },
{ name: 'Orange', bg: 'bg-orange-100 dark:bg-orange-900/30', text: 'text-orange-700 dark:text-orange-300', preview: '#ffedd5', dot: '#f97316' },
{ name: 'Teal', bg: 'bg-teal-100 dark:bg-teal-900/30', text: 'text-teal-700 dark:text-teal-300', preview: '#ccfbf1', dot: '#14b8a6' },
{ name: 'Indigo', bg: 'bg-indigo-100 dark:bg-indigo-900/30', text: 'text-indigo-700 dark:text-indigo-300', preview: '#e0e7ff', dot: '#6366f1' },
{ name: 'Cyan', bg: 'bg-cyan-100 dark:bg-cyan-900/30', text: 'text-cyan-700 dark:text-cyan-300', preview: '#cffafe', dot: '#06b6d4' },
];
export function CategoriesSidebar({ export function CategoriesSidebar({
categories, categories,
selectedCategory, selectedCategory,
onSelectCategory, onSelectCategory,
onCreateCategory, onCreateCategory,
onRenameCategory,
isCollapsed, isCollapsed,
onToggleCollapse, onToggleCollapse,
username, username,
@@ -58,7 +74,31 @@ export function CategoriesSidebar({
}: CategoriesSidebarProps) { }: CategoriesSidebarProps) {
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [newCategoryName, setNewCategoryName] = useState(''); const [newCategoryName, setNewCategoryName] = useState('');
const [renamingCategory, setRenamingCategory] = useState<string | null>(null);
const [renameCategoryValue, setRenameCategoryValue] = useState('');
const [categoryColors, setCategoryColors] = useState<Record<string, number>>(() => categoryColorsSync.getAllColors());
const [colorPickerCategory, setColorPickerCategory] = useState<string | null>(null);
const [isSettingsCollapsed, setIsSettingsCollapsed] = useState(true);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const handleColorChange = () => {
setCategoryColors(categoryColorsSync.getAllColors());
};
categoryColorsSync.setChangeCallback(handleColorChange);
window.addEventListener('categoryColorChanged', handleColorChange);
return () => {
window.removeEventListener('categoryColorChanged', handleColorChange);
};
}, []);
const setCategoryColor = async (category: string, colorIndex: number | null) => {
await categoryColorsSync.setColor(category, colorIndex);
setColorPickerCategory(null);
};
useEffect(() => { useEffect(() => {
if (isCreating && inputRef.current) { if (isCreating && inputRef.current) {
@@ -66,6 +106,13 @@ export function CategoriesSidebar({
} }
}, [isCreating]); }, [isCreating]);
useEffect(() => {
if (renamingCategory && renameInputRef.current) {
renameInputRef.current.focus();
renameInputRef.current.select();
}
}, [renamingCategory]);
const handleCreateCategory = () => { const handleCreateCategory = () => {
if (newCategoryName.trim()) { if (newCategoryName.trim()) {
onCreateCategory(newCategoryName.trim()); onCreateCategory(newCategoryName.trim());
@@ -74,6 +121,19 @@ export function CategoriesSidebar({
} }
}; };
const handleRenameCategory = () => {
if (renameCategoryValue.trim() && renamingCategory && renameCategoryValue.trim() !== renamingCategory) {
onRenameCategory(renamingCategory, renameCategoryValue.trim());
}
setRenamingCategory(null);
setRenameCategoryValue('');
};
const startRenaming = (category: string) => {
setRenamingCategory(category);
setRenameCategoryValue(category);
};
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleCreateCategory(); handleCreateCategory();
@@ -83,6 +143,15 @@ export function CategoriesSidebar({
} }
}; };
const handleRenameKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleRenameCategory();
} else if (e.key === 'Escape') {
setRenamingCategory(null);
setRenameCategoryValue('');
}
};
if (isCollapsed) { if (isCollapsed) {
return ( return (
<button <button
@@ -142,20 +211,104 @@ export function CategoriesSidebar({
</button> </button>
{categories.map((category) => ( {categories.map((category) => (
<button renamingCategory === category ? (
key={category} <div key={category} className="space-y-1">
onClick={() => onSelectCategory(category)} <div className="flex items-center gap-2 px-3 py-2 bg-white dark:bg-gray-700 rounded-lg border border-blue-500">
className={`w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center ${ <svg className="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<input
ref={renameInputRef}
type="text"
value={renameCategoryValue}
onChange={(e) => setRenameCategoryValue(e.target.value)}
onKeyDown={handleRenameKeyDown}
onBlur={handleRenameCategory}
className="flex-1 text-sm px-2 py-1 border-none bg-transparent text-gray-900 dark:text-gray-100 focus:outline-none"
/>
</div>
<div className="px-3 text-xs text-gray-500 dark:text-gray-400">
Press <kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">Enter</kbd> to save, <kbd className="px-1 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">Esc</kbd> to cancel
</div>
</div>
) : (
<div key={category} className="relative">
<div
className={`group w-full px-3 py-2 rounded-lg transition-colors flex items-center ${
selectedCategory === category selectedCategory === category
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300' ? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
: 'hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300' : 'hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
}`} }`}
> >
<svg className="w-4 h-4 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <button
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> onClick={() => onSelectCategory(category)}
className="flex items-center flex-1 min-w-0 text-left"
>
{(() => {
const colorIndex = categoryColors[category];
const color = colorIndex !== undefined ? CATEGORY_COLORS[colorIndex] : null;
return (
<svg
className="w-4 h-4 mr-2 flex-shrink-0"
fill={color ? color.dot : "currentColor"}
viewBox="0 0 24 24"
>
<path d="M3 7c0-1.1.9-2 2-2h4l2 2h6c1.1 0 2 .9 2 2v10c0 1.1-.9 2-2 2H5c-1.1 0-2-.9-2-2V7z" />
</svg> </svg>
);
})()}
<span className="text-sm truncate">{category}</span> <span className="text-sm truncate">{category}</span>
</button> </button>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.stopPropagation();
setColorPickerCategory(colorPickerCategory === category ? null : category);
}}
className="p-1 hover:bg-gray-300 dark:hover:bg-gray-600 rounded flex-shrink-0"
title="Change color"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
startRenaming(category);
}}
className="p-1 hover:bg-gray-300 dark:hover:bg-gray-600 rounded flex-shrink-0"
title="Rename category"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
</div>
</div>
{colorPickerCategory === category && (
<div className="absolute left-0 right-0 top-full mt-1 bg-white dark:bg-gray-700 rounded-lg shadow-lg border border-gray-200 dark:border-gray-600 p-2 z-10">
<div className="grid grid-cols-5 gap-1.5 mb-2">
{CATEGORY_COLORS.map((color, idx) => (
<button
key={idx}
onClick={() => setCategoryColor(category, idx)}
className="w-7 h-7 rounded hover:scale-110 transition-transform border-2 border-gray-300 dark:border-gray-600"
style={{ backgroundColor: color.preview }}
title={color.name}
/>
))}
</div>
<button
onClick={() => setCategoryColor(category, null)}
className="w-full text-xs py-1.5 px-2 bg-gray-100 dark:bg-gray-600 hover:bg-gray-200 dark:hover:bg-gray-500 rounded text-gray-700 dark:text-gray-200 transition-colors"
>
Remove Color
</button>
</div>
)}
</div>
)
))} ))}
{isCreating && ( {isCreating && (
@@ -185,14 +338,24 @@ export function CategoriesSidebar({
</div> </div>
{/* User Info and Settings */} {/* User Info and Settings */}
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-white dark:bg-gray-900"> <div className="border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between p-4 pb-3">
<div className="flex items-center space-x-2 min-w-0"> <div className="flex items-center space-x-2 min-w-0">
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white font-semibold flex-shrink-0"> <div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white font-semibold flex-shrink-0">
{username.charAt(0).toUpperCase()} {username.charAt(0).toUpperCase()}
</div> </div>
<span className="text-sm text-gray-700 dark:text-gray-200 truncate font-medium">{username}</span> <span className="text-sm text-gray-700 dark:text-gray-200 truncate font-medium">{username}</span>
</div> </div>
<div className="flex items-center gap-1">
<button
onClick={() => setIsSettingsCollapsed(!isSettingsCollapsed)}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors flex-shrink-0"
title={isSettingsCollapsed ? "Show Settings" : "Hide Settings"}
>
<svg className={`w-4 h-4 text-gray-600 dark:text-gray-300 transition-transform ${isSettingsCollapsed ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<button <button
onClick={onLogout} onClick={onLogout}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors flex-shrink-0" className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors flex-shrink-0"
@@ -203,7 +366,10 @@ export function CategoriesSidebar({
</svg> </svg>
</button> </button>
</div> </div>
</div>
{!isSettingsCollapsed && (
<div className="px-4 pb-4">
{/* Theme Toggle */} {/* Theme Toggle */}
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="text-xs text-gray-500 dark:text-gray-400">Theme</span> <span className="text-xs text-gray-500 dark:text-gray-400">Theme</span>
@@ -322,6 +488,8 @@ export function CategoriesSidebar({
</div> </div>
</div> </div>
</div> </div>
)}
</div>
</div> </div>
); );
} }

View File

@@ -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">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Notes</h2> <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Notes</h2>
{!isOnline && (
<span className="px-2 py-0.5 text-xs font-medium bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-300 rounded-full flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3" />
</svg>
Offline
</span>
)}
{pendingSyncCount > 0 && (
<span className="px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded-full">
{pendingSyncCount} pending
</span>
)}
</div>
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<button <button
onClick={handleSync} onClick={handleSync}
@@ -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 {