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:
@@ -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 }),
|
||||
}));
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}),
|
||||
}));
|
||||
@@ -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,
|
||||
})),
|
||||
}));
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user