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 } 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>
);
}