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