@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,34 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import { selectionColors, useTheme } from "../theme"
3
+
4
+ interface Props {
5
+ art: string
6
+ title: string
7
+ subtitle?: string
8
+ hint?: string
9
+ }
10
+
11
+ // Full-body centered state: not-found, network failure, and friends.
12
+ export function MessageView({ art, title, subtitle, hint }: Props) {
13
+ const t = useTheme()
14
+ return (
15
+ <box flexGrow={1} flexDirection="column" alignItems="center" justifyContent="center" gap={1}>
16
+ <text fg={t.accent} {...selectionColors(t)}>
17
+ {art}
18
+ </text>
19
+ <text fg={t.text} {...selectionColors(t)} attributes={TextAttributes.BOLD}>
20
+ {title}
21
+ </text>
22
+ {subtitle ? (
23
+ <text fg={t.statusHint} {...selectionColors(t)}>
24
+ {subtitle}
25
+ </text>
26
+ ) : null}
27
+ {hint ? (
28
+ <text fg={t.statusHint} {...selectionColors(t)}>
29
+ {hint}
30
+ </text>
31
+ ) : null}
32
+ </box>
33
+ )
34
+ }
@@ -0,0 +1,116 @@
1
+ import { forwardRef, useEffect } from "react"
2
+ import type { ScrollBoxRenderable } from "@opentui/core"
3
+ import { TextAttributes } from "@opentui/core"
4
+ import { CommentNode } from "../components/CommentNode"
5
+ import { Loader } from "../components/Loader"
6
+ import type { FlatComment } from "../hooks/useCommentTree"
7
+ import type { Item } from "../api/types"
8
+ import { hostname, htmlToText, relativeTime } from "../utils/format"
9
+ import { openUrl } from "../utils/openUrl"
10
+ import { selectionColors, useTheme } from "../theme"
11
+
12
+ interface Props {
13
+ story: Item
14
+ flat: FlatComment[]
15
+ cursor: number
16
+ collapsed: Set<number>
17
+ loading: boolean
18
+ saved?: boolean
19
+ emptyMessage?: string
20
+ onSelectComment: (idx: number) => void
21
+ onToggleComment: (id: number) => void
22
+ onOpenLinks: (id: number) => void
23
+ }
24
+
25
+ export const StoryDetailView = forwardRef<ScrollBoxRenderable, Props>(function StoryDetailView(
26
+ { story, flat, cursor, collapsed, loading, saved, emptyMessage, onSelectComment, onToggleComment, onOpenLinks },
27
+ ref,
28
+ ) {
29
+ const t = useTheme()
30
+ useEffect(() => {
31
+ const sb = (ref as React.RefObject<ScrollBoxRenderable>)?.current
32
+ if (!sb || flat.length === 0) return
33
+ const target = flat[cursor]
34
+ if (!target) return
35
+ sb.scrollChildIntoView(`comment-${target.node.item.id}`)
36
+ }, [cursor, flat])
37
+
38
+ const host = hostname(story.url)
39
+ const text = htmlToText(story.text)
40
+
41
+ return (
42
+ <box flexDirection="column" flexGrow={1}>
43
+ <box
44
+ flexDirection="column"
45
+ flexShrink={0}
46
+ paddingLeft={1}
47
+ paddingRight={1}
48
+ paddingTop={1}
49
+ paddingBottom={1}
50
+ backgroundColor={t.strip}
51
+ border={["bottom"]}
52
+ borderStyle="single"
53
+ borderColor={t.border}
54
+ >
55
+ <text fg={t.text} {...selectionColors(t)} attributes={TextAttributes.BOLD} wrapMode="word">
56
+ {saved ? "★ " : ""}
57
+ {story.title ?? "(untitled)"}
58
+ </text>
59
+ <text {...selectionColors(t)}>
60
+ <span fg={t.stripAccent}>▲ {story.score ?? 0}</span>
61
+ <span fg={t.textDim}>{` by `}</span>
62
+ <span fg={t.textMuted}>{story.by ?? "?"}</span>
63
+ <span fg={t.textDim}>{` ${relativeTime(story.time)} | `}</span>
64
+ <span fg={t.textMuted}>{`${story.descendants ?? 0} comments`}</span>
65
+ {host ? <span fg={t.textDim}>{` · ${host}`}</span> : null}
66
+ </text>
67
+ {story.url ? (
68
+ <text fg={t.link} {...selectionColors(t)} onMouseDown={() => openUrl(story.url!)}>
69
+ {story.url}
70
+ </text>
71
+ ) : null}
72
+ </box>
73
+ <scrollbox
74
+ ref={ref}
75
+ flexGrow={1}
76
+ scrollY={true}
77
+ scrollX={false}
78
+ verticalScrollbarOptions={{
79
+ trackOptions: { backgroundColor: t.scrollTrack, foregroundColor: t.scrollThumb },
80
+ }}
81
+ >
82
+ {text ? (
83
+ <box paddingLeft={1} paddingRight={1} paddingTop={1}>
84
+ <text fg={t.textBody} {...selectionColors(t)} wrapMode="word">
85
+ {text}
86
+ </text>
87
+ </box>
88
+ ) : null}
89
+ <box paddingLeft={1} paddingRight={1} paddingTop={1}>
90
+ {loading ? (
91
+ <box flexDirection="row" alignItems="center" gap={1}>
92
+ <Loader />
93
+ <text fg={t.statusHint} {...selectionColors(t)}>Loading comments…</text>
94
+ </box>
95
+ ) : flat.length === 0 ? (
96
+ <text fg={t.statusHint} {...selectionColors(t)}>{emptyMessage ?? "No comments yet."}</text>
97
+ ) : (
98
+ flat.map((fc, idx) => (
99
+ <CommentNode
100
+ key={fc.node.item.id}
101
+ node={fc.node}
102
+ depth={fc.depth}
103
+ collapsed={collapsed.has(fc.node.item.id)}
104
+ selected={idx === cursor}
105
+ hiddenChildren={fc.hiddenChildren}
106
+ onSelect={() => onSelectComment(idx)}
107
+ onToggle={() => onToggleComment(fc.node.item.id)}
108
+ onOpenLinks={() => onOpenLinks(fc.node.item.id)}
109
+ />
110
+ ))
111
+ )}
112
+ </box>
113
+ </scrollbox>
114
+ </box>
115
+ )
116
+ })
@@ -0,0 +1,83 @@
1
+ import { forwardRef, useEffect } from "react"
2
+ import type { MouseEvent, ScrollBoxRenderable } from "@opentui/core"
3
+ import { StoryRow } from "../components/StoryRow"
4
+ import { Loader } from "../components/Loader"
5
+ import type { Item } from "../api/types"
6
+ import { selectionColors, useTheme } from "../theme"
7
+
8
+ interface Props {
9
+ items: Item[]
10
+ cursor: number
11
+ loading: boolean
12
+ savedIds?: Set<number>
13
+ viewedIds?: Set<number>
14
+ emptyMessage?: string
15
+ loadingMessage?: string
16
+ onSelect: (idx: number) => void
17
+ onActivate: (idx: number) => void
18
+ onContextMenu?: (idx: number, ev: MouseEvent) => void
19
+ }
20
+
21
+ export const StoryListView = forwardRef<ScrollBoxRenderable, Props>(function StoryListView(
22
+ {
23
+ items,
24
+ cursor,
25
+ loading,
26
+ savedIds,
27
+ viewedIds,
28
+ emptyMessage,
29
+ loadingMessage,
30
+ onSelect,
31
+ onActivate,
32
+ onContextMenu,
33
+ },
34
+ ref,
35
+ ) {
36
+ const t = useTheme()
37
+ useEffect(() => {
38
+ const sb = (ref as React.RefObject<ScrollBoxRenderable>)?.current
39
+ if (!sb || items.length === 0) return
40
+ sb.scrollChildIntoView(`row-${cursor + 1}`)
41
+ }, [cursor, items.length])
42
+
43
+ if (items.length === 0) {
44
+ return (
45
+ <box flexGrow={1} flexDirection="column" alignItems="center" justifyContent="center" gap={1}>
46
+ {loading ? (
47
+ <>
48
+ <Loader />
49
+ <text fg={t.statusHint} {...selectionColors(t)}>{loadingMessage ?? "Loading stories…"}</text>
50
+ </>
51
+ ) : (
52
+ <text fg={t.statusHint} {...selectionColors(t)}>{emptyMessage ?? "No stories"}</text>
53
+ )}
54
+ </box>
55
+ )
56
+ }
57
+
58
+ return (
59
+ <scrollbox
60
+ ref={ref}
61
+ flexGrow={1}
62
+ scrollY={true}
63
+ scrollX={false}
64
+ verticalScrollbarOptions={{
65
+ trackOptions: { backgroundColor: t.scrollTrack, foregroundColor: t.scrollThumb },
66
+ }}
67
+ >
68
+ {items.map((item, idx) => (
69
+ <StoryRow
70
+ key={item.id}
71
+ rank={idx + 1}
72
+ item={item}
73
+ selected={idx === cursor}
74
+ saved={savedIds?.has(item.id)}
75
+ visited={viewedIds?.has(item.id)}
76
+ onSelect={() => onSelect(idx)}
77
+ onActivate={() => onActivate(idx)}
78
+ onContextMenu={(ev) => onContextMenu?.(idx, ev)}
79
+ />
80
+ ))}
81
+ </scrollbox>
82
+ )
83
+ })