From ed88aac5d5c6cd7a30c6982ca780124345adffde Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 23 Nov 2025 05:01:43 +0000 Subject: [PATCH] WebUI - UI fixes, additional features and refactoring (#324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One more on the frontend with some code cleanup, additional features and bug fixes after testing this last week or so :) * Various refactoring - removing reused code where possible and creating new shared components (AdvancedFilters, DownloadButton, SearchBar, buildSearchQuery, BookCard etc). Will hopefully help with further features and improvements within the frontend. (cc @ZYancey) * Added book descriptions to the details pane, grabbed alongside all other info. * Moved format selection into a dropdown list * Added a toast notification if no results are found when searching * Added “Clear search” button * Added “Report bug” link in the header menu, linking to the issues page * Fixed various UI bugs (Mouse hover colors, download badge) --- book_manager.py | 53 +++- models.py | 1 + src/frontend/src/App.tsx | 37 +-- .../src/components/AdvancedFilters.tsx | 83 +++--- .../src/components/BookDownloadButton.tsx | 196 ++++++++++++-- .../src/components/CircularProgress.tsx | 45 --- src/frontend/src/components/DetailsModal.tsx | 120 +++----- .../src/components/DownloadsSidebar.tsx | 14 +- src/frontend/src/components/Dropdown.tsx | 36 ++- src/frontend/src/components/DropdownList.tsx | 2 +- src/frontend/src/components/Footer.tsx | 8 +- src/frontend/src/components/Header.tsx | 134 ++++----- src/frontend/src/components/LoginForm.tsx | 2 +- .../src/components/ResultsSection.tsx | 13 +- src/frontend/src/components/SearchBar.tsx | 174 ++++++++++++ src/frontend/src/components/SearchSection.tsx | 256 ++---------------- src/frontend/src/components/StatusSection.tsx | 202 -------------- .../src/components/ToastContainer.tsx | 8 +- src/frontend/src/components/index.ts | 2 +- .../src/components/resultsViews/CardView.tsx | 2 +- .../components/resultsViews/CompactView.tsx | 2 +- .../src/components/resultsViews/ListView.tsx | 95 +------ src/frontend/src/styles.css | 45 ++- src/frontend/src/types/index.ts | 1 + src/frontend/src/utils/buildSearchQuery.ts | 46 ++++ 25 files changed, 724 insertions(+), 853 deletions(-) delete mode 100644 src/frontend/src/components/CircularProgress.tsx create mode 100644 src/frontend/src/components/SearchBar.tsx delete mode 100644 src/frontend/src/components/StatusSection.tsx create mode 100644 src/frontend/src/utils/buildSearchQuery.ts diff --git a/book_manager.py b/book_manager.py index f64b5d88..f6198b08 100644 --- a/book_manager.py +++ b/book_manager.py @@ -250,6 +250,8 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str) -> BookInfo: book_title = _find_in_divs(divs, "🔍").strip("🔍").strip() # Extract basic information + description = _extract_book_description(soup) + book_info = BookInfo( id=book_id, preview=preview, @@ -258,6 +260,7 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str) -> BookInfo: author=_find_in_divs(divs, "icon-[mdi--user-edit]", isClass=True), format=format, size=size, + description=description, download_urls=urls, ) @@ -303,9 +306,53 @@ def _get_download_urls_from_welib(book_id: str) -> set[str]: download_links = [downloader.get_absolute_url(url, link) for link in download_links] return set(download_links) -def _extract_book_metadata( - metadata_divs -) -> Dict[str, List[str]]: +def _get_next_value_div(label_div: Tag) -> Optional[Tag]: + """Find the next sibling div that holds the value for a metadata label.""" + sibling = label_div.next_sibling + while sibling: + if isinstance(sibling, Tag) and sibling.name == "div": + return sibling + sibling = sibling.next_sibling + return None + +def _extract_book_description(soup: BeautifulSoup) -> Optional[str]: + """Extract the primary or alternative description from the book page.""" + container = soup.select_one(".js-md5-top-box-description") + if not container: + return None + + description: Optional[str] = None + alternative: Optional[str] = None + + label_divs = container.select("div.text-xs.text-gray-500.uppercase") + for label_div in label_divs: + label_text = label_div.get_text(strip=True).lower() + value_div = _get_next_value_div(label_div) + if not value_div: + continue + + value_text = value_div.get_text(separator=" ", strip=True) + if not value_text: + continue + + if label_text == "description": + return value_text + if label_text == "alternative description" and not alternative: + alternative = value_text + + if alternative: + return alternative + + # Fallback to the first text block inside the description container + fallback_div = container.find("div", class_="mb-1") + if fallback_div: + fallback_text = fallback_div.get_text(separator=" ", strip=True) + if fallback_text: + return fallback_text + + return None + +def _extract_book_metadata(metadata_divs) -> Dict[str, List[str]]: """Extract metadata from book info divs.""" info: Dict[str, List[str]] = {} diff --git a/models.py b/models.py index f8340be9..f0a77d6b 100644 --- a/models.py +++ b/models.py @@ -50,6 +50,7 @@ class BookInfo: format: Optional[str] = None size: Optional[str] = None info: Optional[Dict[str, List[str]]] = None + description: Optional[str] = None download_urls: List[str] = field(default_factory=list) download_path: Optional[str] = None priority: int = 0 diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 3b4c0a01..fb6db7bd 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -21,7 +21,8 @@ import { ToastContainer } from './components/ToastContainer'; import { Footer } from './components/Footer'; import { LoginPage } from './pages/LoginPage'; import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages'; -import { getLanguageFilterValues, LANGUAGE_OPTION_DEFAULT } from './utils/languageFilters'; +import { LANGUAGE_OPTION_DEFAULT } from './utils/languageFilters'; +import { buildSearchQuery } from './utils/buildSearchQuery'; import './styles.css'; const DEFAULT_FORMAT_SELECTION = DEFAULT_SUPPORTED_FORMATS.filter(format => format !== 'pdf'); @@ -265,6 +266,9 @@ function App() { try { const results = await searchBooks(query); setBooks(results); + if (results.length === 0) { + showToast('No results found', 'error'); + } } catch (error) { if (error instanceof AuthenticationError) { setIsAuthenticated(false); @@ -425,29 +429,14 @@ function App() { isAuthenticated={isAuthenticated} onLogout={handleLogout} onSearch={() => { - const q: string[] = []; - const basic = searchInput.trim(); - if (basic) q.push(`query=${encodeURIComponent(basic)}`); - - if (showAdvanced) { - if (advancedFilters.isbn) q.push(`isbn=${encodeURIComponent(advancedFilters.isbn)}`); - if (advancedFilters.author) q.push(`author=${encodeURIComponent(advancedFilters.author)}`); - if (advancedFilters.title) q.push(`title=${encodeURIComponent(advancedFilters.title)}`); - if (advancedFilters.content) q.push(`content=${encodeURIComponent(advancedFilters.content)}`); - advancedFilters.formats.forEach(f => q.push(`format=${encodeURIComponent(f)}`)); - const resolvedLangs = getLanguageFilterValues( - advancedFilters.lang, - bookLanguages, - defaultLanguageCodes, - ); - resolvedLangs?.forEach(code => q.push(`lang=${encodeURIComponent(code)}`)); - } - - if (advancedFilters.sort) { - q.push(`sort=${encodeURIComponent(advancedFilters.sort)}`); - } - - handleSearch(q.join('&')); + const query = buildSearchQuery({ + searchInput, + showAdvanced, + advancedFilters, + bookLanguages, + defaultLanguage: defaultLanguageCodes, + }); + handleSearch(query); }} onAdvancedToggle={() => setShowAdvanced(!showAdvanced)} isLoading={isSearching} diff --git a/src/frontend/src/components/AdvancedFilters.tsx b/src/frontend/src/components/AdvancedFilters.tsx index a2544774..76f79133 100644 --- a/src/frontend/src/components/AdvancedFilters.tsx +++ b/src/frontend/src/components/AdvancedFilters.tsx @@ -1,9 +1,12 @@ +import { ReactNode } from 'react'; import { AdvancedFilterState, Language } from '../types'; import { normalizeLanguageSelection } from '../utils/languageFilters'; import { LanguageMultiSelect } from './LanguageMultiSelect'; import { DropdownList } from './DropdownList'; import { CONTENT_OPTIONS } from '../data/filterOptions'; +const FORMAT_TYPES = ['pdf', 'epub', 'mobi', 'azw3', 'fb2', 'djvu', 'cbz', 'cbr'] as const; + interface AdvancedFiltersProps { visible: boolean; bookLanguages: Language[]; @@ -11,6 +14,8 @@ interface AdvancedFiltersProps { supportedFormats: string[]; filters: AdvancedFilterState; onFiltersChange: (updates: Partial) => void; + formClassName?: string; + renderWrapper?: (form: ReactNode) => ReactNode; } export const AdvancedFilters = ({ @@ -20,6 +25,8 @@ export const AdvancedFilters = ({ supportedFormats, filters, onFiltersChange, + formClassName, + renderWrapper, }: AdvancedFiltersProps) => { const { isbn, author, title, lang, content, formats } = filters; @@ -33,22 +40,27 @@ export const AdvancedFilters = ({ onFiltersChange({ content: value }); }; - const toggleFormat = (format: string) => { - const newFormats = formats.includes(format) - ? formats.filter(f => f !== format) - : [...formats, format]; - onFiltersChange({ formats: newFormats }); + const handleFormatsChange = (next: string[] | string) => { + const nextFormats = Array.isArray(next) ? next : next ? [next] : []; + onFiltersChange({ formats: nextFormats }); }; + const formatOptions = FORMAT_TYPES.map(format => ({ + value: format, + label: format.toUpperCase(), + disabled: !supportedFormats.includes(format), + })); + if (!visible) return null; - return ( -
-
-
+ const form = ( +
+
)} diff --git a/src/frontend/src/components/LoginForm.tsx b/src/frontend/src/components/LoginForm.tsx index 0795d289..db5cc182 100644 --- a/src/frontend/src/components/LoginForm.tsx +++ b/src/frontend/src/components/LoginForm.tsx @@ -164,7 +164,7 @@ export const LoginForm = ({ type="button" onClick={() => setShowPassword((current) => !current)} disabled={isLoading} - className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" + className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1.5 rounded-full hover-action disabled:opacity-50 disabled:cursor-not-allowed transition-colors" aria-label={showPassword ? 'Hide password' : 'Show password'} > {showPassword ? : } diff --git a/src/frontend/src/components/ResultsSection.tsx b/src/frontend/src/components/ResultsSection.tsx index 29c010c2..58b76d74 100644 --- a/src/frontend/src/components/ResultsSection.tsx +++ b/src/frontend/src/components/ResultsSection.tsx @@ -31,7 +31,6 @@ export const ResultsSection = ({ }); const [isDesktop, setIsDesktop] = useState(false); - useEffect(() => { localStorage.setItem('bookViewMode', viewMode); }, [viewMode]); @@ -51,7 +50,7 @@ export const ResultsSection = ({ return (
-
+
{/* View toggle buttons - Desktop: show all 3, Mobile: show Compact and List only */} @@ -62,7 +61,7 @@ export const ResultsSection = ({ className={`p-2 rounded-full transition-all duration-200 ${ viewMode === 'card' ? 'text-white bg-sky-700 hover:bg-sky-800' - : 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100' + : 'hover-action text-gray-900 dark:text-gray-100' }`} title="Card view" aria-label="Card view" @@ -88,7 +87,7 @@ export const ResultsSection = ({ className={`p-2 rounded-full transition-all duration-200 ${ viewMode === 'compact' ? 'text-white bg-sky-700 hover:bg-sky-800' - : 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100' + : 'hover-action text-gray-900 dark:text-gray-100' }`} title="Compact view" aria-label="Compact view" @@ -112,7 +111,7 @@ export const ResultsSection = ({ className={`p-2 rounded-full transition-all duration-200 ${ viewMode === 'list' ? 'text-white bg-sky-700 hover:bg-sky-800' - : 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100' + : 'hover-action text-gray-900 dark:text-gray-100' }`} title="List view" aria-label="List view" @@ -198,7 +197,7 @@ const SortControl = ({ value, onChange }: SortControlProps) => { + )} + {onAdvancedToggle && ( + + )} + +
+
+ ); +}; + + diff --git a/src/frontend/src/components/SearchSection.tsx b/src/frontend/src/components/SearchSection.tsx index 5b11626f..edfdf033 100644 --- a/src/frontend/src/components/SearchSection.tsx +++ b/src/frontend/src/components/SearchSection.tsx @@ -1,9 +1,7 @@ -import { KeyboardEvent } from 'react'; import { AdvancedFilterState, Language } from '../types'; -import { getLanguageFilterValues, normalizeLanguageSelection } from '../utils/languageFilters'; -import { LanguageMultiSelect } from './LanguageMultiSelect'; -import { DropdownList } from './DropdownList'; -import { CONTENT_OPTIONS } from '../data/filterOptions'; +import { buildSearchQuery } from '../utils/buildSearchQuery'; +import { AdvancedFilters } from './AdvancedFilters'; +import { SearchBar } from './SearchBar'; interface SearchSectionProps { onSearch: (query: string) => void; @@ -36,54 +34,17 @@ export const SearchSection = ({ advancedFilters, onAdvancedFiltersChange, }: SearchSectionProps) => { - const { isbn, author, title, lang, content, formats } = advancedFilters; - - const buildQuery = () => { - const q: string[] = []; - const basic = searchInput.trim(); - if (basic) q.push(`query=${encodeURIComponent(basic)}`); - - if (!showAdvanced) return q.join('&'); - - if (isbn) q.push(`isbn=${encodeURIComponent(isbn)}`); - if (author) q.push(`author=${encodeURIComponent(author)}`); - if (title) q.push(`title=${encodeURIComponent(title)}`); - const selectedLanguages = getLanguageFilterValues(lang, bookLanguages, defaultLanguage); - selectedLanguages?.forEach(code => q.push(`lang=${encodeURIComponent(code)}`)); - if (content) q.push(`content=${encodeURIComponent(content)}`); - formats.forEach(f => q.push(`format=${encodeURIComponent(f)}`)); - - return q.join('&'); - }; - const handleSearch = () => { - const query = buildQuery(); + const query = buildSearchQuery({ + searchInput, + showAdvanced, + advancedFilters, + bookLanguages, + defaultLanguage, + }); onSearch(query); }; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter') { - handleSearch(); - (e.target as HTMLInputElement).blur(); - } - }; - - const handleLanguageChange = (next: string[]) => { - onAdvancedFiltersChange({ lang: normalizeLanguageSelection(next) }); - }; - - const handleContentChange = (next: string[] | string) => { - const value = Array.isArray(next) ? next[0] ?? '' : next; - onAdvancedFiltersChange({ content: value }); - }; - - const toggleFormat = (format: string) => { - const nextFormats = formats.includes(format) - ? formats.filter(f => f !== format) - : [...formats, format]; - onAdvancedFiltersChange({ formats: nextFormats }); - }; - return (
-
- onSearchInputChange(e.target.value)} - onKeyDown={handleKeyDown} - /> -
- - -
-
- {/* Advanced Filters */} -
-
- - onAdvancedFiltersChange({ isbn: e.target.value })} - /> -
-
- - onAdvancedFiltersChange({ author: e.target.value })} - /> -
-
- - onAdvancedFiltersChange({ title: e.target.value })} - /> -
- - -
- -
- {['pdf', 'epub', 'mobi', 'azw3', 'fb2', 'djvu', 'cbz', 'cbr'].map(format => { - const isSupported = supportedFormats.includes(format); - return ( - - ); - })} -
-
- + + form} + />
); diff --git a/src/frontend/src/components/StatusSection.tsx b/src/frontend/src/components/StatusSection.tsx deleted file mode 100644 index 5cef2117..00000000 --- a/src/frontend/src/components/StatusSection.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import { StatusData } from '../types'; - -interface StatusBadgeProps { - status: string; -} - -const STATUS_STYLES: Record = { - queued: { - bg: 'bg-amber-500/10', - text: 'text-amber-600', - label: 'Queued', - }, - resolving: { - bg: 'bg-indigo-500/10', - text: 'text-indigo-600', - label: 'Resolving', - }, - bypassing: { - bg: 'bg-purple-500/10', - text: 'text-purple-600', - label: 'Bypassing', - }, - downloading: { - bg: 'bg-blue-500/10', - text: 'text-blue-600', - label: 'Downloading', - }, - verifying: { - bg: 'bg-cyan-500/10', - text: 'text-cyan-600', - label: 'Verifying', - }, - ingesting: { - bg: 'bg-teal-500/10', - text: 'text-teal-600', - label: 'Ingesting', - }, - complete: { - bg: 'bg-green-500/10', - text: 'text-green-600', - label: 'Complete', - }, - completed: { - bg: 'bg-green-500/10', - text: 'text-green-600', - label: 'Completed', - }, - available: { - bg: 'bg-green-500/10', - text: 'text-green-600', - label: 'Available', - }, - done: { - bg: 'bg-green-500/10', - text: 'text-green-600', - label: 'Done', - }, - error: { - bg: 'bg-red-500/10', - text: 'text-red-600', - label: 'Error', - }, - cancelled: { - bg: 'bg-gray-500/10', - text: 'text-gray-600', - label: 'Cancelled', - }, -}; - -const StatusBadge = ({ status }: StatusBadgeProps) => { - const style = STATUS_STYLES[status.toLowerCase()] || { - bg: 'bg-gray-500/10', - text: 'text-gray-600', - label: status.charAt(0).toUpperCase() + status.slice(1), - }; - - return ( - - {style.label} - - ); -}; - -interface StatusSectionProps { - status: StatusData; - visible: boolean; - activeCount: number; - onRefresh: () => void; - onClearCompleted: () => void; - onCancel: (id: string) => void; -} - -export const StatusSection = ({ - status, - visible, - activeCount, - onRefresh, - onClearCompleted, - onCancel, -}: StatusSectionProps) => { - if (!visible) return null; - - const renderSection = (name: string, items: Record) => { - if (!items || Object.keys(items).length === 0) return null; - - return ( -
-

- {name.charAt(0).toUpperCase() + name.slice(1)} -

-
    - {Object.values(items).map((book: any) => { - const maybeLinkedTitle = book.download_path ? ( - - {book.title || '-'} - - ) : ( - book.title || '-' - ); - - const actions = - name === 'queued' || name === 'resolving' || name === 'bypassing' || name === 'downloading' || name === 'verifying' || name === 'ingesting' ? ( - - ) : null; - - const progress = - name === 'downloading' && typeof book.progress === 'number' ? ( -
    -
    -
    - ) : null; - - return ( -
  • -
    - {maybeLinkedTitle} -
    - {progress} - {actions &&
    {actions}
    } -
  • - ); - })} -
-
- ); - }; - - const sections = ['queued', 'resolving', 'bypassing', 'downloading', 'verifying', 'ingesting', 'complete', 'completed', 'available', 'done', 'error', 'cancelled']; - const renderedSections = sections - .map(name => renderSection(name, (status as any)[name] || {})) - .filter(Boolean); - - return ( -
-
-

Downloads

-
- - - Active: {activeCount} -
-
-
- {renderedSections.length > 0 ? ( - renderedSections - ) : ( -
No items.
- )} -
-
- ); -}; diff --git a/src/frontend/src/components/ToastContainer.tsx b/src/frontend/src/components/ToastContainer.tsx index bafc6ca2..27e2128f 100644 --- a/src/frontend/src/components/ToastContainer.tsx +++ b/src/frontend/src/components/ToastContainer.tsx @@ -18,13 +18,19 @@ export const ToastContainer = ({ toasts }: ToastContainerProps) => { }); }, [toasts]); + const toastTypeClasses: Record = { + success: 'bg-green-600 text-white', + error: 'bg-red-600 text-white', + info: 'bg-blue-600 text-white', + }; + return (
{toasts.map(toast => (
{toast.message} diff --git a/src/frontend/src/components/index.ts b/src/frontend/src/components/index.ts index fb4574c5..973e34fa 100644 --- a/src/frontend/src/components/index.ts +++ b/src/frontend/src/components/index.ts @@ -1,8 +1,8 @@ export { Header } from './Header'; export { SearchSection } from './SearchSection'; +export { SearchBar } from './SearchBar'; export { ResultsSection } from './ResultsSection'; export { DetailsModal } from './DetailsModal'; -export { StatusSection } from './StatusSection'; export { DownloadsSidebar } from './DownloadsSidebar'; export { ToastContainer } from './ToastContainer'; export { Footer } from './Footer'; diff --git a/src/frontend/src/components/resultsViews/CardView.tsx b/src/frontend/src/components/resultsViews/CardView.tsx index 826b3ba7..695d1576 100644 --- a/src/frontend/src/components/resultsViews/CardView.tsx +++ b/src/frontend/src/components/resultsViews/CardView.tsx @@ -31,7 +31,7 @@ export const CardView = ({ book, onDetails, onDownload, buttonState, animationDe return (
ButtonStateInfo; } -interface ListViewDownloadButtonProps { - buttonState: ButtonStateInfo; - onDownload: () => Promise; -} - -const ListViewDownloadButton = ({ buttonState, onDownload }: ListViewDownloadButtonProps) => { - const [isQueuing, setIsQueuing] = useState(false); - - useEffect(() => { - if (isQueuing && buttonState.state !== 'download') { - setIsQueuing(false); - } - }, [buttonState.state, isQueuing]); - - const isCompleted = buttonState.state === 'completed'; - const hasError = buttonState.state === 'error'; - const isInProgress = ['queued', 'resolving', 'bypassing', 'downloading', 'verifying', 'ingesting'].includes( - buttonState.state, - ); - const isDisabled = buttonState.state !== 'download' || isQueuing || isCompleted; - const showCircularProgress = buttonState.state === 'downloading' && buttonState.progress !== undefined; - const showSpinner = (isInProgress && !showCircularProgress) || isQueuing; - - const handleDownload = async () => { - if (isDisabled) return; - setIsQueuing(true); - try { - await onDownload(); - } catch (error) { - setIsQueuing(false); - } - }; - - const baseClasses = 'flex items-center justify-center p-1.5 sm:p-2 rounded-full transition-all duration-200'; - const stateClasses = isCompleted - ? 'bg-green-600 text-white cursor-not-allowed' - : hasError - ? 'bg-red-600 text-white cursor-not-allowed opacity-75' - : isInProgress - ? 'bg-gray-500 text-white cursor-not-allowed opacity-75' - : 'text-gray-600 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700'; - - return ( - - ); -}; - const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: string }) => { const [imageLoaded, setImageLoaded] = useState(false); const [imageError, setImageError] = useState(false); @@ -201,7 +119,7 @@ export const ListView = ({ books, onDetails, onDownload, getButtonState }: ListV return (
- onDownload(book)} + variant="icon" + size="md" + ariaLabel={buttonState.text} />
diff --git a/src/frontend/src/styles.css b/src/frontend/src/styles.css index 36db61be..f9b4b3b8 100644 --- a/src/frontend/src/styles.css +++ b/src/frontend/src/styles.css @@ -35,6 +35,9 @@ input, select, textarea, button, --text: var(--text-color); --border-muted: var(--border-color); --bg-soft: var(--card-background); + --hover-surface: rgba(15, 23, 42, 0.05); + --hover-action: rgba(15, 23, 42, 0.08); + --hover-row: rgba(15, 23, 42, 0.07); } [data-theme="dark"] { @@ -45,6 +48,9 @@ input, select, textarea, button, --card-background: #1e1e1e; --border-color: #404040; --input-background: #2d2d2d; + --hover-surface: rgba(255, 255, 255, 0.05); + --hover-action: rgba(255, 255, 255, 0.12); + --hover-row: rgba(255, 255, 255, 0.08); } * { @@ -184,17 +190,17 @@ footer { } /* Button spinner styles */ -#search-spinner { +.search-bar-spinner { border-radius: 50%; animation: spin 0.8s linear infinite; transition: opacity 0.2s ease; } -#search-icon { +.search-bar-icon { transition: opacity 0.2s ease; } -#search-button:disabled { +.search-bar-button:disabled { cursor: not-allowed; } @@ -289,6 +295,24 @@ button:disabled { cursor: not-allowed; } +.hover-surface, +.hover-action, +.hover-row { + transition: background-color 0.2s ease, color 0.2s ease; +} + +.hover-surface:hover { + background-color: var(--hover-surface); +} + +.hover-action:hover { + background-color: var(--hover-action); +} + +.hover-row:hover { + background-color: var(--hover-row); +} + /* Search wrapper and input - base styles */ .search-wrapper { width: 100%; @@ -296,6 +320,21 @@ button:disabled { .search-input { min-width: 0; /* Allow input to shrink on mobile flex containers */ + -webkit-appearance: none; + appearance: none; +} + +.search-input::-webkit-search-cancel-button, +.search-input::-webkit-search-decoration, +.search-input::-webkit-search-results-button, +.search-input::-webkit-search-results-decoration { + display: none; +} + +.search-input::-ms-clear { + display: none; + width: 0; + height: 0; } /* ============================================ diff --git a/src/frontend/src/types/index.ts b/src/frontend/src/types/index.ts index 7e7ac4bb..d489d55a 100644 --- a/src/frontend/src/types/index.ts +++ b/src/frontend/src/types/index.ts @@ -10,6 +10,7 @@ export interface Book { preview?: string; publisher?: string; info?: Record; + description?: string; download_path?: string; progress?: number; } diff --git a/src/frontend/src/utils/buildSearchQuery.ts b/src/frontend/src/utils/buildSearchQuery.ts new file mode 100644 index 00000000..f89a2eba --- /dev/null +++ b/src/frontend/src/utils/buildSearchQuery.ts @@ -0,0 +1,46 @@ +import { AdvancedFilterState, Language } from '../types'; +import { getLanguageFilterValues } from './languageFilters'; + +interface BuildSearchQueryOptions { + searchInput: string; + showAdvanced: boolean; + advancedFilters: AdvancedFilterState; + bookLanguages: Language[]; + defaultLanguage: string[]; +} + +export const buildSearchQuery = ({ + searchInput, + showAdvanced, + advancedFilters, + bookLanguages, + defaultLanguage, +}: BuildSearchQueryOptions): string => { + const queryParts: string[] = []; + + const basic = searchInput.trim(); + if (basic) { + queryParts.push(`query=${encodeURIComponent(basic)}`); + } + + if (showAdvanced) { + const { isbn, author, title, content, formats, lang } = advancedFilters; + + if (isbn) queryParts.push(`isbn=${encodeURIComponent(isbn)}`); + if (author) queryParts.push(`author=${encodeURIComponent(author)}`); + if (title) queryParts.push(`title=${encodeURIComponent(title)}`); + + const selectedLanguages = getLanguageFilterValues(lang, bookLanguages, defaultLanguage); + selectedLanguages?.forEach(code => queryParts.push(`lang=${encodeURIComponent(code)}`)); + + if (content) queryParts.push(`content=${encodeURIComponent(content)}`); + formats.forEach(format => queryParts.push(`format=${encodeURIComponent(format)}`)); + } + + if (advancedFilters.sort) { + queryParts.push(`sort=${encodeURIComponent(advancedFilters.sort)}`); + } + + return queryParts.join('&'); +}; +