feat(web): add spaces, grouped channels and file chat
This commit is contained in:
1 parent
c7d8ef15bc
commit
f6cfb12716
111 files changed
+4371
-506
No files matched your search
@@ -7,7 +7,7 @@ import styles from './ActionBar.module.css';
|
||||
interface Props {
|
||||
/** dock:手机底部动作栏;toolbar:电脑列表上方的工具条 */
|
||||
layout: 'dock' | 'toolbar';
|
||||
/** 添加文件、文本:频道发到频道里,分享放进草稿 */
|
||||
/** 添加文件、文本:空间发到空间里,分享放进草稿 */
|
||||
onFiles: (files: File[]) => void;
|
||||
onText: (text: string) => void;
|
||||
onCompose: () => void;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
.member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.member input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.check input {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.field input:disabled,
|
||||
.field select:disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react';
|
||||
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';
|
||||
|
||||
const TTL_OPTIONS = [
|
||||
{ label: '1 小时', value: 3600 },
|
||||
{ label: '24 小时', value: 86400 },
|
||||
{ label: '7 天', value: 604800 },
|
||||
];
|
||||
const DOWNLOAD_OPTIONS = [0, 1, 3, 5, 10, 20];
|
||||
|
||||
interface Props {
|
||||
channel: ChannelInfo;
|
||||
onClose: () => void;
|
||||
onSave: (patch: ChannelSettingsPatch) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ChannelSettingsSheet({ channel, onClose, onSave }: Props) {
|
||||
const owner = channel.role === 'owner';
|
||||
const [name, setName] = useState(channel.name ?? '');
|
||||
const [seats, setSeats] = useState(channel.seats);
|
||||
const [membersCanUpload, setMembersCanUpload] = useState(channel.membersCanUpload ?? channel.canUpload ?? true);
|
||||
const [ttlSeconds, setTtlSeconds] = useState(channel.ttlSeconds ?? 86400);
|
||||
const [fileMaxDownloads, setFileMaxDownloads] = useState(channel.maxDownloads ?? 10);
|
||||
const [joinPolicy, setJoinPolicy] = useState<'open' | 'invite'>(channel.joinPolicy ?? 'open');
|
||||
const [allowGuests, setAllowGuests] = useState(channel.allowGuests ?? true);
|
||||
const [joinPassword, setJoinPassword] = useState('');
|
||||
const [clearPassword, setClearPassword] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const ttlOptions = TTL_OPTIONS.some((option) => option.value === ttlSeconds)
|
||||
? TTL_OPTIONS
|
||||
: [{ label: `当前设置 (${ttlSeconds} 秒)`, value: ttlSeconds }, ...TTL_OPTIONS];
|
||||
const downloadOptions = [...new Set([...DOWNLOAD_OPTIONS, fileMaxDownloads])].sort((a, b) => a - b);
|
||||
|
||||
async function save() {
|
||||
if (!owner || saving) return;
|
||||
setSaving(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
await onSave({ name: name.trim(), seats, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy, allowGuests,
|
||||
...(clearPassword ? { joinPassword: '' } : joinPassword ? { joinPassword } : {}) });
|
||||
onClose();
|
||||
} catch {
|
||||
setFailed(true);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet title="空间设置" onClose={onClose}>
|
||||
<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)} />
|
||||
<span className={styles.note}>空间名称和设置对加入者及服务端可见。</span>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>设备席位</span>
|
||||
<input type="number" min={1} max={64} step={1} value={seats} disabled={!owner} onChange={(event) => setSeats(Number(event.target.value))} />
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>内容保留时间</span>
|
||||
<select value={ttlSeconds} disabled={!owner} onChange={(event) => setTtlSeconds(Number(event.target.value))}>
|
||||
{ttlOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>受限文件下载次数</span>
|
||||
<select value={fileMaxDownloads} disabled={!owner} onChange={(event) => setFileMaxDownloads(Number(event.target.value))}>
|
||||
{downloadOptions.map((value) => <option key={value} value={value}>{value === 0 ? '不限' : `${value} 次`}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className={styles.check}>
|
||||
<input type="checkbox" checked={membersCanUpload} disabled={!owner} onChange={(event) => setMembersCanUpload(event.target.checked)} />
|
||||
<span>允许成员上传</span>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}><span>加入方式</span>
|
||||
<select value={joinPolicy} disabled={!owner} onChange={(event) => setJoinPolicy(event.target.value as 'open' | 'invite')}>
|
||||
<option value="open">持链接加入</option><option value="invite">仅受邀账号加入</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className={styles.check}><input type="checkbox" checked={allowGuests} disabled={!owner || joinPolicy === 'invite'} onChange={(event) => setAllowGuests(event.target.checked)} /><span>允许未登录的陌生人加入</span></label>
|
||||
<label className={styles.field}><span>{channel.passwordRequired ? '更换空间加入密码' : '空间加入密码(可选)'}</span>
|
||||
<input type="password" autoComplete="new-password" minLength={8} maxLength={256} value={joinPassword} disabled={!owner || clearPassword} placeholder="至少 8 个字符,留空不修改" onChange={(event) => setJoinPassword(event.target.value)} />
|
||||
<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} />
|
||||
{owner ? (
|
||||
<>
|
||||
{failed ? <p className={styles.error} role="alert">保存失败,请检查名称和设置后重试。</p> : null}
|
||||
<Button variant="primary" disabled={saving || seats < 1 || seats > 64 || Boolean(joinPassword && joinPassword.length < 8)} onClick={() => void save()}>{saving ? '保存中…' : '保存设置'}</Button>
|
||||
</>
|
||||
) : <p className={styles.note}>只有所有者可以修改空间设置。</p>}
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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);
|
||||
const personal = useAccount((s) => s.personal);
|
||||
return <section className={styles.panel} aria-label="我的空间导航">
|
||||
<button className={styles.identity} type="button" onClick={() => navigate('/')} aria-label="账号与设备设置">
|
||||
{personal?.avatar ? <img src={personal.avatar} className={styles.avatar} alt="" /> : <Icon name="desktop" size={20} />}
|
||||
<span>{personal?.displayName || username || '访客'}</span>
|
||||
</button>
|
||||
<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>;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from 'react';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { useDownload } from '@/features/item/download';
|
||||
import { iconOf } from '@/features/item/summary';
|
||||
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, onOpenFile, onDelete }: { id: string; onOpenFile: (id: string) => void; onDelete: (id: string) => void }) {
|
||||
const item = useWorkspace((s) => s.byId[id]);
|
||||
const owner = useWorkspace((s) => s.channel?.role === 'owner');
|
||||
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;
|
||||
const ready = item.state.kind === 'complete';
|
||||
async function save() {
|
||||
if (!item || downloading) return;
|
||||
setDownloading(true); try { await download(item); } finally { setDownloading(false); }
|
||||
}
|
||||
return <article className={`${styles.message} ${self ? styles.mine : ''}`} aria-label={item.kind === 'file' ? `${name} 上传了 ${item.name}` : `${name} 的消息`}>
|
||||
{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}>
|
||||
<header><strong className={styles.senderName} title={sender ? `${sender.username} · ${item.deviceId}` : item.deviceId}>{name}</strong><time>{formatTime(item.ts)}</time>
|
||||
{owner || self ? <Button variant="ghost" icon="trash" label={item.kind === 'file' ? `删除文件消息 ${item.name}` : '删除消息'} onClick={() => onDelete(id)} /> : null}
|
||||
</header>
|
||||
{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)}>
|
||||
<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>
|
||||
</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}
|
||||
</div>
|
||||
</article>;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
.pane {
|
||||
display: flex;
|
||||
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;
|
||||
min-height: var(--bar-h);
|
||||
padding: 0 var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.head span,
|
||||
.empty,
|
||||
.message time {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--space-4);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.mine {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-avatar);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatarFallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
min-width: 0;
|
||||
max-width: min(85%, 560px);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.mine .bubble {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.message header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.senderName {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.message header time {
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
|
||||
.messageText {
|
||||
margin: var(--space-1) 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fileMessage {
|
||||
width: 280px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.fileCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
padding: var(--space-3) 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fileIcon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fileInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fileInfo strong {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fileInfo > span,
|
||||
.delivery {
|
||||
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);
|
||||
}
|
||||
|
||||
.delivery progress {
|
||||
width: 100%;
|
||||
height: var(--space-1);
|
||||
accent-color: var(--busy);
|
||||
}
|
||||
|
||||
.integrity {
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.composer {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.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);
|
||||
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;
|
||||
}
|
||||
|
||||
@media (width <= 899px) {
|
||||
.pane {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { ChatMessage } from './ChatMessage';
|
||||
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 }
|
||||
|
||||
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 fileInput = useRef<HTMLInputElement>(null);
|
||||
const chronological = ids.slice().reverse();
|
||||
|
||||
useEffect(() => { messages.current?.scrollTo({ top: messages.current.scrollHeight }); }, [chronological.length]);
|
||||
|
||||
async 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); }
|
||||
}
|
||||
|
||||
function keyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); void submit(); }
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.pane} aria-label={`${threadName} 聊天`}>
|
||||
<div className={styles.head}><strong>{threadName}</strong><span>频道聊天</span></div>
|
||||
<div className={styles.messages} ref={messages} aria-live="polite">
|
||||
{chronological.length === 0 ? <p className={styles.empty}>在这里发送消息或文件,成员可以看到你的名称和头像。</p> : null}
|
||||
{chronological.map((id) => <ChatMessage key={id} id={id} onOpenFile={onOpenFile} onDelete={onDelete} />)}
|
||||
</div>
|
||||
<form className={styles.composer} onSubmit={(event) => { event.preventDefault(); void 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()} />
|
||||
</form>
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -24,40 +24,87 @@
|
||||
|
||||
.device {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-height: 40px;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.deviceRow,
|
||||
.deviceIdentity,
|
||||
.deviceActions,
|
||||
.deviceEditor,
|
||||
.deviceMetaRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.deviceIdentity {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.deviceMetaRow {
|
||||
min-height: 32px;
|
||||
padding-left: var(--space-6);
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.online,
|
||||
.offline {
|
||||
.current,
|
||||
.deviceMeta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.online {
|
||||
.current {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.online::before {
|
||||
content: "";
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--ok);
|
||||
animation: breathe 2.4s ease-in-out infinite;
|
||||
.deviceMeta {
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.offline {
|
||||
color: var(--text-dim);
|
||||
.deviceEditor {
|
||||
padding-left: var(--space-6);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.deviceActions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.deviceActions > button {
|
||||
min-height: 36px;
|
||||
padding-inline: var(--space-2);
|
||||
}
|
||||
|
||||
.deviceInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.deviceError {
|
||||
padding-left: var(--space-6);
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.link {
|
||||
@@ -97,15 +144,3 @@
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.online::before {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -11,24 +11,93 @@ export function DevicePanel({ qrOpen = false }: { qrOpen?: boolean }) {
|
||||
const devices = useWorkspace((s) => s.devices);
|
||||
const channel = useWorkspace((s) => s.channel);
|
||||
const simulate = useWorkspace((s) => s.simulateConnection);
|
||||
const renameDevice = useWorkspace((s) => s.renameDevice);
|
||||
const releaseDevice = useWorkspace((s) => s.releaseDevice);
|
||||
const [showOwner, setShowOwner] = useState(false);
|
||||
const [showQr, setShowQr] = useState(qrOpen);
|
||||
const online = devices.filter((d) => d.online).length;
|
||||
const [editingId, setEditingId] = useState<string>();
|
||||
const [nameDraft, setNameDraft] = useState('');
|
||||
const [confirmKickId, setConfirmKickId] = useState<string>();
|
||||
const [busyId, setBusyId] = useState<string>();
|
||||
const [deviceError, setDeviceError] = useState<{ id: string; message: string }>();
|
||||
const owner = channel?.role === 'owner';
|
||||
const seatsUsed = channel?.seatsUsed ?? '—';
|
||||
const accountAddress = channel ? `${location.origin}/c/${channel.id}` : '';
|
||||
|
||||
async function saveDeviceName(id: string) {
|
||||
if (busyId) return;
|
||||
setBusyId(id);
|
||||
setDeviceError(undefined);
|
||||
try {
|
||||
await renameDevice(id, nameDraft);
|
||||
setEditingId(undefined);
|
||||
} catch {
|
||||
setDeviceError({ id, message: '设备名称保存失败,请重试。' });
|
||||
} finally {
|
||||
setBusyId(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function kickDevice(id: string) {
|
||||
if (busyId) return;
|
||||
setBusyId(id);
|
||||
setDeviceError(undefined);
|
||||
try {
|
||||
await releaseDevice(id);
|
||||
setConfirmKickId(undefined);
|
||||
} catch {
|
||||
setDeviceError({ id, message: '移除设备失败,请重试。' });
|
||||
} finally {
|
||||
setBusyId(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={styles.panel}>
|
||||
<section>
|
||||
<h2 className={styles.h}>设备 <span className={styles.dim}>{online}/{channel?.seats} 在线</span></h2>
|
||||
<h2 className={styles.h}>设备 <span className={styles.dim}>{seatsUsed}/{channel?.seats} 已加入</span></h2>
|
||||
<ul className={styles.list}>
|
||||
{devices.map((d) => (
|
||||
<li key={d.id} className={styles.device}>
|
||||
<Icon name={d.kind} size={18} />
|
||||
<span className={styles.name}>
|
||||
{d.name}
|
||||
</span>
|
||||
<span className={d.online ? styles.online : styles.offline}>
|
||||
{d.online ? '在线' : d.lastSeen ? formatAgo(d.lastSeen) : '离线'}
|
||||
</span>
|
||||
<div className={styles.deviceRow}>
|
||||
<div className={styles.deviceIdentity}>
|
||||
<Icon name={d.kind} size={18} />
|
||||
<span className={styles.name}>{d.name}</span>
|
||||
</div>
|
||||
{(owner || d.self) && editingId !== d.id ? (
|
||||
<Button variant="ghost" icon="edit" label={`重命名 ${d.name}`} onClick={() => { setEditingId(d.id); setNameDraft(d.name); setDeviceError(undefined); }} />
|
||||
) : null}
|
||||
{owner && !d.self ? (
|
||||
confirmKickId !== d.id ? <Button variant="ghost" icon="trash" label={`移除 ${d.name}`} onClick={() => { setConfirmKickId(d.id); setDeviceError(undefined); }} /> : null
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.deviceMetaRow}>
|
||||
<span className={d.self ? styles.current : styles.deviceMeta}>
|
||||
{d.self ? '当前设备' : d.joinedAt ? `加入于${formatAgo(d.joinedAt)}` : '已加入'}
|
||||
</span>
|
||||
{owner && confirmKickId === d.id ? (
|
||||
<span className={styles.deviceActions}>
|
||||
<Button variant="ghost" disabled={busyId === d.id} onClick={() => void kickDevice(d.id)}>{busyId === d.id ? '移除中' : '确认移除'}</Button>
|
||||
<Button variant="ghost" disabled={busyId === d.id} onClick={() => setConfirmKickId(undefined)}>取消</Button>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{editingId === d.id ? (
|
||||
<div className={styles.deviceEditor}>
|
||||
<input
|
||||
className={styles.deviceInput}
|
||||
type="text"
|
||||
maxLength={48}
|
||||
value={nameDraft}
|
||||
onChange={(event) => setNameDraft(event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter') void saveDeviceName(d.id); }}
|
||||
aria-label={`设备名称 ${d.name}`}
|
||||
/>
|
||||
<Button variant="ghost" disabled={busyId === d.id || !nameDraft.trim()} onClick={() => void saveDeviceName(d.id)}>{busyId === d.id ? '保存中' : '保存'}</Button>
|
||||
<Button variant="ghost" onClick={() => setEditingId(undefined)}>取消</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{deviceError?.id === d.id ? <span className={styles.deviceError} role="alert">{deviceError.message}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -36,6 +105,13 @@ export function DevicePanel({ qrOpen = false }: { qrOpen?: boolean }) {
|
||||
|
||||
<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>
|
||||
@@ -58,8 +134,9 @@ export function DevicePanel({ qrOpen = false }: { qrOpen?: boolean }) {
|
||||
</div>
|
||||
) : null}
|
||||
<p className={styles.note}>
|
||||
持有链接即可访问,并能读取此频道的全部历史内容。不隐藏 IP 与访问时间。
|
||||
持有链接并符合空间加入规则后,可读取空间历史内容。不隐藏 IP 与访问时间。
|
||||
</p>
|
||||
</> : null}
|
||||
</section>
|
||||
|
||||
{import.meta.env.DEV ? (
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { DragEvent, MouseEvent } from 'react';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { iconOf } from '@/features/item/summary';
|
||||
import styles from './FileExplorer.module.css';
|
||||
|
||||
interface Props {
|
||||
id: string; selected: boolean; checked: boolean; enabled: boolean;
|
||||
onSelect: (id: string, event: MouseEvent) => void; onOpen: (id: string) => void; onToggle: (id: string) => void;
|
||||
onMenu: (id: string, x: number, y: number) => void;
|
||||
onHover: (id: string, rect: DOMRect) => void; onLeave: () => void;
|
||||
onDrag: (id: string, event: DragEvent) => void; onDragEnd: () => void;
|
||||
}
|
||||
export function ExplorerFileRow(props: Props) {
|
||||
const { id } = props;
|
||||
const item = useWorkspace((s) => s.byId[id]);
|
||||
if (!item || item.kind !== 'file') return null;
|
||||
const available = item.state.kind !== 'receiving' && item.state.kind !== 'tampered';
|
||||
return <tr className={props.checked ? styles.selected : undefined} aria-selected={props.checked}
|
||||
onClick={(event) => props.onSelect(id, event)} onDoubleClick={() => { if (available) props.onOpen(id); }}
|
||||
onContextMenu={(event) => { event.preventDefault(); event.stopPropagation(); props.onMenu(id, event.clientX, event.clientY); }}
|
||||
onMouseEnter={(event) => props.onHover(id, event.currentTarget.getBoundingClientRect())} onMouseLeave={props.onLeave}
|
||||
draggable={props.enabled && available} onDragStart={(event) => props.onDrag(id, event)} onDragEnd={props.onDragEnd}>
|
||||
<td><input type="checkbox" aria-label={`选择文件 ${item.name}`} checked={props.checked} onChange={() => props.onToggle(id)} onClick={(event) => event.stopPropagation()} /></td>
|
||||
<td><button className={styles.name} type="button" onKeyDown={(event) => { if (event.key === 'Enter') { event.stopPropagation(); if (available) props.onOpen(id); }
|
||||
if (event.key === 'ContextMenu' || event.shiftKey && event.key === 'F10') { event.preventDefault(); const rect = event.currentTarget.getBoundingClientRect(); props.onMenu(id, rect.left, rect.bottom); } }}>
|
||||
<Icon name={item.state.kind === 'tampered' ? 'warn' : iconOf(item)} size={18} /><span>{item.name}</span>
|
||||
</button>{item.state.kind === 'receiving' ? <span className={styles.progress}>{formatTransferProgress(item.state)}</span> : null}
|
||||
{item.state.kind === 'tampered' ? <span className={styles.error}>内容校验失败</span> : null}</td>
|
||||
<td className={styles.numeric}>{formatSize(item.size)}</td><td className={styles.date}>{formatTime(item.ts)}</td>
|
||||
<td><Button variant="ghost" icon="more" label={`文件操作 ${item.name}`} onClick={(event) => { event.stopPropagation(); const rect = event.currentTarget.getBoundingClientRect(); props.onMenu(id, rect.right, rect.bottom); }} /></td>
|
||||
</tr>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { DragEvent } from 'react';
|
||||
import type { FolderInfo } from '@/core-bridge/types';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import styles from './FileExplorer.module.css';
|
||||
|
||||
interface Props {
|
||||
folder: FolderInfo; selected: boolean; dropping: boolean; enabled: boolean;
|
||||
onSelect: () => void; onOpen: () => void; onRename: () => void; onMenu: (x: number, y: number) => void;
|
||||
drop: { onDragOver: (e: DragEvent) => void; onDragLeave: (e: DragEvent) => void; onDrop: (e: DragEvent) => void };
|
||||
}
|
||||
export function ExplorerFolderRow(props: Props) {
|
||||
return <tr className={`${props.selected ? styles.selected : ''} ${props.dropping ? styles.dropTarget : ''}`} {...props.drop} data-drop-folder={props.folder.id}
|
||||
onClick={props.onSelect} onDoubleClick={props.onOpen} onContextMenu={(event) => { event.preventDefault(); event.stopPropagation(); props.onMenu(event.clientX, event.clientY); }}>
|
||||
<td /><td><button className={styles.name} type="button" onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') { event.preventDefault(); event.stopPropagation(); props.onOpen(); }
|
||||
if (event.key === 'F2' && props.enabled) { event.preventDefault(); props.onRename(); }
|
||||
}}><Icon name="folder" size={18} /><span>{props.folder.name}</span></button></td>
|
||||
<td className={styles.numeric}>文件夹</td><td className={styles.date}>—</td>
|
||||
<td><Button variant="ghost" icon="more" label={`文件夹操作 ${props.folder.name}`} onClick={(event) => { event.stopPropagation(); const rect = event.currentTarget.getBoundingClientRect(); props.onMenu(rect.right, rect.bottom); }} /></td>
|
||||
</tr>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { useBlobUrl } from '@/features/item/useBlobUrl';
|
||||
import { formatSize, formatTime } from '@/lib/format';
|
||||
import { fitFloatingPoint } from '@/lib/explorer';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { iconOf } from '@/features/item/summary';
|
||||
import styles from './FileExplorer.module.css';
|
||||
|
||||
export function ExplorerHover({ id, point }: { id: string; point: { x: number; y: number } }) {
|
||||
const item = useWorkspace((s) => s.byId[id]);
|
||||
const encrypted = useWorkspace((s) => s.channel?.mode === 'enc');
|
||||
if (!item || item.kind !== 'file') return null;
|
||||
const position = fitFloatingPoint(point.x, point.y, 280, 300, innerWidth, innerHeight);
|
||||
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.deviceId}</p>
|
||||
{preview ? <HoverContent id={id} image={item.preview === 'image'} /> : <p>{item.gated ? '受限文件:悬停不读取内容,点击预览后再解密。' : item.state.kind === 'tampered' ? '内容校验失败,已拒绝预览。' : '双击文件打开完整预览。'}</p>}
|
||||
</aside>, document.body);
|
||||
}
|
||||
|
||||
function HoverContent({ id, image }: { id: string; image: boolean }) {
|
||||
const blob = useBlobUrl(id);
|
||||
const [text, setText] = useState('');
|
||||
useEffect(() => {
|
||||
if (image || blob.status !== 'ready') return;
|
||||
let active = true;
|
||||
void blob.blob.slice(0, 4096).text().then((value) => { if (active) setText(value); });
|
||||
return () => { active = false; };
|
||||
}, [blob, image]);
|
||||
if (blob.status === 'loading') return <p>正在生成预览…</p>;
|
||||
if (blob.status === 'error') return <p>预览不可用,可双击文件查看。</p>;
|
||||
return image ? <img className={styles.hoverImage} src={blob.url} alt="文件缩略预览" /> : <pre className={styles.hoverText}>{text}</pre>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useState } from 'react';
|
||||
import type { FolderInfo } from '@/core-bridge/types';
|
||||
import { Sheet } from '@/ui/Sheet';
|
||||
import { Button } from '@/ui/Button';
|
||||
import styles from './FileExplorer.module.css';
|
||||
|
||||
export function ExplorerMoveDialog({ folders, count, currentFolderId, onMove, onClose }: {
|
||||
folders: FolderInfo[]; count: number; currentFolderId?: string; onMove: (id?: string) => Promise<void>; onClose: () => void;
|
||||
}) {
|
||||
const [target, setTarget] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
async function move() {
|
||||
setBusy(true); setError('');
|
||||
try { await onMove(target || undefined); onClose(); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : '移动失败'); setBusy(false); }
|
||||
}
|
||||
return <Sheet title={`移动 ${count} 个文件`} onClose={onClose} actions={<Button variant="primary" disabled={busy || (target || undefined) === currentFolderId} onClick={() => { void move(); }}>{busy ? '移动中…' : '移动'}</Button>}>
|
||||
<label className={styles.moveLabel}>目标文件夹<select aria-label="目标文件夹" value={target} onChange={(event) => setTarget(event.target.value)}><option value="">根目录</option>
|
||||
{folders.map((folder) => <option key={folder.id} value={folder.id}>{folder.name}</option>)}
|
||||
</select></label>{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
</Sheet>;
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
.explorer {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.heading,
|
||||
.toolbar,
|
||||
.breadcrumb,
|
||||
.batch,
|
||||
.status,
|
||||
.edit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.heading {
|
||||
min-height: var(--bar-h);
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.heading span,
|
||||
.status,
|
||||
.empty span {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.batch {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
min-height: 40px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.breadcrumb span,
|
||||
.breadcrumb button,
|
||||
.name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.breadcrumb button,
|
||||
.table button {
|
||||
padding: var(--space-1);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: var(--space-2) var(--space-1);
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--surface-2);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.table th:first-child {
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.table th:nth-child(3) {
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
.table th:nth-child(4) {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.table th:last-child {
|
||||
width: 38px;
|
||||
}
|
||||
|
||||
.table tbody tr[draggable="true"] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.dropTarget {
|
||||
outline: 2px solid var(--busy);
|
||||
outline-offset: -2px;
|
||||
background: var(--busy-soft);
|
||||
}
|
||||
|
||||
.hover {
|
||||
position: fixed;
|
||||
z-index: 70;
|
||||
width: 280px;
|
||||
max-width: calc(100vw - 16px);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow-pop);
|
||||
pointer-events: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hoverTitle {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hover p {
|
||||
margin: var(--space-2) 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.hoverImage {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 150px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.hoverText {
|
||||
max-height: 150px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
white-space: pre-wrap;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.moveLabel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.moveLabel select {
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.name {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.name svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.name span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table tbody tr:hover,
|
||||
.selected {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.numeric {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: var(--space-6);
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status {
|
||||
border-top: 1px solid var(--line);
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.status span {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.edit input,
|
||||
.batch select {
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.edit input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.progress {
|
||||
display: block;
|
||||
padding: 0 var(--space-1);
|
||||
color: var(--busy);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (width <= 1100px) {
|
||||
.date {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useRef, useState, type FormEvent } from 'react';
|
||||
import type { FolderInfo } from '@/core-bridge/types';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { ExplorerFileRow } from './ExplorerFileRow';
|
||||
import { ExplorerFolderRow } from './ExplorerFolderRow';
|
||||
import { ExplorerHover } from './ExplorerHover';
|
||||
import { ExplorerMoveDialog } from './ExplorerMoveDialog';
|
||||
import { useExplorerInteractions } from './useExplorerInteractions';
|
||||
import { ContextMenu } from '@/ui/ContextMenu';
|
||||
import styles from './FileExplorer.module.css';
|
||||
|
||||
interface Props {
|
||||
scope: string;
|
||||
threadId?: string;
|
||||
threadName: string;
|
||||
folders: FolderInfo[];
|
||||
folderId?: string;
|
||||
ids: string[];
|
||||
selectedId?: string;
|
||||
checked: ReadonlySet<string>;
|
||||
enabled: boolean;
|
||||
onFolder: (id?: string) => void;
|
||||
onSelect: (id?: string) => void;
|
||||
onOpen: (id: string) => void;
|
||||
onToggle: (id: string) => void;
|
||||
onSelection: (ids: ReadonlySet<string>) => void;
|
||||
onFiles: (files: File[], folderId?: string) => void;
|
||||
onCreateFolder: (name: string) => Promise<void>;
|
||||
onRenameFolder: (id: string, name: string) => Promise<void>;
|
||||
onDownloadItems: (ids: string[]) => Promise<void>;
|
||||
onDeleteItems: (ids: string[]) => Promise<void>;
|
||||
onMoveItems: (ids: string[], folderId?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function FileExplorer(props: Props) {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const [sort, setSort] = useState<'name' | 'size' | 'date'>('name');
|
||||
const [edit, setEdit] = useState<string>();
|
||||
const [name, setName] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [selectedFolder, setSelectedFolder] = useState<string>();
|
||||
const byId = useWorkspace.getState().byId;
|
||||
const ids = props.ids.slice().sort((a, b) => {
|
||||
const first = byId[a]; const second = byId[b];
|
||||
if (first?.kind !== 'file' || second?.kind !== 'file') return 0;
|
||||
return sort === 'size' ? second.size - first.size : sort === 'date' ? second.ts - first.ts : first.name.localeCompare(second.name, 'zh-CN');
|
||||
});
|
||||
const path: FolderInfo[] = [];
|
||||
const seen = new Set<string>();
|
||||
let current = props.folders.find((folder) => folder.id === props.folderId);
|
||||
while (current && !seen.has(current.id)) {
|
||||
seen.add(current.id); path.unshift(current); current = props.folders.find((folder) => folder.id === current?.parentId);
|
||||
}
|
||||
const children = props.folders.filter((folder) => folder.parentId === props.folderId).sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
|
||||
function beginFolder(id?: string) { const folder = props.folders.find((row) => row.id === id); setEdit(id ?? 'create'); setName(folder?.name ?? ''); setError(''); }
|
||||
const interaction = useExplorerInteractions({ ...props, ids, onDownload: props.onDownloadItems, onDelete: props.onDeleteItems, onMove: props.onMoveItems,
|
||||
onRenameFolder: (id) => beginFolder(id), onCreateFolder: () => beginFolder(), onUpload: () => input.current?.click(), onFolder: navigateFolder });
|
||||
function navigateFolder(id?: string) { interaction.dismiss(); setSelectedFolder(undefined); props.onFolder(id); }
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault(); if (!edit || busy || !name.trim()) return;
|
||||
setBusy(true); setError('');
|
||||
try { if (edit === 'create') await props.onCreateFolder(name); else await props.onRenameFolder(edit, name); setEdit(undefined); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : '目录操作失败'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
return <section className={styles.explorer} aria-label="频道文件管理器" tabIndex={0} onKeyDown={interaction.keyDown}>
|
||||
<header className={styles.heading}><strong>频道文件</strong><span>{props.threadName}</span></header>
|
||||
<div className={styles.toolbar}>
|
||||
<input type="file" multiple hidden ref={input} onChange={(event) => { if (event.target.files?.length) props.onFiles([...event.target.files], props.folderId); event.target.value = ''; }} />
|
||||
<Button icon="share" disabled={!props.enabled} onClick={() => input.current?.click()}>上传文件</Button>
|
||||
<Button variant="ghost" icon="folder" disabled={!props.enabled || busy} onClick={() => beginFolder()}>新建文件夹</Button>
|
||||
<Button variant="ghost" icon="eye" disabled={!props.selectedId} onClick={() => { if (props.selectedId) props.onOpen(props.selectedId); }}>预览</Button>
|
||||
</div>
|
||||
<nav className={styles.breadcrumb} aria-label="文件夹路径"><Button variant="ghost" icon="chevronLeft" label="返回上级文件夹" disabled={!props.folderId} onClick={() => navigateFolder(path[path.length - 1]?.parentId)} />
|
||||
<button type="button" data-drop-folder="root" className={interaction.dropFolder === '__root' ? styles.dropTarget : ''} {...interaction.dropHandlers()} onClick={() => navigateFolder()}><Icon name="folder" size={16} />根目录</button>
|
||||
{path.map((folder) => <span key={folder.id}><Icon name="chevronRight" size={12} /><button type="button" className={interaction.dropFolder === folder.id ? styles.dropTarget : ''} {...interaction.dropHandlers(folder.id)} onClick={() => navigateFolder(folder.id)}>{folder.name}</button></span>)}
|
||||
</nav>
|
||||
{edit ? <form className={styles.edit} onSubmit={(event) => { void submit(event); }}><input autoFocus maxLength={80} aria-label="文件夹名称" value={name} onChange={(event) => setName(event.target.value)} />
|
||||
<Button type="submit" disabled={busy || !name.trim()}>{edit === 'create' ? '创建' : '保存'}</Button><Button variant="ghost" onClick={() => setEdit(undefined)}>取消</Button></form> : null}
|
||||
<div className={`${styles.scroll} ${interaction.dropFolder === (props.folderId ?? '__root') ? styles.dropTarget : ''}`} {...interaction.dropHandlers(props.folderId)} onContextMenu={interaction.blankMenu}
|
||||
onClick={(event) => { if (!(event.target as HTMLElement).closest('tr,button,input')) { props.onSelection(new Set()); props.onSelect(undefined); setSelectedFolder(undefined); event.currentTarget.focus(); } }} tabIndex={-1}>
|
||||
<table className={styles.table}><thead><tr><th aria-label="选择" /><th><button type="button" onClick={() => setSort('name')}>名称{sort === 'name' ? ' ↑' : ''}</button></th>
|
||||
<th><button type="button" onClick={() => setSort('size')}>大小{sort === 'size' ? ' ↓' : ''}</button></th><th className={styles.date}><button type="button" onClick={() => setSort('date')}>时间{sort === 'date' ? ' ↓' : ''}</button></th><th aria-label="操作" /></tr></thead>
|
||||
<tbody>{children.map((folder) => <ExplorerFolderRow key={folder.id} folder={folder} selected={folder.id === selectedFolder} enabled={props.enabled}
|
||||
dropping={interaction.dropFolder === folder.id} drop={interaction.dropHandlers(folder.id)} onOpen={() => navigateFolder(folder.id)}
|
||||
onSelect={() => { interaction.dismiss(); setSelectedFolder(folder.id); props.onSelection(new Set()); props.onSelect(undefined); }} onRename={() => beginFolder(folder.id)}
|
||||
onMenu={(x, y) => { setSelectedFolder(folder.id); props.onSelection(new Set()); props.onSelect(undefined); interaction.showFolderMenu(folder.id, folder.name, x, y); }} />)}
|
||||
{ids.map((id) => <ExplorerFileRow key={id} id={id} selected={id === props.selectedId} checked={props.checked.has(id)} enabled={props.enabled}
|
||||
onSelect={(id, event) => { setSelectedFolder(undefined); interaction.select(id, event); }} onOpen={props.onOpen} onToggle={props.onToggle} onMenu={(id, x, y) => { setSelectedFolder(undefined); interaction.showFileMenu(id, x, y); }}
|
||||
onHover={interaction.hoverFile} onLeave={interaction.leave} onDrag={interaction.drag} onDragEnd={interaction.dismiss} />)}</tbody>
|
||||
</table>{ids.length === 0 && children.length === 0 ? <div className={styles.empty}><Icon name="folder" size={28} /><p>此文件夹还没有文件</p><span>上传文件或将文件拖到窗口中。</span></div> : null}</div>
|
||||
{props.checked.size > 0 ? <div className={styles.batch}><span>已选 {props.checked.size} 项</span><Button variant="ghost" icon="download" onClick={() => { void props.onDownloadItems([...props.checked]); }}>下载所选</Button>
|
||||
{props.enabled ? <select aria-label="移动所选文件到文件夹" value="__choose" onChange={(event) => {
|
||||
const target = event.target.value; if (target !== '__choose') void props.onMoveItems([...props.checked], target === '__root' ? undefined : target).catch((cause: unknown) => setError(cause instanceof Error ? cause.message : '移动失败'));
|
||||
}}><option value="__choose">移动到…</option><option value="__root">根目录</option>{props.folders.map((folder) => <option value={folder.id} key={folder.id}>{folder.name}</option>)}</select> : null}
|
||||
</div> : null}
|
||||
{error || interaction.error ? <p className={styles.error} role="alert">{error || interaction.error}</p> : null}
|
||||
<footer className={styles.status}>{children.length} 个文件夹 · {ids.length} 个文件<span>双击打开 · 右键操作 · 拖动移动</span></footer>
|
||||
{interaction.menu ? <ContextMenu point={interaction.menu} actions={interaction.actions} onClose={interaction.dismiss} /> : null}
|
||||
{interaction.hover && ids.includes(interaction.hover.id) && !interaction.menu ? <ExplorerHover key={interaction.hover.id} {...interaction.hover} /> : null}
|
||||
{interaction.moveIds ? <ExplorerMoveDialog folders={props.folders} count={interaction.moveIds.length} currentFolderId={props.folderId} onClose={() => interaction.setMoveIds(undefined)}
|
||||
onMove={(folderId) => props.onMoveItems(interaction.moveIds!, folderId)} /> : null}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useRef } from 'react';
|
||||
import styles from './Workspace.module.css';
|
||||
|
||||
export function FilePaneResize({ width, onWidth }: { width?: number; onWidth: (width: number) => void }) {
|
||||
const drag = useRef<{ right: number; maximum: number } | undefined>(undefined);
|
||||
function bounds(element: HTMLElement) {
|
||||
const parent = element.parentElement!;
|
||||
const rect = parent.getBoundingClientRect();
|
||||
const navigation = parent.querySelector<HTMLElement>('[data-space-nav]');
|
||||
return { right: rect.right, maximum: Math.max(320, rect.width - (navigation?.offsetWidth ?? 232) - 266) };
|
||||
}
|
||||
return <div className={styles.resize} role="separator" aria-label="调整文件区宽度" aria-orientation="vertical" tabIndex={0}
|
||||
aria-valuemin={320} aria-valuemax={Math.max(320, innerWidth - (innerWidth <= 1100 ? 200 : 232) - 266)} aria-valuenow={width ?? Math.max(320, Math.round(innerWidth * 0.36))}
|
||||
onPointerDown={(event) => { event.preventDefault(); drag.current = bounds(event.currentTarget); event.currentTarget.setPointerCapture(event.pointerId); }}
|
||||
onPointerMove={(event) => { if (drag.current) onWidth(Math.min(drag.current.maximum, Math.max(320, drag.current.right - event.clientX))); }}
|
||||
onPointerUp={(event) => { drag.current = undefined; if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }}
|
||||
onPointerCancel={() => { drag.current = undefined; }}
|
||||
onKeyDown={(event) => {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault(); const limit = bounds(event.currentTarget);
|
||||
const current = event.currentTarget.parentElement?.querySelector<HTMLElement>('[data-file-pane]')?.offsetWidth ?? 320;
|
||||
onWidth(event.key === 'Home' ? 320 : event.key === 'End' ? limit.maximum : Math.min(limit.maximum, Math.max(320, current + (event.key === 'ArrowLeft' ? 24 : -24))));
|
||||
}} />;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import styles from './Notepad.module.css';
|
||||
|
||||
const SAVE_DELAY_MS = 400;
|
||||
|
||||
/** 共享便签:频道唯一的快照槽,最后写入者胜(protocol §9)。默认折叠,不占首屏 */
|
||||
/** 共享便签:空间唯一的快照槽,最后写入者胜(protocol §9)。默认折叠,不占首屏 */
|
||||
export function Notepad({ defaultOpen = false }: { defaultOpen?: boolean }) {
|
||||
const snapshot = useWorkspace((s) => s.snapshot);
|
||||
const setSnapshot = useWorkspace((s) => s.setSnapshot);
|
||||
|
||||
@@ -25,11 +25,9 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--ok);
|
||||
.countLabel {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.open {
|
||||
|
||||
@@ -11,8 +11,7 @@ interface Props {
|
||||
|
||||
/** 电脑右侧的设备与分享:低频,默认收成窄栏,需要时展开 */
|
||||
export function SideRail({ open, qrOpen, onToggle }: Props) {
|
||||
const online = useWorkspace((s) => s.devices.filter((d) => d.online).length);
|
||||
const seats = useWorkspace((s) => s.channel?.seats);
|
||||
const channel = useWorkspace((s) => s.channel);
|
||||
|
||||
if (open) {
|
||||
return (
|
||||
@@ -29,9 +28,9 @@ export function SideRail({ open, qrOpen, onToggle }: Props) {
|
||||
return (
|
||||
<aside className={styles.rail}>
|
||||
<Button variant="ghost" icon="chevronLeft" label="展开设备与分享" onClick={onToggle} />
|
||||
<span className={styles.count} title="在线设备">
|
||||
<span className={styles.dot} />
|
||||
{online}/{seats}
|
||||
<span className={styles.count} title="已加入席位 / 总席位">
|
||||
{channel?.seatsUsed ?? '—'}/{channel?.seats ?? '—'}
|
||||
<span className={styles.countLabel}>席位</span>
|
||||
</span>
|
||||
<Button variant="ghost" icon="share" label="分享" onClick={onToggle} />
|
||||
</aside>
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.channels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
max-height: 28vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.group {
|
||||
min-width: 0;
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.groupHeading,
|
||||
.groupActions,
|
||||
.groupToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.groupToggle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: var(--space-1) 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.groupToggle span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.groupToggle svg,
|
||||
.groupActions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.groupToggle small {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.groupLabel {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.channelEditor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.channelEditor label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.channelEditor input,
|
||||
.channelEditor select {
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: var(--bar-h);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: var(--bar-h);
|
||||
height: var(--bar-h);
|
||||
border-radius: var(--radius-md);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.heading,
|
||||
.folderHeading,
|
||||
.threadRow,
|
||||
.folderRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.heading,
|
||||
.folderHeading {
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.folderHeading {
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.threads,
|
||||
.folders {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.threadRow,
|
||||
.folderRow {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.folderRow {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.thread,
|
||||
.folder {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.thread span,
|
||||
.folder span:last-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.thread small {
|
||||
margin-inline-start: auto;
|
||||
color: var(--text-dim);
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.folder {
|
||||
padding-inline-start: var(--space-2);
|
||||
}
|
||||
|
||||
.thread:hover,
|
||||
.folder:hover,
|
||||
.selected {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.empty,
|
||||
.hint,
|
||||
.error {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.edit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.edit input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.folders ul {
|
||||
flex: 1 0 100%;
|
||||
margin: 0;
|
||||
padding-inline-start: var(--space-4);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
@media (width <= 899px) {
|
||||
.panel {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.threads {
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.thread {
|
||||
flex: 0 0 auto;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.folders {
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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 styles from './ThreadSidebar.module.css';
|
||||
|
||||
interface Props {
|
||||
threads: ThreadInfo[];
|
||||
groups: ChannelGroupInfo[];
|
||||
selectedThreadId?: string;
|
||||
enabled: boolean;
|
||||
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>;
|
||||
onGroupThread: (id: string, groupId?: string) => Promise<void>;
|
||||
}
|
||||
type Editor = { kind: 'channel' | 'group'; id?: string; groupId?: string };
|
||||
|
||||
export function ThreadSidebar(props: Props) {
|
||||
const [edit, setEdit] = useState<Editor>();
|
||||
const [name, setName] = useState('');
|
||||
const [groupId, setGroupId] = useState('');
|
||||
const [busy, setBusy] = 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(''); }
|
||||
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 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);
|
||||
} 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><span className={styles.groupActions}>
|
||||
<Button variant="ghost" icon="folder" label="新建频道分组" disabled={!props.enabled || busy} onClick={() => begin({ kind: 'group' })} />
|
||||
<Button variant="ghost" icon="edit" label="新建频道" disabled={!props.enabled || busy} onClick={() => begin({ kind: 'channel' })} />
|
||||
</span></div>
|
||||
{ungrouped.length ? <div className={styles.groupLabel}>未分组</div> : null}
|
||||
<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)} />
|
||||
{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><small>{threads.length}</small>
|
||||
</button>{props.enabled ? <span className={styles.groupActions}>
|
||||
<Button variant="ghost" icon="edit" label={`重命名分组 ${group.name}`} disabled={busy} onClick={() => begin({ kind: 'group', id: group.id }, group.name)} />
|
||||
<Button variant="ghost" icon="file" label={`在 ${group.name} 新建频道`} disabled={busy} onClick={() => begin({ kind: 'channel', groupId: group.id })} />
|
||||
</span> : null}</div>
|
||||
{!collapsed.has(group.id) ? <ChannelRows threads={threads} selectedId={props.selectedThreadId} enabled={props.enabled} busy={busy} 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 ? <form className={styles.channelEditor} onSubmit={(event) => { void submit(event); }}>
|
||||
<label>{edit.kind === 'group' ? '分组名称' : '频道名称'}<input autoFocus disabled={busy} aria-label={edit.kind === 'group' ? '分组名称' : '频道名称'} maxLength={80} value={name} 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}
|
||||
<div className={styles.groupActions}><Button type="submit" variant="primary" disabled={busy || !name.trim()}>{edit.id ? '保存' : '创建'}</Button>
|
||||
<Button variant="ghost" onClick={() => setEdit(undefined)}>取消</Button></div>
|
||||
</form> : null}
|
||||
{!props.threads.length && !props.groups.length ? <p className={styles.empty}>创建一个频道后开始聊天和上传文件。</p> : null}
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function ChannelRows({ threads, selectedId, enabled, busy, onSelect, onEdit }: {
|
||||
threads: ThreadInfo[]; selectedId?: string; enabled: boolean; busy: boolean; onSelect: (id: string) => void; onEdit: (thread: ThreadInfo) => void;
|
||||
}) {
|
||||
return <ul className={styles.threads}>{threads.map((thread) => <li className={styles.threadRow} 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 && thread.id === selectedId ? <Button variant="ghost" icon="more" disabled={busy} label={`频道设置 ${thread.name}`} onClick={() => onEdit(thread)} /> : null}
|
||||
</li>)}</ul>;
|
||||
}
|
||||
@@ -42,12 +42,18 @@
|
||||
.name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.idSuffix {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.badgeWarn {
|
||||
display: inline-flex;
|
||||
@@ -86,24 +92,9 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.dots {
|
||||
display: inline-flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.on,
|
||||
.off {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.on {
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.off {
|
||||
background: var(--line-strong);
|
||||
.countLabel {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.active {
|
||||
@@ -127,7 +118,7 @@
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* 窄屏(如 iPhone SE、小屏 Android):只留在线点,收起数字与徽标文字 */
|
||||
/* 窄屏(如 iPhone SE、小屏 Android):收起席位数字与徽标文字 */
|
||||
@media (width < 360px) {
|
||||
.count {
|
||||
display: none;
|
||||
|
||||
@@ -10,24 +10,26 @@ interface Props {
|
||||
onBack: () => void;
|
||||
/** 手机上点开设备与分享面板;桌面端面板在右侧栏,不传 */
|
||||
onOpenPanel?: () => void;
|
||||
onOpenSettings: () => void;
|
||||
searchOpen: boolean;
|
||||
/** 有筛选条件在生效 */
|
||||
filtering: boolean;
|
||||
onToggleSearch: () => void;
|
||||
}
|
||||
|
||||
export function TopBar({ onBack, onOpenPanel, searchOpen, filtering, onToggleSearch }: Props) {
|
||||
export function TopBar({ onBack, onOpenPanel, onOpenSettings, searchOpen, filtering, onToggleSearch }: Props) {
|
||||
const channel = useWorkspace((s) => s.channel);
|
||||
const connection = useWorkspace((s) => s.connection);
|
||||
const devices = useWorkspace((s) => s.devices);
|
||||
const online = devices.filter((d) => d.online).length;
|
||||
const seatCount = `${channel?.seatsUsed ?? '—'}/${channel?.seats ?? '—'}`;
|
||||
const raw = channel?.mode === 'raw';
|
||||
|
||||
return (
|
||||
<header className={`${styles.bar} ${raw ? styles.raw : ''}`}>
|
||||
<Button variant="ghost" icon="chevronLeft" label="返回频道列表" onClick={onBack} />
|
||||
<Button variant="ghost" icon="chevronLeft" label="返回空间列表" onClick={onBack} />
|
||||
<div className={styles.id}>
|
||||
<span className={styles.name}>{channel?.id.slice(0, 8)}…</span>
|
||||
<span className={styles.name}>{channel?.name || `${channel?.id.slice(0, 8)}…`}</span>
|
||||
{channel?.name ? <span className={styles.idSuffix}>{channel.id.slice(0, 8)}…</span> : null}
|
||||
{channel?.creatorAccount ? <span className={styles.idSuffix}>创建者 {channel.creatorAccount}</span> : null}
|
||||
{raw ? (
|
||||
<span className={styles.badgeWarn}>明文 · 服务端可见</span>
|
||||
) : (
|
||||
@@ -37,13 +39,8 @@ export function TopBar({ onBack, onOpenPanel, searchOpen, filtering, onToggleSea
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className={styles.presence} onClick={onOpenPanel} disabled={!onOpenPanel}>
|
||||
<span className={styles.dots} aria-hidden="true">
|
||||
{devices.map((d) => (
|
||||
<span key={d.id} className={d.online ? styles.on : styles.off} />
|
||||
))}
|
||||
</span>
|
||||
<span className={styles.count}>
|
||||
{online}/{channel?.seats ?? devices.length}
|
||||
<span className={styles.count} title="已加入席位 / 总席位" aria-label={`已加入 ${channel?.seatsUsed ?? '未知'} 个设备,共 ${channel?.seats ?? '未知'} 个席位`}>
|
||||
{seatCount} <span className={styles.countLabel}>席位</span>
|
||||
</span>
|
||||
</button>
|
||||
<span className={`${styles.conn} ${styles[connection]}`} role="status">
|
||||
@@ -58,6 +55,7 @@ export function TopBar({ onBack, onOpenPanel, searchOpen, filtering, onToggleSea
|
||||
onClick={onToggleSearch}
|
||||
/>
|
||||
<ThemeMenu />
|
||||
<Button variant="ghost" icon="settings" label="空间设置" onClick={onOpenSettings} />
|
||||
{onOpenPanel ? <Button variant="ghost" icon="share" label="设备与分享" onClick={onOpenPanel} /> : null}
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.check input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.field select {
|
||||
min-height: 40px;
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState } from 'react';
|
||||
import type { FileUploadOptions } from '@/core-bridge/types';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Sheet } from '@/ui/Sheet';
|
||||
import styles from './UploadOptionsSheet.module.css';
|
||||
|
||||
const LIMITS = [0, 1, 3, 5, 10, 20];
|
||||
|
||||
interface Props {
|
||||
encrypted?: boolean;
|
||||
files: File[];
|
||||
defaultMaxDownloads: number;
|
||||
onClose: () => void;
|
||||
onUpload: (options: FileUploadOptions) => void;
|
||||
}
|
||||
|
||||
export function UploadOptionsSheet({ files, defaultMaxDownloads, onClose, onUpload, encrypted = true }: Props) {
|
||||
const [directPreview, setDirectPreview] = useState(true);
|
||||
const [maxDownloads, setMaxDownloads] = useState(defaultMaxDownloads);
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
title={`上传 ${files.length} 个文件`}
|
||||
onClose={onClose}
|
||||
actions={<Button variant="primary" onClick={() => onUpload({ directPreview, maxDownloads })}>继续上传</Button>}
|
||||
>
|
||||
<div className={styles.form}>
|
||||
<label className={styles.check}>
|
||||
<input type="checkbox" checked={directPreview} onChange={(e) => setDirectPreview(e.target.checked)} />
|
||||
<span>单帧文件加入空间后可直接预览</span>
|
||||
</label>
|
||||
{directPreview ? (
|
||||
<p className={styles.note}>图片、文本等单帧文件会随空间同步,加入者可直接预览且不计下载次数。大文件必须分帧,仍会受空间下载上限限制。</p>
|
||||
) : (
|
||||
<>
|
||||
<label className={styles.field}>
|
||||
<span>每个文件最多下载次数</span>
|
||||
<select value={maxDownloads} onChange={(e) => setMaxDownloads(Number(e.target.value))}>
|
||||
{Array.from(new Set([...LIMITS, defaultMaxDownloads])).sort((a, b) => a - b).map((n) => (
|
||||
<option key={n} value={n}>{n === 0 ? '不限' : `${n} 次`}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className={styles.note}>预览和下载内容会消耗次数。{encrypted ? '文件名与类型通过加密目录同步,不消耗次数。' : '此空间未启用端到端加密,服务端能看到文件名与类型。'}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +1,94 @@
|
||||
/* 电脑:左列表 | 内联预览 | 右侧窄栏 */
|
||||
.desktop {
|
||||
--nav-width: 232px;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 380px minmax(0, 1fr) auto;
|
||||
grid-template-columns: var(--nav-width) minmax(260px, 1fr) 6px clamp(320px, var(--files-width, 36%), calc(100% - var(--nav-width) - 266px));
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.desktop > header {
|
||||
grid-column: 1 / 3;
|
||||
grid-column: 1 / -1;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.list {
|
||||
.navigation,
|
||||
.center,
|
||||
.files,
|
||||
.mobileContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.scroll {
|
||||
flex: 1;
|
||||
.navigation {
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--line);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.batch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: linear-gradient(90deg, var(--busy-soft), transparent);
|
||||
font-size: 13px;
|
||||
.files {
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.resize {
|
||||
background: var(--line);
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.resize:hover,
|
||||
.resize:focus-visible {
|
||||
background: var(--busy);
|
||||
}
|
||||
|
||||
/* 手机:整列滚动,顶栏与底部动作栏 sticky,内容从它们下方滑过 */
|
||||
.mobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
padding-inline: env(safe-area-inset-left) env(safe-area-inset-right);
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior-y: contain;
|
||||
.mobile .navigation {
|
||||
max-height: 30dvh;
|
||||
flex-shrink: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.grow {
|
||||
.mobileContent {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
flex: 1;
|
||||
min-height: 44px;
|
||||
border: 0;
|
||||
background: var(--surface);
|
||||
color: var(--text-dim);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tabs button[aria-pressed="true"] {
|
||||
border-bottom: 2px solid var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.legacy {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.drop {
|
||||
@@ -80,3 +116,9 @@
|
||||
color: var(--danger);
|
||||
border: 1px solid var(--danger);
|
||||
}
|
||||
|
||||
@media (width >= 900px) and (width <= 1100px) {
|
||||
.desktop {
|
||||
--nav-width: 200px;
|
||||
}
|
||||
}
|
||||
@@ -1,208 +1,132 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState, type DragEvent } from 'react';
|
||||
import { lazy, Suspense, useCallback, useState, type CSSProperties, type DragEvent } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { channelPath, navigate, sharePath } from '@/app/navigation';
|
||||
import { useDownload } from '@/features/item/download';
|
||||
import { isFiltering, matchesFilter, NO_FILTER } from '@/lib/filter';
|
||||
import type { ClipboardPayload } from '@/lib/clipboard';
|
||||
import { matchesFilter, isFiltering, NO_FILTER } from '@/lib/filter';
|
||||
import { useGlobalPaste } from '@/lib/useGlobalPaste';
|
||||
import { useMediaQuery } from '@/lib/useMediaQuery';
|
||||
import { useChannels } from '@/store/channels';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { Button } from '@/ui/Button';
|
||||
import type { FileUploadOptions } from '@/core-bridge/types';
|
||||
import type { ClipboardPayload } from '@/lib/clipboard';
|
||||
import { Sheet } from '@/ui/Sheet';
|
||||
import { ActionBar } from './ActionBar';
|
||||
import { ComposeSheet } from './ComposeSheet';
|
||||
import { DevicePanel } from './DevicePanel';
|
||||
import { FilterBar } from './FilterBar';
|
||||
import { ItemList } from './ItemList';
|
||||
import { LatestCard } from './LatestCard';
|
||||
import { Notepad } from './Notepad';
|
||||
import { SideRail } from './SideRail';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { ChannelSwitcher } from './ChannelSwitcher';
|
||||
import { ThreadSidebar } from './ThreadSidebar';
|
||||
import { ChatPane } from './ChatPane';
|
||||
import { FileExplorer } from './FileExplorer';
|
||||
import { FilePaneResize } from './FilePaneResize';
|
||||
import { TopBar } from './TopBar';
|
||||
import { UndoBar } from './UndoBar';
|
||||
import { FilterBar } from './FilterBar';
|
||||
import { Notepad } from './Notepad';
|
||||
import { DevicePanel } from './DevicePanel';
|
||||
import { ChannelSettingsSheet } from './ChannelSettingsSheet';
|
||||
import { UploadOptionsSheet } from './UploadOptionsSheet';
|
||||
import styles from './Workspace.module.css';
|
||||
|
||||
const PreviewSheet = lazy(() => import('@/features/item/PreviewSheet'));
|
||||
const PreviewPane = lazy(() => import('@/features/item/PreviewPane'));
|
||||
const ScanSheet = lazy(() => import('@/features/scan/ScanSheet').then((m) => ({ default: m.ScanSheet })));
|
||||
|
||||
const UNDO_MS = 5000;
|
||||
|
||||
export function Workspace() {
|
||||
export function Workspace({ onCreateChannel }: { onCreateChannel: () => void }) {
|
||||
const desktop = useMediaQuery('(width >= 900px) and (height >= 500px)');
|
||||
const sendFiles = useWorkspace((s) => s.sendFiles);
|
||||
const channel = useWorkspace((s) => s.channel);
|
||||
const threads = useWorkspace((s) => s.threads);
|
||||
const groups = useWorkspace((s) => s.groups);
|
||||
const createGroup = useWorkspace((s) => s.createGroup);
|
||||
const renameGroup = useWorkspace((s) => s.renameGroup);
|
||||
const groupThread = useWorkspace((s) => s.groupThread);
|
||||
const folders = useWorkspace((s) => s.folders);
|
||||
const sendText = useWorkspace((s) => s.sendText);
|
||||
const sendFiles = useWorkspace((s) => s.sendFiles);
|
||||
const createThread = useWorkspace((s) => s.createThread);
|
||||
const renameThread = useWorkspace((s) => s.renameThread);
|
||||
const createFolder = useWorkspace((s) => s.createFolder);
|
||||
const renameFolder = useWorkspace((s) => s.renameFolder);
|
||||
const moveItems = useWorkspace((s) => s.moveItems);
|
||||
const remove = useWorkspace((s) => s.remove);
|
||||
const updateSettings = useWorkspace((s) => s.updateChannelSettings);
|
||||
const error = useWorkspace((s) => s.error);
|
||||
const download = useDownload();
|
||||
|
||||
// 刚创建的频道:直接展示分享面板和二维码,让对方马上能扫
|
||||
const [justCreated] = useState(() => useChannels.getState().justCreatedId !== null);
|
||||
const [panelOpen, setPanelOpen] = useState(justCreated); // 手机:设备与分享抽屉
|
||||
const [railOpen, setRailOpen] = useState(justCreated); // 电脑:右侧栏展开
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [filter, setFilter] = useState(NO_FILTER);
|
||||
const [threadId, setThreadId] = useState<string>();
|
||||
const [folderId, setFolderId] = useState<string>();
|
||||
const [selectedId, setSelectedId] = useState<string>();
|
||||
const [sheetId, setSheetId] = useState<string>();
|
||||
const [previewId, setPreviewId] = useState<string>();
|
||||
const [checked, setChecked] = useState<ReadonlySet<string>>(new Set());
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
const [filter, setFilter] = useState(NO_FILTER);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [scanOpen, setScanOpen] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [pending, setPending] = useState<string>();
|
||||
const pendingRef = useRef<{ id: string; timer: number }>(undefined);
|
||||
const [mobileView, setMobileView] = useState<'chat' | 'files'>('chat');
|
||||
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];
|
||||
const activeThreadId = activeThread?.id;
|
||||
const activeFolders = folders.filter((folder) => folder.threadId === activeThreadId);
|
||||
const activeFolderId = activeFolders.some((folder) => folder.id === folderId) ? folderId : undefined;
|
||||
const enabled = Boolean(activeThreadId && channel?.canUpload !== false);
|
||||
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);
|
||||
})));
|
||||
const messages = useWorkspace(useShallow((s) => s.order.filter((id) => {
|
||||
const item = s.byId[id]; return Boolean(item && item.threadId === activeThreadId && !(item.kind === 'file' && item.metadataHidden));
|
||||
})));
|
||||
const restricted = useWorkspace(useShallow((s) => s.order.filter((id) => {
|
||||
const item = s.byId[id]; return item?.kind === 'file' && item.metadataHidden;
|
||||
})));
|
||||
|
||||
const sendPayload = useCallback(
|
||||
(p: ClipboardPayload) => {
|
||||
if (p.files.length > 0) void sendFiles(p.files);
|
||||
else if (p.text) void sendText(p.text);
|
||||
},
|
||||
[sendFiles, sendText],
|
||||
);
|
||||
useGlobalPaste(sendPayload);
|
||||
|
||||
// 从首页「快速分享」带过来的内容,进入频道后发送
|
||||
useEffect(() => {
|
||||
useChannels.getState().clearJustCreated();
|
||||
}, [sendPayload]);
|
||||
|
||||
const ids = useWorkspace(
|
||||
useShallow((s) =>
|
||||
s.order.filter((id) => {
|
||||
const item = s.byId[id];
|
||||
return id !== pending && item !== undefined && matchesFilter(item, filter);
|
||||
}),
|
||||
),
|
||||
);
|
||||
const filtering = isFiltering(filter);
|
||||
const latestId = !desktop && !filtering ? ids[0] : undefined;
|
||||
const listIds = latestId ? ids.slice(1) : ids;
|
||||
const activeId = selectedId && ids.includes(selectedId) ? selectedId : ids[0];
|
||||
|
||||
const closeSheet = useCallback(() => setSheetId(undefined), []);
|
||||
const closePanel = useCallback(() => setPanelOpen(false), []);
|
||||
const closeCompose = useCallback(() => setComposeOpen(false), []);
|
||||
const closeScan = useCallback(() => setScanOpen(false), []);
|
||||
|
||||
function requestDelete(id: string) {
|
||||
if (pendingRef.current) {
|
||||
clearTimeout(pendingRef.current.timer);
|
||||
void remove(pendingRef.current.id);
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
void remove(id);
|
||||
pendingRef.current = undefined;
|
||||
setPending(undefined);
|
||||
}, UNDO_MS);
|
||||
pendingRef.current = { id, timer };
|
||||
setPending(id);
|
||||
const requestFiles = useCallback((incoming: File[], destination?: { folderId?: string }) => {
|
||||
const target = destination ? destination.folderId : activeFolderId;
|
||||
if (incoming.length && activeThreadId && enabled) setUpload({ files: incoming, threadId: activeThreadId, ...(target ? { folderId: target } : {}) });
|
||||
}, [activeThreadId, activeFolderId, enabled]);
|
||||
const paste = useCallback((payload: ClipboardPayload) => {
|
||||
if (payload.files.length) requestFiles(payload.files);
|
||||
else if (payload.text && activeThreadId && enabled) void sendText(payload.text, activeThreadId);
|
||||
}, [requestFiles, activeThreadId, sendText, enabled]);
|
||||
useGlobalPaste(paste);
|
||||
function selectThread(id: string) { setThreadId(id); setFolderId(undefined); setSelectedId(undefined); setChecked(new Set()); }
|
||||
function selectFolder(id?: string) { setFolderId(id); setSelectedId(undefined); setChecked(new Set()); }
|
||||
function toggle(id: string) { setChecked((previous) => { const next = new Set(previous); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }
|
||||
function confirmUpload(options: FileUploadOptions) {
|
||||
const target = upload; setUpload(undefined);
|
||||
if (target) void sendFiles(target.files, options, target.threadId, target.folderId);
|
||||
}
|
||||
|
||||
function undoDelete() {
|
||||
if (!pendingRef.current) return;
|
||||
clearTimeout(pendingRef.current.timer);
|
||||
pendingRef.current = undefined;
|
||||
setPending(undefined);
|
||||
async function downloadItems(ids: string[]) { for (const id of ids) { const item = useWorkspace.getState().byId[id]; if (item) await download(item); } }
|
||||
async function deleteFiles(ids: string[]) {
|
||||
if (!window.confirm(ids.length === 1 ? '删除这项内容?' : `删除所选的 ${ids.length} 个文件?`)) return;
|
||||
for (const id of ids) await remove(id);
|
||||
setChecked(new Set()); setSelectedId(undefined); setPreviewId(undefined);
|
||||
}
|
||||
|
||||
function toggleSearch() {
|
||||
if (searchOpen) setFilter((f) => ({ ...f, query: '' }));
|
||||
setSearchOpen((v) => !v);
|
||||
}
|
||||
|
||||
function toggleChecked(id: string) {
|
||||
setChecked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (!next.delete(id)) next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function downloadChecked() {
|
||||
const { byId } = useWorkspace.getState();
|
||||
for (const id of checked) {
|
||||
const item = byId[id];
|
||||
if (item) void download(item);
|
||||
}
|
||||
setChecked(new Set());
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent) {
|
||||
if (!e.dataTransfer.types.includes('Files')) return;
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
const files = [...e.dataTransfer.files];
|
||||
if (files.length) void sendFiles(files);
|
||||
}
|
||||
|
||||
const emptyText = filtering ? '没有符合条件的内容。' : '还没有内容。粘贴、拖入文件,或在其他设备上打开此频道。';
|
||||
const topBar = (
|
||||
<TopBar
|
||||
onBack={() => navigate('/')}
|
||||
onOpenPanel={desktop ? undefined : () => setPanelOpen(true)}
|
||||
searchOpen={searchOpen}
|
||||
filtering={filtering}
|
||||
onToggleSearch={toggleSearch}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={desktop ? styles.desktop : styles.mobile} onDragOver={onDragOver} onDragLeave={() => setDragging(false)} onDrop={onDrop}>
|
||||
{desktop ? (
|
||||
<>
|
||||
{topBar}
|
||||
<div className={styles.list}>
|
||||
<ActionBar layout="toolbar" onFiles={(f) => void sendFiles(f)} onText={(t) => void sendText(t)} onCompose={() => setComposeOpen(true)} onScan={() => setScanOpen(true)} />
|
||||
<Notepad />
|
||||
<FilterBar filter={filter} searchOpen={searchOpen} onChange={setFilter} />
|
||||
{checked.size > 0 ? (
|
||||
<div className={styles.batch}>
|
||||
<span>已选 {checked.size} 项</span>
|
||||
<span>
|
||||
<Button variant="primary" icon="download" onClick={downloadChecked}>下载</Button>
|
||||
<Button variant="ghost" onClick={() => setChecked(new Set())}>取消</Button>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.scroll}>
|
||||
<ItemList ids={ids} selectedId={activeId} checked={checked} empty={emptyText} onSelect={setSelectedId} onToggle={toggleChecked} />
|
||||
</div>
|
||||
</div>
|
||||
<Suspense fallback={null}>
|
||||
<PreviewPane id={activeId} onDelete={requestDelete} />
|
||||
</Suspense>
|
||||
<SideRail open={railOpen} qrOpen={justCreated} onToggle={() => setRailOpen((v) => !v)} />
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.column}>
|
||||
{topBar}
|
||||
{latestId ? <LatestCard id={latestId} onOpen={setSheetId} /> : null}
|
||||
<Notepad />
|
||||
<FilterBar filter={filter} searchOpen={searchOpen} onChange={setFilter} />
|
||||
<div className={styles.grow}>
|
||||
<ItemList ids={listIds} empty={emptyText} onSelect={setSheetId} />
|
||||
</div>
|
||||
<ActionBar layout="dock" onFiles={(f) => void sendFiles(f)} onText={(t) => void sendText(t)} onCompose={() => setComposeOpen(true)} onScan={() => setScanOpen(true)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? <div className={styles.error} role="alert">{error}</div> : null}
|
||||
{!desktop && panelOpen ? (
|
||||
<Sheet title="设备与分享" onClose={closePanel}>
|
||||
<DevicePanel qrOpen={justCreated} />
|
||||
</Sheet>
|
||||
) : null}
|
||||
<Suspense fallback={null}>
|
||||
{!desktop && sheetId ? <PreviewSheet id={sheetId} onClose={closeSheet} onDelete={requestDelete} /> : null}
|
||||
{scanOpen ? <ScanSheet onClose={closeScan} onChannel={(c) => navigate(channelPath(c.id))} onShare={(sh) => navigate(sharePath(sh.id))} /> : null}
|
||||
</Suspense>
|
||||
{composeOpen ? <ComposeSheet onClose={closeCompose} onSubmit={(t) => void sendText(t)} /> : null}
|
||||
{pending ? <UndoBar onUndo={undoDelete} /> : null}
|
||||
{dragging ? <div className={styles.drop}>松开以发送文件</div> : null}
|
||||
</div>
|
||||
);
|
||||
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} />
|
||||
<ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={channel?.canUpload !== false} onSelectThread={selectThread} onCreateThread={createThread} onRenameThread={renameThread}
|
||||
onCreateGroup={createGroup} onRenameGroup={renameGroup} onGroupThread={groupThread} />
|
||||
{desktop ? <Notepad /> : null}<Button variant="ghost" icon="scan" onClick={() => setScanOpen(true)}>加入空间</Button></>;
|
||||
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} />
|
||||
<FileExplorer key={activeThreadId} scope={`${channel?.id}:${activeThreadId}`} threadId={activeThreadId} threadName={activeThread?.name ?? '选择频道'} folders={activeFolders} folderId={activeFolderId} ids={files}
|
||||
selectedId={files.includes(selectedId ?? '') ? selectedId : undefined} checked={checked} enabled={enabled} onFolder={selectFolder} onSelect={setSelectedId} onOpen={setPreviewId} onToggle={toggle} onSelection={setChecked}
|
||||
onFiles={(incoming, target) => requestFiles(incoming, { folderId: target })}
|
||||
onCreateFolder={(name) => activeThreadId ? createFolder(activeThreadId, name, activeFolderId) : Promise.reject(new Error('请先选择频道'))}
|
||||
onRenameFolder={(id, name) => activeThreadId ? renameFolder(id, activeThreadId, name) : Promise.reject(new Error('请先选择频道'))}
|
||||
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.legacy}><span>尚未同步目录的旧文件</span>{restricted.map((id) => <Button variant="ghost" key={id} onClick={() => setPreviewId(id)}>查看受限文件</Button>)}</div> : null}
|
||||
</>;
|
||||
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></>}
|
||||
{error ? <div className={styles.error} role="alert">{error}</div> : null}
|
||||
{panelOpen ? <Sheet title="设备与账号邀请" onClose={() => setPanelOpen(false)}><DevicePanel /></Sheet> : null}
|
||||
{settingsOpen && channel ? <ChannelSettingsSheet channel={channel} onClose={() => setSettingsOpen(false)} onSave={updateSettings} /> : null}
|
||||
{upload ? <UploadOptionsSheet files={upload.files} encrypted={channel?.mode === 'enc'} defaultMaxDownloads={channel?.maxDownloads ?? 10} onClose={() => setUpload(undefined)} onUpload={confirmUpload} /> : null}
|
||||
<Suspense fallback={null}>{previewId ? <PreviewSheet id={previewId} onClose={() => setPreviewId(undefined)} onDelete={deleteItem} /> : null}
|
||||
{scanOpen ? <ScanSheet onClose={() => setScanOpen(false)} onChannel={(resource) => navigate(channelPath(resource.id))} onShare={(resource) => navigate(sharePath(resource.id))} /> : null}</Suspense>
|
||||
{dragging ? <div className={styles.drop}>松开以上传到当前频道</div> : null}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useCallback, useEffect, useRef, useState, type DragEvent, type KeyboardEvent, type MouseEvent } from 'react';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { FILE_DRAG_TYPE, readFileDrag, selectFiles } from '@/lib/explorer';
|
||||
import type { MenuAction } from '@/ui/ContextMenu';
|
||||
|
||||
interface Options {
|
||||
scope: string; threadId?: string; ids: string[]; checked: ReadonlySet<string>; selectedId?: string; folderId?: string; enabled: boolean;
|
||||
onSelect: (id?: string) => void; onSelection: (ids: ReadonlySet<string>) => void; onOpen: (id: string) => void;
|
||||
onDownload: (ids: string[]) => Promise<void>; onDelete: (ids: string[]) => Promise<void>; onMove: (ids: string[], folder?: string) => Promise<void>;
|
||||
onFiles: (files: File[], folder?: string) => void; onFolder: (id?: string) => void;
|
||||
onRenameFolder: (id: string) => void; onCreateFolder: () => void; onUpload: () => void;
|
||||
}
|
||||
type Menu = { x: number; y: number; kind: 'file' | 'folder' | 'blank'; id?: string; name?: string; targets: string[] };
|
||||
export function useExplorerInteractions(options: Options) {
|
||||
const [menu, setMenu] = useState<Menu>();
|
||||
const [hover, setHover] = useState<{ id: string; point: { x: number; y: number } }>();
|
||||
const [moveIds, setMoveIds] = useState<string[]>();
|
||||
const [dropFolder, setDropFolder] = useState<string>();
|
||||
const [error, setError] = useState('');
|
||||
const anchor = useRef<string | undefined>(undefined);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const dismiss = useCallback(() => { clearTimeout(timer.current); setMenu(undefined); setHover(undefined); setDropFolder(undefined); }, []);
|
||||
const leave = useCallback(() => { clearTimeout(timer.current); setHover(undefined); }, []);
|
||||
useEffect(() => () => clearTimeout(timer.current), []);
|
||||
function run(task: Promise<void>) { setError(''); void task.catch((cause: unknown) => setError(cause instanceof Error ? cause.message : '操作失败')); }
|
||||
function select(id: string, event: MouseEvent) {
|
||||
dismiss(); options.onSelect(id);
|
||||
options.onSelection(selectFiles(options.ids, options.checked, id, anchor.current, { additive: event.ctrlKey || event.metaKey, range: event.shiftKey }));
|
||||
if (!event.shiftKey) anchor.current = id;
|
||||
}
|
||||
function showFileMenu(id: string, x: number, y: number) {
|
||||
leave(); options.onSelect(id);
|
||||
const targets = options.checked.has(id) ? options.ids.filter((key) => options.checked.has(key)) : [id];
|
||||
if (!options.checked.has(id)) options.onSelection(new Set([id]));
|
||||
setMenu({ x, y, kind: 'file', id, targets });
|
||||
}
|
||||
function showFolderMenu(id: string, name: string, x: number, y: number) { leave(); setMenu({ x, y, kind: 'folder', id, name, targets: [] }); }
|
||||
function blankMenu(event: MouseEvent) {
|
||||
if ((event.target as HTMLElement).closest('input,select,textarea')) return;
|
||||
event.preventDefault(); leave(); setMenu({ x: event.clientX, y: event.clientY, kind: 'blank', targets: [] });
|
||||
}
|
||||
function hoverFile(id: string, rect: DOMRect) {
|
||||
if (menu || !matchMedia('(hover: hover)').matches) return;
|
||||
clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setHover({ id, point: { x: rect.left - 288, y: rect.top } }), 450);
|
||||
}
|
||||
function drag(id: string, event: DragEvent) {
|
||||
dismiss(); const ids = options.checked.has(id) ? options.ids.filter((key) => options.checked.has(key)) : [id];
|
||||
event.dataTransfer.setData(FILE_DRAG_TYPE, JSON.stringify({ scope: options.scope, ids })); event.dataTransfer.effectAllowed = 'move';
|
||||
if (!options.checked.has(id)) { options.onSelection(new Set([id])); options.onSelect(id); }
|
||||
}
|
||||
function dropHandlers(folder?: string) {
|
||||
const marker = folder ?? '__root';
|
||||
return {
|
||||
onDragOver(event: DragEvent) {
|
||||
if (!options.enabled || ![...event.dataTransfer.types].some((type) => type === FILE_DRAG_TYPE || type === 'Files')) return;
|
||||
event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = event.dataTransfer.types.includes(FILE_DRAG_TYPE) ? 'move' : 'copy'; setDropFolder(marker); leave();
|
||||
},
|
||||
onDragLeave(event: DragEvent) { if (!(event.currentTarget as HTMLElement).contains(event.relatedTarget as Node)) setDropFolder(undefined); },
|
||||
onDrop(event: DragEvent) {
|
||||
if (!options.enabled) return;
|
||||
event.preventDefault(); event.stopPropagation(); setDropFolder(undefined);
|
||||
if (event.dataTransfer.types.includes(FILE_DRAG_TYPE)) {
|
||||
const available = new Set(Object.values(useWorkspace.getState().byId).filter((item) => item.kind === 'file' && item.threadId === options.threadId).map((item) => item.id));
|
||||
const ids = readFileDrag(event.dataTransfer.getData(FILE_DRAG_TYPE), options.scope, available);
|
||||
if (!ids) { setError('只能移动当前空间、当前频道中的文件。'); return; }
|
||||
run(options.onMove(ids, folder));
|
||||
} else if (event.dataTransfer.files.length) options.onFiles([...event.dataTransfer.files], folder);
|
||||
},
|
||||
};
|
||||
}
|
||||
function keyDown(event: KeyboardEvent) {
|
||||
if ((event.target as HTMLElement).closest('input:not([type=checkbox]),select,textarea,[role=menu],[role=dialog]')) return;
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { event.preventDefault(); options.onSelection(new Set(options.ids)); }
|
||||
else if (event.key === 'Escape') { dismiss(); options.onSelection(new Set()); options.onSelect(undefined); }
|
||||
else if (event.key === 'Delete' && options.enabled && options.checked.size) { event.preventDefault(); run(options.onDelete([...options.checked])); }
|
||||
else if (event.key === 'Enter' && options.selectedId && !(event.target as HTMLElement).closest('button,input')) { event.preventDefault(); options.onOpen(options.selectedId); }
|
||||
}
|
||||
const actions: MenuAction[] = [];
|
||||
if (menu?.kind === 'file' && menu.id) {
|
||||
const item = useWorkspace.getState().byId[menu.id];
|
||||
const unavailable = menu.targets.some((id) => { const item = useWorkspace.getState().byId[id]; return !item || item.state.kind === 'receiving' || item.state.kind === 'tampered'; });
|
||||
actions.push({ label: '预览', icon: 'eye', disabled: unavailable || menu.targets.length !== 1, action: () => options.onOpen(menu.id!) },
|
||||
{ label: menu.targets.length > 1 ? '下载所选文件' : '下载', icon: 'download', disabled: unavailable, action: () => run(options.onDownload(menu.targets)) },
|
||||
{ label: '复制名称', icon: 'copy', disabled: !item || menu.targets.length !== 1, action: () => { if (item?.kind === 'file') run(Promise.resolve().then(() => navigator.clipboard.writeText(item.name))); } },
|
||||
{ label: '移动到文件夹…', icon: 'folder', disabled: !options.enabled || unavailable, action: () => setMoveIds(menu.targets) },
|
||||
{ label: '删除', icon: 'trash', disabled: !options.enabled || unavailable, action: () => run(options.onDelete(menu.targets)) });
|
||||
} else if (menu?.kind === 'folder' && menu.id) {
|
||||
actions.push({ label: '打开文件夹', icon: 'folder', action: () => { dismiss(); options.onFolder(menu.id); } },
|
||||
{ label: '重命名', icon: 'edit', disabled: !options.enabled, action: () => options.onRenameFolder(menu.id!) });
|
||||
} else if (menu) actions.push({ label: '上传文件', icon: 'share', disabled: !options.enabled, action: options.onUpload },
|
||||
{ label: '新建文件夹', icon: 'folder', disabled: !options.enabled, action: options.onCreateFolder },
|
||||
{ label: '全选文件', icon: 'check', disabled: !options.ids.length, action: () => options.onSelection(new Set(options.ids)) });
|
||||
return { menu, actions, hover, moveIds, setMoveIds, dropFolder, error, dismiss, leave, select, showFileMenu, showFolderMenu, blankMenu, hoverFile, drag, dropHandlers, keyDown };
|
||||
}
|
||||
Reference in new issue
Block a user