WebUI - UI fixes, additional features and refactoring (#324)

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)
This commit is contained in:
Alex
2025-11-23 00:01:43 -05:00
committed by GitHub
parent 5751910426
commit ed88aac5d5
25 changed files with 724 additions and 853 deletions
+50 -3
View File
@@ -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]] = {}
+1
View File
@@ -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
+13 -24
View File
@@ -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}
+45 -38
View File
@@ -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<AdvancedFilterState>) => 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 (
<div className="w-full border-b pt-6 pb-4 mb-4" style={{ borderColor: 'var(--border-muted)' }}>
<div className="w-full px-4 sm:px-6 lg:px-8">
<form
id="search-filters"
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2 lg:ml-[calc(3rem+1rem)] lg:w-[50vw]"
>
const form = (
<form
id="search-filters"
className={
formClassName ??
'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2 lg:ml-[calc(3rem+1rem)] lg:w-[50vw]'
}
>
<div>
<label htmlFor="isbn-input" className="block text-sm mb-1 opacity-80">
ISBN
@@ -126,33 +138,28 @@ export const AdvancedFilters = ({
onChange={handleContentChange}
placeholder="All"
/>
<div className="md:col-span-2 lg:col-span-3">
<label className="block text-sm mb-1 opacity-80">Formats</label>
<div className="flex flex-wrap gap-3 text-sm">
{['pdf', 'epub', 'mobi', 'azw3', 'fb2', 'djvu', 'cbz', 'cbr'].map(format => {
const isSupported = supportedFormats.includes(format);
return (
<label
key={format}
className={`inline-flex items-center gap-2 ${
!isSupported ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
<input
type="checkbox"
value={format}
checked={formats.includes(format)}
onChange={() => toggleFormat(format)}
disabled={!isSupported}
/>
{format.toUpperCase()}
</label>
);
})}
</div>
<div>
<DropdownList
label="Formats"
placeholder="Any"
options={formatOptions}
value={formats}
onChange={handleFormatsChange}
multiple
showCheckboxes
keepOpenOnSelect
/>
</div>
</form>
</div>
</form>
);
const wrappedForm = renderWrapper ? (
renderWrapper(form)
) : (
<div className="w-full border-b pt-6 pb-4 mb-4" style={{ borderColor: 'var(--border-muted)' }}>
<div className="w-full px-4 sm:px-6 lg:px-8">{form}</div>
</div>
);
return wrappedForm;
};
@@ -1,8 +1,48 @@
import { useEffect, useState, CSSProperties } from 'react';
import { ButtonStateInfo } from '../types';
import { CircularProgress } from './CircularProgress';
interface CircularProgressProps {
progress?: number;
size?: number;
className?: string;
}
const CircularProgress = ({ progress, size = 16, className }: CircularProgressProps) => {
const radius = (size - 2) / 2;
const circumference = 2 * Math.PI * radius;
const progressValue = progress ?? 0;
const strokeDashoffset = circumference - (progressValue / 100) * circumference;
const svgClassName = className ? `transform -rotate-90 ${className}` : 'transform -rotate-90';
return (
<svg width={size} height={size} className={svgClassName}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="2"
opacity="0.3"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
style={{ transition: 'stroke-dashoffset 0.3s ease' }}
/>
</svg>
);
};
type ButtonSize = 'sm' | 'md';
type ButtonVariant = 'primary' | 'icon';
interface BookDownloadButtonProps {
buttonState: ButtonStateInfo;
@@ -12,6 +52,8 @@ interface BookDownloadButtonProps {
className?: string;
showIcon?: boolean;
style?: CSSProperties;
variant?: ButtonVariant;
ariaLabel?: string;
}
const sizeClasses: Record<ButtonSize, string> = {
@@ -19,11 +61,26 @@ const sizeClasses: Record<ButtonSize, string> = {
md: 'px-4 py-2.5 text-sm',
};
const iconSizes: Record<ButtonSize, string> = {
const iconVariantSizeClasses: Record<ButtonSize, string> = {
sm: 'p-1 sm:p-1.5',
md: 'p-1.5 sm:p-2',
};
const primaryIconSizes: Record<ButtonSize, string> = {
sm: 'w-3.5 h-3.5',
md: 'w-4 h-4',
};
const iconVariantIconSizes: Record<ButtonSize, { mobile: string; desktop: string }> = {
sm: { mobile: 'w-3.5 h-3.5', desktop: 'w-4 h-4' },
md: { mobile: 'w-4 h-4', desktop: 'w-5 h-5' },
};
const iconVariantProgressSizes: Record<ButtonSize, { mobile: number; desktop: number }> = {
sm: { mobile: 14, desktop: 16 },
md: { mobile: 16, desktop: 20 },
};
export const BookDownloadButton = ({
buttonState,
onDownload,
@@ -32,6 +89,8 @@ export const BookDownloadButton = ({
className = '',
showIcon = false,
style,
variant = 'primary',
ariaLabel,
}: BookDownloadButtonProps) => {
const [isQueuing, setIsQueuing] = useState(false);
@@ -51,7 +110,7 @@ export const BookDownloadButton = ({
const showCircularProgress = buttonState.state === 'downloading' && buttonState.progress !== undefined;
const showSpinner = (isInProgress && !showCircularProgress) || isQueuing;
const stateClasses =
const primaryStateClasses =
isCompleted
? 'bg-green-600 cursor-not-allowed'
: hasError
@@ -60,9 +119,25 @@ export const BookDownloadButton = ({
? 'bg-gray-500 cursor-not-allowed opacity-75'
: 'bg-sky-700 hover:bg-sky-800';
const widthClasses = fullWidth ? 'w-full' : '';
const iconStateClasses =
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-action';
const stateClasses = variant === 'icon' ? iconStateClasses : primaryStateClasses;
const widthClasses = variant === 'primary' && fullWidth ? 'w-full' : '';
const baseClasses =
'inline-flex items-center justify-center gap-1.5 rounded text-white transition-all duration-200 disabled:opacity-80 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-sky-500';
variant === 'icon'
? 'flex items-center justify-center rounded-full transition-all duration-200 disabled:opacity-80 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-sky-500'
: 'inline-flex items-center justify-center gap-1.5 rounded text-white transition-all duration-200 disabled:opacity-80 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-sky-500';
const sizeClass = variant === 'icon' ? iconVariantSizeClasses[size] : sizeClasses[size];
const iconSizes = variant === 'icon' ? iconVariantIconSizes[size] : undefined;
const handleDownload = async () => {
if (isDisabled) return;
@@ -74,36 +149,107 @@ export const BookDownloadButton = ({
}
};
const renderStatusIcon = () => {
if (isCompleted) {
if (variant === 'icon' && iconSizes) {
return (
<>
<svg className={`${iconSizes.mobile} sm:hidden`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 13l4 4L19 7" />
</svg>
<svg className={`${iconSizes.desktop} hidden sm:block`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 13l4 4L19 7" />
</svg>
</>
);
}
return (
<svg className={primaryIconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
);
}
if (hasError) {
if (variant === 'icon' && iconSizes) {
return (
<>
<svg className={`${iconSizes.mobile} sm:hidden`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
</svg>
<svg className={`${iconSizes.desktop} hidden sm:block`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" />
</svg>
</>
);
}
return (
<svg className={primaryIconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
);
}
if (showCircularProgress) {
if (variant === 'icon') {
const sizes = iconVariantProgressSizes[size];
return (
<>
<CircularProgress progress={buttonState.progress} size={sizes.mobile} className="block sm:hidden" />
<CircularProgress progress={buttonState.progress} size={sizes.desktop} className="hidden sm:block" />
</>
);
}
return <CircularProgress progress={buttonState.progress} size={size === 'sm' ? 12 : 16} />;
}
if (showSpinner) {
const spinnerClass =
variant === 'icon'
? size === 'sm'
? 'w-3.5 h-3.5 sm:w-4 h-4'
: 'w-4 h-4 sm:w-5 h-5'
: size === 'sm'
? 'w-3 h-3'
: 'w-4 h-4';
return <div className={`${spinnerClass} border-2 border-current border-t-transparent rounded-full animate-spin`} />;
}
if (variant === 'icon' && iconSizes) {
return (
<>
<svg className={`${iconSizes.mobile} sm:hidden`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
<svg className={`${iconSizes.desktop} hidden sm:block`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
</>
);
}
return null;
};
return (
<button
className={`${baseClasses} ${sizeClasses[size]} ${stateClasses} ${widthClasses} ${className}`.trim()}
className={`${baseClasses} ${sizeClass} ${stateClasses} ${widthClasses} ${className}`.trim()}
onClick={handleDownload}
disabled={isDisabled || isInProgress}
data-action="download"
style={style}
aria-label={ariaLabel ?? displayText}
>
{showIcon && !isCompleted && !hasError && !showCircularProgress && !showSpinner && (
<svg className={iconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
{variant === 'primary' && showIcon && !isCompleted && !hasError && !showCircularProgress && !showSpinner && (
<svg className={primaryIconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v12m0 0l-4-4m4 4 4-4M6 20h12" />
</svg>
)}
<span className="download-button-text">{displayText}</span>
{isCompleted && (
<svg className={iconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
{hasError && (
<svg className={iconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
{showCircularProgress && <CircularProgress progress={buttonState.progress} size={size === 'sm' ? 12 : 16} />}
{showSpinner && (
<div
className={`${size === 'sm' ? 'w-3 h-3' : 'w-4 h-4'} border-2 border-white border-t-transparent rounded-full animate-spin`}
/>
)}
{variant === 'primary' && <span className="download-button-text">{displayText}</span>}
{variant === 'icon' && <span className="sr-only">{ariaLabel ?? displayText}</span>}
{renderStatusIcon()}
</button>
);
};
@@ -1,45 +0,0 @@
interface CircularProgressProps {
progress?: number;
size?: number;
className?: string;
}
/**
* Circular progress indicator component
* Displays a circular SVG progress ring that fills based on the progress percentage
*/
export const CircularProgress = ({ progress, size = 16, className }: CircularProgressProps) => {
const radius = (size - 2) / 2;
const circumference = 2 * Math.PI * radius;
const progressValue = progress ?? 0;
const strokeDashoffset = circumference - (progressValue / 100) * circumference;
const svgClassName = className ? `transform -rotate-90 ${className}` : 'transform -rotate-90';
return (
<svg width={size} height={size} className={svgClassName}>
{/* Background circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="2"
opacity="0.3"
/>
{/* Progress circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
style={{ transition: 'stroke-dashoffset 0.3s ease' }}
/>
</svg>
);
};
+36 -84
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { Book, ButtonStateInfo } from '../types';
import { CircularProgress } from './CircularProgress';
import { BookDownloadButton } from './BookDownloadButton';
interface DetailsModalProps {
book: Book | null;
@@ -46,17 +46,6 @@ export const DetailsModal = ({ book, onClose, onDownload, buttonState }: Details
const titleId = `book-details-title-${book.id}`;
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 displayText = isQueuing ? 'Queuing...' : buttonState.text;
// Show circular progress only for downloading state with progress data
const showCircularProgress = buttonState.state === 'downloading' && buttonState.progress !== undefined;
// Show spinner for other in-progress states or when queuing
const showSpinner = (isInProgress && !showCircularProgress) || isQueuing;
const handleDownload = async () => {
setIsQueuing(true);
try {
@@ -85,6 +74,11 @@ export const DetailsModal = ({ book, onClose, onDownload, buttonState }: Details
return normalized !== 'language' && normalized !== 'year';
})
: [];
const extendedInfoEntries = [[publisherInfo.label, publisherInfo.value], ...additionalInfo];
const infoCardClass = 'rounded-2xl border border-[var(--border-muted)] px-4 py-3 text-sm';
const infoCardStyle = { background: 'var(--bg)' };
const infoLabelClass = 'text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400';
const infoValueClass = 'text-gray-900 dark:text-gray-100';
return (
<div
@@ -113,7 +107,7 @@ export const DetailsModal = ({ book, onClose, onDownload, buttonState }: Details
<button
type="button"
onClick={onClose}
className="rounded-full p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-700 dark:hover:text-gray-100"
className="rounded-full p-2 text-gray-500 transition-colors hover-action hover:text-gray-900 dark:hover:text-gray-100"
aria-label="Close details"
>
<svg
@@ -154,51 +148,33 @@ export const DetailsModal = ({ book, onClose, onDownload, buttonState }: Details
</div>
<div className="flex flex-1 flex-col gap-4 sm:gap-5 lg:min-h-0">
<div className="space-y-4 text-sm">
<div
className="rounded-2xl border border-[var(--border-muted)] px-4 py-3"
style={{ background: 'var(--bg)' }}
>
<p className="text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400">
{publisherInfo.label}
</p>
<p className="font-medium text-gray-900 dark:text-gray-100">{publisherInfo.value}</p>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-2 lg:grid-cols-4 lg:gap-4">
{metadata.map(item => (
<div
key={item.label}
className="rounded-2xl border border-[var(--border-muted)] px-4 py-3"
style={{ background: 'var(--bg)' }}
>
<p className="text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400">
{item.label}
</p>
<p className="font-medium text-gray-900 dark:text-gray-100">{item.value}</p>
</div>
))}
{book.description && (
<div className={`${infoCardClass} space-y-1`} style={infoCardStyle}>
<p className={infoLabelClass}>Description</p>
<p className={`${infoValueClass} whitespace-pre-line`}>{book.description}</p>
</div>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-2 lg:grid-cols-4 lg:gap-4">
{metadata.map(item => (
<div key={item.label} className={`${infoCardClass} space-y-1`} style={infoCardStyle}>
<p className={infoLabelClass}>{item.label}</p>
<p className={infoValueClass}>{item.value}</p>
</div>
))}
</div>
{additionalInfo.length > 0 && (
<section
className="space-y-3 rounded-2xl border border-[var(--border-muted)] px-4 py-4"
style={{ background: 'var(--bg)' }}
>
<h4 className="text-sm font-semibold">Further Information</h4>
<ul className="space-y-2 text-sm">
{additionalInfo.map(([key, value]) => (
<li key={key} className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{key}
</span>
<span className="text-gray-900 dark:text-gray-100">
{Array.isArray(value) ? value.join(', ') : value}
</span>
{extendedInfoEntries.length > 0 && (
<div className={`${infoCardClass} space-y-3`} style={infoCardStyle}>
<ul className="space-y-3 list-none">
{extendedInfoEntries.map(([key, value]) => (
<li key={key} className="space-y-1">
<p className={infoLabelClass}>{key}</p>
<p className={infoValueClass}>{Array.isArray(value) ? value.join(', ') : value}</p>
</li>
))}
</ul>
</section>
</div>
)}
</div>
</div>
@@ -206,38 +182,14 @@ export const DetailsModal = ({ book, onClose, onDownload, buttonState }: Details
<footer className="border-t border-[var(--border-muted)] bg-[var(--bg-soft)] px-5 py-4">
<div className="flex justify-end">
<button
id="download-button"
data-id={book.id}
type="button"
className={`inline-flex items-center justify-center gap-2 rounded-full px-4 py-3 text-sm font-medium text-white transition-colors ${
isCompleted
? 'bg-green-600'
: hasError
? 'bg-red-600'
: isInProgress || isQueuing
? 'bg-gray-500'
: 'bg-sky-700 hover:bg-sky-800'
} ${isDisabled ? 'cursor-not-allowed opacity-75' : ''}`}
onClick={handleDownload}
disabled={isDisabled || isInProgress}
>
<span className="download-button-text">{displayText}</span>
{isCompleted && (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
{hasError && (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
{showCircularProgress && <CircularProgress progress={buttonState.progress} size={16} />}
{showSpinner && (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
)}
</button>
<BookDownloadButton
buttonState={buttonState}
onDownload={handleDownload}
size="md"
fullWidth
className="rounded-full px-4 py-3 text-sm font-medium"
ariaLabel={`Download ${book.title || 'book'}`}
/>
</div>
</footer>
</div>
@@ -220,7 +220,7 @@ export const DownloadsSidebar = ({
<button
type="button"
onClick={() => onCancel(book.id)}
className="text-xs px-2 py-1 rounded border hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
className="text-xs px-2 py-1 rounded border hover-action transition-colors"
style={{ borderColor: 'var(--border-muted)' }}
title="Cancel download"
>
@@ -288,15 +288,12 @@ export const DownloadsSidebar = ({
style={{ background: 'var(--bg)' }}
>
{/* Header */}
<div
className="flex items-center justify-between p-4 border-b"
style={{ borderColor: 'var(--border-muted)' }}
>
<div className="flex items-center justify-between p-4">
<h2 className="text-lg font-semibold">Downloads</h2>
<button
type="button"
onClick={onClose}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
className="flex h-10 w-10 items-center justify-center rounded-full hover-action transition-colors"
aria-label="Close sidebar"
>
<svg
@@ -320,7 +317,7 @@ export const DownloadsSidebar = ({
<button
type="button"
onClick={onClearCompleted}
className="flex-1 flex items-center justify-center px-3 py-2 rounded border text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
className="flex-1 flex items-center justify-center px-3 py-2 h-10 rounded border text-sm hover-action transition-colors"
style={{ borderColor: 'var(--border-muted)' }}
>
Clear Completed
@@ -328,8 +325,7 @@ export const DownloadsSidebar = ({
<button
type="button"
onClick={onRefresh}
className="flex items-center justify-center px-3 py-2 rounded border text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
style={{ borderColor: 'var(--border-muted)' }}
className="flex items-center justify-center h-10 w-10 rounded-full text-sm hover-action transition-colors ml-auto"
aria-label="Refresh"
title="Refresh"
>
+34 -2
View File
@@ -1,4 +1,4 @@
import { ReactNode, useEffect, useRef, useState } from 'react';
import { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
interface DropdownProps {
label?: string;
@@ -25,6 +25,8 @@ export const Dropdown = ({
}: DropdownProps) => {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [panelDirection, setPanelDirection] = useState<'down' | 'up'>('down');
const toggleOpen = () => {
if (disabled) return;
@@ -57,6 +59,33 @@ export const Dropdown = ({
};
}, [isOpen]);
useLayoutEffect(() => {
if (!isOpen) return;
const updatePanelDirection = () => {
if (!containerRef.current || !panelRef.current) {
return;
}
const rect = containerRef.current.getBoundingClientRect();
const panelHeight = panelRef.current.offsetHeight || panelRef.current.scrollHeight;
const spaceBelow = window.innerHeight - rect.bottom - 8;
const spaceAbove = rect.top - 8;
const shouldOpenUp = spaceBelow < panelHeight && spaceAbove >= panelHeight;
setPanelDirection(shouldOpenUp ? 'up' : 'down');
};
updatePanelDirection();
window.addEventListener('resize', updatePanelDirection);
window.addEventListener('scroll', updatePanelDirection, true);
return () => {
window.removeEventListener('resize', updatePanelDirection);
window.removeEventListener('scroll', updatePanelDirection, true);
};
}, [isOpen]);
return (
<div className={`relative ${widthClassName}`} ref={containerRef}>
{label && (
@@ -95,7 +124,10 @@ export const Dropdown = ({
{isOpen && (
<div
className={`absolute ${align === 'right' ? 'right-0' : 'left-0'} mt-2 rounded-md border shadow-lg z-20 ${panelClassName || widthClassName}`}
ref={panelRef}
className={`absolute ${align === 'right' ? 'right-0' : 'left-0'} ${
panelDirection === 'down' ? 'mt-2' : 'bottom-full mb-2'
} rounded-md border shadow-lg z-20 ${panelClassName || widthClassName}`}
style={{
background: 'var(--bg)',
borderColor: 'var(--border-muted)',
+1 -1
View File
@@ -102,7 +102,7 @@ export const DropdownList = ({
<button
type="button"
key={option.value}
className={`w-full px-3 py-2 text-left text-base flex items-center gap-2 hover:bg-gray-100 dark:hover:bg-gray-800 ${
className={`w-full px-3 py-2 text-left text-base flex items-center gap-2 hover-surface ${
option.disabled ? 'opacity-50 cursor-not-allowed' : ''
}`}
onClick={() => handleOptionClick(option, close)}
+4 -4
View File
@@ -6,16 +6,16 @@ interface FooterProps {
export const Footer = ({ buildVersion, releaseVersion, appEnv }: FooterProps) => {
return (
<footer className="mt-10 border-t pt-6 pb-10" style={{ borderColor: 'var(--border-muted)' }}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-between">
<div>
<footer className="mt-8 border-t py-6" style={{ borderColor: 'var(--border-muted)' }}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 text-left">
<p className="text-sm opacity-80">Calibre Web Book Downloader</p>
<p className="text-xs opacity-60 mt-1">
Build: {buildVersion || 'dev'} • Release: {releaseVersion || 'dev'} • Env:{' '}
{appEnv || 'development'}
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-3 shrink-0">
<a
href="https://github.com/calibrain/calibre-web-automated-book-downloader"
className="opacity-80 hover:opacity-100"
+49 -85
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import { SearchBar } from './SearchBar';
interface StatusCounts {
ongoing: number;
@@ -141,11 +142,12 @@ export const Header = ({
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && onSearch) {
onSearch();
(e.target as HTMLInputElement).blur();
}
const handleHeaderSearch = () => {
onSearch?.();
};
const handleSearchChange = (value: string) => {
onSearchChange?.(value);
};
// Icon buttons component - reused for both states
@@ -157,7 +159,7 @@ export const Header = ({
href={calibreWebUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 text-gray-900 dark:text-gray-100"
className="flex items-center gap-2 px-3 py-2 rounded-full hover-action transition-all duration-200 text-gray-900 dark:text-gray-100"
aria-label="Open Calibre-Web"
title="Go To Library"
>
@@ -172,7 +174,7 @@ export const Header = ({
{onDownloadsClick && (
<button
onClick={onDownloadsClick}
className="relative flex items-center gap-2 px-3 py-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 text-gray-900 dark:text-gray-100"
className="relative flex items-center gap-2 px-3 py-2 rounded-full hover-action transition-all duration-200 text-gray-900 dark:text-gray-100"
aria-label="View downloads"
title="Downloads"
>
@@ -193,8 +195,8 @@ export const Header = ({
</svg>
{/* Show badge with appropriate color based on status */}
{(statusCounts.ongoing > 0 || statusCounts.completed > 0 || statusCounts.errored > 0) && (
<span
className={`absolute -top-1 -right-1 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center ${
<span
className={`absolute -top-1 -right-1 text-white text-[0.55rem] font-bold rounded-full w-3.5 h-3.5 flex items-center justify-center ${
statusCounts.errored > 0
? 'bg-red-500'
: statusCounts.ongoing > 0
@@ -215,7 +217,7 @@ export const Header = ({
<div className="relative" ref={dropdownRef}>
<button
onClick={toggleDropdown}
className={`relative p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
className={`relative p-2 rounded-full hover-action transition-colors ${
isDropdownOpen ? 'bg-gray-100 dark:bg-gray-700' : ''
}`}
aria-label="User menu"
@@ -254,7 +256,7 @@ export const Header = ({
<button
type="button"
onClick={cycleTheme}
className="w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-3"
className="w-full text-left px-4 py-2 hover-surface transition-colors flex items-center gap-3"
>
{theme === 'light' && (
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
@@ -274,12 +276,36 @@ export const Header = ({
<span>Theme: {theme.charAt(0).toUpperCase() + theme.slice(1)}</span>
</button>
<a
href="https://github.com/calibrain/calibre-web-automated-book-downloader/issues"
target="_blank"
rel="noopener noreferrer"
className="w-full text-left px-4 py-2 hover-surface transition-colors flex items-center gap-3 text-slate-700 dark:text-slate-200"
title="Submit a bug report"
>
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5"
/>
</svg>
<span>Report a Bug</span>
</a>
{/* Debug Buttons */}
{debug && (
<>
<form action="/debug" method="get" className="w-full">
<button
className="w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-3 text-orange-600 dark:text-orange-400"
className="w-full text-left px-4 py-2 hover-surface transition-colors flex items-center gap-3 text-orange-600 dark:text-orange-400"
type="submit"
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
@@ -290,7 +316,7 @@ export const Header = ({
</form>
<form action="/api/restart" method="get" className="w-full">
<button
className="w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-3 text-orange-600 dark:text-orange-400"
className="w-full text-left px-4 py-2 hover-surface transition-colors flex items-center gap-3 text-orange-600 dark:text-orange-400"
type="submit"
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
@@ -307,7 +333,7 @@ export const Header = ({
<button
type="button"
onClick={handleLogout}
className="w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-3 text-red-600 dark:text-red-400"
className="w-full text-left px-4 py-2 hover-surface transition-colors flex items-center gap-3 text-red-600 dark:text-red-400"
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
@@ -356,77 +382,15 @@ export const Header = ({
className="hidden lg:block h-12 w-12 flex-shrink-0 cursor-pointer"
/>
)}
<div className="relative flex-1 lg:flex-initial">
<input
type="search"
placeholder="Search by ISBN, title, author..."
aria-label="Search books"
autoComplete="off"
enterKeyHint="search"
className="w-full lg:w-[50vw] pl-4 pr-28 py-3 rounded-full border outline-none search-input"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
value={searchInput}
onChange={(e) => onSearchChange?.(e.target.value)}
onKeyDown={handleKeyDown}
/>
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-2">
<button
type="button"
onClick={onAdvancedToggle}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center justify-center transition-colors"
aria-label="Advanced Search"
title="Advanced Search"
>
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
style={{ color: 'var(--text)' }}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
/>
</svg>
</button>
<button
type="button"
onClick={onSearch}
className="p-2 rounded-full text-white bg-sky-700 hover:bg-sky-800 disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center transition-colors"
aria-label="Search books"
title="Search"
disabled={isLoading}
>
{!isLoading && (
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
/>
</svg>
)}
{isLoading && (
<div className="spinner w-3 h-3 border-2 border-white border-t-transparent" />
)}
</button>
</div>
</div>
<SearchBar
className="flex-1 lg:flex-initial"
inputClassName="lg:w-[50vw]"
value={searchInput}
onChange={handleSearchChange}
onSubmit={handleHeaderSearch}
onAdvancedToggle={onAdvancedToggle}
isLoading={isLoading}
/>
</div>
</div>
)}
+1 -1
View File
@@ -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 ? <EyeSlashIcon /> : <EyeIcon />}
@@ -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 (
<section id="results-section" className="mb-4 sm:mb-8 w-full">
<div className="flex items-center justify-between mb-2 sm:mb-3">
<div className="flex items-center justify-between mb-2 sm:mb-3 relative z-10">
<SortControl value={sortValue} onChange={onSortChange} />
{/* 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) => {
<button
type="button"
onClick={toggle}
className={`relative flex items-center gap-2 px-3 py-2 rounded-full transition-all duration-200 text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-700 ${
className={`relative flex items-center gap-2 px-3 py-2 rounded-full transition-all duration-200 text-gray-900 dark:text-gray-100 hover-action ${
isOpen ? 'bg-gray-100 dark:bg-gray-700' : ''
} animate-fade-in-up`}
aria-haspopup="listbox"
@@ -231,7 +230,7 @@ const SortControl = ({ value, onChange }: SortControlProps) => {
<button
type="button"
key={option.value || 'default'}
className={`w-full px-3 py-2 text-left text-base flex items-center justify-between gap-2 hover:bg-gray-100 dark:hover:bg-gray-800 ${
className={`w-full px-3 py-2 text-left text-base flex items-center justify-between gap-2 hover-surface ${
isSelected ? 'text-sky-600 dark:text-sky-300 font-medium' : ''
}`}
onClick={() => {
+174
View File
@@ -0,0 +1,174 @@
import { KeyboardEvent, InputHTMLAttributes, useRef } from 'react';
interface SearchBarProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
isLoading?: boolean;
onAdvancedToggle?: () => void;
placeholder?: string;
inputAriaLabel?: string;
className?: string;
inputClassName?: string;
controlsClassName?: string;
clearButtonLabel?: string;
clearButtonTitle?: string;
advancedButtonLabel?: string;
advancedButtonTitle?: string;
searchButtonLabel?: string;
searchButtonTitle?: string;
autoComplete?: string;
enterKeyHint?: InputHTMLAttributes<HTMLInputElement>['enterKeyHint'];
}
export const SearchBar = ({
value,
onChange,
onSubmit,
isLoading = false,
onAdvancedToggle,
placeholder = 'Search by ISBN, title, author...',
inputAriaLabel = 'Search books',
className = '',
inputClassName = '',
controlsClassName = '',
clearButtonLabel = 'Clear search input',
clearButtonTitle = 'Clear search',
advancedButtonLabel = 'Advanced Search',
advancedButtonTitle = 'Advanced Search',
searchButtonLabel = 'Search books',
searchButtonTitle = 'Search',
autoComplete = 'off',
enterKeyHint = 'search',
}: SearchBarProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const hasSearchQuery = value.trim().length > 0;
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onSubmit();
(e.target as HTMLInputElement).blur();
}
};
const handleClearSearch = () => {
onChange('');
inputRef.current?.focus();
};
const wrapperClasses = ['relative', className].filter(Boolean).join(' ').trim();
const inputClasses = [
'w-full pl-4 pr-40 py-3 rounded-full border outline-none search-input',
inputClassName,
]
.filter(Boolean)
.join(' ')
.trim();
const controlsClasses = [
'absolute inset-y-0 right-0 flex items-center gap-1 pr-2',
controlsClassName,
]
.filter(Boolean)
.join(' ')
.trim();
return (
<div className={wrapperClasses}>
<input
type="search"
placeholder={placeholder}
aria-label={inputAriaLabel}
autoComplete={autoComplete}
enterKeyHint={enterKeyHint}
className={inputClasses}
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
value={value}
onChange={e => onChange(e.target.value)}
onKeyDown={handleKeyDown}
ref={inputRef}
/>
<div className={controlsClasses}>
{hasSearchQuery && (
<button
type="button"
onClick={handleClearSearch}
className="p-2 rounded-full hover-action flex items-center justify-center transition-colors"
aria-label={clearButtonLabel}
title={clearButtonTitle}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
className="w-5 h-5"
style={{ color: 'var(--text)' }}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
)}
{onAdvancedToggle && (
<button
type="button"
onClick={onAdvancedToggle}
className="p-2 rounded-full hover-action flex items-center justify-center transition-colors"
aria-label={advancedButtonLabel}
title={advancedButtonTitle}
>
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
style={{ color: 'var(--text)' }}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
/>
</svg>
</button>
)}
<button
type="button"
onClick={onSubmit}
className="p-2 rounded-full text-white bg-sky-700 hover:bg-sky-800 disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center transition-colors search-bar-button"
aria-label={searchButtonLabel}
title={searchButtonTitle}
disabled={isLoading}
>
{!isLoading && (
<svg
className="w-5 h-5 search-bar-icon"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
/>
</svg>
)}
{isLoading && (
<div className="spinner w-3 h-3 border-2 border-white border-t-transparent search-bar-spinner" />
)}
</button>
</div>
</div>
);
};
+27 -229
View File
@@ -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 (
<section
id="search-section"
@@ -102,186 +63,23 @@ export const SearchSection = ({
<div className={`flex flex-col gap-3 search-wrapper transition-all duration-500 ${
isInitialState ? '' : 'hidden'
}`}>
<div className="relative">
<input
type="search"
placeholder="Search by ISBN, title, author..."
aria-label="Search books"
autoComplete="off"
enterKeyHint="search"
className="w-full pl-4 pr-28 py-3 rounded-full border outline-none search-input"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
value={searchInput}
onChange={e => onSearchInputChange(e.target.value)}
onKeyDown={handleKeyDown}
/>
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-2">
<button
type="button"
onClick={onAdvancedToggle}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center justify-center transition-colors"
aria-label="Advanced Search"
title="Advanced Search"
>
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
style={{ color: 'var(--text)' }}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
/>
</svg>
</button>
<button
type="button"
onClick={handleSearch}
className="p-2 rounded-full text-white bg-sky-700 hover:bg-sky-800 disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center transition-colors"
aria-label="Search books"
title="Search"
disabled={isLoading}
>
{!isLoading && (
<svg
id="search-icon"
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="2"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
/>
</svg>
)}
{isLoading && (
<div
id="search-spinner"
className="spinner w-3 h-3 border-2 border-white border-t-transparent"
/>
)}
</button>
</div>
</div>
{/* Advanced Filters */}
<form
id="search-filters"
className={`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2 ${
showAdvanced ? '' : 'hidden'
}`}
>
<div>
<label htmlFor="isbn-input" className="block text-sm mb-1 opacity-80">
ISBN
</label>
<input
id="isbn-input"
type="text"
placeholder="ISBN"
autoComplete="off"
className="w-full px-3 py-2 rounded-md border"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
value={isbn}
onChange={e => onAdvancedFiltersChange({ isbn: e.target.value })}
/>
</div>
<div>
<label htmlFor="author-input" className="block text-sm mb-1 opacity-80">
Author
</label>
<input
id="author-input"
type="text"
placeholder="Author"
autoComplete="off"
className="w-full px-3 py-2 rounded-md border"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
value={author}
onChange={e => onAdvancedFiltersChange({ author: e.target.value })}
/>
</div>
<div>
<label htmlFor="title-input" className="block text-sm mb-1 opacity-80">
Title
</label>
<input
id="title-input"
type="text"
placeholder="Title"
autoComplete="off"
className="w-full px-3 py-2 rounded-md border"
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
value={title}
onChange={e => onAdvancedFiltersChange({ title: e.target.value })}
/>
</div>
<LanguageMultiSelect
options={bookLanguages}
value={lang}
onChange={handleLanguageChange}
defaultLanguageCodes={defaultLanguage}
label="Language"
/>
<DropdownList
label="Content"
options={CONTENT_OPTIONS}
value={content}
onChange={handleContentChange}
placeholder="All"
/>
<div className="md:col-span-2 lg:col-span-3">
<label className="block text-sm mb-1 opacity-80">Formats</label>
<div className="flex flex-wrap gap-3 text-sm">
{['pdf', 'epub', 'mobi', 'azw3', 'fb2', 'djvu', 'cbz', 'cbr'].map(format => {
const isSupported = supportedFormats.includes(format);
return (
<label
key={format}
className={`inline-flex items-center gap-2 ${
!isSupported ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
<input
type="checkbox"
value={format}
checked={formats.includes(format)}
onChange={() => toggleFormat(format)}
disabled={!isSupported}
/>
{format.toUpperCase()}
</label>
);
})}
</div>
</div>
</form>
<SearchBar
value={searchInput}
onChange={onSearchInputChange}
onSubmit={handleSearch}
isLoading={isLoading}
onAdvancedToggle={onAdvancedToggle}
/>
<AdvancedFilters
visible={showAdvanced}
bookLanguages={bookLanguages}
defaultLanguage={defaultLanguage}
supportedFormats={supportedFormats}
filters={advancedFilters}
onFiltersChange={onAdvancedFiltersChange}
formClassName="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2"
renderWrapper={form => form}
/>
</div>
</section>
);
@@ -1,202 +0,0 @@
import { StatusData } from '../types';
interface StatusBadgeProps {
status: string;
}
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
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 (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${style.bg} ${style.text}`}
>
{style.label}
</span>
);
};
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<string, any>) => {
if (!items || Object.keys(items).length === 0) return null;
return (
<div key={name}>
<h4 className="font-semibold mb-2">
{name.charAt(0).toUpperCase() + name.slice(1)}
</h4>
<ul className="space-y-2">
{Object.values(items).map((book: any) => {
const maybeLinkedTitle = book.download_path ? (
<a
href={`/api/localdownload?id=${encodeURIComponent(book.id)}`}
className="text-blue-600 hover:underline"
>
{book.title || '-'}
</a>
) : (
book.title || '-'
);
const actions =
name === 'queued' || name === 'resolving' || name === 'bypassing' || name === 'downloading' || name === 'verifying' || name === 'ingesting' ? (
<button
className="px-2 py-1 rounded border text-xs"
style={{ borderColor: 'var(--border-muted)' }}
onClick={() => onCancel(book.id)}
>
Cancel
</button>
) : null;
const progress =
name === 'downloading' && typeof book.progress === 'number' ? (
<div className="h-2 bg-black/10 rounded overflow-hidden">
<div
className="h-2 bg-blue-600"
style={{ width: `${Math.round(book.progress)}%` }}
/>
</div>
) : null;
return (
<li
key={book.id}
className="p-3 rounded border flex flex-col gap-2"
style={{ borderColor: 'var(--border-muted)', background: 'var(--bg-soft)' }}
>
<div className="text-sm flex items-center gap-2">
<StatusBadge status={name} /> <strong>{maybeLinkedTitle}</strong>
</div>
{progress}
{actions && <div className="flex items-center gap-2">{actions}</div>}
</li>
);
})}
</ul>
</div>
);
};
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 (
<section id="status-section">
<div className="flex items-center flex-wrap mb-3">
<h2 className="text-xl font-semibold mr-4 sm:mr-6">Downloads</h2>
<div className="flex items-center gap-3 ml-4 sm:ml-auto">
<button
onClick={onRefresh}
className="px-3 py-1 rounded border text-sm"
style={{ borderColor: 'var(--border-muted)' }}
>
Refresh
</button>
<button
onClick={onClearCompleted}
className="px-3 py-1 rounded border text-sm"
style={{ borderColor: 'var(--border-muted)' }}
>
Clear Completed
</button>
<span className="text-sm opacity-80">Active: {activeCount}</span>
</div>
</div>
<div id="status-list" className="space-y-2">
{renderedSections.length > 0 ? (
renderedSections
) : (
<div className="text-sm opacity-80">No items.</div>
)}
</div>
</section>
);
};
@@ -18,13 +18,19 @@ export const ToastContainer = ({ toasts }: ToastContainerProps) => {
});
}, [toasts]);
const toastTypeClasses: Record<Toast['type'], string> = {
success: 'bg-green-600 text-white',
error: 'bg-red-600 text-white',
info: 'bg-blue-600 text-white',
};
return (
<div id="toast-container" className="fixed bottom-4 right-4 z-50 space-y-2">
{toasts.map(toast => (
<div
key={toast.id}
className={`toast-notification px-4 py-3 rounded-md shadow-lg text-sm font-medium transition-all duration-300 ${
toast.type === 'success' ? 'bg-green-600 text-white' : 'bg-blue-600 text-white'
toastTypeClasses[toast.type]
} ${visibleToasts.has(toast.id) ? 'toast-visible' : ''}`}
>
{toast.message}
+1 -1
View File
@@ -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';
@@ -31,7 +31,7 @@ export const CardView = ({ book, onDetails, onDownload, buttonState, animationDe
return (
<article
className="book-card overflow-hidden flex flex-col sm:flex-col max-sm:flex-row space-between w-full sm:max-w-[292px] max-sm:h-[180px] h-full transition-shadow duration-300 animate-slide-up"
className="book-card overflow-hidden flex flex-col sm:flex-col max-sm:flex-row space-between w-full sm:max-w-[292px] max-sm:h-[180px] h-full transition-shadow duration-300 animate-slide-up will-change-transform"
style={{
background: 'var(--bg-soft)',
borderRadius: '.75rem',
@@ -32,7 +32,7 @@ export const CompactView = ({ book, onDetails, onDownload, buttonState, showDeta
return (
<article
className="book-card overflow-hidden !flex !flex-row w-full !h-[180px] transition-shadow duration-300 animate-slide-up"
className="book-card overflow-hidden !flex !flex-row w-full !h-[180px] transition-shadow duration-300 animate-slide-up will-change-transform"
style={{
background: 'var(--bg-soft)',
borderRadius: '.75rem',
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { Book, ButtonStateInfo } from '../../types';
import { CircularProgress } from '../CircularProgress';
import { BookDownloadButton } from '../BookDownloadButton';
interface ListViewProps {
books: Book[];
@@ -9,88 +9,6 @@ interface ListViewProps {
getButtonState: (bookId: string) => ButtonStateInfo;
}
interface ListViewDownloadButtonProps {
buttonState: ButtonStateInfo;
onDownload: () => Promise<void>;
}
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 (
<button
className={`${baseClasses} ${stateClasses}`}
onClick={handleDownload}
disabled={isDisabled || isInProgress}
data-action="download"
aria-label={buttonState.text}
>
{isCompleted ? (
<svg className="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : hasError ? (
<svg className="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
) : showCircularProgress ? (
<>
<CircularProgress
progress={buttonState.progress}
size={16}
className="block sm:hidden"
/>
<CircularProgress
progress={buttonState.progress}
size={20}
className="hidden sm:block"
/>
</>
) : showSpinner ? (
<div className="w-4 h-4 sm:w-5 sm:h-5 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<svg className="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
)}
</button>
);
};
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 (
<div
key={book.id}
className="px-1.5 sm:px-2 py-1.5 sm:py-2 transition-colors duration-200 hover:bg-white/60 dark:hover:bg-gray-800/40 w-full animate-slide-up"
className="px-1.5 sm:px-2 py-1.5 sm:py-2 transition-colors duration-200 hover-row w-full animate-slide-up will-change-transform"
style={{
animationDelay: `${index * 50}ms`,
animationFillMode: 'both',
@@ -265,7 +183,7 @@ export const ListView = ({ books, onDetails, onDownload, getButtonState }: ListV
{/* Action Buttons */}
<div className="flex flex-row justify-end gap-0.5 sm:gap-1">
<button
className="flex items-center justify-center p-1.5 sm:p-2 rounded-full text-gray-600 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200"
className="flex items-center justify-center p-1.5 sm:p-2 rounded-full text-gray-600 dark:text-gray-200 hover-action transition-all duration-200"
onClick={() => handleDetails(book.id)}
disabled={isLoadingDetails}
aria-label={`View details for ${book.title || 'this book'}`}
@@ -278,9 +196,12 @@ export const ListView = ({ books, onDetails, onDownload, getButtonState }: ListV
</svg>
)}
</button>
<ListViewDownloadButton
<BookDownloadButton
buttonState={buttonState}
onDownload={() => onDownload(book)}
variant="icon"
size="md"
ariaLabel={buttonState.text}
/>
</div>
</div>
+42 -3
View File
@@ -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;
}
/* ============================================
+1
View File
@@ -10,6 +10,7 @@ export interface Book {
preview?: string;
publisher?: string;
info?: Record<string, string | string[]>;
description?: string;
download_path?: string;
progress?: number;
}
@@ -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('&');
};