feat(web): connect quick share flow

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

No files matched your search

+128
View File
@@ -0,0 +1,128 @@
.row {
display: flex;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-2) var(--space-3) var(--space-2) var(--space-4);
border-bottom: 1px solid var(--line);
animation: row-in 0.21s ease-out;
}
.selected {
background: var(--accent-soft);
}
.check {
flex: none;
width: 16px;
height: 16px;
margin: var(--space-3) 0 0;
accent-color: var(--accent);
}
.body {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
padding: var(--space-1) 0;
}
.head {
display: flex;
align-items: center;
gap: var(--space-3);
min-width: 0;
min-height: 28px;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
text-align: left;
}
.head:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.inline {
min-width: 0;
}
.text {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.title {
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub {
color: var(--text-dim);
font-size: 12px;
overflow-wrap: anywhere;
}
.tag,
.update,
.conflict {
margin-left: var(--space-2);
padding: 0 var(--space-1);
border-radius: var(--radius-sm);
}
.tag {
background: var(--accent-soft);
}
.update {
background: var(--busy-soft);
color: var(--busy);
}
.conflict {
background: var(--warn-soft);
color: var(--warn);
}
.bar {
display: block;
height: 3px;
margin-top: var(--space-1);
overflow: hidden;
border-radius: var(--radius-sm);
background: var(--busy-soft);
}
.fill {
display: block;
height: 100%;
background: var(--progress-gradient);
transition: width 0.25s linear;
}
.tampered {
border-left: 3px solid var(--danger);
background: linear-gradient(90deg, var(--danger-soft), transparent);
color: var(--danger);
}
@keyframes row-in {
from {
opacity: 0;
translate: 0 -6px;
}
}
@media (prefers-reduced-motion: reduce) {
.row {
animation: none;
}
}
+93
View File
@@ -0,0 +1,93 @@
import { memo } from 'react';
import { formatSize, formatTime } from '@/lib/format';
import { Button } from '@/ui/Button';
import { CopyButton } from '@/ui/CopyButton';
import { useDownload } from './download';
import { InlineContent, inlineKindOf } from './InlineContent';
import { useItemSource } from './source';
import { shortDevice, titleOf, versionOf } from './summary';
import { Tile } from './Tile';
import styles from './CompactRow.module.css';
interface Props {
id: string;
selected: boolean;
/** 桌面端才有多选 */
checked?: boolean;
onSelect: (id: string) => void;
onToggle?: (id: string) => void;
}
/**
* 暂存区里的一行。文本、JSON 和不大的视频直接画在行里,不用点开;
* 其他内容显示缩略图或类型图标 + 标题 + 一行小字。
*/
export const CompactRow = memo(function CompactRow({ id, selected, checked, onSelect, onToggle }: Props) {
const source = useItemSource();
const item = source.useItem(id);
const nameOf = source.useDeviceNamer();
const download = useDownload();
if (!item) return null;
if (item.state.kind === 'tampered') {
return <li className={`${styles.row} ${styles.tampered}`}>内容被篡改,已丢弃</li>;
}
const receiving = item.state.kind === 'receiving' ? item.state.progress : null;
const version = versionOf(item);
const size = item.kind === 'file' ? item.size : new Blob([item.text]).size;
const inline = inlineKindOf(item);
const meta = (
<>
{shortDevice(nameOf(item.deviceId))} · {formatTime(item.ts)} · {formatSize(size)}
{item.kind === 'file' && item.remaining !== null ? ` · 剩 ${item.remaining} 次` : ''}
{version > 1 ? <span className={styles.tag}>v{version}</span> : null}
{item.kind === 'file' && item.unseen ? <span className={styles.update}>有更新</span> : null}
{item.kind === 'text' && item.conflict ? <span className={styles.conflict}>冲突</span> : null}
</>
);
return (
<li className={`${styles.row} ${selected ? styles.selected : ''}`}>
{onToggle ? (
<input type="checkbox" className={styles.check} checked={checked ?? false} onChange={() => onToggle(id)} aria-label="选择" />
) : null}
<div className={styles.body}>
{inline === 'text' ? (
// 文本没有标题,内容本身就是标题;这个按钮只显示小字,供键盘进入详情
<button type="button" className={styles.head} onClick={() => onSelect(id)}>
<span className={styles.sub}>{meta}</span>
</button>
) : (
<button type="button" className={styles.head} onClick={() => onSelect(id)}>
{inline === 'video' ? null : <Tile item={item} />}
<span className={styles.text}>
<span className={styles.title}>{titleOf(item)}</span>
<span className={styles.sub}>{meta}</span>
</span>
</button>
)}
{inline ? (
<div className={styles.inline}>
<InlineContent item={item} onOpen={() => onSelect(id)} />
</div>
) : null}
{receiving !== null ? (
<span className={styles.bar} role="progressbar" aria-valuenow={Math.round(receiving * 100)}>
<span className={styles.fill} style={{ width: `${Math.round(receiving * 100)}%` }} />
</span>
) : null}
</div>
{item.kind === 'text' ? (
<CopyButton text={item.text} variant="ghost" iconOnly />
) : (
<Button variant="ghost" icon="download" label="下载" disabled={receiving !== null} onClick={() => void download(item)} />
)}
</li>
);
});
@@ -0,0 +1,44 @@
.history {
padding-top: var(--space-3);
border-top: 1px solid var(--line);
}
.h {
margin: 0 0 var(--space-2);
font-size: 13px;
}
.list {
margin: 0;
padding: 0;
list-style: none;
}
.row {
display: flex;
align-items: center;
gap: var(--space-3);
min-height: 40px;
border-bottom: 1px solid var(--line);
}
.v {
width: 28px;
font-family: var(--font-mono);
font-weight: 600;
}
.info {
flex: 1;
min-width: 0;
color: var(--text-dim);
font-size: 13px;
}
.current {
padding: 0 var(--space-2);
border-radius: var(--radius-sm);
background: var(--ok-soft);
color: var(--ok);
font-size: 12px;
}
+41
View File
@@ -0,0 +1,41 @@
import type { FileItem } from '@/core-bridge/types';
import { formatSize, formatTime } from '@/lib/format';
import { saveBlob } from '@/lib/download';
import { Button } from '@/ui/Button';
import { useItemSource } from './source';
import { shortDevice } from './summary';
import styles from './HistoryList.module.css';
/** 文件的修改历史:当前版本在最上,旧版本可单独下载 */
export function HistoryList({ item }: { item: FileItem }) {
const { getBlob, useDeviceNamer } = useItemSource();
const namer = useDeviceNamer();
const versions = item.versions ?? [];
const nameOf = (id: string) => shortDevice(namer(id));
const total = versions.length + 1;
return (
<section className={styles.history} aria-label="修改历史">
<h3 className={styles.h}>修改历史</h3>
<ul className={styles.list}>
<li className={styles.row}>
<span className={styles.v}>v{total}</span>
<span className={styles.info}>{nameOf(item.deviceId)} · {formatTime(item.ts)} · {formatSize(item.size)}</span>
<span className={styles.current}>当前</span>
</li>
{versions.map((v, i) => (
<li key={v.id} className={styles.row}>
<span className={styles.v}>v{total - 1 - i}</span>
<span className={styles.info}>{nameOf(v.deviceId)} · {formatTime(v.ts)} · {formatSize(v.size)}</span>
<Button
variant="ghost"
icon="download"
label={`下载 v${total - 1 - i}`}
onClick={() => void getBlob(item.id, v.id).then((b) => saveBlob(b, item.name))}
/>
</li>
))}
</ul>
</section>
);
}
@@ -0,0 +1,55 @@
.text {
margin: 0;
font-family: inherit;
overflow-wrap: anywhere;
white-space: pre-wrap;
cursor: pointer;
}
.json {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
background: var(--surface-2);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.5;
}
.clamp {
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 6;
-webkit-box-orient: vertical;
}
.video {
display: block;
width: 100%;
max-height: 260px;
border-radius: var(--radius-md);
background: var(--surface-2);
}
.poster {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-2);
width: 100%;
min-height: 140px;
border: 1px dashed var(--line-strong);
border-radius: var(--radius-md);
background: var(--tile-gradient);
color: var(--text-dim);
font-size: 13px;
}
button.poster {
cursor: pointer;
}
button.poster:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
+52
View File
@@ -0,0 +1,52 @@
import { useMemo, useState } from 'react';
import type { FileItem, Item, TextItem } from '@/core-bridge/types';
import { textView, VIDEO_INLINE_MAX } from '@/lib/inline';
import { Icon } from '@/ui/Icon';
import { useBlobUrl } from './useBlobUrl';
import styles from './InlineContent.module.css';
/** 列表里能直接画出来的内容:文本与 JSON(不管多短都画),不大的视频 */
export function inlineKindOf(item: Item): 'text' | 'video' | null {
if (item.kind === 'text') return 'text';
if (item.preview === 'video' && item.size <= VIDEO_INLINE_MAX && item.state.kind === 'complete') return 'video';
return null;
}
export function InlineContent({ item, onOpen }: { item: Item; onOpen: () => void }) {
return item.kind === 'text' ? <InlineText item={item} onOpen={onOpen} /> : <InlineVideo item={item} />;
}
function InlineText({ item, onOpen }: { item: TextItem; onOpen: () => void }) {
const v = useMemo(() => textView(item.text), [item.text]);
// 纯展示的区域,键盘用户从标题按钮进入详情;这里的点击只是给鼠标和触屏多一个入口
return (
<pre
className={`${styles.text} ${v.json ? styles.json : ''} ${v.long ? styles.clamp : ''}`}
onClick={onOpen}
>
{v.body}
</pre>
);
}
/**
* 视频直接在列表里播放。没有下载上限时自动加载;有上限时,拉取内容会消耗一次下载(GATED,api.md),
* 所以先放占位,点了才加载,避免滚动列表就把次数用光。
*/
function InlineVideo({ item }: { item: FileItem }) {
const free = item.remaining === null;
const [play, setPlay] = useState(free);
const blob = useBlobUrl(item.id, play);
if (!play) {
return (
<button type="button" className={styles.poster} onClick={() => setPlay(true)}>
<Icon name="video" size={28} />
<span>点击播放 · 会消耗 1 次下载</span>
</button>
);
}
if (blob.status === 'loading') return <div className={styles.poster}>加载中…</div>;
if (blob.status === 'error') return <div className={styles.poster}>无法播放,可以直接下载。</div>;
return <video className={styles.video} src={blob.url} controls preload="metadata" playsInline />;
}
@@ -0,0 +1,55 @@
.detail {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.title {
margin: 0;
overflow-wrap: anywhere;
font-size: 18px;
}
.meta {
margin: 0;
color: var(--text-dim);
font-size: 13px;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
.banner {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-left: 3px solid var(--line-strong);
border-radius: var(--radius-sm);
font-size: 13px;
}
.update {
border-color: var(--busy);
background: linear-gradient(90deg, var(--busy-soft), transparent);
}
.conflict {
border-color: var(--warn);
background: linear-gradient(90deg, var(--warn-soft), transparent);
}
.body {
min-width: 0;
}
.dim {
margin: 0;
color: var(--text-dim);
}
+100
View File
@@ -0,0 +1,100 @@
import { useState } from 'react';
import type { Item } from '@/core-bridge/types';
import { isLongText } from '@/lib/fileKind';
import { formatSize, formatTime } from '@/lib/format';
import { Button } from '@/ui/Button';
import { CopyButton } from '@/ui/CopyButton';
import { useDownload } from './download';
import { HistoryList } from './HistoryList';
import { ArchivePreview } from './previews/ArchivePreview';
import { MediaPreview } from './previews/MediaPreview';
import { TextPreview } from './previews/TextPreview';
import { useItemSource } from './source';
import { shortDevice, titleOf } from './summary';
import { TextEditor } from './TextEditor';
import styles from './ItemDetail.module.css';
interface Props {
id: string;
/** 桌面内联预览区需要标题;抽屉里标题在抽屉头部 */
showTitle?: boolean;
/** 只读分享:不显示编辑、删除、冲突与更新提醒 */
readOnly?: boolean;
onDelete: (id: string) => void;
}
/** 条目详情:预览 + 操作 + 历史。桌面是右侧内联预览区,手机是底部抽屉 */
export default function ItemDetail({ id, showTitle, readOnly, onDelete }: Props) {
const source = useItemSource();
const item = source.useItem(id);
const nameOf = source.useDeviceNamer();
const download = useDownload();
const actions = source.actions;
const [editing, setEditing] = useState(false);
if (!item) return null;
const size = item.kind === 'file' ? item.size : new Blob([item.text]).size;
const long = item.kind === 'text' && isLongText(item.text);
const receiving = item.state.kind === 'receiving' ? item.state.progress : null;
return (
<div className={styles.detail}>
{showTitle ? <h2 className={styles.title}>{titleOf(item)}</h2> : null}
<p className={styles.meta}>
{shortDevice(nameOf(item.deviceId))} · {formatTime(item.ts)} · {formatSize(size)}
{item.kind === 'file' && item.remaining !== null ? ` · 剩余 ${item.remaining} 次下载` : ''}
</p>
<div className={styles.actions}>
{item.kind === 'text' ? <CopyButton text={item.text} variant="primary" /> : null}
<Button variant={item.kind === 'text' ? 'quiet' : 'primary'} icon="download" disabled={receiving !== null} onClick={() => void download(item)}>
{item.kind === 'text' ? (long ? '存为 .txt' : '下载') : '下载'}
</Button>
{item.kind === 'text' && !readOnly ? <Button icon="edit" onClick={() => setEditing(true)}>编辑</Button> : null}
{readOnly ? null : <Button variant="ghost" icon="trash" label="删除" onClick={() => onDelete(id)} />}
</div>
{!readOnly && item.kind === 'file' && item.unseen ? (
<div className={`${styles.banner} ${styles.update}`} role="status">
<span>{shortDevice(nameOf(item.deviceId))} 更新了此文件 · {formatTime(item.ts)}</span>
<Button variant="ghost" onClick={() => void actions?.markSeen(id)}>知道了</Button>
</div>
) : null}
{!readOnly && item.kind === 'text' && item.conflict ? (
<div className={`${styles.banner} ${styles.conflict}`} role="status">
<span>{shortDevice(item.kind === 'text' ? nameOf(item.conflict?.deviceId) : undefined)} 同时改过:「{item.conflict.text}」</span>
<span>
<Button variant="ghost" onClick={() => void actions?.resolveConflict(id, 'theirs')}>用他的</Button>
<Button variant="ghost" onClick={() => void actions?.resolveConflict(id, 'mine')}>保留我的</Button>
</span>
</div>
) : null}
<div className={styles.body}>
{receiving !== null ? <p className={styles.dim}>传输中 {Math.round(receiving * 100)}%,完成后可预览。</p> : null}
{receiving === null && editing && item.kind === 'text' ? (
<TextEditor item={item} onDone={() => setEditing(false)} />
) : null}
{receiving === null && !editing ? <Body item={item} /> : null}
</div>
{item.kind === 'file' && item.versions?.length ? <HistoryList item={item} /> : null}
</div>
);
}
function Body({ item }: { item: Item }) {
if (item.kind === 'text') return <TextPreview item={item} />;
switch (item.preview) {
case 'text':
return <TextPreview item={item} />;
case 'archive':
return <ArchivePreview id={item.id} />;
case 'image':
case 'video':
case 'audio':
return <MediaPreview id={item.id} kind={item.preview} />;
case 'none':
return <p className={styles.dim}>此类型暂不支持预览,可以直接下载。</p>;
}
}
@@ -0,0 +1,10 @@
.pane {
min-width: 0;
padding: var(--space-6);
overflow-y: auto;
}
.empty {
margin: 0;
color: var(--text-dim);
}
+16
View File
@@ -0,0 +1,16 @@
import ItemDetail from './ItemDetail';
import styles from './PreviewPane.module.css';
interface Props {
id: string | undefined;
onDelete: (id: string) => void;
}
/** 电脑上的条目详情:列表右侧的内联预览区,不弹窗 */
export default function PreviewPane({ id, onDelete }: Props) {
return (
<section className={styles.pane} aria-label="预览">
{id ? <ItemDetail key={id} id={id} showTitle onDelete={onDelete} /> : <p className={styles.empty}>选择左侧的内容进行预览。</p>}
</section>
);
}
@@ -0,0 +1,12 @@
.size {
margin-left: var(--space-2);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 12px;
font-weight: 400;
}
.none {
margin: 0;
color: var(--text-dim);
}
+28
View File
@@ -0,0 +1,28 @@
import { Sheet } from '@/ui/Sheet';
import ItemDetail from './ItemDetail';
import { useItemSource } from './source';
import { titleOf } from './summary';
interface Props {
id: string;
onClose: () => void;
readOnly?: boolean;
onDelete?: (id: string) => void;
}
/** 手机上的条目详情:底部抽屉 */
export default function PreviewSheet({ id, onClose, readOnly, onDelete }: Props) {
const item = useItemSource().useItem(id);
return (
<Sheet wide title={item ? titleOf(item) : ''} onClose={onClose}>
<ItemDetail
id={id}
readOnly={readOnly}
onDelete={(i) => {
onDelete?.(i);
onClose();
}}
/>
</Sheet>
);
}
@@ -0,0 +1,26 @@
.editor {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.input {
width: 100%;
padding: var(--space-3);
border: 1px solid var(--line-strong);
border-radius: var(--radius-md);
background: var(--surface);
font-family: var(--font-mono);
font-size: 13px;
resize: vertical;
}
.input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.actions {
display: flex;
gap: var(--space-2);
}
+30
View File
@@ -0,0 +1,30 @@
import { useState } from 'react';
import type { TextItem } from '@/core-bridge/types';
import { useItemSource } from './source';
import { Button } from '@/ui/Button';
import styles from './TextEditor.module.css';
/** 修改 = 追加新版本,后写者胜(ui-design §6.4) */
export function TextEditor({ item, onDone }: { item: TextItem; onDone: () => void }) {
const editText = useItemSource().actions?.editText;
const [draft, setDraft] = useState(item.text);
return (
<div className={styles.editor}>
<textarea className={styles.input} value={draft} rows={8} autoFocus onChange={(e) => setDraft(e.target.value)} />
<div className={styles.actions}>
<Button
variant="primary"
disabled={draft === item.text}
onClick={() => {
void editText?.(item.id, draft);
onDone();
}}
>
保存
</Button>
<Button onClick={onDone}>取消</Button>
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
.tile {
display: grid;
flex: none;
place-items: center;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--tile-gradient);
color: var(--text-dim);
}
.thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
+23
View File
@@ -0,0 +1,23 @@
import type { Item } from '@/core-bridge/types';
import { Icon } from '@/ui/Icon';
import { iconOf } from './summary';
import { useBlobUrl } from './useBlobUrl';
import styles from './Tile.module.css';
function Thumb({ id }: { id: string }) {
const blob = useBlobUrl(id);
return blob.status === 'ready' ? <img className={styles.thumb} src={blob.url} alt="" /> : null;
}
/**
* 条目的缩略图:图片显示缩略图,其余显示类型图标。
* 有下载上限的文件(GATED)拉取一次就消耗一次下载,所以不自动拉取缩略图。
*/
export function Tile({ item, size = 44 }: { item: Item; size?: number }) {
const thumb = item.kind === 'file' && item.preview === 'image' && item.state.kind === 'complete' && item.remaining === null;
return (
<span className={styles.tile} style={{ width: size, height: size }}>
{thumb ? <Thumb id={item.id} /> : <Icon name={iconOf(item)} size={Math.round(size / 2.2)} />}
</span>
);
}
+20
View File
@@ -0,0 +1,20 @@
import type { Item } from '@/core-bridge/types';
import { saveBlob } from '@/lib/download';
import { useItemSource } from './source';
export function itemFileName(item: Item): string {
return item.kind === 'file' ? item.name : `文本-${item.id}.txt`;
}
/** 返回一个下载函数,取内容的来源由当前上下文(频道或分享)决定 */
export function useDownload(): (item: Item) => Promise<void> {
const { getBlob } = useItemSource();
return async (item) => {
try {
saveBlob(await getBlob(item.id), itemFileName(item));
} catch {
// 原型用 alert 兜底;下载次数用完是 410 gone(api.md)
alert('无法下载:下载次数已用完,或内容已过期。');
}
};
}
@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
import type { ArchiveEntry } from '@/core-bridge/types';
import { formatSize } from '@/lib/format';
import { Icon } from '@/ui/Icon';
import { useItemSource } from '../source';
import styles from './previews.module.css';
/** 列出压缩包内的文件(不解压、不执行) */
export function ArchivePreview({ id }: { id: string }) {
const { listArchive } = useItemSource();
const [entries, setEntries] = useState<ArchiveEntry[] | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
listArchive(id).then(
(e) => !cancelled && setEntries(e),
() => !cancelled && setFailed(true),
);
return () => {
cancelled = true;
};
}, [id, listArchive]);
if (failed) return <p className={styles.hint}>无法读取压缩包目录,可以直接下载。</p>;
if (!entries) return <p className={styles.hint}>读取目录…</p>;
const files = entries.filter((e) => !e.dir);
const total = files.reduce((sum, e) => sum + e.size, 0);
return (
<>
<p className={styles.summary}>
{files.length} 个文件,解压后约 {formatSize(total)}
</p>
<ul className={styles.tree}>
{entries.map((e) => {
const depth = e.path.replace(/\/$/, '').split('/').length - 1;
const name = e.path.replace(/\/$/, '').split('/').pop();
return (
<li key={e.path} className={styles.entry} style={{ paddingLeft: `${depth * 16}px` }}>
<Icon name={e.dir ? 'archive' : 'file'} size={16} />
<span className={styles.entryName}>{name}</span>
{e.dir ? null : <span className={styles.entrySize}>{formatSize(e.size)}</span>}
</li>
);
})}
</ul>
</>
);
}
@@ -0,0 +1,21 @@
import { useState } from 'react';
import { useBlobUrl } from '../useBlobUrl';
import styles from './previews.module.css';
interface Props {
id: string;
kind: 'image' | 'video' | 'audio';
}
/** 媒体用原生元素加载 blob URL,不执行任何内容里的脚本 */
export function MediaPreview({ id, kind }: Props) {
const blob = useBlobUrl(id);
const [broken, setBroken] = useState(false);
if (blob.status === 'loading') return <p className={styles.hint}>加载中…</p>;
if (blob.status === 'error') return <p className={styles.hint}>无法预览此内容,可以直接下载。</p>;
if (broken) return <p className={styles.hint}>此浏览器无法显示该格式(例如 iPhone 的 HEIC 照片),可以直接下载。</p>;
if (kind === 'image') return <img className={styles.media} src={blob.url} alt="" onError={() => setBroken(true)} />;
if (kind === 'video') return <video className={styles.media} src={blob.url} controls playsInline onError={() => setBroken(true)} />;
return <audio className={styles.audio} src={blob.url} controls />;
}
@@ -0,0 +1,35 @@
import { useEffect, useState } from 'react';
import type { Item } from '@/core-bridge/types';
import { useBlobUrl } from '../useBlobUrl';
import styles from './previews.module.css';
const MAX_CHARS = 200_000;
function pretty(name: string, text: string): string {
if (!name.endsWith('.json')) return text;
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text;
}
}
/** 只当纯文本渲染(threat-model §4:raw 内容不内联渲染) */
export function TextPreview({ item }: { item: Item }) {
const blob = useBlobUrl(item.id, item.kind === 'file');
const [fileText, setFileText] = useState<string | null>(null);
useEffect(() => {
if (item.kind !== 'file' || blob.status !== 'ready') return;
let cancelled = false;
void blob.blob.slice(0, MAX_CHARS).text().then((t) => !cancelled && setFileText(t));
return () => {
cancelled = true;
};
}, [item.kind, blob]);
const text = item.kind === 'text' ? item.text : fileText;
if (text === null) return <p className={styles.hint}>{blob.status === 'error' ? '无法读取内容。' : '加载中…'}</p>;
return <pre className={styles.code}>{item.kind === 'file' ? pretty(item.name, text) : text}</pre>;
}
@@ -0,0 +1,61 @@
.hint {
margin: 0;
color: var(--text-dim);
}
.media {
display: block;
max-width: 100%;
max-height: 68dvh;
margin: 0 auto;
border-radius: var(--radius-md);
background: var(--surface-2);
}
.audio {
width: 100%;
}
.code {
margin: 0;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.55;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.summary {
margin: 0 0 var(--space-3);
color: var(--text-dim);
font-size: 13px;
}
.tree {
margin: 0;
padding: 0;
list-style: none;
}
.entry {
display: flex;
align-items: center;
gap: var(--space-2);
min-height: 32px;
border-bottom: 1px solid var(--line);
color: var(--text);
}
.entryName {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entrySize {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 12px;
}
+53
View File
@@ -0,0 +1,53 @@
import { createContext, useContext } from 'react';
import type { ArchiveEntry, Item } from '@/core-bridge/types';
import { useShareSession } from '@/store/shareSession';
import { useWorkspace } from '@/store/workspace';
export interface ItemActions {
markSeen: (id: string) => Promise<void>;
editText: (id: string, text: string) => Promise<void>;
resolveConflict: (id: string, choice: 'mine' | 'theirs') => Promise<void>;
}
/**
* 条目组件(行、详情、预览)的数据来源。频道和便捷分享各自提供自己的,
* 这样条目组件不依赖任何一个 store,两边也不会互相牵连。
*/
export interface ItemSource {
useItem: (id: string) => Item | undefined;
/** 返回「设备 id → 名字」的函数 */
useDeviceNamer: () => (deviceId: string | undefined) => string | undefined;
getBlob: (id: string, versionId?: string) => Promise<Blob>;
listArchive: (id: string) => Promise<ArchiveEntry[]>;
/** 只有频道有:分享是只读的 */
actions?: ItemActions;
}
export const channelSource: ItemSource = {
useItem: (id) => useWorkspace((s) => s.byId[id]),
useDeviceNamer: () => {
const devices = useWorkspace((s) => s.devices);
return (deviceId) => devices.find((d) => d.id === deviceId)?.name;
},
getBlob: (id, versionId) => useWorkspace.getState().getBlob(id, versionId),
listArchive: (id) => useWorkspace.getState().listArchive(id),
actions: {
markSeen: (id) => useWorkspace.getState().markSeen(id),
editText: (id, text) => useWorkspace.getState().editText(id, text),
resolveConflict: (id, choice) => useWorkspace.getState().resolveConflict(id, choice),
},
};
export const shareSource: ItemSource = {
useItem: (id) => useShareSession((s) => s.byId[id]),
// 分享里没有「设备」的概念,内容都来自创建者
useDeviceNamer: () => () => '创建者',
getBlob: (id) => useShareSession.getState().getBlob(id),
listArchive: (id) => useShareSession.getState().listArchive(id),
};
export const ItemSourceContext = createContext<ItemSource>(channelSource);
export function useItemSource(): ItemSource {
return useContext(ItemSourceContext);
}
+30
View File
@@ -0,0 +1,30 @@
import type { Item } from '@/core-bridge/types';
import type { IconName } from '@/ui/Icon';
const FILE_ICON = {
image: 'image',
video: 'video',
audio: 'file',
text: 'text',
archive: 'archive',
none: 'file',
} as const satisfies Record<string, IconName>;
export function iconOf(item: Item): IconName {
return item.kind === 'text' ? 'text' : FILE_ICON[item.preview];
}
/** 列表里的一行标题:文件名,或文本的第一行 */
export function titleOf(item: Item): string {
if (item.kind === 'file') return item.name;
return item.text.split('\n').find((l) => l.trim() !== '')?.trim() ?? '(空)';
}
export function shortDevice(name: string | undefined): string {
return (name ?? '未知设备').split(' · ')[0] ?? '未知设备';
}
/** 当前版本号:旧版本数 + 1 */
export function versionOf(item: Item): number {
return item.kind === 'file' ? (item.versions?.length ?? 0) + 1 : 1;
}
+33
View File
@@ -0,0 +1,33 @@
import { useEffect, useState } from 'react';
import { useItemSource } from './source';
type BlobState =
| { status: 'loading' }
| { status: 'ready'; blob: Blob; url: string }
| { status: 'error' };
/** 取条目内容并生成 object URL,卸载时释放 */
export function useBlobUrl(id: string, enabled = true): BlobState {
const { getBlob } = useItemSource();
const [state, setState] = useState<BlobState>({ status: 'loading' });
useEffect(() => {
if (!enabled) return;
let url: string | null = null;
let cancelled = false;
getBlob(id).then(
(blob) => {
if (cancelled) return;
url = URL.createObjectURL(blob);
setState({ status: 'ready', blob, url });
},
() => !cancelled && setState({ status: 'error' }),
);
return () => {
cancelled = true;
if (url) URL.revokeObjectURL(url);
};
}, [id, enabled, getBlob]);
return state;
}