diff --git a/cmd/server/main.go b/cmd/server/main.go index d4aa111..33f948f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -97,6 +97,7 @@ func main() { // Permissions checker permissionsChecker := permissions.NewChecker(database.DB) + memberHandler := server.NewMemberHandler(database.DB) r := chi.NewRouter() r.Use(chimw.Logger) @@ -134,11 +135,22 @@ func main() { // Servers r.Route("/servers", func(r chi.Router) { - server.NewHandler(database.DB).RegisterRoutes(r) + serverHandler := server.NewHandler(database.DB) + r.Post("/", serverHandler.Create) + r.Get("/", serverHandler.List) - // Channels (nested under servers) - r.Route("/{serverID}/channels", func(r chi.Router) { - channel.NewHandler(database.DB, permissionsChecker).RegisterRoutes(r) + r.Route("/{serverID}", func(r chi.Router) { + r.Get("/", serverHandler.Get) + r.Patch("/", serverHandler.Update) + r.Delete("/", serverHandler.Delete) + + // Server members + memberHandler.RegisterRoutes(r) + + // Channels (nested under servers) + r.Route("/channels", func(r chi.Router) { + channel.NewHandler(database.DB, permissionsChecker).RegisterRoutes(r) + }) }) }) diff --git a/internal/server/members.go b/internal/server/members.go new file mode 100644 index 0000000..955b37d --- /dev/null +++ b/internal/server/members.go @@ -0,0 +1,76 @@ +package server + +import ( + "database/sql" + "encoding/json" + "net/http" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "github.com/go-chi/chi/v5" +) + +type MemberHandler struct { + db *sql.DB +} + +func NewMemberHandler(db *sql.DB) *MemberHandler { + return &MemberHandler{db: db} +} + +func (h *MemberHandler) RegisterRoutes(r chi.Router) { + r.Get("/members", h.ListMembers) +} + +type memberResponse struct { + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Avatar string `json:"avatar"` + Status string `json:"status"` + StatusText string `json:"status_text"` +} + +func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + serverID := chi.URLParam(r, "serverID") + + // Verify the caller is a member + var exists bool + err := h.db.QueryRowContext(r.Context(), + `SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2)`, + userID, serverID).Scan(&exists) + if err != nil || !exists { + http.Error(w, `{"error":"not a member"}`, http.StatusForbidden) + return + } + + rows, err := h.db.QueryContext(r.Context(), ` + SELECT u.id, u.username, u.display_name, COALESCE(u.avatar, ""), u.status, COALESCE(u.status_text, "") + FROM members m + JOIN users u ON u.id = m.user_id + WHERE m.server_id = $1 + ORDER BY u.username + `, serverID) + if err != nil { + http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) + return + } + defer rows.Close() + + members := make([]memberResponse, 0) + for rows.Next() { + var m memberResponse + if err := rows.Scan(&m.ID, &m.Username, &m.DisplayName, &m.Avatar, &m.Status, &m.StatusText); err != nil { + continue + } + members = append(members, m) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(members) +} diff --git a/web/src/components/ChatArea.tsx b/web/src/components/ChatArea.tsx index 8533913..b77fff8 100644 --- a/web/src/components/ChatArea.tsx +++ b/web/src/components/ChatArea.tsx @@ -1,57 +1,78 @@ -import { useEffect, useRef, useState } from 'react'; -import { useMessageStore } from '../stores/message.ts'; -import { useChannelStore } from '../stores/channel.ts'; -import { GiphyPicker, type Gif } from './GiphyPicker'; +import { useEffect, useRef, useState } from "react"; +import { useMessageStore } from "../stores/message.ts"; +import { useChannelStore } from "../stores/channel.ts"; +import { useServerStore } from "../stores/server.ts"; +import { GiphyPicker, type Gif } from "./GiphyPicker"; function formatTime(iso: string): string { const date = new Date(iso); - const hours = date.getHours().toString().padStart(2, '0'); - const minutes = date.getMinutes().toString().padStart(2, '0'); + const hours = date.getHours().toString().padStart(2, "0"); + const minutes = date.getMinutes().toString().padStart(2, "0"); return `${hours}:${minutes}`; } export function ChatArea() { - const activeChannelId = useChannelStore((state) => state.activeChannelId); - const messages = useMessageStore((state) => - activeChannelId ? state.messagesByChannel[activeChannelId] || [] : [] + const activeChannelId = useChannelStore((s) => s.activeChannelId); + const channelsByServer = useChannelStore((s) => s.channelsByServer); + const activeServerId = useServerStore((s) => s.activeServerId); + const messages = useMessageStore((s) => + activeChannelId ? s.messagesByChannel[activeChannelId] || [] : [], ); - const isLoading = useMessageStore((state) => state.isLoading); - const fetchMessages = useMessageStore((state) => state.fetchMessages); - const sendMessage = useMessageStore((state) => state.sendMessage); - const [input, setInput] = useState(''); + const isLoading = useMessageStore((s) => s.isLoading); + const fetchMessages = useMessageStore((s) => s.fetchMessages); + const sendMessage = useMessageStore((s) => s.sendMessage); + const [input, setInput] = useState(""); + const [error, setError] = useState(null); const [showGifPicker, setShowGifPicker] = useState(false); const bottomRef = useRef(null); + const channels = activeServerId + ? channelsByServer[activeServerId] || [] + : []; + const activeChannel = channels.find((c) => c.id === activeChannelId); + useEffect(() => { if (activeChannelId) { fetchMessages(activeChannelId); + setError(null); } }, [activeChannelId, fetchMessages]); useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: 'auto' }); + bottomRef.current?.scrollIntoView({ behavior: "auto" }); }, [messages]); const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); - if (!activeChannelId || !input.trim()) { - return; + if (!activeChannelId || !input.trim()) return; + setError(null); + try { + await sendMessage(activeChannelId, input.trim()); + setInput(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to send"); } - await sendMessage(activeChannelId, input.trim()); - setInput(''); }; const handleGifSelect = async (gif: Gif) => { if (!activeChannelId) return; - const content = `![${gif.title || 'GIF'}](${gif.url})`; - await sendMessage(activeChannelId, content); - setShowGifPicker(false); + const content = `![${gif.title || "GIF"}](${gif.url})`; + try { + await sendMessage(activeChannelId, content); + setShowGifPicker(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to send"); + } }; return (
- {activeChannelId ? `#${activeChannelId}` : '[NO CHANNEL SELECTED]'} + {activeChannel + ? `# ${activeChannel.name}` + : activeChannelId + ? `#${activeChannelId}` + : "[NO CHANNEL SELECTED]"}
{isLoading &&

[loading...]

} @@ -60,8 +81,12 @@ export function ChatArea() { )} {messages.map((message) => (
- [{formatTime(message.createdAt)}]{' '} - <{message.author.username}>{' '} + + [{formatTime(message.createdAt)}] + {" "} + + <{message.author.username}> + {" "} {message.content}
))} @@ -75,21 +100,26 @@ export function ChatArea() { />
)} + {error && ( +
+

ERR: {error}

+
+ )}
- {'>'} + {">"} setInput(e.target.value)} - placeholder="_" - className="terminal-input" + placeholder="type a message..." + className="terminal-input flex-1 min-w-0" disabled={!activeChannelId} />
- {members.length === 0 && ( + {!activeServerId && ( +

[select a server]

+ )} + {activeServerId && members.length === 0 && (

[no members]

)} {online.length > 0 && (
ONLINE
---
- {online.map((member) => ( - + {online.map((m) => ( + ))}
)} @@ -102,8 +107,8 @@ export function MemberList({ members = [] }: MemberListProps) {
OFFLINE
---
- {offline.map((member) => ( - + {offline.map((m) => ( + ))}
)} diff --git a/web/src/stores/member.ts b/web/src/stores/member.ts new file mode 100644 index 0000000..e165e24 --- /dev/null +++ b/web/src/stores/member.ts @@ -0,0 +1,37 @@ +import { create } from "zustand"; +import { api } from "../lib/api.ts"; + +export interface Member { + id: string; + username: string; + display_name: string; + avatar: string; + status: "online" | "idle" | "dnd" | "offline"; + status_text: string; +} + +interface MemberState { + membersByServer: Record; + isLoading: boolean; + fetchMembers: (serverId: string) => Promise; +} + +export const useMemberStore = create((set) => ({ + membersByServer: {}, + isLoading: false, + + fetchMembers: async (serverId) => { + set({ isLoading: true }); + try { + const members = await api.get( + `/servers/${serverId}/members`, + ); + set((state) => ({ + membersByServer: { ...state.membersByServer, [serverId]: members }, + isLoading: false, + })); + } catch { + set({ isLoading: false }); + } + }, +}));