From 74d2153ffa9ff467f5d1b91f34a5804db4348b1a Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Tue, 30 Jun 2026 11:26:15 -0400 Subject: [PATCH] feat(phase5): list channels - todo/in_progress/done columns, CRUD --- GUILDED_ROADMAP.md | 2 +- internal/channel/handlers.go | 1 + internal/channel/lists.go | 155 ++++++++++++++++++++++ internal/db/db.go | 14 ++ web/src/components/ChannelList.tsx | 2 +- web/src/components/ChatArea.tsx | 9 +- web/src/components/CreateChannelModal.tsx | 7 +- web/src/components/ListView.tsx | 97 ++++++++++++++ web/src/stores/channel.ts | 2 +- web/tsconfig.app.tsbuildinfo | 2 +- 10 files changed, 282 insertions(+), 9 deletions(-) create mode 100644 internal/channel/lists.go create mode 100644 web/src/components/ListView.tsx diff --git a/GUILDED_ROADMAP.md b/GUILDED_ROADMAP.md index 755b504..dbef479 100644 --- a/GUILDED_ROADMAP.md +++ b/GUILDED_ROADMAP.md @@ -135,7 +135,7 @@ | # | Feature | Status | |---|---------|--------| | 5.1 | Calendar Channels | ✅ Events, RSVPs, month grid | -| 5.2 | Docs Channels | Pending | +| 5.2 | Docs Channels | ✅ CRUD, wiki sidebar, markdown editor | | 5.3 | Lists Channels | Pending | | 5.4 | Scheduling / Availability | Pending | diff --git a/internal/channel/handlers.go b/internal/channel/handlers.go index 1a15d94..6b1f7aa 100644 --- a/internal/channel/handlers.go +++ b/internal/channel/handlers.go @@ -33,6 +33,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) { h.registerForumRoutes(r) h.registerCalendarRoutes(r) h.registerDocRoutes(r) + h.registerListRoutes(r) h.registerOverrideRoutes(r) } diff --git a/internal/channel/lists.go b/internal/channel/lists.go new file mode 100644 index 0000000..8bc469e --- /dev/null +++ b/internal/channel/lists.go @@ -0,0 +1,155 @@ +package channel + +import ( + "encoding/json" + "net/http" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "github.com/go-chi/chi/v5" +) + +type listItem struct { + ID string `json:"id"` + ChannelID string `json:"channel_id"` + CreatorID string `json:"creator_id"` + Title string `json:"title"` + Status string `json:"status"` + Position int `json:"position"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// ponytail: no description, assignee, due_date, parent_id, completed_at, bulk update, or filter. +// Add when someone uses lists for more than a checklist. + +func (h *Handler) registerListRoutes(r chi.Router) { + r.Get("/{channelID}/items", h.ListItems) + r.Post("/{channelID}/items", h.CreateItem) + r.Patch("/items/{itemID}", h.UpdateItem) + r.Delete("/items/{itemID}", h.DeleteItem) +} + +func (h *Handler) ListItems(w http.ResponseWriter, r *http.Request) { + channelID := chi.URLParam(r, "channelID") + userID, _ := middleware.UserIDFromContext(r.Context()) + if !h.checkAccess(r.Context(), w, channelID, userID) { + return + } + + rows, err := h.db.QueryContext(r.Context(), ` + SELECT id, channel_id, creator_id, title, status, position, created_at, updated_at + FROM list_items WHERE channel_id = $1 + ORDER BY position, created_at + `, channelID) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + defer rows.Close() + + items := make([]listItem, 0) + for rows.Next() { + var it listItem + if err := rows.Scan(&it.ID, &it.ChannelID, &it.CreatorID, &it.Title, &it.Status, &it.Position, &it.CreatedAt, &it.UpdatedAt); err != nil { + continue + } + items = append(items, it) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(items) +} + +func (h *Handler) CreateItem(w http.ResponseWriter, r *http.Request) { + channelID := chi.URLParam(r, "channelID") + userID, _ := middleware.UserIDFromContext(r.Context()) + if !h.checkAccess(r.Context(), w, channelID, userID) { + return + } + + var req struct { + Title string `json:"title"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Title == "" { + http.Error(w, `{"error":"title required"}`, http.StatusBadRequest) + return + } + + var it listItem + err := h.db.QueryRowContext(r.Context(), ` + INSERT INTO list_items (channel_id, creator_id, title) + VALUES ($1, $2, $3) + RETURNING id, channel_id, creator_id, title, status, position, created_at, updated_at + `, channelID, userID, req.Title).Scan( + &it.ID, &it.ChannelID, &it.CreatorID, &it.Title, &it.Status, &it.Position, &it.CreatedAt, &it.UpdatedAt, + ) + if err != nil { + http.Error(w, `{"error":"failed to create item"}`, http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(it) +} + +func (h *Handler) UpdateItem(w http.ResponseWriter, r *http.Request) { + itemID := chi.URLParam(r, "itemID") + userID, _ := middleware.UserIDFromContext(r.Context()) + + var channelID string + err := h.db.QueryRowContext(r.Context(), `SELECT channel_id FROM list_items WHERE id = $1`, itemID).Scan(&channelID) + if err != nil { + http.Error(w, `{"error":"item not found"}`, http.StatusNotFound) + return + } + if !h.checkAccess(r.Context(), w, channelID, userID) { + return + } + + var req struct { + Title string `json:"title"` + Status string `json:"status"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + + var it listItem + err = h.db.QueryRowContext(r.Context(), ` + UPDATE list_items + SET title = COALESCE(NULLIF($2, ''), title), + status = COALESCE(NULLIF($3, ''), status), + updated_at = NOW() + WHERE id = $1 + RETURNING id, channel_id, creator_id, title, status, position, created_at, updated_at + `, itemID, req.Title, req.Status).Scan( + &it.ID, &it.ChannelID, &it.CreatorID, &it.Title, &it.Status, &it.Position, &it.CreatedAt, &it.UpdatedAt, + ) + if err != nil { + http.Error(w, `{"error":"failed to update item"}`, http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(it) +} + +func (h *Handler) DeleteItem(w http.ResponseWriter, r *http.Request) { + itemID := chi.URLParam(r, "itemID") + userID, _ := middleware.UserIDFromContext(r.Context()) + + var channelID string + err := h.db.QueryRowContext(r.Context(), `SELECT channel_id FROM list_items WHERE id = $1`, itemID).Scan(&channelID) + if err != nil { + http.Error(w, `{"error":"item not found"}`, http.StatusNotFound) + return + } + if !h.checkAccess(r.Context(), w, channelID, userID) { + return + } + + _, err = h.db.ExecContext(r.Context(), `DELETE FROM list_items WHERE id = $1`, itemID) + if err != nil { + http.Error(w, `{"error":"failed to delete item"}`, http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/db/db.go b/internal/db/db.go index 9d88aa1..b2c6368 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -421,6 +421,20 @@ CREATE TABLE IF NOT EXISTS documents ( CREATE INDEX IF NOT EXISTS idx_documents_channel ON documents(channel_id, updated_at DESC); +-- List items (task lists) +CREATE TABLE IF NOT EXISTS list_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(511) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'todo', + position INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_list_items_channel ON list_items(channel_id, position); + -- Message full-text search 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); diff --git a/web/src/components/ChannelList.tsx b/web/src/components/ChannelList.tsx index 92ec5b7..ab87c6f 100644 --- a/web/src/components/ChannelList.tsx +++ b/web/src/components/ChannelList.tsx @@ -92,7 +92,7 @@ export function ChannelList() { channel.id === activeChannelId ? 'terminal-active' : 'hover:bg-gb-bg-t text-gb-fg-s' }`} > - {channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : '#'} + {channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : channel.type === 'list' ? '☑' : '#'} {channel.name} { e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }} diff --git a/web/src/components/ChatArea.tsx b/web/src/components/ChatArea.tsx index 1c9199a..50855ed 100644 --- a/web/src/components/ChatArea.tsx +++ b/web/src/components/ChatArea.tsx @@ -12,6 +12,7 @@ import { ThreadPanel } from "./ThreadPanel.tsx"; import { ForumView } from "./ForumView.tsx"; import { CalendarView } from "./CalendarView.tsx"; import { DocsView } from "./DocsView.tsx"; +import { ListView } from "./ListView.tsx"; import { UserProfileModal } from "./UserProfileModal.tsx"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -251,12 +252,14 @@ export function ChatArea() { ? `○ ${activeChannel.name}` : activeChannel.type === 'docs' ? `☰ ${activeChannel.name}` - : `# ${activeChannel.name}` + : activeChannel.type === 'list' + ? `☑ ${activeChannel.name}` + : `# ${activeChannel.name}` : activeChannelId ? `#${activeChannelId}` : "[NO CHANNEL SELECTED]"} - {activeChannelId && activeChannel?.type !== 'forum' && ( + {activeChannelId && activeChannel?.type === 'text' && (
+ {it.title} + +
+ + ))} + + + ); + + return ( +
+
+ ☑ {channelName} +
+
+ setNewTitle(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && addItem()} + placeholder="add item..." + className="terminal-input flex-1" + /> + +
+
+ {col(todo, 'TODO', 'text-gb-fg')} + {col(inProgress, 'DOING', 'text-gb-blue')} + {col(done, 'DONE', 'text-gb-fg-f')} +
+
+ ); +} diff --git a/web/src/stores/channel.ts b/web/src/stores/channel.ts index c8e7655..1ae609f 100644 --- a/web/src/stores/channel.ts +++ b/web/src/stores/channel.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { api } from '../lib/api.ts'; -export type ChannelType = 'text' | 'voice' | 'forum' | 'calendar' | 'docs'; +export type ChannelType = 'text' | 'voice' | 'forum' | 'calendar' | 'docs' | 'list'; export interface Channel { id: string; diff --git a/web/tsconfig.app.tsbuildinfo b/web/tsconfig.app.tsbuildinfo index a249d24..d67db0c 100644 --- a/web/tsconfig.app.tsbuildinfo +++ b/web/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/CalendarView.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/DocsView.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/UserProfileModal.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"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/CalendarView.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/DocsView.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/ListView.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/UserProfileModal.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"} \ No newline at end of file