31cb295a24
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
192 lines
6.3 KiB
TypeScript
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>
|
|
);
|
|
}
|