feat(web): connect quick share flow
Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
1 parent
53768a195d
commit
e02e024605
138 files changed
+10614
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.local
|
||||||
@@ -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
@@ -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. 没有违反上面的「必须遵守」与「禁止」。
|
||||||
@@ -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>
|
||||||
Generated
+3178
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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' }));
|
||||||
|
});
|
||||||
@@ -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}`;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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(/=+$/, '');
|
||||||
|
}
|
||||||
@@ -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' });
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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[]>;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { ChannelInfo, Share } from '@/core-bridge/types';
|
||||||
|
import { formatAgo, formatRemaining } from '@/lib/format';
|
||||||
|
import { useChannels } from '@/store/channels';
|
||||||
|
import { useShares } from '@/store/shares';
|
||||||
|
import { Button } from '@/ui/Button';
|
||||||
|
import { Icon } from '@/ui/Icon';
|
||||||
|
import styles from './ChannelList.module.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
kind: 'channel' | 'share';
|
||||||
|
title: string;
|
||||||
|
onOpen: (c: ChannelInfo | Share) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChannelList({ kind, title, onOpen }: Props) {
|
||||||
|
const channels = useChannels((s) => s.list);
|
||||||
|
const forgetChannel = useChannels((s) => s.forget);
|
||||||
|
const shares = useShares((s) => s.list);
|
||||||
|
const forgetShare = useShares((s) => s.forget);
|
||||||
|
const list = kind === 'share' ? shares : channels;
|
||||||
|
const [confirmId, setConfirmId] = useState<string>();
|
||||||
|
if (list.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label={title}>
|
||||||
|
<h2 className={styles.h}>{title}</h2>
|
||||||
|
<ul className={styles.list}>
|
||||||
|
{list.map((c) => (
|
||||||
|
<li key={c.id} className={styles.row}>
|
||||||
|
<button type="button" className={styles.open} onClick={() => onOpen(c)}>
|
||||||
|
<span className={styles.id}>{c.id.slice(0, 8)}…</span>
|
||||||
|
<span className={styles.meta}>
|
||||||
|
{'state' in c ? (
|
||||||
|
<>
|
||||||
|
<span className={styles.enc}><Icon name="lock" size={12} /> 加密</span>
|
||||||
|
<span className={c.state === 'sealed' ? styles.enc : styles.raw}>{c.state === 'sealed' ? '已封存' : '草稿'}</span>
|
||||||
|
<span>{c.mine ? '我创建的' : '收到的'}</span>
|
||||||
|
<span>{formatRemaining(c.createdAt + c.ttlSeconds * 1000)}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className={c.mode === 'enc' ? styles.enc : styles.raw}>
|
||||||
|
{c.mode === 'enc' ? <><Icon name="lock" size={12} /> 加密</> : '明文'}
|
||||||
|
</span>
|
||||||
|
<span>{c.role === 'owner' ? '所有者' : '成员'}</span>
|
||||||
|
<span>{c.seats} 台设备</span>
|
||||||
|
<span>{formatAgo(c.createdAt)}创建</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{confirmId === c.id ? (
|
||||||
|
<span className={styles.confirm}>
|
||||||
|
<Button variant="ghost" onClick={() => { (kind === 'share' ? forgetShare : forgetChannel)(c.id); setConfirmId(undefined); }}>确认移除</Button>
|
||||||
|
<Button variant="ghost" onClick={() => setConfirmId(undefined)}>取消</Button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Button variant="ghost" icon="trash" label={kind === 'share' ? '从本机移除' : '从本机移除(不会删除频道)'} onClick={() => setConfirmId(c.id)} />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field legend {
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field select {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg {
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg button {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg button[aria-checked="true"] {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check input {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warn {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--warn);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { ChannelInfo, CreateOptions } from '@/core-bridge/types';
|
||||||
|
import { useChannels } from '@/store/channels';
|
||||||
|
import { Button } from '@/ui/Button';
|
||||||
|
import { Sheet } from '@/ui/Sheet';
|
||||||
|
import styles from './CreateSheet.module.css';
|
||||||
|
|
||||||
|
const SEATS = [2, 3, 5, 10];
|
||||||
|
const TTLS = [
|
||||||
|
{ label: '1 小时', value: 3600 },
|
||||||
|
{ label: '24 小时', value: 86400 },
|
||||||
|
{ label: '7 天', value: 604800 },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: (c: ChannelInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新建频道:协议 §12 里与创建相关的设置子集 */
|
||||||
|
export function CreateSheet({ onClose, onCreated }: Props) {
|
||||||
|
const create = useChannels((s) => s.create);
|
||||||
|
const [opts, setOpts] = useState<CreateOptions>({ kind: 'channel', mode: 'enc', seats: 3, ttlSeconds: 86400, membersCanUpload: true, maxDownloads: null });
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const set = <K extends keyof CreateOptions>(k: K, v: CreateOptions[K]) => setOpts((o) => ({ ...o, [k]: v }));
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
setBusy(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
onCreated(await create(opts));
|
||||||
|
} catch {
|
||||||
|
setFailed(true);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet title="新建频道" onClose={onClose}>
|
||||||
|
<div className={styles.form}>
|
||||||
|
<fieldset className={styles.field}>
|
||||||
|
<legend>模式</legend>
|
||||||
|
<div className={styles.seg} role="radiogroup">
|
||||||
|
<button type="button" role="radio" aria-checked={opts.mode === 'enc'} onClick={() => set('mode', 'enc')}>加密(推荐)</button>
|
||||||
|
<button type="button" role="radio" aria-checked={opts.mode === 'raw'} onClick={() => set('mode', 'raw')}>明文</button>
|
||||||
|
</div>
|
||||||
|
{opts.mode === 'raw' ? <p className={styles.warn}>明文频道里的内容服务端可见,创建后不能改成加密。</p> : null}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<label className={styles.field}>
|
||||||
|
<span>设备数(席位)</span>
|
||||||
|
<select value={opts.seats} onChange={(e) => set('seats', Number(e.target.value))}>
|
||||||
|
{SEATS.map((n) => <option key={n} value={n}>{n} 台</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className={styles.field}>
|
||||||
|
<span>内容保留时间</span>
|
||||||
|
<select value={opts.ttlSeconds} onChange={(e) => set('ttlSeconds', Number(e.target.value))}>
|
||||||
|
{TTLS.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className={styles.check}>
|
||||||
|
<input type="checkbox" checked={opts.membersCanUpload} onChange={(e) => set('membersCanUpload', e.target.checked)} />
|
||||||
|
<span>允许成员上传</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{failed ? <p className={styles.warn} role="alert">创建失败,请重试。</p> : null}
|
||||||
|
<Button variant="primary" disabled={busy} onClick={() => void submit()}>{busy ? '创建中…' : '创建'}</Button>
|
||||||
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
.page {
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: page-in 0.21s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-6) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
padding: var(--space-4) 0 var(--space-2);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
margin: var(--space-2) 0 0;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions > * {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin: calc(var(--space-3) * -1) 0 0;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes page-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.page {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { lazy, Suspense, useCallback, useState, type DragEvent } from 'react';
|
||||||
|
import { channelPath, navigate, sharePath } from '@/app/navigation';
|
||||||
|
import type { ChannelInfo, Share } from '@/core-bridge/types';
|
||||||
|
import { ComposeSheet } from '@/features/channel/ComposeSheet';
|
||||||
|
import type { ClipboardPayload } from '@/lib/clipboard';
|
||||||
|
import { useGlobalPaste } from '@/lib/useGlobalPaste';
|
||||||
|
import { useChannels } from '@/store/channels';
|
||||||
|
import { useShares } from '@/store/shares';
|
||||||
|
import { Button } from '@/ui/Button';
|
||||||
|
import { ChannelList } from './ChannelList';
|
||||||
|
import { CreateSheet } from './CreateSheet';
|
||||||
|
import { QuickShare } from './QuickShare';
|
||||||
|
import styles from './Home.module.css';
|
||||||
|
|
||||||
|
const ScanSheet = lazy(() => import('@/features/scan/ScanSheet').then((m) => ({ default: m.ScanSheet })));
|
||||||
|
|
||||||
|
/** 初始状态:什么都没创建时看到的页面。核心是「快速分享」 */
|
||||||
|
export function Home() {
|
||||||
|
const createDraft = useShares((s) => s.createDraft);
|
||||||
|
const loadDemo = useChannels((s) => s.loadDemo);
|
||||||
|
const loadShareDemo = useShares((s) => s.loadDemo);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [scanOpen, setScanOpen] = useState(false);
|
||||||
|
const [composeOpen, setComposeOpen] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
const open = (c: ChannelInfo | Share) => navigate('state' in c ? sharePath(c.id) : channelPath(c.id));
|
||||||
|
|
||||||
|
const share = useCallback(
|
||||||
|
async (p: ClipboardPayload) => {
|
||||||
|
if (busy || (p.files.length === 0 && !p.text)) return;
|
||||||
|
setBusy(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
open(await createDraft(p));
|
||||||
|
} catch {
|
||||||
|
setFailed(true);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[busy, createDraft],
|
||||||
|
);
|
||||||
|
|
||||||
|
useGlobalPaste(share);
|
||||||
|
|
||||||
|
function onDragOver(e: DragEvent) {
|
||||||
|
if (!e.dataTransfer.types.includes('Files')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setDragging(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragging(false);
|
||||||
|
void share({ files: [...e.dataTransfer.files], text: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page} onDragOver={onDragOver} onDragLeave={() => setDragging(false)} onDrop={onDrop}>
|
||||||
|
<main className={styles.main}>
|
||||||
|
<header className={styles.head}>
|
||||||
|
<h1 className={styles.logo}>WebDrop</h1>
|
||||||
|
<p className={styles.sub}>在自己的设备之间快速传文件和文本,也可以分享给别人。</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<QuickShare busy={busy} dragging={dragging} onShare={share} onCompose={() => setComposeOpen(true)} />
|
||||||
|
{failed ? <p role="alert" className={styles.error}>创建分享失败,请重试。</p> : null}
|
||||||
|
|
||||||
|
<ChannelList kind="share" title="我的分享" onOpen={open} />
|
||||||
|
|
||||||
|
<div className={styles.divider}>常驻频道:多台自己的设备之间同步</div>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Button icon="edit" onClick={() => setCreateOpen(true)}>新建频道</Button>
|
||||||
|
<Button icon="scan" onClick={() => setScanOpen(true)}>加入频道</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChannelList kind="channel" title="我的频道" onOpen={open} />
|
||||||
|
|
||||||
|
<p className={styles.note}>
|
||||||
|
加密频道的密钥只在链接里,服务器看不到内容。持有链接的人可以读取该频道的全部历史。
|
||||||
|
</p>
|
||||||
|
{import.meta.env.DEV ? (
|
||||||
|
<Button variant="ghost" onClick={() => { loadDemo(); loadShareDemo(); }}>载入演示数据</Button>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{createOpen ? <CreateSheet onClose={() => setCreateOpen(false)} onCreated={open} /> : null}
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
{scanOpen ? <ScanSheet onClose={() => setScanOpen(false)} onJoined={open} /> : null}
|
||||||
|
</Suspense>
|
||||||
|
{composeOpen ? (
|
||||||
|
<ComposeSheet onClose={() => setComposeOpen(false)} onSubmit={(text) => void share({ files: [], text })} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-6) var(--space-4);
|
||||||
|
border: 2px dashed var(--line-strong);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--surface);
|
||||||
|
text-align: center;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragging {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--busy-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 1 1 120px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active:not(:disabled) {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.btn:hover:not(:disabled) {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import type { ClipboardPayload } from '@/lib/clipboard';
|
||||||
|
import { readClipboard } from '@/lib/clipboard';
|
||||||
|
import { useMediaQuery } from '@/lib/useMediaQuery';
|
||||||
|
import { Icon, type IconName } from '@/ui/Icon';
|
||||||
|
import styles from './QuickShare.module.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
busy: boolean;
|
||||||
|
dragging: boolean;
|
||||||
|
onShare: (p: ClipboardPayload) => void;
|
||||||
|
onCompose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 快速分享:拖入、粘贴或选择内容,自动创建一个临时加密频道并给出分享链接 */
|
||||||
|
export function QuickShare({ busy, dragging, onShare, onCompose }: Props) {
|
||||||
|
const touch = useMediaQuery('(pointer: coarse)');
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const galleryRef = useRef<HTMLInputElement>(null);
|
||||||
|
const cameraRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
async function paste() {
|
||||||
|
const clip = await readClipboard();
|
||||||
|
if (!clip) return onCompose(); // 无权限时退回手动输入
|
||||||
|
onShare({ files: clip.files, text: clip.files.length > 0 ? '' : clip.text.trim() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(files: FileList | null) {
|
||||||
|
if (files?.length) onShare({ files: [...files], text: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttons: { key: string; label: string; icon: IconName; run: () => void; touchOnly?: boolean }[] = [
|
||||||
|
{ key: 'file', label: '选择文件', icon: 'file', run: () => fileRef.current?.click() },
|
||||||
|
{ key: 'paste', label: '粘贴', icon: 'paste', run: () => void paste() },
|
||||||
|
{ key: 'write', label: '写文本', icon: 'edit', run: onCompose },
|
||||||
|
{ key: 'gallery', label: '相册', icon: 'image', run: () => galleryRef.current?.click(), touchOnly: true },
|
||||||
|
{ key: 'camera', label: '拍照', icon: 'camera', run: () => cameraRef.current?.click(), touchOnly: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={`${styles.card} ${dragging ? styles.dragging : ''}`} aria-busy={busy}>
|
||||||
|
<input ref={fileRef} type="file" multiple hidden onChange={(e) => { pick(e.target.files); e.target.value = ''; }} />
|
||||||
|
<input ref={galleryRef} type="file" accept="image/*,video/*" multiple hidden onChange={(e) => { pick(e.target.files); e.target.value = ''; }} />
|
||||||
|
<input ref={cameraRef} type="file" accept="image/*" capture="environment" hidden onChange={(e) => { pick(e.target.files); e.target.value = ''; }} />
|
||||||
|
|
||||||
|
<h2 className={styles.title}>{busy ? '正在创建分享…' : '快速分享'}</h2>
|
||||||
|
<p className={styles.hint}>
|
||||||
|
{touch ? '选择文件或文本' : '把文件拖到这里,或直接 Ctrl+V 粘贴文本'},做成一个分享列表发给别人下载。完成后内容只读,连你自己也不能再改。
|
||||||
|
</p>
|
||||||
|
<div className={styles.buttons}>
|
||||||
|
{buttons.filter((b) => !b.touchOnly || touch).map((b) => (
|
||||||
|
<button key={b.key} type="button" className={styles.btn} disabled={busy} onClick={b.run}>
|
||||||
|
<Icon name={b.icon} size={20} />
|
||||||
|
<span>{b.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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)} 天`;
|
||||||
|
}
|
||||||
@@ -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
Reference in new issue
Block a user