From 7aaa3d5b1e0337c010f11151fcb5a6befbca7610 Mon Sep 17 00:00:00 2001 From: Krapfal Date: Fri, 17 Apr 2026 13:07:00 +0200 Subject: [PATCH] Enhancement: Tag autocomplete, keyboard shortcut overlay, and reading progress reset - InlineTagEditor: add suggestions prop with dropdown autocomplete (arrow key navigation) - ReaderView: add keyboard shortcut overlay (? key) with LuKeyboard toolbar button, fully i18n'd - BookEditor: add reset reading progress button (visible only when progress exists), allTags prop for tag autocomplete - en-US.json / de-DE.json: add reader shortcut keys and bookEditor reset progress keys Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/maps/InlineTagEditor.jsx | 60 +++++++++++++++---- frontend/src/components/system/BookEditor.jsx | 17 +++++- frontend/src/locales/de-DE.json | 15 ++++- frontend/src/locales/en-US.json | 15 ++++- frontend/src/views/ReaderView.jsx | 39 +++++++++++- frontend/src/views/SystemDetailView.jsx | 2 + 6 files changed, 128 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/maps/InlineTagEditor.jsx b/frontend/src/components/maps/InlineTagEditor.jsx index 75aab38..8eb8650 100644 --- a/frontend/src/components/maps/InlineTagEditor.jsx +++ b/frontend/src/components/maps/InlineTagEditor.jsx @@ -2,17 +2,23 @@ import { useState, useRef, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { LuX } from 'react-icons/lu' -export default function InlineTagEditor({ tags, onSave, onCancel }) { +export default function InlineTagEditor({ tags, onSave, onCancel, suggestions = [] }) { const { t } = useTranslation() const [draft, setDraft] = useState([...tags]) const [input, setInput] = useState('') + const [activeIdx, setActiveIdx] = useState(-1) const inputRef = useRef(null) useEffect(() => { inputRef.current?.focus() }, []) - const commit = () => { - const tag = input.trim().toLowerCase().replace(/,+$/, '') + const filtered = input.trim().length > 0 + ? suggestions.filter(s => s.toLowerCase().includes(input.trim().toLowerCase()) && !draft.includes(s)) + : [] + + const commit = (value) => { + const tag = (value ?? input).trim().toLowerCase().replace(/,+$/, '') setInput('') + setActiveIdx(-1) if (tag && !draft.includes(tag)) { const next = [...draft, tag] setDraft(next) @@ -27,8 +33,13 @@ export default function InlineTagEditor({ tags, onSave, onCancel }) { } const handleKey = (e) => { + if (filtered.length > 0) { + if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIdx(i => Math.min(i + 1, filtered.length - 1)); return } + if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIdx(i => Math.max(i - 1, -1)); return } + if (e.key === 'Enter' && activeIdx >= 0) { e.preventDefault(); commit(filtered[activeIdx]); return } + } if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); commit() } - else if (e.key === 'Escape') onCancel() + else if (e.key === 'Escape') { if (filtered.length > 0) { setInput(''); setActiveIdx(-1) } else onCancel() } else if (e.key === 'Backspace' && !input && draft.length > 0) { const next = draft.slice(0, -1) setDraft(next) @@ -37,7 +48,7 @@ export default function InlineTagEditor({ tags, onSave, onCancel }) { } return ( -
+
{draft.map(tag => ( {tag} @@ -50,14 +61,37 @@ export default function InlineTagEditor({ tags, onSave, onCancel }) { ))} - setInput(e.target.value)} - onKeyDown={handleKey} - placeholder={t('inlineTagEditor.placeholder')} - style={{ fontSize: 13, padding: '2px 8px', borderRadius: 10, width: 100, background: 'var(--bg-input)', border: '1px solid var(--border)', color: 'var(--text)' }} - /> +
+ { setInput(e.target.value); setActiveIdx(-1) }} + onKeyDown={handleKey} + placeholder={t('inlineTagEditor.placeholder')} + style={{ fontSize: 13, padding: '2px 8px', borderRadius: 10, width: 120, background: 'var(--bg-input)', border: '1px solid var(--border)', color: 'var(--text)' }} + /> + {filtered.length > 0 && ( +
+ {filtered.map((s, i) => ( +
{ e.preventDefault(); commit(s) }} + style={{ + padding: '6px 12px', fontSize: 13, cursor: 'pointer', + background: i === activeIdx ? 'var(--bg-card-hover)' : 'transparent', + color: 'var(--text)', + }} + > + {s} +
+ ))} +
+ )} +
) diff --git a/frontend/src/components/system/BookEditor.jsx b/frontend/src/components/system/BookEditor.jsx index 01945ff..e601c6c 100644 --- a/frontend/src/components/system/BookEditor.jsx +++ b/frontend/src/components/system/BookEditor.jsx @@ -3,8 +3,9 @@ import { useTranslation } from 'react-i18next' import { LuX } from 'react-icons/lu' import api from '../../api' import InlineTagEditor from '../maps/InlineTagEditor' +import { saveBookPrefs, getBookPrefs } from '../../hooks/useBookPrefs' -export default function BookEditor({ book, onSave, onClose }) { +export default function BookEditor({ book, onSave, onClose, allTags = [] }) { const { t } = useTranslation() const [form, setForm] = useState({ title: book.title || '', @@ -18,6 +19,8 @@ export default function BookEditor({ book, onSave, onClose }) { const [tags, setTags] = useState(book.tags || []) const [editingTags, setEditingTags] = useState(false) const [saving, setSaving] = useState(false) + const [progressReset, setProgressReset] = useState(false) + const hasProgress = !!getBookPrefs(book.id).page const field = (label, key, opts = {}) => (
@@ -83,6 +86,7 @@ export default function BookEditor({ book, onSave, onClose }) { tags={tags} onSave={setTags} onCancel={() => setEditingTags(false)} + suggestions={allTags.filter(t => !tags.includes(t))} /> ) : (
@@ -98,13 +102,22 @@ export default function BookEditor({ book, onSave, onClose }) { )}
-
+
+ {(hasProgress || progressReset) && ( + + )}
) diff --git a/frontend/src/locales/de-DE.json b/frontend/src/locales/de-DE.json index 092ed13..482c05d 100644 --- a/frontend/src/locales/de-DE.json +++ b/frontend/src/locales/de-DE.json @@ -139,7 +139,16 @@ "addToFavorites": "Zu Favoriten hinzufügen", "removeFromFavorites": "Aus Favoriten entfernen", "bookmarkPage": "Seite als Lesezeichen speichern", - "downloadFile": "Herunterladen" + "downloadFile": "Herunterladen", + "keyboardShortcuts": "Tastaturkürzel", + "shortcutPrevNext": "Vorherige / nächste Seite", + "shortcutPrevNextVertical": "Vorherige / nächste Seite (vertikal)", + "shortcutFavorite": "Favorit umschalten", + "shortcutToc": "Inhaltsverzeichnis", + "shortcutBookmarks": "Lesezeichen", + "shortcutSearch": "Suche", + "shortcutHelp": "Diese Hilfe", + "shortcutClose": "Panels schließen" }, "search": { "title": "Bibliothek durchsuchen", @@ -771,7 +780,9 @@ "save": "Speichern", "saving": "Speichern…", "cancel": "Abbrechen", - "failed": "Speichern fehlgeschlagen." + "failed": "Speichern fehlgeschlagen.", + "resetProgress": "Lesefortschritt zurücksetzen", + "progressReset": "Fortschritt zurückgesetzt" }, "bookRow": { "missingFile": "Fehlend", diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index 17ec206..bb764b0 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -139,7 +139,16 @@ "addToFavorites": "Add to favorites", "removeFromFavorites": "Remove from favorites", "bookmarkPage": "Bookmark this page", - "downloadFile": "Download" + "downloadFile": "Download", + "keyboardShortcuts": "Keyboard Shortcuts", + "shortcutPrevNext": "Previous / next page", + "shortcutPrevNextVertical": "Previous / next page (vertical)", + "shortcutFavorite": "Toggle favorite", + "shortcutToc": "Table of contents", + "shortcutBookmarks": "Bookmarks", + "shortcutSearch": "Search", + "shortcutHelp": "This help", + "shortcutClose": "Close panels" }, "search": { "title": "Search Your Library", @@ -771,7 +780,9 @@ "save": "Save", "saving": "Saving…", "cancel": "Cancel", - "failed": "Failed to save." + "failed": "Failed to save.", + "resetProgress": "Reset reading progress", + "progressReset": "Progress reset" }, "bookRow": { "missingFile": "Missing", diff --git a/frontend/src/views/ReaderView.jsx b/frontend/src/views/ReaderView.jsx index f9faf65..cee7b79 100644 --- a/frontend/src/views/ReaderView.jsx +++ b/frontend/src/views/ReaderView.jsx @@ -3,7 +3,7 @@ import { useParams, useNavigate, useSearchParams } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { LuArrowLeft, LuChevronLeft, LuChevronRight, LuDownload, - LuFileText, LuColumns2, LuFile, LuSearch, LuList, LuBookmark, LuBookmarkPlus, LuHeart, + LuFileText, LuColumns2, LuFile, LuSearch, LuList, LuBookmark, LuBookmarkPlus, LuHeart, LuKeyboard, } from 'react-icons/lu' import api, { mediaUrl } from '../api' import Spinner from '../components/Spinner' @@ -72,6 +72,7 @@ export default function ReaderView() { const [pendingBookmark, setPendingBookmark] = useState(null) const [pendingLabel, setPendingLabel] = useState('') const [pendingNotes, setPendingNotes] = useState('') + const [showShortcuts, setShowShortcuts] = useState(false) const [zoom, setZoom] = useState(1) const [pan, setPan] = useState({ x: 0, y: 0 }) @@ -239,6 +240,8 @@ export default function ReaderView() { if (e.key === 't') togglePanel('toc') if (e.key === 'b') togglePanel('bookmarks') if (e.key === 's') togglePanel('search') + if (e.key === '?') setShowShortcuts(v => !v) + if (e.key === 'Escape') setShowShortcuts(false) } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) @@ -386,8 +389,42 @@ export default function ReaderView() { }}> + +
+ {showShortcuts && ( +
setShowShortcuts(false)} + style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }} + > +
e.stopPropagation()} style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 10, padding: 24, minWidth: 280, boxShadow: '0 8px 32px rgba(0,0,0,0.4)' }}> +
{t('reader.keyboardShortcuts')}
+ {[ + ['← / →', t('reader.shortcutPrevNext')], + ['↑ / ↓', t('reader.shortcutPrevNextVertical')], + ['f', t('reader.shortcutFavorite')], + ['t', t('reader.shortcutToc')], + ['b', t('reader.shortcutBookmarks')], + ['s', t('reader.shortcutSearch')], + ['?', t('reader.shortcutHelp')], + ['Esc', t('reader.shortcutClose')], + ].map(([key, desc]) => ( +
+ {key} + {desc} +
+ ))} +
+
+ )} + {/* Content + optional sidebar */}
{ saveBook(book.id, updated); setEditingBookId(null) }} onClose={() => setEditingBookId(null)} /> @@ -410,6 +411,7 @@ export default function SystemDetailView() { {editingBookId === book.id && ( { saveBook(book.id, updated); setEditingBookId(null) }} onClose={() => setEditingBookId(null)} />