mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 22:05:50 +01:00
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:
+13
-24
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
|
||||
@@ -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('&');
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user