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

+63
View File
@@ -0,0 +1,63 @@
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-height: 36px;
padding: 0 var(--space-3);
border: 1px solid transparent;
border-radius: var(--radius-md);
background: transparent;
cursor: pointer;
white-space: nowrap;
transition: background-color 0.12s;
}
.btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.btn:disabled {
opacity: 0.45;
cursor: default;
}
.iconOnly {
min-width: 36px;
padding: 0;
}
.primary {
background: var(--accent);
color: var(--on-accent);
font-weight: 600;
}
.quiet {
border-color: var(--line-strong);
background: var(--surface);
}
.quiet:active:not(:disabled),
.ghost:active:not(:disabled) {
background: var(--accent-soft);
}
/* 触屏没有真正的 hover,会粘在上一次点击的元素上 */
@media (hover: hover) {
.quiet:hover:not(:disabled),
.ghost:hover:not(:disabled) {
background: var(--accent-soft);
}
}
@media (pointer: coarse) {
.btn {
min-height: 44px;
}
.iconOnly {
min-width: 44px;
}
}
+20
View File
@@ -0,0 +1,20 @@
import type { ButtonHTMLAttributes } from 'react';
import { Icon, type IconName } from './Icon';
import styles from './Button.module.css';
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'quiet' | 'ghost';
icon?: IconName;
/** 仅图标时必填,作为无障碍名称 */
label?: string;
}
export function Button({ variant = 'quiet', icon, label, children, className, ...rest }: Props) {
const cls = [styles.btn, styles[variant], children ? '' : styles.iconOnly, className].filter(Boolean).join(' ');
return (
<button type="button" className={cls} aria-label={label} title={label} {...rest}>
{icon ? <Icon name={icon} size={18} /> : null}
{children}
</button>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { useState } from 'react';
import { copyText } from '@/lib/download';
import { Button } from './Button';
interface Props {
text: string | (() => string);
variant?: 'primary' | 'quiet' | 'ghost';
label?: string;
/** 只显示图标(列表里的快捷复制) */
iconOnly?: boolean;
}
export function CopyButton({ text, variant = 'quiet', label = '复制', iconOnly }: Props) {
const [done, setDone] = useState(false);
async function onClick(e: React.MouseEvent) {
e.stopPropagation();
const ok = await copyText(typeof text === 'function' ? text() : text);
if (!ok) return;
setDone(true);
setTimeout(() => setDone(false), 1500);
}
const icon = done ? 'check' : 'copy';
return iconOnly ? (
<Button variant={variant} icon={icon} label={done ? '已复制' : label} onClick={onClick} />
) : (
<Button variant={variant} icon={icon} onClick={onClick}>
{done ? '已复制' : label}
</Button>
);
}
+61
View File
@@ -0,0 +1,61 @@
const PATHS = {
lock: 'M8 11V8a4 4 0 018 0v3 M6 11h12v9H6z',
share: 'M12 4v11 M8 8l4-4 4 4 M5 14v6h14v-6',
copy: 'M9 9h10v11H9z M5 15V4h10',
download: 'M12 4v11 M8 11l4 4 4-4 M5 20h14',
eye: 'M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z M12 9a3 3 0 100 6 3 3 0 000-6z',
file: 'M7 3h7l5 5v13H7z M14 3v5h5',
image: 'M4 5h16v14H4z M4 16l5-5 4 4 3-3 4 4 M9 9.5h.01',
video: 'M4 6h12v12H4z M16 10l5-3v10l-5-3',
archive: 'M5 4h14v4H5z M6 8v12h12V8 M10 12h4',
text: 'M5 6h14 M5 10h14 M5 14h14 M5 18h9',
send: 'M4 12l16-8-6 16-3-7z',
clip: 'M20 11l-8 8a5 5 0 01-7-7l8-8a3.5 3.5 0 015 5l-8 8a2 2 0 01-3-3l7-7',
close: 'M6 6l12 12 M18 6L6 18',
check: 'M5 12l5 5 9-10',
edit: 'M4 20h4L19 9l-4-4L4 16z',
trash: 'M5 7h14 M9 7V4h6v3 M7 7l1 13h8l1-13',
desktop: 'M3 5h18v11H3z M9 20h6 M12 16v4',
phone: 'M8 3h8v18H8z M11 18h2',
tablet: 'M5 4h14v16H5z M11 17h2',
more: 'M5 12h.01 M12 12h.01 M19 12h.01',
undo: 'M9 14L4 9l5-5 M4 9h10a6 6 0 010 12h-3',
warn: 'M12 4l9 16H3z M12 10v4 M12 17h.01',
sun: 'M12 8a4 4 0 100 8 4 4 0 000-8z M12 2v2 M12 20v2 M2 12h2 M20 12h2 M5 5l1.5 1.5 M17.5 17.5L19 19 M5 19l1.5-1.5 M17.5 6.5L19 5',
moon: 'M20 14A8 8 0 0110 4a8 8 0 1010 10z',
auto: 'M12 4a8 8 0 100 16 8 8 0 000-16z M12 4v16',
search: 'M11 4a7 7 0 100 14 7 7 0 000-14z M16 16l5 5',
scan: 'M4 8V5a1 1 0 011-1h3 M16 4h3a1 1 0 011 1v3 M20 16v3a1 1 0 01-1 1h-3 M8 20H5a1 1 0 01-1-1v-3 M8 12h8',
camera: 'M4 8h3l2-3h6l2 3h3v11H4z M12 11a3.5 3.5 0 100 7 3.5 3.5 0 000-7z',
paste: 'M9 4h6v3H9z M7 5H5v16h14V5h-2',
history: 'M4 12a8 8 0 108-8 M4 4v5h5 M12 8v4l3 2',
chevronRight: 'M9 6l6 6-6 6',
chevronLeft: 'M15 6l-6 6 6 6',
panelRight: 'M4 5h16v14H4z M15 5v14',
bell: 'M6 16V11a6 6 0 0112 0v5l2 2H4z M10 20h4',
} as const;
export type IconName = keyof typeof PATHS;
interface Props {
name: IconName;
size?: number;
}
export function Icon({ name, size = 20 }: Props) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d={PATHS[name]} />
</svg>
);
}
+30
View File
@@ -0,0 +1,30 @@
.wrap {
position: relative;
}
.pop {
position: absolute;
top: calc(100% + var(--space-2));
right: 0;
z-index: 20;
min-width: 160px;
padding: var(--space-1);
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--surface);
box-shadow: var(--shadow-pop);
animation: pop-in 0.12s ease-out;
}
@keyframes pop-in {
from {
opacity: 0;
translate: 0 -4px;
}
}
@media (prefers-reduced-motion: reduce) {
.pop {
animation: none;
}
}
+38
View File
@@ -0,0 +1,38 @@
import { useEffect, useRef, type ReactNode } from 'react';
import styles from './Popover.module.css';
interface Props {
open: boolean;
onClose: () => void;
/** 触发按钮,和浮层放在同一个定位容器里 */
trigger: ReactNode;
children: ReactNode;
}
/** 轻量下拉:点外面或按 Esc 关闭。阴影只给浮层用(ui-design §4) */
export function Popover({ open, onClose, trigger, children }: Props) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
if (!ref.current?.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('pointerdown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('pointerdown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [open, onClose]);
return (
<div ref={ref} className={styles.wrap}>
{trigger}
{open ? <div className={styles.pop}>{children}</div> : null}
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
.qr {
display: block;
width: 100%;
max-width: 240px;
aspect-ratio: 1;
border: 1px solid var(--line);
border-radius: var(--radius-md);
}
.bg {
fill: var(--qr-bg);
}
.fg {
fill: var(--qr-fg);
}
.fail {
margin: 0;
color: var(--text-dim);
font-size: 13px;
}
+29
View File
@@ -0,0 +1,29 @@
import { useMemo } from 'react';
import { encodeQr } from '@/lib/qr';
import styles from './QrCode.module.css';
const QUIET = 4; // 码周围的静区(模块数),规范要求 ≥ 4
export function QrCode({ text, label }: { text: string; label: string }) {
const grid = useMemo(() => {
try {
return encodeQr(text);
} catch {
return null;
}
}, [text]);
if (!grid) return <p className={styles.fail}>内容太长,无法生成二维码。</p>;
const n = grid.length + QUIET * 2;
const path = grid
.flatMap((row, y) => row.map((dark, x) => (dark ? `M${x + QUIET} ${y + QUIET}h1v1h-1z` : '')))
.join('');
return (
<svg className={styles.qr} viewBox={`0 0 ${n} ${n}`} role="img" aria-label={label} shapeRendering="crispEdges">
<rect width={n} height={n} className={styles.bg} />
<path d={path} className={styles.fg} />
</svg>
);
}
+109
View File
@@ -0,0 +1,109 @@
.scrim {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: flex-end;
justify-content: center;
background: var(--scrim);
animation: scrim-in 0.15s ease-out;
}
.sheet {
display: flex;
flex-direction: column;
width: 100%;
max-height: 88vh;
max-height: 88dvh;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
background: var(--surface);
box-shadow: var(--shadow-sheet);
outline: none;
animation: sheet-in 0.2s ease-out;
}
.head {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--line);
}
.title {
flex: 1;
min-width: 0;
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
display: flex;
gap: var(--space-2);
}
.body {
overflow: auto;
padding: var(--space-4);
padding-bottom: calc(var(--space-4) + env(safe-area-inset-bottom));
}
.top {
align-items: flex-start;
}
.top .sheet {
padding-top: env(safe-area-inset-top);
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
animation-name: sheet-in-top;
}
@keyframes sheet-in-top {
from {
opacity: 0;
translate: 0 -16px;
}
}
@media (width >= 900px) {
.scrim {
align-items: center;
}
.sheet,
.top .sheet {
width: 520px;
padding-top: 0;
border-radius: var(--radius-lg);
}
.top {
align-items: center;
}
.wide {
width: min(880px, 92vw);
}
}
@keyframes scrim-in {
from {
opacity: 0;
}
}
@keyframes sheet-in {
from {
opacity: 0;
translate: 0 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.scrim,
.sheet {
animation: none;
}
}
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useBackToClose } from '@/lib/useBackToClose';
import { Button } from './Button';
import styles from './Sheet.module.css';
interface Props {
title: ReactNode;
onClose: () => void;
/** 标题栏右侧的操作区 */
actions?: ReactNode;
wide?: boolean;
/** 手机上从哪边出来。需要键盘输入的用 top,避免被软键盘遮住(iOS 不会缩小布局视口) */
placement?: 'bottom' | 'top';
children: ReactNode;
}
/** 手机为底部抽屉(或顶部),桌面为居中面板。遮罩是纯色,不用 blur(ui-design §4) */
export function Sheet({ title, onClose, actions, wide, placement = 'bottom', children }: Props) {
const ref = useRef<HTMLDivElement>(null);
// onClose 放进 ref:父组件传内联函数也不会重复聚焦
const close = useRef(onClose);
useEffect(() => {
close.current = onClose;
}, [onClose]);
useBackToClose(onClose);
useEffect(() => {
const prev = document.activeElement as HTMLElement | null;
if (!ref.current?.contains(document.activeElement)) ref.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close.current();
};
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('keydown', onKey);
prev?.focus();
};
}, []);
// 用 portal 挂到 body:列表项上的 content-visibility 会让 position:fixed 失效
return createPortal(
<div className={`${styles.scrim} ${placement === 'top' ? styles.top : ''}`} onClick={onClose}>
<div
ref={ref}
tabIndex={-1}
role="dialog"
aria-modal="true"
className={`${styles.sheet} ${wide ? styles.wide : ''}`}
onClick={(e) => e.stopPropagation()}
>
<header className={styles.head}>
<div className={styles.title}>{title}</div>
<div className={styles.actions}>{actions}</div>
<Button variant="ghost" icon="close" label="关闭" onClick={onClose} />
</header>
<div className={styles.body}>{children}</div>
</div>
</div>,
document.body,
);
}
+32
View File
@@ -0,0 +1,32 @@
.item {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
min-height: 40px;
padding: 0 var(--space-3);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
cursor: pointer;
text-align: left;
}
.item:active {
background: var(--accent-soft);
}
@media (hover: hover) {
.item:hover {
background: var(--accent-soft);
}
}
.item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.label {
flex: 1;
}
+47
View File
@@ -0,0 +1,47 @@
import { useCallback, useState } from 'react';
import { setTheme, useTheme, type ThemePref } from '@/lib/theme';
import { Button } from './Button';
import { Icon, type IconName } from './Icon';
import { Popover } from './Popover';
import styles from './ThemeMenu.module.css';
const OPTIONS: { value: ThemePref; label: string; icon: IconName }[] = [
{ value: 'system', label: '跟随系统', icon: 'auto' },
{ value: 'light', label: '亮色', icon: 'sun' },
{ value: 'dark', label: '暗色', icon: 'moon' },
];
export function ThemeMenu() {
const theme = useTheme();
const [open, setOpen] = useState(false);
const close = useCallback(() => setOpen(false), []);
const current = OPTIONS.find((o) => o.value === theme) ?? OPTIONS[0];
return (
<Popover
open={open}
onClose={close}
trigger={<Button variant="ghost" icon={current?.icon ?? 'auto'} label="外观" onClick={() => setOpen((v) => !v)} />}
>
<div role="radiogroup" aria-label="外观">
{OPTIONS.map((o) => (
<button
key={o.value}
type="button"
role="radio"
aria-checked={o.value === theme}
className={styles.item}
onClick={() => {
setTheme(o.value);
close();
}}
>
<Icon name={o.icon} size={18} />
<span className={styles.label}>{o.label}</span>
{o.value === theme ? <Icon name="check" size={16} /> : null}
</button>
))}
</div>
</Popover>
);
}