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

+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
*.local
+14
View File
@@ -0,0 +1,14 @@
{
"extends": ["stylelint-config-standard"],
"ignoreFiles": ["src/styles/tokens.css"],
"rules": {
"color-no-hex": true,
"color-named": "never",
"function-disallowed-list": ["rgb", "rgba", "hsl", "hsla", "oklch", "color-mix"],
"selector-class-pattern": null,
"custom-property-pattern": null,
"declaration-property-value-allowed-list": {
"/^(border-radius|box-shadow|backdrop-filter)$/": ["/var[(]/", "none", "0", "inherit"]
}
}
}
+106
View File
@@ -0,0 +1,106 @@
# web/ 契约(给人和 AI 代理)
本目录是 WebDrop 前端。动手前先读,冲突时以此文件与 `../docs/` 为准。
## 权威顺序
1. `../docs/threat-model.md`、`../docs/protocol.md`、`../docs/api.md`(协议与接口,不得违背)
2. `../docs/ui-design.md`(视觉与交互)
3. `../docs/frontend.md`(工程约定)
4. 本文件
5. 代码
文档与代码不一致时,**以文档为准**。要改行为,先改文档再改代码。
## 范围
- 只修改 `web/` 与前端相关文档(`docs/ui-design.md`、`docs/frontend.md`)。
- **不修改** `server/`、`docs/protocol.md`、`docs/api.md`、`docs/openapi.yaml`、`docs/threat-model.md`、`docs/architecture.md`。需要变更时,写明诉求交给负责人。
- `@webdrop/core` 未就绪时,在 `src/core-bridge/` 按文档接口做适配与假实现,不自行发明协议。
## 命令
```
npm run dev 开发
npm run typecheck 类型检查
npm run lint:css 样式检查(颜色、圆角、阴影、blur 必须走 token)
npm test Vitest
npm run build 生产构建
```
提交前 `typecheck`、`lint:css`、`test`、`build` 必须全部通过。
## 必须遵守
### 安全
- 取出 URL fragment 后,在任何异步代码之前 `history.replaceState`。
- 密钥 K 导入为不可导出的 `CryptoKey`;不把密钥、令牌、明文写入 `console`、URL、`localStorage`、错误信息。
- 链接含 `k` 就只接受 enc 且认证通过的帧;模式与链接不符直接拒绝。
- 同一 `(item_id, frame_index)` 只能加密一次;重试发送队列里已持久化的相同字节(protocol §5)。
- 不使用 `dangerouslySetInnerHTML`;预览走沙箱 iframe;raw 内容只当文本。
- 不引入第三方脚本、统计、CDN 字体。
### 分层
- 依赖单向:`app → features → ui`;`features → store → core-bridge`;`lib` 不依赖其他层。
- `ui/` 不依赖业务;`features/` 之间不互相导入,**唯一例外**:`features/channel/`、`features/home/`、`features/share/` 是组合层,可以导入 `features/item/`、`features/scan/`(home、share 还可导入 channel 里的 ActionBar 与 ComposeSheet);组件不直接调用网络或加解密。
- 不写 barrel(`index.ts` 汇总导出)。
- 所有页面跳转经 `app/navigation.ts` 的 `navigate()`,不直接操作 `history`。页面一加载就在 `app/entry.ts` 里同步取走并清除 fragment。**唯一例外**:弹层的返回键关闭,在 `lib/useBackToClose.ts`。
### 样式
- 颜色字面量只允许出现在 `src/styles/tokens.css`。
- 圆角、阴影、`backdrop-filter` 只能取 token。
- `backdrop-filter` 仅用于顶栏和底部输入栏,同屏不超过 2 个;必须有 `prefers-reduced-transparency` 与不支持时的回退。
- 阴影只用于浮层(菜单、弹窗、Toast);列表用 1px `--line` 分隔。
- 不引入 Tailwind、CSS-in-JS、UI 组件库、图标库、动画库。
### 状态与 React
- 条目状态由日志推导,用判别联合,`switch` 必须穷尽。
- 订阅最小派生值,不订阅整个 store;高频值(进度)不触发整列表重渲染。
- 派生状态在渲染期计算,不用 effect 同步;不在组件内定义组件。
- 条件渲染用三元,不用 `&&` 渲染可能为 `0` 的值。
- 加解密、装配、哈希在 Worker,主线程不处理大文件。
### 动效
- `ViewTransition` 一律 `default="none"`,`enter` 与 `exit` 成对;只用于有空间关系的变化(层级导航、条目增删)。
- 状态变化、重连、后台同步不加动画。
- 动画 CSS 只放在 `styles/transitions.css`,取自技能配方;必须尊重 `prefers-reduced-motion`。
- 方向性 `ViewTransition` 放在页面组件里,不放布局里。
### 文案
- 只做中文,文案直接写在组件里,不做 i18n。
- 安全相关文案必须诚实(见 `ui-design.md §7.4`):`ui_restrict_copy_download` 不是安全保证;raw 模式必须明确提示服务端可见;不夸大「匿名」「不可追踪」。
## 禁止
- 自动加载有下载上限(GATED)的文件内容:缩略图、内联视频一律等用户点击,滚动列表不能消耗下载次数。
- 为了「看起来现代」加装饰性渐变、霓虹、大圆角、pill 容器、眉标签、装饰性副标题。渐变只能用 `tokens.css` 里的渐变 token,且只表达状态或层次。
- 弹层不用 portal 就渲染在带 `content-visibility` 的列表项内(`position: fixed` 会失效)。
- 新增运行时依赖而没有书面理由(解决什么、为何不能自写、体积)。
- 提前抽象。同样的代码第二次出现才抽,第三次必须抽。
- 把颜色、间距、圆角写死在组件里。
- 静默吞掉认证失败或篡改;`tampered` 必须可见。
## 约定
- 文件单一职责,组件约 150 行为上限。
- 导出面最小化,不被使用的符号不导出。
- 注释只写「为什么」,并指向文档章节,例如 `// protocol §5: 重试必须发相同字节`。
- `store/` 与 `lib/` 的模块配 Vitest;用户可见流程配 Playwright。
- 路径别名 `@/` 指向 `src/`。
- 默认中文界面;提交信息沿用仓库风格。
## 完成的定义
一项改动完成,需要:
1. 对应文档已更新(如行为有变)。
2. `typecheck`、`lint:css`、`test`、`build` 全部通过。
3. 亮暗两套、手机与桌面宽度都看过。
4. 没有违反上面的「必须遵守」与「禁止」。
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="referrer" content="no-referrer" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#f7f7f6" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="WebDrop" />
<title>WebDrop</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3178
View File
File diff suppressed because it is too large. Load diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@webdrop/web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --noEmit",
"lint:css": "stylelint \"src/**/*.css\"",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"idb": "^8.0.3",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"zustand": "^5.0.15"
},
"devDependencies": {
"@types/node": "^26.6.3",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"stylelint": "^17.16.0",
"stylelint-config-standard": "^40.0.0",
"typescript": "^7.0.2",
"vite": "^8.3.2",
"vitest": "^5.0.3"
}
}
+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);
});
});
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff. Show more