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:
parent
009162e22c
commit
e711c52757
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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' })}>
|
||||
{[
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue