- chat: left-aligned discord-style messages with grouped senders, hover actions, an auto-growing composer and file cards - send: render messages and files locally first and replace the placeholder when the worker publishes the real item; failures keep the text with a retry action - navigation: space rail plus a flatter channel list with the space name header and collapsible groups - members: right pane tabs (files / members) grouped by online, offline and pending users, with a prominent invite action and a top bar shortcut - notifications: toast host for invite results; invitees get a toast with an accept action, and the invitation payload now carries the space name and inviter inside the encrypted grant - users over devices: drop device names, the device list and device rename/release; senders show the account (guests show as guest) and the share panel only keeps links - follow contract 7.0.0 (max_members, members_used, members_full, /join) - docs: ui-design and frontend updated for the new layout and concepts
60 lines
2.5 KiB
TypeScript
60 lines
2.5 KiB
TypeScript
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
|
|
import { useDownload } from '@/features/item/download';
|
|
import { InlineContent, inlineKindOf } from '@/features/item/InlineContent';
|
|
import { senderLabel } from '@/lib/sender';
|
|
import { 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 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}>
|
|
{senderLabel(item)} · {formatTime(item.ts)} · {formatSize(size)}
|
|
{receiving !== null && item.state.kind === 'receiving' ? ` · ${formatTransferProgress(item.state)}` : ''}
|
|
</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>
|
|
);
|
|
}
|