@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.
Files changed (89) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.en.md +26 -121
  3. package/README.i18n.yaml +3 -3
  4. package/README.md +26 -121
  5. package/cordis.patch.yml +7 -0
  6. package/lib/client.js +30580 -6892
  7. package/lib/index.js +4189 -621
  8. package/lib/typert.host.js +2265 -319
  9. package/lib/typert.remote-client.d.ts +99 -3
  10. package/lib/typert.remote-client.js +1618 -240
  11. package/lib/types/captions.d.ts +99 -0
  12. package/lib/types/captions.js +236 -0
  13. package/lib/types/client/MediaPlayer.d.ts +55 -0
  14. package/lib/types/client/MediaPlayer.js +106 -0
  15. package/lib/types/client/NotesView.d.ts +57 -0
  16. package/lib/types/client/NotesView.js +124 -0
  17. package/lib/types/client/ReaderPane.d.ts +20 -3
  18. package/lib/types/client/ReaderPane.js +905 -164
  19. package/lib/types/client/SpeakerLegend.d.ts +18 -0
  20. package/lib/types/client/SpeakerLegend.js +26 -0
  21. package/lib/types/client/canvas-sync.d.ts +84 -0
  22. package/lib/types/client/canvas-sync.js +59 -0
  23. package/lib/types/client/contract.d.ts +138 -3
  24. package/lib/types/client/extract-article.d.ts +38 -1
  25. package/lib/types/client/extract-article.js +501 -28
  26. package/lib/types/client/icons.d.ts +1 -1
  27. package/lib/types/client/icons.js +1 -1
  28. package/lib/types/client/index.js +82 -2
  29. package/lib/types/client/locales.d.ts +279 -11
  30. package/lib/types/client/locales.js +278 -10
  31. package/lib/types/client/notes.d.ts +142 -0
  32. package/lib/types/client/notes.js +334 -0
  33. package/lib/types/client/parse-rss.js +40 -0
  34. package/lib/types/client/preset-visibility.d.ts +93 -0
  35. package/lib/types/client/preset-visibility.js +115 -0
  36. package/lib/types/client/selectors.d.ts +5 -0
  37. package/lib/types/client/selectors.js +5 -0
  38. package/lib/types/client/session.d.ts +21 -1
  39. package/lib/types/client/session.js +12 -0
  40. package/lib/types/client/store.js +4 -1
  41. package/lib/types/client/translate.d.ts +26 -1
  42. package/lib/types/client/translate.js +29 -12
  43. package/lib/types/client/useLocalTranscript.d.ts +43 -0
  44. package/lib/types/client/useLocalTranscript.js +129 -0
  45. package/lib/types/client/useMedia.d.ts +68 -0
  46. package/lib/types/client/useMedia.js +299 -0
  47. package/lib/types/client/useMediaStates.d.ts +9 -0
  48. package/lib/types/client/useMediaStates.js +43 -0
  49. package/lib/types/client/useNotes.d.ts +110 -0
  50. package/lib/types/client/useNotes.js +423 -0
  51. package/lib/types/client/usePolish.d.ts +47 -0
  52. package/lib/types/client/usePolish.js +162 -0
  53. package/lib/types/image-fetch.d.ts +81 -0
  54. package/lib/types/image-fetch.js +385 -0
  55. package/lib/types/index.d.ts +6 -0
  56. package/lib/types/index.js +3 -0
  57. package/lib/types/local-transcribe.d.ts +171 -0
  58. package/lib/types/local-transcribe.js +473 -0
  59. package/lib/types/media-host.d.ts +91 -0
  60. package/lib/types/media-host.js +230 -0
  61. package/lib/types/media.d.ts +100 -0
  62. package/lib/types/media.js +207 -0
  63. package/lib/types/notes-types.d.ts +158 -0
  64. package/lib/types/notes-types.js +8 -0
  65. package/lib/types/notes.d.ts +213 -0
  66. package/lib/types/notes.js +718 -0
  67. package/lib/types/polish.d.ts +41 -0
  68. package/lib/types/polish.js +136 -0
  69. package/lib/types/remote.d.ts +127 -1
  70. package/lib/types/remote.js +162 -1
  71. package/lib/types/service.d.ts +100 -1
  72. package/lib/types/service.js +320 -59
  73. package/lib/types/skill.d.ts +20 -0
  74. package/lib/types/skill.js +78 -0
  75. package/lib/types/store.js +30 -8
  76. package/lib/types/tool.d.ts +23 -0
  77. package/lib/types/tool.js +40 -0
  78. package/lib/types/tools.d.ts +69 -0
  79. package/lib/types/tools.js +400 -0
  80. package/lib/types/types.d.ts +106 -0
  81. package/lib/types/types.js +66 -0
  82. package/lib/types/xiaoyuzhou.d.ts +43 -0
  83. package/lib/types/xiaoyuzhou.js +134 -0
  84. package/lib/types/ytdlp.d.ts +111 -0
  85. package/lib/types/ytdlp.js +269 -0
  86. package/package.json +29 -20
  87. package/lib/invariant.js +0 -38
  88. package/lib/types/invariant.d.ts +0 -29
  89. 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), and it is why the pane
