feat(phase3): forum channels + tags + card UI
This commit is contained in:
+2
-2
@@ -35,14 +35,14 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 3 — Threads & Forums 🔄 IN PROGRESS
|
## Phase 3 — Threads & Forums ✅ COMPLETE
|
||||||
|
|
||||||
> Guilded's thread and forum system kept busy servers organized.
|
> Guilded's thread and forum system kept busy servers organized.
|
||||||
|
|
||||||
| # | Feature | Status |
|
| # | Feature | Status |
|
||||||
|---|---------|--------|
|
|---|---------|--------|
|
||||||
| 3.1 | Threads | ✅ Reused channels table + thread panel |
|
| 3.1 | Threads | ✅ Reused channels table + thread panel |
|
||||||
| 3.2 | Forum Channels | Pending |
|
| 3.2 | Forum Channels | ✅ Forum posts as threads + tags + card view |
|
||||||
|
|
||||||
### 2.1 Per-Channel Permission Overrides
|
### 2.1 Per-Channel Permission Overrides
|
||||||
- **Backend:** New `channel_overrides` table (channel_id, target_type: role/user, target_id, allow_bitflags, deny_bitflags). Extend permission checker to layer channel overrides on top of role permissions.
|
- **Backend:** New `channel_overrides` table (channel_id, target_type: role/user, target_id, allow_bitflags, deny_bitflags). Extend permission checker to layer channel overrides on top of role permissions.
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package channel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type forumTag struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Emoji *string `json:"emoji"`
|
||||||
|
Color *string `json:"color"`
|
||||||
|
TagID string `json:"tag_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type forumPost struct {
|
||||||
|
threadResponse
|
||||||
|
TagIDs []string `json:"tag_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) registerForumRoutes(r chi.Router) {
|
||||||
|
r.Get("/{channelID}/forum-tags", h.ListForumTags)
|
||||||
|
r.Post("/{channelID}/forum-tags", h.CreateForumTag)
|
||||||
|
r.Delete("/forum-tags/{tagID}", h.DeleteForumTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateForumTag creates a tag for a forum channel.
|
||||||
|
func (h *Handler) CreateForumTag(w http.ResponseWriter, r *http.Request) {
|
||||||
|
channelID := chi.URLParam(r, "channelID")
|
||||||
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serverID, err := h.serverIDForChannel(r.Context(), channelID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
member, err := h.isMember(r.Context(), userID, serverID)
|
||||||
|
if err != nil || !member {
|
||||||
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Emoji *string `json:"emoji"`
|
||||||
|
Color *string `json:"color"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
|
||||||
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var tag forumTag
|
||||||
|
err = h.db.QueryRowContext(r.Context(), `
|
||||||
|
INSERT INTO forum_tags (channel_id, name, emoji, color)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, name, emoji, color
|
||||||
|
`, channelID, req.Name, req.Emoji, req.Color).Scan(&tag.ID, &tag.Name, &tag.Emoji, &tag.Color)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to create tag"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tag.TagID = tag.ID
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListForumTags lists tags for a forum channel.
|
||||||
|
func (h *Handler) ListForumTags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
channelID := chi.URLParam(r, "channelID")
|
||||||
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serverID, err := h.serverIDForChannel(r.Context(), channelID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
member, err := h.isMember(r.Context(), userID, serverID)
|
||||||
|
if err != nil || !member {
|
||||||
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := h.db.QueryContext(r.Context(), `
|
||||||
|
SELECT id, name, emoji, color FROM forum_tags WHERE channel_id = $1 ORDER BY name
|
||||||
|
`, channelID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
tags := make([]forumTag, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var t forumTag
|
||||||
|
var emoji, color sql.NullString
|
||||||
|
if err := rows.Scan(&t.ID, &t.Name, &emoji, &color); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if emoji.Valid {
|
||||||
|
t.Emoji = &emoji.String
|
||||||
|
}
|
||||||
|
if color.Valid {
|
||||||
|
t.Color = &color.String
|
||||||
|
}
|
||||||
|
t.TagID = t.ID
|
||||||
|
tags = append(tags, t)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteForumTag deletes a forum tag.
|
||||||
|
func (h *Handler) DeleteForumTag(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tagID := chi.URLParam(r, "tagID")
|
||||||
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var channelID string
|
||||||
|
err := h.db.QueryRowContext(r.Context(), `SELECT channel_id FROM forum_tags WHERE id = $1`, tagID).Scan(&channelID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"tag not found"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serverID, err := h.serverIDForChannel(r.Context(), channelID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
member, err := h.isMember(r.Context(), userID, serverID)
|
||||||
|
if err != nil || !member {
|
||||||
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.db.ExecContext(r.Context(), `DELETE FROM forum_tags WHERE id = $1`, tagID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to delete tag"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
|||||||
r.Post("/{channelID}/threads", h.CreateThread)
|
r.Post("/{channelID}/threads", h.CreateThread)
|
||||||
r.Get("/{channelID}/threads", h.ListThreads)
|
r.Get("/{channelID}/threads", h.ListThreads)
|
||||||
r.Patch("/threads/{threadID}", h.UpdateThread)
|
r.Patch("/threads/{threadID}", h.UpdateThread)
|
||||||
|
h.registerForumRoutes(r)
|
||||||
h.registerOverrideRoutes(r)
|
h.registerOverrideRoutes(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type createThreadRequest struct {
|
type createThreadRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
MessageID *string `json:"message_id,omitempty"`
|
MessageID *string `json:"message_id,omitempty"`
|
||||||
|
TagIDs []string `json:"tag_ids,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type threadResponse struct {
|
type threadResponse struct {
|
||||||
@@ -95,6 +96,16 @@ func (h *Handler) CreateThread(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attach optional tags for forum posts.
|
||||||
|
if len(req.TagIDs) > 0 {
|
||||||
|
// ponytail: ignore tag errors; best-effort assignment
|
||||||
|
for _, tagID := range req.TagIDs {
|
||||||
|
_, _ = h.db.ExecContext(r.Context(), `
|
||||||
|
INSERT INTO channel_tags (channel_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING
|
||||||
|
`, thread.ID, tagID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
json.NewEncoder(w).Encode(thread)
|
json.NewEncoder(w).Encode(thread)
|
||||||
|
|||||||
+8
-8
@@ -316,14 +316,6 @@ CREATE TABLE IF NOT EXISTS channel_overrides (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_channel_overrides_channel ON channel_overrides(channel_id);
|
CREATE INDEX IF NOT EXISTS idx_channel_overrides_channel ON channel_overrides(channel_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_channel_overrides_target ON channel_overrides(channel_id, target_type, target_id);
|
CREATE INDEX IF NOT EXISTS idx_channel_overrides_target ON channel_overrides(channel_id, target_type, target_id);
|
||||||
|
|
||||||
-- Threads: reuse channels table by adding parent_channel_id; thread messages are still messages scoped to the thread channel.
|
|
||||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS parent_channel_id UUID REFERENCES channels(id) ON DELETE CASCADE;
|
|
||||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
|
|
||||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS auto_archive_duration INTEGER NOT NULL DEFAULT 1440;
|
|
||||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS message_count INTEGER NOT NULL DEFAULT 0;
|
|
||||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS last_message_at TIMESTAMPTZ;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_channels_parent ON channels(parent_channel_id);
|
|
||||||
|
|
||||||
-- Forum tags
|
-- Forum tags
|
||||||
CREATE TABLE IF NOT EXISTS forum_tags (
|
CREATE TABLE IF NOT EXISTS forum_tags (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -344,6 +336,14 @@ CREATE TABLE IF NOT EXISTS channel_tags (
|
|||||||
PRIMARY KEY (channel_id, tag_id)
|
PRIMARY KEY (channel_id, tag_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Threads: reuse channels table by adding parent_channel_id; thread messages are still messages scoped to the thread channel.
|
||||||
|
ALTER TABLE channels ADD COLUMN IF NOT EXISTS parent_channel_id UUID REFERENCES channels(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE channels ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE channels ADD COLUMN IF NOT EXISTS auto_archive_duration INTEGER NOT NULL DEFAULT 1440;
|
||||||
|
ALTER TABLE channels ADD COLUMN IF NOT EXISTS message_count INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE channels ADD COLUMN IF NOT EXISTS last_message_at TIMESTAMPTZ;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_channels_parent ON channels(parent_channel_id);
|
||||||
|
|
||||||
-- Message full-text search
|
-- Message full-text search
|
||||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS search_vector tsvector;
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS search_vector tsvector;
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_search ON messages USING GIN(search_vector);
|
CREATE INDEX IF NOT EXISTS idx_messages_search ON messages USING GIN(search_vector);
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function ChannelList() {
|
|||||||
channel.id === activeChannelId ? 'terminal-active' : 'hover:bg-gb-bg-t text-gb-fg-s'
|
channel.id === activeChannelId ? 'terminal-active' : 'hover:bg-gb-bg-t text-gb-fg-s'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-gb-fg-f">#</span>
|
<span className="text-gb-fg-f">{channel.type === 'forum' ? '■' : '#'}</span>
|
||||||
<span className="truncate flex-1">{channel.name}</span>
|
<span className="truncate flex-1">{channel.name}</span>
|
||||||
<span
|
<span
|
||||||
onClick={(e) => { e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }}
|
onClick={(e) => { e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { MentionDropdown } from "./MentionDropdown";
|
|||||||
import { MessageSearch } from "./MessageSearch";
|
import { MessageSearch } from "./MessageSearch";
|
||||||
import { ThreadListPanel } from "./ThreadListPanel.tsx";
|
import { ThreadListPanel } from "./ThreadListPanel.tsx";
|
||||||
import { ThreadPanel } from "./ThreadPanel.tsx";
|
import { ThreadPanel } from "./ThreadPanel.tsx";
|
||||||
|
import { ForumView } from "./ForumView.tsx";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
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">
|
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s flex items-center justify-between">
|
||||||
<span>
|
<span>
|
||||||
{activeChannel
|
{activeChannel
|
||||||
? `# ${activeChannel.name}`
|
? activeChannel.type === 'forum'
|
||||||
|
? `■ ${activeChannel.name}`
|
||||||
|
: `# ${activeChannel.name}`
|
||||||
: activeChannelId
|
: activeChannelId
|
||||||
? `#${activeChannelId}`
|
? `#${activeChannelId}`
|
||||||
: "[NO CHANNEL SELECTED]"}
|
: "[NO CHANNEL SELECTED]"}
|
||||||
</span>
|
</span>
|
||||||
{activeChannelId && (
|
{activeChannelId && activeChannel?.type !== 'forum' && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowThreads((prev) => !prev)}
|
onClick={() => setShowThreads((prev) => !prev)}
|
||||||
@@ -278,14 +281,14 @@ export function ChatArea() {
|
|||||||
onClose={() => setShowSearch(false)}
|
onClose={() => setShowSearch(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showThreads && activeChannelId && (
|
{showThreads && activeChannelId && activeChannel?.type !== 'forum' && (
|
||||||
<ThreadListPanel
|
<ThreadListPanel
|
||||||
channelId={activeChannelId}
|
channelId={activeChannelId}
|
||||||
onSelect={(t) => setActiveThread(t)}
|
onSelect={(t) => setActiveThread(t)}
|
||||||
onClose={() => setShowThreads(false)}
|
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">
|
<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>
|
<span>{selectedIds.size} selected</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -314,75 +317,81 @@ export function ChatArea() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
{activeChannel && activeChannel.type === 'forum' && activeChannelId ? (
|
||||||
<div className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
|
<ForumView channelId={activeChannelId} channelName={activeChannel.name} onOpenThread={(t) => setActiveThread(t)} />
|
||||||
{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>
|
<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">
|
||||||
{messages.map((message) => (
|
{isLoading && <p className="text-gb-fg-f">[loading...]</p>}
|
||||||
<div
|
{!isLoading && messages.length === 0 && (
|
||||||
key={message.id}
|
<p className="text-gb-fg-f">[no messages in this channel]</p>
|
||||||
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>{" "}
|
{messages.map((message) => (
|
||||||
<span className="text-gb-aqua"><{message.author_username}></span>{" "}
|
<div
|
||||||
<span className="text-gb-fg">{renderContent(message.content, memberUsernames)}</span>
|
key={message.id}
|
||||||
{renderEmbeds(message.embeds)}
|
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"><{message.author_username}></span>{" "}
|
||||||
|
<span className="text-gb-fg">{renderContent(message.content, memberUsernames)}</span>
|
||||||
|
{renderEmbeds(message.embeds)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
{showGifPicker && (
|
||||||
<div ref={bottomRef} />
|
<div className="px-3 pb-1">
|
||||||
</div>
|
<GiphyPicker onSelect={handleGifSelect} onClose={() => setShowGifPicker(false)} />
|
||||||
{showGifPicker && (
|
</div>
|
||||||
<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} />
|
|
||||||
)}
|
)}
|
||||||
|
{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>
|
</div>
|
||||||
<button
|
{activeThread && (
|
||||||
type="button"
|
<ThreadPanel threadId={activeThread.id} threadName={activeThread.name} onClose={() => setActiveThread(null)} />
|
||||||
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)} />
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ interface ChannelApiResponse {
|
|||||||
id: string;
|
id: string;
|
||||||
server_id: string;
|
server_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'text' | 'voice';
|
type: 'text' | 'voice' | 'forum';
|
||||||
category: string;
|
category: string;
|
||||||
position: number;
|
position: number;
|
||||||
|
slowmode_seconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SLOWMODE_OPTIONS = [
|
const SLOWMODE_OPTIONS = [
|
||||||
@@ -28,7 +29,7 @@ const SLOWMODE_OPTIONS = [
|
|||||||
|
|
||||||
export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProps) {
|
export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProps) {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [type, setType] = useState<'text' | 'voice'>('text');
|
const [type, setType] = useState<'text' | 'voice' | 'forum'>('text');
|
||||||
const [category, setCategory] = useState('general');
|
const [category, setCategory] = useState('general');
|
||||||
const [slowmodeSeconds, setSlowmodeSeconds] = useState(0);
|
const [slowmodeSeconds, setSlowmodeSeconds] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -65,6 +66,7 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
|
|||||||
type: result.type,
|
type: result.type,
|
||||||
category: result.category || null,
|
category: result.category || null,
|
||||||
position: result.position,
|
position: result.position,
|
||||||
|
slowmode_seconds: result.slowmode_seconds,
|
||||||
};
|
};
|
||||||
useChannelStore.getState().addChannel(newChannel);
|
useChannelStore.getState().addChannel(newChannel);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -105,11 +107,12 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
|
|||||||
<label className="text-xs text-gb-fg-f block mb-1">TYPE:</label>
|
<label className="text-xs text-gb-fg-f block mb-1">TYPE:</label>
|
||||||
<select
|
<select
|
||||||
value={type}
|
value={type}
|
||||||
onChange={(e) => setType(e.target.value as 'text' | 'voice')}
|
onChange={(e) => setType(e.target.value as 'text' | 'voice' | 'forum')}
|
||||||
className="terminal-input w-full"
|
className="terminal-input w-full"
|
||||||
>
|
>
|
||||||
<option value="text">text</option>
|
<option value="text">text</option>
|
||||||
<option value="voice">voice</option>
|
<option value="voice">voice</option>
|
||||||
|
<option value="forum">forum</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useThreadStore, type Thread, type ForumTag } from '../stores/thread.ts';
|
||||||
|
|
||||||
|
interface ForumViewProps {
|
||||||
|
channelId: string;
|
||||||
|
channelName: string;
|
||||||
|
onOpenThread: (thread: { id: string; name: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ForumView({ channelId, channelName, onOpenThread }: ForumViewProps) {
|
||||||
|
const threads = useThreadStore((s) => s.threadsByParent[channelId] || []);
|
||||||
|
const tags = useThreadStore((s) => s.tagsByForum[channelId] || []);
|
||||||
|
const fetchThreads = useThreadStore((s) => s.fetchThreads);
|
||||||
|
const fetchForumTags = useThreadStore((s) => s.fetchForumTags);
|
||||||
|
const createThread = useThreadStore((s) => s.createThread);
|
||||||
|
const createForumTag = useThreadStore((s) => s.createForumTag);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [body, setBody] = useState('');
|
||||||
|
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||||
|
const [tagName, setTagName] = useState('');
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchThreads(channelId);
|
||||||
|
fetchForumTags(channelId);
|
||||||
|
}, [channelId, fetchThreads, fetchForumTags]);
|
||||||
|
|
||||||
|
const handlePost = async () => {
|
||||||
|
if (!title.trim()) return;
|
||||||
|
const t = await createThread(channelId, { name: title.trim(), tag_ids: selectedTagIds });
|
||||||
|
if (body.trim()) {
|
||||||
|
// ponytail: send first message via thread store after creation
|
||||||
|
const { sendThreadMessage } = useThreadStore.getState();
|
||||||
|
await sendThreadMessage(t.id, body.trim());
|
||||||
|
}
|
||||||
|
setTitle('');
|
||||||
|
setBody('');
|
||||||
|
setSelectedTagIds([]);
|
||||||
|
setShowForm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTag = (id: string) => {
|
||||||
|
setSelectedTagIds((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateTag = async () => {
|
||||||
|
if (!tagName.trim()) return;
|
||||||
|
await createForumTag(channelId, { name: tagName.trim() });
|
||||||
|
setTagName('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-gb-bg">
|
||||||
|
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s flex items-center justify-between">
|
||||||
|
<span>■ {channelName}</span>
|
||||||
|
<button onClick={() => setShowForm((p) => !p)} className="text-xs text-gb-fg-f hover:text-gb-orange font-mono">[NEW POST]</button>
|
||||||
|
</div>
|
||||||
|
{showForm && (
|
||||||
|
<div className="p-3 border-b border-gb-bg-t space-y-2 font-mono text-xs">
|
||||||
|
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="post title" className="terminal-input w-full" />
|
||||||
|
<textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="body (optional)" className="terminal-input w-full h-20" />
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tags.map((tag: ForumTag) => (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
onClick={() => toggleTag(tag.tag_id || tag.id)}
|
||||||
|
className={`px-2 py-0.5 border ${selectedTagIds.includes(tag.tag_id || tag.id) ? 'bg-gb-orange text-gb-bg border-gb-orange' : 'border-gb-bg-t text-gb-fg'}`}
|
||||||
|
>
|
||||||
|
{tag.emoji && <span>{tag.emoji}</span>} {tag.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input value={tagName} onChange={(e) => setTagName(e.target.value)} placeholder="new tag" className="terminal-input flex-1" />
|
||||||
|
<button onClick={handleCreateTag} className="px-2 bg-gb-bg-s border border-gb-bg-t hover:border-gb-fg-t">[+TAG]</button>
|
||||||
|
</div>
|
||||||
|
<button onClick={handlePost} disabled={!title.trim()} className="px-3 py-1 bg-gb-orange text-gb-bg disabled:opacity-50">[POST]</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||||
|
{threads.length === 0 && <p className="text-gb-fg-f text-xs font-mono">[no posts]</p>}
|
||||||
|
{threads.map((t: Thread) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => onOpenThread({ id: t.id, name: t.name })}
|
||||||
|
className="w-full text-left border border-gb-bg-t p-2 hover:border-gb-fg-t bg-gb-bg-s"
|
||||||
|
>
|
||||||
|
<div className="text-gb-fg text-sm font-bold truncate">{t.name}</div>
|
||||||
|
<div className="text-gb-fg-f text-xs">
|
||||||
|
replies:{t.message_count} latest:{t.last_message_at ? new Date(t.last_message_at).toLocaleDateString() : '—'}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { api } from '../lib/api.ts';
|
import { api } from '../lib/api.ts';
|
||||||
|
|
||||||
export type ChannelType = 'text' | 'voice';
|
export type ChannelType = 'text' | 'voice' | 'forum';
|
||||||
|
|
||||||
export interface Channel {
|
export interface Channel {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -10,6 +10,7 @@ export interface Channel {
|
|||||||
type: ChannelType;
|
type: ChannelType;
|
||||||
category: string | null;
|
category: string | null;
|
||||||
position: number;
|
position: number;
|
||||||
|
slowmode_seconds?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChannelState {
|
interface ChannelState {
|
||||||
|
|||||||
@@ -15,15 +15,26 @@ export interface Thread {
|
|||||||
message_count: number;
|
message_count: number;
|
||||||
last_message_at: string | null;
|
last_message_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
tag_ids?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForumTag {
|
||||||
|
id: string;
|
||||||
|
tag_id: string;
|
||||||
|
name: string;
|
||||||
|
emoji: string | null;
|
||||||
|
color: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateThreadData {
|
export interface CreateThreadData {
|
||||||
name: string;
|
name: string;
|
||||||
message_id?: string;
|
message_id?: string;
|
||||||
|
tag_ids?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ThreadState {
|
interface ThreadState {
|
||||||
threadsByParent: Record<string, Thread[]>;
|
threadsByParent: Record<string, Thread[]>;
|
||||||
|
tagsByForum: Record<string, ForumTag[]>;
|
||||||
activeThreadId: string | null;
|
activeThreadId: string | null;
|
||||||
messagesByThread: Record<string, Message[]>;
|
messagesByThread: Record<string, Message[]>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -35,10 +46,14 @@ interface ThreadState {
|
|||||||
fetchThreadMessages: (threadId: string, before?: string) => Promise<void>;
|
fetchThreadMessages: (threadId: string, before?: string) => Promise<void>;
|
||||||
sendThreadMessage: (threadId: string, content: string) => Promise<Message>;
|
sendThreadMessage: (threadId: string, content: string) => Promise<Message>;
|
||||||
addThreadMessage: (message: Message) => void;
|
addThreadMessage: (message: Message) => void;
|
||||||
|
fetchForumTags: (forumChannelId: string) => Promise<void>;
|
||||||
|
createForumTag: (forumChannelId: string, data: { name: string; emoji?: string; color?: string }) => Promise<ForumTag>;
|
||||||
|
deleteForumTag: (tagId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useThreadStore = create<ThreadState>((set) => ({
|
export const useThreadStore = create<ThreadState>((set) => ({
|
||||||
threadsByParent: {},
|
threadsByParent: {},
|
||||||
|
tagsByForum: {},
|
||||||
activeThreadId: null,
|
activeThreadId: null,
|
||||||
messagesByThread: {},
|
messagesByThread: {},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -113,4 +128,31 @@ export const useThreadStore = create<ThreadState>((set) => ({
|
|||||||
if (list.some((m) => m.id === message.id)) return state;
|
if (list.some((m) => m.id === message.id)) return state;
|
||||||
return { messagesByThread: { ...state.messagesByThread, [message.channel_id]: [...list, message] } };
|
return { messagesByThread: { ...state.messagesByThread, [message.channel_id]: [...list, message] } };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
fetchForumTags: async (forumChannelId) => {
|
||||||
|
const tags = await api.get<ForumTag[]>(`/channels/${forumChannelId}/forum-tags`);
|
||||||
|
set((state) => ({
|
||||||
|
tagsByForum: { ...state.tagsByForum, [forumChannelId]: Array.isArray(tags) ? tags : [] },
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
createForumTag: async (forumChannelId, data) => {
|
||||||
|
const tag = await api.post<ForumTag>(`/channels/${forumChannelId}/forum-tags`, data);
|
||||||
|
set((state) => {
|
||||||
|
const list = state.tagsByForum[forumChannelId] || [];
|
||||||
|
return { tagsByForum: { ...state.tagsByForum, [forumChannelId]: [...list, tag] } };
|
||||||
|
});
|
||||||
|
return tag;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteForumTag: async (tagId) => {
|
||||||
|
await api.delete(`/forum-tags/${tagId}`);
|
||||||
|
set((state) => {
|
||||||
|
const next: Record<string, ForumTag[]> = {};
|
||||||
|
for (const forumId of Object.keys(state.tagsByForum)) {
|
||||||
|
next[forumId] = state.tagsByForum[forumId].filter((t) => t.id !== tagId && t.tag_id !== tagId);
|
||||||
|
}
|
||||||
|
return { tagsByForum: next };
|
||||||
|
});
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChannelSettingsModal.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/ConversationList.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/DMChat.tsx","./src/components/EmojiPicker.tsx","./src/components/ForumView.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberContextMenu.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionDropdown.tsx","./src/components/MentionPopup.tsx","./src/components/MessageSearch.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/NewConversationModal.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/ServerSettingsModal.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/ThreadListPanel.tsx","./src/components/ThreadPanel.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/lib/usePermissions.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/conversation.ts","./src/stores/layout.ts","./src/stores/member.ts","./src/stores/message.ts","./src/stores/moderation.ts","./src/stores/permissions.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/thread.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user