Initial commit: Nextcloud Notes Tauri app with WYSIWYG editor

Features:
- WYSIWYG markdown editor with TipTap
- Manual save with unsaved changes indicator
- Auto-title derivation from first line (with manual override)
- Manual sync button with visual feedback
- Auto-sync every 5 minutes
- Full formatting toolbar (bold, italic, headings, lists, code)
- Note creation, editing, deletion
- Search and favorites filter
- Cross-platform desktop app built with Tauri + React + TypeScript
This commit is contained in:
drelich
2026-03-16 23:49:51 +01:00
commit 2ad076c052
47 changed files with 5294 additions and 0 deletions

116
src/App.css Normal file
View File

@@ -0,0 +1,116 @@
.logo.vite:hover {
filter: drop-shadow(0 0 2em #747bff);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafb);
}
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: #0f0f0f;
background-color: #f6f6f6;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
.container {
margin: 0;
padding-top: 10vh;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: 0.75s;
}
.logo.tauri:hover {
filter: drop-shadow(0 0 2em #24c8db);
}
.row {
display: flex;
justify-content: center;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
h1 {
text-align: center;
}
input,
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
color: #0f0f0f;
background-color: #ffffff;
transition: border-color 0.25s;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
}
button {
cursor: pointer;
}
button:hover {
border-color: #396cd8;
}
button:active {
border-color: #396cd8;
background-color: #e8e8e8;
}
input,
button {
outline: none;
}
#greet-input {
margin-right: 5px;
}
@media (prefers-color-scheme: dark) {
:root {
color: #f6f6f6;
background-color: #2f2f2f;
}
a:hover {
color: #24c8db;
}
input,
button {
color: #ffffff;
background-color: #0f0f0f98;
}
button:active {
background-color: #0f0f0f69;
}
}

153
src/App.tsx Normal file
View File

@@ -0,0 +1,153 @@
import { useState, useEffect } from 'react';
import { LoginView } from './components/LoginView';
import { NotesList } from './components/NotesList';
import { NoteEditor } from './components/NoteEditor';
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 [fontSize] = useState(14);
useEffect(() => {
const savedServer = localStorage.getItem('serverURL');
const savedUsername = localStorage.getItem('username');
const savedPassword = localStorage.getItem('password');
if (savedServer && savedUsername && savedPassword) {
const apiInstance = new NextcloudAPI({
serverURL: savedServer,
username: savedUsername,
password: savedPassword,
});
setApi(apiInstance);
setIsLoggedIn(true);
}
}, []);
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);
setIsLoggedIn(true);
};
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}`, '', '');
setNotes([note, ...notes]);
setSelectedNoteId(note.id);
} catch (error) {
console.error('Create note failed:', error);
}
};
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;
if (!confirm(`Delete "${note.title}"?`)) 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 filteredNotes = notes.filter(note => {
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">
<NotesList
notes={filteredNotes}
selectedNoteId={selectedNoteId}
onSelectNote={setSelectedNoteId}
onCreateNote={handleCreateNote}
onDeleteNote={handleDeleteNote}
onSync={syncNotes}
searchText={searchText}
onSearchChange={setSearchText}
showFavoritesOnly={showFavoritesOnly}
onToggleFavorites={() => setShowFavoritesOnly(!showFavoritesOnly)}
/>
<NoteEditor
note={selectedNote}
onUpdateNote={handleUpdateNote}
fontSize={fontSize}
/>
</div>
);
}
export default App;

58
src/api/nextcloud.ts Normal file
View File

@@ -0,0 +1,58 @@
import { Note, APIConfig } from '../types';
export class NextcloudAPI {
private baseURL: string;
private authHeader: string;
constructor(config: APIConfig) {
const url = config.serverURL.replace(/\/$/, '');
this.baseURL = `${url}/index.php/apps/notes/api/v1`;
this.authHeader = 'Basic ' + btoa(`${config.username}:${config.password}`);
}
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${this.baseURL}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': this.authHeader,
...options.headers,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(`HTTP ${response.status}: ${text || response.statusText}`);
}
return response.json();
}
async fetchNotes(): Promise<Note[]> {
return this.request<Note[]>('/notes');
}
async createNote(title: string, content: string, category: string): Promise<Note> {
return this.request<Note>('/notes', {
method: 'POST',
body: JSON.stringify({ title, content, category, favorite: false }),
});
}
async updateNote(note: Note): Promise<Note> {
return this.request<Note>(`/notes/${note.id}`, {
method: 'PUT',
body: JSON.stringify({
title: note.title,
content: note.content,
category: note.category,
favorite: note.favorite,
}),
});
}
async deleteNote(id: number): Promise<void> {
await this.request<void>(`/notes/${id}`, { method: 'DELETE' });
}
}

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,117 @@
import { useState } from 'react';
import { NextcloudAPI } from '../api/nextcloud';
interface LoginViewProps {
onLogin: (serverURL: string, username: string, password: string) => void;
}
export function LoginView({ onLogin }: LoginViewProps) {
const [serverURL, setServerURL] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
console.log('Attempting to connect to:', serverURL);
const api = new NextcloudAPI({ serverURL, username, password });
const notes = await api.fetchNotes();
console.log('Successfully fetched notes:', notes.length);
onLogin(serverURL, username, password);
} catch (err) {
console.error('Login error:', err);
const errorMsg = err instanceof Error ? err.message : 'Failed to connect';
setError(`Connection failed: ${errorMsg}. Check console for details.`);
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-md">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-blue-500 rounded-full mb-4">
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
</div>
<h1 className="text-3xl font-bold text-gray-900">Nextcloud Notes</h1>
<p className="text-gray-600 mt-2">Connect to your Nextcloud instance</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Server URL
</label>
<input
type="url"
value={serverURL}
onChange={(e) => setServerURL(e.target.value)}
placeholder="https://cloud.example.com"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="your.username"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password / App Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg flex items-start">
<svg className="w-5 h-5 mr-2 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<span className="text-sm">{error}</span>
</div>
)}
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 text-white font-semibold py-3 px-4 rounded-lg transition-colors"
>
{isLoading ? 'Connecting...' : 'Connect'}
</button>
</form>
<p className="text-xs text-gray-500 text-center mt-6">
<a href="https://docs.nextcloud.com/server/latest/user_manual/en/session_management.html" target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">
Using an App Password is recommended
</a>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,343 @@
import { useState, useEffect, useRef } from 'react';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline';
import Strike from '@tiptap/extension-strike';
import TurndownService from 'turndown';
import { marked } from 'marked';
import { Note } from '../types';
interface NoteEditorProps {
note: Note | null;
onUpdateNote: (note: Note) => void;
fontSize: number;
}
const turndownService = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
});
export function NoteEditor({ note, onUpdateNote, fontSize }: NoteEditorProps) {
const [localTitle, setLocalTitle] = 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 previousNoteIdRef = useRef<number | null>(null);
const editor = useEditor({
extensions: [
StarterKit,
Underline,
Strike,
],
content: '',
editorProps: {
attributes: {
class: 'prose prose-slate max-w-none focus:outline-none p-6',
style: `font-size: ${fontSize}px`,
},
},
onUpdate: ({ editor }) => {
setHasUnsavedChanges(true);
if (!titleManuallyEdited) {
const text = editor.getText();
const firstLine = text.split('\n')[0].trim();
if (firstLine) {
setLocalTitle(firstLine.substring(0, 50));
}
}
},
});
useEffect(() => {
if (previousNoteIdRef.current !== null && previousNoteIdRef.current !== note?.id) {
handleSave();
}
if (note && editor) {
setLocalTitle(note.title);
setLocalCategory(note.category);
setLocalFavorite(note.favorite);
setHasUnsavedChanges(false);
// Only reset titleManuallyEdited when switching to a different note
// Check if the current title matches the first line of content
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;
// Convert markdown to HTML using marked library
const html = marked.parse(note.content || '', { async: false }) as string;
editor.commands.setContent(html);
}
}, [note?.id, editor]);
const handleSave = () => {
if (!note || !hasUnsavedChanges || !editor) return;
// Convert HTML to markdown
const html = editor.getHTML();
const markdown = turndownService.turndown(html);
console.log('Saving note content length:', markdown.length);
console.log('Last 50 chars:', markdown.slice(-50));
setIsSaving(true);
setHasUnsavedChanges(false);
onUpdateNote({
...note,
title: localTitle,
content: markdown,
category: localCategory,
favorite: localFavorite,
});
setTimeout(() => setIsSaving(false), 500);
};
const handleTitleChange = (value: string) => {
setLocalTitle(value);
setTitleManuallyEdited(true);
setHasUnsavedChanges(true);
};
const handleCategoryChange = (value: string) => {
setLocalCategory(value);
setHasUnsavedChanges(true);
};
const handleFavoriteToggle = () => {
setLocalFavorite(!localFavorite);
if (note) {
onUpdateNote({
...note,
title: localTitle,
content: editor ? turndownService.turndown(editor.getHTML()) : note.content,
category: localCategory,
favorite: !localFavorite,
});
}
};
if (!note) {
return (
<div className="flex-1 flex items-center justify-center bg-white text-gray-400">
<div className="text-center">
<svg className="w-20 h-20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-lg font-medium">No Note Selected</p>
<p className="text-sm mt-2">Select a note from the sidebar or create a new one</p>
</div>
</div>
);
}
if (!editor) {
return null;
}
return (
<div className="flex-1 flex flex-col bg-white">
<div className="border-b border-gray-200 p-4 flex items-center justify-between">
<input
type="text"
value={localTitle}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder="Note Title"
className="flex-1 text-2xl font-bold border-none outline-none focus:ring-0"
/>
<div className="flex items-center space-x-2 ml-4">
{hasUnsavedChanges && (
<span className="text-sm text-orange-500">Unsaved changes</span>
)}
{isSaving && (
<span className="text-sm text-gray-500">Saving...</span>
)}
<button
onClick={handleSave}
disabled={!hasUnsavedChanges || isSaving}
className={`p-2 rounded-lg transition-colors ${
hasUnsavedChanges && !isSaving
? 'bg-blue-500 text-white hover:bg-blue-600'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
title="Save Note"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</button>
<button
onClick={handleFavoriteToggle}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
title={localFavorite ? "Remove from Favorites" : "Add to Favorites"}
>
<svg
className={`w-6 h-6 ${localFavorite ? 'text-yellow-500 fill-current' : '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>
<input
type="text"
value={localCategory}
onChange={(e) => handleCategoryChange(e.target.value)}
placeholder="Category"
className="w-32 px-3 py-1 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{/* Formatting Toolbar */}
<div className="border-b border-gray-200 px-4 py-2 flex items-center space-x-1 bg-gray-50">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={`p-2 rounded transition-colors ${
editor.isActive('bold') ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-200'
}`}
title="Bold"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M13.5,15.5H10V12.5H13.5A1.5,1.5 0 0,1 15,14A1.5,1.5 0 0,1 13.5,15.5M10,6.5H13A1.5,1.5 0 0,1 14.5,8A1.5,1.5 0 0,1 13,9.5H10M15.6,10.79C16.57,10.11 17.25,9 17.25,8C17.25,5.74 15.5,4 13.25,4H7V18H14.04C16.14,18 17.75,16.3 17.75,14.21C17.75,12.69 16.89,11.39 15.6,10.79Z" />
</svg>
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={`p-2 rounded transition-colors ${
editor.isActive('italic') ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-200'
}`}
title="Italic"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M10,4V7H12.21L8.79,15H6V18H14V15H11.79L15.21,7H18V4H10Z" />
</svg>
</button>
<button
onClick={() => editor.chain().focus().toggleStrike().run()}
className={`p-2 rounded transition-colors ${
editor.isActive('strike') ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-200'
}`}
title="Strikethrough"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M3,14H21V12H3M5,4V7H10V10H14V7H19V4M10,19H14V16H10V19Z" />
</svg>
</button>
<button
onClick={() => editor.chain().focus().toggleUnderline().run()}
className={`p-2 rounded transition-colors ${
editor.isActive('underline') ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-200'
}`}
title="Underline"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M5,21H19V19H5V21M12,17A6,6 0 0,0 18,11V3H15.5V11A3.5,3.5 0 0,1 12,14.5A3.5,3.5 0 0,1 8.5,11V3H6V11A6,6 0 0,0 12,17Z" />
</svg>
</button>
<div className="w-px h-6 bg-gray-300 mx-1"></div>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={`px-2 py-1 rounded transition-colors font-bold text-sm ${
editor.isActive('heading', { level: 1 }) ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-200'
}`}
title="Heading 1"
>
H1
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={`px-2 py-1 rounded transition-colors font-bold text-sm ${
editor.isActive('heading', { level: 2 }) ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-200'
}`}
title="Heading 2"
>
H2
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
className={`px-2 py-1 rounded transition-colors font-bold text-sm ${
editor.isActive('heading', { level: 3 }) ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-200'
}`}
title="Heading 3"
>
H3
</button>
<div className="w-px h-6 bg-gray-300 mx-1"></div>
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={`p-2 rounded transition-colors ${
editor.isActive('bulletList') ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-200'
}`}
title="Bullet List"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M7,5H21V7H7V5M7,13V11H21V13H7M4,4.5A1.5,1.5 0 0,1 5.5,6A1.5,1.5 0 0,1 4,7.5A1.5,1.5 0 0,1 2.5,6A1.5,1.5 0 0,1 4,4.5M4,10.5A1.5,1.5 0 0,1 5.5,12A1.5,1.5 0 0,1 4,13.5A1.5,1.5 0 0,1 2.5,12A1.5,1.5 0 0,1 4,10.5M7,19V17H21V19H7M4,16.5A1.5,1.5 0 0,1 5.5,18A1.5,1.5 0 0,1 4,19.5A1.5,1.5 0 0,1 2.5,18A1.5,1.5 0 0,1 4,16.5Z" />
</svg>
</button>
<button
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={`p-2 rounded transition-colors ${
editor.isActive('orderedList') ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-200'
}`}
title="Numbered List"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M7,13V11H21V13H7M7,19V17H21V19H7M7,7V5H21V7H7M3,8V5H2V4H4V8H3M2,17V16H5V20H2V19H4V18.5H3V17.5H4V17H2M4.25,10A0.75,0.75 0 0,1 5,10.75C5,10.95 4.92,11.14 4.79,11.27L3.12,13H5V14H2V13.08L4,11H2V10H4.25Z" />
</svg>
</button>
<div className="w-px h-6 bg-gray-300 mx-1"></div>
<button
onClick={() => editor.chain().focus().toggleCode().run()}
className={`p-2 rounded transition-colors ${
editor.isActive('code') ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-200'
}`}
title="Inline Code"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8.5,18L3.5,13L8.5,8L9.91,9.41L6.33,13L9.91,16.59L8.5,18M15.5,18L14.09,16.59L17.67,13L14.09,9.41L15.5,8L20.5,13L15.5,18Z" />
</svg>
</button>
<button
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
className={`p-2 rounded transition-colors ${
editor.isActive('codeBlock') ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-200'
}`}
title="Code Block"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M15,4V6H18V18H15V20H20V4M4,4V20H9V18H6V6H9V4H4Z" />
</svg>
</button>
</div>
<div className="flex-1 overflow-auto">
<EditorContent editor={editor} />
</div>
</div>
);
}

View File

@@ -0,0 +1,173 @@
import React from 'react';
import { Note } from '../types';
interface NotesListProps {
notes: Note[];
selectedNoteId: number | null;
onSelectNote: (id: number) => void;
onCreateNote: () => void;
onDeleteNote: (note: Note) => void;
onSync: () => void;
searchText: string;
onSearchChange: (text: string) => void;
showFavoritesOnly: boolean;
onToggleFavorites: () => void;
}
export function NotesList({
notes,
selectedNoteId,
onSelectNote,
onCreateNote,
onDeleteNote,
onSync,
searchText,
onSearchChange,
showFavoritesOnly,
onToggleFavorites,
}: NotesListProps) {
const [isSyncing, setIsSyncing] = React.useState(false);
const handleSync = async () => {
setIsSyncing(true);
await onSync();
setTimeout(() => setIsSyncing(false), 500);
};
const formatDate = (timestamp: number) => {
const date = new Date(timestamp * 1000);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return date.toLocaleDateString();
};
const getPreview = (content: string) => {
const lines = content.split('\n').filter(l => l.trim());
return lines.slice(1, 3).join(' ').substring(0, 100);
};
return (
<div className="w-80 bg-gray-50 border-r border-gray-200 flex flex-col">
<div className="p-4 border-b border-gray-200">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-gray-900">Notes</h2>
<div className="flex items-center space-x-1">
<button
onClick={handleSync}
disabled={isSyncing}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
title="Sync with Server"
>
<svg
className={`w-5 h-5 ${isSyncing ? 'animate-spin' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button
onClick={onCreateNote}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
title="New Note"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
</div>
<input
type="text"
value={searchText}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search notes..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<div className="flex items-center justify-between mt-3">
<button
onClick={onToggleFavorites}
className="text-xs text-gray-600 hover:text-gray-900 flex items-center"
>
<svg className="w-4 h-4 mr-1" fill={showFavoritesOnly ? "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>
{showFavoritesOnly ? 'All Notes' : 'Favorites'}
</button>
<span className="text-xs text-gray-500">{notes.length} notes</span>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{notes.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-400 p-8">
<svg className="w-16 h-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-sm">No notes found</p>
</div>
) : (
notes.map((note) => (
<div
key={note.id}
onClick={() => onSelectNote(note.id)}
className={`p-4 border-b border-gray-200 cursor-pointer hover:bg-gray-100 transition-colors ${
selectedNoteId === note.id ? 'bg-blue-50 hover:bg-blue-50' : ''
}`}
>
<div className="flex items-start justify-between mb-1">
<div className="flex items-center flex-1 min-w-0">
{note.favorite && (
<svg className="w-4 h-4 text-yellow-500 mr-1 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
)}
<h3 className="font-medium text-gray-900 truncate">
{note.title || 'Untitled'}
</h3>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onDeleteNote(note);
}}
className="ml-2 p-1 hover:bg-red-100 rounded text-red-600 opacity-0 group-hover:opacity-100 transition-opacity"
title="Delete"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<div className="flex items-center text-xs text-gray-500 mb-2">
{note.category && (
<span className="bg-gray-200 px-2 py-0.5 rounded-full mr-2">
{note.category}
</span>
)}
<span>{formatDate(note.modified)}</span>
</div>
{getPreview(note.content) && (
<p className="text-sm text-gray-600 line-clamp-2">
{getPreview(note.content)}
</p>
)}
</div>
))
)}
</div>
</div>
);
}

