Restructure + abstraction, plugin system, settings UI, universal search mode (#351)

Key changes:   

| Category | Lines | What it is |

|--------------------------|--------|----------------------------------------------------------------------|
| Docs | ~2,100 | plugin-settings.md, release-sources-plugin-guide.md,
provider README |
| Settings UI | ~1,650 | Modal, sidebar, field components (TextField,
SelectField, etc.) |
| ReleaseModal | ~1,200 | Universal mode release picker UI |
| Metadata Providers | ~2,100 | Hardcover + OpenLibrary + base classes |
| Core Infrastructure | ~2,150 | Cache decorator, queue, image cache,
models, config |
| main.py | ~1,570 | Flask routes (replaces old app.py but bigger) |
| Orchestrator | ~590 | Download queue management |
| Config/Settings Registry | ~1,400 | Backend settings system |
| Frontend Hooks | ~750 | useSettings, useSearch, useDownloadTracking,
etc. |
| Other Frontend | ~500 | BookGetButton, ReleaseCell, utils |
| Release Sources base | ~320 | Plugin interfaces |
This commit is contained in:
Alex
2025-12-22 12:13:11 -05:00
committed by GitHub
parent 15a61a5191
commit a4173eafcb
114 changed files with 17382 additions and 3279 deletions
+360 -251
View File
@@ -1,82 +1,130 @@
import { useState, useEffect, useCallback, useRef, CSSProperties } from 'react';
import { Navigate, Route, Routes, useNavigate } from 'react-router-dom';
import { useState, useEffect, useCallback, useRef, useMemo, CSSProperties } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import {
Book,
Release,
StatusData,
ButtonStateInfo,
AppConfig,
LoginCredentials,
AdvancedFilterState,
} from './types';
import { searchBooks, getBookInfo, downloadBook, cancelDownload, clearCompleted, getConfig, login, logout, checkAuth, AuthenticationError } from './services/api';
import { getBookInfo, getMetadataBookInfo, downloadBook, downloadRelease, cancelDownload, clearCompleted, getConfig } from './services/api';
import { useToast } from './hooks/useToast';
import { useRealtimeStatus } from './hooks/useRealtimeStatus';
import { useAuth } from './hooks/useAuth';
import { useSearch } from './hooks/useSearch';
import { useUrlSearch } from './hooks/useUrlSearch';
import { useDownloadTracking } from './hooks/useDownloadTracking';
import { Header } from './components/Header';
import { SearchSection } from './components/SearchSection';
import { AdvancedFilters } from './components/AdvancedFilters';
import { ResultsSection } from './components/ResultsSection';
import { DetailsModal } from './components/DetailsModal';
import { ReleaseModal } from './components/ReleaseModal';
import { DownloadsSidebar } from './components/DownloadsSidebar';
import { ToastContainer } from './components/ToastContainer';
import { Footer } from './components/Footer';
import { LoginPage } from './pages/LoginPage';
import { SettingsModal } from './components/settings';
import { ConfigSetupBanner } from './components/ConfigSetupBanner';
import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages';
import { LANGUAGE_OPTION_DEFAULT } from './utils/languageFilters';
import { buildSearchQuery } from './utils/buildSearchQuery';
import { SearchModeProvider } from './contexts/SearchModeContext';
import './styles.css';
const DEFAULT_FORMAT_SELECTION = DEFAULT_SUPPORTED_FORMATS.filter(format => format !== 'pdf');
function App() {
// Authentication state
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [authRequired, setAuthRequired] = useState<boolean>(true);
const [authChecked, setAuthChecked] = useState<boolean>(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [isLoggingIn, setIsLoggingIn] = useState<boolean>(false);
const navigate = useNavigate();
const [books, setBooks] = useState<Book[]>([]);
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [config, setConfig] = useState<AppConfig | null>(null);
const [searchInput, setSearchInput] = useState('');
const [showAdvanced, setShowAdvanced] = useState(false);
const [downloadsSidebarOpen, setDownloadsSidebarOpen] = useState(false);
const [lastSearchQuery, setLastSearchQuery] = useState('');
const [advancedFilters, setAdvancedFilters] = useState<AdvancedFilterState>({
isbn: '',
author: '',
title: '',
lang: [LANGUAGE_OPTION_DEFAULT],
sort: '',
content: '',
formats: DEFAULT_FORMAT_SELECTION,
});
const { toasts, showToast, removeToast } = useToast();
const updateAdvancedFilters = useCallback((updates: Partial<AdvancedFilterState>) => {
setAdvancedFilters(prev => ({ ...prev, ...updates }));
}, []);
// Determine WebSocket URL based on current location
// In production, use the same origin as the page; in dev, use localhost
// WebSocket URL based on current location
const wsUrl = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
? 'http://localhost:8084'
: window.location.origin;
// Use realtime status with WebSocket and polling fallback
const {
status: currentStatus,
// Realtime status with WebSocket and polling fallback
const {
status: currentStatus,
isUsingWebSocket,
forceRefresh: fetchStatus
forceRefresh: fetchStatus
} = useRealtimeStatus({
wsUrl,
pollInterval: 5000,
reconnectAttempts: 3,
});
// Calculate status counts for header badges
const getStatusCounts = () => {
// Download tracking for universal mode
const {
bookToReleaseMap,
trackRelease,
markBookCompleted,
clearTracking,
getButtonState,
getUniversalButtonState,
} = useDownloadTracking(currentStatus);
// Authentication state and handlers
// Initialized first since search hook needs auth state
const {
isAuthenticated,
authRequired,
authChecked,
loginError,
isLoggingIn,
setIsAuthenticated,
handleLogin,
handleLogout,
} = useAuth({
showToast,
});
// Search state and handlers
const {
books,
setBooks,
isSearching,
searchInput,
setSearchInput,
showAdvanced,
setShowAdvanced,
advancedFilters,
setAdvancedFilters,
updateAdvancedFilters,
handleSearch,
handleResetSearch,
handleSortChange,
resetSortFilter,
searchFieldValues,
updateSearchFieldValue,
} = useSearch({
showToast,
setIsAuthenticated,
authRequired,
onSearchReset: clearTracking,
});
// Wire up logout callback to clear search state
const handleLogoutWithCleanup = useCallback(async () => {
await handleLogout();
setBooks([]);
clearTracking();
}, [handleLogout, setBooks, clearTracking]);
// UI state
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
const [releaseBook, setReleaseBook] = useState<Book | null>(null);
const [config, setConfig] = useState<AppConfig | null>(null);
const [downloadsSidebarOpen, setDownloadsSidebarOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [configBannerOpen, setConfigBannerOpen] = useState(false);
// URL-based search: parse URL params for automatic search on page load
const urlSearchEnabled = isAuthenticated && config !== null;
const { parsedParams, wasProcessed } = useUrlSearch({ enabled: urlSearchEnabled });
const urlSearchExecutedRef = useRef(false);
// Track previous status and search mode for change detection
const prevStatusRef = useRef<StatusData>({});
const prevSearchModeRef = useRef<string | undefined>(undefined);
// Calculate status counts for header badges (memoized)
const statusCounts = useMemo(() => {
const ongoing = [
currentStatus.queued,
currentStatus.resolving,
@@ -90,9 +138,8 @@ function App() {
const errored = currentStatus.error ? Object.keys(currentStatus.error).length : 0;
return { ongoing, completed, errored };
};
}, [currentStatus]);
const statusCounts = getStatusCounts();
const activeCount = statusCounts.ongoing;
// Compute visibility states
@@ -133,6 +180,13 @@ function App() {
if (prevDownloadingIds.has(bookId) || prevQueuedIds.has(bookId)) {
const book = currComplete[bookId];
showToast(`${book.title || 'Book'} completed`, 'success');
// Track completed release IDs in session state for universal mode
Object.entries(bookToReleaseMap).forEach(([metadataBookId, releaseIds]) => {
if (releaseIds.includes(bookId)) {
markBookCompleted(metadataBookId);
}
});
}
});
@@ -145,72 +199,7 @@ function App() {
showToast(`${book.title || 'Book'}: ${errorMsg}`, 'error');
}
});
}, [showToast]);
// Track previous status for change detection
const prevStatusRef = useRef<StatusData>({});
// Check authentication on mount
useEffect(() => {
const verifyAuth = async () => {
try {
const response = await checkAuth();
const authenticated = response.authenticated || false;
const authIsRequired = response.auth_required !== false; // Default to true if undefined
setAuthRequired(authIsRequired);
setIsAuthenticated(authenticated);
} catch (error) {
console.error('Auth check failed:', error);
// On error, assume auth is required and user is not authenticated
setAuthRequired(true);
setIsAuthenticated(false);
} finally {
setAuthChecked(true);
}
};
verifyAuth();
}, []);
// Authentication handlers
const handleLogin = async (credentials: LoginCredentials) => {
setIsLoggingIn(true);
setLoginError(null);
try {
const response = await login(credentials);
if (response.success) {
setIsAuthenticated(true);
setLoginError(null);
navigate('/', { replace: true });
} else {
setLoginError(response.error || 'Login failed');
}
} catch (error) {
if (error instanceof Error) {
setLoginError(error.message || 'Login failed');
} else {
setLoginError('Login failed');
}
} finally {
setIsLoggingIn(false);
}
};
const handleLogout = async () => {
try {
await logout();
setIsAuthenticated(false);
// Clear application state
setBooks([]);
setSelectedBook(null);
setSearchInput('');
setLastSearchQuery('');
navigate('/login', { replace: true });
} catch (error) {
console.error('Logout failed:', error);
showToast('Logout failed', 'error');
}
};
}, [showToast, bookToReleaseMap, markBookCompleted]);
// Detect status changes when currentStatus updates
useEffect(() => {
@@ -220,32 +209,119 @@ function App() {
prevStatusRef.current = currentStatus;
}, [currentStatus, detectChanges]);
// Fetch config on mount and when authentication changes
useEffect(() => {
const loadConfig = async () => {
try {
const cfg = await getConfig();
setConfig(cfg);
// Update format selection to match supported formats from config
// This ensures PDF is auto-selected when added to SUPPORTED_FORMATS env var
if (cfg?.supported_formats) {
// Load config function
const loadConfig = useCallback(async (mode: 'initial' | 'settings-saved' = 'initial') => {
try {
const cfg = await getConfig();
// Check if search mode changed (only on settings save)
if (mode === 'settings-saved' && prevSearchModeRef.current !== cfg.search_mode) {
setBooks([]);
setSelectedBook(null);
resetSortFilter();
clearTracking();
}
prevSearchModeRef.current = cfg.search_mode;
setConfig(cfg);
if (cfg?.supported_formats) {
if (mode === 'initial') {
setAdvancedFilters(prev => ({
...prev,
formats: cfg.supported_formats,
}));
} else if (mode === 'settings-saved') {
setAdvancedFilters(prev => ({
...prev,
formats: prev.formats.filter(f => cfg.supported_formats.includes(f)),
}));
}
} catch (error) {
console.error('Failed to load config:', error);
// Use defaults if config fails to load
}
};
// Only fetch config if authenticated (or auth is not required)
if (isAuthenticated) {
loadConfig();
} catch (error) {
console.error('Failed to load config:', error);
}
}, [isAuthenticated]);
}, [setBooks, setAdvancedFilters, resetSortFilter, clearTracking]);
// Log WebSocket connection status changes
// Fetch config when authenticated
useEffect(() => {
if (isAuthenticated) {
loadConfig('initial');
}
}, [isAuthenticated, loadConfig]);
// Execute URL-based search when params are present
useEffect(() => {
if (
wasProcessed &&
parsedParams?.hasSearchParams &&
!urlSearchExecutedRef.current &&
config
) {
urlSearchExecutedRef.current = true;
const searchMode = config.search_mode || 'direct';
const bookLanguages = config.book_languages || [];
const defaultLanguageCodes =
config.default_language && config.default_language.length > 0
? config.default_language
: [bookLanguages[0]?.code || 'en'];
// Populate search input from URL
if (parsedParams.searchInput) {
setSearchInput(parsedParams.searchInput);
}
// Apply advanced filters from URL
if (Object.keys(parsedParams.advancedFilters).length > 0) {
setAdvancedFilters(prev => ({
...prev,
...parsedParams.advancedFilters,
}));
// Show advanced panel if we have filter values (not just query/sort)
const hasAdvancedValues = ['isbn', 'author', 'title', 'content'].some(
key => parsedParams.advancedFilters[key as keyof typeof parsedParams.advancedFilters]
);
if (hasAdvancedValues) {
setShowAdvanced(true);
}
}
// Build query and trigger search
const mergedFilters = {
...advancedFilters,
...parsedParams.advancedFilters,
};
const query = buildSearchQuery({
searchInput: parsedParams.searchInput,
showAdvanced: true,
advancedFilters: mergedFilters as typeof advancedFilters,
bookLanguages,
defaultLanguage: defaultLanguageCodes,
searchMode,
});
handleSearch(query, config, searchFieldValues);
}
}, [
wasProcessed,
parsedParams,
config,
advancedFilters,
searchFieldValues,
handleSearch,
setSearchInput,
setAdvancedFilters,
setShowAdvanced,
]);
const handleSettingsSaved = useCallback(() => {
loadConfig('settings-saved');
}, [loadConfig]);
// Log WebSocket connection status
useEffect(() => {
if (isUsingWebSocket) {
console.log('✅ Using WebSocket for real-time updates');
@@ -254,67 +330,52 @@ function App() {
}
}, [isUsingWebSocket]);
// Fetch status immediately on startup
// Fetch status on startup
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
// Search handler
const handleSearch = async (query: string) => {
if (!query) {
setBooks([]);
setLastSearchQuery('');
return;
}
setIsSearching(true);
setLastSearchQuery(query);
try {
const results = await searchBooks(query);
setBooks(results);
if (results.length === 0) {
showToast('No results found', 'error');
// Show book details
const handleShowDetails = async (id: string): Promise<void> => {
const metadataBook = books.find(b => b.id === id && b.provider && b.provider_id);
if (metadataBook) {
try {
const fullBook = await getMetadataBookInfo(metadataBook.provider!, metadataBook.provider_id!);
setSelectedBook({
...metadataBook,
description: fullBook.description || metadataBook.description,
});
} catch (error) {
console.error('Failed to load book description, using search data:', error);
setSelectedBook(metadataBook);
}
} catch (error) {
if (error instanceof AuthenticationError) {
setIsAuthenticated(false);
if (authRequired) {
navigate('/login', { replace: true });
}
} else {
console.error('Search failed:', error);
setBooks([]);
const message = error instanceof Error ? error.message : 'Search failed';
const friendly = message.includes("Anna's Archive") || message.includes('Network restricted')
? message
: "Unable to reach Anna's Archive. Network may be restricted or mirrors blocked.";
showToast(friendly, 'error');
} else {
try {
const book = await getBookInfo(id);
setSelectedBook(book);
} catch (error) {
console.error('Failed to load book details:', error);
showToast('Failed to load book details', 'error');
}
} finally {
setIsSearching(false);
}
};
// Show book details
const handleShowDetails = async (id: string): Promise<void> => {
try {
const book = await getBookInfo(id);
setSelectedBook(book);
} catch (error) {
console.error('Failed to load book details:', error);
showToast('Failed to load book details', 'error');
}
// Handle "Find Downloads" from DetailsModal
const handleFindDownloads = (book: Book) => {
setSelectedBook(null);
setReleaseBook(book);
};
// Download book
const handleDownload = async (book: Book): Promise<void> => {
try {
await downloadBook(book.id);
// Fetch status to update button states (detectChanges will show toast)
await fetchStatus();
} catch (error) {
console.error('Download failed:', error);
showToast('Failed to queue download', 'error');
throw error; // Re-throw so button components can reset their queuing state
throw error;
}
};
@@ -338,68 +399,51 @@ function App() {
}
};
// Reset search state (clear books and search input)
const handleResetSearch = () => {
setBooks([]);
setSearchInput('');
setShowAdvanced(false);
setLastSearchQuery('');
// Use config's supported formats if available, otherwise fall back to default
const resetFormats = config?.supported_formats || DEFAULT_FORMAT_SELECTION;
setAdvancedFilters({
isbn: '',
author: '',
title: '',
lang: [LANGUAGE_OPTION_DEFAULT],
sort: '',
content: '',
formats: resetFormats,
});
};
const handleSortChange = (value: string) => {
updateAdvancedFilters({ sort: value });
if (!lastSearchQuery) return;
const params = new URLSearchParams(lastSearchQuery);
if (value) {
params.set('sort', value);
// Open release modal
const handleGetReleases = async (book: Book) => {
if (book.provider && book.provider_id) {
try {
const fullBook = await getMetadataBookInfo(book.provider, book.provider_id);
setReleaseBook({
...book,
description: fullBook.description || book.description,
});
} catch (error) {
console.error('Failed to load book description, using search data:', error);
setReleaseBook(book);
}
} else {
params.delete('sort');
setReleaseBook(book);
}
const nextQuery = params.toString();
if (!nextQuery) return;
handleSearch(nextQuery);
};
// Get button state for a book - memoized to ensure proper re-renders when status changes
const getButtonState = useCallback((bookId: string): ButtonStateInfo => {
// Check error first
if (currentStatus.error && currentStatus.error[bookId]) {
return { text: 'Failed', state: 'error' };
// Handle download from ReleaseModal
const handleReleaseDownload = async (book: Book, release: Release) => {
try {
trackRelease(book.id, release.source_id);
await downloadRelease({
source: release.source,
source_id: release.source_id,
title: release.title,
format: release.format,
size: release.size,
size_bytes: release.size_bytes,
download_url: release.download_url,
protocol: release.protocol,
indexer: release.indexer,
seeders: release.seeders,
extra: release.extra,
preview: book.preview, // Pass book cover from metadata
author: book.author, // Pass author from metadata
});
await fetchStatus();
} catch (error) {
console.error('Release download failed:', error);
showToast('Failed to queue download', 'error');
throw error;
}
// Check completed
if (currentStatus.complete && currentStatus.complete[bookId]) {
return { text: 'Downloaded', state: 'complete' };
}
// Check in-progress states
if (currentStatus.downloading && currentStatus.downloading[bookId]) {
const book = currentStatus.downloading[bookId];
return {
text: 'Downloading',
state: 'downloading',
progress: book.progress
};
}
if (currentStatus.resolving && currentStatus.resolving[bookId]) {
return { text: 'Resolving', state: 'resolving' };
}
if (currentStatus.queued && currentStatus.queued[bookId]) {
return { text: 'Queued', state: 'queued' };
}
return { text: 'Download', state: 'download' };
}, [currentStatus]);
};
const bookLanguages = config?.book_languages || DEFAULT_LANGUAGES;
const supportedFormats = config?.supported_formats || DEFAULT_SUPPORTED_FORMATS;
@@ -408,21 +452,30 @@ function App() {
? config.default_language
: [bookLanguages[0]?.code || 'en'];
const searchMode = config?.search_mode || 'direct';
const mainAppContent = (
<>
<Header
calibreWebUrl={config?.calibre_web_url || ''}
<SearchModeProvider searchMode={searchMode}>
<Header
calibreWebUrl={config?.calibre_web_url || ''}
debug={config?.debug || false}
logoUrl="/logo.png"
showSearch={!isInitialState}
searchInput={searchInput}
onSearchChange={setSearchInput}
onDownloadsClick={() => setDownloadsSidebarOpen(true)}
onSettingsClick={() => {
if (config?.settings_enabled) {
setSettingsOpen(true);
} else {
setConfigBannerOpen(true);
}
}}
statusCounts={statusCounts}
onLogoClick={handleResetSearch}
onLogoClick={() => handleResetSearch(config)}
authRequired={authRequired}
isAuthenticated={isAuthenticated}
onLogout={handleLogout}
onLogout={handleLogoutWithCleanup}
onSearch={() => {
const query = buildSearchQuery({
searchInput,
@@ -430,15 +483,16 @@ function App() {
advancedFilters,
bookLanguages,
defaultLanguage: defaultLanguageCodes,
searchMode,
});
handleSearch(query);
handleSearch(query, config, searchFieldValues);
}}
onAdvancedToggle={() => setShowAdvanced(!showAdvanced)}
isLoading={isSearching}
onShowToast={showToast}
onRemoveToast={removeToast}
/>
<AdvancedFilters
visible={showAdvanced && !isInitialState}
bookLanguages={bookLanguages}
@@ -446,11 +500,25 @@ function App() {
supportedFormats={supportedFormats}
filters={advancedFilters}
onFiltersChange={updateAdvancedFilters}
metadataSearchFields={config?.metadata_search_fields}
searchFieldValues={searchFieldValues}
onSearchFieldChange={updateSearchFieldValue}
onSubmit={() => {
const query = buildSearchQuery({
searchInput,
showAdvanced,
advancedFilters,
bookLanguages,
defaultLanguage: defaultLanguageCodes,
searchMode,
});
handleSearch(query, config, searchFieldValues);
}}
/>
<main className="w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-6">
<SearchSection
onSearch={handleSearch}
onSearch={(query) => handleSearch(query, config, searchFieldValues)}
isLoading={isSearching}
isInitialState={isInitialState}
bookLanguages={bookLanguages}
@@ -461,8 +529,11 @@ function App() {
onSearchInputChange={setSearchInput}
showAdvanced={showAdvanced}
onAdvancedToggle={() => setShowAdvanced(!showAdvanced)}
advancedFilters={advancedFilters}
onAdvancedFiltersChange={updateAdvancedFilters}
advancedFilters={advancedFilters}
onAdvancedFiltersChange={updateAdvancedFilters}
metadataSearchFields={config?.metadata_search_fields}
searchFieldValues={searchFieldValues}
onSearchFieldChange={updateSearchFieldValue}
/>
<ResultsSection
@@ -470,9 +541,12 @@ function App() {
visible={hasResults}
onDetails={handleShowDetails}
onDownload={handleDownload}
onGetReleases={handleGetReleases}
getButtonState={getButtonState}
getUniversalButtonState={getUniversalButtonState}
sortValue={advancedFilters.sort}
onSortChange={handleSortChange}
onSortChange={(value) => handleSortChange(value, config)}
metadataSortOptions={config?.metadata_sort_options}
/>
{selectedBook && (
@@ -480,20 +554,33 @@ function App() {
book={selectedBook}
onClose={() => setSelectedBook(null)}
onDownload={handleDownload}
onFindDownloads={handleFindDownloads}
buttonState={getButtonState(selectedBook.id)}
/>
)}
{releaseBook && (
<ReleaseModal
book={releaseBook}
onClose={() => setReleaseBook(null)}
onDownload={handleReleaseDownload}
supportedFormats={supportedFormats}
defaultLanguages={defaultLanguageCodes}
bookLanguages={bookLanguages}
currentStatus={currentStatus}
defaultReleaseSource={config?.default_release_source}
/>
)}
</main>
<Footer
buildVersion={config?.build_version}
releaseVersion={config?.release_version}
<Footer
buildVersion={config?.build_version}
releaseVersion={config?.release_version}
debug={config?.debug}
/>
<ToastContainer toasts={toasts} />
{/* Downloads Sidebar */}
<DownloadsSidebar
isOpen={downloadsSidebarOpen}
onClose={() => setDownloadsSidebarOpen(false)}
@@ -503,7 +590,29 @@ function App() {
onCancel={handleCancel}
activeCount={activeCount}
/>
</>
<SettingsModal
isOpen={settingsOpen}
onClose={() => setSettingsOpen(false)}
onShowToast={showToast}
onSettingsSaved={handleSettingsSaved}
/>
{/* Auto-show banner on startup for users without config */}
{config && (
<ConfigSetupBanner settingsEnabled={config.settings_enabled} />
)}
{/* Controlled banner shown when clicking settings without config */}
<ConfigSetupBanner
isOpen={configBannerOpen}
onClose={() => setConfigBannerOpen(false)}
onContinue={() => {
setConfigBannerOpen(false);
setSettingsOpen(true);
}}
/>
</SearchModeProvider>
);
const visuallyHiddenStyle: CSSProperties = {
@@ -1,9 +1,11 @@
import { ReactNode } from 'react';
import { AdvancedFilterState, Language } from '../types';
import { ReactNode, KeyboardEvent } from 'react';
import { AdvancedFilterState, Language, MetadataSearchField } from '../types';
import { normalizeLanguageSelection } from '../utils/languageFilters';
import { useSearchMode } from '../contexts/SearchModeContext';
import { LanguageMultiSelect } from './LanguageMultiSelect';
import { DropdownList } from './DropdownList';
import { CONTENT_OPTIONS } from '../data/filterOptions';
import { SearchFieldRenderer } from './shared';
const FORMAT_TYPES = ['pdf', 'epub', 'mobi', 'azw3', 'fb2', 'djvu', 'cbz', 'cbr'] as const;
@@ -16,6 +18,12 @@ interface AdvancedFiltersProps {
onFiltersChange: (updates: Partial<AdvancedFilterState>) => void;
formClassName?: string;
renderWrapper?: (form: ReactNode) => ReactNode;
// Universal mode props
metadataSearchFields?: MetadataSearchField[];
searchFieldValues?: Record<string, string | number | boolean>;
onSearchFieldChange?: (key: string, value: string | number | boolean) => void;
// Submit handler for Enter key
onSubmit?: () => void;
}
export const AdvancedFilters = ({
@@ -27,9 +35,21 @@ export const AdvancedFilters = ({
onFiltersChange,
formClassName,
renderWrapper,
metadataSearchFields = [],
searchFieldValues = {},
onSearchFieldChange,
onSubmit,
}: AdvancedFiltersProps) => {
const { searchMode } = useSearchMode();
const { isbn, author, title, lang, content, formats } = filters;
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && onSubmit) {
e.preventDefault();
onSubmit();
}
};
const handleLangChange = (next: string[]) => {
const normalized = normalizeLanguageSelection(next);
onFiltersChange({ lang: normalized });
@@ -53,6 +73,52 @@ export const AdvancedFilters = ({
if (!visible) return null;
// Universal search mode: render dynamic provider fields
if (searchMode === 'universal') {
// If no fields defined for this provider, don't show the section
if (metadataSearchFields.length === 0) return null;
const universalForm = (
<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]'
}
>
{metadataSearchFields.map((field) => (
<div key={field.key}>
{field.type !== 'CheckboxSearchField' && (
<label htmlFor={`${field.key}-input`} className="block text-sm mb-1 opacity-80">
{field.label}
</label>
)}
<SearchFieldRenderer
field={field}
value={searchFieldValues[field.key] ?? (field.type === 'CheckboxSearchField' ? false : '')}
onChange={(value) => onSearchFieldChange?.(field.key, value)}
onSubmit={onSubmit}
/>
{field.description && (
<p className="text-xs mt-1 opacity-60">{field.description}</p>
)}
</div>
))}
</form>
);
const wrappedUniversalForm = renderWrapper ? (
renderWrapper(universalForm)
) : (
<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">{universalForm}</div>
</div>
);
return wrappedUniversalForm;
}
// Direct download mode: render existing hardcoded filters
const form = (
<form
id="search-filters"
@@ -70,6 +136,7 @@ export const AdvancedFilters = ({
type="text"
placeholder="ISBN"
autoComplete="off"
enterKeyHint="search"
className="w-full px-3 py-2 rounded-md border"
style={{
background: 'var(--bg-soft)',
@@ -80,6 +147,7 @@ export const AdvancedFilters = ({
onChange={e => {
onFiltersChange({ isbn: e.target.value });
}}
onKeyDown={handleKeyDown}
/>
</div>
<div>
@@ -91,6 +159,7 @@ export const AdvancedFilters = ({
type="text"
placeholder="Author"
autoComplete="off"
enterKeyHint="search"
className="w-full px-3 py-2 rounded-md border"
style={{
background: 'var(--bg-soft)',
@@ -101,6 +170,7 @@ export const AdvancedFilters = ({
onChange={e => {
onFiltersChange({ author: e.target.value });
}}
onKeyDown={handleKeyDown}
/>
</div>
<div>
@@ -112,6 +182,7 @@ export const AdvancedFilters = ({
type="text"
placeholder="Title"
autoComplete="off"
enterKeyHint="search"
className="w-full px-3 py-2 rounded-md border"
style={{
background: 'var(--bg-soft)',
@@ -122,6 +193,7 @@ export const AdvancedFilters = ({
onChange={e => {
onFiltersChange({ title: e.target.value });
}}
onKeyDown={handleKeyDown}
/>
</div>
<LanguageMultiSelect
@@ -0,0 +1,65 @@
import { CSSProperties } from 'react';
import { Book, ButtonStateInfo } from '../types';
import { useSearchMode } from '../contexts/SearchModeContext';
import { BookDownloadButton } from './BookDownloadButton';
import { BookGetButton } from './BookGetButton';
type ButtonSize = 'sm' | 'md';
type ButtonVariant = 'default' | 'icon';
interface BookActionButtonProps {
book: Book;
buttonState: ButtonStateInfo;
onDownload: (book: Book) => Promise<void>;
onGetReleases: (book: Book) => void;
isLoadingReleases?: boolean;
size?: ButtonSize;
variant?: ButtonVariant;
fullWidth?: boolean;
className?: string;
style?: CSSProperties;
}
export function BookActionButton({
book,
buttonState,
onDownload,
onGetReleases,
isLoadingReleases,
size,
variant = 'default',
fullWidth,
className,
style,
}: BookActionButtonProps) {
const { searchMode } = useSearchMode();
if (searchMode === 'universal') {
return (
<BookGetButton
book={book}
onGetReleases={onGetReleases}
buttonState={buttonState}
isLoading={isLoadingReleases}
size={size}
variant={variant}
fullWidth={fullWidth}
className={className}
style={style}
/>
);
}
return (
<BookDownloadButton
buttonState={buttonState}
onDownload={() => onDownload(book)}
size={size}
variant={variant === 'default' ? 'primary' : 'icon'}
fullWidth={fullWidth}
className={className}
style={style}
ariaLabel={buttonState.text}
/>
);
}
@@ -1,45 +1,6 @@
import { useEffect, useState, CSSProperties } from 'react';
import { ButtonStateInfo } from '../types';
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>
);
};
import { CircularProgress } from './shared';
type ButtonSize = 'sm' | 'md';
type ButtonVariant = 'primary' | 'icon';
@@ -62,8 +23,8 @@ const sizeClasses: Record<ButtonSize, string> = {
};
const iconVariantSizeClasses: Record<ButtonSize, string> = {
sm: 'p-1 sm:p-1.5',
md: 'p-1.5 sm:p-2',
sm: 'p-px m-0.5 sm:p-1 sm:m-0.5 aspect-square',
md: 'p-0.5 m-0.5 sm:p-1.5 sm:m-0.5 aspect-square',
};
const primaryIconSizes: Record<ButtonSize, string> = {
@@ -72,13 +33,13 @@ const primaryIconSizes: Record<ButtonSize, string> = {
};
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' },
sm: { mobile: 'w-5 h-5', desktop: 'w-5 h-5' },
md: { mobile: 'w-6 h-6', desktop: 'w-6 h-6' },
};
const iconVariantProgressSizes: Record<ButtonSize, { mobile: number; desktop: number }> = {
sm: { mobile: 14, desktop: 16 },
md: { mobile: 16, desktop: 20 },
sm: { mobile: 20, desktop: 20 },
md: { mobile: 24, desktop: 24 },
};
export const BookDownloadButton = ({
@@ -190,26 +151,19 @@ export const BookDownloadButton = ({
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" />
</>
);
const progressSize = iconVariantProgressSizes[size].mobile;
return <CircularProgress progress={buttonState.progress} size={progressSize} />;
}
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';
if (variant === 'icon' && iconSizes) {
return (
<div className={`${iconSizes.mobile} border-2 border-current border-t-transparent rounded-full animate-spin`} />
);
}
const spinnerClass = 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`} />;
}
@@ -0,0 +1,178 @@
import { CSSProperties } from 'react';
import { Book, ButtonStateInfo } from '../types';
import { CircularProgress } from './shared';
type ButtonSize = 'sm' | 'md';
type ButtonVariant = 'default' | 'icon';
interface BookGetButtonProps {
book: Book;
onGetReleases: (book: Book) => void;
buttonState?: ButtonStateInfo;
isLoading?: boolean;
size?: ButtonSize;
variant?: ButtonVariant;
fullWidth?: boolean;
className?: string;
style?: CSSProperties;
}
const sizeClasses: Record<ButtonSize, string> = {
sm: 'px-2.5 py-1.5 text-xs',
md: 'px-4 py-2.5 text-sm',
};
const iconSizeClasses: Record<ButtonSize, string> = {
sm: 'p-1.5',
md: 'p-1.5 sm:p-2',
};
const iconSizes: Record<ButtonSize, string> = {
sm: 'w-3.5 h-3.5',
md: 'w-4 h-4',
};
const iconOnlySizes: Record<ButtonSize, string> = {
sm: 'w-4 h-4',
md: 'w-4 h-4 sm:w-5 sm:h-5',
};
export const BookGetButton = ({
book,
onGetReleases,
buttonState,
isLoading = false,
size = 'md',
variant = 'default',
fullWidth = false,
className = '',
style,
}: BookGetButtonProps) => {
const isIconVariant = variant === 'icon';
const widthClasses = fullWidth ? 'w-full' : '';
const sizeClass = isIconVariant ? iconSizeClasses[size] : sizeClasses[size];
const iconSize = isIconVariant ? iconOnlySizes[size] : iconSizes[size];
// Determine states based on buttonState
const isCompleted = buttonState?.state === 'complete';
const hasError = buttonState?.state === 'error';
const isInProgress = buttonState && ['queued', 'resolving', 'downloading'].includes(buttonState.state);
const showCircularProgress = buttonState?.state === 'downloading' && buttonState.progress !== undefined;
const showSpinner = (isInProgress && !showCircularProgress) || isLoading;
// Disable button while loading metadata
const isDisabled = isLoading;
// Determine button styling based on state
const getButtonClasses = () => {
if (isCompleted) {
return isIconVariant
? 'bg-green-600 text-white'
: 'bg-green-600 hover:bg-green-700';
}
if (hasError) {
return isIconVariant
? 'bg-red-600 text-white opacity-75'
: 'bg-red-600 hover:bg-red-700';
}
if (isLoading) {
// Show loading state (fetching metadata)
return isIconVariant
? 'text-gray-400 dark:text-gray-500'
: 'bg-emerald-600/70';
}
if (isInProgress) {
// Show progress state but keep it clickable
return isIconVariant
? 'bg-sky-600 text-white'
: 'bg-sky-600 hover:bg-sky-700';
}
// Default state - icon variant has no background
return isIconVariant
? 'text-gray-600 dark:text-gray-200 hover-action'
: 'bg-emerald-600 hover:bg-emerald-700';
};
const handleClick = () => {
if (isDisabled) return;
onGetReleases(book);
};
// Determine display text
const getDisplayText = () => {
if (isCompleted) return 'Downloaded';
if (hasError) return 'Failed';
if (isLoading) return 'Loading';
if (buttonState?.state === 'downloading') return 'Downloading';
if (buttonState?.state === 'resolving') return 'Resolving';
if (buttonState?.state === 'queued') return 'Queued';
return 'Get';
};
// Render appropriate icon based on state
const renderIcon = () => {
if (isCompleted) {
return (
<svg className={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
);
}
if (hasError) {
return (
<svg className={iconSize} 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) {
const progressSize = isIconVariant ? (size === 'sm' ? 16 : 20) : (size === 'sm' ? 12 : 16);
return <CircularProgress progress={buttonState?.progress} size={progressSize} />;
}
if (showSpinner) {
return (
<div
className={`${iconSize} border-2 border-current border-t-transparent rounded-full animate-spin`}
/>
);
}
// Default "+" icon for Get action
return (
<svg className={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
);
};
// Icon variant renders as a circular button without text
if (isIconVariant) {
return (
<button
className={`flex items-center justify-center rounded-full transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-emerald-500 ${sizeClass} ${getButtonClasses()} ${className}`.trim()}
onClick={handleClick}
disabled={isDisabled}
style={style}
aria-label={`${getDisplayText()} releases for ${book.title}`}
>
{renderIcon()}
</button>
);
}
return (
<button
className={`inline-flex items-center justify-center gap-1.5 rounded text-white transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-emerald-500 ${sizeClass} ${widthClasses} ${getButtonClasses()} ${className}`.trim()}
onClick={handleClick}
disabled={isDisabled}
style={style}
aria-label={`${getDisplayText()} releases for ${book.title}`}
>
{renderIcon()}
<span>{getDisplayText()}</span>
</button>
);
};
@@ -0,0 +1,176 @@
import { useState, useEffect, useCallback } from 'react';
const STORAGE_KEY = 'cwa-config-banner-dismissed';
interface ConfigSetupBannerProps {
/** Whether to show the banner (controlled mode) */
isOpen?: boolean;
/** Called when banner is closed */
onClose?: () => void;
/** Called when "Continue to Settings" is clicked (only shown if provided) */
onContinue?: () => void;
/** Auto-show mode: show banner if settings not enabled and not dismissed */
settingsEnabled?: boolean;
}
export const ConfigSetupBanner = ({
isOpen: controlledOpen,
onClose,
onContinue,
settingsEnabled,
}: ConfigSetupBannerProps) => {
const [autoShowVisible, setAutoShowVisible] = useState(false);
const [isClosing, setIsClosing] = useState(false);
// Auto-show mode: check localStorage on mount
useEffect(() => {
if (settingsEnabled !== undefined) {
const dismissed = localStorage.getItem(STORAGE_KEY);
setAutoShowVisible(!settingsEnabled && dismissed !== 'true');
}
}, [settingsEnabled]);
// Determine if we should show based on controlled or auto-show mode
const isControlledMode = controlledOpen !== undefined;
const isVisible = isControlledMode ? controlledOpen : autoShowVisible;
const handleClose = useCallback(() => {
setIsClosing(true);
setTimeout(() => {
setIsClosing(false);
if (isControlledMode) {
onClose?.();
} else {
// Auto-show mode: save to localStorage
localStorage.setItem(STORAGE_KEY, 'true');
setAutoShowVisible(false);
}
}, 150);
}, [isControlledMode, onClose]);
const handleContinue = useCallback(() => {
setIsClosing(true);
setTimeout(() => {
setIsClosing(false);
onContinue?.();
}, 150);
}, [onContinue]);
if (!isVisible && !isClosing) return null;
// Determine which mode we're in for the footer buttons
const showContinueButton = !!onContinue;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150
${isClosing ? 'opacity-0' : 'opacity-100'}`}
onClick={handleClose}
/>
{/* Modal */}
<div
className={`relative w-full max-w-lg rounded-xl
border border-[var(--border-muted)] shadow-2xl
overflow-hidden
${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
style={{ background: 'var(--bg)' }}
role="dialog"
aria-modal="true"
aria-label="Settings Setup Information"
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--border-muted)]">
<h2 className="text-lg font-semibold">
{showContinueButton ? 'Config Volume Required' : 'New Feature: Settings Page'}
</h2>
<button
onClick={handleClose}
className="p-1.5 rounded-lg hover:bg-[var(--hover-surface)] transition-colors"
aria-label="Close"
>
<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"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
<div className="px-5 py-4 space-y-4">
<p className="text-sm opacity-80">
{showContinueButton
? 'To save settings, add a config volume to your Docker Compose file:'
: 'CWA Book Downloader now has a settings page! To enable it, add a config volume to your Docker Compose file:'}
</p>
{/* Code snippet */}
<div className="rounded-lg overflow-hidden border border-[var(--border-muted)]">
<div className="px-3 py-1.5 text-xs font-medium opacity-60 border-b border-[var(--border-muted)]"
style={{ background: 'var(--bg-soft)' }}>
docker-compose.yml
</div>
<pre
className="px-3 py-3 text-sm overflow-x-auto"
style={{ background: 'var(--bg-soft)' }}
>
<code>
<span className="opacity-60">services:</span>{'\n'}
<span className="opacity-60">{' '}cwa-book-downloader:</span>{'\n'}
{' '}volumes:{'\n'}
{' '}- <span className="text-blue-400">/path/to/config</span>:<span className="text-green-400">/config</span>
</code>
</pre>
</div>
<p className="text-xs opacity-60">
{showContinueButton
? 'Without this volume, settings changes will not persist across container restarts.'
: 'This allows you to configure settings through the UI and persist them across container restarts.'}
</p>
</div>
{/* Footer */}
<div className="px-5 py-4 border-t border-[var(--border-muted)] flex justify-end gap-3">
{showContinueButton ? (
<>
<button
onClick={handleClose}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-[var(--bg-soft)] border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors"
>
Close
</button>
<button
onClick={handleContinue}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-[var(--primary-color)] text-white
hover:bg-[var(--primary-dark)] transition-colors"
>
Continue to Settings
</button>
</>
) : (
<button
onClick={handleClose}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-[var(--primary-color)] text-white
hover:bg-[var(--primary-dark)] transition-colors"
>
Got it
</button>
)}
</div>
</div>
</div>
);
};
+158 -34
View File
@@ -1,47 +1,60 @@
import { useState, useEffect } from 'react';
import { Book, ButtonStateInfo } from '../types';
import { useState, useEffect, useCallback } from 'react';
import { Book, ButtonStateInfo, isMetadataBook } from '../types';
import { BookDownloadButton } from './BookDownloadButton';
interface DetailsModalProps {
book: Book | null;
onClose: () => void;
onDownload: (book: Book) => Promise<void>;
onFindDownloads?: (book: Book) => void; // For Universal mode
buttonState: ButtonStateInfo;
}
export const DetailsModal = ({ book, onClose, onDownload, buttonState }: DetailsModalProps) => {
export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, buttonState }: DetailsModalProps) => {
const [isQueuing, setIsQueuing] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const handleClose = useCallback(() => {
setIsClosing(true);
setTimeout(() => {
onClose();
setIsClosing(false);
}, 150);
}, [onClose]);
// Clear queuing state and close modal once button state changes from download
useEffect(() => {
if (isQueuing && buttonState.state !== 'download') {
setIsQueuing(false);
// Close modal after status has updated
const timer = setTimeout(onClose, 500);
const timer = setTimeout(handleClose, 500);
return () => clearTimeout(timer);
}
}, [buttonState.state, isQueuing, onClose]);
}, [buttonState.state, isQueuing, handleClose]);
// Handle ESC key to close modal
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
handleClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [onClose]);
}, [handleClose]);
useEffect(() => {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, []);
if (book) {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}
}, [book]);
if (!book && !isClosing) return null;
if (!book) return null;
const titleId = `book-details-title-${book.id}`;
@@ -54,17 +67,41 @@ export const DetailsModal = ({ book, onClose, onDownload, buttonState }: Details
} catch (error) {
setIsQueuing(false);
// Close on error
setTimeout(onClose, 300);
setTimeout(handleClose, 300);
}
};
// Determine if this is a metadata book (Universal mode) vs a release (Direct Download)
const isMetadata = isMetadataBook(book);
const publisherInfo = { label: 'Publisher', value: book.publisher || '-' };
const metadata = [
{ label: 'Year', value: book.year || '-' },
{ label: 'Language', value: book.language || '-' },
{ label: 'Format', value: book.format || '-' },
{ label: 'Size', value: book.size || '-' },
];
// Build metadata grid based on mode
// Universal mode: Year, Genres (no language, no publisher - often blank from providers)
// Direct Download mode: Year, Language, Format, Size
const metadata = isMetadata
? [
{ label: 'Year', value: book.year || '-' },
...(book.genres && book.genres.length > 0
? [{ label: 'Genres', value: book.genres.slice(0, 3).join(', ') }]
: []),
]
: [
{ label: 'Year', value: book.year || '-' },
{ label: 'Language', value: book.language || '-' },
{ label: 'Format', value: book.format || '-' },
{ label: 'Size', value: book.size || '-' },
];
// Extract rating and readers from display_fields for dedicated boxes (Universal mode)
const ratingField = isMetadata && book.display_fields?.find(f => f.icon === 'star');
const readersField = isMetadata && book.display_fields?.find(f => f.icon === 'users');
// Other display fields (pages, editions, etc.) shown inline
const otherDisplayFields = isMetadata && book.display_fields?.filter(f => f.icon !== 'star' && f.icon !== 'users');
// Use provider display name from backend, fall back to capitalized provider name
const providerDisplay = book.provider_display_name
|| (book.provider ? book.provider.charAt(0).toUpperCase() + book.provider.slice(1) : '');
const artworkMaxHeight = 'calc(90vh - 220px)';
const artworkMaxWidth = 'min(45vw, 520px, calc((90vh - 220px) / 1.6))';
const additionalInfo =
@@ -84,11 +121,11 @@ export const DetailsModal = ({ book, onClose, onDownload, buttonState }: Details
<div
className="modal-overlay active px-4 py-6 sm:px-6"
onClick={e => {
if (e.target === e.currentTarget) onClose();
if (e.target === e.currentTarget) handleClose();
}}
>
<div
className="details-container w-full max-w-4xl animate-fade-in-up"
className={`details-container w-full max-w-4xl ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
@@ -106,7 +143,7 @@ export const DetailsModal = ({ book, onClose, onDownload, buttonState }: Details
</div>
<button
type="button"
onClick={onClose}
onClick={handleClose}
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"
>
@@ -155,16 +192,74 @@ export const DetailsModal = ({ book, onClose, onDownload, buttonState }: Details
</div>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-2 lg:grid-cols-4 lg:gap-4">
{/* Metadata grid - adapts columns based on mode and available data */}
<div className={`grid grid-cols-2 gap-3 lg:gap-4 ${isMetadata ? 'lg:grid-cols-2' : 'lg:grid-cols-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>
))}
{/* Rating box - Universal mode only */}
{ratingField && (
<div className={`${infoCardClass} space-y-1`} style={infoCardStyle}>
<p className={infoLabelClass}>{ratingField.label}</p>
<p className={`${infoValueClass} flex items-center gap-1.5`}>
<svg className="h-4 w-4 text-amber-500" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
{ratingField.value}
</p>
</div>
)}
{/* Readers box - Universal mode only */}
{readersField && (
<div className={`${infoCardClass} space-y-1`} style={infoCardStyle}>
<p className={infoLabelClass}>{readersField.label}</p>
<p className={`${infoValueClass} flex items-center gap-1.5`}>
<svg className="h-4 w-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
</svg>
{readersField.value}
</p>
</div>
)}
</div>
{extendedInfoEntries.length > 0 && (
{/* Other display fields (pages, editions) - Universal mode only */}
{otherDisplayFields && otherDisplayFields.length > 0 && (
<div className="flex flex-wrap gap-4 text-sm">
{otherDisplayFields.map(field => (
<span key={field.label} className="flex items-center gap-1.5">
{field.icon === 'book' && (
<svg className="h-4 w-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
</svg>
)}
{field.icon === 'editions' && (
<svg className="h-4 w-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6.878V6a2.25 2.25 0 0 1 2.25-2.25h7.5A2.25 2.25 0 0 1 18 6v.878m-12 0c.235-.083.487-.128.75-.128h10.5c.263 0 .515.045.75.128m-12 0A2.25 2.25 0 0 0 4.5 9v.878m13.5-3A2.25 2.25 0 0 1 19.5 9v.878m0 0a2.246 2.246 0 0 0-.75-.128H5.25c-.263 0-.515.045-.75.128m15 0A2.25 2.25 0 0 1 21 12v6a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 18v-6c0-.98.626-1.813 1.5-2.122" />
</svg>
)}
<span className="text-gray-500 dark:text-gray-400">{field.label}:</span>
<span className="text-gray-900 dark:text-gray-100">{field.value}</span>
</span>
))}
</div>
)}
{/* ISBN - Universal mode only */}
{isMetadata && (book.isbn_13 || book.isbn_10) && (
<div className={`${infoCardClass} space-y-1`} style={infoCardStyle}>
<p className={infoLabelClass}>ISBN</p>
<p className={infoValueClass}>{book.isbn_13 || book.isbn_10}</p>
</div>
)}
{/* Extended info (publisher, etc.) - Direct Download mode only */}
{!isMetadata && extendedInfoEntries.length > 0 && (
<div className={`${infoCardClass} space-y-3`} style={infoCardStyle}>
<ul className="space-y-3 list-none">
{extendedInfoEntries.map(([key, value]) => (
@@ -181,15 +276,44 @@ export const DetailsModal = ({ book, onClose, onDownload, buttonState }: Details
</div>
<footer className="border-t border-[var(--border-muted)] bg-[var(--bg-soft)] px-5 py-4">
<div className="flex justify-end">
<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 className="flex items-center justify-between gap-4">
{/* Source link - Universal mode only */}
{isMetadata && book.source_url ? (
<a
href={book.source_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--border-muted)] bg-[var(--bg)] px-3 py-2 text-xs font-medium text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-900 dark:text-gray-400 dark:hover:border-gray-500 dark:hover:text-gray-200"
>
View on {providerDisplay}
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
) : (
<div />
)}
{isMetadata ? (
<button
onClick={() => onFindDownloads?.(book)}
className="rounded-full bg-emerald-600 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
>
Find Downloads
</button>
) : (
<BookDownloadButton
buttonState={buttonState}
onDownload={handleDownload}
size="md"
className="rounded-full px-6 py-2.5 text-sm font-medium"
ariaLabel={`Download ${book.title || 'book'}`}
/>
)}
</div>
</footer>
</div>
@@ -33,9 +33,36 @@ if (!document.head.querySelector('style[data-wave-animation]')) {
document.head.appendChild(styleSheet);
}
// Helper to get book preview image
const getBookPreview = (book: Book): string => {
return book.preview || '/placeholder-book.png';
// Book thumbnail component with fallback
const BookThumbnail = ({ preview, title }: { preview?: string; title?: string }) => {
if (!preview) {
return (
<div
className="w-16 h-24 rounded-tl bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[8px] font-medium text-gray-500 dark:text-gray-400"
style={{ aspectRatio: '2/3' }}
>
No Cover
</div>
);
}
return (
<img
src={preview}
alt={title || 'Book cover'}
className="w-16 h-24 object-cover rounded-tl shadow-sm"
style={{ aspectRatio: '2/3' }}
onError={(e) => {
// Replace with placeholder on error
const target = e.target as HTMLImageElement;
const placeholder = document.createElement('div');
placeholder.className = 'w-16 h-24 rounded-tl bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-[8px] font-medium text-gray-500 dark:text-gray-400';
placeholder.style.aspectRatio = '2/3';
placeholder.textContent = 'No Cover';
target.replaceWith(placeholder);
}}
/>
);
};
// Helper to get progress percentage based on status
@@ -126,19 +153,14 @@ export const DownloadsSidebar = ({
const progress = getStatusProgress(statusName, book.progress);
const progressBarColor = getProgressBarColor(statusName);
// Format progress text - use status_message if available, otherwise fall back to label
// Format progress text - use status_message from backend if available
let progressText = book.status_message || statusStyle.label;
if (statusName === 'downloading' && book.progress && book.size) {
if (statusName === 'downloading' && !book.status_message && book.progress && book.size) {
// Fallback: calculate size progress only if backend didn't provide a message
const sizeValue = parseFloat(book.size.replace(/[^\d.]/g, ''));
const sizeUnit = book.size.replace(/[\d.\s]/g, ''); // Extract unit as-is from backend
const sizeUnit = book.size.replace(/[\d.\s]/g, '');
const downloadedSize = (book.progress / 100) * sizeValue;
const sizeProgress = `${downloadedSize.toFixed(1)}${sizeUnit} / ${book.size}`;
// If there's attempt info in the status message, prepend it to the progress
if (book.status_message?.startsWith('Attempt')) {
progressText = `${book.status_message} - ${sizeProgress}`;
} else {
progressText = sizeProgress;
}
progressText = `${downloadedSize.toFixed(1)}${sizeUnit} / ${book.size}`;
} else if (isCompleted) {
progressText = 'Complete';
} else if (hasError) {
@@ -171,22 +193,13 @@ export const DownloadsSidebar = ({
<div className="flex gap-2">
{/* Book Thumbnail - left side */}
<div className="flex-shrink-0">
<img
src={getBookPreview(book)}
alt={book.title || 'Book cover'}
className="w-16 h-24 object-cover rounded shadow-sm"
style={{ aspectRatio: '2/3' }}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = '/placeholder-book.png';
}}
/>
<BookThumbnail preview={book.preview} title={book.title} />
</div>
{/* Book Info - right side */}
<div className="flex-1 min-w-0 flex flex-col justify-between px-3 pt-2 pb-3">
<div className="flex-1 min-w-0 flex flex-col pl-1.5 pr-3 pt-2 pb-2">
{/* Title & Author - with safe area for cancel/clear button */}
<div className="mb-1 pr-6">
<div className="pr-6">
<h3 className="font-semibold text-sm truncate" title={book.title}>
{isCompleted && book.download_path ? (
<a
@@ -204,59 +217,59 @@ export const DownloadsSidebar = ({
</p>
</div>
{/* Details Row */}
<div className="space-y-1 pb-8">
<div className="flex items-center gap-2">
{/* Format and Size */}
<div className="text-xs opacity-70">
{book.format && <span className="uppercase">{book.format}</span>}
{book.format && book.size && <span> • </span>}
{book.size && <span>{book.size}</span>}
</div>
</div>
{/* Format, Size, Source */}
<div className="text-xs opacity-70 mt-1">
{book.format && <span className="uppercase">{book.format}</span>}
{book.format && book.size && <span> • </span>}
{book.size && <span>{book.size}</span>}
{book.source_display_name && (
<>
<span> • </span>
<span>{book.source_display_name}</span>
</>
)}
</div>
{/* Status Badge */}
<div className="flex justify-end mt-auto pt-1">
<span
className={`relative px-2 py-0.5 rounded-lg text-xs font-medium ${statusStyle.bg} ${statusStyle.text}`}
>
{/* Wave animation overlay for in-progress states */}
{isInProgress && statusStyle.waveColor && (
<span
key={statusName}
className="absolute inset-0 rounded-lg"
style={{
background: `linear-gradient(90deg, transparent 0%, ${statusStyle.waveColor} 50%, transparent 100%)`,
backgroundSize: '200% 100%',
animation: 'wave 2s linear infinite',
}}
/>
)}
<span className="relative">{progressText}</span>
</span>
</div>
</div>
</div>
{/* Progress Bar - absolute positioned at bottom - always visible */}
<div className="absolute bottom-0 left-0 right-0 pointer-events-none">
{/* ml-16 clears the 64px thumbnail, gap-2 adds spacing */}
<div className="flex justify-end p-2 ml-16 gap-2">
<span
className={`relative px-2 py-0.5 rounded-lg text-xs font-medium text-right ${statusStyle.bg} ${statusStyle.text}`}
>
{/* Wave animation overlay for in-progress states */}
{isInProgress && statusStyle.waveColor && (
<span
key={statusName}
className="absolute inset-0 rounded-lg"
style={{
background: `linear-gradient(90deg, transparent 0%, ${statusStyle.waveColor} 50%, transparent 100%)`,
backgroundSize: '200% 100%',
animation: 'wave 2s linear infinite',
}}
/>
)}
<span className="relative">{progressText}</span>
</span>
</div>
<div className="h-1.5 bg-gray-200 dark:bg-gray-700 overflow-hidden relative">
<div
className={`h-full ${progressBarColor} transition-all duration-300 relative overflow-hidden`}
style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
>
{/* Animated wave effect for in-progress states */}
{isInProgress && progress < 100 && (
<div
className="absolute inset-0 opacity-30"
style={{
background: 'linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.5) 50%, transparent 100%)',
backgroundSize: '200% 100%',
animation: 'wave 2s ease-in-out infinite',
}}
/>
)}
</div>
{/* Progress Bar - at bottom */}
<div className="h-1.5 bg-gray-200 dark:bg-gray-700 overflow-hidden relative">
<div
className={`h-full ${progressBarColor} transition-all duration-300 relative overflow-hidden`}
style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
>
{/* Animated wave effect for in-progress states */}
{isInProgress && progress < 100 && (
<div
className="absolute inset-0 opacity-30"
style={{
background: 'linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.5) 50%, transparent 100%)',
backgroundSize: '200% 100%',
animation: 'wave 2s ease-in-out infinite',
}}
/>
)}
</div>
</div>
</div>
+2 -2
View File
@@ -100,14 +100,14 @@ export const Dropdown = ({
type="button"
onClick={toggleOpen}
disabled={disabled}
className={`w-full px-3 py-2 rounded-md border flex items-center justify-between text-left text-base focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 ${buttonClassName}`}
className={`w-full px-3 py-2 rounded-md border flex items-center justify-between text-left focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 ${buttonClassName}`}
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
>
<span className="truncate text-base">
<span className="truncate">
{summary ?? <span className="opacity-60">Select an option</span>}
</span>
<svg
+10 -3
View File
@@ -49,7 +49,14 @@ export const DropdownList = ({
}
if (selectedOptions.length === 0) {
return <span className="opacity-60 text-base">{placeholder}</span>;
// For single select with empty string value, find and show the empty value option label
if (!multiple) {
const emptyOption = options.find(opt => opt.value === '');
if (emptyOption) {
return emptyOption.label;
}
}
return <span className="opacity-60">{placeholder}</span>;
}
if (!multiple) {
@@ -102,7 +109,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-surface ${
className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 hover-surface ${
option.disabled ? 'opacity-50 cursor-not-allowed' : ''
}`}
onClick={() => handleOptionClick(option, close)}
@@ -118,7 +125,7 @@ export const DropdownList = ({
)}
{option.icon}
<div className="flex flex-col">
<span className="text-base">{option.label}</span>
<span>{option.label}</span>
{option.description && (
<span className="text-xs opacity-70">{option.description}</span>
)}
+71 -67
View File
@@ -1,5 +1,9 @@
import { useState, useEffect, useRef } from 'react';
import { SearchBar } from './SearchBar';
import { useState, useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
import { SearchBar, SearchBarHandle } from './SearchBar';
export interface HeaderHandle {
submitSearch: () => void;
}
interface StatusCounts {
ongoing: number;
@@ -18,6 +22,7 @@ interface HeaderProps {
onAdvancedToggle?: () => void;
isLoading?: boolean;
onDownloadsClick?: () => void;
onSettingsClick?: () => void;
statusCounts?: StatusCounts;
onLogoClick?: () => void;
authRequired?: boolean;
@@ -27,8 +32,8 @@ interface HeaderProps {
onRemoveToast?: (id: string) => void;
}
export const Header = ({
calibreWebUrl,
export const Header = forwardRef<HeaderHandle, HeaderProps>(({
calibreWebUrl,
debug,
logoUrl,
showSearch = false,
@@ -38,6 +43,7 @@ export const Header = ({
onAdvancedToggle,
isLoading = false,
onDownloadsClick,
onSettingsClick,
statusCounts = { ongoing: 0, completed: 0, errored: 0 },
onLogoClick,
authRequired = false,
@@ -45,8 +51,14 @@ export const Header = ({
onLogout,
onShowToast,
onRemoveToast,
}: HeaderProps) => {
const [theme, setTheme] = useState<string>('auto');
}, ref) => {
const searchBarRef = useRef<SearchBarHandle>(null);
useImperativeHandle(ref, () => ({
submitSearch: () => {
searchBarRef.current?.submit();
},
}));
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [shouldAnimateIn, setShouldAnimateIn] = useState(false);
@@ -54,9 +66,8 @@ export const Header = ({
useEffect(() => {
const saved = localStorage.getItem('preferred-theme') || 'auto';
setTheme(saved);
applyTheme(saved);
// Remove preload class after initial theme is applied to enable transitions
requestAnimationFrame(() => {
document.documentElement.classList.remove('preload');
@@ -117,19 +128,6 @@ export const Header = ({
}
};
const handleThemeChange = (newTheme: string) => {
localStorage.setItem('preferred-theme', newTheme);
setTheme(newTheme);
applyTheme(newTheme);
};
const cycleTheme = () => {
const themeOrder = ['light', 'dark', 'auto'];
const currentIndex = themeOrder.indexOf(theme);
const nextIndex = (currentIndex + 1) % themeOrder.length;
handleThemeChange(themeOrder[nextIndex]);
};
const handleLogout = () => {
closeDropdown();
onLogout?.();
@@ -199,12 +197,12 @@ export const Header = ({
</svg>
{/* Show badge with appropriate color based on status */}
{(statusCounts.ongoing > 0 || statusCounts.completed > 0 || statusCounts.errored > 0) && (
<span
<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
? 'bg-blue-500'
statusCounts.errored > 0
? 'bg-red-500'
: statusCounts.ongoing > 0
? 'bg-blue-500'
: 'bg-green-500'
}`}
title={`${statusCounts.ongoing} ongoing, ${statusCounts.completed} completed, ${statusCounts.errored} failed`}
@@ -256,30 +254,6 @@ export const Header = ({
}}
>
<div className="py-1">
{/* Theme Button */}
<button
type="button"
onClick={cycleTheme}
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">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
)}
{theme === 'dark' && (
<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="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
</svg>
)}
{theme === 'auto' && (
<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="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25" />
</svg>
)}
<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"
@@ -304,6 +278,35 @@ export const Header = ({
<span>Report a Bug</span>
</a>
{/* Settings Button */}
{onSettingsClick && (
<button
type="button"
onClick={() => {
closeDropdown();
onSettingsClick();
}}
className="w-full text-left px-4 py-2 hover-surface transition-colors flex items-center gap-3"
>
<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="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>Settings</span>
</button>
)}
{/* Debug Buttons */}
{debug && (
<>
@@ -318,16 +321,16 @@ export const Header = ({
method: 'GET',
credentials: 'include',
});
// Remove the loading toast
if (loadingToastId) onRemoveToast?.(loadingToastId);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
onShowToast?.(`Debug download failed: ${errorData.error || response.statusText}`, 'error');
return;
}
// Get the filename from Content-Disposition header or use default
const contentDisposition = response.headers.get('Content-Disposition');
let filename = 'debug.zip';
@@ -337,7 +340,7 @@ export const Header = ({
filename = filenameMatch[1].replace(/['"]/g, '');
}
}
// Create blob and trigger download
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
@@ -348,7 +351,7 @@ export const Header = ({
a.click();
window.URL.revokeObjectURL(url);
a.remove();
onShowToast?.('Debug logs downloaded successfully', 'success');
} catch (error) {
// Remove the loading toast on error too
@@ -412,14 +415,14 @@ export const Header = ({
<div className="flex items-center justify-between w-full lg:w-auto lg:justify-end lg:order-2">
{/* Logo - visible on mobile only, aligned left */}
{logoUrl && (
<img
src={logoUrl}
onClick={onLogoClick}
alt="Logo"
className="h-10 w-10 flex-shrink-0 cursor-pointer lg:hidden"
<img
src={logoUrl}
onClick={onLogoClick}
alt="Logo"
className="h-10 w-10 flex-shrink-0 cursor-pointer lg:hidden"
/>
)}
<IconButtons />
</div>
@@ -427,14 +430,15 @@ export const Header = ({
<div className="flex items-center gap-4 lg:order-1 flex-1">
{/* Logo - visible on desktop only, aligned with search */}
{logoUrl && (
<img
src={logoUrl}
onClick={onLogoClick}
alt="Logo"
className="hidden lg:block h-12 w-12 flex-shrink-0 cursor-pointer"
<img
src={logoUrl}
onClick={onLogoClick}
alt="Logo"
className="hidden lg:block h-12 w-12 flex-shrink-0 cursor-pointer"
/>
)}
<SearchBar
ref={searchBarRef}
className="flex-1 lg:flex-initial"
inputClassName="lg:w-[50vw]"
value={searchInput}
@@ -456,4 +460,4 @@ export const Header = ({
</div>
</header>
);
};
});
+167
View File
@@ -0,0 +1,167 @@
import { ColumnSchema, ColumnColorHint, Release } from '../types';
import { getFormatColor, getLanguageColor, getDownloadTypeColor, ColorStyle } from '../utils/colorMaps';
interface ReleaseCellProps {
column: ColumnSchema;
release: Release;
compact?: boolean; // When true, renders badges as plain text (for mobile info lines)
}
/**
* Get a nested value from an object using dot-notation path.
* e.g., getNestedValue(obj, "extra.language") returns obj.extra.language
*/
const getNestedValue = (obj: Record<string, unknown>, path: string): unknown => {
return path.split('.').reduce((current, key) => {
if (current && typeof current === 'object') {
return (current as Record<string, unknown>)[key];
}
return undefined;
}, obj as unknown);
};
const DEFAULT_COLOR_STYLE: ColorStyle = { bg: 'bg-gray-500/20', text: 'text-gray-700 dark:text-gray-300' };
/**
* Get the color style for a value based on the color hint.
*/
const getColorStyle = (value: string, colorHint?: ColumnColorHint | null): ColorStyle => {
if (!colorHint) return DEFAULT_COLOR_STYLE;
if (colorHint.type === 'static') {
// For static hints, assume it's a bg class and pair with default text
return { bg: colorHint.value, text: 'text-gray-700 dark:text-gray-300' };
}
if (colorHint.type === 'map') {
switch (colorHint.value) {
case 'format':
return getFormatColor(value);
case 'language':
return getLanguageColor(value);
case 'download_type':
return getDownloadTypeColor(value);
default:
return DEFAULT_COLOR_STYLE;
}
}
return DEFAULT_COLOR_STYLE;
};
/**
* Generic cell renderer for release list columns.
* Renders different column types (text, badge, size, number, seeders) based on schema.
* When compact=true, badges render as plain text for use in mobile info lines.
*/
export const ReleaseCell = ({ column, release, compact = false }: ReleaseCellProps) => {
const rawValue = getNestedValue(release as unknown as Record<string, unknown>, column.key);
const value = rawValue !== undefined && rawValue !== null
? String(rawValue)
: column.fallback;
const displayValue = column.uppercase ? value.toUpperCase() : value;
// Alignment classes
const alignClass = {
left: 'text-left justify-start',
center: 'text-center justify-center',
right: 'text-right justify-end',
}[column.align];
// Render based on type
switch (column.render_type) {
case 'badge': {
// Compact mode: render as plain text (for mobile info lines)
if (compact) {
return <span>{displayValue}</span>;
}
const colorStyle = getColorStyle(value, column.color_hint);
return (
<div className={`flex items-center ${alignClass}`}>
{value !== column.fallback ? (
<span className={`${colorStyle.bg} ${colorStyle.text} text-[10px] sm:text-[11px] font-semibold px-1.5 sm:px-2 py-0.5 rounded-lg tracking-wide`}>
{displayValue}
</span>
) : (
<span className="text-[10px] sm:text-xs text-gray-500 dark:text-gray-400">{column.fallback}</span>
)}
</div>
);
}
case 'size':
if (compact) {
return <span>{displayValue}</span>;
}
return (
<div className={`flex items-center ${alignClass} text-xs text-gray-600 dark:text-gray-300`}>
{displayValue}
</div>
);
case 'peers': {
// Peers display: "S/L" string with badge colored by seeder count
// Color logic: 0 = red, 1-10 = yellow, 10+ = blue
const seeders = release.seeders;
const peersValue = value || column.fallback;
const isFallback = seeders == null || peersValue === column.fallback;
// If no data, show plain text like badge type does
if (isFallback) {
if (compact) {
return <span>{column.fallback}</span>;
}
return (
<div className={`flex items-center ${alignClass}`}>
<span className="text-[10px] sm:text-xs text-gray-500 dark:text-gray-400">{column.fallback}</span>
</div>
);
}
// Determine color based on seeder count
let badgeColors: string;
if (seeders >= 10) {
badgeColors = 'bg-blue-500/20 text-blue-700 dark:text-blue-300';
} else if (seeders >= 1) {
badgeColors = 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-300';
} else {
badgeColors = 'bg-red-500/20 text-red-700 dark:text-red-300';
}
if (compact) {
return <span className={`font-medium ${badgeColors.split(' ').slice(1).join(' ')}`}>{peersValue}</span>;
}
return (
<div className={`flex items-center ${alignClass}`}>
<span className={`${badgeColors} text-[10px] sm:text-[11px] font-semibold px-1.5 sm:px-2 py-0.5 rounded-lg tracking-wide`}>
{peersValue}
</span>
</div>
);
}
case 'number':
if (compact) {
return <span>{displayValue}</span>;
}
return (
<div className={`flex items-center ${alignClass} text-xs text-gray-600 dark:text-gray-300`}>
{displayValue}
</div>
);
case 'text':
default:
if (compact) {
return <span>{displayValue}</span>;
}
return (
<div className={`flex items-center ${alignClass} text-xs text-gray-600 dark:text-gray-300 truncate`}>
{displayValue}
</div>
);
}
};
export default ReleaseCell;
File diff suppressed because it is too large Load Diff
+71 -24
View File
@@ -1,19 +1,30 @@
import { useState, useEffect } from 'react';
import { Book, ButtonStateInfo } from '../types';
import { Book, ButtonStateInfo, SortOption } from '../types';
import { useSearchMode } from '../contexts/SearchModeContext';
import { CardView } from './resultsViews/CardView';
import { CompactView } from './resultsViews/CompactView';
import { ListView } from './resultsViews/ListView';
import { Dropdown } from './Dropdown';
import { SORT_OPTIONS } from '../data/filterOptions';
// Grid layout classes by view mode
const GRID_CLASSES = {
mobile: 'grid-cols-1 items-start',
card: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 items-stretch',
compact: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 items-start',
} as const;
interface ResultsSectionProps {
books: Book[];
visible: boolean;
onDetails: (id: string) => Promise<void>;
onDownload: (book: Book) => Promise<void>;
onGetReleases: (book: Book) => Promise<void>;
getButtonState: (bookId: string) => ButtonStateInfo;
getUniversalButtonState: (bookId: string) => ButtonStateInfo;
sortValue: string;
onSortChange: (value: string) => void;
metadataSortOptions?: SortOption[];
}
export const ResultsSection = ({
@@ -21,10 +32,14 @@ export const ResultsSection = ({
visible,
onDetails,
onDownload,
onGetReleases,
getButtonState,
getUniversalButtonState,
sortValue,
onSortChange,
metadataSortOptions,
}: ResultsSectionProps) => {
const { searchMode } = useSearchMode();
const [viewMode, setViewMode] = useState<'card' | 'compact' | 'list'>(() => {
const saved = localStorage.getItem('bookViewMode');
return saved === 'card' || saved === 'compact' || saved === 'list' ? saved : 'compact';
@@ -36,14 +51,25 @@ export const ResultsSection = ({
}, [viewMode]);
// Track whether we're in desktop layout (sm breakpoint and above)
// Debounced to avoid excessive state updates during resize
useEffect(() => {
let timeoutId: number;
const checkDesktop = () => {
setIsDesktop(window.innerWidth >= 640); // sm breakpoint
clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => {
setIsDesktop(window.innerWidth >= 640); // sm breakpoint
}, 100);
};
checkDesktop();
// Initial check without debounce
setIsDesktop(window.innerWidth >= 640);
window.addEventListener('resize', checkDesktop);
return () => window.removeEventListener('resize', checkDesktop);
return () => {
clearTimeout(timeoutId);
window.removeEventListener('resize', checkDesktop);
};
}, []);
if (!visible) return null;
@@ -51,7 +77,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 relative z-10">
<SortControl value={sortValue} onChange={onSortChange} />
<SortControl value={sortValue} onChange={onSortChange} metadataSortOptions={metadataSortOptions} />
{/* View toggle buttons - Desktop: show all 3, Mobile: show Compact and List only */}
<div className="flex items-center gap-2">
@@ -60,7 +86,9 @@ export const ResultsSection = ({
onClick={() => setViewMode('card')}
className={`p-2 rounded-full transition-all duration-200 ${
viewMode === 'card'
? 'text-white bg-sky-700 hover:bg-sky-800'
? searchMode === 'universal'
? 'text-white bg-emerald-600 hover:bg-emerald-700'
: 'text-white bg-sky-700 hover:bg-sky-800'
: 'hover-action text-gray-900 dark:text-gray-100'
}`}
title="Card view"
@@ -86,7 +114,9 @@ export const ResultsSection = ({
onClick={() => setViewMode('compact')}
className={`p-2 rounded-full transition-all duration-200 ${
viewMode === 'compact'
? 'text-white bg-sky-700 hover:bg-sky-800'
? searchMode === 'universal'
? 'text-white bg-emerald-600 hover:bg-emerald-700'
: 'text-white bg-sky-700 hover:bg-sky-800'
: 'hover-action text-gray-900 dark:text-gray-100'
}`}
title="Compact view"
@@ -110,7 +140,9 @@ export const ResultsSection = ({
onClick={() => setViewMode('list')}
className={`p-2 rounded-full transition-all duration-200 ${
viewMode === 'list'
? 'text-white bg-sky-700 hover:bg-sky-800'
? searchMode === 'universal'
? 'text-white bg-emerald-600 hover:bg-emerald-700'
: 'text-white bg-sky-700 hover:bg-sky-800'
: 'hover-action text-gray-900 dark:text-gray-100'
}`}
title="List view"
@@ -134,22 +166,19 @@ export const ResultsSection = ({
</div>
</div>
{viewMode === 'list' ? (
<ListView books={books} onDetails={onDetails} onDownload={onDownload} getButtonState={getButtonState} />
<ListView books={books} onDetails={onDetails} onDownload={onDownload} onGetReleases={onGetReleases} getButtonState={getButtonState} getUniversalButtonState={getUniversalButtonState} />
) : (
<div
id="results-grid"
className={`grid gap-8 ${
!isDesktop
? 'grid-cols-1 items-start'
: viewMode === 'card'
? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 items-stretch'
: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 items-start'
}`}
className={`grid gap-8 ${!isDesktop ? GRID_CLASSES.mobile : GRID_CLASSES[viewMode]}`}
>
{books.map((book, index) => {
const shouldUseCardLayout = isDesktop && viewMode === 'card';
const animationDelay = index * 50;
// Use appropriate button state function based on search mode
const buttonState = searchMode === 'universal'
? getUniversalButtonState(book.id)
: getButtonState(book.id);
return shouldUseCardLayout ? (
<CardView
@@ -157,7 +186,8 @@ export const ResultsSection = ({
book={book}
onDetails={onDetails}
onDownload={onDownload}
buttonState={getButtonState(book.id)}
onGetReleases={onGetReleases}
buttonState={buttonState}
animationDelay={animationDelay}
/>
) : (
@@ -166,7 +196,8 @@ export const ResultsSection = ({
book={book}
onDetails={onDetails}
onDownload={onDownload}
buttonState={getButtonState(book.id)}
onGetReleases={onGetReleases}
buttonState={buttonState}
showDetailsButton={!isDesktop}
animationDelay={animationDelay}
/>
@@ -184,10 +215,22 @@ export const ResultsSection = ({
interface SortControlProps {
value: string;
onChange: (value: string) => void;
metadataSortOptions?: SortOption[];
}
const SortControl = ({ value, onChange }: SortControlProps) => {
const selected = SORT_OPTIONS.find(option => option.value === value) ?? SORT_OPTIONS[0];
// Default universal mode sort options (fallback if not provided by API)
const DEFAULT_UNIVERSAL_SORT_OPTIONS: SortOption[] = [
{ value: 'relevance', label: 'Most relevant' },
];
const SortControl = ({ value, onChange, metadataSortOptions }: SortControlProps) => {
const { searchMode } = useSearchMode();
// Use different sort options based on search mode
// For universal mode, use dynamic options from API (with fallback)
const sortOptions = searchMode === 'universal'
? (metadataSortOptions && metadataSortOptions.length > 0 ? metadataSortOptions : DEFAULT_UNIVERSAL_SORT_OPTIONS)
: SORT_OPTIONS;
const selected = sortOptions.find(option => option.value === value) ?? sortOptions[0];
return (
<Dropdown
@@ -224,14 +267,18 @@ const SortControl = ({ value, onChange }: SortControlProps) => {
>
{({ close }) => (
<div role="listbox" aria-label="Sort results">
{SORT_OPTIONS.map(option => {
{sortOptions.map(option => {
const isSelected = option.value === selected.value;
return (
<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-surface ${
isSelected ? 'text-sky-600 dark:text-sky-300 font-medium' : ''
isSelected
? searchMode === 'universal'
? 'text-emerald-600 dark:text-emerald-400 font-medium'
: 'text-sky-600 dark:text-sky-300 font-medium'
: ''
}`}
onClick={() => {
onChange(option.value);
+23 -7
View File
@@ -1,4 +1,5 @@
import { KeyboardEvent, InputHTMLAttributes, useRef } from 'react';
import { KeyboardEvent, InputHTMLAttributes, useRef, forwardRef, useImperativeHandle } from 'react';
import { useSearchMode } from '../contexts/SearchModeContext';
interface SearchBarProps {
value: string;
@@ -21,7 +22,11 @@ interface SearchBarProps {
enterKeyHint?: InputHTMLAttributes<HTMLInputElement>['enterKeyHint'];
}
export const SearchBar = ({
export interface SearchBarHandle {
submit: () => void;
}
export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(({
value,
onChange,
onSubmit,
@@ -40,10 +45,18 @@ export const SearchBar = ({
searchButtonTitle = 'Search',
autoComplete = 'off',
enterKeyHint = 'search',
}: SearchBarProps) => {
}, ref) => {
const { searchMode } = useSearchMode();
const inputRef = useRef<HTMLInputElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const hasSearchQuery = value.trim().length > 0;
useImperativeHandle(ref, () => ({
submit: () => {
buttonRef.current?.click();
},
}));
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onSubmit();
@@ -139,9 +152,14 @@ export const SearchBar = ({
</button>
)}
<button
ref={buttonRef}
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"
className={`p-2 rounded-full text-white disabled:opacity-60 disabled:cursor-not-allowed flex items-center justify-center transition-colors search-bar-button ${
searchMode === 'universal'
? 'bg-emerald-600 hover:bg-emerald-700'
: 'bg-sky-700 hover:bg-sky-800'
}`}
aria-label={searchButtonLabel}
title={searchButtonTitle}
disabled={isLoading}
@@ -169,6 +187,4 @@ export const SearchBar = ({
</div>
</div>
);
};
});
+16 -1
View File
@@ -1,5 +1,6 @@
import { AdvancedFilterState, Language } from '../types';
import { AdvancedFilterState, Language, MetadataSearchField } from '../types';
import { buildSearchQuery } from '../utils/buildSearchQuery';
import { useSearchMode } from '../contexts/SearchModeContext';
import { AdvancedFilters } from './AdvancedFilters';
import { SearchBar } from './SearchBar';
@@ -17,6 +18,10 @@ interface SearchSectionProps {
onAdvancedToggle: () => void;
advancedFilters: AdvancedFilterState;
onAdvancedFiltersChange: (updates: Partial<AdvancedFilterState>) => void;
// Universal mode props
metadataSearchFields?: MetadataSearchField[];
searchFieldValues?: Record<string, string | number | boolean>;
onSearchFieldChange?: (key: string, value: string | number | boolean) => void;
}
export const SearchSection = ({
@@ -33,7 +38,12 @@ export const SearchSection = ({
onAdvancedToggle,
advancedFilters,
onAdvancedFiltersChange,
metadataSearchFields,
searchFieldValues,
onSearchFieldChange,
}: SearchSectionProps) => {
const { searchMode } = useSearchMode();
const handleSearch = () => {
const query = buildSearchQuery({
searchInput,
@@ -41,6 +51,7 @@ export const SearchSection = ({
advancedFilters,
bookLanguages,
defaultLanguage,
searchMode,
});
onSearch(query);
};
@@ -79,6 +90,10 @@ export const SearchSection = ({
onFiltersChange={onAdvancedFiltersChange}
formClassName="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-2"
renderWrapper={form => form}
metadataSearchFields={metadataSearchFields}
searchFieldValues={searchFieldValues}
onSearchFieldChange={onSearchFieldChange}
onSubmit={handleSearch}
/>
</div>
</section>
@@ -25,7 +25,7 @@ export const ToastContainer = ({ toasts }: ToastContainerProps) => {
};
return (
<div id="toast-container" className="fixed bottom-4 right-4 z-50 space-y-2">
<div id="toast-container" className="fixed bottom-4 right-4 z-[100] space-y-2">
{toasts.map(toast => (
<div
key={toast.id}
@@ -1,6 +1,8 @@
import { useState } from 'react';
import { Book, ButtonStateInfo } from '../../types';
import { BookDownloadButton } from '../BookDownloadButton';
import { useSearchMode } from '../../contexts/SearchModeContext';
import { BookActionButton } from '../BookActionButton';
import { DisplayFieldBadges } from '../shared';
const SkeletonLoader = () => (
<div className="w-full h-full bg-gradient-to-r from-gray-300 via-gray-200 to-gray-300 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
@@ -10,12 +12,15 @@ interface CardViewProps {
book: Book;
onDetails: (id: string) => Promise<void>;
onDownload: (book: Book) => Promise<void>;
onGetReleases: (book: Book) => Promise<void>;
buttonState: ButtonStateInfo;
animationDelay?: number;
}
export const CardView = ({ book, onDetails, onDownload, buttonState, animationDelay = 0 }: CardViewProps) => {
export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonState, animationDelay = 0 }: CardViewProps) => {
const { searchMode } = useSearchMode();
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
const [isLoadingReleases, setIsLoadingReleases] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
const [isHovered, setIsHovered] = useState(false);
@@ -29,6 +34,15 @@ export const CardView = ({ book, onDetails, onDownload, buttonState, animationDe
}
};
const handleGetReleases = async (book: Book) => {
setIsLoadingReleases(true);
try {
await onGetReleases(book);
} finally {
setIsLoadingReleases(false);
}
};
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 will-change-transform"
@@ -104,19 +118,27 @@ export const CardView = ({ book, onDetails, onDownload, buttonState, animationDe
{book.title || 'Untitled'}
</h3>
<p className="text-sm max-sm:text-xs opacity-80 truncate max-sm:min-w-0">{book.author || 'Unknown author'}</p>
<div className="text-xs max-sm:text-[10px] opacity-70 flex flex-wrap gap-2 max-sm:gap-1">
<span>{book.year || '-'}</span>
<span>•</span>
<span>{book.language || '-'}</span>
<span>•</span>
<span>{book.format || '-'}</span>
{book.size && (
<>
<span>•</span>
<span>{book.size}</span>
</>
)}
</div>
{searchMode === 'universal' && book.display_fields && book.display_fields.length > 0 ? (
<div className="text-xs max-sm:text-[10px] opacity-70 flex flex-wrap gap-2 max-sm:gap-1">
<span>{book.year || '-'}</span>
<span>•</span>
<DisplayFieldBadges fields={book.display_fields} />
</div>
) : (
<div className="text-xs max-sm:text-[10px] opacity-70 flex flex-wrap gap-2 max-sm:gap-1">
<span>{book.year || '-'}</span>
<span>•</span>
<span>{book.language || '-'}</span>
<span>•</span>
<span>{book.format || '-'}</span>
{book.size && (
<>
<span>•</span>
<span>{book.size}</span>
</>
)}
</div>
)}
</div>
<div className="flex gap-1.5 sm:hidden">
@@ -131,13 +153,24 @@ export const CardView = ({ book, onDetails, onDownload, buttonState, animationDe
className={`details-spinner w-3 h-3 border-2 border-current border-t-transparent rounded-full ${isLoadingDetails ? '' : 'hidden'}`}
/>
</button>
<BookDownloadButton buttonState={buttonState} onDownload={() => onDownload(book)} size="sm" className="flex-1" />
<BookActionButton
book={book}
buttonState={buttonState}
onDownload={onDownload}
onGetReleases={handleGetReleases}
isLoadingReleases={isLoadingReleases}
size="sm"
className="flex-1"
/>
</div>
</div>
<BookDownloadButton
<BookActionButton
book={book}
buttonState={buttonState}
onDownload={() => onDownload(book)}
onDownload={onDownload}
onGetReleases={handleGetReleases}
isLoadingReleases={isLoadingReleases}
className="hidden sm:flex rounded-none"
fullWidth
style={{
@@ -1,6 +1,8 @@
import { useState } from 'react';
import { Book, ButtonStateInfo } from '../../types';
import { BookDownloadButton } from '../BookDownloadButton';
import { useSearchMode } from '../../contexts/SearchModeContext';
import { BookActionButton } from '../BookActionButton';
import { DisplayFieldBadges } from '../shared';
const SkeletonLoader = () => (
<div className="w-full h-full bg-gradient-to-r from-gray-300 via-gray-200 to-gray-300 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 animate-pulse" />
@@ -10,13 +12,16 @@ interface CompactViewProps {
book: Book;
onDetails: (id: string) => Promise<void>;
onDownload: (book: Book) => Promise<void>;
onGetReleases: (book: Book) => Promise<void>;
buttonState: ButtonStateInfo;
showDetailsButton?: boolean;
animationDelay?: number;
}
export const CompactView = ({ book, onDetails, onDownload, buttonState, showDetailsButton = false, animationDelay = 0 }: CompactViewProps) => {
export const CompactView = ({ book, onDetails, onDownload, onGetReleases, buttonState, showDetailsButton = false, animationDelay = 0 }: CompactViewProps) => {
const { searchMode } = useSearchMode();
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
const [isLoadingReleases, setIsLoadingReleases] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
const [isHovered, setIsHovered] = useState(false);
@@ -30,6 +35,15 @@ export const CompactView = ({ book, onDetails, onDownload, buttonState, showDeta
}
};
const handleGetReleases = async (book: Book) => {
setIsLoadingReleases(true);
try {
await onGetReleases(book);
} finally {
setIsLoadingReleases(false);
}
};
return (
<article
className="book-card overflow-hidden !flex !flex-row w-full !h-[180px] transition-shadow duration-300 animate-slide-up will-change-transform"
@@ -104,23 +118,27 @@ export const CompactView = ({ book, onDetails, onDownload, buttonState, showDeta
{book.title || 'Untitled'}
</h3>
<p className="text-xs opacity-80 truncate min-w-0">{book.author || 'Unknown author'}</p>
<div className="text-[10px] opacity-70">
<div className="text-xs opacity-70">
<span>{book.year || '-'}</span>
</div>
</div>
<div className="mt-auto flex flex-col gap-2">
<div className="text-[10px] opacity-70 flex flex-wrap gap-1">
<span>{book.language || '-'}</span>
<span>•</span>
<span>{book.format || '-'}</span>
{book.size && (
<>
<span>•</span>
<span>{book.size}</span>
</>
)}
</div>
{searchMode === 'universal' && book.display_fields && book.display_fields.length > 0 ? (
<DisplayFieldBadges fields={book.display_fields} className="text-xs opacity-70" />
) : (
<div className="text-xs opacity-70 flex flex-wrap gap-1">
<span>{book.language || '-'}</span>
<span>•</span>
<span>{book.format || '-'}</span>
{book.size && (
<>
<span>•</span>
<span>{book.size}</span>
</>
)}
</div>
)}
{showDetailsButton ? (
<div className="flex gap-1.5">
@@ -133,10 +151,26 @@ export const CompactView = ({ book, onDetails, onDownload, buttonState, showDeta
<span className="details-button-text">{isLoadingDetails ? 'Loading' : 'Details'}</span>
{isLoadingDetails && <div className="w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin" />}
</button>
<BookDownloadButton buttonState={buttonState} onDownload={() => onDownload(book)} size="sm" className="flex-1" />
<BookActionButton
book={book}
buttonState={buttonState}
onDownload={onDownload}
onGetReleases={handleGetReleases}
isLoadingReleases={isLoadingReleases}
size="sm"
className="flex-1"
/>
</div>
) : (
<BookDownloadButton buttonState={buttonState} onDownload={() => onDownload(book)} size="sm" fullWidth />
<BookActionButton
book={book}
buttonState={buttonState}
onDownload={onDownload}
onGetReleases={handleGetReleases}
isLoadingReleases={isLoadingReleases}
size="sm"
fullWidth
/>
)}
</div>
</div>
@@ -1,12 +1,17 @@
import { useState } from 'react';
import { Book, ButtonStateInfo } from '../../types';
import { BookDownloadButton } from '../BookDownloadButton';
import { useSearchMode } from '../../contexts/SearchModeContext';
import { BookActionButton } from '../BookActionButton';
import { DisplayFieldIcon, DisplayFieldBadge } from '../shared';
import { getFormatColor, getLanguageColor } from '../../utils/colorMaps';
interface ListViewProps {
books: Book[];
onDetails: (id: string) => Promise<void>;
onDownload: (book: Book) => Promise<void>;
onGetReleases: (book: Book) => Promise<void>;
getButtonState: (bookId: string) => ButtonStateInfo;
getUniversalButtonState: (bookId: string) => ButtonStateInfo;
}
const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: string }) => {
@@ -42,51 +47,10 @@ const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: strin
);
};
const getLanguageColor = (language?: string): string => {
if (!language || language === '-') return 'bg-gray-400 dark:bg-gray-600';
const lang = language.toLowerCase();
const colorMap: Record<string, string> = {
en: 'bg-blue-500 dark:bg-blue-600',
english: 'bg-blue-500 dark:bg-blue-600',
es: 'bg-orange-500 dark:bg-orange-600',
spanish: 'bg-orange-500 dark:bg-orange-600',
fr: 'bg-purple-500 dark:bg-purple-600',
french: 'bg-purple-500 dark:bg-purple-600',
de: 'bg-yellow-500 dark:bg-yellow-600',
german: 'bg-yellow-500 dark:bg-yellow-600',
it: 'bg-green-500 dark:bg-green-600',
italian: 'bg-green-500 dark:bg-green-600',
pt: 'bg-teal-500 dark:bg-teal-600',
portuguese: 'bg-teal-500 dark:bg-teal-600',
ru: 'bg-red-500 dark:bg-red-600',
russian: 'bg-red-500 dark:bg-red-600',
ja: 'bg-pink-500 dark:bg-pink-600',
japanese: 'bg-pink-500 dark:bg-pink-600',
zh: 'bg-rose-500 dark:bg-rose-600',
chinese: 'bg-rose-500 dark:bg-rose-600',
};
return colorMap[lang] || 'bg-indigo-500 dark:bg-indigo-600';
};
const getFormatColor = (format?: string): string => {
if (!format || format === '-') return 'bg-gray-400 dark:bg-gray-600';
const fmt = format.toLowerCase();
const colorMap: Record<string, string> = {
pdf: 'bg-red-500 dark:bg-red-600',
epub: 'bg-green-500 dark:bg-green-600',
mobi: 'bg-blue-500 dark:bg-blue-600',
azw3: 'bg-purple-500 dark:bg-purple-600',
txt: 'bg-gray-500 dark:bg-gray-600',
djvu: 'bg-orange-500 dark:bg-orange-600',
fb2: 'bg-teal-500 dark:bg-teal-600',
cbr: 'bg-yellow-500 dark:bg-yellow-600',
cbz: 'bg-amber-500 dark:bg-amber-600',
};
return colorMap[fmt] || 'bg-cyan-500 dark:bg-cyan-600';
};
export const ListView = ({ books, onDetails, onDownload, getButtonState }: ListViewProps) => {
export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButtonState, getUniversalButtonState }: ListViewProps) => {
const { searchMode } = useSearchMode();
const [detailsLoadingId, setDetailsLoadingId] = useState<string | null>(null);
const [releasesLoadingId, setReleasesLoadingId] = useState<string | null>(null);
if (books.length === 0) {
return null;
@@ -101,6 +65,15 @@ export const ListView = ({ books, onDetails, onDownload, getButtonState }: ListV
}
};
const handleGetReleases = async (book: Book) => {
setReleasesLoadingId(book.id);
try {
await onGetReleases(book);
} finally {
setReleasesLoadingId((current) => (current === book.id ? null : current));
}
};
return (
<article
className="w-full overflow-hidden rounded-lg sm:rounded-2xl"
@@ -113,7 +86,10 @@ export const ListView = ({ books, onDetails, onDownload, getButtonState }: ListV
>
<div className="divide-y divide-gray-200/60 dark:divide-gray-800/60 w-full">
{books.map((book, index) => {
const buttonState = getButtonState(book.id);
// Use appropriate button state function based on search mode
const buttonState = searchMode === 'universal'
? getUniversalButtonState(book.id)
: getButtonState(book.id);
const isLoadingDetails = detailsLoadingId === book.id;
return (
@@ -127,7 +103,12 @@ export const ListView = ({ books, onDetails, onDownload, getButtonState }: ListV
role="article"
>
{/* Mobile and Desktop: Single row layout */}
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto_auto] sm:grid-cols-[auto_minmax(0,2fr)_minmax(50px,0.25fr)_minmax(60px,0.3fr)_minmax(60px,0.3fr)_minmax(60px,0.3fr)_auto] items-center gap-2 sm:gap-y-1 sm:gap-x-0.5 w-full">
{/* Universal mode uses separate columns for each display field, direct mode uses language/format/size */}
<div className={`grid items-center gap-2 sm:gap-y-1 sm:gap-x-0.5 w-full ${
searchMode === 'universal'
? 'grid-cols-[auto_minmax(0,1fr)_auto_auto] sm:grid-cols-[auto_minmax(0,2fr)_minmax(50px,0.25fr)_minmax(80px,0.4fr)_minmax(80px,0.4fr)_auto]'
: 'grid-cols-[auto_minmax(0,1fr)_auto_auto] sm:grid-cols-[auto_minmax(0,2fr)_minmax(50px,0.25fr)_minmax(60px,0.3fr)_minmax(60px,0.3fr)_minmax(60px,0.3fr)_auto]'
}`}>
{/* Thumbnail */}
<div className="flex items-center pl-1 sm:pl-3">
<ListViewThumbnail preview={book.preview} title={book.title} />
@@ -144,10 +125,21 @@ export const ListView = ({ books, onDetails, onDownload, getButtonState }: ListV
</p>
</div>
{/* Format and Size - Mobile only */}
{/* Mobile universal mode info */}
<div className="flex sm:hidden flex-col items-end text-[10px] opacity-70 leading-tight">
<span>{book.format || '-'}</span>
{book.size && <span>{book.size}</span>}
{searchMode === 'universal' && book.display_fields && book.display_fields.length > 0 ? (
book.display_fields.slice(0, 2).map((field, idx) => (
<span key={idx} className="flex items-center gap-0.5" title={field.label}>
<DisplayFieldIcon icon={field.icon} />
<span>{field.value}</span>
</span>
))
) : (
<>
<span>{book.format || '-'}</span>
{book.size && <span>{book.size}</span>}
</>
)}
</div>
{/* Year - Desktop only */}
@@ -155,33 +147,61 @@ export const ListView = ({ books, onDetails, onDownload, getButtonState }: ListV
{book.year || '-'}
</div>
{/* Language Badge - Desktop only */}
<div className="hidden sm:flex justify-center">
<span
className={`${getLanguageColor(book.language)} text-white text-[11px] font-semibold px-2 py-0.5 rounded uppercase tracking-wide`}
title={book.language || 'Unknown'}
>
{book.language || '-'}
</span>
</div>
{/* Universal mode: Display fields as separate columns - Desktop only */}
{searchMode === 'universal' && (
<>
{/* First display field column */}
<div className="hidden sm:flex justify-center">
{book.display_fields && book.display_fields[0] ? (
<DisplayFieldBadge field={book.display_fields[0]} />
) : (
<span className="text-xs text-gray-500">-</span>
)}
</div>
{/* Second display field column */}
<div className="hidden sm:flex justify-center">
{book.display_fields && book.display_fields[1] ? (
<DisplayFieldBadge field={book.display_fields[1]} />
) : (
<span className="text-xs text-gray-500">-</span>
)}
</div>
</>
)}
{/* Format Badge - Desktop only */}
<div className="hidden sm:flex justify-center">
<span
className={`${getFormatColor(book.format)} text-white text-[11px] font-semibold px-2 py-0.5 rounded uppercase tracking-wide`}
title={book.format || 'Unknown'}
>
{book.format || '-'}
</span>
</div>
{/* Direct mode: Language Badge - Desktop only */}
{searchMode !== 'universal' && (
<div className="hidden sm:flex justify-center">
<span
className={`${getLanguageColor(book.language)} text-white text-[11px] font-semibold px-2 py-0.5 rounded uppercase tracking-wide`}
title={book.language || 'Unknown'}
>
{book.language || '-'}
</span>
</div>
)}
{/* Size - Desktop only */}
<div className="hidden sm:flex text-xs text-gray-700 dark:text-gray-200 justify-center">
{book.size || '-'}
</div>
{/* Direct mode: Format Badge - Desktop only */}
{searchMode !== 'universal' && (
<div className="hidden sm:flex justify-center">
<span
className={`${getFormatColor(book.format)} text-white text-[11px] font-semibold px-2 py-0.5 rounded uppercase tracking-wide`}
title={book.format || 'Unknown'}
>
{book.format || '-'}
</span>
</div>
)}
{/* Direct mode: Size - Desktop only */}
{searchMode !== 'universal' && (
<div className="hidden sm:flex text-xs text-gray-700 dark:text-gray-200 justify-center">
{book.size || '-'}
</div>
)}
{/* Action Buttons */}
<div className="flex flex-row justify-end gap-0.5 sm:gap-1">
<div className="flex flex-row justify-end gap-0.5 sm:gap-1 sm:pr-3">
<button
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)}
@@ -196,12 +216,14 @@ export const ListView = ({ books, onDetails, onDownload, getButtonState }: ListV
</svg>
)}
</button>
<BookDownloadButton
<BookActionButton
book={book}
buttonState={buttonState}
onDownload={() => onDownload(book)}
onDownload={onDownload}
onGetReleases={handleGetReleases}
isLoadingReleases={releasesLoadingId === book.id}
variant="icon"
size="md"
ariaLabel={buttonState.text}
/>
</div>
</div>
@@ -0,0 +1,265 @@
import { useEffect, useRef } from 'react';
import {
SettingsTab,
SettingsField,
ActionResult,
TextFieldConfig,
PasswordFieldConfig,
NumberFieldConfig,
CheckboxFieldConfig,
SelectFieldConfig,
MultiSelectFieldConfig,
ActionButtonConfig,
HeadingFieldConfig,
} from '../../types/settings';
import { FieldWrapper } from './shared';
import {
TextField,
PasswordField,
NumberField,
CheckboxField,
SelectField,
MultiSelectField,
ActionButton,
HeadingField,
} from './fields';
interface SettingsContentProps {
tab: SettingsTab;
values: Record<string, unknown>;
onChange: (key: string, value: unknown) => void;
onSave: () => Promise<void>;
onAction: (key: string) => Promise<ActionResult>;
isSaving: boolean;
hasChanges: boolean;
}
// Check if a field should be visible based on showWhen condition
function isFieldVisible(
field: SettingsField,
values: Record<string, unknown>
): boolean {
// HeadingField doesn't have showWhen
if (field.type === 'HeadingField') {
return true;
}
const showWhen = field.showWhen;
if (!showWhen) return true;
const currentValue = values[showWhen.field];
// Handle array of allowed values or single value
return Array.isArray(showWhen.value)
? showWhen.value.includes(currentValue as string)
: currentValue === showWhen.value;
}
// Check if a field should be disabled based on disabledWhen condition
// Returns { disabled: boolean, reason?: string }
function getDisabledState(
field: SettingsField,
values: Record<string, unknown>
): { disabled: boolean; reason?: string } {
// HeadingField doesn't have disabledWhen
if (field.type === 'HeadingField') {
return { disabled: false };
}
// Check if value is locked by environment variable
if ('fromEnv' in field && field.fromEnv) {
return { disabled: true };
}
// Check static disabled first
if ('disabled' in field && field.disabled) {
return {
disabled: true,
reason: 'disabledReason' in field ? field.disabledReason : undefined,
};
}
// Check disabledWhen condition
if (!('disabledWhen' in field) || !field.disabledWhen) {
return { disabled: false };
}
const { field: conditionField, value: conditionValue, reason } = field.disabledWhen;
const currentValue = values[conditionField];
// Check if condition is met (handles both array and single value)
const isDisabled = Array.isArray(conditionValue)
? conditionValue.includes(currentValue as string)
: currentValue === conditionValue;
return {
disabled: isDisabled,
reason: isDisabled ? reason : undefined,
};
}
// Render the appropriate field component based on type
const renderField = (
field: SettingsField,
value: unknown,
onChange: (value: unknown) => void,
onAction: () => Promise<ActionResult>,
isDisabled: boolean
) => {
switch (field.type) {
case 'TextField':
return (
<TextField
field={field as TextFieldConfig}
value={(value as string) ?? ''}
onChange={onChange}
disabled={isDisabled}
/>
);
case 'PasswordField':
return (
<PasswordField
field={field as PasswordFieldConfig}
value={(value as string) ?? ''}
onChange={onChange}
disabled={isDisabled}
/>
);
case 'NumberField':
return (
<NumberField
field={field as NumberFieldConfig}
value={(value as number) ?? 0}
onChange={onChange}
disabled={isDisabled}
/>
);
case 'CheckboxField':
return (
<CheckboxField
field={field as CheckboxFieldConfig}
value={(value as boolean) ?? false}
onChange={onChange}
disabled={isDisabled}
/>
);
case 'SelectField':
return (
<SelectField
field={field as SelectFieldConfig}
value={(value as string) ?? ''}
onChange={onChange}
disabled={isDisabled}
/>
);
case 'MultiSelectField':
return (
<MultiSelectField
field={field as MultiSelectFieldConfig}
value={(value as string[]) ?? []}
onChange={onChange}
disabled={isDisabled}
/>
);
case 'ActionButton':
return <ActionButton field={field as ActionButtonConfig} onAction={onAction} disabled={isDisabled} />;
case 'HeadingField':
return <HeadingField field={field as HeadingFieldConfig} />;
default:
return <div>Unknown field type</div>;
}
};
export const SettingsContent = ({
tab,
values,
onChange,
onSave,
onAction,
isSaving,
hasChanges,
}: SettingsContentProps) => {
const scrollRef = useRef<HTMLDivElement>(null);
// Reset scroll position when tab changes
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
}
}, [tab.name]);
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Scrollable content area */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto p-6"
style={{ paddingBottom: hasChanges ? 'calc(5rem + env(safe-area-inset-bottom))' : '1.5rem' }}
>
<div className="space-y-5">
{tab.fields
.filter((field) => isFieldVisible(field, values))
.map((field) => {
const disabledState = getDisabledState(field, values);
return (
<FieldWrapper
key={`${tab.name}-${field.key}`}
field={field}
disabledOverride={disabledState.disabled}
disabledReasonOverride={disabledState.reason}
>
{renderField(
field,
values[field.key],
(v) => onChange(field.key, v),
() => onAction(field.key),
disabledState.disabled
)}
</FieldWrapper>
);
})}
</div>
</div>
{/* Save button - only visible when there are changes */}
{hasChanges && (
<div
className="flex-shrink-0 px-6 py-4 border-t border-[var(--border-muted)] bg-[var(--bg)] animate-slide-up"
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
>
<button
onClick={onSave}
disabled={isSaving}
className="w-full py-2.5 px-4 rounded-lg font-medium transition-colors
bg-sky-600 text-white hover:bg-sky-700
disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Saving...
</span>
) : (
'Save Changes'
)}
</button>
</div>
)}
</div>
);
};
@@ -0,0 +1,58 @@
interface SettingsHeaderProps {
title: string;
showBack?: boolean;
onBack?: () => void;
onClose: () => void;
}
export const SettingsHeader = ({
title,
showBack = false,
onBack,
onClose,
}: SettingsHeaderProps) => (
<header
className="flex items-center gap-3 px-5 py-4 border-b border-[var(--border-muted)] flex-shrink-0"
style={{ paddingTop: 'calc(1rem + env(safe-area-inset-top))' }}
>
{showBack && (
<button
onClick={onBack}
className="p-2 -ml-2 rounded-full hover-action transition-colors"
aria-label="Go back"
>
<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 19.5L8.25 12l7.5-7.5"
/>
</svg>
</button>
)}
<h2 className="text-lg font-semibold flex-1">{title}</h2>
<button
onClick={onClose}
className="p-2 rounded-full hover-action transition-colors"
aria-label="Close settings"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</header>
);
@@ -0,0 +1,321 @@
import { useEffect, useState, useCallback } from 'react';
import { useSettings } from '../../hooks/useSettings';
import { SettingsHeader } from './SettingsHeader';
import { SettingsSidebar } from './SettingsSidebar';
import { SettingsContent } from './SettingsContent';
interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
onSettingsSaved?: () => void;
}
export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }: SettingsModalProps) => {
const {
tabs,
groups,
isLoading,
error,
selectedTab,
setSelectedTab,
values,
updateValue,
hasChanges,
saveTab,
executeAction,
isSaving,
} = useSettings();
// Track if we're showing detail view on mobile
const [isMobile, setIsMobile] = useState(false);
const [showMobileDetail, setShowMobileDetail] = useState(false);
const [isClosing, setIsClosing] = useState(false);
// Check for mobile viewport
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
const handleClose = useCallback(() => {
setIsClosing(true);
setTimeout(() => {
onClose();
setIsClosing(false);
}, 150);
}, [onClose]);
// Handle ESC key
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (isMobile && showMobileDetail) {
setShowMobileDetail(false);
} else {
handleClose();
}
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, isMobile, showMobileDetail, handleClose]);
// Prevent body scroll when open
useEffect(() => {
if (isOpen) {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}
}, [isOpen]);
// Reset mobile detail view when modal opens
useEffect(() => {
if (isOpen) {
setShowMobileDetail(false);
setIsClosing(false);
}
}, [isOpen]);
// On desktop, select first tab when modal first opens (only if no tab selected)
useEffect(() => {
if (isOpen && !isMobile && tabs.length > 0 && !selectedTab) {
setSelectedTab(tabs[0].name);
}
}, [isOpen, isMobile, tabs, selectedTab, setSelectedTab]);
const handleSelectTab = useCallback(
(tabName: string) => {
setSelectedTab(tabName);
if (isMobile) {
setShowMobileDetail(true);
}
},
[isMobile, setSelectedTab]
);
const handleBack = useCallback(() => {
setShowMobileDetail(false);
}, []);
const handleSave = useCallback(async () => {
if (!selectedTab) return;
const result = await saveTab(selectedTab);
if (result.success) {
onShowToast?.(result.message, 'success');
// Notify parent that settings were saved so it can refresh config
onSettingsSaved?.();
// Show additional toast if some settings require restart
if (result.requiresRestart) {
setTimeout(() => {
onShowToast?.('Some settings require a container restart to take effect', 'info');
}, 500);
}
} else {
onShowToast?.(result.message, 'error');
}
}, [selectedTab, saveTab, onShowToast, onSettingsSaved]);
const handleAction = useCallback(
async (actionKey: string) => {
if (!selectedTab) {
return { success: false, message: 'No tab selected' };
}
return executeAction(selectedTab, actionKey);
},
[selectedTab, executeAction]
);
if (!isOpen && !isClosing) return null;
const currentTab = tabs.find((t) => t.name === selectedTab);
const currentTabDisplayName = currentTab?.displayName || 'Settings';
// Loading state
if (isLoading) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={handleClose}
/>
<div
className="relative bg-[var(--bg)] rounded-xl p-8 shadow-2xl"
style={{ background: 'var(--bg)' }}
>
<div className="flex items-center gap-3">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span>Loading settings...</span>
</div>
</div>
</div>
);
}
// Error state
if (error) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={handleClose}
/>
<div
className="relative bg-[var(--bg)] rounded-xl p-8 shadow-2xl max-w-md"
style={{ background: 'var(--bg)' }}
>
<div className="text-center space-y-4">
<div className="text-red-500">
<svg
className="w-12 h-12 mx-auto"
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="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
</div>
<p className="text-sm">{error}</p>
<button
onClick={handleClose}
className="px-4 py-2 rounded-lg text-sm font-medium
bg-[var(--bg-soft)] border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors"
>
Close
</button>
</div>
</div>
</div>
);
}
// Mobile layout
if (isMobile) {
return (
<div
className={`fixed inset-0 z-50 flex flex-col
${isClosing ? 'animate-fade-out' : 'animate-fade-in'}`}
style={{ background: 'var(--bg)' }}
>
{!showMobileDetail ? (
// Category list view
<>
<SettingsHeader title="Settings" onClose={handleClose} />
<SettingsSidebar
tabs={tabs}
groups={groups}
selectedTab={selectedTab}
onSelectTab={handleSelectTab}
mode="list"
/>
</>
) : (
// Detail view
<>
<SettingsHeader
title={currentTabDisplayName}
showBack
onBack={handleBack}
onClose={handleClose}
/>
{currentTab && (
<SettingsContent
tab={currentTab}
values={values[currentTab.name] || {}}
onChange={(key, value) => updateValue(currentTab.name, key, value)}
onSave={handleSave}
onAction={handleAction}
isSaving={isSaving}
hasChanges={hasChanges(currentTab.name)}
/>
)}
</>
)}
</div>
);
}
// Desktop layout
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150
${isClosing ? 'opacity-0' : 'opacity-100'}`}
onClick={handleClose}
/>
{/* Modal */}
<div
className={`relative w-full max-w-4xl h-[85vh] rounded-xl
border border-[var(--border-muted)] shadow-2xl
flex flex-col overflow-hidden
${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
style={{ background: 'var(--bg)' }}
role="dialog"
aria-modal="true"
aria-label="Settings"
>
<SettingsHeader title="Settings" onClose={handleClose} />
<div className="flex flex-1 min-h-0">
<SettingsSidebar
tabs={tabs}
groups={groups}
selectedTab={selectedTab}
onSelectTab={handleSelectTab}
mode="sidebar"
/>
{currentTab ? (
<SettingsContent
tab={currentTab}
values={values[currentTab.name] || {}}
onChange={(key, value) => updateValue(currentTab.name, key, value)}
onSave={handleSave}
onAction={handleAction}
isSaving={isSaving}
hasChanges={hasChanges(currentTab.name)}
/>
) : (
<div className="flex-1 flex items-center justify-center text-sm opacity-60">
Select a category to configure
</div>
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,319 @@
import { useState } from 'react';
import { SettingsTab, SettingsGroup } from '../../types/settings';
interface SettingsSidebarProps {
tabs: SettingsTab[];
groups: SettingsGroup[];
selectedTab: string | null;
onSelectTab: (tabName: string) => void;
mode: 'sidebar' | 'list';
}
// Map icon names to SVG paths
const getIcon = (iconName?: string) => {
switch (iconName) {
case 'settings':
case 'cog':
return (
<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="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
case 'folder':
return (
<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="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
</svg>
);
case 'shield':
return (
<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="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
);
case 'globe':
return (
<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="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
</svg>
);
case 'download':
return (
<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 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
);
case 'book':
return (
<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="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
</svg>
);
case 'library':
return (
<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="M12 21v-8.25M15.75 21v-8.25M8.25 21v-8.25M3 9l9-6 9 6m-1.5 12V10.332A48.36 48.36 0 0012 9.75c-2.551 0-5.056.2-7.5.582V21M3 21h18M12 6.75h.008v.008H12V6.75z" />
</svg>
);
case 'beaker':
case 'wrench':
return (
<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="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 004.486-6.336l-3.276 3.277a3.004 3.004 0 01-2.25-2.25l3.276-3.276a4.5 4.5 0 00-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437l1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008z" />
</svg>
);
default:
return (
<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="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75" />
</svg>
);
}
};
// Chevron icon for expandable groups
const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
<svg
className={`w-5 h-5 opacity-30 transition-transform duration-200 ${expanded ? 'rotate-90' : ''}`}
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="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
);
// Represents either a tab, a group, or a section header in the sorted list
type SidebarItem =
| { type: 'tab'; tab: SettingsTab; order: number }
| { type: 'group'; group: SettingsGroup; tabs: SettingsTab[]; order: number }
| { type: 'section'; label: string; order: number };
// Section headers for organizing the sidebar
// Can trigger before a group (beforeGroup) or before a tab (beforeTab)
const SECTION_HEADERS: { beforeGroup?: string; beforeTab?: string; label: string }[] = [
{ beforeGroup: 'direct_download', label: 'Release Sources' },
{ beforeTab: 'hardcover', label: 'Metadata Providers' },
];
export const SettingsSidebar = ({
tabs,
groups,
selectedTab,
onSelectTab,
mode,
}: SettingsSidebarProps) => {
// Track which groups are expanded (all closed by default)
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
() => new Set()
);
const toggleGroup = (groupName: string) => {
setExpandedGroups((prev) => {
const next = new Set(prev);
if (next.has(groupName)) {
next.delete(groupName);
} else {
next.add(groupName);
}
return next;
});
};
// Build grouped tabs map
const groupedTabs = new Map<string, SettingsTab[]>();
tabs.forEach((tab) => {
if (tab.group) {
const existing = groupedTabs.get(tab.group) || [];
existing.push(tab);
groupedTabs.set(tab.group, existing);
}
});
// Build a unified sorted list of tabs and groups
const sidebarItems: SidebarItem[] = [];
// Add ungrouped tabs (with section headers where needed)
tabs.forEach((tab) => {
if (!tab.group) {
// Check if this tab needs a section header before it
const sectionHeader = SECTION_HEADERS.find((s) => s.beforeTab === tab.name);
if (sectionHeader) {
sidebarItems.push({ type: 'section', label: sectionHeader.label, order: tab.order - 0.5 });
}
sidebarItems.push({ type: 'tab', tab, order: tab.order });
}
});
// Add groups (with their tabs) and section headers
groups.forEach((group) => {
const groupTabList = groupedTabs.get(group.name) || [];
if (groupTabList.length > 0) {
// Check if this group needs a section header before it
const sectionHeader = SECTION_HEADERS.find((s) => s.beforeGroup === group.name);
if (sectionHeader) {
// Insert section header just before this group (order - 0.5 to sort before)
sidebarItems.push({ type: 'section', label: sectionHeader.label, order: group.order - 0.5 });
}
sidebarItems.push({ type: 'group', group, tabs: groupTabList, order: group.order });
}
});
// Sort by order
sidebarItems.sort((a, b) => a.order - b.order);
if (mode === 'list') {
// Mobile: Clean list style with inset dividers
return (
<div
className="flex-1 overflow-y-auto"
style={{ paddingBottom: 'calc(1rem + env(safe-area-inset-bottom))' }}
>
{sidebarItems.map((item, itemIndex) => {
if (item.type === 'section') {
return (
<div key={item.label} className="px-5 pt-6 pb-2">
<span className="text-xs font-semibold uppercase tracking-wider opacity-50">
{item.label}
</span>
</div>
);
}
if (item.type === 'tab') {
return (
<div key={item.tab.name}>
<button
onClick={() => onSelectTab(item.tab.name)}
className="w-full flex items-center gap-4 px-5 py-4
active:bg-[var(--hover-surface)] transition-colors text-left"
>
<span className="opacity-50">{getIcon(item.tab.icon)}</span>
<span className="flex-1">{item.tab.displayName}</span>
</button>
{itemIndex < sidebarItems.length - 1 && (
<div className="ml-14 mr-5 border-b border-[var(--border-muted)]" />
)}
</div>
);
}
// Group
const isExpanded = expandedGroups.has(item.group.name);
return (
<div key={item.group.name}>
<button
onClick={() => toggleGroup(item.group.name)}
className="w-full flex items-center gap-4 px-5 py-4
active:bg-[var(--hover-surface)] transition-colors text-left"
>
<span className="opacity-50">{getIcon(item.group.icon)}</span>
<span className="flex-1">{item.group.displayName}</span>
<ChevronIcon expanded={isExpanded} />
</button>
{isExpanded && (
<div className="bg-[var(--bg-soft)]/50">
{item.tabs.map((tab, index) => (
<div key={tab.name}>
<button
onClick={() => onSelectTab(tab.name)}
className="w-full flex items-center gap-4 pl-14 pr-5 py-3.5
active:bg-[var(--hover-surface)] transition-colors text-left"
>
<span className="flex-1 text-[15px]">{tab.displayName}</span>
</button>
{index < item.tabs.length - 1 && (
<div className="ml-14 mr-5 border-b border-[var(--border-muted)]" />
)}
</div>
))}
</div>
)}
{itemIndex < sidebarItems.length - 1 && (
<div className="ml-14 mr-5 border-b border-[var(--border-muted)]" />
)}
</div>
);
})}
</div>
);
}
// Desktop: Sidebar navigation
return (
<nav className="w-56 border-r border-[var(--border-muted)] py-2 flex-shrink-0 overflow-y-auto">
{sidebarItems.map((item) => {
if (item.type === 'section') {
return (
<div key={item.label} className="px-4 pt-5 pb-2">
<span className="text-[11px] font-semibold uppercase tracking-wider opacity-40">
{item.label}
</span>
</div>
);
}
if (item.type === 'tab') {
return (
<button
key={item.tab.name}
onClick={() => onSelectTab(item.tab.name)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left
transition-colors ${
selectedTab === item.tab.name
? 'bg-[var(--hover-action)] font-medium'
: 'hover:bg-[var(--hover-surface)]'
}`}
>
<span className="opacity-60">{getIcon(item.tab.icon)}</span>
<span>{item.tab.displayName}</span>
</button>
);
}
// Group
const isExpanded = expandedGroups.has(item.group.name);
const hasSelectedTab = item.tabs.some((tab) => tab.name === selectedTab);
return (
<div key={item.group.name}>
<button
onClick={() => toggleGroup(item.group.name)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left
transition-colors hover:bg-[var(--hover-surface)]
${hasSelectedTab && !isExpanded ? 'bg-[var(--hover-action)]/50' : ''}`}
>
<span className="opacity-60">{getIcon(item.group.icon)}</span>
<span className="flex-1">{item.group.displayName}</span>
<ChevronIcon expanded={isExpanded} />
</button>
{isExpanded && (
<div className="ml-8 border-l border-[var(--border-muted)]">
{item.tabs.map((tab) => (
<button
key={tab.name}
onClick={() => onSelectTab(tab.name)}
className={`w-full flex items-center pl-4 pr-4 py-2 text-sm text-left
transition-colors ${
selectedTab === tab.name
? 'bg-[var(--hover-action)] font-medium'
: 'hover:bg-[var(--hover-surface)]'
}`}
>
<span>{tab.displayName}</span>
</button>
))}
</div>
)}
</div>
);
})}
</nav>
);
};
@@ -0,0 +1,96 @@
import { useState } from 'react';
import { ActionButtonConfig, ActionResult } from '../../../types/settings';
interface ActionButtonProps {
field: ActionButtonConfig;
onAction: () => Promise<ActionResult>;
disabled?: boolean;
}
export const ActionButton = ({ field, onAction, disabled }: ActionButtonProps) => {
const [isLoading, setIsLoading] = useState(false);
const [result, setResult] = useState<ActionResult | null>(null);
const isDisabled = disabled ?? field.disabled ?? isLoading;
const handleClick = async () => {
if (isDisabled) return;
setIsLoading(true);
setResult(null);
try {
const res = await onAction();
setResult(res);
} catch (err) {
setResult({
success: false,
message: err instanceof Error ? err.message : 'Action failed',
});
} finally {
setIsLoading(false);
}
};
const styleClasses = {
default:
'bg-[var(--bg-soft)] border border-[var(--border-muted)] hover:bg-[var(--hover-surface)]',
primary: 'bg-sky-600 text-white hover:bg-sky-700',
danger: 'bg-red-600 text-white hover:bg-red-700',
};
return (
<div className={`space-y-2 ${field.disabled ? 'opacity-60' : ''}`}>
<div className="flex items-start gap-3">
<button
type="button"
onClick={handleClick}
disabled={isDisabled}
className={`px-4 py-2 rounded-lg text-sm font-medium
transition-colors disabled:opacity-60 disabled:cursor-not-allowed
${styleClasses[field.style]}`}
>
{isLoading ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Running...
</span>
) : (
field.label
)}
</button>
{field.description && (
<span className="text-xs opacity-60 pt-2">{field.description}</span>
)}
</div>
{result && (
<div
className={`text-sm px-3 py-2 rounded-lg ${
result.success
? 'bg-green-500/20 text-green-700 dark:text-green-300'
: 'bg-red-500/20 text-red-700 dark:text-red-300'
}`}
>
{result.message}
</div>
)}
{field.disabled && field.disabledReason && (
<p className="text-xs text-zinc-500 italic">{field.disabledReason}</p>
)}
</div>
);
};
@@ -0,0 +1,33 @@
import { CheckboxFieldConfig } from '../../../types/settings';
interface CheckboxFieldProps {
field: CheckboxFieldConfig;
value: boolean;
onChange: (value: boolean) => void;
disabled?: boolean; // Override for dynamic disabled state
}
export const CheckboxField = ({ field: _field, value, onChange, disabled }: CheckboxFieldProps) => {
// disabled prop is already computed by SettingsContent.getDisabledState()
const isDisabled = disabled ?? false;
return (
<button
type="button"
role="switch"
aria-checked={value}
onClick={() => !isDisabled && onChange(!value)}
disabled={isDisabled}
className={`relative inline-flex h-6 w-11 items-center rounded-full
transition-colors duration-200 focus:outline-none focus:ring-2
focus:ring-sky-500/50 disabled:opacity-60 disabled:cursor-not-allowed
${value ? 'bg-sky-600' : 'bg-gray-300 dark:bg-gray-600'}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white
shadow-sm transition-transform duration-200
${value ? 'translate-x-6' : 'translate-x-1'}`}
/>
</button>
);
};
@@ -0,0 +1,29 @@
import { HeadingFieldConfig } from '../../../types/settings';
interface HeadingFieldProps {
field: HeadingFieldConfig;
}
export const HeadingField = ({ field }: HeadingFieldProps) => (
<div className="pb-2">
<h3 className="text-base font-semibold mb-1">{field.title}</h3>
{field.description && (
<p className="text-sm opacity-70">
{field.description}
{field.linkUrl && (
<>
{' '}
<a
href={field.linkUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sky-500 hover:text-sky-400 underline"
>
{field.linkText || field.linkUrl}
</a>
</>
)}
</p>
)}
</div>
);
@@ -0,0 +1,198 @@
import { useState, useRef, useEffect } from 'react';
import { MultiSelectFieldConfig } from '../../../types/settings';
interface MultiSelectFieldProps {
field: MultiSelectFieldConfig;
value: string[];
onChange: (value: string[]) => void;
disabled?: boolean;
}
// Threshold for when to enable collapsible behavior
const COLLAPSE_THRESHOLD_OPTIONS = 12;
// Approximate height for ~4 rows of pills (pills are ~32px + 8px gap)
const COLLAPSED_HEIGHT = 156;
/**
* Sort options with selected items first, preserving relative order within each group
*/
const sortOptionsWithSelectedFirst = (
options: MultiSelectFieldConfig['options'],
selectedValues: string[]
): MultiSelectFieldConfig['options'] => {
const selectedSet = new Set(selectedValues);
const selectedOptions = options.filter((opt) => selectedSet.has(opt.value));
const unselectedOptions = options.filter((opt) => !selectedSet.has(opt.value));
return [...selectedOptions, ...unselectedOptions];
};
export const MultiSelectField = ({ field, value, onChange, disabled }: MultiSelectFieldProps) => {
const selected = value ?? [];
// disabled prop is already computed by SettingsContent.getDisabledState()
const isDisabled = disabled ?? false;
const [isExpanded, setIsExpanded] = useState(false);
// Initialize based on option count to avoid flash of expanded content
const [needsCollapse, setNeedsCollapse] = useState(
() => field.options.length > COLLAPSE_THRESHOLD_OPTIONS
);
const containerRef = useRef<HTMLDivElement>(null);
// Track the last value we set via onChange to detect external changes
const lastInternalValueRef = useRef<string[] | null>(null);
// Sorted options - initialized with selected items first, updated only on external changes
const [sortedOptions, setSortedOptions] = useState(() =>
sortOptionsWithSelectedFirst(field.options, selected)
);
// Detect external value changes (like after save or initial load) and re-sort
useEffect(() => {
// If the value changed and it's not from our own onChange call, re-sort
const lastInternal = lastInternalValueRef.current;
const isExternalChange =
lastInternal === null || // Initial mount
lastInternal.length !== selected.length ||
!lastInternal.every((v) => selected.includes(v));
// Only re-sort if the change wasn't triggered by user interaction
if (isExternalChange && lastInternal !== null) {
// Check if this is truly external (values differ in a way that suggests a save/reset)
const wasInternalToggle =
Math.abs(lastInternal.length - selected.length) === 1 &&
(lastInternal.every((v) => selected.includes(v)) ||
selected.every((v) => lastInternal.includes(v)));
if (!wasInternalToggle) {
setSortedOptions(sortOptionsWithSelectedFirst(field.options, selected));
}
}
}, [selected, field.options]);
// Update sortedOptions when field.options changes (e.g., different field)
useEffect(() => {
setSortedOptions(sortOptionsWithSelectedFirst(field.options, selected));
}, [field.key]);
// Verify collapse need after render (handles edge cases where few options still fit)
useEffect(() => {
if (containerRef.current) {
if (field.options.length > COLLAPSE_THRESHOLD_OPTIONS) {
const scrollHeight = containerRef.current.scrollHeight;
setNeedsCollapse(scrollHeight > COLLAPSED_HEIGHT + 20);
} else {
setNeedsCollapse(false);
}
}
}, [field.options.length]);
const toggleOption = (optValue: string) => {
if (isDisabled) return;
let newValue: string[];
if (selected.includes(optValue)) {
newValue = selected.filter((v) => v !== optValue);
} else {
newValue = [...selected, optValue];
}
// Track this as an internal change so we don't re-sort
lastInternalValueRef.current = newValue;
onChange(newValue);
};
const isCollapsible = needsCollapse;
const isCollapsed = isCollapsible && !isExpanded;
return (
<div>
{/* Container with optional max-height constraint */}
<div className="relative">
<div
ref={containerRef}
className={`flex flex-wrap gap-2 transition-[max-height] duration-300 ease-in-out ${
isCollapsed ? 'overflow-hidden' : ''
}`}
style={{
maxHeight: isCollapsed ? `${COLLAPSED_HEIGHT}px` : '2000px',
}}
>
{sortedOptions.map((opt) => {
const isSelected = selected.includes(opt.value);
return (
<button
key={opt.value}
type="button"
onClick={() => toggleOption(opt.value)}
disabled={isDisabled}
className={`px-3 py-1.5 rounded-full text-sm font-medium
transition-colors border
disabled:opacity-60 disabled:cursor-not-allowed
${
isSelected
? 'bg-sky-600 text-white border-sky-600'
: 'bg-transparent border-[var(--border-muted)] hover:bg-[var(--hover-surface)]'
}`}
>
{opt.label}
</button>
);
})}
</div>
{/* Gradient fade overlay when collapsed */}
{isCollapsed && (
<div
className="absolute bottom-0 left-0 right-0 h-20 pointer-events-none"
style={{
background: 'linear-gradient(to top, var(--bg) 0%, transparent 85%)',
}}
/>
)}
</div>
{/* Expand/Collapse toggle - outside the relative container */}
{isCollapsible && (
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="mt-2 text-sm text-sky-500 hover:text-sky-400
transition-colors flex items-center gap-1"
>
{isExpanded ? (
<>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 15l7-7 7 7"
/>
</svg>
Show less
</>
) : (
<>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
Show all {field.options.length} options
</>
)}
</button>
)}
</div>
);
};
@@ -0,0 +1,30 @@
import { NumberFieldConfig } from '../../../types/settings';
interface NumberFieldProps {
field: NumberFieldConfig;
value: number;
onChange: (value: number) => void;
disabled?: boolean;
}
export const NumberField = ({ field, value, onChange, disabled }: NumberFieldProps) => {
// disabled prop is already computed by SettingsContent.getDisabledState()
const isDisabled = disabled ?? false;
return (
<input
type="number"
value={value ?? field.min ?? 0}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
min={field.min}
max={field.max}
step={field.step ?? 1}
disabled={isDisabled}
className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)]
bg-[var(--bg-soft)] text-sm
focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
disabled:opacity-60 disabled:cursor-not-allowed
transition-colors"
/>
);
};
@@ -0,0 +1,79 @@
import { useState } from 'react';
import { PasswordFieldConfig } from '../../../types/settings';
interface PasswordFieldProps {
field: PasswordFieldConfig;
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}
export const PasswordField = ({ field, value, onChange, disabled }: PasswordFieldProps) => {
const [showPassword, setShowPassword] = useState(false);
// disabled prop is already computed by SettingsContent.getDisabledState()
const isDisabled = disabled ?? false;
return (
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
disabled={isDisabled}
className="w-full px-3 py-2 pr-10 rounded-lg border border-[var(--border-muted)]
bg-[var(--bg-soft)] text-sm
focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
disabled:opacity-60 disabled:cursor-not-allowed
transition-colors"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
disabled={isDisabled}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded
hover:bg-[var(--hover-action)] transition-colors
disabled:opacity-60 disabled:cursor-not-allowed"
tabIndex={-1}
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? (
<svg
className="w-4 h-4 opacity-60"
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.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88"
/>
</svg>
) : (
<svg
className="w-4 h-4 opacity-60"
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="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
)}
</button>
</div>
);
};
@@ -0,0 +1,43 @@
import { SelectFieldConfig } from '../../../types/settings';
interface SelectFieldProps {
field: SelectFieldConfig;
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}
export const SelectField = ({ field, value, onChange, disabled }: SelectFieldProps) => {
// disabled prop is already computed by SettingsContent.getDisabledState()
const isDisabled = disabled ?? false;
return (
<select
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
disabled={isDisabled}
className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)]
bg-[var(--bg-soft)] text-sm appearance-none
focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
disabled:opacity-60 disabled:cursor-not-allowed
transition-colors pr-10"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E")`,
backgroundPosition: 'right 0.5rem center',
backgroundRepeat: 'no-repeat',
backgroundSize: '1.5em 1.5em',
}}
>
{!value && (
<option value="" disabled hidden>
Select...
</option>
)}
{field.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
};
@@ -0,0 +1,29 @@
import { TextFieldConfig } from '../../../types/settings';
interface TextFieldProps {
field: TextFieldConfig;
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}
export const TextField = ({ field, value, onChange, disabled }: TextFieldProps) => {
// disabled prop is already computed by SettingsContent.getDisabledState()
const isDisabled = disabled ?? false;
return (
<input
type="text"
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
maxLength={field.maxLength}
disabled={isDisabled}
className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)]
bg-[var(--bg-soft)] text-sm
focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
disabled:opacity-60 disabled:cursor-not-allowed
transition-colors"
/>
);
};
@@ -0,0 +1,8 @@
export { TextField } from './TextField';
export { PasswordField } from './PasswordField';
export { NumberField } from './NumberField';
export { CheckboxField } from './CheckboxField';
export { SelectField } from './SelectField';
export { MultiSelectField } from './MultiSelectField';
export { ActionButton } from './ActionButton';
export { HeadingField } from './HeadingField';
@@ -0,0 +1,4 @@
export { SettingsModal } from './SettingsModal';
export { SettingsHeader } from './SettingsHeader';
export { SettingsSidebar } from './SettingsSidebar';
export { SettingsContent } from './SettingsContent';
@@ -0,0 +1,28 @@
interface EnvLockBadgeProps {
className?: string;
}
export const EnvLockBadge = ({ className = '' }: EnvLockBadgeProps) => (
<span
className={`inline-flex items-center gap-1 px-1.5 py-0.5
text-[10px] font-medium uppercase tracking-wide
bg-gray-200 dark:bg-gray-700
text-gray-600 dark:text-gray-400
rounded ${className}`}
title="This setting is controlled by an environment variable"
>
<svg
className="w-3 h-3"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z"
clipRule="evenodd"
/>
</svg>
ENV
</span>
);
@@ -0,0 +1,106 @@
import { ReactNode } from 'react';
import { SettingsField } from '../../../types/settings';
import { EnvLockBadge } from './EnvLockBadge';
interface FieldWrapperProps {
field: SettingsField;
children: ReactNode;
// Optional overrides for dynamic disabled state (from disabledWhen)
disabledOverride?: boolean;
disabledReasonOverride?: string;
}
// Badge shown when a field is disabled
const DisabledBadge = ({ reason }: { reason?: string }) => (
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs rounded
bg-zinc-500/20 text-zinc-400 border border-zinc-500/30"
title={reason || 'This setting is not available'}
>
<svg
className="w-3 h-3"
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="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg>
Unavailable
</span>
);
// Badge shown when changing a field requires a container restart
const RestartRequiredBadge = () => (
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs rounded
bg-amber-500/20 text-amber-600 dark:text-amber-400 border border-amber-500/30"
title="Changing this setting requires a container restart to take effect"
>
<svg
className="w-3 h-3"
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="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
Restart
</span>
);
export const FieldWrapper = ({
field,
children,
disabledOverride,
disabledReasonOverride,
}: FieldWrapperProps) => {
// Action buttons and headings handle their own layout
if (field.type === 'ActionButton' || field.type === 'HeadingField') {
return <>{children}</>;
}
// At this point, field is a regular input field with standard properties
// Use overrides if provided (from disabledWhen), otherwise use field's static values
const isDisabled = disabledOverride ?? field.disabled;
const disabledReason = disabledReasonOverride ?? field.disabledReason;
const requiresRestart = field.requiresRestart;
// ENV-locked fields should only dim the control, not the label/description
const isFullyDimmed = isDisabled && !field.fromEnv;
return (
<div className={`space-y-1.5 ${isFullyDimmed ? 'opacity-60' : ''}`}>
<div className="flex items-center gap-2">
<label className={`text-sm font-medium ${isFullyDimmed ? 'text-zinc-500' : ''}`}>
{field.label}
{field.required && !isDisabled && <span className="text-red-500 ml-0.5">*</span>}
</label>
{field.fromEnv && <EnvLockBadge />}
{requiresRestart && !isDisabled && !field.fromEnv && <RestartRequiredBadge />}
{isDisabled && !field.fromEnv && <DisabledBadge reason={disabledReason} />}
</div>
{children}
{field.description && (
<p className="text-xs opacity-60">{field.description}</p>
)}
{isDisabled && disabledReason && (
<p className="text-xs text-zinc-500 italic">{disabledReason}</p>
)}
</div>
);
};
@@ -0,0 +1,2 @@
export { EnvLockBadge } from './EnvLockBadge';
export { FieldWrapper } from './FieldWrapper';
@@ -0,0 +1,39 @@
interface CircularProgressProps {
progress?: number;
size?: number;
className?: string;
}
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}>
<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>
);
};
@@ -0,0 +1,68 @@
import { DisplayField } from '../../types';
interface DisplayFieldIconProps {
icon?: string;
className?: string;
}
export function DisplayFieldIcon({ icon, className = 'w-3 h-3' }: DisplayFieldIconProps) {
switch (icon) {
case 'star':
return (
<svg className={className} fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
);
case 'book':
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
);
case 'users':
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
</svg>
);
case 'editions':
return (
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
);
default:
return null;
}
}
interface DisplayFieldBadgeProps {
field: DisplayField;
className?: string;
}
export function DisplayFieldBadge({ field, className = '' }: DisplayFieldBadgeProps) {
return (
<span className={`flex items-center gap-0.5 text-xs text-gray-700 dark:text-gray-200 ${className}`} title={field.label}>
<DisplayFieldIcon icon={field.icon} />
<span>{field.value}</span>
</span>
);
}
interface DisplayFieldBadgesProps {
fields?: DisplayField[];
className?: string;
}
export function DisplayFieldBadges({ fields, className = '' }: DisplayFieldBadgesProps) {
if (!fields || fields.length === 0) return null;
return (
<div className={`flex flex-wrap gap-1.5 ${className}`}>
{fields.map((field, idx) => (
<DisplayFieldBadge key={idx} field={field} />
))}
</div>
);
}
@@ -0,0 +1,104 @@
import { KeyboardEvent } from 'react';
import { MetadataSearchField } from '../../types';
import { DropdownList } from '../DropdownList';
interface SearchFieldRendererProps {
field: MetadataSearchField;
value: string | number | boolean;
onChange: (value: string | number | boolean) => void;
onSubmit?: () => void;
}
const baseInputClass =
'w-full px-3 py-2 rounded-md border border-[var(--border-muted)] ' +
'bg-[var(--bg-soft)] text-sm ' +
'focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500 ' +
'transition-colors';
export const SearchFieldRenderer = ({ field, value, onChange, onSubmit }: SearchFieldRendererProps) => {
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && onSubmit) {
e.preventDefault();
onSubmit();
}
};
switch (field.type) {
case 'TextSearchField':
return (
<input
type="text"
value={(value as string) ?? ''}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={field.placeholder}
autoComplete="off"
enterKeyHint="search"
className={baseInputClass}
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
/>
);
case 'NumberSearchField': {
const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value;
if (!raw) {
onChange('');
return;
}
const num = parseInt(raw, 10);
if (!isNaN(num)) {
onChange(num);
}
};
return (
<input
type="number"
value={value === '' ? '' : (value as number)}
onChange={handleNumberChange}
onKeyDown={handleKeyDown}
placeholder={field.placeholder}
enterKeyHint="search"
min={field.min}
max={field.max}
step={field.step}
className={baseInputClass}
style={{
background: 'var(--bg-soft)',
color: 'var(--text)',
borderColor: 'var(--border-muted)',
}}
/>
);
}
case 'SelectSearchField':
return (
<DropdownList
options={field.options}
value={(value as string) ?? ''}
onChange={(v) => onChange(Array.isArray(v) ? v[0] ?? '' : v)}
placeholder="All"
/>
);
case 'CheckboxSearchField':
return (
<label className="flex items-center gap-2 cursor-pointer py-2">
<input
type="checkbox"
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
className="w-4 h-4 rounded border-[var(--border-muted)] text-emerald-500 focus:ring-emerald-500/50"
/>
<span className="text-sm">{field.label}</span>
</label>
);
default:
return null;
}
};
@@ -0,0 +1,3 @@
export { DisplayFieldIcon, DisplayFieldBadge, DisplayFieldBadges } from './DisplayFieldIcon';
export { CircularProgress } from './CircularProgress';
export { SearchFieldRenderer } from './SearchFieldRenderer';
@@ -0,0 +1,30 @@
import { createContext, useContext, ReactNode } from 'react';
import { SearchMode } from '../types';
interface SearchModeContextValue {
searchMode: SearchMode;
isUniversalMode: boolean;
}
const SearchModeContext = createContext<SearchModeContextValue | null>(null);
export function useSearchMode(): SearchModeContextValue {
const ctx = useContext(SearchModeContext);
if (!ctx) {
throw new Error('useSearchMode must be used within SearchModeProvider');
}
return ctx;
}
interface SearchModeProviderProps {
searchMode: SearchMode;
children: ReactNode;
}
export function SearchModeProvider({ searchMode, children }: SearchModeProviderProps) {
return (
<SearchModeContext.Provider value={{ searchMode, isUniversalMode: searchMode === 'universal' }}>
{children}
</SearchModeContext.Provider>
);
}
+5
View File
@@ -1,3 +1,4 @@
// Direct download mode sort options (Anna's Archive)
export const SORT_OPTIONS = [
{ value: '', label: 'Most relevant' },
{ value: 'newest', label: 'Newest (publication year)' },
@@ -8,6 +9,10 @@ export const SORT_OPTIONS = [
{ value: 'oldest_added', label: 'Oldest (open sourced)' },
];
// Note: Metadata mode sort options are now dynamic per provider
// They come from the /api/config endpoint as metadata_sort_options
// Direct download mode content type options (Anna's Archive)
export const CONTENT_OPTIONS = [
{ value: '', label: 'All' },
{ value: 'book_nonfiction', label: 'Book (non-fiction)' },
+98
View File
@@ -0,0 +1,98 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { LoginCredentials } from '../types';
import { login, logout, checkAuth } from '../services/api';
interface UseAuthOptions {
onLogoutSuccess?: () => void;
showToast?: (message: string, type: 'info' | 'success' | 'error') => void;
}
interface UseAuthReturn {
isAuthenticated: boolean;
authRequired: boolean;
authChecked: boolean;
loginError: string | null;
isLoggingIn: boolean;
setIsAuthenticated: (value: boolean) => void;
handleLogin: (credentials: LoginCredentials) => Promise<void>;
handleLogout: () => Promise<void>;
}
export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
const { onLogoutSuccess, showToast } = options;
const navigate = useNavigate();
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [authRequired, setAuthRequired] = useState<boolean>(true);
const [authChecked, setAuthChecked] = useState<boolean>(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [isLoggingIn, setIsLoggingIn] = useState<boolean>(false);
// Check authentication on mount
useEffect(() => {
const verifyAuth = async () => {
try {
const response = await checkAuth();
const authenticated = response.authenticated || false;
const authIsRequired = response.auth_required !== false;
setAuthRequired(authIsRequired);
setIsAuthenticated(authenticated);
} catch (error) {
console.error('Auth check failed:', error);
setAuthRequired(true);
setIsAuthenticated(false);
} finally {
setAuthChecked(true);
}
};
verifyAuth();
}, []);
const handleLogin = useCallback(async (credentials: LoginCredentials) => {
setIsLoggingIn(true);
setLoginError(null);
try {
const response = await login(credentials);
if (response.success) {
setIsAuthenticated(true);
setLoginError(null);
navigate('/', { replace: true });
} else {
setLoginError(response.error || 'Login failed');
}
} catch (error) {
if (error instanceof Error) {
setLoginError(error.message || 'Login failed');
} else {
setLoginError('Login failed');
}
} finally {
setIsLoggingIn(false);
}
}, [navigate]);
const handleLogout = useCallback(async () => {
try {
await logout();
setIsAuthenticated(false);
onLogoutSuccess?.();
navigate('/login', { replace: true });
} catch (error) {
console.error('Logout failed:', error);
showToast?.('Logout failed', 'error');
}
}, [navigate, onLogoutSuccess, showToast]);
return {
isAuthenticated,
authRequired,
authChecked,
loginError,
isLoggingIn,
setIsAuthenticated,
handleLogin,
handleLogout,
};
}
@@ -0,0 +1,124 @@
import { useState, useCallback } from 'react';
import { StatusData, ButtonStateInfo } from '../types';
interface UseDownloadTrackingReturn {
bookToReleaseMap: Record<string, string[]>;
sessionCompletedBookIds: Set<string>;
trackRelease: (bookId: string, releaseId: string) => void;
markBookCompleted: (bookId: string) => void;
clearTracking: () => void;
getButtonState: (bookId: string) => ButtonStateInfo;
getUniversalButtonState: (bookId: string) => ButtonStateInfo;
}
export function useDownloadTracking(currentStatus: StatusData): UseDownloadTrackingReturn {
// Track mapping of metadata book IDs to release source IDs for universal mode
const [bookToReleaseMap, setBookToReleaseMap] = useState<Record<string, string[]>>({});
// Session-only tracking of completed book IDs (survives clearCompleted, resets on refresh)
const [sessionCompletedBookIds, setSessionCompletedBookIds] = useState<Set<string>>(new Set());
const trackRelease = useCallback((bookId: string, releaseId: string) => {
setBookToReleaseMap(prev => ({
...prev,
[bookId]: [...(prev[bookId] || []), releaseId],
}));
}, []);
const markBookCompleted = useCallback((bookId: string) => {
setSessionCompletedBookIds(prev => new Set([...prev, bookId]));
}, []);
const clearTracking = useCallback(() => {
setBookToReleaseMap({});
setSessionCompletedBookIds(new Set());
}, []);
// Get button state for a book in direct mode
const getButtonState = useCallback((bookId: string): ButtonStateInfo => {
if (currentStatus.error && currentStatus.error[bookId]) {
return { text: 'Failed', state: 'error' };
}
if (currentStatus.complete && currentStatus.complete[bookId]) {
return { text: 'Downloaded', state: 'complete' };
}
if (currentStatus.downloading && currentStatus.downloading[bookId]) {
const book = currentStatus.downloading[bookId];
return {
text: 'Downloading',
state: 'downloading',
progress: book.progress,
};
}
if (currentStatus.resolving && currentStatus.resolving[bookId]) {
return { text: 'Resolving', state: 'resolving' };
}
if (currentStatus.queued && currentStatus.queued[bookId]) {
return { text: 'Queued', state: 'queued' };
}
return { text: 'Download', state: 'download' };
}, [currentStatus]);
// Get button state for a metadata book in universal mode
const getUniversalButtonState = useCallback((bookId: string): ButtonStateInfo => {
const releaseIds = bookToReleaseMap[bookId] || [];
// No releases downloaded yet - check session state
if (releaseIds.length === 0) {
if (sessionCompletedBookIds.has(bookId)) {
return { text: 'Downloaded', state: 'complete' };
}
return { text: 'Get', state: 'download' };
}
// Check each release ID and find the most relevant state
let bestState: ButtonStateInfo = { text: 'Get', state: 'download' };
let foundActiveState = false;
for (const releaseId of releaseIds) {
if (currentStatus.downloading && currentStatus.downloading[releaseId]) {
const downloadingBook = currentStatus.downloading[releaseId];
return {
text: 'Downloading',
state: 'downloading',
progress: downloadingBook.progress,
};
}
if (currentStatus.resolving && currentStatus.resolving[releaseId]) {
return { text: 'Resolving', state: 'resolving' };
}
if (currentStatus.queued && currentStatus.queued[releaseId]) {
return { text: 'Queued', state: 'queued' };
}
if (!foundActiveState) {
if (currentStatus.complete && currentStatus.complete[releaseId]) {
bestState = { text: 'Downloaded', state: 'complete' };
foundActiveState = true;
} else if (currentStatus.error && currentStatus.error[releaseId]) {
if (bestState.state === 'download') {
bestState = { text: 'Failed', state: 'error' };
}
}
}
}
// Check session tracking if no state found
if (bestState.state === 'download' && sessionCompletedBookIds.has(bookId)) {
return { text: 'Downloaded', state: 'complete' };
}
return bestState;
}, [currentStatus, bookToReleaseMap, sessionCompletedBookIds]);
return {
bookToReleaseMap,
sessionCompletedBookIds,
trackRelease,
markBookCompleted,
clearTracking,
getButtonState,
getUniversalButtonState,
};
}
+236
View File
@@ -0,0 +1,236 @@
import { useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Book, AppConfig, AdvancedFilterState } from '../types';
import { searchBooks, searchMetadata, AuthenticationError } from '../services/api';
import { LANGUAGE_OPTION_DEFAULT } from '../utils/languageFilters';
import { DEFAULT_SUPPORTED_FORMATS } from '../data/languages';
const DEFAULT_FORMAT_SELECTION = DEFAULT_SUPPORTED_FORMATS.filter(format => format !== 'pdf');
interface UseSearchOptions {
showToast: (message: string, type: 'info' | 'success' | 'error') => void;
setIsAuthenticated: (value: boolean) => void;
authRequired: boolean;
onSearchReset?: () => void;
}
// Search field values for universal mode (provider-specific fields)
type SearchFieldValues = Record<string, string | number | boolean>;
interface UseSearchReturn {
books: Book[];
setBooks: (books: Book[]) => void;
isSearching: boolean;
lastSearchQuery: string;
searchInput: string;
setSearchInput: (value: string) => void;
showAdvanced: boolean;
setShowAdvanced: (value: boolean) => void;
advancedFilters: AdvancedFilterState;
setAdvancedFilters: React.Dispatch<React.SetStateAction<AdvancedFilterState>>;
updateAdvancedFilters: (updates: Partial<AdvancedFilterState>) => void;
handleSearch: (query: string, config: AppConfig | null, fieldValues?: Record<string, string | number | boolean>) => Promise<void>;
handleResetSearch: (config: AppConfig | null) => void;
handleSortChange: (value: string, config: AppConfig | null) => void;
resetSortFilter: () => void;
// Universal mode search field values
searchFieldValues: SearchFieldValues;
updateSearchFieldValue: (key: string, value: string | number | boolean) => void;
}
export function useSearch(options: UseSearchOptions): UseSearchReturn {
const { showToast, setIsAuthenticated, authRequired, onSearchReset } = options;
const navigate = useNavigate();
const [books, setBooks] = useState<Book[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [lastSearchQuery, setLastSearchQuery] = useState('');
const [searchInput, setSearchInput] = useState('');
const [showAdvanced, setShowAdvanced] = useState(false);
const [advancedFilters, setAdvancedFilters] = useState<AdvancedFilterState>({
isbn: '',
author: '',
title: '',
lang: [LANGUAGE_OPTION_DEFAULT],
sort: '',
content: '',
formats: DEFAULT_FORMAT_SELECTION,
});
// Universal mode: provider-specific search field values
const [searchFieldValues, setSearchFieldValues] = useState<SearchFieldValues>({});
const updateAdvancedFilters = useCallback((updates: Partial<AdvancedFilterState>) => {
setAdvancedFilters(prev => ({ ...prev, ...updates }));
}, []);
const updateSearchFieldValue = useCallback((key: string, value: string | number | boolean) => {
console.log('[useSearch] updateSearchFieldValue:', key, '=', value);
setSearchFieldValues(prev => {
const next = { ...prev, [key]: value };
console.log('[useSearch] searchFieldValues updated:', next);
return next;
});
}, []);
const resetSortFilter = useCallback(() => {
setAdvancedFilters(prev => ({ ...prev, sort: '' }));
}, []);
const handleSearch = useCallback(async (
query: string,
config: AppConfig | null,
fieldValues?: Record<string, string | number | boolean>
) => {
const searchMode = config?.search_mode || 'direct';
// In universal mode, check if we have either a query or field values
if (searchMode === 'universal') {
const params = new URLSearchParams(query);
const searchQuery = params.get('query') || '';
const sort = params.get('sort') || 'relevance';
// Use explicitly passed fieldValues if provided, otherwise fall back to state
const effectiveFieldValues = fieldValues ?? searchFieldValues;
const hasFieldValues = Object.values(effectiveFieldValues).some(v => v !== '' && v !== false);
// Debug logging
console.log('[useSearch] Universal mode search:', {
query,
searchQuery,
sort,
searchFieldValues,
fieldValues,
effectiveFieldValues,
hasFieldValues,
});
if (!searchQuery && !hasFieldValues) {
console.log('[useSearch] Early return: no query and no field values');
setBooks([]);
setLastSearchQuery('');
return;
}
setIsSearching(true);
setLastSearchQuery(query);
try {
const results = await searchMetadata(searchQuery, 20, sort, effectiveFieldValues);
if (results.length > 0) {
setBooks(results);
} else {
showToast('No results found', 'error');
}
} catch (error) {
if (error instanceof AuthenticationError) {
setIsAuthenticated(false);
if (authRequired) {
navigate('/login', { replace: true });
}
} else {
console.error('Search failed:', error);
// API now returns user-friendly error messages directly
const message = error instanceof Error ? error.message : 'Search failed';
showToast(message, 'error');
}
} finally {
setIsSearching(false);
}
return;
}
// Direct mode: require a query
if (!query) {
setBooks([]);
setLastSearchQuery('');
return;
}
setIsSearching(true);
setLastSearchQuery(query);
try {
const results = await searchBooks(query);
if (results.length > 0) {
setBooks(results);
} else {
showToast('No results found', 'error');
}
} catch (error) {
if (error instanceof AuthenticationError) {
setIsAuthenticated(false);
if (authRequired) {
navigate('/login', { replace: true });
}
} else {
console.error('Search failed:', error);
const message = error instanceof Error ? error.message : 'Search failed';
const friendly = message.includes("Anna's Archive") || message.includes('Network restricted')
? message
: "Unable to reach Anna's Archive. Network may be restricted or mirrors blocked.";
showToast(friendly, 'error');
}
} finally {
setIsSearching(false);
}
}, [showToast, setIsAuthenticated, authRequired, navigate, searchFieldValues]);
const handleResetSearch = useCallback((config: AppConfig | null) => {
setBooks([]);
setSearchInput('');
setShowAdvanced(false);
setLastSearchQuery('');
onSearchReset?.();
const resetFormats = config?.supported_formats || DEFAULT_FORMAT_SELECTION;
setAdvancedFilters({
isbn: '',
author: '',
title: '',
lang: [LANGUAGE_OPTION_DEFAULT],
sort: '',
content: '',
formats: resetFormats,
});
// Reset universal mode search field values
setSearchFieldValues({});
}, [onSearchReset]);
const handleSortChange = useCallback((value: string, config: AppConfig | null) => {
updateAdvancedFilters({ sort: value });
if (!lastSearchQuery) return;
const params = new URLSearchParams(lastSearchQuery);
if (value) {
params.set('sort', value);
} else {
params.delete('sort');
}
const nextQuery = params.toString();
if (!nextQuery) return;
handleSearch(nextQuery, config);
}, [lastSearchQuery, updateAdvancedFilters, handleSearch]);
return {
books,
setBooks,
isSearching,
lastSearchQuery,
searchInput,
setSearchInput,
showAdvanced,
setShowAdvanced,
advancedFilters,
setAdvancedFilters,
updateAdvancedFilters,
handleSearch,
handleResetSearch,
handleSortChange,
resetSortFilter,
// Universal mode search field values
searchFieldValues,
updateSearchFieldValue,
};
}
+264
View File
@@ -0,0 +1,264 @@
import { useState, useCallback, useEffect } from 'react';
import { getSettings, updateSettings, executeSettingsAction } from '../services/api';
import {
SettingsTab,
SettingsGroup,
SettingsField,
SelectFieldConfig,
ActionResult,
UpdateResult,
} from '../types/settings';
// Client-side only theme field that gets injected into the general tab
const THEME_FIELD: SelectFieldConfig = {
type: 'SelectField',
key: '_THEME',
label: 'Theme',
description: 'Choose your preferred color scheme.',
value: 'auto', // Placeholder, actual value comes from localStorage
options: [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'auto', label: 'Auto (System)' },
],
};
// Apply theme to document
function applyTheme(theme: string): void {
const effectiveTheme = theme === 'auto'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme;
document.documentElement.setAttribute('data-theme', effectiveTheme);
}
// Extract value from a field based on its type
function getFieldValue(field: SettingsField): unknown {
// These field types have no value property
if (field.type === 'ActionButton' || field.type === 'HeadingField') {
return undefined;
}
// All other fields have a value property
return field.value ?? '';
}
interface UseSettingsReturn {
tabs: SettingsTab[];
groups: SettingsGroup[];
isLoading: boolean;
error: string | null;
selectedTab: string | null;
setSelectedTab: (tab: string | null) => void;
values: Record<string, Record<string, unknown>>;
updateValue: (tabName: string, key: string, value: unknown) => void;
hasChanges: (tabName: string) => boolean;
saveTab: (tabName: string) => Promise<UpdateResult>;
executeAction: (tabName: string, actionKey: string) => Promise<ActionResult>;
isSaving: boolean;
refetch: () => Promise<void>;
}
export function useSettings(): UseSettingsReturn {
const [tabs, setTabs] = useState<SettingsTab[]>([]);
const [groups, setGroups] = useState<SettingsGroup[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedTab, setSelectedTab] = useState<string | null>(null);
const [values, setValues] = useState<Record<string, Record<string, unknown>>>({});
const [originalValues, setOriginalValues] = useState<Record<string, Record<string, unknown>>>({});
const [isSaving, setIsSaving] = useState(false);
const fetchSettings = useCallback(async (silent = false) => {
if (!silent) {
setIsLoading(true);
}
setError(null);
try {
const response = await getSettings();
// Inject theme field into the general tab at the beginning
const tabsWithTheme = response.tabs.map((tab) => {
if (tab.name === 'general') {
return {
...tab,
fields: [THEME_FIELD, ...tab.fields],
};
}
return tab;
});
setTabs(tabsWithTheme);
setGroups(response.groups || []);
// Initialize values from fetched data
const initialValues: Record<string, Record<string, unknown>> = {};
tabsWithTheme.forEach((tab) => {
initialValues[tab.name] = {};
tab.fields.forEach((field) => {
if (field.type !== 'ActionButton') {
// Special handling for theme field - get from localStorage
if (field.key === '_THEME') {
initialValues[tab.name][field.key] = localStorage.getItem('preferred-theme') || 'auto';
} else {
initialValues[tab.name][field.key] = getFieldValue(field);
}
}
});
});
setValues(initialValues);
setOriginalValues(JSON.parse(JSON.stringify(initialValues)));
// Select first tab by default if none selected
if (tabsWithTheme.length > 0) {
setSelectedTab((current) => current ?? tabsWithTheme[0].name);
}
} catch (err) {
console.error('Failed to fetch settings:', err);
setError(err instanceof Error ? err.message : 'Failed to load settings');
} finally {
if (!silent) {
setIsLoading(false);
}
}
}, []);
useEffect(() => {
fetchSettings();
}, [fetchSettings]);
const updateValue = useCallback((tabName: string, key: string, value: unknown) => {
// Apply theme immediately when changed (no save button needed)
if (key === '_THEME' && typeof value === 'string') {
localStorage.setItem('preferred-theme', value);
applyTheme(value);
// Also update original value so it doesn't show as pending change
setOriginalValues((prev) => ({
...prev,
[tabName]: {
...prev[tabName],
[key]: value,
},
}));
}
setValues((prev) => ({
...prev,
[tabName]: {
...prev[tabName],
[key]: value,
},
}));
}, []);
const hasChanges = useCallback(
(tabName: string) => {
const current = values[tabName];
const original = originalValues[tabName];
if (!current || !original) return false;
const tab = tabs.find((t) => t.name === tabName);
if (!tab) return false;
for (const field of tab.fields) {
if (field.type === 'ActionButton' || field.type === 'HeadingField') continue;
const currentValue = current[field.key];
const originalValue = original[field.key];
// Compare values - works for all field types including password
if (JSON.stringify(currentValue) !== JSON.stringify(originalValue)) {
return true;
}
}
return false;
},
[values, originalValues, tabs]
);
const saveTab = useCallback(
async (tabName: string): Promise<UpdateResult> => {
setIsSaving(true);
try {
const tabValues = values[tabName] || {};
const originalTabValues = originalValues[tabName] || {};
// Only send values that actually changed
const tab = tabs.find((t) => t.name === tabName);
const valuesToSave: Record<string, unknown> = {};
if (tab) {
for (const field of tab.fields) {
if (field.type === 'ActionButton' || field.type === 'HeadingField') continue;
if (field.fromEnv) continue; // Skip env-locked fields
if (field.key === '_THEME') continue; // Skip client-side only theme field
const value = tabValues[field.key];
const originalValue = originalTabValues[field.key];
// Skip empty password fields
if (field.type === 'PasswordField' && (!value || value === '')) {
continue;
}
// Only include if value actually changed
if (JSON.stringify(value) !== JSON.stringify(originalValue)) {
valuesToSave[field.key] = value;
}
}
}
const result = await updateSettings(tabName, valuesToSave);
if (result.success) {
// Refetch all settings silently to pick up any backend-triggered changes
// (e.g., enabling a metadata provider auto-updates METADATA_PROVIDER)
await fetchSettings(true);
}
return result;
} catch (err) {
console.error('Failed to save settings tab:', tabName, err);
return {
success: false,
message: err instanceof Error ? err.message : 'Failed to save settings',
updated: [],
};
} finally {
setIsSaving(false);
}
},
[values, originalValues, tabs]
);
const executeAction = useCallback(
async (tabName: string, actionKey: string): Promise<ActionResult> => {
try {
// Pass current form values so action can use unsaved values
const currentValues = values[tabName] || {};
return await executeSettingsAction(tabName, actionKey, currentValues);
} catch (err) {
console.error('Action execution failed:', tabName, actionKey, err);
return {
success: false,
message: err instanceof Error ? err.message : 'Action failed',
};
}
},
[values]
);
return {
tabs,
groups,
isLoading,
error,
selectedTab,
setSelectedTab,
values,
updateValue,
hasChanges,
saveTab,
executeAction,
isSaving,
refetch: fetchSettings,
};
}
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { parseUrlSearchParams, ParsedUrlSearch } from '../utils/parseUrlSearchParams';
interface UseUrlSearchOptions {
/** Only process URL params after auth check and config are loaded */
enabled: boolean;
}
interface UseUrlSearchReturn {
/** Parsed URL parameters, or null if none found */
parsedParams: ParsedUrlSearch | null;
/** Whether URL has been processed (regardless of whether params existed) */
wasProcessed: boolean;
}
/**
* Hook to parse URL search parameters on initial page load.
*
* This is a read-only operation - URL params are parsed once when enabled,
* and the URL is not updated when users perform searches.
*
* @example
* // In App.tsx:
* const { parsedParams, wasProcessed } = useUrlSearch({
* enabled: isAuthenticated && config !== null,
* });
*
* useEffect(() => {
* if (wasProcessed && parsedParams?.hasSearchParams) {
* // Trigger search with parsed params
* }
* }, [wasProcessed, parsedParams]);
*/
export function useUrlSearch({ enabled }: UseUrlSearchOptions): UseUrlSearchReturn {
const [searchParams] = useSearchParams();
const processedRef = useRef(false);
const parsedRef = useRef<ParsedUrlSearch | null>(null);
useEffect(() => {
if (enabled && !processedRef.current) {
const parsed = parseUrlSearchParams(searchParams);
if (parsed.hasSearchParams) {
parsedRef.current = parsed;
}
processedRef.current = true;
}
}, [enabled, searchParams]);
return {
parsedParams: parsedRef.current,
wasProcessed: processedRef.current,
};
}
+149 -6
View File
@@ -1,10 +1,13 @@
import { Book, StatusData, AppConfig, LoginCredentials, AuthResponse } from '../types';
import { Book, StatusData, AppConfig, LoginCredentials, AuthResponse, ReleaseSource, ReleasesResponse } from '../types';
import { SettingsResponse, ActionResult, UpdateResult } from '../types/settings';
import { MetadataBookData, transformMetadataToBook } from '../utils/bookTransformers';
const API_BASE = '/api';
// API endpoints
const API = {
search: `${API_BASE}/search`,
metadataSearch: `${API_BASE}/metadata/search`,
info: `${API_BASE}/info`,
download: `${API_BASE}/download`,
status: `${API_BASE}/status`,
@@ -14,7 +17,8 @@ const API = {
config: `${API_BASE}/config`,
login: `${API_BASE}/auth/login`,
logout: `${API_BASE}/auth/logout`,
authCheck: `${API_BASE}/auth/check`
authCheck: `${API_BASE}/auth/check`,
settings: `${API_BASE}/settings`,
};
// Custom error class for authentication failures
@@ -41,18 +45,22 @@ async function fetchJSON<T>(url: string, opts: RequestInit = {}): Promise<T> {
let errorMessage = `${res.status} ${res.statusText}`;
try {
const errorData = await res.json();
if (errorData.error) {
// Prefer user-friendly 'message' field, fall back to 'error'
if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.error) {
errorMessage = errorData.error;
}
} catch (e) {
// If we can't parse JSON, use the default error message
// Log parse failure for debugging - server may have returned non-JSON (e.g., HTML error page)
console.warn(`Failed to parse error response from ${url}:`, e instanceof Error ? e.message : e);
}
// Throw appropriate error based on status code
if (res.status === 401) {
throw new AuthenticationError(errorMessage);
}
throw new Error(errorMessage);
}
@@ -65,14 +73,92 @@ export const searchBooks = async (query: string): Promise<Book[]> => {
return fetchJSON<Book[]>(`${API.search}?${query}`);
};
// Metadata search response type (internal)
interface MetadataSearchResponse {
books: MetadataBookData[];
provider: string;
query: string;
}
// Search metadata providers and normalize to Book format
export const searchMetadata = async (
query: string,
limit: number = 20,
sort: string = 'relevance',
fields: Record<string, string | number | boolean> = {}
): Promise<Book[]> => {
const hasFields = Object.values(fields).some(v => v !== '' && v !== false);
// Debug logging
console.log('[api.searchMetadata] Called with:', { query, limit, sort, fields, hasFields });
if (!query && !hasFields) {
console.log('[api.searchMetadata] Early return: no query and no fields');
return [];
}
const params = new URLSearchParams();
if (query) {
params.set('query', query);
}
params.set('limit', String(limit));
params.set('sort', sort);
// Add custom search field values
Object.entries(fields).forEach(([key, value]) => {
if (value !== '' && value !== false) {
params.set(key, String(value));
}
});
const requestUrl = `${API.metadataSearch}?${params.toString()}`;
console.log('[api.searchMetadata] Request URL:', requestUrl);
const response = await fetchJSON<MetadataSearchResponse>(requestUrl);
console.log('[api.searchMetadata] Response:', response);
return response.books.map(transformMetadataToBook);
};
export const getBookInfo = async (id: string): Promise<Book> => {
return fetchJSON<Book>(`${API.info}?id=${encodeURIComponent(id)}`);
};
// Get full book details from a metadata provider
export const getMetadataBookInfo = async (provider: string, bookId: string): Promise<Book> => {
const response = await fetchJSON<MetadataBookData>(
`${API_BASE}/metadata/book/${encodeURIComponent(provider)}/${encodeURIComponent(bookId)}`
);
return transformMetadataToBook(response);
};
export const downloadBook = async (id: string): Promise<void> => {
await fetchJSON(`${API.download}?id=${encodeURIComponent(id)}`);
};
// Download a specific release (from ReleaseModal)
export const downloadRelease = async (release: {
source: string;
source_id: string;
title: string;
format?: string;
size?: string;
size_bytes?: number;
download_url?: string;
protocol?: string;
indexer?: string;
seeders?: number;
extra?: Record<string, unknown>;
preview?: string; // Book cover from metadata provider
author?: string; // Author from metadata provider
}): Promise<void> => {
await fetchJSON(`${API_BASE}/releases/download`, {
method: 'POST',
body: JSON.stringify(release),
});
};
export const getStatus = async (): Promise<StatusData> => {
return fetchJSON<StatusData>(API.status);
};
@@ -106,3 +192,60 @@ export const logout = async (): Promise<AuthResponse> => {
export const checkAuth = async (): Promise<AuthResponse> => {
return fetchJSON<AuthResponse>(API.authCheck);
};
// Settings API functions
export const getSettings = async (): Promise<SettingsResponse> => {
return fetchJSON<SettingsResponse>(API.settings);
};
export const updateSettings = async (
tabName: string,
values: Record<string, unknown>
): Promise<UpdateResult> => {
return fetchJSON<UpdateResult>(`${API.settings}/${tabName}`, {
method: 'PUT',
body: JSON.stringify(values),
});
};
export const executeSettingsAction = async (
tabName: string,
actionKey: string,
currentValues?: Record<string, unknown>
): Promise<ActionResult> => {
return fetchJSON<ActionResult>(`${API.settings}/${tabName}/action/${actionKey}`, {
method: 'POST',
body: currentValues ? JSON.stringify(currentValues) : undefined,
});
};
// Release source API functions
// Get available release sources from plugin registry
export const getReleaseSources = async (): Promise<ReleaseSource[]> => {
return fetchJSON<ReleaseSource[]>(`${API_BASE}/release-sources`);
};
// Search for releases of a book
export const getReleases = async (
provider: string,
bookId: string,
source?: string,
title?: string,
author?: string
): Promise<ReleasesResponse> => {
const params = new URLSearchParams({
provider,
book_id: bookId,
});
if (source) {
params.set('source', source);
}
if (title) {
params.set('title', title);
}
if (author) {
params.set('author', author);
}
return fetchJSON<ReleasesResponse>(`${API_BASE}/releases?${params.toString()}`);
};
+81 -1
View File
@@ -3,6 +3,17 @@
@tailwind components;
@tailwind utilities;
/* Custom utilities */
@layer utilities {
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
}
/* Disable transitions on initial load to prevent flash */
html.preload *,
html.preload *::before,
@@ -247,7 +258,27 @@ footer {
}
/* Responsive Design */
@media (max-width: 768px) {
@media (max-width: 639px) {
.modal-overlay {
padding: 0;
align-items: stretch;
/* Extend background into iOS safe areas */
background: var(--bg-soft);
}
.details-container {
max-width: none;
max-height: none;
height: 100%;
/* Add safe area padding for iOS notch/home indicator */
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
}
@media (min-width: 640px) and (max-width: 768px) {
.modal-overlay {
padding: 1rem;
}
@@ -545,3 +576,52 @@ button:disabled {
background-size: 200% 100%;
animation: shimmer 2s ease-in-out infinite;
}
/* Settings Modal Animations */
@keyframes settings-modal-enter {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes settings-modal-exit {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.95);
}
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
.settings-modal-enter {
animation: settings-modal-enter 0.2s ease-out;
}
.settings-modal-exit {
animation: settings-modal-exit 0.15s ease-in;
}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}
.animate-fade-out {
animation: fade-out 0.15s ease-in;
}
+168
View File
@@ -1,3 +1,18 @@
// Search mode constants and type
export const SEARCH_MODE = {
DIRECT: 'direct',
UNIVERSAL: 'universal',
} as const;
export type SearchMode = typeof SEARCH_MODE[keyof typeof SEARCH_MODE];
// Display field for metadata cards (provider-specific info like ratings, pages, etc.)
export interface DisplayField {
label: string; // e.g., "Rating", "Pages", "Readers"
value: string; // e.g., "4.5", "496", "8,041"
icon?: string; // Icon name: "star", "book", "users", "editions"
}
// Book data types
export interface Book {
id: string;
@@ -15,6 +30,17 @@ export interface Book {
progress?: number;
status_message?: string; // Detailed status message (e.g., "Trying Libgen (2/5)")
added_time?: number; // Timestamp when added to queue
source?: string; // Release source handler (e.g., "direct_download", "prowlarr")
source_display_name?: string; // Human-readable source name (e.g., "Direct Download")
// Metadata provider fields (used in universal search mode)
provider?: string; // e.g., 'hardcover', 'openlibrary'
provider_display_name?: string; // e.g., 'Hardcover', 'Open Library'
provider_id?: string; // ID in provider's system
isbn_10?: string;
isbn_13?: string;
genres?: string[];
source_url?: string; // Link to book on provider's site
display_fields?: DisplayField[]; // Provider-specific display data
}
// Status response types
@@ -63,6 +89,54 @@ export interface Toast {
type: 'success' | 'error' | 'info';
}
// Sort option for dropdowns
export interface SortOption {
value: string;
label: string;
}
// Search field types (mirror backend search field types)
export type SearchFieldType =
| 'TextSearchField'
| 'NumberSearchField'
| 'SelectSearchField'
| 'CheckboxSearchField';
interface SearchFieldBase {
key: string;
label: string;
type: SearchFieldType;
placeholder?: string;
description?: string;
}
export interface TextSearchField extends SearchFieldBase {
type: 'TextSearchField';
}
export interface NumberSearchField extends SearchFieldBase {
type: 'NumberSearchField';
min?: number;
max?: number;
step?: number;
}
export interface SelectSearchField extends SearchFieldBase {
type: 'SelectSearchField';
options: SortOption[];
}
export interface CheckboxSearchField extends SearchFieldBase {
type: 'CheckboxSearchField';
default?: boolean;
}
export type MetadataSearchField =
| TextSearchField
| NumberSearchField
| SelectSearchField
| CheckboxSearchField;
// App configuration
export interface AppConfig {
calibre_web_url: string;
@@ -72,6 +146,11 @@ export interface AppConfig {
book_languages: Language[];
default_language: string[];
supported_formats: string[];
search_mode: SearchMode;
metadata_sort_options: SortOption[];
metadata_search_fields: MetadataSearchField[];
default_release_source?: string; // Default tab in ReleaseModal (e.g., 'direct_download')
settings_enabled: boolean; // Whether config directory is mounted and writable
}
// Authentication types
@@ -87,3 +166,92 @@ export interface AuthResponse {
auth_required?: boolean;
error?: string;
}
// Type guard to check if a book is from a metadata provider
// Returns true and narrows type to include required provider fields
export const isMetadataBook = (book: Book): book is Book & {
provider: string;
provider_id: string;
} => {
return !!book.provider && !!book.provider_id;
};
// Release source types (from plugin system)
export interface ReleaseSource {
name: string; // e.g., 'direct_download', 'prowlarr'
display_name: string; // e.g., 'Direct Download', 'Prowlarr'
}
// Column schema types for plugin-driven release list UI
export type ColumnRenderType = 'text' | 'badge' | 'size' | 'number' | 'peers';
export type ColumnAlign = 'left' | 'center' | 'right';
export interface ColumnColorHint {
type: 'map' | 'static'; // 'map' uses colorMaps.ts, 'static' is a fixed Tailwind class
value: string; // Map name ('format', 'language') or Tailwind class
}
export interface ColumnSchema {
key: string; // Data path (e.g., 'format', 'extra.language')
label: string; // Accessibility label
render_type: ColumnRenderType;
align: ColumnAlign;
width: string; // CSS width (e.g., '80px')
hide_mobile: boolean; // Hide on small screens
color_hint?: ColumnColorHint | null;
fallback: string; // Value when data is missing
uppercase: boolean; // Force uppercase display
}
// Leading cell config - what to show in the left-most position of each row
export type LeadingCellType = 'thumbnail' | 'badge' | 'none';
export interface LeadingCellConfig {
type: LeadingCellType;
key?: string; // Field path for data (e.g., 'extra.preview' or 'extra.download_type')
color_hint?: ColumnColorHint; // For badge type - maps values to colors
uppercase?: boolean; // Force uppercase for badge text
}
export interface ReleaseColumnConfig {
columns: ColumnSchema[];
grid_template: string; // CSS grid-template-columns for dynamic section
leading_cell?: LeadingCellConfig; // Defaults to thumbnail from extra.preview
}
// A downloadable release from any source
export interface Release {
source: string; // Source plugin name
source_id: string; // ID within that source
title: string;
format?: string; // epub, pdf, mobi, etc.
language?: string; // ISO 639-1 code (e.g., "en", "de", "fr")
size?: string; // Human-readable size
size_bytes?: number; // Size in bytes for sorting
download_url?: string;
info_url?: string; // Link to release info page (e.g., tracker page) - makes title clickable
protocol?: 'http' | 'torrent' | 'nzb' | 'dcc';
indexer?: string; // Display name for the source/indexer
seeders?: number; // For torrents
peers?: string; // For torrents: "seeders/leechers" display string
extra?: Record<string, unknown>; // Source-specific metadata
}
// Response from /api/releases endpoint
export interface ReleasesResponse {
releases: Release[];
book: {
provider: string;
provider_id: string;
title: string;
authors?: string[];
isbn_10?: string;
isbn_13?: string;
cover_url?: string;
publish_year?: number;
language?: string;
};
sources_searched: string[];
errors?: string[];
column_config?: ReleaseColumnConfig | null; // Plugin-driven column configuration
}
+148
View File
@@ -0,0 +1,148 @@
// Settings field types matching backend settings_registry.py
export type FieldType =
| 'TextField'
| 'PasswordField'
| 'NumberField'
| 'CheckboxField'
| 'SelectField'
| 'MultiSelectField'
| 'ActionButton'
| 'HeadingField';
export interface SelectOption {
value: string;
label: string;
}
// Conditional visibility configuration
export interface ShowWhenCondition {
field: string; // The field key to check
value: string | string[]; // The value(s) that make this field visible
}
// Conditional disable configuration
export interface DisabledWhenCondition {
field: string; // The field key to check
value: string | string[] | boolean; // The value(s) that disable this field
reason?: string; // Explanation shown when disabled
}
// Base field interface - common properties
export interface BaseField {
key: string;
label: string;
type: FieldType;
description?: string;
required?: boolean;
fromEnv?: boolean; // True if value is set via environment variable
disabled?: boolean; // True if field is disabled/greyed out
disabledReason?: string; // Explanation shown when field is disabled
showWhen?: ShowWhenCondition; // Conditional visibility based on another field's value
disabledWhen?: DisabledWhenCondition; // Conditional disable based on another field's value
requiresRestart?: boolean; // True if changing this setting requires a container restart
}
// Specific field interfaces
export interface TextFieldConfig extends BaseField {
type: 'TextField';
value: string;
placeholder?: string;
maxLength?: number;
}
export interface PasswordFieldConfig extends BaseField {
type: 'PasswordField';
value: string;
placeholder?: string;
}
export interface NumberFieldConfig extends BaseField {
type: 'NumberField';
value: number;
min?: number;
max?: number;
step?: number;
}
export interface CheckboxFieldConfig extends BaseField {
type: 'CheckboxField';
value: boolean;
}
export interface SelectFieldConfig extends BaseField {
type: 'SelectField';
value: string;
options: SelectOption[];
}
export interface MultiSelectFieldConfig extends BaseField {
type: 'MultiSelectField';
value: string[];
options: SelectOption[];
}
export interface ActionButtonConfig extends BaseField {
type: 'ActionButton';
style: 'default' | 'primary' | 'danger';
}
export interface HeadingFieldConfig {
key: string;
type: 'HeadingField';
title: string;
description?: string;
linkUrl?: string;
linkText?: string;
}
// Union type for all fields
export type SettingsField =
| TextFieldConfig
| PasswordFieldConfig
| NumberFieldConfig
| CheckboxFieldConfig
| SelectFieldConfig
| MultiSelectFieldConfig
| ActionButtonConfig
| HeadingFieldConfig;
// Settings tab structure
export interface SettingsTab {
name: string; // Internal identifier
displayName: string; // UI display name
icon?: string; // Icon name
order: number; // Sort order
group?: string; // Group this tab belongs to
fields: SettingsField[];
}
// Settings group structure
export interface SettingsGroup {
name: string; // Internal identifier
displayName: string; // UI display name
icon?: string; // Icon name
order: number; // Sort order
}
// API response types
export interface SettingsResponse {
tabs: SettingsTab[];
groups: SettingsGroup[];
}
export interface ActionResult {
success: boolean;
message: string;
}
export interface UpdateResult {
success: boolean;
message: string;
updated: string[]; // Keys that were updated
requiresRestart?: boolean; // True if any updated setting requires a restart
restartRequiredFor?: string[]; // Keys of settings that require restart
}
// Form values type - maps field keys to their values
export type SettingsValues = Record<string, Record<string, unknown>>;
@@ -0,0 +1,57 @@
import { Book } from '../types';
/**
* Raw metadata book data from the API (provider responses).
* Used by both search and single-book endpoints.
*/
export interface MetadataBookData {
provider: string;
provider_display_name?: string;
provider_id: string;
title: string;
authors?: string[];
isbn_10?: string;
isbn_13?: string;
cover_url?: string;
description?: string;
publisher?: string;
publish_year?: number;
language?: string;
genres?: string[];
source_url?: string;
display_fields?: Array<{
label: string;
value: string;
icon?: string;
}>;
}
/**
* Transform raw metadata book data to the frontend Book format.
* Handles ID generation, author joining, and info object construction.
*/
export function transformMetadataToBook(data: MetadataBookData): Book {
return {
id: `${data.provider}:${data.provider_id}`,
title: data.title,
author: data.authors?.join(', ') || 'Unknown',
year: data.publish_year?.toString(),
language: data.language,
preview: data.cover_url,
publisher: data.publisher,
description: data.description,
provider: data.provider,
provider_display_name: data.provider_display_name,
provider_id: data.provider_id,
isbn_10: data.isbn_10,
isbn_13: data.isbn_13,
genres: data.genres,
source_url: data.source_url,
display_fields: data.display_fields,
info: {
...(data.isbn_13 && { ISBN: data.isbn_13 }),
...(data.isbn_10 && !data.isbn_13 && { ISBN: data.isbn_10 }),
...(data.genres && data.genres.length > 0 && { Genres: data.genres }),
},
};
}
@@ -7,6 +7,7 @@ interface BuildSearchQueryOptions {
advancedFilters: AdvancedFilterState;
bookLanguages: Language[];
defaultLanguage: string[];
searchMode?: 'direct' | 'universal';
}
export const buildSearchQuery = ({
@@ -15,6 +16,7 @@ export const buildSearchQuery = ({
advancedFilters,
bookLanguages,
defaultLanguage,
searchMode = 'direct',
}: BuildSearchQueryOptions): string => {
const queryParts: string[] = [];
@@ -23,6 +25,16 @@ export const buildSearchQuery = ({
queryParts.push(`query=${encodeURIComponent(basic)}`);
}
// In universal mode, only include query and sort
// Provider-specific fields are handled separately via searchFieldValues
if (searchMode === 'universal') {
if (advancedFilters.sort) {
queryParts.push(`sort=${encodeURIComponent(advancedFilters.sort)}`);
}
return queryParts.join('&');
}
// Direct mode: include all Anna's Archive filters
if (showAdvanced) {
const { isbn, author, title, content, formats, lang } = advancedFilters;
+68
View File
@@ -0,0 +1,68 @@
// Color styles with transparent backgrounds and contrasting text (matches DownloadsSidebar style)
interface ColorStyle {
bg: string;
text: string;
}
const FORMAT_COLORS: Record<string, ColorStyle> = {
pdf: { bg: 'bg-red-500/20', text: 'text-red-700 dark:text-red-300' },
epub: { bg: 'bg-green-500/20', text: 'text-green-700 dark:text-green-300' },
mobi: { bg: 'bg-blue-500/20', text: 'text-blue-700 dark:text-blue-300' },
azw3: { bg: 'bg-purple-500/20', text: 'text-purple-700 dark:text-purple-300' },
txt: { bg: 'bg-gray-500/20', text: 'text-gray-700 dark:text-gray-300' },
djvu: { bg: 'bg-orange-500/20', text: 'text-orange-700 dark:text-orange-300' },
fb2: { bg: 'bg-teal-500/20', text: 'text-teal-700 dark:text-teal-300' },
cbr: { bg: 'bg-yellow-500/20', text: 'text-yellow-700 dark:text-yellow-300' },
cbz: { bg: 'bg-amber-500/20', text: 'text-amber-700 dark:text-amber-300' },
};
const LANGUAGE_COLORS: Record<string, ColorStyle> = {
en: { bg: 'bg-blue-500/20', text: 'text-blue-700 dark:text-blue-300' },
english: { bg: 'bg-blue-500/20', text: 'text-blue-700 dark:text-blue-300' },
es: { bg: 'bg-orange-500/20', text: 'text-orange-700 dark:text-orange-300' },
spanish: { bg: 'bg-orange-500/20', text: 'text-orange-700 dark:text-orange-300' },
fr: { bg: 'bg-purple-500/20', text: 'text-purple-700 dark:text-purple-300' },
french: { bg: 'bg-purple-500/20', text: 'text-purple-700 dark:text-purple-300' },
de: { bg: 'bg-yellow-500/20', text: 'text-yellow-700 dark:text-yellow-300' },
german: { bg: 'bg-yellow-500/20', text: 'text-yellow-700 dark:text-yellow-300' },
it: { bg: 'bg-green-500/20', text: 'text-green-700 dark:text-green-300' },
italian: { bg: 'bg-green-500/20', text: 'text-green-700 dark:text-green-300' },
pt: { bg: 'bg-teal-500/20', text: 'text-teal-700 dark:text-teal-300' },
portuguese: { bg: 'bg-teal-500/20', text: 'text-teal-700 dark:text-teal-300' },
ru: { bg: 'bg-red-500/20', text: 'text-red-700 dark:text-red-300' },
russian: { bg: 'bg-red-500/20', text: 'text-red-700 dark:text-red-300' },
ja: { bg: 'bg-pink-500/20', text: 'text-pink-700 dark:text-pink-300' },
japanese: { bg: 'bg-pink-500/20', text: 'text-pink-700 dark:text-pink-300' },
zh: { bg: 'bg-rose-500/20', text: 'text-rose-700 dark:text-rose-300' },
chinese: { bg: 'bg-rose-500/20', text: 'text-rose-700 dark:text-rose-300' },
};
const DOWNLOAD_TYPE_COLORS: Record<string, ColorStyle> = {
torrent: { bg: 'bg-orange-500/20', text: 'text-orange-700 dark:text-orange-300' },
usenet: { bg: 'bg-sky-500/20', text: 'text-sky-700 dark:text-sky-300' },
nzb: { bg: 'bg-sky-500/20', text: 'text-sky-700 dark:text-sky-300' },
ddl: { bg: 'bg-emerald-500/20', text: 'text-emerald-700 dark:text-emerald-300' },
direct: { bg: 'bg-emerald-500/20', text: 'text-emerald-700 dark:text-emerald-300' },
};
const DEFAULT_FORMAT_COLOR: ColorStyle = { bg: 'bg-cyan-500/20', text: 'text-cyan-700 dark:text-cyan-300' };
const DEFAULT_LANGUAGE_COLOR: ColorStyle = { bg: 'bg-indigo-500/20', text: 'text-indigo-700 dark:text-indigo-300' };
const DEFAULT_DOWNLOAD_TYPE_COLOR: ColorStyle = { bg: 'bg-violet-500/20', text: 'text-violet-700 dark:text-violet-300' };
const FALLBACK_COLOR: ColorStyle = { bg: 'bg-gray-500/20', text: 'text-gray-700 dark:text-gray-300' };
export function getFormatColor(format?: string): ColorStyle {
if (!format || format === '-') return FALLBACK_COLOR;
return FORMAT_COLORS[format.toLowerCase()] || DEFAULT_FORMAT_COLOR;
}
export function getLanguageColor(language?: string): ColorStyle {
if (!language || language === '-') return FALLBACK_COLOR;
return LANGUAGE_COLORS[language.toLowerCase()] || DEFAULT_LANGUAGE_COLOR;
}
export function getDownloadTypeColor(downloadType?: string): ColorStyle {
if (!downloadType || downloadType === '-') return FALLBACK_COLOR;
return DOWNLOAD_TYPE_COLORS[downloadType.toLowerCase()] || DEFAULT_DOWNLOAD_TYPE_COLOR;
}
export type { ColorStyle };
@@ -0,0 +1,80 @@
import { AdvancedFilterState } from '../types';
/**
* Parsed search parameters from URL
*/
export interface ParsedUrlSearch {
searchInput: string;
advancedFilters: Partial<AdvancedFilterState>;
hasSearchParams: boolean;
}
/**
* Parse URL search parameters into search state.
*
* Supports both Direct Download and Universal mode parameters.
* In Universal mode, only query and sort are used (others are parsed but
* ignored by buildSearchQuery).
*
* @example
* // Direct mode: /?q=harry+potter&author=rowling&format=epub&lang=en
* // Universal mode: /?q=dune&sort=popularity
*/
export function parseUrlSearchParams(searchParams: URLSearchParams): ParsedUrlSearch {
const result: ParsedUrlSearch = {
searchInput: '',
advancedFilters: {},
hasSearchParams: false,
};
// Parse main query (support both 'q' and 'query' for flexibility)
const query = searchParams.get('q') || searchParams.get('query') || '';
if (query) {
result.searchInput = query;
result.hasSearchParams = true;
}
// Parse single-value filters
const isbn = searchParams.get('isbn');
const author = searchParams.get('author');
const title = searchParams.get('title');
const sort = searchParams.get('sort');
const content = searchParams.get('content');
if (isbn) {
result.advancedFilters.isbn = isbn;
result.hasSearchParams = true;
}
if (author) {
result.advancedFilters.author = author;
result.hasSearchParams = true;
}
if (title) {
result.advancedFilters.title = title;
result.hasSearchParams = true;
}
if (sort) {
result.advancedFilters.sort = sort;
result.hasSearchParams = true;
}
if (content) {
result.advancedFilters.content = content;
result.hasSearchParams = true;
}
// Parse multi-value filters (can appear multiple times in URL)
// e.g., /?lang=en&lang=de or /?format=epub&format=mobi
const langValues = searchParams.getAll('lang').filter(Boolean);
const formatValues = searchParams.getAll('format').filter(Boolean);
if (langValues.length > 0) {
result.advancedFilters.lang = langValues;
result.hasSearchParams = true;
}
if (formatValues.length > 0) {
result.advancedFilters.formats = formatValues;
result.hasSearchParams = true;
}
return result;
}