feat(web): connect quick share flow

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

No files matched your search

@@ -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;
}
}
+67
View File
@@ -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;
}
+38
View File
@@ -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;
}
}
+77
View File
@@ -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;
}
}
+49
View File
@@ -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);
}
+30
View File
@@ -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;
}
}
+58
View File
@@ -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;
}
}
+48
View File
@@ -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;
}
+39
View File
@@ -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>
);
}
+140
View File
@@ -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);
}
}
+64
View File
@@ -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;
}
}
+12
View File
@@ -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;
}
+209
View File
@@ -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>
);
}