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