fix: improve responsive library sidebar
This commit is contained in:
@@ -29,6 +29,18 @@ const renderDropdown = () =>
|
||||
</DropdownProvider>,
|
||||
);
|
||||
|
||||
const renderTwoDropdowns = () =>
|
||||
render(
|
||||
<DropdownProvider>
|
||||
<Dropdown label='First Menu' toggleButton={<span>First</span>} showTooltip={false}>
|
||||
<div>First content</div>
|
||||
</Dropdown>
|
||||
<Dropdown label='Second Menu' toggleButton={<span>Second</span>} showTooltip={false}>
|
||||
<div>Second content</div>
|
||||
</Dropdown>
|
||||
</DropdownProvider>,
|
||||
);
|
||||
|
||||
describe('Dropdown keyboard activation', () => {
|
||||
it('opens when Enter is pressed on the toggle button', () => {
|
||||
renderDropdown();
|
||||
@@ -91,4 +103,29 @@ describe('Dropdown keyboard activation', () => {
|
||||
window.removeEventListener('keydown', onWindowKeyDown);
|
||||
}
|
||||
});
|
||||
|
||||
it('closes when the pointer is pressed outside', () => {
|
||||
renderDropdown();
|
||||
const toggle = screen.getByRole('button', { name: 'Test Menu' });
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true');
|
||||
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('switches directly to another dropdown on the first click', () => {
|
||||
renderTwoDropdowns();
|
||||
const first = screen.getByRole('button', { name: 'First Menu' });
|
||||
const second = screen.getByRole('button', { name: 'Second Menu' });
|
||||
|
||||
fireEvent.click(first);
|
||||
expect(first.getAttribute('aria-expanded')).toBe('true');
|
||||
|
||||
fireEvent.pointerDown(second);
|
||||
fireEvent.click(second);
|
||||
expect(first.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(second.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,7 +126,7 @@ const BookItem: React.FC<BookItemProps> = ({
|
||||
role='none'
|
||||
className={clsx(
|
||||
'book-item flex',
|
||||
mode === 'grid' && 'h-full flex-col justify-end',
|
||||
mode === 'grid' && 'h-full w-full flex-col justify-end',
|
||||
mode === 'list' && 'min-h-28 flex-row gap-4 overflow-hidden',
|
||||
mode === 'list' ? 'library-list-item' : 'library-grid-item',
|
||||
appService?.hasContextMenu ? 'cursor-pointer' : '',
|
||||
|
||||
@@ -90,6 +90,7 @@ interface BookshelfProps {
|
||||
handlePushLibrary: () => Promise<void>;
|
||||
booksTransferProgress: { [key: string]: number | null };
|
||||
categoryFilter?: LibraryCategoryFilter;
|
||||
sidebarVisible?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,6 +101,7 @@ interface BookshelfProps {
|
||||
type BookshelfListContext = {
|
||||
autoColumns: boolean;
|
||||
fixedColumns: number;
|
||||
sidebarVisible: boolean;
|
||||
/**
|
||||
* The recently-read shelf, rendered in the Virtuoso header so it scrolls with
|
||||
* the shelf content (not sticky). `null` when hidden. Passed through context
|
||||
@@ -109,9 +111,12 @@ type BookshelfListContext = {
|
||||
recentShelfHeader: React.ReactNode;
|
||||
};
|
||||
|
||||
const BOOKSHELF_GRID_CLASSES =
|
||||
'bookshelf-items transform-wrapper grid gap-x-4 px-4 sm:gap-x-0 sm:px-2 ' +
|
||||
const BOOKSHELF_GRID_BASE_CLASSES =
|
||||
'bookshelf-items transform-wrapper grid gap-x-4 px-4 sm:gap-x-0 sm:px-2';
|
||||
const BOOKSHELF_GRID_DEFAULT_COLUMNS =
|
||||
'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12';
|
||||
const BOOKSHELF_GRID_SIDEBAR_COLUMNS =
|
||||
'grid-cols-3 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-8';
|
||||
|
||||
const BOOKSHELF_LIST_CLASSES = 'bookshelf-items transform-wrapper flex flex-col';
|
||||
|
||||
@@ -122,7 +127,11 @@ const BookshelfGridList: GridComponents<BookshelfListContext>['List'] = React.fo
|
||||
<div
|
||||
ref={ref}
|
||||
data-testid={testId}
|
||||
className={clsx(BOOKSHELF_GRID_CLASSES, className)}
|
||||
className={clsx(
|
||||
BOOKSHELF_GRID_BASE_CLASSES,
|
||||
context?.sidebarVisible ? BOOKSHELF_GRID_SIDEBAR_COLUMNS : BOOKSHELF_GRID_DEFAULT_COLUMNS,
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
...style,
|
||||
gridTemplateColumns:
|
||||
@@ -178,6 +187,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
handlePushLibrary,
|
||||
booksTransferProgress,
|
||||
categoryFilter = 'all',
|
||||
sidebarVisible = false,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -849,6 +859,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
coverFit={coverFit as LibraryCoverFitType}
|
||||
autoColumns={settings.libraryAutoColumns}
|
||||
fixedColumns={settings.libraryColumns}
|
||||
sidebarVisible={sidebarVisible}
|
||||
onOpenBook={openRecentBook}
|
||||
handleBookUpload={handleBookUpload}
|
||||
handleBookDownload={handleBookDownload}
|
||||
@@ -861,6 +872,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
coverFit,
|
||||
settings.libraryAutoColumns,
|
||||
settings.libraryColumns,
|
||||
sidebarVisible,
|
||||
openRecentBook,
|
||||
handleBookUpload,
|
||||
handleBookDownload,
|
||||
@@ -872,9 +884,10 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
() => ({
|
||||
autoColumns: settings.libraryAutoColumns,
|
||||
fixedColumns: settings.libraryColumns,
|
||||
sidebarVisible,
|
||||
recentShelfHeader,
|
||||
}),
|
||||
[settings.libraryAutoColumns, settings.libraryColumns, recentShelfHeader],
|
||||
[settings.libraryAutoColumns, settings.libraryColumns, sidebarVisible, recentShelfHeader],
|
||||
);
|
||||
|
||||
const renderBookshelfItem = useCallback(
|
||||
|
||||
@@ -22,6 +22,7 @@ const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupS
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [showLeftArrow, setShowLeftArrow] = useState(false);
|
||||
const [showRightArrow, setShowRightArrow] = useState(false);
|
||||
const isSingleBookGrid = mode === 'grid' && group.books.length === 1;
|
||||
|
||||
const checkScrollArrows = () => {
|
||||
if (mode === 'list' && scrollContainerRef.current) {
|
||||
@@ -115,7 +116,10 @@ const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupS
|
||||
<div
|
||||
ref={mode === 'list' ? scrollContainerRef : undefined}
|
||||
className={clsx(
|
||||
mode === 'grid' && 'grid w-full grid-cols-2 grid-rows-2 gap-1 overflow-hidden',
|
||||
mode === 'grid' &&
|
||||
(isSingleBookGrid
|
||||
? 'flex w-full overflow-hidden'
|
||||
: 'grid w-full grid-cols-2 grid-rows-2 gap-1 overflow-hidden'),
|
||||
mode === 'list' && 'flex h-28 gap-2 overflow-x-auto overflow-y-hidden',
|
||||
mode === 'list' ? 'library-list-item' : 'library-grid-item',
|
||||
)}
|
||||
@@ -137,7 +141,7 @@ const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupS
|
||||
key={book.hash}
|
||||
className={clsx(
|
||||
'relative aspect-[28/41] h-full',
|
||||
mode === 'grid' && 'w-full',
|
||||
mode === 'grid' && (isSingleBookGrid ? 'mx-auto' : 'w-full'),
|
||||
mode === 'list' && 'flex-shrink-0',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -8,6 +8,7 @@ import Menu from '@/components/Menu';
|
||||
|
||||
interface ImportMenuProps {
|
||||
setIsDropdownOpen?: (open: boolean) => void;
|
||||
menuClassName?: string;
|
||||
onImportBooksFromFiles: () => void;
|
||||
onImportBooksFromDirectory?: () => void;
|
||||
onImportBookFromUrl?: () => void;
|
||||
@@ -16,6 +17,7 @@ interface ImportMenuProps {
|
||||
|
||||
const ImportMenu: React.FC<ImportMenuProps> = ({
|
||||
setIsDropdownOpen,
|
||||
menuClassName,
|
||||
onImportBooksFromFiles,
|
||||
onImportBooksFromDirectory,
|
||||
onImportBookFromUrl,
|
||||
@@ -46,7 +48,10 @@ const ImportMenu: React.FC<ImportMenuProps> = ({
|
||||
|
||||
return (
|
||||
<Menu
|
||||
className={clsx('dropdown-content bg-base-100 rounded-box !relative z-[1] mt-3 p-2 shadow')}
|
||||
className={clsx(
|
||||
'dropdown-content bg-base-100 rounded-box !relative z-[1] mt-3 p-2 shadow',
|
||||
menuClassName,
|
||||
)}
|
||||
onCancel={() => setIsDropdownOpen?.(false)}
|
||||
>
|
||||
<MenuItem
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useTrafficLight } from '@/hooks/useTrafficLight';
|
||||
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
||||
import { useIsMobileViewport } from '@/hooks/useIsMobileViewport';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import useShortcuts from '@/hooks/useShortcuts';
|
||||
import WindowButtons from '@/components/WindowButtons';
|
||||
@@ -33,6 +34,7 @@ interface LibraryHeaderProps {
|
||||
onToggleSelectMode: () => void;
|
||||
onSelectAll: () => void;
|
||||
onDeselectAll: () => void;
|
||||
onToggleSidebar: () => void;
|
||||
}
|
||||
|
||||
const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
@@ -46,6 +48,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
onToggleSelectMode,
|
||||
onSelectAll,
|
||||
onDeselectAll,
|
||||
onToggleSidebar,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -59,6 +62,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
const { isTrafficLightVisible } = useTrafficLight(headerRef);
|
||||
const iconSize18 = useResponsiveSize(18);
|
||||
const { safeAreaInsets: insets } = useThemeStore();
|
||||
const isNarrowViewport = useIsMobileViewport(641);
|
||||
|
||||
useShortcuts({
|
||||
onToggleSelectMode,
|
||||
@@ -92,7 +96,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
|
||||
if (!insets) return null;
|
||||
|
||||
const isMobile = appService?.isMobile || window.innerWidth <= 640;
|
||||
const isMobile = appService?.isMobile || isNarrowViewport;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -109,8 +113,17 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
||||
}}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between space-x-6 sm:space-x-12'>
|
||||
<div className='exclude-title-bar-mousedown relative flex w-full items-center pl-4'>
|
||||
<div className='relative flex h-9 w-full items-center sm:h-7'>
|
||||
<div className='exclude-title-bar-mousedown relative flex w-full items-center gap-2 pl-2 sm:pl-4'>
|
||||
<button
|
||||
type='button'
|
||||
className='btn btn-ghost h-8 min-h-8 w-8 shrink-0 p-0'
|
||||
aria-label={_('Toggle Sidebar')}
|
||||
title={_('Toggle Sidebar')}
|
||||
onClick={onToggleSidebar}
|
||||
>
|
||||
<MdOutlineMenu role='none' size={iconSize18} />
|
||||
</button>
|
||||
<div className='relative flex h-9 min-w-0 flex-1 items-center sm:h-7'>
|
||||
<span className='text-base-content/50 absolute ps-3'>
|
||||
<FaSearch className='h-4 w-4' />
|
||||
</span>
|
||||
|
||||
@@ -44,6 +44,8 @@ interface LibrarySidebarProps {
|
||||
onImportBookFromUrl?: () => void;
|
||||
onOpenCatalogManager: () => void;
|
||||
onToggleSelectMode: () => void;
|
||||
onToggleSidebar: () => void;
|
||||
isViewportResolved: boolean;
|
||||
}
|
||||
|
||||
type CategoryItem = {
|
||||
@@ -63,6 +65,8 @@ const LibrarySidebar: React.FC<LibrarySidebarProps> = ({
|
||||
onImportBookFromUrl,
|
||||
onOpenCatalogManager,
|
||||
onToggleSelectMode,
|
||||
onToggleSidebar,
|
||||
isViewportResolved,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -160,13 +164,22 @@ const LibrarySidebar: React.FC<LibrarySidebarProps> = ({
|
||||
return (
|
||||
<aside
|
||||
className={clsx(
|
||||
'library-sidebar bg-base-200/80 border-base-300/70 hidden h-full w-[248px] shrink-0 flex-col border-r md:flex',
|
||||
'library-sidebar bg-base-200/95 border-base-300/70 fixed inset-y-0 start-0 z-50 h-full w-[min(82vw,248px)] shrink-0 flex-col border-r md:relative md:inset-auto md:z-auto md:w-[248px]',
|
||||
isViewportResolved ? 'flex' : 'hidden md:flex',
|
||||
'pt-[max(env(safe-area-inset-top),0px)]',
|
||||
)}
|
||||
>
|
||||
<div className='exclude-title-bar-mousedown flex min-h-0 flex-1 flex-col px-3 py-3'>
|
||||
<div className='mb-3 flex h-8 items-center gap-2 px-1'>
|
||||
<MdOutlineMenu className='text-base-content/70 h-5 w-5 shrink-0' />
|
||||
<button
|
||||
type='button'
|
||||
className='btn btn-ghost h-7 min-h-7 w-7 shrink-0 p-0'
|
||||
aria-label={_('Toggle Sidebar')}
|
||||
title={_('Toggle Sidebar')}
|
||||
onClick={onToggleSidebar}
|
||||
>
|
||||
<MdOutlineMenu className='text-base-content/70 h-5 w-5' />
|
||||
</button>
|
||||
<div className='min-w-0 flex-1 truncate text-sm font-semibold'>Readest</div>
|
||||
</div>
|
||||
|
||||
@@ -268,6 +281,7 @@ const LibrarySidebar: React.FC<LibrarySidebarProps> = ({
|
||||
<Dropdown
|
||||
label={_('Import Books')}
|
||||
className='exclude-title-bar-mousedown dropdown-top dropdown-start'
|
||||
menuClassName='mb-2 mt-0'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
toggleButton={<PiPlus role='none' size={iconSize18} />}
|
||||
>
|
||||
@@ -280,7 +294,8 @@ const LibrarySidebar: React.FC<LibrarySidebarProps> = ({
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
label={_('View Menu')}
|
||||
className='exclude-title-bar-mousedown dropdown-top dropdown-start'
|
||||
className='exclude-title-bar-mousedown dropdown-top dropdown-center'
|
||||
menuClassName='mb-2 mt-0'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
toggleButton={<PiDotsThreeCircle role='none' size={iconSize18} />}
|
||||
>
|
||||
@@ -301,7 +316,8 @@ const LibrarySidebar: React.FC<LibrarySidebarProps> = ({
|
||||
<div className='flex-1' />
|
||||
<Dropdown
|
||||
label={_('Settings Menu')}
|
||||
className='exclude-title-bar-mousedown dropdown-top dropdown-start'
|
||||
className='exclude-title-bar-mousedown dropdown-top dropdown-end'
|
||||
menuClassName='mb-2 mt-0'
|
||||
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
||||
toggleButton={<PiGear role='none' size={iconSize18} />}
|
||||
>
|
||||
|
||||
@@ -19,6 +19,7 @@ interface RecentShelfProps {
|
||||
// Mirror the bookshelf grid's column model so covers are the same size.
|
||||
autoColumns: boolean;
|
||||
fixedColumns: number;
|
||||
sidebarVisible?: boolean;
|
||||
onOpenBook: (book: Book) => void;
|
||||
handleBookUpload: (book: Book) => void;
|
||||
handleBookDownload: (book: Book, options?: { redownload?: boolean; queued?: boolean }) => void;
|
||||
@@ -110,6 +111,7 @@ const RecentShelf: React.FC<RecentShelfProps> = ({
|
||||
coverFit,
|
||||
autoColumns,
|
||||
fixedColumns,
|
||||
sidebarVisible = false,
|
||||
onOpenBook,
|
||||
handleBookUpload,
|
||||
handleBookDownload,
|
||||
@@ -121,7 +123,9 @@ const RecentShelf: React.FC<RecentShelfProps> = ({
|
||||
// `--rs-gap` mirrors the grid's `gap-x-4 sm:gap-x-0` so the width formula
|
||||
// subtracts the right gap at each breakpoint.
|
||||
const colsClass = autoColumns
|
||||
? '[--rs-cols:3] sm:[--rs-cols:4] md:[--rs-cols:6] xl:[--rs-cols:8] 2xl:[--rs-cols:12]'
|
||||
? sidebarVisible
|
||||
? '[--rs-cols:3] sm:[--rs-cols:4] md:[--rs-cols:3] lg:[--rs-cols:4] xl:[--rs-cols:5] 2xl:[--rs-cols:8]'
|
||||
: '[--rs-cols:3] sm:[--rs-cols:4] md:[--rs-cols:6] xl:[--rs-cols:8] 2xl:[--rs-cols:12]'
|
||||
: '';
|
||||
const colsStyle = autoColumns
|
||||
? undefined
|
||||
@@ -162,7 +166,7 @@ const RecentShelf: React.FC<RecentShelfProps> = ({
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [measure, books, autoColumns, fixedColumns, coverFit]);
|
||||
}, [measure, books, autoColumns, fixedColumns, sidebarVisible, coverFit]);
|
||||
|
||||
const scrollByPage = (direction: -1 | 1) => {
|
||||
const el = scrollerRef.current;
|
||||
|
||||
@@ -47,9 +47,14 @@ import { type AppLockDialogMode, useAppLockStore } from '@/store/appLockStore';
|
||||
interface SettingsMenuProps {
|
||||
onPullLibrary: (fullRefresh?: boolean, verbose?: boolean) => void;
|
||||
setIsDropdownOpen?: (isOpen: boolean) => void;
|
||||
menuClassName?: string;
|
||||
}
|
||||
|
||||
const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdownOpen }) => {
|
||||
const SettingsMenu: React.FC<SettingsMenuProps> = ({
|
||||
onPullLibrary,
|
||||
setIsDropdownOpen,
|
||||
menuClassName,
|
||||
}) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const { envConfig, appService } = useEnv();
|
||||
@@ -318,6 +323,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdow
|
||||
className={clsx(
|
||||
'settings-menu dropdown-content no-triangle',
|
||||
'z-20 mt-2 max-w-[90vw] shadow-2xl',
|
||||
menuClassName,
|
||||
)}
|
||||
onCancel={() => setIsDropdownOpen?.(false)}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEnv } from '@/context/EnvContext';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
@@ -15,17 +16,21 @@ import { navigateToLibrary } from '@/utils/nav';
|
||||
import NumberInput from '@/components/settings/NumberInput';
|
||||
import MenuItem from '@/components/MenuItem';
|
||||
import Menu from '@/components/Menu';
|
||||
import { useIsMobileViewport } from '@/hooks/useIsMobileViewport';
|
||||
|
||||
interface ViewMenuProps {
|
||||
setIsDropdownOpen?: (isOpen: boolean) => void;
|
||||
menuClassName?: string;
|
||||
}
|
||||
|
||||
const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen, menuClassName }) => {
|
||||
const _ = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { envConfig } = useEnv();
|
||||
const { settings } = useSettingsStore();
|
||||
const isPhoneViewport = useIsMobileViewport();
|
||||
const isCompactViewport = useIsMobileViewport(1024);
|
||||
|
||||
const viewMode = settings.libraryViewMode;
|
||||
const coverFit = settings.libraryCoverFit;
|
||||
@@ -170,7 +175,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
|
||||
return (
|
||||
<Menu
|
||||
className='view-menu dropdown-content no-triangle z-20 mt-2 shadow-2xl'
|
||||
className={clsx('view-menu dropdown-content no-triangle z-20 mt-2 shadow-2xl', menuClassName)}
|
||||
onCancel={() => setIsDropdownOpen?.(false)}
|
||||
>
|
||||
{/* View Mode */}
|
||||
@@ -201,11 +206,12 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
|
||||
value={columns}
|
||||
disabled={viewMode === 'list'}
|
||||
onChange={handleSetColumns}
|
||||
min={window.innerWidth < 640 ? 1 : window.innerWidth < 1024 ? 2 : 3}
|
||||
max={window.innerWidth < 640 ? 4 : window.innerWidth < 1024 ? 6 : 12}
|
||||
min={isPhoneViewport ? 1 : isCompactViewport ? 2 : 3}
|
||||
max={isPhoneViewport ? 4 : isCompactViewport ? 6 : 12}
|
||||
/>
|
||||
}
|
||||
onClick={() => handleToggleAutoColumns()}
|
||||
transient
|
||||
/>
|
||||
|
||||
{/* Book Covers */}
|
||||
|
||||
@@ -122,6 +122,7 @@ import DropIndicator from '@/components/DropIndicator';
|
||||
import SettingsDialog from '@/components/settings/SettingsDialog';
|
||||
import ModalPortal from '@/components/ModalPortal';
|
||||
import TransferQueuePanel from './components/TransferQueuePanel';
|
||||
import { Overlay } from '@/components/Overlay';
|
||||
|
||||
/** Skip tiny non-book artifacts during folder auto-scan (matches the manual import dialog default). */
|
||||
const AUTO_IMPORT_MIN_SIZE_BYTES = 20 * 1024;
|
||||
@@ -158,6 +159,19 @@ const LAST_IMPORT_FOLDER_MIN_SIZE_KEY = 'readest:lastImportFolderMinSizeKB';
|
||||
* dialog forces the toggle ON regardless of this value.
|
||||
*/
|
||||
const LAST_IMPORT_FOLDER_READ_IN_PLACE_KEY = 'readest:lastImportFolderReadInPlace';
|
||||
const LIBRARY_SIDEBAR_VISIBLE_KEY = 'readest:librarySidebarVisible';
|
||||
|
||||
const readLibrarySidebarVisibility = (): boolean | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const stored = window.localStorage.getItem(LIBRARY_SIDEBAR_VISIBLE_KEY);
|
||||
if (stored === '1') return true;
|
||||
if (stored === '0') return false;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const LibraryPageWithSearchParams = () => {
|
||||
const searchParams = useSearchParams();
|
||||
@@ -218,6 +232,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
const [isSelectNone, setIsSelectNone] = useState(false);
|
||||
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
|
||||
const [failedImportsModal, setFailedImportsModal] = useState<FailedImport[] | null>(null);
|
||||
const [isDesktopLibraryViewport, setDesktopLibraryViewport] = useState(true);
|
||||
const [isLibrarySidebarDocked, setLibrarySidebarDockedState] = useState(true);
|
||||
const [isLibrarySidebarDrawerOpen, setLibrarySidebarDrawerOpen] = useState(false);
|
||||
const [isLibraryViewportResolved, setLibraryViewportResolved] = useState(false);
|
||||
// "Import from folder" dialog state. Held as a small object rather
|
||||
// than a boolean because we need a default starting directory to seed
|
||||
// the path field, and we want the dialog to remain mounted long
|
||||
@@ -300,6 +318,51 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
useClipUrlIngress();
|
||||
useTransferQueue(libraryLoaded);
|
||||
|
||||
const setLibrarySidebarDocked = useCallback((visible: boolean) => {
|
||||
setLibrarySidebarDockedState(visible);
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(LIBRARY_SIDEBAR_VISIBLE_KEY, visible ? '1' : '0');
|
||||
} catch {
|
||||
// localStorage can be unavailable in constrained webviews; the in-memory
|
||||
// state still gives the user a working toggle for this session.
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const mediaQuery = window.matchMedia('(min-width: 768px)');
|
||||
const syncSidebarMode = () => {
|
||||
const isDesktop = mediaQuery.matches;
|
||||
setDesktopLibraryViewport(isDesktop);
|
||||
setLibrarySidebarDrawerOpen(false);
|
||||
if (isDesktop) {
|
||||
setLibrarySidebarDockedState(readLibrarySidebarVisibility() ?? true);
|
||||
}
|
||||
setLibraryViewportResolved(true);
|
||||
};
|
||||
|
||||
syncSidebarMode();
|
||||
mediaQuery.addEventListener('change', syncSidebarMode);
|
||||
return () => mediaQuery.removeEventListener('change', syncSidebarMode);
|
||||
}, []);
|
||||
|
||||
const openLibrarySidebar = useCallback(() => {
|
||||
if (isDesktopLibraryViewport) {
|
||||
setLibrarySidebarDocked(true);
|
||||
} else {
|
||||
setLibrarySidebarDrawerOpen(true);
|
||||
}
|
||||
}, [isDesktopLibraryViewport, setLibrarySidebarDocked]);
|
||||
|
||||
const closeLibrarySidebar = useCallback(() => {
|
||||
if (isDesktopLibraryViewport) {
|
||||
setLibrarySidebarDocked(false);
|
||||
} else {
|
||||
setLibrarySidebarDrawerOpen(false);
|
||||
}
|
||||
}, [isDesktopLibraryViewport, setLibrarySidebarDocked]);
|
||||
|
||||
const { pullLibrary, pushLibrary } = useBooksSync();
|
||||
// Library-scoped auto-sync for the active third-party cloud provider (WebDAV /
|
||||
// Google Drive): keeps library.json current on import / delete / book-close,
|
||||
@@ -1595,6 +1658,11 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
|
||||
const showBookshelf = libraryLoaded || libraryBooks.length > 0;
|
||||
const categoryFilter = ensureLibraryCategoryFilter(searchParams?.get('category'));
|
||||
const isLibrarySidebarVisible = isDesktopLibraryViewport
|
||||
? isLibrarySidebarDocked
|
||||
: isLibrarySidebarDrawerOpen;
|
||||
const isLibrarySidebarDockedVisible = isDesktopLibraryViewport && isLibrarySidebarDocked;
|
||||
const showLibraryHeader = !isLibrarySidebarVisible;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1606,21 +1674,39 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
appService?.hasRoundedWindow && isRoundedWindow && 'window-border rounded-window',
|
||||
)}
|
||||
>
|
||||
<LibrarySidebar
|
||||
books={libraryBooks}
|
||||
isSelectMode={isSelectMode}
|
||||
onPullLibrary={pullLibrary}
|
||||
onImportBooksFromFiles={handleImportBooksFromFiles}
|
||||
onImportBooksFromDirectory={
|
||||
appService?.canReadExternalDir ? handleImportBooksFromDirectory : undefined
|
||||
}
|
||||
onImportBookFromUrl={isTauriAppPlatform() ? () => setShowImportFromUrl(true) : undefined}
|
||||
onOpenCatalogManager={handleShowOPDSDialog}
|
||||
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
|
||||
/>
|
||||
{isLibrarySidebarVisible && (
|
||||
<>
|
||||
{!isDesktopLibraryViewport && (
|
||||
<Overlay className='z-40 bg-black/20' onDismiss={closeLibrarySidebar} />
|
||||
)}
|
||||
<LibrarySidebar
|
||||
books={libraryBooks}
|
||||
isSelectMode={isSelectMode}
|
||||
onPullLibrary={pullLibrary}
|
||||
onImportBooksFromFiles={handleImportBooksFromFiles}
|
||||
onImportBooksFromDirectory={
|
||||
appService?.canReadExternalDir ? handleImportBooksFromDirectory : undefined
|
||||
}
|
||||
onImportBookFromUrl={
|
||||
isTauriAppPlatform() ? () => setShowImportFromUrl(true) : undefined
|
||||
}
|
||||
onOpenCatalogManager={handleShowOPDSDialog}
|
||||
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
|
||||
onToggleSidebar={closeLibrarySidebar}
|
||||
isViewportResolved={isLibraryViewportResolved}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className='flex min-w-0 flex-1 flex-col overflow-hidden'>
|
||||
<div
|
||||
className='relative top-0 z-40 w-full md:hidden'
|
||||
className={clsx(
|
||||
'relative top-0 z-40 w-full',
|
||||
isLibraryViewportResolved
|
||||
? showLibraryHeader
|
||||
? 'block'
|
||||
: 'hidden'
|
||||
: 'block md:hidden',
|
||||
)}
|
||||
role='banner'
|
||||
tabIndex={-1}
|
||||
aria-label={_('Library Header')}
|
||||
@@ -1640,6 +1726,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
|
||||
onSelectAll={handleSelectAll}
|
||||
onDeselectAll={handleDeselectAll}
|
||||
onToggleSidebar={openLibrarySidebar}
|
||||
/>
|
||||
<progress
|
||||
aria-label={_('Library Sync Progress')}
|
||||
@@ -1652,16 +1739,18 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
max='100'
|
||||
/>
|
||||
</div>
|
||||
<progress
|
||||
aria-label={_('Library Sync Progress')}
|
||||
aria-hidden={isSyncing ? 'false' : 'true'}
|
||||
className={clsx(
|
||||
'progress progress-success hidden h-1 rounded-none transition-opacity duration-200 md:block',
|
||||
isSyncing ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
value={syncProgress * 100}
|
||||
max='100'
|
||||
/>
|
||||
{!showLibraryHeader && (
|
||||
<progress
|
||||
aria-label={_('Library Sync Progress')}
|
||||
aria-hidden={isSyncing ? 'false' : 'true'}
|
||||
className={clsx(
|
||||
'progress progress-success hidden h-1 rounded-none transition-opacity duration-200 md:block',
|
||||
isSyncing ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
value={syncProgress * 100}
|
||||
max='100'
|
||||
/>
|
||||
)}
|
||||
{(loading || isSyncing) && (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center'>
|
||||
<Spinner loading />
|
||||
@@ -1739,6 +1828,7 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
|
||||
handleLibraryNavigation={handleLibraryNavigation}
|
||||
booksTransferProgress={booksTransferProgress}
|
||||
handlePushLibrary={pushLibrary}
|
||||
sidebarVisible={isLibrarySidebarDockedVisible}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useState, isValidElement, ReactElement, ReactNode, useRef, useId } from 'react';
|
||||
import React, {
|
||||
useState,
|
||||
isValidElement,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
useRef,
|
||||
useId,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { useDropdownContext } from '@/context/DropdownContext';
|
||||
import { Overlay } from './Overlay';
|
||||
import MenuItem from './MenuItem';
|
||||
|
||||
interface DropdownProps {
|
||||
@@ -74,6 +81,7 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||
const dropdownId = useId();
|
||||
const context = useDropdownContext();
|
||||
const isOpen = context ? context.openDropdownId === dropdownId : false;
|
||||
const hasOpenDropdown = context ? context.openDropdownId !== null : isOpen;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
@@ -108,6 +116,21 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (containerRef.current?.contains(event.target as Node)) return;
|
||||
if (event.target instanceof Element && event.target.closest('.dropdown-container') !== null) {
|
||||
return;
|
||||
}
|
||||
setIsDropdownOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
return () => document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
const childrenWithToggle = isValidElement(children)
|
||||
? React.cloneElement(children, {
|
||||
...(typeof children.type !== 'string' && {
|
||||
@@ -120,8 +143,7 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={clsx('dropdown-container flex', containerClassName)}>
|
||||
{isOpen && <Overlay onDismiss={() => setIsDropdownOpen(false)} />}
|
||||
<div className={clsx('relative', isOpen && 'z-50')}>
|
||||
<div className={clsx('relative', isOpen ? 'z-[60]' : hasOpenDropdown && 'z-[70]')}>
|
||||
<button
|
||||
tabIndex={0}
|
||||
aria-haspopup='menu'
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const useIsMobileViewport = (breakpoint = 640) => {
|
||||
const readViewport = () =>
|
||||
typeof window === 'undefined' ? false : window.innerWidth < breakpoint;
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(readViewport);
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
Reference in New Issue
Block a user