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:
-184
@@ -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
@@ -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;
|
||||
|
||||
@@ -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">♫</span>
|
||||
) : (
|
||||
<span className="text-gb-fg-f">#</span>
|
||||
)}
|
||||
<span className="truncate">{channel.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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"><{message.author.username}></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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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>,
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user