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
+68
View File
@@ -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">&#9835;</span>
) : (
<span className="text-gb-fg-f">#</span>
)}
<span className="truncate">{channel.name}</span>
</button>
))}
</div>
))}
</div>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useRef, useState } from 'react';
import { useMessageStore } from '../stores/message.ts';
import { useChannelStore } from '../stores/channel.ts';
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');
return `${hours}:${minutes}`;
}
export function ChatArea() {
const activeChannelId = useChannelStore((state) => state.activeChannelId);
const messages = useMessageStore((state) =>
activeChannelId ? state.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 bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (activeChannelId) {
fetchMessages(activeChannelId);
}
}, [activeChannelId, fetchMessages]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'auto' });
}, [messages]);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!activeChannelId || !input.trim()) {
return;
}
await sendMessage(activeChannelId, input.trim());
setInput('');
};
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">
{activeChannelId ? `#${activeChannelId}` : '[NO CHANNEL SELECTED]'}
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-1 font-mono text-sm">
{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>
)}
{messages.map((message) => (
<div key={message.id} className="break-words">
<span className="text-gb-fg-f">[{formatTime(message.createdAt)}]</span>{' '}
<span className="text-gb-aqua">&lt;{message.author.username}&gt;</span>{' '}
<span className="text-gb-fg">{message.content}</span>
</div>
))}
<div ref={bottomRef} />
</div>
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2">
<span className="text-gb-fg-f select-none">{'>'}</span>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="_"
className="terminal-input"
disabled={!activeChannelId}
/>
</form>
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { useEffect } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../stores/auth.ts';
import { ServerBar } from './ServerBar.tsx';
import { ChannelList } from './ChannelList.tsx';
import { MemberList } from './MemberList.tsx';
export function Layout() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const isLoading = useAuthStore((state) => state.isLoading);
const user = useAuthStore((state) => state.user);
const fetchMe = useAuthStore((state) => state.fetchMe);
const logout = useAuthStore((state) => state.logout);
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
fetchMe();
}, [fetchMe]);
useEffect(() => {
if (!isLoading && !isAuthenticated && location.pathname !== '/login') {
navigate('/login', { replace: true });
}
}, [isLoading, isAuthenticated, location.pathname, navigate]);
if (isLoading) {
return (
<div className="h-full w-full flex items-center justify-center bg-gb-bg text-gb-fg-f font-mono">
[booting...]
</div>
);
}
if (!isAuthenticated) {
return null;
}
return (
<div className="h-full w-full bg-gb-bg text-gb-fg font-mono flex flex-col p-2">
<div className="flex-1 terminal-border bg-gb-bg-h flex flex-col min-h-0">
<div className="flex items-center justify-between px-3 py-1 border-b border-gb-bg-t bg-gb-bg-s">
<span className="text-gb-orange font-bold">DUMPSTER</span>
<div className="flex items-center gap-4 text-xs text-gb-fg-s">
<span>
USER: <span className="text-gb-aqua">{user?.username || 'unknown'}</span>
</span>
<button onClick={() => logout().then(() => navigate('/login'))} className="terminal-button text-xs">
[LOGOUT]
</button>
</div>
</div>
<div className="flex-1 flex min-h-0">
<ServerBar />
<ChannelList />
<div className="flex-1 min-w-0 flex flex-col">
<Outlet />
</div>
<MemberList />
</div>
<div className="px-3 py-1 border-t border-gb-bg-t text-xs text-gb-fg-f flex justify-between bg-gb-bg-s">
<span>TERM v1.0</span>
<span>{new Date().toISOString().slice(0, 10)}</span>
</div>
</div>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../stores/auth.ts';
export function LoginForm() {
const [isRegister, setIsRegister] = useState(false);
const [email, setEmail] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const { login, register, isLoading, error, clearError } = useAuthStore();
const navigate = useNavigate();
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
clearError();
if (isRegister) {
if (password !== confirmPassword) {
return;
}
await register(email, username, password);
} else {
await login(email, password);
}
navigate('/');
};
return (
<div className="h-full w-full flex items-center justify-center bg-gb-bg p-4">
<div className="terminal-border bg-gb-bg-h p-6 w-full max-w-md">
<pre className="text-gb-orange font-mono text-lg mb-6">{'┌─ DUMPSTER ─┐'}</pre>
<h2 className="text-gb-fg-s mb-4">{isRegister ? '[REGISTER]' : '[LOGIN]'}</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-gb-fg-t mb-1">EMAIL:</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="terminal-input"
required
autoComplete="email"
/>
</div>
{isRegister && (
<div>
<label className="block text-gb-fg-t mb-1">USERNAME:</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="terminal-input"
required
autoComplete="username"
/>
</div>
)}
<div>
<label className="block text-gb-fg-t mb-1">PASSWORD:</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="terminal-input"
required
autoComplete={isRegister ? 'new-password' : 'current-password'}
/>
</div>
{isRegister && (
<div>
<label className="block text-gb-fg-t mb-1">CONFIRM PASSWORD:</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="terminal-input"
required
/>
</div>
)}
{error && <p className="text-gb-red text-sm">ERR: {error}</p>}
<button type="submit" className="terminal-button w-full" disabled={isLoading}>
{isLoading ? '[PROCESSING...]' : isRegister ? '[REGISTER]' : '[LOGIN]'}
</button>
</form>
<div className="mt-4 text-center">
<button
type="button"
onClick={() => {
setIsRegister(!isRegister);
clearError();
}}
className="text-gb-blue hover:text-gb-aqua text-sm"
>
{isRegister ? '[BACK TO LOGIN]' : '[CREATE ACCOUNT]'}
</button>
</div>
{!isRegister && (
<p className="text-gb-fg-f text-xs mt-4 text-center">
Use Link component: <Link to="/" className="text-gb-blue hover:text-gb-aqua">[HOME]</Link>
</p>
)}
</div>
</div>
);
}
+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>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { useEffect } from 'react';
import { useServerStore } from '../stores/server.ts';
import { useChannelStore } from '../stores/channel.ts';
function getInitials(name: string): string {
const words = name.trim().split(/\s+/);
if (words.length === 1) {
return words[0].slice(0, 2).toUpperCase();
}
return (words[0][0] + words[words.length - 1][0]).toUpperCase();
}
export function ServerBar() {
const servers = useServerStore((state) => state.servers);
const activeServerId = useServerStore((state) => state.activeServerId);
const fetchServers = useServerStore((state) => state.fetchServers);
const setActiveServer = useServerStore((state) => state.setActiveServer);
const setActiveChannel = useChannelStore((state) => state.setActiveChannel);
useEffect(() => {
fetchServers();
}, [fetchServers]);
const handleSelect = (id: string) => {
setActiveServer(id);
setActiveChannel(null);
};
return (
<div className="h-full w-16 bg-gb-bg-h border-r border-gb-bg-t flex flex-col items-center py-3 gap-2 overflow-y-auto">
{servers.map((server) => (
<button
key={server.id}
onClick={() => handleSelect(server.id)}
className={`w-11 h-11 flex items-center justify-center font-mono text-sm border ${
server.id === activeServerId
? 'bg-gb-bg-t text-gb-orange border-gb-orange'
: 'bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t hover:text-gb-aqua'
}`}
title={server.name}
>
{server.unread && server.id !== activeServerId ? (
<span className="text-gb-aqua">[{getInitials(server.name)}]</span>
) : (
`[${getInitials(server.name)}]`
)}
</button>
))}
</div>
);
}