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
+93
View File
@@ -0,0 +1,93 @@
import { create } from 'zustand';
import { api } from '../lib/api.ts';
export type UserStatus = 'online' | 'idle' | 'dnd' | 'offline';
export interface User {
id: string;
username: string;
displayName: string;
email: string;
avatar: string | null;
status: UserStatus;
}
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
login: (email: string, password: string) => Promise<void>;
register: (email: string, username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
fetchMe: () => Promise<void>;
clearError: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
login: async (email, password) => {
set({ isLoading: true, error: null });
try {
await api.post('/auth/login/password', { email, password });
const user = await api.get<User>('/auth/me');
set({ user, isAuthenticated: true, isLoading: false });
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Login failed',
});
throw error;
}
},
register: async (email, username, password) => {
set({ isLoading: true, error: null });
try {
await api.post('/auth/register', { email, username, password });
const user = await api.get<User>('/auth/me');
set({ user, isAuthenticated: true, isLoading: false });
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Registration failed',
});
throw error;
}
},
logout: async () => {
set({ isLoading: true, error: null });
try {
await api.post('/auth/logout');
set({ user: null, isAuthenticated: false, isLoading: false });
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Logout failed',
});
throw error;
}
},
fetchMe: async () => {
set({ isLoading: true, error: null });
try {
const user = await api.get<User>('/auth/me');
set({ user, isAuthenticated: true, isLoading: false });
} catch (error) {
set({
user: null,
isAuthenticated: false,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch user',
});
}
},
clearError: () => set({ error: null }),
}));
+84
View File
@@ -0,0 +1,84 @@
import { create } from 'zustand';
import { api } from '../lib/api.ts';
export type ChannelType = 'text' | 'voice';
export interface Channel {
id: string;
serverId: string;
name: string;
type: ChannelType;
category: string | null;
position: number;
}
interface ChannelState {
channelsByServer: Record<string, Channel[]>;
activeChannelId: string | null;
isLoading: boolean;
error: string | null;
fetchChannels: (serverId: string) => Promise<void>;
setActiveChannel: (id: string | null) => void;
addChannel: (channel: Channel) => void;
updateChannel: (channel: Channel) => void;
removeChannel: (id: string) => void;
}
export const useChannelStore = create<ChannelState>((set) => ({
channelsByServer: {},
activeChannelId: null,
isLoading: false,
error: null,
fetchChannels: async (serverId) => {
set({ isLoading: true, error: null });
try {
const channels = await api.get<Channel[]>(`/servers/${serverId}/channels`);
set((state) => ({
channelsByServer: { ...state.channelsByServer, [serverId]: channels },
isLoading: false,
}));
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch channels',
});
}
},
setActiveChannel: (id) => set({ activeChannelId: id }),
addChannel: (channel) =>
set((state) => {
const list = state.channelsByServer[channel.serverId] || [];
return {
channelsByServer: {
...state.channelsByServer,
[channel.serverId]: [...list, channel],
},
};
}),
updateChannel: (channel) =>
set((state) => {
const list = state.channelsByServer[channel.serverId] || [];
return {
channelsByServer: {
...state.channelsByServer,
[channel.serverId]: list.map((c) => (c.id === channel.id ? channel : c)),
},
};
}),
removeChannel: (id) =>
set((state) => {
const next: Record<string, Channel[]> = {};
for (const serverId of Object.keys(state.channelsByServer)) {
next[serverId] = state.channelsByServer[serverId].filter((c) => c.id !== id);
}
return {
channelsByServer: next,
activeChannelId: state.activeChannelId === id ? null : state.activeChannelId,
};
}),
}));
+96
View File
@@ -0,0 +1,96 @@
import { create } from 'zustand';
import { api } from '../lib/api.ts';
import type { User } from './auth.ts';
export interface Message {
id: string;
channelId: string;
author: User;
content: string;
createdAt: string;
updatedAt?: string;
}
interface MessageState {
messagesByChannel: Record<string, Message[]>;
isLoading: boolean;
error: string | null;
fetchMessages: (channelId: string, before?: string) => Promise<void>;
sendMessage: (channelId: string, content: string) => Promise<Message>;
addMessage: (message: Message) => void;
updateMessage: (message: Message) => void;
removeMessage: (channelId: string, messageId: string) => void;
}
export const useMessageStore = create<MessageState>((set) => ({
messagesByChannel: {},
isLoading: false,
error: null,
fetchMessages: async (channelId, before) => {
set({ isLoading: true, error: null });
try {
const params = before ? `?before=${encodeURIComponent(before)}` : '';
const messages = await api.get<Message[]>(`/channels/${channelId}/messages${params}`);
set((state) => ({
messagesByChannel: { ...state.messagesByChannel, [channelId]: messages },
isLoading: false,
}));
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch messages',
});
}
},
sendMessage: async (channelId, content) => {
const message = await api.post<Message>(`/channels/${channelId}/messages`, { content });
set((state) => {
const list = state.messagesByChannel[channelId] || [];
return {
messagesByChannel: {
...state.messagesByChannel,
[channelId]: [...list, message],
},
};
});
return message;
},
addMessage: (message) =>
set((state) => {
const list = state.messagesByChannel[message.channelId] || [];
if (list.some((m) => m.id === message.id)) {
return state;
}
return {
messagesByChannel: {
...state.messagesByChannel,
[message.channelId]: [...list, message],
},
};
}),
updateMessage: (message) =>
set((state) => {
const list = state.messagesByChannel[message.channelId] || [];
return {
messagesByChannel: {
...state.messagesByChannel,
[message.channelId]: list.map((m) => (m.id === message.id ? message : m)),
},
};
}),
removeMessage: (channelId, messageId) =>
set((state) => {
const list = state.messagesByChannel[channelId] || [];
return {
messagesByChannel: {
...state.messagesByChannel,
[channelId]: list.filter((m) => m.id !== messageId),
},
};
}),
}));
+60
View File
@@ -0,0 +1,60 @@
import { create } from 'zustand';
import { api } from '../lib/api.ts';
export interface Server {
id: string;
name: string;
icon: string | null;
ownerId: string;
unread?: boolean;
}
interface ServerState {
servers: Server[];
activeServerId: string | null;
isLoading: boolean;
error: string | null;
fetchServers: () => Promise<void>;
setActiveServer: (id: string | null) => void;
addServer: (server: Server) => void;
updateServer: (server: Server) => void;
removeServer: (id: string) => void;
}
export const useServerStore = create<ServerState>((set) => ({
servers: [],
activeServerId: null,
isLoading: false,
error: null,
fetchServers: async () => {
set({ isLoading: true, error: null });
try {
const servers = await api.get<Server[]>('/servers');
set({ servers, isLoading: false });
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch servers',
});
}
},
setActiveServer: (id) => set({ activeServerId: id }),
addServer: (server) =>
set((state) => ({
servers: [...state.servers, server],
})),
updateServer: (server) =>
set((state) => ({
servers: state.servers.map((s) => (s.id === server.id ? server : s)),
})),
removeServer: (id) =>
set((state) => ({
servers: state.servers.filter((s) => s.id !== id),
activeServerId: state.activeServerId === id ? null : state.activeServerId,
})),
}));
+181
View File
@@ -0,0 +1,181 @@
import { create } from 'zustand';
import { useMessageStore } from './message.ts';
import { useChannelStore } from './channel.ts';
import { useServerStore } from './server.ts';
import type { Message } from './message.ts';
import type { Channel } from './channel.ts';
import type { Server } from './server.ts';
type UnknownPayload = Record<string, unknown>;
export interface WsEvent {
type: string;
payload: UnknownPayload;
}
interface WebSocketState {
socket: WebSocket | null;
connected: boolean;
connect: (token: string) => void;
disconnect: () => void;
send: (event: WsEvent) => void;
}
function getWsHost(): string {
const { protocol, host } = window.location;
const wsProtocol = protocol === 'https:' ? 'wss:' : 'ws:';
return `${wsProtocol}//${host}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function extractMessage(payload: UnknownPayload): Message | null {
if (!isRecord(payload.message)) return null;
return payload.message as unknown as Message;
}
function extractChannel(payload: UnknownPayload): Channel | null {
if (!isRecord(payload.channel)) return null;
return payload.channel as unknown as Channel;
}
function extractServer(payload: UnknownPayload): Server | null {
if (!isRecord(payload.server)) return null;
return payload.server as unknown as Server;
}
function extractIds(payload: UnknownPayload): { channelId: string; messageId: string } | null {
if (typeof payload.channelId !== 'string' || typeof payload.messageId !== 'string') {
return null;
}
return { channelId: payload.channelId, messageId: payload.messageId };
}
function extractChannelId(payload: UnknownPayload): string | null {
return typeof payload.channelId === 'string' ? payload.channelId : null;
}
export const useWebSocketStore = create<WebSocketState>((set, get) => ({
socket: null,
connected: false,
connect: (token: string) => {
const existing = get().socket;
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) {
return;
}
const socket = new WebSocket(`${getWsHost()}/ws?token=${encodeURIComponent(token)}`);
let reconnectDelay = 1000;
const maxReconnectDelay = 30000;
let reconnectTimeout: number | null = null;
const scheduleReconnect = () => {
if (reconnectTimeout) {
window.clearTimeout(reconnectTimeout);
}
reconnectTimeout = window.setTimeout(() => {
if (!get().connected) {
get().connect(token);
}
}, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, maxReconnectDelay);
};
socket.onopen = () => {
set({ connected: true });
reconnectDelay = 1000;
};
socket.onmessage = (event) => {
let data: WsEvent;
try {
data = JSON.parse(event.data) as WsEvent;
} catch {
return;
}
if (data.type === 'ping') {
get().send({ type: 'pong', payload: {} });
return;
}
const addMessage = useMessageStore.getState().addMessage;
const updateMessage = useMessageStore.getState().updateMessage;
const removeMessage = useMessageStore.getState().removeMessage;
const addChannel = useChannelStore.getState().addChannel;
const updateChannel = useChannelStore.getState().updateChannel;
const removeChannel = useChannelStore.getState().removeChannel;
const updateServer = useServerStore.getState().updateServer;
switch (data.type) {
case 'message': {
const msg = extractMessage(data.payload);
if (msg) addMessage(msg);
break;
}
case 'message_updated': {
const msg = extractMessage(data.payload);
if (msg) updateMessage(msg);
break;
}
case 'message_deleted': {
const ids = extractIds(data.payload);
if (ids) removeMessage(ids.channelId, ids.messageId);
break;
}
case 'channel_created': {
const channel = extractChannel(data.payload);
if (channel) addChannel(channel);
break;
}
case 'channel_updated': {
const channel = extractChannel(data.payload);
if (channel) updateChannel(channel);
break;
}
case 'channel_deleted': {
const channelId = extractChannelId(data.payload);
if (channelId) removeChannel(channelId);
break;
}
case 'server_updated': {
const server = extractServer(data.payload);
if (server) updateServer(server);
break;
}
default:
break;
}
};
socket.onclose = () => {
set({ connected: false, socket: null });
scheduleReconnect();
};
socket.onerror = () => {
socket.close();
};
set({ socket });
},
disconnect: () => {
const socket = get().socket;
if (socket) {
socket.close();
}
set({ socket: null, connected: false });
},
send: (event) => {
const socket = get().socket;
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(event));
}
},
}));