feat(phase3): forum channels + tags + card UI

This commit is contained in:
2026-06-30 10:44:52 -04:00
parent f3f03df710
commit 368172e3d6
12 changed files with 407 additions and 87 deletions
+78 -69
View File
@@ -9,6 +9,7 @@ import { MentionDropdown } from "./MentionDropdown";
import { MessageSearch } from "./MessageSearch";
import { ThreadListPanel } from "./ThreadListPanel.tsx";
import { ThreadPanel } from "./ThreadPanel.tsx";
import { ForumView } from "./ForumView.tsx";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
@@ -240,12 +241,14 @@ export function ChatArea() {
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s flex items-center justify-between">
<span>
{activeChannel
? `# ${activeChannel.name}`
? activeChannel.type === 'forum'
? `${activeChannel.name}`
: `# ${activeChannel.name}`
: activeChannelId
? `#${activeChannelId}`
: "[NO CHANNEL SELECTED]"}
</span>
{activeChannelId && (
{activeChannelId && activeChannel?.type !== 'forum' && (
<div className="flex items-center gap-2">
<button
onClick={() => setShowThreads((prev) => !prev)}
@@ -278,14 +281,14 @@ export function ChatArea() {
onClose={() => setShowSearch(false)}
/>
)}
{showThreads && activeChannelId && (
{showThreads && activeChannelId && activeChannel?.type !== 'forum' && (
<ThreadListPanel
channelId={activeChannelId}
onSelect={(t) => setActiveThread(t)}
onClose={() => setShowThreads(false)}
/>
)}
{selectMode && activeChannelId && (
{selectMode && activeChannelId && activeChannel?.type !== 'forum' && (
<div className="px-3 py-1 text-xs font-mono text-gb-fg-f flex items-center justify-between bg-gb-bg-s border-b border-gb-bg-t">
<span>{selectedIds.size} selected</span>
<div className="flex items-center gap-2">
@@ -314,75 +317,81 @@ export function ChatArea() {
</div>
)}
<div className="flex flex-1 min-h-0">
<div className="flex-1 flex flex-col min-w-0">
<div className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
{isLoading && <p className="text-gb-fg-f">[loading...]</p>}
{!isLoading && messages.length === 0 && (
<p className="text-gb-fg-f">[no messages in this channel]</p>
)}
{messages.map((message) => (
<div
key={message.id}
onClick={() => selectMode && activeChannelId && toggleSelectedMessage(activeChannelId, message.id)}
className={`break-words ${selectMode ? 'cursor-pointer hover:bg-gb-bg-s' : ''} ${selectedIds.has(message.id) ? 'bg-gb-bg-s' : ''}`}
>
{selectMode && activeChannelId && (
<span className="text-gb-fg-f mr-2">
{selectedIds.has(message.id) ? '[x]' : '[ ]'}
</span>
{activeChannel && activeChannel.type === 'forum' && activeChannelId ? (
<ForumView channelId={activeChannelId} channelName={activeChannel.name} onOpenThread={(t) => setActiveThread(t)} />
) : (
<>
<div className="flex-1 flex flex-col min-w-0">
<div className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
{isLoading && <p className="text-gb-fg-f">[loading...]</p>}
{!isLoading && messages.length === 0 && (
<p className="text-gb-fg-f">[no messages in this channel]</p>
)}
<span className="text-gb-fg-f">[{formatTime(message.created_at)}]</span>{" "}
<span className="text-gb-aqua">&lt;{message.author_username}&gt;</span>{" "}
<span className="text-gb-fg">{renderContent(message.content, memberUsernames)}</span>
{renderEmbeds(message.embeds)}
{messages.map((message) => (
<div
key={message.id}
onClick={() => selectMode && activeChannelId && toggleSelectedMessage(activeChannelId, message.id)}
className={`break-words ${selectMode ? 'cursor-pointer hover:bg-gb-bg-s' : ''} ${selectedIds.has(message.id) ? 'bg-gb-bg-s' : ''}`}
>
{selectMode && activeChannelId && (
<span className="text-gb-fg-f mr-2">
{selectedIds.has(message.id) ? '[x]' : '[ ]'}
</span>
)}
<span className="text-gb-fg-f">[{formatTime(message.created_at)}]</span>{" "}
<span className="text-gb-aqua">&lt;{message.author_username}&gt;</span>{" "}
<span className="text-gb-fg">{renderContent(message.content, memberUsernames)}</span>
{renderEmbeds(message.embeds)}
</div>
))}
<div ref={bottomRef} />
</div>
))}
<div ref={bottomRef} />
</div>
{showGifPicker && (
<div className="px-3 pb-1">
<GiphyPicker onSelect={handleGifSelect} onClose={() => setShowGifPicker(false)} />
</div>
)}
{error && (
<div className="px-3 pb-1">
<p className="text-gb-red text-xs font-mono">ERR: {error}</p>
</div>
)}
{slowmodeRemaining > 0 && (
<div className="px-3 pt-1 text-xs text-gb-fg-f font-mono">
SLOWMODE: wait {slowmodeRemaining}s
</div>
)}
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2 relative">
<span className="text-gb-fg-f select-none shrink-0">{">"}</span>
<div className="flex-1 min-w-0 relative">
<input
ref={inputRef}
type="text"
value={input}
onChange={handleInputChange}
placeholder="type a message..."
className="terminal-input w-full"
disabled={!activeChannelId || slowmodeRemaining > 0}
/>
{mentionQuery !== null && (
<MentionDropdown query={mentionQuery} members={members} onSelect={handleMentionSelect} />
{showGifPicker && (
<div className="px-3 pb-1">
<GiphyPicker onSelect={handleGifSelect} onClose={() => setShowGifPicker(false)} />
</div>
)}
{error && (
<div className="px-3 pb-1">
<p className="text-gb-red text-xs font-mono">ERR: {error}</p>
</div>
)}
{slowmodeRemaining > 0 && (
<div className="px-3 pt-1 text-xs text-gb-fg-f font-mono">
SLOWMODE: wait {slowmodeRemaining}s
</div>
)}
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2 relative">
<span className="text-gb-fg-f select-none shrink-0">{">"}</span>
<div className="flex-1 min-w-0 relative">
<input
ref={inputRef}
type="text"
value={input}
onChange={handleInputChange}
placeholder="type a message..."
className="terminal-input w-full"
disabled={!activeChannelId || slowmodeRemaining > 0}
/>
{mentionQuery !== null && (
<MentionDropdown query={mentionQuery} members={members} onSelect={handleMentionSelect} />
)}
</div>
<button
type="button"
onClick={() => setShowGifPicker((prev) => !prev)}
disabled={!activeChannelId}
className="text-gb-fg-f hover:text-gb-orange font-mono text-sm select-none disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
title="Toggle GIF picker"
>
[GIF]
</button>
</form>
</div>
<button
type="button"
onClick={() => setShowGifPicker((prev) => !prev)}
disabled={!activeChannelId}
className="text-gb-fg-f hover:text-gb-orange font-mono text-sm select-none disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
title="Toggle GIF picker"
>
[GIF]
</button>
</form>
</div>
{activeThread && (
<ThreadPanel threadId={activeThread.id} threadName={activeThread.name} onClose={() => setActiveThread(null)} />
{activeThread && (
<ThreadPanel threadId={activeThread.id} threadName={activeThread.name} onClose={() => setActiveThread(null)} />
)}
</>
)}
</div>
</div>