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>
This commit is contained in:
Nirodan
2026-05-06 10:19:40 +02:00
parent 7827cda224
commit 20cdbd69eb
3 changed files with 79 additions and 18 deletions
+21 -16
View File
@@ -20,6 +20,9 @@ function NotesTool() {
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const debounceRef = useRef(null); 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 isNew = selected === null;
const loadNotes = useCallback(async () => { const loadNotes = useCallback(async () => {
@@ -34,6 +37,7 @@ function NotesTool() {
useEffect(() => { loadNotes(); }, [loadNotes]); useEffect(() => { loadNotes(); }, [loadNotes]);
const selectNote = (note) => { const selectNote = (note) => {
isDirtyRef.current = false;
setSelected(note.id); setSelected(note.id);
setTitle(note.title); setTitle(note.title);
setContent(note.content || ''); setContent(note.content || '');
@@ -44,6 +48,7 @@ function NotesTool() {
}; };
const newNote = () => { const newNote = () => {
isDirtyRef.current = false;
setSelected(null); setSelected(null);
setTitle('Neue Notiz'); setTitle('Neue Notiz');
setContent(''); setContent('');
@@ -58,10 +63,12 @@ function NotesTool() {
try { try {
if (isNew) { if (isNew) {
const res = await axios.post('/api/notes', { title, content, language }); const res = await axios.post('/api/notes', { title, content, language });
isDirtyRef.current = false;
setSelected(res.data.id); setSelected(res.data.id);
await loadNotes(); await loadNotes();
} else { } else {
await axios.put(`/api/notes/${selected}`, { title, content, language }); await axios.put(`/api/notes/${selected}`, { title, content, language });
isDirtyRef.current = false;
await loadNotes(); await loadNotes();
} }
setSaved(true); setSaved(true);
@@ -82,20 +89,23 @@ function NotesTool() {
} }
}; };
// Debounced auto-save for existing notes // 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(() => { useEffect(() => {
if (isNew) return; if (isNew || !isDirtyRef.current) return;
clearTimeout(debounceRef.current); clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => { debounceRef.current = setTimeout(async () => {
if (!isDirtyRef.current) return;
try { try {
await axios.put(`/api/notes/${selected}`, { title, content, language }); await axios.put(`/api/notes/${selected}`, { title, content, language });
isDirtyRef.current = false;
await loadNotes(); await loadNotes();
setSaved(true); setSaved(true);
setTimeout(() => setSaved(false), 1500); setTimeout(() => setSaved(false), 1500);
} catch { /* silent */ } } catch { /* silent — manual save still available */ }
}, 1000); }, 1000);
return () => clearTimeout(debounceRef.current); return () => clearTimeout(debounceRef.current);
}, [title, content, language]); // eslint-disable-line react-hooks/exhaustive-deps }, [title, content, language, selected, isNew, loadNotes]);
const formatDate = (iso) => { const formatDate = (iso) => {
if (!iso) return ''; if (!iso) return '';
@@ -107,9 +117,9 @@ function NotesTool() {
return ( return (
<div className="main-content" style={{ maxWidth: '1100px' }}> <div className="main-content" style={{ maxWidth: '1100px' }}>
<h2>Notizen & Snippets</h2> <h2>Notizen &amp; Snippets</h2>
<div style={{ display: 'grid', gridTemplateColumns: '260px 1fr', gap: '16px', alignItems: 'start' }}> <div className="notes-layout">
{/* Sidebar */} {/* Sidebar */}
<div style={{ <div style={{
background: 'var(--surface-2)', background: 'var(--surface-2)',
@@ -165,14 +175,13 @@ function NotesTool() {
<input <input
type="text" type="text"
value={title} value={title}
onChange={(e) => { setTitle(e.target.value); setSaved(false); }} onChange={(e) => { isDirtyRef.current = true; setTitle(e.target.value); setSaved(false); }}
placeholder="Titel" placeholder="Titel"
style={{ flex: 1, margin: 0, minWidth: '150px' }} style={{ flex: 1, margin: 0, minWidth: '150px' }}
/> />
<select <select
value={language} value={language}
onChange={(e) => { setLanguage(e.target.value); setSaved(false); }} onChange={(e) => { isDirtyRef.current = true; setLanguage(e.target.value); setSaved(false); }}
style={{ margin: 0, width: 'auto' }}
> >
{LANGUAGES.map(({ value, label }) => ( {LANGUAGES.map(({ value, label }) => (
<option key={value} value={value}>{label}</option> <option key={value} value={value}>{label}</option>
@@ -183,7 +192,7 @@ function NotesTool() {
<textarea <textarea
rows={16} rows={16}
value={content} value={content}
onChange={(e) => { setContent(e.target.value); setSaved(false); }} onChange={(e) => { isDirtyRef.current = true; setContent(e.target.value); setSaved(false); }}
placeholder="Inhalt..." placeholder="Inhalt..."
style={{ fontFamily: 'monospace', resize: 'vertical', fontSize: '0.88rem', margin: 0 }} style={{ fontFamily: 'monospace', resize: 'vertical', fontSize: '0.88rem', margin: 0 }}
/> />
@@ -196,13 +205,9 @@ function NotesTool() {
</button> </button>
{!isNew && ( {!isNew && (
<button <button
className="ghost" className={confirmDelete ? 'ghost danger' : 'ghost'}
onClick={deleteNote} onClick={deleteNote}
style={{ style={{ margin: 0 }}
margin: 0,
color: confirmDelete ? '#ef4444' : 'var(--text)',
borderColor: confirmDelete ? '#ef4444' : 'var(--border)',
}}
> >
{confirmDelete ? 'Wirklich löschen?' : 'Löschen'} {confirmDelete ? 'Wirklich löschen?' : 'Löschen'}
</button> </button>
+33 -2
View File
@@ -26,7 +26,7 @@ p {
color: var(--muted); color: var(--muted);
} }
input, textarea { input, textarea, select {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
min-width: 0; min-width: 0;
@@ -36,10 +36,22 @@ input, textarea {
border-radius: 12px; border-radius: 12px;
padding: 12px 14px; padding: 12px 14px;
font-size: 1rem; font-size: 1rem;
font-family: inherit;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
} }
input:focus, textarea:focus { select {
width: auto;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%239fb3d8' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
input:focus, textarea:focus, select:focus {
outline: none; outline: none;
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 4px var(--focus-ring); box-shadow: 0 0 0 4px var(--focus-ring);
@@ -49,6 +61,25 @@ input::placeholder, textarea::placeholder {
color: var(--muted); color: var(--muted);
} }
.error {
color: #ef4444;
font-size: 0.9rem;
margin: 4px 0 0;
}
.notes-layout {
display: grid;
grid-template-columns: 260px 1fr;
gap: 16px;
align-items: start;
}
@media (max-width: 680px) {
.notes-layout {
grid-template-columns: 1fr;
}
}
.card-grid { .card-grid {
display: grid; display: grid;
gap: 14px; gap: 14px;
+25
View File
@@ -29,3 +29,28 @@ button:disabled {
cursor: not-allowed; cursor: not-allowed;
box-shadow: none; box-shadow: none;
} }
button.ghost {
background: transparent;
color: var(--text);
border-color: var(--border);
box-shadow: none;
}
button.ghost:hover {
border-color: var(--accent);
color: var(--accent);
box-shadow: none;
filter: none;
}
button.ghost.danger {
color: #ef4444;
border-color: #ef4444;
}
button.ghost.danger:hover {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
border-color: #ef4444;
}