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:
@@ -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 & 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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user