feat(web): connect quick share flow
Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
1 parent
53768a195d
commit
e02e024605
138 files changed
+10614
No files matched your search
@@ -0,0 +1,64 @@
|
||||
.h {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.open {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
min-height: 56px;
|
||||
padding: var(--space-2) 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.open:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.id {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.enc {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.raw {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.confirm {
|
||||
display: flex;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState } from 'react';
|
||||
import type { ChannelInfo, Share } from '@/core-bridge/types';
|
||||
import { formatAgo, formatRemaining } from '@/lib/format';
|
||||
import { useChannels } from '@/store/channels';
|
||||
import { useShares } from '@/store/shares';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import styles from './ChannelList.module.css';
|
||||
|
||||
interface Props {
|
||||
kind: 'channel' | 'share';
|
||||
title: string;
|
||||
onOpen: (c: ChannelInfo | Share) => void;
|
||||
}
|
||||
|
||||
export function ChannelList({ kind, title, onOpen }: Props) {
|
||||
const channels = useChannels((s) => s.list);
|
||||
const forgetChannel = useChannels((s) => s.forget);
|
||||
const shares = useShares((s) => s.list);
|
||||
const forgetShare = useShares((s) => s.forget);
|
||||
const list = kind === 'share' ? shares : channels;
|
||||
const [confirmId, setConfirmId] = useState<string>();
|
||||
if (list.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section aria-label={title}>
|
||||
<h2 className={styles.h}>{title}</h2>
|
||||
<ul className={styles.list}>
|
||||
{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.meta}>
|
||||
{'state' in c ? (
|
||||
<>
|
||||
<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 className={c.mode === 'enc' ? styles.enc : styles.raw}>
|
||||
{c.mode === 'enc' ? <><Icon name="lock" size={12} /> 加密</> : '明文'}
|
||||
</span>
|
||||
<span>{c.role === 'owner' ? '所有者' : '成员'}</span>
|
||||
<span>{c.seats} 台设备</span>
|
||||
<span>{formatAgo(c.createdAt)}创建</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{confirmId === c.id ? (
|
||||
<span className={styles.confirm}>
|
||||
<Button variant="ghost" onClick={() => { (kind === 'share' ? forgetShare : forgetChannel)(c.id); setConfirmId(undefined); }}>确认移除</Button>
|
||||
<Button variant="ghost" onClick={() => setConfirmId(undefined)}>取消</Button>
|
||||
</span>
|
||||
) : (
|
||||
<Button variant="ghost" icon="trash" label={kind === 'share' ? '从本机移除' : '从本机移除(不会删除频道)'} onClick={() => setConfirmId(c.id)} />
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.field legend {
|
||||
padding: 0;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.field select {
|
||||
min-height: 40px;
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
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;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.check input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.warn {
|
||||
margin: 0;
|
||||
color: var(--warn);
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState } from 'react';
|
||||
import type { ChannelInfo, CreateOptions } from '@/core-bridge/types';
|
||||
import { useChannels } from '@/store/channels';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Sheet } from '@/ui/Sheet';
|
||||
import styles from './CreateSheet.module.css';
|
||||
|
||||
const SEATS = [2, 3, 5, 10];
|
||||
const TTLS = [
|
||||
{ label: '1 小时', value: 3600 },
|
||||
{ label: '24 小时', value: 86400 },
|
||||
{ label: '7 天', value: 604800 },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onCreated: (c: ChannelInfo) => void;
|
||||
}
|
||||
|
||||
/** 新建频道:协议 §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 [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 }));
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
onCreated(await create(opts));
|
||||
} catch {
|
||||
setFailed(true);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
<select value={opts.seats} onChange={(e) => set('seats', Number(e.target.value))}>
|
||||
{SEATS.map((n) => <option key={n} value={n}>{n} 台</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>内容保留时间</span>
|
||||
<select value={opts.ttlSeconds} onChange={(e) => set('ttlSeconds', Number(e.target.value))}>
|
||||
{TTLS.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className={styles.check}>
|
||||
<input type="checkbox" checked={opts.membersCanUpload} onChange={(e) => set('membersCanUpload', e.target.checked)} />
|
||||
<span>允许成员上传</span>
|
||||
</label>
|
||||
|
||||
{failed ? <p className={styles.warn} role="alert">创建失败,请重试。</p> : null}
|
||||
<Button variant="primary" disabled={busy} onClick={() => void submit()}>{busy ? '创建中…' : '创建'}</Button>
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
.page {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||
overflow-y: auto;
|
||||
animation: page-in 0.21s ease-out;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-6) var(--space-4);
|
||||
}
|
||||
|
||||
.head {
|
||||
padding: var(--space-4) 0 var(--space-2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.actions > * {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: calc(var(--space-3) * -1) 0 0;
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@keyframes page-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { lazy, Suspense, useCallback, useState, type DragEvent } 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 { Button } from '@/ui/Button';
|
||||
import { ChannelList } from './ChannelList';
|
||||
import { CreateSheet } from './CreateSheet';
|
||||
import { QuickShare } from './QuickShare';
|
||||
import styles from './Home.module.css';
|
||||
|
||||
const ScanSheet = lazy(() => import('@/features/scan/ScanSheet').then((m) => ({ default: m.ScanSheet })));
|
||||
|
||||
/** 初始状态:什么都没创建时看到的页面。核心是「快速分享」 */
|
||||
export function Home() {
|
||||
const createDraft = useShares((s) => s.createDraft);
|
||||
const loadDemo = useChannels((s) => s.loadDemo);
|
||||
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 open = (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;
|
||||
setBusy(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
open(await createDraft(p));
|
||||
} catch {
|
||||
setFailed(true);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[busy, createDraft],
|
||||
);
|
||||
|
||||
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: '' });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page} onDragOver={onDragOver} onDragLeave={() => setDragging(false)} onDrop={onDrop}>
|
||||
<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)} />
|
||||
{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>
|
||||
|
||||
<ChannelList kind="channel" title="我的频道" onOpen={open} />
|
||||
|
||||
<p className={styles.note}>
|
||||
加密频道的密钥只在链接里,服务器看不到内容。持有链接的人可以读取该频道的全部历史。
|
||||
</p>
|
||||
{import.meta.env.DEV ? (
|
||||
<Button variant="ghost" onClick={() => { loadDemo(); loadShareDemo(); }}>载入演示数据</Button>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
{createOpen ? <CreateSheet onClose={() => setCreateOpen(false)} onCreated={open} /> : null}
|
||||
<Suspense fallback={null}>
|
||||
{scanOpen ? <ScanSheet onClose={() => setScanOpen(false)} onJoined={open} /> : null}
|
||||
</Suspense>
|
||||
{composeOpen ? (
|
||||
<ComposeSheet onClose={() => setComposeOpen(false)} onSubmit={(text) => void share({ files: [], text })} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
.card {
|
||||
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);
|
||||
background: var(--surface);
|
||||
text-align: center;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.dragging {
|
||||
border-color: var(--accent);
|
||||
background: var(--busy-soft);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.btn {
|
||||
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);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn:active:not(:disabled) {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.btn:hover:not(:disabled) {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useRef } from 'react';
|
||||
import type { ClipboardPayload } from '@/lib/clipboard';
|
||||
import { readClipboard } from '@/lib/clipboard';
|
||||
import { useMediaQuery } from '@/lib/useMediaQuery';
|
||||
import { Icon, type IconName } from '@/ui/Icon';
|
||||
import styles from './QuickShare.module.css';
|
||||
|
||||
interface Props {
|
||||
busy: boolean;
|
||||
dragging: boolean;
|
||||
onShare: (p: ClipboardPayload) => void;
|
||||
onCompose: () => void;
|
||||
}
|
||||
|
||||
/** 快速分享:拖入、粘贴或选择内容,自动创建一个临时加密频道并给出分享链接 */
|
||||
export function QuickShare({ busy, dragging, onShare, onCompose }: Props) {
|
||||
const touch = useMediaQuery('(pointer: coarse)');
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const galleryRef = useRef<HTMLInputElement>(null);
|
||||
const cameraRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function paste() {
|
||||
const clip = await readClipboard();
|
||||
if (!clip) return onCompose(); // 无权限时退回手动输入
|
||||
onShare({ files: clip.files, text: clip.files.length > 0 ? '' : clip.text.trim() });
|
||||
}
|
||||
|
||||
function pick(files: FileList | null) {
|
||||
if (files?.length) onShare({ files: [...files], text: '' });
|
||||
}
|
||||
|
||||
const buttons: { 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 = ''; }} />
|
||||
|
||||
<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>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user