feat: integrate client and server sharing workflows
This commit is contained in:
1 parent
42b93ae2d8
commit
7406e2fe37
101 files changed
+3153
-253
No files matched your search
@@ -68,3 +68,15 @@
|
||||
font-size: 18px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.error {
|
||||
position: fixed;
|
||||
top: var(--bar-h);
|
||||
left: var(--space-4);
|
||||
right: var(--space-4);
|
||||
z-index: 20;
|
||||
padding: var(--space-3);
|
||||
background: var(--surface);
|
||||
color: var(--danger);
|
||||
border: 1px solid var(--danger);
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export function Workspace() {
|
||||
const sendFiles = useWorkspace((s) => s.sendFiles);
|
||||
const sendText = useWorkspace((s) => s.sendText);
|
||||
const remove = useWorkspace((s) => s.remove);
|
||||
const error = useWorkspace((s) => s.error);
|
||||
const download = useDownload();
|
||||
|
||||
// 刚创建的频道:直接展示分享面板和二维码,让对方马上能扫
|
||||
@@ -61,10 +62,7 @@ export function Workspace() {
|
||||
|
||||
// 从首页「快速分享」带过来的内容,进入频道后发送
|
||||
useEffect(() => {
|
||||
const { consumePayload, clearJustCreated } = useChannels.getState();
|
||||
const p = consumePayload();
|
||||
if (p) sendPayload(p);
|
||||
clearJustCreated();
|
||||
useChannels.getState().clearJustCreated();
|
||||
}, [sendPayload]);
|
||||
|
||||
const ids = useWorkspace(
|
||||
@@ -192,6 +190,7 @@ export function Workspace() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? <div className={styles.error} role="alert">{error}</div> : null}
|
||||
{!desktop && panelOpen ? (
|
||||
<Sheet title="设备与分享" onClose={closePanel}>
|
||||
<DevicePanel qrOpen={justCreated} />
|
||||
|
||||
@@ -36,7 +36,7 @@ export function ChannelList({ kind, title, onOpen }: Props) {
|
||||
<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>{formatRemaining(c.expiresAt)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ChannelInfo, CreateOptions } from '@/core-bridge/types';
|
||||
import { useChannels } from '@/store/channels';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Sheet } from '@/ui/Sheet';
|
||||
import { solveCreationCaptcha } from '@/core-bridge/captcha';
|
||||
import styles from './CreateSheet.module.css';
|
||||
|
||||
const SEATS = [2, 3, 5, 10];
|
||||
@@ -20,7 +21,7 @@ interface Props {
|
||||
/** 新建频道:协议 §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 [opts, setOpts] = useState<CreateOptions>({ mode: 'enc', seats: 3, ttlSeconds: 86400, membersCanUpload: true });
|
||||
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 }));
|
||||
@@ -29,7 +30,8 @@ export function CreateSheet({ onClose, onCreated }: Props) {
|
||||
setBusy(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
onCreated(await create(opts));
|
||||
const captchaToken = await solveCreationCaptcha();
|
||||
onCreated(await create(opts, captchaToken));
|
||||
} catch {
|
||||
setFailed(true);
|
||||
setBusy(false);
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
max-width: 560px;
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-6) var(--space-4);
|
||||
}
|
||||
@@ -31,11 +31,23 @@
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin-top: var(--space-2);
|
||||
.channels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.sectionHead h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.sectionHead p {
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -48,12 +60,22 @@
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0;
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (width >= 900px) {
|
||||
.main {
|
||||
padding-top: 8vh;
|
||||
}
|
||||
|
||||
.head {
|
||||
padding-bottom: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: calc(var(--space-3) * -1) 0 0;
|
||||
color: var(--danger);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { lazy, Suspense, useCallback, useState, type DragEvent } from 'react';
|
||||
import { lazy, Suspense, useCallback, useState } 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 { solveCreationCaptcha } from '@/core-bridge/captcha';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { ChannelList } from './ChannelList';
|
||||
import { CreateSheet } from './CreateSheet';
|
||||
@@ -21,67 +21,74 @@ export function Home() {
|
||||
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 [shareText, setShareText] = useState('');
|
||||
const [shareFiles, setShareFiles] = useState<File[]>([]);
|
||||
|
||||
const open = (c: ChannelInfo | Share) => navigate('state' in c ? sharePath(c.id) : channelPath(c.id));
|
||||
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)) return;
|
||||
if (busy || (p.files.length === 0 && !p.text.trim())) return false;
|
||||
setBusy(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
open(await createDraft(p));
|
||||
const captchaToken = await solveCreationCaptcha();
|
||||
open(await createDraft(p, captchaToken));
|
||||
setShareFiles([]);
|
||||
setShareText('');
|
||||
return true;
|
||||
} catch {
|
||||
setFailed(true);
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[busy, createDraft],
|
||||
[busy, createDraft, open],
|
||||
);
|
||||
|
||||
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: '' });
|
||||
}
|
||||
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} onDragOver={onDragOver} onDragLeave={() => setDragging(false)} onDrop={onDrop}>
|
||||
<div className={styles.page}>
|
||||
<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)} />
|
||||
<QuickShare
|
||||
busy={busy}
|
||||
text={shareText}
|
||||
files={shareFiles}
|
||||
onTextChange={setShareText}
|
||||
onFilesChange={setShareFiles}
|
||||
onShare={share}
|
||||
/>
|
||||
{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>
|
||||
<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} />
|
||||
<ChannelList kind="channel" title="我的频道" onOpen={open} />
|
||||
<p className={styles.note}>加密频道的密钥只在链接里,服务器看不到内容。持有链接的人可以读取该频道的全部历史。</p>
|
||||
</section>
|
||||
|
||||
<p className={styles.note}>
|
||||
加密频道的密钥只在链接里,服务器看不到内容。持有链接的人可以读取该频道的全部历史。
|
||||
</p>
|
||||
{import.meta.env.DEV ? (
|
||||
<Button variant="ghost" onClick={() => { loadDemo(); loadShareDemo(); }}>载入演示数据</Button>
|
||||
) : null}
|
||||
@@ -89,11 +96,8 @@ export function Home() {
|
||||
|
||||
{createOpen ? <CreateSheet onClose={() => setCreateOpen(false)} onCreated={open} /> : null}
|
||||
<Suspense fallback={null}>
|
||||
{scanOpen ? <ScanSheet onClose={() => setScanOpen(false)} onJoined={open} /> : null}
|
||||
{scanOpen ? <ScanSheet onClose={() => setScanOpen(false)} onChannel={open} onShare={open} /> : null}
|
||||
</Suspense>
|
||||
{composeOpen ? (
|
||||
<ComposeSheet onClose={() => setComposeOpen(false)} onSubmit={(text) => void share({ files: [], text })} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,10 @@
|
||||
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);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
text-align: center;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
@@ -15,55 +14,174 @@
|
||||
background: var(--busy-soft);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--ok-soft);
|
||||
color: var(--ok);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.editor {
|
||||
width: 100%;
|
||||
min-height: 112px;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.editor:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.editor::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 40px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.file > span {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file button {
|
||||
display: grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.btn {
|
||||
.btn,
|
||||
.submit {
|
||||
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);
|
||||
min-height: 42px;
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
.submit {
|
||||
flex: 0 0 auto;
|
||||
min-width: 160px;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.btn:disabled,
|
||||
.submit:disabled,
|
||||
.file button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn:focus-visible {
|
||||
.btn:focus-visible,
|
||||
.submit:focus-visible,
|
||||
.file button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn:active:not(:disabled) {
|
||||
.file button:hover:not(:disabled) {
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.btn:hover:not(:disabled) {
|
||||
background: var(--accent-soft);
|
||||
.submit:hover:not(:disabled) {
|
||||
opacity: 0.86;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.head,
|
||||
.footer {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tag {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.buttons > * {
|
||||
flex: 1 1 110px;
|
||||
}
|
||||
|
||||
.submit {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef } from 'react';
|
||||
import { useRef, useState, type DragEvent } from 'react';
|
||||
import type { ClipboardPayload } from '@/lib/clipboard';
|
||||
import { readClipboard } from '@/lib/clipboard';
|
||||
import { useMediaQuery } from '@/lib/useMediaQuery';
|
||||
@@ -7,54 +7,103 @@ import styles from './QuickShare.module.css';
|
||||
|
||||
interface Props {
|
||||
busy: boolean;
|
||||
dragging: boolean;
|
||||
onShare: (p: ClipboardPayload) => void;
|
||||
onCompose: () => void;
|
||||
text: string;
|
||||
files: File[];
|
||||
onTextChange: (text: string) => void;
|
||||
onFilesChange: (files: File[]) => void;
|
||||
onShare: (payload: ClipboardPayload) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/** 快速分享:拖入、粘贴或选择内容,自动创建一个临时加密频道并给出分享链接 */
|
||||
export function QuickShare({ busy, dragging, onShare, onCompose }: Props) {
|
||||
/** 临时分享入口:先收集文本和文件,确认后再建立草稿。 */
|
||||
export function QuickShare({ busy, text, files, onTextChange, onFilesChange, onShare }: Props) {
|
||||
const touch = useMediaQuery('(pointer: coarse)');
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const galleryRef = useRef<HTMLInputElement>(null);
|
||||
const cameraRef = useRef<HTMLInputElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const canShare = text.trim().length > 0 || files.length > 0;
|
||||
|
||||
async function paste() {
|
||||
const clip = await readClipboard();
|
||||
if (!clip) return onCompose(); // 无权限时退回手动输入
|
||||
onShare({ files: clip.files, text: clip.files.length > 0 ? '' : clip.text.trim() });
|
||||
if (!clip) return;
|
||||
if (clip.files.length) onFilesChange([...files, ...clip.files]);
|
||||
if (clip.text) onTextChange(text ? `${text}\n${clip.text}` : clip.text);
|
||||
}
|
||||
|
||||
function pick(files: FileList | null) {
|
||||
if (files?.length) onShare({ files: [...files], text: '' });
|
||||
function onDrop(event: DragEvent<HTMLElement>) {
|
||||
event.preventDefault();
|
||||
setDragging(false);
|
||||
if (event.dataTransfer.files.length) onFilesChange([...files, ...event.dataTransfer.files]);
|
||||
}
|
||||
|
||||
const buttons: { key: string; label: string; icon: IconName; run: () => void; touchOnly?: boolean }[] = [
|
||||
function pick(list: FileList | null) {
|
||||
if (list?.length) onFilesChange([...files, ...list]);
|
||||
}
|
||||
|
||||
const actions: { 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 = ''; }} />
|
||||
<section
|
||||
className={`${styles.card} ${dragging ? styles.dragging : ''}`}
|
||||
aria-busy={busy}
|
||||
onDragOver={(event) => { if (event.dataTransfer.types.includes('Files')) { event.preventDefault(); setDragging(true); } }}
|
||||
onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false); }}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<input ref={fileRef} type="file" multiple hidden onChange={(event) => { pick(event.target.files); event.target.value = ''; }} />
|
||||
<input ref={galleryRef} type="file" accept="image/*,video/*" multiple hidden onChange={(event) => { pick(event.target.files); event.target.value = ''; }} />
|
||||
<input ref={cameraRef} type="file" accept="image/*" capture="environment" hidden onChange={(event) => { pick(event.target.files); event.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>
|
||||
<header className={styles.head}>
|
||||
<div>
|
||||
<h2 className={styles.title}>快速分享</h2>
|
||||
<p className={styles.hint}>临时文本直接写在这里,也可以拖入文件。内容封存后只读。</p>
|
||||
</div>
|
||||
<span className={styles.tag}><Icon name="lock" size={13} /> 端到端加密</span>
|
||||
</header>
|
||||
|
||||
<textarea
|
||||
className={styles.editor}
|
||||
aria-label="临时分享文本"
|
||||
placeholder="输入或粘贴要分享的文本…"
|
||||
value={text}
|
||||
onChange={(event) => onTextChange(event.target.value)}
|
||||
rows={4}
|
||||
disabled={busy}
|
||||
/>
|
||||
|
||||
{files.length > 0 ? (
|
||||
<ul className={styles.files} aria-label="待分享文件">
|
||||
{files.map((file, index) => (
|
||||
<li className={styles.file} key={`${file.name}:${file.size}:${index}`}>
|
||||
<Icon name="file" size={16} />
|
||||
<span>{file.name}</span>
|
||||
<button type="button" aria-label={`移除 ${file.name}`} disabled={busy} onClick={() => onFilesChange(files.filter((_, i) => i !== index))}>
|
||||
<Icon name="close" size={15} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<footer className={styles.footer}>
|
||||
<div className={styles.buttons}>
|
||||
{actions.filter((action) => !action.touchOnly || touch).map((action) => (
|
||||
<button key={action.key} type="button" className={styles.btn} disabled={busy} onClick={action.run}>
|
||||
<Icon name={action.icon} size={18} />
|
||||
<span>{action.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className={styles.submit} disabled={busy || !canShare} onClick={() => void onShare({ files, text })}>
|
||||
{busy ? '正在创建分享…' : '创建临时分享'}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ function InlineText({ item, onOpen }: { item: TextItem; onOpen: () => void }) {
|
||||
* 所以先放占位,点了才加载,避免滚动列表就把次数用光。
|
||||
*/
|
||||
function InlineVideo({ item }: { item: FileItem }) {
|
||||
const free = item.remaining === null;
|
||||
const free = item.remaining === null && !item.gated;
|
||||
const [play, setPlay] = useState(free);
|
||||
const blob = useBlobUrl(item.id, play);
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ function Thumb({ id }: { id: string }) {
|
||||
* 有下载上限的文件(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;
|
||||
const thumb = item.kind === 'file' && !item.gated && 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)} />}
|
||||
|
||||
@@ -27,7 +27,7 @@ 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;
|
||||
return (deviceId) => devices.find((d) => d.id === deviceId)?.name ?? deviceId;
|
||||
},
|
||||
getBlob: (id, versionId) => useWorkspace.getState().getBlob(id, versionId),
|
||||
listArchive: (id) => useWorkspace.getState().listArchive(id),
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
.empty {
|
||||
display: grid;
|
||||
min-height: 260px;
|
||||
padding: var(--space-6);
|
||||
place-items: center;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.head h2 {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.head p {
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.editor {
|
||||
width: 100%;
|
||||
min-height: 280px;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
line-height: 1.65;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.editor:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fileActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.fileActions > span {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.head {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.fileActions {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor {
|
||||
min-height: 180px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import type { FileItem, Item, TextItem } from '@/core-bridge/types';
|
||||
import ItemDetail from '@/features/item/ItemDetail';
|
||||
import { formatSize, formatTime } from '@/lib/format';
|
||||
import { useShareSession } from '@/store/shareSession';
|
||||
import { Button } from '@/ui/Button';
|
||||
import styles from './DraftPreview.module.css';
|
||||
|
||||
interface Props {
|
||||
item?: Item;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
export function DraftPreview({ item, onSelect }: Props) {
|
||||
if (!item) return <div className={styles.empty}>选择一项内容进行预览或修改。</div>;
|
||||
return item.kind === 'text'
|
||||
? <TextDraft item={item} />
|
||||
: <FileDraft item={item} onSelect={onSelect} />;
|
||||
}
|
||||
|
||||
function TextDraft({ item }: { item: TextItem }) {
|
||||
const [text, setText] = useState(item.text);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const editText = useShareSession((s) => s.editText);
|
||||
const dirty = text !== item.text;
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await editText(item.id, text);
|
||||
} catch {
|
||||
setError('保存失败,请重试。');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.preview} aria-label="文本编辑">
|
||||
<header className={styles.head}>
|
||||
<div>
|
||||
<h2>文本</h2>
|
||||
<p>{formatTime(item.ts)} · {formatSize(new Blob([text]).size)}</p>
|
||||
</div>
|
||||
<Button variant="primary" disabled={!dirty || saving} onClick={() => void save()}>{saving ? '保存中…' : '保存修改'}</Button>
|
||||
</header>
|
||||
<textarea className={styles.editor} aria-label="修改分享文本" value={text} onChange={(event) => setText(event.target.value)} />
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FileDraft({ item, onSelect }: { item: FileItem; onSelect: (id: string) => void }) {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const [replacing, setReplacing] = useState(false);
|
||||
const addFiles = useShareSession((s) => s.addFiles);
|
||||
const removeItem = useShareSession((s) => s.removeItem);
|
||||
|
||||
async function replace(file: File | undefined) {
|
||||
if (!file || replacing) return;
|
||||
setReplacing(true);
|
||||
const oldIds = new Set(useShareSession.getState().order);
|
||||
try {
|
||||
await addFiles([file]);
|
||||
const state = useShareSession.getState();
|
||||
const newId = state.order.find((id) => !oldIds.has(id));
|
||||
const next = newId ? state.byId[newId] : undefined;
|
||||
if (newId && next?.state.kind === 'complete') {
|
||||
await removeItem(item.id);
|
||||
onSelect(newId);
|
||||
}
|
||||
} finally {
|
||||
setReplacing(false);
|
||||
if (input.current) input.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.preview} aria-label="文件预览">
|
||||
<input ref={input} className={styles.hidden} type="file" onChange={(event) => void replace(event.target.files?.[0])} />
|
||||
<div className={styles.fileActions}>
|
||||
<Button disabled={replacing || item.state.kind === 'receiving'} onClick={() => input.current?.click()}>
|
||||
{replacing ? '正在替换…' : '替换文件'}
|
||||
</Button>
|
||||
<span>替换成功后,原文件会从草稿中移除。</span>
|
||||
</div>
|
||||
<ItemDetail id={item.id} showTitle readOnly onDelete={() => {}} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,35 @@
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3) var(--space-2) var(--space-4);
|
||||
gap: var(--space-1);
|
||||
min-height: 68px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
animation: row-in 0.21s ease-out;
|
||||
}
|
||||
|
||||
.selected {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.select {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
min-height: 56px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.select:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.text {
|
||||
@@ -41,16 +66,3 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { Tile } from '@/features/item/Tile';
|
||||
import styles from './DraftRow.module.css';
|
||||
|
||||
/** 草稿里的一行:可以移除。只在草稿阶段存在 */
|
||||
export function DraftRow({ id }: { id: string }) {
|
||||
export function DraftRow({ id, selected, onSelect }: { id: string; selected: boolean; onSelect: () => void }) {
|
||||
const item = useShareSession((s) => s.byId[id]);
|
||||
const removeItem = useShareSession((s) => s.removeItem);
|
||||
if (!item) return null;
|
||||
@@ -15,17 +15,19 @@ export function DraftRow({ id }: { id: string }) {
|
||||
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>
|
||||
<li className={`${styles.row} ${selected ? styles.selected : ''}`}>
|
||||
<button type="button" className={styles.select} aria-pressed={selected} onClick={onSelect}>
|
||||
<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>
|
||||
<Button variant="ghost" icon="close" label="移除" onClick={() => void removeItem(id)} />
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.bar {
|
||||
background: var(--bg);
|
||||
/* stylelint-disable-next-line property-no-vendor-prefix */
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
width: min(100%, 640px);
|
||||
width: min(100%, 1120px);
|
||||
min-height: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -31,8 +32,52 @@
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
padding: var(--space-6) var(--space-4);
|
||||
padding: var(--space-4);
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
min-height: 320px;
|
||||
flex: 1;
|
||||
grid-template-columns: minmax(260px, 340px) minmax(0, 1fr);
|
||||
border-top: 1px solid var(--line);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.listPane {
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.listTitle {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin: 0;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.listTitle span {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.previewPane {
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.list {
|
||||
@@ -80,8 +125,32 @@
|
||||
backdrop-filter: var(--blur-bar);
|
||||
}
|
||||
|
||||
.footer > * {
|
||||
width: min(100%, 640px);
|
||||
.completion {
|
||||
display: grid;
|
||||
width: min(100%, 1120px);
|
||||
grid-template-columns: minmax(0, 1fr) minmax(220px, 280px);
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.acknowledgement {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.acknowledgement input {
|
||||
flex: 0 0 auto;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 1px 0 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.completion > button {
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
@@ -94,27 +163,42 @@
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.footer {
|
||||
background: var(--bg);
|
||||
/* stylelint-disable-next-line property-no-vendor-prefix */
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
@media (width <= 899px) {
|
||||
.main {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.listPane {
|
||||
max-height: 35vh;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.previewPane {
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm p {
|
||||
margin: 0;
|
||||
}
|
||||
@media (width <= 600px) {
|
||||
.completion {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.dim {
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.confirmActions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
.settings {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ 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 { DraftPreview } from './DraftPreview';
|
||||
import { ShareBar } from './ShareBar';
|
||||
import styles from './ShareDraft.module.css';
|
||||
|
||||
@@ -29,20 +29,25 @@ const DOWNLOADS = [
|
||||
export function ShareDraft() {
|
||||
const share = useShareSession((s) => s.share);
|
||||
const ids = useShareSession((s) => s.order);
|
||||
const error = useShareSession((s) => s.error);
|
||||
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 [acknowledged, setAcknowledged] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<string>();
|
||||
const [sealError, setSealError] = useState('');
|
||||
const activeId = selectedId && ids.includes(selectedId) ? selectedId : ids[0];
|
||||
const selectedItem = useShareSession((s) => activeId ? s.byId[activeId] : undefined);
|
||||
|
||||
const add = useCallback(
|
||||
(p: ClipboardPayload) => {
|
||||
if (p.files.length > 0) void addFiles(p.files);
|
||||
else if (p.text) void addText(p.text);
|
||||
if (p.text.trim()) void addText(p.text);
|
||||
},
|
||||
[addFiles, addText],
|
||||
);
|
||||
@@ -61,11 +66,13 @@ export function ShareDraft() {
|
||||
async function finish() {
|
||||
if (!share) return;
|
||||
setSealing(true);
|
||||
setSealError('');
|
||||
try {
|
||||
await seal(share.id); // 成功后分享状态变为 sealed,页面自动切换到只读页
|
||||
} catch {
|
||||
setSealError('完成分享失败,草稿仍可修改,请重试。');
|
||||
} finally {
|
||||
setSealing(false);
|
||||
setConfirmOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,13 +99,23 @@ export function ShareDraft() {
|
||||
onCompose={() => setComposeOpen(true)}
|
||||
/>
|
||||
|
||||
{ids.length === 0 ? (
|
||||
<p className={styles.empty}>还没有内容。拖入文件、粘贴文本,或点上面的按钮。</p>
|
||||
) : (
|
||||
<ul className={styles.list}>
|
||||
{ids.map((id) => <DraftRow key={id} id={id} />)}
|
||||
</ul>
|
||||
)}
|
||||
<div className={styles.content}>
|
||||
<aside className={styles.listPane} aria-label="草稿内容列表">
|
||||
<h2 className={styles.listTitle}>内容清单 <span>{ids.length}</span></h2>
|
||||
{ids.length === 0 ? (
|
||||
<p className={styles.empty}>拖入文件、粘贴文本,或点上方按钮添加。</p>
|
||||
) : (
|
||||
<ul className={styles.list}>
|
||||
{ids.map((id) => (
|
||||
<DraftRow key={id} id={id} selected={id === activeId} onSelect={() => setSelectedId(id)} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</aside>
|
||||
<section className={styles.previewPane} aria-label="选中内容">
|
||||
<DraftPreview item={selectedItem} onSelect={setSelectedId} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className={styles.settings} aria-label="分享设置">
|
||||
<label>
|
||||
@@ -109,7 +126,7 @@ export function ShareDraft() {
|
||||
</label>
|
||||
<label>
|
||||
<span>最多访客</span>
|
||||
<select value={share.seats} onChange={(e) => patch({ seats: Number(e.target.value) })}>
|
||||
<select value={share.maxVisitors} onChange={(e) => patch({ maxVisitors: Number(e.target.value) })}>
|
||||
{SEATS.map((n) => <option key={n} value={n}>{n} 人</option>)}
|
||||
</select>
|
||||
</label>
|
||||
@@ -120,27 +137,23 @@ export function ShareDraft() {
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
{sealError ? <p className={styles.error} role="alert">{sealError}</p> : null}
|
||||
</main>
|
||||
|
||||
<footer className={styles.footer}>
|
||||
<Button variant="primary" disabled={ids.length === 0 || busy} onClick={() => setConfirmOpen(true)}>
|
||||
{busy ? '传输中…' : '完成分享'}
|
||||
</Button>
|
||||
<div className={styles.completion}>
|
||||
<label className={styles.acknowledgement}>
|
||||
<input type="checkbox" checked={acknowledged} disabled={sealing} onChange={(event) => setAcknowledged(event.target.checked)} />
|
||||
<span>完成后会生成分享链接和二维码。此后任何人(包括你)都不能再添加、修改或删除内容,只能等它过期。</span>
|
||||
</label>
|
||||
<Button variant="primary" disabled={ids.length === 0 || busy || sealing || !acknowledged} onClick={() => void finish()}>
|
||||
{busy ? '传输中…' : sealing ? '正在完成…' : '完成分享'}
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -69,6 +69,13 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -18,13 +18,15 @@ const noop = () => {};
|
||||
export function ShareView() {
|
||||
const share = useShareSession((s) => s.share);
|
||||
const ids = useShareSession((s) => s.order);
|
||||
const integrityError = useShareSession((s) => s.integrityError);
|
||||
const error = useShareSession((s) => s.error);
|
||||
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);
|
||||
const remaining = formatRemaining(share.expiresAt);
|
||||
|
||||
async function downloadAll() {
|
||||
const { byId } = useShareSession.getState();
|
||||
@@ -42,6 +44,8 @@ export function ShareView() {
|
||||
badge={<span className={styles.badge}><Icon name="lock" size={12} /> 已封存 · 只读</span>}
|
||||
/>
|
||||
<main className={styles.main}>
|
||||
{integrityError ? <p className={styles.error} role="alert">{integrityError}</p> : null}
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
{share.mine ? (
|
||||
<section className={styles.share} aria-label="分享给别人">
|
||||
<h2 className={styles.h}>分享给别人</h2>
|
||||
|
||||
Reference in new issue
Block a user