Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30a5587bca | ||
|
|
4082a246a3 | ||
|
|
9703fed94c | ||
|
|
5dc636a71c | ||
|
|
16d978db9a | ||
|
|
e711c52757 |
21
CHANGELOG.md
21
CHANGELOG.md
@@ -1,3 +1,24 @@
|
||||
## [4.52.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.52.1...v4.52.2) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** use actual container dimensions for flashcard positioning ([4082a24](https://github.com/antialias/soroban-abacus-flashcards/commit/4082a246a33ea67617b762d5b7490a8c9af0ad49))
|
||||
|
||||
## [4.52.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.52.0...v4.52.1) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** correct flashcard transform rendering ([5dc636a](https://github.com/antialias/soroban-abacus-flashcards/commit/5dc636a71c15db28c029fd4f60e4a6c95620f953))
|
||||
|
||||
## [4.52.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.51.0...v4.52.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **homepage:** add interactive draggable flashcards with physics ([e711c52](https://github.com/antialias/soroban-abacus-flashcards/commit/e711c527574412de2f9d451c7985c4f8667d269a))
|
||||
|
||||
## [4.51.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.50.1...v4.51.0) (2025-10-20)
|
||||
|
||||
|
||||
|
||||
@@ -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' })}>
|
||||
{[
|
||||
|
||||
202
apps/web/src/components/InteractiveFlashcards.tsx
Normal file
202
apps/web/src/components/InteractiveFlashcards.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
'use client'
|
||||
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import { useDrag } from '@use-gesture/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { animated, config, to, 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
|
||||
}
|
||||
|
||||
/**
|
||||
* InteractiveFlashcards - A fun, physics-based flashcard display
|
||||
* Users can drag and throw flashcards around with realistic momentum
|
||||
*/
|
||||
export function InteractiveFlashcards() {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
// Generate 8-15 random flashcards (client-side only to avoid hydration errors)
|
||||
const [cards, setCards] = useState<Flashcard[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for container to mount and get actual dimensions
|
||||
if (!containerRef.current) return
|
||||
|
||||
const containerWidth = containerRef.current.offsetWidth
|
||||
const containerHeight = containerRef.current.offsetHeight
|
||||
|
||||
const count = Math.floor(Math.random() * 8) + 8 // 8-15 cards
|
||||
const generated: Flashcard[] = []
|
||||
|
||||
// Position cards within the actual container bounds
|
||||
const cardWidth = 120 // approximate card width
|
||||
const cardHeight = 200 // approximate card height
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
generated.push({
|
||||
id: i,
|
||||
number: Math.floor(Math.random() * 900) + 100, // 100-999
|
||||
initialX: Math.random() * (containerWidth - cardWidth - 40) + 20,
|
||||
initialY: Math.random() * (containerHeight - cardHeight - 40) + 20,
|
||||
initialRotation: Math.random() * 40 - 20, // -20 to 20 degrees
|
||||
zIndex: i,
|
||||
})
|
||||
}
|
||||
|
||||
setCards(generated)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
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={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
transform: to(
|
||||
[x, y, rotation, scale],
|
||||
(x, y, r, s) => `translate(${x}px, ${y}px) rotate(${r}deg) scale(${s})`
|
||||
),
|
||||
zIndex,
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "4.51.0",
|
||||
"version": "4.52.2",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user