Files
Tools/frontend/src/components/NotesTool.jsx
T
Nirodan 20cdbd69eb Fix Notes feature: correct auto-save logic and missing CSS
Functional fixes:
- Add isDirtyRef to distinguish user edits from note selection; auto-save now
  only fires after the user actually modifies content, not on every click
- Add selected and isNew to auto-save effect deps so the closure is never stale
  when switching between notes (previously could save to the wrong note)
- Double-check isDirtyRef inside the debounce callback to handle rapid note
  switches within the 1-second window
- Reset isDirtyRef to false after every successful save (manual and auto)

CSS fixes:
- Add button.ghost and button.ghost.danger styles to buttons.css; delete button
  was invisible/identical to the save button because .ghost was never defined
- Add select element styling to base.css to match input/textarea theming;
  language picker was unstyled browser-default in both dark and light mode
- Add .error class (red text) to base.css; error messages had no colour
- Add .notes-layout grid class with responsive media query (stacks below 680px);
  remove inline gridTemplateColumns that overflowed on small screens

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 10:19:40 +02:00

231 lines
7.9 KiB
React

import { useState, useEffect, useRef, useCallback } from 'react';
import axios from '../services/api';
const LANGUAGES = [
{ value: 'text', label: 'Text' },
{ value: 'python', label: 'Python' },
{ value: 'javascript', label: 'JavaScript' },
{ value: 'sql', label: 'SQL' },
{ value: 'bash', label: 'Bash' },
{ value: 'json', label: 'JSON' },
];
function NotesTool() {
const [notes, setNotes] = useState([]);
const [selected, setSelected] = useState(null);
const [title, setTitle] = useState('Neue Notiz');
const [content, setContent] = useState('');
const [language, setLanguage] = useState('text');
const [saved, setSaved] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [error, setError] = useState('');
const debounceRef = useRef(null);
// Tracks whether the user has actually edited the current note.
// Prevents auto-save from firing just because a note was selected.
const isDirtyRef = useRef(false);
const isNew = selected === null;
const loadNotes = useCallback(async () => {
try {
const res = await axios.get('/api/notes');
setNotes(res.data);
} catch {
setError('Fehler beim Laden der Notizen');
}
}, []);
useEffect(() => { loadNotes(); }, [loadNotes]);
const selectNote = (note) => {
isDirtyRef.current = false;
setSelected(note.id);
setTitle(note.title);
setContent(note.content || '');
setLanguage(note.language || 'text');
setSaved(false);
setConfirmDelete(false);
setError('');
};
const newNote = () => {
isDirtyRef.current = false;
setSelected(null);
setTitle('Neue Notiz');
setContent('');
setLanguage('text');
setSaved(false);
setConfirmDelete(false);
setError('');
};
const save = async () => {
setError('');
try {
if (isNew) {
const res = await axios.post('/api/notes', { title, content, language });
isDirtyRef.current = false;
setSelected(res.data.id);
await loadNotes();
} else {
await axios.put(`/api/notes/${selected}`, { title, content, language });
isDirtyRef.current = false;
await loadNotes();
}
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (err) {
setError(err.response?.data?.message || 'Fehler beim Speichern');
}
};
const deleteNote = async () => {
if (!confirmDelete) { setConfirmDelete(true); return; }
try {
await axios.delete(`/api/notes/${selected}`);
setNotes(prev => prev.filter(n => n.id !== selected));
newNote();
} catch (err) {
setError(err.response?.data?.message || 'Fehler beim Löschen');
}
};
// Auto-save for existing notes — only fires when the user actually edited content.
// selected is in deps so the closure is never stale when switching between notes.
useEffect(() => {
if (isNew || !isDirtyRef.current) return;
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
if (!isDirtyRef.current) return;
try {
await axios.put(`/api/notes/${selected}`, { title, content, language });
isDirtyRef.current = false;
await loadNotes();
setSaved(true);
setTimeout(() => setSaved(false), 1500);
} catch { /* silent — manual save still available */ }
}, 1000);
return () => clearTimeout(debounceRef.current);
}, [title, content, language, selected, isNew, loadNotes]);
const formatDate = (iso) => {
if (!iso) return '';
return new Date(iso).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: '2-digit',
hour: '2-digit', minute: '2-digit',
});
};
return (
<div className="main-content" style={{ maxWidth: '1100px' }}>
<h2>Notizen &amp; Snippets</h2>
<div className="notes-layout">
{/* Sidebar */}
<div style={{
background: 'var(--surface-2)',
border: '1px solid var(--border)',
borderRadius: '12px',
overflow: 'hidden',
}}>
<div style={{ padding: '10px', borderBottom: '1px solid var(--border)' }}>
<button onClick={newNote} style={{ width: '100%', margin: 0, padding: '8px', fontSize: '0.85rem' }}>
+ Neue Notiz
</button>
</div>
<div style={{ maxHeight: '500px', overflowY: 'auto' }}>
{notes.length === 0 ? (
<p style={{ color: 'var(--muted)', padding: '16px', fontSize: '0.85rem', textAlign: 'center' }}>
Keine Notizen vorhanden
</p>
) : notes.map((note) => (
<div
key={note.id}
onClick={() => selectNote(note)}
style={{
padding: '10px 14px',
cursor: 'pointer',
borderBottom: '1px solid var(--border)',
background: selected === note.id ? 'rgba(34,211,238,0.08)' : 'transparent',
borderLeft: selected === note.id ? '3px solid var(--accent)' : '3px solid transparent',
}}
>
<div style={{
fontWeight: 600, color: 'var(--text)', fontSize: '0.9rem',
marginBottom: '2px', overflow: 'hidden',
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{note.title}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--muted)' }}>
{formatDate(note.updated_at)}
</div>
</div>
))}
</div>
</div>
{/* Editor */}
<div style={{
background: 'var(--surface-2)',
border: '1px solid var(--border)',
borderRadius: '12px',
padding: '16px',
}}>
<div style={{ display: 'flex', gap: '8px', marginBottom: '10px', flexWrap: 'wrap', alignItems: 'center' }}>
<input
type="text"
value={title}
onChange={(e) => { isDirtyRef.current = true; setTitle(e.target.value); setSaved(false); }}
placeholder="Titel"
style={{ flex: 1, margin: 0, minWidth: '150px' }}
/>
<select
value={language}
onChange={(e) => { isDirtyRef.current = true; setLanguage(e.target.value); setSaved(false); }}
>
{LANGUAGES.map(({ value, label }) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
<textarea
rows={16}
value={content}
onChange={(e) => { isDirtyRef.current = true; setContent(e.target.value); setSaved(false); }}
placeholder="Inhalt..."
style={{ fontFamily: 'monospace', resize: 'vertical', fontSize: '0.88rem', margin: 0 }}
/>
{error && <p className="error" style={{ marginTop: '8px', marginBottom: 0 }}>{error}</p>}
<div style={{ display: 'flex', gap: '8px', marginTop: '10px', alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={save} style={{ margin: 0 }}>
{isNew ? 'Erstellen' : 'Speichern'}
</button>
{!isNew && (
<button
className={confirmDelete ? 'ghost danger' : 'ghost'}
onClick={deleteNote}
style={{ margin: 0 }}
>
{confirmDelete ? 'Wirklich löschen?' : 'Löschen'}
</button>
)}
{confirmDelete && (
<button className="ghost" onClick={() => setConfirmDelete(false)} style={{ margin: 0 }}>
Abbrechen
</button>
)}
{saved && (
<span style={{ color: '#22c55e', fontSize: '0.85rem', fontWeight: 600 }}> Gespeichert</span>
)}
</div>
</div>
</div>
</div>
);
}
export default NotesTool;