Phase 1 MVP: gateway, CRUD handlers, frontend components
Backend: - WebSocket gateway (hub, client, events) with fanout broadcast - Server CRUD handlers (create, list, get, update, delete) - Channel CRUD handlers (create, list, get, update, delete) - Message CRUD handlers (list with cursor pagination, create, update, delete) - cmd/migrate standalone migration CLI (up/down) - cmd/server wired to all handlers + WebSocket + static file serving Frontend: - Zustand stores: auth, server, channel, message, websocket - API client with fetch wrapper - Terminal-styled components: Layout, LoginForm, ChatArea, ChannelList, ServerBar, MemberList - React Router with login and main routes - Gruvbox dark palette throughout Ops: - Docker Compose with app service (multi-stage build) - Caddyfile with WebSocket upgrade support - Makefile for common tasks
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useServerStore } from '../stores/server.ts';
|
||||
import { useChannelStore } from '../stores/channel.ts';
|
||||
|
||||
export function ChannelList() {
|
||||
const activeServerId = useServerStore((state) => state.activeServerId);
|
||||
const channelsByServer = useChannelStore((state) => state.channelsByServer);
|
||||
const fetchChannels = useChannelStore((state) => state.fetchChannels);
|
||||
const activeChannelId = useChannelStore((state) => state.activeChannelId);
|
||||
const setActiveChannel = useChannelStore((state) => state.setActiveChannel);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeServerId) {
|
||||
fetchChannels(activeServerId);
|
||||
}
|
||||
}, [activeServerId, fetchChannels]);
|
||||
|
||||
const channels = activeServerId ? channelsByServer[activeServerId] || [] : [];
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const map = new Map<string, typeof channels>();
|
||||
for (const channel of channels) {
|
||||
const category = channel.category || 'TEXT CHANNELS';
|
||||
const list = map.get(category) || [];
|
||||
list.push(channel);
|
||||
map.set(category, list);
|
||||
}
|
||||
for (const list of map.values()) {
|
||||
list.sort((a, b) => a.position - b.position);
|
||||
}
|
||||
return Array.from(map.entries());
|
||||
}, [channels]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-56 bg-gb-bg-s border-r border-gb-bg-t flex flex-col">
|
||||
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg truncate">
|
||||
{activeServerId ? `[SERVER ${activeServerId}]` : '[NO SERVER]'}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 font-mono text-sm">
|
||||
{categories.length === 0 && (
|
||||
<p className="text-gb-fg-f">[no channels]</p>
|
||||
)}
|
||||
{categories.map(([category, list]) => (
|
||||
<div key={category} className="mb-3">
|
||||
<div className="text-gb-fg-t text-xs uppercase mb-1">{category}</div>
|
||||
<div className="text-gb-fg-f text-xs mb-1">---</div>
|
||||
{list.map((channel) => (
|
||||
<button
|
||||
key={channel.id}
|
||||
onClick={() => setActiveChannel(channel.id)}
|
||||
className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
|
||||
channel.id === activeChannelId ? 'terminal-active' : 'hover:bg-gb-bg-t text-gb-fg-s'
|
||||
}`}
|
||||
>
|
||||
{channel.type === 'voice' ? (
|
||||
<span className="text-gb-fg-f">♫</span>
|
||||
) : (
|
||||
<span className="text-gb-fg-f">#</span>
|
||||
)}
|
||||
<span className="truncate">{channel.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user