feat(opds): add facet navigation and quick catalog registration in header (#4348)
* feat(opds): add facet navigation and quick catalog registration to header - Add an options dropdown in the header to navigate OPDS feed facets on compact viewports. - Implement an "Add to My Catalogs" dialog to save the current feed, inheriting credentials, headers, and config. - Render a standalone shortcut button in the header when no facets are present, hiding the dropdown. * fix(opds): scope window rounding to full route + guard duplicate add Reviewing the facet-navigation feature surfaced three issues in the quick-add flow and an unrelated window-rounding change: - Restore the standard full-screen-route rounding pattern on the OPDS browser. The header change had dropped the `isRoundedWindow` guard (rounding maximized/fullscreen windows leaves gaps at the edges) and switched to left-only corners (a docked-sidebar pattern). Match the library/auth/user/reader pages: `isRoundedWindow && window-border rounded-window`. - Guard "Add to My Catalogs" against re-adding a catalog whose URL is already saved. `addCatalog` dedups by contentId and would silently overwrite the existing entry while toasting "added successfully"; now it detects the duplicate via `findByUrl` and shows an info toast. - Replace `feed!.facets!` non-null assertions with optional chaining. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Huang Xin <chrox.huang@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { VirtuosoGrid } from 'react-virtuoso';
|
||||
import { IoChevronBack, IoChevronForward, IoFilter } from 'react-icons/io5';
|
||||
import { IoAdd, IoChevronBack, IoChevronForward, IoFilter } from 'react-icons/io5';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { OPDSFeed, OPDSGenericLink } from '@/types/opds';
|
||||
import { PublicationCard } from './PublicationCard';
|
||||
@@ -17,6 +17,7 @@ interface FeedViewProps {
|
||||
onPublicationSelect: (groupIndex: number, itemIndex: number) => void;
|
||||
onGenerateCachedImageUrl: (url: string) => Promise<string>;
|
||||
isOPDSCatalog: (type?: string) => boolean;
|
||||
onAddCatalog?: () => void;
|
||||
}
|
||||
|
||||
const gridClassName = 'grid grid-cols-3 gap-4 px-4 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6';
|
||||
@@ -30,6 +31,7 @@ export function FeedView({
|
||||
onNavigate,
|
||||
onPublicationSelect,
|
||||
onGenerateCachedImageUrl,
|
||||
onAddCatalog,
|
||||
}: FeedViewProps) {
|
||||
const _ = useTranslation();
|
||||
const linksByRel = useMemo(() => groupByArray(feed.links, (link) => link.rel), [feed.links]);
|
||||
@@ -80,9 +82,20 @@ export function FeedView({
|
||||
{hasFacets && (
|
||||
<aside className='hidden w-64 flex-shrink-0 overflow-y-auto lg:block'>
|
||||
<div className='px-4'>
|
||||
{onAddCatalog && (
|
||||
<div className='mb-4'>
|
||||
<button
|
||||
onClick={onAddCatalog}
|
||||
className='btn btn-primary btn-sm w-full flex items-center justify-center gap-2'
|
||||
>
|
||||
<IoAdd className='h-4 w-4' />
|
||||
{_('Add to My Catalogs')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className='mb-4 flex items-center gap-2'>
|
||||
<IoFilter className='h-5 w-5' />
|
||||
<h2 className='text-lg font-semibold'>Filters</h2>
|
||||
<h2 className='text-lg font-semibold'>{_('Filters')}</h2>
|
||||
</div>
|
||||
<div className='space-y-6'>
|
||||
{feed.facets?.map((facet, index: number) => (
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { FaSearch } from 'react-icons/fa';
|
||||
import { IoMdCloseCircle } from 'react-icons/io';
|
||||
import { IoChevronBack, IoChevronForward, IoHome } from 'react-icons/io5';
|
||||
import { IoChevronBack, IoChevronForward, IoHome, IoFilter, IoAdd } from 'react-icons/io5';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useTrafficLight } from '@/hooks/useTrafficLight';
|
||||
@@ -13,6 +13,11 @@ import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
import { closeOPDSBrowser } from '../utils/opdsClose';
|
||||
import Dropdown from '@/components/Dropdown';
|
||||
import Menu from '@/components/Menu';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
import { OPDSFeed } from '@/types/opds';
|
||||
import { useDropdownContext } from '@/context/DropdownContext';
|
||||
|
||||
interface NavigationProps {
|
||||
searchTerm?: string;
|
||||
@@ -23,6 +28,11 @@ interface NavigationProps {
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
hasSearch: boolean;
|
||||
feed?: OPDSFeed;
|
||||
baseURL?: string;
|
||||
resolveURL?: (url: string, base: string) => string;
|
||||
onNavigate?: (url: string) => void;
|
||||
onAddCatalog?: () => void;
|
||||
}
|
||||
|
||||
export function Navigation({
|
||||
@@ -34,6 +44,11 @@ export function Navigation({
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
hasSearch = false,
|
||||
feed,
|
||||
baseURL,
|
||||
resolveURL,
|
||||
onNavigate,
|
||||
onAddCatalog,
|
||||
}: NavigationProps) {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -47,6 +62,8 @@ export function Navigation({
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const { isTrafficLightVisible } = useTrafficLight(headerRef);
|
||||
|
||||
const dropdownContext = useDropdownContext();
|
||||
|
||||
useEffect(() => {
|
||||
setSearchQuery(searchTerm || '');
|
||||
}, [searchTerm]);
|
||||
@@ -57,6 +74,15 @@ export function Navigation({
|
||||
}
|
||||
}, [hasSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(min-width: 1024px)');
|
||||
|
||||
const handleMediaChange = (e: MediaQueryListEvent) => e.matches && dropdownContext?.closeAll();
|
||||
|
||||
mediaQuery.addEventListener('change', handleMediaChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleMediaChange);
|
||||
}, [dropdownContext]);
|
||||
|
||||
const handleGoLibrary = useCallback(() => {
|
||||
closeOPDSBrowser(router, searchParams);
|
||||
}, [router, searchParams]);
|
||||
@@ -77,6 +103,8 @@ export function Navigation({
|
||||
debouncedUpdateQueryParam(newQuery);
|
||||
};
|
||||
|
||||
const hasFacets = feed?.facets && feed.facets.length > 0;
|
||||
|
||||
return (
|
||||
<header
|
||||
ref={headerRef}
|
||||
@@ -155,7 +183,79 @@ export function Navigation({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='justify-end gap-2 px-1'>
|
||||
<div className='justify-end gap-2 px-1 flex items-center'>
|
||||
{hasFacets ? (
|
||||
<div className='lg:hidden flex items-center'>
|
||||
<Dropdown
|
||||
label={_('Options')}
|
||||
className='dropdown-bottom dropdown-end'
|
||||
buttonClassName='btn btn-ghost btn-sm px-2 flex items-center justify-center'
|
||||
toggleButton={<IoFilter className='h-5 w-5 text-base-content/85' />}
|
||||
>
|
||||
<Menu className='dropdown-content no-triangle border-base-300 bg-base-100 z-20 mt-1 min-w-[14rem] max-h-[70vh] overflow-y-auto rounded-lg border shadow-lg'>
|
||||
{onAddCatalog && (
|
||||
<MenuItem
|
||||
label={_('Add to My Catalogs')}
|
||||
Icon={IoAdd}
|
||||
onClick={onAddCatalog}
|
||||
transient
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
'flex flex-col',
|
||||
onAddCatalog && 'mt-1 pt-1 border-t border-base-200',
|
||||
)}
|
||||
>
|
||||
{feed?.facets?.map((facet, i) => (
|
||||
<div key={i} className='mb-2 last:mb-0'>
|
||||
{facet.metadata?.title && (
|
||||
<div className='px-4 py-2 text-xs font-semibold opacity-50 uppercase tracking-wider'>
|
||||
{facet.metadata.title}
|
||||
</div>
|
||||
)}
|
||||
{facet.links.map((link, j) => {
|
||||
const isActiveMapped = link.rel?.includes('self');
|
||||
const href = resolveURL ? resolveURL(link.href || '', baseURL || '') : '';
|
||||
const labelText = link.title || _('Untitled');
|
||||
const countText = link.properties?.numberOfItems
|
||||
? ` (${link.properties.numberOfItems})`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={j}
|
||||
label={`${labelText}${countText}`}
|
||||
toggled={isActiveMapped}
|
||||
onClick={() => {
|
||||
if (onNavigate && href) onNavigate(href);
|
||||
}}
|
||||
transient
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
) : (
|
||||
onAddCatalog && (
|
||||
<div className='flex items-center'>
|
||||
<button
|
||||
className='btn btn-ghost btn-sm px-2 flex items-center justify-center'
|
||||
title={_('Add to My Catalogs')}
|
||||
aria-label={_('Add to My Catalogs')}
|
||||
onClick={onAddCatalog}
|
||||
>
|
||||
<IoAdd className='h-5 w-5 text-base-content/85' />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<WindowButtons
|
||||
className='window-buttons flex h-full items-center'
|
||||
onClose={() => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useCustomOPDSStore } from '@/store/customOPDSStore';
|
||||
import { transferManager } from '@/services/transferManager';
|
||||
import { useTransferQueue } from '@/hooks/useTransferQueue';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
@@ -51,6 +52,7 @@ import { Navigation } from './components/Navigation';
|
||||
import { normalizeOPDSCustomHeaders } from './utils/customHeaders';
|
||||
import { closeOPDSBrowser, stashOPDSReturnTarget } from './utils/opdsClose';
|
||||
import { findExistingBookForPublication } from './utils/findExistingBook';
|
||||
import Dialog from '@/components/Dialog';
|
||||
|
||||
type ViewMode = 'feed' | 'publication' | 'search' | 'loading' | 'error';
|
||||
|
||||
@@ -73,7 +75,7 @@ interface HistoryEntry {
|
||||
export default function BrowserPage() {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { appService } = useEnv();
|
||||
const { appService, envConfig } = useEnv();
|
||||
const { user } = useAuth();
|
||||
const { libraryLoaded } = useLibrary();
|
||||
// Subscribe to library so the publication detail page can detect copies
|
||||
@@ -95,6 +97,8 @@ export default function BrowserPage() {
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
const [showAddCatalog, setShowAddCatalog] = useState(false);
|
||||
const [newCatalogName, setNewCatalogName] = useState('');
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const catalogUrl = searchParams?.get('url') || '';
|
||||
@@ -828,6 +832,48 @@ export default function BrowserPage() {
|
||||
};
|
||||
}, [appService, catalogSourceId, publication, state.baseURL, library, libraryLoaded]);
|
||||
|
||||
const handleOpenAddCatalog = useCallback(() => {
|
||||
const defaultName =
|
||||
state.feed?.metadata?.title || state.search?.metadata?.title || _('New Catalog');
|
||||
const prefix = catalog?.name ? `${catalog.name} - ` : '';
|
||||
setNewCatalogName(`${prefix}${defaultName}`);
|
||||
setShowAddCatalog(true);
|
||||
}, [state.feed, state.search, catalog, _]);
|
||||
|
||||
const handleConfirmAddCatalog = useCallback(() => {
|
||||
if (!newCatalogName.trim()) return;
|
||||
|
||||
if (useCustomOPDSStore.getState().findByUrl(state.currentURL)) {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'info',
|
||||
message: _('Catalog already in My Catalogs'),
|
||||
timeout: 2500,
|
||||
});
|
||||
setShowAddCatalog(false);
|
||||
return;
|
||||
}
|
||||
|
||||
useCustomOPDSStore.getState().addCatalog({
|
||||
id: Date.now().toString(),
|
||||
name: newCatalogName.trim(),
|
||||
url: state.currentURL,
|
||||
username: usernameRef.current || undefined,
|
||||
password: passwordRef.current || undefined,
|
||||
customHeaders: customHeadersRef.current,
|
||||
autoDownload: catalog?.autoDownload || false,
|
||||
});
|
||||
|
||||
useCustomOPDSStore.getState().saveCustomOPDSCatalogs(envConfig);
|
||||
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'success',
|
||||
message: _('Catalog added successfully'),
|
||||
timeout: 2500,
|
||||
});
|
||||
|
||||
setShowAddCatalog(false);
|
||||
}, [newCatalogName, state.currentURL, catalog, envConfig, _]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
@@ -850,6 +896,11 @@ export default function BrowserPage() {
|
||||
canGoBack={canGoBack}
|
||||
canGoForward={canGoForward}
|
||||
hasSearch={hasSearch}
|
||||
feed={state.feed}
|
||||
baseURL={state.baseURL}
|
||||
resolveURL={resolveURL}
|
||||
onNavigate={handleNavigate}
|
||||
onAddCatalog={handleOpenAddCatalog}
|
||||
/>
|
||||
</div>
|
||||
<main className='flex-1 overflow-auto'>
|
||||
@@ -885,6 +936,7 @@ export default function BrowserPage() {
|
||||
resolveURL={resolveURL}
|
||||
onGenerateCachedImageUrl={handleGenerateCachedImageUrl}
|
||||
isOPDSCatalog={isOPDSCatalog}
|
||||
onAddCatalog={handleOpenAddCatalog}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -909,6 +961,48 @@ export default function BrowserPage() {
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<Dialog
|
||||
isOpen={showAddCatalog}
|
||||
title={_('Add to My Catalogs')}
|
||||
onClose={() => setShowAddCatalog(false)}
|
||||
boxClassName='sm:max-w-md sm:h-auto'
|
||||
contentClassName='!px-6 !py-4'
|
||||
>
|
||||
<div className='flex flex-col gap-4 pt-2'>
|
||||
<div className='form-control'>
|
||||
<label className='label'>
|
||||
<span className='label-text font-medium text-sm'>{_('Catalog Name')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={newCatalogName}
|
||||
onChange={(e) => setNewCatalogName(e.target.value)}
|
||||
className='input input-bordered eink-bordered w-full'
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && newCatalogName.trim()) {
|
||||
e.preventDefault();
|
||||
handleConfirmAddCatalog();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='mt-2 flex justify-end gap-2'>
|
||||
<button className='btn btn-ghost btn-sm' onClick={() => setShowAddCatalog(false)}>
|
||||
{_('Cancel')}
|
||||
</button>
|
||||
<button
|
||||
className='btn btn-primary btn-sm'
|
||||
onClick={handleConfirmAddCatalog}
|
||||
disabled={!newCatalogName.trim()}
|
||||
>
|
||||
{_('Save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Toast />
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user