5e366018df
* fix(cbz,i18n): ComicInfo metadata + CBZ page count + WebDAV i18n Closes #4253 (ComicInfo.xml not read) and #4255 (CBZ shows "1 page left"). CBZ / ComicInfo (foliate-js submodule + Readest derivation): - comic-book.js: find ComicInfo.xml in subdirectories too, parse description / subject / identifier / published / series fields beyond the prior name+position pair. Series Count populates the canonical `belongsTo.series.total`; no top-level duplication. - bookService.ts / readerStore.ts: derive `metadata.seriesTotal` from `belongsTo.series.total` in parallel to the existing series / seriesIndex derivation. - ProgressBar / FooterBar / DesktopFooterBar: drop the hard-coded `pagesLeft = 1` for fixed-layout books and compute it from `section.total - section.current`. FooterBar uses `FIXED_LAYOUT_FORMATS.has(bookFormat)` so CBZ picks `section` (correct image count) instead of `pageinfo` (locations). - ProgressBar: switch the remaining-pages text to "in book" for fixed-layout titles (no chapter structure) and keep "in chapter" for reflowable books. WebDAV refactor for translation coverage: - WebDAVBrowsePane / SyncHistoryPanel called `t(...)` (passed as a prop) instead of `_(...)`. The i18next-scanner only looks for `_`, so ~53 strings were unreachable and shipped in English to every locale. Switched both components to call `useTranslation()` themselves; helpers that aren't React FCs take `_: TranslationFunc` so the scanner sees the literal calls. - WebDAVClient.checkConnection now returns a `code` discriminator (`SERVER_URL_REQUIRED` / `AUTH_FAILED` / `ROOT_NOT_FOUND` / `UNEXPECTED_STATUS` / `NETWORK`); raw English `message` is reserved for the dev console. New `formatConnectError` and `formatSyncError` helpers in WebDAVForm translate via a switch where each branch is a literal `_('...')`. Same treatment for the sync-failure path that previously surfaced raw e.message. - "Syncing 0 / {{total}}" is now parameterized as "Syncing {{n}} / {{total}}" with n=0 at startup so the digit formats naturally and the template can be reused mid-sync. - "Cleanup · {{count}} book(s)" hard-coded options used unsupported ternary; rewrote as plural-aware key. i18n scanner fix (i18next-scanner.config.cjs): - vinyl-fs walked into directories whose names end in source-file extensions (Next.js route folder `runtime-config.js/`, Playwright screenshot folder `*.test.tsx/`) and crashed with EISDIR. Resolved by expanding globs via `fs.globSync` and filtering to files only before handing to the scanner. TypeScript-syntax sites that broke esprima during extraction: - WebDAVBrowsePane / WebDAVForm: `(e as Error).message` and `failed[0]!.title` inside `_(..., options)` arguments. Replaced with `e instanceof Error ? e.message : String(e)` and `failed[0]?.title ?? ''` — also runtime-safer. User-facing em-dash cleanup: - Removed em-dashes from translation keys across SyncHistoryPanel / WebDAVForm / WebDAVBrowsePane / SyncPassphraseSection / send/page / replicaCryptoMiddleware / AIPanel. Tagline in `layout.tsx` kept. Locale translations: - ~2400 translations applied across all 33 locales for the keys that were either newly extractable, freshly worded, or pre-existing but untranslated. Zero `__STRING_NOT_TRANSLATED__` remain after the run. Misc: - next.config.mjs: drop `eslint.ignoreDuringBuilds: true` so build runs the same lint as CI. - Collection type: add `total?: string` for ComicInfo series count. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(test): fix vitest invocation, run with 4 workers `pnpm test:pr:web` was chaining `pnpm test -- --watch=false`, which pnpm expanded into: dotenv -e .env -e .env.test.local -- vitest -- --watch=false The second `--` made vitest treat `--watch=false` as a positional file pattern, not a flag. Vitest then fell back to defaults (in CI's non-TTY env that still meant a one-shot run, so the suite passed), but the worker pool was effectively serialized for big chunks of the 243-file run — wall ~90 s on a 4-vCPU runner where the parallel-sum of phases was ~236 s (≈2.6× effective parallelism). Replace the chained pnpm invocation with a direct call to `vitest run --maxWorkers=4`, matching the 4 vCPUs the GH Actions ubuntu-latest runner provides. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
import clsx from 'clsx';
|
|
import React, { useEffect, useState } from 'react';
|
|
import { Trans } from 'react-i18next';
|
|
import type { Insets } from '@/types/misc';
|
|
import { useEnv } from '@/context/EnvContext';
|
|
import { useReaderStore } from '@/store/readerStore';
|
|
import { useTranslation } from '@/hooks/useTranslation';
|
|
import { useBookDataStore } from '@/store/bookDataStore';
|
|
import { formatNumber, formatProgress } from '@/utils/progress';
|
|
import { saveViewSettings } from '@/helpers/settings';
|
|
import { eventDispatcher } from '@/utils/event';
|
|
import { SIZE_PER_LOC, SIZE_PER_TIME_UNIT } from '@/services/constants';
|
|
import type { ProgressBarMode } from '@/types/book.ts';
|
|
import StatusInfo from './StatusInfo.tsx';
|
|
|
|
interface ProgressBarProps {
|
|
bookKey: string;
|
|
horizontalGap: number;
|
|
contentInsets: Insets;
|
|
gridInsets: Insets;
|
|
}
|
|
|
|
const ProgressBar: React.FC<ProgressBarProps> = ({
|
|
bookKey,
|
|
horizontalGap,
|
|
contentInsets,
|
|
gridInsets,
|
|
}) => {
|
|
const _ = useTranslation();
|
|
const { envConfig, appService } = useEnv();
|
|
const { getBookData } = useBookDataStore();
|
|
const { getProgress, getViewSettings, getView } = useReaderStore();
|
|
const view = getView(bookKey);
|
|
const bookData = getBookData(bookKey);
|
|
const viewSettings = getViewSettings(bookKey)!;
|
|
const progress = getProgress(bookKey);
|
|
const { section, pageinfo } = progress || {};
|
|
|
|
const showDoubleBorder = viewSettings.vertical && viewSettings.doubleBorder;
|
|
const isVertical = viewSettings.vertical;
|
|
const isEink = viewSettings.isEink;
|
|
const { progressStyle: readingProgressStyle } = viewSettings;
|
|
|
|
const template =
|
|
readingProgressStyle === 'fraction'
|
|
? isVertical
|
|
? '{current} · {total}'
|
|
: '{current} / {total}'
|
|
: '{percent}%';
|
|
|
|
const lang = localStorage?.getItem('i18nextLng') || '';
|
|
const localize = isVertical && lang.toLowerCase().startsWith('zh');
|
|
const pageInfo = bookData?.isFixedLayout ? section : pageinfo;
|
|
const progressInfo = formatProgress(pageInfo?.current, pageInfo?.total, template, localize, lang);
|
|
|
|
const { page: current = 0, pages: total = 0 } = view?.renderer || {};
|
|
const pagesLeft = bookData?.isFixedLayout
|
|
? pageInfo
|
|
? Math.max(pageInfo.total - pageInfo.current, 1)
|
|
: 0
|
|
: Math.min(Math.max(total - current, 1), pageInfo ? pageInfo.total - pageInfo.current : total);
|
|
const showPagesLeft = pagesLeft > 0 && (total > 0 || !!bookData?.isFixedLayout);
|
|
// Fixed-layout formats (CBZ, PDF) have no chapter structure — every page is
|
|
// its own section — so the remaining count is the whole book, not a chapter.
|
|
const remainingInBook = !!bookData?.isFixedLayout;
|
|
const timeLeftStr = showPagesLeft
|
|
? remainingInBook
|
|
? _('{{time}} min left in book', {
|
|
time: formatNumber(
|
|
Math.round((pagesLeft * SIZE_PER_LOC) / SIZE_PER_TIME_UNIT),
|
|
localize,
|
|
lang,
|
|
),
|
|
})
|
|
: _('{{time}} min left in chapter', {
|
|
time: formatNumber(
|
|
Math.round((pagesLeft * SIZE_PER_LOC) / SIZE_PER_TIME_UNIT),
|
|
localize,
|
|
lang,
|
|
),
|
|
})
|
|
: '';
|
|
const pagesLeftStr = showPagesLeft
|
|
? localize
|
|
? remainingInBook
|
|
? _('{{number}} pages left in book', {
|
|
number: formatNumber(pagesLeft, localize, lang),
|
|
})
|
|
: _('{{number}} pages left in chapter', {
|
|
number: formatNumber(pagesLeft, localize, lang),
|
|
})
|
|
: remainingInBook
|
|
? _('{{count}} pages left in book', {
|
|
count: pagesLeft,
|
|
})
|
|
: _('{{count}} pages left in chapter', {
|
|
count: pagesLeft,
|
|
})
|
|
: '';
|
|
|
|
const [progressBarMode, setProgressBarMode] = useState<string>(viewSettings.progressInfoMode);
|
|
|
|
const hasRemainingInfo = viewSettings.showRemainingTime || viewSettings.showRemainingPages;
|
|
const hasProgressInfo = viewSettings.showProgressInfo;
|
|
const hasTimeInfo = viewSettings.showCurrentTime;
|
|
const hasBatteryInfo = viewSettings.showCurrentBatteryStatus;
|
|
const cycleProgressInfoModes = () => {
|
|
if (!viewSettings.tapToToggleFooter) return;
|
|
|
|
const modeSequence: string[] = [
|
|
'all',
|
|
`${hasRemainingInfo ? 'remaining+' : ''}${hasProgressInfo ? 'progress' : ''}`,
|
|
`${hasRemainingInfo ? 'remaining' : ''}`,
|
|
`${hasProgressInfo ? 'progress' : ''}`,
|
|
`${hasBatteryInfo ? 'battery+' : ''}${hasTimeInfo ? 'time' : ''}`,
|
|
`${hasBatteryInfo ? 'battery' : ''}`,
|
|
`${hasTimeInfo ? 'time' : ''}`,
|
|
'none',
|
|
]
|
|
.map((mode) => mode.replace(/^\+|\+$/g, ''))
|
|
.filter((mode) => mode !== '')
|
|
.filter((mode, index, self) => self.indexOf(mode) === index);
|
|
|
|
const currentMode = progressBarMode;
|
|
const currentIndex = modeSequence.indexOf(currentMode);
|
|
for (let i = 1; i <= modeSequence.length; i++) {
|
|
const nextIndex = (currentIndex + i) % modeSequence.length;
|
|
const nextMode = modeSequence[nextIndex]!;
|
|
|
|
const currentRenders = {
|
|
remaining:
|
|
currentMode === 'all' || currentMode.includes('remaining') ? hasRemainingInfo : false,
|
|
progress:
|
|
currentMode === 'all' || currentMode.includes('progress') ? hasProgressInfo : false,
|
|
battery: currentMode === 'all' || currentMode.includes('battery') ? hasBatteryInfo : false,
|
|
time: currentMode === 'all' || currentMode.includes('time') ? hasTimeInfo : false,
|
|
none: currentMode === 'none',
|
|
};
|
|
|
|
const nextRenders = {
|
|
remaining: nextMode === 'all' || nextMode.includes('remaining') ? hasRemainingInfo : false,
|
|
progress: nextMode === 'all' || nextMode.includes('progress') ? hasProgressInfo : false,
|
|
battery: nextMode === 'all' || nextMode.includes('battery') ? hasBatteryInfo : false,
|
|
time: nextMode === 'all' || nextMode.includes('time') ? hasTimeInfo : false,
|
|
none: nextMode === 'none',
|
|
};
|
|
|
|
const isDifferent =
|
|
currentRenders.remaining !== nextRenders.remaining ||
|
|
currentRenders.progress !== nextRenders.progress ||
|
|
currentRenders.battery !== nextRenders.battery ||
|
|
currentRenders.time !== nextRenders.time ||
|
|
currentRenders.none !== nextRenders.none;
|
|
if (isDifferent) {
|
|
setProgressBarMode(nextMode);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const nextIndex = (currentIndex + 1) % modeSequence.length;
|
|
setProgressBarMode(modeSequence[nextIndex]!);
|
|
};
|
|
|
|
useEffect(() => {
|
|
saveViewSettings(envConfig, bookKey, 'progressInfoMode', progressBarMode as ProgressBarMode);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [progressBarMode]);
|
|
|
|
// Self-heal a stuck "none" (or partial) mode left over from a prior
|
|
// tap-to-toggle session. Without this, dismissing the footer via tap
|
|
// and then disabling the toggle in settings would leave the footer
|
|
// permanently hidden — the user's only way back to a visible footer
|
|
// would be to re-enable the toggle and tap through the cycle.
|
|
useEffect(() => {
|
|
if (!viewSettings.tapToToggleFooter && progressBarMode !== 'all') {
|
|
setProgressBarMode('all');
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [viewSettings.tapToToggleFooter]);
|
|
|
|
const isMobile = appService?.isMobile || window.innerWidth < 640;
|
|
const showStatusInfo =
|
|
(progressBarMode === 'all' ||
|
|
progressBarMode.includes('battery') ||
|
|
progressBarMode.includes('time')) &&
|
|
(hasTimeInfo || hasBatteryInfo);
|
|
|
|
return (
|
|
<div
|
|
role='presentation'
|
|
tabIndex={-1}
|
|
className={clsx(
|
|
'progressinfo absolute bottom-0 flex items-center justify-between font-sans',
|
|
isEink ? 'text-sm font-normal' : 'text-neutral-content text-xs font-extralight',
|
|
isVertical ? 'writing-vertical-rl' : 'w-full',
|
|
isMobile ? 'pointer-events-auto' : 'pointer-events-none',
|
|
)}
|
|
onClick={() => {
|
|
if (eventDispatcher.dispatchSync('iframe-single-click')) return;
|
|
cycleProgressInfoModes();
|
|
}}
|
|
aria-label={[
|
|
progress
|
|
? _('On {{current}} of {{total}} page', {
|
|
current: current + 1,
|
|
total: total,
|
|
})
|
|
: '',
|
|
timeLeftStr,
|
|
pagesLeftStr,
|
|
]
|
|
.filter(Boolean)
|
|
.join(', ')}
|
|
style={
|
|
isVertical
|
|
? {
|
|
top: `${(contentInsets.top - gridInsets.top) * 1.5}px`,
|
|
bottom: `${(contentInsets.bottom - gridInsets.bottom) * 1.5}px`,
|
|
left: showDoubleBorder
|
|
? `calc(${contentInsets.left}px)`
|
|
: `calc(${Math.max(0, contentInsets.left - 32)}px)`,
|
|
width: showDoubleBorder ? '32px' : `${contentInsets.left}px`,
|
|
}
|
|
: {
|
|
paddingInlineStart: `calc(${horizontalGap / 2}% + ${contentInsets.left / 2}px)`,
|
|
paddingInlineEnd: `calc(${horizontalGap / 2}% + ${contentInsets.right / 2}px)`,
|
|
paddingBottom: appService?.hasSafeAreaInset ? `${gridInsets.bottom * 0.33}px` : 0,
|
|
}
|
|
}
|
|
>
|
|
<div
|
|
aria-hidden='true'
|
|
className={clsx('flex items-center justify-between', isVertical ? 'h-full' : 'w-full')}
|
|
style={isVertical ? {} : { height: `${viewSettings.marginBottomPx}px` }}
|
|
>
|
|
{(progressBarMode === 'all' || progressBarMode.includes('remaining')) &&
|
|
hasRemainingInfo && (
|
|
<div
|
|
className={clsx(
|
|
'remaining-info flex-1 whitespace-nowrap text-start',
|
|
showStatusInfo && 'overflow-hidden',
|
|
)}
|
|
>
|
|
{viewSettings.showRemainingTime ? (
|
|
<span className='time-left-label text-start'>{timeLeftStr}</span>
|
|
) : viewSettings.showRemainingPages && showPagesLeft ? (
|
|
<span className='text-start'>
|
|
{localize ? (
|
|
remainingInBook ? (
|
|
<Trans
|
|
i18nKey='{{number}} pages left in book'
|
|
values={{ number: formatNumber(pagesLeft, localize, lang) }}
|
|
>
|
|
<span className='pages-left-number'>{'{{number}}'}</span>
|
|
<span className='pages-left-label'>{' pages left in book'}</span>
|
|
</Trans>
|
|
) : (
|
|
<Trans
|
|
i18nKey='{{number}} pages left in chapter'
|
|
values={{ number: formatNumber(pagesLeft, localize, lang) }}
|
|
>
|
|
<span className='pages-left-number'>{'{{number}}'}</span>
|
|
<span className='pages-left-label'>{' pages left in chapter'}</span>
|
|
</Trans>
|
|
)
|
|
) : remainingInBook ? (
|
|
<Trans i18nKey='{{count}} pages left in book' count={pagesLeft}>
|
|
<span className='pages-left-number'>{'{{count}}'}</span>
|
|
<span className='pages-left-label'>{' pages left in book'}</span>
|
|
</Trans>
|
|
) : (
|
|
<Trans i18nKey='{{count}} pages left in chapter' count={pagesLeft}>
|
|
<span className='pages-left-number'>{'{{count}}'}</span>
|
|
<span className='pages-left-label'>{' pages left in chapter'}</span>
|
|
</Trans>
|
|
)}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
{showStatusInfo && (
|
|
<StatusInfo
|
|
showTime={
|
|
(progressBarMode === 'all' || progressBarMode.includes('time')) && hasTimeInfo
|
|
}
|
|
use24Hour={viewSettings.use24HourClock}
|
|
showBattery={
|
|
(progressBarMode === 'all' || progressBarMode.includes('battery')) && hasBatteryInfo
|
|
}
|
|
showBatteryPercentage={viewSettings.showBatteryPercentage}
|
|
isVertical={isVertical}
|
|
isEink={isEink}
|
|
/>
|
|
)}
|
|
|
|
<div className='progress-info flex-1 items-center overflow-hidden whitespace-nowrap text-end tabular-nums'>
|
|
{(progressBarMode === 'all' || progressBarMode.includes('progress')) && (
|
|
<>
|
|
{viewSettings.showProgressInfo && (
|
|
<span
|
|
className={clsx(
|
|
'progress-info-label text-end',
|
|
isVertical ? 'mt-auto' : 'ms-auto',
|
|
)}
|
|
>
|
|
{progressInfo}
|
|
</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ProgressBar;
|