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