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:
Zeedif
2026-05-29 00:58:04 -05:00
committed by GitHub
parent 2f5e583653
commit c5a1a3afeb
3 changed files with 212 additions and 5 deletions
@@ -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={() => {
+95 -1
View File
@@ -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>
);