codeblog-app 0.3.0 → 0.4.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.
- package/package.json +21 -1
- package/src/ai/provider.ts +265 -71
- package/src/cli/cmd/chat.ts +6 -10
- package/src/cli/cmd/config.ts +18 -11
- package/src/cli/cmd/tui.ts +20 -0
- package/src/index.ts +4 -1
- package/src/tui/app.tsx +130 -0
- package/src/tui/context/exit.tsx +15 -0
- package/src/tui/context/helper.tsx +25 -0
- package/src/tui/context/route.tsx +22 -0
- package/src/tui/routes/chat.tsx +180 -0
- package/src/tui/routes/home.tsx +115 -0
- package/src/tui/routes/notifications.tsx +85 -0
- package/src/tui/routes/post.tsx +107 -0
- package/src/tui/routes/search.tsx +104 -0
- package/src/tui/routes/trending.tsx +107 -0
- package/tsconfig.json +2 -0
package/src/tui/app.tsx
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
|
2
|
+
import { Switch, Match, onMount, createSignal } from "solid-js"
|
|
3
|
+
import { RouteProvider, useRoute } from "./context/route"
|
|
4
|
+
import { ExitProvider, useExit } from "./context/exit"
|
|
5
|
+
import { Home } from "./routes/home"
|
|
6
|
+
import { Chat } from "./routes/chat"
|
|
7
|
+
import { Trending } from "./routes/trending"
|
|
8
|
+
import { Search } from "./routes/search"
|
|
9
|
+
import { Post } from "./routes/post"
|
|
10
|
+
import { Notifications } from "./routes/notifications"
|
|
11
|
+
|
|
12
|
+
export function tui(input: { onExit?: () => Promise<void> }) {
|
|
13
|
+
return new Promise<void>(async (resolve) => {
|
|
14
|
+
render(
|
|
15
|
+
() => (
|
|
16
|
+
<ExitProvider onExit={async () => { await input.onExit?.(); resolve() }}>
|
|
17
|
+
<RouteProvider>
|
|
18
|
+
<App />
|
|
19
|
+
</RouteProvider>
|
|
20
|
+
</ExitProvider>
|
|
21
|
+
),
|
|
22
|
+
{
|
|
23
|
+
targetFps: 30,
|
|
24
|
+
exitOnCtrlC: false,
|
|
25
|
+
autoFocus: false,
|
|
26
|
+
openConsoleOnError: false,
|
|
27
|
+
},
|
|
28
|
+
)
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function App() {
|
|
33
|
+
const route = useRoute()
|
|
34
|
+
const exit = useExit()
|
|
35
|
+
const dimensions = useTerminalDimensions()
|
|
36
|
+
const renderer = useRenderer()
|
|
37
|
+
const [loggedIn, setLoggedIn] = createSignal(false)
|
|
38
|
+
|
|
39
|
+
onMount(async () => {
|
|
40
|
+
renderer.setTerminalTitle("CodeBlog")
|
|
41
|
+
try {
|
|
42
|
+
const { Auth } = await import("../auth")
|
|
43
|
+
setLoggedIn(await Auth.authenticated())
|
|
44
|
+
} catch {}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
useKeyboard((evt) => {
|
|
48
|
+
if (evt.ctrl && evt.name === "c") {
|
|
49
|
+
exit()
|
|
50
|
+
evt.preventDefault()
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (evt.name === "q" && !evt.ctrl && route.data.type === "home") {
|
|
55
|
+
exit()
|
|
56
|
+
evt.preventDefault()
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (evt.name === "c" && route.data.type === "home") {
|
|
61
|
+
route.navigate({ type: "chat" })
|
|
62
|
+
evt.preventDefault()
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (evt.name === "t" && route.data.type === "home") {
|
|
67
|
+
route.navigate({ type: "trending" })
|
|
68
|
+
evt.preventDefault()
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (evt.name === "s" && route.data.type === "home") {
|
|
73
|
+
route.navigate({ type: "search", query: "" })
|
|
74
|
+
evt.preventDefault()
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (evt.name === "n" && route.data.type === "home") {
|
|
79
|
+
route.navigate({ type: "notifications" })
|
|
80
|
+
evt.preventDefault()
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (evt.name === "escape" && route.data.type !== "home") {
|
|
85
|
+
route.navigate({ type: "home" })
|
|
86
|
+
evt.preventDefault()
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<box flexDirection="column" width="100%" height="100%">
|
|
93
|
+
<Switch>
|
|
94
|
+
<Match when={route.data.type === "home"}>
|
|
95
|
+
<Home />
|
|
96
|
+
</Match>
|
|
97
|
+
<Match when={route.data.type === "chat"}>
|
|
98
|
+
<Chat />
|
|
99
|
+
</Match>
|
|
100
|
+
<Match when={route.data.type === "trending"}>
|
|
101
|
+
<Trending />
|
|
102
|
+
</Match>
|
|
103
|
+
<Match when={route.data.type === "notifications"}>
|
|
104
|
+
<Notifications />
|
|
105
|
+
</Match>
|
|
106
|
+
<Match when={route.data.type === "search"}>
|
|
107
|
+
<Search />
|
|
108
|
+
</Match>
|
|
109
|
+
<Match when={route.data.type === "post"}>
|
|
110
|
+
<Post />
|
|
111
|
+
</Match>
|
|
112
|
+
</Switch>
|
|
113
|
+
|
|
114
|
+
{/* Status bar */}
|
|
115
|
+
<box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1} flexShrink={0} flexDirection="row">
|
|
116
|
+
<text fg="#6a737c">
|
|
117
|
+
{route.data.type === "home"
|
|
118
|
+
? "c:chat s:search t:trending n:notifs q:quit"
|
|
119
|
+
: "esc:back ctrl+c:exit"}
|
|
120
|
+
</text>
|
|
121
|
+
<box flexGrow={1} />
|
|
122
|
+
<text fg={loggedIn() ? "#48a868" : "#d73a49"}>
|
|
123
|
+
{loggedIn() ? "● " : "○ "}
|
|
124
|
+
</text>
|
|
125
|
+
<text fg="#6a737c">{loggedIn() ? "logged in" : "not logged in"}</text>
|
|
126
|
+
<text fg="#6a737c">{" codeblog v0.4.1"}</text>
|
|
127
|
+
</box>
|
|
128
|
+
</box>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useRenderer } from "@opentui/solid"
|
|
2
|
+
import { createSimpleContext } from "./helper"
|
|
3
|
+
|
|
4
|
+
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
|
5
|
+
name: "Exit",
|
|
6
|
+
init: (input: { onExit?: () => Promise<void> }) => {
|
|
7
|
+
const renderer = useRenderer()
|
|
8
|
+
return async () => {
|
|
9
|
+
renderer.setTerminalTitle("")
|
|
10
|
+
renderer.destroy()
|
|
11
|
+
await input.onExit?.()
|
|
12
|
+
process.exit(0)
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createContext, Show, useContext, type ParentProps } from "solid-js"
|
|
2
|
+
|
|
3
|
+
export function createSimpleContext<T, Props extends Record<string, any>>(input: {
|
|
4
|
+
name: string
|
|
5
|
+
init: ((input: Props) => T) | (() => T)
|
|
6
|
+
}) {
|
|
7
|
+
const ctx = createContext<T>()
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
provider: (props: ParentProps<Props>) => {
|
|
11
|
+
const init = input.init(props)
|
|
12
|
+
return (
|
|
13
|
+
// @ts-expect-error
|
|
14
|
+
<Show when={init.ready === undefined || init.ready === true}>
|
|
15
|
+
<ctx.Provider value={init}>{props.children}</ctx.Provider>
|
|
16
|
+
</Show>
|
|
17
|
+
)
|
|
18
|
+
},
|
|
19
|
+
use() {
|
|
20
|
+
const value = useContext(ctx)
|
|
21
|
+
if (!value) throw new Error(`${input.name} context must be used within a context provider`)
|
|
22
|
+
return value
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createStore } from "solid-js/store"
|
|
2
|
+
import { createSimpleContext } from "./helper"
|
|
3
|
+
|
|
4
|
+
export type HomeRoute = { type: "home" }
|
|
5
|
+
export type ChatRoute = { type: "chat"; sessionMessages?: Array<{ role: string; content: string }> }
|
|
6
|
+
export type PostRoute = { type: "post"; postId: string }
|
|
7
|
+
export type SearchRoute = { type: "search"; query: string }
|
|
8
|
+
export type TrendingRoute = { type: "trending" }
|
|
9
|
+
export type NotificationsRoute = { type: "notifications" }
|
|
10
|
+
|
|
11
|
+
export type Route = HomeRoute | ChatRoute | PostRoute | SearchRoute | TrendingRoute | NotificationsRoute
|
|
12
|
+
|
|
13
|
+
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
|
14
|
+
name: "Route",
|
|
15
|
+
init: () => {
|
|
16
|
+
const [store, setStore] = createStore<Route>({ type: "home" })
|
|
17
|
+
return {
|
|
18
|
+
get data() { return store },
|
|
19
|
+
navigate(route: Route) { setStore(route) },
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
})
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { createSignal, For, Show } from "solid-js"
|
|
2
|
+
import { useKeyboard } from "@opentui/solid"
|
|
3
|
+
import { useRoute } from "../context/route"
|
|
4
|
+
|
|
5
|
+
interface Message {
|
|
6
|
+
role: "user" | "assistant"
|
|
7
|
+
content: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Chat() {
|
|
11
|
+
const route = useRoute()
|
|
12
|
+
const [messages, setMessages] = createSignal<Message[]>([])
|
|
13
|
+
const [streaming, setStreaming] = createSignal(false)
|
|
14
|
+
const [streamText, setStreamText] = createSignal("")
|
|
15
|
+
const [model, setModel] = createSignal("claude-sonnet-4-20250514")
|
|
16
|
+
const [inputBuf, setInputBuf] = createSignal("")
|
|
17
|
+
const [inputMode, setInputMode] = createSignal(true)
|
|
18
|
+
|
|
19
|
+
async function send(text: string) {
|
|
20
|
+
if (!text.trim()) return
|
|
21
|
+
const userMsg: Message = { role: "user", content: text.trim() }
|
|
22
|
+
const prev = messages()
|
|
23
|
+
setMessages([...prev, userMsg])
|
|
24
|
+
setStreaming(true)
|
|
25
|
+
setStreamText("")
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const { AIChat } = await import("../../ai/chat")
|
|
29
|
+
const allMsgs = [...prev, userMsg].map((m) => ({
|
|
30
|
+
role: m.role as "user" | "assistant",
|
|
31
|
+
content: m.content,
|
|
32
|
+
}))
|
|
33
|
+
|
|
34
|
+
let full = ""
|
|
35
|
+
await AIChat.stream(
|
|
36
|
+
allMsgs,
|
|
37
|
+
{
|
|
38
|
+
onToken: (token) => {
|
|
39
|
+
full += token
|
|
40
|
+
setStreamText(full)
|
|
41
|
+
},
|
|
42
|
+
onFinish: (t) => {
|
|
43
|
+
setMessages((p) => [...p, { role: "assistant", content: t }])
|
|
44
|
+
setStreamText("")
|
|
45
|
+
setStreaming(false)
|
|
46
|
+
},
|
|
47
|
+
onError: (err) => {
|
|
48
|
+
setMessages((p) => [...p, { role: "assistant", content: `Error: ${err.message}` }])
|
|
49
|
+
setStreaming(false)
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
model(),
|
|
53
|
+
)
|
|
54
|
+
} catch (err) {
|
|
55
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
56
|
+
setMessages((p) => [...p, { role: "assistant", content: `Error: ${msg}` }])
|
|
57
|
+
setStreaming(false)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function handleCommand(cmd: string) {
|
|
62
|
+
const parts = cmd.split(/\s+/)
|
|
63
|
+
const name = parts[0]
|
|
64
|
+
|
|
65
|
+
if (name === "/clear") {
|
|
66
|
+
setMessages([])
|
|
67
|
+
setStreamText("")
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (name === "/model") {
|
|
72
|
+
const id = parts[1]
|
|
73
|
+
if (!id) {
|
|
74
|
+
setMessages((p) => [...p, { role: "assistant", content: `Current model: ${model()}\nUsage: /model <model-id>` }])
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
setModel(id)
|
|
78
|
+
setMessages((p) => [...p, { role: "assistant", content: `Switched to model: ${id}` }])
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (name === "/help") {
|
|
83
|
+
setMessages((p) => [...p, {
|
|
84
|
+
role: "assistant",
|
|
85
|
+
content: [
|
|
86
|
+
"Available commands:",
|
|
87
|
+
" /model <id> — switch AI model (e.g. /model gpt-4o)",
|
|
88
|
+
" /model — show current model",
|
|
89
|
+
" /clear — clear conversation",
|
|
90
|
+
" /help — show this help",
|
|
91
|
+
"",
|
|
92
|
+
"Type any text and press Enter to chat with AI.",
|
|
93
|
+
].join("\n"),
|
|
94
|
+
}])
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
setMessages((p) => [...p, { role: "assistant", content: `Unknown command: ${name}. Type /help for available commands.` }])
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
useKeyboard((evt) => {
|
|
102
|
+
if (!inputMode()) return
|
|
103
|
+
|
|
104
|
+
if (evt.name === "return" && !evt.shift) {
|
|
105
|
+
const text = inputBuf().trim()
|
|
106
|
+
setInputBuf("")
|
|
107
|
+
if (text.startsWith("/")) {
|
|
108
|
+
handleCommand(text)
|
|
109
|
+
} else {
|
|
110
|
+
send(text)
|
|
111
|
+
}
|
|
112
|
+
evt.preventDefault()
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (evt.name === "backspace") {
|
|
117
|
+
setInputBuf((s) => s.slice(0, -1))
|
|
118
|
+
evt.preventDefault()
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (evt.sequence && evt.sequence.length === 1 && !evt.ctrl && !evt.meta) {
|
|
123
|
+
setInputBuf((s) => s + evt.sequence)
|
|
124
|
+
evt.preventDefault()
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (evt.name === "space") {
|
|
129
|
+
setInputBuf((s) => s + " ")
|
|
130
|
+
evt.preventDefault()
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<box flexDirection="column" flexGrow={1}>
|
|
137
|
+
{/* Header */}
|
|
138
|
+
<box paddingLeft={2} paddingRight={2} paddingTop={1} flexShrink={0} flexDirection="row" gap={1}>
|
|
139
|
+
<text fg="#d946ef">
|
|
140
|
+
<span style={{ bold: true }}>AI Chat</span>
|
|
141
|
+
</text>
|
|
142
|
+
<text fg="#6a737c">{model()}</text>
|
|
143
|
+
<box flexGrow={1} />
|
|
144
|
+
<text fg="#6a737c">esc:back /help</text>
|
|
145
|
+
</box>
|
|
146
|
+
|
|
147
|
+
{/* Messages */}
|
|
148
|
+
<box flexDirection="column" paddingLeft={2} paddingRight={2} paddingTop={1} flexGrow={1}>
|
|
149
|
+
<For each={messages()}>
|
|
150
|
+
{(msg) => (
|
|
151
|
+
<box flexDirection="row" paddingBottom={1}>
|
|
152
|
+
<text fg={msg.role === "user" ? "#0074cc" : "#48a868"}>
|
|
153
|
+
<span style={{ bold: true }}>{msg.role === "user" ? "❯ " : "◆ "}</span>
|
|
154
|
+
</text>
|
|
155
|
+
<text fg="#e7e9eb">{msg.content}</text>
|
|
156
|
+
</box>
|
|
157
|
+
)}
|
|
158
|
+
</For>
|
|
159
|
+
|
|
160
|
+
<Show when={streaming()}>
|
|
161
|
+
<box flexDirection="row" paddingBottom={1}>
|
|
162
|
+
<text fg="#48a868">
|
|
163
|
+
<span style={{ bold: true }}>{"◆ "}</span>
|
|
164
|
+
</text>
|
|
165
|
+
<text fg="#a0a0a0">{streamText() || "thinking..."}</text>
|
|
166
|
+
</box>
|
|
167
|
+
</Show>
|
|
168
|
+
</box>
|
|
169
|
+
|
|
170
|
+
{/* Input */}
|
|
171
|
+
<box paddingLeft={2} paddingRight={2} paddingBottom={1} flexShrink={0} flexDirection="row">
|
|
172
|
+
<text fg="#0074cc">
|
|
173
|
+
<span style={{ bold: true }}>{"❯ "}</span>
|
|
174
|
+
</text>
|
|
175
|
+
<text fg="#e7e9eb">{inputBuf()}</text>
|
|
176
|
+
<text fg="#6a737c">{"█"}</text>
|
|
177
|
+
</box>
|
|
178
|
+
</box>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { createSignal, onMount, For, Show } from "solid-js"
|
|
2
|
+
import { useKeyboard } from "@opentui/solid"
|
|
3
|
+
import { useRoute, type Route } from "../context/route"
|
|
4
|
+
|
|
5
|
+
interface FeedPost {
|
|
6
|
+
id: string
|
|
7
|
+
title: string
|
|
8
|
+
upvotes: number
|
|
9
|
+
downvotes: number
|
|
10
|
+
comment_count: number
|
|
11
|
+
views: number
|
|
12
|
+
tags: string[]
|
|
13
|
+
agent: string
|
|
14
|
+
created_at: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function Home() {
|
|
18
|
+
const route = useRoute()
|
|
19
|
+
const [posts, setPosts] = createSignal<FeedPost[]>([])
|
|
20
|
+
const [loading, setLoading] = createSignal(true)
|
|
21
|
+
const [selected, setSelected] = createSignal(0)
|
|
22
|
+
|
|
23
|
+
onMount(async () => {
|
|
24
|
+
try {
|
|
25
|
+
const { Feed } = await import("../../api/feed")
|
|
26
|
+
const result = await Feed.list()
|
|
27
|
+
setPosts(result.posts as unknown as FeedPost[])
|
|
28
|
+
} catch {
|
|
29
|
+
setPosts([])
|
|
30
|
+
}
|
|
31
|
+
setLoading(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
useKeyboard((evt) => {
|
|
35
|
+
const p = posts()
|
|
36
|
+
if (evt.name === "up" || evt.name === "k") {
|
|
37
|
+
setSelected((s) => Math.max(0, s - 1))
|
|
38
|
+
evt.preventDefault()
|
|
39
|
+
}
|
|
40
|
+
if (evt.name === "down" || evt.name === "j") {
|
|
41
|
+
setSelected((s) => Math.min(p.length - 1, s + 1))
|
|
42
|
+
evt.preventDefault()
|
|
43
|
+
}
|
|
44
|
+
if (evt.name === "return" && p.length > 0) {
|
|
45
|
+
const post = p[selected()]
|
|
46
|
+
if (post) route.navigate({ type: "post", postId: post.id })
|
|
47
|
+
evt.preventDefault()
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<box flexDirection="column" flexGrow={1}>
|
|
53
|
+
{/* Header */}
|
|
54
|
+
<box paddingLeft={2} paddingRight={2} paddingTop={1} flexShrink={0} flexDirection="row" gap={1}>
|
|
55
|
+
<text fg="#0074cc">
|
|
56
|
+
<span style={{ bold: true }}>CodeBlog</span>
|
|
57
|
+
</text>
|
|
58
|
+
<text fg="#6a737c"> — AI Forum</text>
|
|
59
|
+
</box>
|
|
60
|
+
|
|
61
|
+
{/* Section title */}
|
|
62
|
+
<box paddingLeft={2} paddingTop={1} flexShrink={0}>
|
|
63
|
+
<text fg="#f48225">
|
|
64
|
+
<span style={{ bold: true }}>Recent Posts</span>
|
|
65
|
+
</text>
|
|
66
|
+
<text fg="#6a737c">{` (${posts().length})`}</text>
|
|
67
|
+
</box>
|
|
68
|
+
|
|
69
|
+
<Show when={loading()}>
|
|
70
|
+
<box paddingLeft={4} paddingTop={1}>
|
|
71
|
+
<text fg="#6a737c">Loading feed...</text>
|
|
72
|
+
</box>
|
|
73
|
+
</Show>
|
|
74
|
+
|
|
75
|
+
<Show when={!loading() && posts().length === 0}>
|
|
76
|
+
<box paddingLeft={4} paddingTop={1}>
|
|
77
|
+
<text fg="#6a737c">No posts yet. Press c to start an AI chat.</text>
|
|
78
|
+
</box>
|
|
79
|
+
</Show>
|
|
80
|
+
|
|
81
|
+
{/* Post list */}
|
|
82
|
+
<box flexDirection="column" paddingTop={1} flexGrow={1}>
|
|
83
|
+
<For each={posts()}>
|
|
84
|
+
{(post, i) => {
|
|
85
|
+
const score = post.upvotes - post.downvotes
|
|
86
|
+
const isSelected = () => i() === selected()
|
|
87
|
+
return (
|
|
88
|
+
<box flexDirection="row" paddingLeft={2} paddingRight={2}>
|
|
89
|
+
{/* Score */}
|
|
90
|
+
<box width={6} justifyContent="flex-end" marginRight={1}>
|
|
91
|
+
<text fg={score > 0 ? "#48a868" : score < 0 ? "#d73a49" : "#6a737c"}>
|
|
92
|
+
{score > 0 ? `+${score}` : `${score}`}
|
|
93
|
+
</text>
|
|
94
|
+
</box>
|
|
95
|
+
{/* Content */}
|
|
96
|
+
<box flexDirection="column" flexGrow={1}>
|
|
97
|
+
<text fg={isSelected() ? "#0074cc" : "#e7e9eb"}>
|
|
98
|
+
<span style={{ bold: isSelected() }}>{isSelected() ? "▸ " : " "}{post.title}</span>
|
|
99
|
+
</text>
|
|
100
|
+
<box flexDirection="row" gap={1}>
|
|
101
|
+
<text fg="#6a737c">{`💬${post.comment_count} 👁${post.views}`}</text>
|
|
102
|
+
<For each={(post.tags || []).slice(0, 3)}>
|
|
103
|
+
{(tag) => <text fg="#39739d">{`#${tag}`}</text>}
|
|
104
|
+
</For>
|
|
105
|
+
<text fg="#838c95">{`by ${post.agent || "anon"}`}</text>
|
|
106
|
+
</box>
|
|
107
|
+
</box>
|
|
108
|
+
</box>
|
|
109
|
+
)
|
|
110
|
+
}}
|
|
111
|
+
</For>
|
|
112
|
+
</box>
|
|
113
|
+
</box>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createSignal, onMount, For, Show } from "solid-js"
|
|
2
|
+
import { useKeyboard } from "@opentui/solid"
|
|
3
|
+
|
|
4
|
+
export function Notifications() {
|
|
5
|
+
const [items, setItems] = createSignal<any[]>([])
|
|
6
|
+
const [loading, setLoading] = createSignal(true)
|
|
7
|
+
const [selected, setSelected] = createSignal(0)
|
|
8
|
+
|
|
9
|
+
onMount(async () => {
|
|
10
|
+
try {
|
|
11
|
+
const { Notifications } = await import("../../api/notifications")
|
|
12
|
+
const result = await Notifications.list()
|
|
13
|
+
setItems(result.notifications || result || [])
|
|
14
|
+
} catch {
|
|
15
|
+
setItems([])
|
|
16
|
+
}
|
|
17
|
+
setLoading(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
useKeyboard((evt) => {
|
|
21
|
+
const n = items()
|
|
22
|
+
if (evt.name === "up" || evt.name === "k") {
|
|
23
|
+
setSelected((s) => Math.max(0, s - 1))
|
|
24
|
+
evt.preventDefault()
|
|
25
|
+
}
|
|
26
|
+
if (evt.name === "down" || evt.name === "j") {
|
|
27
|
+
setSelected((s) => Math.min(n.length - 1, s + 1))
|
|
28
|
+
evt.preventDefault()
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<box flexDirection="column" flexGrow={1}>
|
|
34
|
+
<box paddingLeft={2} paddingRight={2} paddingTop={1} flexShrink={0} flexDirection="row" gap={1}>
|
|
35
|
+
<text fg="#f48225">
|
|
36
|
+
<span style={{ bold: true }}>Notifications</span>
|
|
37
|
+
</text>
|
|
38
|
+
<text fg="#6a737c">{`(${items().length})`}</text>
|
|
39
|
+
<box flexGrow={1} />
|
|
40
|
+
<text fg="#6a737c">esc:back j/k:navigate</text>
|
|
41
|
+
</box>
|
|
42
|
+
|
|
43
|
+
<Show when={loading()}>
|
|
44
|
+
<box paddingLeft={4} paddingTop={1}>
|
|
45
|
+
<text fg="#6a737c">Loading notifications...</text>
|
|
46
|
+
</box>
|
|
47
|
+
</Show>
|
|
48
|
+
|
|
49
|
+
<Show when={!loading() && items().length === 0}>
|
|
50
|
+
<box paddingLeft={4} paddingTop={1}>
|
|
51
|
+
<text fg="#6a737c">No notifications.</text>
|
|
52
|
+
</box>
|
|
53
|
+
</Show>
|
|
54
|
+
|
|
55
|
+
<box flexDirection="column" paddingTop={1} flexGrow={1}>
|
|
56
|
+
<For each={items()}>
|
|
57
|
+
{(item: any, i) => {
|
|
58
|
+
const isSelected = () => i() === selected()
|
|
59
|
+
const isRead = item.read || item.is_read
|
|
60
|
+
return (
|
|
61
|
+
<box flexDirection="row" paddingLeft={2} paddingRight={2}>
|
|
62
|
+
<box width={3}>
|
|
63
|
+
<text fg={isRead ? "#6a737c" : "#0074cc"}>
|
|
64
|
+
{isRead ? " " : "● "}
|
|
65
|
+
</text>
|
|
66
|
+
</box>
|
|
67
|
+
<box flexDirection="column" flexGrow={1}>
|
|
68
|
+
<text fg={isSelected() ? "#0074cc" : "#e7e9eb"}>
|
|
69
|
+
<span style={{ bold: isSelected() }}>
|
|
70
|
+
{item.message || item.content || item.type || "Notification"}
|
|
71
|
+
</span>
|
|
72
|
+
</text>
|
|
73
|
+
<box flexDirection="row" gap={1}>
|
|
74
|
+
<text fg="#838c95">{item.from_user || item.actor || ""}</text>
|
|
75
|
+
<text fg="#6a737c">{item.created_at || ""}</text>
|
|
76
|
+
</box>
|
|
77
|
+
</box>
|
|
78
|
+
</box>
|
|
79
|
+
)
|
|
80
|
+
}}
|
|
81
|
+
</For>
|
|
82
|
+
</box>
|
|
83
|
+
</box>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createSignal, onMount, For, Show } from "solid-js"
|
|
2
|
+
import { useKeyboard } from "@opentui/solid"
|
|
3
|
+
import { useRoute } from "../context/route"
|
|
4
|
+
|
|
5
|
+
export function Post() {
|
|
6
|
+
const route = useRoute()
|
|
7
|
+
const postId = () => route.data.type === "post" ? route.data.postId : ""
|
|
8
|
+
const [post, setPost] = createSignal<any>(null)
|
|
9
|
+
const [comments, setComments] = createSignal<any[]>([])
|
|
10
|
+
const [loading, setLoading] = createSignal(true)
|
|
11
|
+
const [scroll, setScroll] = createSignal(0)
|
|
12
|
+
|
|
13
|
+
onMount(async () => {
|
|
14
|
+
try {
|
|
15
|
+
const { Posts } = await import("../../api/posts")
|
|
16
|
+
const result = await Posts.detail(postId())
|
|
17
|
+
setPost(result.post || result)
|
|
18
|
+
setComments(result.comments || [])
|
|
19
|
+
} catch {
|
|
20
|
+
setPost(null)
|
|
21
|
+
}
|
|
22
|
+
setLoading(false)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
useKeyboard((evt) => {
|
|
26
|
+
if (evt.name === "up" || evt.name === "k") {
|
|
27
|
+
setScroll((s) => Math.max(0, s - 1))
|
|
28
|
+
evt.preventDefault()
|
|
29
|
+
}
|
|
30
|
+
if (evt.name === "down" || evt.name === "j") {
|
|
31
|
+
setScroll((s) => s + 1)
|
|
32
|
+
evt.preventDefault()
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<box flexDirection="column" flexGrow={1}>
|
|
38
|
+
<Show when={loading()}>
|
|
39
|
+
<box paddingLeft={4} paddingTop={2}>
|
|
40
|
+
<text fg="#6a737c">Loading post...</text>
|
|
41
|
+
</box>
|
|
42
|
+
</Show>
|
|
43
|
+
|
|
44
|
+
<Show when={!loading() && !post()}>
|
|
45
|
+
<box paddingLeft={4} paddingTop={2}>
|
|
46
|
+
<text fg="#d73a49">Post not found.</text>
|
|
47
|
+
</box>
|
|
48
|
+
</Show>
|
|
49
|
+
|
|
50
|
+
<Show when={!loading() && post()}>
|
|
51
|
+
{/* Title */}
|
|
52
|
+
<box paddingLeft={2} paddingRight={2} paddingTop={1} flexShrink={0}>
|
|
53
|
+
<text fg="#e7e9eb">
|
|
54
|
+
<span style={{ bold: true }}>{post()?.title}</span>
|
|
55
|
+
</text>
|
|
56
|
+
</box>
|
|
57
|
+
|
|
58
|
+
{/* Meta */}
|
|
59
|
+
<box paddingLeft={2} paddingTop={0} flexShrink={0} flexDirection="row" gap={2}>
|
|
60
|
+
<text fg="#48a868">{`▲${(post()?.upvotes ?? 0) - (post()?.downvotes ?? 0)}`}</text>
|
|
61
|
+
<text fg="#6a737c">{`💬${post()?.comment_count ?? 0} 👁${post()?.views ?? 0}`}</text>
|
|
62
|
+
<text fg="#838c95">{`by ${post()?.agent ?? "anon"}`}</text>
|
|
63
|
+
</box>
|
|
64
|
+
|
|
65
|
+
{/* Tags */}
|
|
66
|
+
<Show when={(post()?.tags || []).length > 0}>
|
|
67
|
+
<box paddingLeft={2} paddingTop={0} flexShrink={0} flexDirection="row" gap={1}>
|
|
68
|
+
<For each={post()?.tags || []}>
|
|
69
|
+
{(tag: string) => <text fg="#39739d">{`#${tag}`}</text>}
|
|
70
|
+
</For>
|
|
71
|
+
</box>
|
|
72
|
+
</Show>
|
|
73
|
+
|
|
74
|
+
{/* Content */}
|
|
75
|
+
<box paddingLeft={2} paddingRight={2} paddingTop={1} flexGrow={1}>
|
|
76
|
+
<text fg="#c9d1d9">{post()?.content?.slice(0, 2000) || post()?.summary || ""}</text>
|
|
77
|
+
</box>
|
|
78
|
+
|
|
79
|
+
{/* Comments */}
|
|
80
|
+
<Show when={comments().length > 0}>
|
|
81
|
+
<box paddingLeft={2} paddingTop={1} flexShrink={0}>
|
|
82
|
+
<text fg="#f48225">
|
|
83
|
+
<span style={{ bold: true }}>{`Comments (${comments().length})`}</span>
|
|
84
|
+
</text>
|
|
85
|
+
</box>
|
|
86
|
+
<box flexDirection="column" paddingLeft={2} paddingRight={2}>
|
|
87
|
+
<For each={comments()}>
|
|
88
|
+
{(comment: any) => (
|
|
89
|
+
<box flexDirection="column" paddingTop={1}>
|
|
90
|
+
<box flexDirection="row" gap={1}>
|
|
91
|
+
<text fg="#0074cc">
|
|
92
|
+
<span style={{ bold: true }}>{comment.agent || comment.user || "anon"}</span>
|
|
93
|
+
</text>
|
|
94
|
+
<text fg="#6a737c">{comment.created_at || ""}</text>
|
|
95
|
+
</box>
|
|
96
|
+
<box paddingLeft={2}>
|
|
97
|
+
<text fg="#c9d1d9">{comment.content || comment.body || ""}</text>
|
|
98
|
+
</box>
|
|
99
|
+
</box>
|
|
100
|
+
)}
|
|
101
|
+
</For>
|
|
102
|
+
</box>
|
|
103
|
+
</Show>
|
|
104
|
+
</Show>
|
|
105
|
+
</box>
|
|
106
|
+
)
|
|
107
|
+
}
|