feat(web): add spaces, grouped channels and file chat

This commit is contained in:
justme committed 2026-10-13 01:39:23 +08:00
1 parent c7d8ef15bc
commit f6cfb12716
111 files changed
+4371 -506

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
import { useEffect, useState } from 'react';
import { accountApi, type AccountDevice } from '@/core-bridge/account';
import { useAccount } from '@/store/account';
import { Button } from '@/ui/Button';
import styles from './AccountPanel.module.css';
export function AccountDevices() {
const [devices, setDevices] = useState<AccountDevice[]>([]);
const [names, setNames] = useState<Record<string, string>>({});
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const refreshAccount = useAccount((s) => s.refresh);
async function refresh() { setDevices(await accountApi.devices()); }
useEffect(() => { void refresh().catch(() => setError('设备清单读取失败')); }, []);
async function change(device: AccountDevice, revoke: boolean) {
setBusy(true); setError('');
try {
if (revoke) {
await accountApi.revokeDevice(device.id); await refreshAccount();
} else await accountApi.renameDevice(device.id, names[device.id] ?? device.name);
await refresh();
} catch (cause) { setError(cause instanceof Error ? cause.message : '设备更新失败'); }
finally { setBusy(false); }
}
return <section aria-label="账号设备"><strong>我的设备</strong>
<p className={styles.note}>同一账号可登录多台设备。移除设备会撤销它的登录与空间席位,保留账号的空间成员资格。</p>
{devices.map((device) => <div className={styles.actions} key={device.id}>
<input aria-label={`${device.name} 的设备名称`} maxLength={80} value={names[device.id] ?? device.name} onChange={(e) => setNames((values) => ({ ...values, [device.id]: e.target.value }))} />
<span>{device.current ? '本机' : '其他设备'}</span>
<Button disabled={busy} onClick={() => { void change(device, false); }}>重命名</Button>
<Button variant="ghost" disabled={busy || device.current} onClick={() => {
if (window.confirm(`移除设备「${device.name}」并撤销它的登录?`)) void change(device, true);
}}>移除设备</Button>
</div>)}
<Button variant="ghost" disabled={busy} onClick={() => { void refresh().catch(() => setError('设备清单读取失败')); }}>刷新设备</Button>
{error ? <p className={styles.error} role="alert">{error}</p> : null}
</section>;
}
@@ -0,0 +1,96 @@
.panel {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--line);
background: var(--surface);
}
.avatar {
width: var(--bar-h);
height: var(--bar-h);
object-fit: cover;
border-radius: var(--radius-md);
}
.heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
}
.heading h2 {
margin: 0;
font-size: 16px;
}
.heading p,
.note,
.message,
.error {
margin: var(--space-1) 0 0;
color: var(--text-dim);
font-size: 12px;
line-height: 1.6;
}
.user {
color: var(--ok);
font-size: 13px;
}
.form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.form label {
display: flex;
flex-direction: column;
gap: var(--space-1);
color: var(--text-dim);
font-size: 12px;
}
.form input {
min-width: 0;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--line-strong);
border-radius: var(--radius-md);
background: var(--surface-2);
color: var(--text);
font: inherit;
font-size: 14px;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
.form .actions {
grid-column: 1 / -1;
}
.error {
color: var(--danger);
}
.message {
color: var(--ok);
}
@media (width <= 560px) {
.form {
grid-template-columns: 1fr;
}
.form .actions {
grid-column: auto;
}
}
+74
View File
@@ -0,0 +1,74 @@
import { useState, type FormEvent } from 'react';
import { useAccount } from '@/store/account';
import { useChannels } from '@/store/channels';
import { useShares } from '@/store/shares';
import { Button } from '@/ui/Button';
import styles from './AccountPanel.module.css';
import { navigate, channelPath } from '@/app/navigation';
import { InvitationInbox } from './InvitationInbox';
import { PersonalSettings } from './PersonalSettings';
import { AccountDevices } from './AccountDevices';
export function AccountPanel() {
const account = useAccount((s) => s.username);
const busy = useAccount((s) => s.busy);
const error = useAccount((s) => s.error);
const login = useAccount((s) => s.login);
const logout = useAccount((s) => s.logout);
const sync = useAccount((s) => s.sync);
const channelsReady = useChannels((s) => s.hydrated);
const sharesReady = useShares((s) => s.hydrated);
const refreshChannels = useChannels((s) => s.refresh);
const refreshShares = useShares((s) => s.refresh);
const [register, setRegister] = useState(true);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [message, setMessage] = useState('');
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (busy) return;
setMessage('');
try {
await login(username, password, register);
setPassword('');
await Promise.all([refreshChannels(), refreshShares()]);
setMessage(register ? '账号已创建,资源保险库已同步。' : '已登录,资源保险库已同步。');
const pending = useChannels.getState().pendingLink;
if (pending) navigate(channelPath(pending.id));
} catch { /* 错误由账号状态呈现 */ }
finally { setPassword(''); }
}
return (
<section className={styles.panel} aria-labelledby="account-title">
<div className={styles.heading}>
<div>
<h2 id="account-title">账号与同步</h2>
<p>登录后可在其他设备恢复本账号保存的空间和分享。</p>
</div>
{account ? <span className={styles.user}>{account}</span> : null}
</div>
{account ? (
<div className={styles.actions}>
<Button disabled={busy} onClick={() => { void sync().then(() => setMessage('资源保险库已同步。')).catch(() => {}); }}>{busy ? '同步中…' : '立即同步'}</Button>
<Button variant="ghost" disabled={busy} onClick={() => { void logout().then(() => navigate('/')); setMessage(''); }}>退出登录</Button>
</div>
) : (
<form className={styles.form} onSubmit={(event) => { void submit(event); }}>
<label>用户名<input autoComplete="username" minLength={3} maxLength={32} value={username} onChange={(event) => setUsername(event.target.value)} required /></label>
<label>密码<input autoComplete={register ? 'new-password' : 'current-password'} type="password" minLength={register ? 12 : undefined} value={password} onChange={(event) => setPassword(event.target.value)} required /></label>
<div className={styles.actions}>
<Button type="submit" variant="primary" disabled={busy || !channelsReady || !sharesReady}>{busy ? '处理中…' : !channelsReady || !sharesReady ? '正在读取本机资源…' : register ? '创建账号' : '登录并同步'}</Button>
<Button variant="ghost" onClick={() => { setRegister((value) => !value); setMessage(''); }}>{register ? '已有账号' : '创建账号'}</Button>
</div>
</form>
)}
{error ? <p className={styles.error} role="alert">{error}</p> : null}
{message ? <p className={styles.message} role="status">{message}</p> : null}
{account ? <InvitationInbox /> : null}
{account ? <PersonalSettings /> : null}
{account ? <AccountDevices /> : null}
<p className={styles.note}>资源清单在浏览器加密后再同步,服务端无法读取空间链接。密码没有找回功能;忘记密码将无法解密账号保险库。</p>
</section>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { AccountPanel } from './AccountPanel';
import { ThemeMenu } from '@/ui/ThemeMenu';
import styles from './Home.module.css';
export function AccountScreen() {
return <div className={styles.page}><div className={styles.appearance}><ThemeMenu /></div><main className={styles.main}>
<header className={styles.head}><h1 className={styles.logo}>WebDrop</h1><p className={styles.sub}>注册或登录后,管理你的空间和分享。</p></header>
<AccountPanel />
</main></div>;
}
@@ -36,8 +36,15 @@
}
.id {
overflow: hidden;
font-family: var(--font-mono);
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.channelId {
font-family: var(--font-mono);
}
.meta {
+3 -2
View File
@@ -29,7 +29,7 @@ export function ChannelList({ kind, title, onOpen }: Props) {
{list.map((c) => (
<li key={c.id} className={styles.row}>
<button type="button" className={styles.open} onClick={() => onOpen(c)}>
<span className={styles.id}>{c.id.slice(0, 8)}…</span>
<span className={styles.id}>{'state' in c ? c.id.slice(0, 8) : c.name || c.id.slice(0, 8)}…</span>
<span className={styles.meta}>
{'state' in c ? (
<>
@@ -40,6 +40,7 @@ export function ChannelList({ kind, title, onOpen }: Props) {
</>
) : (
<>
{c.name ? <span className={styles.channelId}>{c.id.slice(0, 8)}…</span> : null}
<span className={c.mode === 'enc' ? styles.enc : styles.raw}>
{c.mode === 'enc' ? <><Icon name="lock" size={12} /> 加密</> : '明文'}
</span>
@@ -56,7 +57,7 @@ export function ChannelList({ kind, title, onOpen }: Props) {
<Button variant="ghost" onClick={() => setConfirmId(undefined)}>取消</Button>
</span>
) : (
<Button variant="ghost" icon="trash" label={kind === 'share' ? '从本机移除' : '从本机移除(不会删除频道)'} onClick={() => setConfirmId(c.id)} />
<Button variant="ghost" icon="trash" label="从当前资源清单移除(不会删除服务端内容)" onClick={() => setConfirmId(c.id)} />
)}
</li>
))}
+6 -25
View File
@@ -14,12 +14,8 @@
font-size: 13px;
}
.field legend {
padding: 0;
margin-bottom: var(--space-2);
}
.field select {
.field select,
.field input[type="text"] {
min-height: 40px;
padding: 0 var(--space-3);
border: 1px solid var(--line-strong);
@@ -28,25 +24,10 @@
font-size: 15px;
}
.seg {
display: flex;
overflow: hidden;
border: 1px solid var(--line-strong);
border-radius: var(--radius-md);
}
.seg button {
flex: 1;
min-height: 40px;
border: 0;
background: transparent;
cursor: pointer;
}
.seg button[aria-checked="true"] {
background: var(--accent);
color: var(--on-accent);
font-weight: 600;
.secure {
margin: 0;
color: var(--ok);
font-size: 13px;
}
.check {
+19 -11
View File
@@ -7,6 +7,7 @@ import { solveCreationCaptcha } from '@/core-bridge/captcha';
import styles from './CreateSheet.module.css';
const SEATS = [2, 3, 5, 10];
const DOWNLOAD_LIMITS = [0, 1, 3, 5, 10, 20];
const TTLS = [
{ label: '1 小时', value: 3600 },
{ label: '24 小时', value: 86400 },
@@ -18,10 +19,10 @@ interface Props {
onCreated: (c: ChannelInfo) => void;
}
/** 新建频道:协议 §12 里与创建相关的设置子集 */
/** 新建空间:协议 §12 里与创建相关的设置子集 */
export function CreateSheet({ onClose, onCreated }: Props) {
const create = useChannels((s) => s.create);
const [opts, setOpts] = useState<CreateOptions>({ mode: 'enc', seats: 3, ttlSeconds: 86400, membersCanUpload: true });
const [opts, setOpts] = useState<CreateOptions>({ mode: 'enc', seats: 10, ttlSeconds: 86400, membersCanUpload: true, fileMaxDownloads: 10, joinPolicy: 'invite', allowGuests: false });
const [busy, setBusy] = useState(false);
const [failed, setFailed] = useState(false);
const set = <K extends keyof CreateOptions>(k: K, v: CreateOptions[K]) => setOpts((o) => ({ ...o, [k]: v }));
@@ -39,16 +40,16 @@ export function CreateSheet({ onClose, onCreated }: Props) {
}
return (
<Sheet title="新建频道" onClose={onClose}>
<Sheet title="新建空间" onClose={onClose}>
<div className={styles.form}>
<fieldset className={styles.field}>
<legend>模式</legend>
<div className={styles.seg} role="radiogroup">
<button type="button" role="radio" aria-checked={opts.mode === 'enc'} onClick={() => set('mode', 'enc')}>加密(推荐)</button>
<button type="button" role="radio" aria-checked={opts.mode === 'raw'} onClick={() => set('mode', 'raw')}>明文</button>
</div>
{opts.mode === 'raw' ? <p className={styles.warn}>明文频道里的内容服务端可见,创建后不能改成加密。</p> : null}
</fieldset>
<label className={styles.field}>
<span>空间名称(加入者和服务端可见)</span>
<input type="text" maxLength={48} value={opts.name ?? ''} placeholder="例如:工作设备" onChange={(e) => set('name', e.target.value)} />
</label>
<label className={styles.check}><input type="checkbox" checked={opts.mode === 'enc'} onChange={(event) => set('mode', event.target.checked ? 'enc' : 'raw')} /><span>启用端到端加密</span></label>
<p className={opts.mode === 'enc' ? styles.secure : styles.warn}>{opts.mode === 'enc' ? '内容和文件名在设备端加密。' : '未加密的内容、文件名和频道信息对服务端可见。'}创建后不能改变加密方式。</p>
<p className={styles.secure}>空间属于当前账号,并自动创建“常规”频道。默认通过账号邀请加入,成员在自己的设备上自动领取密钥包。</p>
<label className={styles.field}>
<span>设备数(席位)</span>
@@ -64,6 +65,13 @@ export function CreateSheet({ onClose, onCreated }: Props) {
</select>
</label>
<label className={styles.field}>
<span>每个文件的受限下载次数</span>
<select value={opts.fileMaxDownloads ?? 10} onChange={(e) => set('fileMaxDownloads', Number(e.target.value))}>
{DOWNLOAD_LIMITS.map((n) => <option key={n} value={n}>{n === 0 ? '不限' : `${n} 次`}</option>)}
</select>
</label>
<label className={styles.check}>
<input type="checkbox" checked={opts.membersCanUpload} onChange={(e) => set('membersCanUpload', e.target.checked)} />
<span>允许成员上传</span>
+9 -6
View File
@@ -11,6 +11,7 @@ import { errorMessage } from '@/core-bridge/errors';
import { Button } from '@/ui/Button';
import { ThemeMenu } from '@/ui/ThemeMenu';
import { ChannelList } from './ChannelList';
import { AccountPanel } from './AccountPanel';
import { CreateSheet } from './CreateSheet';
import { QuickShare } from './QuickShare';
import styles from './Home.module.css';
@@ -77,20 +78,22 @@ export function Home() {
/>
{failedMessage ? <p role="alert" className={styles.error}>创建分享失败:{failedMessage}</p> : null}
<AccountPanel />
<ChannelList kind="share" title="我的分享" onOpen={open} />
<section className={styles.channels} aria-labelledby="channels-title">
<div className={styles.sectionHead}>
<h2 id="channels-title">设备频道</h2>
<p>多台自己的设备之间长期同步</p>
<h2 id="channels-title">空间工作区</h2>
<p>同一账号跨设备使用,也可邀请其他账号共同使用频道与文件。</p>
</div>
<div className={styles.actions}>
<Button icon="edit" onClick={() => setCreateOpen(true)}>新建频道</Button>
<Button icon="scan" onClick={() => setScanOpen(true)}>加入频道</Button>
<Button icon="edit" onClick={() => setCreateOpen(true)}>新建空间</Button>
<Button icon="scan" onClick={() => setScanOpen(true)}>加入空间</Button>
</div>
<ChannelList kind="channel" title="我的频道" onOpen={open} />
<p className={styles.note}>加密频道的密钥只在链接里,服务器看不到内容。持有链接的人可以读取该频道的全部历史。</p>
<ChannelList kind="channel" title="我的空间" onOpen={open} />
<p className={styles.note}>空间密钥按账号加密分发,登录其他设备后可自动恢复。已授权成员可读取空间历史。</p>
</section>
{import.meta.env.DEV ? (
+25
View File
@@ -0,0 +1,25 @@
import { useEffect, useState } from 'react';
import { accountApi, type AccountInvitation } from '@/core-bridge/account';
import { useChannels } from '@/store/channels';
import { Button } from '@/ui/Button';
import { channelPath, navigate } from '@/app/navigation';
import styles from './AccountPanel.module.css';
export function InvitationInbox() {
const [invitations, setInvitations] = useState<AccountInvitation[]>([]);
const [error, setError] = useState('');
const [busy, setBusy] = useState<string>();
useEffect(() => { void accountApi.invitations().then(setInvitations).catch(() => setError('读取邀请失败')); }, []);
async function accept(id: string) {
setBusy(id); setError('');
try { await accountApi.acceptInvitation(id); await useChannels.getState().refresh(); navigate(channelPath(id)); }
catch (cause) { setError(cause instanceof Error ? cause.message : '加入失败'); }
finally { setBusy(undefined); }
}
return <div><strong>空间邀请</strong>
<Button variant="ghost" disabled={Boolean(busy)} onClick={() => { void accountApi.invitations().then(setInvitations).catch(() => setError('读取邀请失败')); }}>刷新邀请</Button>
{invitations.length === 0 ? <p className={styles.note}>暂无邀请。</p> : invitations.map((row) => <div className={styles.actions} key={row.channel_id}>
<span>空间 {row.channel_id.slice(0, 8)}…</span><Button disabled={Boolean(busy)} onClick={() => { void accept(row.channel_id); }}>{busy === row.channel_id ? '加入中…' : '接受邀请'}</Button>
</div>)}
{error ? <p className={styles.error} role="alert">{error}</p> : null}
</div>;
}
@@ -0,0 +1,44 @@
import { useEffect, useState, type FormEvent } from 'react';
import { useAccount } from '@/store/account';
import { Button } from '@/ui/Button';
import styles from './AccountPanel.module.css';
export function PersonalSettings() {
const personal = useAccount((s) => s.personal);
const username = useAccount((s) => s.username);
const busy = useAccount((s) => s.busy);
const update = useAccount((s) => s.updatePersonal);
const [name, setName] = useState(personal?.displayName ?? '');
const [avatar, setAvatar] = useState(personal?.avatar);
const [message, setMessage] = useState('');
useEffect(() => { setName(personal?.displayName ?? ''); setAvatar(personal?.avatar); }, [personal?.displayName, personal?.avatar]);
async function choose(file?: File) {
if (!file) return;
setMessage('');
try {
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type) || file.size > 250_000) throw new Error('请选择 250 KB 以内的 PNG、JPEG 或 WebP 图片');
const bitmap = await createImageBitmap(file);
const valid = bitmap.width > 0 && bitmap.height > 0 && bitmap.width <= 4096 && bitmap.height <= 4096;
bitmap.close(); if (!valid) throw new Error('头像宽高不能超过 4096 像素');
const value = await new Promise<string>((resolve, reject) => {
const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(new Error('头像读取失败')); reader.readAsDataURL(file);
});
setAvatar(value);
} catch (cause) { setMessage(cause instanceof Error ? cause.message : '头像读取失败'); }
}
async function submit(event: FormEvent) {
event.preventDefault(); setMessage('');
try { await update(name, avatar); setMessage('个人资料已保存并同步。'); }
catch (cause) { setMessage(cause instanceof Error ? cause.message : '保存失败'); }
}
return <form className={styles.form} onSubmit={(event) => { void submit(event); }} aria-label="个人资料">
<label>个人名称<input maxLength={48} value={name} placeholder={username ?? ''} onChange={(e) => setName(e.target.value)} /></label>
<label>头像<input type="file" accept="image/png,image/jpeg,image/webp" onChange={(e) => { void choose(e.target.files?.[0]); e.target.value = ''; }} /></label>
<div className={styles.actions}>{avatar ? <img className={styles.avatar} src={avatar} alt="个人头像" /> : null}
<Button type="submit" disabled={busy}>保存个人资料</Button>
{avatar ? <Button variant="ghost" onClick={() => setAvatar(undefined)}>移除头像</Button> : null}
</div>
<p className={styles.note}>用户名是固定的账号身份。个人名称与头像会加密同步,设备名称单独管理。</p>
{message ? <p className={styles.note} role="status">{message}</p> : null}
</form>;
}