feat(web): connect quick share flow

Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
justme committed 2026-10-02 00:59:37 +08:00
1 parent 53768a195d
commit e02e024605
138 files changed
+10614

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
import { useState } from 'react';
import { Home } from '@/features/home/Home';
import { JoinScreen, MissingAccess } from '@/features/scan/JoinScreen';
import { useChannels } from '@/store/channels';
import { useWorkspace } from '@/store/workspace';
import { ChannelScreen } from './ChannelScreen';
import { ShareScreen } from './ShareScreen';
import { channelPath, navigate, useRoute } from './navigation';
import { Splash } from './Splash';
const SEEN_KEY = 'wd.splash';
function splashSeen(): boolean {
try {
return sessionStorage.getItem(SEEN_KEY) === '1';
} catch {
return false;
}
}
export function App() {
const route = useRoute();
const list = useChannels((s) => s.list);
const pendingLink = useChannels((s) => s.pendingLink);
const setPendingLink = useChannels((s) => s.setPendingLink);
const wsReady = useWorkspace((s) => s.ready);
const [splash, setSplash] = useState(() => !splashSeen());
const saved = route.name === 'channel' && list.some((c) => c.id === route.id);
// 只有打开已保存的频道才需要等数据;首页与加入页没有加载过程
const ready = !saved || wsReady;
function onSplashDone() {
try {
sessionStorage.setItem(SEEN_KEY, '1');
} catch {
/* 无存储时每次都播,不影响功能 */
}
setSplash(false);
}
let screen;
if (route.name === 'home') screen = <Home />;
else if (route.name === 'share') screen = <ShareScreen id={route.id} />;
else if (saved) screen = <ChannelScreen id={route.id} />;
else if (pendingLink?.id === route.id) {
screen = (
<JoinScreen
link={pendingLink}
onJoined={(c) => navigate(channelPath(c.id))}
onCancel={() => {
setPendingLink(null);
navigate('/');
}}
/>
);
} else screen = <MissingAccess onHome={() => navigate('/')} />;
return (
<>
{screen}
{splash ? <Splash ready={ready} onDone={onSplashDone} /> : null}
</>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { useEffect } from 'react';
import { Workspace } from '@/features/channel/Workspace';
import { useWorkspace } from '@/store/workspace';
/** 打开(订阅)频道,数据就绪后显示暂存区 */
export function ChannelScreen({ id }: { id: string }) {
const open = useWorkspace((s) => s.open);
const ready = useWorkspace((s) => s.ready);
useEffect(() => open(id), [open, id]);
return ready ? <Workspace /> : null;
}
+41
View File
@@ -0,0 +1,41 @@
import { useEffect } from 'react';
import { ItemSourceContext, shareSource } from '@/features/item/source';
import { MissingAccess } from '@/features/scan/JoinScreen';
import { ShareDraft } from '@/features/share/ShareDraft';
import { ShareView } from '@/features/share/ShareView';
import { useShares } from '@/store/shares';
import { useShareSession } from '@/store/shareSession';
import { navigate } from './navigation';
/**
* 便捷分享页。已在「我的分享」里就直接打开;从链接进来的访客**直接加入**,不预览、不展示人数;
* 都不是就提示缺少链接。条目组件通过 ItemSourceContext 读分享自己的数据。
*/
export function ShareScreen({ id }: { id: string }) {
const known = useShares((s) => s.list.some((x) => x.id === id));
const pending = useShares((s) => s.pendingLink);
const join = useShares((s) => s.join);
useEffect(() => {
if (!known && pending?.id === id) void join(pending);
}, [known, pending, id, join]);
if (known) return <ShareSession id={id} />;
if (pending?.id === id) return null; // 正在加入
return <MissingAccess onHome={() => navigate('/')} />;
}
function ShareSession({ id }: { id: string }) {
const open = useShareSession((s) => s.open);
const ready = useShareSession((s) => s.ready);
const share = useShareSession((s) => s.share);
useEffect(() => open(id), [open, id]);
if (!ready || !share) return null;
return (
<ItemSourceContext.Provider value={shareSource}>
{share.state === 'draft' && share.mine ? <ShareDraft /> : <ShareView />}
</ItemSourceContext.Provider>
);
}
+76
View File
@@ -0,0 +1,76 @@
.splash {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-4);
background: var(--bg);
cursor: pointer;
}
.leaving {
animation: leave 0.2s ease-out forwards;
pointer-events: none;
}
.word {
width: min(62vw, 300px);
fill: none;
stroke: var(--text);
stroke-width: 3.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.stroke {
stroke-dasharray: 1;
stroke-dashoffset: 1;
animation: draw 0.3s ease-out forwards;
animation-delay: calc(var(--i) * 0.13s);
}
.hint {
margin: 0;
color: var(--text-dim);
font-size: 13px;
opacity: 0;
animation: fade-in 0.3s ease-out 0.6s forwards;
}
@keyframes draw {
to {
stroke-dashoffset: 0;
}
}
@keyframes fade-in {
to {
opacity: 1;
}
}
@keyframes leave {
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.stroke {
stroke-dashoffset: 0;
animation: none;
}
.hint {
opacity: 1;
animation: none;
}
.leaving {
animation: none;
opacity: 0;
}
}
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useState, type AnimationEvent } from 'react';
import styles from './Splash.module.css';
/** 单线手写 WebDrop,逐笔画出。每个字母一条路径,按序号错开 */
const STROKES = [
'M4 14 L14 58 L24 30 L34 58 L44 14',
'M52 42 H76 C76 28 66 26 60 31 C50 38 53 58 63 58 C69 59 74 56 77 52',
'M88 8 L88 58 M88 46 C92 34 106 36 106 47 C106 59 92 62 88 52',
'M120 14 L120 58 M120 14 C156 12 160 58 120 58',
'M172 30 L172 58 M172 42 C176 31 186 29 192 33',
'M204 46 C204 33 222 33 222 46 C222 59 204 59 204 46',
'M236 32 L236 74 M236 46 C240 34 256 36 256 48 C256 60 242 64 236 54',
];
const reduced = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
interface Props {
ready: boolean;
onDone: () => void;
}
export function Splash({ ready, onDone }: Props) {
const [animDone, setAnimDone] = useState(reduced);
const [skipped, setSkipped] = useState(false);
const leaving = skipped || (animDone && ready);
useEffect(() => {
const skip = () => setSkipped(true);
window.addEventListener('keydown', skip);
return () => window.removeEventListener('keydown', skip);
}, []);
function onEnd(e: AnimationEvent) {
if (e.animationName.includes('draw') && e.target instanceof SVGElement && e.target.dataset.last) setAnimDone(true);
if (e.animationName.includes('leave') && e.target === e.currentTarget) onDone();
}
return (
<div
className={`${styles.splash} ${leaving ? styles.leaving : ''}`}
onClick={() => setSkipped(true)}
onAnimationEnd={onEnd}
role="status"
aria-label="加载中"
>
<svg viewBox="0 0 262 82" className={styles.word} aria-hidden="true">
{STROKES.map((d, i) => (
<path
key={d}
d={d}
pathLength={1}
className={styles.stroke}
style={{ '--i': i } as React.CSSProperties}
data-last={i === STROKES.length - 1 ? '1' : undefined}
/>
))}
</svg>
<p className={styles.hint}>加载中</p>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { parseChannelLink, type ParsedChannelLink } from '@/lib/channelLink';
import { parseShareLink, type ParsedShareLink } from '@/lib/shareLink';
export type EntryLink =
| { kind: 'channel'; link: ParsedChannelLink }
| { kind: 'share'; link: ParsedShareLink };
/**
* 页面一加载就取出 URL fragment 里的密钥,并立刻 history.replaceState 清掉(threat-model §4)。
* 必须在任何异步代码和渲染之前同步调用。
*/
export function takeLinkFromLocation(): EntryLink | null {
if (!location.hash) return null;
const href = location.href;
history.replaceState(null, '', location.pathname + location.search);
const share = parseShareLink(href);
if (share.ok) return { kind: 'share', link: share.link };
const channel = parseChannelLink(href);
return channel.ok ? { kind: 'channel', link: channel.link } : null;
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
// navigation.ts 在模块加载时读 location/window,这里只测纯函数,用最小的桩
Object.assign(globalThis, {
location: { pathname: '/', origin: 'http://x' },
window: { addEventListener: () => {} },
history: { state: null },
});
const { parseRoute } = await import('./navigation');
describe('parseRoute', () => {
it('根路径是首页', () => expect(parseRoute('/')).toEqual({ name: 'home' }));
it('/c/:id 是频道', () => {
expect(parseRoute('/c/k3Fq9xWm2LpA7sTdQ8vZ1n')).toEqual({ name: 'channel', id: 'k3Fq9xWm2LpA7sTdQ8vZ1n' });
});
it('/s/:id 是便捷分享', () => {
expect(parseRoute('/s/k3Fq9xWm2LpA7sTdQ8vZ1n')).toEqual({ name: 'share', id: 'k3Fq9xWm2LpA7sTdQ8vZ1n' });
});
it('id 格式不对回到首页', () => expect(parseRoute('/c/short')).toEqual({ name: 'home' }));
it('未知路径回到首页', () => expect(parseRoute('/other')).toEqual({ name: 'home' }));
});
+50
View File
@@ -0,0 +1,50 @@
import { useSyncExternalStore } from 'react';
import { isSheetEntry } from '@/lib/useBackToClose';
export type Route = { name: 'home' } | { name: 'channel'; id: string } | { name: 'share'; id: string };
const CHANNEL_RE = /^\/c\/([A-Za-z0-9_-]{22})\/?$/;
const SHARE_RE = /^\/s\/([A-Za-z0-9_-]{22})\/?$/;
export function parseRoute(pathname: string): Route {
const c = CHANNEL_RE.exec(pathname);
if (c?.[1]) return { name: 'channel', id: c[1] };
const s = SHARE_RE.exec(pathname);
return s?.[1] ? { name: 'share', id: s[1] } : { name: 'home' };
}
let current: Route = parseRoute(location.pathname);
const listeners = new Set<() => void>();
const same = (a: Route, b: Route) => a.name === b.name && (a.name === 'home' || ('id' in b && a.id === b.id));
function set(route: Route) {
if (same(current, route)) return;
current = route;
for (const l of listeners) l();
}
window.addEventListener('popstate', () => set(parseRoute(location.pathname)));
/**
* 所有页面跳转的唯一入口。
* 从弹层里跳转时替换掉弹层压入的那条历史,避免返回键多退一步(lib/useBackToClose.ts)。
*/
export function navigate(path: string): void {
if (isSheetEntry()) history.replaceState(null, '', path);
else history.pushState(null, '', path);
set(parseRoute(path));
}
export function useRoute(): Route {
return useSyncExternalStore(
(cb) => {
listeners.add(cb);
return () => listeners.delete(cb);
},
() => current,
);
}
export const channelPath = (id: string) => `/c/${id}`;
export const sharePath = (id: string) => `/s/${id}`;
+32
View File
@@ -0,0 +1,32 @@
import type { ParsedChannelLink } from '@/lib/channelLink';
import type { ArchiveEntry, ChannelInfo, ConnectionState, CreateOptions, WorkspaceEvent } from './types';
/**
* 常驻频道的后端边界。真实实现接 @webdrop/core 与 api.md,现在由 mock.ts 提供假实现。
* 便捷分享有自己的边界,见 shareApi.ts。
*/
export interface WorkspaceApi {
/** 新建频道:本地生成 id 与密钥、算 PoW、POST channels(api.md) */
createChannel(opts: CreateOptions): Promise<ChannelInfo>;
/** 通过链接加入:先 GET 频道信息预览,再 POST seats */
joinChannel(link: ParsedChannelLink): Promise<ChannelInfo>;
/** 仅假实现提供:载入演示用的频道 */
loadDemo?(): ChannelInfo[];
/** 订阅某个频道的事件流(对应追加日志的推送),返回取消订阅函数 */
connect(channelId: string, onEvent: (e: WorkspaceEvent) => void): () => void;
sendText(text: string): Promise<void>;
sendFiles(files: File[]): Promise<void>;
/** 修改 = 追加一个替代旧条目的新版本,后写者胜 */
editText(id: string, text: string): Promise<void>;
resolveConflict(id: string, choice: 'mine' | 'theirs'): Promise<void>;
remove(id: string): Promise<void>;
setSnapshot(text: string): Promise<void>;
/** 取文件内容(GATED 条目会计一次下载,见 api.md);带 versionId 取旧版本 */
getBlob(id: string, versionId?: string): Promise<Blob>;
listArchive(id: string): Promise<ArchiveEntry[]>;
/** 标记「别的设备的修改」已看过(本地状态) */
markSeen(id: string): Promise<void>;
/** 仅假实现提供,用于演示连接状态 */
simulateConnection?(state: ConnectionState): void;
}
+8
View File
@@ -0,0 +1,8 @@
import type { WorkspaceApi } from './api';
import { createMockApi } from './mock';
import { createMockShareApi } from './mockShare';
import type { ShareApi } from './shareApi';
/** 接真实后端时,只改这两行 */
export const api: WorkspaceApi = createMockApi();
export const shareApi: ShareApi = createMockShareApi();
+209
View File
@@ -0,0 +1,209 @@
import type { ParsedChannelLink } from '@/lib/channelLink';
import { previewKindOf } from '@/lib/fileKind';
import type { WorkspaceApi } from './api';
import { b64url } from './mockLinks';
import { PHONE, SELF, archiveEntries, channel as demoChannel, devices as demoDevices, makeSvgBlob, makeVideoBlob, seedItems, snapshot as demoSnapshot } from './mockSeed';
import type { ChannelInfo, ConnectionState, CreateOptions, Device, FileItem, Item, Snapshot, WorkspaceEvent } from './types';
const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/** protocol §2:链接形态。enc 带 k,raw 不带 */
function buildLinks(id: string, o: CreateOptions): Pick<ChannelInfo, 'memberLink' | 'ownerLink'> {
const base = `${location.origin}/c/${id}`;
const a = b64url(32);
if (o.mode === 'enc') {
const k = b64url(32);
return { memberLink: `${base}#k=${k}`, ownerLink: `${base}#k=${k}&a=${a}` };
}
const w = o.membersCanUpload ? b64url(32) : null;
return { memberLink: w ? `${base}#w=${w}` : base, ownerLink: `${base}#a=${a}${w ? `&w=${w}` : ''}` };
}
interface Runtime {
info: ChannelInfo;
items: Map<string, Item>;
blobs: Map<string, Blob | Promise<Blob>>;
devices: Device[];
snapshot: Snapshot;
demo: boolean;
}
const selfDevice: Device = { id: SELF, name: '本机', kind: 'desktop', online: true, self: true };
/** 频道的假实现:不联网,数据都在内存里,刷新即丢。接口见 api.ts */
export function createMockApi(): WorkspaceApi {
const runtimes = new Map<string, Runtime>();
const timers = new Set<number>();
let current: Runtime | undefined;
let emit: (e: WorkspaceEvent) => void = () => {};
const later = (ms: number, fn: () => void) => {
const id = window.setTimeout(() => {
timers.delete(id);
fn();
}, ms);
timers.add(id);
};
const uid = () => `it-${Math.random().toString(36).slice(2, 10)}`;
const put = (item: Item) => {
current?.items.set(item.id, item);
emit({ t: 'item', item });
};
function newRuntime(info: ChannelInfo, devices: Device[]): Runtime {
const rt: Runtime = { info, items: new Map(), blobs: new Map(), devices, snapshot: { text: '', updatedBy: '', updatedAt: 0 }, demo: false };
runtimes.set(info.id, rt);
return rt;
}
function seedDemo(): Runtime {
const rt = newRuntime(demoChannel, demoDevices);
rt.demo = true;
rt.snapshot = demoSnapshot;
for (const item of seedItems().items) rt.items.set(item.id, item);
rt.blobs.set('it-image', makeSvgBlob());
rt.blobs.set('it-video', makeVideoBlob().then((b) => b ?? Promise.reject(new Error('unsupported'))));
rt.blobs.set('it-zip', new Blob(['mock zip'], { type: 'application/zip' }));
rt.blobs.set('it-pdf', new Blob(['%PDF-1.4 mock'], { type: 'application/pdf' }));
return rt;
}
/** 演示频道里的实时动静:iPhone 发来消息、改了文件、短暂编辑便签 */
function runDemoActivity(rt: Runtime) {
later(9000, () => put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: PHONE, state: { kind: 'complete' }, text: '到楼下了' }));
later(14000, () => {
const cur = rt.items.get('it-image');
if (cur?.kind !== 'file') return;
const prev = { id: `v${(cur.versions?.length ?? 0) + 1}`, ts: cur.ts, deviceId: cur.deviceId, size: cur.size };
put({ ...cur, ts: Date.now(), deviceId: PHONE, size: cur.size + 140, versions: [prev, ...(cur.versions ?? [])], unseen: true });
});
later(5000, () => emit({ t: 'devices', devices: rt.devices.map((d) => (d.id === PHONE ? { ...d, editing: true } : d)) }));
later(11000, () => emit({ t: 'devices', devices: rt.devices }));
}
return {
async createChannel(opts) {
await wait(300);
const id = b64url(16);
const info: ChannelInfo = { id, mode: opts.mode, role: 'owner', seats: opts.seats, createdAt: Date.now(), ...buildLinks(id, opts) };
newRuntime(info, [selfDevice]);
return info;
},
async joinChannel(link: ParsedChannelLink) {
await wait(500);
const known = runtimes.get(link.id);
if (known) return { ...known.info, role: 'member', ownerLink: undefined };
const base = `${location.origin}/c/${link.id}`;
const frag = (keys: Record<string, string | undefined>) =>
Object.entries(keys).filter(([, v]) => v).map(([k, v]) => `${k}=${v}`).join('&');
const member = frag({ k: link.keys.k, w: link.keys.w });
const info: ChannelInfo = {
id: link.id,
mode: link.mode,
role: link.isOwner ? 'owner' : 'member',
seats: 3,
createdAt: Date.now(),
memberLink: member ? `${base}#${member}` : base,
ownerLink: link.isOwner ? `${base}#${frag(link.keys)}` : undefined,
};
newRuntime(info, [selfDevice, { id: 'peer', name: '创建者的设备', kind: 'desktop', online: true }]);
return info;
},
loadDemo() {
return [(runtimes.get(demoChannel.id) ?? seedDemo()).info];
},
connect(channelId, onEvent) {
emit = onEvent;
current = runtimes.get(channelId);
const rt = current;
if (!rt) return () => {};
later(500, () => {
emit({ t: 'channel', info: rt.info });
emit({ t: 'devices', devices: rt.devices });
emit({ t: 'snapshot', snapshot: rt.snapshot });
for (const item of rt.items.values()) emit({ t: 'item', item });
emit({ t: 'connection', state: 'online' });
emit({ t: 'ready' });
});
if (rt.demo) runDemoActivity(rt);
return () => {
for (const id of timers) clearTimeout(id);
timers.clear();
emit = () => {};
};
},
async sendText(text) {
put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: SELF, state: { kind: 'complete' }, text });
},
async sendFiles(files) {
for (const file of files) {
const id = uid();
const base: FileItem = {
id, kind: 'file', ts: Date.now(), deviceId: SELF, state: { kind: 'receiving', progress: 0 },
name: file.name, mime: file.type, size: file.size, remaining: 10,
preview: previewKindOf(file.type, file.name),
};
current?.blobs.set(id, file);
put(base);
for (let p = 0.2; p < 1; p += 0.2) {
await wait(250);
put({ ...base, state: { kind: 'receiving', progress: p } });
}
await wait(250);
put({ ...base, state: { kind: 'complete' } });
}
},
async editText(id, text) {
const cur = current?.items.get(id);
if (cur?.kind !== 'text') return;
put({ ...cur, text, editedAt: Date.now() });
},
async resolveConflict(id, choice) {
const cur = current?.items.get(id);
if (cur?.kind !== 'text' || !cur.conflict) return;
put({ ...cur, text: choice === 'theirs' ? cur.conflict.text : cur.text, conflict: undefined });
},
async remove(id) {
current?.items.delete(id);
current?.blobs.delete(id);
emit({ t: 'removed', id });
},
async setSnapshot(text) {
const snapshot = { text, updatedBy: SELF, updatedAt: Date.now() };
if (current) current.snapshot = snapshot;
emit({ t: 'snapshot', snapshot });
},
async getBlob(id) {
const b = current?.blobs.get(id);
if (b) return b;
const item = current?.items.get(id);
if (item?.kind === 'text') return new Blob([item.text], { type: 'text/plain' });
throw new Error('not found');
},
async markSeen(id) {
const cur = current?.items.get(id);
if (cur?.kind !== 'file' || !cur.unseen) return;
put({ ...cur, unseen: false });
},
async listArchive() {
await wait(250);
return archiveEntries;
},
simulateConnection(state: ConnectionState) {
emit({ t: 'connection', state });
},
};
}
+5
View File
@@ -0,0 +1,5 @@
/** 假实现用的随机 id 与密钥:n 字节 → base64url 无填充(16 字节 22 字符,32 字节 43 字符) */
export function b64url(bytes: number): string {
const b = crypto.getRandomValues(new Uint8Array(bytes));
return btoa(String.fromCharCode(...b)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
+145
View File
@@ -0,0 +1,145 @@
import type { ArchiveEntry, ChannelInfo, Device, Item, Snapshot } from './types';
/** 仅供假实现使用的演示数据与演示用文件内容 */
export const SELF = 'self';
export const PHONE = 'phone';
export const DEMO_ID = 'k3Fq9xWm2LpA7sTdQ8vZ1n';
const KEY = 'Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF';
export const devices: Device[] = [
{ id: SELF, name: '本机 · Chrome', kind: 'desktop', online: true, self: true },
{ id: PHONE, name: 'iPhone', kind: 'phone', online: true },
{ id: 'tablet', name: 'iPad', kind: 'tablet', online: false, lastSeen: Date.now() - 3 * 3600_000 },
];
export const channel: ChannelInfo = {
id: DEMO_ID,
mode: 'enc',
role: 'owner',
seats: 3,
createdAt: Date.now() - 3 * 3600_000,
memberLink: `${location.origin}/c/${DEMO_ID}#k=${KEY}`,
ownerLink: `${location.origin}/c/${DEMO_ID}#k=${KEY}&a=${KEY}`,
};
export const snapshot: Snapshot = {
text: '待办\n- 取快递\n- 发周报\n- 备份相册',
updatedBy: PHONE,
updatedAt: Date.now() - 20 * 60_000,
};
export const archiveEntries: ArchiveEntry[] = [
{ path: 'project-assets/', size: 0, dir: true },
{ path: 'project-assets/README.md', size: 2_140, dir: false },
{ path: 'project-assets/design/', size: 0, dir: true },
{ path: 'project-assets/design/logo.svg', size: 8_420, dir: false },
{ path: 'project-assets/design/cover.png', size: 3_281_990, dir: false },
{ path: 'project-assets/design/mockup.fig', size: 18_204_113, dir: false },
{ path: 'project-assets/fonts/', size: 0, dir: true },
{ path: 'project-assets/fonts/SourceHanSans-Regular.otf', size: 16_532_204, dir: false },
{ path: 'project-assets/data/', size: 0, dir: true },
{ path: 'project-assets/data/export.csv', size: 412_873, dir: false },
{ path: 'project-assets/notes.txt', size: 1_822, dir: false },
];
const ago = (min: number) => Date.now() - min * 60_000;
export function seedItems(): { items: Item[]; sizeOverride: Record<string, number> } {
const logText = Array.from({ length: 180 }, (_, i) => {
const level = i % 17 === 0 ? 'WARN' : 'INFO';
return `2026-10-01 14:${String(i % 60).padStart(2, '0')}:${String((i * 7) % 60).padStart(2, '0')} ${level} worker-${i % 4} handled request #${1000 + i} in ${20 + ((i * 13) % 80)}ms`;
}).join('\n');
const items: Item[] = [
{
id: 'it-video', kind: 'file', ts: ago(3), deviceId: PHONE, state: { kind: 'complete' },
name: '屏幕录制.webm', mime: 'video/webm', size: 1_840_000, remaining: null, preview: 'video',
},
{
id: 'it-zip', kind: 'file', ts: ago(7), deviceId: PHONE, state: { kind: 'complete' },
name: 'project-assets.zip', mime: 'application/zip', size: 48_200_000, remaining: 8, preview: 'archive',
},
{
id: 'it-image', kind: 'file', ts: ago(12), deviceId: PHONE, state: { kind: 'complete' },
name: '设计稿.svg', mime: 'image/svg+xml', size: 6_420, remaining: null, preview: 'image',
versions: [
{ id: 'v2', ts: ago(55), deviceId: SELF, size: 6_110 },
{ id: 'v1', ts: ago(130), deviceId: SELF, size: 5_820 },
],
unseen: true,
},
{
id: 'it-log', kind: 'text', ts: ago(18), deviceId: SELF, state: { kind: 'complete' },
text: logText,
},
{
id: 'it-conflict', kind: 'text', ts: ago(26), deviceId: SELF, state: { kind: 'complete' },
text: '会议室 B204', editedAt: ago(25), conflict: { deviceId: PHONE, text: '会议室 B305' },
},
{
id: 'it-json', kind: 'text', ts: ago(30), deviceId: PHONE, state: { kind: 'complete' },
text: '{"name":"webdrop","version":1,"tags":["share","e2ee"],"limits":{"seats":3,"ttl":86400}}',
},
{
id: 'it-short', kind: 'text', ts: ago(41), deviceId: PHONE, state: { kind: 'complete' },
text: '明天 10 点开会,带电脑',
},
{
id: 'it-pdf', kind: 'file', ts: ago(95), deviceId: SELF, state: { kind: 'complete' },
name: 'invoice-2026-09.pdf', mime: 'application/pdf', size: 1_240_000, remaining: null, preview: 'none',
},
];
return { items, sizeOverride: {} };
}
export function makeSvgBlob(): Blob {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 400">
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#d9d9d4"/><stop offset="1" stop-color="#8f8f88"/></linearGradient></defs>
<rect width="640" height="400" fill="url(#g)"/>
<rect x="60" y="60" width="520" height="280" rx="12" fill="#fff" opacity=".85"/>
<text x="320" y="215" font-size="42" text-anchor="middle" font-family="sans-serif" fill="#1a1a1a">设计稿 · 演示图片</text></svg>`;
return new Blob([svg], { type: 'image/svg+xml' });
}
/** 用 canvas + MediaRecorder 临时生成一段几秒的演示视频;浏览器不支持则返回 null */
export async function makeVideoBlob(): Promise<Blob | null> {
try {
if (typeof MediaRecorder === 'undefined') return null;
const canvas = document.createElement('canvas');
canvas.width = 480;
canvas.height = 270;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
const rec = new MediaRecorder(canvas.captureStream(24), { mimeType: 'video/webm' });
const chunks: Blob[] = [];
rec.ondataavailable = (e) => chunks.push(e.data);
const done = new Promise<Blob>((res) => {
rec.onstop = () => res(new Blob(chunks, { type: 'video/webm' }));
});
rec.start();
const t0 = performance.now();
await new Promise<void>((resolve) => {
const draw = () => {
const t = (performance.now() - t0) / 1000;
ctx.fillStyle = `hsl(${(t * 60) % 360} 10% ${30 + 10 * Math.sin(t * 2)}%)`;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#fff';
ctx.font = '28px sans-serif';
ctx.fillText(`演示视频 ${t.toFixed(1)}s`, 150, 140);
if (t < 3) requestAnimationFrame(draw);
else resolve();
};
draw();
});
rec.stop();
return await done;
} catch {
return null;
}
}
export function makeLogBlob(text: string): Blob {
return new Blob([text], { type: 'text/plain' });
}
+191
View File
@@ -0,0 +1,191 @@
import { previewKindOf } from '@/lib/fileKind';
import type { ShareApi } from './shareApi';
import { b64url } from './mockLinks';
import { PHONE, archiveEntries, makeSvgBlob, seedItems } from './mockSeed';
import type { FileItem, Item, Share, ShareEvent, ShareSettings } from './types';
const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
const CREATOR = 'creator';
export const DEMO_SHARE_ID = 'ShareDemoShareDemoAbcd';
const DEMO_KEY = 'Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF';
interface Runtime {
share: Share;
items: Map<string, Item>;
blobs: Map<string, Blob | Promise<Blob>>;
}
/** 便捷分享的假实现:不联网,数据都在内存里,刷新即丢。接口见 shareApi.ts */
export function createMockShareApi(): ShareApi {
const runtimes = new Map<string, Runtime>();
let current: Runtime | undefined;
let emit: (e: ShareEvent) => void = () => {};
const timers = new Set<number>();
const uid = () => `it-${Math.random().toString(36).slice(2, 10)}`;
const put = (item: Item) => {
current?.items.set(item.id, item);
emit({ t: 'item', item });
};
const draftOnly = () => {
if (!current || current.share.state !== 'draft') throw new Error('forbidden');
return current;
};
function seedDemo(): Runtime {
const share: Share = {
id: DEMO_SHARE_ID,
state: 'sealed',
mine: false,
link: `${location.origin}/s/${DEMO_SHARE_ID}#k=${DEMO_KEY}`,
createdAt: Date.now() - 2 * 3600_000,
ttlSeconds: 86400,
seats: 10,
maxDownloads: 5,
};
const rt: Runtime = { share, items: new Map(), blobs: new Map() };
const keep = new Set(['it-zip', 'it-image', 'it-json', 'it-short', 'it-pdf']);
for (const item of seedItems().items) {
if (!keep.has(item.id)) continue;
rt.items.set(item.id, item.kind === 'file'
? { ...item, deviceId: CREATOR, versions: undefined, unseen: false, remaining: share.maxDownloads }
: { ...item, deviceId: CREATOR });
}
rt.blobs.set('it-image', makeSvgBlob());
rt.blobs.set('it-zip', new Blob(['mock zip'], { type: 'application/zip' }));
rt.blobs.set('it-pdf', new Blob(['%PDF-1.4 mock'], { type: 'application/pdf' }));
runtimes.set(share.id, rt);
return rt;
}
return {
async createDraft(settings) {
await wait(300);
const id = b64url(16);
const share: Share = {
id,
state: 'draft',
mine: true,
link: `${location.origin}/s/${id}#k=${b64url(32)}`,
createdAt: Date.now(),
...settings,
};
runtimes.set(id, { share, items: new Map(), blobs: new Map() });
return share;
},
async updateSettings(id, patch: Partial<ShareSettings>) {
const rt = runtimes.get(id);
if (!rt || rt.share.state !== 'draft') throw new Error('forbidden');
rt.share = { ...rt.share, ...patch };
emit({ t: 'share', share: rt.share });
return rt.share;
},
async seal(id) {
await wait(300);
const rt = runtimes.get(id);
if (!rt || rt.share.state !== 'draft') throw new Error('forbidden');
// 丢弃所有者密钥:此后没有人能改,创建者也只是一个读者
rt.share = { ...rt.share, state: 'sealed' };
for (const item of rt.items.values()) {
if (item.kind === 'file') rt.items.set(item.id, { ...item, remaining: rt.share.maxDownloads });
}
emit({ t: 'share', share: rt.share });
for (const item of rt.items.values()) emit({ t: 'item', item });
return rt.share;
},
async join(link) {
await wait(300);
const known = runtimes.get(link.id);
if (known) return known.share;
// 原型里不认识的链接:当作一个已封存、没有内容的分享
const share: Share = {
id: link.id,
state: 'sealed',
mine: false,
link: `${location.origin}/s/${link.id}#k=${link.key}`,
createdAt: Date.now(),
ttlSeconds: 86400,
seats: 10,
maxDownloads: null,
};
runtimes.set(link.id, { share, items: new Map(), blobs: new Map() });
return share;
},
loadDemo() {
return [(runtimes.get(DEMO_SHARE_ID) ?? seedDemo()).share];
},
connect(shareId, onEvent) {
emit = onEvent;
current = runtimes.get(shareId);
const rt = current;
if (!rt) return () => {};
const id = window.setTimeout(() => {
timers.delete(id);
emit({ t: 'share', share: rt.share });
for (const item of rt.items.values()) emit({ t: 'item', item });
emit({ t: 'ready' });
}, 400);
timers.add(id);
return () => {
for (const t of timers) clearTimeout(t);
timers.clear();
emit = () => {};
};
},
async addText(text) {
draftOnly();
put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: PHONE, state: { kind: 'complete' }, text });
},
async addFiles(files) {
const rt = draftOnly();
for (const file of files) {
const id = uid();
const base: FileItem = {
id, kind: 'file', ts: Date.now(), deviceId: PHONE, state: { kind: 'receiving', progress: 0 },
name: file.name, mime: file.type, size: file.size, remaining: null,
preview: previewKindOf(file.type, file.name),
};
rt.blobs.set(id, file);
put(base);
for (let p = 0.25; p < 1; p += 0.25) {
await wait(200);
put({ ...base, state: { kind: 'receiving', progress: p } });
}
await wait(200);
put({ ...base, state: { kind: 'complete' } });
}
},
async removeItem(id) {
const rt = draftOnly();
rt.items.delete(id);
rt.blobs.delete(id);
emit({ t: 'removed', id });
},
async getBlob(id) {
const item = current?.items.get(id);
// 封存后每次拉取消耗一次下载(GATED,api.md);用完即 410
if (item?.kind === 'file' && current?.share.state === 'sealed' && item.remaining !== null) {
if (item.remaining <= 0) throw new Error('gone');
put({ ...item, remaining: item.remaining - 1 });
}
const b = current?.blobs.get(id);
if (b) return b;
if (item?.kind === 'text') return new Blob([item.text], { type: 'text/plain' });
throw new Error('not found');
},
async listArchive() {
await wait(250);
return archiveEntries;
},
};
}
+32
View File
@@ -0,0 +1,32 @@
import type { ParsedShareLink } from '@/lib/shareLink';
import type { ArchiveEntry, Share, ShareEvent, ShareSettings } from './types';
/**
* 便捷分享的后端边界。和频道分开:分享没有设备、便签、冲突与历史,只有
* 「草稿 → 封存 → 过期」。线路上仍是 channels(成员不可上传),所以由同一个 core 会话实现。
*/
export interface ShareApi {
/** 新建草稿:创建者握着所有者密钥。此时还没有可分享的链接 */
createDraft(settings: ShareSettings): Promise<Share>;
/** 草稿阶段调整设置(PATCH settings,需所有者令牌) */
updateSettings(id: string, patch: Partial<ShareSettings>): Promise<Share>;
/**
* 完成分享:创建者丢弃所有者密钥 A,此后任何人都不能追加、改设置或删除,只能等 TTL。
* 客户端丢弃是自觉行为;让服务端也强制,需要后端提供「封存」使 owner_hash 失效。
*/
seal(id: string): Promise<Share>;
/** 访客通过链接直接加入(不预览、不展示人数),返回这个分享 */
join(link: ParsedShareLink): Promise<Share>;
/** 仅假实现提供:载入演示用的分享 */
loadDemo?(): Share[];
/** 订阅某个分享的事件流,返回取消订阅函数 */
connect(shareId: string, onEvent: (e: ShareEvent) => void): () => void;
/** 草稿阶段添加与移除内容 */
addText(text: string): Promise<void>;
addFiles(files: File[]): Promise<void>;
removeItem(id: string): Promise<void>;
/** 取内容(封存后每次拉取消耗一次下载,见 api.md) */
getBlob(id: string): Promise<Blob>;
listArchive(id: string): Promise<ArchiveEntry[]>;
}
+138
View File
@@ -0,0 +1,138 @@
export type DeviceKind = 'desktop' | 'phone' | 'tablet';
export type ConnectionState = 'online' | 'reconnecting' | 'offline';
export type PreviewKind = 'image' | 'video' | 'audio' | 'text' | 'archive' | 'none';
export interface Device {
id: string;
name: string;
kind: DeviceKind;
online: boolean;
self?: boolean;
/** 正在编辑便签 */
editing?: boolean;
lastSeen?: number;
}
/** 常驻频道:自己多台设备之间长期同步 */
export interface ChannelInfo {
id: string;
mode: 'enc' | 'raw';
role: 'owner' | 'member';
seats: number;
memberLink: string;
/** 只有所有者有 */
ownerLink?: string;
createdAt: number;
}
/** 新建频道的选项(protocol §12 设置的子集) */
export interface CreateOptions {
mode: 'enc' | 'raw';
seats: number;
ttlSeconds: number;
membersCanUpload: boolean;
}
/**
* 便捷分享:把一批文件和文本做成只读清单发给别人。草稿阶段创建者握着所有者密钥,
* 完成分享时丢弃,此后无人可改,只能等过期。始终是加密模式。
*/
export interface Share {
id: string;
/** draft:创建者可编辑;sealed:已封存,只读 */
state: 'draft' | 'sealed';
/** 创建在本机 */
mine: boolean;
/** 访客链接 `/s/:id#k=…`。草稿阶段不展示 */
link: string;
createdAt: number;
ttlSeconds: number;
/** 最多访客(创建者的设置,不向访客展示) */
seats: number;
/** 每个文件最多下载次数,null 为不限(protocol §12 file_max_downloads) */
maxDownloads: number | null;
}
/** 草稿阶段可调整的设置 */
export interface ShareSettings {
ttlSeconds: number;
seats: number;
maxDownloads: number | null;
}
/** protocol §7、§14:由帧推导,不单独存储 */
export type ItemState =
| { kind: 'receiving'; progress: number }
| { kind: 'complete' }
| { kind: 'incomplete' }
| { kind: 'tampered' };
interface ItemBase {
id: string;
ts: number;
deviceId: string;
state: ItemState;
}
export interface TextItem extends ItemBase {
kind: 'text';
text: string;
editedAt?: number;
/** 另一台设备同时改过(后写者胜,见 ui-design §8) */
conflict?: { deviceId: string; text: string };
}
export interface FileItem extends ItemBase {
kind: 'file';
name: string;
mime: string;
size: number;
/** 剩余下载次数,null 为不限(protocol §12 file_max_downloads) */
remaining: number | null;
preview: PreviewKind;
/**
* 旧版本,新的在前,不含当前版本。
* 约定(待写入 protocol §7):新版本是一个新条目,其加密的 meta_json 带 `replaces`,
* 客户端据此把它们折叠成同一个文件的历史。后端不解析 meta。
*/
versions?: FileVersion[];
/** 当前版本是别的设备改的,而你还没看过 */
unseen?: boolean;
}
export interface FileVersion {
id: string;
ts: number;
deviceId: string;
size: number;
}
export type Item = TextItem | FileItem;
export interface ArchiveEntry {
path: string;
size: number;
dir: boolean;
}
/** protocol §9:频道唯一的快照槽,最后写入者胜 */
export interface Snapshot {
text: string;
updatedBy: string;
updatedAt: number;
}
export type WorkspaceEvent =
| { t: 'ready' }
| { t: 'connection'; state: ConnectionState }
| { t: 'channel'; info: ChannelInfo }
| { t: 'devices'; devices: Device[] }
| { t: 'item'; item: Item }
| { t: 'removed'; id: string }
| { t: 'snapshot'; snapshot: Snapshot };
export type ShareEvent =
| { t: 'ready' }
| { t: 'share'; share: Share }
| { t: 'item'; item: Item }
| { t: 'removed'; id: string };
@@ -0,0 +1,72 @@
.action {
display: flex;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
color: var(--text);
cursor: pointer;
}
.action:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.dock {
position: sticky;
bottom: 0;
z-index: 10;
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
padding: var(--space-1) var(--space-2);
padding-bottom: calc(var(--space-1) + env(safe-area-inset-bottom));
background: var(--glass);
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-backdrop-filter: var(--blur-bar);
backdrop-filter: var(--blur-bar);
view-transition-name: bar-bottom;
}
.dock .action {
flex-direction: column;
gap: 2px;
min-height: 56px;
border-radius: var(--radius-md);
font-size: 12px;
}
.toolbar {
display: flex;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--line);
}
.toolbar .action {
flex: 1;
gap: var(--space-2);
min-height: 36px;
border: 1px solid var(--line-strong);
border-radius: var(--radius-md);
background: var(--surface);
}
@media (hover: hover) {
.toolbar .action:hover {
background: var(--accent-soft);
}
}
.dock .action:active {
background: var(--accent-soft);
}
@media (height < 500px) {
.dock .action {
flex-direction: row;
gap: var(--space-2);
min-height: 44px;
}
}
+67
View File
@@ -0,0 +1,67 @@
import { useRef } from 'react';
import { readClipboard } from '@/lib/clipboard';
import { useMediaQuery } from '@/lib/useMediaQuery';
import { Icon, type IconName } from '@/ui/Icon';
import styles from './ActionBar.module.css';
interface Props {
/** dock:手机底部动作栏;toolbar:电脑列表上方的工具条 */
layout: 'dock' | 'toolbar';
/** 添加文件、文本:频道发到频道里,分享放进草稿 */
onFiles: (files: File[]) => void;
onText: (text: string) => void;
onCompose: () => void;
/** 不传则不显示扫码(分享草稿里不需要) */
onScan?: () => void;
}
interface Action {
key: string;
label: string;
icon: IconName;
/** 触屏专有(相册、拍照) */
touchOnly?: boolean;
run: () => void;
}
export function ActionBar({ layout, onFiles, onText, onCompose, onScan }: 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(); // 无权限时退回到手动输入
if (clip.files.length > 0) onFiles(clip.files);
else if (clip.text.trim()) onText(clip.text.trim());
}
const actions: Action[] = [
{ key: 'paste', label: '粘贴', icon: 'paste', run: () => void paste() },
{ key: 'file', label: '文件', icon: 'file', run: () => fileRef.current?.click() },
{ key: 'gallery', label: '相册', icon: 'image', touchOnly: true, run: () => galleryRef.current?.click() },
{ key: 'camera', label: '拍照', icon: 'camera', touchOnly: true, run: () => cameraRef.current?.click() },
...(onScan ? [{ key: 'scan', label: '扫码', icon: 'scan' as const, run: onScan }] : []),
{ key: 'write', label: '写', icon: 'edit', run: onCompose },
];
const shown = actions.filter((a) => layout === 'dock' || touch || !a.touchOnly);
function onPick(files: FileList | null) {
if (files?.length) onFiles([...files]);
}
return (
<div className={`${styles.bar} ${styles[layout]}`} role="toolbar" aria-label="添加内容">
<input ref={fileRef} type="file" multiple hidden onChange={(e) => { onPick(e.target.files); e.target.value = ''; }} />
<input ref={galleryRef} type="file" accept="image/*,video/*" multiple hidden onChange={(e) => { onPick(e.target.files); e.target.value = ''; }} />
<input ref={cameraRef} type="file" accept="image/*" capture="environment" hidden onChange={(e) => { onPick(e.target.files); e.target.value = ''; }} />
{shown.map((a) => (
<button key={a.key} type="button" className={styles.action} onClick={a.run}>
<Icon name={a.icon} size={layout === 'dock' ? 22 : 18} />
<span>{a.label}</span>
</button>
))}
</div>
);
}
@@ -0,0 +1,13 @@
.input {
width: 100%;
padding: var(--space-3);
border: 1px solid var(--line-strong);
border-radius: var(--radius-md);
background: var(--surface);
resize: vertical;
}
.input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
+38
View File
@@ -0,0 +1,38 @@
import { useState, type KeyboardEvent } from 'react';
import { Button } from '@/ui/Button';
import { Sheet } from '@/ui/Sheet';
import styles from './ComposeSheet.module.css';
export function ComposeSheet({ onClose, onSubmit }: { onClose: () => void; onSubmit: (text: string) => void }) {
const [text, setText] = useState('');
const empty = text.trim() === '';
function submit() {
if (empty) return;
onSubmit(text.trim());
onClose();
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) submit();
}
return (
<Sheet
title="写文本"
placement="top"
onClose={onClose}
actions={<Button variant="primary" icon="send" disabled={empty} onClick={submit}>发送</Button>}
>
<textarea
className={styles.input}
autoFocus
rows={6}
value={text}
placeholder="输入或粘贴文本"
onChange={(e) => setText(e.target.value)}
onKeyDown={onKeyDown}
/>
</Sheet>
);
}
@@ -0,0 +1,111 @@
.panel {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.h {
margin: 0 0 var(--space-3);
font-size: 13px;
font-weight: 600;
}
.dim {
margin-left: var(--space-2);
color: var(--text-dim);
font-weight: 400;
}
.list {
margin: 0;
padding: 0;
list-style: none;
}
.device {
display: flex;
align-items: center;
gap: var(--space-3);
min-height: 40px;
border-bottom: 1px solid var(--line);
}
.name {
flex: 1;
min-width: 0;
}
.online,
.offline {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: 12px;
}
.online {
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;
}
.offline {
color: var(--text-dim);
}
.link {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.label {
font-size: 12px;
color: var(--text-dim);
}
.warn {
margin-left: var(--space-1);
color: var(--warn);
}
.code {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
background: var(--surface-2);
font-family: var(--font-mono);
font-size: 12px;
overflow-wrap: anywhere;
}
.row {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.note {
margin: 0;
color: var(--text-dim);
font-size: 12px;
}
@keyframes breathe {
50% {
opacity: 0.4;
}
}
@media (prefers-reduced-motion: reduce) {
.online::before {
animation: none;
}
}
+77
View File
@@ -0,0 +1,77 @@
import { useState } from 'react';
import { formatAgo } from '@/lib/format';
import { useWorkspace } from '@/store/workspace';
import { Button } from '@/ui/Button';
import { CopyButton } from '@/ui/CopyButton';
import { Icon } from '@/ui/Icon';
import { QrCode } from '@/ui/QrCode';
import styles from './DevicePanel.module.css';
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 [showOwner, setShowOwner] = useState(false);
const [showQr, setShowQr] = useState(qrOpen);
const online = devices.filter((d) => d.online).length;
return (
<aside className={styles.panel}>
<section>
<h2 className={styles.h}>设备 <span className={styles.dim}>{online}/{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>
</li>
))}
</ul>
</section>
<section>
<h2 className={styles.h}>分享</h2>
<div className={styles.link}>
<div className={styles.label}>成员链接</div>
<code className={styles.code}>{channel?.memberLink}</code>
<div className={styles.row}>
<CopyButton text={channel?.memberLink ?? ''} />
<Button variant="ghost" icon="scan" aria-expanded={showQr} onClick={() => setShowQr((v) => !v)}>
{showQr ? '收起二维码' : '二维码'}
</Button>
</div>
{showQr && channel ? <QrCode text={channel.memberLink} label="成员链接二维码" /> : null}
</div>
{channel?.ownerLink ? (
<div className={styles.link}>
<div className={styles.label}>所有者链接 <span className={styles.warn}>含管理权限</span></div>
<code className={styles.code}>{showOwner ? channel.ownerLink : '••••••••••••••••••••'}</code>
<div className={styles.row}>
<Button variant="ghost" icon="eye" onClick={() => setShowOwner((v) => !v)}>{showOwner ? '隐藏' : '显示'}</Button>
<CopyButton text={channel.ownerLink} />
</div>
</div>
) : null}
<p className={styles.note}>
持有链接即可访问,并能读取此频道的全部历史内容。不隐藏 IP 与访问时间。
</p>
</section>
{import.meta.env.DEV ? (
<section>
<h2 className={styles.h}>演示</h2>
<div className={styles.row}>
<Button variant="ghost" onClick={() => simulate('online')}>在线</Button>
<Button variant="ghost" onClick={() => simulate('reconnecting')}>重连中</Button>
<Button variant="ghost" onClick={() => simulate('offline')}>离线</Button>
</div>
</section>
) : null}
</aside>
);
}
@@ -0,0 +1,55 @@
.bar {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--line);
}
.input {
min-height: 36px;
padding: 0 var(--space-3);
border: 1px solid var(--line-strong);
border-radius: var(--radius-md);
background: var(--surface);
}
.input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.chips {
display: flex;
gap: var(--space-1);
overflow-x: auto;
scrollbar-width: none;
}
.chip {
flex: none;
min-height: 32px;
padding: 0 var(--space-3);
border: 1px solid var(--line-strong);
border-radius: var(--radius-md);
background: transparent;
color: var(--text-dim);
cursor: pointer;
}
.chip[aria-checked="true"] {
border-color: var(--accent);
background: var(--accent);
color: var(--on-accent);
}
.chip:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@media (pointer: coarse) {
.chip {
min-height: 40px;
}
}
+49
View File
@@ -0,0 +1,49 @@
import type { ItemFilter, KindFilter } from '@/lib/filter';
import styles from './FilterBar.module.css';
const KINDS: { value: KindFilter; label: string }[] = [
{ value: 'all', label: '全部' },
{ value: 'file', label: '文件' },
{ value: 'text', label: '文本' },
{ value: 'image', label: '图片' },
{ value: 'video', label: '视频' },
{ value: 'archive', label: '压缩包' },
];
interface Props {
filter: ItemFilter;
searchOpen: boolean;
onChange: (f: ItemFilter) => void;
}
/** 类型分组常驻;搜索框由顶栏的放大镜展开 */
export function FilterBar({ filter, searchOpen, onChange }: Props) {
return (
<div className={styles.bar}>
{searchOpen ? (
<input
className={styles.input}
type="search"
autoFocus
placeholder="搜索文本内容或文件名"
value={filter.query}
onChange={(e) => onChange({ ...filter, query: e.target.value })}
/>
) : null}
<div className={styles.chips} role="radiogroup" aria-label="类型">
{KINDS.map((k) => (
<button
key={k.value}
type="button"
role="radio"
aria-checked={filter.kind === k.value}
className={styles.chip}
onClick={() => onChange({ ...filter, kind: k.value })}
>
{k.label}
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,16 @@
.list {
margin: 0;
padding: 0;
list-style: none;
}
.list > li {
content-visibility: auto;
contain-intrinsic-size: auto 96px;
}
.empty {
margin: 0;
padding: var(--space-6) var(--space-4);
color: var(--text-dim);
}
+30
View File
@@ -0,0 +1,30 @@
import { CompactRow } from '@/features/item/CompactRow';
import styles from './ItemList.module.css';
interface Props {
ids: string[];
selectedId?: string;
/** 桌面端才有多选 */
checked?: ReadonlySet<string>;
empty: string;
onSelect: (id: string) => void;
onToggle?: (id: string) => void;
}
export function ItemList({ ids, selectedId, checked, empty, onSelect, onToggle }: Props) {
if (ids.length === 0) return <p className={styles.empty}>{empty}</p>;
return (
<ul className={styles.list}>
{ids.map((id) => (
<CompactRow
key={id}
id={id}
selected={id === selectedId}
checked={checked?.has(id)}
onSelect={onSelect}
onToggle={onToggle}
/>
))}
</ul>
);
}
@@ -0,0 +1,76 @@
.card {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-3) var(--space-4) var(--space-4);
border-bottom: 1px solid var(--line);
background: linear-gradient(to bottom, var(--surface-2), transparent);
}
.label {
color: var(--text-dim);
font-size: 12px;
font-weight: 600;
}
.open {
display: flex;
align-items: center;
gap: var(--space-3);
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
text-align: left;
}
.text {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
display: -webkit-box;
overflow: hidden;
font-size: 16px;
font-weight: 600;
overflow-wrap: anywhere;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.sub {
color: var(--text-dim);
font-size: 12px;
}
.actions {
display: flex;
gap: var(--space-2);
}
.actions > * {
flex: 1;
min-height: 44px;
}
.bar {
height: 4px;
overflow: hidden;
border-radius: var(--radius-sm);
background: var(--busy-soft);
}
.fill {
height: 100%;
background: var(--progress-gradient);
transition: width 0.25s linear;
}
/* 横屏手机高度很小,首屏留给列表 */
@media (height < 500px) {
.card {
display: none;
}
}
+58
View File
@@ -0,0 +1,58 @@
import { formatSize, formatTime } from '@/lib/format';
import { useDownload } from '@/features/item/download';
import { InlineContent, inlineKindOf } from '@/features/item/InlineContent';
import { shortDevice, titleOf } from '@/features/item/summary';
import { Tile } from '@/features/item/Tile';
import { useWorkspace } from '@/store/workspace';
import { Button } from '@/ui/Button';
import { CopyButton } from '@/ui/CopyButton';
import styles from './LatestCard.module.css';
interface Props {
id: string;
onOpen: (id: string) => void;
}
/** 手机首屏:最新一项放大,拇指区一键取走 */
export function LatestCard({ id, onOpen }: Props) {
const item = useWorkspace((s) => s.byId[id]);
const device = useWorkspace((s) => s.devices.find((d) => d.id === s.byId[id]?.deviceId)?.name);
const download = useDownload();
if (!item) return null;
const receiving = item.state.kind === 'receiving' ? item.state.progress : null;
const inline = inlineKindOf(item);
const size = item.kind === 'file' ? item.size : new Blob([item.text]).size;
return (
<section className={styles.card} aria-label="最新">
<div className={styles.label}>最新</div>
<button type="button" className={styles.open} onClick={() => onOpen(id)}>
{inline ? null : <Tile item={item} size={56} />}
<span className={styles.text}>
{inline === 'text' ? null : <span className={styles.title}>{titleOf(item)}</span>}
<span className={styles.sub}>
{shortDevice(device)} · {formatTime(item.ts)} · {formatSize(size)}
</span>
</span>
</button>
{inline ? <InlineContent item={item} onOpen={() => onOpen(id)} /> : null}
{receiving !== null ? (
<div className={styles.bar} role="progressbar" aria-valuenow={Math.round(receiving * 100)}>
<div className={styles.fill} style={{ width: `${Math.round(receiving * 100)}%` }} />
</div>
) : (
<div className={styles.actions}>
{item.kind === 'text' ? (
<CopyButton text={item.text} variant="primary" />
) : (
<>
{item.preview !== 'none' ? <Button variant="primary" icon="eye" onClick={() => onOpen(id)}>预览</Button> : null}
<Button variant="primary" icon="download" onClick={() => void download(item)}>下载</Button>
</>
)}
</div>
)}
</section>
);
}
@@ -0,0 +1,74 @@
.pad {
padding: var(--space-2) var(--space-4);
border-bottom: 1px solid var(--line);
}
.head {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
min-height: 40px;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
font-size: 13px;
text-align: left;
}
.head:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.chev {
display: inline-flex;
color: var(--text-dim);
transition: rotate 0.15s;
}
.open {
rotate: 90deg;
}
.title {
font-weight: 600;
}
.preview {
flex: 1;
min-width: 0;
overflow: hidden;
color: var(--text-dim);
text-overflow: ellipsis;
white-space: nowrap;
}
.editing {
margin-left: auto;
color: var(--busy);
font-size: 12px;
white-space: nowrap;
}
.input {
width: 100%;
margin-bottom: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--surface);
resize: vertical;
}
.input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
@media (prefers-reduced-motion: reduce) {
.chev {
transition: none;
}
}
+48
View File
@@ -0,0 +1,48 @@
import { useEffect, useRef, useState } from 'react';
import { useWorkspace } from '@/store/workspace';
import { Icon } from '@/ui/Icon';
import styles from './Notepad.module.css';
const SAVE_DELAY_MS = 400;
/** 共享便签:频道唯一的快照槽,最后写入者胜(protocol §9)。默认折叠,不占首屏 */
export function Notepad({ defaultOpen = false }: { defaultOpen?: boolean }) {
const snapshot = useWorkspace((s) => s.snapshot);
const setSnapshot = useWorkspace((s) => s.setSnapshot);
const editor = useWorkspace((s) => s.devices.find((d) => d.editing && !d.self)?.name);
const [open, setOpen] = useState(defaultOpen);
const [draft, setDraft] = useState<string | null>(null);
const timer = useRef<number | undefined>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
function onChange(text: string) {
setDraft(text);
clearTimeout(timer.current);
timer.current = window.setTimeout(() => void setSnapshot(text), SAVE_DELAY_MS);
}
const text = draft ?? snapshot.text;
const firstLine = text.split('\n').find((l) => l.trim() !== '') ?? '空';
return (
<section className={styles.pad}>
<button type="button" className={styles.head} aria-expanded={open} onClick={() => setOpen((v) => !v)}>
<span className={`${styles.chev} ${open ? styles.open : ''}`}><Icon name="chevronRight" size={16} /></span>
<span className={styles.title}>便签</span>
{open ? null : <span className={styles.preview}>{firstLine}</span>}
{editor ? <span className={styles.editing}>{editor} 正在编辑</span> : null}
</button>
{open ? (
<textarea
className={styles.input}
rows={4}
placeholder="常改的草稿放这里,所有设备实时同步"
value={text}
onChange={(e) => onChange(e.target.value)}
onBlur={() => setDraft(null)}
/>
) : null}
</section>
);
}
@@ -0,0 +1,50 @@
.rail,
.open {
grid-row: 1 / -1;
grid-column: 3;
border-left: 1px solid var(--line);
background: var(--surface);
}
.rail {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
width: 56px;
padding: var(--space-3) 0;
}
.count {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 12px;
}
.dot {
width: 8px;
height: 8px;
border-radius: var(--radius-lg);
background: var(--ok);
}
.open {
width: 320px;
padding: var(--space-3) var(--space-4) var(--space-4);
overflow-y: auto;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-3);
}
.heading {
font-weight: 600;
}
+39
View File
@@ -0,0 +1,39 @@
import { useWorkspace } from '@/store/workspace';
import { Button } from '@/ui/Button';
import { DevicePanel } from './DevicePanel';
import styles from './SideRail.module.css';
interface Props {
open: boolean;
qrOpen: boolean;
onToggle: () => void;
}
/** 电脑右侧的设备与分享:低频,默认收成窄栏,需要时展开 */
export function SideRail({ open, qrOpen, onToggle }: Props) {
const online = useWorkspace((s) => s.devices.filter((d) => d.online).length);
const seats = useWorkspace((s) => s.channel?.seats);
if (open) {
return (
<aside className={styles.open}>
<div className={styles.head}>
<span className={styles.heading}>设备与分享</span>
<Button variant="ghost" icon="chevronRight" label="收起" onClick={onToggle} />
</div>
<DevicePanel qrOpen={qrOpen} />
</aside>
);
}
return (
<aside className={styles.rail}>
<Button variant="ghost" icon="chevronLeft" label="展开设备与分享" onClick={onToggle} />
<span className={styles.count} title="在线设备">
<span className={styles.dot} />
{online}/{seats}
</span>
<Button variant="ghost" icon="share" label="分享" onClick={onToggle} />
</aside>
);
}
+140
View File
@@ -0,0 +1,140 @@
.bar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2) var(--space-3);
min-height: var(--bar-h);
padding: 0 var(--space-4);
padding-top: env(safe-area-inset-top);
background: var(--glass);
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-backdrop-filter: var(--blur-bar);
backdrop-filter: var(--blur-bar);
view-transition-name: bar-top;
}
/* 底边渐隐,代替一条硬分隔线 */
.bar::after {
content: "";
position: absolute;
inset: 100% 0 auto;
height: 14px;
background: var(--fade-down);
pointer-events: none;
}
.raw::after {
height: 3px;
background: var(--warn-bar);
}
.id {
display: flex;
flex: 1;
align-items: center;
gap: var(--space-2);
min-width: 0;
}
.name {
min-width: 0;
overflow: hidden;
font-family: var(--font-mono);
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge,
.badgeWarn {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 1px var(--space-2);
border-radius: var(--radius-sm);
font-size: 12px;
white-space: nowrap;
}
.badge {
background: var(--ok-soft);
color: var(--ok);
}
.badgeWarn {
background: var(--warn-soft);
color: var(--warn);
}
.presence {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-2);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--text-dim);
font-size: 13px;
cursor: pointer;
}
.presence:disabled {
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);
}
.active {
background: var(--accent-soft);
color: var(--busy);
}
.conn {
font-size: 12px;
}
.online {
display: none;
}
.reconnecting {
color: var(--busy);
}
.offline {
color: var(--danger);
}
/* 窄屏(如 iPhone SE、小屏 Android):只留在线点,收起数字与徽标文字 */
@media (width < 360px) {
.count {
display: none;
}
.bar {
gap: var(--space-1);
padding-inline: var(--space-3);
}
}
+64
View File
@@ -0,0 +1,64 @@
import { useWorkspace } from '@/store/workspace';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { ThemeMenu } from '@/ui/ThemeMenu';
import styles from './TopBar.module.css';
const CONNECTION_TEXT = { online: '在线', reconnecting: '重连中', offline: '离线' } as const;
interface Props {
onBack: () => void;
/** 手机上点开设备与分享面板;桌面端面板在右侧栏,不传 */
onOpenPanel?: () => void;
searchOpen: boolean;
/** 有筛选条件在生效 */
filtering: boolean;
onToggleSearch: () => void;
}
export function TopBar({ onBack, onOpenPanel, 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 raw = channel?.mode === 'raw';
return (
<header className={`${styles.bar} ${raw ? styles.raw : ''}`}>
<Button variant="ghost" icon="chevronLeft" label="返回频道列表" onClick={onBack} />
<div className={styles.id}>
<span className={styles.name}>{channel?.id.slice(0, 8)}…</span>
{raw ? (
<span className={styles.badgeWarn}>明文 · 服务端可见</span>
) : (
<span className={styles.badge}>
<Icon name="lock" size={14} /> 加密
</span>
)}
</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>
</button>
<span className={`${styles.conn} ${styles[connection]}`} role="status">
{CONNECTION_TEXT[connection]}
</span>
<Button
variant="ghost"
icon="search"
label="搜索"
aria-pressed={searchOpen}
className={filtering ? styles.active : undefined}
onClick={onToggleSearch}
/>
<ThemeMenu />
{onOpenPanel ? <Button variant="ghost" icon="share" label="设备与分享" onClick={onOpenPanel} /> : null}
</header>
);
}
@@ -0,0 +1,35 @@
.bar {
position: fixed;
bottom: calc(88px + env(safe-area-inset-bottom));
left: 50%;
z-index: 30;
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-1) var(--space-2) var(--space-1) var(--space-4);
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--surface);
box-shadow: var(--shadow-pop);
translate: -50% 0;
animation: bar-in 0.15s ease-out;
}
@media (width >= 900px) {
.bar {
bottom: var(--space-6);
}
}
@keyframes bar-in {
from {
opacity: 0;
translate: -50% 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bar {
animation: none;
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Button } from '@/ui/Button';
import styles from './UndoBar.module.css';
/** 删除后 5 秒内可撤销(ui-design §8) */
export function UndoBar({ onUndo }: { onUndo: () => void }) {
return (
<div className={styles.bar} role="status">
<span>已删除</span>
<Button variant="ghost" icon="undo" onClick={onUndo}>撤销</Button>
</div>
);
}
@@ -0,0 +1,70 @@
/* 电脑:左列表 | 内联预览 | 右侧窄栏 */
.desktop {
display: grid;
grid-template-columns: 380px minmax(0, 1fr) auto;
grid-template-rows: auto minmax(0, 1fr);
height: 100vh;
height: 100dvh;
}
.desktop > header {
grid-column: 1 / 3;
border-bottom: 1px solid var(--line);
}
.list {
display: flex;
flex-direction: column;
min-height: 0;
border-right: 1px solid var(--line);
}
.scroll {
flex: 1;
overflow-y: auto;
}
.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;
}
/* 手机:整列滚动,顶栏与底部动作栏 sticky,内容从它们下方滑过 */
.mobile {
display: flex;
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;
}
.grow {
flex: 1;
}
.drop {
position: fixed;
inset: var(--space-4);
z-index: 40;
display: grid;
place-items: center;
border: 2px dashed var(--line-strong);
border-radius: var(--radius-lg);
background: var(--scrim);
color: var(--on-accent);
font-size: 18px;
pointer-events: none;
}
+209
View File
@@ -0,0 +1,209 @@
import { lazy, Suspense, useCallback, useEffect, useRef, useState, 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 { 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 { 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 { TopBar } from './TopBar';
import { UndoBar } from './UndoBar';
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() {
const desktop = useMediaQuery('(width >= 900px) and (height >= 500px)');
const sendFiles = useWorkspace((s) => s.sendFiles);
const sendText = useWorkspace((s) => s.sendText);
const remove = useWorkspace((s) => s.remove);
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 [selectedId, setSelectedId] = useState<string>();
const [sheetId, setSheetId] = useState<string>();
const [checked, setChecked] = useState<ReadonlySet<string>>(new Set());
const [composeOpen, setComposeOpen] = 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 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(() => {
const { consumePayload, clearJustCreated } = useChannels.getState();
const p = consumePayload();
if (p) sendPayload(p);
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);
}
function undoDelete() {
if (!pendingRef.current) return;
clearTimeout(pendingRef.current.timer);
pendingRef.current = undefined;
setPending(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>
)}
{!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>
);
}
@@ -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;
}
+66
View File
@@ -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;
}
+75
View File
@@ -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>
);
}
+74
View File
@@ -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;
}
}
+99
View File
@@ -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);
}
}
+60
View File
@@ -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>
);
}
+128
View File
@@ -0,0 +1,128 @@
.row {
display: flex;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-2) var(--space-3) var(--space-2) var(--space-4);
border-bottom: 1px solid var(--line);
animation: row-in 0.21s ease-out;
}
.selected {
background: var(--accent-soft);
}
.check {
flex: none;
width: 16px;
height: 16px;
margin: var(--space-3) 0 0;
accent-color: var(--accent);
}
.body {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
padding: var(--space-1) 0;
}
.head {
display: flex;
align-items: center;
gap: var(--space-3);
min-width: 0;
min-height: 28px;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
text-align: left;
}
.head:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.inline {
min-width: 0;
}
.text {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.title {
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub {
color: var(--text-dim);
font-size: 12px;
overflow-wrap: anywhere;
}
.tag,
.update,
.conflict {
margin-left: var(--space-2);
padding: 0 var(--space-1);
border-radius: var(--radius-sm);
}
.tag {
background: var(--accent-soft);
}
.update {
background: var(--busy-soft);
color: var(--busy);
}
.conflict {
background: var(--warn-soft);
color: var(--warn);
}
.bar {
display: block;
height: 3px;
margin-top: var(--space-1);
overflow: hidden;
border-radius: var(--radius-sm);
background: var(--busy-soft);
}
.fill {
display: block;
height: 100%;
background: var(--progress-gradient);
transition: width 0.25s linear;
}
.tampered {
border-left: 3px solid var(--danger);
background: linear-gradient(90deg, var(--danger-soft), transparent);
color: var(--danger);
}
@keyframes row-in {
from {
opacity: 0;
translate: 0 -6px;
}
}
@media (prefers-reduced-motion: reduce) {
.row {
animation: none;
}
}
+93
View File
@@ -0,0 +1,93 @@
import { memo } from 'react';
import { formatSize, formatTime } from '@/lib/format';
import { Button } from '@/ui/Button';
import { CopyButton } from '@/ui/CopyButton';
import { useDownload } from './download';
import { InlineContent, inlineKindOf } from './InlineContent';
import { useItemSource } from './source';
import { shortDevice, titleOf, versionOf } from './summary';
import { Tile } from './Tile';
import styles from './CompactRow.module.css';
interface Props {
id: string;
selected: boolean;
/** 桌面端才有多选 */
checked?: boolean;
onSelect: (id: string) => void;
onToggle?: (id: string) => void;
}
/**
* 暂存区里的一行。文本、JSON 和不大的视频直接画在行里,不用点开;
* 其他内容显示缩略图或类型图标 + 标题 + 一行小字。
*/
export const CompactRow = memo(function CompactRow({ id, selected, checked, onSelect, onToggle }: Props) {
const source = useItemSource();
const item = source.useItem(id);
const nameOf = source.useDeviceNamer();
const download = useDownload();
if (!item) return null;
if (item.state.kind === 'tampered') {
return <li className={`${styles.row} ${styles.tampered}`}>内容被篡改,已丢弃</li>;
}
const receiving = item.state.kind === 'receiving' ? item.state.progress : null;
const version = versionOf(item);
const size = item.kind === 'file' ? item.size : new Blob([item.text]).size;
const inline = inlineKindOf(item);
const meta = (
<>
{shortDevice(nameOf(item.deviceId))} · {formatTime(item.ts)} · {formatSize(size)}
{item.kind === 'file' && item.remaining !== null ? ` · 剩 ${item.remaining} 次` : ''}
{version > 1 ? <span className={styles.tag}>v{version}</span> : null}
{item.kind === 'file' && item.unseen ? <span className={styles.update}>有更新</span> : null}
{item.kind === 'text' && item.conflict ? <span className={styles.conflict}>冲突</span> : null}
</>
);
return (
<li className={`${styles.row} ${selected ? styles.selected : ''}`}>
{onToggle ? (
<input type="checkbox" className={styles.check} checked={checked ?? false} onChange={() => onToggle(id)} aria-label="选择" />
) : null}
<div className={styles.body}>
{inline === 'text' ? (
// 文本没有标题,内容本身就是标题;这个按钮只显示小字,供键盘进入详情
<button type="button" className={styles.head} onClick={() => onSelect(id)}>
<span className={styles.sub}>{meta}</span>
</button>
) : (
<button type="button" className={styles.head} onClick={() => onSelect(id)}>
{inline === 'video' ? null : <Tile item={item} />}
<span className={styles.text}>
<span className={styles.title}>{titleOf(item)}</span>
<span className={styles.sub}>{meta}</span>
</span>
</button>
)}
{inline ? (
<div className={styles.inline}>
<InlineContent item={item} onOpen={() => onSelect(id)} />
</div>
) : null}
{receiving !== null ? (
<span className={styles.bar} role="progressbar" aria-valuenow={Math.round(receiving * 100)}>
<span className={styles.fill} style={{ width: `${Math.round(receiving * 100)}%` }} />
</span>
) : null}
</div>
{item.kind === 'text' ? (
<CopyButton text={item.text} variant="ghost" iconOnly />
) : (
<Button variant="ghost" icon="download" label="下载" disabled={receiving !== null} onClick={() => void download(item)} />
)}
</li>
);
});
@@ -0,0 +1,44 @@
.history {
padding-top: var(--space-3);
border-top: 1px solid var(--line);
}
.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-3);
min-height: 40px;
border-bottom: 1px solid var(--line);
}
.v {
width: 28px;
font-family: var(--font-mono);
font-weight: 600;
}
.info {
flex: 1;
min-width: 0;
color: var(--text-dim);
font-size: 13px;
}
.current {
padding: 0 var(--space-2);
border-radius: var(--radius-sm);
background: var(--ok-soft);
color: var(--ok);
font-size: 12px;
}
+41
View File
@@ -0,0 +1,41 @@
import type { FileItem } from '@/core-bridge/types';
import { formatSize, formatTime } from '@/lib/format';
import { saveBlob } from '@/lib/download';
import { Button } from '@/ui/Button';
import { useItemSource } from './source';
import { shortDevice } from './summary';
import styles from './HistoryList.module.css';
/** 文件的修改历史:当前版本在最上,旧版本可单独下载 */
export function HistoryList({ item }: { item: FileItem }) {
const { getBlob, useDeviceNamer } = useItemSource();
const namer = useDeviceNamer();
const versions = item.versions ?? [];
const nameOf = (id: string) => shortDevice(namer(id));
const total = versions.length + 1;
return (
<section className={styles.history} aria-label="修改历史">
<h3 className={styles.h}>修改历史</h3>
<ul className={styles.list}>
<li className={styles.row}>
<span className={styles.v}>v{total}</span>
<span className={styles.info}>{nameOf(item.deviceId)} · {formatTime(item.ts)} · {formatSize(item.size)}</span>
<span className={styles.current}>当前</span>
</li>
{versions.map((v, i) => (
<li key={v.id} className={styles.row}>
<span className={styles.v}>v{total - 1 - i}</span>
<span className={styles.info}>{nameOf(v.deviceId)} · {formatTime(v.ts)} · {formatSize(v.size)}</span>
<Button
variant="ghost"
icon="download"
label={`下载 v${total - 1 - i}`}
onClick={() => void getBlob(item.id, v.id).then((b) => saveBlob(b, item.name))}
/>
</li>
))}
</ul>
</section>
);
}
@@ -0,0 +1,55 @@
.text {
margin: 0;
font-family: inherit;
overflow-wrap: anywhere;
white-space: pre-wrap;
cursor: pointer;
}
.json {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
background: var(--surface-2);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.5;
}
.clamp {
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 6;
-webkit-box-orient: vertical;
}
.video {
display: block;
width: 100%;
max-height: 260px;
border-radius: var(--radius-md);
background: var(--surface-2);
}
.poster {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-2);
width: 100%;
min-height: 140px;
border: 1px dashed var(--line-strong);
border-radius: var(--radius-md);
background: var(--tile-gradient);
color: var(--text-dim);
font-size: 13px;
}
button.poster {
cursor: pointer;
}
button.poster:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
+52
View File
@@ -0,0 +1,52 @@
import { useMemo, useState } from 'react';
import type { FileItem, Item, TextItem } from '@/core-bridge/types';
import { textView, VIDEO_INLINE_MAX } from '@/lib/inline';
import { Icon } from '@/ui/Icon';
import { useBlobUrl } from './useBlobUrl';
import styles from './InlineContent.module.css';
/** 列表里能直接画出来的内容:文本与 JSON(不管多短都画),不大的视频 */
export function inlineKindOf(item: Item): 'text' | 'video' | null {
if (item.kind === 'text') return 'text';
if (item.preview === 'video' && item.size <= VIDEO_INLINE_MAX && item.state.kind === 'complete') return 'video';
return null;
}
export function InlineContent({ item, onOpen }: { item: Item; onOpen: () => void }) {
return item.kind === 'text' ? <InlineText item={item} onOpen={onOpen} /> : <InlineVideo item={item} />;
}
function InlineText({ item, onOpen }: { item: TextItem; onOpen: () => void }) {
const v = useMemo(() => textView(item.text), [item.text]);
// 纯展示的区域,键盘用户从标题按钮进入详情;这里的点击只是给鼠标和触屏多一个入口
return (
<pre
className={`${styles.text} ${v.json ? styles.json : ''} ${v.long ? styles.clamp : ''}`}
onClick={onOpen}
>
{v.body}
</pre>
);
}
/**
* 视频直接在列表里播放。没有下载上限时自动加载;有上限时,拉取内容会消耗一次下载(GATED,api.md),
* 所以先放占位,点了才加载,避免滚动列表就把次数用光。
*/
function InlineVideo({ item }: { item: FileItem }) {
const free = item.remaining === null;
const [play, setPlay] = useState(free);
const blob = useBlobUrl(item.id, play);
if (!play) {
return (
<button type="button" className={styles.poster} onClick={() => setPlay(true)}>
<Icon name="video" size={28} />
<span>点击播放 · 会消耗 1 次下载</span>
</button>
);
}
if (blob.status === 'loading') return <div className={styles.poster}>加载中…</div>;
if (blob.status === 'error') return <div className={styles.poster}>无法播放,可以直接下载。</div>;
return <video className={styles.video} src={blob.url} controls preload="metadata" playsInline />;
}
@@ -0,0 +1,55 @@
.detail {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.title {
margin: 0;
overflow-wrap: anywhere;
font-size: 18px;
}
.meta {
margin: 0;
color: var(--text-dim);
font-size: 13px;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
.banner {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-left: 3px solid var(--line-strong);
border-radius: var(--radius-sm);
font-size: 13px;
}
.update {
border-color: var(--busy);
background: linear-gradient(90deg, var(--busy-soft), transparent);
}
.conflict {
border-color: var(--warn);
background: linear-gradient(90deg, var(--warn-soft), transparent);
}
.body {
min-width: 0;
}
.dim {
margin: 0;
color: var(--text-dim);
}
+100
View File
@@ -0,0 +1,100 @@
import { useState } from 'react';
import type { Item } from '@/core-bridge/types';
import { isLongText } from '@/lib/fileKind';
import { formatSize, formatTime } from '@/lib/format';
import { Button } from '@/ui/Button';
import { CopyButton } from '@/ui/CopyButton';
import { useDownload } from './download';
import { HistoryList } from './HistoryList';
import { ArchivePreview } from './previews/ArchivePreview';
import { MediaPreview } from './previews/MediaPreview';
import { TextPreview } from './previews/TextPreview';
import { useItemSource } from './source';
import { shortDevice, titleOf } from './summary';
import { TextEditor } from './TextEditor';
import styles from './ItemDetail.module.css';
interface Props {
id: string;
/** 桌面内联预览区需要标题;抽屉里标题在抽屉头部 */
showTitle?: boolean;
/** 只读分享:不显示编辑、删除、冲突与更新提醒 */
readOnly?: boolean;
onDelete: (id: string) => void;
}
/** 条目详情:预览 + 操作 + 历史。桌面是右侧内联预览区,手机是底部抽屉 */
export default function ItemDetail({ id, showTitle, readOnly, onDelete }: Props) {
const source = useItemSource();
const item = source.useItem(id);
const nameOf = source.useDeviceNamer();
const download = useDownload();
const actions = source.actions;
const [editing, setEditing] = useState(false);
if (!item) return null;
const size = item.kind === 'file' ? item.size : new Blob([item.text]).size;
const long = item.kind === 'text' && isLongText(item.text);
const receiving = item.state.kind === 'receiving' ? item.state.progress : null;
return (
<div className={styles.detail}>
{showTitle ? <h2 className={styles.title}>{titleOf(item)}</h2> : null}
<p className={styles.meta}>
{shortDevice(nameOf(item.deviceId))} · {formatTime(item.ts)} · {formatSize(size)}
{item.kind === 'file' && item.remaining !== null ? ` · 剩余 ${item.remaining} 次下载` : ''}
</p>
<div className={styles.actions}>
{item.kind === 'text' ? <CopyButton text={item.text} variant="primary" /> : null}
<Button variant={item.kind === 'text' ? 'quiet' : 'primary'} icon="download" disabled={receiving !== null} onClick={() => void download(item)}>
{item.kind === 'text' ? (long ? '存为 .txt' : '下载') : '下载'}
</Button>
{item.kind === 'text' && !readOnly ? <Button icon="edit" onClick={() => setEditing(true)}>编辑</Button> : null}
{readOnly ? null : <Button variant="ghost" icon="trash" label="删除" onClick={() => onDelete(id)} />}
</div>
{!readOnly && item.kind === 'file' && item.unseen ? (
<div className={`${styles.banner} ${styles.update}`} role="status">
<span>{shortDevice(nameOf(item.deviceId))} 更新了此文件 · {formatTime(item.ts)}</span>
<Button variant="ghost" onClick={() => void actions?.markSeen(id)}>知道了</Button>
</div>
) : null}
{!readOnly && item.kind === 'text' && item.conflict ? (
<div className={`${styles.banner} ${styles.conflict}`} role="status">
<span>{shortDevice(item.kind === 'text' ? nameOf(item.conflict?.deviceId) : undefined)} 同时改过:「{item.conflict.text}」</span>
<span>
<Button variant="ghost" onClick={() => void actions?.resolveConflict(id, 'theirs')}>用他的</Button>
<Button variant="ghost" onClick={() => void actions?.resolveConflict(id, 'mine')}>保留我的</Button>
</span>
</div>
) : null}
<div className={styles.body}>
{receiving !== null ? <p className={styles.dim}>传输中 {Math.round(receiving * 100)}%,完成后可预览。</p> : null}
{receiving === null && editing && item.kind === 'text' ? (
<TextEditor item={item} onDone={() => setEditing(false)} />
) : null}
{receiving === null && !editing ? <Body item={item} /> : null}
</div>
{item.kind === 'file' && item.versions?.length ? <HistoryList item={item} /> : null}
</div>
);
}
function Body({ item }: { item: Item }) {
if (item.kind === 'text') return <TextPreview item={item} />;
switch (item.preview) {
case 'text':
return <TextPreview item={item} />;
case 'archive':
return <ArchivePreview id={item.id} />;
case 'image':
case 'video':
case 'audio':
return <MediaPreview id={item.id} kind={item.preview} />;
case 'none':
return <p className={styles.dim}>此类型暂不支持预览,可以直接下载。</p>;
}
}
@@ -0,0 +1,10 @@
.pane {
min-width: 0;
padding: var(--space-6);
overflow-y: auto;
}
.empty {
margin: 0;
color: var(--text-dim);
}
+16
View File
@@ -0,0 +1,16 @@
import ItemDetail from './ItemDetail';
import styles from './PreviewPane.module.css';
interface Props {
id: string | undefined;
onDelete: (id: string) => void;
}
/** 电脑上的条目详情:列表右侧的内联预览区,不弹窗 */
export default function PreviewPane({ id, onDelete }: Props) {
return (
<section className={styles.pane} aria-label="预览">
{id ? <ItemDetail key={id} id={id} showTitle onDelete={onDelete} /> : <p className={styles.empty}>选择左侧的内容进行预览。</p>}
</section>
);
}
@@ -0,0 +1,12 @@
.size {
margin-left: var(--space-2);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 12px;
font-weight: 400;
}
.none {
margin: 0;
color: var(--text-dim);
}
+28
View File
@@ -0,0 +1,28 @@
import { Sheet } from '@/ui/Sheet';
import ItemDetail from './ItemDetail';
import { useItemSource } from './source';
import { titleOf } from './summary';
interface Props {
id: string;
onClose: () => void;
readOnly?: boolean;
onDelete?: (id: string) => void;
}
/** 手机上的条目详情:底部抽屉 */
export default function PreviewSheet({ id, onClose, readOnly, onDelete }: Props) {
const item = useItemSource().useItem(id);
return (
<Sheet wide title={item ? titleOf(item) : ''} onClose={onClose}>
<ItemDetail
id={id}
readOnly={readOnly}
onDelete={(i) => {
onDelete?.(i);
onClose();
}}
/>
</Sheet>
);
}
@@ -0,0 +1,26 @@
.editor {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.input {
width: 100%;
padding: var(--space-3);
border: 1px solid var(--line-strong);
border-radius: var(--radius-md);
background: var(--surface);
font-family: var(--font-mono);
font-size: 13px;
resize: vertical;
}
.input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.actions {
display: flex;
gap: var(--space-2);
}
+30
View File
@@ -0,0 +1,30 @@
import { useState } from 'react';
import type { TextItem } from '@/core-bridge/types';
import { useItemSource } from './source';
import { Button } from '@/ui/Button';
import styles from './TextEditor.module.css';
/** 修改 = 追加新版本,后写者胜(ui-design §6.4) */
export function TextEditor({ item, onDone }: { item: TextItem; onDone: () => void }) {
const editText = useItemSource().actions?.editText;
const [draft, setDraft] = useState(item.text);
return (
<div className={styles.editor}>
<textarea className={styles.input} value={draft} rows={8} autoFocus onChange={(e) => setDraft(e.target.value)} />
<div className={styles.actions}>
<Button
variant="primary"
disabled={draft === item.text}
onClick={() => {
void editText?.(item.id, draft);
onDone();
}}
>
保存
</Button>
<Button onClick={onDone}>取消</Button>
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
.tile {
display: grid;
flex: none;
place-items: center;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--tile-gradient);
color: var(--text-dim);
}
.thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
+23
View File
@@ -0,0 +1,23 @@
import type { Item } from '@/core-bridge/types';
import { Icon } from '@/ui/Icon';
import { iconOf } from './summary';
import { useBlobUrl } from './useBlobUrl';
import styles from './Tile.module.css';
function Thumb({ id }: { id: string }) {
const blob = useBlobUrl(id);
return blob.status === 'ready' ? <img className={styles.thumb} src={blob.url} alt="" /> : null;
}
/**
* 条目的缩略图:图片显示缩略图,其余显示类型图标。
* 有下载上限的文件(GATED)拉取一次就消耗一次下载,所以不自动拉取缩略图。
*/
export function Tile({ item, size = 44 }: { item: Item; size?: number }) {
const thumb = item.kind === 'file' && item.preview === 'image' && item.state.kind === 'complete' && item.remaining === null;
return (
<span className={styles.tile} style={{ width: size, height: size }}>
{thumb ? <Thumb id={item.id} /> : <Icon name={iconOf(item)} size={Math.round(size / 2.2)} />}
</span>
);
}
+20
View File
@@ -0,0 +1,20 @@
import type { Item } from '@/core-bridge/types';
import { saveBlob } from '@/lib/download';
import { useItemSource } from './source';
export function itemFileName(item: Item): string {
return item.kind === 'file' ? item.name : `文本-${item.id}.txt`;
}
/** 返回一个下载函数,取内容的来源由当前上下文(频道或分享)决定 */
export function useDownload(): (item: Item) => Promise<void> {
const { getBlob } = useItemSource();
return async (item) => {
try {
saveBlob(await getBlob(item.id), itemFileName(item));
} catch {
// 原型用 alert 兜底;下载次数用完是 410 gone(api.md)
alert('无法下载:下载次数已用完,或内容已过期。');
}
};
}
@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
import type { ArchiveEntry } from '@/core-bridge/types';
import { formatSize } from '@/lib/format';
import { Icon } from '@/ui/Icon';
import { useItemSource } from '../source';
import styles from './previews.module.css';
/** 列出压缩包内的文件(不解压、不执行) */
export function ArchivePreview({ id }: { id: string }) {
const { listArchive } = useItemSource();
const [entries, setEntries] = useState<ArchiveEntry[] | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
listArchive(id).then(
(e) => !cancelled && setEntries(e),
() => !cancelled && setFailed(true),
);
return () => {
cancelled = true;
};
}, [id, listArchive]);
if (failed) return <p className={styles.hint}>无法读取压缩包目录,可以直接下载。</p>;
if (!entries) return <p className={styles.hint}>读取目录…</p>;
const files = entries.filter((e) => !e.dir);
const total = files.reduce((sum, e) => sum + e.size, 0);
return (
<>
<p className={styles.summary}>
{files.length} 个文件,解压后约 {formatSize(total)}
</p>
<ul className={styles.tree}>
{entries.map((e) => {
const depth = e.path.replace(/\/$/, '').split('/').length - 1;
const name = e.path.replace(/\/$/, '').split('/').pop();
return (
<li key={e.path} className={styles.entry} style={{ paddingLeft: `${depth * 16}px` }}>
<Icon name={e.dir ? 'archive' : 'file'} size={16} />
<span className={styles.entryName}>{name}</span>
{e.dir ? null : <span className={styles.entrySize}>{formatSize(e.size)}</span>}
</li>
);
})}
</ul>
</>
);
}
@@ -0,0 +1,21 @@
import { useState } from 'react';
import { useBlobUrl } from '../useBlobUrl';
import styles from './previews.module.css';
interface Props {
id: string;
kind: 'image' | 'video' | 'audio';
}
/** 媒体用原生元素加载 blob URL,不执行任何内容里的脚本 */
export function MediaPreview({ id, kind }: Props) {
const blob = useBlobUrl(id);
const [broken, setBroken] = useState(false);
if (blob.status === 'loading') return <p className={styles.hint}>加载中…</p>;
if (blob.status === 'error') return <p className={styles.hint}>无法预览此内容,可以直接下载。</p>;
if (broken) return <p className={styles.hint}>此浏览器无法显示该格式(例如 iPhone 的 HEIC 照片),可以直接下载。</p>;
if (kind === 'image') return <img className={styles.media} src={blob.url} alt="" onError={() => setBroken(true)} />;
if (kind === 'video') return <video className={styles.media} src={blob.url} controls playsInline onError={() => setBroken(true)} />;
return <audio className={styles.audio} src={blob.url} controls />;
}
@@ -0,0 +1,35 @@
import { useEffect, useState } from 'react';
import type { Item } from '@/core-bridge/types';
import { useBlobUrl } from '../useBlobUrl';
import styles from './previews.module.css';
const MAX_CHARS = 200_000;
function pretty(name: string, text: string): string {
if (!name.endsWith('.json')) return text;
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text;
}
}
/** 只当纯文本渲染(threat-model §4:raw 内容不内联渲染) */
export function TextPreview({ item }: { item: Item }) {
const blob = useBlobUrl(item.id, item.kind === 'file');
const [fileText, setFileText] = useState<string | null>(null);
useEffect(() => {
if (item.kind !== 'file' || blob.status !== 'ready') return;
let cancelled = false;
void blob.blob.slice(0, MAX_CHARS).text().then((t) => !cancelled && setFileText(t));
return () => {
cancelled = true;
};
}, [item.kind, blob]);
const text = item.kind === 'text' ? item.text : fileText;
if (text === null) return <p className={styles.hint}>{blob.status === 'error' ? '无法读取内容。' : '加载中…'}</p>;
return <pre className={styles.code}>{item.kind === 'file' ? pretty(item.name, text) : text}</pre>;
}
@@ -0,0 +1,61 @@
.hint {
margin: 0;
color: var(--text-dim);
}
.media {
display: block;
max-width: 100%;
max-height: 68dvh;
margin: 0 auto;
border-radius: var(--radius-md);
background: var(--surface-2);
}
.audio {
width: 100%;
}
.code {
margin: 0;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.55;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.summary {
margin: 0 0 var(--space-3);
color: var(--text-dim);
font-size: 13px;
}
.tree {
margin: 0;
padding: 0;
list-style: none;
}
.entry {
display: flex;
align-items: center;
gap: var(--space-2);
min-height: 32px;
border-bottom: 1px solid var(--line);
color: var(--text);
}
.entryName {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entrySize {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 12px;
}
+53
View File
@@ -0,0 +1,53 @@
import { createContext, useContext } from 'react';
import type { ArchiveEntry, Item } from '@/core-bridge/types';
import { useShareSession } from '@/store/shareSession';
import { useWorkspace } from '@/store/workspace';
export interface ItemActions {
markSeen: (id: string) => Promise<void>;
editText: (id: string, text: string) => Promise<void>;
resolveConflict: (id: string, choice: 'mine' | 'theirs') => Promise<void>;
}
/**
* 条目组件(行、详情、预览)的数据来源。频道和便捷分享各自提供自己的,
* 这样条目组件不依赖任何一个 store,两边也不会互相牵连。
*/
export interface ItemSource {
useItem: (id: string) => Item | undefined;
/** 返回「设备 id → 名字」的函数 */
useDeviceNamer: () => (deviceId: string | undefined) => string | undefined;
getBlob: (id: string, versionId?: string) => Promise<Blob>;
listArchive: (id: string) => Promise<ArchiveEntry[]>;
/** 只有频道有:分享是只读的 */
actions?: ItemActions;
}
export const channelSource: ItemSource = {
useItem: (id) => useWorkspace((s) => s.byId[id]),
useDeviceNamer: () => {
const devices = useWorkspace((s) => s.devices);
return (deviceId) => devices.find((d) => d.id === deviceId)?.name;
},
getBlob: (id, versionId) => useWorkspace.getState().getBlob(id, versionId),
listArchive: (id) => useWorkspace.getState().listArchive(id),
actions: {
markSeen: (id) => useWorkspace.getState().markSeen(id),
editText: (id, text) => useWorkspace.getState().editText(id, text),
resolveConflict: (id, choice) => useWorkspace.getState().resolveConflict(id, choice),
},
};
export const shareSource: ItemSource = {
useItem: (id) => useShareSession((s) => s.byId[id]),
// 分享里没有「设备」的概念,内容都来自创建者
useDeviceNamer: () => () => '创建者',
getBlob: (id) => useShareSession.getState().getBlob(id),
listArchive: (id) => useShareSession.getState().listArchive(id),
};
export const ItemSourceContext = createContext<ItemSource>(channelSource);
export function useItemSource(): ItemSource {
return useContext(ItemSourceContext);
}
+30
View File
@@ -0,0 +1,30 @@
import type { Item } from '@/core-bridge/types';
import type { IconName } from '@/ui/Icon';
const FILE_ICON = {
image: 'image',
video: 'video',
audio: 'file',
text: 'text',
archive: 'archive',
none: 'file',
} as const satisfies Record<string, IconName>;
export function iconOf(item: Item): IconName {
return item.kind === 'text' ? 'text' : FILE_ICON[item.preview];
}
/** 列表里的一行标题:文件名,或文本的第一行 */
export function titleOf(item: Item): string {
if (item.kind === 'file') return item.name;
return item.text.split('\n').find((l) => l.trim() !== '')?.trim() ?? '(空)';
}
export function shortDevice(name: string | undefined): string {
return (name ?? '未知设备').split(' · ')[0] ?? '未知设备';
}
/** 当前版本号:旧版本数 + 1 */
export function versionOf(item: Item): number {
return item.kind === 'file' ? (item.versions?.length ?? 0) + 1 : 1;
}
+33
View File
@@ -0,0 +1,33 @@
import { useEffect, useState } from 'react';
import { useItemSource } from './source';
type BlobState =
| { status: 'loading' }
| { status: 'ready'; blob: Blob; url: string }
| { status: 'error' };
/** 取条目内容并生成 object URL,卸载时释放 */
export function useBlobUrl(id: string, enabled = true): BlobState {
const { getBlob } = useItemSource();
const [state, setState] = useState<BlobState>({ status: 'loading' });
useEffect(() => {
if (!enabled) return;
let url: string | null = null;
let cancelled = false;
getBlob(id).then(
(blob) => {
if (cancelled) return;
url = URL.createObjectURL(blob);
setState({ status: 'ready', blob, url });
},
() => !cancelled && setState({ status: 'error' }),
);
return () => {
cancelled = true;
if (url) URL.revokeObjectURL(url);
};
}, [id, enabled, getBlob]);
return state;
}
+53
View File
@@ -0,0 +1,53 @@
import { useState } from 'react';
import type { ChannelInfo } from '@/core-bridge/types';
import type { ParsedChannelLink } from '@/lib/channelLink';
import { Button } from '@/ui/Button';
import styles from './ScanSheet.module.css';
interface Props {
link: ParsedChannelLink;
onJoin: (link: ParsedChannelLink) => Promise<ChannelInfo>;
onJoined: (c: ChannelInfo) => void;
/** 返回上一步(重新扫描 / 取消) */
onBack: () => void;
backLabel?: string;
}
/** 加入前先预览:模式与权限看清楚,确认后才占席位(api.md:GET 预览、POST seats) */
export function JoinPreview({ link, onJoin, onJoined, onBack, backLabel = '重新扫描' }: Props) {
const [phase, setPhase] = useState<'ask' | 'joining' | 'failed'>('ask');
async function join() {
setPhase('joining');
try {
onJoined(await onJoin(link));
} catch {
setPhase('failed');
}
}
return (
<div className={styles.body}>
<dl className={styles.facts}>
<dt>频道</dt>
<dd><code>{link.id.slice(0, 8)}…</code></dd>
<dt>模式</dt>
<dd className={link.mode === 'raw' ? styles.warn : styles.ok}>
{link.mode === 'enc' ? '加密 · 内容在浏览器内加密' : '明文 · 服务端可见全部内容'}
</dd>
<dt>权限</dt>
<dd className={link.isOwner ? styles.warn : undefined}>
{link.isOwner ? '所有者 · 含管理权限' : link.canWrite ? '成员 · 可上传' : '成员'}
</dd>
</dl>
<p className={styles.dim}>持有此链接的人可以读取该频道的全部历史内容。</p>
{phase === 'failed' ? <p className={styles.error} role="alert">加入失败,请重试。</p> : null}
<div className={styles.row}>
<Button variant="primary" disabled={phase === 'joining'} onClick={() => void join()}>
{phase === 'joining' ? '加入中…' : '加入'}
</Button>
<Button onClick={onBack}>{backLabel}</Button>
</div>
</div>
);
}
@@ -0,0 +1,24 @@
.page {
display: grid;
min-height: 100vh;
min-height: 100dvh;
place-items: center;
padding: var(--space-4);
}
.card {
display: flex;
flex-direction: column;
gap: var(--space-3);
width: min(100%, 420px);
}
.title {
margin: 0;
font-size: 20px;
}
.dim {
margin: 0;
color: var(--text-dim);
}
+38
View File
@@ -0,0 +1,38 @@
import type { ChannelInfo } from '@/core-bridge/types';
import type { ParsedChannelLink } from '@/lib/channelLink';
import { useChannels } from '@/store/channels';
import { Button } from '@/ui/Button';
import { JoinPreview } from './JoinPreview';
import styles from './JoinScreen.module.css';
interface Props {
link: ParsedChannelLink;
onJoined: (c: ChannelInfo) => void;
onCancel: () => void;
}
/** 别人发来的频道链接:整页预览,确认后才加入 */
export function JoinScreen({ link, onJoined, onCancel }: Props) {
const join = useChannels((s) => s.join);
return (
<div className={styles.page}>
<main className={styles.card}>
<h1 className={styles.title}>加入频道</h1>
<JoinPreview link={link} onJoin={join} onJoined={onJoined} onBack={onCancel} backLabel="取消" />
</main>
</div>
);
}
/** 路由里有频道 id,但本机没有这个频道,链接里也没有密钥(例如刷新后 fragment 已被清除) */
export function MissingAccess({ onHome }: { onHome: () => void }) {
return (
<div className={styles.page}>
<main className={styles.card}>
<h1 className={styles.title}>无法打开此频道</h1>
<p className={styles.dim}>本机没有这个频道的链接。请使用完整的分享链接(含 # 后面的密钥)重新打开,或扫描对方的二维码。</p>
<Button variant="primary" onClick={onHome}>回到首页</Button>
</main>
</div>
);
}
@@ -0,0 +1,90 @@
.body {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.view {
position: relative;
overflow: hidden;
aspect-ratio: 4 / 3;
border-radius: var(--radius-md);
background: var(--surface-2);
}
.video {
width: 100%;
height: 100%;
object-fit: cover;
}
.hint {
position: absolute;
inset: auto 0 0;
margin: 0;
padding: var(--space-2) var(--space-3);
background: var(--glass);
font-size: 13px;
}
.paste {
display: flex;
gap: var(--space-2);
}
.input {
flex: 1;
min-width: 0;
min-height: 36px;
padding: 0 var(--space-3);
border: 1px solid var(--line-strong);
border-radius: var(--radius-md);
background: var(--surface);
font-family: var(--font-mono);
font-size: 12px;
}
.input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.error {
margin: 0;
color: var(--danger);
font-size: 13px;
}
.dim {
margin: 0;
color: var(--text-dim);
font-size: 13px;
}
.facts {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-2) var(--space-4);
margin: 0;
}
.facts dt {
color: var(--text-dim);
}
.facts dd {
margin: 0;
}
.ok {
color: var(--ok);
}
.warn {
color: var(--warn);
}
.row {
display: flex;
gap: var(--space-2);
}
+97
View File
@@ -0,0 +1,97 @@
import { useRef, useState } from 'react';
import type { ChannelInfo, Share } from '@/core-bridge/types';
import { parseChannelLink, type LinkError, type ParsedChannelLink } from '@/lib/channelLink';
import { parseShareLink } from '@/lib/shareLink';
import { useChannels } from '@/store/channels';
import { useShares } from '@/store/shares';
import { Button } from '@/ui/Button';
import { Sheet } from '@/ui/Sheet';
import { JoinPreview } from './JoinPreview';
import { useQrScanner } from './useQrScanner';
import styles from './ScanSheet.module.css';
const ERROR_TEXT: Record<LinkError, string> = {
bad_url: '这不是 WebDrop 的频道或分享链接。',
bad_id: '链接里的 id 不对。',
bad_key: '链接里的密钥格式不对,可能被截断了。',
};
const SCANNER_HINT = {
starting: '正在打开相机…',
scanning: '把二维码放进取景框',
unsupported: '此浏览器不支持扫码识别。iPhone 可以直接用系统相机扫码并打开链接,或在下面粘贴链接。',
denied: '无法使用相机(没有权限,或页面不是 https)。请粘贴链接。',
} as const;
interface Props {
onClose: () => void;
/** 扫到频道链接:预览后加入 */
onChannel: (c: ChannelInfo) => void;
/** 扫到便捷分享链接:直接进入,不预览 */
onShare: (s: Share) => void;
}
/** 扫码或粘贴链接。频道链接先预览再加入;便捷分享链接直接进入清单 */
export function ScanSheet({ onClose, onChannel, onShare }: Props) {
const joinChannel = useChannels((s) => s.join);
const joinShare = useShares((s) => s.join);
const videoRef = useRef<HTMLVideoElement>(null);
const [link, setLink] = useState<ParsedChannelLink | null>(null);
const [opening, setOpening] = useState(false);
const [error, setError] = useState<LinkError | null>(null);
const [paste, setPaste] = useState('');
function accept(text: string) {
if (opening) return;
const share = parseShareLink(text);
if (share.ok) {
setError(null);
setOpening(true);
joinShare(share.link).then(onShare, () => setOpening(false));
return;
}
const channel = parseChannelLink(text);
if (channel.ok) {
setError(null);
setLink(channel.link);
} else {
// 两种都不是:报第二种的错误即可(都是「不是链接」时一样)
setError(channel.error);
}
}
const scanner = useQrScanner(videoRef, link === null && !opening, accept);
return (
<Sheet title={link ? '加入频道' : '扫码或粘贴链接'} onClose={onClose}>
{link ? (
<JoinPreview link={link} onJoin={joinChannel} onJoined={onChannel} onBack={() => setLink(null)} />
) : (
<div className={styles.body}>
<div className={styles.view}>
<video ref={videoRef} className={styles.video} muted playsInline />
<p className={styles.hint}>{opening ? '正在打开分享…' : SCANNER_HINT[scanner]}</p>
</div>
<form
className={styles.paste}
onSubmit={(e) => {
e.preventDefault();
accept(paste);
}}
>
<input
className={styles.input}
placeholder="或粘贴频道、分享链接"
value={paste}
onChange={(e) => setPaste(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
<Button variant="primary" type="submit" disabled={!paste.trim() || opening}>识别</Button>
</form>
{error ? <p className={styles.error} role="alert">{ERROR_TEXT[error]}</p> : null}
</div>
)}
</Sheet>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useRef, useState, type RefObject } from 'react';
interface Detector {
detect(source: HTMLVideoElement): Promise<{ rawValue: string }[]>;
}
type DetectorCtor = new (opts: { formats: string[] }) => Detector;
const Ctor = (globalThis as { BarcodeDetector?: DetectorCtor }).BarcodeDetector;
export type ScannerState = 'starting' | 'scanning' | 'unsupported' | 'denied';
const SCAN_INTERVAL_MS = 250;
/** 用相机 + 浏览器自带的 BarcodeDetector 识别二维码。不支持时由界面退回「粘贴链接」 */
export function useQrScanner(
videoRef: RefObject<HTMLVideoElement | null>,
enabled: boolean,
onText: (text: string) => void,
): ScannerState {
const [state, setState] = useState<ScannerState>(Ctor ? 'starting' : 'unsupported');
const latest = useRef(onText);
useEffect(() => {
latest.current = onText;
}, [onText]);
useEffect(() => {
if (!enabled || !Ctor) return;
let stream: MediaStream | null = null;
let timer: number | undefined;
let cancelled = false;
const detector = new Ctor({ formats: ['qr_code'] });
navigator.mediaDevices
?.getUserMedia({ video: { facingMode: 'environment' } })
.then(async (s) => {
if (cancelled) return s.getTracks().forEach((t) => t.stop());
stream = s;
const video = videoRef.current;
if (!video) return;
video.srcObject = s;
await video.play();
setState('scanning');
timer = window.setInterval(async () => {
try {
const found = await detector.detect(video);
const text = found[0]?.rawValue;
if (text) latest.current(text);
} catch {
/* 单帧识别失败,等下一帧 */
}
}, SCAN_INTERVAL_MS);
})
.catch(() => !cancelled && setState('denied'));
return () => {
cancelled = true;
clearInterval(timer);
stream?.getTracks().forEach((t) => t.stop());
};
}, [enabled, videoRef]);
return state;
}
@@ -0,0 +1,56 @@
.row {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3) var(--space-2) var(--space-4);
border-bottom: 1px solid var(--line);
animation: row-in 0.21s ease-out;
}
.text {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.title {
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub {
color: var(--text-dim);
font-size: 12px;
}
.bar {
display: block;
height: 3px;
margin-top: var(--space-1);
overflow: hidden;
border-radius: var(--radius-sm);
background: var(--busy-soft);
}
.fill {
display: block;
height: 100%;
background: var(--progress-gradient);
transition: width 0.25s linear;
}
@keyframes row-in {
from {
opacity: 0;
translate: 0 -6px;
}
}
@media (prefers-reduced-motion: reduce) {
.row {
animation: none;
}
}
+32
View File
@@ -0,0 +1,32 @@
import { formatSize } from '@/lib/format';
import { useShareSession } from '@/store/shareSession';
import { Button } from '@/ui/Button';
import { titleOf } from '@/features/item/summary';
import { Tile } from '@/features/item/Tile';
import styles from './DraftRow.module.css';
/** 草稿里的一行:可以移除。只在草稿阶段存在 */
export function DraftRow({ id }: { id: string }) {
const item = useShareSession((s) => s.byId[id]);
const removeItem = useShareSession((s) => s.removeItem);
if (!item) return null;
const receiving = item.state.kind === 'receiving' ? item.state.progress : null;
const size = item.kind === 'file' ? item.size : new Blob([item.text]).size;
return (
<li className={styles.row}>
<Tile item={item} />
<span className={styles.text}>
<span className={styles.title}>{titleOf(item)}</span>
<span className={styles.sub}>{formatSize(size)}{receiving !== null ? ` · 传输中 ${Math.round(receiving * 100)}%` : ''}</span>
{receiving !== null ? (
<span className={styles.bar} role="progressbar" aria-valuenow={Math.round(receiving * 100)}>
<span className={styles.fill} style={{ width: `${Math.round(receiving * 100)}%` }} />
</span>
) : null}
</span>
<Button variant="ghost" icon="close" label="移除" onClick={() => void removeItem(id)} />
</li>
);
}
@@ -0,0 +1,37 @@
.bar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
gap: var(--space-2);
min-height: var(--bar-h);
padding: env(safe-area-inset-top) var(--space-4) 0;
background: var(--glass);
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-backdrop-filter: var(--blur-bar);
backdrop-filter: var(--blur-bar);
}
@supports not (backdrop-filter: blur(1px)) {
.bar {
background: var(--bg);
}
}
@media (prefers-reduced-transparency: reduce) {
.bar {
background: var(--bg);
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
}
.title {
margin: 0;
font-size: 16px;
}
.spacer {
flex: 1;
}
+18
View File
@@ -0,0 +1,18 @@
import type { ReactNode } from 'react';
import { navigate } from '@/app/navigation';
import { Button } from '@/ui/Button';
import { ThemeMenu } from '@/ui/ThemeMenu';
import styles from './ShareBar.module.css';
/** 分享页的顶栏:返回、标题、状态徽标 */
export function ShareBar({ title, badge }: { title: string; badge: ReactNode }) {
return (
<header className={styles.bar}>
<Button variant="ghost" icon="chevronLeft" label="返回首页" onClick={() => navigate('/')} />
<h1 className={styles.title}>{title}</h1>
{badge}
<span className={styles.spacer} />
<ThemeMenu />
</header>
);
}
@@ -0,0 +1,120 @@
.page {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
overflow-y: auto;
padding-inline: env(safe-area-inset-left) env(safe-area-inset-right);
overscroll-behavior-y: contain;
}
.main {
display: flex;
flex: 1;
flex-direction: column;
width: min(100%, 640px);
margin: 0 auto;
}
.dragging {
outline: 2px dashed var(--accent);
outline-offset: -8px;
}
.badge {
padding: 0 var(--space-2);
border-radius: var(--radius-sm);
background: var(--warn-soft);
color: var(--warn);
font-size: 12px;
}
.empty {
margin: 0;
padding: var(--space-6) var(--space-4);
color: var(--text-dim);
}
.list {
margin: 0;
padding: 0;
list-style: none;
}
.settings {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--space-3);
margin-top: auto;
padding: var(--space-4);
border-top: 1px solid var(--line);
font-size: 13px;
}
.settings label {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.settings 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;
}
.footer {
position: sticky;
bottom: 0;
z-index: 10;
display: flex;
justify-content: center;
padding: var(--space-3) var(--space-4);
padding-bottom: calc(var(--space-3) + env(safe-area-inset-bottom));
background: var(--glass);
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-backdrop-filter: var(--blur-bar);
backdrop-filter: var(--blur-bar);
}
.footer > * {
width: min(100%, 640px);
min-height: 48px;
}
@supports not (backdrop-filter: blur(1px)) {
.footer {
background: var(--bg);
}
}
@media (prefers-reduced-transparency: reduce) {
.footer {
background: var(--bg);
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
}
.confirm {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.confirm p {
margin: 0;
}
.dim {
color: var(--text-dim);
font-size: 13px;
}
.confirmActions {
display: flex;
gap: var(--space-2);
}
+146
View File
@@ -0,0 +1,146 @@
import { useCallback, useEffect, useState, type DragEvent } from 'react';
import { ActionBar } from '@/features/channel/ActionBar';
import { ComposeSheet } from '@/features/channel/ComposeSheet';
import type { ShareSettings } from '@/core-bridge/types';
import type { ClipboardPayload } from '@/lib/clipboard';
import { useGlobalPaste } from '@/lib/useGlobalPaste';
import { useShares } from '@/store/shares';
import { useShareSession } from '@/store/shareSession';
import { Button } from '@/ui/Button';
import { Sheet } from '@/ui/Sheet';
import { DraftRow } from './DraftRow';
import { ShareBar } from './ShareBar';
import styles from './ShareDraft.module.css';
const TTLS = [
{ label: '1 小时', value: 3600 },
{ label: '24 小时', value: 86400 },
{ label: '7 天', value: 604800 },
];
const SEATS = [2, 5, 10, 20];
const DOWNLOADS = [
{ label: '不限', value: 0 },
{ label: '1 次', value: 1 },
{ label: '3 次', value: 3 },
{ label: '10 次', value: 10 },
];
/** 分享草稿:创建者还握着所有者密钥,可以添加、删除、调整设置;点「完成分享」后无人可改 */
export function ShareDraft() {
const share = useShareSession((s) => s.share);
const ids = useShareSession((s) => s.order);
const busy = useShareSession((s) => s.order.some((id) => s.byId[id]?.state.kind === 'receiving'));
const addFiles = useShareSession((s) => s.addFiles);
const addText = useShareSession((s) => s.addText);
const updateSettings = useShares((s) => s.updateSettings);
const seal = useShares((s) => s.seal);
const [composeOpen, setComposeOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [sealing, setSealing] = useState(false);
const [dragging, setDragging] = useState(false);
const add = useCallback(
(p: ClipboardPayload) => {
if (p.files.length > 0) void addFiles(p.files);
else if (p.text) void addText(p.text);
},
[addFiles, addText],
);
useGlobalPaste(add);
// 从首页「便捷分享」带过来的内容
useEffect(() => {
const p = useShares.getState().consumePayload();
if (p) add(p);
}, [add]);
if (!share) return null;
const patch = (p: Partial<ShareSettings>) => void updateSettings(share.id, p);
async function finish() {
if (!share) return;
setSealing(true);
try {
await seal(share.id); // 成功后分享状态变为 sealed,页面自动切换到只读页
} finally {
setSealing(false);
setConfirmOpen(false);
}
}
function onDragOver(e: DragEvent) {
if (!e.dataTransfer.types.includes('Files')) return;
e.preventDefault();
setDragging(true);
}
function onDrop(e: DragEvent) {
e.preventDefault();
setDragging(false);
add({ files: [...e.dataTransfer.files], text: '' });
}
return (
<div className={styles.page} onDragOver={onDragOver} onDragLeave={() => setDragging(false)} onDrop={onDrop}>
<ShareBar title="新建分享" badge={<span className={styles.badge}>草稿</span>} />
<main className={`${styles.main} ${dragging ? styles.dragging : ''}`}>
<ActionBar
layout="toolbar"
onFiles={(f) => void addFiles(f)}
onText={(t) => void addText(t)}
onCompose={() => setComposeOpen(true)}
/>
{ids.length === 0 ? (
<p className={styles.empty}>还没有内容。拖入文件、粘贴文本,或点上面的按钮。</p>
) : (
<ul className={styles.list}>
{ids.map((id) => <DraftRow key={id} id={id} />)}
</ul>
)}
<section className={styles.settings} aria-label="分享设置">
<label>
<span>有效期</span>
<select value={share.ttlSeconds} onChange={(e) => patch({ ttlSeconds: Number(e.target.value) })}>
{TTLS.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</label>
<label>
<span>最多访客</span>
<select value={share.seats} onChange={(e) => patch({ seats: Number(e.target.value) })}>
{SEATS.map((n) => <option key={n} value={n}>{n} 人</option>)}
</select>
</label>
<label>
<span>每个文件下载</span>
<select value={share.maxDownloads ?? 0} onChange={(e) => patch({ maxDownloads: Number(e.target.value) || null })}>
{DOWNLOADS.map((d) => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
</label>
</section>
</main>
<footer className={styles.footer}>
<Button variant="primary" disabled={ids.length === 0 || busy} onClick={() => setConfirmOpen(true)}>
{busy ? '传输中…' : '完成分享'}
</Button>
</footer>
{composeOpen ? <ComposeSheet onClose={() => setComposeOpen(false)} onSubmit={(t) => void addText(t)} /> : null}
{confirmOpen ? (
<Sheet title="完成分享" onClose={() => setConfirmOpen(false)}>
<div className={styles.confirm}>
<p>完成后会生成分享链接和二维码。<strong>此后任何人(包括你)都不能再添加、修改或删除内容</strong>,只能等它过期。</p>
<p className={styles.dim}>发错了也撤不回,请先确认列表。</p>
<div className={styles.confirmActions}>
<Button variant="primary" disabled={sealing} onClick={() => void finish()}>{sealing ? '正在完成…' : '确认完成'}</Button>
<Button disabled={sealing} onClick={() => setConfirmOpen(false)}>返回修改</Button>
</div>
</div>
</Sheet>
) : null}
</div>
);
}
@@ -0,0 +1,85 @@
.page {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
overflow-y: auto;
padding-inline: env(safe-area-inset-left) env(safe-area-inset-right);
overscroll-behavior-y: contain;
}
.main {
width: min(100%, 640px);
margin: 0 auto;
padding-bottom: env(safe-area-inset-bottom);
}
.badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 0 var(--space-2);
border-radius: var(--radius-sm);
background: var(--ok-soft);
color: var(--ok);
font-size: 12px;
white-space: nowrap;
}
.share {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
border-bottom: 1px solid var(--line);
background: linear-gradient(to bottom, var(--surface-2), transparent);
}
.h {
margin: 0;
font-size: 14px;
}
.link {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
background: var(--surface);
font-family: var(--font-mono);
font-size: 12px;
overflow-wrap: anywhere;
}
.row {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.dim {
margin: 0;
color: var(--text-dim);
font-size: 12px;
}
.note {
margin: 0;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--line);
color: var(--text-dim);
font-size: 13px;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--line);
font-size: 13px;
}
.list {
margin: 0;
padding: 0;
list-style: none;
}
+82
View File
@@ -0,0 +1,82 @@
import { lazy, Suspense, useCallback, useState } from 'react';
import { CompactRow } from '@/features/item/CompactRow';
import { useDownload } from '@/features/item/download';
import { formatRemaining } from '@/lib/format';
import { useShareSession } from '@/store/shareSession';
import { Button } from '@/ui/Button';
import { CopyButton } from '@/ui/CopyButton';
import { Icon } from '@/ui/Icon';
import { QrCode } from '@/ui/QrCode';
import { ShareBar } from './ShareBar';
import styles from './ShareView.module.css';
const PreviewSheet = lazy(() => import('@/features/item/PreviewSheet'));
const noop = () => {};
/** 已封存的分享:只读。访客与创建者看到的清单相同,创建者多一张「分享给别人」卡片 */
export function ShareView() {
const share = useShareSession((s) => s.share);
const ids = useShareSession((s) => s.order);
const download = useDownload();
const [sheetId, setSheetId] = useState<string>();
const [showQr, setShowQr] = useState(true);
const closeSheet = useCallback(() => setSheetId(undefined), []);
if (!share) return null;
const remaining = formatRemaining(share.createdAt + share.ttlSeconds * 1000);
async function downloadAll() {
const { byId } = useShareSession.getState();
for (const id of ids) {
const item = byId[id];
if (item) await download(item);
await new Promise((r) => setTimeout(r, 300)); // 浏览器会拦截太密集的多文件下载
}
}
return (
<div className={styles.page}>
<ShareBar
title={share.mine ? '我的分享' : '收到的分享'}
badge={<span className={styles.badge}><Icon name="lock" size={12} /> 已封存 · 只读</span>}
/>
<main className={styles.main}>
{share.mine ? (
<section className={styles.share} aria-label="分享给别人">
<h2 className={styles.h}>分享给别人</h2>
<code className={styles.link}>{share.link}</code>
<div className={styles.row}>
<CopyButton text={share.link} variant="primary" label="复制链接" />
<Button variant="ghost" icon="scan" aria-expanded={showQr} onClick={() => setShowQr((v) => !v)}>
{showQr ? '收起二维码' : '二维码'}
</Button>
</div>
{showQr ? <QrCode text={share.link} label="分享链接二维码" /> : null}
<p className={styles.dim}>
访客只能预览和下载。内容已封存,你也不能再修改或删除,{remaining}后自动清除。持有链接的人都能下载。
</p>
</section>
) : (
<p className={styles.note}>
这是一个只读分享:内容不会再被修改,{remaining}后自动清除。
</p>
)}
<div className={styles.head}>
<span>{ids.length} 项内容</span>
<Button variant="primary" icon="download" onClick={() => void downloadAll()} disabled={ids.length === 0}>全部下载</Button>
</div>
<ul className={styles.list}>
{ids.map((id) => (
<CompactRow key={id} id={id} selected={false} onSelect={setSheetId} />
))}
</ul>
</main>
<Suspense fallback={null}>
{sheetId ? <PreviewSheet id={sheetId} onClose={closeSheet} readOnly onDelete={noop} /> : null}
</Suspense>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { parseChannelLink } from './channelLink';
const ID = 'k3Fq9xWm2LpA7sTdQ8vZ1n';
const KEY = 'Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF';
describe('parseChannelLink', () => {
it('成员链接:带 k 即 enc', () => {
const r = parseChannelLink(`https://webdrop.example/c/${ID}#k=${KEY}`);
expect(r).toMatchObject({ ok: true, link: { id: ID, mode: 'enc', isOwner: false } });
});
it('所有者链接:带 a', () => {
const r = parseChannelLink(`https://webdrop.example/c/${ID}#k=${KEY}&a=${KEY}`);
expect(r).toMatchObject({ ok: true, link: { mode: 'enc', isOwner: true } });
});
it('不带 k 即 raw;带 w 表示可上传', () => {
const r = parseChannelLink(`https://webdrop.example/c/${ID}#w=${KEY}`);
expect(r).toMatchObject({ ok: true, link: { mode: 'raw', canWrite: true } });
});
it('忽略首尾空白', () => {
expect(parseChannelLink(` https://x.example/c/${ID} `).ok).toBe(true);
});
it.each([
['不是链接', 'hello', 'bad_url'],
['路径不对', `https://x.example/other/${ID}`, 'bad_url'],
['id 长度不对', 'https://x.example/c/abc', 'bad_id'],
['密钥长度不对', `https://x.example/c/${ID}#k=short`, 'bad_key'],
])('%s → %s', (_n, input, error) => {
expect(parseChannelLink(input)).toEqual({ ok: false, error });
});
});
+43
View File
@@ -0,0 +1,43 @@
/** protocol §2:频道链接。密钥只在 fragment 里,这里的结果只放内存,不写日志、不持久化 */
export interface ParsedChannelLink {
id: string;
mode: 'enc' | 'raw';
/** 含 a:所有者链接 */
isOwner: boolean;
/** raw 模式含 w:可上传 */
canWrite: boolean;
keys: { k?: string; a?: string; w?: string };
}
export type LinkError = 'bad_url' | 'bad_id' | 'bad_key';
export type LinkResult = { ok: true; link: ParsedChannelLink } | { ok: false; error: LinkError };
const ID_RE = /^[A-Za-z0-9_-]{22}$/; // 16 B → base64url 无填充
const KEY_RE = /^[A-Za-z0-9_-]{43}$/; // 32 B → base64url 无填充
export function parseChannelLink(input: string): LinkResult {
let url: URL;
try {
url = new URL(input.trim());
} catch {
return { ok: false, error: 'bad_url' };
}
const m = /^\/c\/([^/]+)\/?$/.exec(url.pathname);
if (!m || !m[1] || !ID_RE.test(m[1])) return { ok: false, error: m ? 'bad_id' : 'bad_url' };
const params = new URLSearchParams(url.hash.replace(/^#/, ''));
const keys: ParsedChannelLink['keys'] = {};
for (const name of ['k', 'a', 'w'] as const) {
const v = params.get(name);
if (v === null) continue;
if (!KEY_RE.test(v)) return { ok: false, error: 'bad_key' };
keys[name] = v;
}
// 降级防御:带 k 就是 enc,不带就是 raw
return {
ok: true,
link: { id: m[1], mode: keys.k ? 'enc' : 'raw', isOwner: keys.a !== undefined, canWrite: keys.w !== undefined, keys },
};
}
+26
View File
@@ -0,0 +1,26 @@
export interface ClipboardPayload {
files: File[];
text: string;
}
/** 读取剪贴板(需要用户手势与权限)。失败或被拒绝返回 null */
export async function readClipboard(): Promise<ClipboardPayload | null> {
try {
if (!navigator.clipboard.read) return { files: [], text: await navigator.clipboard.readText() };
const files: File[] = [];
let text = '';
for (const entry of await navigator.clipboard.read()) {
for (const type of entry.types) {
const blob = await entry.getType(type);
if (type.startsWith('image/')) {
files.push(new File([blob], `粘贴-${Date.now()}.${type.split('/')[1] ?? 'png'}`, { type }));
} else if (type === 'text/plain' && !text) {
text = await blob.text();
}
}
}
return { files, text };
} catch {
return null;
}
}
+17
View File
@@ -0,0 +1,17 @@
export function saveBlob(blob: Blob, name: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 10_000);
}
export async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { isLongText, previewKindOf } from './fileKind';
describe('previewKindOf', () => {
it.each([
['image/png', 'a.png', 'image'],
['video/mp4', 'a.mp4', 'video'],
['application/zip', 'a.zip', 'archive'],
['application/octet-stream', 'a.tar.gz', 'archive'],
['', 'server.log', 'text'],
['application/json', 'a.json', 'text'],
['application/pdf', 'a.pdf', 'none'],
])('%s %s → %s', (mime, name, expected) => {
expect(previewKindOf(mime, name)).toBe(expected);
});
});
describe('isLongText', () => {
it('短文本不算长', () => expect(isLongText('hello')).toBe(false));
it('超过 8 行算长', () => expect(isLongText('a\n'.repeat(9))).toBe(true));
it('超过 400 字符算长', () => expect(isLongText('a'.repeat(401))).toBe(true));
});
+20
View File
@@ -0,0 +1,20 @@
import type { PreviewKind } from '@/core-bridge/types';
const ARCHIVE_EXT = /\.(zip|tar|gz|tgz|7z|rar|xz|bz2)$/i;
const TEXT_EXT = /\.(txt|md|log|json|csv|ya?ml|toml|ini|xml|js|ts|tsx|css|html|py|rs|go|c|cpp|h|sh)$/i;
export function previewKindOf(mime: string, name: string): PreviewKind {
if (mime.startsWith('image/')) return 'image';
if (mime.startsWith('video/')) return 'video';
if (mime.startsWith('audio/')) return 'audio';
if (ARCHIVE_EXT.test(name) || /zip|x-tar|x-7z|rar|gzip/.test(mime)) return 'archive';
if (mime.startsWith('text/') || mime === 'application/json' || TEXT_EXT.test(name)) return 'text';
return 'none';
}
const LONG_TEXT_CHARS = 400;
const LONG_TEXT_LINES = 8;
export function isLongText(text: string): boolean {
return text.length > LONG_TEXT_CHARS || text.split('\n').length > LONG_TEXT_LINES;
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import type { FileItem, TextItem } from '@/core-bridge/types';
import { isFiltering, matchesFilter, NO_FILTER } from './filter';
const base = { ts: 1, deviceId: 'self', state: { kind: 'complete' } as const };
const text: TextItem = { ...base, id: 't', kind: 'text', text: '明天 10 点开会' };
const zip: FileItem = { ...base, id: 'z', kind: 'file', name: 'Project.zip', mime: 'application/zip', size: 1, remaining: null, preview: 'archive' };
describe('matchesFilter', () => {
it('无筛选时全部通过', () => {
expect(matchesFilter(text, NO_FILTER)).toBe(true);
expect(isFiltering(NO_FILTER)).toBe(false);
});
it('按类型', () => {
expect(matchesFilter(zip, { query: '', kind: 'archive' })).toBe(true);
expect(matchesFilter(text, { query: '', kind: 'file' })).toBe(false);
expect(matchesFilter(zip, { query: '', kind: 'image' })).toBe(false);
});
it('按关键字,不区分大小写,文本看内容、文件看名称', () => {
expect(matchesFilter(zip, { query: 'project', kind: 'all' })).toBe(true);
expect(matchesFilter(text, { query: '开会', kind: 'all' })).toBe(true);
expect(matchesFilter(text, { query: 'zip', kind: 'all' })).toBe(false);
});
});
+37
View File
@@ -0,0 +1,37 @@
import type { Item } from '@/core-bridge/types';
export type KindFilter = 'all' | 'text' | 'file' | 'image' | 'video' | 'archive';
export interface ItemFilter {
query: string;
kind: KindFilter;
}
export const NO_FILTER: ItemFilter = { query: '', kind: 'all' };
export function isFiltering(f: ItemFilter): boolean {
return f.query.trim() !== '' || f.kind !== 'all';
}
function matchesKind(item: Item, kind: KindFilter): boolean {
switch (kind) {
case 'all':
return true;
case 'text':
return item.kind === 'text';
case 'file':
return item.kind === 'file';
case 'image':
case 'video':
case 'archive':
return item.kind === 'file' && item.preview === kind;
}
}
export function matchesFilter(item: Item, f: ItemFilter): boolean {
if (!matchesKind(item, f.kind)) return false;
const q = f.query.trim().toLowerCase();
if (q === '') return true;
const haystack = item.kind === 'text' ? item.text : item.name;
return haystack.toLowerCase().includes(q);
}
+33
View File
@@ -0,0 +1,33 @@
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let v = bytes / 1024;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i += 1;
}
return `${v >= 100 ? v.toFixed(0) : v.toFixed(1)} ${units[i]}`;
}
const timeFmt = new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false });
export function formatTime(ts: number): string {
return timeFmt.format(ts);
}
export function formatAgo(ts: number, now = Date.now()): string {
const m = Math.max(0, Math.round((now - ts) / 60000));
if (m < 1) return '刚刚';
if (m < 60) return `${m} 分钟前`;
return `${Math.round(m / 60)} 小时前`;
}
/** 距离过期还剩多久。到期后返回「已过期」 */
export function formatRemaining(expiresAt: number, now = Date.now()): string {
const m = Math.round((expiresAt - now) / 60000);
if (m <= 0) return '已过期';
if (m < 60) return `剩 ${m} 分钟`;
if (m < 60 * 48) return `剩 ${Math.round(m / 60)} 小时`;
return `剩 ${Math.round(m / 1440)} 天`;
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { textView } from './inline';
describe('textView', () => {
it('普通文本原样显示', () => {
expect(textView('明天开会')).toEqual({ json: false, body: '明天开会', long: false });
});
it('JSON 对象被格式化', () => {
const v = textView('{"a":1,"b":[1,2]}');
expect(v.json).toBe(true);
expect(v.body).toBe('{\n "a": 1,\n "b": [\n 1,\n 2\n ]\n}');
expect(v.long).toBe(false);
});
it('看起来像 JSON 但不合法,按普通文本', () => {
expect(textView('{not json').json).toBe(false);
});
it('JSON 很长时标记为 long', () => {
const big = JSON.stringify(Array.from({ length: 40 }, (_, i) => i));
expect(textView(big)).toMatchObject({ json: true, long: true });
});
it('文本很长时标记为 long', () => {
expect(textView('x'.repeat(500)).long).toBe(true);
});
});
+30
View File
@@ -0,0 +1,30 @@
import { isLongText } from './fileKind';
/** 不大的视频才直接放进列表播放 */
export const VIDEO_INLINE_MAX = 25 * 1024 * 1024;
const JSON_MAX_LINES = 14;
const JSON_MAX_CHARS = 1200;
const JSON_PARSE_MAX_CHARS = 100_000;
export interface TextView {
json: boolean;
/** 要显示的内容:JSON 已格式化 */
body: string;
/** 太长,列表里只显示前几行 */
long: boolean;
}
/** 文本与 JSON 的内联显示:能解析成对象或数组的当作 JSON 格式化,否则原样显示 */
export function textView(text: string): TextView {
const t = text.trim();
if ((t.startsWith('{') || t.startsWith('[')) && t.length <= JSON_PARSE_MAX_CHARS) {
try {
const body = JSON.stringify(JSON.parse(t) as unknown, null, 2);
return { json: true, body, long: body.split('\n').length > JSON_MAX_LINES || body.length > JSON_MAX_CHARS };
} catch {
/* 不是合法 JSON,按普通文本显示 */
}
}
return { json: false, body: text, long: isLongText(text) };
}
+141
View File
@@ -0,0 +1,141 @@
/** 由 Python qrcode 库生成的参考矩阵,用来逐模块验证 lib/qr.ts(纠错 M、字节模式、固定掩码) */
export const QR_FIXTURES = {
"short": {
"text": "hi",
"mask": 2,
"version": 1,
"rows": [
"111111100111101111111",
"100000100110101000001",
"101110101101101011101",
"101110101100101011101",
"101110101001101011101",
"100000101100101000001",
"111111101010101111111",
"000000001011100000000",
"101111100000101111100",
"011101010010100100001",
"001100110101010011110",
"111010000100000110100",
"111010100001010010101",
"000000001001111001001",
"111111100010101100010",
"100000101111111001001",
"101110101000100100100",
"101110101110100100100",
"101110101001010011100",
"100000100110000110100",
"111111101011010011110"
]
},
"member": {
"text": "https://webdrop.example/c/k3Fq9xWm2LpA7sTdQ8vZ1n#k=Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF",
"mask": 5,
"version": 6,
"rows": [
"11111110011101001111001001010000101111111",
"10000010101011100010111001100000001000001",
"10111010100011111000100010011001101011101",
"10111010101101010011000100100000101011101",
"10111010000011011101010001011010101011101",
"10000010001110001100100011101100001000001",
"11111110101010101010101010101010101111111",
"00000000101011100001001100110001000000000",
"10000010110010100101001011110100111001110",
"10001001000000110111111100010111010110011",
"00001010000101011000100010110000101000000",
"11010000010100101000100010010011110111000",
"10001111000000010101101000011000111000010",
"00011100011011100000111011001001111110011",
"00011010001001000101011001011100110111100",
"00010000101100000011000010100010110001111",
"00011111011010001111011001101101010100011",
"00000001011001110101100001010010111111011",
"11110011111100110001011110011001010111011",
"00111100011010001110001101011010100011011",
"11110011000010011001010000111101100001011",
"01001101000010010011010100110001111111100",
"01100111111001001110111010011000111000100",
"01110101010100000001001010011000110000001",
"10111111011001010011100000110000101110000",
"01011001100111001111101100011011010111010",
"10000110001011001110010000110010111110000",
"01010100001110011010000000100011011101110",
"11010011111101001101101010110001100101110",
"10100100110101011010000001111010111011011",
"11110010101100111001000011010011110011011",
"10000001110000001010000100100001101011010",
"10111110000110111100101101001000111111101",
"00000000100111111010010100000100100010000",
"11111110010001011010101000010011101011000",
"10000010001100001000010010100001100011011",
"10111010010100001110110001100101111111000",
"10111010010101110001000111110001000101010",
"10111010010101100100010001111110111110000",
"10000010010010001101001010101010011011101",
"11111110100100101101001011110101001111000"
]
},
"owner": {
"text": "https://webdrop.example/c/k3Fq9xWm2LpA7sTdQ8vZ1n#k=Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF&a=Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF",
"mask": 0,
"version": 8,
"rows": [
"1111111001010001110111101011111001100000101111111",
"1000001011001000001100011001001001111011101000001",
"1011101001001011010110011010110111011001101011101",
"1011101000000010011000010010010001100001001011101",
"1011101010010011000010111111111111111100001011101",
"1000001001101101010000100010111011111110001000001",
"1111111010101010101010101010101010101010101111111",
"0000000000001111111100100010011001101010000000000",
"1010101000001010110111111111111111101001000010010",
"1000000001010111100000000001000000000101000110101",
"0111111011101111011010001010000001010000010111111",
"0110010110001110111001010000111111111110011000001",
"1000001000101010000011011000100111101111000110111",
"0100010001010000110010100100100100010001010101101",
"1111001000111110011000010100000010001101100001111",
"1111000011111001011010101001011100100000111010010",
"0010101111110010100100101001011111111000001011000",
"1001010110000111100100000001100001010000000000001",
"0011101110000101100001010101011101011001110010101",
"1110000010100100001000001011100010001101000010000",
"1010111011011110001100011001111011111000000011010",
"0101010101000101101000000010010000011000010011011",
"0000111110001001110001111110000011011001111111011",
"0101100011100000000001100010001010011100100010000",
"1001101011110101011100101011111110011101101011100",
"0001100010111011110000100010100110011001100010001",
"0110111110110000101010111110101111100000111110001",
"1010010011000001000011111101101010101001101101000",
"1000111011110011111111001111111111111000000011001",
"1010010000000010010101001110110000001001100000011",
"0110001110000010000111000111010100110111111110011",
"0011100010011101001101001011011111101100100010010",
"0110011000110111100110100110111111101100110111110",
"1001110110111111101001100101100000011000001000011",
"0101011110011011111010111111100001011111001100111",
"1011010101110111100110111000001111110000011100011",
"1011011011101111011010000001110110111010111111000",
"0101010101111011001001100001100110000000011000000",
"0100011010101000110111100111100110010101011010111",
"0111000110010110000010111001100101100110100000000",
"1110001111101011100010111111100111111100111110000",
"0000000010011011100001100011110000000100100010010",
"1111111000111011110001101011110100010101101010011",
"1000001001011001000101100011111110011110100010010",
"1011101011000111100011111110111110011111111110001",
"1011101001000010011011101100100001011001100011110",
"1011101011100110000000101110110010001001101111100",
"1000001000111010001010100101001000000110000100010",
"1111111010010000110111101111111110011010011010011"
]
}
} as const;
/** 同一段内容(所有者链接)在 8 种掩码下的参考矩阵:自动选掩码的结果必须等于其中之一 */
export const QR_ALL_MASKS = {
text: "https://webdrop.example/c/k3Fq9xWm2LpA7sTdQ8vZ1n#k=Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF&a=Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF",
rows: [["1111111001010001110111101011111001100000101111111", "1000001011001000001100011001001001111011101000001", "1011101001001011010110011010110111011001101011101", "1011101000000010011000010010010001100001001011101", "1011101010010011000010111111111111111100001011101", "1000001001101101010000100010111011111110001000001", "1111111010101010101010101010101010101010101111111", "0000000000001111111100100010011001101010000000000", "1010101000001010110111111111111111101001000010010", "1000000001010111100000000001000000000101000110101", "0111111011101111011010001010000001010000010111111", "0110010110001110111001010000111111111110011000001", "1000001000101010000011011000100111101111000110111", "0100010001010000110010100100100100010001010101101", "1111001000111110011000010100000010001101100001111", "1111000011111001011010101001011100100000111010010", "0010101111110010100100101001011111111000001011000", "1001010110000111100100000001100001010000000000001", "0011101110000101100001010101011101011001110010101", "1110000010100100001000001011100010001101000010000", "1010111011011110001100011001111011111000000011010", "0101010101000101101000000010010000011000010011011", "0000111110001001110001111110000011011001111111011", "0101100011100000000001100010001010011100100010000", "1001101011110101011100101011111110011101101011100", "0001100010111011110000100010100110011001100010001", "0110111110110000101010111110101111100000111110001", "1010010011000001000011111101101010101001101101000", "1000111011110011111111001111111111111000000011001", "1010010000000010010101001110110000001001100000011", "0110001110000010000111000111010100110111111110011", "0011100010011101001101001011011111101100100010010", "0110011000110111100110100110111111101100110111110", "1001110110111111101001100101100000011000001000011", "0101011110011011111010111111100001011111001100111", "1011010101110111100110111000001111110000011100011", "1011011011101111011010000001110110111010111111000", "0101010101111011001001100001100110000000011000000", "0100011010101000110111100111100110010101011010111", "0111000110010110000010111001100101100110100000000", "1110001111101011100010111111100111111100111110000", "0000000010011011100001100011110000000100100010010", "1111111000111011110001101011110100010101101010011", "1000001001011001000101100011111110011110100010010", "1011101011000111100011111110111110011111111110001", "1011101001000010011011101100100001011001100011110", "1011101011100110000000101110110010001001101111100", "1000001000111010001010100101001000000110000100010", "1111111010010000110111101111111110011010011010011"], ["1111111010000100100010111110101100110100101111111", "1000001000011101011001001100011100101111101000001", "1011101010011110000011001111100010001101101011101", "1011101001010111001101000111000100110101001011101", "1011101001000110010111111110101010101000001011101", "1000001010111000000101100011101110101010001000001", "1111111010101010101010101010101010101010101111111", "0000000001011010101001100011001100111111000000000", "1010001101011111100010111110101010111100000100101", "1101010100000010110101010100010101010000010011111", "0010101110111010001111011111010100000101000010101", "0011000011011011101100000101101010101011001101011", "1101011101111111010110001101110010111010010011101", "0001000100000101100111110001110001000100000000111", "1010011101101011001101000001010111011000110100101", "1010010110101100001111111100001001110101101111000", "0111111010100111110001111100001010101101011110010", "1100000011010010110001010100110100000101010101011", "0110111011010000110100000000001000001100100111111", "1011010111110001011101011110110111011000010111010", "1111101110001011011001001100101110101101010110000", "0000000000010000111101010111000101001101000110001", "0101111111011100100100111111010110001100111110001", "0000100010110101010100100011011111001001100011010", "1100101010100000001001101010101011001000101010110", "0100100011101110100101100011110011001100100011011", "0011111111100101111111111111111010110101111111011", "1111000110010100010110101000111111111100111000010", "1101101110100110101010011010101010101101010110011", "1111000101010111000000011011100101011100110101001", "0011011011010111010010010010000001100010101011001", "0110110111001000011000011110001010111001110111000", "0011001101100010110011110011101010111001100010100", "1100100011101010111100110000110101001101011101001", "0000001011001110101111101010110100001010011001101", "1110000000100010110011101101011010100101001001001", "1110001110111010001111010100100011101111101010010", "0000000000101110011100110100110011010101001101010", "0100011111111101100010110010110011000000001111101", "0111000011000011010111101100110000110011110101010", "1110001010111110110111111110110010101001111111010", "0000000011001110110100100010100101010001100011000", "1111111011101110100100101010100001000000101011001", "1000001000001100010000100010101011001011100011000", "1011101000010010110110111111101011001010111111011", "1011Line truncated
} as const;
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { encodeQr } from './qr';
import { QR_ALL_MASKS, QR_FIXTURES } from './qr.fixtures';
const toRows = (grid: boolean[][]) => grid.map((r) => r.map((c) => (c ? '1' : '0')).join(''));
describe('encodeQr', () => {
// 参考矩阵来自 Python qrcode(纠错 M、字节模式)。逐模块一致,说明版本选择、
// Reed-Solomon、交错、功能图形、格式位与版本位(v7+)都正确
it.each(Object.entries(QR_FIXTURES))('与参考实现逐模块一致:%s', (_name, fx) => {
expect(toRows(encodeQr(fx.text, fx.mask))).toEqual(fx.rows);
});
it('自动选掩码:结果必须等于参考实现 8 种掩码之一(验证掩码应用与还原)', () => {
const got = toRows(encodeQr(QR_ALL_MASKS.text)).join('|');
expect(QR_ALL_MASKS.rows.map((r) => r.join('|'))).toContain(got);
});
it('自动选择掩码时,尺寸与版本一致', () => {
const fx = QR_FIXTURES.member;
expect(encodeQr(fx.text)).toHaveLength(fx.rows.length);
});
it('内容太长时抛错', () => {
expect(() => encodeQr('x'.repeat(300))).toThrow(RangeError);
});
});
+303
View File
@@ -0,0 +1,303 @@
/**
* 极简二维码编码器:字节模式、纠错等级 M、版本 1–10(最多 213 字节)。
* 足够容纳频道链接。结构参照 ISO/IEC 18004 与 Nayuki 的公开实现思路。
* 为什么自写:避免为一个 100 行量级的功能引入依赖(frontend.md 依赖策略)。
*/
const ECC_PER_BLOCK_M = [0, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26] as const;
const NUM_BLOCKS_M = [0, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5] as const;
const MAX_VERSION = 10;
const FORMAT_M = 0; // 纠错等级 M 的格式位值
type Grid = boolean[][];
function rawModules(ver: number): number {
let n = (16 * ver + 128) * ver + 64;
if (ver >= 2) {
const align = Math.floor(ver / 7) + 2;
n -= (25 * align - 10) * align - 55;
if (ver >= 7) n -= 36;
}
return n;
}
const rawCodewords = (ver: number) => Math.floor(rawModules(ver) / 8);
const dataCodewords = (ver: number) => rawCodewords(ver) - ECC_PER_BLOCK_M[ver]! * NUM_BLOCKS_M[ver]!;
// ---- GF(256) 与 Reed-Solomon ----
function gfMul(x: number, y: number): number {
let z = 0;
for (let i = 7; i >= 0; i--) {
z = (z << 1) ^ ((z >>> 7) * 0x11d);
z ^= ((y >>> i) & 1) * x;
}
return z;
}
function rsDivisor(degree: number): number[] {
const result = new Array<number>(degree).fill(0);
result[degree - 1] = 1;
let root = 1;
for (let i = 0; i < degree; i++) {
for (let j = 0; j < degree; j++) {
result[j] = gfMul(result[j]!, root);
if (j + 1 < degree) result[j]! ^= result[j + 1]!;
}
root = gfMul(root, 2);
}
return result;
}
function rsRemainder(data: number[], divisor: number[]): number[] {
const result = divisor.map(() => 0);
for (const b of data) {
const factor = b ^ result.shift()!;
result.push(0);
divisor.forEach((coef, i) => {
result[i]! ^= gfMul(coef, factor);
});
}
return result;
}
// ---- 数据编码 ----
function encodeData(bytes: Uint8Array): { ver: number; codewords: number[] } {
let ver = 1;
const bitsFor = (v: number) => 4 + (v < 10 ? 8 : 16) + bytes.length * 8;
while (ver <= MAX_VERSION && bitsFor(ver) > dataCodewords(ver) * 8) ver++;
if (ver > MAX_VERSION) throw new RangeError('内容太长,无法编码为二维码');
const bits: number[] = [];
const push = (value: number, len: number) => {
for (let i = len - 1; i >= 0; i--) bits.push((value >>> i) & 1);
};
push(0b0100, 4);
push(bytes.length, ver < 10 ? 8 : 16);
for (const b of bytes) push(b, 8);
const capacity = dataCodewords(ver) * 8;
push(0, Math.min(4, capacity - bits.length));
push(0, (8 - (bits.length % 8)) % 8);
for (let pad = 0xec; bits.length < capacity; pad ^= 0xec ^ 0x11) push(pad, 8);
const data: number[] = [];
for (let i = 0; i < bits.length; i += 8) {
data.push(bits.slice(i, i + 8).reduce((a, b) => (a << 1) | b, 0));
}
return { ver, codewords: addEccAndInterleave(data, ver) };
}
function addEccAndInterleave(data: number[], ver: number): number[] {
const blocks = NUM_BLOCKS_M[ver]!;
const eccLen = ECC_PER_BLOCK_M[ver]!;
const raw = rawCodewords(ver);
const shortLen = Math.floor(raw / blocks);
const numShort = blocks - (raw % blocks);
const divisor = rsDivisor(eccLen);
const parts: number[][] = [];
for (let i = 0, k = 0; i < blocks; i++) {
const len = shortLen - eccLen + (i < numShort ? 0 : 1);
const dat = data.slice(k, k + len);
k += len;
const ecc = rsRemainder(dat, divisor);
if (i < numShort) dat.push(0);
parts.push(dat.concat(ecc));
}
const out: number[] = [];
for (let i = 0; i < parts[0]!.length; i++) {
parts.forEach((block, j) => {
if (i !== shortLen - eccLen || j >= numShort) out.push(block[i]!);
});
}
return out;
}
// ---- 矩阵 ----
class Matrix {
readonly size: number;
readonly modules: Grid;
readonly isFn: Grid;
constructor(readonly ver: number) {
this.size = ver * 4 + 17;
this.modules = Array.from({ length: this.size }, () => new Array<boolean>(this.size).fill(false));
this.isFn = Array.from({ length: this.size }, () => new Array<boolean>(this.size).fill(false));
this.drawFunctionPatterns();
}
private setFn(x: number, y: number, dark: boolean) {
this.modules[y]![x] = dark;
this.isFn[y]![x] = true;
}
private drawFunctionPatterns() {
const { size, ver } = this;
for (let i = 0; i < size; i++) {
this.setFn(6, i, i % 2 === 0);
this.setFn(i, 6, i % 2 === 0);
}
this.finder(3, 3);
this.finder(size - 4, 3);
this.finder(3, size - 4);
const pos = alignPositions(ver);
pos.forEach((cy, i) =>
pos.forEach((cx, j) => {
const corner = (i === 0 && j === 0) || (i === 0 && j === pos.length - 1) || (i === pos.length - 1 && j === 0);
if (!corner) this.align(cx, cy);
}),
);
this.drawFormat(0);
this.drawVersion();
}
private finder(cx: number, cy: number) {
for (let dy = -4; dy <= 4; dy++) {
for (let dx = -4; dx <= 4; dx++) {
const dist = Math.max(Math.abs(dx), Math.abs(dy));
const x = cx + dx;
const y = cy + dy;
if (x >= 0 && x < this.size && y >= 0 && y < this.size) this.setFn(x, y, dist !== 2 && dist !== 4);
}
}
}
private align(cx: number, cy: number) {
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) this.setFn(cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
}
}
drawFormat(mask: number) {
const data = (FORMAT_M << 3) | mask;
let rem = data;
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
const bits = ((data << 10) | rem) ^ 0x5412;
const bit = (i: number) => ((bits >>> i) & 1) !== 0;
const { size } = this;
for (let i = 0; i <= 5; i++) this.setFn(8, i, bit(i));
this.setFn(8, 7, bit(6));
this.setFn(8, 8, bit(7));
this.setFn(7, 8, bit(8));
for (let i = 9; i < 15; i++) this.setFn(14 - i, 8, bit(i));
for (let i = 0; i < 8; i++) this.setFn(size - 1 - i, 8, bit(i));
for (let i = 8; i < 15; i++) this.setFn(8, size - 15 + i, bit(i));
this.setFn(8, size - 8, true);
}
private drawVersion() {
if (this.ver < 7) return;
let rem = this.ver;
for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
const bits = (this.ver << 12) | rem;
for (let i = 0; i < 18; i++) {
const dark = ((bits >>> i) & 1) !== 0;
const a = this.size - 11 + (i % 3);
const b = Math.floor(i / 3);
this.setFn(a, b, dark);
this.setFn(b, a, dark);
}
}
drawCodewords(data: number[]) {
const { size } = this;
let i = 0;
for (let right = size - 1; right >= 1; right -= 2) {
if (right === 6) right = 5;
for (let vert = 0; vert < size; vert++) {
for (let j = 0; j < 2; j++) {
const x = right - j;
const upward = ((right + 1) & 2) === 0;
const y = upward ? size - 1 - vert : vert;
if (!this.isFn[y]![x] && i < data.length * 8) {
this.modules[y]![x] = ((data[i >>> 3]! >>> (7 - (i & 7))) & 1) !== 0;
i++;
}
}
}
}
}
applyMask(mask: number) {
for (let y = 0; y < this.size; y++) {
for (let x = 0; x < this.size; x++) {
if (!this.isFn[y]![x] && maskCondition(mask, x, y)) this.modules[y]![x] = !this.modules[y]![x];
}
}
}
/** 简化的惩罚分:同色连续、同色 2×2 块、深浅失衡。只影响掩码选择,不影响可读性 */
penalty(): number {
const { size, modules } = this;
let score = 0;
for (let y = 0; y < size; y++) {
for (const horizontal of [true, false]) {
let run = 1;
for (let i = 1; i < size; i++) {
const cur = horizontal ? modules[y]![i] : modules[i]![y];
const prev = horizontal ? modules[y]![i - 1] : modules[i - 1]![y];
run = cur === prev ? run + 1 : 1;
if (run === 5) score += 3;
else if (run > 5) score += 1;
}
}
}
for (let y = 0; y < size - 1; y++) {
for (let x = 0; x < size - 1; x++) {
const c = modules[y]![x];
if (c === modules[y]![x + 1] && c === modules[y + 1]![x] && c === modules[y + 1]![x + 1]) score += 3;
}
}
const dark = modules.reduce((n, row) => n + row.filter(Boolean).length, 0);
return score + Math.floor(Math.abs(dark * 20 - size * size * 10) / (size * size)) * 10;
}
}
function alignPositions(ver: number): number[] {
if (ver === 1) return [];
const n = Math.floor(ver / 7) + 2;
const step = Math.ceil((ver * 4 + 4) / (n * 2 - 2)) * 2;
const result = [6];
for (let pos = ver * 4 + 10; result.length < n; pos -= step) result.splice(1, 0, pos);
return result;
}
function maskCondition(mask: number, x: number, y: number): boolean {
switch (mask) {
case 0: return (x + y) % 2 === 0;
case 1: return y % 2 === 0;
case 2: return x % 3 === 0;
case 3: return (x + y) % 3 === 0;
case 4: return (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0;
case 5: return ((x * y) % 2) + ((x * y) % 3) === 0;
case 6: return ((((x * y) % 2) + ((x * y) % 3)) % 2) === 0;
default: return ((((x + y) % 2) + ((x * y) % 3)) % 2) === 0;
}
}
/** 编码为深浅矩阵(true = 深色)。mask 仅供测试固定掩码,默认自动选择 */
export function encodeQr(text: string, mask?: number): Grid {
const { ver, codewords } = encodeData(new TextEncoder().encode(text));
const m = new Matrix(ver);
m.drawCodewords(codewords);
let best = mask ?? 0;
if (mask === undefined) {
let bestScore = Infinity;
for (let candidate = 0; candidate < 8; candidate++) {
m.applyMask(candidate);
m.drawFormat(candidate);
const score = m.penalty();
if (score < bestScore) {
bestScore = score;
best = candidate;
}
m.applyMask(candidate); // 还原
}
}
m.applyMask(best);
m.drawFormat(best);
return m.modules;
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { parseShareLink } from './shareLink';
const ID = 'k3Fq9xWm2LpA7sTdQ8vZ1n';
const KEY = 'Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF';
describe('parseShareLink', () => {
it('解析访客链接', () => {
expect(parseShareLink(`https://webdrop.example/s/${ID}#k=${KEY}`)).toEqual({ ok: true, link: { id: ID, key: KEY } });
});
it('忽略首尾空白', () => {
expect(parseShareLink(` https://x.example/s/${ID}#k=${KEY} `).ok).toBe(true);
});
it.each([
['不是链接', 'hello', 'bad_url'],
['频道链接不是分享链接', `https://x.example/c/${ID}#k=${KEY}`, 'bad_url'],
['id 长度不对', `https://x.example/s/abc#k=${KEY}`, 'bad_id'],
['缺少密钥', `https://x.example/s/${ID}`, 'bad_key'],
['密钥长度不对', `https://x.example/s/${ID}#k=short`, 'bad_key'],
])('%s → %s', (_n, input, error) => {
expect(parseShareLink(input)).toEqual({ ok: false, error });
});
});
+29
View File
@@ -0,0 +1,29 @@
import type { LinkError } from './channelLink';
/** 便捷分享的访客链接:`/s/{id}#k={K}`。分享始终是加密模式,没有所有者链接(完成时已丢弃) */
export interface ParsedShareLink {
id: string;
/** 解密密钥 K,只放内存 */
key: string;
}
export type ShareLinkResult = { ok: true; link: ParsedShareLink } | { ok: false; error: LinkError };
const ID_RE = /^[A-Za-z0-9_-]{22}$/;
const KEY_RE = /^[A-Za-z0-9_-]{43}$/;
export function parseShareLink(input: string): ShareLinkResult {
let url: URL;
try {
url = new URL(input.trim());
} catch {
return { ok: false, error: 'bad_url' };
}
const m = /^\/s\/([^/]+)\/?$/.exec(url.pathname);
if (!m?.[1]) return { ok: false, error: 'bad_url' };
if (!ID_RE.test(m[1])) return { ok: false, error: 'bad_id' };
const key = new URLSearchParams(url.hash.replace(/^#/, '')).get('k');
if (key === null || !KEY_RE.test(key)) return { ok: false, error: 'bad_key' };
return { ok: true, link: { id: m[1], key } };
}
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff. Show more