15
- * never hosts a sandboxed iframe.
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 { kindQuery, linkEntryId, READER_SOURCE_KINDS, } from "../types.js";
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, formatReaderRef, mergedDraft, provenanceOf, relativeWhen } from "./quote.js";
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.tagRow, "data-on": on || undefined,
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
- /** The reader tab body. */
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
- const { sessionId, useStore, actions, t } = props;
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
- /** True while a capture-rendered refetch of the open entry is in flight. */
571
- const [captureBusy, setCaptureBusy] = useState(false);
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
- actions.setLoading(true);
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
- const known = listed.value.sources;
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
- const wanted = known.filter(source => source.hasBody || source.kind === 'link');
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
- actions.setParsed({ id: source.id, entries: [], error: body.error ?? t('detail.extractFailed') });
887
+ put({ id: source.id, entries: [], error: body.error ?? t('detail.extractFailed') });
760
888
  }
761
889
  else {
762
- actions.setParsed({
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
- actions.setParsed(result.ok
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
- actions.setParsed({
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, the pair is unavailable on this device,
1181
- * or the body is already the target language. All three hide the control
1182
- * rather than offering one that cannot work — the pass's own degradation rule.
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 = translator !== null
1185
- && translateAvailability !== null
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 (api === null || container === null)
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
- persistTranslations(pair, result.learned, result.recalled, openEntryId !== null && articleHtml !== null
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
- if (!isTargetLanguage(row.entry.title))
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
- return undefined;
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
- setCaptureBusy(true);
2271
+ setCaptureBusyId(entryId);
2272
+ setCaptureFailure(null);
1821
2273
  try {
1822
2274
  const rendered = await capture.render({ url: link });
1823
2275
  if (!rendered.ok) {
1824
- if (openRequestRef.current === entryId)
1825
- actions.setArticle(articleHtml ?? '', false, rendered.error.message);
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
- if (openRequestRef.current === entryId)
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
- setCaptureBusy(false);
2322
+ setCaptureBusyId(current => (current === entryId ? null : current));
1863
2323
  }
1864
- }, [actions, props, articleHtml, syncFetchState]);
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 continues into the rendered fetch on its own —
1869
- * the gesture already authorized capturing that URL. Fires once per entry
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, count) => {
1874
- if (link === undefined || count <= 0)
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(count => maybeAutoRender(entryId, link, count ?? 0));
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
- if (cachedTranslator(sources, TARGET_CANDIDATES) === undefined)
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
- if (url.length === 0)
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
- const result = await props.addSource(url);
2410
- if (!result.ok) {
2411
- // A transport-level failure: the host never answered, so the reason is
2412
- // whatever the wire layer said.
2413
- setVerdict({ kind: 'fetchFailed', reason: result.error.message });
2414
- return;
2415
- }
2416
- const value = result.value;
2417
- if (typeof value === 'string') {
2418
- setVerdict({ kind: verdictForRefusal(value) });
2419
- return;
2420
- }
2421
- if (value.outcome === 'fetch-failed') {
2422
- // The fetch failed with a reason of its own ("connect ECONNREFUSED …",
2423
- // "body over the seam's byte cap"). Showing it is the difference between
2424
- // a retry and a report nobody can act on.
2425
- setVerdict({ kind: 'fetchFailed', reason: value.reason });
2426
- return;
2427
- }
2428
- if (value.failure !== undefined) {
2429
- // Saved, but there is no body to preview. This is NOT a failure verdict:
2430
- // the link is in the wall, and the sentence names the reason so the
2431
- // reader knows whether it is worth opening in a browser. The URL rides
2432
- // along: for the causes a real browser solves, the verdict itself offers
2433
- // the way out (the moment "why" lands is the moment to act on it).
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: 'savedLinkNoPreview',
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
- setVerdict({
2446
- kind: value.outcome === 'subscribed' ? 'subscribed' : 'savedLink',
2447
- label: value.label,
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 count = await fetchBody(entry.id, entry.link);
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, count ?? 0);
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) }), filterOpen && (_jsx("div", { className: css.filterPanel, onKeyDown: event => { if (event.key === 'Escape')
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
- 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: openEntry.title }), _jsx("span", { className: css.spacer }), 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('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))), (translatePhase === 'ready' || translatePhase === 'failed') && (_jsxs("button", { type: "button", role: "menuitem", className: css.translateMenuItem, onClick: () => {
2857
- setTranslateMenu(false);
2858
- if (builtRef.current !== null)
2859
- restoreArticle(builtRef.current.root);
2860
- builtRef.current = null;
2861
- void startTranslation(lastViewRef.current);
2862
- }, children: [_jsx("span", { className: css.translateCheck }), _jsx("span", { className: css.translateMenuLabel, children: t('translate.retry') })] }))] }))] })), _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)
2863
- 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 }) }))] }), _jsxs("div", { className: css.detailBody, ref: detailRef, onScroll: onDetailScroll, 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?:\/\//, '') })] }))] }), _jsx("h1", { className: css.detailTitle, 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 && (_jsxs("button", { type: "button", className: css.refetch,
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 }), (translatePhase === 'pack' || translatePhase === 'working') && (_jsxs("div", { className: css.translateStatus, children: [_jsx("span", { children: translatePhase === 'pack'
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("p", { className: css.incomplete, children: t('detail.fetchingBody') })), scriptFigures > 0 && (_jsxs("p", { className: css.incomplete, children: [t('detail.scriptFigures', { count: scriptFigures }), ' ', captureRemote !== undefined && openEntry.link !== undefined && (captureBusy
2870
- ? _jsx("span", { children: t('detail.rendering') })
2871
- : (_jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { void captureBody(openEntry.id, openEntry.link); }, children: t('detail.renderFetch') }))), ' ', openEntry.link !== undefined && (_jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { openElsewhere(openEntry.link); }, children: browserTab ? t('action.openExternal') : t('detail.readOriginal') }))] })), articleError !== null && (_jsxs("p", { className: css.incomplete, children: [previewReason(t, sourceSummary?.failure?.code, articleError), openEntry.link !== undefined && (_jsxs(_Fragment, { children: [' ', _jsx("button", { type: "button", className: css.incompleteLink, onClick: () => { openElsewhere(openEntry.link); }, children: t('detail.readOriginal') })] }))] })), openEntry.summaryOnly === true && articleHtml !== null && articleHtml === openEntry.contentHtml && (_jsx("p", { className: css.incomplete, children: t('detail.summaryOnly') })), articleHtml !== null && articleHtml.length > 0 && (_jsx("div", {
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, iframes
2874
- // and event attributes are already gone, and every URL that
2875
- // survives has been scheme-checked. Re-sanitizing here would mean
2876
- // a second implementation of the same policy.
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, className: `${css.article} ${isCjk(articleHtml) ? css.articleZh : css.articleEn}`, dangerouslySetInnerHTML: { __html: articleHtml } })), (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)
2882
- props.openExternal(openEntry.link); }, children: t('detail.readOriginal') })] })), _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
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)))] }))] }), _jsxs("div", { className: css.composer, children: [_jsx("div", { className: css.composerLabel, children: t('detail.composerLabel') }), _jsx("div", { className: `${css.composerText} ${draft.length === 0 ? css.composerEmpty : ''}`, children: draft.length === 0 ? t('detail.composerEmpty') : draft })] }), _jsxs("div", { className: css.foot, children: [_jsx("button", { type: "button", className: css.incompleteLink, onClick: () => quote(), children: t('action.quote') }), sideChatAvailable && openEntry.link !== undefined && (_jsx("button", { type: "button", className: css.incompleteLink, onClick: () => {
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
- _jsx("div", { className: css.notice, children: 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))) })), !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 => {
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 }), 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') })), 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 && (() => {
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'