Files
WebDrop/web/src/features/home/Home.tsx
T
justme 10aa0304e4
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
feat(web): separate build sources and localize time
2026-10-12 16:50:15 +08:00

114 lines
4.8 KiB
TypeScript

import { lazy, Suspense, useCallback, useState } from 'react';
import { channelPath, navigate, sharePath } from '@/app/navigation';
import { buildInfo } from '@/lib/buildInfo';
import type { ChannelInfo, Share } from '@/core-bridge/types';
import type { ClipboardPayload } from '@/lib/clipboard';
import { useGlobalPaste } from '@/lib/useGlobalPaste';
import { useChannels } from '@/store/channels';
import { useShares } from '@/store/shares';
import { solveCreationCaptcha } from '@/core-bridge/captcha';
import { errorMessage } from '@/core-bridge/errors';
import { Button } from '@/ui/Button';
import { ThemeMenu } from '@/ui/ThemeMenu';
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 [busy, setBusy] = useState(false);
const [failedMessage, setFailedMessage] = useState('');
const [shareText, setShareText] = useState('');
const [shareFiles, setShareFiles] = useState<File[]>([]);
const open = useCallback((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.trim())) return false;
setBusy(true);
setFailedMessage('');
try {
const captchaToken = await solveCreationCaptcha();
open(await createDraft(p, captchaToken));
setShareFiles([]);
setShareText('');
return true;
} catch (error) {
setFailedMessage(errorMessage(error));
return false;
} finally {
setBusy(false);
}
},
[busy, createDraft, open],
);
const onClipboard = useCallback((p: ClipboardPayload) => {
if (p.files.length > 0) setShareFiles((files) => [...files, ...p.files]);
if (p.text) setShareText((text) => text ? `${text}\n${p.text}` : p.text);
}, []);
useGlobalPaste(onClipboard);
return (
<div className={styles.page}>
<div className={styles.appearance}><ThemeMenu /></div>
<main className={styles.main}>
<header className={styles.head}>
<h1 className={styles.logo}>WebDrop</h1>
<p className={styles.sub}>在自己的设备之间快速传文件和文本,也可以分享给别人。</p>
</header>
<QuickShare
busy={busy}
text={shareText}
files={shareFiles}
onTextChange={setShareText}
onFilesChange={setShareFiles}
onShare={share}
/>
{failedMessage ? <p role="alert" className={styles.error}>创建分享失败:{failedMessage}</p> : null}
<ChannelList kind="share" title="我的分享" onOpen={open} />
<section className={styles.channels} aria-labelledby="channels-title">
<div className={styles.sectionHead}>
<h2 id="channels-title">设备频道</h2>
<p>多台自己的设备之间长期同步</p>
</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>
</section>
{import.meta.env.DEV ? (
<Button variant="ghost" onClick={() => { loadDemo(); loadShareDemo(); }}>载入演示数据</Button>
) : null}
</main>
<aside className={styles.buildInfo} aria-label="构建信息">
<span>前端 v{buildInfo.frontendVersion} · tag {buildInfo.frontendTag || '未标记'} · commit {buildInfo.frontendCommit}</span>
<span>后端 tag {buildInfo.backendTag || '未标记'} · commit {buildInfo.backendCommit}</span>
<time dateTime={buildInfo.builtAt}>构建时间 {buildInfo.builtAtShanghai}</time>
</aside>
{createOpen ? <CreateSheet onClose={() => setCreateOpen(false)} onCreated={open} /> : null}
<Suspense fallback={null}>
{scanOpen ? <ScanSheet onClose={() => setScanOpen(false)} onChannel={open} onShare={open} /> : null}
</Suspense>
</div>
);
}