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:
2026-06-26 14:47:29 -04:00
parent aa7854aee2
commit bb5a56816b
32 changed files with 2310 additions and 339 deletions
+90
View File
@@ -0,0 +1,90 @@
import type { User, UserStatus } from '../stores/auth.ts';
interface MemberListProps {
members?: User[];
}
function statusIcon(status: UserStatus): string {
switch (status) {
case 'online':
return '\u25cf';
case 'idle':
return '\u25d0';
case 'dnd':
return '\u26d5';
default:
return '\u25cb';
}
}
function statusColor(status: UserStatus): string {
switch (status) {
case 'online':
return 'text-gb-green';
case 'idle':
return 'text-gb-yellow';
case 'dnd':
return 'text-gb-red';
default:
return 'text-gb-gray';
}
}
function usernameColor(status: UserStatus): string {
switch (status) {
case 'online':
return 'text-gb-aqua';
case 'idle':
return 'text-gb-yellow';
case 'dnd':
return 'text-gb-red';
default:
return 'text-gb-fg-f';
}
}
export function MemberList({ members = [] }: MemberListProps) {
const online = members.filter((m) => m.status !== 'offline');
const offline = members.filter((m) => m.status === 'offline');
return (
<div className="h-full w-52 bg-gb-bg-s border-l border-gb-bg-t flex flex-col">
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s">
[MEMBERS]
</div>
<div className="flex-1 overflow-y-auto p-2 font-mono text-sm">
{members.length === 0 && (
<p className="text-gb-fg-f">[no members]</p>
)}
{online.length > 0 && (
<div className="mb-3">
<div className="text-gb-fg-t text-xs uppercase mb-1">ONLINE</div>
<div className="text-gb-fg-f text-xs mb-1">---</div>
{online.map((member) => (
<div key={member.id} className="flex items-center gap-2 px-2 py-1">
<span className={statusColor(member.status)}>{statusIcon(member.status)}</span>
<span className={`truncate ${usernameColor(member.status)}`}>
{member.username}
</span>
</div>
))}
</div>
)}
{offline.length > 0 && (
<div>
<div className="text-gb-fg-t text-xs uppercase mb-1">OFFLINE</div>
<div className="text-gb-fg-f text-xs mb-1">---</div>
{offline.map((member) => (
<div key={member.id} className="flex items-center gap-2 px-2 py-1">
<span className={statusColor(member.status)}>{statusIcon(member.status)}</span>
<span className={`truncate ${usernameColor(member.status)}`}>
{member.username}
</span>
</div>
))}
</div>
)}
</div>
</div>
);
}