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