feat(phase8.1): server groups — table, CRUD handler, collapsible channel sections
This commit is contained in:
@@ -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)} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface Channel {
|
||||
category: string | null;
|
||||
position: number;
|
||||
slowmode_seconds?: number;
|
||||
group_id?: string | null;
|
||||
}
|
||||
|
||||
interface ChannelState {
|
||||
|
||||
Reference in New Issue
Block a user