136 lines
10 KiB
TypeScript
136 lines
10 KiB
TypeScript
import { useState, type FormEvent } from 'react';
|
|
import type { ChannelGroupInfo, ThreadInfo } from '@/core-bridge/types';
|
|
import { Button } from '@/ui/Button';
|
|
import { Icon } from '@/ui/Icon';
|
|
import { Sheet } from '@/ui/Sheet';
|
|
import styles from './ThreadSidebar.module.css';
|
|
|
|
interface Props {
|
|
threads: ThreadInfo[];
|
|
groups: ChannelGroupInfo[];
|
|
selectedThreadId?: string;
|
|
enabled: boolean;
|
|
owner: boolean;
|
|
onHistory: (id: string, chatHistory: boolean, filesHistory: boolean) => Promise<void>;
|
|
onSelectThread: (id: string) => void;
|
|
onCreateThread: (name: string, groupId?: string) => Promise<string>;
|
|
onRenameThread: (id: string, name: string) => Promise<void>;
|
|
onCreateGroup: (name: string) => Promise<string>;
|
|
onRenameGroup: (id: string, name: string) => Promise<void>;
|
|
onDeleteGroup: (id: string) => Promise<void>;
|
|
onGroupThread: (id: string, groupId?: string) => Promise<void>;
|
|
}
|
|
type Editor = { kind: 'channel' | 'group'; id?: string; groupId?: string };
|
|
const UNGROUPED = '__ungrouped__';
|
|
|
|
export function ThreadSidebar(props: Props) {
|
|
const [edit, setEdit] = useState<Editor>();
|
|
const [name, setName] = useState('');
|
|
const [groupId, setGroupId] = useState('');
|
|
const [chatHistory, setChatHistory] = useState(true);
|
|
const [filesHistory, setFilesHistory] = useState(true);
|
|
const [busy, setBusy] = useState(false);
|
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());
|
|
const knownGroups = new Set(props.groups.map((group) => group.id));
|
|
const ungrouped = props.threads.filter((thread) => !thread.groupId || !knownGroups.has(thread.groupId));
|
|
function begin(target: Editor, initial = '') {
|
|
setEdit(target); setName(initial); setGroupId(target.groupId ?? ''); setError(''); setConfirmDelete(false);
|
|
const channel = props.threads.find(t => t.id === target.id); setChatHistory(channel?.chatHistory ?? true); setFilesHistory(channel?.filesHistory ?? true);
|
|
}
|
|
function toggle(id: string) { setCollapsed((previous) => { const next = new Set(previous); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }
|
|
function select(id: string) { props.onSelectThread(id); setEdit(undefined); }
|
|
async function removeGroup() {
|
|
if (!edit?.id || busy || !props.enabled) return;
|
|
setBusy(true); setError('');
|
|
try { await props.onDeleteGroup(edit.id); setEdit(undefined); }
|
|
catch (cause) { setError(cause instanceof Error ? cause.message : '删除失败'); }
|
|
finally { setBusy(false); }
|
|
}
|
|
async function submit(event: FormEvent) {
|
|
event.preventDefault(); if (!edit || !name.trim() || busy || !props.enabled) return;
|
|
setBusy(true); setError('');
|
|
try {
|
|
if (edit.kind === 'group') {
|
|
if (edit.id) await props.onRenameGroup(edit.id, name); else await props.onCreateGroup(name);
|
|
} else if (edit.id) {
|
|
const original = props.threads.find((thread) => thread.id === edit.id);
|
|
if (original?.name !== name.trim()) await props.onRenameThread(edit.id, name);
|
|
if ((original?.groupId ?? '') !== groupId) await props.onGroupThread(edit.id, groupId || undefined);
|
|
if (props.owner && (original?.chatHistory !== chatHistory || original?.filesHistory !== filesHistory)) await props.onHistory(edit.id, chatHistory, filesHistory);
|
|
} else props.onSelectThread(await props.onCreateThread(name, groupId || undefined));
|
|
if (groupId) setCollapsed((previous) => { const next = new Set(previous); next.delete(groupId); return next; });
|
|
setEdit(undefined);
|
|
} catch (cause) { setError(cause instanceof Error ? cause.message : '操作失败'); }
|
|
finally { setBusy(false); }
|
|
}
|
|
return <section className={styles.panel} aria-label="空间频道导航">
|
|
<div className={styles.heading}><strong>频道</strong><Button variant="ghost" icon="plus" label="新建频道分组" disabled={!props.enabled || busy} onClick={() => begin({ kind: 'group' })} /></div>
|
|
{(ungrouped.length > 0 || props.groups.length === 0) ? <section className={styles.group} aria-label="未分组频道">
|
|
<div className={styles.groupHeading}>
|
|
<button className={styles.groupToggle} type="button" aria-label="未分组频道" aria-expanded={!collapsed.has(UNGROUPED)} onClick={() => toggle(UNGROUPED)}>
|
|
<Icon name={collapsed.has(UNGROUPED) ? 'chevronRight' : 'chevronDown'} size={14} /><span>未分组</span>
|
|
</button>
|
|
{props.enabled ? <span className={styles.categoryActions}>
|
|
<Button variant="ghost" icon="plus" label="在未分组新建频道" disabled={busy} onClick={() => begin({ kind: 'channel' })} />
|
|
</span> : null}
|
|
</div>
|
|
{!collapsed.has(UNGROUPED) ? <ChannelRows threads={ungrouped} selectedId={props.selectedThreadId} enabled={props.enabled} busy={busy} onSelect={select}
|
|
onEdit={(thread) => begin({ kind: 'channel', id: thread.id, groupId: thread.groupId }, thread.name)} /> : null}
|
|
</section> : null}
|
|
{props.groups.map((group) => {
|
|
const threads = props.threads.filter((thread) => thread.groupId === group.id);
|
|
return <section className={styles.group} aria-label={`频道分组 ${group.name}`} key={group.id}>
|
|
<div className={styles.groupHeading}><button className={styles.groupToggle} type="button" aria-label={`分组 ${group.name}`} aria-expanded={!collapsed.has(group.id)} onClick={() => toggle(group.id)}>
|
|
<Icon name={collapsed.has(group.id) ? 'chevronRight' : 'chevronDown'} size={14} /><span>{group.name}</span>
|
|
</button>{props.enabled ? <span className={styles.categoryActions}>
|
|
<Button variant="ghost" icon="plus" label={`在 ${group.name} 新建频道`} disabled={busy} onClick={() => begin({ kind: 'channel', groupId: group.id })} />
|
|
<Button className={styles.groupMore} variant="ghost" icon="more" label={`分组设置 ${group.name}`} disabled={busy} onClick={() => begin({ kind: 'group', id: group.id }, group.name)} />
|
|
</span> : null}</div>
|
|
{!collapsed.has(group.id) ? <ChannelRows threads={threads} selectedId={props.selectedThreadId} enabled={props.enabled} busy={busy} nested onSelect={select}
|
|
onEdit={(thread) => begin({ kind: 'channel', id: thread.id, groupId: thread.groupId }, thread.name)} /> : null}
|
|
{!collapsed.has(group.id) && !threads.length ? <p className={styles.empty}>此分组还没有频道。</p> : null}
|
|
</section>;
|
|
})}
|
|
{edit ? <Sheet title={`${edit.id ? '设置' : '新建'}${edit.kind === 'group' ? '频道分组' : '频道'}`} onClose={() => { if (!busy) setEdit(undefined); }}>
|
|
<form className={`${styles.channelEditor} ${styles.modalEditor}`} onSubmit={(event) => { void submit(event); }}>
|
|
<label>{edit.kind === 'group' ? '分组名称' : '频道名称'}<input autoFocus disabled={busy} aria-label={edit.kind === 'group' ? '分组名称' : '频道名称'} maxLength={80} value={name} placeholder={edit.kind === 'channel' ? '例如:项目讨论' : '例如:项目组'} onChange={(event) => setName(event.target.value)} /></label>
|
|
{edit.kind === 'channel' ? <label>所属分组<select aria-label="所属分组" disabled={busy} value={groupId} onChange={(event) => setGroupId(event.target.value)}>
|
|
<option value="">未分组</option>{props.groups.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
|
|
</select></label> : null}
|
|
{edit.kind === 'channel' && edit.id && props.owner ? <>
|
|
<label className={styles.check}><input type="checkbox" checked={chatHistory} onChange={event => setChatHistory(event.target.checked)} />新成员可读历史聊天</label>
|
|
<label className={styles.check}><input type="checkbox" checked={filesHistory} onChange={event => setFilesHistory(event.target.checked)} />新成员可读历史文件</label>
|
|
</> : null}
|
|
{edit.kind === 'group' && edit.id ? <div className={styles.dangerZone}>
|
|
{confirmDelete ? <>
|
|
<p className={styles.hint}>分组会被删除,其中的频道回到「未分组」,频道和消息都不会丢。</p>
|
|
<div className={styles.groupActions}>
|
|
<Button variant="ghost" disabled={busy} onClick={() => setConfirmDelete(false)}>先不删</Button>
|
|
<Button variant="danger" disabled={busy} onClick={() => { void removeGroup(); }}>确认删除分组</Button>
|
|
</div>
|
|
</> : <Button variant="ghost" icon="trash" disabled={busy} onClick={() => setConfirmDelete(true)}>删除分组</Button>}
|
|
</div> : null}
|
|
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
|
<div className={`${styles.groupActions} ${styles.modalActions}`}>
|
|
<Button variant="ghost" disabled={busy} onClick={() => setEdit(undefined)}>取消</Button>
|
|
<Button type="submit" variant="primary" disabled={busy || !name.trim()}>{busy ? '保存中…' : edit.id ? '保存' : edit.kind === 'channel' ? '创建频道' : '创建分组'}</Button>
|
|
</div>
|
|
</form>
|
|
</Sheet> : null}
|
|
{!props.threads.length && !props.groups.length ? <p className={styles.empty}>创建一个频道后开始聊天和上传文件。</p> : null}
|
|
{!edit && error ? <p className={styles.error} role="alert">{error}</p> : null}
|
|
</section>;
|
|
}
|
|
|
|
function ChannelRows({ threads, selectedId, enabled, busy, nested = false, onSelect, onEdit }: {
|
|
threads: ThreadInfo[]; selectedId?: string; enabled: boolean; busy: boolean; nested?: boolean; onSelect: (id: string) => void; onEdit: (thread: ThreadInfo) => void;
|
|
}) {
|
|
return <ul className={`${styles.threads} ${nested ? styles.nestedThreads : ''}`}>{threads.map((thread) => <li className={`${styles.threadRow} ${thread.id === selectedId ? styles.activeRow : ''}`} key={thread.id}>
|
|
<button className={`${styles.thread} ${thread.id === selectedId ? styles.selected : ''}`} type="button" disabled={busy} aria-current={thread.id === selectedId ? 'page' : undefined}
|
|
onClick={() => onSelect(thread.id)}><span aria-hidden="true">#</span><span>{thread.name}</span></button>
|
|
{enabled ? <Button className={styles.rowActions} variant="ghost" icon="more" disabled={busy} label={`频道设置 ${thread.name}`} onClick={() => onEdit(thread)} /> : null}
|
|
</li>)}</ul>;
|
|
}
|