Files
nextcloud-notes-desktop-app/src/App.tsx
drelich c11e792062 Initial release: Nextcloud Notes Desktop App
A cross-platform desktop application for Nextcloud Notes built with Tauri, React, and TypeScript.

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

Tech Stack:
- Tauri (Rust backend)
- React + TypeScript
- TailwindCSS for styling
- Vite for build tooling
- Markdown-it for rendering
2026-03-18 16:03:53 +01:00

291 lines
9.9 KiB
TypeScript

import { useState, useEffect } from 'react';
import { LoginView } from './components/LoginView';
import { NotesList } from './components/NotesList';
import { NoteEditor } from './components/NoteEditor';
import { CategoriesSidebar } from './components/CategoriesSidebar';
import { NextcloudAPI } from './api/nextcloud';
import { Note } from './types';
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 [searchText, setSearchText] = useState('');
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
const [selectedCategory, setSelectedCategory] = useState('');
const [manualCategories, setManualCategories] = useState<string[]>([]);
const [isCategoriesCollapsed, setIsCategoriesCollapsed] = useState(false);
const [isFocusMode, setIsFocusMode] = useState(false);
const [username, setUsername] = useState('');
const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('system');
const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light');
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [editorFont, setEditorFont] = useState('Source Code Pro');
const [editorFontSize, setEditorFontSize] = useState(14);
const [previewFont, setPreviewFont] = useState('Merriweather');
const [previewFontSize, setPreviewFontSize] = useState(16);
useEffect(() => {
const savedServer = localStorage.getItem('serverURL');
const savedUsername = localStorage.getItem('username');
const savedPassword = localStorage.getItem('password');
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | 'system' | null;
const savedEditorFont = localStorage.getItem('editorFont');
const savedPreviewFont = localStorage.getItem('previewFont');
if (savedTheme) {
setTheme(savedTheme);
}
if (savedEditorFont) {
setEditorFont(savedEditorFont);
}
if (savedPreviewFont) {
setPreviewFont(savedPreviewFont);
}
const savedEditorFontSize = localStorage.getItem('editorFontSize');
const savedPreviewFontSize = localStorage.getItem('previewFontSize');
if (savedEditorFontSize) {
setEditorFontSize(parseInt(savedEditorFontSize, 10));
}
if (savedPreviewFontSize) {
setPreviewFontSize(parseInt(savedPreviewFontSize, 10));
}
if (savedServer && savedUsername && savedPassword) {
const apiInstance = new NextcloudAPI({
serverURL: savedServer,
username: savedUsername,
password: savedPassword,
});
setApi(apiInstance);
setUsername(savedUsername);
setIsLoggedIn(true);
}
}, []);
useEffect(() => {
const updateEffectiveTheme = () => {
if (theme === 'system') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
setEffectiveTheme(isDark ? 'dark' : 'light');
} else {
setEffectiveTheme(theme);
}
};
updateEffectiveTheme();
if (theme === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => updateEffectiveTheme();
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}
}, [theme]);
useEffect(() => {
document.documentElement.classList.toggle('dark', effectiveTheme === 'dark');
}, [effectiveTheme]);
useEffect(() => {
if (api && isLoggedIn) {
syncNotes();
const interval = setInterval(syncNotes, 300000);
return () => clearInterval(interval);
}
}, [api, isLoggedIn]);
const syncNotes = async () => {
if (!api) return;
try {
const fetched = await api.fetchNotes();
setNotes(fetched.sort((a, b) => b.modified - a.modified));
if (!selectedNoteId && fetched.length > 0) {
setSelectedNoteId(fetched[0].id);
}
} catch (error) {
console.error('Sync failed:', error);
}
};
const handleLogin = (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);
setUsername(username);
setIsLoggedIn(true);
};
const handleLogout = () => {
localStorage.removeItem('serverURL');
localStorage.removeItem('username');
localStorage.removeItem('password');
setApi(null);
setUsername('');
setNotes([]);
setSelectedNoteId(null);
setIsLoggedIn(false);
};
const handleThemeChange = (newTheme: 'light' | 'dark' | 'system') => {
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
};
const handleEditorFontChange = (font: string) => {
setEditorFont(font);
localStorage.setItem('editorFont', font);
};
const handlePreviewFontChange = (font: string) => {
setPreviewFont(font);
localStorage.setItem('previewFont', font);
};
const handleEditorFontSizeChange = (size: number) => {
setEditorFontSize(size);
localStorage.setItem('editorFontSize', size.toString());
};
const handlePreviewFontSizeChange = (size: number) => {
setPreviewFontSize(size);
localStorage.setItem('previewFontSize', size.toString());
};
const handleCreateNote = async () => {
if (!api) return;
try {
const timestamp = new Date().toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).replace(/[/:]/g, '-').replace(', ', ' ');
const note = await api.createNote(`New Note ${timestamp}`, '', selectedCategory);
setNotes([note, ...notes]);
setSelectedNoteId(note.id);
} catch (error) {
console.error('Create note failed:', error);
}
};
const handleCreateCategory = (name: string) => {
if (!manualCategories.includes(name)) {
setManualCategories([...manualCategories, name]);
}
};
const handleUpdateNote = async (updatedNote: Note) => {
if (!api) return;
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));
} 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));
if (selectedNoteId === note.id) {
setSelectedNoteId(notes[0]?.id || null);
}
} catch (error) {
console.error('Delete note failed:', error);
}
};
const categoriesFromNotes = Array.from(new Set(notes.map(n => n.category).filter(c => c)));
const categories = Array.from(new Set([...categoriesFromNotes, ...manualCategories])).sort();
const filteredNotes = notes.filter(note => {
if (selectedCategory && note.category !== selectedCategory) return false;
if (showFavoritesOnly && !note.favorite) return false;
if (searchText) {
const search = searchText.toLowerCase();
return note.title.toLowerCase().includes(search) ||
note.content.toLowerCase().includes(search);
}
return true;
});
const selectedNote = notes.find(n => n.id === selectedNoteId) || null;
if (!isLoggedIn) {
return <LoginView onLogin={handleLogin} />;
}
return (
<div className="flex h-screen">
{!isFocusMode && (
<>
<CategoriesSidebar
categories={categories}
selectedCategory={selectedCategory}
onSelectCategory={setSelectedCategory}
onCreateCategory={handleCreateCategory}
isCollapsed={isCategoriesCollapsed}
onToggleCollapse={() => setIsCategoriesCollapsed(!isCategoriesCollapsed)}
username={username}
onLogout={handleLogout}
theme={theme}
onThemeChange={handleThemeChange}
editorFont={editorFont}
onEditorFontChange={handleEditorFontChange}
editorFontSize={editorFontSize}
onEditorFontSizeChange={handleEditorFontSizeChange}
previewFont={previewFont}
onPreviewFontChange={handlePreviewFontChange}
previewFontSize={previewFontSize}
onPreviewFontSizeChange={handlePreviewFontSizeChange}
/>
<NotesList
notes={filteredNotes}
selectedNoteId={selectedNoteId}
onSelectNote={setSelectedNoteId}
onCreateNote={handleCreateNote}
onDeleteNote={handleDeleteNote}
onSync={syncNotes}
searchText={searchText}
onSearchChange={setSearchText}
showFavoritesOnly={showFavoritesOnly}
onToggleFavorites={() => setShowFavoritesOnly(!showFavoritesOnly)}
hasUnsavedChanges={hasUnsavedChanges}
/>
</>
)}
<NoteEditor
note={selectedNote}
onUpdateNote={handleUpdateNote}
onUnsavedChanges={setHasUnsavedChanges}
categories={categories}
isFocusMode={isFocusMode}
onToggleFocusMode={() => setIsFocusMode(!isFocusMode)}
editorFont={editorFont}
editorFontSize={editorFontSize}
previewFont={previewFont}
previewFontSize={previewFontSize}
api={api}
/>
</div>
);
}
export default App;