hn-bits 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -13
- package/dist/api/algolia.js +31 -11
- package/dist/db/bookmarks.js +37 -0
- package/dist/db/db.js +64 -0
- package/dist/db/seen.js +12 -0
- package/dist/db/subscriptions.js +44 -0
- package/dist/index.js +85 -5
- package/dist/lib/config.js +1 -0
- package/dist/lib/configKeys.js +9 -0
- package/dist/lib/listNavigation.js +7 -7
- package/dist/notify/notifier.js +1 -0
- package/dist/notify/telegram.js +58 -0
- package/dist/ui/App.js +92 -10
- package/dist/ui/AskAI.js +2 -1
- package/dist/ui/Comments.js +18 -9
- package/dist/ui/HelpOverlay.js +2 -1
- package/dist/ui/Layout.js +2 -1
- package/dist/ui/LoadingIndicator.js +2 -1
- package/dist/ui/SavedList.js +90 -0
- package/dist/ui/SearchResults.js +13 -4
- package/dist/ui/StoryList.js +14 -7
- package/dist/ui/StoryRow.js +7 -3
- package/dist/ui/SubscriptionForm.js +104 -0
- package/dist/ui/SubscriptionMatches.js +103 -0
- package/dist/ui/SubscriptionsView.js +83 -0
- package/dist/ui/SummaryPanel.js +2 -1
- package/dist/ui/TabBar.js +13 -10
- package/dist/ui/ThemePicker.js +28 -0
- package/dist/ui/keymap.js +48 -0
- package/dist/ui/theme.js +38 -2
- package/dist/ui/useFlash.js +14 -0
- package/dist/watch.js +96 -0
- package/package.json +3 -1
package/dist/ui/App.js
CHANGED
|
@@ -2,37 +2,94 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useState } from 'react';
|
|
3
3
|
import { useApp, useInput } from 'ink';
|
|
4
4
|
import { loadConfig } from '../lib/config.js';
|
|
5
|
+
import { setConfigValue } from '../lib/configStore.js';
|
|
6
|
+
import { nextTab, previousTab } from '../lib/listNavigation.js';
|
|
5
7
|
import { AskAI } from './AskAI.js';
|
|
6
8
|
import { Comments } from './Comments.js';
|
|
7
9
|
import { HelpOverlay } from './HelpOverlay.js';
|
|
8
|
-
import { COMMENTS_KEYS, LIST_KEYS, SEARCH_RESULTS_KEYS, footerHint } from './keymap.js';
|
|
10
|
+
import { COMMENTS_KEYS, LIST_KEYS, SAVED_KEYS, SEARCH_RESULTS_KEYS, SUB_MATCHES_KEYS, SUBS_KEYS, THEME_PICKER_KEYS, footerHint, } from './keymap.js';
|
|
9
11
|
import { Body, Footer, Header, Screen } from './Layout.js';
|
|
12
|
+
import { SavedList } from './SavedList.js';
|
|
10
13
|
import { SearchInput } from './SearchInput.js';
|
|
11
14
|
import { SearchResults } from './SearchResults.js';
|
|
12
15
|
import { StoryList } from './StoryList.js';
|
|
16
|
+
import { SubscriptionForm } from './SubscriptionForm.js';
|
|
17
|
+
import { SubscriptionMatches } from './SubscriptionMatches.js';
|
|
18
|
+
import { SubscriptionsView } from './SubscriptionsView.js';
|
|
13
19
|
import { TabBar } from './TabBar.js';
|
|
14
|
-
|
|
20
|
+
import { ThemePicker } from './ThemePicker.js';
|
|
21
|
+
import { resolvePaletteName, resolveTheme, ThemeContext } from './theme.js';
|
|
22
|
+
export function App({ initialQuery, initialView }) {
|
|
15
23
|
const [feed, setFeed] = useState('top');
|
|
16
|
-
const [view, setView] = useState(initialQuery
|
|
24
|
+
const [view, setView] = useState(initialQuery
|
|
25
|
+
? { name: 'search', query: initialQuery, from: 'cli' }
|
|
26
|
+
: initialView === 'saved'
|
|
27
|
+
? { name: 'saved' }
|
|
28
|
+
: initialView === 'subs'
|
|
29
|
+
? { name: 'subs' }
|
|
30
|
+
: { name: 'list' });
|
|
17
31
|
const [helpOpen, setHelpOpen] = useState(false);
|
|
18
32
|
const [config] = useState(loadConfig);
|
|
33
|
+
const [paletteName, setPaletteName] = useState(() => resolvePaletteName());
|
|
34
|
+
const [themePickerOpen, setThemePickerOpen] = useState(false);
|
|
35
|
+
const activeTheme = resolveTheme(paletteName);
|
|
19
36
|
const { exit } = useApp();
|
|
20
37
|
useInput((input) => {
|
|
38
|
+
if (themePickerOpen)
|
|
39
|
+
return;
|
|
21
40
|
if (helpOpen)
|
|
22
41
|
return setHelpOpen(false);
|
|
23
|
-
if (view.name === 'search-input' || view.name === 'ask')
|
|
42
|
+
if (view.name === 'search-input' || view.name === 'ask' || view.name === 'sub-form')
|
|
24
43
|
return;
|
|
25
44
|
if (input === 'q')
|
|
26
45
|
return exit();
|
|
27
46
|
if (input === '?')
|
|
28
47
|
return setHelpOpen(true);
|
|
48
|
+
if (input === 'T')
|
|
49
|
+
return setThemePickerOpen(true);
|
|
29
50
|
});
|
|
30
|
-
|
|
31
|
-
|
|
51
|
+
function handleThemeSelected(name) {
|
|
52
|
+
setConfigValue('ui.theme', name);
|
|
53
|
+
setPaletteName(name);
|
|
54
|
+
setThemePickerOpen(false);
|
|
55
|
+
}
|
|
56
|
+
function changeFeed(target) {
|
|
57
|
+
setFeed(target);
|
|
58
|
+
setView({ name: 'list' });
|
|
59
|
+
}
|
|
60
|
+
function changeTab(direction) {
|
|
61
|
+
const next = direction === 1 ? nextTab(activeTab) : previousTab(activeTab);
|
|
62
|
+
if (next === 'saved')
|
|
63
|
+
return setView({ name: 'saved' });
|
|
64
|
+
if (next === 'subs')
|
|
65
|
+
return setView({ name: 'subs' });
|
|
66
|
+
changeFeed(next);
|
|
67
|
+
}
|
|
68
|
+
const activeTab = tabForView(view) ?? feed;
|
|
69
|
+
const ctx = { feed, config, setFeed: changeFeed, setTab: changeTab, setView, exit };
|
|
70
|
+
return (_jsx(ThemeContext.Provider, { value: activeTheme, children: _jsxs(Screen, { children: [_jsx(Header, { children: _jsx(TabBar, { active: activeTab }) }), _jsx(Body, { children: themePickerOpen ? (_jsx(ThemePicker, { current: paletteName, onSelect: handleThemeSelected, onCancel: () => setThemePickerOpen(false) })) : helpOpen ? (_jsx(HelpOverlay, { ...helpFor(view) })) : (renderBody(view, ctx)) }), _jsx(Footer, { children: themePickerOpen ? footerHint(THEME_PICKER_KEYS) : renderFooter(view, ctx) })] }) }));
|
|
71
|
+
}
|
|
72
|
+
/** Traces returnTo chains back to the tab a nested view (comments/ask/sub-form) was opened from. */
|
|
73
|
+
function tabForView(view) {
|
|
74
|
+
if (view.name === 'saved' || view.name === 'subs')
|
|
75
|
+
return view.name;
|
|
76
|
+
if (view.name === 'sub-matches')
|
|
77
|
+
return 'subs';
|
|
78
|
+
if (view.name === 'comments' || view.name === 'ask' || view.name === 'sub-form')
|
|
79
|
+
return tabForView(view.returnTo);
|
|
80
|
+
return null;
|
|
32
81
|
}
|
|
33
82
|
function renderBody(view, ctx) {
|
|
34
83
|
if (view.name === 'list')
|
|
35
84
|
return renderList(ctx);
|
|
85
|
+
if (view.name === 'saved')
|
|
86
|
+
return renderSaved(ctx);
|
|
87
|
+
if (view.name === 'subs')
|
|
88
|
+
return renderSubs(ctx);
|
|
89
|
+
if (view.name === 'sub-matches')
|
|
90
|
+
return renderSubMatches(view, ctx);
|
|
91
|
+
if (view.name === 'sub-form')
|
|
92
|
+
return renderSubForm(view, ctx);
|
|
36
93
|
if (view.name === 'search')
|
|
37
94
|
return renderSearch(view, ctx);
|
|
38
95
|
if (view.name === 'search-input')
|
|
@@ -45,10 +102,16 @@ function renderFooter(view, ctx) {
|
|
|
45
102
|
if (view.name === 'search-input') {
|
|
46
103
|
return (_jsx(SearchInput, { onSubmit: (query) => ctx.setView({ name: 'search', query, from: view.from }), onCancel: () => ctx.setView({ name: 'list' }) }));
|
|
47
104
|
}
|
|
48
|
-
if (view.name === 'ask')
|
|
105
|
+
if (view.name === 'ask' || view.name === 'sub-form')
|
|
49
106
|
return null;
|
|
50
107
|
if (view.name === 'list')
|
|
51
108
|
return footerHint(LIST_KEYS);
|
|
109
|
+
if (view.name === 'saved')
|
|
110
|
+
return footerHint(SAVED_KEYS);
|
|
111
|
+
if (view.name === 'subs')
|
|
112
|
+
return footerHint(SUBS_KEYS);
|
|
113
|
+
if (view.name === 'sub-matches')
|
|
114
|
+
return footerHint(SUB_MATCHES_KEYS);
|
|
52
115
|
if (view.name === 'comments')
|
|
53
116
|
return footerHint(COMMENTS_KEYS);
|
|
54
117
|
return footerHint(SEARCH_RESULTS_KEYS);
|
|
@@ -58,14 +121,33 @@ function helpFor(view) {
|
|
|
58
121
|
return { title: 'comments', keys: COMMENTS_KEYS };
|
|
59
122
|
if (view.name === 'search')
|
|
60
123
|
return { title: 'search results', keys: SEARCH_RESULTS_KEYS };
|
|
124
|
+
if (view.name === 'saved')
|
|
125
|
+
return { title: 'saved', keys: SAVED_KEYS };
|
|
126
|
+
if (view.name === 'subs')
|
|
127
|
+
return { title: 'subs', keys: SUBS_KEYS };
|
|
128
|
+
if (view.name === 'sub-matches')
|
|
129
|
+
return { title: 'sub matches', keys: SUB_MATCHES_KEYS };
|
|
61
130
|
return { title: 'story list', keys: LIST_KEYS };
|
|
62
131
|
}
|
|
63
|
-
function renderList({ feed, config, setFeed, setView }) {
|
|
64
|
-
return (_jsx(StoryList, { feed: feed, config: config, onFeedChange: setFeed, onSelectStory: (story) => setView({ name: 'comments', story, returnTo: { name: 'list' } }), onSearchRequested: () => setView({ name: 'search-input', from: 'tui' }), onAskAI: (story) => setView({ name: 'ask', story, comments: null, returnTo: { name: 'list' } }) }));
|
|
132
|
+
function renderList({ feed, config, setFeed, setTab, setView }) {
|
|
133
|
+
return (_jsx(StoryList, { feed: feed, config: config, onFeedChange: setFeed, onTabChange: setTab, onSelectStory: (story) => setView({ name: 'comments', story, returnTo: { name: 'list' } }), onSearchRequested: () => setView({ name: 'search-input', from: 'tui' }), onAskAI: (story) => setView({ name: 'ask', story, comments: null, returnTo: { name: 'list' } }) }));
|
|
134
|
+
}
|
|
135
|
+
function renderSaved({ config, setFeed, setTab, setView }) {
|
|
136
|
+
return (_jsx(SavedList, { config: config, onFeedChange: setFeed, onTabChange: setTab, onSelectStory: (story) => setView({ name: 'comments', story, returnTo: { name: 'saved' } }), onSearchRequested: () => setView({ name: 'search-input', from: 'tui' }), onAskAI: (story) => setView({ name: 'ask', story, comments: null, returnTo: { name: 'saved' } }) }));
|
|
137
|
+
}
|
|
138
|
+
function renderSubs({ setFeed, setTab, setView }) {
|
|
139
|
+
return (_jsx(SubscriptionsView, { onSelectMatches: (subscription) => setView({ name: 'sub-matches', subscription }), onAdd: () => setView({ name: 'sub-form', mode: 'add', returnTo: { name: 'subs' } }), onEdit: (subscription) => setView({ name: 'sub-form', mode: 'edit', subscription, returnTo: { name: 'subs' } }), onFeedChange: setFeed, onTabChange: setTab }));
|
|
140
|
+
}
|
|
141
|
+
function renderSubMatches(view, { config, setView }) {
|
|
142
|
+
const returnTo = { name: 'sub-matches', subscription: view.subscription };
|
|
143
|
+
return (_jsx(SubscriptionMatches, { subscription: view.subscription, config: config, onSelectStory: (story) => setView({ name: 'comments', story, returnTo }), onBack: () => setView({ name: 'subs' }), onAskAI: (story) => setView({ name: 'ask', story, comments: null, returnTo }) }));
|
|
144
|
+
}
|
|
145
|
+
function renderSubForm(view, { setView }) {
|
|
146
|
+
return (_jsx(SubscriptionForm, { mode: view.mode, subscription: view.subscription, prefillQuery: view.prefillQuery, onSave: () => setView(view.returnTo), onCancel: () => setView(view.returnTo) }));
|
|
65
147
|
}
|
|
66
148
|
function renderSearch(view, { config, setView, exit }) {
|
|
67
149
|
const returnTo = { name: 'search', query: view.query, from: view.from };
|
|
68
|
-
return (_jsx(SearchResults, { query: view.query, config: config, onSelectStory: (story) => setView({ name: 'comments', story, returnTo }), onExit: () => (view.from === 'tui' ? setView({ name: 'list' }) : exit()), onSearchAgain: () => setView({ name: 'search-input', from: view.from }), onAskAI: (story) => setView({ name: 'ask', story, comments: null, returnTo }) }));
|
|
150
|
+
return (_jsx(SearchResults, { query: view.query, config: config, onSelectStory: (story) => setView({ name: 'comments', story, returnTo }), onExit: () => (view.from === 'tui' ? setView({ name: 'list' }) : exit()), onSearchAgain: () => setView({ name: 'search-input', from: view.from }), onAskAI: (story) => setView({ name: 'ask', story, comments: null, returnTo }), onSubscribe: () => setView({ name: 'sub-form', mode: 'add', prefillQuery: view.query, returnTo }) }));
|
|
69
151
|
}
|
|
70
152
|
function renderComments(view, { config, setView }) {
|
|
71
153
|
return (_jsx(Comments, { story: view.story, config: config, onBack: () => setView(view.returnTo), onAskAI: (comments) => setView({ name: 'ask', story: view.story, comments, returnTo: view }) }));
|
package/dist/ui/AskAI.js
CHANGED
|
@@ -10,7 +10,7 @@ import { truncateTitle } from '../lib/format.js';
|
|
|
10
10
|
import { htmlToText } from '../lib/html.js';
|
|
11
11
|
import { wrapPlainText } from '../lib/viewport.js';
|
|
12
12
|
import { HEADER_ROWS } from './Layout.js';
|
|
13
|
-
import {
|
|
13
|
+
import { useTheme } from './theme.js';
|
|
14
14
|
async function resolveArticle(story) {
|
|
15
15
|
if (story.url) {
|
|
16
16
|
try {
|
|
@@ -25,6 +25,7 @@ async function resolveArticle(story) {
|
|
|
25
25
|
return { article: null, reason: 'text post' };
|
|
26
26
|
}
|
|
27
27
|
export function AskAI({ story, comments, config, onBack }) {
|
|
28
|
+
const theme = useTheme();
|
|
28
29
|
const { columns, rows } = useWindowSize();
|
|
29
30
|
const [status, setStatus] = useState(config ? 'preparing' : 'hint');
|
|
30
31
|
const [unavailableDetail, setUnavailableDetail] = useState('');
|
package/dist/ui/Comments.js
CHANGED
|
@@ -5,6 +5,7 @@ import open from 'open';
|
|
|
5
5
|
import { buildCommentsSummaryPrompt } from '../ai/summaryPrompts.js';
|
|
6
6
|
import { fetchComments } from '../api/algolia.js';
|
|
7
7
|
import { hnItemUrl } from '../api/firebase.js';
|
|
8
|
+
import { toggleBookmark } from '../db/bookmarks.js';
|
|
8
9
|
import { collapseAll, flattenTree, headerOnlyAll, rehideRevealed, revealHeaderOnly, toggleFold, } from '../lib/commentTree.js';
|
|
9
10
|
import { tokenizeContacts } from '../lib/contactHighlight.js';
|
|
10
11
|
import { formatAge } from '../lib/format.js';
|
|
@@ -15,7 +16,8 @@ import { COMMENTS_KEYS, footerRows } from './keymap.js';
|
|
|
15
16
|
import { HEADER_ROWS } from './Layout.js';
|
|
16
17
|
import { LoadingIndicator } from './LoadingIndicator.js';
|
|
17
18
|
import { SummaryPanel } from './SummaryPanel.js';
|
|
18
|
-
import {
|
|
19
|
+
import { useTheme } from './theme.js';
|
|
20
|
+
import { useFlash } from './useFlash.js';
|
|
19
21
|
const HEADER_BORDER_LINES = 2;
|
|
20
22
|
// borderStyle (2 cols) + paddingX (2 cols) eaten from the card's interior width.
|
|
21
23
|
const HEADER_FRAME_WIDTH = 4;
|
|
@@ -30,6 +32,7 @@ export function commentsHeaderLines(story, columns) {
|
|
|
30
32
|
return HEADER_BORDER_LINES + titleLines + 1 + urlLines;
|
|
31
33
|
}
|
|
32
34
|
export function Comments({ story, config, onBack, onAskAI }) {
|
|
35
|
+
const theme = useTheme();
|
|
33
36
|
const { columns, rows } = useWindowSize();
|
|
34
37
|
const headerLines = commentsHeaderLines(story, columns);
|
|
35
38
|
const viewportLines = Math.max(1, rows - HEADER_ROWS - footerRows(COMMENTS_KEYS, columns) - headerLines);
|
|
@@ -43,6 +46,7 @@ export function Comments({ story, config, onBack, onAskAI }) {
|
|
|
43
46
|
});
|
|
44
47
|
const [selected, setSelected] = useState(0);
|
|
45
48
|
const [summaryOpen, setSummaryOpen] = useState(false);
|
|
49
|
+
const [flashMessage, flash] = useFlash();
|
|
46
50
|
const pendingTopJump = useRef(false);
|
|
47
51
|
const topLineRef = useRef(0);
|
|
48
52
|
const { folded, headerOnly, revealed } = fold;
|
|
@@ -64,7 +68,7 @@ export function Comments({ story, config, onBack, onAskAI }) {
|
|
|
64
68
|
}
|
|
65
69
|
}
|
|
66
70
|
const flat = useMemo(() => flattenTree(comments, folded, headerOnly), [comments, folded, headerOnly]);
|
|
67
|
-
const rowsData = useMemo(() => flat.map((entry) => buildRow(entry, columns)), [flat, columns]);
|
|
71
|
+
const rowsData = useMemo(() => flat.map((entry) => buildRow(entry, columns, theme)), [flat, columns, theme]);
|
|
68
72
|
const clampedSelected = clampSelection(selected, 0, flat.length);
|
|
69
73
|
function openStory() {
|
|
70
74
|
void open(story.url ?? hnItemUrl(story.id));
|
|
@@ -119,6 +123,8 @@ export function Comments({ story, config, onBack, onAskAI }) {
|
|
|
119
123
|
return setSummaryOpen(true);
|
|
120
124
|
if (input === 'a')
|
|
121
125
|
return onAskAI(comments);
|
|
126
|
+
if (input === 'B')
|
|
127
|
+
return flash(toggleBookmark(story) ? 'bookmarked ✓' : 'bookmark removed');
|
|
122
128
|
}
|
|
123
129
|
useInput(handleInput, { isActive: !summaryOpen });
|
|
124
130
|
if (status === 'loading')
|
|
@@ -126,12 +132,13 @@ export function Comments({ story, config, onBack, onAskAI }) {
|
|
|
126
132
|
if (status === 'error')
|
|
127
133
|
return _jsxs(Text, { color: theme.colors.error, children: [error, " (r to retry)"] });
|
|
128
134
|
const panelHeight = summaryOpen ? Math.max(6, Math.floor(viewportLines / 2)) : 0;
|
|
129
|
-
const
|
|
135
|
+
const flashLineHeight = flashMessage ? 1 : 0;
|
|
136
|
+
const listViewportLines = Math.max(1, viewportLines - panelHeight - flashLineHeight);
|
|
130
137
|
const heights = rowsData.map((row) => row.lines.length);
|
|
131
138
|
const topLine = ensureVisibleLines(heights, clampedSelected, topLineRef.current, listViewportLines);
|
|
132
139
|
topLineRef.current = topLine;
|
|
133
140
|
const { first, last, clipTop, clipBottom } = sliceByLines(heights, topLine, listViewportLines);
|
|
134
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(CommentsHeader, { story: story }), flat.length === 0 && _jsx(Text, { dimColor: true, children: "no comments yet" }), rowsData.slice(first, last + 1).map((row, i) => {
|
|
141
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(CommentsHeader, { story: story }), flashMessage && _jsx(Text, { dimColor: true, children: flashMessage }), flat.length === 0 && _jsx(Text, { dimColor: true, children: "no comments yet" }), rowsData.slice(first, last + 1).map((row, i) => {
|
|
135
142
|
const rowIndex = first + i;
|
|
136
143
|
const start = rowIndex === first ? clipTop : 0;
|
|
137
144
|
const end = rowIndex === last ? row.lines.length - clipBottom : row.lines.length;
|
|
@@ -139,6 +146,7 @@ export function Comments({ story, config, onBack, onAskAI }) {
|
|
|
139
146
|
}), summaryOpen && (_jsx(SummaryPanel, { config: config, buildPrompt: () => Promise.resolve(buildCommentsSummaryPrompt(story, comments)), height: panelHeight, width: columns, onClose: () => setSummaryOpen(false) }))] }));
|
|
140
147
|
}
|
|
141
148
|
function CommentsHeader({ story }) {
|
|
149
|
+
const theme = useTheme();
|
|
142
150
|
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.colors.accent, paddingX: 1, children: [_jsx(Text, { bold: true, color: theme.colors.title, children: story.title }), _jsxs(Text, { dimColor: true, children: [theme.glyphs.upvote, " ", story.score, " points by ", story.by, " ", formatAge(story.time), " | ", story.descendants, ' ', "comments"] }), story.url && _jsx(Text, { color: theme.colors.accent, children: story.url })] }));
|
|
143
151
|
}
|
|
144
152
|
function replyBadge(entry) {
|
|
@@ -148,7 +156,7 @@ function replyBadge(entry) {
|
|
|
148
156
|
return `[${entry.descendantCount} more]`;
|
|
149
157
|
return entry.descendantCount === 1 ? '1 reply' : `${entry.descendantCount} replies`;
|
|
150
158
|
}
|
|
151
|
-
function buildHeaderSpans(entry) {
|
|
159
|
+
function buildHeaderSpans(entry, theme) {
|
|
152
160
|
const glyph = entry.headerOnly ? theme.glyphs.foldClosed : theme.glyphs.foldOpen;
|
|
153
161
|
const badge = replyBadge(entry);
|
|
154
162
|
const spans = [
|
|
@@ -160,24 +168,24 @@ function buildHeaderSpans(entry) {
|
|
|
160
168
|
spans.push({ text: ` | ${badge}`, color: theme.colors.muted });
|
|
161
169
|
return spans;
|
|
162
170
|
}
|
|
163
|
-
function tokenToSpan(token) {
|
|
171
|
+
function tokenToSpan(token, theme) {
|
|
164
172
|
if (token.kind === 'link')
|
|
165
173
|
return { text: token.text, color: theme.colors.link, underline: true };
|
|
166
174
|
if (token.kind === 'email')
|
|
167
175
|
return { text: token.text, color: theme.colors.email, bold: true };
|
|
168
176
|
return { text: token.text };
|
|
169
177
|
}
|
|
170
|
-
function buildRow(entry, columns) {
|
|
178
|
+
function buildRow(entry, columns, theme) {
|
|
171
179
|
// Reserve 2 columns for the selection bar so wrapping doesn't shift when selection moves,
|
|
172
180
|
// plus 1 trailing column: a line that exactly fills the terminal width triggers a VT100
|
|
173
181
|
// delayed-wrap that drops the selection bar/stripe from the following line.
|
|
174
182
|
const width = Math.max(1, columns - 1 - entry.depth * 2 - ROW_BORDER_WIDTH);
|
|
175
|
-
const header = { kind: 'header', spans: buildHeaderSpans(entry) };
|
|
183
|
+
const header = { kind: 'header', spans: buildHeaderSpans(entry, theme) };
|
|
176
184
|
const body = entry.headerOnly
|
|
177
185
|
? []
|
|
178
186
|
: wrapPlainText(htmlToText(entry.node.text), width).map((line) => ({
|
|
179
187
|
kind: 'body',
|
|
180
|
-
spans: tokenizeContacts(line).map(tokenToSpan),
|
|
188
|
+
spans: tokenizeContacts(line).map((token) => tokenToSpan(token, theme)),
|
|
181
189
|
}));
|
|
182
190
|
return { id: entry.node.id, depth: entry.depth, width, lines: [header, ...body] };
|
|
183
191
|
}
|
|
@@ -185,6 +193,7 @@ function spanLength(spans) {
|
|
|
185
193
|
return spans.reduce((sum, span) => sum + span.text.length, 0);
|
|
186
194
|
}
|
|
187
195
|
function CommentRowView({ depth, width, lines, isSelected }) {
|
|
196
|
+
const theme = useTheme();
|
|
188
197
|
const background = isSelected ? theme.colors.selectionBackground : undefined;
|
|
189
198
|
const prefix = isSelected ? `${SELECTION_BAR} ` : ' ';
|
|
190
199
|
const rowWidth = width + ROW_BORDER_WIDTH;
|
package/dist/ui/HelpOverlay.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { GLOBAL_KEYS } from './keymap.js';
|
|
4
|
-
import {
|
|
4
|
+
import { useTheme } from './theme.js';
|
|
5
5
|
export function HelpOverlay({ title, keys }) {
|
|
6
|
+
const theme = useTheme();
|
|
6
7
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.colors.title, children: title }), keys.map((binding) => (_jsx(KeyRow, { binding: binding }, binding.key))), _jsx(Text, { children: " " }), _jsx(Text, { color: theme.colors.title, children: "global" }), GLOBAL_KEYS.map((binding) => (_jsx(KeyRow, { binding: binding }, binding.key))), _jsx(Text, { dimColor: true, children: "any key closes" })] }));
|
|
7
8
|
}
|
|
8
9
|
function KeyRow({ binding }) {
|
package/dist/ui/Layout.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text, useWindowSize } from 'ink';
|
|
3
|
-
import {
|
|
3
|
+
import { useTheme } from './theme.js';
|
|
4
4
|
const MIN_ROWS = 8;
|
|
5
5
|
export const HEADER_ROWS = 3;
|
|
6
6
|
export function Screen({ children }) {
|
|
@@ -17,5 +17,6 @@ export function Body({ children }) {
|
|
|
17
17
|
return (_jsx(Box, { flexGrow: 1, overflowY: "hidden", flexDirection: "column", children: children }));
|
|
18
18
|
}
|
|
19
19
|
export function Footer({ children }) {
|
|
20
|
+
const theme = useTheme();
|
|
20
21
|
return (_jsx(Box, { flexShrink: 0, children: _jsx(Text, { color: theme.colors.muted, children: children }) }));
|
|
21
22
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useState } from 'react';
|
|
3
3
|
import { Text } from 'ink';
|
|
4
|
-
import {
|
|
4
|
+
import { useTheme } from './theme.js';
|
|
5
5
|
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
6
6
|
const FRAME_INTERVAL_MS = 80;
|
|
7
7
|
export function LoadingIndicator({ label }) {
|
|
8
|
+
const theme = useTheme();
|
|
8
9
|
const [frame, setFrame] = useState(0);
|
|
9
10
|
useEffect(() => {
|
|
10
11
|
const id = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), FRAME_INTERVAL_MS);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useRef, useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput, useWindowSize } from 'ink';
|
|
4
|
+
import open from 'open';
|
|
5
|
+
import { buildListSummaryPrompt } from '../ai/summaryPrompts.js';
|
|
6
|
+
import { hnItemUrl } from '../api/firebase.js';
|
|
7
|
+
import { listBookmarks, toggleBookmark } from '../db/bookmarks.js';
|
|
8
|
+
import { clampSelection, mapFeedKey } from '../lib/listNavigation.js';
|
|
9
|
+
import { ensureVisibleLines } from '../lib/viewport.js';
|
|
10
|
+
import { footerRows, SAVED_KEYS } from './keymap.js';
|
|
11
|
+
import { HEADER_ROWS } from './Layout.js';
|
|
12
|
+
import { STORY_ROW_HEIGHT } from './StoryRow.js';
|
|
13
|
+
import { StoryListView } from './StoryListView.js';
|
|
14
|
+
import { SummaryPanel } from './SummaryPanel.js';
|
|
15
|
+
import { useFlash } from './useFlash.js';
|
|
16
|
+
export function SavedList({ config, onSelectStory, onFeedChange, onTabChange, onSearchRequested, onAskAI, }) {
|
|
17
|
+
const { columns, rows } = useWindowSize();
|
|
18
|
+
const bodyHeight = Math.max(1, rows - HEADER_ROWS - footerRows(SAVED_KEYS, columns));
|
|
19
|
+
const [stories, setStories] = useState(() => listBookmarks());
|
|
20
|
+
const [selected, setSelected] = useState(0);
|
|
21
|
+
const [summaryOpen, setSummaryOpen] = useState(false);
|
|
22
|
+
const [flashMessage, flash] = useFlash();
|
|
23
|
+
const pendingTopJump = useRef(false);
|
|
24
|
+
const topLineRef = useRef(0);
|
|
25
|
+
function reload() {
|
|
26
|
+
const next = listBookmarks();
|
|
27
|
+
setStories(next);
|
|
28
|
+
setSelected((s) => clampSelection(s, 0, next.length));
|
|
29
|
+
}
|
|
30
|
+
function openSelectedStory() {
|
|
31
|
+
const story = stories[selected];
|
|
32
|
+
if (story)
|
|
33
|
+
void open(story.url ?? hnItemUrl(story.id));
|
|
34
|
+
}
|
|
35
|
+
function removeSelectedBookmark() {
|
|
36
|
+
const story = stories[selected];
|
|
37
|
+
if (!story)
|
|
38
|
+
return;
|
|
39
|
+
toggleBookmark(story);
|
|
40
|
+
flash('bookmark removed');
|
|
41
|
+
reload();
|
|
42
|
+
}
|
|
43
|
+
function handleInput(input, key) {
|
|
44
|
+
const isG = input === 'g';
|
|
45
|
+
if (isG && pendingTopJump.current) {
|
|
46
|
+
pendingTopJump.current = false;
|
|
47
|
+
return setSelected(0);
|
|
48
|
+
}
|
|
49
|
+
pendingTopJump.current = isG;
|
|
50
|
+
const targetFeed = mapFeedKey(input);
|
|
51
|
+
if (targetFeed)
|
|
52
|
+
return onFeedChange(targetFeed);
|
|
53
|
+
if (key.leftArrow)
|
|
54
|
+
return onTabChange(-1);
|
|
55
|
+
if (key.rightArrow)
|
|
56
|
+
return onTabChange(1);
|
|
57
|
+
if (input === '/')
|
|
58
|
+
return onSearchRequested();
|
|
59
|
+
if (input === 'j' || key.downArrow)
|
|
60
|
+
return setSelected((s) => clampSelection(s, 1, stories.length));
|
|
61
|
+
if (input === 'k' || key.upArrow)
|
|
62
|
+
return setSelected((s) => clampSelection(s, -1, stories.length));
|
|
63
|
+
if (input === 'G')
|
|
64
|
+
return setSelected(Math.max(0, stories.length - 1));
|
|
65
|
+
if (input === 'o')
|
|
66
|
+
return openSelectedStory();
|
|
67
|
+
if (input === 'r')
|
|
68
|
+
return reload();
|
|
69
|
+
if (input === 'B' && stories[selected])
|
|
70
|
+
return removeSelectedBookmark();
|
|
71
|
+
if (input === 's' && stories[selected])
|
|
72
|
+
return setSummaryOpen(true);
|
|
73
|
+
if (input === 'a' && stories[selected])
|
|
74
|
+
return onAskAI(stories[selected]);
|
|
75
|
+
if (key.return && stories[selected])
|
|
76
|
+
return onSelectStory(stories[selected]);
|
|
77
|
+
}
|
|
78
|
+
useInput(handleInput, { isActive: !summaryOpen });
|
|
79
|
+
const panelHeight = summaryOpen ? Math.max(6, Math.floor(bodyHeight / 2)) : 0;
|
|
80
|
+
const statusLineHeight = flashMessage ? 1 : 0;
|
|
81
|
+
const listHeight = Math.max(1, bodyHeight - statusLineHeight - panelHeight);
|
|
82
|
+
const selectedStory = stories[selected];
|
|
83
|
+
if (stories.length === 0) {
|
|
84
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "no bookmarks yet \u2014 press B on a story" }), flashMessage && _jsx(Text, { dimColor: true, children: flashMessage })] }));
|
|
85
|
+
}
|
|
86
|
+
const heights = stories.map(() => STORY_ROW_HEIGHT);
|
|
87
|
+
const topLine = ensureVisibleLines(heights, selected, topLineRef.current, listHeight);
|
|
88
|
+
topLineRef.current = topLine;
|
|
89
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StoryListView, { stories: stories, selected: selected, topLine: topLine, height: listHeight, width: columns }), flashMessage && _jsx(Text, { dimColor: true, children: flashMessage }), summaryOpen && selectedStory && (_jsx(SummaryPanel, { config: config, buildPrompt: (signal) => buildListSummaryPrompt(selectedStory, signal), height: panelHeight, width: columns, onClose: () => setSummaryOpen(false) }))] }));
|
|
90
|
+
}
|
package/dist/ui/SearchResults.js
CHANGED
|
@@ -5,6 +5,7 @@ import open from 'open';
|
|
|
5
5
|
import { buildListSummaryPrompt } from '../ai/summaryPrompts.js';
|
|
6
6
|
import { searchStories } from '../api/algolia.js';
|
|
7
7
|
import { hnItemUrl } from '../api/firebase.js';
|
|
8
|
+
import { toggleBookmark } from '../db/bookmarks.js';
|
|
8
9
|
import { clampSelection } from '../lib/listNavigation.js';
|
|
9
10
|
import { ensureVisibleLines, shouldFetchMore } from '../lib/viewport.js';
|
|
10
11
|
import { footerRows, SEARCH_RESULTS_KEYS } from './keymap.js';
|
|
@@ -13,10 +14,12 @@ import { LoadingIndicator } from './LoadingIndicator.js';
|
|
|
13
14
|
import { STORY_ROW_HEIGHT } from './StoryRow.js';
|
|
14
15
|
import { StoryListView } from './StoryListView.js';
|
|
15
16
|
import { SummaryPanel } from './SummaryPanel.js';
|
|
16
|
-
import {
|
|
17
|
+
import { useTheme } from './theme.js';
|
|
18
|
+
import { useFlash } from './useFlash.js';
|
|
17
19
|
const FETCH_THRESHOLD = 10;
|
|
18
20
|
const HEADER_LINES = 1;
|
|
19
|
-
export function SearchResults({ query, config, onSelectStory, onExit, onSearchAgain, onAskAI, }) {
|
|
21
|
+
export function SearchResults({ query, config, onSelectStory, onExit, onSearchAgain, onAskAI, onSubscribe, }) {
|
|
22
|
+
const theme = useTheme();
|
|
20
23
|
const { columns, rows } = useWindowSize();
|
|
21
24
|
const bodyHeight = Math.max(1, rows - HEADER_ROWS - footerRows(SEARCH_RESULTS_KEYS, columns) - HEADER_LINES);
|
|
22
25
|
const [stories, setStories] = useState([]);
|
|
@@ -27,6 +30,7 @@ export function SearchResults({ query, config, onSelectStory, onExit, onSearchAg
|
|
|
27
30
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
28
31
|
const [error, setError] = useState('');
|
|
29
32
|
const [summaryOpen, setSummaryOpen] = useState(false);
|
|
33
|
+
const [flashMessage, flash] = useFlash();
|
|
30
34
|
const token = useRef(0);
|
|
31
35
|
const nextPage = useRef(0);
|
|
32
36
|
const topLineRef = useRef(0);
|
|
@@ -110,6 +114,10 @@ export function SearchResults({ query, config, onSelectStory, onExit, onSearchAg
|
|
|
110
114
|
return setSummaryOpen(true);
|
|
111
115
|
if (input === 'a' && stories[selected])
|
|
112
116
|
return onAskAI(stories[selected]);
|
|
117
|
+
if (input === 'B' && stories[selected])
|
|
118
|
+
return flash(toggleBookmark(stories[selected]) ? 'bookmarked ✓' : 'bookmark removed');
|
|
119
|
+
if (input === 'S')
|
|
120
|
+
return onSubscribe();
|
|
113
121
|
if (key.return && stories[selected])
|
|
114
122
|
return onSelectStory(stories[selected]);
|
|
115
123
|
}
|
|
@@ -119,10 +127,11 @@ export function SearchResults({ query, config, onSelectStory, onExit, onSearchAg
|
|
|
119
127
|
if (status === 'error')
|
|
120
128
|
return _jsxs(Text, { color: theme.colors.error, children: [error, " (r to retry)"] });
|
|
121
129
|
const panelHeight = summaryOpen ? Math.max(6, Math.floor(bodyHeight / 2)) : 0;
|
|
122
|
-
const
|
|
130
|
+
const statusLineHeight = loadingMore || flashMessage ? 1 : 0;
|
|
131
|
+
const listHeight = Math.max(1, bodyHeight - statusLineHeight - panelHeight);
|
|
123
132
|
const heights = stories.map(() => STORY_ROW_HEIGHT);
|
|
124
133
|
const topLine = ensureVisibleLines(heights, selected, topLineRef.current, listHeight);
|
|
125
134
|
topLineRef.current = topLine;
|
|
126
135
|
const selectedStory = stories[selected];
|
|
127
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["search: ", query, totalHits !== null ? ` ${totalHits} results` : ''] }), _jsx(StoryListView, { stories: stories, selected: selected, topLine: topLine, height: listHeight, width: columns }), loadingMore && _jsx(Text, { dimColor: true, children: "loading more\u2026" }), summaryOpen && selectedStory && (_jsx(SummaryPanel, { config: config, buildPrompt: (signal) => buildListSummaryPrompt(selectedStory, signal), height: panelHeight, width: columns, onClose: () => setSummaryOpen(false) }))] }));
|
|
136
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["search: ", query, totalHits !== null ? ` ${totalHits} results` : ''] }), _jsx(StoryListView, { stories: stories, selected: selected, topLine: topLine, height: listHeight, width: columns }), flashMessage ? _jsx(Text, { dimColor: true, children: flashMessage }) : loadingMore && _jsx(Text, { dimColor: true, children: "loading more\u2026" }), summaryOpen && selectedStory && (_jsx(SummaryPanel, { config: config, buildPrompt: (signal) => buildListSummaryPrompt(selectedStory, signal), height: panelHeight, width: columns, onClose: () => setSummaryOpen(false) }))] }));
|
|
128
137
|
}
|
package/dist/ui/StoryList.js
CHANGED
|
@@ -4,7 +4,8 @@ import { Box, Text, useInput, useWindowSize } from 'ink';
|
|
|
4
4
|
import open from 'open';
|
|
5
5
|
import { buildListSummaryPrompt } from '../ai/summaryPrompts.js';
|
|
6
6
|
import { fetchStories, fetchStoryIds, hnItemUrl } from '../api/firebase.js';
|
|
7
|
-
import {
|
|
7
|
+
import { toggleBookmark } from '../db/bookmarks.js';
|
|
8
|
+
import { clampSelection, mapFeedKey } from '../lib/listNavigation.js';
|
|
8
9
|
import { ensureVisibleLines, shouldFetchMore } from '../lib/viewport.js';
|
|
9
10
|
import { footerRows, LIST_KEYS } from './keymap.js';
|
|
10
11
|
import { HEADER_ROWS } from './Layout.js';
|
|
@@ -12,10 +13,12 @@ import { LoadingIndicator } from './LoadingIndicator.js';
|
|
|
12
13
|
import { STORY_ROW_HEIGHT } from './StoryRow.js';
|
|
13
14
|
import { StoryListView } from './StoryListView.js';
|
|
14
15
|
import { SummaryPanel } from './SummaryPanel.js';
|
|
15
|
-
import {
|
|
16
|
+
import { useTheme } from './theme.js';
|
|
17
|
+
import { useFlash } from './useFlash.js';
|
|
16
18
|
const BATCH_SIZE = 30;
|
|
17
19
|
const FETCH_THRESHOLD = 10;
|
|
18
|
-
export function StoryList({ feed, config, onFeedChange, onSelectStory, onSearchRequested, onAskAI, }) {
|
|
20
|
+
export function StoryList({ feed, config, onFeedChange, onTabChange, onSelectStory, onSearchRequested, onAskAI, }) {
|
|
21
|
+
const theme = useTheme();
|
|
19
22
|
const { columns, rows } = useWindowSize();
|
|
20
23
|
const bodyHeight = Math.max(1, rows - HEADER_ROWS - footerRows(LIST_KEYS, columns));
|
|
21
24
|
const [storyIds, setStoryIds] = useState([]);
|
|
@@ -25,6 +28,7 @@ export function StoryList({ feed, config, onFeedChange, onSelectStory, onSearchR
|
|
|
25
28
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
26
29
|
const [error, setError] = useState('');
|
|
27
30
|
const [summaryOpen, setSummaryOpen] = useState(false);
|
|
31
|
+
const [flashMessage, flash] = useFlash();
|
|
28
32
|
const token = useRef(0);
|
|
29
33
|
const pendingTopJump = useRef(false);
|
|
30
34
|
const topLineRef = useRef(0);
|
|
@@ -97,9 +101,9 @@ export function StoryList({ feed, config, onFeedChange, onSelectStory, onSearchR
|
|
|
97
101
|
if (targetFeed)
|
|
98
102
|
return onFeedChange(targetFeed);
|
|
99
103
|
if (key.leftArrow)
|
|
100
|
-
return
|
|
104
|
+
return onTabChange(-1);
|
|
101
105
|
if (key.rightArrow)
|
|
102
|
-
return
|
|
106
|
+
return onTabChange(1);
|
|
103
107
|
if (input === '/')
|
|
104
108
|
return onSearchRequested();
|
|
105
109
|
if (input === 'j' || key.downArrow)
|
|
@@ -116,6 +120,8 @@ export function StoryList({ feed, config, onFeedChange, onSelectStory, onSearchR
|
|
|
116
120
|
return setSummaryOpen(true);
|
|
117
121
|
if (input === 'a' && stories[selected])
|
|
118
122
|
return onAskAI(stories[selected]);
|
|
123
|
+
if (input === 'B' && stories[selected])
|
|
124
|
+
return flash(toggleBookmark(stories[selected]) ? 'bookmarked ✓' : 'bookmark removed');
|
|
119
125
|
if (key.return && stories[selected])
|
|
120
126
|
return onSelectStory(stories[selected]);
|
|
121
127
|
}
|
|
@@ -125,10 +131,11 @@ export function StoryList({ feed, config, onFeedChange, onSelectStory, onSearchR
|
|
|
125
131
|
if (status === 'error')
|
|
126
132
|
return _jsxs(Text, { color: theme.colors.error, children: [error, " (r to retry)"] });
|
|
127
133
|
const panelHeight = summaryOpen ? Math.max(6, Math.floor(bodyHeight / 2)) : 0;
|
|
128
|
-
const
|
|
134
|
+
const statusLineHeight = loadingMore || flashMessage ? 1 : 0;
|
|
135
|
+
const listHeight = Math.max(1, bodyHeight - statusLineHeight - panelHeight);
|
|
129
136
|
const heights = stories.map(() => STORY_ROW_HEIGHT);
|
|
130
137
|
const topLine = ensureVisibleLines(heights, selected, topLineRef.current, listHeight);
|
|
131
138
|
topLineRef.current = topLine;
|
|
132
139
|
const selectedStory = stories[selected];
|
|
133
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StoryListView, { stories: stories, selected: selected, topLine: topLine, height: listHeight, width: columns }), loadingMore && _jsx(Text, { dimColor: true, children: "loading more\u2026" }), summaryOpen && selectedStory && (_jsx(SummaryPanel, { config: config, buildPrompt: (signal) => buildListSummaryPrompt(selectedStory, signal), height: panelHeight, width: columns, onClose: () => setSummaryOpen(false) }))] }));
|
|
140
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StoryListView, { stories: stories, selected: selected, topLine: topLine, height: listHeight, width: columns }), flashMessage ? _jsx(Text, { dimColor: true, children: flashMessage }) : loadingMore && _jsx(Text, { dimColor: true, children: "loading more\u2026" }), summaryOpen && selectedStory && (_jsx(SummaryPanel, { config: config, buildPrompt: (signal) => buildListSummaryPrompt(selectedStory, signal), height: panelHeight, width: columns, onClose: () => setSummaryOpen(false) }))] }));
|
|
134
141
|
}
|
package/dist/ui/StoryRow.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
+
import { isBookmarked } from '../db/bookmarks.js';
|
|
3
4
|
import { formatAge, truncateTitle } from '../lib/format.js';
|
|
4
5
|
import { getHostname } from '../lib/url.js';
|
|
5
|
-
import {
|
|
6
|
+
import { useTheme } from './theme.js';
|
|
6
7
|
export const STORY_ROW_HEIGHT = 3;
|
|
7
8
|
function fillPad(isSelected, safeWidth, usedLength) {
|
|
8
9
|
return isSelected ? ' '.repeat(Math.max(0, safeWidth - usedLength)) : '';
|
|
@@ -11,6 +12,7 @@ function metaText(story) {
|
|
|
11
12
|
return `${story.score} points by ${story.by} ${formatAge(story.time)} ago | ${story.descendants} comments`;
|
|
12
13
|
}
|
|
13
14
|
export function StoryRow({ story, rank, rankWidth, isSelected, width, showMeta = true, showSeparator = true, }) {
|
|
15
|
+
const theme = useTheme();
|
|
14
16
|
// Never let a line's content reach the literal terminal edge: a line that exactly
|
|
15
17
|
// fills the width triggers a VT100 delayed-wrap that corrupts Ink's cursor position
|
|
16
18
|
// for the row below it.
|
|
@@ -22,6 +24,8 @@ export function StoryRow({ story, rank, rankWidth, isSelected, width, showMeta =
|
|
|
22
24
|
const titleWidth = Math.max(1, safeWidth - prefix.length - hostnameSuffix.length);
|
|
23
25
|
const title = truncateTitle(story.title, titleWidth);
|
|
24
26
|
const background = isSelected ? theme.colors.selectionBackground : undefined;
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
+
const bookmarked = isBookmarked(story.id);
|
|
28
|
+
const starPrefix = bookmarked ? `${theme.glyphs.bookmark} ` : '';
|
|
29
|
+
const meta = `${starPrefix}${metaText(story)}`;
|
|
30
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { backgroundColor: background, children: [prefix, _jsx(Text, { bold: true, color: theme.colors.title, children: title }), hostname && _jsx(Text, { color: theme.colors.muted, children: hostnameSuffix }), fillPad(isSelected, safeWidth, prefix.length + title.length + hostnameSuffix.length)] }), showMeta && (_jsxs(Text, { backgroundColor: background, children: [' '.repeat(prefix.length), bookmarked && _jsx(Text, { color: theme.colors.accent, children: starPrefix }), _jsxs(Text, { color: theme.colors.score, children: [story.score, " points"] }), _jsxs(Text, { color: theme.colors.muted, children: [' ', "by ", story.by, " ", formatAge(story.time), " ago | ", story.descendants, " comments"] }), fillPad(isSelected, safeWidth, prefix.length + meta.length)] })), showSeparator && _jsx(Text, { children: " " })] }));
|
|
27
31
|
}
|