3f33859f4a
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
96 lines
3.6 KiB
TypeScript
96 lines
3.6 KiB
TypeScript
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) {
|
|
fetchChannels(activeServerId);
|
|
}
|
|
}, [activeServerId, fetchChannels]);
|
|
|
|
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>();
|
|
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 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 && (
|
|
<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) =>
|
|
channel.type === 'voice' ? (
|
|
<VoiceChannel
|
|
key={channel.id}
|
|
channelId={channel.id}
|
|
channelName={channel.name}
|
|
/>
|
|
) : (
|
|
<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'
|
|
}`}
|
|
>
|
|
<span className="text-gb-fg-f">#</span>
|
|
<span className="truncate">{channel.name}</span>
|
|
</button>
|
|
)
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
{showCreate && activeServerId && (
|
|
<CreateChannelModal serverId={activeServerId} onClose={() => setShowCreate(false)} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|