@ahmd-sh/hntui 0.3.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.
@@ -0,0 +1,84 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { Effect, Layer } from "effect"
3
+ import { HnApiLive, HnTransport, fetchItem, resolveStory } from "./hn"
4
+
5
+ // A transport made of canned JSON. The REAL HnApiLive layer — schema
6
+ // decoding, the item cache, resolveStory's parent walk — runs on top of it,
7
+ // hermetically. Before Layers this required monkeypatching globalThis.fetch.
8
+ const apiWith = (db: Record<string, unknown>, log?: string[]) =>
9
+ HnApiLive.pipe(
10
+ Layer.provide(
11
+ Layer.succeed(HnTransport, {
12
+ getJson: (path) =>
13
+ Effect.sync(() => {
14
+ log?.push(path)
15
+ return db[path] ?? null
16
+ }),
17
+ }),
18
+ ),
19
+ )
20
+
21
+ const DB = {
22
+ "item/1.json": { id: 1, type: "story", title: "Root story", kids: [2, 4] },
23
+ "item/2.json": { id: 2, type: "comment", parent: 1, text: "child" },
24
+ "item/3.json": { id: 3, type: "comment", parent: 2, text: "grandchild" },
25
+ "item/4.json": { id: 4, type: "comment", parent: 1, deleted: true },
26
+ "item/5.json": { id: 5, type: "story", title: "Deleted story", deleted: true },
27
+ "item/9.json": { id: "not-a-number", type: "story" },
28
+ }
29
+
30
+ describe("resolveStory (hermetic, via test transport layer)", () => {
31
+ test("a grandchild comment walks up to the root story and focuses itself", async () => {
32
+ const r = await Effect.runPromise(Effect.provide(resolveStory({ id: 3 }), apiWith(DB)))
33
+ expect(r.story.id).toBe(1)
34
+ expect(r.focusId).toBe(3)
35
+ })
36
+
37
+ test("an explicit #anchor wins over the walked-from comment", async () => {
38
+ const r = await Effect.runPromise(
39
+ Effect.provide(resolveStory({ id: 1, anchorId: 2 }), apiWith(DB)),
40
+ )
41
+ expect(r.story.id).toBe(1)
42
+ expect(r.focusId).toBe(2)
43
+ })
44
+
45
+ test("an unknown id fails with HnItemGone", async () => {
46
+ const e = await Effect.runPromise(
47
+ Effect.provide(Effect.flip(resolveStory({ id: 404 })), apiWith(DB)),
48
+ )
49
+ expect(e._tag).toBe("HnItemGone")
50
+ })
51
+
52
+ test("a deleted story fails with HnItemGone", async () => {
53
+ const e = await Effect.runPromise(
54
+ Effect.provide(Effect.flip(resolveStory({ id: 5 })), apiWith(DB)),
55
+ )
56
+ expect(e._tag).toBe("HnItemGone")
57
+ })
58
+
59
+ test("a deleted comment still walks through to its living root", async () => {
60
+ const r = await Effect.runPromise(Effect.provide(resolveStory({ id: 4 }), apiWith(DB)))
61
+ expect(r.story.id).toBe(1)
62
+ expect(r.focusId).toBe(4)
63
+ })
64
+
65
+ test("a payload that violates the schema fails with HnDecodeError", async () => {
66
+ const e = await Effect.runPromise(
67
+ Effect.provide(Effect.flip(fetchItem(9)), apiWith(DB)),
68
+ )
69
+ expect(e._tag).toBe("HnDecodeError")
70
+ })
71
+ })
72
+
73
+ describe("item cache (hermetic)", () => {
74
+ test("repeated fetches of one id hit the transport once", async () => {
75
+ const log: string[] = []
76
+ const program = Effect.gen(function* () {
77
+ yield* fetchItem(1)
78
+ yield* fetchItem(1)
79
+ yield* fetchItem(1)
80
+ })
81
+ await Effect.runPromise(Effect.provide(program, apiWith(DB, log)))
82
+ expect(log.filter((p) => p === "item/1.json")).toHaveLength(1)
83
+ })
84
+ })
package/src/api/hn.ts ADDED
@@ -0,0 +1,200 @@
1
+ import { Array as Arr, Cache, Context, Data, Duration, Effect, Layer, Schedule, Schema } from "effect"
2
+ import { ItemSchema } from "./types"
3
+ import type { FeedCategory, Item } from "./types"
4
+ import type { HnItemRef } from "../utils/format"
5
+
6
+ const BASE = "https://hacker-news.firebaseio.com/v0"
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Errors — every way a call can fail, as its own named type.
10
+ // These show up in the *type* of each effect below.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export class HnRequestError extends Data.TaggedError("HnRequestError")<{
14
+ readonly path: string
15
+ readonly cause: unknown
16
+ }> {}
17
+
18
+ export class HnStatusError extends Data.TaggedError("HnStatusError")<{
19
+ readonly path: string
20
+ readonly status: number
21
+ }> {}
22
+
23
+ export class HnTimeoutError extends Data.TaggedError("HnTimeoutError")<{
24
+ readonly path: string
25
+ }> {}
26
+
27
+ export class HnDecodeError extends Data.TaggedError("HnDecodeError")<{
28
+ readonly path: string
29
+ readonly issue: string
30
+ }> {}
31
+
32
+ export type HnError = HnRequestError | HnStatusError | HnTimeoutError | HnDecodeError
33
+
34
+ // Not an API failure — a link resolved to nothing viewable.
35
+ export class HnItemGone extends Data.TaggedError("HnItemGone")<{
36
+ readonly id: number
37
+ }> {}
38
+
39
+ export interface ResolvedLink {
40
+ story: Item
41
+ // the comment the link pointed at, to focus after opening (best effort)
42
+ focusId?: number
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // HnTransport — the service that produces raw JSON. This is the app's only
47
+ // contact with the outside world; everything above it is policy and domain
48
+ // logic. Swapping this layer swaps the data source (live / demo / test).
49
+ // ---------------------------------------------------------------------------
50
+
51
+ export interface HnTransportShape {
52
+ readonly getJson: (path: string) => Effect.Effect<unknown, HnError>
53
+ }
54
+
55
+ export class HnTransport extends Context.Tag("HnTransport")<HnTransport, HnTransportShape>() {}
56
+
57
+ // The live transport: fetch against Firebase. Timeout aborts the underlying
58
+ // request (via the injected AbortSignal); transient failures retry with
59
+ // exponential backoff, client errors don't.
60
+ export const makeLiveGetJson =
61
+ (): HnTransportShape["getJson"] =>
62
+ (path) =>
63
+ Effect.gen(function* () {
64
+ const res = yield* Effect.tryPromise({
65
+ try: (signal) => fetch(`${BASE}/${path}`, { signal }),
66
+ catch: (cause) => new HnRequestError({ path, cause }),
67
+ })
68
+ if (!res.ok) {
69
+ return yield* new HnStatusError({ path, status: res.status })
70
+ }
71
+ return yield* Effect.tryPromise({
72
+ try: () => res.json() as Promise<unknown>,
73
+ catch: (cause) => new HnRequestError({ path, cause }),
74
+ })
75
+ }).pipe(
76
+ Effect.timeoutFail({
77
+ duration: "5 seconds",
78
+ onTimeout: () => new HnTimeoutError({ path }),
79
+ }),
80
+ Effect.retry({
81
+ schedule: Schedule.exponential("250 millis"),
82
+ times: 2,
83
+ while: (e) => e._tag !== "HnStatusError" || e.status >= 500,
84
+ }),
85
+ )
86
+
87
+ export const HnTransportLive = Layer.succeed(HnTransport, { getJson: makeLiveGetJson() })
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // HnApi — the domain service: feeds, items, link resolution. Its layer asks
91
+ // for a transport and builds the item cache, so the cache lives exactly as
92
+ // long as the runtime that built the layer — not "forever, from module
93
+ // import" like the old module-global Effect.runSync version.
94
+ // ---------------------------------------------------------------------------
95
+
96
+ export interface HnApiShape {
97
+ readonly fetchIds: (category: FeedCategory) => Effect.Effect<number[], HnError>
98
+ readonly fetchItem: (id: number) => Effect.Effect<Item | null, HnError>
99
+ readonly fetchItems: (ids: ReadonlyArray<number>, concurrency?: number) => Effect.Effect<Item[]>
100
+ readonly resolveStory: (ref: HnItemRef) => Effect.Effect<ResolvedLink, HnError | HnItemGone>
101
+ }
102
+
103
+ export class HnApi extends Context.Tag("HnApi")<HnApi, HnApiShape>() {}
104
+
105
+ // HN returns `null` (with a 200) for deleted/nonexistent ids — the schemas
106
+ // say so explicitly instead of a cast papering over it.
107
+ const IdsPayload = Schema.NullOr(Schema.mutable(Schema.Array(Schema.Number)))
108
+ const ItemPayload = Schema.NullOr(ItemSchema)
109
+
110
+ const isComment = (i: Item) => i.type === "comment" || i.type === "pollopt"
111
+
112
+ export const HnApiLive = Layer.effect(
113
+ HnApi,
114
+ Effect.gen(function* () {
115
+ const transport = yield* HnTransport
116
+
117
+ // GET + validate: the `unknown` from the wire only becomes an A by
118
+ // passing through the schema.
119
+ const getDecoded = <A, I>(
120
+ path: string,
121
+ schema: Schema.Schema<A, I, never>,
122
+ ): Effect.Effect<A, HnError> =>
123
+ transport.getJson(path).pipe(
124
+ Effect.flatMap(Schema.decodeUnknown(schema)),
125
+ Effect.catchTag("ParseError", (e) =>
126
+ Effect.fail(new HnDecodeError({ path, issue: e.message })),
127
+ ),
128
+ )
129
+
130
+ // Concurrent lookups of the same id share one request, successes are
131
+ // memoized, failures are evicted so the next attempt refetches.
132
+ const cache = yield* Cache.make({
133
+ capacity: 50_000,
134
+ timeToLive: Duration.infinity,
135
+ lookup: (id: number) => getDecoded(`item/${id}.json`, ItemPayload),
136
+ })
137
+
138
+ const fetchIds: HnApiShape["fetchIds"] = (category) =>
139
+ getDecoded(`${category}stories.json`, IdsPayload).pipe(Effect.map((ids) => ids ?? []))
140
+
141
+ const fetchItem: HnApiShape["fetchItem"] = (id) =>
142
+ cache.get(id).pipe(Effect.tapError(() => cache.invalidate(id)))
143
+
144
+ // Fetch many items, at most `concurrency` in flight, failures and null
145
+ // items dropped, input order preserved.
146
+ const fetchItems: HnApiShape["fetchItems"] = (ids, concurrency = 10) =>
147
+ Effect.forEach(ids, (id) => fetchItem(id).pipe(Effect.option), {
148
+ concurrency,
149
+ }).pipe(
150
+ Effect.map((opts) => Arr.getSomes(opts).filter((x): x is Item => x !== null)),
151
+ )
152
+
153
+ // An /item?id=N link may point at a comment: walk `parent` upward until
154
+ // the root story. Every hop inherits retry/timeout/caching from
155
+ // fetchItem, and interruption aborts mid-chain.
156
+ const resolveStory: HnApiShape["resolveStory"] = (ref) =>
157
+ Effect.gen(function* () {
158
+ const gone = () => new HnItemGone({ id: ref.id })
159
+ const first = yield* fetchItem(ref.id)
160
+ if (!first) return yield* gone()
161
+ // deleted comments still carry `parent`, so we can walk through them
162
+ const fromComment = isComment(first) ? first.id : undefined
163
+ let cur: Item = first
164
+ let hops = 0
165
+ while (isComment(cur)) {
166
+ if (cur.parent == null || ++hops > 64) return yield* gone()
167
+ const parent: Item | null = yield* fetchItem(cur.parent)
168
+ if (!parent) return yield* gone()
169
+ cur = parent
170
+ }
171
+ if (cur.deleted || cur.dead) return yield* gone()
172
+ return { story: cur, focusId: ref.anchorId ?? fromComment }
173
+ })
174
+
175
+ return HnApi.of({ fetchIds, fetchItem, fetchItems, resolveStory })
176
+ }),
177
+ )
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Accessors — call sites keep the exact shape they had. The only change is
181
+ // in the type: `HnApi` in the R channel names the dependency, and the
182
+ // compiler refuses to run these until something provides it.
183
+ // ---------------------------------------------------------------------------
184
+
185
+ export const fetchIds = (category: FeedCategory): Effect.Effect<number[], HnError, HnApi> =>
186
+ Effect.flatMap(HnApi, (api) => api.fetchIds(category))
187
+
188
+ export const fetchItem = (id: number): Effect.Effect<Item | null, HnError, HnApi> =>
189
+ Effect.flatMap(HnApi, (api) => api.fetchItem(id))
190
+
191
+ export const fetchItems = (
192
+ ids: ReadonlyArray<number>,
193
+ concurrency = 10,
194
+ ): Effect.Effect<Item[], never, HnApi> =>
195
+ Effect.flatMap(HnApi, (api) => api.fetchItems(ids, concurrency))
196
+
197
+ export const resolveStory = (
198
+ ref: HnItemRef,
199
+ ): Effect.Effect<ResolvedLink, HnError | HnItemGone, HnApi> =>
200
+ Effect.flatMap(HnApi, (api) => api.resolveStory(ref))
@@ -0,0 +1,70 @@
1
+ import { Effect, Layer } from "effect"
2
+ import { HnTransport, makeLiveGetJson } from "./hn"
3
+ import type { HnTransportShape } from "./hn"
4
+
5
+ // Demo transport: the live data source, plus one synthetic comment spliced
6
+ // into the first story of the first feed the app loads. This is the typed
7
+ // replacement for the old globalThis.fetch monkeypatch preload — select it
8
+ // with HN_DEMO=1 (see src/runtime.ts). Real HN is never touched.
9
+ //
10
+ // HN_DEMO=1 bun src/index.tsx
11
+ //
12
+ // The comment carries four links:
13
+ // 1. a real HN story link → opens in the TUI (item 1, pg's first post)
14
+ // 2. a real HN comment link → walks to its story, cursor lands on the comment
15
+ // 3. a broken HN link → Not Found state
16
+ // 4. an external link → opens in the browser
17
+
18
+ const REAL_STORY_LINK = "https://news.ycombinator.com/item?id=1"
19
+ const REAL_COMMENT_LINK = "https://news.ycombinator.com/item?id=15"
20
+ const BROKEN_LINK = "https://news.ycombinator.com/item?id=999999999999"
21
+ const FAKE_ID = 999_999_001
22
+
23
+ export const HnTransportLinkDemo = Layer.sync(HnTransport, () => {
24
+ const live = makeLiveGetJson()
25
+ let hookedStoryId: number | null = null
26
+
27
+ const getJson: HnTransportShape["getJson"] = (path) => {
28
+ // hook the first story of the first feed the app loads
29
+ if (/^(top|new|best|ask|show|job)stories\.json$/.test(path)) {
30
+ return live(path).pipe(
31
+ Effect.tap((ids) =>
32
+ Effect.sync(() => {
33
+ if (Array.isArray(ids) && ids.length > 0) hookedStoryId ??= ids[0] as number
34
+ }),
35
+ ),
36
+ )
37
+ }
38
+ if (hookedStoryId !== null && path === `item/${hookedStoryId}.json`) {
39
+ return live(path).pipe(
40
+ Effect.map((raw) => {
41
+ const item = raw as { kids?: number[]; descendants?: number } | null
42
+ if (!item) return raw
43
+ return {
44
+ ...item,
45
+ kids: [FAKE_ID, ...(item.kids ?? [])],
46
+ descendants: (item.descendants ?? 0) + 1,
47
+ }
48
+ }),
49
+ )
50
+ }
51
+ if (path === `item/${FAKE_ID}.json`) {
52
+ return Effect.succeed({
53
+ id: FAKE_ID,
54
+ type: "comment",
55
+ by: "link-demo",
56
+ time: Math.floor(Date.now() / 1000),
57
+ parent: hookedStoryId ?? undefined,
58
+ text:
59
+ `[SIMULATED COMMENT] Press ⏎ on me to list my links: ` +
60
+ `<a href="${REAL_STORY_LINK}">the first HN post ever</a>, ` +
61
+ `<a href="${REAL_COMMENT_LINK}">a comment under it</a>, ` +
62
+ `<a href="${BROKEN_LINK}">a broken HN link</a>, ` +
63
+ `and <a href="https://example.com/">an external link</a>.`,
64
+ })
65
+ }
66
+ return live(path)
67
+ }
68
+
69
+ return { getJson }
70
+ })
@@ -0,0 +1,40 @@
1
+ import { Schema } from "effect"
2
+
3
+ export type FeedCategory = "top" | "new" | "best" | "ask" | "show" | "job"
4
+ export type Category = FeedCategory | "saved" | "history"
5
+
6
+ export const FEED_CATEGORIES: { key: FeedCategory; label: string }[] = [
7
+ { key: "top", label: "Top" },
8
+ { key: "new", label: "New" },
9
+ { key: "best", label: "Best" },
10
+ { key: "ask", label: "Ask" },
11
+ { key: "show", label: "Show" },
12
+ { key: "job", label: "Jobs" },
13
+ ]
14
+
15
+ export const ALL_CATEGORIES: Category[] = [
16
+ ...FEED_CATEGORIES.map((c) => c.key),
17
+ "history",
18
+ "saved",
19
+ ]
20
+
21
+ // Backwards-compat alias used in older imports
22
+ export const CATEGORIES = FEED_CATEGORIES
23
+
24
+ export const ItemSchema = Schema.Struct({
25
+ id: Schema.Number,
26
+ type: Schema.Literal("story", "comment", "job", "poll", "pollopt"),
27
+ by: Schema.optional(Schema.String),
28
+ time: Schema.optional(Schema.Number),
29
+ text: Schema.optional(Schema.String),
30
+ dead: Schema.optional(Schema.Boolean),
31
+ deleted: Schema.optional(Schema.Boolean),
32
+ parent: Schema.optional(Schema.Number),
33
+ kids: Schema.optional(Schema.mutable(Schema.Array(Schema.Number))),
34
+ url: Schema.optional(Schema.String),
35
+ score: Schema.optional(Schema.Number),
36
+ title: Schema.optional(Schema.String),
37
+ descendants: Schema.optional(Schema.Number),
38
+ })
39
+
40
+ export type Item = typeof ItemSchema.Type
@@ -0,0 +1,81 @@
1
+ import { useRef } from "react"
2
+ import type { CommentNode as CN } from "../hooks/useCommentTree"
3
+ import { htmlToText, relativeTime } from "../utils/format"
4
+ import { selectionColors, useTheme } from "../theme"
5
+
6
+ interface Props {
7
+ node: CN
8
+ depth: number
9
+ collapsed: boolean
10
+ selected: boolean
11
+ hiddenChildren: number
12
+ onSelect: () => void
13
+ onToggle: () => void
14
+ onOpenLinks: () => void
15
+ }
16
+
17
+ export function CommentNode({
18
+ node,
19
+ depth,
20
+ collapsed,
21
+ selected,
22
+ hiddenChildren,
23
+ onSelect,
24
+ onToggle,
25
+ onOpenLinks,
26
+ }: Props) {
27
+ const t = useTheme()
28
+ const author = node.item.by ?? "?"
29
+ const age = relativeTime(node.item.time)
30
+ const body = htmlToText(node.item.text)
31
+ const accent = t.commentDepth[depth % t.commentDepth.length]
32
+ const indent = depth * 2
33
+ const bg = selected ? t.rowHighlight : undefined
34
+
35
+ const lastClick = useRef(0)
36
+ const checkDouble = () => {
37
+ const now = Date.now()
38
+ const isDouble = now - lastClick.current < 400
39
+ lastClick.current = now
40
+ return isDouble
41
+ }
42
+
43
+ const handleHeaderClick = () => {
44
+ onSelect()
45
+ onToggle()
46
+ }
47
+
48
+ const handleBodyClick = () => {
49
+ if (checkDouble()) {
50
+ onOpenLinks()
51
+ } else {
52
+ onSelect()
53
+ }
54
+ }
55
+
56
+ return (
57
+ <box
58
+ id={`comment-${node.item.id}`}
59
+ flexDirection="column"
60
+ marginLeft={indent}
61
+ marginTop={1}
62
+ backgroundColor={bg}
63
+ >
64
+ <text {...selectionColors(t)} onMouseDown={handleHeaderClick}>
65
+ <span fg={accent}>{selected ? "▶ " : "│ "}</span>
66
+ <span fg={t.accent}>{author}</span>
67
+ <span fg={t.textDim}>{` · ${age}`}</span>
68
+ {hiddenChildren > 0 || collapsed ? (
69
+ <span fg={t.textMuted}>
70
+ {` · ${collapsed ? "[+]" : "[-]"} ${hiddenChildren} repl${hiddenChildren === 1 ? "y" : "ies"}`}
71
+ </span>
72
+ ) : null}
73
+ </text>
74
+ {!collapsed && body ? (
75
+ <text fg={t.textBody} {...selectionColors(t)} wrapMode="word" onMouseDown={handleBodyClick}>
76
+ {body}
77
+ </text>
78
+ ) : null}
79
+ </box>
80
+ )
81
+ }
@@ -0,0 +1,78 @@
1
+ import { useTerminalDimensions } from "@opentui/react"
2
+ import { selectionColors, useTheme } from "../theme"
3
+
4
+ export interface MenuItem {
5
+ label: string
6
+ action: () => void
7
+ disabled?: boolean
8
+ }
9
+
10
+ interface Props {
11
+ x: number
12
+ y: number
13
+ items: MenuItem[]
14
+ cursor: number
15
+ onSelect: (idx: number) => void
16
+ onActivate: (idx: number) => void
17
+ onClose: () => void
18
+ }
19
+
20
+ export function ContextMenu({ x, y, items, cursor, onSelect, onActivate, onClose }: Props) {
21
+ const t = useTheme()
22
+ const { width: termW, height: termH } = useTerminalDimensions()
23
+ const longest = items.reduce((n, it) => Math.max(n, it.label.length), 0)
24
+ const menuW = Math.min(longest + 4, 40)
25
+ const menuH = items.length + 2
26
+ const left = Math.min(Math.max(0, x), termW - menuW)
27
+ const top = Math.min(Math.max(0, y), termH - menuH - 1)
28
+
29
+ const hoverFg = t.name === "dark" ? "#000000" : "#ffffff"
30
+
31
+ return (
32
+ <box
33
+ position="absolute"
34
+ top={0}
35
+ left={0}
36
+ width="100%"
37
+ height="100%"
38
+ zIndex={200}
39
+ onMouseDown={onClose}
40
+ >
41
+ <box
42
+ position="absolute"
43
+ top={top}
44
+ left={left}
45
+ width={menuW}
46
+ flexDirection="column"
47
+ backgroundColor={t.menuBg}
48
+ border={true}
49
+ borderStyle="single"
50
+ borderColor={t.textMuted}
51
+ onMouseDown={(ev) => ev.stopPropagation()}
52
+ >
53
+ {items.map((item, idx) => {
54
+ const selected = idx === cursor
55
+ const fg = item.disabled ? t.textDim : selected ? hoverFg : t.text
56
+ const bg = selected && !item.disabled ? t.textMuted : undefined
57
+ return (
58
+ <box
59
+ key={idx}
60
+ backgroundColor={bg}
61
+ paddingLeft={1}
62
+ paddingRight={1}
63
+ onMouseDown={() => {
64
+ if (item.disabled) return
65
+ onActivate(idx)
66
+ }}
67
+ onMouseOver={() => {
68
+ if (!item.disabled) onSelect(idx)
69
+ }}
70
+ >
71
+ <text fg={fg} {...selectionColors(t)}>{item.label}</text>
72
+ </box>
73
+ )
74
+ })}
75
+ </box>
76
+ </box>
77
+ )
78
+ }
@@ -0,0 +1,89 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import type { Category } from "../api/types"
3
+ import { FEED_CATEGORIES } from "../api/types"
4
+ import { selectionColors, useTheme } from "../theme"
5
+
6
+ interface Props {
7
+ category: Category
8
+ onSelect: (c: Category) => void
9
+ onHome: () => void
10
+ showTabs?: boolean
11
+ // how many views are stacked beneath the current one (internal-link navigation)
12
+ depth?: number
13
+ }
14
+
15
+ export function Header({ category, onSelect, onHome, showTabs = true, depth = 0 }: Props) {
16
+ const t = useTheme()
17
+ return (
18
+ <box
19
+ flexDirection="column"
20
+ flexShrink={0}
21
+ height={showTabs ? 5 : 3}
22
+ paddingTop={1}
23
+ paddingLeft={1}
24
+ paddingRight={1}
25
+ backgroundColor={t.strip}
26
+ border={["bottom"]}
27
+ borderStyle="single"
28
+ borderColor={t.border}
29
+ >
30
+ <box flexDirection="row" flexShrink={0} height={1} alignItems="center" gap={2}>
31
+ <text
32
+ bg={t.brandTileBg}
33
+ fg={t.brandTileFg}
34
+ {...selectionColors(t)}
35
+ attributes={TextAttributes.BOLD}
36
+ onMouseDown={onHome}
37
+ >
38
+ {" Y "}
39
+ </text>
40
+ <text fg={t.brandText} {...selectionColors(t)} attributes={TextAttributes.BOLD}>
41
+ HackerNews
42
+ </text>
43
+ <text fg={t.brandSubtle} {...selectionColors(t)}>· TUI</text>
44
+ <box flexGrow={1} />
45
+ {depth > 0 ? (
46
+ <text fg={t.brandSubtle} {...selectionColors(t)}>{`↩ ${depth}`}</text>
47
+ ) : null}
48
+ </box>
49
+ {showTabs ? (
50
+ <box flexDirection="row" flexShrink={0} height={1} marginTop={1} gap={1}>
51
+ {FEED_CATEGORIES.map((c, i) => {
52
+ const active = c.key === category
53
+ return (
54
+ <box
55
+ key={c.key}
56
+ flexShrink={0}
57
+ onMouseDown={() => onSelect(c.key)}
58
+ backgroundColor={active ? t.tabActiveBg : undefined}
59
+ >
60
+ <text fg={active ? t.tabActiveFg : t.tabInactiveFg} {...selectionColors(t)}>
61
+ {` ${i + 1} ${c.label} `}
62
+ </text>
63
+ </box>
64
+ )
65
+ })}
66
+ <box flexGrow={1} />
67
+ <box
68
+ flexShrink={0}
69
+ onMouseDown={() => onSelect("history")}
70
+ backgroundColor={category === "history" ? t.tabActiveBg : undefined}
71
+ >
72
+ <text fg={category === "history" ? t.tabActiveFg : t.tabInactiveFg} {...selectionColors(t)}>
73
+ {" [H]istory "}
74
+ </text>
75
+ </box>
76
+ <box
77
+ flexShrink={0}
78
+ onMouseDown={() => onSelect("saved")}
79
+ backgroundColor={category === "saved" ? t.tabActiveBg : undefined}
80
+ >
81
+ <text fg={category === "saved" ? t.tabActiveFg : t.tabInactiveFg} {...selectionColors(t)}>
82
+ {" [S]aved "}
83
+ </text>
84
+ </box>
85
+ </box>
86
+ ) : null}
87
+ </box>
88
+ )
89
+ }