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:
116
src/App.css
Normal file
116
src/App.css
Normal 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
153
src/App.tsx
Normal 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
58
src/api/nextcloud.ts
Normal 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
1
src/assets/react.svg
Normal 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 |
117
src/components/LoginView.tsx
Normal file
117
src/components/LoginView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
343
src/components/NoteEditor.tsx
Normal file
343
src/components/NoteEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
173
src/components/NotesList.tsx
Normal file
173
src/components/NotesList.tsx
Normal 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
121
src/index.css
Normal 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
10
src/main.tsx
Normal 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
23
src/types.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user