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

+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>
);
}