121
src/index.css Normal file
View File

@@ -0,0 +1,121 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
/* TipTap Editor Styles */
.ProseMirror {
min-height: 100%;
}
.ProseMirror:focus {
outline: none;
}
.ProseMirror h1 {
font-size: 2em;
font-weight: bold;
margin-top: 0.5em;
margin-bottom: 0.5em;
}
.ProseMirror h2 {
font-size: 1.5em;
font-weight: bold;
margin-top: 0.5em;
margin-bottom: 0.5em;
}
.ProseMirror h3 {
font-size: 1.25em;
font-weight: bold;
margin-top: 0.5em;
margin-bottom: 0.5em;
}
.ProseMirror p {
margin-bottom: 0.5em;
}
.ProseMirror strong {
font-weight: bold;
}
.ProseMirror em {
font-style: italic;
}
.ProseMirror s {
text-decoration: line-through;
}
.ProseMirror u {
text-decoration: underline;
}
.ProseMirror code {
background-color: #f3f4f6;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
.ProseMirror pre {
background-color: #1f2937;
color: #f3f4f6;
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin: 1em 0;
}
.ProseMirror pre code {
background-color: transparent;
padding: 0;
color: inherit;
}
.ProseMirror ul,
.ProseMirror ol {
padding-left: 1.5rem;
margin: 0.5em 0;
}
.ProseMirror ul {
list-style-type: disc;
}
.ProseMirror ol {
list-style-type: decimal;
}
.ProseMirror li {
margin: 0.25em 0;
}
.ProseMirror blockquote {
border-left: 3px solid #d1d5db;
padding-left: 1rem;
margin: 1em 0;
color: #6b7280;
}
.ProseMirror hr {
border: none;
border-top: 2px solid #e5e7eb;
margin: 2em 0;
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

23
src/types.ts Normal file
View File

@@ -0,0 +1,23 @@
export interface Note {
id: number;
etag: string;
readonly: boolean;
content: string;
title: string;
category: string;
favorite: boolean;
modified: number;
}
export interface APIConfig {
serverURL: string;
username: string;
password: string;
}
export interface AppSettings {
serverURL: string;
username: string;
syncInterval: number;
fontSize: number;
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />