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
-184
View File
@@ -1,184 +0,0 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+31 -15
View File
@@ -1,17 +1,33 @@
function App() {
return (
<div className="h-full w-full flex flex-col items-center justify-center bg-gb-bg text-gb-fg">
<div className="terminal-border p-6 bg-gb-bg-h">
<pre className="text-gb-orange font-mono text-lg mb-4">
{"┌─ DUMPSTER ─┐"}
</pre>
<p className="text-gb-fg-s mb-4">The server is running. Frontend coming soon.</p>
<button className="terminal-button">
[LOGIN]
</button>
</div>
</div>
)
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { LoginForm } from './components/LoginForm.tsx';
import { Layout } from './components/Layout.tsx';
import { ChatArea } from './components/ChatArea.tsx';
import { useAuthStore } from './stores/auth.ts';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
}
export default App
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginForm />} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<ChatArea />} />
<Route path="channels/:channelId" element={<ChatArea />} />
</Route>
</Routes>
</BrowserRouter>
);
}
export default App;
+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>
);
}
-111
View File
@@ -1,111 +0,0 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
+45
View File
@@ -0,0 +1,45 @@
const API_BASE = '/api/v1';
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = {};
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
}
const response = await fetch(`${API_BASE}${path}`, {
method,
headers,
credentials: 'include',
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
let errorMessage = `Request failed: ${response.status}`;
try {
const errorData = await response.json();
if (typeof errorData?.message === 'string') {
errorMessage = errorData.message;
} else if (typeof errorData?.error === 'string') {
errorMessage = errorData.error;
}
} catch {
// ignore parse error
}
throw new Error(errorMessage);
}
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
export const api = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
delete: <T>(path: string) => request<T>('DELETE', path),
};
export default api;
+5 -5
View File
@@ -1,10 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './styles/index.css'
import App from './App.tsx'
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './styles/index.css';
import App from './App.tsx';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
);
+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));
}
},
}));
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberList.tsx","./src/components/ServerBar.tsx","./src/lib/api.ts","./src/stores/auth.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/server.ts","./src/stores/ws.ts"],"version":"5.9.3"}