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,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;
|
||||
}
|
||||
Reference in new issue
Block a user