@khorsheed/dsh-reader 0.3.0 → 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 (87) hide show
  1. package/CHANGELOG.md +22 -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 +29054 -6698
  7. package/lib/index.js +3416 -334
  8. package/lib/typert.host.js +2145 -259
  9. package/lib/typert.remote-client.d.ts +97 -3
  10. package/lib/typert.remote-client.js +1590 -250
  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 +860 -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 +130 -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 +81 -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/index.d.ts +6 -0
  54. package/lib/types/index.js +3 -0
  55. package/lib/types/local-transcribe.d.ts +171 -0
  56. package/lib/types/local-transcribe.js +473 -0
  57. package/lib/types/media-host.d.ts +91 -0
  58. package/lib/types/media-host.js +230 -0
  59. package/lib/types/media.d.ts +100 -0
  60. package/lib/types/media.js +207 -0
  61. package/lib/types/notes-types.d.ts +158 -0
  62. package/lib/types/notes-types.js +8 -0
  63. package/lib/types/notes.d.ts +213 -0
  64. package/lib/types/notes.js +718 -0
  65. package/lib/types/polish.d.ts +41 -0
  66. package/lib/types/polish.js +136 -0
  67. package/lib/types/remote.d.ts +118 -0
  68. package/lib/types/remote.js +151 -1
  69. package/lib/types/service.d.ts +71 -1
  70. package/lib/types/service.js +280 -59
  71. package/lib/types/skill.d.ts +20 -0
  72. package/lib/types/skill.js +78 -0
  73. package/lib/types/store.js +30 -8
  74. package/lib/types/tool.d.ts +23 -0
  75. package/lib/types/tool.js +40 -0
  76. package/lib/types/tools.d.ts +69 -0
  77. package/lib/types/tools.js +400 -0
  78. package/lib/types/types.d.ts +87 -0
  79. package/lib/types/types.js +66 -0
  80. package/lib/types/xiaoyuzhou.d.ts +43 -0
  81. package/lib/types/xiaoyuzhou.js +134 -0
  82. package/lib/types/ytdlp.d.ts +111 -0
  83. package/lib/types/ytdlp.js +269 -0
  84. package/package.json +29 -20
  85. package/lib/invariant.js +0 -38
  86. package/lib/types/invariant.d.ts +0 -29
  87. 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);
@@ -630,6 +716,14 @@ export function ReaderPane(props) {
630
716
  const rescuedImagesRef = useRef(new Map());
631
717
  /** The detail view's scroller, which is where a reading position lives. */
632
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);
633
727
  /** The live segmentation, or null while the article is untouched. */
634
728
  const builtRef = useRef(null);
635
729
  /** The running translation's cancel flag. */
