feat(phase8.1): server groups — table, CRUD handler, collapsible channel sections

This commit is contained in:
2026-06-30 12:50:53 -04:00
parent 6d66d7775f
commit d663638387
7 changed files with 587 additions and 88 deletions
+191 -60
View File
@@ -7,6 +7,7 @@ import { InviteModal } from './InviteModal.tsx';
import { ChannelSettingsModal } from './ChannelSettingsModal.tsx';
import { useNotificationSettingsStore } from '../stores/notificationSettings.ts';
import { useReadStatesStore } from '../stores/readStates.ts';
import { api } from '../lib/api.ts';
type NotifLevel = 'all' | 'mentions' | 'none';
@@ -18,6 +19,39 @@ const NOTIF_LABELS: Record<NotifLevel, string> = {
const NOTIF_CYCLE: NotifLevel[] = ['all', 'mentions', 'none'];
interface ServerGroup {
id: string;
server_id: string;
name: string;
position: number;
}
function collapsedKey(serverId: string, groupId: string) {
return 'collapsed:' + serverId + ':' + groupId;
}
function isCollapsed(serverId: string, groupId: string): boolean {
try {
return localStorage.getItem(collapsedKey(serverId, groupId)) === '1';
} catch {
return false;
}
}
function toggleCollapsed(serverId: string, groupId: string) {
const key = collapsedKey(serverId, groupId);
const now = isCollapsed(serverId, groupId);
try {
if (now) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, '1');
}
} catch {
// ignore
}
}
export function ChannelList() {
const activeServerId = useServerStore((state) => state.activeServerId);
const servers = useServerStore((state) => state.servers);
@@ -28,6 +62,8 @@ export function ChannelList() {
const [showCreate, setShowCreate] = useState(false);
const [showInvite, setShowInvite] = useState(false);
const [settingsChannel, setSettingsChannel] = useState<{ id: string; name: string } | null>(null);
const [groups, setGroups] = useState<ServerGroup[]>([]);
const [toggleTick, setToggleTick] = useState(0);
const notifSettings = useNotificationSettingsStore((state) => state.settings);
const fetchNotifSettings = useNotificationSettingsStore((state) => state.fetchSettings);
@@ -39,6 +75,11 @@ export function ChannelList() {
useEffect(() => {
if (activeServerId) {
fetchChannels(activeServerId);
api.get<ServerGroup[]>('/servers/' + activeServerId + '/groups')
.then(setGroups)
.catch(() => setGroups([]));
} else {
setGroups([]);
}
}, [activeServerId, fetchChannels]);
@@ -56,19 +97,43 @@ export function ChannelList() {
return servers.find((s) => s.id === activeServerId) || null;
}, [servers, activeServerId]);
const categories = useMemo(() => {
const map = new Map<string, typeof channels>();
// Build ordered sections: sorted groups first, then ungroupped channels by category
const sections = useMemo(() => {
type Section = { type: 'group'; group: ServerGroup; channels: typeof channels } | { type: 'category'; name: string; channels: typeof channels };
const grouped: Record<string, typeof channels> = {};
const ungrouped: Record<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);
if (channel.group_id) {
const list = grouped[channel.group_id] || [];
list.push(channel);
grouped[channel.group_id] = list;
} else {
const cat = channel.category || 'TEXT CHANNELS';
const list = ungrouped[cat] || [];
list.push(channel);
ungrouped[cat] = list;
}
}
for (const list of map.values()) {
list.sort((a, b) => a.position - b.position);
const result: Section[] = [];
// Sorted groups
const sortedGroups = [...groups].sort((a, b) => a.position - b.position);
for (const grp of sortedGroups) {
const chs = (grouped[grp.id] || []).sort((a, b) => a.position - b.position);
result.push({ type: 'group', group: grp, channels: chs });
}
return Array.from(map.entries());
}, [channels]);
// Ungroupped channels by category
const sortedCats = Object.keys(ungrouped).sort();
for (const cat of sortedCats) {
result.push({ type: 'category', name: cat, channels: ungrouped[cat].sort((a, b) => a.position - b.position) });
}
return result;
}, [channels, groups, toggleTick]);
const getLevel = (channelId: string): NotifLevel => {
return notifSettings[channelId] || 'all';
@@ -116,59 +181,125 @@ export function ChannelList() {
)}
</div>
<div className="flex-1 overflow-y-auto p-2 font-mono text-sm">
{categories.length === 0 && (
{sections.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}
/>
) : (
<div key={channel.id} className="group">
<button
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">
{channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : channel.type === 'list' ? '☑' : '#'}
</span>
<span className="truncate flex-1">{channel.name}</span>
{hasUnread(channel.id) && (
<span className="text-gb-orange text-xs font-bold" title="Unread messages"></span>
)}
<span className="flex items-center gap-1 text-xs">
<button
onClick={(e) => handleNotifClick(e, channel.id)}
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100 transition-opacity"
title={`Notifications: ${NOTIF_LABELS[getLevel(channel.id)]}`}
>
{notifIcon(channel.id)}
</button>
<span
onClick={(e) => { e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }}
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100"
title="Channel settings"
role="button"
tabIndex={0}
>
[]
</span>
</span>
</button>
{sections.map((section) => {
if (section.type === 'group') {
const grp = section.group;
const collapsed = isCollapsed(activeServerId || '', grp.id);
return (
<div key={'grp:' + grp.id} className="mb-3">
<div
className="text-gb-fg-t text-xs uppercase mb-1 cursor-pointer select-none hover:text-gb-fg"
onClick={() => { toggleCollapsed(activeServerId || '', grp.id); setToggleTick(t => t + 1); }}
>
{collapsed ? '[+]' : '[-]'} {grp.name}
</div>
)
)}
</div>
))}
{!collapsed && (
<>
<div className="text-gb-fg-f text-xs mb-1">---</div>
{section.channels.map((channel) =>
channel.type === 'voice' ? (
<VoiceChannel
key={channel.id}
channelId={channel.id}
channelName={channel.name}
/>
) : (
<div key={channel.id} className="group">
<button
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">
{channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : channel.type === 'list' ? '☑' : '#'}
</span>
<span className="truncate flex-1">{channel.name}</span>
{hasUnread(channel.id) && (
<span className="text-gb-orange text-xs font-bold" title="Unread messages"></span>
)}
<span className="flex items-center gap-1 text-xs">
<button
onClick={(e) => handleNotifClick(e, channel.id)}
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100 transition-opacity"
title={`Notifications: ${NOTIF_LABELS[getLevel(channel.id)]}`}
>
{notifIcon(channel.id)}
</button>
<span
onClick={(e) => { e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }}
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100"
title="Channel settings"
role="button"
tabIndex={0}
>
[]
</span>
</span>
</button>
</div>
)
)}
</>
)}
</div>
);
}
// category section (fallback for ungrouped channels)
return (
<div key={'cat:' + section.name} className="mb-3">
<div className="text-gb-fg-t text-xs uppercase mb-1">{section.name}</div>
<div className="text-gb-fg-f text-xs mb-1">---</div>
{section.channels.map((channel) =>
channel.type === 'voice' ? (
<VoiceChannel
key={channel.id}
channelId={channel.id}
channelName={channel.name}
/>
) : (
<div key={channel.id} className="group">
<button
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">
{channel.type === 'forum' ? '■' : channel.type === 'calendar' ? '○' : channel.type === 'docs' ? '☰' : channel.type === 'list' ? '☑' : '#'}
</span>
<span className="truncate flex-1">{channel.name}</span>
{hasUnread(channel.id) && (
<span className="text-gb-orange text-xs font-bold" title="Unread messages"></span>
)}
<span className="flex items-center gap-1 text-xs">
<button
onClick={(e) => handleNotifClick(e, channel.id)}
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100 transition-opacity"
title={`Notifications: ${NOTIF_LABELS[getLevel(channel.id)]}`}
>
{notifIcon(channel.id)}
</button>
<span
onClick={(e) => { e.stopPropagation(); setSettingsChannel({ id: channel.id, name: channel.name }); }}
className="text-gb-fg-f hover:text-gb-orange opacity-0 group-hover:opacity-100"
title="Channel settings"
role="button"
tabIndex={0}
>
[]
</span>
</span>
</button>
</div>
)
)}
</div>
);
})}
</div>
{showCreate && activeServerId && (
<CreateChannelModal serverId={activeServerId} onClose={() => setShowCreate(false)} />
+37 -2
View File
@@ -7,6 +7,14 @@ interface CreateChannelModalProps {
onClose: () => void;
}
interface ServerGroup {
id: string;
server_id: string;
name: string;
position: number;
created_at: string;
}
interface ChannelApiResponse {
id: string;
server_id: string;
@@ -15,6 +23,7 @@ interface ChannelApiResponse {
category: string;
position: number;
slowmode_seconds: number;
group_id: string | null;
}
const SLOWMODE_OPTIONS = [
@@ -32,9 +41,17 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
const [type, setType] = useState<'text' | 'voice' | 'forum' | 'calendar' | 'docs' | 'list'>('text');
const [category, setCategory] = useState('general');
const [slowmodeSeconds, setSlowmodeSeconds] = useState(0);
const [groups, setGroups] = useState<ServerGroup[]>([]);
const [selectedGroupId, setSelectedGroupId] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.get<ServerGroup[]>('/servers/' + serverId + '/groups')
.then(setGroups)
.catch(() => {});
}, [serverId]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
@@ -53,12 +70,16 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
setLoading(true);
setError(null);
try {
const result = await api.post<ChannelApiResponse>("/servers/" + serverId + "/channels", {
const body: Record<string, unknown> = {
name: name.trim(),
type,
category: category.trim() || 'general',
slowmode_seconds: slowmodeSeconds,
});
};
if (selectedGroupId) {
body.group_id = selectedGroupId;
}
const result = await api.post<ChannelApiResponse>('/servers/' + serverId + '/channels', body);
const newChannel = {
id: result.id,
serverId: result.server_id,
@@ -67,6 +88,7 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
category: result.category || null,
position: result.position,
slowmode_seconds: result.slowmode_seconds,
group_id: result.group_id,
};
useChannelStore.getState().addChannel(newChannel);
onClose();
@@ -118,6 +140,19 @@ export function CreateChannelModal({ serverId, onClose }: CreateChannelModalProp
<option value="list">list</option>
</select>
</div>
<div>
<label className="text-xs text-gb-fg-f block mb-1">GROUP:</label>
<select
value={selectedGroupId}
onChange={(e) => setSelectedGroupId(e.target.value)}
className="terminal-input w-full"
>
<option value="">(none use category)</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-gb-fg-f block mb-1">CATEGORY:</label>
<input
+1
View File
@@ -11,6 +11,7 @@ export interface Channel {
category: string | null;
position: number;
slowmode_seconds?: number;
group_id?: string | null;
}
interface ChannelState {