@khorsheed/dsh-reader 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.en.md +26 -121
- package/README.i18n.yaml +3 -3
- package/README.md +26 -121
- package/cordis.patch.yml +7 -0
- package/lib/client.js +30580 -6892
- package/lib/index.js +4189 -621
- package/lib/typert.host.js +2265 -319
- package/lib/typert.remote-client.d.ts +99 -3
- package/lib/typert.remote-client.js +1618 -240
- package/lib/types/captions.d.ts +99 -0
- package/lib/types/captions.js +236 -0
- package/lib/types/client/MediaPlayer.d.ts +55 -0
- package/lib/types/client/MediaPlayer.js +106 -0
- package/lib/types/client/NotesView.d.ts +57 -0
- package/lib/types/client/NotesView.js +124 -0
- package/lib/types/client/ReaderPane.d.ts +20 -3
- package/lib/types/client/ReaderPane.js +905 -164
- package/lib/types/client/SpeakerLegend.d.ts +18 -0
- package/lib/types/client/SpeakerLegend.js +26 -0
- package/lib/types/client/canvas-sync.d.ts +84 -0
- package/lib/types/client/canvas-sync.js +59 -0
- package/lib/types/client/contract.d.ts +138 -3
- package/lib/types/client/extract-article.d.ts +38 -1
- package/lib/types/client/extract-article.js +501 -28
- package/lib/types/client/icons.d.ts +1 -1
- package/lib/types/client/icons.js +1 -1
- package/lib/types/client/index.js +82 -2
- package/lib/types/client/locales.d.ts +279 -11
- package/lib/types/client/locales.js +278 -10
- package/lib/types/client/notes.d.ts +142 -0
- package/lib/types/client/notes.js +334 -0
- package/lib/types/client/parse-rss.js +40 -0
- package/lib/types/client/preset-visibility.d.ts +93 -0
- package/lib/types/client/preset-visibility.js +115 -0
- package/lib/types/client/selectors.d.ts +5 -0
- package/lib/types/client/selectors.js +5 -0
- package/lib/types/client/session.d.ts +21 -1
- package/lib/types/client/session.js +12 -0
- package/lib/types/client/store.js +4 -1
- package/lib/types/client/translate.d.ts +26 -1
- package/lib/types/client/translate.js +29 -12
- package/lib/types/client/useLocalTranscript.d.ts +43 -0
- package/lib/types/client/useLocalTranscript.js +129 -0
- package/lib/types/client/useMedia.d.ts +68 -0
- package/lib/types/client/useMedia.js +299 -0
- package/lib/types/client/useMediaStates.d.ts +9 -0
- package/lib/types/client/useMediaStates.js +43 -0
- package/lib/types/client/useNotes.d.ts +110 -0
- package/lib/types/client/useNotes.js +423 -0
- package/lib/types/client/usePolish.d.ts +47 -0
- package/lib/types/client/usePolish.js +162 -0
- package/lib/types/image-fetch.d.ts +81 -0
- package/lib/types/image-fetch.js +385 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/index.js +3 -0
- package/lib/types/local-transcribe.d.ts +171 -0
- package/lib/types/local-transcribe.js +473 -0
- package/lib/types/media-host.d.ts +91 -0
- package/lib/types/media-host.js +230 -0
- package/lib/types/media.d.ts +100 -0
- package/lib/types/media.js +207 -0
- package/lib/types/notes-types.d.ts +158 -0
- package/lib/types/notes-types.js +8 -0
- package/lib/types/notes.d.ts +213 -0
- package/lib/types/notes.js +718 -0
- package/lib/types/polish.d.ts +41 -0
- package/lib/types/polish.js +136 -0
- package/lib/types/remote.d.ts +127 -1
- package/lib/types/remote.js +162 -1
- package/lib/types/service.d.ts +100 -1
- package/lib/types/service.js +320 -59
- package/lib/types/skill.d.ts +20 -0
- package/lib/types/skill.js +78 -0
- package/lib/types/store.js +30 -8
- package/lib/types/tool.d.ts +23 -0
- package/lib/types/tool.js +40 -0
- package/lib/types/tools.d.ts +69 -0
- package/lib/types/tools.js +400 -0
- package/lib/types/types.d.ts +106 -0
- package/lib/types/types.js +66 -0
- package/lib/types/xiaoyuzhou.d.ts +43 -0
- package/lib/types/xiaoyuzhou.js +134 -0
- package/lib/types/ytdlp.d.ts +111 -0
- package/lib/types/ytdlp.js +269 -0
- package/package.json +29 -20
- package/lib/invariant.js +0 -38
- package/lib/types/invariant.d.ts +0 -29
- package/lib/types/invariant.js +0 -38
|
@@ -11,8 +11,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
11
11
|
* round trip.
|
|
12
12
|
* - **the detail view** — clicking a card opens it. This is the whole point:
|
|
13
13
|
* the body renders as DOM text, which is what makes passage quoting work
|
|
14
|
-
* (the QUOTE plugin's own selection menu sees it)
|
|
15
|
-
*
|
|
14
|
+
* (the QUOTE plugin's own selection menu sees it). The one non-text island:
|
|
15
|
+
* an article's content embeds survive as sandboxed iframes (opaque origin,
|
|
16
|
+
* no `allow-same-origin`) or controls-only media elements — neither can see
|
|
17
|
+
* this page, and neither carries quotable text.
|
|
16
18
|
* - **the add dialog** — an overlay in the members-tab idiom, because adding is
|
|
17
19
|
* a momentary act and the wall should stay where it was behind it. The host
|
|
18
20
|
* decides whether a pasted URL is a feed or an article from what comes back
|
|
@@ -30,14 +32,44 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
30
32
|
*/
|
|
31
33
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
32
34
|
import { IconChevronDownOutlineMedium, IconChevronLeftOutlineMedium, IconClockOutlineMedium, IconCopyOutlineMedium, IconGlobeOutlineMedium, IconPlusOutlineMedium, IconRefreshOutlineMedium, IconRightUpOutlineMedium, IconSettingsOutlineMedium, IconTrashOutlineMedium } from "./icons.js";
|
|
33
|
-
import {
|
|
35
|
+
import { ChipRow, PanelHeader, PanelRoot, UnderlineTabs, usePanelCompactContext } from '@khorsheed/dsh-client-ui-panel-kit/src/client/index.ts';
|
|
36
|
+
import { NoteSheet, NotesView } from "./NotesView.js";
|
|
37
|
+
import { MediaPlayer } from "./MediaPlayer.js";
|
|
38
|
+
import { useMedia } from "./useMedia.js";
|
|
39
|
+
import { useLocalTranscript } from "./useLocalTranscript.js";
|
|
40
|
+
import { useMediaStates } from "./useMediaStates.js";
|
|
41
|
+
import { SpeakerLegend } from "./SpeakerLegend.js";
|
|
42
|
+
import { usePolish } from "./usePolish.js";
|
|
43
|
+
import { blockElements } from "./notes.js";
|
|
44
|
+
import { readMediaMeta } from "../media.js";
|
|
45
|
+
import { formatClock } from "../captions.js";
|
|
46
|
+
import { MEDIA_QUERIES } from "./selectors.js";
|
|
47
|
+
import { useNotes } from "./useNotes.js";
|
|
48
|
+
import { kindQuery, linkEntryId, READER_SOURCE_KINDS, isCookieHandshake, } from "../types.js";
|
|
34
49
|
import { extractArticle } from "./extract-article.js";
|
|
35
50
|
import { parseFeed } from "./parse-rss.js";
|
|
36
|
-
import { absoluteDate, clockOf,
|
|
37
|
-
import { TARGET_CANDIDATES, buildArticle, createSession, detectSourceLanguage, detectTranslator, isTargetLanguage, primeMemory, restoreArticle, runTranslation, segmentAt, segmentsOf, setPairHover, setView, toggleSegment, translateTexts, translationHash, } from "./translate.js";
|
|
51
|
+
import { absoluteDate, clockOf, relativeWhen } from "./quote.js";
|
|
52
|
+
import { TARGET_CANDIDATES, applyRemembered, buildArticle, createSession, detectSourceLanguage, detectTranslator, isTargetLanguage, primeMemory, restoreArticle, runTranslation, segmentAt, segmentsOf, setPairHover, setView, toggleSegment, remembered, translateTexts, translationHash, } from "./translate.js";
|
|
38
53
|
import { countUnread, dedupeRows, flattenEntries, fetchStateOf, LIST_RENDER_LIMIT, hueForSource, rowFor, selectRows, sourceQuery, tagQuery, tileForSource, } from "./selectors.js";
|
|
39
|
-
import { cachedTranslator, forgetTranslation, patchSession, readSession, rememberReadingPosition, rememberTranslation, rememberWallScroll, rememberTranslator, } from "./session.js";
|
|
54
|
+
import { cachedTranslator, forgetTranslation, patchSession, readSession, rememberReadingPosition, rememberTranslation, rememberWallScroll, rememberTranslator, readWallCache, sourceStamp, writeWallCache, } from "./session.js";
|
|
40
55
|
import css from './ReaderPane.module.css';
|
|
56
|
+
/**
|
|
57
|
+
* An in-flight line that says WHAT is running and, once it has taken a moment,
|
|
58
|
+
* how long it has been — the difference between "working" and "dead" on a slow
|
|
59
|
+
* page. The elapsed figure only appears after a few seconds: a fast fetch never
|
|
60
|
+
* shows a number at all.
|
|
61
|
+
*/
|
|
62
|
+
function BusyLine({ t, label, since }) {
|
|
63
|
+
const [now, setNow] = useState(() => Date.now());
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const timer = setInterval(() => { setNow(Date.now()); }, 1000);
|
|
66
|
+
return () => { clearInterval(timer); };
|
|
67
|
+
}, []);
|
|
68
|
+
const elapsed = since === undefined ? 0 : Math.max(0, Math.round((now - since) / 1000));
|
|
69
|
+
return (_jsx("p", { className: css.incomplete, children: _jsxs("span", { className: css.renderingLine, children: [_jsx("span", { className: css.renderingRing, "aria-hidden": "true" }), label, elapsed >= 3 ? ` · ${t('detail.fetchElapsed', { seconds: elapsed })}` : ''] }) }));
|
|
70
|
+
}
|
|
71
|
+
/** The global 「转完自动翻译」 preference (a per-browser convenience). */
|
|
72
|
+
const AUTO_TRANSLATE_KEY = 'dsh-reader.autoTranslateTranscripts';
|
|
41
73
|
/** A tiny stroke glyph set for the chrome the host has no icon for. */
|
|
42
74
|
const STROKE = {
|
|
43
75
|
search: 'M7 2.2a4.8 4.8 0 1 0 0 9.6 4.8 4.8 0 0 0 0-9.6Zm3.4 8.2 3 3',
|
|
@@ -72,6 +104,9 @@ function isCjk(html) {
|
|
|
72
104
|
/** The wall's tag panel box: the width it is clamped with, and the height
|
|
73
105
|
* placement estimates from the vocabulary (its real height is bounded by CSS). */
|
|
74
106
|
const TAG_PANEL_WIDTH = 236;
|
|
107
|
+
/** The card menu's footprint, for keeping a menu opened at the screen's edge on screen. */
|
|
108
|
+
const CARD_MENU_WIDTH = 168;
|
|
109
|
+
const CARD_MENU_HEIGHT = 120;
|
|
75
110
|
const TAG_PANEL_MAX_HEIGHT = 296;
|
|
76
111
|
/**
|
|
77
112
|
* How many sources it takes before the picker's own search box appears. Below
|
|
@@ -113,7 +148,7 @@ function TagSuggestions({ t, tags, applied, draft, onToggle, onCreate }) {
|
|
|
113
148
|
const exact = tags.some(tag => tag.name.toLowerCase() === needle);
|
|
114
149
|
return (_jsxs(_Fragment, { children: [suggested.map(tag => {
|
|
115
150
|
const on = applied.includes(tag.id);
|
|
116
|
-
return (_jsxs("button", { type: "button", className: css.
|
|
151
|
+
return (_jsxs("button", { type: "button", className: css.tagOption, "data-on": on || undefined,
|
|
117
152
|
// Keep the field focused: the detail editor closes on blur, and a
|
|
118
153
|
// mousedown that moves focus would dismiss the list mid-click.
|
|
119
154
|
onMouseDown: event => { event.preventDefault(); }, onClick: () => { onToggle(tag); }, children: [_jsx("span", { className: css.tagRowCheck, children: on ? '✓' : '' }), _jsx("span", { className: css.tagRowName, children: tag.name })] }, tag.id));
|
|
@@ -203,7 +238,7 @@ function whenLabel(t, iso) {
|
|
|
203
238
|
* @returns the reader-facing sentence.
|
|
204
239
|
*/
|
|
205
240
|
function describeFetchFailure(reason, t) {
|
|
206
|
-
if (/^HTTP (401|403)/.test(reason))
|
|
241
|
+
if (/^HTTP (401|403)/.test(reason) || /bot challenge/i.test(reason) || isCookieHandshake(reason))
|
|
207
242
|
return t('sources.blocked');
|
|
208
243
|
if (/^HTTP \d{3}/.test(reason))
|
|
209
244
|
return t('sources.httpError');
|
|
@@ -212,6 +247,19 @@ function describeFetchFailure(reason, t) {
|
|
|
212
247
|
}
|
|
213
248
|
return t('detail.fetchFailed', { reason: reason.slice(0, 160) });
|
|
214
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* Whether a fetch failure is one a real browser gets past: a bot challenge,
|
|
252
|
+
* a 401/403 that refuses non-browser requests, or a cookie handshake
|
|
253
|
+
* (Springer Nature's idp bounce). These are the reasons the
|
|
254
|
+
* rendered fetch (the capture package's headless Chrome) exists to answer; a
|
|
255
|
+
* login wall is not among them — a cookie-less browser meets it too.
|
|
256
|
+
*
|
|
257
|
+
* @param reason - the host's recorded message.
|
|
258
|
+
* @returns true when escalating to the rendered fetch is worth a try.
|
|
259
|
+
*/
|
|
260
|
+
export function browserSolvable(reason) {
|
|
261
|
+
return /bot challenge|^HTTP 40[13]\b/i.test(reason) || isCookieHandshake(reason);
|
|
262
|
+
}
|
|
215
263
|
/** How many entries the automatic backfill fetches at once. */
|
|
216
264
|
const BACKFILL_CONCURRENCY = 2;
|
|
217
265
|
/**
|
|
@@ -438,9 +486,17 @@ function linkMetaOf(row) {
|
|
|
438
486
|
return undefined;
|
|
439
487
|
return { title: row.entry.title, ...(row.entry.summary === undefined ? {} : { excerpt: row.entry.summary }) };
|
|
440
488
|
}
|
|
441
|
-
/**
|
|
489
|
+
/**
|
|
490
|
+
* The reader tab body, inside the shared panel root: the `dsh-panel` size
|
|
491
|
+
* container its compact rules query, and the compact flag the wall's and the
|
|
492
|
+
* detail's headers read (panel-responsive-spec P2).
|
|
493
|
+
*/
|
|
442
494
|
export function ReaderPane(props) {
|
|
443
|
-
|
|
495
|
+
return _jsx(PanelRoot, { className: css.panelRoot, children: _jsx(ReaderPaneBody, { ...props }) });
|
|
496
|
+
}
|
|
497
|
+
/** The reader's pages: wall, detail, recent, manage. */
|
|
498
|
+
function ReaderPaneBody(props) {
|
|
499
|
+
const { useStore, actions, t } = props;
|
|
444
500
|
const sources = useStore(s => s.sources);
|
|
445
501
|
const parsed = useStore(s => s.parsed);
|
|
446
502
|
const articleHtml = useStore(s => s.articleHtml);
|
|
@@ -469,6 +525,8 @@ export function ReaderPane(props) {
|
|
|
469
525
|
const refreshing = useStore(s => s.refreshing);
|
|
470
526
|
const tags = useStore(s => s.tags);
|
|
471
527
|
const tagCounts = useStore(s => s.tagCounts);
|
|
528
|
+
/** A panel at or below the compact width (a phone, a narrow sidebar). */
|
|
529
|
+
const compact = usePanelCompactContext() === true;
|
|
472
530
|
const entryTagIds = useStore(s => s.entryTagIds);
|
|
473
531
|
const cacheTtlHours = useStore(s => s.cacheTtlHours);
|
|
474
532
|
const backfill = useStore(s => s.backfill);
|
|
@@ -567,12 +625,29 @@ export function ReaderPane(props) {
|
|
|
567
625
|
* (cached or fresh), so reopening an entry shows the same note.
|
|
568
626
|
*/
|
|
569
627
|
const [scriptFigures, setScriptFigures] = useState(0);
|
|
570
|
-
/**
|
|
571
|
-
|
|
628
|
+
/**
|
|
629
|
+
* The entry whose capture-rendered refetch is in flight. Per entry, not a
|
|
630
|
+
* pane-wide flag: opening another article mid-render must not show it the
|
|
631
|
+
* first one's progress line (or disable its 「重新抓取」).
|
|
632
|
+
*/
|
|
633
|
+
const [captureBusyId, setCaptureBusyId] = useState(null);
|
|
634
|
+
const captureBusy = captureBusyId !== null && captureBusyId === openEntryId;
|
|
635
|
+
/**
|
|
636
|
+
* Why the last rendered fetch of an entry came back without a body. The
|
|
637
|
+
* recorded refusal (「拒绝自动抓取」) stays the line's lead, so without this
|
|
638
|
+
* a failed render would be indistinguishable from one never attempted.
|
|
639
|
+
*/
|
|
640
|
+
const [captureFailure, setCaptureFailure] = useState(null);
|
|
572
641
|
/** Entries whose stored body came from a rendered fetch (the「重新抓取」route reads it). */
|
|
573
642
|
const renderedBodiesRef = useRef(new Set());
|
|
574
643
|
/** Auto-render fires once per entry per pane session; failures leave the manual retry. */
|
|
575
644
|
const autoRenderTriedRef = useRef(new Set());
|
|
645
|
+
/**
|
|
646
|
+
* Which leg of a fetch is running, keyed to its entry: the detail view's
|
|
647
|
+
* 「抓取中」 line renders the phase (download → extract → store) and the
|
|
648
|
+
* elapsed time, so a slow page reads as progress instead of a dead click.
|
|
649
|
+
*/
|
|
650
|
+
const [bodyFetch, setBodyFetch] = useState(null);
|
|
576
651
|
/** What the plugin holds per entry, as the host reports it (the 抓取 pills). */
|
|
577
652
|
const fetchStates = useStore(s => s.fetchStates);
|
|
578
653
|
/** What the reader opened, as the host stores it (the 「最近阅读」 page). */
|
|
@@ -580,11 +655,15 @@ export function ReaderPane(props) {
|
|
|
580
655
|
const [addOpen, setAddOpen] = useState(false);
|
|
581
656
|
const [sortOpen, setSortOpen] = useState(false);
|
|
582
657
|
const [draftUrl, setDraftUrl] = useState('');
|
|
658
|
+
/** When the in-flight add began — the dialog's busy line, and the double-submit guard. */
|
|
659
|
+
const [addBusySince, setAddBusySince] = useState(null);
|
|
583
660
|
const [verdict, setVerdict] = useState(null);
|
|
584
|
-
const [draft, setDraft] = useState('');
|
|
585
|
-
const [sideChatAvailable, setSideChatAvailable] = useState(false);
|
|
586
661
|
const [timeOfDay, setTimeOfDay] = useState('10:00');
|
|
587
662
|
const [filterOpen, setFilterOpen] = useState(false);
|
|
663
|
+
/** Compact only: the search row is folded behind the header's search icon until asked for. */
|
|
664
|
+
const [searchOpen, setSearchOpen] = useState(false);
|
|
665
|
+
/** Several incomplete sources fold into one line until the reader asks for the list. */
|
|
666
|
+
const [noticeOpen, setNoticeOpen] = useState(false);
|
|
588
667
|
/** The filter panel's page: its root, or the drilled-in source list. */
|
|
589
668
|
const [filterPage, setFilterPage] = useState('root');
|
|
590
669
|
/** The source page's own search box (the list grows without bound). */
|
|
@@ -617,6 +696,13 @@ export function ReaderPane(props) {
|
|
|
617
696
|
/** The page's Translator API, read once: no API means no button at all. */
|
|
618
697
|
const translator = useMemo(() => detectTranslator(), []);
|
|
619
698
|
const [translateAvailability, setTranslateAvailability] = useState(null);
|
|
699
|
+
/**
|
|
700
|
+
* Without a Translator API (iOS, every non-Chromium browser): the pair of the
|
|
701
|
+
* host's stored translation of THIS body, when there is one — the globe then
|
|
702
|
+
* shows that translation and can make no new one. Null on a page that has the
|
|
703
|
+
* API, and on a body nothing was stored for.
|
|
704
|
+
*/
|
|
705
|
+
const [storedPair, setStoredPair] = useState(null);
|
|
620
706
|
const [translatePhase, setTranslatePhase] = useState('idle');
|
|
621
707
|
const [translateView, setTranslateView] = useState('trans');
|
|
622
708
|
const [packProgress, setPackProgress] = useState(null);
|
|
@@ -626,8 +712,18 @@ export function ReaderPane(props) {
|
|
|
626
712
|
const [tipDismissed, setTipDismissed] = useState(false);
|
|
627
713
|
/** The rendered article, so the segmentation can be applied to it. */
|
|
628
714
|
const articleRef = useRef(null);
|
|
715
|
+
/** Images already rescued through the host, original URL → data URI (this mount's). */
|
|
716
|
+
const rescuedImagesRef = useRef(new Map());
|
|
629
717
|
/** The detail view's scroller, which is where a reading position lives. */
|
|
630
718
|
const detailRef = useRef(null);
|
|
719
|
+
/** The article's own heading, which the compact bar watches scroll away. */
|
|
720
|
+
const detailTitleRef = useRef(null);
|
|
721
|
+
/**
|
|
722
|
+
* Whether the article's heading has scrolled up out of the body. The compact
|
|
723
|
+
* bar starts empty (the heading sits right below it) and takes the title only
|
|
724
|
+
* then, so a reader deep in the text still sees what they are reading.
|
|
725
|
+
*/
|
|
726
|
+
const [titleScrolledAway, setTitleScrolledAway] = useState(false);
|
|
631
727
|
/** The live segmentation, or null while the article is untouched. */
|
|
632
728
|
const builtRef = useRef(null);
|
|
633
729
|
/** The running translation's cancel flag. */
|
|
@@ -650,6 +746,9 @@ export function ReaderPane(props) {
|
|
|
650
746
|
const [wallAvailability, setWallAvailability] = useState(null);
|
|
651
747
|
/** Translated card fields, by entry id. */
|
|
652
748
|
const [cardTranslations, setCardTranslations] = useState(() => ({ ...snapshot.cardTranslations }));
|
|
749
|
+
/** The card texts as the wall pass sees them (it runs outside render). */
|
|
750
|
+
const cardTranslationsRef = useRef(cardTranslations);
|
|
751
|
+
cardTranslationsRef.current = cardTranslations;
|
|
653
752
|
const [wallProgress, setWallProgress] = useState(null);
|
|
654
753
|
/**
|
|
655
754
|
* The translated FIELD under the pointer, in the translation-only view.
|
|
@@ -719,23 +818,52 @@ export function ReaderPane(props) {
|
|
|
719
818
|
reveal: css.reveal ?? 'dsh-reader-reveal',
|
|
720
819
|
line: css.revealLine ?? 'dsh-reader-reveal-line',
|
|
721
820
|
}), []);
|
|
821
|
+
/** This mount has not loaded yet (the cached wall is shown once, on the first load). */
|
|
822
|
+
const firstLoadRef = useRef(true);
|
|
722
823
|
/** Load the source list and parse whatever payloads the host is holding. */
|
|
723
824
|
const load = useCallback(async () => {
|
|
724
|
-
|
|
825
|
+
// A remount (the reader came back from another sidebar tab) shows the wall
|
|
826
|
+
// it already parsed at once, and only re-reads what changed meanwhile.
|
|
827
|
+
const cached = readWallCache();
|
|
828
|
+
const restored = cached !== undefined && firstLoadRef.current;
|
|
829
|
+
firstLoadRef.current = false;
|
|
830
|
+
if (restored) {
|
|
831
|
+
actions.setSources([...cached.sources]);
|
|
832
|
+
for (const { parsed: entry, label } of cached.parsed.values()) {
|
|
833
|
+
actions.setParsed(entry);
|
|
834
|
+
if (label !== undefined)
|
|
835
|
+
actions.setSourceLabel(entry.id, label);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
if (cached === undefined)
|
|
839
|
+
actions.setLoading(true);
|
|
840
|
+
const fresh = new Map(cached?.parsed ?? []);
|
|
841
|
+
const labels = new Map();
|
|
842
|
+
let stampOf = new Map();
|
|
843
|
+
const put = (parsed) => {
|
|
844
|
+
actions.setParsed(parsed);
|
|
845
|
+
const stamp = stampOf.get(parsed.id);
|
|
846
|
+
if (stamp !== undefined)
|
|
847
|
+
fresh.set(parsed.id, { stamp, parsed, ...(labels.has(parsed.id) ? { label: labels.get(parsed.id) } : {}) });
|
|
848
|
+
};
|
|
849
|
+
let known = [];
|
|
725
850
|
try {
|
|
726
851
|
const listed = await props.listSources();
|
|
727
852
|
if (!listed.ok) {
|
|
728
853
|
actions.setError(listed.error.message);
|
|
729
854
|
return;
|
|
730
855
|
}
|
|
731
|
-
|
|
856
|
+
known = [...listed.value.sources];
|
|
732
857
|
actions.setSources(known);
|
|
858
|
+
stampOf = new Map(known.map(source => [source.id, sourceStamp(source)]));
|
|
733
859
|
// Sources whose payload is wanted: the ones that have a body, PLUS every
|
|
734
860
|
// saved link — a link with no body is still a card the reader must see,
|
|
735
861
|
// and the host answers that request with the reason instead of a payload.
|
|
736
862
|
// (Asking only for sources with bodies made an unreadable link vanish from
|
|
737
863
|
// the wall entirely, which is a worse answer than "link only".)
|
|
738
|
-
|
|
864
|
+
// Unchanged since the cached parse: nothing to fetch (the wall already shows it).
|
|
865
|
+
// (Only on that restore: a refresh or an add re-reads everything, as before.)
|
|
866
|
+
const wanted = known.filter(source => (source.hasBody || source.kind === 'link') && (!restored || fresh.get(source.id)?.stamp !== stampOf.get(source.id)));
|
|
739
867
|
if (wanted.length === 0)
|
|
740
868
|
return;
|
|
741
869
|
const bodies = await props.getBodies(wanted.map(source => source.id));
|
|
@@ -756,10 +884,10 @@ export function ReaderPane(props) {
|
|
|
756
884
|
const cut = body.truncated === true;
|
|
757
885
|
if (body.raw === undefined) {
|
|
758
886
|
if (source.kind === 'rss') {
|
|
759
|
-
|
|
887
|
+
put({ id: source.id, entries: [], error: body.error ?? t('detail.extractFailed') });
|
|
760
888
|
}
|
|
761
889
|
else {
|
|
762
|
-
|
|
890
|
+
put({
|
|
763
891
|
id: source.id,
|
|
764
892
|
entries: [{
|
|
765
893
|
id: linkEntryId(source.id),
|
|
@@ -789,8 +917,9 @@ export function ReaderPane(props) {
|
|
|
789
917
|
// parse a feed — it has no XML parser), so the parsed title wins.
|
|
790
918
|
if (result.ok && result.feed.title !== undefined) {
|
|
791
919
|
actions.setSourceLabel(source.id, result.feed.title);
|
|
920
|
+
labels.set(source.id, result.feed.title);
|
|
792
921
|
}
|
|
793
|
-
|
|
922
|
+
put(result.ok
|
|
794
923
|
? {
|
|
795
924
|
id: source.id,
|
|
796
925
|
entries: result.feed.entries.map(entry => cut ? { ...entry, truncated: true } : entry),
|
|
@@ -801,13 +930,15 @@ export function ReaderPane(props) {
|
|
|
801
930
|
}
|
|
802
931
|
else {
|
|
803
932
|
const extracted = extractArticle(body.raw, source.url);
|
|
933
|
+
// A video / episode link's synthetic page carries its media (media-host.ts).
|
|
934
|
+
const linkMedia = readMediaMeta(body.raw);
|
|
804
935
|
// The card's title/excerpt: the article's own when THIS extraction
|
|
805
936
|
// read one, else the captured ones from an earlier extraction, else
|
|
806
937
|
// the URL-derived label — a saved link's card names the paper.
|
|
807
938
|
const extractedTitle = extracted.ok ? extracted.title : undefined;
|
|
808
939
|
const extractedExcerpt = extracted.ok ? extracted.excerpt : undefined;
|
|
809
940
|
const excerpt = extractedExcerpt ?? body.excerpt;
|
|
810
|
-
|
|
941
|
+
put({
|
|
811
942
|
id: source.id,
|
|
812
943
|
entries: [{
|
|
813
944
|
id: linkEntryId(source.id),
|
|
@@ -825,6 +956,7 @@ export function ReaderPane(props) {
|
|
|
825
956
|
// Incompleteness comes from the FETCH, not from extraction: the
|
|
826
957
|
// seam truncated the page, so whatever we extracted is partial.
|
|
827
958
|
...(cut ? { truncated: true } : {}),
|
|
959
|
+
...(linkMedia === undefined ? {} : { media: linkMedia }),
|
|
828
960
|
}],
|
|
829
961
|
});
|
|
830
962
|
}
|
|
@@ -832,6 +964,10 @@ export function ReaderPane(props) {
|
|
|
832
964
|
}
|
|
833
965
|
finally {
|
|
834
966
|
actions.setLoading(false);
|
|
967
|
+
if (known.length > 0) {
|
|
968
|
+
const ids = new Set(known.map(source => source.id));
|
|
969
|
+
writeWallCache({ sources: known, parsed: new Map([...fresh].filter(([id]) => ids.has(id))) });
|
|
970
|
+
}
|
|
835
971
|
}
|
|
836
972
|
}, [actions, props, t]);
|
|
837
973
|
const backfillRunning = useRef(false);
|
|
@@ -886,7 +1022,6 @@ export function ReaderPane(props) {
|
|
|
886
1022
|
void props.capabilities().then(result => {
|
|
887
1023
|
if (!result.ok)
|
|
888
1024
|
return;
|
|
889
|
-
setSideChatAvailable(result.value.hasSideChat);
|
|
890
1025
|
actions.setSchedule(result.value.lastRefreshAt, result.value.nextRefreshAt);
|
|
891
1026
|
if (result.value.nextRefreshAt !== undefined)
|
|
892
1027
|
setTimeOfDay(clockOf(result.value.nextRefreshAt));
|
|
@@ -938,6 +1073,17 @@ export function ReaderPane(props) {
|
|
|
938
1073
|
}
|
|
939
1074
|
return counts;
|
|
940
1075
|
}, [allEntries, presentation]);
|
|
1076
|
+
/** How many video / audio entries the wall holds, for the type filter. */
|
|
1077
|
+
const mediaCounts = useMemo(() => {
|
|
1078
|
+
const counts = { video: 0, audio: 0 };
|
|
1079
|
+
for (const entry of allEntries)
|
|
1080
|
+
if (entry.media !== undefined)
|
|
1081
|
+
counts[entry.media.kind] += 1;
|
|
1082
|
+
return counts;
|
|
1083
|
+
}, [allEntries]);
|
|
1084
|
+
/** The wall's video / audio cards, for their transcript pills. */
|
|
1085
|
+
const mediaEntryIds = useMemo(() => allEntries.filter(entry => entry.media !== undefined).slice(0, LIST_RENDER_LIMIT).map(entry => entry.id), [allEntries]);
|
|
1086
|
+
const mediaCards = useMediaStates(mediaEntryIds, view === 'list', props);
|
|
941
1087
|
/** One sweep at a time: the wall can re-render while an extraction runs. */
|
|
942
1088
|
const sweepRunning = useRef(false);
|
|
943
1089
|
/** The entry ids currently on the wall, for the fetch-state round trip. */
|
|
@@ -1109,6 +1255,126 @@ export function ReaderPane(props) {
|
|
|
1109
1255
|
return item === undefined ? undefined : entryFromRecent(item);
|
|
1110
1256
|
}, [allEntries, recent]);
|
|
1111
1257
|
const openEntry = openEntryId === null ? undefined : entryById(openEntryId);
|
|
1258
|
+
/** The open article's notes (see useNotes — they read the article, never drive it). */
|
|
1259
|
+
// A transcript's speakers: the reader's names for them (host-kept), else 说话人 A, B, ….
|
|
1260
|
+
const hasSpeakers = articleHtml?.includes('data-spk=') === true;
|
|
1261
|
+
const [speakerNamesOf, setSpeakerNamesOf] = useState(undefined);
|
|
1262
|
+
const speakerNames = useMemo(() => (speakerNamesOf !== undefined && speakerNamesOf.entryId === openEntry?.id ? speakerNamesOf.names : {}), [speakerNamesOf, openEntry?.id]);
|
|
1263
|
+
useEffect(() => {
|
|
1264
|
+
const id = openEntry?.id;
|
|
1265
|
+
if (id === undefined || !hasSpeakers)
|
|
1266
|
+
return;
|
|
1267
|
+
void props.speakerNames({ entryId: id }).then(answer => { if (answer.ok)
|
|
1268
|
+
setSpeakerNamesOf({ entryId: id, names: answer.value }); });
|
|
1269
|
+
}, [openEntry?.id, hasSpeakers, props]);
|
|
1270
|
+
const speakerLabel = useCallback((speaker) => speakerNames[String(speaker)] ?? t('media.speakerDefault', { letter: String.fromCharCode(65 + speaker) }), [speakerNames, t]);
|
|
1271
|
+
/** How many voices the open transcript has (its highest speaker index + 1). */
|
|
1272
|
+
const speakerCount = useMemo(() => {
|
|
1273
|
+
if (!hasSpeakers || articleHtml === null)
|
|
1274
|
+
return 0;
|
|
1275
|
+
let top = -1;
|
|
1276
|
+
for (const match of articleHtml.matchAll(/data-spk="(\d+)"/g))
|
|
1277
|
+
top = Math.max(top, Number(match[1]));
|
|
1278
|
+
return top + 1;
|
|
1279
|
+
}, [hasSpeakers, articleHtml]);
|
|
1280
|
+
const renameSpeaker = useCallback((speaker, name) => {
|
|
1281
|
+
const id = openEntry?.id;
|
|
1282
|
+
if (id === undefined)
|
|
1283
|
+
return;
|
|
1284
|
+
void props.nameSpeaker({ entryId: id, speaker, name }).then(answer => { if (answer.ok)
|
|
1285
|
+
setSpeakerNamesOf({ entryId: id, names: answer.value }); });
|
|
1286
|
+
}, [openEntry?.id, props]);
|
|
1287
|
+
const notesFace = useNotes({
|
|
1288
|
+
props,
|
|
1289
|
+
entry: openEntry,
|
|
1290
|
+
sourceLabel: openEntry === undefined ? '' : presentation.get(openEntry.sourceId)?.label ?? '',
|
|
1291
|
+
articleHtml,
|
|
1292
|
+
articleRef,
|
|
1293
|
+
builtRef,
|
|
1294
|
+
detailRef,
|
|
1295
|
+
paintKey: `${translatePhase}:${String(translateProgress?.done ?? 0)}`,
|
|
1296
|
+
translateView,
|
|
1297
|
+
...(hasSpeakers ? { speakerLabel } : {}),
|
|
1298
|
+
});
|
|
1299
|
+
/** The open entry's video / audio (see useMedia — it decorates the transcript, never rewrites it). */
|
|
1300
|
+
const openProvider = openEntry?.media?.provider;
|
|
1301
|
+
const chipTitle = useCallback((clock) => t(openProvider === 'bilibili' ? 'media.chipTitleReload' : 'media.chipTitle', { time: clock }), [t, openProvider]);
|
|
1302
|
+
const mediaFace = useMedia({ entryId: openEntry?.id, media: openEntry?.media, articleHtml, articleRef, detailRef, chipTitle, speakerLabel });
|
|
1303
|
+
// 转完自动翻译: one global preference, off until the reader ticks it once.
|
|
1304
|
+
const [autoTranslate, setAutoTranslate] = useState(() => {
|
|
1305
|
+
try {
|
|
1306
|
+
return globalThis.localStorage?.getItem(AUTO_TRANSLATE_KEY) === '1';
|
|
1307
|
+
}
|
|
1308
|
+
catch {
|
|
1309
|
+
return false;
|
|
1310
|
+
}
|
|
1311
|
+
});
|
|
1312
|
+
const setAutoTranslatePref = useCallback((on) => {
|
|
1313
|
+
setAutoTranslate(on);
|
|
1314
|
+
try {
|
|
1315
|
+
if (on)
|
|
1316
|
+
globalThis.localStorage?.setItem(AUTO_TRANSLATE_KEY, '1');
|
|
1317
|
+
else
|
|
1318
|
+
globalThis.localStorage?.removeItem(AUTO_TRANSLATE_KEY);
|
|
1319
|
+
}
|
|
1320
|
+
catch {
|
|
1321
|
+
// Remembered for this page only.
|
|
1322
|
+
}
|
|
1323
|
+
}, []);
|
|
1324
|
+
const setTranscriptState = mediaFace.setTranscript;
|
|
1325
|
+
// No captions and nothing stored: the open entry transcribes on this machine.
|
|
1326
|
+
const localFace = useLocalTranscript({
|
|
1327
|
+
entry: openEntry,
|
|
1328
|
+
wanted: openEntry?.media !== undefined && mediaFace.transcript === 'none',
|
|
1329
|
+
hidden: mediaFace.hidden,
|
|
1330
|
+
rejoin: openEntry?.media !== undefined && mediaFace.transcript === 'ready' && articleHtml?.includes('data-src="local"') === true,
|
|
1331
|
+
remote: props,
|
|
1332
|
+
onHtml: (entryId, html, partial) => {
|
|
1333
|
+
if (openRequestRef.current !== entryId)
|
|
1334
|
+
return;
|
|
1335
|
+
actions.setArticle(html, false, null);
|
|
1336
|
+
if (!partial)
|
|
1337
|
+
setTranscriptState(entryId, 'ready');
|
|
1338
|
+
},
|
|
1339
|
+
});
|
|
1340
|
+
const localActive = localFace.status !== undefined && ['fetching', 'queued', 'transcribing'].includes(localFace.status.state);
|
|
1341
|
+
// 润色: the agent's translation layer over the body.
|
|
1342
|
+
const polishFace = usePolish({
|
|
1343
|
+
entryId: openEntry?.id,
|
|
1344
|
+
articleHtml,
|
|
1345
|
+
articleRef,
|
|
1346
|
+
translating: translatePhase === 'ready' || translatePhase === 'working',
|
|
1347
|
+
translateView,
|
|
1348
|
+
remote: props,
|
|
1349
|
+
tagLabel: t('polish.tag'),
|
|
1350
|
+
tagTitle: t('polish.tagTitle'),
|
|
1351
|
+
});
|
|
1352
|
+
/** Ask the agent to polish the whole article (or continue at a paragraph). */
|
|
1353
|
+
const requestPolish = useCallback((from) => {
|
|
1354
|
+
const article = articleRef.current;
|
|
1355
|
+
const total = polishFace.view?.total ?? (article === null ? 0 : blockElements(article).length);
|
|
1356
|
+
notesFace.polish(total, from);
|
|
1357
|
+
polishFace.requested();
|
|
1358
|
+
}, [polishFace, notesFace]);
|
|
1359
|
+
// The compact bar's title (see `titleScrolledAway`): watch the article's
|
|
1360
|
+
// heading leave the body through its top.
|
|
1361
|
+
useEffect(() => {
|
|
1362
|
+
setTitleScrolledAway(false);
|
|
1363
|
+
const root = detailRef.current;
|
|
1364
|
+
const heading = detailTitleRef.current;
|
|
1365
|
+
// No observer (jsdom, an old engine) degrades to the bar staying empty.
|
|
1366
|
+
if (!compact || view !== 'detail' || root === null || heading === null || typeof IntersectionObserver === 'undefined')
|
|
1367
|
+
return undefined;
|
|
1368
|
+
const observer = new IntersectionObserver(([entry]) => {
|
|
1369
|
+
if (entry === undefined)
|
|
1370
|
+
return;
|
|
1371
|
+
// Away only when it left through the TOP: a heading below the fold (a
|
|
1372
|
+
// restored reading position mid-render) is not "scrolled past".
|
|
1373
|
+
setTitleScrolledAway(!entry.isIntersecting && entry.boundingClientRect.bottom <= (entry.rootBounds?.top ?? 0));
|
|
1374
|
+
}, { root, threshold: 0 });
|
|
1375
|
+
observer.observe(heading);
|
|
1376
|
+
return () => { observer.disconnect(); };
|
|
1377
|
+
}, [compact, view, openEntryId, openEntry !== undefined]);
|
|
1112
1378
|
/** The source the strip is currently narrowing to, if any. */
|
|
1113
1379
|
const activeSource = sources.find(source => query.trim() === sourceQuery(source.id))?.id ?? null;
|
|
1114
1380
|
/** The source KIND the search box is narrowing to, if any (`#rss` / `#link`). */
|
|
@@ -1177,14 +1443,14 @@ export function ReaderPane(props) {
|
|
|
1177
1443
|
}, [view, articleHtml, guessSource]);
|
|
1178
1444
|
/**
|
|
1179
1445
|
* Whether the globe belongs on this article at all. Three ways it does not:
|
|
1180
|
-
* the browser has no Translator API
|
|
1181
|
-
*
|
|
1182
|
-
*
|
|
1446
|
+
* the browser has no Translator API (and the host stored no translation of
|
|
1447
|
+
* this body), the pair is unavailable on this device, or the body is already
|
|
1448
|
+
* the target language. All three hide the control rather than offering one
|
|
1449
|
+
* that cannot work — the pass's own degradation rule.
|
|
1183
1450
|
*/
|
|
1184
|
-
const translationOffered =
|
|
1185
|
-
|
|
1186
|
-
&& translateAvailability !== 'unavailable'
|
|
1187
|
-
&& translationSource !== TRANSLATION_TARGET;
|
|
1451
|
+
const translationOffered = translationSource !== TRANSLATION_TARGET && (translator === null
|
|
1452
|
+
? storedPair !== null
|
|
1453
|
+
: translateAvailability !== null && translateAvailability !== 'unavailable');
|
|
1188
1454
|
// Ask the browser about this pair once per opened article, in every target
|
|
1189
1455
|
// spelling: the probe is async, and the control appears only once one of them
|
|
1190
1456
|
// is not `unavailable`.
|
|
@@ -1245,7 +1511,34 @@ export function ReaderPane(props) {
|
|
|
1245
1511
|
setTranslateError(null);
|
|
1246
1512
|
setTranslateMenu(false);
|
|
1247
1513
|
setTranslateAvailability(null);
|
|
1514
|
+
setStoredPair(null);
|
|
1248
1515
|
}, [openEntryId, articleHtml]);
|
|
1516
|
+
// No Translator API: ask the host whether a translation of exactly this body
|
|
1517
|
+
// is stored (made earlier in a browser that had one). Only the entry's own
|
|
1518
|
+
// map counts — its bodyHash proves the pairing; the global sentence memory
|
|
1519
|
+
// alone could cover a few stray sentences of an untranslated article.
|
|
1520
|
+
useEffect(() => {
|
|
1521
|
+
if (view !== 'detail' || translator !== null || openEntryId === null)
|
|
1522
|
+
return undefined;
|
|
1523
|
+
if (articleHtml === null || articleHtml.length === 0)
|
|
1524
|
+
return undefined;
|
|
1525
|
+
let cancelled = false;
|
|
1526
|
+
void (async () => {
|
|
1527
|
+
try {
|
|
1528
|
+
const answer = await props.getEntryTranslation(openEntryId);
|
|
1529
|
+
const record = answer.ok ? answer.value.translation : undefined;
|
|
1530
|
+
if (cancelled || record === undefined)
|
|
1531
|
+
return;
|
|
1532
|
+
if (record.bodyHash !== translationHash(articleHtml) || Object.keys(record.segments).length === 0)
|
|
1533
|
+
return;
|
|
1534
|
+
setStoredPair(record.pair);
|
|
1535
|
+
}
|
|
1536
|
+
catch {
|
|
1537
|
+
// Unreadable is the same as absent: no globe.
|
|
1538
|
+
}
|
|
1539
|
+
})();
|
|
1540
|
+
return () => { cancelled = true; };
|
|
1541
|
+
}, [view, translator, openEntryId, articleHtml, props]);
|
|
1249
1542
|
// Leaving the pane must not leave spans behind in a DOM React will reuse.
|
|
1250
1543
|
useEffect(() => () => {
|
|
1251
1544
|
if (cancelRef.current !== null)
|
|
@@ -1343,6 +1636,106 @@ export function ReaderPane(props) {
|
|
|
1343
1636
|
...(entry === undefined ? {} : { entryId: entry.entryId, bodyHash: entry.bodyHash }),
|
|
1344
1637
|
});
|
|
1345
1638
|
}, [props]);
|
|
1639
|
+
/**
|
|
1640
|
+
* The open article's title in the reader's language, once the globe has
|
|
1641
|
+
* translated the body. Keyed by entry so a stale answer never titles the
|
|
1642
|
+
* next article.
|
|
1643
|
+
*/
|
|
1644
|
+
const [titleTranslation, setTitleTranslation] = useState(null);
|
|
1645
|
+
/**
|
|
1646
|
+
* Translate the open article's title alongside its body: the page memory,
|
|
1647
|
+
* then the host's sentence memory, then (with a live session) the model.
|
|
1648
|
+
* Without a session — the stored-translation path — a miss leaves the
|
|
1649
|
+
* title in its original, the same rule the body follows.
|
|
1650
|
+
*
|
|
1651
|
+
* @returns what a model call learned, for the caller to fold into its own
|
|
1652
|
+
* persist batch (one write per run, never a second one for the title).
|
|
1653
|
+
*/
|
|
1654
|
+
const translateTitle = useCallback(async (pair, session, cancelled) => {
|
|
1655
|
+
const entry = openEntryId === null ? undefined : entryById(openEntryId);
|
|
1656
|
+
const title = entry?.title.trim() ?? '';
|
|
1657
|
+
if (entry === undefined || title === '' || isTargetLanguage(title))
|
|
1658
|
+
return [];
|
|
1659
|
+
const settle = (text) => {
|
|
1660
|
+
if (text !== undefined && text.trim() !== '' && !cancelled())
|
|
1661
|
+
setTitleTranslation({ id: entry.id, text: text.trim() });
|
|
1662
|
+
};
|
|
1663
|
+
const known = remembered(pair, title) ?? cardTranslationsRef.current[entry.id]?.title;
|
|
1664
|
+
if (known !== undefined) {
|
|
1665
|
+
settle(known);
|
|
1666
|
+
return [];
|
|
1667
|
+
}
|
|
1668
|
+
try {
|
|
1669
|
+
const slice = await props.getSentenceTranslations({ pair, hashes: [translationHash(title)] });
|
|
1670
|
+
const stored = slice.ok ? slice.value.translations[translationHash(title)] : undefined;
|
|
1671
|
+
if (stored !== undefined) {
|
|
1672
|
+
primeMemory(pair, [{ text: title, translation: stored }]);
|
|
1673
|
+
settle(stored);
|
|
1674
|
+
return [];
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
catch {
|
|
1678
|
+
// A miss: the model's job, when there is one.
|
|
1679
|
+
}
|
|
1680
|
+
if (session === null || cancelled())
|
|
1681
|
+
return [];
|
|
1682
|
+
const outcome = await translateTexts([title], pair, session, cancelled);
|
|
1683
|
+
settle(outcome.translated.get(title));
|
|
1684
|
+
return outcome.learned;
|
|
1685
|
+
}, [openEntryId, entryById, props]);
|
|
1686
|
+
/**
|
|
1687
|
+
* The globe on a page with no Translator API: paint the host's stored
|
|
1688
|
+
* translation of this body and stop. Nothing is sent to a model; a sentence
|
|
1689
|
+
* the store does not cover stays in its original.
|
|
1690
|
+
*
|
|
1691
|
+
* @param initialView - the view to show.
|
|
1692
|
+
* @param pair - the stored record's own pair label.
|
|
1693
|
+
*/
|
|
1694
|
+
const showStoredTranslation = useCallback(async (initialView, pair) => {
|
|
1695
|
+
const container = articleRef.current;
|
|
1696
|
+
if (container === null || openEntryId === null || articleHtml === null)
|
|
1697
|
+
return;
|
|
1698
|
+
if (cancelRef.current !== null)
|
|
1699
|
+
cancelRef.current.cancelled = true;
|
|
1700
|
+
const previous = builtRef.current;
|
|
1701
|
+
if (previous !== null)
|
|
1702
|
+
restoreArticle(previous.root);
|
|
1703
|
+
builtRef.current = null;
|
|
1704
|
+
const cancel = { cancelled: false };
|
|
1705
|
+
cancelRef.current = cancel;
|
|
1706
|
+
setTranslateError(null);
|
|
1707
|
+
setTranslateProgress(null);
|
|
1708
|
+
const built = buildArticle(container, translateClasses);
|
|
1709
|
+
if (built === null) {
|
|
1710
|
+
cancelRef.current = null;
|
|
1711
|
+
setStoredPair(null);
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
setTranslatePhase('working');
|
|
1715
|
+
void translateTitle(pair, null, () => cancel.cancelled);
|
|
1716
|
+
await warmMemory(pair, openEntryId, articleHtml, built);
|
|
1717
|
+
if (cancel.cancelled)
|
|
1718
|
+
return;
|
|
1719
|
+
cancelRef.current = null;
|
|
1720
|
+
const result = applyRemembered(built, pair);
|
|
1721
|
+
if (result.done === 0) {
|
|
1722
|
+
// The record named this body but none of its sentences matched the
|
|
1723
|
+
// segmentation here: nothing to show, so no globe either.
|
|
1724
|
+
restoreArticle(built.root);
|
|
1725
|
+
setTranslatePhase('idle');
|
|
1726
|
+
setStoredPair(null);
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
builtRef.current = built;
|
|
1730
|
+
if (initialView !== 'orig')
|
|
1731
|
+
lastViewRef.current = initialView;
|
|
1732
|
+
setTranslateView(initialView);
|
|
1733
|
+
setView(built, initialView, translateClasses);
|
|
1734
|
+
setTranslatePhase('ready');
|
|
1735
|
+
rememberTranslation(openEntryId, initialView, pair.split('→')[0] ?? translationSource);
|
|
1736
|
+
// Only the LRU stamps move: a stored translation being read is a use.
|
|
1737
|
+
persistTranslations(pair, [], result.recalled, { entryId: openEntryId, bodyHash: translationHash(articleHtml) });
|
|
1738
|
+
}, [openEntryId, articleHtml, translateClasses, warmMemory, persistTranslations, translationSource, translateTitle]);
|
|
1346
1739
|
/**
|
|
1347
1740
|
* Get the article translated, reusing a session this page already built.
|
|
1348
1741
|
*
|
|
@@ -1359,8 +1752,13 @@ export function ReaderPane(props) {
|
|
|
1359
1752
|
const startTranslation = useCallback(async (initialView, sourceOverride) => {
|
|
1360
1753
|
const api = translator;
|
|
1361
1754
|
const container = articleRef.current;
|
|
1362
|
-
if (
|
|
1755
|
+
if (container === null)
|
|
1756
|
+
return;
|
|
1757
|
+
if (api === null) {
|
|
1758
|
+
if (storedPair !== null)
|
|
1759
|
+
await showStoredTranslation(initialView, storedPair);
|
|
1363
1760
|
return;
|
|
1761
|
+
}
|
|
1364
1762
|
// One run at a time. A second start (the reader's retry, or the restore
|
|
1365
1763
|
// re-applying a translation to a body that was just replaced) must not build
|
|
1366
1764
|
// its own segmentation over the first one's: two overlapping runs would
|
|
@@ -1442,6 +1840,9 @@ export function ReaderPane(props) {
|
|
|
1442
1840
|
// The host's tiers answer first: the entry's exact-fit map, then the global
|
|
1443
1841
|
// sentence memory. Only what neither knows is spent on the model.
|
|
1444
1842
|
const pair = `${sourceLanguage}→${TRANSLATION_TARGET}`;
|
|
1843
|
+
// The title rides the same session, beside the body rather than after it:
|
|
1844
|
+
// one short string, and the header should not wait for a long article.
|
|
1845
|
+
const titleRun = translateTitle(pair, session, () => cancel.cancelled);
|
|
1445
1846
|
if (openEntryId !== null && articleHtml !== null) {
|
|
1446
1847
|
await warmMemory(pair, openEntryId, articleHtml, built);
|
|
1447
1848
|
if (cancel.cancelled) {
|
|
@@ -1458,8 +1859,9 @@ export function ReaderPane(props) {
|
|
|
1458
1859
|
});
|
|
1459
1860
|
// What the run learned (and what it was served) persists in one batch: a
|
|
1460
1861
|
// reload loses the page's memory, and the next visit pays the model again
|
|
1461
|
-
// without this.
|
|
1462
|
-
|
|
1862
|
+
// without this. The title's lesson rides in the same batch.
|
|
1863
|
+
const titleLearned = await titleRun;
|
|
1864
|
+
persistTranslations(pair, [...result.learned, ...titleLearned], result.recalled, openEntryId !== null && articleHtml !== null
|
|
1463
1865
|
? { entryId: openEntryId, bodyHash: translationHash(articleHtml) }
|
|
1464
1866
|
: undefined);
|
|
1465
1867
|
if (cancel.cancelled)
|
|
@@ -1476,7 +1878,7 @@ export function ReaderPane(props) {
|
|
|
1476
1878
|
setTranslateProgress(null);
|
|
1477
1879
|
// Re-paint once the first units exist, so the translated typography applies.
|
|
1478
1880
|
setView(built, initialView, translateClasses);
|
|
1479
|
-
}, [translator, translationSource, translateClasses, t, openEntryId, articleHtml, warmMemory, persistTranslations]);
|
|
1881
|
+
}, [translator, storedPair, showStoredTranslation, translationSource, translateClasses, t, openEntryId, articleHtml, warmMemory, persistTranslations, translateTitle]);
|
|
1480
1882
|
/** Apply one view to a finished translation. */
|
|
1481
1883
|
const applyView = useCallback((next) => {
|
|
1482
1884
|
setTranslateView(next);
|
|
@@ -1550,6 +1952,46 @@ export function ReaderPane(props) {
|
|
|
1550
1952
|
return;
|
|
1551
1953
|
toggleSegment(built, segment, translateClasses);
|
|
1552
1954
|
}, [translateClasses, translatePhase]);
|
|
1955
|
+
/**
|
|
1956
|
+
* The CORP rescue. The extractor already emits absolute URLs with
|
|
1957
|
+
* `referrerpolicy="no-referrer"`, which covers referer-gated CDNs — but a
|
|
1958
|
+
* response headed `Cross-Origin-Resource-Policy: same-origin` (claude.dev's
|
|
1959
|
+
* `/media/*` figures, measured 2026-09-29) is withheld from this page no
|
|
1960
|
+
* matter what the tag asks for: the request answers 200 and the browser
|
|
1961
|
+
* still refuses to hand the pixels over, leaving the broken-image icon over
|
|
1962
|
+
* the alt text. A no-cors browser fetch cannot read those bytes either; the
|
|
1963
|
+
* host process can, so the failed image is re-pointed at what the host
|
|
1964
|
+
* fetched. Only images that ALREADY failed come here — a healthy image never
|
|
1965
|
+
* pays the proxy — and each `<img>` tries once: the marker doubles as the
|
|
1966
|
+
* loop guard should the data URI itself fail.
|
|
1967
|
+
*/
|
|
1968
|
+
const onArticleImageError = useCallback((event) => {
|
|
1969
|
+
const target = event.target;
|
|
1970
|
+
if (!(target instanceof HTMLImageElement))
|
|
1971
|
+
return;
|
|
1972
|
+
if (target.dataset['readerRescued'] !== undefined)
|
|
1973
|
+
return;
|
|
1974
|
+
const url = target.getAttribute('src') ?? '';
|
|
1975
|
+
// Only a remote URL can be rescued; a data: failure is a broken payload.
|
|
1976
|
+
if (!/^https?:\/\//i.test(url))
|
|
1977
|
+
return;
|
|
1978
|
+
target.dataset['readerRescued'] = '1';
|
|
1979
|
+
const known = rescuedImagesRef.current.get(url);
|
|
1980
|
+
if (known !== undefined) {
|
|
1981
|
+
target.src = known;
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
void props.fetchImage(url).then(result => {
|
|
1985
|
+
if (!result.ok)
|
|
1986
|
+
return;
|
|
1987
|
+
const { mime, base64 } = result.value;
|
|
1988
|
+
if (mime === undefined || base64 === undefined)
|
|
1989
|
+
return;
|
|
1990
|
+
const dataUri = `data:${mime};base64,${base64}`;
|
|
1991
|
+
rescuedImagesRef.current.set(url, dataUri);
|
|
1992
|
+
target.src = dataUri;
|
|
1993
|
+
}, () => undefined);
|
|
1994
|
+
}, [props]);
|
|
1553
1995
|
/* ---------------------------------------------------- the wall's own pass */
|
|
1554
1996
|
// The wall has no article to segment: a card's unit is a FIELD (title,
|
|
1555
1997
|
// summary), and the wall must translate lazily — a wall of forty cards is
|
|
@@ -1628,10 +2070,12 @@ export function ReaderPane(props) {
|
|
|
1628
2070
|
continue;
|
|
1629
2071
|
// Chinese cards (and Chinese fields inside a mixed card) are left
|
|
1630
2072
|
// exactly as they are: translating them to Chinese is not a feature.
|
|
1631
|
-
|
|
2073
|
+
// Already translated (this visit, or before a remount): nothing to do.
|
|
2074
|
+
const done = cardTranslationsRef.current[id];
|
|
2075
|
+
if (!isTargetLanguage(row.entry.title) && done?.title === undefined)
|
|
1632
2076
|
fields.push({ id, field: 'title', text: row.entry.title });
|
|
1633
2077
|
const summary = row.entry.summary;
|
|
1634
|
-
if (summary !== undefined && summary.length > 0 && !isTargetLanguage(summary)) {
|
|
2078
|
+
if (summary !== undefined && summary.length > 0 && !isTargetLanguage(summary) && done?.summary === undefined) {
|
|
1635
2079
|
fields.push({ id, field: 'summary', text: summary });
|
|
1636
2080
|
}
|
|
1637
2081
|
}
|
|
@@ -1758,11 +2202,16 @@ export function ReaderPane(props) {
|
|
|
1758
2202
|
*
|
|
1759
2203
|
* @param entryId - the entry whose body is wanted.
|
|
1760
2204
|
* @param url - the article URL to fetch.
|
|
2205
|
+
* @returns whether the answer wants the rendered fetch next: a body that
|
|
2206
|
+
* still counts script figures, or a refusal a real browser gets past.
|
|
1761
2207
|
*/
|
|
1762
2208
|
const fetchBody = useCallback(async (entryId, url) => {
|
|
1763
2209
|
actions.setFetching(entryId, true);
|
|
2210
|
+
setBodyFetch({ entryId, phase: 'downloading', since: Date.now() });
|
|
1764
2211
|
try {
|
|
1765
|
-
const result = await props.fetchEntryBody(entryId, url
|
|
2212
|
+
const result = await props.fetchEntryBody(entryId, url, phase => {
|
|
2213
|
+
setBodyFetch(current => (current?.entryId === entryId ? { entryId, phase, since: current.since } : current));
|
|
2214
|
+
});
|
|
1766
2215
|
// A late answer belongs to the entry that ASKED for it: if the detail
|
|
1767
2216
|
// view has moved on (another entry, or the wall), the article area is no
|
|
1768
2217
|
// longer this entry's to write. The per-entry records are unaffected —
|
|
@@ -1784,7 +2233,7 @@ export function ReaderPane(props) {
|
|
|
1784
2233
|
}
|
|
1785
2234
|
// The caller escalates on a shell: a body that still counts script
|
|
1786
2235
|
// figures wants the rendered fetch next.
|
|
1787
|
-
return result.scriptFigures ?? 0;
|
|
2236
|
+
return (result.scriptFigures ?? 0) > 0;
|
|
1788
2237
|
}
|
|
1789
2238
|
else if (result.error !== undefined && onScreen) {
|
|
1790
2239
|
// Keep whatever is already rendered (a feed summary, say) and add the
|
|
@@ -1792,10 +2241,12 @@ export function ReaderPane(props) {
|
|
|
1792
2241
|
// It also leaves the host's copy — and its stale marker — untouched.
|
|
1793
2242
|
actions.setArticle(articleHtml ?? '', false, result.error);
|
|
1794
2243
|
}
|
|
1795
|
-
|
|
2244
|
+
// …and so does a bot wall: the plain fetch is what it refused.
|
|
2245
|
+
return result.error !== undefined && browserSolvable(result.error);
|
|
1796
2246
|
}
|
|
1797
2247
|
finally {
|
|
1798
2248
|
actions.setFetching(entryId, false);
|
|
2249
|
+
setBodyFetch(current => (current?.entryId === entryId ? null : current));
|
|
1799
2250
|
// The card reads the host's annotation through the mirror: re-read the
|
|
1800
2251
|
// one entry this fetch settled, so the pill flips on this gesture and
|
|
1801
2252
|
// not at the next mount.
|
|
@@ -1817,19 +2268,20 @@ export function ReaderPane(props) {
|
|
|
1817
2268
|
const capture = props.captureRemote();
|
|
1818
2269
|
if (capture === undefined)
|
|
1819
2270
|
return;
|
|
1820
|
-
|
|
2271
|
+
setCaptureBusyId(entryId);
|
|
2272
|
+
setCaptureFailure(null);
|
|
1821
2273
|
try {
|
|
1822
2274
|
const rendered = await capture.render({ url: link });
|
|
1823
2275
|
if (!rendered.ok) {
|
|
1824
|
-
|
|
1825
|
-
|
|
2276
|
+
// The reason the plain fetch failed stays the line's lead; the render's
|
|
2277
|
+
// own failure is said after it (captureFailure).
|
|
2278
|
+
setCaptureFailure({ entryId, message: rendered.error.message });
|
|
1826
2279
|
return;
|
|
1827
2280
|
}
|
|
1828
2281
|
const finalUrl = rendered.value.finalUrl ?? link;
|
|
1829
2282
|
const extracted = extractArticle(rendered.value.html, finalUrl);
|
|
1830
2283
|
if (!extracted.ok) {
|
|
1831
|
-
|
|
1832
|
-
actions.setArticle(articleHtml ?? '', false, extracted.error);
|
|
2284
|
+
setCaptureFailure({ entryId, message: extracted.error });
|
|
1833
2285
|
return;
|
|
1834
2286
|
}
|
|
1835
2287
|
const stored = await props.storeEntryBody({
|
|
@@ -1837,6 +2289,10 @@ export function ReaderPane(props) {
|
|
|
1837
2289
|
url: finalUrl,
|
|
1838
2290
|
html: extracted.html,
|
|
1839
2291
|
bodyHash: translationHash(extracted.html),
|
|
2292
|
+
// The article's own title/excerpt: a link saved behind a wall has only
|
|
2293
|
+
// its URL-derived label until the render reads the page.
|
|
2294
|
+
...(extracted.title === undefined ? {} : { title: extracted.title }),
|
|
2295
|
+
...(extracted.excerpt === undefined ? {} : { excerpt: extracted.excerpt }),
|
|
1840
2296
|
...(rendered.value.truncated === true ? { truncated: true } : {}),
|
|
1841
2297
|
// A capture render ALWAYS carries its count, zero included: the entry's
|
|
1842
2298
|
// own load-time count (84 script figures, say) is what the remount
|
|
@@ -1849,6 +2305,10 @@ export function ReaderPane(props) {
|
|
|
1849
2305
|
rendered: true,
|
|
1850
2306
|
});
|
|
1851
2307
|
renderedBodiesRef.current.add(entryId);
|
|
2308
|
+
actions.noteExtractedMeta(entryId, {
|
|
2309
|
+
...(extracted.title === undefined ? {} : { title: extracted.title }),
|
|
2310
|
+
...(extracted.excerpt === undefined ? {} : { excerpt: extracted.excerpt }),
|
|
2311
|
+
});
|
|
1852
2312
|
if (openRequestRef.current !== entryId)
|
|
1853
2313
|
return;
|
|
1854
2314
|
if (stored.ok && stored.value.html !== undefined) {
|
|
@@ -1859,19 +2319,21 @@ export function ReaderPane(props) {
|
|
|
1859
2319
|
}
|
|
1860
2320
|
}
|
|
1861
2321
|
finally {
|
|
1862
|
-
|
|
2322
|
+
setCaptureBusyId(current => (current === entryId ? null : current));
|
|
1863
2323
|
}
|
|
1864
|
-
}, [actions, props,
|
|
2324
|
+
}, [actions, props, syncFetchState]);
|
|
1865
2325
|
/**
|
|
1866
2326
|
* One gesture, the whole pipeline: an explicit per-entry ask (add a link,
|
|
1867
2327
|
* the card's 「抓取」, opening an entry, the detail's 「重新抓取」) that lands
|
|
1868
|
-
* on a script-figure shell
|
|
1869
|
-
*
|
|
2328
|
+
* on a script-figure shell — or on a bot wall that refuses the plain fetch
|
|
2329
|
+
* (mp.weixin.qq.com answers 「环境异常」 to anything but a browser) —
|
|
2330
|
+
* continues into the rendered fetch on its own: the gesture already
|
|
2331
|
+
* authorized capturing that URL. Fires once per entry
|
|
1870
2332
|
* per pane session (a failure leaves the manual「渲染抓取」retry); background
|
|
1871
2333
|
* paths (scheduled feed refresh, backfill) never reach here.
|
|
1872
2334
|
*/
|
|
1873
|
-
const maybeAutoRender = useCallback((entryId, link,
|
|
1874
|
-
if (link === undefined ||
|
|
2335
|
+
const maybeAutoRender = useCallback((entryId, link, wanted) => {
|
|
2336
|
+
if (link === undefined || !wanted)
|
|
1875
2337
|
return;
|
|
1876
2338
|
if (props.captureRemote() === undefined)
|
|
1877
2339
|
return;
|
|
@@ -1882,7 +2344,7 @@ export function ReaderPane(props) {
|
|
|
1882
2344
|
}, [props, captureBody]);
|
|
1883
2345
|
/** A plain fetch that escalates on a shell — the explicit-gesture fetch path. */
|
|
1884
2346
|
const fetchThenMaybeRender = useCallback((entryId, link) => {
|
|
1885
|
-
void fetchBody(entryId, link).then(
|
|
2347
|
+
void fetchBody(entryId, link).then(wanted => maybeAutoRender(entryId, link, wanted));
|
|
1886
2348
|
}, [fetchBody, maybeAutoRender]);
|
|
1887
2349
|
/** Open one entry: mark it read and make sure a body is available. */
|
|
1888
2350
|
const open = useCallback(async (row) => {
|
|
@@ -1916,6 +2378,43 @@ export function ReaderPane(props) {
|
|
|
1916
2378
|
// A cached fetch outlives the feed's own payload, so the host is the one
|
|
1917
2379
|
// that knows whether there is full text: it returns the fresh cache, else
|
|
1918
2380
|
// the feed's body, else nothing plus the reason a previous fetch failed.
|
|
2381
|
+
if (row.entry.link !== undefined && row.entry.media !== undefined) {
|
|
2382
|
+
// A video or audio entry: its text is a TRANSCRIPT, never the page behind
|
|
2383
|
+
// the link — none of the article paths below (fetch, backfill, persist)
|
|
2384
|
+
// may run for it, or they would crawl YouTube's watch page.
|
|
2385
|
+
const shownotes = row.entry.contentHtml ?? '';
|
|
2386
|
+
setScriptFigures(0);
|
|
2387
|
+
// A stored transcript (platform captions, or a finished local one) is the body.
|
|
2388
|
+
const cached = await props.getEntryBody({ entryId: row.entry.id, url: row.entry.link });
|
|
2389
|
+
if (openRequestRef.current !== row.entry.id)
|
|
2390
|
+
return;
|
|
2391
|
+
if (cached.ok && cached.value.cached === true && cached.value.html !== undefined) {
|
|
2392
|
+
actions.setArticle(cached.value.html, false, null);
|
|
2393
|
+
setTranscriptState(row.entry.id, 'ready');
|
|
2394
|
+
return;
|
|
2395
|
+
}
|
|
2396
|
+
if (row.entry.media.kind !== 'video' || (row.entry.media.provider !== 'youtube' && row.entry.media.provider !== 'bilibili')) {
|
|
2397
|
+
// Audio has no platform captions: the shownotes show until the local
|
|
2398
|
+
// transcript (`useLocalTranscript`) replaces them.
|
|
2399
|
+
actions.setArticle(shownotes, false, null);
|
|
2400
|
+
setTranscriptState(row.entry.id, 'none');
|
|
2401
|
+
return;
|
|
2402
|
+
}
|
|
2403
|
+
actions.setArticle(shownotes, false, null);
|
|
2404
|
+
setTranscriptState(row.entry.id, 'loading');
|
|
2405
|
+
const fetched = await props.mediaTranscript({ entryId: row.entry.id, url: row.entry.link });
|
|
2406
|
+
if (openRequestRef.current !== row.entry.id)
|
|
2407
|
+
return;
|
|
2408
|
+
if (fetched.ok && fetched.value.html !== undefined) {
|
|
2409
|
+
actions.setArticle(fetched.value.html, false, null);
|
|
2410
|
+
setTranscriptState(row.entry.id, 'ready');
|
|
2411
|
+
void syncFetchState(row.entry.id);
|
|
2412
|
+
}
|
|
2413
|
+
else {
|
|
2414
|
+
setTranscriptState(row.entry.id, fetched.ok && fetched.value.captions === 'none' ? 'none' : 'failed');
|
|
2415
|
+
}
|
|
2416
|
+
return;
|
|
2417
|
+
}
|
|
1919
2418
|
if (row.entry.link !== undefined) {
|
|
1920
2419
|
// A feed that published only a summary still owes the reader an article,
|
|
1921
2420
|
// whichever branch below supplies the text on screen (the host answers
|
|
@@ -1956,7 +2455,7 @@ export function ReaderPane(props) {
|
|
|
1956
2455
|
// load-time extraction, whose count rides the entry instead.
|
|
1957
2456
|
const knownCount = view.value.scriptFigures ?? row.entry.scriptFigures ?? 0;
|
|
1958
2457
|
setScriptFigures(knownCount);
|
|
1959
|
-
maybeAutoRender(row.entry.id, row.entry.link, knownCount);
|
|
2458
|
+
maybeAutoRender(row.entry.id, row.entry.link, knownCount > 0);
|
|
1960
2459
|
actions.setStaleBody(row.entry.id, view.value.fresh === false);
|
|
1961
2460
|
// `html` here is either the FEED's own payload or a body this plugin
|
|
1962
2461
|
// already fetched and cached — a cached one is paid for already.
|
|
@@ -2007,6 +2506,10 @@ export function ReaderPane(props) {
|
|
|
2007
2506
|
if (row.sourceKind === 'rss' && view.value.error === undefined) {
|
|
2008
2507
|
fetchThenMaybeRender(row.entry.id, row.entry.link);
|
|
2009
2508
|
}
|
|
2509
|
+
// A recorded bot wall (the add-time preview, or an earlier fetch) is
|
|
2510
|
+
// the one recorded reason a gesture CAN change: opening the entry
|
|
2511
|
+
// asks for its text, and a headless browser is what the wall admits.
|
|
2512
|
+
maybeAutoRender(row.entry.id, row.entry.link, view.value.error !== undefined && browserSolvable(view.value.error));
|
|
2010
2513
|
}
|
|
2011
2514
|
return;
|
|
2012
2515
|
}
|
|
@@ -2036,11 +2539,11 @@ export function ReaderPane(props) {
|
|
|
2036
2539
|
// fetch paths set it from the host's answer, this one computes it locally.
|
|
2037
2540
|
const count = extracted.scriptFigures ?? 0;
|
|
2038
2541
|
setScriptFigures(count);
|
|
2039
|
-
maybeAutoRender(row.entry.id, row.entry.link, count);
|
|
2542
|
+
maybeAutoRender(row.entry.id, row.entry.link, count > 0);
|
|
2040
2543
|
}
|
|
2041
2544
|
else
|
|
2042
2545
|
actions.setArticle('', truncated, extracted.error);
|
|
2043
|
-
}, [actions, props, t, fetchThenMaybeRender, persistFeedBody, recent, fetchStates, syncFetchState, maybeAutoRender]);
|
|
2546
|
+
}, [actions, props, t, fetchThenMaybeRender, persistFeedBody, recent, fetchStates, syncFetchState, maybeAutoRender, setTranscriptState]);
|
|
2044
2547
|
/* ------------------------------ coming back to where the reader already was */
|
|
2045
2548
|
/**
|
|
2046
2549
|
* Re-open the article the pane had on screen when the sidebar went away.
|
|
@@ -2388,6 +2891,11 @@ export function ReaderPane(props) {
|
|
|
2388
2891
|
return;
|
|
2389
2892
|
if (translatePhase !== 'idle')
|
|
2390
2893
|
return;
|
|
2894
|
+
// A transcript still arriving replaces the body every poll: restoring on
|
|
2895
|
+
// each would re-translate (and re-raise its bar) over and over. The
|
|
2896
|
+
// record stays; the finished transcript gets it once.
|
|
2897
|
+
if (localActive)
|
|
2898
|
+
return;
|
|
2391
2899
|
if (restoredTranslationRef.current === openEntryId)
|
|
2392
2900
|
return;
|
|
2393
2901
|
const wanted = readSession().translationView?.[openEntryId];
|
|
@@ -2395,62 +2903,92 @@ export function ReaderPane(props) {
|
|
|
2395
2903
|
return;
|
|
2396
2904
|
const source = readSession().translationSource?.[openEntryId] ?? translationSource;
|
|
2397
2905
|
const sources = source === 'en' ? ['en'] : [source, 'en'];
|
|
2398
|
-
|
|
2906
|
+
// A stored translation needs no session (and no activation) to be shown.
|
|
2907
|
+
if (translator === null ? storedPair === null : cachedTranslator(sources, TARGET_CANDIDATES) === undefined)
|
|
2399
2908
|
return;
|
|
2400
2909
|
restoredTranslationRef.current = openEntryId;
|
|
2401
2910
|
void startTranslation(wanted, source);
|
|
2402
|
-
}, [view, openEntryId, articleHtml, translatePhase, translationSource, startTranslation]);
|
|
2911
|
+
}, [view, openEntryId, articleHtml, translatePhase, translationSource, translator, storedPair, startTranslation, localActive]);
|
|
2912
|
+
/**
|
|
2913
|
+
* 转完自动翻译: a whole transcript (platform captions, or a finished local
|
|
2914
|
+
* one) in another language opens translated, side by side, when the reader
|
|
2915
|
+
* ticked the preference. Only when the pair is already on the device:
|
|
2916
|
+
* building a session that must download needs a click (user activation),
|
|
2917
|
+
* and an automatic start must never fail at the reader.
|
|
2918
|
+
*/
|
|
2919
|
+
const autoTranslatedRef = useRef(null);
|
|
2920
|
+
useEffect(() => {
|
|
2921
|
+
if (!autoTranslate || view !== 'detail' || openEntry?.media === undefined || openEntryId === null)
|
|
2922
|
+
return;
|
|
2923
|
+
if (mediaFace.transcript !== 'ready' || localActive || !translationOffered || translatePhase !== 'idle')
|
|
2924
|
+
return;
|
|
2925
|
+
if (translator !== null && translateAvailability !== 'available')
|
|
2926
|
+
return;
|
|
2927
|
+
if (autoTranslatedRef.current === openEntryId)
|
|
2928
|
+
return;
|
|
2929
|
+
autoTranslatedRef.current = openEntryId;
|
|
2930
|
+
void startTranslation('both');
|
|
2931
|
+
}, [autoTranslate, view, openEntry, openEntryId, mediaFace.transcript, localActive, translationOffered, translatePhase, translator, translateAvailability, startTranslation]);
|
|
2403
2932
|
/** Submit the add form and report the host's verdict. */
|
|
2404
2933
|
const submit = useCallback(async () => {
|
|
2405
2934
|
const url = draftUrl.trim();
|
|
2406
|
-
|
|
2935
|
+
// The await below chains the resolver APIs, the fetch, and its fallback —
|
|
2936
|
+
// seconds to half a minute. Without the busy line (and this re-entry guard)
|
|
2937
|
+
// the dialog read as a dead click the whole time.
|
|
2938
|
+
if (url.length === 0 || addBusySince !== null)
|
|
2407
2939
|
return;
|
|
2408
2940
|
setVerdict(null);
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2941
|
+
setAddBusySince(Date.now());
|
|
2942
|
+
try {
|
|
2943
|
+
const result = await props.addSource(url);
|
|
2944
|
+
if (!result.ok) {
|
|
2945
|
+
// A transport-level failure: the host never answered, so the reason is
|
|
2946
|
+
// whatever the wire layer said.
|
|
2947
|
+
setVerdict({ kind: 'fetchFailed', reason: result.error.message });
|
|
2948
|
+
return;
|
|
2949
|
+
}
|
|
2950
|
+
const value = result.value;
|
|
2951
|
+
if (typeof value === 'string') {
|
|
2952
|
+
setVerdict({ kind: verdictForRefusal(value) });
|
|
2953
|
+
return;
|
|
2954
|
+
}
|
|
2955
|
+
if (value.outcome === 'fetch-failed') {
|
|
2956
|
+
// The fetch failed with a reason of its own ("connect ECONNREFUSED …",
|
|
2957
|
+
// "body over the seam's byte cap"). Showing it is the difference between
|
|
2958
|
+
// a retry and a report nobody can act on.
|
|
2959
|
+
setVerdict({ kind: 'fetchFailed', reason: value.reason });
|
|
2960
|
+
return;
|
|
2961
|
+
}
|
|
2962
|
+
if (value.failure !== undefined) {
|
|
2963
|
+
// Saved, but there is no body to preview. This is NOT a failure verdict:
|
|
2964
|
+
// the link is in the wall, and the sentence names the reason so the
|
|
2965
|
+
// reader knows whether it is worth opening in a browser. The URL rides
|
|
2966
|
+
// along: for the causes a real browser solves, the verdict itself offers
|
|
2967
|
+
// the way out (the moment "why" lands is the moment to act on it).
|
|
2968
|
+
setVerdict({
|
|
2969
|
+
kind: 'savedLinkNoPreview',
|
|
2970
|
+
label: value.label,
|
|
2971
|
+
reason: value.failure.message,
|
|
2972
|
+
code: value.failure.code,
|
|
2973
|
+
url,
|
|
2974
|
+
});
|
|
2975
|
+
setDraftUrl('');
|
|
2976
|
+
actions.refresh();
|
|
2977
|
+
return;
|
|
2978
|
+
}
|
|
2434
2979
|
setVerdict({
|
|
2435
|
-
kind: '
|
|
2980
|
+
kind: value.outcome === 'subscribed' ? 'subscribed' : 'savedLink',
|
|
2436
2981
|
label: value.label,
|
|
2437
|
-
reason: value.failure.message,
|
|
2438
|
-
code: value.failure.code,
|
|
2439
|
-
url,
|
|
2440
2982
|
});
|
|
2441
2983
|
setDraftUrl('');
|
|
2984
|
+
// Reload so the new entry is on the wall behind the dialog, not only in
|
|
2985
|
+
// the host's state.
|
|
2442
2986
|
actions.refresh();
|
|
2443
|
-
return;
|
|
2444
2987
|
}
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
setDraftUrl('');
|
|
2450
|
-
// Reload so the new entry is on the wall behind the dialog, not only in
|
|
2451
|
-
// the host's state.
|
|
2452
|
-
actions.refresh();
|
|
2453
|
-
}, [actions, draftUrl, props]);
|
|
2988
|
+
finally {
|
|
2989
|
+
setAddBusySince(null);
|
|
2990
|
+
}
|
|
2991
|
+
}, [actions, addBusySince, draftUrl, props]);
|
|
2454
2992
|
/** Re-fetch one source from the subscription page. */
|
|
2455
2993
|
const refreshOne = useCallback(async (id) => {
|
|
2456
2994
|
actions.setRefreshing(true);
|
|
@@ -2624,10 +3162,10 @@ export function ReaderPane(props) {
|
|
|
2624
3162
|
// stores the payload first, so leaving the page does not cancel it. It also
|
|
2625
3163
|
// re-reads this entry's state from the host when the fetch settles, so the
|
|
2626
3164
|
// pill flips without a full-wall poll here.
|
|
2627
|
-
const
|
|
3165
|
+
const wanted = await fetchBody(entry.id, entry.link);
|
|
2628
3166
|
// One gesture, the whole pipeline: a shell answer continues into the
|
|
2629
3167
|
// rendered fetch on its own (see maybeAutoRender).
|
|
2630
|
-
maybeAutoRender(entry.id, entry.link,
|
|
3168
|
+
maybeAutoRender(entry.id, entry.link, wanted);
|
|
2631
3169
|
}, [actions, fetchBody, maybeAutoRender]);
|
|
2632
3170
|
/** Reload the tags on one entry. */
|
|
2633
3171
|
const loadEntryTags = useCallback(async (entryId) => {
|
|
@@ -2721,19 +3259,6 @@ export function ReaderPane(props) {
|
|
|
2721
3259
|
setTagInputOpen(false);
|
|
2722
3260
|
await toggleTag(entryId, created.value.id, true);
|
|
2723
3261
|
}, [props, toggleTag]);
|
|
2724
|
-
/** Quote the open entry (or a selection) into the conversation draft. */
|
|
2725
|
-
const quote = useCallback((explicit) => {
|
|
2726
|
-
if (openEntry === undefined)
|
|
2727
|
-
return;
|
|
2728
|
-
const selection = explicit ?? window.getSelection()?.toString() ?? '';
|
|
2729
|
-
const source = presentation.get(openEntry.sourceId);
|
|
2730
|
-
const block = formatReaderRef(selection, provenanceOf(openEntry, {
|
|
2731
|
-
label: source?.label ?? openEntry.title,
|
|
2732
|
-
}));
|
|
2733
|
-
const next = mergedDraft(props.readDraft(), block);
|
|
2734
|
-
props.setDraft(next);
|
|
2735
|
-
setDraft(next);
|
|
2736
|
-
}, [openEntry, presentation, props]);
|
|
2737
3262
|
const copyLink = useCallback(async () => {
|
|
2738
3263
|
if (openEntry?.link === undefined)
|
|
2739
3264
|
return;
|
|
@@ -2755,6 +3280,115 @@ export function ReaderPane(props) {
|
|
|
2755
3280
|
props.openExternal(url);
|
|
2756
3281
|
}, [props]);
|
|
2757
3282
|
/* ------------------------------------------------------------------ render */
|
|
3283
|
+
/** The wall's translation switch (the globe in the wide header, a ⋯ row in the compact one). */
|
|
3284
|
+
const toggleWall = () => {
|
|
3285
|
+
setSortOpen(false);
|
|
3286
|
+
setFilterOpen(false);
|
|
3287
|
+
if (wallOn) {
|
|
3288
|
+
setWallOn(false);
|
|
3289
|
+
return;
|
|
3290
|
+
}
|
|
3291
|
+
wallCancelRef.current = { cancelled: false };
|
|
3292
|
+
setWallOn(true);
|
|
3293
|
+
// Cards already on screen are queued by the observer effect; a
|
|
3294
|
+
// wall that never scrolls still gets translated in one pass.
|
|
3295
|
+
void runWallPass();
|
|
3296
|
+
};
|
|
3297
|
+
/**
|
|
3298
|
+
* The wall's filter panel and sort menu. Built once and placed by whichever
|
|
3299
|
+
* header is showing: the wide header hangs each under its own button; the
|
|
3300
|
+
* compact header has no such buttons (sort lives in ⋯, the filter behind the
|
|
3301
|
+
* chip row's 「筛选」), so both hang under the compact header's right edge.
|
|
3302
|
+
*/
|
|
3303
|
+
const filterPanel = filterOpen && (_jsx("div", { className: css.filterPanel, onKeyDown: event => { if (event.key === 'Escape')
|
|
3304
|
+
setFilterOpen(false); }, children: filterPage === 'source' ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: css.filterPanelHead, children: [_jsx("button", { type: "button", className: css.filterBack, title: t('action.back'), onClick: () => { setFilterPage('root'); setSourceFilter(''); }, children: _jsx(IconChevronLeftOutlineMedium, { size: 12 }) }), _jsx("span", { className: css.filterPanelTitle, children: t('filter.bySource') }), _jsx("span", { className: css.filterCount, children: sources.length })] }), sources.length >= SOURCE_SEARCH_MIN && (_jsxs("div", { className: css.filterSearch, children: [glyph('search', 11), _jsx("input", { autoFocus: true, value: sourceFilter, placeholder: t('filter.searchSource'), onChange: event => setSourceFilter(event.target.value) }), sourceFilter.length > 0 && (_jsx("button", { type: "button", className: css.searchClear, title: t('action.clearSearch'), "aria-label": t('action.clearSearch'), onClick: () => { setSourceFilter(''); }, children: glyph('close', 13) }))] })), _jsxs("div", { className: css.filterList, children: [_jsxs("button", { type: "button", className: css.filterRow, onClick: () => { actions.setQuery(''); setFilterOpen(false); }, children: [_jsx("span", { className: css.filterCheck, children: activeSource === null ? '✓' : '' }), _jsx("span", { className: css.filterLabel, children: t('filter.all') }), _jsx("span", { className: css.filterCount, children: allEntries.length })] }), matchingSources.map(source => {
|
|
3305
|
+
const group = parsed[source.id];
|
|
3306
|
+
const broken = group?.error !== undefined || group?.incomplete === true;
|
|
3307
|
+
return (_jsxs("button", { type: "button", className: css.filterRow, title: source.url, onClick: () => { actions.setQuery(sourceQuery(source.id)); setFilterOpen(false); }, children: [_jsx("span", { className: css.filterCheck, children: activeSource === source.id ? '✓' : '' }), _jsx("span", { className: css.filterLabel, children: source.label }), broken
|
|
3308
|
+
? _jsx("span", { className: css.chipBroken, title: t('state.incomplete'), children: "!" })
|
|
3309
|
+
: _jsx("span", { className: css.filterCount, children: group?.entries.length ?? 0 })] }, source.id));
|
|
3310
|
+
}), matchingSources.length === 0 && (_jsx("div", { className: css.filterEmpty, children: t('filter.noSourceMatch') }))] })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: css.filterSection, children: t('filter.readState') }), _jsxs("button", { type: "button", className: css.filterRow, onClick: () => actions.toggleUnreadOnly(), children: [_jsx("span", { className: css.filterCheck, children: unreadOnly ? '✓' : '' }), _jsx("span", { className: css.filterLabel, children: t('filter.unreadOnly') })] }), _jsxs("button", { type: "button", className: css.filterRow, onClick: () => actions.toggleHideDupes(), children: [_jsx("span", { className: css.filterCheck, children: hideDupes ? '✓' : '' }), _jsx("span", { className: css.filterLabel, children: t('filter.hideDupes') })] }), _jsxs("button", { type: "button", className: css.filterRow, onClick: () => { setSourceFilter(''); setFilterPage('source'); }, children: [_jsx("span", { className: css.filterLabel, children: t('filter.bySource') }), _jsx("span", { className: css.filterValue, children: activeSourceLabel ?? t('filter.all') }), _jsx("span", { className: css.filterChevron, children: glyph('chevron', 12) })] }), _jsx("div", { className: css.filterSection, children: t('filter.byKind') }), _jsxs("button", { type: "button", className: css.filterRow, onClick: () => { actions.setQuery(''); setFilterOpen(false); }, children: [_jsx("span", { className: css.filterCheck, children: query.trim() === '' ? '✓' : '' }), _jsx("span", { className: css.filterLabel, children: t('filter.all') }), _jsx("span", { className: css.filterCount, children: allEntries.length })] }), READER_SOURCE_KINDS.map(kind => (_jsxs("button", { type: "button", className: css.filterRow, onClick: () => { actions.setQuery(kindQuery(kind)); setFilterOpen(false); }, children: [_jsx("span", { className: css.filterCheck, children: activeKind === kind ? '✓' : '' }), _jsx("span", { className: css.filterLabel, children: t(kind === 'link' ? 'sources.kindLink' : 'sources.kindRss') }), _jsx("span", { className: css.filterCount, children: kindCounts[kind] })] }, kind))), ['video', 'audio'].filter(kind => mediaCounts[kind] > 0).map(kind => (_jsxs("button", { type: "button", className: css.filterRow, onClick: () => { actions.setQuery(MEDIA_QUERIES[kind]); setFilterOpen(false); }, children: [_jsx("span", { className: css.filterCheck, children: query.trim() === MEDIA_QUERIES[kind] ? '✓' : '' }), _jsx("span", { className: css.filterLabel, children: t(kind === 'video' ? 'media.kindVideo' : 'media.kindAudio') }), _jsx("span", { className: css.filterCount, children: mediaCounts[kind] })] }, kind))), tags.length > 0 && _jsx("div", { className: css.filterSection, children: t('filter.byTag') }), tags.map(tag => (_jsxs("div", { className: css.filterTagRow, children: [_jsxs("button", { type: "button", className: css.filterRow, onClick: () => { actions.setQuery(tagQuery(tag.id)); setFilterOpen(false); }, children: [_jsx("span", { className: css.filterCheck, children: query.trim() === tagQuery(tag.id) ? '✓' : '' }), _jsx("span", { className: css.filterLabel, children: tag.name }), _jsx("span", { className: css.filterCount, children: tagCounts[tag.id] ?? 0 })] }), _jsx("button", { type: "button", className: css.filterTagDelete, title: t('filter.deleteTag'), "aria-label": `${t('filter.deleteTag')}: ${tag.name}`, onClick: () => { void removeTag(tag.id); }, children: "\u00D7" })] }, tag.id)))] })) }));
|
|
3311
|
+
const sortMenu = sortOpen && (_jsx("div", { className: css.menu, children: ['newest', 'oldest', 'source'].map(option => (_jsxs("button", { type: "button", onClick: () => { actions.setSort(option); setSortOpen(false); }, children: [_jsx("span", { children: t(`sort.${option}`) }), sort === option && _jsx("span", { className: css.check, children: "\u2713" })] }, option))) }));
|
|
3312
|
+
/**
|
|
3313
|
+
* The compact wall header (panel-responsive-spec P2): the pane's name with
|
|
3314
|
+
* its unread count, search and add always visible, and everything else —
|
|
3315
|
+
* refresh, sort, the wall's translation switch, history, subscriptions — in
|
|
3316
|
+
* one ⋯ menu. Sort and filter have no buttons of their own here, so their
|
|
3317
|
+
* popovers hang under the header's right edge.
|
|
3318
|
+
*/
|
|
3319
|
+
const searchVisible = searchOpen || query !== '';
|
|
3320
|
+
const compactActions = [
|
|
3321
|
+
{
|
|
3322
|
+
id: 'search', label: t('search.placeholder'), icon: glyph('search', 16), pinned: true, pressed: searchVisible,
|
|
3323
|
+
onSelect: () => { if (searchVisible) {
|
|
3324
|
+
setSearchOpen(false);
|
|
3325
|
+
actions.setQuery('');
|
|
3326
|
+
}
|
|
3327
|
+
else
|
|
3328
|
+
setSearchOpen(true); },
|
|
3329
|
+
},
|
|
3330
|
+
{ id: 'add', label: t('action.add'), icon: _jsx(IconPlusOutlineMedium, { size: 16 }), pinned: true, onSelect: () => { setAddOpen(true); setVerdict(null); } },
|
|
3331
|
+
{ id: 'refresh', label: t('action.refresh'), icon: _jsx(IconRefreshOutlineMedium, { size: 16 }), disabled: refreshing, onSelect: () => { void refreshAll(); } },
|
|
3332
|
+
{
|
|
3333
|
+
id: 'sort', label: `${t('sort.title')} · ${t(`sort.${sort}`)}`, icon: glyph('sort', 16),
|
|
3334
|
+
onSelect: () => { setFilterOpen(false); setSortOpen(open => !open); },
|
|
3335
|
+
},
|
|
3336
|
+
...(wallTranslateOffered
|
|
3337
|
+
? [{ id: 'translate', label: t(wallOn ? 'translate.wallOff' : 'action.translate'), icon: _jsx(IconGlobeOutlineMedium, { size: 16 }), onSelect: toggleWall }]
|
|
3338
|
+
: []),
|
|
3339
|
+
{ id: 'recent', label: t('action.recent'), icon: _jsx(IconClockOutlineMedium, { size: 16 }), onSelect: () => { actions.closeEntry(); actions.setView('recent'); } },
|
|
3340
|
+
{ id: 'manage', label: t('action.manage'), icon: _jsx(IconSettingsOutlineMedium, { size: 16 }), onSelect: () => { actions.closeEntry(); actions.setView('manage'); } },
|
|
3341
|
+
];
|
|
3342
|
+
const compactWallHeader = (_jsxs("div", { className: css.compactHead, children: [_jsx(PanelHeader, { title: t('tab.label'), meta: `${countUnread(displayRows)} ${t('foot.unread')}`, actions: compactActions, moreLabel: t('action.more') }), _jsxs("span", { className: css.compactPop, children: [filterPanel, sortMenu] })] }));
|
|
3343
|
+
/**
|
|
3344
|
+
* The compact wall's filter chips: the narrowings a reader reaches for most
|
|
3345
|
+
* — everything, unread, the two source kinds, the reader's own tags — one tap
|
|
3346
|
+
* each, and 「筛选」 for the rest (one source, hiding duplicates) in the same
|
|
3347
|
+
* panel the wide header opens. Every chip but 未读 sets the search query (the
|
|
3348
|
+
* wall's one narrowing channel, `#source` / `@tag` included), so tapping a
|
|
3349
|
+
* pressed chip clears it and 全部 clears everything.
|
|
3350
|
+
*/
|
|
3351
|
+
const wallChips = [
|
|
3352
|
+
{ id: 'all', label: t('filter.all'), count: allEntries.length },
|
|
3353
|
+
{ id: 'unread', label: t('chip.unread') },
|
|
3354
|
+
...READER_SOURCE_KINDS.map(kind => ({ id: `kind:${kind}`, label: t(kind === 'link' ? 'sources.kindLink' : 'sources.kindRss'), count: kindCounts[kind] })),
|
|
3355
|
+
...['video', 'audio'].filter(kind => mediaCounts[kind] > 0)
|
|
3356
|
+
.map(kind => ({ id: `media:${kind}`, label: t(kind === 'video' ? 'media.kindVideo' : 'media.kindAudio'), count: mediaCounts[kind] })),
|
|
3357
|
+
...tags.map(tag => ({ id: `tag:${tag.id}`, label: tag.name, count: tagCounts[tag.id] ?? 0 })),
|
|
3358
|
+
{ id: 'more', label: activeSource === null ? t('chip.more') : `${t('chip.more')} · ${activeSourceLabel ?? ''}` },
|
|
3359
|
+
];
|
|
3360
|
+
const wallChipValue = [
|
|
3361
|
+
...(query.trim() === '' && !unreadOnly ? ['all'] : []),
|
|
3362
|
+
...(unreadOnly ? ['unread'] : []),
|
|
3363
|
+
...(activeKind === null ? [] : [`kind:${activeKind}`]),
|
|
3364
|
+
...['video', 'audio'].filter(kind => query.trim() === MEDIA_QUERIES[kind]).map(kind => `media:${kind}`),
|
|
3365
|
+
...tags.filter(tag => query.trim() === tagQuery(tag.id)).map(tag => `tag:${tag.id}`),
|
|
3366
|
+
...(activeSource === null ? [] : ['more']),
|
|
3367
|
+
];
|
|
3368
|
+
const pickChip = (id) => {
|
|
3369
|
+
if (id === 'all') {
|
|
3370
|
+
actions.setQuery('');
|
|
3371
|
+
if (unreadOnly)
|
|
3372
|
+
actions.toggleUnreadOnly();
|
|
3373
|
+
return;
|
|
3374
|
+
}
|
|
3375
|
+
if (id === 'unread') {
|
|
3376
|
+
actions.toggleUnreadOnly();
|
|
3377
|
+
return;
|
|
3378
|
+
}
|
|
3379
|
+
if (id === 'more') {
|
|
3380
|
+
setSortOpen(false);
|
|
3381
|
+
if (!filterOpen) {
|
|
3382
|
+
setFilterPage('root');
|
|
3383
|
+
setSourceFilter('');
|
|
3384
|
+
}
|
|
3385
|
+
setFilterOpen(!filterOpen);
|
|
3386
|
+
return;
|
|
3387
|
+
}
|
|
3388
|
+
const next = id.startsWith('media:') ? MEDIA_QUERIES[id.slice('media:'.length)]
|
|
3389
|
+
: id.startsWith('kind:') ? kindQuery(id.slice('kind:'.length)) : tagQuery(id.slice('tag:'.length));
|
|
3390
|
+
actions.setQuery(query.trim() === next ? '' : next);
|
|
3391
|
+
};
|
|
2758
3392
|
/**
|
|
2759
3393
|
* The pane's header.
|
|
2760
3394
|
*
|
|
@@ -2772,26 +3406,7 @@ export function ReaderPane(props) {
|
|
|
2772
3406
|
setSourceFilter('');
|
|
2773
3407
|
}
|
|
2774
3408
|
setFilterOpen(!filterOpen);
|
|
2775
|
-
}, children: glyph('funnel', 15) }),
|
|
2776
|
-
setFilterOpen(false); }, children: filterPage === 'source' ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: css.filterPanelHead, children: [_jsx("button", { type: "button", className: css.filterBack, title: t('action.back'), onClick: () => { setFilterPage('root'); setSourceFilter(''); }, children: _jsx(IconChevronLeftOutlineMedium, { size: 12 }) }), _jsx("span", { className: css.filterPanelTitle, children: t('filter.bySource') }), _jsx("span", { className: css.filterCount, children: sources.length })] }), sources.length >= SOURCE_SEARCH_MIN && (_jsxs("div", { className: css.filterSearch, children: [glyph('search', 11), _jsx("input", { autoFocus: true, value: sourceFilter, placeholder: t('filter.searchSource'), onChange: event => setSourceFilter(event.target.value) }), sourceFilter.length > 0 && (_jsx("button", { type: "button", className: css.searchClear, title: t('action.clearSearch'), "aria-label": t('action.clearSearch'), onClick: () => { setSourceFilter(''); }, children: glyph('close', 13) }))] })), _jsxs("div", { className: css.filterList, children: [_jsxs("button", { type: "button", className: css.filterRow, onClick: () => { actions.setQuery(''); setFilterOpen(false); }, children: [_jsx("span", { className: css.filterCheck, children: activeSource === null ? '✓' : '' }), _jsx("span", { className: css.filterLabel, children: t('filter.all') }), _jsx("span", { className: css.filterCount, children: allEntries.length })] }), matchingSources.map(source => {
|
|
2777
|
-
const group = parsed[source.id];
|
|
2778
|
-
const broken = group?.error !== undefined || group?.incomplete === true;
|
|
2779
|
-
return (_jsxs("button", { type: "button", className: css.filterRow, title: source.url, onClick: () => { actions.setQuery(sourceQuery(source.id)); setFilterOpen(false); }, children: [_jsx("span", { className: css.filterCheck, children: activeSource === source.id ? '✓' : '' }), _jsx("span", { className: css.filterLabel, children: source.label }), broken
|
|
2780
|
-
? _jsx("span", { className: css.chipBroken, title: t('state.incomplete'), children: "!" })
|
|
2781
|
-
: _jsx("span", { className: css.filterCount, children: group?.entries.length ?? 0 })] }, source.id));
|
|
2782
|
-
}), matchingSources.length === 0 && (_jsx("div", { className: css.filterEmpty, children: t('filter.noSourceMatch') }))] })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: css.filterSection, children: t('filter.readState') }), _jsxs("button", { type: "button", className: css.filterRow, onClick: () => actions.toggleUnreadOnly(), children: [_jsx("span", { className: css.filterCheck, children: unreadOnly ? '✓' : '' }), _jsx("span", { className: css.filterLabel, children: t('filter.unreadOnly') })] }), _jsxs("button", { type: "button", className: css.filterRow, onClick: () => actions.toggleHideDupes(), children: [_jsx("span", { className: css.filterCheck, children: hideDupes ? '✓' : '' }), _jsx("span", { className: css.filterLabel, children: t('filter.hideDupes') })] }), _jsxs("button", { type: "button", className: css.filterRow, onClick: () => { setSourceFilter(''); setFilterPage('source'); }, children: [_jsx("span", { className: css.filterLabel, children: t('filter.bySource') }), _jsx("span", { className: css.filterValue, children: activeSourceLabel ?? t('filter.all') }), _jsx("span", { className: css.filterChevron, children: glyph('chevron', 12) })] }), _jsx("div", { className: css.filterSection, children: t('filter.byKind') }), _jsxs("button", { type: "button", className: css.filterRow, onClick: () => { actions.setQuery(''); setFilterOpen(false); }, children: [_jsx("span", { className: css.filterCheck, children: query.trim() === '' ? '✓' : '' }), _jsx("span", { className: css.filterLabel, children: t('filter.all') }), _jsx("span", { className: css.filterCount, children: allEntries.length })] }), READER_SOURCE_KINDS.map(kind => (_jsxs("button", { type: "button", className: css.filterRow, onClick: () => { actions.setQuery(kindQuery(kind)); setFilterOpen(false); }, children: [_jsx("span", { className: css.filterCheck, children: activeKind === kind ? '✓' : '' }), _jsx("span", { className: css.filterLabel, children: t(kind === 'link' ? 'sources.kindLink' : 'sources.kindRss') }), _jsx("span", { className: css.filterCount, children: kindCounts[kind] })] }, kind))), tags.length > 0 && _jsx("div", { className: css.filterSection, children: t('filter.byTag') }), tags.map(tag => (_jsxs("div", { className: css.filterTagRow, children: [_jsxs("button", { type: "button", className: css.filterRow, onClick: () => { actions.setQuery(tagQuery(tag.id)); setFilterOpen(false); }, children: [_jsx("span", { className: css.filterCheck, children: query.trim() === tagQuery(tag.id) ? '✓' : '' }), _jsx("span", { className: css.filterLabel, children: tag.name }), _jsx("span", { className: css.filterCount, children: tagCounts[tag.id] ?? 0 })] }), _jsx("button", { type: "button", className: css.filterTagDelete, title: t('filter.deleteTag'), "aria-label": `${t('filter.deleteTag')}: ${tag.name}`, onClick: () => { void removeTag(tag.id); }, children: "\u00D7" })] }, tag.id)))] })) }))] }), _jsxs("span", { className: css.toolWrap, children: [_jsx("button", { type: "button", className: `${css.tool} ${sort === 'newest' ? '' : css.toolOn}`, title: t('sort.title'), onClick: () => { setFilterOpen(false); setSortOpen(open => !open); }, children: glyph('sort', 15) }), sortOpen && (_jsx("div", { className: css.menu, children: ['newest', 'oldest', 'source'].map(option => (_jsxs("button", { type: "button", onClick: () => { actions.setSort(option); setSortOpen(false); }, children: [_jsx("span", { children: t(`sort.${option}`) }), sort === option && _jsx("span", { className: css.check, children: "\u2713" })] }, option))) }))] }), wallTranslateOffered && (_jsxs("span", { className: css.translateWrap, children: [_jsx("button", { type: "button", className: `${css.tool}${wallOn ? ` ${css.toolOn}` : ''}`, title: t('action.translate'), "aria-pressed": wallOn, onClick: () => {
|
|
2783
|
-
setSortOpen(false);
|
|
2784
|
-
setFilterOpen(false);
|
|
2785
|
-
if (wallOn) {
|
|
2786
|
-
setWallOn(false);
|
|
2787
|
-
return;
|
|
2788
|
-
}
|
|
2789
|
-
wallCancelRef.current = { cancelled: false };
|
|
2790
|
-
setWallOn(true);
|
|
2791
|
-
// Cards already on screen are queued by the observer effect; a
|
|
2792
|
-
// wall that never scrolls still gets translated in one pass.
|
|
2793
|
-
void runWallPass();
|
|
2794
|
-
}, children: _jsx(IconGlobeOutlineMedium, { size: 15 }) }), wallOn && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: css.translateCaret, "aria-expanded": wallMenu, title: t('translate.view'), onClick: () => { setWallMenu(open => !open); }, children: _jsx(IconChevronDownOutlineMedium, { size: 10 }) }), wallMenu && (_jsxs("div", { className: css.translateMenu, role: "menu", children: [_jsx("div", { className: css.translateMenuHead, children: t('translate.local') }), _jsxs("button", { type: "button", role: "menuitemradio", "aria-checked": !wallBoth, className: css.translateMenuItem, onClick: () => { setWallMenu(false); setWallBoth(false); }, children: [_jsx("span", { className: css.translateCheck, children: !wallBoth ? '✓' : '' }), _jsx("span", { className: css.translateMenuLabel, children: t('translate.onlyTranslation') })] }), _jsxs("button", { type: "button", role: "menuitemradio", "aria-checked": wallBoth, className: css.translateMenuItem, onClick: () => { setWallMenu(false); setWallBoth(true); }, children: [_jsx("span", { className: css.translateCheck, children: wallBoth ? '✓' : '' }), _jsx("span", { className: css.translateMenuLabel, children: t('translate.bilingual') })] })] }))] }))] })), _jsx("button", { type: "button", className: css.tool, title: t('action.recent'), onClick: () => { actions.closeEntry(); actions.setView('recent'); }, children: _jsx(IconClockOutlineMedium, { size: 15 }) }), _jsx("button", { type: "button", className: css.tool, title: t('action.manage'), onClick: () => { actions.closeEntry(); actions.setView('manage'); }, children: _jsx(IconSettingsOutlineMedium, { size: 15 }) }), _jsx("button", { type: "button", className: css.tool, title: t('action.add'), onClick: () => { setAddOpen(true); setVerdict(null); }, children: _jsx(IconPlusOutlineMedium, { size: 15 }) })] }));
|
|
3409
|
+
}, children: glyph('funnel', 15) }), filterPanel] }), _jsxs("span", { className: css.toolWrap, children: [_jsx("button", { type: "button", className: `${css.tool} ${sort === 'newest' ? '' : css.toolOn}`, title: t('sort.title'), onClick: () => { setFilterOpen(false); setSortOpen(open => !open); }, children: glyph('sort', 15) }), sortMenu] }), wallTranslateOffered && (_jsxs("span", { className: css.translateWrap, children: [_jsx("button", { type: "button", className: `${css.tool}${wallOn ? ` ${css.toolOn}` : ''}`, title: t('action.translate'), "aria-pressed": wallOn, onClick: toggleWall, children: _jsx(IconGlobeOutlineMedium, { size: 15 }) }), wallOn && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: css.translateCaret, "aria-expanded": wallMenu, title: t('translate.view'), onClick: () => { setWallMenu(open => !open); }, children: _jsx(IconChevronDownOutlineMedium, { size: 10 }) }), wallMenu && (_jsxs("div", { className: css.translateMenu, role: "menu", children: [_jsx("div", { className: css.translateMenuHead, children: t('translate.local') }), _jsxs("button", { type: "button", role: "menuitemradio", "aria-checked": !wallBoth, className: css.translateMenuItem, onClick: () => { setWallMenu(false); setWallBoth(false); }, children: [_jsx("span", { className: css.translateCheck, children: !wallBoth ? '✓' : '' }), _jsx("span", { className: css.translateMenuLabel, children: t('translate.onlyTranslation') })] }), _jsxs("button", { type: "button", role: "menuitemradio", "aria-checked": wallBoth, className: css.translateMenuItem, onClick: () => { setWallMenu(false); setWallBoth(true); }, children: [_jsx("span", { className: css.translateCheck, children: wallBoth ? '✓' : '' }), _jsx("span", { className: css.translateMenuLabel, children: t('translate.bilingual') })] })] }))] }))] })), _jsx("button", { type: "button", className: css.tool, title: t('action.recent'), onClick: () => { actions.closeEntry(); actions.setView('recent'); }, children: _jsx(IconClockOutlineMedium, { size: 15 }) }), _jsx("button", { type: "button", className: css.tool, title: t('action.manage'), onClick: () => { actions.closeEntry(); actions.setView('manage'); }, children: _jsx(IconSettingsOutlineMedium, { size: 15 }) }), _jsx("button", { type: "button", className: css.tool, title: t('action.add'), onClick: () => { setAddOpen(true); setVerdict(null); }, children: _jsx(IconPlusOutlineMedium, { size: 15 }) })] }));
|
|
2795
3410
|
/** Load the states once the wall knows which entries it is showing. */
|
|
2796
3411
|
useEffect(() => { void refreshFetchStates(); }, [refreshFetchStates, rev]);
|
|
2797
3412
|
/**
|
|
@@ -2819,12 +3434,12 @@ export function ReaderPane(props) {
|
|
|
2819
3434
|
/** The add dialog: overlay + centered card, Esc and overlay-click close. */
|
|
2820
3435
|
const dialog = addOpen && (_jsx("div", { className: css.overlay, role: "presentation", onClick: event => { if (event.target === event.currentTarget)
|
|
2821
3436
|
setAddOpen(false); }, children: _jsxs("div", { className: css.dialog, role: "dialog", "aria-modal": "true", "aria-label": t('add.title'), onKeyDown: event => { if (event.key === 'Escape')
|
|
2822
|
-
setAddOpen(false); }, children: [_jsx("h2", { children: t('add.title') }), _jsx("p", { children: t('add.help') }), _jsx("input", { autoFocus: true, value: draftUrl, placeholder: t('add.placeholder'), onChange: event => setDraftUrl(event.target.value), onKeyDown: event => { if (event.key === 'Enter')
|
|
2823
|
-
void submit(); } }), verdict !== null && (_jsxs("div", { className: css.verdict, children: [_jsx("b", { children: t(VERDICT_KEY[verdict.kind], verdict.kind === 'savedLinkNoPreview'
|
|
3437
|
+
setAddOpen(false); }, children: [_jsx("h2", { children: t('add.title') }), _jsx("p", { children: t('add.help') }), _jsx("input", { autoFocus: true, value: draftUrl, placeholder: t('add.placeholder'), disabled: addBusySince !== null, onChange: event => setDraftUrl(event.target.value), onKeyDown: event => { if (event.key === 'Enter')
|
|
3438
|
+
void submit(); } }), addBusySince !== null && _jsx(BusyLine, { t: t, label: t('add.busy'), since: addBusySince }), verdict !== null && (_jsxs("div", { className: css.verdict, children: [_jsx("b", { children: t(VERDICT_KEY[verdict.kind], verdict.kind === 'savedLinkNoPreview'
|
|
2824
3439
|
? { label: verdict.label ?? '', reason: previewReason(t, verdict.code, verdict.reason ?? '') }
|
|
2825
|
-
: verdict.label === undefined ? {} : { label: verdict.label }) }), verdict.reason !== undefined && verdict.kind === 'fetchFailed' && (_jsx("span", { className: css.verdictReason, children: verdict.reason })), verdict.url !== undefined && verdict.code !== undefined && OPEN_IN_BROWSER_CODES.has(verdict.code) && (_jsxs(_Fragment, { children: [' ', _jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { openElsewhere(verdict.url); }, children: t('action.openExternal') })] }))] })), _jsxs("div", { className: css.dialogActions, children: [_jsx("button", { type: "button", className: css.ghost, onClick: () => setAddOpen(false), children: verdict !== null && (verdict.kind === 'subscribed' || verdict.kind === 'savedLink' || verdict.kind === 'savedLinkNoPreview')
|
|
3440
|
+
: verdict.label === undefined ? {} : { label: verdict.label }) }), verdict.reason !== undefined && verdict.kind === 'fetchFailed' && (_jsx("span", { className: css.verdictReason, children: verdict.reason })), verdict.code === 'blocked' && captureRemote !== undefined && (_jsx("span", { className: css.verdictReason, children: t('verdict.willRender') })), verdict.url !== undefined && verdict.code !== undefined && OPEN_IN_BROWSER_CODES.has(verdict.code) && (_jsxs(_Fragment, { children: [' ', _jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { openElsewhere(verdict.url); }, children: t('action.openExternal') })] }))] })), _jsxs("div", { className: css.dialogActions, children: [_jsx("button", { type: "button", className: css.ghost, onClick: () => setAddOpen(false), children: verdict !== null && (verdict.kind === 'subscribed' || verdict.kind === 'savedLink' || verdict.kind === 'savedLinkNoPreview')
|
|
2826
3441
|
? t('action.done')
|
|
2827
|
-
: t('action.cancel') }), _jsx("button", { type: "button", className: css.submit, onClick: () => { void submit(); }, children: t('action.submit') })] })] }) }));
|
|
3442
|
+
: t('action.cancel') }), _jsx("button", { type: "button", className: css.submit, disabled: addBusySince !== null || draftUrl.trim() === '', onClick: () => { void submit(); }, children: addBusySince !== null ? t('add.fetching') : t('action.submit') })] })] }) }));
|
|
2828
3443
|
/* ------------------------------------------------------------- detail view */
|
|
2829
3444
|
if (view === 'detail' && (openEntry !== undefined || openEntryId !== null)) {
|
|
2830
3445
|
/**
|
|
@@ -2839,9 +3454,25 @@ export function ReaderPane(props) {
|
|
|
2839
3454
|
if (openEntry === undefined) {
|
|
2840
3455
|
return (_jsxs("div", { className: css.root, children: [_jsxs("div", { className: css.bar, children: [_jsx("button", { type: "button", className: css.tool, title: t('action.back'), onClick: () => { actions.setView('list'); actions.closeEntry(); }, children: _jsx(IconChevronLeftOutlineMedium, { size: 14 }) }), _jsx("span", { className: css.barLabel, children: t('tab.label') }), _jsx("span", { className: css.spacer })] }), _jsx("div", { className: css.detailBody, children: _jsx("p", { className: css.incomplete, children: t('state.loading') }) })] }));
|
|
2841
3456
|
}
|
|
3457
|
+
/**
|
|
3458
|
+
* The title follows the body's translation: 译文 shows it translated,
|
|
3459
|
+
* 对照 translated with the original beneath, 原文 (or no translation) as
|
|
3460
|
+
* the source wrote it. The header bar carries the shown one.
|
|
3461
|
+
*/
|
|
3462
|
+
const translatedTitle = titleTranslation?.id === openEntry.id && translateView !== 'orig'
|
|
3463
|
+
&& (translatePhase === 'ready' || translatePhase === 'working')
|
|
3464
|
+
? titleTranslation.text
|
|
3465
|
+
: undefined;
|
|
3466
|
+
const shownTitle = translatedTitle ?? openEntry.title;
|
|
2842
3467
|
const source = presentation.get(openEntry.sourceId);
|
|
2843
3468
|
/** The host's own row for this entry's source: the failure code lives here. */
|
|
2844
3469
|
const sourceSummary = sources.find(item => item.id === openEntry.sourceId);
|
|
3470
|
+
/**
|
|
3471
|
+
* The plain fetch met a wall a headless browser gets past, and the
|
|
3472
|
+
* rendered fetch is mounted: the error line is that render's slot.
|
|
3473
|
+
*/
|
|
3474
|
+
const wallRenderable = captureRemote !== undefined && openEntry.link !== undefined && articleError !== null
|
|
3475
|
+
&& (browserSolvable(articleError) || sourceSummary?.failure?.code === 'blocked' || captureFailure?.entryId === openEntry.id);
|
|
2845
3476
|
const when = relativeWhen(openEntry.publishedAt, new Date());
|
|
2846
3477
|
const date = absoluteDate(openEntry.publishedAt);
|
|
2847
3478
|
const alsoFrom = allEntries
|
|
@@ -2853,33 +3484,126 @@ export function ReaderPane(props) {
|
|
|
2853
3484
|
: translateProgress !== null && translateProgress.total > 0
|
|
2854
3485
|
? Math.round((translateProgress.done / translateProgress.total) * 100)
|
|
2855
3486
|
: 0;
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
3487
|
+
/*
|
|
3488
|
+
* The globe is a switch, not a verb: lit means this body is showing a
|
|
3489
|
+
* translation, dark means it is showing the original — and the caret holds
|
|
3490
|
+
* the view (translation only / side by side / original). Everything is
|
|
3491
|
+
* hidden when the browser has no Translator API (and no stored translation
|
|
3492
|
+
* of this body), the pair is unavailable, or the body is already Chinese:
|
|
3493
|
+
* an offer that cannot work is worse than no offer.
|
|
3494
|
+
*/
|
|
3495
|
+
const detailTranslate = translationOffered && localActive ? (
|
|
3496
|
+
// A transcript still arriving is rewritten every poll: a translation
|
|
3497
|
+
// would be wiped, so the globe waits for the whole transcript.
|
|
3498
|
+
_jsx("span", { className: css.translateWrap, children: _jsx("button", { type: "button", className: css.tool, disabled: true, "aria-disabled": "true", title: t('media.translateAfter'), children: _jsx(IconGlobeOutlineMedium, { size: 15 }) }) })) : translationOffered && (_jsxs("span", { className: css.translateWrap, children: [_jsx("button", { type: "button", className: `${css.tool}${translatePhase === 'pack' || translatePhase === 'working' ? ` ${css.toolSpinning}` : ''}${translatePhase === 'ready' && translateView !== 'orig' ? ` ${css.toolOn}` : ''}`, title: t('action.translate'), "aria-pressed": translatePhase === 'ready' && translateView !== 'orig', onClick: toggleGlobe, children: _jsx(IconGlobeOutlineMedium, { size: 15 }) }), _jsx("button", { type: "button", className: css.translateCaret, "aria-expanded": translateMenu, title: t('translate.view'), onClick: () => { setTranslateMenu(open => !open); }, children: _jsx(IconChevronDownOutlineMedium, { size: 10 }) }), translateMenu && (_jsxs("div", { className: css.translateMenu, role: "menu", children: [_jsx("div", { className: css.translateMenuHead, children: t(translator === null ? 'translate.stored' : 'translate.local') }), TRANSLATION_VIEWS.map(item => (_jsxs("button", { type: "button", role: "menuitemradio", "aria-checked": translateView === item.view, className: css.translateMenuItem, onClick: () => { pickView(item.view); }, children: [_jsx("span", { className: css.translateCheck, children: translateView === item.view ? '✓' : '' }), _jsx("span", { className: css.translateMenuLabel, children: t(item.key) })] }, item.view))), _jsxs("button", { type: "button", role: "menuitem", className: css.translateMenuItem, onClick: () => { setTranslateMenu(false); requestPolish(); }, children: [_jsx("span", { className: css.translateCheck }), _jsx("span", { className: css.translateMenuLabel, children: t('polish.action') })] }), openEntry.media !== undefined && (_jsxs("button", { type: "button", role: "menuitemcheckbox", "aria-checked": autoTranslate, className: css.translateMenuItem, onClick: () => { setAutoTranslatePref(!autoTranslate); }, children: [_jsx("span", { className: css.translateCheck, children: autoTranslate ? '✓' : '' }), _jsx("span", { className: css.translateMenuLabel, children: t('media.autoTranslate') })] })), translator !== null && (translatePhase === 'ready' || translatePhase === 'failed') && (_jsxs("button", { type: "button", role: "menuitem", className: css.translateMenuItem, onClick: () => {
|
|
3499
|
+
setTranslateMenu(false);
|
|
3500
|
+
if (builtRef.current !== null)
|
|
3501
|
+
restoreArticle(builtRef.current.root);
|
|
3502
|
+
builtRef.current = null;
|
|
3503
|
+
void startTranslation(lastViewRef.current);
|
|
3504
|
+
}, children: [_jsx("span", { className: css.translateCheck }), _jsx("span", { className: css.translateMenuLabel, children: t('translate.retry') })] }))] }))] }));
|
|
3505
|
+
/** The detail's ordinary actions: inline in the wide bar, in ⋯ on a compact one. */
|
|
3506
|
+
/** A foreign body the globe does not offer (no on-device translator): the agent needs none. */
|
|
3507
|
+
const polishOutsideGlobe = !translationOffered && articleHtml !== null && articleHtml.length > 0 && !isTargetLanguage(articleHtml.slice(0, 2000));
|
|
3508
|
+
const detailActions = [
|
|
3509
|
+
// Also here, for a body the globe does not offer (no on-device translator): the agent needs none.
|
|
3510
|
+
...(polishOutsideGlobe
|
|
3511
|
+
? [{ id: 'polish', label: t('polish.action'), icon: _jsx(IconGlobeOutlineMedium, { size: 16 }), menuOnly: true, onSelect: () => { requestPolish(); } }]
|
|
3512
|
+
: []),
|
|
3513
|
+
{ id: 'copy', label: t('action.copyLink'), icon: _jsx(IconCopyOutlineMedium, { size: 16 }), menuOnly: true, onSelect: () => { void copyLink(); } },
|
|
3514
|
+
{
|
|
3515
|
+
id: 'open', label: t('action.openExternal'), icon: _jsx(IconRightUpOutlineMedium, { size: 16 }), menuOnly: true,
|
|
3516
|
+
onSelect: () => { if (openEntry.link !== undefined)
|
|
3517
|
+
props.openExternal(openEntry.link); },
|
|
3518
|
+
},
|
|
3519
|
+
...(sourceSummary?.kind === 'link'
|
|
3520
|
+
? [{ id: 'remove', label: t('detail.removeLink'), icon: _jsx(IconTrashOutlineMedium, { size: 16 }), menuOnly: true, danger: true, onSelect: () => { void removeLink(openEntry.sourceId); } }]
|
|
3521
|
+
: []),
|
|
3522
|
+
];
|
|
3523
|
+
return (
|
|
3524
|
+
// data-quote-source: a passage quoted from here names this article, not
|
|
3525
|
+
// the conversation the pane happens to sit beside (the quote plugin
|
|
3526
|
+
// reads it; without that plugin it is inert).
|
|
3527
|
+
_jsxs("div", { className: css.root, "data-quote-source": shownTitle, children: [compact ? (
|
|
3528
|
+
// No title at first: the article's own heading sits right below with
|
|
3529
|
+
// its source and date in the kicker, so a title here would say it
|
|
3530
|
+
// twice. Once that heading scrolls away, the bar takes the title.
|
|
3531
|
+
_jsx(PanelHeader, { className: css.compactBar, title: titleScrolledAway ? _jsx("span", { className: css.compactBarTitle, children: shownTitle }) : '', onBack: () => { actions.setView('list'); actions.closeEntry(); }, backLabel: t('action.back'), extra: detailTranslate, actions: detailActions, moreLabel: t('action.more') })) : _jsxs("div", { className: css.bar, children: [_jsx("button", { type: "button", className: css.tool, title: t('action.back'), onClick: () => { actions.setView('list'); actions.closeEntry(); }, children: _jsx(IconChevronLeftOutlineMedium, { size: 14 }) }), _jsx("span", { className: css.barLabel, title: translatedTitle === undefined ? undefined : openEntry.title, children: shownTitle }), _jsx("span", { className: css.spacer }), detailTranslate, polishOutsideGlobe && (_jsx("button", { type: "button", className: css.tool, title: t('polish.action'), onClick: () => { requestPolish(); }, children: _jsx(IconGlobeOutlineMedium, { size: 15 }) })), _jsx("button", { type: "button", className: css.tool, title: t('action.copyLink'), onClick: () => { void copyLink(); }, children: _jsx(IconCopyOutlineMedium, { size: 15 }) }), _jsx("button", { type: "button", className: css.tool, title: t('action.openExternal'), onClick: () => { if (openEntry.link !== undefined)
|
|
3532
|
+
props.openExternal(openEntry.link); }, children: _jsx(IconRightUpOutlineMedium, { size: 15 }) }), sourceSummary?.kind === 'link' && (_jsx("button", { type: "button", className: `${css.tool} ${css.toolDanger}`, title: t('detail.removeLink'), onClick: () => { void removeLink(openEntry.sourceId); }, children: _jsx(IconTrashOutlineMedium, { size: 15 }) }))] }), mediaFace.media !== undefined && (_jsx(MediaPlayer, { ref: mediaFace.playerRef, media: mediaFace.media, title: openEntry.title, startAt: mediaFace.startAt, onTime: mediaFace.onTime, mini: mediaFace.mini, onMini: mediaFace.setMini, labels: {
|
|
3533
|
+
collapse: t('media.collapse'), expand: t('media.expand'), noFollow: t('media.noFollow'),
|
|
3534
|
+
play: t('media.play'), pause: t('media.pause'), startedAt: t('media.startedAt'),
|
|
3535
|
+
}, leading: (mediaFace.transcript === 'ready' || mediaFace.transcript === 'loading' || localActive || (mediaFace.transcript === 'none' && mediaFace.hidden)) && (_jsxs("label", { className: css.mediaSwitch, children: [_jsx("input", { type: "checkbox", checked: !mediaFace.hidden, onChange: event => {
|
|
3536
|
+
mediaFace.setHidden(!event.target.checked);
|
|
3537
|
+
// Back on with nothing to read: transcribe again.
|
|
3538
|
+
if (event.target.checked && mediaFace.transcript === 'none' && !localActive)
|
|
3539
|
+
localFace.retry();
|
|
3540
|
+
} }), t('media.captions')] })), trailing: localFace.status !== undefined && localActive
|
|
3541
|
+
? (_jsxs(_Fragment, { children: [_jsxs("span", { className: css.mediaSource, children: [localFace.status.state === 'fetching' ? t('media.localFetching')
|
|
3542
|
+
: localFace.status.state === 'queued' ? (localFace.status.ahead !== undefined && localFace.status.ahead > 0 ? t('media.localQueuedAhead', { count: localFace.status.ahead }) : t('media.localQueued'))
|
|
3543
|
+
: t('media.localRunning', { percent: Math.round((localFace.status.progress ?? 0) * 100) }), localFace.etaSeconds !== undefined && ` · ${t('media.localEta', { minutes: Math.max(1, Math.round(localFace.etaSeconds / 60)) })}`, ' ', _jsx("button", { type: "button", className: css.mediaLink, onClick: localFace.stop, children: t('media.localStop') })] }), _jsxs("label", { className: css.mediaSwitch, children: [_jsx("input", { type: "checkbox", checked: autoTranslate, onChange: event => { setAutoTranslatePref(event.target.checked); } }), t('media.autoTranslate')] })] }))
|
|
3544
|
+
: (_jsx("span", { className: css.mediaSource, children: mediaFace.transcript === 'ready' ? (articleHtml?.includes('data-src="local"') === true
|
|
3545
|
+
? localFace.status?.state === 'speakers'
|
|
3546
|
+
? _jsxs(_Fragment, { children: [t('media.sourceLocal'), " \u00B7 ", t('media.speakersRunning')] })
|
|
3547
|
+
: _jsxs(_Fragment, { children: [t('media.sourceLocal'), " \u00B7 ", _jsx("button", { type: "button", className: css.mediaLink, onClick: localFace.retry, children: t('media.localRetry') })] })
|
|
3548
|
+
: t('media.sourcePlatform'))
|
|
3549
|
+
: mediaFace.transcript === 'loading' ? t('media.loading')
|
|
3550
|
+
: mediaFace.transcript === 'failed' ? t('media.failed')
|
|
3551
|
+
: mediaFace.transcript === 'none' && !mediaFace.hidden ? (localFace.status?.state === 'unavailable' ? (mediaFace.media.kind === 'audio' ? t('media.noCaptionsAudio') : t('media.noCaptions'))
|
|
3552
|
+
: localFace.status?.state === 'failed' ? (_jsxs(_Fragment, { children: [t('media.localFailed', { error: localFace.status.error ?? '' }), " ", _jsx("button", { type: "button", className: css.mediaLink, onClick: localFace.retry, children: t('media.localRetry') })] }))
|
|
3553
|
+
: localFace.status?.state === 'idle' ? (_jsxs(_Fragment, { children: [t('media.localStopped'), " ", _jsx("button", { type: "button", className: css.mediaLink, onClick: localFace.retry, children: t('media.localRetry') })] })) : '') : '' })) }, openEntry.id)), mediaFace.media !== undefined && hasSpeakers && !mediaFace.hidden && !mediaFace.mini && (_jsx(SpeakerLegend, { count: speakerCount, label: speakerLabel, onRename: renameSpeaker, hint: t('media.speakerHint'), renameTitle: t('media.speakerRename') })), mediaFace.media !== undefined && localFace.status?.state === 'needs-models' && !mediaFace.hidden && (_jsxs("div", { className: css.mediaConfirm, children: [_jsx("span", { children: t('media.localModels', { size: Math.max(1, Math.round((localFace.status.bytes ?? 0) / 1e6)) }) }), _jsx("button", { type: "button", className: css.mediaConfirmButton, onClick: localFace.confirm, children: t('media.localModelsGo') })] })), !(mediaFace.media !== undefined && mediaFace.hidden && mediaFace.transcript === 'ready') && _jsx(UnderlineTabs, { className: css.detailTabs, label: t('notes.tabsLabel'), value: notesFace.tab, onChange: notesFace.setTab,
|
|
3554
|
+
// 考考我 is always in reach, not only at the end of a long body.
|
|
3555
|
+
trailing: articleHtml !== null && articleHtml.length > 0 && !localActive && (_jsx("button", { type: "button", className: css.checkChip, onClick: notesFace.check, title: t('notes.check'), children: t('notes.checkShort') })), tabs: [
|
|
3556
|
+
{ id: 'article', label: mediaFace.media === undefined ? t('notes.tabArticle') : t('media.tabTranscript') },
|
|
3557
|
+
{ id: 'notes', label: t('notes.tabNotes'), ...(notesFace.count > 0 ? { badge: notesFace.count } : {}) },
|
|
3558
|
+
] }), mediaFace.media !== undefined && mediaFace.hidden && mediaFace.transcript === 'ready' && _jsx("div", { className: css.mediaFill }), notesFace.tab === 'notes' && !(mediaFace.media !== undefined && mediaFace.hidden && mediaFace.transcript === 'ready') && (_jsx(NotesView, { t: t, entry: notesFace.entry, missing: notesFace.missing, sessionTitle: notesFace.sessionTitle, canOpenSession: notesFace.canOpenSession, onJump: notesFace.jump, onEdit: notesFace.edit, onDelete: notesFace.remove, onDiscuss: notesFace.discuss, onOpenSession: notesFace.openSession, onAddReply: notesFace.addReply, onEditReply: notesFace.editReply, onDeleteDiscussion: notesFace.deleteDiscussion, onJumpPara: notesFace.jumpPara, onCheck: notesFace.check, sync: notesFace.sync, ...(mediaFace.media === undefined ? {} : {
|
|
3559
|
+
onPlay: (note) => {
|
|
3560
|
+
notesFace.jump(note);
|
|
3561
|
+
if (note.anchor.time !== undefined)
|
|
3562
|
+
mediaFace.seek(note.anchor.time);
|
|
3563
|
+
},
|
|
3564
|
+
}) })), _jsxs("div", { className: css.detailBody, ref: detailRef, onScroll: onDetailScroll,
|
|
3565
|
+
// Hidden, not unmounted: the article's segmentation, its translation
|
|
3566
|
+
// and the reading position all live on this subtree. The 「字幕」
|
|
3567
|
+
// switch hides it the same way (just the video, no transcript).
|
|
3568
|
+
hidden: notesFace.tab === 'notes' || (mediaFace.media !== undefined && mediaFace.hidden && mediaFace.transcript === 'ready'), children: [_jsxs("div", { className: css.kicker, children: [_jsx("span", { className: css.tile, style: { background: source?.hue }, children: source?.tile }), _jsx("span", { children: source?.label }), _jsx("span", { className: css.sep, children: "\u00B7" }), _jsx("span", { children: date ?? t(when.key, when.count === undefined ? {} : { count: when.count }) }), openEntry.author !== undefined && (_jsxs(_Fragment, { children: [_jsx("span", { className: css.sep, children: "\u00B7" }), _jsx("span", { children: openEntry.author })] })), sourceSummary?.resolvedFrom !== undefined && (_jsxs(_Fragment, { children: [_jsx("span", { className: css.sep, children: "\u00B7" }), _jsx("button", { type: "button", className: css.incompleteLink, title: t('detail.resolvedFrom', { url: sourceSummary.resolvedFrom }), onClick: () => { props.openExternal(sourceSummary.resolvedFrom); }, children: sourceSummary.resolvedFrom.replace(/^https?:\/\//, '') })] }))] }), _jsxs("h1", { ref: detailTitleRef, className: css.detailTitle, children: [shownTitle, translatedTitle !== undefined && translateView === 'both' && (_jsx("span", { className: css.detailTitleOrig, children: openEntry.title }))] }), ((openEntry.tags ?? []).length > 0 || openEntry.link !== undefined) && (_jsxs("div", { className: css.tagRow, children: [(openEntry.tags ?? []).map(tag => _jsx("span", { className: css.tag, children: tag }, tag)), openEntry.link !== undefined && openEntry.media === undefined && (_jsxs("button", { type: "button", className: css.refetch,
|
|
2864
3569
|
// A rendered entry's「重新抓取」re-renders through capture —
|
|
2865
3570
|
// captureBusy is that in-flight state, or the button looks
|
|
2866
3571
|
// dead for minutes.
|
|
2867
|
-
disabled: bodyFetching || captureBusy, title: t('detail.refetchTitle'), onClick: () => { void refetchEntry(openEntry); }, children: [glyph('fetch', 11), _jsx("span", { children: bodyFetching || captureBusy ? t('detail.refetching') : t('detail.refetch') })] }))] })), _jsx("div", { className: css.rule }), (
|
|
3572
|
+
disabled: bodyFetching || captureBusy, title: t('detail.refetchTitle'), onClick: () => { void refetchEntry(openEntry); }, children: [glyph('fetch', 11), _jsx("span", { children: bodyFetching || captureBusy ? t('detail.refetching') : t('detail.refetch') })] }))] })), _jsx("div", { className: css.rule }), mediaFace.media !== undefined && mediaFace.hintOwed && articleHtml?.includes('data-t=') === true && (_jsxs("div", { className: css.timeHint, children: [_jsx("span", { children: t(mediaFace.media.kind === 'video' ? 'media.timeHint' : 'media.timeHintAudio') }), _jsx("button", { type: "button", className: css.mediaLink, onClick: mediaFace.dismissHint, children: t('media.timeHintOk') })] })), polishFace.view !== undefined ? (_jsxs("div", { className: `${css.polishStatus} ${polishFace.view.complete ? css.polishDone : ''}`, children: [_jsx("span", { className: css.polishText, children: polishFace.view.complete ? t('polish.done', { done: polishFace.view.done })
|
|
3573
|
+
: polishFace.stalled ? t('polish.stalled', { done: polishFace.view.done, total: polishFace.view.total })
|
|
3574
|
+
: t('polish.running', { done: polishFace.view.done, total: polishFace.view.total }) }), !polishFace.view.complete && !polishFace.stalled && (_jsx("span", { className: css.translateBar, children: _jsx("i", { style: { width: `${String(Math.round((polishFace.view.done / Math.max(1, polishFace.view.total)) * 100))}%` } }) })), polishFace.stalled && (() => {
|
|
3575
|
+
const view = polishFace.view;
|
|
3576
|
+
let from = 1;
|
|
3577
|
+
while (from <= view.total && view.paragraphs[String(from)] !== undefined)
|
|
3578
|
+
from += 1;
|
|
3579
|
+
return (_jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { requestPolish(from); }, children: t('polish.continue', { count: view.total - view.done }) }));
|
|
3580
|
+
})(), _jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { polishFace.setHidden(!polishFace.hidden); }, children: polishFace.hidden ? t('polish.showPolish') : t('polish.showMachine') })] })) : polishFace.awaiting && (_jsx("div", { className: css.polishStatus, children: _jsx("span", { className: css.polishText, children: t('polish.awaiting') }) })), (translatePhase === 'pack' || translatePhase === 'working') && (_jsxs("div", { className: css.translateStatus, children: [_jsx("span", { children: translatePhase === 'pack'
|
|
2868
3581
|
? t('translate.preparing')
|
|
2869
|
-
: _jsxs(_Fragment, { children: [_jsx("b", { children: t('translate.working') }), translateProgress !== null ? ` ${translateProgress.done}/${translateProgress.total}` : ''] }) }), _jsx("span", { className: css.translateBar, children: _jsx("i", { style: { width: `${String(statusPercent)}%` } }) }), _jsx("button", { type: "button", className: css.incompleteLink, onClick: cancelTranslation, children: t('action.cancel') })] })), translatePhase === 'failed' && translateError !== null && (_jsxs("div", { className: css.translateStatus, "data-failed": "true", children: [translateError, _jsx("button", { type: "button", className: css.incompleteLink, onClick: cancelTranslation, children: t('action.done') })] })), translatePhase === 'ready' && !tipDismissed && (_jsxs("div", { className: css.translateTip, children: [t('translate.tip'), _jsx("button", { type: "button", title: t('translate.dismiss'), "aria-label": t('translate.dismiss'), onClick: () => { setTipDismissed(true); }, children: "\u00D7" })] })), bodyFetching && (_jsx(
|
|
2870
|
-
?
|
|
2871
|
-
: (_jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { void captureBody(openEntry.id, openEntry.link); }, children: t('detail.renderFetch') }))), ' ', openEntry.
|
|
3582
|
+
: _jsxs(_Fragment, { children: [_jsx("b", { children: t('translate.working') }), translateProgress !== null ? ` ${translateProgress.done}/${translateProgress.total}` : ''] }) }), _jsx("span", { className: css.translateBar, children: _jsx("i", { style: { width: `${String(statusPercent)}%` } }) }), _jsx("button", { type: "button", className: css.incompleteLink, onClick: cancelTranslation, children: t('action.cancel') })] })), translatePhase === 'failed' && translateError !== null && (_jsxs("div", { className: css.translateStatus, "data-failed": "true", children: [translateError, _jsx("button", { type: "button", className: css.incompleteLink, onClick: cancelTranslation, children: t('action.done') })] })), translatePhase === 'ready' && !tipDismissed && (_jsxs("div", { className: css.translateTip, children: [t('translate.tip'), _jsx("button", { type: "button", title: t('translate.dismiss'), "aria-label": t('translate.dismiss'), onClick: () => { setTipDismissed(true); }, children: "\u00D7" })] })), bodyFetching && (_jsx(BusyLine, { t: t, label: bodyFetch?.entryId === openEntryId ? t(`detail.phase.${bodyFetch.phase}`) : t('detail.fetchingBody'), ...(bodyFetch?.entryId === openEntryId ? { since: bodyFetch.since } : {}) })), scriptFigures > 0 && (_jsxs("p", { className: css.incomplete, children: [t('detail.scriptFigures', { count: scriptFigures }), ' ', captureRemote !== undefined && openEntry.link !== undefined && (captureBusy
|
|
3583
|
+
? _jsxs("span", { className: css.renderingLine, children: [_jsx("span", { className: css.renderingRing, "aria-hidden": "true" }), t('detail.rendering')] })
|
|
3584
|
+
: (_jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { void captureBody(openEntry.id, openEntry.link); }, children: t('detail.renderFetch') }))), ' ', !captureBusy && captureFailure?.entryId === openEntry.id && (_jsxs("span", { children: [t('detail.renderFailed', { reason: captureFailure.message }), ' '] })), openEntry.link !== undefined && (_jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { openElsewhere(openEntry.link); }, children: browserTab ? t('action.openExternal') : t('detail.readOriginal') }))] })), articleError !== null && (wallRenderable && captureBusy
|
|
3585
|
+
? (
|
|
3586
|
+
// The open gesture already escalated: while the headless render
|
|
3587
|
+
// runs, the line says THAT (and how long it takes) instead of
|
|
3588
|
+
// leading with a refusal the reader cannot tell is being handled.
|
|
3589
|
+
_jsx("p", { className: css.incomplete, children: _jsxs("span", { className: css.renderingLine, children: [_jsx("span", { className: css.renderingRing, "aria-hidden": "true" }), t('detail.renderingWall')] }) }))
|
|
3590
|
+
: (_jsxs("p", { className: css.incomplete, children: [previewReason(t, sourceSummary?.failure?.code, articleError), captureFailure?.entryId === openEntry.id && (_jsxs(_Fragment, { children: [' ', t('detail.renderFailed', { reason: captureFailure.message })] })), wallRenderable && (_jsxs(_Fragment, { children: [' ', _jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { void captureBody(openEntry.id, openEntry.link); }, children: t('detail.renderFetch') })] })), openEntry.link !== undefined && (_jsxs(_Fragment, { children: [' ', _jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { openElsewhere(openEntry.link); }, children: t('detail.readOriginal') })] }))] }))), openEntry.summaryOnly === true && openEntry.media === undefined && articleHtml !== null && articleHtml === openEntry.contentHtml && (_jsx("p", { className: css.incomplete, children: t('detail.summaryOnly') })), articleHtml !== null && articleHtml.length > 0 && (_jsx("div", {
|
|
2872
3591
|
// The markup is the output of this package's own whitelist
|
|
2873
|
-
// normalizer (`extract-article`): scripts, styles, forms
|
|
2874
|
-
//
|
|
2875
|
-
//
|
|
2876
|
-
//
|
|
3592
|
+
// normalizer (`extract-article`): scripts, styles, forms and
|
|
3593
|
+
// event attributes are already gone, every URL that survives has
|
|
3594
|
+
// been scheme-checked, and a kept embed is a sandboxed
|
|
3595
|
+
// opaque-origin iframe or a controls-only media element.
|
|
3596
|
+
// Re-sanitizing here would mean a second implementation of the
|
|
3597
|
+
// same policy.
|
|
2877
3598
|
//
|
|
2878
3599
|
// `translate.ts` decorates THIS subtree in place when the reader
|
|
2879
3600
|
// asks for a translation (text nodes only), so the click handler
|
|
2880
3601
|
// is delegation: the sentences it creates are not React's.
|
|
2881
|
-
ref: articleRef, onClick: onArticleClick, onMouseOver: onArticleHover, onMouseOut: onArticleHover,
|
|
2882
|
-
|
|
3602
|
+
ref: articleRef, onClick: onArticleClick, onMouseOver: onArticleHover, onMouseOut: onArticleHover,
|
|
3603
|
+
// Image error events do not bubble; the capture phase is the
|
|
3604
|
+
// only way one handler sees every failed figure in the body.
|
|
3605
|
+
onErrorCapture: onArticleImageError, className: `${css.article} ${isCjk(articleHtml) ? css.articleZh : css.articleEn}`, dangerouslySetInnerHTML: { __html: articleHtml } })), articleHtml !== null && articleHtml.length > 0 && (articleTruncated || openEntry.truncated === true) && (_jsxs("p", { className: css.incomplete, children: [t('detail.incomplete'), " \u2014", ' ', _jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { if (openEntry.link !== undefined)
|
|
3606
|
+
props.openExternal(openEntry.link); }, children: t('detail.readOriginal') })] })), articleHtml !== null && articleHtml.length > 0 && (_jsxs("div", { className: css.checkEnd, children: [_jsx("span", { className: css.checkLead, children: t('notes.checkLead') }), _jsxs("button", { type: "button", className: css.checkLink, onClick: notesFace.check, children: [t('notes.check'), " \u2192"] })] })), _jsxs("div", { className: css.tagEdit, children: [_jsx("span", { className: css.tagEditLabel, children: t('tag.title') }), tags.filter(tag => (entryTagIds[openEntry.id] ?? []).includes(tag.id)).map(tag => (_jsxs("span", { className: css.tagChip, children: [tag.name, _jsx("button", { type: "button", className: css.tagChipRemove, title: t('action.remove'), onClick: () => { void toggleTag(openEntry.id, tag.id, false); }, children: "\u00D7" })] }, tag.id))), tagInputOpen
|
|
2883
3607
|
? (_jsxs("div", { className: css.tagEditField, children: [_jsx("input", { autoFocus: true, className: css.tagInput, value: tagDraft, placeholder: t('tag.placeholder'), onChange: event => setTagDraft(event.target.value),
|
|
2884
3608
|
// A suggestion's mousedown is suppressed, so this fires only
|
|
2885
3609
|
// when focus really left the editor.
|
|
@@ -2913,13 +3637,7 @@ export function ReaderPane(props) {
|
|
|
2913
3637
|
sourceKind: source?.kind ?? 'rss',
|
|
2914
3638
|
unread: read[entry.id] !== true,
|
|
2915
3639
|
});
|
|
2916
|
-
}, children: [_jsx("span", { className: css.alsoFromTitle, children: entry.title }), _jsx("span", { className: css.when, children: whenLabel(t, entry.publishedAt) })] }, entry.id)))] }))
|
|
2917
|
-
void props.quoteToSideChat({
|
|
2918
|
-
contextKey: sessionId,
|
|
2919
|
-
label: openEntry.title,
|
|
2920
|
-
text: formatReaderRef('', provenanceOf(openEntry, { label: source?.label ?? '' })),
|
|
2921
|
-
});
|
|
2922
|
-
}, children: t('quote.toSideChat') }))] })] }));
|
|
3640
|
+
}, children: [_jsx("span", { className: css.alsoFromTitle, children: entry.title }), _jsx("span", { className: css.when, children: whenLabel(t, entry.publishedAt) })] }, entry.id)))] })), mediaFace.followPaused && (_jsx("div", { className: css.followDock, children: _jsxs("button", { type: "button", className: css.followButton, onClick: mediaFace.resumeFollow, children: ["\u2193 ", t('media.backToNow')] }) }))] }), notesFace.sheet !== null && (_jsx(NoteSheet, { t: t, selection: notesFace.sheet, saving: notesFace.saving, failed: notesFace.failed, onSave: notesFace.saveNote, onCancel: notesFace.cancelSheet }))] }));
|
|
2923
3641
|
}
|
|
2924
3642
|
/* ------------------------------------------------------ subscriptions page */
|
|
2925
3643
|
if (view === 'recent') {
|
|
@@ -2980,7 +3698,7 @@ export function ReaderPane(props) {
|
|
|
2980
3698
|
}) }))] }), dialog] }));
|
|
2981
3699
|
}
|
|
2982
3700
|
/* ------------------------------------------------------------------- wall */
|
|
2983
|
-
return (_jsxs("div", { className: css.root, children: [header(false), _jsx("div", { className: css.tools, children: _jsxs("div", { className: css.search, children: [glyph('search', 12), _jsx("input", { value: query, placeholder: t('search.placeholder'), onChange: event => actions.setQuery(event.target.value) }), query.length > 0 && (_jsx("button", { type: "button", className: css.searchClear, title: t('action.clearSearch'), "aria-label": t('action.clearSearch'), onClick: () => { actions.setQuery(''); }, children: glyph('close', 15) }))] }) }), wallProgress !== null && (_jsxs("div", { className: css.translateStatus, children: [_jsxs("span", { children: [_jsx("b", { children: t('translate.working') }), ` ${wallProgress.done}/${wallProgress.total}`] }), _jsx("span", { className: css.translateBar, children: _jsx("i", { style: { width: `${wallProgress.total > 0 ? Math.round((wallProgress.done / wallProgress.total) * 100) : 0}%` } }) }), _jsx("button", { type: "button", className: css.incompleteLink, onClick: () => {
|
|
3701
|
+
return (_jsxs("div", { className: css.root, children: [compact ? compactWallHeader : header(false), (!compact || searchVisible) && _jsx("div", { className: css.tools, children: _jsxs("div", { className: css.search, children: [glyph('search', 12), _jsx("input", { autoFocus: compact && searchOpen, value: query, placeholder: t('search.placeholder'), onChange: event => actions.setQuery(event.target.value) }), query.length > 0 && (_jsx("button", { type: "button", className: css.searchClear, title: t('action.clearSearch'), "aria-label": t('action.clearSearch'), onClick: () => { actions.setQuery(''); }, children: glyph('close', 15) }))] }) }), compact && sources.length > 0 && (_jsx(ChipRow, { className: css.chips, label: t('action.filter'), chips: wallChips, value: wallChipValue, onSelect: pickChip })), wallProgress !== null && (_jsxs("div", { className: css.translateStatus, children: [_jsxs("span", { children: [_jsx("b", { children: t('translate.working') }), ` ${wallProgress.done}/${wallProgress.total}`] }), _jsx("span", { className: css.translateBar, children: _jsx("i", { style: { width: `${wallProgress.total > 0 ? Math.round((wallProgress.done / wallProgress.total) * 100) : 0}%` } }) }), _jsx("button", { type: "button", className: css.incompleteLink, onClick: () => {
|
|
2984
3702
|
if (wallCancelRef.current !== null)
|
|
2985
3703
|
wallCancelRef.current.cancelled = true;
|
|
2986
3704
|
wallPendingRef.current.clear();
|
|
@@ -2991,7 +3709,7 @@ export function ReaderPane(props) {
|
|
|
2991
3709
|
_jsxs("button", { type: "button", className: css.emptyCard, onClick: () => { setAddOpen(true); setVerdict(null); }, children: [_jsx("span", { className: css.emptyTitle, children: t('state.emptyTitle') }), _jsx("span", { className: css.emptyBody, children: t('state.emptyBody') })] })), !loading && sources.length > 0 && brokenSources.length > 0 && (
|
|
2992
3710
|
// "Why is this list short?" answered where the reader is looking,
|
|
2993
3711
|
// once per affected source, with the way to the full text.
|
|
2994
|
-
|
|
3712
|
+
_jsxs("div", { className: css.notice, children: [brokenSources.length > 1 && !noticeOpen ? (_jsxs("div", { className: css.noticeRow, children: [_jsx("span", { className: css.noticeText, children: t('state.incompleteCount', { count: String(brokenSources.length) }) }), _jsx("button", { type: "button", className: css.noticeLink, onClick: () => { setNoticeOpen(true); }, children: t('action.showList') })] })) : brokenSources.map(source => (_jsxs("div", { className: css.noticeRow, children: [_jsxs("span", { className: css.noticeText, children: [t('state.incomplete'), " \u00B7 ", source.label, parsed[source.id]?.incomplete === true && _jsxs(_Fragment, { children: [" \u2014 ", t('state.incompleteReason')] })] }), _jsx("button", { type: "button", className: css.noticeLink, onClick: () => props.openExternal(source.url), children: t('detail.readOriginal') })] }, source.id))), brokenSources.length > 1 && noticeOpen && (_jsx("button", { type: "button", className: `${css.noticeLink} ${css.noticeFold}`, onClick: () => { setNoticeOpen(false); }, children: t('action.foldList') }))] })), !loading && sources.length > 0 && displayRows.length === 0 && (_jsx("div", { className: css.state, children: query.trim().length === 0 ? t('state.emptyWall') : t('state.noMatch', { query }) })), !loading && displayRows.length > 0 && (_jsx("div", { className: css.list, children: displayRows.map(row => {
|
|
2995
3713
|
// The card's text box is FIXED (two clamped lines per field, see
|
|
2996
3714
|
// the CSS), because Chinese and English wrap differently: swapping
|
|
2997
3715
|
// languages must not resize the card or shift the grid.
|
|
@@ -3015,6 +3733,12 @@ export function ReaderPane(props) {
|
|
|
3015
3733
|
// The duplicates this card stands for, when the wall's dedupe
|
|
3016
3734
|
// folded them behind it (a badge, never a deletion).
|
|
3017
3735
|
const dupes = dupesBy.get(row.entry.id) ?? [];
|
|
3736
|
+
// A video / audio card with a cover: the cover takes the summary's
|
|
3737
|
+
// place beside the title, so the card stays as tall as an article's.
|
|
3738
|
+
const media = row.entry.media;
|
|
3739
|
+
// YouTube's 16:9 still (mqdefault) beats a feed's 4:3 one with bars.
|
|
3740
|
+
const thumb = media?.provider === 'youtube' ? `https://i.ytimg.com/vi/${media.ref}/mqdefault.jpg`
|
|
3741
|
+
: media?.thumbnail === undefined ? undefined : media.thumbnail.replace(/^http:\/\//, 'https://');
|
|
3018
3742
|
// Only a field that actually has a translation can be peeked: an
|
|
3019
3743
|
// untranslated (or Chinese) card keeps its ordinary hover.
|
|
3020
3744
|
const peekable = (field) => wallOn && !wallBoth && card?.[field] !== undefined;
|
|
@@ -3024,7 +3748,7 @@ export function ReaderPane(props) {
|
|
|
3024
3748
|
onMouseLeave: () => { setHoverField(current => (current?.id === row.entry.id && current.field === field ? null : current)); },
|
|
3025
3749
|
}
|
|
3026
3750
|
: {};
|
|
3027
|
-
return (_jsxs("div", { className: css.card, "data-reader-entry": row.entry.id, "data-translated": wallOn && card !== undefined ? '1' : undefined, "data-unread": row.unread || undefined, role: "button", tabIndex: 0, onClick: () => { if (cardMenu !== null || cardTag !== null) {
|
|
3751
|
+
return (_jsxs("div", { className: css.card, "data-reader-entry": row.entry.id, "data-translated": wallOn && card !== undefined ? '1' : undefined, "data-unread": row.unread || undefined, "data-thumb": thumb === undefined ? undefined : media?.kind, role: "button", tabIndex: 0, onClick: () => { if (cardMenu !== null || cardTag !== null) {
|
|
3028
3752
|
setCardMenu(null);
|
|
3029
3753
|
setCardTag(null);
|
|
3030
3754
|
return;
|
|
@@ -3035,8 +3759,25 @@ export function ReaderPane(props) {
|
|
|
3035
3759
|
if (row.entry.link === undefined)
|
|
3036
3760
|
return;
|
|
3037
3761
|
event.preventDefault();
|
|
3038
|
-
setCardMenu({ entryId: row.entry.id, top: event.clientY, left: event.clientX });
|
|
3039
|
-
}, children: [_jsxs("span", { className: css.tileWrap, children: [_jsx("span", { className: css.tile, style: { background: row.sourceHue }, children: row.sourceTile }), row.unread && _jsx("span", { className: css.unread })] }), _jsxs("span", { className: css.meta, children: [_jsx("span", { className: css.source, children: row.sourceLabel }), _jsx("span", { className: css.sep, children: "\u00B7" }), _jsx("span", { className: css.when, children: whenLabel(t, row.entry.publishedAt) })] }), _jsx("span", { className: css.title, ...hoverProps('title'), children: titleText }), originalTitle !== null && _jsx("span", { className: css.cardOrigTitle, children: originalTitle }),
|
|
3762
|
+
setCardMenu({ entryId: row.entry.id, top: Math.max(8, Math.min(event.clientY, window.innerHeight - CARD_MENU_HEIGHT - 8)), left: Math.max(8, Math.min(event.clientX, window.innerWidth - CARD_MENU_WIDTH - 8)) });
|
|
3763
|
+
}, children: [_jsxs("span", { className: css.tileWrap, children: [_jsx("span", { className: css.tile, style: { background: row.sourceHue }, children: row.sourceTile }), row.unread && _jsx("span", { className: css.unread })] }), _jsxs("span", { className: css.meta, children: [row.unread && _jsx("span", { className: css.metaDot }), _jsx("span", { className: css.source, children: row.sourceLabel }), _jsx("span", { className: css.sep, children: "\u00B7" }), _jsx("span", { className: css.when, children: whenLabel(t, row.entry.publishedAt) })] }), _jsx("span", { className: css.title, ...hoverProps('title'), children: titleText }), originalTitle !== null && _jsx("span", { className: css.cardOrigTitle, children: originalTitle }), thumb !== undefined && media !== undefined && (_jsxs("span", { className: css.cardThumb, "data-kind": media.kind, children: [_jsx("img", { src: thumb, alt: "", loading: "lazy", referrerPolicy: "no-referrer", onError: event => { event.currentTarget.style.visibility = 'hidden'; } }), media.kind === 'video' ? _jsx("span", { className: css.cardThumbPlay }) : _jsx("span", { className: css.cardThumbGlyph, children: "\u266A" }), media.durationSec !== undefined && _jsx("span", { className: css.cardThumbTime, children: formatClock(media.durationSec) })] })), thumb !== undefined
|
|
3764
|
+
? _jsx("span", { className: css.cardThumbSpacer })
|
|
3765
|
+
: summary !== undefined && _jsx("span", { className: css.summary, ...hoverProps('summary'), children: summaryText }), originalSummary !== null && _jsx("span", { className: css.cardOrigSummary, children: originalSummary }), _jsxs("span", { className: css.tags, children: [(row.entry.tags ?? []).slice(0, 2).map(tag => _jsx("span", { className: css.tag, children: tag }, tag)), row.entry.author !== undefined && _jsx("span", { className: css.author, children: row.entry.author }), backfilled[row.entry.id] === true && (_jsx("span", { className: css.tagOwned, title: t('detail.filledIn'), children: t('detail.filledInBadge') })), media !== undefined && thumb === undefined && (_jsxs("span", { className: css.tagMedia, children: [media.kind === 'video' ? '▶ ' : '♪ ', t(media.kind === 'video' ? 'media.kindVideo' : 'media.kindAudio'), media.durationSec === undefined ? '' : ` · ${formatClock(media.durationSec)}`] })), media !== undefined && (() => {
|
|
3766
|
+
// The transcript pill: where this card's transcript stands.
|
|
3767
|
+
const status = mediaCards?.states[row.entry.id];
|
|
3768
|
+
const engine = mediaCards?.engine === true;
|
|
3769
|
+
const state = status?.state ?? (media.kind === 'audio' ? 'none' : undefined);
|
|
3770
|
+
if (state === undefined)
|
|
3771
|
+
return null;
|
|
3772
|
+
const label = state === 'platform' ? t('media.cardPlatform')
|
|
3773
|
+
: state === 'local' ? t('media.cardLocal')
|
|
3774
|
+
: state === 'queued' ? (status?.ahead !== undefined && status.ahead > 0 ? t('media.cardQueuedAhead', { count: status.ahead + 1 }) : t('media.cardQueued'))
|
|
3775
|
+
: state === 'fetching' || state === 'transcribing' ? t('media.cardRunning', { percent: Math.round((status?.progress ?? 0) * 100) })
|
|
3776
|
+
: state === 'failed' ? t('media.cardFailed')
|
|
3777
|
+
: engine ? t('media.cardNoneAuto') : t('media.cardNone');
|
|
3778
|
+
const tone = state === 'platform' || state === 'local' ? 'ready' : state === 'failed' ? 'failed' : state === 'none' ? 'none' : 'fetching';
|
|
3779
|
+
return _jsx("span", { className: `${css.mediaPill} ${css[`fetchPill_${tone}`] ?? ''}`, children: label });
|
|
3780
|
+
})(), linkOnly && (_jsx("span", { className: css.tagLinkOnly, title: linkOnlyReason, children: t('detail.linkOnlyBadge') })), dupes.length > 0 && (_jsx("span", { className: css.tagOwned, title: t('dedupe.title', { sources: dupes.map(dupe => dupe.sourceLabel).join('、') }), children: t('dedupe.badge', { count: dupes.length }) })), (entryTagIds[row.entry.id] ?? []).map(tagId => (_jsx("span", { className: css.tagOwned, children: tags.find(tag => tag.id === tagId)?.name ?? tagId }, tagId))), row.entry.link !== undefined && row.entry.media === undefined && (() => {
|
|
3040
3781
|
const fetchState = fetchStateOf(fetchStates, row.entry.id);
|
|
3041
3782
|
const state = fetchState.state;
|
|
3042
3783
|
const labelKey = state === 'none' ? 'fetch.none'
|