Files
WebDrop/web/src/features/channel/SpaceRail.tsx
T
justme 4b8d0b5a5f
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
feat(web): discord-style chat, members pane, invitations and optimistic send [CI]
- chat: left-aligned discord-style messages with grouped senders, hover
  actions, an auto-growing composer and file cards
- send: render messages and files locally first and replace the
  placeholder when the worker publishes the real item; failures keep the
  text with a retry action
- navigation: space rail plus a flatter channel list with the space name
  header and collapsible groups
- members: right pane tabs (files / members) grouped by online, offline
  and pending users, with a prominent invite action and a top bar shortcut
- notifications: toast host for invite results; invitees get a toast with
  an accept action, and the invitation payload now carries the space name
  and inviter inside the encrypted grant
- users over devices: drop device names, the device list and device
  rename/release; senders show the account (guests show as guest) and the
  share panel only keeps links
- follow contract 7.0.0 (max_members, members_used, members_full, /join)
- docs: ui-design and frontend updated for the new layout and concepts
2026-10-14 22:19:25 +08:00

37 lines
2.0 KiB
TypeScript

import { useChannels } from '@/store/channels';
import { useAccount } from '@/store/account';
import { useWorkspace } from '@/store/workspace';
import { channelPath, navigate } from '@/app/navigation';
import { Icon } from '@/ui/Icon';
import styles from './SpaceRail.module.css';
interface RailProps { id?: string; onCreate: () => void; onJoin: () => void }
/** 空间切换:桌面是最左一列窄栏,手机是横向一排方块 */
export function SpaceRail({ id, onCreate, onJoin }: RailProps) {
const channels = useChannels((s) => s.list);
const username = useAccount((s) => s.username);
return <nav className={styles.rail} aria-label="空间列表">
{channels.map((channel) => {
const name = channel.name || channel.id.slice(0, 8);
return <button key={channel.id} type="button" className={`${styles.space} ${channel.id === id ? styles.current : ''}`}
aria-current={channel.id === id ? 'page' : undefined} title={name} aria-label={name} onClick={() => navigate(channelPath(channel.id))}>
{Array.from(name)[0]?.toLocaleUpperCase()}
</button>;
})}
<button type="button" className={`${styles.space} ${styles.action}`} title="新建空间" aria-label="新建空间" disabled={!username} onClick={onCreate}><Icon name="plus" size={20} /></button>
<button type="button" className={`${styles.space} ${styles.action}`} title="加入空间" aria-label="加入空间" onClick={onJoin}><Icon name="scan" size={20} /></button>
</nav>;
}
/** 频道列表顶部:空间名与加密状态,点击打开空间设置 */
export function SpaceHeader({ onSettings }: { onSettings: () => void }) {
const channel = useWorkspace((s) => s.channel);
const name = channel?.name || `${channel?.id.slice(0, 8) ?? ''}…`;
return <button type="button" className={styles.header} aria-label={`${name} 空间设置`} onClick={onSettings}>
{channel?.mode === 'raw' ? <span className={styles.raw}>明文</span> : <Icon name="lock" size={14} />}
<strong>{name}</strong>
<Icon name="settings" size={16} />
</button>;
}