Files
dumpsterChat/web/src/components/CreateChannelModal.tsx
T
hobokenchicken 31cb295a24 feat: right-click context menus for channels, groups, and servers
ContextMenu: reusable hook-based component that positions a menu at
the cursor and auto-closes on outside click or Escape.

ChannelList:
- Right-click channel -> Edit Name, Permissions, Delete Channel
- Right-click group header -> Edit Name, Create Channel Here, Delete Group
- Inline rename: replacing name text with an input, commits on Enter/blur
- CreateChannelModal now accepts defaultGroupId to pre-select a group

ServerBar:
- Right-click server icon -> Server Settings, Invite People, Leave Server
- Leave calls DELETE /servers/{id}/members/me

Backend:
- Added LeaveServer handler (DELETE /servers/{serverID}/members/me)
- Server owner cannot leave; must transfer or delete
2026-07-02 08:18:12 -04:00

192 lines
6.3 KiB
TypeScript

import { useState, useEffect } from 'react';
import { api } from '../lib/api.ts';
import { useChannelStore } from '../stores/channel.ts';
interface CreateChannelModalProps {
serverId: string;
defaultGroupId?: string | null;
onClose: () => void;
}
interface ServerGroup {
id: string;
server_id: string;
name: string;
position: number;
created_at: string;
}
interface ChannelApiResponse {
id: string;
server_id: string;
name: string;
type: 'text' | 'voice' | 'forum' | 'calendar' | 'docs' | 'list';
category: string;
position: number;
slowmode_seconds: number;
group_id: string | null;
}
const SLOWMODE_OPTIONS = [
{ label: 'Off', value: 0 },
{ label: '5 seconds', value: 5 },
{ label: '10 seconds', value: 10 },
{ label: '30 seconds', value: 30 },
{ label: '1 minute', value: 60 },
{ label: '2 minutes', value: 120 },
{ label: '5 minutes', value: 300 },
];
export function CreateChannelModal({ serverId, defaultGroupId, onClose }: CreateChannelModalProps) {
const [name, setName] = useState('');
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(defaultGroupId || '');
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') {
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 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,
server_id: result.server_id,
name: result.name,
type: result.type,
category: result.category || null,
position: result.position,
slowmode_seconds: result.slowmode_seconds,
group_id: result.group_id,
};
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' | 'forum' | 'calendar' | 'docs' | 'list')}
className="terminal-input w-full"
>
<option value="text">text</option>
<option value="voice">voice</option>
<option value="forum">forum</option>
<option value="calendar">calendar</option>
<option value="docs">docs</option>
<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
type="text"
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="general"
className="terminal-input w-full"
/>
</div>
<div>
<label className="text-xs text-gb-fg-f block mb-1">SLOWMODE:</label>
<select
value={slowmodeSeconds}
onChange={(e) => setSlowmodeSeconds(parseInt(e.target.value, 10))}
className="terminal-input w-full"
>
{SLOWMODE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</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>
);
}