Files
stack/apps/design-lab/src/components/MemphisCard.tsx
T

137 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** CourseCard —— 教程的入口载体(孟菲斯骨架 × 拟物纸感)
* 卡头三件套=靶子:▲目标 + ○预期结果(16:9 相框) + ●积木点列
* 交互:hover/聚焦拿起(浮起+投影加深)并播放 GIF;desc ⇄ GIF 每 8000ms 翻转;
* hover/Tab 锁定;触屏与 prefers-reduced-motion 不翻转。 */
import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { Chapter } from '../lib/types'
import { Ring, Tri } from './shapes'
const FLIP_MS = 8000
export function BrickDots({ bricks }: { bricks: Chapter['target']['bricks'] }) {
return (
<span className="flex items-center gap-1" aria-label={`积木 ${bricks.filter((b) => b.learned).length}/${bricks.length}`}>
{bricks.map((b, i) => (
<i
key={i}
className="inline-block h-[7px] w-[7px] rounded-full border-[1.5px] border-ink"
style={{ background: b.learned ? 'var(--color-ink)' : '#fff' }}
/>
))}
</span>
)
}
export function EntryBadge({ n }: { n: 1 | 2 | 3 }) {
return (
<span
className="absolute -left-[11px] -top-[11px] z-10 flex h-6 w-6 items-center justify-center rounded-full border-2 border-paper bg-ink text-[12px] font-extrabold text-clash2a"
aria-label={`推荐入口 ${n}`}
>
{'①②③'[n - 1]}
</span>
)
}
export function MemphisCard({
chapter,
entry,
compact = false,
zoneText,
}: {
chapter: Chapter
entry?: 1 | 2 | 3
compact?: boolean
/** 静置态相框文字(可含 <Mark> 荧光标记):教程卡片的价值区用法示例 */
zoneText?: ReactNode
}) {
const [gifOn, setGifOn] = useState(false)
const [locked, setLocked] = useState(false)
const boxRef = useRef<HTMLAnchorElement>(null)
useEffect(() => {
const coarse = matchMedia('(pointer: coarse)').matches
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches
if (coarse || reduced) return
const io = new IntersectionObserver(
([e]) => {
if (e.isIntersecting) setGifOn(true)
else setGifOn(false)
},
{ threshold: 0.3 },
)
if (boxRef.current) io.observe(boxRef.current)
return () => io.disconnect()
}, [])
useEffect(() => {
if (!gifOn) return
const t = setInterval(() => {
setFlip((f) => !f)
}, FLIP_MS)
return () => clearInterval(t)
}, [gifOn])
const [flip, setFlip] = useState(false)
const showGif = gifOn && (locked ? true : flip)
return (
<a
ref={boxRef}
href={`#ch${chapter.id}`}
data-comp="course-card"
className="group relative block rounded-[14px] border-2 border-ink bg-paper p-[11px] no-underline
shadow-hard transition-transform duration-150 focus-visible:outline-none
hover:-translate-y-[3px] hover:shadow-hard-lift
focus-visible:-translate-y-[3px] focus-visible:shadow-hard-lift"
onMouseEnter={() => setLocked(true)}
onMouseLeave={() => setLocked(false)}
onFocus={() => setLocked(true)}
onBlur={() => setLocked(false)}
aria-label={`${chapter.title}:${chapter.target.goal}`}
>
{entry && <EntryBadge n={entry} />}
{/* ○ 预期结果:16:9 相框(章节撞色只进这里) */}
<div
className="relative flex aspect-video items-center justify-center overflow-hidden rounded-lg border-[3px] border-ink"
style={{
background: chapter.color,
boxShadow: 'inset 0 3px 8px rgba(26,26,26,0.30), inset 0 -1px 0 rgba(255,255,255,0.55)',
}}
>
{showGif ? (
chapter.gifUrl ? (
<img src={chapter.gifUrl} alt={chapter.target.expectation} className="h-full w-full object-cover" />
) : (
<span className="px-3 text-center text-[11px] font-bold leading-snug text-paper">
▶ {chapter.target.expectation}
</span>
)
) : flip ? (
<span className="px-3 text-center text-[11px] leading-snug text-ink/80 mix-blend-multiply">
{chapter.target.goal}
</span>
) : zoneText ? (
/* 静置态(用法示例):过程文字骑在相框里,价值区短语带荧光标记 */
<span className="px-3 text-center text-[11.5px] font-semibold leading-snug text-ink">{zoneText}</span>
) : (
/* 静置态:章节 emoji 是相框的视觉主体(对齐设计稿解剖卡) */
<span style={{ fontSize: compact ? '28px' : '40px' }}>{chapter.emoji}</span>
)}
<span className="absolute left-1.5 top-1 text-[9px] font-bold tracking-wide text-paper">
预期结果{showGif ? ' · GIF' : ''}
</span>
<Ring size={16} className="absolute bottom-1.5 right-1.5" />
</div>
{/* 卡头:▲ 目标 + 标题 + ● 积木点列 */}
<div className="mt-2 flex items-center gap-2">
<Tri size={14} />
<b className="flex-1 text-[14px] text-[#2b1d10] [text-shadow:0_1px_1px_rgba(255,255,255,0.9)]">
{chapter.title}
</b>
{!compact && <BrickDots bricks={chapter.target.bricks} />}
</div>
</a>
)
}