feat: UI for server/channel creation, settings link, role management backend

Frontend:
- Settings link in header next to logout
- [+] button to create server (name + optional icon)
- [#] button to join server via invite code
- [+] button to create channel (name, type, category)
- Server name shown instead of UUID in channel list header
- /invites/:code route wired for JoinServer component

Backend:
- Wire role CRUD + member-role assignment routes (MANAGE_SERVER gated)
- Seed @everyone default role on server creation
This commit is contained in:
2026-06-29 12:50:46 -04:00
parent 3072cda051
commit 3f33859f4a
11 changed files with 778 additions and 27 deletions
+9
View File
@@ -6,6 +6,7 @@ import { UserSettings } from './components/UserSettings.tsx';
import { BotManager } from './components/BotManager.tsx';
import { CommandManager } from './components/CommandManager.tsx';
import { RoleManager } from './components/RoleManager.tsx';
import { JoinServer } from './components/JoinServer.tsx';
import { useAuthStore } from './stores/auth.ts';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -90,6 +91,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/invites/:code"
element={
<ProtectedRoute>
<JoinServer />
</ProtectedRoute>
}
/>
<Route
path="/"
element={
+26 -4
View File
@@ -1,14 +1,17 @@
import { useEffect, useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useServerStore } from '../stores/server.ts';
import { useChannelStore } from '../stores/channel.ts';
import { VoiceChannel } from './VoiceChannel.tsx';
import { CreateChannelModal } from './CreateChannelModal.tsx';
export function ChannelList() {
const activeServerId = useServerStore((state) => state.activeServerId);
const servers = useServerStore((state) => state.servers);
const channelsByServer = useChannelStore((state) => state.channelsByServer);
const fetchChannels = useChannelStore((state) => state.fetchChannels);
const activeChannelId = useChannelStore((state) => state.activeChannelId);
const setActiveChannel = useChannelStore((state) => state.setActiveChannel);
const [showCreate, setShowCreate] = useState(false);
useEffect(() => {
if (activeServerId) {
@@ -16,7 +19,14 @@ export function ChannelList() {
}
}, [activeServerId, fetchChannels]);
const channels = activeServerId ? channelsByServer[activeServerId] || [] : [];
const channels = useMemo(() => {
return activeServerId ? channelsByServer[activeServerId] || [] : [];
}, [activeServerId, channelsByServer]);
const activeServer = useMemo(() => {
if (!activeServerId) return null;
return servers.find((s) => s.id === activeServerId) || null;
}, [servers, activeServerId]);
const categories = useMemo(() => {
const map = new Map<string, typeof channels>();
@@ -34,8 +44,17 @@ export function ChannelList() {
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 className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg truncate flex items-center justify-between">
<span>{activeServerId ? `[SERVER ${activeServer?.name ?? activeServerId}]` : '[NO SERVER]'}</span>
{activeServerId && (
<button
onClick={() => setShowCreate(true)}
className="terminal-button text-xs"
title="Create channel"
>
[+]
</button>
)}
</div>
<div className="flex-1 overflow-y-auto p-2 font-mono text-sm">
{categories.length === 0 && (
@@ -68,6 +87,9 @@ export function ChannelList() {
</div>
))}
</div>
{showCreate && activeServerId && (
<CreateChannelModal serverId={activeServerId} onClose={() => setShowCreate(false)} />
)}
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { useState, useEffect } from 'react';
import { api } from '../lib/api.ts';
import { useChannelStore } from '../stores/channel.ts';
interface CreateChannelModalProps {
serverId: string;
onClose: () => void;
}
interface ChannelApiResponse {
id: string;
server_id: string;
name: string;
type: 'text' | 'voice';
category: string;
position: number;
}
export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProps) {
const [name, setName] = useState('');
const [type, setType] = useState<'text' | 'voice'>('text');
const [category, setCategory] = useState('general');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const createChannel = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
setLoading(true);
setError(null);
try {
const result = await api.post<ChannelApiResponse>(`/servers/${serverId}/channels`, {
name: name.trim(),
type,
category: category.trim() || 'general',
});
const newChannel = {
id: result.id,
serverId: result.server_id,
name: result.name,
type: result.type,
category: result.category || null,
position: result.position,
};
useChannelStore.getState().addChannel(newChannel);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create channel');
} finally {
setLoading(false);
}
};
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={onClose}
>
<div
className="bg-gb-bg border border-gb-bg-t p-4 min-w-[350px] font-mono"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-gb-orange">CREATE CHANNEL</span>
<button onClick={onClose} className="text-xs text-gb-red">[x]</button>
</div>
<form onSubmit={createChannel} className="space-y-3">
<div>
<label className="text-xs text-gb-fg-f block mb-1">NAME:</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="channel name"
required
className="terminal-input w-full"
/>
</div>
<div>
<label className="text-xs text-gb-fg-f block mb-1">TYPE:</label>
<select
value={type}
onChange={(e) => setType(e.target.value as 'text' | 'voice')}
className="terminal-input w-full"
>
<option value="text">text</option>
<option value="voice">voice</option>
</select>
</div>
<div>
<label className="text-xs text-gb-fg-f block mb-1">CATEGORY:</label>
<input
type="text"
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="general"
className="terminal-input w-full"
/>
</div>
{error && <div className="text-xs text-gb-red">{error}</div>}
<button
type="submit"
disabled={loading || !name.trim()}
className="w-full px-3 py-1.5 bg-gb-orange text-gb-bg text-xs font-mono hover:bg-gb-yellow transition-colors disabled:opacity-50"
>
{loading ? 'CREATING...' : '[CREATE CHANNEL]'}
</button>
</form>
</div>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { useState, useEffect } from 'react';
import { api } from '../lib/api.ts';
import { useServerStore } from '../stores/server.ts';
interface CreateServerModalProps {
onClose: () => void;
}
interface ServerApiResponse {
id: string;
name: string;
icon: string | null;
owner_id: string;
}
export function CreateServerModal({ onClose }: CreateServerModalProps) {
const [name, setName] = useState('');
const [icon, setIcon] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const createServer = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
setLoading(true);
setError(null);
try {
const result = await api.post<ServerApiResponse>('/servers', {
name: name.trim(),
icon: icon.trim() || undefined,
});
const newServer = {
id: result.id,
name: result.name,
icon: result.icon,
ownerId: result.owner_id,
};
useServerStore.getState().addServer(newServer);
useServerStore.getState().setActiveServer(newServer.id);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create server');
} finally {
setLoading(false);
}
};
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={onClose}
>
<div
className="bg-gb-bg border border-gb-bg-t p-4 min-w-[350px] font-mono"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-gb-orange">CREATE SERVER</span>
<button onClick={onClose} className="text-xs text-gb-red">[x]</button>
</div>
<form onSubmit={createServer} className="space-y-3">
<div>
<label className="text-xs text-gb-fg-f block mb-1">NAME:</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="server name"
required
className="terminal-input w-full"
/>
</div>
<div>
<label className="text-xs text-gb-fg-f block mb-1">ICON URL:</label>
<input
type="text"
value={icon}
onChange={(e) => setIcon(e.target.value)}
placeholder="https://..."
className="terminal-input w-full"
/>
</div>
{error && <div className="text-xs text-gb-red">{error}</div>}
<button
type="submit"
disabled={loading || !name.trim()}
className="w-full px-3 py-1.5 bg-gb-orange text-gb-bg text-xs font-mono hover:bg-gb-yellow transition-colors disabled:opacity-50"
>
{loading ? 'CREATING...' : '[CREATE SERVER]'}
</button>
</form>
</div>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { useState, useEffect } from 'react';
import { api } from '../lib/api.ts';
import { useServerStore } from '../stores/server.ts';
interface JoinServerModalProps {
onClose: () => void;
}
interface JoinServerApiResponse {
server_id: string;
message: string;
}
export function JoinServerModal({ onClose }: JoinServerModalProps) {
const [code, setCode] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const joinServer = async (e: React.FormEvent) => {
e.preventDefault();
if (!code.trim()) return;
setLoading(true);
setError(null);
try {
const result = await api.post<JoinServerApiResponse>(`/invites/${code.trim()}/join`);
await useServerStore.getState().fetchServers();
useServerStore.getState().setActiveServer(result.server_id);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to join server');
} finally {
setLoading(false);
}
};
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={onClose}
>
<div
className="bg-gb-bg border border-gb-bg-t p-4 min-w-[350px] font-mono"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-gb-orange">JOIN SERVER</span>
<button onClick={onClose} className="text-xs text-gb-red">[x]</button>
</div>
<form onSubmit={joinServer} className="space-y-3">
<div>
<label className="text-xs text-gb-fg-f block mb-1">INVITE CODE:</label>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="enter invite code"
required
className="terminal-input w-full"
/>
</div>
{error && <div className="text-xs text-gb-red">{error}</div>}
<button
type="submit"
disabled={loading || !code.trim()}
className="w-full px-3 py-1.5 bg-gb-orange text-gb-bg text-xs font-mono hover:bg-gb-yellow transition-colors disabled:opacity-50"
>
{loading ? 'JOINING...' : '[JOIN SERVER]'}
</button>
</form>
</div>
</div>
);
}
+2 -1
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../stores/auth.ts';
import { ServerBar } from './ServerBar.tsx';
import { ChannelList } from './ChannelList.tsx';
@@ -46,6 +46,7 @@ export function Layout() {
<span>
USER: <span className="text-gb-aqua">{user?.username || 'unknown'}</span>
</span>
<Link to="/settings" className="terminal-button text-xs">[SETTINGS]</Link>
<button onClick={() => logout().then(() => navigate('/login'))} className="terminal-button text-xs">
[LOGOUT]
</button>
+45 -21
View File
@@ -1,6 +1,8 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useServerStore } from '../stores/server.ts';
import { useChannelStore } from '../stores/channel.ts';
import { CreateServerModal } from './CreateServerModal.tsx';
import { JoinServerModal } from './JoinServerModal.tsx';
function getInitials(name: string): string {
const words = name.trim().split(/\s+/);
@@ -16,6 +18,8 @@ export function ServerBar() {
const fetchServers = useServerStore((state) => state.fetchServers);
const setActiveServer = useServerStore((state) => state.setActiveServer);
const setActiveChannel = useChannelStore((state) => state.setActiveChannel);
const [showCreate, setShowCreate] = useState(false);
const [showJoin, setShowJoin] = useState(false);
useEffect(() => {
fetchServers();
@@ -27,25 +31,45 @@ export function ServerBar() {
};
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>
<>
<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 className="mt-2 flex flex-col gap-2 items-center">
<button
onClick={() => setShowCreate(true)}
className="w-11 h-11 flex items-center justify-center font-mono text-sm border bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t hover:text-gb-aqua"
title="Create server"
>
[+]
</button>
<button
onClick={() => setShowJoin(true)}
className="w-11 h-11 flex items-center justify-center font-mono text-xs border bg-gb-bg-s text-gb-fg border-gb-bg-t hover:border-gb-fg-t hover:text-gb-aqua"
title="Join server"
>
[#]
</button>
</div>
</div>
{showCreate && <CreateServerModal onClose={() => setShowCreate(false)} />}
{showJoin && <JoinServerModal onClose={() => setShowJoin(false)} />}
</>
);
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionPopup.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/components/BotManager.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/CommandManager.tsx","./src/components/CreateChannelModal.tsx","./src/components/CreateServerModal.tsx","./src/components/EmojiPicker.tsx","./src/components/GiphyPicker.tsx","./src/components/InstallPrompt.tsx","./src/components/InviteModal.tsx","./src/components/JoinServer.tsx","./src/components/JoinServerModal.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberList.tsx","./src/components/MemberRoleAssign.tsx","./src/components/MentionPopup.tsx","./src/components/MobileDrawer.tsx","./src/components/MobileNav.tsx","./src/components/ReactionBar.tsx","./src/components/ReplyBar.tsx","./src/components/RoleManager.tsx","./src/components/ServerBar.tsx","./src/components/SlashCommandPopup.tsx","./src/components/ThemeToggle.tsx","./src/components/TypingIndicator.tsx","./src/components/UserSettings.tsx","./src/components/VoiceChannel.tsx","./src/components/VoiceControls.tsx","./src/components/VoicePanel.tsx","./src/lib/api.ts","./src/stores/auth.ts","./src/stores/bot.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/presence.ts","./src/stores/push.ts","./src/stores/role.ts","./src/stores/server.ts","./src/stores/typing.ts","./src/stores/voice.ts","./src/stores/ws.ts"],"version":"5.9.3"}