mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-02-20 15:56:36 -05:00
- Refactored activity backend for full user-level management, using the db file - Revamped the activity sidebar UX and categorisation - Added download history and user filtering - Added User Preferences modal, giving limited configuration for non-admins - replaces the "restrict settings" config option. - Many many bug fixes - Many many new tests
1420 lines
47 KiB
TypeScript
1420 lines
47 KiB
TypeScript
import { useState, useEffect, useCallback, useRef, useMemo, CSSProperties } from 'react';
|
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
|
import {
|
|
Book,
|
|
Release,
|
|
RequestRecord,
|
|
StatusData,
|
|
AppConfig,
|
|
ContentType,
|
|
ButtonStateInfo,
|
|
RequestPolicyMode,
|
|
CreateRequestPayload,
|
|
} from './types';
|
|
import {
|
|
getBookInfo,
|
|
getMetadataBookInfo,
|
|
downloadBook,
|
|
downloadRelease,
|
|
cancelDownload,
|
|
getConfig,
|
|
createRequest,
|
|
isApiResponseError,
|
|
} 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 { useRequestPolicy } from './hooks/useRequestPolicy';
|
|
import { resolveDefaultModeFromPolicy, resolveSourceModeFromPolicy } from './hooks/requestPolicyCore';
|
|
import { useRequests } from './hooks/useRequests';
|
|
import { useActivity } from './hooks/useActivity';
|
|
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 { RequestConfirmationModal } from './components/RequestConfirmationModal';
|
|
import { ToastContainer } from './components/ToastContainer';
|
|
import { Footer } from './components/Footer';
|
|
import { ActivitySidebar } from './components/activity';
|
|
import { LoginPage } from './pages/LoginPage';
|
|
import { SelfSettingsModal, SettingsModal } from './components/settings';
|
|
import { ConfigSetupBanner } from './components/ConfigSetupBanner';
|
|
import { OnboardingModal } from './components/OnboardingModal';
|
|
import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages';
|
|
import { buildSearchQuery } from './utils/buildSearchQuery';
|
|
import { withBasePath } from './utils/basePath';
|
|
import {
|
|
applyDirectPolicyModeToButtonState,
|
|
applyUniversalPolicyModeToButtonState,
|
|
} from './utils/requestPolicyUi';
|
|
import {
|
|
buildDirectRequestPayload,
|
|
buildMetadataBookRequestData,
|
|
buildReleaseDataFromMetadataRelease,
|
|
getRequestSuccessMessage,
|
|
toContentType,
|
|
} from './utils/requestPayload';
|
|
import { bookFromRequestData } from './utils/requestFulfil';
|
|
import { policyTrace } from './utils/policyTrace';
|
|
import { SearchModeProvider } from './contexts/SearchModeContext';
|
|
import { useSocket } from './contexts/SocketContext';
|
|
import './styles.css';
|
|
|
|
const CONTENT_TYPE_STORAGE_KEY = 'preferred-content-type';
|
|
|
|
const getInitialContentType = (): ContentType => {
|
|
try {
|
|
const saved = localStorage.getItem(CONTENT_TYPE_STORAGE_KEY);
|
|
if (saved === 'ebook' || saved === 'audiobook') {
|
|
return saved;
|
|
}
|
|
} catch {
|
|
// localStorage may be unavailable in private browsing
|
|
}
|
|
return 'ebook';
|
|
};
|
|
|
|
const POLICY_GUARD_ERROR_CODES = new Set(['policy_requires_request', 'policy_blocked']);
|
|
const isPolicyGuardError = (error: unknown): boolean => {
|
|
return (
|
|
isApiResponseError(error) &&
|
|
error.status === 403 &&
|
|
Boolean(error.code && POLICY_GUARD_ERROR_CODES.has(error.code))
|
|
);
|
|
};
|
|
|
|
const asRequestPolicyMode = (value: unknown): RequestPolicyMode | null => {
|
|
return value === 'download' || value === 'request_release' || value === 'request_book' || value === 'blocked'
|
|
? value
|
|
: null;
|
|
};
|
|
|
|
const getPolicyGuardRequiredMode = (error: unknown): RequestPolicyMode | null => {
|
|
if (!isPolicyGuardError(error) || !isApiResponseError(error)) {
|
|
return null;
|
|
}
|
|
const explicitMode = asRequestPolicyMode(error.requiredMode);
|
|
if (explicitMode) {
|
|
return explicitMode;
|
|
}
|
|
if (error.code === 'policy_blocked') {
|
|
return 'blocked';
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const getErrorMessage = (error: unknown, fallback: string): string => {
|
|
if (error instanceof Error && error.message) {
|
|
return error.message;
|
|
}
|
|
return fallback;
|
|
};
|
|
|
|
function App() {
|
|
const { toasts, showToast, removeToast } = useToast();
|
|
const { socket } = useSocket();
|
|
|
|
// Realtime status with WebSocket and polling fallback
|
|
// Socket connection is managed by SocketProvider in main.tsx
|
|
const {
|
|
status: currentStatus,
|
|
isUsingWebSocket,
|
|
forceRefresh: fetchStatus
|
|
} = useRealtimeStatus({
|
|
pollInterval: 5000,
|
|
});
|
|
|
|
// 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,
|
|
isAdmin: authIsAdmin,
|
|
authMode,
|
|
username,
|
|
displayName,
|
|
oidcButtonLabel,
|
|
loginError,
|
|
isLoggingIn,
|
|
setIsAuthenticated,
|
|
handleLogin,
|
|
handleLogout,
|
|
} = useAuth({
|
|
showToast,
|
|
});
|
|
|
|
// Re-request status after auth is established so the server can re-scope socket room membership.
|
|
useEffect(() => {
|
|
if (!authChecked || !isAuthenticated) {
|
|
return;
|
|
}
|
|
policyTrace('auth.status', { authChecked, isAuthenticated, isAdmin: authIsAdmin, username });
|
|
void fetchStatus();
|
|
}, [authChecked, isAuthenticated, authIsAdmin, username, fetchStatus]);
|
|
|
|
// Content type state (ebook vs audiobook) - defined before useSearch since it's passed to it
|
|
const [contentType, setContentType] = useState<ContentType>(() => getInitialContentType());
|
|
|
|
useEffect(() => {
|
|
try {
|
|
localStorage.setItem(CONTENT_TYPE_STORAGE_KEY, contentType);
|
|
} catch {
|
|
// localStorage may be unavailable in private browsing
|
|
}
|
|
}, [contentType]);
|
|
|
|
const {
|
|
policy: requestPolicy,
|
|
getDefaultMode,
|
|
getSourceMode,
|
|
requestsEnabled: requestsPolicyEnabled,
|
|
allowNotes: allowRequestNotes,
|
|
refresh: refreshRequestPolicy,
|
|
} = useRequestPolicy({
|
|
enabled: isAuthenticated,
|
|
isAdmin: authIsAdmin,
|
|
});
|
|
|
|
const requestRoleIsAdmin = requestPolicy ? Boolean(requestPolicy.is_admin) : false;
|
|
|
|
const {
|
|
isLoading: isRequestsLoading,
|
|
cancelRequest: cancelUserRequest,
|
|
fulfilRequest: fulfilSidebarRequest,
|
|
rejectRequest: rejectSidebarRequest,
|
|
} = useRequests({
|
|
isAdmin: requestRoleIsAdmin,
|
|
enabled: isAuthenticated,
|
|
});
|
|
|
|
const {
|
|
requestItems,
|
|
dismissedActivityKeys,
|
|
historyItems,
|
|
pendingRequestCount,
|
|
isActivitySnapshotLoading,
|
|
activityHistoryLoading,
|
|
activityHistoryHasMore,
|
|
refreshActivitySnapshot,
|
|
resetActivity,
|
|
handleActivityTabChange,
|
|
handleActivityHistoryLoadMore,
|
|
handleRequestDismiss,
|
|
handleDownloadDismiss,
|
|
handleClearCompleted,
|
|
handleClearHistory,
|
|
} = useActivity({
|
|
isAuthenticated,
|
|
isAdmin: requestRoleIsAdmin,
|
|
showToast,
|
|
socket,
|
|
});
|
|
|
|
const showRequestsTab = useMemo(() => {
|
|
if (requestRoleIsAdmin) {
|
|
return true;
|
|
}
|
|
if (!isAuthenticated || !requestsPolicyEnabled) {
|
|
return false;
|
|
}
|
|
if (!requestPolicy) {
|
|
return false;
|
|
}
|
|
return !(
|
|
requestPolicy.defaults.ebook === 'download' &&
|
|
requestPolicy.defaults.audiobook === 'download'
|
|
);
|
|
}, [requestRoleIsAdmin, isAuthenticated, requestsPolicyEnabled, requestPolicy]);
|
|
|
|
// Search state and handlers
|
|
const {
|
|
books,
|
|
setBooks,
|
|
isSearching,
|
|
searchInput,
|
|
setSearchInput,
|
|
showAdvanced,
|
|
setShowAdvanced,
|
|
advancedFilters,
|
|
setAdvancedFilters,
|
|
updateAdvancedFilters,
|
|
handleSearch,
|
|
handleResetSearch,
|
|
handleSortChange,
|
|
searchFieldValues,
|
|
updateSearchFieldValue,
|
|
// Pagination (universal mode)
|
|
hasMore,
|
|
isLoadingMore,
|
|
loadMore,
|
|
totalFound,
|
|
} = useSearch({
|
|
showToast,
|
|
setIsAuthenticated,
|
|
authRequired,
|
|
onSearchReset: clearTracking,
|
|
contentType,
|
|
});
|
|
|
|
const [pendingRequestPayload, setPendingRequestPayload] = useState<CreateRequestPayload | null>(null);
|
|
const [fulfillingRequest, setFulfillingRequest] = useState<{
|
|
requestId: number;
|
|
book: Book;
|
|
contentType: ContentType;
|
|
} | null>(null);
|
|
|
|
// Wire up logout callback to clear search state
|
|
const handleLogoutWithCleanup = useCallback(async () => {
|
|
await handleLogout();
|
|
setBooks([]);
|
|
clearTracking();
|
|
setPendingRequestPayload(null);
|
|
setFulfillingRequest(null);
|
|
resetActivity();
|
|
setSettingsOpen(false);
|
|
setSelfSettingsOpen(false);
|
|
}, [handleLogout, setBooks, clearTracking, resetActivity]);
|
|
|
|
// 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 [sidebarPinnedOpen, setSidebarPinnedOpen] = useState(false);
|
|
const [headerHeight, setHeaderHeight] = useState(0);
|
|
const headerObserverRef = useRef<ResizeObserver | null>(null);
|
|
const headerRef = useCallback((el: HTMLDivElement | null) => {
|
|
if (headerObserverRef.current) {
|
|
headerObserverRef.current.disconnect();
|
|
headerObserverRef.current = null;
|
|
}
|
|
if (!el) return;
|
|
setHeaderHeight(el.getBoundingClientRect().height);
|
|
const observer = new ResizeObserver(() => {
|
|
setHeaderHeight(el.getBoundingClientRect().height);
|
|
});
|
|
observer.observe(el);
|
|
headerObserverRef.current = observer;
|
|
}, []);
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
const [selfSettingsOpen, setSelfSettingsOpen] = useState(false);
|
|
const [configBannerOpen, setConfigBannerOpen] = useState(false);
|
|
const [onboardingOpen, setOnboardingOpen] = useState(false);
|
|
|
|
// Expose debug function to trigger onboarding from browser console
|
|
useEffect(() => {
|
|
(window as unknown as { showOnboarding: () => void }).showOnboarding = () => setOnboardingOpen(true);
|
|
return () => {
|
|
delete (window as unknown as { showOnboarding?: () => void }).showOnboarding;
|
|
};
|
|
}, []);
|
|
|
|
// 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 dismissedKeySet = new Set(dismissedActivityKeys);
|
|
const countVisibleDownloads = (
|
|
bucket: Record<string, Book> | undefined,
|
|
options: { filterDismissed: boolean }
|
|
): number => {
|
|
const { filterDismissed } = options;
|
|
if (!bucket) {
|
|
return 0;
|
|
}
|
|
if (!filterDismissed) {
|
|
return Object.keys(bucket).length;
|
|
}
|
|
return Object.keys(bucket).filter((taskId) => !dismissedKeySet.has(`download:${taskId}`)).length;
|
|
};
|
|
|
|
const ongoing = [
|
|
currentStatus.queued,
|
|
currentStatus.resolving,
|
|
currentStatus.locating,
|
|
currentStatus.downloading,
|
|
].reduce((sum, status) => sum + countVisibleDownloads(status, { filterDismissed: false }), 0);
|
|
|
|
const completed = countVisibleDownloads(currentStatus.complete, { filterDismissed: true });
|
|
const errored = countVisibleDownloads(currentStatus.error, { filterDismissed: true });
|
|
const pendingVisibleRequests = requestItems.filter((item) => {
|
|
const requestId = item.requestId;
|
|
if (!requestId || item.requestRecord?.status !== 'pending') {
|
|
return false;
|
|
}
|
|
return !dismissedKeySet.has(`request:${requestId}`);
|
|
}).length;
|
|
|
|
return {
|
|
ongoing,
|
|
completed,
|
|
errored,
|
|
pendingRequests: pendingVisibleRequests,
|
|
};
|
|
}, [currentStatus, dismissedActivityKeys, requestItems]);
|
|
|
|
|
|
// Compute visibility states
|
|
const hasResults = books.length > 0;
|
|
const isInitialState = !hasResults;
|
|
|
|
// Detect status changes and show notifications
|
|
const detectChanges = useCallback((prev: StatusData, curr: StatusData) => {
|
|
if (!prev || Object.keys(prev).length === 0) return;
|
|
|
|
// Check for new items in queue
|
|
const prevQueued = prev.queued || {};
|
|
const currQueued = curr.queued || {};
|
|
Object.keys(currQueued).forEach(bookId => {
|
|
if (!prevQueued[bookId]) {
|
|
const book = currQueued[bookId];
|
|
showToast(`${book.title || 'Book'} added to queue`, 'info');
|
|
// Auto-open downloads sidebar if enabled
|
|
if (config?.auto_open_downloads_sidebar !== false) {
|
|
setDownloadsSidebarOpen(true);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Check for items that started downloading
|
|
const prevDownloading = prev.downloading || {};
|
|
const currDownloading = curr.downloading || {};
|
|
Object.keys(currDownloading).forEach(bookId => {
|
|
if (!prevDownloading[bookId]) {
|
|
const book = currDownloading[bookId];
|
|
showToast(`${book.title || 'Book'} started downloading`, 'info');
|
|
}
|
|
});
|
|
|
|
// Check for completed items
|
|
const prevDownloadingIds = new Set(Object.keys(prevDownloading));
|
|
const prevResolvingIds = new Set(Object.keys(prev.resolving || {}));
|
|
const prevQueuedIds = new Set(Object.keys(prevQueued));
|
|
const currComplete = curr.complete || {};
|
|
|
|
Object.keys(currComplete).forEach(bookId => {
|
|
if (prevDownloadingIds.has(bookId) || prevQueuedIds.has(bookId)) {
|
|
const book = currComplete[bookId];
|
|
showToast(`${book.title || 'Book'} completed`, 'success');
|
|
|
|
// Auto-download to browser if enabled
|
|
if (config?.download_to_browser && book.download_path) {
|
|
const link = document.createElement('a');
|
|
link.href = withBasePath(`/api/localdownload?id=${encodeURIComponent(bookId)}`);
|
|
link.download = '';
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
}
|
|
|
|
// Track completed release IDs in session state for universal mode
|
|
Object.entries(bookToReleaseMap).forEach(([metadataBookId, releaseIds]) => {
|
|
if (releaseIds.includes(bookId)) {
|
|
markBookCompleted(metadataBookId);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Check for failed items
|
|
const currError = curr.error || {};
|
|
Object.keys(currError).forEach(bookId => {
|
|
if (prevDownloadingIds.has(bookId) || prevResolvingIds.has(bookId) || prevQueuedIds.has(bookId)) {
|
|
const book = currError[bookId];
|
|
const errorMsg = book.status_message || 'Download failed';
|
|
showToast(`${book.title || 'Book'}: ${errorMsg}`, 'error');
|
|
}
|
|
});
|
|
}, [showToast, bookToReleaseMap, markBookCompleted, config]);
|
|
|
|
// Detect status changes when currentStatus updates
|
|
useEffect(() => {
|
|
if (prevStatusRef.current && Object.keys(prevStatusRef.current).length > 0) {
|
|
detectChanges(prevStatusRef.current, currentStatus);
|
|
}
|
|
prevStatusRef.current = currentStatus;
|
|
}, [currentStatus, detectChanges]);
|
|
|
|
// 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);
|
|
clearTracking();
|
|
}
|
|
|
|
prevSearchModeRef.current = cfg.search_mode;
|
|
setConfig(cfg);
|
|
|
|
// Show onboarding modal on first run (settings enabled but not completed yet)
|
|
if (mode === 'initial' && cfg.settings_enabled && !cfg.onboarding_complete) {
|
|
setOnboardingOpen(true);
|
|
}
|
|
|
|
// Determine the default sort based on search mode
|
|
const defaultSort = cfg.search_mode === 'universal'
|
|
? (cfg.metadata_default_sort || 'relevance')
|
|
: (cfg.default_sort || 'relevance');
|
|
|
|
if (cfg?.supported_formats) {
|
|
if (mode === 'initial') {
|
|
setAdvancedFilters(prev => ({
|
|
...prev,
|
|
formats: cfg.supported_formats,
|
|
sort: defaultSort,
|
|
}));
|
|
} else if (mode === 'settings-saved') {
|
|
// On settings save, update formats and reset sort to new default
|
|
setAdvancedFilters(prev => ({
|
|
...prev,
|
|
formats: prev.formats.filter(f => cfg.supported_formats.includes(f)),
|
|
sort: defaultSort,
|
|
}));
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load config:', error);
|
|
}
|
|
}, [setBooks, setAdvancedFilters, clearTracking]);
|
|
|
|
// Fetch config when authenticated
|
|
useEffect(() => {
|
|
if (isAuthenticated) {
|
|
loadConfig('initial');
|
|
}
|
|
}, [isAuthenticated, loadConfig]);
|
|
|
|
const runSearchWithPolicyRefresh = useCallback(
|
|
(query: string, fields = searchFieldValues) => {
|
|
void refreshRequestPolicy();
|
|
handleSearch(query, config, fields);
|
|
},
|
|
[refreshRequestPolicy, handleSearch, config, searchFieldValues]
|
|
);
|
|
|
|
// 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,
|
|
});
|
|
|
|
runSearchWithPolicyRefresh(query);
|
|
}
|
|
}, [
|
|
wasProcessed,
|
|
parsedParams,
|
|
config,
|
|
advancedFilters,
|
|
searchFieldValues,
|
|
runSearchWithPolicyRefresh,
|
|
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');
|
|
} else {
|
|
console.log('⏳ Using polling fallback (5s interval)');
|
|
}
|
|
}, [isUsingWebSocket]);
|
|
|
|
// Fetch status on startup
|
|
useEffect(() => {
|
|
fetchStatus();
|
|
}, [fetchStatus]);
|
|
|
|
// 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,
|
|
series_name: fullBook.series_name,
|
|
series_position: fullBook.series_position,
|
|
series_count: fullBook.series_count,
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to load book description, using search data:', error);
|
|
setSelectedBook(metadataBook);
|
|
}
|
|
} 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');
|
|
}
|
|
}
|
|
};
|
|
|
|
// Handle "Find Downloads" from DetailsModal
|
|
const handleFindDownloads = (book: Book) => {
|
|
setSelectedBook(null);
|
|
setReleaseBook(book);
|
|
};
|
|
|
|
const submitRequest = useCallback(
|
|
async (payload: CreateRequestPayload, successMessage: string): Promise<boolean> => {
|
|
try {
|
|
await createRequest(payload);
|
|
await refreshActivitySnapshot();
|
|
showToast(successMessage, 'success');
|
|
await refreshRequestPolicy({ force: true });
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Request creation failed:', error);
|
|
showToast(getErrorMessage(error, 'Failed to create request'), 'error');
|
|
if (isPolicyGuardError(error)) {
|
|
await refreshRequestPolicy({ force: true });
|
|
}
|
|
return false;
|
|
}
|
|
},
|
|
[showToast, refreshRequestPolicy, refreshActivitySnapshot]
|
|
);
|
|
|
|
const openRequestConfirmation = useCallback((payload: CreateRequestPayload) => {
|
|
setPendingRequestPayload(payload);
|
|
}, []);
|
|
|
|
const handleConfirmRequest = useCallback(
|
|
async (payload: CreateRequestPayload): Promise<boolean> => {
|
|
const success = await submitRequest(payload, getRequestSuccessMessage(payload));
|
|
if (success) {
|
|
setPendingRequestPayload(null);
|
|
}
|
|
return success;
|
|
},
|
|
[submitRequest]
|
|
);
|
|
|
|
const getDirectPolicyMode = useCallback((): RequestPolicyMode => {
|
|
return getSourceMode('direct_download', 'ebook');
|
|
}, [getSourceMode]);
|
|
|
|
const getUniversalDefaultPolicyMode = useCallback((): RequestPolicyMode => {
|
|
return getDefaultMode(contentType);
|
|
}, [getDefaultMode, contentType]);
|
|
|
|
// Direct-mode action (download or release-level request based on policy).
|
|
const handleDownload = async (book: Book): Promise<void> => {
|
|
let mode = getDirectPolicyMode();
|
|
policyTrace('direct.action:start', {
|
|
bookId: book.id,
|
|
contentType: 'ebook',
|
|
cachedMode: mode,
|
|
isAdmin: requestRoleIsAdmin,
|
|
});
|
|
try {
|
|
const latestPolicy = await refreshRequestPolicy({ force: true });
|
|
const effectiveIsAdmin = latestPolicy ? Boolean(latestPolicy.is_admin) : requestRoleIsAdmin;
|
|
mode = resolveSourceModeFromPolicy(latestPolicy, effectiveIsAdmin, 'direct_download', 'ebook');
|
|
policyTrace('direct.action:resolved', {
|
|
bookId: book.id,
|
|
resolvedMode: mode,
|
|
effectiveIsAdmin,
|
|
defaults: latestPolicy?.defaults ?? null,
|
|
requestsEnabled: latestPolicy?.requests_enabled ?? null,
|
|
});
|
|
} catch (error) {
|
|
console.warn('Failed to refresh request policy before direct action:', error);
|
|
policyTrace('direct.action:refresh_failed', {
|
|
bookId: book.id,
|
|
mode,
|
|
message: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
|
|
if (mode === 'blocked') {
|
|
policyTrace('direct.action:block', { bookId: book.id, mode });
|
|
showToast('Download blocked by policy', 'error');
|
|
await refreshRequestPolicy({ force: true });
|
|
return;
|
|
}
|
|
|
|
if (mode === 'request_release' || mode === 'request_book') {
|
|
policyTrace('direct.action:request_modal', { bookId: book.id, mode });
|
|
openRequestConfirmation(buildDirectRequestPayload(book, mode));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await downloadBook(book.id);
|
|
await fetchStatus();
|
|
} catch (error) {
|
|
console.error('Download failed:', error);
|
|
if (isPolicyGuardError(error)) {
|
|
const requiredMode = getPolicyGuardRequiredMode(error);
|
|
policyTrace('direct.action:policy_guard', {
|
|
bookId: book.id,
|
|
requiredMode,
|
|
code: isApiResponseError(error) ? error.code : null,
|
|
});
|
|
if (requiredMode === 'request_release' || requiredMode === 'request_book') {
|
|
openRequestConfirmation(buildDirectRequestPayload(book, requiredMode));
|
|
await refreshRequestPolicy({ force: true });
|
|
return;
|
|
}
|
|
showToast('Download blocked by policy', 'error');
|
|
await refreshRequestPolicy({ force: true });
|
|
return;
|
|
}
|
|
showToast(getErrorMessage(error, 'Failed to queue download'), 'error');
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// Cancel download
|
|
const handleCancel = async (id: string) => {
|
|
try {
|
|
await cancelDownload(id);
|
|
await fetchStatus();
|
|
} catch (error) {
|
|
console.error('Cancel failed:', error);
|
|
showToast('Failed to cancel/clear download', 'error');
|
|
}
|
|
};
|
|
|
|
// Universal-mode "Get" action (open releases, request-book, or block by policy).
|
|
const handleGetReleases = async (book: Book) => {
|
|
let mode = getUniversalDefaultPolicyMode();
|
|
const normalizedContentType = toContentType(contentType);
|
|
policyTrace('universal.get:start', {
|
|
bookId: book.id,
|
|
contentType: normalizedContentType,
|
|
cachedMode: mode,
|
|
isAdmin: requestRoleIsAdmin,
|
|
});
|
|
try {
|
|
const latestPolicy = await refreshRequestPolicy({ force: true });
|
|
const effectiveIsAdmin = latestPolicy ? Boolean(latestPolicy.is_admin) : requestRoleIsAdmin;
|
|
mode = resolveDefaultModeFromPolicy(latestPolicy, effectiveIsAdmin, contentType);
|
|
policyTrace('universal.get:resolved', {
|
|
bookId: book.id,
|
|
contentType: normalizedContentType,
|
|
resolvedMode: mode,
|
|
effectiveIsAdmin,
|
|
defaults: latestPolicy?.defaults ?? null,
|
|
requestsEnabled: latestPolicy?.requests_enabled ?? null,
|
|
});
|
|
} catch (error) {
|
|
console.warn('Failed to refresh request policy before universal action:', error);
|
|
policyTrace('universal.get:refresh_failed', {
|
|
bookId: book.id,
|
|
contentType: normalizedContentType,
|
|
mode,
|
|
message: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
|
|
if (mode === 'blocked') {
|
|
policyTrace('universal.get:block', { bookId: book.id, contentType: normalizedContentType });
|
|
showToast('This title is unavailable by policy', 'error');
|
|
return;
|
|
}
|
|
|
|
if (mode === 'request_book') {
|
|
policyTrace('universal.get:request_modal', {
|
|
bookId: book.id,
|
|
requestLevel: 'book',
|
|
contentType: normalizedContentType,
|
|
});
|
|
openRequestConfirmation({
|
|
book_data: buildMetadataBookRequestData(book, normalizedContentType),
|
|
release_data: null,
|
|
context: {
|
|
source: '*',
|
|
content_type: normalizedContentType,
|
|
request_level: 'book',
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (book.provider && book.provider_id) {
|
|
try {
|
|
policyTrace('universal.get:open_release_modal', {
|
|
bookId: book.id,
|
|
contentType: normalizedContentType,
|
|
});
|
|
const fullBook = await getMetadataBookInfo(book.provider, book.provider_id);
|
|
setReleaseBook({
|
|
...book,
|
|
description: fullBook.description || book.description,
|
|
series_name: fullBook.series_name,
|
|
series_position: fullBook.series_position,
|
|
series_count: fullBook.series_count,
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to load book description, using search data:', error);
|
|
policyTrace('universal.get:open_release_modal_fallback', {
|
|
bookId: book.id,
|
|
contentType: normalizedContentType,
|
|
message: error instanceof Error ? error.message : String(error),
|
|
});
|
|
setReleaseBook(book);
|
|
}
|
|
} else {
|
|
policyTrace('universal.get:open_release_modal_no_provider', {
|
|
bookId: book.id,
|
|
contentType: normalizedContentType,
|
|
});
|
|
setReleaseBook(book);
|
|
}
|
|
};
|
|
|
|
// Handle download from ReleaseModal (universal mode release rows).
|
|
const handleReleaseDownload = async (book: Book, release: Release, releaseContentType: ContentType) => {
|
|
try {
|
|
policyTrace('release.action:start', {
|
|
bookId: book.id,
|
|
releaseId: release.source_id,
|
|
source: release.source,
|
|
contentType: toContentType(releaseContentType),
|
|
});
|
|
trackRelease(book.id, release.source_id);
|
|
|
|
await downloadRelease({
|
|
source: release.source,
|
|
source_id: release.source_id,
|
|
title: book.title, // Use book metadata title, not release/torrent title
|
|
author: book.author, // Pass author from metadata
|
|
year: book.year, // Pass year from metadata
|
|
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
|
|
content_type: releaseContentType, // For audiobook directory routing
|
|
series_name: book.series_name,
|
|
series_position: book.series_position,
|
|
subtitle: book.subtitle,
|
|
});
|
|
await fetchStatus();
|
|
} catch (error) {
|
|
console.error('Release download failed:', error);
|
|
if (isPolicyGuardError(error)) {
|
|
const requiredMode = getPolicyGuardRequiredMode(error);
|
|
const normalizedContentType = toContentType(releaseContentType);
|
|
policyTrace('release.action:policy_guard', {
|
|
bookId: book.id,
|
|
releaseId: release.source_id,
|
|
source: release.source,
|
|
requiredMode,
|
|
code: isApiResponseError(error) ? error.code : null,
|
|
contentType: normalizedContentType,
|
|
});
|
|
if (requiredMode === 'request_release') {
|
|
openRequestConfirmation({
|
|
book_data: buildMetadataBookRequestData(book, normalizedContentType),
|
|
release_data: buildReleaseDataFromMetadataRelease(book, release, normalizedContentType),
|
|
context: {
|
|
source: release.source || 'direct_download',
|
|
content_type: normalizedContentType,
|
|
request_level: 'release',
|
|
},
|
|
});
|
|
await refreshRequestPolicy({ force: true });
|
|
return;
|
|
}
|
|
if (requiredMode === 'request_book') {
|
|
setReleaseBook(null);
|
|
openRequestConfirmation({
|
|
book_data: buildMetadataBookRequestData(book, normalizedContentType),
|
|
release_data: null,
|
|
context: {
|
|
source: release.source || 'direct_download',
|
|
content_type: normalizedContentType,
|
|
request_level: 'book',
|
|
},
|
|
});
|
|
await refreshRequestPolicy({ force: true });
|
|
return;
|
|
}
|
|
showToast('Download blocked by policy', 'error');
|
|
await refreshRequestPolicy({ force: true });
|
|
return;
|
|
}
|
|
showToast(getErrorMessage(error, 'Failed to queue download'), 'error');
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const handleReleaseRequest = useCallback(
|
|
async (book: Book, release: Release, releaseContentType: ContentType): Promise<void> => {
|
|
void refreshRequestPolicy();
|
|
const normalizedContentType = toContentType(releaseContentType);
|
|
openRequestConfirmation({
|
|
book_data: buildMetadataBookRequestData(book, normalizedContentType),
|
|
release_data: buildReleaseDataFromMetadataRelease(book, release, normalizedContentType),
|
|
context: {
|
|
source: release.source || 'direct_download',
|
|
content_type: normalizedContentType,
|
|
request_level: 'release',
|
|
},
|
|
});
|
|
},
|
|
[openRequestConfirmation, refreshRequestPolicy]
|
|
);
|
|
|
|
const handleRequestCancel = useCallback(
|
|
async (requestId: number) => {
|
|
try {
|
|
await cancelUserRequest(requestId);
|
|
await refreshActivitySnapshot();
|
|
showToast('Request cancelled', 'success');
|
|
} catch (error) {
|
|
showToast(getErrorMessage(error, 'Failed to cancel request'), 'error');
|
|
}
|
|
},
|
|
[cancelUserRequest, refreshActivitySnapshot, showToast]
|
|
);
|
|
|
|
const handleRequestReject = useCallback(
|
|
async (requestId: number, adminNote?: string) => {
|
|
if (!requestRoleIsAdmin) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await rejectSidebarRequest(requestId, adminNote);
|
|
await refreshActivitySnapshot();
|
|
showToast('Request rejected', 'success');
|
|
} catch (error) {
|
|
showToast(getErrorMessage(error, 'Failed to reject request'), 'error');
|
|
}
|
|
},
|
|
[refreshActivitySnapshot, requestRoleIsAdmin, rejectSidebarRequest, showToast]
|
|
);
|
|
|
|
const handleRequestApprove = useCallback(
|
|
async (requestId: number, record: RequestRecord) => {
|
|
if (!requestRoleIsAdmin) {
|
|
return;
|
|
}
|
|
|
|
if (record.request_level === 'release') {
|
|
try {
|
|
await fulfilSidebarRequest(requestId, record.release_data || undefined);
|
|
await refreshActivitySnapshot();
|
|
showToast('Request approved', 'success');
|
|
await fetchStatus();
|
|
} catch (error) {
|
|
showToast(getErrorMessage(error, 'Failed to approve request'), 'error');
|
|
}
|
|
return;
|
|
}
|
|
|
|
setReleaseBook(null);
|
|
setFulfillingRequest({
|
|
requestId,
|
|
book: bookFromRequestData(record.book_data),
|
|
contentType: record.content_type,
|
|
});
|
|
},
|
|
[requestRoleIsAdmin, fulfilSidebarRequest, showToast, fetchStatus, refreshActivitySnapshot]
|
|
);
|
|
|
|
const handleBrowseFulfilDownload = useCallback(
|
|
async (book: Book, release: Release, releaseContentType: ContentType) => {
|
|
if (!fulfillingRequest) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await fulfilSidebarRequest(
|
|
fulfillingRequest.requestId,
|
|
buildReleaseDataFromMetadataRelease(book, release, toContentType(releaseContentType))
|
|
);
|
|
await refreshActivitySnapshot();
|
|
showToast(`Request approved: ${book.title || 'Untitled'}`, 'success');
|
|
setFulfillingRequest(null);
|
|
await fetchStatus();
|
|
} catch (error) {
|
|
console.error('Browse fulfil failed:', error);
|
|
showToast(getErrorMessage(error, 'Failed to fulfil request'), 'error');
|
|
throw error;
|
|
}
|
|
},
|
|
[fulfillingRequest, fulfilSidebarRequest, showToast, fetchStatus, refreshActivitySnapshot]
|
|
);
|
|
|
|
const getDirectActionButtonState = useCallback(
|
|
(bookId: string): ButtonStateInfo => {
|
|
const baseState = getButtonState(bookId);
|
|
const mode = getDirectPolicyMode();
|
|
return applyDirectPolicyModeToButtonState(baseState, mode);
|
|
},
|
|
[getButtonState, getDirectPolicyMode]
|
|
);
|
|
|
|
const getUniversalActionButtonState = useCallback(
|
|
(bookId: string): ButtonStateInfo => {
|
|
const baseState = getUniversalButtonState(bookId);
|
|
const mode = getUniversalDefaultPolicyMode();
|
|
return applyUniversalPolicyModeToButtonState(baseState, mode);
|
|
},
|
|
[getUniversalButtonState, getUniversalDefaultPolicyMode]
|
|
);
|
|
|
|
const bookLanguages = config?.book_languages || DEFAULT_LANGUAGES;
|
|
const supportedFormats = config?.supported_formats || DEFAULT_SUPPORTED_FORMATS;
|
|
const defaultLanguageCodes =
|
|
config?.default_language && config.default_language.length > 0
|
|
? config.default_language
|
|
: [bookLanguages[0]?.code || 'en'];
|
|
|
|
const searchMode = config?.search_mode || 'direct';
|
|
const logoUrl = withBasePath('/logo.png');
|
|
|
|
// Handle "View Series" - trigger search with series field and series order sort
|
|
const handleSearchSeries = useCallback((seriesName: string) => {
|
|
// Clear UI state
|
|
setSearchInput('');
|
|
setSelectedBook(null);
|
|
setReleaseBook(null);
|
|
clearTracking();
|
|
|
|
// Set sort to series_order (but don't show advanced panel or persist series value)
|
|
const newFilters = { ...advancedFilters, sort: 'series_order' };
|
|
setAdvancedFilters(newFilters);
|
|
|
|
// Trigger search with series field (passed directly, not persisted in UI)
|
|
const query = buildSearchQuery({
|
|
searchInput: '',
|
|
showAdvanced: true,
|
|
advancedFilters: newFilters,
|
|
bookLanguages,
|
|
defaultLanguage: defaultLanguageCodes,
|
|
searchMode,
|
|
});
|
|
runSearchWithPolicyRefresh(query, { ...searchFieldValues, series: seriesName });
|
|
}, [setSearchInput, clearTracking, searchFieldValues, advancedFilters, setAdvancedFilters, bookLanguages, defaultLanguageCodes, searchMode, runSearchWithPolicyRefresh]);
|
|
|
|
const isBrowseFulfilMode = fulfillingRequest !== null;
|
|
const activeReleaseBook = fulfillingRequest?.book ?? releaseBook;
|
|
const activeReleaseContentType = fulfillingRequest?.contentType ?? contentType;
|
|
const usePinnedMainScrollContainer = sidebarPinnedOpen;
|
|
|
|
const handleReleaseModalClose = useCallback(() => {
|
|
if (isBrowseFulfilMode) {
|
|
setFulfillingRequest(null);
|
|
return;
|
|
}
|
|
setReleaseBook(null);
|
|
}, [isBrowseFulfilMode]);
|
|
|
|
const mainAppContent = (
|
|
<SearchModeProvider searchMode={searchMode}>
|
|
<div ref={headerRef} className="fixed top-0 left-0 right-0 z-40">
|
|
<Header
|
|
calibreWebUrl={config?.calibre_web_url || ''}
|
|
audiobookLibraryUrl={config?.audiobook_library_url || ''}
|
|
debug={config?.debug || false}
|
|
logoUrl={logoUrl}
|
|
showSearch={!isInitialState}
|
|
searchInput={searchInput}
|
|
onSearchChange={setSearchInput}
|
|
onDownloadsClick={() => setDownloadsSidebarOpen((prev) => !prev)}
|
|
onSettingsClick={() => {
|
|
if (config?.settings_enabled) {
|
|
if (authIsAdmin) {
|
|
setSettingsOpen(true);
|
|
} else {
|
|
setSelfSettingsOpen(true);
|
|
}
|
|
} else {
|
|
setConfigBannerOpen(true);
|
|
}
|
|
}}
|
|
isAdmin={requestRoleIsAdmin}
|
|
canAccessSettings={isAuthenticated}
|
|
username={username}
|
|
displayName={displayName}
|
|
statusCounts={statusCounts}
|
|
onLogoClick={() => handleResetSearch(config)}
|
|
authRequired={authRequired}
|
|
isAuthenticated={isAuthenticated}
|
|
onLogout={handleLogoutWithCleanup}
|
|
onSearch={() => {
|
|
const query = buildSearchQuery({
|
|
searchInput,
|
|
showAdvanced,
|
|
advancedFilters,
|
|
bookLanguages,
|
|
defaultLanguage: defaultLanguageCodes,
|
|
searchMode,
|
|
});
|
|
runSearchWithPolicyRefresh(query);
|
|
}}
|
|
onAdvancedToggle={() => setShowAdvanced(!showAdvanced)}
|
|
isLoading={isSearching}
|
|
onShowToast={showToast}
|
|
onRemoveToast={removeToast}
|
|
contentType={contentType}
|
|
onContentTypeChange={setContentType}
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
className={`flex flex-col${
|
|
usePinnedMainScrollContainer
|
|
? ' min-h-0 overflow-y-auto overscroll-y-contain'
|
|
: ' flex-1'
|
|
}`}
|
|
style={
|
|
usePinnedMainScrollContainer
|
|
? {
|
|
position: 'fixed',
|
|
top: `${headerHeight}px`,
|
|
bottom: 0,
|
|
left: 0,
|
|
right: '25rem',
|
|
zIndex: 40,
|
|
}
|
|
: { paddingTop: `${headerHeight}px` }
|
|
}
|
|
>
|
|
<AdvancedFilters
|
|
visible={showAdvanced && !isInitialState}
|
|
bookLanguages={bookLanguages}
|
|
defaultLanguage={defaultLanguageCodes}
|
|
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,
|
|
});
|
|
runSearchWithPolicyRefresh(query);
|
|
}}
|
|
/>
|
|
|
|
<main
|
|
className="relative w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-6"
|
|
style={
|
|
usePinnedMainScrollContainer
|
|
? { display: 'block', flex: '0 0 auto', minHeight: 0 }
|
|
: undefined
|
|
}
|
|
>
|
|
<SearchSection
|
|
onSearch={(query) => runSearchWithPolicyRefresh(query)}
|
|
isLoading={isSearching}
|
|
isInitialState={isInitialState}
|
|
bookLanguages={bookLanguages}
|
|
defaultLanguage={defaultLanguageCodes}
|
|
supportedFormats={config?.supported_formats || DEFAULT_SUPPORTED_FORMATS}
|
|
logoUrl={logoUrl}
|
|
searchInput={searchInput}
|
|
onSearchInputChange={setSearchInput}
|
|
showAdvanced={showAdvanced}
|
|
onAdvancedToggle={() => setShowAdvanced(!showAdvanced)}
|
|
advancedFilters={advancedFilters}
|
|
onAdvancedFiltersChange={updateAdvancedFilters}
|
|
metadataSearchFields={config?.metadata_search_fields}
|
|
searchFieldValues={searchFieldValues}
|
|
onSearchFieldChange={updateSearchFieldValue}
|
|
contentType={contentType}
|
|
onContentTypeChange={setContentType}
|
|
/>
|
|
|
|
<ResultsSection
|
|
books={books}
|
|
visible={hasResults}
|
|
onDetails={handleShowDetails}
|
|
onDownload={handleDownload}
|
|
onGetReleases={handleGetReleases}
|
|
getButtonState={getDirectActionButtonState}
|
|
getUniversalButtonState={getUniversalActionButtonState}
|
|
sortValue={advancedFilters.sort}
|
|
onSortChange={(value) => handleSortChange(value, config)}
|
|
metadataSortOptions={config?.metadata_sort_options}
|
|
hasMore={hasMore}
|
|
isLoadingMore={isLoadingMore}
|
|
onLoadMore={() => loadMore(config)}
|
|
totalFound={totalFound}
|
|
/>
|
|
|
|
{selectedBook && (
|
|
<DetailsModal
|
|
book={selectedBook}
|
|
onClose={() => setSelectedBook(null)}
|
|
onDownload={handleDownload}
|
|
onFindDownloads={handleFindDownloads}
|
|
onSearchSeries={handleSearchSeries}
|
|
buttonState={getDirectActionButtonState(selectedBook.id)}
|
|
/>
|
|
)}
|
|
|
|
{activeReleaseBook && (
|
|
<ReleaseModal
|
|
book={activeReleaseBook}
|
|
onClose={handleReleaseModalClose}
|
|
onDownload={isBrowseFulfilMode ? handleBrowseFulfilDownload : handleReleaseDownload}
|
|
onRequestRelease={isBrowseFulfilMode ? undefined : handleReleaseRequest}
|
|
getPolicyModeForSource={isBrowseFulfilMode ? () => 'download' : (source, ct) => getSourceMode(source, ct)}
|
|
onPolicyRefresh={() => refreshRequestPolicy({ force: true })}
|
|
supportedFormats={supportedFormats}
|
|
supportedAudiobookFormats={config?.supported_audiobook_formats || []}
|
|
contentType={activeReleaseContentType}
|
|
defaultLanguages={defaultLanguageCodes}
|
|
bookLanguages={bookLanguages}
|
|
currentStatus={currentStatus}
|
|
defaultReleaseSource={config?.default_release_source}
|
|
onSearchSeries={isBrowseFulfilMode ? undefined : handleSearchSeries}
|
|
/>
|
|
)}
|
|
|
|
{pendingRequestPayload && (
|
|
<RequestConfirmationModal
|
|
payload={pendingRequestPayload}
|
|
allowNotes={allowRequestNotes}
|
|
onConfirm={handleConfirmRequest}
|
|
onClose={() => setPendingRequestPayload(null)}
|
|
/>
|
|
)}
|
|
|
|
</main>
|
|
|
|
<div className={usePinnedMainScrollContainer ? 'mt-auto' : undefined}>
|
|
<Footer
|
|
buildVersion={config?.build_version}
|
|
releaseVersion={config?.release_version}
|
|
debug={config?.debug}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<ActivitySidebar
|
|
isOpen={downloadsSidebarOpen}
|
|
onClose={() => setDownloadsSidebarOpen(false)}
|
|
status={currentStatus}
|
|
isAdmin={requestRoleIsAdmin}
|
|
onClearCompleted={handleClearCompleted}
|
|
onCancel={handleCancel}
|
|
onDownloadDismiss={handleDownloadDismiss}
|
|
requestItems={requestItems}
|
|
dismissedItemKeys={dismissedActivityKeys}
|
|
historyItems={historyItems}
|
|
historyHasMore={activityHistoryHasMore}
|
|
historyLoading={activityHistoryLoading}
|
|
onHistoryLoadMore={handleActivityHistoryLoadMore}
|
|
onClearHistory={handleClearHistory}
|
|
onActiveTabChange={handleActivityTabChange}
|
|
pendingRequestCount={pendingRequestCount}
|
|
showRequestsTab={showRequestsTab}
|
|
isRequestsLoading={isRequestsLoading || isActivitySnapshotLoading}
|
|
onRequestCancel={showRequestsTab ? handleRequestCancel : undefined}
|
|
onRequestApprove={requestRoleIsAdmin ? handleRequestApprove : undefined}
|
|
onRequestReject={requestRoleIsAdmin ? handleRequestReject : undefined}
|
|
onRequestDismiss={showRequestsTab ? handleRequestDismiss : undefined}
|
|
onPinnedOpenChange={setSidebarPinnedOpen}
|
|
pinnedTopOffset={headerHeight}
|
|
/>
|
|
|
|
<ToastContainer toasts={toasts} />
|
|
|
|
<SettingsModal
|
|
isOpen={settingsOpen}
|
|
authMode={authMode}
|
|
onClose={() => setSettingsOpen(false)}
|
|
onShowToast={showToast}
|
|
onSettingsSaved={handleSettingsSaved}
|
|
/>
|
|
|
|
<SelfSettingsModal
|
|
isOpen={selfSettingsOpen}
|
|
onClose={() => setSelfSettingsOpen(false)}
|
|
onShowToast={showToast}
|
|
/>
|
|
|
|
{/* 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);
|
|
if (authIsAdmin) {
|
|
setSettingsOpen(true);
|
|
} else {
|
|
setSelfSettingsOpen(true);
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{/* Onboarding wizard shown on first run */}
|
|
<OnboardingModal
|
|
isOpen={onboardingOpen}
|
|
onClose={() => setOnboardingOpen(false)}
|
|
onComplete={() => loadConfig('settings-saved')}
|
|
onShowToast={showToast}
|
|
/>
|
|
|
|
</SearchModeProvider>
|
|
);
|
|
|
|
const visuallyHiddenStyle: CSSProperties = {
|
|
position: 'absolute',
|
|
width: '1px',
|
|
height: '1px',
|
|
padding: 0,
|
|
margin: '-1px',
|
|
overflow: 'hidden',
|
|
clip: 'rect(0, 0, 0, 0)',
|
|
whiteSpace: 'nowrap',
|
|
border: 0,
|
|
};
|
|
|
|
if (!authChecked) {
|
|
return (
|
|
<div aria-live="polite" style={visuallyHiddenStyle}>
|
|
Checking authentication…
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Wait for config to load before rendering main UI to prevent flicker
|
|
if (isAuthenticated && !config) {
|
|
return (
|
|
<div aria-live="polite" style={visuallyHiddenStyle}>
|
|
Loading configuration…
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const shouldRedirectFromLogin = !authRequired || isAuthenticated;
|
|
const appElement = authRequired && !isAuthenticated ? (
|
|
<Navigate to="/login" replace />
|
|
) : (
|
|
mainAppContent
|
|
);
|
|
|
|
return (
|
|
<Routes>
|
|
<Route
|
|
path="/login"
|
|
element={
|
|
shouldRedirectFromLogin ? (
|
|
<Navigate to="/" replace />
|
|
) : (
|
|
<LoginPage
|
|
onLogin={handleLogin}
|
|
error={loginError}
|
|
isLoading={isLoggingIn}
|
|
authMode={authMode}
|
|
oidcButtonLabel={oidcButtonLabel}
|
|
/>
|
|
)
|
|
}
|
|
/>
|
|
<Route path="/*" element={appElement} />
|
|
</Routes>
|
|
);
|
|
}
|
|
|
|
export default App;
|