feat(web): connect quick share flow
Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
1 parent
53768a195d
commit
e02e024605
138 files changed
+10614
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user