forked from akai/readest
a1279a65ce
Builds the URL-clipping path of the "Send to Readest" feature: paste a
link, the renderer ingests the rendered page, and a self-contained EPUB
lands in the library. No server proxy, no external CDN refs left in the
EPUB once it's saved.
Architecture
- New Rust `clip_url` command spawns a hidden Tauri WebviewWindow at the
target URL with a real Chrome UA + WebKit fingerprint mask, so TLS-
fingerprint and JS-challenge walls (Cloudflare, Medium, X, WeChat MP)
resolve naturally instead of bouncing the server proxy.
- Capture transport is URL-payload navigation to a one-shot
127.0.0.1:RANDOM_PORT/clip/{token}?d={url-safe-base64} listener.
Top-level navigation isn't governed by CSP connect-src / form-action /
WebKit Private Network Access — the four earlier transports
(fetch, <form>, custom URI scheme, window.name) were each blocked by
one of those.
- Page-to-EPUB bundler (`assetBundler`) walks <img>/<picture> with
src → data-src → data-original → data-srcset → srcset fallback so lazy-
loading sites don't ship a 60px LQIP; fetches assets in parallel with a
per-asset timeout + per-asset/total caps; failed images degrade to alt-
text placeholders. A per-site rules table (seeded with WeChat MP) + a
selector fallback catches articles Readability misextracts. Builder
prepends the article <h1> + byline so the EPUB has a proper opening.
- Nested EPUB TOC built from h1–h6.
UI surfaces
- "From Web URL" entry in the library Import menu, gated to Tauri; web
build hides the URL field and points at the browser extension.
- `ImportFromUrlDialog` with auto-height (overrides Dialog's `sm:h-[65%]`
default) and a dim placeholder for the URL field.
- Clip webview window styled to match Readest's main window — macOS
decorations + overlay title bar; other desktops decorationless with a
drop shadow; native background + in-page loading overlay pick up the
caller's `themeCode.bg`/`fg` so light/dark/eink/custom themes all
render correctly. Title localised, all five overlay/title strings
translated across 33 locales.
Notes
- Gates the macOS traffic-light positioner to main/reader-* windows so
the decorationless clip window no longer null-derefs in
`position_traffic_lights`.
- Stricter validation across the path: schemes restricted to http/https,
hex-color parsing rejects malformed values, server endpoint returns
400 on missing/invalid base64.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
237 lines
8.5 KiB
TypeScript
237 lines
8.5 KiB
TypeScript
import clsx from 'clsx';
|
|
import React, { useCallback, useRef, useState } from 'react';
|
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
import { FaSearch } from 'react-icons/fa';
|
|
import { PiPlus } from 'react-icons/pi';
|
|
import { PiSelectionAll, PiSelectionAllFill } from 'react-icons/pi';
|
|
import { PiDotsThreeCircle } from 'react-icons/pi';
|
|
import { MdOutlineMenu } from 'react-icons/md';
|
|
import { IoMdCloseCircle } from 'react-icons/io';
|
|
|
|
import { useEnv } from '@/context/EnvContext';
|
|
import { useThemeStore } from '@/store/themeStore';
|
|
import { useTranslation } from '@/hooks/useTranslation';
|
|
import { useLibraryStore } from '@/store/libraryStore';
|
|
import { useTrafficLight } from '@/hooks/useTrafficLight';
|
|
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
|
|
import { debounce } from '@/utils/debounce';
|
|
import useShortcuts from '@/hooks/useShortcuts';
|
|
import WindowButtons from '@/components/WindowButtons';
|
|
import Dropdown from '@/components/Dropdown';
|
|
import SettingsMenu from './SettingsMenu';
|
|
import ImportMenu from './ImportMenu';
|
|
import ViewMenu from './ViewMenu';
|
|
|
|
interface LibraryHeaderProps {
|
|
isSelectMode: boolean;
|
|
isSelectAll: boolean;
|
|
onPullLibrary: () => void;
|
|
onImportBooksFromFiles: () => void;
|
|
onImportBooksFromDirectory?: () => void;
|
|
onImportBookFromUrl?: () => void;
|
|
onOpenCatalogManager: () => void;
|
|
onToggleSelectMode: () => void;
|
|
onSelectAll: () => void;
|
|
onDeselectAll: () => void;
|
|
}
|
|
|
|
const LibraryHeader: React.FC<LibraryHeaderProps> = ({
|
|
isSelectMode,
|
|
isSelectAll,
|
|
onPullLibrary,
|
|
onImportBooksFromFiles,
|
|
onImportBooksFromDirectory,
|
|
onImportBookFromUrl,
|
|
onOpenCatalogManager,
|
|
onToggleSelectMode,
|
|
onSelectAll,
|
|
onDeselectAll,
|
|
}) => {
|
|
const _ = useTranslation();
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const { appService } = useEnv();
|
|
const { systemUIVisible, statusBarHeight } = useThemeStore();
|
|
const { currentBookshelf } = useLibraryStore();
|
|
const { isTrafficLightVisible } = useTrafficLight();
|
|
const [searchQuery, setSearchQuery] = useState(searchParams?.get('q') ?? '');
|
|
|
|
const headerRef = useRef<HTMLDivElement>(null);
|
|
const iconSize18 = useResponsiveSize(18);
|
|
const { safeAreaInsets: insets } = useThemeStore();
|
|
|
|
useShortcuts({
|
|
onToggleSelectMode,
|
|
});
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
const debouncedUpdateQueryParam = useCallback(
|
|
debounce((value: string) => {
|
|
const params = new URLSearchParams(searchParams?.toString());
|
|
if (value) {
|
|
params.set('q', value);
|
|
} else {
|
|
params.delete('q');
|
|
}
|
|
router.push(`?${params.toString()}`);
|
|
}, 500),
|
|
[searchParams],
|
|
);
|
|
|
|
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const newQuery = e.target.value;
|
|
setSearchQuery(newQuery);
|
|
debouncedUpdateQueryParam(newQuery);
|
|
};
|
|
|
|
const windowButtonVisible = appService?.hasWindowBar && !isTrafficLightVisible;
|
|
const currentBooksCount = currentBookshelf.reduce(
|
|
(acc, item) => acc + ('books' in item ? item.books.length : 1),
|
|
0,
|
|
);
|
|
|
|
if (!insets) return null;
|
|
|
|
const isMobile = appService?.isMobile || window.innerWidth <= 640;
|
|
|
|
return (
|
|
<div
|
|
ref={headerRef}
|
|
className={clsx(
|
|
'titlebar z-10 flex h-[52px] w-full items-center py-2 pr-4 sm:h-[48px]',
|
|
windowButtonVisible ? 'sm:pr-4' : 'sm:pr-6',
|
|
isTrafficLightVisible ? 'pl-16' : 'pl-0 sm:pl-2',
|
|
)}
|
|
style={{
|
|
marginTop: appService?.hasSafeAreaInset
|
|
? `max(${insets.top}px, ${systemUIVisible ? statusBarHeight : 0}px)`
|
|
: appService?.hasTrafficLight
|
|
? '-2px'
|
|
: '0px',
|
|
}}
|
|
>
|
|
<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'>
|
|
<span className='text-base-content/50 absolute ps-3'>
|
|
<FaSearch className='h-4 w-4' />
|
|
</span>
|
|
<input
|
|
type='text'
|
|
value={searchQuery}
|
|
placeholder={
|
|
currentBooksCount > 1
|
|
? _('Search in {{count}} Book(s)...', {
|
|
count: currentBooksCount,
|
|
})
|
|
: _('Search Books...')
|
|
}
|
|
onChange={handleSearchChange}
|
|
spellCheck='false'
|
|
className={clsx(
|
|
'search-input input h-9 w-full rounded-full pr-[30%] ps-10 sm:h-7',
|
|
'bg-base-300/45 border-0',
|
|
'font-sans text-sm font-light',
|
|
'placeholder:text-base-content/50 truncate',
|
|
'focus:outline-none focus:ring-0',
|
|
)}
|
|
/>
|
|
</div>
|
|
<div className='text-base-content/50 absolute right-4 flex items-center space-x-2 sm:space-x-4'>
|
|
{searchQuery && (
|
|
<button
|
|
type='button'
|
|
onClick={() => {
|
|
setSearchQuery('');
|
|
debouncedUpdateQueryParam('');
|
|
}}
|
|
className='text-base-content/40 hover:text-base-content/60 pe-1'
|
|
aria-label={_('Clear Search')}
|
|
>
|
|
<IoMdCloseCircle className='h-4 w-4' />
|
|
</button>
|
|
)}
|
|
<span className='bg-base-content/50 mx-2 h-4 w-[0.5px]'></span>
|
|
<Dropdown
|
|
label={_('Import Books')}
|
|
className={clsx(
|
|
'exclude-title-bar-mousedown dropdown-bottom dropdown-center cursor-pointer',
|
|
)}
|
|
buttonClassName='p-0 h-6 min-h-6 w-6 flex touch-target items-center justify-center !bg-transparent'
|
|
toggleButton={<PiPlus role='none' className='m-0.5 h-5 w-5' />}
|
|
>
|
|
<ImportMenu
|
|
onImportBooksFromFiles={onImportBooksFromFiles}
|
|
onImportBooksFromDirectory={onImportBooksFromDirectory}
|
|
onImportBookFromUrl={onImportBookFromUrl}
|
|
onOpenCatalogManager={onOpenCatalogManager}
|
|
/>
|
|
</Dropdown>
|
|
{isMobile ? null : (
|
|
<button
|
|
onClick={onToggleSelectMode}
|
|
aria-label={_('Select Books')}
|
|
title={_('Select Books')}
|
|
className='h-6'
|
|
>
|
|
{isSelectMode ? (
|
|
<PiSelectionAllFill role='button' className='text-base-content/60 h-6 w-6' />
|
|
) : (
|
|
<PiSelectionAll role='button' className='text-base-content/60 h-6 w-6' />
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{isSelectMode ? (
|
|
<div
|
|
className={clsx(
|
|
'flex h-full items-center',
|
|
'w-max-[72px] w-min-[72px] sm:w-max-[80px] sm:w-min-[80px]',
|
|
)}
|
|
>
|
|
<button
|
|
onClick={isSelectAll ? onDeselectAll : onSelectAll}
|
|
className='btn btn-ghost text-base-content/85 h-8 min-h-8 w-[72px] p-0 sm:w-[80px]'
|
|
aria-label={isSelectAll ? _('Deselect') : _('Select All')}
|
|
>
|
|
<span className='font-sans text-base font-normal sm:text-sm'>
|
|
{isSelectAll ? _('Deselect') : _('Select All')}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className='flex h-full items-center gap-x-2 sm:gap-x-4'>
|
|
<Dropdown
|
|
label={_('View Menu')}
|
|
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
|
|
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
|
toggleButton={<PiDotsThreeCircle role='none' size={iconSize18} />}
|
|
>
|
|
<ViewMenu />
|
|
</Dropdown>
|
|
<Dropdown
|
|
label={_('Settings Menu')}
|
|
className='exclude-title-bar-mousedown dropdown-bottom dropdown-end'
|
|
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
|
|
toggleButton={<MdOutlineMenu role='none' size={iconSize18} />}
|
|
>
|
|
<SettingsMenu onPullLibrary={onPullLibrary} />
|
|
</Dropdown>
|
|
{appService?.hasWindowBar && (
|
|
<WindowButtons
|
|
headerRef={headerRef}
|
|
showMinimize={windowButtonVisible}
|
|
showMaximize={windowButtonVisible}
|
|
showClose={windowButtonVisible}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default LibraryHeader;
|