feat: integrate client and server sharing workflows

This commit is contained in:
justme committed 2026-10-02 10:49:45 +08:00
1 parent 42b93ae2d8
commit 7406e2fe37
101 files changed
+3153 -253

No files matched your search

+1 -1
View File
@@ -36,7 +36,7 @@ export function ChannelList({ kind, title, onOpen }: Props) {
<span className={styles.enc}><Icon name="lock" size={12} /> 加密</span>
<span className={c.state === 'sealed' ? styles.enc : styles.raw}>{c.state === 'sealed' ? '已封存' : '草稿'}</span>
<span>{c.mine ? '我创建的' : '收到的'}</span>
<span>{formatRemaining(c.createdAt + c.ttlSeconds * 1000)}</span>
<span>{formatRemaining(c.expiresAt)}</span>
</>
) : (
<>
+4 -2
View File
@@ -3,6 +3,7 @@ import type { ChannelInfo, CreateOptions } from '@/core-bridge/types';
import { useChannels } from '@/store/channels';
import { Button } from '@/ui/Button';
import { Sheet } from '@/ui/Sheet';
import { solveCreationCaptcha } from '@/core-bridge/captcha';
import styles from './CreateSheet.module.css';
const SEATS = [2, 3, 5, 10];
@@ -20,7 +21,7 @@ interface Props {
/** 新建频道:协议 §12 里与创建相关的设置子集 */
export function CreateSheet({ onClose, onCreated }: Props) {
const create = useChannels((s) => s.create);
const [opts, setOpts] = useState<CreateOptions>({ kind: 'channel', mode: 'enc', seats: 3, ttlSeconds: 86400, membersCanUpload: true, maxDownloads: null });
const [opts, setOpts] = useState<CreateOptions>({ mode: 'enc', seats: 3, ttlSeconds: 86400, membersCanUpload: true });
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 }));
@@ -29,7 +30,8 @@ export function CreateSheet({ onClose, onCreated }: Props) {
setBusy(true);
setFailed(false);
try {
onCreated(await create(opts));
const captchaToken = await solveCreationCaptcha();
onCreated(await create(opts, captchaToken));
} catch {
setFailed(true);
setBusy(false);
+28 -6
View File
@@ -10,7 +10,7 @@
display: flex;
flex-direction: column;
gap: var(--space-4);
max-width: 560px;
max-width: 860px;
margin: 0 auto;
padding: var(--space-6) var(--space-4);
}
@@ -31,11 +31,23 @@
color: var(--text-dim);
}
.divider {
margin-top: var(--space-2);
.channels {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding-top: var(--space-4);
border-top: 1px solid var(--line);
}
.sectionHead h2 {
margin: 0;
font-size: 18px;
}
.sectionHead p {
margin: var(--space-1) 0 0;
color: var(--text-dim);
font-size: 12px;
text-align: center;
font-size: 13px;
}
.actions {
@@ -48,12 +60,22 @@
}
.note {
margin: 0;
margin: var(--space-1) 0 0;
color: var(--text-dim);
font-size: 12px;
text-align: center;
}
@media (width >= 900px) {
.main {
padding-top: 8vh;
}
.head {
padding-bottom: var(--space-4);
}
}
.error {
margin: calc(var(--space-3) * -1) 0 0;
color: var(--danger);
+40 -36
View File
@@ -1,11 +1,11 @@
import { lazy, Suspense, useCallback, useState, type DragEvent } from 'react';
import { lazy, Suspense, useCallback, useState } from 'react';
import { channelPath, navigate, sharePath } from '@/app/navigation';
import type { ChannelInfo, Share } from '@/core-bridge/types';
import { ComposeSheet } from '@/features/channel/ComposeSheet';
import type { ClipboardPayload } from '@/lib/clipboard';
import { useGlobalPaste } from '@/lib/useGlobalPaste';
import { useChannels } from '@/store/channels';
import { useShares } from '@/store/shares';
import { solveCreationCaptcha } from '@/core-bridge/captcha';
import { Button } from '@/ui/Button';
import { ChannelList } from './ChannelList';
import { CreateSheet } from './CreateSheet';
@@ -21,67 +21,74 @@ export function Home() {
const loadShareDemo = useShares((s) => s.loadDemo);
const [createOpen, setCreateOpen] = useState(false);
const [scanOpen, setScanOpen] = useState(false);
const [composeOpen, setComposeOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [dragging, setDragging] = useState(false);
const [failed, setFailed] = useState(false);
const [shareText, setShareText] = useState('');
const [shareFiles, setShareFiles] = useState<File[]>([]);
const open = (c: ChannelInfo | Share) => navigate('state' in c ? sharePath(c.id) : channelPath(c.id));
const open = useCallback((c: ChannelInfo | Share) => navigate('state' in c ? sharePath(c.id) : channelPath(c.id)), []);
const share = useCallback(
async (p: ClipboardPayload) => {
if (busy || (p.files.length === 0 && !p.text)) return;
if (busy || (p.files.length === 0 && !p.text.trim())) return false;
setBusy(true);
setFailed(false);
try {
open(await createDraft(p));
const captchaToken = await solveCreationCaptcha();
open(await createDraft(p, captchaToken));
setShareFiles([]);
setShareText('');
return true;
} catch {
setFailed(true);
return false;
} finally {
setBusy(false);
}
},
[busy, createDraft],
[busy, createDraft, open],
);
useGlobalPaste(share);
function onDragOver(e: DragEvent) {
if (!e.dataTransfer.types.includes('Files')) return;
e.preventDefault();
setDragging(true);
}
function onDrop(e: DragEvent) {
e.preventDefault();
setDragging(false);
void share({ files: [...e.dataTransfer.files], text: '' });
}
const onClipboard = useCallback((p: ClipboardPayload) => {
if (p.files.length > 0) setShareFiles((files) => [...files, ...p.files]);
if (p.text) setShareText((text) => text ? `${text}\n${p.text}` : p.text);
}, []);
useGlobalPaste(onClipboard);
return (
<div className={styles.page} onDragOver={onDragOver} onDragLeave={() => setDragging(false)} onDrop={onDrop}>
<div className={styles.page}>
<main className={styles.main}>
<header className={styles.head}>
<h1 className={styles.logo}>WebDrop</h1>
<p className={styles.sub}>在自己的设备之间快速传文件和文本,也可以分享给别人。</p>
</header>
<QuickShare busy={busy} dragging={dragging} onShare={share} onCompose={() => setComposeOpen(true)} />
<QuickShare
busy={busy}
text={shareText}
files={shareFiles}
onTextChange={setShareText}
onFilesChange={setShareFiles}
onShare={share}
/>
{failed ? <p role="alert" className={styles.error}>创建分享失败,请重试。</p> : null}
<ChannelList kind="share" title="我的分享" onOpen={open} />
<div className={styles.divider}>常驻频道:多台自己的设备之间同步</div>
<div className={styles.actions}>
<Button icon="edit" onClick={() => setCreateOpen(true)}>新建频道</Button>
<Button icon="scan" onClick={() => setScanOpen(true)}>加入频道</Button>
</div>
<section className={styles.channels} aria-labelledby="channels-title">
<div className={styles.sectionHead}>
<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>
</div>
<ChannelList kind="channel" title="我的频道" onOpen={open} />
<ChannelList kind="channel" title="我的频道" onOpen={open} />
<p className={styles.note}>加密频道的密钥只在链接里,服务器看不到内容。持有链接的人可以读取该频道的全部历史。</p>
</section>
<p className={styles.note}>
加密频道的密钥只在链接里,服务器看不到内容。持有链接的人可以读取该频道的全部历史。
</p>
{import.meta.env.DEV ? (
<Button variant="ghost" onClick={() => { loadDemo(); loadShareDemo(); }}>载入演示数据</Button>
) : null}
@@ -89,11 +96,8 @@ export function Home() {
{createOpen ? <CreateSheet onClose={() => setCreateOpen(false)} onCreated={open} /> : null}
<Suspense fallback={null}>
{scanOpen ? <ScanSheet onClose={() => setScanOpen(false)} onJoined={open} /> : null}
{scanOpen ? <ScanSheet onClose={() => setScanOpen(false)} onChannel={open} onShare={open} /> : null}
</Suspense>
{composeOpen ? (
<ComposeSheet onClose={() => setComposeOpen(false)} onSubmit={(text) => void share({ files: [], text })} />
) : null}
</div>
);
}
+136 -18
View File
@@ -2,11 +2,10 @@
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-6) var(--space-4);
border: 2px dashed var(--line-strong);
border-radius: var(--radius-lg);
padding: var(--space-4);
border: 1px solid var(--line-strong);
border-radius: var(--radius-md);
background: var(--surface);
text-align: center;
transition: border-color 0.15s;
}
@@ -15,55 +14,174 @@
background: var(--busy-soft);
}
.head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
}
.title {
margin: 0;
font-size: 20px;
font-size: 18px;
}
.hint {
margin: 0;
margin: var(--space-1) 0 0;
color: var(--text-dim);
font-size: 13px;
}
.tag {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
background: var(--ok-soft);
color: var(--ok);
font-size: 12px;
}
.editor {
width: 100%;
min-height: 112px;
padding: var(--space-3);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
font: inherit;
line-height: 1.6;
resize: vertical;
}
.editor:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.editor::placeholder {
color: var(--text-dim);
}
.files {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
border-top: 1px solid var(--line);
list-style: none;
}
.file {
display: flex;
align-items: center;
gap: var(--space-2);
min-height: 40px;
border-bottom: 1px solid var(--line);
}
.file > span {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file button {
display: grid;
width: 36px;
height: 36px;
place-items: center;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-dim);
cursor: pointer;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.buttons {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--space-2);
}
.btn {
.btn,
.submit {
display: inline-flex;
flex: 1 1 120px;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-height: 48px;
padding: 0 var(--space-4);
min-height: 42px;
padding: 0 var(--space-3);
border: 1px solid var(--line-strong);
border-radius: var(--radius-md);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
cursor: pointer;
font: inherit;
font-weight: 600;
}
.btn:disabled {
.submit {
flex: 0 0 auto;
min-width: 160px;
background: var(--accent);
color: var(--on-accent);
}
.btn:disabled,
.submit:disabled,
.file button:disabled {
opacity: 0.5;
cursor: default;
}
.btn:focus-visible {
.btn:focus-visible,
.submit:focus-visible,
.file button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.btn:active:not(:disabled) {
.file button:hover:not(:disabled) {
background: var(--accent-soft);
color: var(--text);
}
.btn:hover:not(:disabled) {
background: var(--accent-soft);
}
@media (hover: hover) {
.btn:hover:not(:disabled) {
background: var(--accent-soft);
.submit:hover:not(:disabled) {
opacity: 0.86;
}
@media (width <= 600px) {
.head,
.footer {
align-items: stretch;
flex-direction: column;
}
.tag {
align-self: flex-start;
}
.buttons > * {
flex: 1 1 110px;
}
.submit {
width: 100%;
}
}
+77 -28
View File
@@ -1,4 +1,4 @@
import { useRef } from 'react';
import { useRef, useState, type DragEvent } from 'react';
import type { ClipboardPayload } from '@/lib/clipboard';
import { readClipboard } from '@/lib/clipboard';
import { useMediaQuery } from '@/lib/useMediaQuery';
@@ -7,54 +7,103 @@ import styles from './QuickShare.module.css';
interface Props {
busy: boolean;
dragging: boolean;
onShare: (p: ClipboardPayload) => void;
onCompose: () => void;
text: string;
files: File[];
onTextChange: (text: string) => void;
onFilesChange: (files: File[]) => void;
onShare: (payload: ClipboardPayload) => Promise<boolean>;
}
/** 快速分享:拖入、粘贴或选择内容,自动创建一个临时加密频道并给出分享链接 */
export function QuickShare({ busy, dragging, onShare, onCompose }: Props) {
/** 临时分享入口:先收集文本和文件,确认后再建立草稿。 */
export function QuickShare({ busy, text, files, onTextChange, onFilesChange, onShare }: Props) {
const touch = useMediaQuery('(pointer: coarse)');
const fileRef = useRef<HTMLInputElement>(null);
const galleryRef = useRef<HTMLInputElement>(null);
const cameraRef = useRef<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const canShare = text.trim().length > 0 || files.length > 0;
async function paste() {
const clip = await readClipboard();
if (!clip) return onCompose(); // 无权限时退回手动输入
onShare({ files: clip.files, text: clip.files.length > 0 ? '' : clip.text.trim() });
if (!clip) return;
if (clip.files.length) onFilesChange([...files, ...clip.files]);
if (clip.text) onTextChange(text ? `${text}\n${clip.text}` : clip.text);
}
function pick(files: FileList | null) {
if (files?.length) onShare({ files: [...files], text: '' });
function onDrop(event: DragEvent<HTMLElement>) {
event.preventDefault();
setDragging(false);
if (event.dataTransfer.files.length) onFilesChange([...files, ...event.dataTransfer.files]);
}
const buttons: { key: string; label: string; icon: IconName; run: () => void; touchOnly?: boolean }[] = [
function pick(list: FileList | null) {
if (list?.length) onFilesChange([...files, ...list]);
}
const actions: { key: string; label: string; icon: IconName; run: () => void; touchOnly?: boolean }[] = [
{ key: 'file', label: '选择文件', icon: 'file', run: () => fileRef.current?.click() },
{ key: 'paste', label: '粘贴', icon: 'paste', run: () => void paste() },
{ key: 'write', label: '写文本', icon: 'edit', run: onCompose },
{ key: 'gallery', label: '相册', icon: 'image', run: () => galleryRef.current?.click(), touchOnly: true },
{ key: 'camera', label: '拍照', icon: 'camera', run: () => cameraRef.current?.click(), touchOnly: true },
];
return (
<section className={`${styles.card} ${dragging ? styles.dragging : ''}`} aria-busy={busy}>
<input ref={fileRef} type="file" multiple hidden onChange={(e) => { pick(e.target.files); e.target.value = ''; }} />
<input ref={galleryRef} type="file" accept="image/*,video/*" multiple hidden onChange={(e) => { pick(e.target.files); e.target.value = ''; }} />
<input ref={cameraRef} type="file" accept="image/*" capture="environment" hidden onChange={(e) => { pick(e.target.files); e.target.value = ''; }} />
<section
className={`${styles.card} ${dragging ? styles.dragging : ''}`}
aria-busy={busy}
onDragOver={(event) => { if (event.dataTransfer.types.includes('Files')) { event.preventDefault(); setDragging(true); } }}
onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false); }}
onDrop={onDrop}
>
<input ref={fileRef} type="file" multiple hidden onChange={(event) => { pick(event.target.files); event.target.value = ''; }} />
<input ref={galleryRef} type="file" accept="image/*,video/*" multiple hidden onChange={(event) => { pick(event.target.files); event.target.value = ''; }} />
<input ref={cameraRef} type="file" accept="image/*" capture="environment" hidden onChange={(event) => { pick(event.target.files); event.target.value = ''; }} />
<h2 className={styles.title}>{busy ? '正在创建分享…' : '快速分享'}</h2>
<p className={styles.hint}>
{touch ? '选择文件或文本' : '把文件拖到这里,或直接 Ctrl+V 粘贴文本'},做成一个分享列表发给别人下载。完成后内容只读,连你自己也不能再改。
</p>
<div className={styles.buttons}>
{buttons.filter((b) => !b.touchOnly || touch).map((b) => (
<button key={b.key} type="button" className={styles.btn} disabled={busy} onClick={b.run}>
<Icon name={b.icon} size={20} />
<span>{b.label}</span>
</button>
))}
</div>
<header className={styles.head}>
<div>
<h2 className={styles.title}>快速分享</h2>
<p className={styles.hint}>临时文本直接写在这里,也可以拖入文件。内容封存后只读。</p>
</div>
<span className={styles.tag}><Icon name="lock" size={13} /> 端到端加密</span>
</header>
<textarea
className={styles.editor}
aria-label="临时分享文本"
placeholder="输入或粘贴要分享的文本…"
value={text}
onChange={(event) => onTextChange(event.target.value)}
rows={4}
disabled={busy}
/>
{files.length > 0 ? (
<ul className={styles.files} aria-label="待分享文件">
{files.map((file, index) => (
<li className={styles.file} key={`${file.name}:${file.size}:${index}`}>
<Icon name="file" size={16} />
<span>{file.name}</span>
<button type="button" aria-label={`移除 ${file.name}`} disabled={busy} onClick={() => onFilesChange(files.filter((_, i) => i !== index))}>
<Icon name="close" size={15} />
</button>
</li>
))}
</ul>
) : null}
<footer className={styles.footer}>
<div className={styles.buttons}>
{actions.filter((action) => !action.touchOnly || touch).map((action) => (
<button key={action.key} type="button" className={styles.btn} disabled={busy} onClick={action.run}>
<Icon name={action.icon} size={18} />
<span>{action.label}</span>
</button>
))}
</div>
<button type="button" className={styles.submit} disabled={busy || !canShare} onClick={() => void onShare({ files, text })}>
{busy ? '正在创建分享…' : '创建临时分享'}
</button>
</footer>
</section>
);
}