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
This commit is contained in:
1 parent
5fd4ddd411
commit
4b8d0b5a5f
83 files changed
+1291
-661
No files matched your search
@@ -1,49 +0,0 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { channelAccessApi, type ChannelMember } from '@/core-bridge/channelAccess';
|
||||
import type { AccountInvitation } from '@/core-bridge/account';
|
||||
import { Button } from '@/ui/Button';
|
||||
import styles from './ChannelSettingsSheet.module.css';
|
||||
|
||||
export function ChannelInvitations({ owner }: { owner: boolean }) {
|
||||
const channelId = useWorkspace((s) => s.channel?.id);
|
||||
const invite = useWorkspace((s) => s.inviteUser);
|
||||
const list = useWorkspace((s) => s.listInvitations);
|
||||
const revoke = useWorkspace((s) => s.revokeInvitation);
|
||||
const [members, setMembers] = useState<ChannelMember[]>([]);
|
||||
const [invitations, setInvitations] = useState<AccountInvitation[]>([]);
|
||||
const [username, setUsername] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
async function refresh() {
|
||||
if (channelId) setMembers(await channelAccessApi.members(channelId));
|
||||
if (owner) setInvitations(await list());
|
||||
}
|
||||
useEffect(() => { void refresh().catch(() => setError('成员清单读取失败')); }, [channelId, owner, list]);
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault(); setBusy(true); setError('');
|
||||
try { await invite(username); setUsername(''); await refresh(); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : '邀请失败'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
async function remove(username: string) {
|
||||
setBusy(true); setError('');
|
||||
try { await revoke(username); await refresh(); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : '移除失败'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
return <section className={styles.form} aria-label="空间成员与邀请"><strong>空间成员</strong>
|
||||
{members.map((member) => <div className={styles.member} key={member.username}>
|
||||
<span>{member.username} · {member.role === 'owner' ? '空间主' : '成员'}</span>
|
||||
{owner && member.role !== 'owner' ? <Button variant="ghost" disabled={busy} onClick={() => { void remove(member.username); }}>移除成员</Button> : null}
|
||||
</div>)}
|
||||
<p className={styles.note}>未登录访客以独立设备身份加入,可在设备面板移除。</p>
|
||||
{owner ? <><strong>邀请用户</strong><form className={styles.member} onSubmit={(event) => { void submit(event); }}>
|
||||
<input aria-label="被邀请用户的用户名" maxLength={32} value={username} onChange={(event) => setUsername(event.target.value)} placeholder="已注册用户的用户名" required />
|
||||
<Button type="submit" disabled={busy || !username.trim()}>发送邀请</Button>
|
||||
</form>
|
||||
{invitations.map((row) => <div className={styles.member} key={row.username}><span>{row.username} · 已邀请</span><Button variant="ghost" disabled={busy} onClick={() => { void remove(row.username); }}>撤销邀请</Button></div>)}
|
||||
<p className={styles.note}>移除会取消加入授权并断开该账号的设备。已读取的内容无法收回。</p></> : null}
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
</section>;
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import type { ChannelInfo, ChannelSettingsPatch } from '@/core-bridge/types';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Sheet } from '@/ui/Sheet';
|
||||
import styles from './ChannelSettingsSheet.module.css';
|
||||
import { ChannelInvitations } from './ChannelInvitations';
|
||||
|
||||
import { useServerSettings } from '@/store/server';
|
||||
import type { ServerInfoBody } from '@/core-bridge/generated/api';
|
||||
@@ -25,7 +24,7 @@ export function ChannelSettingsSheet(props: Props) {
|
||||
function SettingsForm({ channel, onClose, onSave, info }: Props & { info: ServerInfoBody }) {
|
||||
const owner = channel.role === 'owner';
|
||||
const [name, setName] = useState(channel.name ?? '');
|
||||
const [seats, setSeats] = useState(channel.seats);
|
||||
const [maxMembers, setMaxMembers] = useState(channel.maxMembers);
|
||||
const [membersCanUpload, setMembersCanUpload] = useState(channel.membersCanUpload!);
|
||||
const [ttlSeconds, setTtlSeconds] = useState(channel.ttlSeconds!);
|
||||
const [fileMaxDownloads, setFileMaxDownloads] = useState(channel.maxDownloads!);
|
||||
@@ -43,7 +42,7 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
setSaving(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
await onSave({ name: name.trim(), seats, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy, allowGuests,
|
||||
await onSave({ name: name.trim(), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy, allowGuests,
|
||||
...(clearPassword ? { joinPassword: '' } : joinPassword ? { joinPassword } : {}) });
|
||||
onClose();
|
||||
} catch {
|
||||
@@ -58,13 +57,13 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
<div className={styles.form}>
|
||||
<label className={styles.field}>
|
||||
<span>空间名称</span>
|
||||
<input type="text" maxLength={48} value={name} disabled={!owner} placeholder="例如:工作设备" onChange={(event) => setName(event.target.value)} />
|
||||
<input type="text" maxLength={48} value={name} disabled={!owner} placeholder="例如:项目组" onChange={(event) => setName(event.target.value)} />
|
||||
<span className={styles.note}>空间名称和设置对加入者及服务端可见。</span>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>成员人数上限</span>
|
||||
<input type="number" min={1} max={info.limits.max_seats} step={1} value={seats} disabled={!owner} onChange={(event) => setSeats(Number(event.target.value))} />
|
||||
<input type="number" min={1} max={info.limits.max_members} step={1} value={maxMembers} disabled={!owner} onChange={(event) => setMaxMembers(Number(event.target.value))} />
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
@@ -97,11 +96,11 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
||||
<span className={styles.note}>空间主及受邀用户无需加入密码。修改规则不移除已加入成员。</span>
|
||||
</label>
|
||||
{owner && channel.passwordRequired ? <label className={styles.check}><input type="checkbox" checked={clearPassword} onChange={(event) => setClearPassword(event.target.checked)} /><span>移除空间加入密码</span></label> : null}
|
||||
<ChannelInvitations owner={owner} />
|
||||
<p className={styles.note}>成员与邀请请在右侧栏的「成员」页管理。</p>
|
||||
{owner ? (
|
||||
<>
|
||||
{failed ? <p className={styles.error} role="alert">保存失败,请检查名称和设置后重试。</p> : null}
|
||||
<Button variant="primary" disabled={saving || seats < 1 || seats > info.limits.max_seats || ttlSeconds < info.limits.min_ttl_seconds || ttlSeconds > info.limits.max_ttl_seconds || Boolean(joinPassword && joinPassword.length < 8)} onClick={() => void save()}>{saving ? '保存中…' : '保存设置'}</Button>
|
||||
<Button variant="primary" disabled={saving || maxMembers < 1 || maxMembers > info.limits.max_members || ttlSeconds < info.limits.min_ttl_seconds || ttlSeconds > info.limits.max_ttl_seconds || Boolean(joinPassword && joinPassword.length < 8)} onClick={() => void save()}>{saving ? '保存中…' : '保存设置'}</Button>
|
||||
</>
|
||||
) : <p className={styles.note}>只有所有者可以修改空间设置。</p>}
|
||||
</div>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useChannels } from '@/store/channels';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { channelPath, navigate } from '@/app/navigation';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import styles from './ThreadSidebar.module.css';
|
||||
|
||||
export function ChannelSwitcher({ id, onCreate }: { id?: string; onCreate: () => void }) {
|
||||
const channels = useChannels((s) => s.list);
|
||||
const username = useAccount((s) => s.username);
|
||||
return <section className={styles.panel} aria-label="我的空间导航">
|
||||
<div className={styles.heading}><strong>空间</strong><Button variant="ghost" icon="edit" label="新建空间" disabled={!username} onClick={onCreate} /></div>
|
||||
<nav className={styles.channels} aria-label="空间列表">{channels.map((channel) => <button key={channel.id} type="button"
|
||||
className={`${styles.thread} ${channel.id === id ? styles.selected : ''}`} aria-current={channel.id === id ? 'page' : undefined}
|
||||
onClick={() => navigate(channelPath(channel.id))}><Icon name={channel.mode === 'enc' ? 'lock' : 'file'} size={14} /><span>{channel.name || channel.id.slice(0, 8)}</span></button>)}</nav>
|
||||
</section>;
|
||||
}
|
||||
@@ -1,50 +1,56 @@
|
||||
import { useState } from 'react';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { isLocalId, useWorkspace } from '@/store/workspace';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { useDownload } from '@/features/item/download';
|
||||
import { iconOf } from '@/features/item/summary';
|
||||
import { senderLabel } from '@/lib/sender';
|
||||
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Button } from '@/ui/Button';
|
||||
import styles from './ChatPane.module.css';
|
||||
|
||||
export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: { id: string; compact?: boolean; onOpenFile: (id: string) => void; onDelete: (id: string) => void }) {
|
||||
interface Props { id: string; compact?: boolean; onOpenFile: (id: string) => void; onDelete: (id: string) => void }
|
||||
|
||||
export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props) {
|
||||
const item = useWorkspace((s) => s.byId[id]);
|
||||
const owner = useWorkspace((s) => s.channel?.role === 'owner');
|
||||
const ownDevice = useWorkspace((s) => s.devices.find((device) => device.self)?.name);
|
||||
const retry = useWorkspace((s) => s.retrySend);
|
||||
const username = useAccount((s) => s.username);
|
||||
const download = useDownload();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
if (!item) return null;
|
||||
const sender = item.author;
|
||||
const name = sender?.displayName || sender?.username || item.deviceId || '未知用户';
|
||||
const self = sender?.username !== undefined ? sender.username === username : Boolean(ownDevice && item.deviceId === ownDevice);
|
||||
const name = senderLabel(item);
|
||||
const self = sender?.username !== undefined && sender.username === username;
|
||||
const local = isLocalId(id);
|
||||
const sending = local && item.state.kind === 'receiving';
|
||||
const failed = local && item.state.kind === 'incomplete';
|
||||
const ready = item.state.kind === 'complete';
|
||||
const deleteLabel = item.kind === 'file' ? `删除文件消息 ${item.name}` : '删除消息';
|
||||
async function save() {
|
||||
if (!item || downloading) return;
|
||||
setDownloading(true); try { await download(item); } finally { setDownloading(false); }
|
||||
}
|
||||
return <article className={`${styles.message} ${self ? styles.mine : ''} ${compact ? styles.compact : ''}`} title={compact ? `${name} · ${formatTime(item.ts)}` : undefined}
|
||||
aria-label={item.kind === 'file' ? `${name} 上传了 ${item.name}` : `${name} 的消息`}>
|
||||
{compact ? <span className={styles.avatarSpacer} aria-hidden="true" /> : sender?.avatar ? <img className={styles.avatar} src={sender.avatar} alt={`${name} 的头像`} />
|
||||
: <span className={`${styles.avatar} ${styles.avatarFallback}`} role="img" aria-label={`${name} 的默认头像`}>{Array.from(name)[0] ?? '?'}</span>}
|
||||
<div className={styles.bubble}>
|
||||
{!compact ? <header><strong className={styles.senderName} title={sender ? `${sender.username} · ${item.deviceId}` : item.deviceId}>{name}</strong><time>{formatTime(item.ts)}</time>
|
||||
{owner || self ? <Button className={styles.deleteAction} variant="ghost" icon="trash" label={item.kind === 'file' ? `删除文件消息 ${item.name}` : '删除消息'} onClick={() => onDelete(id)} /> : null}
|
||||
</header> : null}
|
||||
{compact && (owner || self) ? <Button className={`${styles.deleteAction} ${styles.compactDelete}`} variant="ghost" icon="trash" label={item.kind === 'file' ? `删除文件消息 ${item.name}` : '删除消息'} onClick={() => onDelete(id)} /> : null}
|
||||
return <article className={`${styles.message} ${compact ? styles.compact : ''} ${sending ? styles.sending : ''}`}
|
||||
aria-label={item.kind === 'file' ? `${name} 上传了 ${item.name}` : `${name} 的消息`} aria-busy={sending}>
|
||||
{compact ? <time className={styles.gutterTime} dateTime={new Date(item.ts).toISOString()}>{formatTime(item.ts)}</time>
|
||||
: sender?.avatar ? <img className={styles.avatar} src={sender.avatar} alt={`${name} 的头像`} />
|
||||
: <span className={`${styles.avatar} ${styles.avatarFallback}`} role="img" aria-label={`${name} 的默认头像`}>{Array.from(name)[0] ?? '?'}</span>}
|
||||
<div className={styles.body}>
|
||||
{!compact ? <header><strong className={styles.senderName} title={sender?.username}>{name}</strong><time>{formatTime(item.ts)}</time></header> : null}
|
||||
{item.state.kind === 'tampered' ? <p className={styles.integrity}>内容校验失败,已拒绝显示。</p> : item.kind === 'text' ? <p className={styles.messageText}>{item.text}</p>
|
||||
: <div className={styles.fileMessage}>
|
||||
<button type="button" className={styles.fileCard} aria-label={`预览文件 ${item.name}`} disabled={!ready} onClick={() => onOpenFile(id)}>
|
||||
: <div className={styles.fileCard}>
|
||||
<button type="button" className={styles.fileOpen} aria-label={`预览文件 ${item.name}`} disabled={!ready} onClick={() => onOpenFile(id)}>
|
||||
<span className={styles.fileIcon}><Icon name={iconOf(item)} size={28} /></span>
|
||||
<span className={styles.fileInfo}><strong>{item.name}</strong><span>{formatSize(item.size)} · {item.mime}</span></span>
|
||||
</button>
|
||||
<div className={styles.fileActions}><Button variant="ghost" icon="eye" disabled={!ready} onClick={() => onOpenFile(id)}>预览</Button>
|
||||
<Button variant="ghost" icon="download" disabled={!ready || downloading} onClick={() => { void save(); }}>{downloading ? '下载中…' : '下载'}</Button>
|
||||
</div>
|
||||
<Button variant="ghost" icon="eye" label="预览" disabled={!ready} onClick={() => onOpenFile(id)} />
|
||||
<Button variant="ghost" icon="download" label={downloading ? '下载中…' : '下载'} disabled={!ready || downloading} onClick={() => { void save(); }} />
|
||||
</div>}
|
||||
{item.state.kind === 'receiving' ? <div className={styles.delivery} role="status">{formatTransferProgress(item.state)}<progress max={1} value={item.state.progress} /></div>
|
||||
: item.state.kind === 'incomplete' ? <span className={styles.delivery}>发送未完成,等待续传。</span> : null}
|
||||
{item.state.kind === 'receiving' && item.kind === 'file' ? <div className={styles.delivery} role="status">{formatTransferProgress(item.state)}<progress max={1} value={item.state.progress} /></div>
|
||||
: failed ? <span className={styles.failure} role="alert">发送失败。<button type="button" onClick={() => { void retry(id); }}>重试</button></span>
|
||||
: item.state.kind === 'incomplete' ? <span className={styles.delivery}>发送未完成,等待续传。</span> : null}
|
||||
</div>
|
||||
{owner || self ? <Button className={styles.deleteAction} variant="ghost" icon="trash" label={deleteLabel} onClick={() => onDelete(id)} /> : null}
|
||||
</article>;
|
||||
}
|
||||
@@ -3,23 +3,28 @@
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
min-height: var(--bar-h);
|
||||
padding: 0 var(--space-3);
|
||||
padding: 0 var(--space-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 12px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.hash {
|
||||
color: var(--text-dim);
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.head span,
|
||||
.empty,
|
||||
.message time {
|
||||
.message time,
|
||||
.delivery {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
@@ -27,59 +32,72 @@
|
||||
.messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
padding: var(--space-4) 0 var(--space-2);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: var(--space-2) 0;
|
||||
margin: 0;
|
||||
padding: 0 var(--space-4);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Discord 式:所有消息同一条左边线,头像 + 名称/时间 + 正文占满宽度 */
|
||||
.message {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
margin-bottom: var(--space-3);
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 40px minmax(0, 1fr);
|
||||
column-gap: var(--space-3);
|
||||
padding: var(--space-1) var(--space-4);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.mine {
|
||||
flex-direction: row-reverse;
|
||||
.message:hover,
|
||||
.message:focus-within {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.compact {
|
||||
margin-top: calc(var(--space-2) * -1);
|
||||
margin-bottom: var(--space-1);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.sending {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
border-radius: var(--radius-avatar);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatarSpacer {
|
||||
width: 40px;
|
||||
height: 1px;
|
||||
flex: 0 0 40px;
|
||||
}
|
||||
|
||||
.avatarFallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
position: relative;
|
||||
/* 连发合并行:头像位置平时留空,悬停才显示时间 */
|
||||
.gutterTime {
|
||||
align-self: start;
|
||||
padding-top: 3px;
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.message:hover .gutterTime,
|
||||
.message:focus-within .gutterTime {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.body {
|
||||
min-width: 0;
|
||||
max-width: min(82%, 760px);
|
||||
padding: 0 0 var(--space-1);
|
||||
}
|
||||
|
||||
.message header {
|
||||
@@ -93,22 +111,28 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.message header time {
|
||||
margin-inline-end: auto;
|
||||
.messageText {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.deleteAction {
|
||||
width: 26px;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
min-height: 26px;
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: var(--space-4);
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
padding: 0;
|
||||
background: var(--surface);
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease-out;
|
||||
}
|
||||
|
||||
.message:hover .deleteAction,
|
||||
@@ -116,24 +140,11 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.compactDelete {
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: calc(var(--space-2) * -1);
|
||||
}
|
||||
|
||||
.messageText {
|
||||
margin: var(--space-1) 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dateDivider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin: var(--space-4) 0 var(--space-3);
|
||||
margin: var(--space-4) var(--space-4) var(--space-2);
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -147,17 +158,26 @@
|
||||
content: '';
|
||||
}
|
||||
|
||||
.fileMessage {
|
||||
width: 280px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.fileCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
width: 360px;
|
||||
max-width: 100%;
|
||||
margin-top: var(--space-1);
|
||||
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.fileOpen {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
padding: var(--space-3) 0;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
@@ -165,6 +185,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fileOpen:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.fileIcon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -181,23 +205,19 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fileInfo > span,
|
||||
.delivery {
|
||||
.fileInfo > span {
|
||||
color: var(--text-dim);
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.fileActions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.delivery {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
width: 360px;
|
||||
max-width: 100%;
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.delivery progress {
|
||||
@@ -206,7 +226,23 @@
|
||||
accent-color: var(--busy);
|
||||
}
|
||||
|
||||
.failure {
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.failure button {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.integrity {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -214,36 +250,56 @@
|
||||
.composer {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
border-top: 1px solid var(--line);
|
||||
gap: var(--space-1);
|
||||
margin: var(--space-2) var(--space-4) var(--space-4);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.composer:focus-within {
|
||||
outline: 2px solid var(--line-strong);
|
||||
}
|
||||
|
||||
.composer textarea {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
max-height: 112px;
|
||||
padding: var(--space-2);
|
||||
resize: vertical;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
min-height: 36px;
|
||||
max-height: 160px;
|
||||
padding: var(--space-2) var(--space-1);
|
||||
resize: none;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
padding: 0 var(--space-3) var(--space-2);
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
@media (width <= 899px) {
|
||||
.pane {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
.message {
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
column-gap: var(--space-2);
|
||||
padding-inline: var(--space-3);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.deleteAction {
|
||||
inset-inline-end: var(--space-2);
|
||||
}
|
||||
|
||||
.composer {
|
||||
margin: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.deleteAction {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -8,36 +8,43 @@ import styles from './ChatPane.module.css';
|
||||
|
||||
interface Props { threadName: string; ids: string[]; enabled: boolean; onSend: (text: string) => Promise<void>; onDelete: (id: string) => void; onOpenFile: (id: string) => void; onFiles?: (files: File[]) => void }
|
||||
|
||||
const MAX_INPUT_HEIGHT = 160;
|
||||
|
||||
export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFile, onFiles }: Props) {
|
||||
const [text, setText] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const messages = useRef<HTMLDivElement>(null);
|
||||
const input = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const byId = useWorkspace((s) => s.byId);
|
||||
const chronological = ids.slice().reverse();
|
||||
|
||||
useEffect(() => { messages.current?.scrollTo({ top: messages.current.scrollHeight }); }, [chronological.length]);
|
||||
|
||||
async function submit() {
|
||||
function fit() {
|
||||
const el = input.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.min(el.scrollHeight, MAX_INPUT_HEIGHT)}px`;
|
||||
}
|
||||
|
||||
// 乐观发送:不等后端,先清空输入框,消息立刻画出来;失败由消息行自己显示重试
|
||||
function submit() {
|
||||
const value = text.trim();
|
||||
if (!value || busy || !enabled) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try { await onSend(value); setText(''); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : '发送失败'); }
|
||||
finally { setBusy(false); }
|
||||
if (!value || !enabled) return;
|
||||
setText('');
|
||||
if (input.current) input.current.style.height = 'auto';
|
||||
void onSend(value);
|
||||
}
|
||||
|
||||
function keyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); void submit(); }
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); submit(); }
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.pane} aria-label={`${threadName} 聊天`}>
|
||||
<div className={styles.head}><strong>{threadName}</strong><span>频道聊天</span></div>
|
||||
<div className={styles.head}><span className={styles.hash} aria-hidden="true">#</span><strong>{threadName}</strong></div>
|
||||
<div className={styles.messages} ref={messages} aria-live="polite">
|
||||
{chronological.length === 0 ? <p className={styles.empty}>在这里发送消息或文件,成员可以看到你的名称和头像。</p> : null}
|
||||
{chronological.length === 0 ? <p className={styles.empty}>这里是 #{threadName} 的开头。发送消息或文件,成员可以看到你的名称和头像。</p> : null}
|
||||
{(() => {
|
||||
let previous: Item | undefined;
|
||||
return chronological.map((id) => {
|
||||
@@ -46,7 +53,7 @@ export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFil
|
||||
const dateKey = new Date(item.ts).toDateString();
|
||||
const previousDate = previous ? new Date(previous.ts).toDateString() : undefined;
|
||||
const showDate = dateKey !== previousDate;
|
||||
const sameSender = previous && (previous.author?.username ?? previous.deviceId) === (item.author?.username ?? item.deviceId);
|
||||
const sameSender = previous && previous.author?.username === item.author?.username;
|
||||
const grouped = Boolean(sameSender && !showDate && item.ts - previous!.ts < 5 * 60_000);
|
||||
previous = item;
|
||||
return <Fragment key={id}>
|
||||
@@ -56,13 +63,13 @@ export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFil
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
<form className={styles.composer} onSubmit={(event) => { event.preventDefault(); void submit(); }}>
|
||||
<form className={styles.composer} onSubmit={(event) => { event.preventDefault(); submit(); }}>
|
||||
{onFiles ? <><input ref={fileInput} type="file" multiple hidden onChange={(event) => { if (event.target.files?.length) onFiles([...event.target.files]); event.target.value = ''; }} />
|
||||
<Button variant="ghost" icon="clip" label="上传文件到当前频道" disabled={!enabled} onClick={() => fileInput.current?.click()} /></> : null}
|
||||
<textarea value={text} disabled={!enabled || busy} rows={2} placeholder={enabled ? '输入消息,Enter 发送,Shift+Enter 换行' : '先创建或加入一个频道'} onChange={(event) => setText(event.target.value)} onKeyDown={keyDown} />
|
||||
<Button variant="primary" icon="send" label="发送消息" disabled={!enabled || busy || !text.trim()} onClick={() => void submit()} />
|
||||
<Button variant="ghost" icon="plus" label="上传文件到当前频道" disabled={!enabled} onClick={() => fileInput.current?.click()} /></> : null}
|
||||
<textarea ref={input} value={text} disabled={!enabled} rows={1} aria-label="消息内容" placeholder={enabled ? `给 #${threadName} 发消息` : '先创建或加入一个频道'}
|
||||
onChange={(event) => { setText(event.target.value); fit(); }} onKeyDown={keyDown} />
|
||||
<Button type="submit" variant="ghost" icon="send" label="发送消息" disabled={!enabled || !text.trim()} />
|
||||
</form>
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { formatAgo } from '@/lib/format';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { channelAccessApi, type ChannelMember } from '@/core-bridge/channelAccess';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { CopyButton } from '@/ui/CopyButton';
|
||||
import { QrCode } from '@/ui/QrCode';
|
||||
import styles from './DevicePanel.module.css';
|
||||
|
||||
export function DevicePanel({ qrOpen = false }: { qrOpen?: boolean }) {
|
||||
const deviceCount = useWorkspace((s) => s.devices.length);
|
||||
const channel = useWorkspace((s) => s.channel);
|
||||
const username = useAccount((s) => s.username);
|
||||
const [members, setMembers] = useState<ChannelMember[]>([]);
|
||||
const [membersError, setMembersError] = useState('');
|
||||
const [showOwner, setShowOwner] = useState(false);
|
||||
const [showQr, setShowQr] = useState(qrOpen);
|
||||
const membersUsed = channel?.membersUsed ?? '—';
|
||||
const accountAddress = channel ? `${location.origin}/c/${channel.id}` : '';
|
||||
const refreshMembers = useCallback(async () => {
|
||||
if (!channel?.id) return;
|
||||
setMembersError('');
|
||||
try { setMembers(await channelAccessApi.members(channel.id)); }
|
||||
catch { setMembersError('成员清单读取失败'); }
|
||||
}, [channel?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshMembers();
|
||||
}, [refreshMembers, deviceCount]);
|
||||
|
||||
return (
|
||||
<aside className={styles.panel}>
|
||||
<section>
|
||||
<div className={styles.memberHeading}>
|
||||
<h2 className={styles.h}>成员 <span className={styles.dim}>{membersUsed}/{channel?.seats} 人</span></h2>
|
||||
<Button variant="ghost" disabled={!channel} onClick={() => { void refreshMembers(); }}>刷新</Button>
|
||||
</div>
|
||||
<ul className={styles.list}>
|
||||
{members.map((member) => <li key={member.username} className={styles.member}>
|
||||
<span className={styles.name}>{member.username}</span>
|
||||
<span className={member.username === username ? styles.memberCurrent : styles.memberRole}>{member.username === username ? '你' : member.role === 'owner' ? '空间主' : '成员'}</span>
|
||||
<time className={styles.memberTime}>{formatAgo(member.joined_at)}</time>
|
||||
</li>)}
|
||||
</ul>
|
||||
{!members.length && !membersError ? <p className={styles.note}>暂无账号成员。</p> : null}
|
||||
{membersError ? <p className={styles.note} role="alert">{membersError}</p> : null}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className={styles.h}>分享</h2>
|
||||
<div className={styles.link}>
|
||||
<div className={styles.label}>账号空间地址</div>
|
||||
<code className={styles.code}>{accountAddress}</code>
|
||||
<CopyButton text={accountAddress} />
|
||||
<p className={styles.note}>此地址不包含密钥。在空间设置中邀请账号后,成员登录即可自动领取密钥包。</p>
|
||||
</div>
|
||||
{channel?.joinPolicy === 'open' ? <>
|
||||
<div className={styles.link}>
|
||||
<div className={styles.label}>成员链接</div>
|
||||
<code className={styles.code}>{channel?.memberLink}</code>
|
||||
<div className={styles.row}>
|
||||
<CopyButton text={channel?.memberLink ?? ''} />
|
||||
<Button variant="ghost" icon="scan" aria-expanded={showQr} onClick={() => setShowQr((v) => !v)}>
|
||||
{showQr ? '收起二维码' : '二维码'}
|
||||
</Button>
|
||||
</div>
|
||||
{showQr && channel ? <QrCode text={channel.memberLink} label="成员链接二维码" /> : null}
|
||||
</div>
|
||||
{channel?.ownerLink ? (
|
||||
<div className={styles.link}>
|
||||
<div className={styles.label}>所有者链接 <span className={styles.warn}>含管理权限</span></div>
|
||||
<code className={styles.code}>{showOwner ? channel.ownerLink : '••••••••••••••••••••'}</code>
|
||||
<div className={styles.row}>
|
||||
<Button variant="ghost" icon="eye" onClick={() => setShowOwner((v) => !v)}>{showOwner ? '隐藏' : '显示'}</Button>
|
||||
<CopyButton text={channel.ownerLink} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<p className={styles.note}>
|
||||
持有链接并符合空间加入规则后,可读取空间历史内容。不隐藏 IP 与访问时间。
|
||||
</p>
|
||||
</> : null}
|
||||
</section>
|
||||
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { senderLabel } from '@/lib/sender';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
@@ -16,7 +17,7 @@ export function ExplorerHover({ id, point }: { id: string; point: { x: number; y
|
||||
const preview = encrypted && !item.gated && item.state.kind === 'complete' && (item.preview === 'image' || item.preview === 'text');
|
||||
return createPortal(<aside className={styles.hover} role="tooltip" aria-label="悬浮文件预览" style={{ left: position.x, top: position.y }}>
|
||||
<div className={styles.hoverTitle}><Icon name={iconOf(item)} size={20} /><strong>{item.name}</strong></div>
|
||||
<p>{formatSize(item.size)} · {item.mime}</p><p>{formatTime(item.ts)} · 上传者 {item.author?.displayName ?? item.author?.username ?? item.deviceId} · {item.deviceId}</p>
|
||||
<p>{formatSize(item.size)} · {item.mime}</p><p>{formatTime(item.ts)} · 上传者 {senderLabel(item)}</p>
|
||||
{preview ? <HoverContent id={id} image={item.preview === 'image'} /> : <p>{item.gated ? '受限文件:悬停不读取内容,点击预览后再解密。' : item.state.kind === 'tampered' ? '内容校验失败,已拒绝预览。' : '双击文件打开完整预览。'}</p>}
|
||||
</aside>, document.body);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { errorMessage } from '@/core-bridge/errors';
|
||||
import { useToast } from '@/store/toast';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Sheet } from '@/ui/Sheet';
|
||||
import styles from './MembersPane.module.css';
|
||||
|
||||
export function InviteSheet({ onClose, onInvited }: { onClose: () => void; onInvited: () => void }) {
|
||||
const invite = useWorkspace((s) => s.inviteUser);
|
||||
const [username, setUsername] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
const target = username.trim();
|
||||
if (!target || busy) return;
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
await invite(target);
|
||||
useToast.getState().push({ kind: 'ok', text: `已向 ${target} 发送邀请` });
|
||||
onInvited(); onClose();
|
||||
} catch (cause) {
|
||||
const message = errorMessage(cause);
|
||||
setError(message);
|
||||
useToast.getState().push({ kind: 'error', text: `邀请失败:${message}` });
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return <Sheet title="邀请成员" onClose={() => { if (!busy) onClose(); }} placement="top">
|
||||
<form className={styles.invite} onSubmit={(event) => { void submit(event); }}>
|
||||
<label>用户名
|
||||
<input autoFocus maxLength={32} value={username} disabled={busy} placeholder="已注册用户的用户名" onChange={(event) => setUsername(event.target.value)} />
|
||||
</label>
|
||||
<p className={styles.note}>对方登录后会收到通知,接受邀请后加入空间。能否读取加入前的历史,取决于各频道的历史开关。</p>
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
<div className={styles.inviteActions}>
|
||||
<Button variant="ghost" disabled={busy} onClick={onClose}>取消</Button>
|
||||
<Button type="submit" variant="primary" disabled={busy || !username.trim()}>{busy ? '发送中…' : '发送邀请'}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Sheet>;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
|
||||
import { useDownload } from '@/features/item/download';
|
||||
import { InlineContent, inlineKindOf } from '@/features/item/InlineContent';
|
||||
import { shortDevice, titleOf } from '@/features/item/summary';
|
||||
import { senderLabel } from '@/lib/sender';
|
||||
import { titleOf } from '@/features/item/summary';
|
||||
import { Tile } from '@/features/item/Tile';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { Button } from '@/ui/Button';
|
||||
@@ -16,7 +17,6 @@ interface Props {
|
||||
/** 手机首屏:最新一项放大,拇指区一键取走 */
|
||||
export function LatestCard({ id, onOpen }: Props) {
|
||||
const item = useWorkspace((s) => s.byId[id]);
|
||||
const device = useWorkspace((s) => s.devices.find((d) => d.id === s.byId[id]?.deviceId)?.name);
|
||||
const download = useDownload();
|
||||
if (!item) return null;
|
||||
|
||||
@@ -32,7 +32,7 @@ export function LatestCard({ id, onOpen }: Props) {
|
||||
<span className={styles.text}>
|
||||
{inline === 'text' ? null : <span className={styles.title}>{titleOf(item)}</span>}
|
||||
<span className={styles.sub}>
|
||||
{shortDevice(device)} · {formatTime(item.ts)} · {formatSize(size)}
|
||||
{senderLabel(item)} · {formatTime(item.ts)} · {formatSize(size)}
|
||||
{receiving !== null && item.state.kind === 'receiving' ? ` · ${formatTransferProgress(item.state)}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
.pane {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
min-height: var(--bar-h);
|
||||
padding: 0 var(--space-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--space-2) var(--space-2) var(--space-4);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
margin: var(--space-4) var(--space-2) var(--space-1);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-height: 44px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.row:hover,
|
||||
.row:focus-within {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
flex: 0 0 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-avatar);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatarFallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dim {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.who em {
|
||||
margin-inline-start: var(--space-2);
|
||||
padding: 0 var(--space-1);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.who {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.who strong,
|
||||
.who small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.who strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.who small {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.online {
|
||||
flex: 0 0 8px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-avatar);
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.remove {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.row:hover .remove,
|
||||
.row:focus-within .remove {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.remove {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.note,
|
||||
.error {
|
||||
margin: var(--space-4) var(--space-2) 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.invite {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.invite label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.invite input {
|
||||
min-height: 42px;
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.invite .note {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.inviteActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||
import { channelAccessApi, type ChannelMember } from '@/core-bridge/channelAccess';
|
||||
import type { AccountInvitation } from '@/core-bridge/account';
|
||||
import { errorMessage } from '@/core-bridge/errors';
|
||||
import type { SenderInfo } from '@/core-bridge/types';
|
||||
import { useToast } from '@/store/toast';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { Button } from '@/ui/Button';
|
||||
import styles from './MembersPane.module.css';
|
||||
|
||||
interface Props { owner: boolean; refreshKey: number; onInvite: () => void }
|
||||
|
||||
function Avatar({ person, name }: { person?: SenderInfo; name: string }) {
|
||||
return person?.avatar ? <img className={styles.avatar} src={person.avatar} alt="" />
|
||||
: <span className={`${styles.avatar} ${styles.avatarFallback}`} aria-hidden="true">{Array.from(name)[0]?.toLocaleUpperCase() ?? '?'}</span>;
|
||||
}
|
||||
|
||||
function Section({ title, count, children }: { title: string; count: number; children: ReactNode }) {
|
||||
return count ? <section className={styles.section}><h3>{title} — {count}</h3><ul>{children}</ul></section> : null;
|
||||
}
|
||||
|
||||
/** 右栏「成员」页:以用户为单位,按在线 / 离线 / 待接受分组。设备只是用户的属性(在线设备数由后端按账号统计) */
|
||||
|
||||
const PRESENCE_POLL_MS = 8000;
|
||||
export function MembersPane({ owner, refreshKey, onInvite }: Props) {
|
||||
const channelId = useWorkspace((s) => s.channel?.id);
|
||||
const maxMembers = useWorkspace((s) => s.channel?.maxMembers);
|
||||
const people = useWorkspace((s) => s.people);
|
||||
const listInvitations = useWorkspace((s) => s.listInvitations);
|
||||
const revoke = useWorkspace((s) => s.revokeInvitation);
|
||||
const [members, setMembers] = useState<ChannelMember[]>([]);
|
||||
const [pending, setPending] = useState<AccountInvitation[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!channelId) return;
|
||||
try {
|
||||
setMembers(await channelAccessApi.members(channelId));
|
||||
setPending(owner ? await listInvitations() : []);
|
||||
setError('');
|
||||
} catch { setError('成员清单读取失败'); }
|
||||
}, [channelId, owner, listInvitations]);
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
// 在线状态不走订阅流,成员页可见期间定时读取
|
||||
const timer = window.setInterval(() => { void refresh(); }, PRESENCE_POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refresh, refreshKey]);
|
||||
|
||||
async function remove(username: string, invited: boolean) {
|
||||
if (!invited && !window.confirm(`移除成员 ${username}?对方的设备会被断开,已读取的内容无法收回。`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await revoke(username);
|
||||
useToast.getState().push({ kind: 'ok', text: invited ? `已撤销对 ${username} 的邀请` : `已移除 ${username}` });
|
||||
await refresh();
|
||||
} catch (cause) { useToast.getState().push({ kind: 'error', text: `操作失败:${errorMessage(cause)}` }); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const online = members.filter((member) => member.online);
|
||||
const offline = members.filter((member) => !member.online);
|
||||
const row = (username: string, detail: string | undefined, invited: boolean, removable: boolean, isOwner = false, isOnline = false) => {
|
||||
const person = people[username];
|
||||
const name = person?.displayName || username;
|
||||
return <li className={`${styles.row} ${invited || (!isOnline && !isOwner) ? styles.dim : ''}`} key={`${invited ? 'i' : 'm'}:${username}`}>
|
||||
<Avatar person={person} name={name} />
|
||||
<span className={styles.who}><strong>{name}{isOwner ? <em>空间主</em> : null}</strong>
|
||||
{detail || name !== username ? <small>{[name !== username ? username : '', detail].filter(Boolean).join(' · ')}</small> : null}</span>
|
||||
{isOnline ? <span className={styles.online} role="img" aria-label="在线" /> : null}
|
||||
{removable ? <Button className={styles.remove} variant="ghost" disabled={busy} onClick={() => { void remove(username, invited); }}>{invited ? '撤销' : '移除'}</Button> : null}
|
||||
</li>;
|
||||
};
|
||||
const member = (item: ChannelMember) => row(item.username,
|
||||
item.devices > 1 || item.online_devices > 1 ? `${item.online_devices}/${item.devices} 台设备在线` : undefined,
|
||||
false, owner && item.role !== 'owner', item.role === 'owner', item.online);
|
||||
|
||||
return <div className={styles.pane}>
|
||||
<div className={styles.head}>
|
||||
<span>成员 {members.length}{maxMembers ? `/${maxMembers}` : ''}</span>
|
||||
{owner ? <Button variant="primary" icon="plus" onClick={onInvite}>邀请成员</Button> : null}
|
||||
</div>
|
||||
<div className={styles.scroll}>
|
||||
<Section title="在线" count={online.length}>{online.map(member)}</Section>
|
||||
<Section title="离线" count={offline.length}>{offline.map(member)}</Section>
|
||||
<Section title="待接受" count={pending.length}>{pending.map((invitation) => row(invitation.username, undefined, true, owner))}</Section>
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
{!owner ? <p className={styles.note}>只有空间主可以邀请或移除成员。</p> : null}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
.pad {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-top: var(--space-2);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.head {
|
||||
|
||||
@@ -9,7 +9,6 @@ const SAVE_DELAY_MS = 400;
|
||||
export function Notepad({ defaultOpen = false }: { defaultOpen?: boolean }) {
|
||||
const snapshot = useWorkspace((s) => s.snapshot);
|
||||
const setSnapshot = useWorkspace((s) => s.setSnapshot);
|
||||
const editor = useWorkspace((s) => s.devices.find((d) => d.editing && !d.self)?.name);
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
@@ -31,7 +30,6 @@ export function Notepad({ defaultOpen = false }: { defaultOpen?: boolean }) {
|
||||
<span className={`${styles.chev} ${open ? styles.open : ''}`}><Icon name="chevronRight" size={16} /></span>
|
||||
<span className={styles.title}>便签</span>
|
||||
{open ? null : <span className={styles.preview}>{firstLine}</span>}
|
||||
{editor ? <span className={styles.editing}>{editor} 正在编辑</span> : null}
|
||||
</button>
|
||||
{open ? (
|
||||
<textarea
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: var(--space-4);
|
||||
min-height: var(--bar-h);
|
||||
padding: 0 var(--space-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: -1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tabs button:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tabs button[aria-selected="true"] {
|
||||
border-bottom-color: var(--accent);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tabs span {
|
||||
padding: 0 var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import styles from './PaneTabs.module.css';
|
||||
|
||||
export type SideTab = 'files' | 'members';
|
||||
|
||||
/** 桌面右栏的「文件 / 成员」切换 */
|
||||
export function PaneTabs({ tab, memberCount, onTab }: { tab: SideTab; memberCount?: number; onTab: (tab: SideTab) => void }) {
|
||||
return <div className={styles.tabs} role="tablist" aria-label="右侧栏">
|
||||
<button type="button" role="tab" aria-selected={tab === 'files'} onClick={() => onTab('files')}>文件</button>
|
||||
<button type="button" role="tab" aria-selected={tab === 'members'} onClick={() => onTab('members')}>成员{memberCount ? <span>{memberCount}</span> : null}</button>
|
||||
</div>;
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,54 @@
|
||||
import { useState } from 'react';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { CopyButton } from '@/ui/CopyButton';
|
||||
import { QrCode } from '@/ui/QrCode';
|
||||
import styles from './SharePanel.module.css';
|
||||
|
||||
/** 分享入口:空间地址与(持链接加入时的)成员链接。成员与邀请在右侧「成员」页管理,这里不列人 */
|
||||
export function SharePanel({ qrOpen = false }: { qrOpen?: boolean }) {
|
||||
const channel = useWorkspace((s) => s.channel);
|
||||
const [showOwner, setShowOwner] = useState(false);
|
||||
const [showQr, setShowQr] = useState(qrOpen);
|
||||
const accountAddress = channel ? `${location.origin}/c/${channel.id}` : '';
|
||||
|
||||
return (
|
||||
<aside className={styles.panel}>
|
||||
<section>
|
||||
<h2 className={styles.h}>分享</h2>
|
||||
<div className={styles.link}>
|
||||
<div className={styles.label}>账号空间地址</div>
|
||||
<code className={styles.code}>{accountAddress}</code>
|
||||
<CopyButton text={accountAddress} />
|
||||
<p className={styles.note}>此地址不包含密钥。在「成员」页邀请账号后,对方登录即可自动领取密钥包。</p>
|
||||
</div>
|
||||
{channel?.joinPolicy === 'open' ? <>
|
||||
<div className={styles.link}>
|
||||
<div className={styles.label}>成员链接</div>
|
||||
<code className={styles.code}>{channel.memberLink}</code>
|
||||
<div className={styles.row}>
|
||||
<CopyButton text={channel.memberLink} />
|
||||
<Button variant="ghost" icon="scan" aria-expanded={showQr} onClick={() => setShowQr((v) => !v)}>
|
||||
{showQr ? '收起二维码' : '二维码'}
|
||||
</Button>
|
||||
</div>
|
||||
{showQr ? <QrCode text={channel.memberLink} label="成员链接二维码" /> : null}
|
||||
</div>
|
||||
{channel.ownerLink ? (
|
||||
<div className={styles.link}>
|
||||
<div className={styles.label}>所有者链接 <span className={styles.warn}>含管理权限</span></div>
|
||||
<code className={styles.code}>{showOwner ? channel.ownerLink : '••••••••••••••••••••'}</code>
|
||||
<div className={styles.row}>
|
||||
<Button variant="ghost" icon="eye" onClick={() => setShowOwner((v) => !v)}>{showOwner ? '隐藏' : '显示'}</Button>
|
||||
<CopyButton text={channel.ownerLink} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<p className={styles.note}>
|
||||
持有链接并符合空间加入规则后,可读取空间历史内容。不隐藏 IP 与访问时间。
|
||||
</p>
|
||||
</> : null}
|
||||
</section>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
.rail,
|
||||
.open {
|
||||
grid-row: 1 / -1;
|
||||
grid-column: 3;
|
||||
border-left: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 56px;
|
||||
padding: var(--space-3) 0;
|
||||
}
|
||||
|
||||
.count {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.countLabel {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.open {
|
||||
width: 320px;
|
||||
padding: var(--space-3) var(--space-4) var(--space-4);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { DevicePanel } from './DevicePanel';
|
||||
import styles from './SideRail.module.css';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
qrOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
/** 电脑右侧的设备与分享:低频,默认收成窄栏,需要时展开 */
|
||||
export function SideRail({ open, qrOpen, onToggle }: Props) {
|
||||
const channel = useWorkspace((s) => s.channel);
|
||||
|
||||
if (open) {
|
||||
return (
|
||||
<aside className={styles.open}>
|
||||
<div className={styles.head}>
|
||||
<span className={styles.heading}>成员与分享</span>
|
||||
<Button variant="ghost" icon="chevronRight" label="收起" onClick={onToggle} />
|
||||
</div>
|
||||
<DevicePanel qrOpen={qrOpen} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={styles.rail}>
|
||||
<Button variant="ghost" icon="chevronLeft" label="展开成员与分享" onClick={onToggle} />
|
||||
<span className={styles.count} title="已加入成员 / 最大成员人数">
|
||||
{channel?.membersUsed ?? '—'}/{channel?.seats ?? '—'}
|
||||
<span className={styles.countLabel}>人</span>
|
||||
</span>
|
||||
<Button variant="ghost" icon="share" label="分享" onClick={onToggle} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
.rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) 0;
|
||||
}
|
||||
|
||||
.space {
|
||||
display: grid;
|
||||
flex: 0 0 44px;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.space:hover {
|
||||
background: var(--line-strong);
|
||||
}
|
||||
|
||||
.space:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.current {
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.current:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.action {
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
outline: 1px dashed var(--line-strong);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
min-height: var(--bar-h);
|
||||
padding: 0 var(--space-4);
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.header strong {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.header svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.raw {
|
||||
color: var(--warn);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (width <= 899px) {
|
||||
/* 手机顶栏已有空间名与设置入口 */
|
||||
.header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rail {
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.space {
|
||||
flex-basis: 40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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>;
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-3) var(--space-2);
|
||||
}
|
||||
|
||||
.channels {
|
||||
@@ -17,7 +16,6 @@
|
||||
|
||||
.group {
|
||||
min-width: 0;
|
||||
margin-inline-start: var(--space-2);
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
@@ -32,15 +30,15 @@
|
||||
.groupToggle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 36px;
|
||||
padding: 0 var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
min-height: 28px;
|
||||
padding: 0 var(--space-1);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -67,6 +65,12 @@
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease-out;
|
||||
}
|
||||
|
||||
.groupHeading:hover .categoryActions,
|
||||
.groupHeading:focus-within .categoryActions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -142,8 +146,10 @@
|
||||
}
|
||||
|
||||
.heading {
|
||||
min-height: 36px;
|
||||
padding-inline: var(--space-2);
|
||||
min-height: 32px;
|
||||
padding-inline: var(--space-1);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.folderHeading {
|
||||
@@ -161,14 +167,13 @@
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* 频道行缩进到分组标题文字之下 */
|
||||
.threads {
|
||||
padding-inline-start: var(--space-4);
|
||||
}
|
||||
|
||||
.nestedThreads {
|
||||
margin-inline-start: 0;
|
||||
padding-inline-start: var(--space-4);
|
||||
border-inline-start: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.threadRow,
|
||||
@@ -213,12 +218,12 @@
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
padding: 0 var(--space-3);
|
||||
min-height: 32px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
color: var(--text-dim);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -247,12 +252,21 @@
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.thread:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.thread.selected {
|
||||
background: var(--busy-soft);
|
||||
color: var(--busy);
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.thread > span:first-child {
|
||||
color: var(--text-dim);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.empty,
|
||||
.hint,
|
||||
.error {
|
||||
@@ -297,7 +311,11 @@
|
||||
|
||||
@media (width <= 899px) {
|
||||
.panel {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
}
|
||||
|
||||
.categoryActions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.threads {
|
||||
|
||||
@@ -11,16 +11,20 @@ interface Props {
|
||||
/** 手机上点开成员与分享面板;桌面端面板在右侧栏,不传 */
|
||||
onOpenPanel?: () => void;
|
||||
onOpenSettings: () => void;
|
||||
/** 打开右侧「成员」页(手机为成员页签) */
|
||||
onOpenMembers: () => void;
|
||||
/** 仅空间主:直接打开邀请弹窗 */
|
||||
onInvite?: () => void;
|
||||
searchOpen: boolean;
|
||||
/** 有筛选条件在生效 */
|
||||
filtering: boolean;
|
||||
onToggleSearch: () => void;
|
||||
}
|
||||
|
||||
export function TopBar({ onBack, onOpenPanel, onOpenSettings, searchOpen, filtering, onToggleSearch }: Props) {
|
||||
export function TopBar({ onBack, onOpenPanel, onOpenSettings, onOpenMembers, onInvite, searchOpen, filtering, onToggleSearch }: Props) {
|
||||
const channel = useWorkspace((s) => s.channel);
|
||||
const connection = useWorkspace((s) => s.connection);
|
||||
const memberCount = `${channel?.membersUsed ?? '—'}/${channel?.seats ?? '—'}`;
|
||||
const memberCount = `${channel?.membersUsed ?? '—'}/${channel?.maxMembers ?? '—'}`;
|
||||
const raw = channel?.mode === 'raw';
|
||||
|
||||
return (
|
||||
@@ -38,11 +42,12 @@ export function TopBar({ onBack, onOpenPanel, onOpenSettings, searchOpen, filter
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className={styles.presence} onClick={onOpenPanel} disabled={!onOpenPanel}>
|
||||
<span className={styles.count} title="已加入成员 / 最大成员人数" aria-label={`已加入 ${channel?.membersUsed ?? '未知'} 人,最多 ${channel?.seats ?? '未知'} 人`}>
|
||||
<button type="button" className={styles.presence} onClick={onOpenMembers} title="查看成员">
|
||||
<span className={styles.count} title="已加入成员 / 最大成员人数" aria-label={`已加入 ${channel?.membersUsed ?? '未知'} 人,最多 ${channel?.maxMembers ?? '未知'} 人`}>
|
||||
{memberCount} <span className={styles.countLabel}>人</span>
|
||||
</span>
|
||||
</button>
|
||||
{onInvite ? <Button variant="ghost" icon="plus" label="邀请成员" onClick={onInvite} /> : null}
|
||||
<span className={`${styles.conn} ${styles[connection]}`} role="status">
|
||||
{CONNECTION_TEXT[connection]}
|
||||
</span>
|
||||
@@ -56,7 +61,7 @@ export function TopBar({ onBack, onOpenPanel, onOpenSettings, searchOpen, filter
|
||||
/>
|
||||
<ThemeMenu />
|
||||
<Button variant="ghost" icon="settings" label="空间设置" onClick={onOpenSettings} />
|
||||
{onOpenPanel ? <Button variant="ghost" icon="share" label="成员与分享" onClick={onOpenPanel} /> : null}
|
||||
{onOpenPanel ? <Button variant="ghost" icon="share" label="分享" onClick={onOpenPanel} /> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
.desktop {
|
||||
--nav-width: 320px;
|
||||
--rail-width: 64px;
|
||||
--nav-width: 240px;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: var(--nav-width) minmax(260px, 1fr) 6px clamp(320px, var(--files-width, 36%), calc(100% - var(--nav-width) - 266px));
|
||||
grid-template-columns: var(--rail-width) var(--nav-width) minmax(260px, 1fr) 6px clamp(320px, var(--files-width, 36%), calc(100% - var(--rail-width) - var(--nav-width) - 266px));
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
@@ -13,6 +14,7 @@
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.rail,
|
||||
.navigation,
|
||||
.center,
|
||||
.files,
|
||||
@@ -23,6 +25,11 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.rail {
|
||||
overflow-y: auto;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.navigation {
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--line);
|
||||
@@ -125,6 +132,6 @@
|
||||
|
||||
@media (width >= 900px) and (width <= 1100px) {
|
||||
.desktop {
|
||||
--nav-width: 272px;
|
||||
--nav-width: 220px;
|
||||
}
|
||||
}
|
||||
@@ -10,15 +10,18 @@ import type { FileUploadOptions } from '@/core-bridge/types';
|
||||
import type { ClipboardPayload } from '@/lib/clipboard';
|
||||
import { Sheet } from '@/ui/Sheet';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { ChannelSwitcher } from './ChannelSwitcher';
|
||||
import { SpaceHeader, SpaceRail } from './SpaceRail';
|
||||
import { ThreadSidebar } from './ThreadSidebar';
|
||||
import { ChatPane } from './ChatPane';
|
||||
import { FileExplorer } from './FileExplorer';
|
||||
import { FilePaneResize } from './FilePaneResize';
|
||||
import { InviteSheet } from './InviteSheet';
|
||||
import { MembersPane } from './MembersPane';
|
||||
import { PaneTabs, type SideTab } from './PaneTabs';
|
||||
import { TopBar } from './TopBar';
|
||||
import { FilterBar } from './FilterBar';
|
||||
import { Notepad } from './Notepad';
|
||||
import { DevicePanel } from './DevicePanel';
|
||||
import { SharePanel } from './SharePanel';
|
||||
import { ChannelSettingsSheet } from './ChannelSettingsSheet';
|
||||
import { UploadOptionsSheet } from './UploadOptionsSheet';
|
||||
import styles from './Workspace.module.css';
|
||||
@@ -60,7 +63,10 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [scanOpen, setScanOpen] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [mobileView, setMobileView] = useState<'chat' | 'files'>('chat');
|
||||
const [mobileView, setMobileView] = useState<'chat' | 'files' | 'members'>('chat');
|
||||
const [sideTab, setSideTab] = useState<SideTab>('files');
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [memberRevision, setMemberRevision] = useState(0);
|
||||
const [fileWidth, setFileWidth] = useState<number>();
|
||||
const [upload, setUpload] = useState<{ files: File[]; threadId: string; folderId?: string }>();
|
||||
const activeThread = threads.find((thread) => thread.id === threadId) ?? threads.find((thread) => thread.name === '常规') ?? threads[threads.length - 1];
|
||||
@@ -68,6 +74,7 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
|
||||
const activeFolders = folders.filter((folder) => folder.threadId === activeThreadId);
|
||||
const activeFolderId = activeFolders.some((folder) => folder.id === folderId) ? folderId : undefined;
|
||||
const enabled = Boolean(activeThreadId && channel?.canUpload);
|
||||
const owner = channel?.role === 'owner';
|
||||
const files = useWorkspace(useShallow((s) => s.order.filter((id) => {
|
||||
const item = s.byId[id]; return item?.kind === 'file' && !item.metadataHidden && item.threadId === activeThreadId
|
||||
&& (item.folderId ?? undefined) === activeFolderId && matchesFilter(item, filter);
|
||||
@@ -103,10 +110,11 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
|
||||
}
|
||||
function drop(event: DragEvent) { event.preventDefault(); setDragging(false); requestFiles([...event.dataTransfer.files]); }
|
||||
function deleteItem(id: string) { void deleteFiles([id]).catch((cause: unknown) => useWorkspace.setState({ error: cause instanceof Error ? cause.message : '删除失败' })); }
|
||||
const navigation = <><ChannelSwitcher id={channel?.id} onCreate={onCreateChannel} />
|
||||
const rail = <SpaceRail id={channel?.id} onCreate={onCreateChannel} onJoin={() => setScanOpen(true)} />;
|
||||
const navigation = <><SpaceHeader onSettings={() => setSettingsOpen(true)} />
|
||||
<ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={Boolean(channel?.canManageStructure)} owner={channel?.role === 'owner'} onHistory={setChannelHistory} onSelectThread={selectThread} onCreateThread={createThread} onRenameThread={renameThread}
|
||||
onCreateGroup={createGroup} onRenameGroup={renameGroup} onGroupThread={groupThread} />
|
||||
{desktop ? <Notepad /> : null}<Button variant="ghost" icon="scan" onClick={() => setScanOpen(true)}>加入空间</Button></>;
|
||||
{desktop ? <Notepad /> : null}</>;
|
||||
const chat = <ChatPane threadName={activeThread?.name ?? '选择频道'} ids={messages} enabled={enabled} onSend={(text) => sendText(text, activeThreadId)} onDelete={deleteItem} onOpenFile={setPreviewId} onFiles={requestFiles} />;
|
||||
const explorer = <>
|
||||
<FilterBar filter={filter} searchOpen={searchOpen} onChange={setFilter} />
|
||||
@@ -120,14 +128,17 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
|
||||
onDownloadItems={downloadItems} onDeleteItems={deleteFiles} onMoveItems={async (ids, target) => { if (activeThreadId) { await moveItems(ids, activeThreadId, target); setChecked(new Set()); setSelectedId(undefined); } }} />
|
||||
{restricted.length ? <div className={styles.pendingCatalog}><span>等待同步文件目录</span>{restricted.map((id) => <Button variant="ghost" key={id} onClick={() => setPreviewId(id)}>查看受限文件</Button>)}</div> : null}
|
||||
</>;
|
||||
const members = <MembersPane owner={owner} refreshKey={memberRevision} onInvite={() => setInviteOpen(true)} />;
|
||||
function openMembers() { if (desktop) setSideTab('members'); else setMobileView('members'); }
|
||||
return <div className={desktop ? styles.desktop : styles.mobile} style={fileWidth ? { '--files-width': `${fileWidth}px` } as CSSProperties : undefined}
|
||||
onDropCapture={() => setDragging(false)} onDragEndCapture={() => setDragging(false)}
|
||||
onDragOver={(event) => { if (event.dataTransfer.types.includes('Files')) { event.preventDefault(); setDragging(true); } }} onDragLeave={() => setDragging(false)} onDrop={drop}>
|
||||
<TopBar onBack={() => navigate('/')} onOpenPanel={() => setPanelOpen(true)} onOpenSettings={() => setSettingsOpen(true)} searchOpen={searchOpen} filtering={isFiltering(filter)} onToggleSearch={() => { setSearchOpen((value) => !value); if (!desktop) setMobileView('files'); }} />
|
||||
{desktop ? <><aside className={styles.navigation} data-space-nav>{navigation}</aside><main className={styles.center}>{chat}</main><FilePaneResize width={fileWidth} onWidth={setFileWidth} /><aside className={styles.files} data-file-pane>{explorer}</aside></>
|
||||
: <><aside className={styles.navigation}>{navigation}</aside><nav className={styles.tabs} aria-label="频道视图"><button type="button" aria-pressed={mobileView === 'chat'} onClick={() => setMobileView('chat')}>聊天</button><button type="button" aria-pressed={mobileView === 'files'} onClick={() => setMobileView('files')}>文件</button></nav><main className={styles.mobileContent}>{mobileView === 'chat' ? chat : explorer}</main></>}
|
||||
<TopBar onBack={() => navigate('/')} onOpenMembers={openMembers} onInvite={owner ? () => { openMembers(); setInviteOpen(true); } : undefined} onOpenPanel={() => setPanelOpen(true)} onOpenSettings={() => setSettingsOpen(true)} searchOpen={searchOpen} filtering={isFiltering(filter)} onToggleSearch={() => { setSearchOpen((value) => !value); if (!desktop) setMobileView('files'); }} />
|
||||
{desktop ? <><aside className={styles.rail}>{rail}</aside><aside className={styles.navigation} data-space-nav>{navigation}</aside><main className={styles.center}>{chat}</main><FilePaneResize width={fileWidth} onWidth={setFileWidth} /><aside className={styles.files} data-file-pane><PaneTabs tab={sideTab} memberCount={channel?.membersUsed} onTab={setSideTab} />{sideTab === 'files' ? explorer : members}</aside></>
|
||||
: <>{rail}<aside className={styles.navigation}>{navigation}</aside><nav className={styles.tabs} aria-label="频道视图"><button type="button" aria-pressed={mobileView === 'chat'} onClick={() => setMobileView('chat')}>聊天</button><button type="button" aria-pressed={mobileView === 'files'} onClick={() => setMobileView('files')}>文件</button><button type="button" aria-pressed={mobileView === 'members'} onClick={() => setMobileView('members')}>成员</button></nav><main className={styles.mobileContent}>{mobileView === 'chat' ? chat : mobileView === 'files' ? explorer : members}</main></>}
|
||||
{error ? <div className={styles.error} role="alert">{error}</div> : null}
|
||||
{panelOpen ? <Sheet title="设备与账号邀请" onClose={() => setPanelOpen(false)}><DevicePanel /></Sheet> : null}
|
||||
{inviteOpen ? <InviteSheet onClose={() => setInviteOpen(false)} onInvited={() => setMemberRevision((value) => value + 1)} /> : null}
|
||||
{panelOpen ? <Sheet title="分享" onClose={() => setPanelOpen(false)}><SharePanel /></Sheet> : null}
|
||||
{settingsOpen && channel ? <ChannelSettingsSheet channel={channel} onClose={() => setSettingsOpen(false)} onSave={updateSettings} /> : null}
|
||||
{upload && channel?.maxDownloads !== undefined ? <UploadOptionsSheet files={upload.files} encrypted={channel?.mode === 'enc'} defaultMaxDownloads={channel.maxDownloads} onClose={() => setUpload(undefined)} onUpload={confirmUpload} /> : null}
|
||||
<Suspense fallback={null}>{previewId ? <PreviewSheet id={previewId} onClose={() => setPreviewId(undefined)} onDelete={deleteItem} /> : null}
|
||||
|
||||
Reference in new issue
Block a user