@@ -652,6 +746,9 @@ export function ReaderPane(props) {
652
746
  const [wallAvailability, setWallAvailability] = useState(null);
653
747
  /** Translated card fields, by entry id. */
654
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;
655
752
  const [wallProgress, setWallProgress] = useState(null);
656
753
  /**
657
754
  * The translated FIELD under the pointer, in the translation-only view.
@@ -721,23 +818,52 @@ export function ReaderPane(props) {
721
818
  reveal: css.reveal ?? 'dsh-reader-reveal',
722
819
  line: css.revealLine ?? 'dsh-reader-reveal-line',
723
820
  }), []);
821
+ /** This mount has not loaded yet (the cached wall is shown once, on the first load). */
822
+ const firstLoadRef = useRef(true);
724
823
  /** Load the source list and parse whatever payloads the host is holding. */
725
824
  const load = useCallback(async () => {
726
- 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 = [];
727
850
  try {
728
851
  const listed = await props.listSources();
729
852
  if (!listed.ok) {
730
853
  actions.setError(listed.error.message);
731
854
  return;
732
855
  }
733
- const known = listed.value.sources;
856
+ known = [...listed.value.sources];
734
857
  actions.setSources(known);
858
+ stampOf = new Map(known.map(source => [source.id, sourceStamp(source)]));
735
859
  // Sources whose payload is wanted: the ones that have a body, PLUS every
736
860
  // saved link — a link with no body is still a card the reader must see,
737
861
  // and the host answers that request with the reason instead of a payload.
738
862
  // (Asking only for sources with bodies made an unreadable link vanish from
739
863
  // the wall entirely, which is a worse answer than "link only".)
740
- 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)));
741
867
  if (wanted.length === 0)
742
868
  return;
743
869
  const bodies = await props.getBodies(wanted.map(source => source.id));
@@ -758,10 +884,10 @@ export function ReaderPane(props) {
758
884
  const cut = body.truncated === true;
759
885
  if (body.raw === undefined) {
760
886
  if (source.kind === 'rss') {
761
- actions.setParsed({ id: source.id, entries: [], error: body.error ?? t('detail.extractFailed') });
887
+ put({ id: source.id, entries: [], error: body.error ?? t('detail.extractFailed') });
762
888
  }
763
889
  else {
764
- actions.setParsed({
890
+ put({
765
891
  id: source.id,
766
892
  entries: [{
767
893
  id: linkEntryId(source.id),
@@ -791,8 +917,9 @@ export function ReaderPane(props) {
791
917
  // parse a feed — it has no XML parser), so the parsed title wins.
792
918
  if (result.ok && result.feed.title !== undefined) {
793
919
  actions.setSourceLabel(source.id, result.feed.title);
920
+ labels.set(source.id, result.feed.title);
794
921
  }
795
- actions.setParsed(result.ok
922
+ put(result.ok
796
923
  ? {
797
924
  id: source.id,
798
925
  entries: result.feed.entries.map(entry => cut ? { ...entry, truncated: true } : entry),
@@ -803,13 +930,15 @@ export function ReaderPane(props) {
803
930
  }
804
931
  else {
805
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);
806
935
  // The card's title/excerpt: the article's own when THIS extraction
807
936
  // read one, else the captured ones from an earlier extraction, else
808
937
  // the URL-derived label — a saved link's card names the paper.
809
938
  const extractedTitle = extracted.ok ? extracted.title : undefined;
810
939
  const extractedExcerpt = extracted.ok ? extracted.excerpt : undefined;
811
940
  const excerpt = extractedExcerpt ?? body.excerpt;
812
- actions.setParsed({
941
+ put({
813
942
  id: source.id,
814
943
  entries: [{
815
944
  id: linkEntryId(source.id),
@@ -827,6 +956,7 @@ export function ReaderPane(props) {
827
956
  // Incompleteness comes from the FETCH, not from extraction: the
828
957
  // seam truncated the page, so whatever we extracted is partial.
829
958
  ...(cut ? { truncated: true } : {}),
959
+ ...(linkMedia === undefined ? {} : { media: linkMedia }),
830
960
  }],
831
961
  });
832
962
  }
@@ -834,6 +964,10 @@ export function ReaderPane(props) {
834
964
  }
835
965
  finally {
836
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
+ }
837
971
  }
838
972
  }, [actions, props, t]);
839
973
  const backfillRunning = useRef(false);
@@ -888,7 +1022,6 @@ export function ReaderPane(props) {
888
1022
  void props.capabilities().then(result => {
889
1023
  if (!result.ok)
890
1024
  return;
891
- setSideChatAvailable(result.value.hasSideChat);
892
1025
  actions.setSchedule(result.value.lastRefreshAt, result.value.nextRefreshAt);
893
1026
  if (result.value.nextRefreshAt !== undefined)
894
1027
  setTimeOfDay(clockOf(result.value.nextRefreshAt));
@@ -940,6 +1073,17 @@ export function ReaderPane(props) {
940
1073
  }
941
1074
  return counts;
942
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);
943
1087
  /** One sweep at a time: the wall can re-render while an extraction runs. */
944
1088
  const sweepRunning = useRef(false);
945
1089
  /** The entry ids currently on the wall, for the fetch-state round trip. */
@@ -1111,6 +1255,126 @@ export function ReaderPane(props) {
1111
1255
  return item === undefined ? undefined : entryFromRecent(item);
1112
1256
  }, [allEntries, recent]);
1113
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]);
1114
1378
  /** The source the strip is currently narrowing to, if any. */
1115
1379
  const activeSource = sources.find(source => query.trim() === sourceQuery(source.id))?.id ?? null;
1116
1380
  /** The source KIND the search box is narrowing to, if any (`#rss` / `#link`). */
@@ -1179,14 +1443,14 @@ export function ReaderPane(props) {
1179
1443
  }, [view, articleHtml, guessSource]);
1180
1444
  /**
1181
1445
  * Whether the globe belongs on this article at all. Three ways it does not:
1182
- * the browser has no Translator API, the pair is unavailable on this device,
1183
- * or the body is already the target language. All three hide the control
1184
- * 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.
1185
1450
  */
1186
- const translationOffered = translator !== null
1187
- && translateAvailability !== null
1188
- && translateAvailability !== 'unavailable'
1189
- && translationSource !== TRANSLATION_TARGET;
1451
+ const translationOffered = translationSource !== TRANSLATION_TARGET && (translator === null
1452
+ ? storedPair !== null
1453
+ : translateAvailability !== null && translateAvailability !== 'unavailable');
1190
1454
  // Ask the browser about this pair once per opened article, in every target
1191
1455
  // spelling: the probe is async, and the control appears only once one of them
1192
1456
  // is not `unavailable`.
@@ -1247,7 +1511,34 @@ export function ReaderPane(props) {
1247
1511
  setTranslateError(null);
1248
1512
  setTranslateMenu(false);
1249
1513
  setTranslateAvailability(null);
1514
+ setStoredPair(null);
1250
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]);
1251
1542
  // Leaving the pane must not leave spans behind in a DOM React will reuse.
1252
1543
  useEffect(() => () => {
1253
1544
  if (cancelRef.current !== null)
@@ -1345,6 +1636,106 @@ export function ReaderPane(props) {
1345
1636
  ...(entry === undefined ? {} : { entryId: entry.entryId, bodyHash: entry.bodyHash }),
1346
1637
  });
1347
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]);
1348
1739
  /**
1349
1740
  * Get the article translated, reusing a session this page already built.
1350
1741
  *
@@ -1361,8 +1752,13 @@ export function ReaderPane(props) {
1361
1752
  const startTranslation = useCallback(async (initialView, sourceOverride) => {
1362
1753
  const api = translator;
1363
1754
  const container = articleRef.current;
1364
- if (api === null || container === null)
1755
+ if (container === null)
1365
1756
  return;
1757
+ if (api === null) {
1758
+ if (storedPair !== null)
1759
+ await showStoredTranslation(initialView, storedPair);
1760
+ return;
1761
+ }
1366
1762
  // One run at a time. A second start (the reader's retry, or the restore
1367
1763
  // re-applying a translation to a body that was just replaced) must not build
1368
1764
  // its own segmentation over the first one's: two overlapping runs would
@@ -1444,6 +1840,9 @@ export function ReaderPane(props) {
1444
1840
  // The host's tiers answer first: the entry's exact-fit map, then the global
1445
1841
  // sentence memory. Only what neither knows is spent on the model.
1446
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);
1447
1846
  if (openEntryId !== null && articleHtml !== null) {
1448
1847
  await warmMemory(pair, openEntryId, articleHtml, built);
1449
1848
  if (cancel.cancelled) {
@@ -1460,8 +1859,9 @@ export function ReaderPane(props) {
1460
1859
  });
1461
1860
  // What the run learned (and what it was served) persists in one batch: a
1462
1861
  // reload loses the page's memory, and the next visit pays the model again
1463
- // without this.
1464
- 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
1465
1865
  ? { entryId: openEntryId, bodyHash: translationHash(articleHtml) }
1466
1866
  : undefined);
1467
1867
  if (cancel.cancelled)
@@ -1478,7 +1878,7 @@ export function ReaderPane(props) {
1478
1878
  setTranslateProgress(null);
1479
1879
  // Re-paint once the first units exist, so the translated typography applies.
1480
1880
  setView(built, initialView, translateClasses);
1481
- }, [translator, translationSource, translateClasses, t, openEntryId, articleHtml, warmMemory, persistTranslations]);
1881
+ }, [translator, storedPair, showStoredTranslation, translationSource, translateClasses, t, openEntryId, articleHtml, warmMemory, persistTranslations, translateTitle]);
1482
1882
  /** Apply one view to a finished translation. */
1483
1883
  const applyView = useCallback((next) => {
1484
1884
  setTranslateView(next);
@@ -1670,10 +2070,12 @@ export function ReaderPane(props) {
1670
2070
  continue;
1671
2071
  // Chinese cards (and Chinese fields inside a mixed card) are left
1672
2072
  // exactly as they are: translating them to Chinese is not a feature.
1673
- 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)
1674
2076
  fields.push({ id, field: 'title', text: row.entry.title });
1675
2077
  const summary = row.entry.summary;
1676
- if (summary !== undefined && summary.length > 0 && !isTargetLanguage(summary)) {
2078
+ if (summary !== undefined && summary.length > 0 && !isTargetLanguage(summary) && done?.summary === undefined) {
1677
2079
  fields.push({ id, field: 'summary', text: summary });
1678
2080
  }
1679
2081
  }
@@ -1800,11 +2202,16 @@ export function ReaderPane(props) {
1800
2202
  *
1801
2203
  * @param entryId - the entry whose body is wanted.
1802
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.
1803
2207
  */
1804
2208
  const fetchBody = useCallback(async (entryId, url) => {
1805
2209
  actions.setFetching(entryId, true);
2210
+ setBodyFetch({ entryId, phase: 'downloading', since: Date.now() });
1806
2211
  try {
1807
- 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
+ });
1808
2215
  // A late answer belongs to the entry that ASKED for it: if the detail
1809
2216
  // view has moved on (another entry, or the wall), the article area is no
1810
2217
  // longer this entry's to write. The per-entry records are unaffected —
@@ -1826,7 +2233,7 @@ export function ReaderPane(props) {
1826
2233
  }
1827
2234
  // The caller escalates on a shell: a body that still counts script
1828
2235
  // figures wants the rendered fetch next.
1829
- return result.scriptFigures ?? 0;
2236
+ return (result.scriptFigures ?? 0) > 0;
1830
2237
  }
1831
2238
  else if (result.error !== undefined && onScreen) {
1832
2239
  // Keep whatever is already rendered (a feed summary, say) and add the
@@ -1834,10 +2241,12 @@ export function ReaderPane(props) {
1834
2241
  // It also leaves the host's copy — and its stale marker — untouched.
1835
2242
  actions.setArticle(articleHtml ?? '', false, result.error);
1836
2243
  }
1837
- return undefined;
2244
+ // …and so does a bot wall: the plain fetch is what it refused.
2245
+ return result.error !== undefined && browserSolvable(result.error);
1838
2246
  }
1839
2247
  finally {
1840
2248
  actions.setFetching(entryId, false);
2249
+ setBodyFetch(current => (current?.entryId === entryId ? null : current));
1841
2250
  // The card reads the host's annotation through the mirror: re-read the
1842
2251
  // one entry this fetch settled, so the pill flips on this gesture and
1843
2252
  // not at the next mount.
@@ -1859,19 +2268,20 @@ export function ReaderPane(props) {
1859
2268
  const capture = props.captureRemote();
1860
2269
  if (capture === undefined)
1861
2270
  return;
1862
- setCaptureBusy(true);
2271
+ setCaptureBusyId(entryId);
2272
+ setCaptureFailure(null);
1863
2273
  try {
1864
2274
  const rendered = await capture.render({ url: link });
1865
2275
  if (!rendered.ok) {
1866
- if (openRequestRef.current === entryId)
1867
- 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 });
1868
2279
  return;
1869
2280
  }
1870
2281
  const finalUrl = rendered.value.finalUrl ?? link;
1871
2282
  const extracted = extractArticle(rendered.value.html, finalUrl);
1872
2283
  if (!extracted.ok) {
1873
- if (openRequestRef.current === entryId)
1874
- actions.setArticle(articleHtml ?? '', false, extracted.error);
2284
+ setCaptureFailure({ entryId, message: extracted.error });
1875
2285
  return;
1876
2286
  }
1877
2287
  const stored = await props.storeEntryBody({
@@ -1879,6 +2289,10 @@ export function ReaderPane(props) {
1879
2289
  url: finalUrl,
1880
2290
  html: extracted.html,
1881
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 }),
1882
2296
  ...(rendered.value.truncated === true ? { truncated: true } : {}),
1883
2297
  // A capture render ALWAYS carries its count, zero included: the entry's
1884
2298
  // own load-time count (84 script figures, say) is what the remount
@@ -1891,6 +2305,10 @@ export function ReaderPane(props) {
1891
2305
  rendered: true,
1892
2306
  });
1893
2307
  renderedBodiesRef.current.add(entryId);
2308
+ actions.noteExtractedMeta(entryId, {
2309
+ ...(extracted.title === undefined ? {} : { title: extracted.title }),
2310
+ ...(extracted.excerpt === undefined ? {} : { excerpt: extracted.excerpt }),
2311
+ });
1894
2312
  if (openRequestRef.current !== entryId)
1895
2313
  return;
1896
2314
  if (stored.ok && stored.value.html !== undefined) {
@@ -1901,19 +2319,21 @@ export function ReaderPane(props) {
1901
2319
  }
1902
2320
  }
1903
2321
  finally {
1904
- setCaptureBusy(false);
2322
+ setCaptureBusyId(current => (current === entryId ? null : current));
1905
2323
  }
1906
- }, [actions, props, articleHtml, syncFetchState]);
2324
+ }, [actions, props, syncFetchState]);
1907
2325
  /**
1908
2326
  * One gesture, the whole pipeline: an explicit per-entry ask (add a link,
1909
2327
  * the card's 「抓取」, opening an entry, the detail's 「重新抓取」) that lands
1910
- * on a script-figure shell continues into the rendered fetch on its own —
1911
- * 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
1912
2332
  * per pane session (a failure leaves the manual「渲染抓取」retry); background
1913
2333
  * paths (scheduled feed refresh, backfill) never reach here.
1914
2334
  */
1915
- const maybeAutoRender = useCallback((entryId, link, count) => {
1916
- if (link === undefined || count <= 0)
2335
+ const maybeAutoRender = useCallback((entryId, link, wanted) => {
2336
+ if (link === undefined || !wanted)
1917
2337
  return;
1918
2338
  if (props.captureRemote() === undefined)
1919
2339
  return;
@@ -1924,7 +2344,7 @@ export function ReaderPane(props) {
1924
2344
  }, [props, captureBody]);
1925
2345
  /** A plain fetch that escalates on a shell — the explicit-gesture fetch path. */
1926
2346
  const fetchThenMaybeRender = useCallback((entryId, link) => {
1927
- void fetchBody(entryId, link).then(count => maybeAutoRender(entryId, link, count ?? 0));
2347
+ void fetchBody(entryId, link).then(wanted => maybeAutoRender(entryId, link, wanted));
1928
2348
  }, [fetchBody, maybeAutoRender]);
1929
2349
  /** Open one entry: mark it read and make sure a body is available. */
1930
2350
  const open = useCallback(async (row) => {
@@ -1958,6 +2378,43 @@ export function ReaderPane(props) {
1958
2378
  // A cached fetch outlives the feed's own payload, so the host is the one
1959
2379
  // that knows whether there is full text: it returns the fresh cache, else
1960
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
+ }
1961
2418
  if (row.entry.link !== undefined) {
1962
2419
  // A feed that published only a summary still owes the reader an article,
1963
2420
  // whichever branch below supplies the text on screen (the host answers
@@ -1998,7 +2455,7 @@ export function ReaderPane(props) {
1998
2455
  // load-time extraction, whose count rides the entry instead.
1999
2456
  const knownCount = view.value.scriptFigures ?? row.entry.scriptFigures ?? 0;
2000
2457
  setScriptFigures(knownCount);
2001
- maybeAutoRender(row.entry.id, row.entry.link, knownCount);
2458
+ maybeAutoRender(row.entry.id, row.entry.link, knownCount > 0);
2002
2459
  actions.setStaleBody(row.entry.id, view.value.fresh === false);
2003
2460
  // `html` here is either the FEED's own payload or a body this plugin
2004
2461
  // already fetched and cached — a cached one is paid for already.
@@ -2049,6 +2506,10 @@ export function ReaderPane(props) {
2049
2506
  if (row.sourceKind === 'rss' && view.value.error === undefined) {
2050
2507
  fetchThenMaybeRender(row.entry.id, row.entry.link);
2051
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));
2052
2513
  }
2053
2514
  return;
2054
2515
  }
@@ -2078,11 +2539,11 @@ export function ReaderPane(props) {
2078
2539
  // fetch paths set it from the host's answer, this one computes it locally.
2079
2540
  const count = extracted.scriptFigures ?? 0;
2080
2541
  setScriptFigures(count);
2081
- maybeAutoRender(row.entry.id, row.entry.link, count);
2542
+ maybeAutoRender(row.entry.id, row.entry.link, count > 0);
2082
2543
  }
2083
2544
  else
2084
2545
  actions.setArticle('', truncated, extracted.error);
2085
- }, [actions, props, t, fetchThenMaybeRender, persistFeedBody, recent, fetchStates, syncFetchState, maybeAutoRender]);
2546
+ }, [actions, props, t, fetchThenMaybeRender, persistFeedBody, recent, fetchStates, syncFetchState, maybeAutoRender, setTranscriptState]);
2086
2547
  /* ------------------------------ coming back to where the reader already was */
2087
2548
  /**
2088
2549
  * Re-open the article the pane had on screen when the sidebar went away.
@@ -2430,6 +2891,11 @@ export function ReaderPane(props) {
2430
2891
  return;
2431
2892
  if (translatePhase !== 'idle')
2432
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;
2433
2899
  if (restoredTranslationRef.current === openEntryId)
2434
2900
  return;
2435
2901
  const wanted = readSession().translationView?.[openEntryId];
@@ -2437,62 +2903,92 @@ export function ReaderPane(props) {
2437
2903
  return;
2438
2904
  const source = readSession().translationSource?.[openEntryId] ?? translationSource;
2439
2905
  const sources = source === 'en' ? ['en'] : [source, 'en'];
2440
- 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)
2441
2908
  return;
2442
2909
  restoredTranslationRef.current = openEntryId;
2443
2910
  void startTranslation(wanted, source);
2444
- }, [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]);
2445
2932
  /** Submit the add form and report the host's verdict. */
2446
2933
  const submit = useCallback(async () => {
2447
2934
  const url = draftUrl.trim();
2448
- 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)
2449
2939
  return;
2450
2940
  setVerdict(null);
2451
- const result = await props.addSource(url);
2452
- if (!result.ok) {
2453
- // A transport-level failure: the host never answered, so the reason is
2454
- // whatever the wire layer said.
2455
- setVerdict({ kind: 'fetchFailed', reason: result.error.message });
2456
- return;
2457
- }
2458
- const value = result.value;
2459
- if (typeof value === 'string') {
2460
- setVerdict({ kind: verdictForRefusal(value) });
2461
- return;
2462
- }
2463
- if (value.outcome === 'fetch-failed') {
2464
- // The fetch failed with a reason of its own ("connect ECONNREFUSED …",
2465
- // "body over the seam's byte cap"). Showing it is the difference between
2466
- // a retry and a report nobody can act on.
2467
- setVerdict({ kind: 'fetchFailed', reason: value.reason });
2468
- return;
2469
- }
2470
- if (value.failure !== undefined) {
2471
- // Saved, but there is no body to preview. This is NOT a failure verdict:
2472
- // the link is in the wall, and the sentence names the reason so the
2473
- // reader knows whether it is worth opening in a browser. The URL rides
2474
- // along: for the causes a real browser solves, the verdict itself offers
2475
- // 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
+ }
2476
2979
  setVerdict({
2477
- kind: 'savedLinkNoPreview',
2980
+ kind: value.outcome === 'subscribed' ? 'subscribed' : 'savedLink',
2478
2981
  label: value.label,
2479
- reason: value.failure.message,
2480
- code: value.failure.code,
2481
- url,
2482
2982
  });
2483
2983
  setDraftUrl('');
2984
+ // Reload so the new entry is on the wall behind the dialog, not only in
2985
+ // the host's state.
2484
2986
  actions.refresh();
2485
- return;
2486
2987
  }
2487
- setVerdict({
2488
- kind: value.outcome === 'subscribed' ? 'subscribed' : 'savedLink',
2489
- label: value.label,
2490
- });
2491
- setDraftUrl('');
2492
- // Reload so the new entry is on the wall behind the dialog, not only in
2493
- // the host's state.
2494
- actions.refresh();
2495
- }, [actions, draftUrl, props]);
2988
+ finally {
2989
+ setAddBusySince(null);
2990
+ }
2991
+ }, [actions, addBusySince, draftUrl, props]);
2496
2992
  /** Re-fetch one source from the subscription page. */
2497
2993
  const refreshOne = useCallback(async (id) => {
2498
2994
  actions.setRefreshing(true);
@@ -2666,10 +3162,10 @@ export function ReaderPane(props) {
2666
3162
  // stores the payload first, so leaving the page does not cancel it. It also
2667
3163
  // re-reads this entry's state from the host when the fetch settles, so the
2668
3164
  // pill flips without a full-wall poll here.
2669
- const count = await fetchBody(entry.id, entry.link);
3165
+ const wanted = await fetchBody(entry.id, entry.link);
2670
3166
  // One gesture, the whole pipeline: a shell answer continues into the
2671
3167
  // rendered fetch on its own (see maybeAutoRender).
2672
- maybeAutoRender(entry.id, entry.link, count ?? 0);
3168
+ maybeAutoRender(entry.id, entry.link, wanted);
2673
3169
  }, [actions, fetchBody, maybeAutoRender]);
2674
3170
  /** Reload the tags on one entry. */
2675
3171
  const loadEntryTags = useCallback(async (entryId) => {
@@ -2763,19 +3259,6 @@ export function ReaderPane(props) {
2763
3259
  setTagInputOpen(false);
2764
3260
  await toggleTag(entryId, created.value.id, true);
2765
3261
  }, [props, toggleTag]);
2766
- /** Quote the open entry (or a selection) into the conversation draft. */
2767
- const quote = useCallback((explicit) => {
2768
- if (openEntry === undefined)
2769
- return;
2770
- const selection = explicit ?? window.getSelection()?.toString() ?? '';
2771
- const source = presentation.get(openEntry.sourceId);
2772
- const block = formatReaderRef(selection, provenanceOf(openEntry, {
2773
- label: source?.label ?? openEntry.title,
2774
- }));
2775
- const next = mergedDraft(props.readDraft(), block);
2776
- props.setDraft(next);
2777
- setDraft(next);
2778
- }, [openEntry, presentation, props]);
2779
3262
  const copyLink = useCallback(async () => {
2780
3263
  if (openEntry?.link === undefined)
2781
3264
  return;
@@ -2797,6 +3280,115 @@ export function ReaderPane(props) {
2797
3280
  props.openExternal(url);
2798
3281
  }, [props]);
2799
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
+ };
2800
3392
  /**
2801
3393
  * The pane's header.
2802
3394
  *
@@ -2814,26 +3406,7 @@ export function ReaderPane(props) {
2814
3406
  setSourceFilter('');
2815
3407
  }
2816
3408
  setFilterOpen(!filterOpen);
2817
- }, children: glyph('funnel', 15) }), filterOpen && (_jsx("div", { className: css.filterPanel, onKeyDown: event => { if (event.key === 'Escape')
2818
- 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 => {
2819
- const group = parsed[source.id];
2820
- const broken = group?.error !== undefined || group?.incomplete === true;
2821
- 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
2822
- ? _jsx("span", { className: css.chipBroken, title: t('state.incomplete'), children: "!" })
2823
- : _jsx("span", { className: css.filterCount, children: group?.entries.length ?? 0 })] }, source.id));
2824
- }), 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: () => {
2825
- setSortOpen(false);
2826
- setFilterOpen(false);
2827
- if (wallOn) {
2828
- setWallOn(false);
2829
- return;
2830
- }
2831
- wallCancelRef.current = { cancelled: false };
2832
- setWallOn(true);
2833
- // Cards already on screen are queued by the observer effect; a
2834
- // wall that never scrolls still gets translated in one pass.
2835
- void runWallPass();
2836
- }, 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 }) })] }));
2837
3410
  /** Load the states once the wall knows which entries it is showing. */
2838
3411
  useEffect(() => { void refreshFetchStates(); }, [refreshFetchStates, rev]);
2839
3412
  /**
@@ -2861,12 +3434,12 @@ export function ReaderPane(props) {
2861
3434
  /** The add dialog: overlay + centered card, Esc and overlay-click close. */
2862
3435
  const dialog = addOpen && (_jsx("div", { className: css.overlay, role: "presentation", onClick: event => { if (event.target === event.currentTarget)
2863
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')
2864
- 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')
2865
- 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'
2866
3439
  ? { label: verdict.label ?? '', reason: previewReason(t, verdict.code, verdict.reason ?? '') }
2867
- : 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')
2868
3441
  ? t('action.done')
2869
- : 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') })] })] }) }));
2870
3443
  /* ------------------------------------------------------------- detail view */
2871
3444
  if (view === 'detail' && (openEntry !== undefined || openEntryId !== null)) {
2872
3445
  /**
@@ -2881,9 +3454,25 @@ export function ReaderPane(props) {
2881
3454
  if (openEntry === undefined) {
2882
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') }) })] }));
2883
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;
2884
3467
  const source = presentation.get(openEntry.sourceId);
2885
3468
  /** The host's own row for this entry's source: the failure code lives here. */
2886
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);
2887
3476
  const when = relativeWhen(openEntry.publishedAt, new Date());
2888
3477
  const date = absoluteDate(openEntry.publishedAt);
2889
3478
  const alsoFrom = allEntries
@@ -2895,27 +3484,117 @@ export function ReaderPane(props) {
2895
3484
  : translateProgress !== null && translateProgress.total > 0
2896
3485
  ? Math.round((translateProgress.done / translateProgress.total) * 100)
2897
3486
  : 0;
2898
- 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: () => {
2899
- setTranslateMenu(false);
2900
- if (builtRef.current !== null)
2901
- restoreArticle(builtRef.current.root);
2902
- builtRef.current = null;
2903
- void startTranslation(lastViewRef.current);
2904
- }, 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)
2905
- 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,
2906
3569
  // A rendered entry's「重新抓取」re-renders through capture —
2907
3570
  // captureBusy is that in-flight state, or the button looks
2908
3571
  // dead for minutes.
2909
- 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'
2910
3581
  ? t('translate.preparing')
2911
- : _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
2912
- ? _jsx("span", { children: t('detail.rendering') })
2913
- : (_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", {
2914
3591
  // The markup is the output of this package's own whitelist
2915
- // normalizer (`extract-article`): scripts, styles, forms, iframes
2916
- // and event attributes are already gone, and every URL that
2917
- // survives has been scheme-checked. Re-sanitizing here would mean
2918
- // 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.
2919
3598
  //
2920
3599
  // `translate.ts` decorates THIS subtree in place when the reader
2921
3600
  // asks for a translation (text nodes only), so the click handler
@@ -2923,8 +3602,8 @@ export function ReaderPane(props) {
2923
3602
  ref: articleRef, onClick: onArticleClick, onMouseOver: onArticleHover, onMouseOut: onArticleHover,
2924
3603
  // Image error events do not bubble; the capture phase is the
2925
3604
  // only way one handler sees every failed figure in the body.
2926
- onErrorCapture: onArticleImageError, 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)
2927
- 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
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
2928
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),
2929
3608
  // A suggestion's mousedown is suppressed, so this fires only
2930
3609
  // when focus really left the editor.
@@ -2958,13 +3637,7 @@ export function ReaderPane(props) {
2958
3637
  sourceKind: source?.kind ?? 'rss',
2959
3638
  unread: read[entry.id] !== true,
2960
3639
  });
2961
- }, 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: () => {
2962
- void props.quoteToSideChat({
2963
- contextKey: sessionId,
2964
- label: openEntry.title,
2965
- text: formatReaderRef('', provenanceOf(openEntry, { label: source?.label ?? '' })),
2966
- });
2967
- }, 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 }))] }));
2968
3641
  }
2969
3642
  /* ------------------------------------------------------ subscriptions page */
2970
3643
  if (view === 'recent') {
@@ -3025,7 +3698,7 @@ export function ReaderPane(props) {
3025
3698
  }) }))] }), dialog] }));
