feat(homepage): add interactive draggable flashcards with physics

Add a fun, interactive flashcard display to the homepage's flashcard
generator section. Users can drag and throw 8-15 randomly generated
flashcards around with realistic physics-based momentum.

Features:
- Drag and drop flashcards with mouse/touch
- Throw cards with velocity-based physics
- 8-15 randomly generated flashcards (100-999 range)
- Real AbacusReact components for each card
- Client-side rendering to avoid hydration errors

Technical implementation:
- Uses @use-gesture/react for drag gesture handling
- Uses @react-spring/web for smooth physics animations
- Cards generated client-side with useEffect to prevent SSR mismatch
- Each card maintains its own spring-based position and rotation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-10-20 17:37:40 -05:00
parent 009162e22c
commit e711c52757
4 changed files with 208 additions and 70 deletions

View File

@ -53,6 +53,7 @@
"@tanstack/react-form": "^0.19.0",
"@tanstack/react-query": "^5.90.2",
"@types/jsdom": "^21.1.7",
"@use-gesture/react": "^10.3.1",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.4.1",
"drizzle-orm": "^0.44.6",

View File

@ -9,6 +9,7 @@ import { PageWithNav } from '@/components/PageWithNav'
import { TutorialPlayer } from '@/components/tutorial/TutorialPlayer'
import { getTutorialForEditor } from '@/utils/tutorialConverter'
import { getAvailableGames } from '@/lib/arcade/game-registry'
import { InteractiveFlashcards } from '@/components/InteractiveFlashcards'
import { css } from '../../styled-system/css'
import { container, grid, hstack, stack } from '../../styled-system/patterns'
import { token } from '../../styled-system/tokens'
@ -566,6 +567,18 @@ export default function HomePage() {
</p>
</div>
{/* Interactive Flashcards Display */}
<div
className={css({
maxW: '1200px',
mx: 'auto',
mb: '8',
})}
>
<InteractiveFlashcards />
</div>
{/* Features and CTA */}
<Link
href="/create"
className={css({
@ -593,76 +606,6 @@ export default function HomePage() {
},
})}
>
{/* Flashcards Display */}
<div
className={css({
position: 'relative',
height: { base: '200px', md: '280px' },
mb: '8',
})}
>
{/* Spread out flashcards */}
{[
{ number: 123, rotation: -8, zIndex: 1, offset: '-60px' },
{ number: 456, rotation: -3, zIndex: 2, offset: '-30px' },
{ number: 789, rotation: 2, zIndex: 3, offset: '0px' },
{ number: 321, rotation: 5, zIndex: 2, offset: '30px' },
{ number: 654, rotation: 9, zIndex: 1, offset: '60px' },
].map((card, i) => (
<div
key={i}
className={css({
position: 'absolute',
left: '50%',
top: '50%',
transform: `translate(-50%, -50%) translateX(${card.offset}) rotate(${card.rotation}deg)`,
transition: 'all 0.3s ease',
_hover: {
transform: `translate(-50%, -50%) translateX(${card.offset}) rotate(${card.rotation}deg) scale(1.05) translateY(-10px)`,
zIndex: 10,
},
})}
style={{ zIndex: card.zIndex }}
>
<div
className={css({
bg: 'white',
rounded: 'lg',
p: '4',
shadow: '0 10px 30px rgba(0, 0, 0, 0.3)',
border: '2px solid',
borderColor: 'gray.200',
width: { base: '120px', md: '160px' },
height: { base: '160px', md: '220px' },
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '3',
})}
>
<div
className={css({
transform: 'scale(0.7)',
})}
>
<AbacusReact value={card.number} columns={3} beadShape="circle" />
</div>
<div
className={css({
fontSize: { base: 'lg', md: 'xl' },
fontWeight: 'bold',
color: 'gray.800',
fontFamily: 'mono',
})}
>
{card.number}
</div>
</div>
</div>
))}
</div>
{/* Features */}
<div className={grid({ columns: { base: 1, md: 3 }, gap: '4', mb: '6' })}>
{[

View File

@ -0,0 +1,191 @@
'use client'
import { AbacusReact } from '@soroban/abacus-react'
import { useDrag } from '@use-gesture/react'
import { useEffect, useState } from 'react'
import { animated, config, useSpring } from '@react-spring/web'
import { css } from '../../styled-system/css'
interface Flashcard {
id: number
number: number
initialX: number
initialY: number
initialRotation: number
zIndex: number
}
const CONTAINER_WIDTH = 800
const CONTAINER_HEIGHT = 500
/**
* InteractiveFlashcards - A fun, physics-based flashcard display
* Users can drag and throw flashcards around with realistic momentum
*/
export function InteractiveFlashcards() {
// Generate 8-15 random flashcards (client-side only to avoid hydration errors)
const [cards, setCards] = useState<Flashcard[]>([])
useEffect(() => {
const count = Math.floor(Math.random() * 8) + 8 // 8-15 cards
const generated: Flashcard[] = []
for (let i = 0; i < count; i++) {
generated.push({
id: i,
number: Math.floor(Math.random() * 900) + 100, // 100-999
initialX: Math.random() * (CONTAINER_WIDTH - 200) + 100,
initialY: Math.random() * (CONTAINER_HEIGHT - 200) + 100,
initialRotation: Math.random() * 40 - 20, // -20 to 20 degrees
zIndex: i,
})
}
setCards(generated)
}, [])
return (
<div
className={css({
position: 'relative',
width: '100%',
height: { base: '400px', md: '500px' },
overflow: 'hidden',
bg: 'rgba(0, 0, 0, 0.3)',
rounded: 'xl',
border: '1px solid rgba(255, 255, 255, 0.1)',
})}
>
{/* Hint text */}
<div
className={css({
position: 'absolute',
top: '4',
left: '50%',
transform: 'translateX(-50%)',
color: 'white',
opacity: '0.6',
fontSize: 'sm',
fontWeight: 'medium',
zIndex: 1000,
pointerEvents: 'none',
})}
>
Drag and throw the flashcards!
</div>
{cards.map((card) => (
<DraggableCard key={card.id} card={card} />
))}
</div>
)
}
interface DraggableCardProps {
card: Flashcard
}
function DraggableCard({ card }: DraggableCardProps) {
const [{ x, y, rotation, scale }, api] = useSpring(() => ({
x: card.initialX,
y: card.initialY,
rotation: card.initialRotation,
scale: 1,
config: config.wobbly,
}))
const [zIndex, setZIndex] = useState(card.zIndex)
const bind = useDrag(
({ down, movement: [mx, my], velocity: [vx, vy], direction: [dx, dy] }) => {
// Bring card to front when dragging
if (down) {
setZIndex(1000)
}
api.start({
x: down ? card.initialX + mx : card.initialX + mx + vx * 100 * dx,
y: down ? card.initialY + my : card.initialY + my + vy * 100 * dy,
scale: down ? 1.1 : 1,
rotation: down ? card.initialRotation + mx / 20 : card.initialRotation + vx * 10,
immediate: down,
config: down ? config.stiff : config.wobbly,
})
// Update initial position after release for next drag
if (!down) {
card.initialX = card.initialX + mx + vx * 100 * dx
card.initialY = card.initialY + my + vy * 100 * dy
card.initialRotation = card.initialRotation + vx * 10
}
},
{
// Prevent scrolling when dragging
preventDefault: true,
filterTaps: true,
}
)
return (
<animated.div
{...bind()}
style={{
x,
y,
rotation,
scale,
zIndex,
position: 'absolute',
touchAction: 'none',
cursor: 'grab',
}}
className={css({
userSelect: 'none',
_active: {
cursor: 'grabbing',
},
})}
>
<div
className={css({
bg: 'white',
rounded: 'lg',
p: '4',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.3)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '2',
minW: '120px',
border: '2px solid rgba(0, 0, 0, 0.1)',
transition: 'box-shadow 0.2s',
_hover: {
boxShadow: '0 12px 32px rgba(0, 0, 0, 0.4)',
},
})}
>
{/* Abacus visualization */}
<div
className={css({
transform: 'scale(0.6)',
transformOrigin: 'center',
})}
>
<AbacusReact value={card.number} columns={3} beadShape="circle" />
</div>
{/* Number display */}
<div
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: 'gray.800',
fontFamily: 'mono',
})}
>
{card.number}
</div>
</div>
</animated.div>
)
}

View File

@ -134,6 +134,9 @@ importers:
'@types/jsdom':
specifier: ^21.1.7
version: 21.1.7
'@use-gesture/react':
specifier: ^10.3.1
version: 10.3.1(react@18.3.1)
bcryptjs:
specifier: ^2.4.3
version: 2.4.3