Compare commits
76 Commits
backup-ui-
...
0.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
525413a08a | ||
|
|
1d15a39b4c | ||
|
|
b31f974411 | ||
|
|
511ebca4ad | ||
|
|
17c79a3aa8 | ||
|
|
0a5dba2a98 | ||
|
|
8bbd5f9262 | ||
|
|
244ba69eed | ||
|
|
36733da434 | ||
|
|
f4ba8c9775 | ||
|
|
dac08f1d2f | ||
|
|
0a6ecd25da | ||
|
|
cb7a8d8276 | ||
|
|
911662b214 | ||
|
|
dfc0e644eb | ||
|
|
5a925dc50e | ||
|
|
70c38cb925 | ||
|
|
4f13b0d57f | ||
|
|
4dbf0233b7 | ||
|
|
5de3cd3789 | ||
|
|
486579809f | ||
|
|
f8b3cc8a9d | ||
|
|
0b13a2df5b | ||
|
|
861eb1e103 | ||
|
|
edc65f2edd | ||
|
|
4ef0814ccd | ||
|
|
c775661caa | ||
|
|
3e93cf2408 | ||
|
|
3e3d9ca7f1 | ||
|
|
ed6dd69b32 | ||
|
|
bd6d2cd404 | ||
|
|
e86e851b31 | ||
|
|
e9ba48d7d4 | ||
|
|
c5c963200a | ||
|
|
013e7670f5 | ||
|
|
23ef338e47 | ||
|
|
1667c6cf13 | ||
|
|
6172abbe53 | ||
|
|
4ddf2d15a9 | ||
|
|
472e6e3b2e | ||
|
|
e3a1d74413 | ||
|
|
c147890138 | ||
|
|
7d992d103c | ||
|
|
ba4600773a | ||
|
|
5ff3427848 | ||
|
|
7fd765ceb6 | ||
|
|
7611f8e82e | ||
|
|
75c3cd4796 | ||
|
|
f06fc640b6 | ||
|
|
28914207f6 | ||
|
|
e94e201ec8 | ||
|
|
db7daa81a3 | ||
|
|
89c161f2f3 | ||
|
|
55f50f3a77 | ||
|
|
19a42a1190 | ||
|
|
e018b9e1e9 | ||
|
|
12579d6198 | ||
|
|
9d3c8b5e3c | ||
|
|
d53a454d7b | ||
|
|
ef7ec39fed | ||
|
|
93e2a87fa6 | ||
|
|
2d1cc4baf0 | ||
|
|
9a229dcc00 | ||
|
|
e6ecab13fa | ||
|
|
489f2f847d | ||
|
|
c7f314d632 | ||
|
|
a2c717c2e2 | ||
|
|
9b0a289cc8 | ||
|
|
42cc1ffcd9 | ||
|
|
d09920850d | ||
|
|
1c9efe6007 | ||
|
|
81cc72b444 | ||
|
|
f3096c16ca | ||
|
|
f4b324b702 | ||
|
|
835643d690 | ||
|
|
074f695b3e |
10
index.html
10
index.html
@@ -4,13 +4,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri + React + Typescript</title>
|
||||
<!-- Editor fonts (monospace) -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<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">
|
||||
<title>Nextcloud Notes</title>
|
||||
<!-- Local fonts for offline support -->
|
||||
<link rel="stylesheet" href="/fonts/fonts.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "nextcloud-notes-tauri",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nextcloud-notes-tauri",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.2",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "nextcloud-notes-tauri",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
BIN
public/fonts/Average-Regular.ttf
Normal file
BIN
public/fonts/Average-Regular.ttf
Normal file
Binary file not shown.
BIN
public/fonts/CrimsonPro-Italic-VariableFont_wght.ttf
Normal file
BIN
public/fonts/CrimsonPro-Italic-VariableFont_wght.ttf
Normal file
Binary file not shown.
BIN
public/fonts/CrimsonPro-VariableFont_wght.ttf
Normal file
BIN
public/fonts/CrimsonPro-VariableFont_wght.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Inconsolata-VariableFont_wdth,wght.ttf
Normal file
BIN
public/fonts/Inconsolata-VariableFont_wdth,wght.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Merriweather-Italic-VariableFont_opsz,wdth,wght.ttf
Normal file
BIN
public/fonts/Merriweather-Italic-VariableFont_opsz,wdth,wght.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Merriweather-VariableFont_opsz,wdth,wght.ttf
Normal file
BIN
public/fonts/Merriweather-VariableFont_opsz,wdth,wght.ttf
Normal file
Binary file not shown.
BIN
public/fonts/RobotoMono-VariableFont_wght.ttf
Normal file
BIN
public/fonts/RobotoMono-VariableFont_wght.ttf
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/fonts/RobotoSerif-VariableFont_GRAD,opsz,wdth,wght.ttf
Normal file
BIN
public/fonts/RobotoSerif-VariableFont_GRAD,opsz,wdth,wght.ttf
Normal file
Binary file not shown.
BIN
public/fonts/SourceCodePro-VariableFont_wght.ttf
Normal file
BIN
public/fonts/SourceCodePro-VariableFont_wght.ttf
Normal file
Binary file not shown.
90
public/fonts/fonts.css
Normal file
90
public/fonts/fonts.css
Normal file
@@ -0,0 +1,90 @@
|
||||
/* Editor Fonts (Monospace) */
|
||||
|
||||
/* Source Code Pro */
|
||||
@font-face {
|
||||
font-family: 'Source Code Pro';
|
||||
font-style: normal;
|
||||
font-weight: 200 900;
|
||||
font-display: swap;
|
||||
src: url('./SourceCodePro-VariableFont_wght.ttf') format('truetype');
|
||||
}
|
||||
|
||||
/* Roboto Mono */
|
||||
@font-face {
|
||||
font-family: 'Roboto Mono';
|
||||
font-style: normal;
|
||||
font-weight: 100 700;
|
||||
font-display: swap;
|
||||
src: url('./RobotoMono-VariableFont_wght.ttf') format('truetype');
|
||||
}
|
||||
|
||||
/* Inconsolata */
|
||||
@font-face {
|
||||
font-family: 'Inconsolata';
|
||||
font-style: normal;
|
||||
font-weight: 200 900;
|
||||
font-display: swap;
|
||||
src: url('./Inconsolata-VariableFont_wdth,wght.ttf') format('truetype');
|
||||
}
|
||||
|
||||
/* Preview Fonts (Serif) */
|
||||
|
||||
/* Merriweather */
|
||||
@font-face {
|
||||
font-family: 'Merriweather';
|
||||
font-style: normal;
|
||||
font-weight: 300 900;
|
||||
font-display: swap;
|
||||
src: url('./Merriweather-VariableFont_opsz,wdth,wght.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Merriweather';
|
||||
font-style: italic;
|
||||
font-weight: 300 900;
|
||||
font-display: swap;
|
||||
src: url('./Merriweather-Italic-VariableFont_opsz,wdth,wght.ttf') format('truetype');
|
||||
}
|
||||
|
||||
/* Crimson Pro */
|
||||
@font-face {
|
||||
font-family: 'Crimson Pro';
|
||||
font-style: normal;
|
||||
font-weight: 200 900;
|
||||
font-display: swap;
|
||||
src: url('./CrimsonPro-VariableFont_wght.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Crimson Pro';
|
||||
font-style: italic;
|
||||
font-weight: 200 900;
|
||||
font-display: swap;
|
||||
src: url('./CrimsonPro-Italic-VariableFont_wght.ttf') format('truetype');
|
||||
}
|
||||
|
||||
/* Roboto Serif */
|
||||
@font-face {
|
||||
font-family: 'Roboto Serif';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('./RobotoSerif-VariableFont_GRAD,opsz,wdth,wght.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Roboto Serif';
|
||||
font-style: italic;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('./RobotoSerif-Italic-VariableFont_GRAD,opsz,wdth,wght.ttf') format('truetype');
|
||||
}
|
||||
|
||||
/* Average */
|
||||
@font-face {
|
||||
font-family: 'Average';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./Average-Regular.ttf') format('truetype');
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Nextcloud Notes",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.2",
|
||||
"identifier": "com.davidrelich.nextcloud-notes",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -15,7 +15,7 @@
|
||||
"title": "Nextcloud Notes",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"devtools": true
|
||||
}
|
||||
|
||||
148
src/App.tsx
148
src/App.tsx
@@ -5,12 +5,16 @@ import { NoteEditor } from './components/NoteEditor';
|
||||
import { CategoriesSidebar } from './components/CategoriesSidebar';
|
||||
import { NextcloudAPI } from './api/nextcloud';
|
||||
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() {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const [api, setApi] = useState<NextcloudAPI | null>(null);
|
||||
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 [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
|
||||
const [selectedCategory, setSelectedCategory] = useState('');
|
||||
@@ -25,8 +29,14 @@ function App() {
|
||||
const [editorFontSize, setEditorFontSize] = useState(14);
|
||||
const [previewFont, setPreviewFont] = useState('Merriweather');
|
||||
const [previewFontSize, setPreviewFontSize] = useState(16);
|
||||
const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle');
|
||||
const [pendingSyncCount, setPendingSyncCount] = useState(0);
|
||||
const isOnline = useOnlineStatus();
|
||||
|
||||
useEffect(() => {
|
||||
const initApp = async () => {
|
||||
await localDB.init();
|
||||
|
||||
const savedServer = localStorage.getItem('serverURL');
|
||||
const savedUsername = localStorage.getItem('username');
|
||||
const savedPassword = localStorage.getItem('password');
|
||||
@@ -59,9 +69,14 @@ function App() {
|
||||
password: savedPassword,
|
||||
});
|
||||
setApi(apiInstance);
|
||||
syncManager.setAPI(apiInstance);
|
||||
categoryColorsSync.setAPI(apiInstance);
|
||||
setUsername(savedUsername);
|
||||
setIsLoggedIn(true);
|
||||
}
|
||||
};
|
||||
|
||||
initApp();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -88,43 +103,70 @@ function App() {
|
||||
document.documentElement.classList.toggle('dark', effectiveTheme === 'dark');
|
||||
}, [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(() => {
|
||||
if (api && isLoggedIn) {
|
||||
syncNotes();
|
||||
const interval = setInterval(syncNotes, 300000);
|
||||
loadNotes();
|
||||
const interval = setInterval(() => syncNotes(), 300000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [api, isLoggedIn]);
|
||||
|
||||
const syncNotes = async () => {
|
||||
if (!api) return;
|
||||
const loadNotes = async () => {
|
||||
try {
|
||||
const fetched = await api.fetchNotes();
|
||||
setNotes(fetched.sort((a, b) => b.modified - a.modified));
|
||||
if (!selectedNoteId && fetched.length > 0) {
|
||||
setSelectedNoteId(fetched[0].id);
|
||||
const loadedNotes = await syncManager.loadNotes();
|
||||
setNotes(loadedNotes.sort((a, b) => b.modified - a.modified));
|
||||
if (!selectedNoteId && loadedNotes.length > 0) {
|
||||
setSelectedNoteId(loadedNotes[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load notes:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const syncNotes = async () => {
|
||||
try {
|
||||
await syncManager.syncWithServer();
|
||||
await loadNotes();
|
||||
} catch (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('username', username);
|
||||
localStorage.setItem('password', password);
|
||||
|
||||
const apiInstance = new NextcloudAPI({ serverURL, username, password });
|
||||
setApi(apiInstance);
|
||||
syncManager.setAPI(apiInstance);
|
||||
categoryColorsSync.setAPI(apiInstance);
|
||||
setUsername(username);
|
||||
setIsLoggedIn(true);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
const handleLogout = async () => {
|
||||
localStorage.removeItem('serverURL');
|
||||
localStorage.removeItem('username');
|
||||
localStorage.removeItem('password');
|
||||
await localDB.clearNotes();
|
||||
setApi(null);
|
||||
syncManager.setAPI(null);
|
||||
categoryColorsSync.setAPI(null);
|
||||
setUsername('');
|
||||
setNotes([]);
|
||||
setSelectedNoteId(null);
|
||||
@@ -156,8 +198,19 @@ function App() {
|
||||
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 () => {
|
||||
if (!api) return;
|
||||
try {
|
||||
const timestamp = new Date().toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
@@ -168,7 +221,7 @@ function App() {
|
||||
hour12: false,
|
||||
}).replace(/[/:]/g, '-').replace(', ', ' ');
|
||||
|
||||
const note = await api.createNote(`New Note ${timestamp}`, '', selectedCategory);
|
||||
const note = await syncManager.createNote(`New Note ${timestamp}`, '', selectedCategory);
|
||||
setNotes([note, ...notes]);
|
||||
setSelectedNoteId(note.id);
|
||||
} catch (error) {
|
||||
@@ -182,29 +235,65 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateNote = async (updatedNote: Note) => {
|
||||
if (!api) return;
|
||||
const handleRenameCategory = async (oldName: string, newName: string) => {
|
||||
// Move all notes from old category to new category
|
||||
const notesToMove = notes.filter(note => note.category === oldName);
|
||||
|
||||
for (const note of notesToMove) {
|
||||
try {
|
||||
console.log('Sending to API - content length:', updatedNote.content.length);
|
||||
console.log('Sending to API - last 50 chars:', updatedNote.content.slice(-50));
|
||||
const result = await api.updateNote(updatedNote);
|
||||
console.log('Received from API - content length:', result.content.length);
|
||||
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));
|
||||
const movedNote = await syncManager.moveNote(note, newName);
|
||||
setNotes(prevNotes => prevNotes.map(n => n.id === note.id ? movedNote : n));
|
||||
} catch (error) {
|
||||
console.error(`Failed to move note ${note.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update manual categories list
|
||||
setManualCategories(prev =>
|
||||
prev.map(cat => cat === oldName ? newName : cat)
|
||||
);
|
||||
|
||||
// Update selected category if it was the renamed one
|
||||
if (selectedCategory === oldName) {
|
||||
setSelectedCategory(newName);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateNote = async (updatedNote: Note) => {
|
||||
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) {
|
||||
console.error('Update note failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteNote = async (note: Note) => {
|
||||
if (!api) return;
|
||||
|
||||
try {
|
||||
await api.deleteNote(note.id);
|
||||
setNotes(notes.filter(n => n.id !== note.id));
|
||||
await syncManager.deleteNote(note);
|
||||
const remainingNotes = notes.filter(n => n.id !== note.id);
|
||||
setNotes(remainingNotes);
|
||||
if (selectedNoteId === note.id) {
|
||||
setSelectedNoteId(notes[0]?.id || null);
|
||||
setSelectedNoteId(remainingNotes[0]?.id || null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete note failed:', error);
|
||||
@@ -240,6 +329,7 @@ function App() {
|
||||
selectedCategory={selectedCategory}
|
||||
onSelectCategory={setSelectedCategory}
|
||||
onCreateCategory={handleCreateCategory}
|
||||
onRenameCategory={handleRenameCategory}
|
||||
isCollapsed={isCategoriesCollapsed}
|
||||
onToggleCollapse={() => setIsCategoriesCollapsed(!isCategoriesCollapsed)}
|
||||
username={username}
|
||||
@@ -267,12 +357,16 @@ function App() {
|
||||
showFavoritesOnly={showFavoritesOnly}
|
||||
onToggleFavorites={() => setShowFavoritesOnly(!showFavoritesOnly)}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
syncStatus={syncStatus}
|
||||
pendingSyncCount={pendingSyncCount}
|
||||
isOnline={isOnline}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<NoteEditor
|
||||
note={selectedNote}
|
||||
onUpdateNote={handleUpdateNote}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
onUnsavedChanges={setHasUnsavedChanges}
|
||||
categories={categories}
|
||||
isFocusMode={isFocusMode}
|
||||
|
||||
@@ -61,7 +61,52 @@ export class NextcloudAPI {
|
||||
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}
|
||||
// The path from markdown is like: .attachments.38479/Screenshot.png
|
||||
// We need to construct the full WebDAV URL
|
||||
@@ -77,7 +122,7 @@ export class NextcloudAPI {
|
||||
webdavPath += `/${path}`;
|
||||
|
||||
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, {
|
||||
headers: {
|
||||
@@ -102,7 +147,7 @@ export class NextcloudAPI {
|
||||
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
|
||||
// Returns the relative path to insert into markdown
|
||||
|
||||
@@ -112,7 +157,14 @@ export class NextcloudAPI {
|
||||
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 fullPath = `${webdavPath}/${attachmentDir}/${fileName}`;
|
||||
|
||||
@@ -152,4 +204,350 @@ export class NextcloudAPI {
|
||||
// Return the relative path for markdown
|
||||
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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { categoryColorsSync } from '../services/categoryColorsSync';
|
||||
|
||||
const EDITOR_FONTS = [
|
||||
{ name: 'Source Code Pro', value: 'Source Code Pro' },
|
||||
@@ -20,6 +21,7 @@ interface CategoriesSidebarProps {
|
||||
selectedCategory: string;
|
||||
onSelectCategory: (category: string) => void;
|
||||
onCreateCategory: (name: string) => void;
|
||||
onRenameCategory: (oldName: string, newName: string) => void;
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
username: string;
|
||||
@@ -36,11 +38,25 @@ interface CategoriesSidebarProps {
|
||||
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({
|
||||
categories,
|
||||
selectedCategory,
|
||||
onSelectCategory,
|
||||
onCreateCategory,
|
||||
onRenameCategory,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
username,
|
||||
@@ -58,7 +74,31 @@ export function CategoriesSidebar({
|
||||
}: CategoriesSidebarProps) {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
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 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(() => {
|
||||
if (isCreating && inputRef.current) {
|
||||
@@ -66,6 +106,13 @@ export function CategoriesSidebar({
|
||||
}
|
||||
}, [isCreating]);
|
||||
|
||||
useEffect(() => {
|
||||
if (renamingCategory && renameInputRef.current) {
|
||||
renameInputRef.current.focus();
|
||||
renameInputRef.current.select();
|
||||
}
|
||||
}, [renamingCategory]);
|
||||
|
||||
const handleCreateCategory = () => {
|
||||
if (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) => {
|
||||
if (e.key === 'Enter') {
|
||||
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) {
|
||||
return (
|
||||
<button
|
||||
@@ -142,20 +211,104 @@ export function CategoriesSidebar({
|
||||
</button>
|
||||
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => onSelectCategory(category)}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center ${
|
||||
renamingCategory === category ? (
|
||||
<div key={category} className="space-y-1">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-white dark:bg-gray-700 rounded-lg border border-blue-500">
|
||||
<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
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2 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" />
|
||||
<button
|
||||
onClick={() => onSelectCategory(category)}
|
||||
className="flex items-center flex-1 min-w-0 text-left"
|
||||
>
|
||||
{(() => {
|
||||
const colorIndex = categoryColors[category];
|
||||
const color = colorIndex !== undefined ? CATEGORY_COLORS[colorIndex] : null;
|
||||
return (
|
||||
<svg
|
||||
className="w-4 h-4 mr-2 flex-shrink-0"
|
||||
fill={color ? color.dot : "currentColor"}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M3 7c0-1.1.9-2 2-2h4l2 2h6c1.1 0 2 .9 2 2v10c0 1.1-.9 2-2 2H5c-1.1 0-2-.9-2-2V7z" />
|
||||
</svg>
|
||||
);
|
||||
})()}
|
||||
<span className="text-sm truncate">{category}</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setColorPickerCategory(colorPickerCategory === category ? null : category);
|
||||
}}
|
||||
className="p-1 hover:bg-gray-300 dark:hover:bg-gray-600 rounded flex-shrink-0"
|
||||
title="Change color"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startRenaming(category);
|
||||
}}
|
||||
className="p-1 hover:bg-gray-300 dark:hover:bg-gray-600 rounded flex-shrink-0"
|
||||
title="Rename category"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{colorPickerCategory === category && (
|
||||
<div className="absolute left-0 right-0 top-full mt-1 bg-white dark:bg-gray-700 rounded-lg shadow-lg border border-gray-200 dark:border-gray-600 p-2 z-10">
|
||||
<div className="grid grid-cols-5 gap-1.5 mb-2">
|
||||
{CATEGORY_COLORS.map((color, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setCategoryColor(category, idx)}
|
||||
className="w-7 h-7 rounded hover:scale-110 transition-transform border-2 border-gray-300 dark:border-gray-600"
|
||||
style={{ backgroundColor: color.preview }}
|
||||
title={color.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCategoryColor(category, null)}
|
||||
className="w-full text-xs py-1.5 px-2 bg-gray-100 dark:bg-gray-600 hover:bg-gray-200 dark:hover:bg-gray-500 rounded text-gray-700 dark:text-gray-200 transition-colors"
|
||||
>
|
||||
Remove Color
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
|
||||
{isCreating && (
|
||||
@@ -185,14 +338,24 @@ export function CategoriesSidebar({
|
||||
</div>
|
||||
|
||||
{/* 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="flex items-center justify-between mb-3">
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
|
||||
<div className="flex items-center justify-between p-4 pb-3">
|
||||
<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">
|
||||
{username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-200 truncate font-medium">{username}</span>
|
||||
</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
|
||||
onClick={onLogout}
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isSettingsCollapsed && (
|
||||
<div className="px-4 pb-4">
|
||||
{/* Theme Toggle */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Theme</span>
|
||||
@@ -322,6 +488,8 @@ export function CategoriesSidebar({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { InsertToolbar } from './InsertToolbar';
|
||||
interface NoteEditorProps {
|
||||
note: Note | null;
|
||||
onUpdateNote: (note: Note) => void;
|
||||
onToggleFavorite?: (note: Note, favorite: boolean) => void;
|
||||
onUnsavedChanges?: (hasChanges: boolean) => void;
|
||||
categories: string[];
|
||||
isFocusMode?: boolean;
|
||||
@@ -24,20 +25,19 @@ interface NoteEditorProps {
|
||||
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) {
|
||||
const [localTitle, setLocalTitle] = useState('');
|
||||
export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChanges, categories, isFocusMode, onToggleFocusMode, editorFont = 'Source Code Pro', editorFontSize = 14, previewFont = 'Merriweather', previewFontSize = 16, api }: NoteEditorProps) {
|
||||
const [localContent, setLocalContent] = useState('');
|
||||
const [localCategory, setLocalCategory] = useState('');
|
||||
const [localFavorite, setLocalFavorite] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [titleManuallyEdited, setTitleManuallyEdited] = useState(false);
|
||||
const [isExportingPDF, setIsExportingPDF] = useState(false);
|
||||
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
||||
const [processedContent, setProcessedContent] = useState('');
|
||||
const [isLoadingImages, setIsLoadingImages] = 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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -63,8 +63,16 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
||||
// Use setTimeout to ensure DOM has updated
|
||||
setTimeout(() => {
|
||||
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 = textareaRef.current.scrollHeight + 'px';
|
||||
|
||||
// Restore cursor position and scroll position
|
||||
textareaRef.current.setSelectionRange(cursorPosition, cursorPosition);
|
||||
textareaRef.current.scrollTop = scrollTop;
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
@@ -77,13 +85,23 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
||||
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 () => {
|
||||
console.log(`[Note ${note.id}] Processing images in preview mode. Content length: ${localContent.length}`);
|
||||
setIsLoadingImages(true);
|
||||
setProcessedContent(''); // Clear old content immediately
|
||||
|
||||
// Find all image references in markdown: 
|
||||
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
let content = localContent;
|
||||
const matches = [...localContent.matchAll(imageRegex)];
|
||||
console.log(`[Note ${note.id}] Found ${matches.length} images to process`);
|
||||
|
||||
for (const match of matches) {
|
||||
const [fullMatch, alt, imagePath] = match;
|
||||
@@ -121,29 +139,39 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
||||
useEffect(() => {
|
||||
const loadNewNote = () => {
|
||||
if (note) {
|
||||
setLocalTitle(note.title);
|
||||
setLocalContent(note.content);
|
||||
setLocalCategory(note.category || '');
|
||||
setLocalFavorite(note.favorite);
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
const firstLine = note.content.split('\n')[0].replace(/^#+\s*/, '').trim();
|
||||
const titleMatchesFirstLine = note.title === firstLine || note.title === firstLine.substring(0, 50);
|
||||
setTitleManuallyEdited(!titleMatchesFirstLine);
|
||||
|
||||
previousNoteIdRef.current = note.id;
|
||||
previousNoteContentRef.current = note.content;
|
||||
}
|
||||
};
|
||||
|
||||
// Switching to a different note
|
||||
if (previousNoteIdRef.current !== null && previousNoteIdRef.current !== note?.id) {
|
||||
console.log(`Switching from note ${previousNoteIdRef.current} to note ${note?.id}`);
|
||||
setProcessedContent('');
|
||||
if (hasUnsavedChanges) {
|
||||
handleSave();
|
||||
}
|
||||
setTimeout(loadNewNote, 100);
|
||||
} else {
|
||||
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 = () => {
|
||||
if (!note || !hasUnsavedChanges) return;
|
||||
@@ -152,9 +180,13 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
||||
console.log('Last 50 chars:', localContent.slice(-50));
|
||||
setIsSaving(true);
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
|
||||
const title = firstLine || 'Untitled';
|
||||
|
||||
onUpdateNote({
|
||||
...note,
|
||||
title: localTitle,
|
||||
title,
|
||||
content: localContent,
|
||||
category: localCategory,
|
||||
favorite: localFavorite,
|
||||
@@ -162,36 +194,33 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
||||
setTimeout(() => setIsSaving(false), 500);
|
||||
};
|
||||
|
||||
const handleTitleChange = (value: string) => {
|
||||
setLocalTitle(value);
|
||||
setTitleManuallyEdited(true);
|
||||
setHasUnsavedChanges(true);
|
||||
};
|
||||
|
||||
const handleContentChange = (value: string) => {
|
||||
setLocalContent(value);
|
||||
setHasUnsavedChanges(true);
|
||||
|
||||
if (!titleManuallyEdited) {
|
||||
const firstLine = value.split('\n')[0].replace(/^#+\s*/, '').trim();
|
||||
if (firstLine) {
|
||||
setLocalTitle(firstLine.substring(0, 50));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
if (!note) return;
|
||||
|
||||
setLocalTitle(note.title);
|
||||
setLocalContent(note.content);
|
||||
setLocalCategory(note.category || '');
|
||||
setLocalFavorite(note.favorite);
|
||||
setHasUnsavedChanges(false);
|
||||
};
|
||||
|
||||
const firstLine = note.content.split('\n')[0].replace(/^#+\s*/, '').trim();
|
||||
const titleMatchesFirstLine = note.title === firstLine || note.title === firstLine.substring(0, 50);
|
||||
setTitleManuallyEdited(!titleMatchesFirstLine);
|
||||
const loadFontAsBase64 = async (fontPath: string): Promise<string> => {
|
||||
const response = await fetch(fontPath);
|
||||
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 () => {
|
||||
@@ -200,14 +229,105 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
||||
setIsExportingPDF(true);
|
||||
|
||||
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, ``);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const dataUrl = await api.fetchAttachment(note.id, imagePath, note.category);
|
||||
imageCache.set(cacheKey, dataUrl);
|
||||
contentForPDF = contentForPDF.replace(fullMatch, ``);
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch attachment for PDF: ${imagePath}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.style.fontFamily = `"${previewFont}", Georgia, serif`;
|
||||
container.style.fontSize = '12px';
|
||||
container.style.fontSize = `${previewFontSize}px`;
|
||||
container.style.lineHeight = '1.6';
|
||||
container.style.color = '#000000';
|
||||
|
||||
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.marginBottom = '20px';
|
||||
titleElement.style.fontSize = '24px';
|
||||
@@ -218,37 +338,40 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
||||
container.appendChild(titleElement);
|
||||
|
||||
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.style.fontSize = '12px';
|
||||
contentElement.style.fontSize = `${previewFontSize}px`;
|
||||
contentElement.style.lineHeight = '1.6';
|
||||
contentElement.style.color = '#000000';
|
||||
container.appendChild(contentElement);
|
||||
|
||||
// Apply monospace font to code elements
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
code, pre { font-family: "Source Code Pro", ui-monospace, monospace !important; }
|
||||
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; }
|
||||
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);
|
||||
|
||||
// Create PDF using jsPDF's html() method (like dompdf)
|
||||
const pdf = new jsPDF({
|
||||
orientation: 'portrait',
|
||||
unit: 'mm',
|
||||
format: 'a4',
|
||||
});
|
||||
|
||||
// Use jsPDF's html() method which handles pagination automatically
|
||||
// Use jsPDF's html() method with custom font set
|
||||
await pdf.html(container, {
|
||||
callback: async (doc) => {
|
||||
// Save the PDF
|
||||
const fileName = `${localTitle || 'note'}.pdf`;
|
||||
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
|
||||
const fileName = `${firstLine || 'note'}.pdf`;
|
||||
doc.save(fileName);
|
||||
|
||||
// Show success message using Tauri dialog
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await message(`PDF exported successfully!\n\nFile: ${fileName}\nLocation: Downloads folder`, {
|
||||
@@ -261,10 +384,10 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
||||
setIsExportingPDF(false);
|
||||
}, 500);
|
||||
},
|
||||
margin: [20, 20, 20, 20], // top, right, bottom, left margins in mm
|
||||
autoPaging: 'text', // Enable automatic page breaks
|
||||
width: 170, // Content width in mm (A4 width 210mm - 40mm margins)
|
||||
windowWidth: 650, // Rendering width in pixels (matches content width ratio)
|
||||
margin: [20, 20, 20, 20],
|
||||
autoPaging: 'text',
|
||||
width: 170,
|
||||
windowWidth: 650,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('PDF export failed:', error);
|
||||
@@ -281,14 +404,23 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
||||
};
|
||||
|
||||
const handleFavoriteToggle = () => {
|
||||
setLocalFavorite(!localFavorite);
|
||||
if (note) {
|
||||
const newFavorite = !localFavorite;
|
||||
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({
|
||||
...note,
|
||||
title: localTitle,
|
||||
title,
|
||||
content: localContent,
|
||||
category: localCategory,
|
||||
favorite: !localFavorite,
|
||||
favorite: newFavorite,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -528,36 +660,6 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<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">
|
||||
@@ -584,42 +686,6 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
||||
</svg>
|
||||
</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 */}
|
||||
<button
|
||||
onClick={() => setIsPreviewMode(!isPreviewMode)}
|
||||
@@ -663,6 +729,21 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
||||
|
||||
{/* Action Buttons */}
|
||||
<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
|
||||
onClick={handleSave}
|
||||
disabled={!hasUnsavedChanges || isSaving}
|
||||
@@ -756,7 +837,7 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
||||
</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 }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: marked.parse(processedContent || '', { async: false }) as string
|
||||
@@ -772,6 +853,13 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
||||
onInsertFile={handleInsertFile}
|
||||
isUploading={isUploading}
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
onChange={handleAttachmentUpload}
|
||||
className="hidden"
|
||||
accept="image/*,.pdf,.doc,.docx,.txt,.md"
|
||||
/>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={localContent}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Note } from '../types';
|
||||
import { SyncStatus } from '../services/syncManager';
|
||||
import { categoryColorsSync } from '../services/categoryColorsSync';
|
||||
|
||||
interface NotesListProps {
|
||||
notes: Note[];
|
||||
selectedNoteId: number | null;
|
||||
onSelectNote: (id: number) => void;
|
||||
selectedNoteId: number | string | null;
|
||||
onSelectNote: (id: number | string) => void;
|
||||
onCreateNote: () => void;
|
||||
onDeleteNote: (note: Note) => void;
|
||||
onSync: () => void;
|
||||
@@ -13,6 +15,9 @@ interface NotesListProps {
|
||||
showFavoritesOnly: boolean;
|
||||
onToggleFavorites: () => void;
|
||||
hasUnsavedChanges: boolean;
|
||||
syncStatus: SyncStatus;
|
||||
pendingSyncCount: number;
|
||||
isOnline: boolean;
|
||||
}
|
||||
|
||||
export function NotesList({
|
||||
@@ -27,16 +32,64 @@ export function NotesList({
|
||||
showFavoritesOnly,
|
||||
onToggleFavorites,
|
||||
hasUnsavedChanges,
|
||||
syncStatus,
|
||||
pendingSyncCount,
|
||||
isOnline,
|
||||
}: NotesListProps) {
|
||||
const [isSyncing, setIsSyncing] = React.useState(false);
|
||||
const [deleteClickedId, setDeleteClickedId] = React.useState<number | null>(null);
|
||||
const [deleteClickedId, setDeleteClickedId] = React.useState<number | string | 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 () => {
|
||||
setIsSyncing(true);
|
||||
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) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -78,11 +131,55 @@ export function NotesList({
|
||||
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 (
|
||||
<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="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>
|
||||
{!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">
|
||||
<button
|
||||
onClick={handleSync}
|
||||
@@ -194,8 +291,24 @@ export function NotesList({
|
||||
</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>
|
||||
{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>
|
||||
|
||||
{getPreview(note.content) && (
|
||||
@@ -207,6 +320,17 @@ export function NotesList({
|
||||
))
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
155
src/db/localDB.ts
Normal file
155
src/db/localDB.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Note } from '../types';
|
||||
|
||||
const DB_NAME = 'nextcloud-notes-db';
|
||||
const DB_VERSION = 2; // Bumped to clear old cache with URL-encoded categories
|
||||
const NOTES_STORE = 'notes';
|
||||
const SYNC_QUEUE_STORE = 'syncQueue';
|
||||
|
||||
export interface SyncOperation {
|
||||
id: string;
|
||||
type: 'create' | 'update' | 'delete';
|
||||
noteId: number | string;
|
||||
note?: Note;
|
||||
timestamp: number;
|
||||
retryCount: number;
|
||||
}
|
||||
|
||||
class LocalDB {
|
||||
private db: IDBDatabase | null = null;
|
||||
|
||||
async init(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
const oldVersion = event.oldVersion;
|
||||
|
||||
if (!db.objectStoreNames.contains(NOTES_STORE)) {
|
||||
const notesStore = db.createObjectStore(NOTES_STORE, { keyPath: 'id' });
|
||||
notesStore.createIndex('modified', 'modified', { unique: false });
|
||||
notesStore.createIndex('category', 'category', { unique: false });
|
||||
} else if (oldVersion < 2) {
|
||||
// Clear notes store when upgrading to v2 to remove old cached notes
|
||||
// with stripped first lines
|
||||
const transaction = (event.target as IDBOpenDBRequest).transaction!;
|
||||
const notesStore = transaction.objectStore(NOTES_STORE);
|
||||
notesStore.clear();
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains(SYNC_QUEUE_STORE)) {
|
||||
db.createObjectStore(SYNC_QUEUE_STORE, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getStore(storeName: string, mode: IDBTransactionMode = 'readonly'): IDBObjectStore {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
const transaction = this.db.transaction(storeName, mode);
|
||||
return transaction.objectStore(storeName);
|
||||
}
|
||||
|
||||
// Notes operations
|
||||
async getAllNotes(): Promise<Note[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this.getStore(NOTES_STORE);
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getNote(id: number | string): Promise<Note | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this.getStore(NOTES_STORE);
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async saveNote(note: Note): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this.getStore(NOTES_STORE, 'readwrite');
|
||||
const request = store.put(note);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async saveNotes(notes: Note[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this.getStore(NOTES_STORE, 'readwrite');
|
||||
const transaction = store.transaction;
|
||||
|
||||
notes.forEach(note => store.put(note));
|
||||
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteNote(id: number | string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this.getStore(NOTES_STORE, 'readwrite');
|
||||
const request = store.delete(id);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async clearNotes(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this.getStore(NOTES_STORE, 'readwrite');
|
||||
const request = store.clear();
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// Sync queue operations
|
||||
async addToSyncQueue(operation: SyncOperation): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this.getStore(SYNC_QUEUE_STORE, 'readwrite');
|
||||
const request = store.put(operation);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getSyncQueue(): Promise<SyncOperation[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this.getStore(SYNC_QUEUE_STORE);
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async removeFromSyncQueue(id: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this.getStore(SYNC_QUEUE_STORE, 'readwrite');
|
||||
const request = store.delete(id);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async clearSyncQueue(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this.getStore(SYNC_QUEUE_STORE, 'readwrite');
|
||||
const request = store.clear();
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const localDB = new LocalDB();
|
||||
20
src/hooks/useOnlineStatus.ts
Normal file
20
src/hooks/useOnlineStatus.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useOnlineStatus() {
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOnline(true);
|
||||
const handleOffline = () => setIsOnline(false);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isOnline;
|
||||
}
|
||||
@@ -16,6 +16,19 @@ code {
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
.ProseMirror {
|
||||
min-height: 100%;
|
||||
@@ -113,11 +126,13 @@ code {
|
||||
|
||||
.ProseMirror code {
|
||||
background-color: #f3f4f6;
|
||||
padding: 0.125rem 0.25rem;
|
||||
padding: 0.05rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 1.1em;
|
||||
color: #1f2937;
|
||||
vertical-align: baseline;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dark .ProseMirror code {
|
||||
|
||||
98
src/services/categoryColorsSync.ts
Normal file
98
src/services/categoryColorsSync.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { NextcloudAPI } from '../api/nextcloud';
|
||||
|
||||
export class CategoryColorsSync {
|
||||
private api: NextcloudAPI | null = null;
|
||||
private colors: Record<string, number> = {};
|
||||
private syncInProgress: boolean = false;
|
||||
private changeCallback: (() => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
this.loadFromLocalStorage();
|
||||
}
|
||||
|
||||
setAPI(api: NextcloudAPI | null) {
|
||||
this.api = api;
|
||||
if (api) {
|
||||
this.syncFromServer();
|
||||
}
|
||||
}
|
||||
|
||||
setChangeCallback(callback: () => void) {
|
||||
this.changeCallback = callback;
|
||||
}
|
||||
|
||||
private loadFromLocalStorage() {
|
||||
const saved = localStorage.getItem('categoryColors');
|
||||
if (saved) {
|
||||
try {
|
||||
this.colors = JSON.parse(saved);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse category colors from localStorage:', e);
|
||||
this.colors = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private saveToLocalStorage() {
|
||||
localStorage.setItem('categoryColors', JSON.stringify(this.colors));
|
||||
}
|
||||
|
||||
private notifyChange() {
|
||||
if (this.changeCallback) {
|
||||
this.changeCallback();
|
||||
}
|
||||
window.dispatchEvent(new Event('categoryColorChanged'));
|
||||
}
|
||||
|
||||
async syncFromServer(): Promise<void> {
|
||||
if (!this.api || this.syncInProgress) return;
|
||||
|
||||
this.syncInProgress = true;
|
||||
try {
|
||||
const serverColors = await this.api.fetchCategoryColors();
|
||||
|
||||
// Merge: server wins for conflicts
|
||||
const hasChanges = JSON.stringify(this.colors) !== JSON.stringify(serverColors);
|
||||
|
||||
if (hasChanges) {
|
||||
this.colors = serverColors;
|
||||
this.saveToLocalStorage();
|
||||
this.notifyChange();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to sync category colors from server:', error);
|
||||
} finally {
|
||||
this.syncInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
async setColor(category: string, colorIndex: number | null): Promise<void> {
|
||||
if (colorIndex === null) {
|
||||
delete this.colors[category];
|
||||
} else {
|
||||
this.colors[category] = colorIndex;
|
||||
}
|
||||
|
||||
this.saveToLocalStorage();
|
||||
this.notifyChange();
|
||||
|
||||
// Sync to server if online
|
||||
if (this.api) {
|
||||
try {
|
||||
await this.api.saveCategoryColors(this.colors);
|
||||
} catch (error) {
|
||||
console.error('Failed to save category colors to server:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getColor(category: string): number | undefined {
|
||||
return this.colors[category];
|
||||
}
|
||||
|
||||
getAllColors(): Record<string, number> {
|
||||
return { ...this.colors };
|
||||
}
|
||||
}
|
||||
|
||||
export const categoryColorsSync = new CategoryColorsSync();
|
||||
375
src/services/syncManager.ts
Normal file
375
src/services/syncManager.ts
Normal 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();
|
||||
@@ -1,5 +1,5 @@
|
||||
export interface Note {
|
||||
id: number;
|
||||
id: number | string; // number for API, string (filename) for WebDAV
|
||||
etag: string;
|
||||
readonly: boolean;
|
||||
content: string;
|
||||
@@ -7,6 +7,8 @@ export interface Note {
|
||||
category: string;
|
||||
favorite: boolean;
|
||||
modified: number;
|
||||
filename?: string; // WebDAV: actual filename on server
|
||||
path?: string; // WebDAV: full path including category
|
||||
}
|
||||
|
||||
export interface APIConfig {
|
||||
|
||||
Reference in New Issue
Block a user