3026
3699
  }
3027
3700
  /* ------------------------------------------------------------------- wall */
3028
- 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: () => {
3029
3702
  if (wallCancelRef.current !== null)
3030
3703
  wallCancelRef.current.cancelled = true;
3031
3704
  wallPendingRef.current.clear();
@@ -3036,7 +3709,7 @@ export function ReaderPane(props) {
3036
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 && (
3037
3710
  // "Why is this list short?" answered where the reader is looking,
3038
3711
  // once per affected source, with the way to the full text.
3039
- _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 => {
3040
3713
  // The card's text box is FIXED (two clamped lines per field, see
3041
3714
  // the CSS), because Chinese and English wrap differently: swapping
3042
3715
  // languages must not resize the card or shift the grid.
@@ -3060,6 +3733,12 @@ export function ReaderPane(props) {
3060
3733
  // The duplicates this card stands for, when the wall's dedupe
3061
3734
  // folded them behind it (a badge, never a deletion).
3062
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://');
3063
3742
  // Only a field that actually has a translation can be peeked: an
3064
3743
  // untranslated (or Chinese) card keeps its ordinary hover.
3065
3744
  const peekable = (field) => wallOn && !wallBoth && card?.[field] !== undefined;
@@ -3069,7 +3748,7 @@ export function ReaderPane(props) {
3069
3748
  onMouseLeave: () => { setHoverField(current => (current?.id === row.entry.id && current.field === field ? null : current)); },
3070
3749
  }
3071
3750
  : {};
3072
- 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) {
3073
3752
  setCardMenu(null);
3074
3753
  setCardTag(null);
3075
3754
  return;
@@ -3080,8 +3759,25 @@ export function ReaderPane(props) {
3080
3759
  if (row.entry.link === undefined)
3081
3760
  return;
3082
3761
  event.preventDefault();
3083
- setCardMenu({ entryId: row.entry.id, top: event.clientY, left: event.clientX });
3084
- }, 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 && (() => {
3085
3781
  const fetchState = fetchStateOf(fetchStates, row.entry.id);
3086
3782
  const state = fetchState.state;
3087
3783
  const labelKey = state === 'none' ? 'fetch.none'