feat: add unified trophy abacus with hero mode integration
Implement a single abacus component that seamlessly transitions between three states: hero mode on the home page, compact button in the corner, and full-screen modal display. Key features: - Hero mode: Large white abacus at top-center of home page, interactive with beads - Button mode: Golden mini abacus in bottom-right corner when hero scrolled away or on other pages - Open mode: Full-screen golden abacus with blur backdrop, accessible from button - Smooth fly-to-corner animation when scrolling past hero section - Two-way sync between hero and trophy abacus values via HomeHeroContext - Highest z-index layer (30000+) to appear above all content Implementation: - Create MyAbacus component handling all three states with smooth transitions - Add MyAbacusContext for global open/close state management - Move HomeHeroProvider to ClientProviders for global access - Replace HeroAbacus with HeroSection placeholder on home page - Fix flashcard z-index by creating proper stacking context (z-index: 1) - Add MY_ABACUS z-index constants (30000-30001) - Add calendar page components with correct styled-system imports 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface CalendarConfigPanelProps {
|
||||
month: number
|
||||
year: number
|
||||
format: 'monthly' | 'daily'
|
||||
paperSize: 'us-letter' | 'a4' | 'a3' | 'tabloid'
|
||||
isGenerating: boolean
|
||||
onMonthChange: (month: number) => void
|
||||
onYearChange: (year: number) => void
|
||||
onFormatChange: (format: 'monthly' | 'daily') => void
|
||||
onPaperSizeChange: (size: 'us-letter' | 'a4' | 'a3' | 'tabloid') => void
|
||||
onGenerate: () => void
|
||||
}
|
||||
|
||||
const MONTHS = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
]
|
||||
|
||||
export function CalendarConfigPanel({
|
||||
month,
|
||||
year,
|
||||
format,
|
||||
paperSize,
|
||||
isGenerating,
|
||||
onMonthChange,
|
||||
onYearChange,
|
||||
onFormatChange,
|
||||
onPaperSizeChange,
|
||||
onGenerate,
|
||||
}: CalendarConfigPanelProps) {
|
||||
const abacusConfig = useAbacusConfig()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="calendar-config-panel"
|
||||
className={css({
|
||||
bg: 'gray.800',
|
||||
borderRadius: '12px',
|
||||
padding: '1.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1.5rem',
|
||||
})}
|
||||
>
|
||||
{/* Format Selection */}
|
||||
<fieldset
|
||||
data-section="format-selection"
|
||||
className={css({
|
||||
border: 'none',
|
||||
padding: '0',
|
||||
margin: '0',
|
||||
})}
|
||||
>
|
||||
<legend
|
||||
className={css({
|
||||
fontSize: '1.125rem',
|
||||
fontWeight: '600',
|
||||
marginBottom: '0.75rem',
|
||||
color: 'yellow.400',
|
||||
})}
|
||||
>
|
||||
Calendar Format
|
||||
</legend>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<label
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
padding: '0.5rem',
|
||||
borderRadius: '6px',
|
||||
_hover: { bg: 'gray.700' },
|
||||
})}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
value="monthly"
|
||||
checked={format === 'monthly'}
|
||||
onChange={(e) => onFormatChange(e.target.value as 'monthly' | 'daily')}
|
||||
className={css({
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
/>
|
||||
<span>Monthly Calendar (one page per month)</span>
|
||||
</label>
|
||||
<label
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
padding: '0.5rem',
|
||||
borderRadius: '6px',
|
||||
_hover: { bg: 'gray.700' },
|
||||
})}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
value="daily"
|
||||
checked={format === 'daily'}
|
||||
onChange={(e) => onFormatChange(e.target.value as 'monthly' | 'daily')}
|
||||
className={css({
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
/>
|
||||
<span>Daily Calendar (one page per day)</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* Date Selection */}
|
||||
<fieldset
|
||||
data-section="date-selection"
|
||||
className={css({
|
||||
border: 'none',
|
||||
padding: '0',
|
||||
margin: '0',
|
||||
})}
|
||||
>
|
||||
<legend
|
||||
className={css({
|
||||
fontSize: '1.125rem',
|
||||
fontWeight: '600',
|
||||
marginBottom: '0.75rem',
|
||||
color: 'yellow.400',
|
||||
})}
|
||||
>
|
||||
Date
|
||||
</legend>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<select
|
||||
data-element="month-select"
|
||||
value={month}
|
||||
onChange={(e) => onMonthChange(Number(e.target.value))}
|
||||
className={css({
|
||||
flex: '1',
|
||||
padding: '0.5rem',
|
||||
borderRadius: '6px',
|
||||
bg: 'gray.700',
|
||||
color: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.600',
|
||||
cursor: 'pointer',
|
||||
_hover: { borderColor: 'gray.500' },
|
||||
})}
|
||||
>
|
||||
{MONTHS.map((monthName, index) => (
|
||||
<option key={monthName} value={index + 1}>
|
||||
{monthName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
data-element="year-input"
|
||||
value={year}
|
||||
onChange={(e) => onYearChange(Number(e.target.value))}
|
||||
min={1}
|
||||
max={9999}
|
||||
className={css({
|
||||
width: '100px',
|
||||
padding: '0.5rem',
|
||||
borderRadius: '6px',
|
||||
bg: 'gray.700',
|
||||
color: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.600',
|
||||
_hover: { borderColor: 'gray.500' },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* Paper Size */}
|
||||
<fieldset
|
||||
data-section="paper-size"
|
||||
className={css({
|
||||
border: 'none',
|
||||
padding: '0',
|
||||
margin: '0',
|
||||
})}
|
||||
>
|
||||
<legend
|
||||
className={css({
|
||||
fontSize: '1.125rem',
|
||||
fontWeight: '600',
|
||||
marginBottom: '0.75rem',
|
||||
color: 'yellow.400',
|
||||
})}
|
||||
>
|
||||
Paper Size
|
||||
</legend>
|
||||
<select
|
||||
data-element="paper-size-select"
|
||||
value={paperSize}
|
||||
onChange={(e) =>
|
||||
onPaperSizeChange(e.target.value as 'us-letter' | 'a4' | 'a3' | 'tabloid')
|
||||
}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '0.5rem',
|
||||
borderRadius: '6px',
|
||||
bg: 'gray.700',
|
||||
color: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.600',
|
||||
cursor: 'pointer',
|
||||
_hover: { borderColor: 'gray.500' },
|
||||
})}
|
||||
>
|
||||
<option value="us-letter">US Letter (8.5" × 11")</option>
|
||||
<option value="a4">A4 (210mm × 297mm)</option>
|
||||
<option value="a3">A3 (297mm × 420mm)</option>
|
||||
<option value="tabloid">Tabloid (11" × 17")</option>
|
||||
</select>
|
||||
</fieldset>
|
||||
|
||||
{/* Abacus Styling Info */}
|
||||
<div
|
||||
data-section="styling-info"
|
||||
className={css({
|
||||
padding: '1rem',
|
||||
bg: 'gray.700',
|
||||
borderRadius: '8px',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
marginBottom: '0.75rem',
|
||||
color: 'gray.300',
|
||||
})}
|
||||
>
|
||||
Using your saved abacus style:
|
||||
</p>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
value={12}
|
||||
columns={2}
|
||||
customStyles={abacusConfig.customStyles}
|
||||
scaleFactor={0.5}
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
href="/create"
|
||||
data-action="edit-style"
|
||||
className={css({
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
fontSize: '0.875rem',
|
||||
color: 'yellow.400',
|
||||
textDecoration: 'underline',
|
||||
_hover: { color: 'yellow.300' },
|
||||
})}
|
||||
>
|
||||
Edit your abacus style →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Generate Button */}
|
||||
<button
|
||||
type="button"
|
||||
data-action="generate-calendar"
|
||||
onClick={onGenerate}
|
||||
disabled={isGenerating}
|
||||
className={css({
|
||||
padding: '1rem',
|
||||
bg: 'yellow.500',
|
||||
color: 'gray.900',
|
||||
fontWeight: '600',
|
||||
fontSize: '1.125rem',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
bg: 'yellow.400',
|
||||
},
|
||||
_disabled: {
|
||||
bg: 'gray.600',
|
||||
color: 'gray.400',
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{isGenerating ? 'Generating PDF...' : 'Generate PDF Calendar'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
266
apps/web/src/app/create/calendar/components/CalendarPreview.tsx
Normal file
266
apps/web/src/app/create/calendar/components/CalendarPreview.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
|
||||
interface CalendarPreviewProps {
|
||||
month: number
|
||||
year: number
|
||||
format: 'monthly' | 'daily'
|
||||
}
|
||||
|
||||
const MONTHS = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
]
|
||||
|
||||
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
|
||||
function getDaysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month, 0).getDate()
|
||||
}
|
||||
|
||||
function getFirstDayOfWeek(year: number, month: number): number {
|
||||
return new Date(year, month - 1, 1).getDay()
|
||||
}
|
||||
|
||||
export function CalendarPreview({ month, year, format }: CalendarPreviewProps) {
|
||||
const abacusConfig = useAbacusConfig()
|
||||
const daysInMonth = getDaysInMonth(year, month)
|
||||
const firstDayOfWeek = getFirstDayOfWeek(year, month)
|
||||
|
||||
if (format === 'daily') {
|
||||
return (
|
||||
<div
|
||||
data-component="calendar-preview"
|
||||
className={css({
|
||||
bg: 'gray.800',
|
||||
borderRadius: '12px',
|
||||
padding: '2rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '600px',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
color: 'gray.300',
|
||||
marginBottom: '1.5rem',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Daily format preview
|
||||
</p>
|
||||
<div
|
||||
className={css({
|
||||
bg: 'white',
|
||||
padding: '3rem 2rem',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
maxWidth: '400px',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
{/* Year at top */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
value={year}
|
||||
columns={4}
|
||||
customStyles={abacusConfig.customStyles}
|
||||
scaleFactor={0.4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Large day number */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
value={1}
|
||||
columns={2}
|
||||
customStyles={abacusConfig.customStyles}
|
||||
scaleFactor={0.8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date text */}
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
color: 'gray.800',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: '600',
|
||||
marginBottom: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{new Date(year, month - 1, 1).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '1rem',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
{MONTHS[month - 1]} 1, {year}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: 'gray.400',
|
||||
marginTop: '1rem',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Example of first day (1 page per day for all {daysInMonth} days)
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Monthly format
|
||||
const calendarDays: (number | null)[] = []
|
||||
|
||||
// Add empty cells for days before the first day of month
|
||||
for (let i = 0; i < firstDayOfWeek; i++) {
|
||||
calendarDays.push(null)
|
||||
}
|
||||
|
||||
// Add actual days
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
calendarDays.push(day)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="calendar-preview"
|
||||
className={css({
|
||||
bg: 'gray.800',
|
||||
borderRadius: '12px',
|
||||
padding: '2rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '2rem',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '1rem',
|
||||
color: 'yellow.400',
|
||||
})}
|
||||
>
|
||||
{MONTHS[month - 1]} {year}
|
||||
</h2>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
value={year}
|
||||
columns={4}
|
||||
customStyles={abacusConfig.customStyles}
|
||||
scaleFactor={0.6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{/* Weekday headers */}
|
||||
{WEEKDAYS.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
fontWeight: '600',
|
||||
padding: '0.5rem',
|
||||
color: 'yellow.400',
|
||||
fontSize: '0.875rem',
|
||||
})}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Calendar days */}
|
||||
{calendarDays.map((day, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={css({
|
||||
aspectRatio: '1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bg: day ? 'gray.700' : 'transparent',
|
||||
borderRadius: '6px',
|
||||
padding: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{day && (
|
||||
<AbacusReact
|
||||
value={day}
|
||||
columns={2}
|
||||
customStyles={abacusConfig.customStyles}
|
||||
scaleFactor={0.35}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: 'gray.400',
|
||||
marginTop: '1.5rem',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Preview of monthly calendar layout (actual PDF will be optimized for printing)
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
128
apps/web/src/app/create/calendar/page.tsx
Normal file
128
apps/web/src/app/create/calendar/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { CalendarConfigPanel } from './components/CalendarConfigPanel'
|
||||
import { CalendarPreview } from './components/CalendarPreview'
|
||||
|
||||
export default function CalendarCreatorPage() {
|
||||
const currentDate = new Date()
|
||||
const abacusConfig = useAbacusConfig()
|
||||
const [month, setMonth] = useState(currentDate.getMonth() + 1) // 1-12
|
||||
const [year, setYear] = useState(currentDate.getFullYear())
|
||||
const [format, setFormat] = useState<'monthly' | 'daily'>('monthly')
|
||||
const [paperSize, setPaperSize] = useState<'us-letter' | 'a4' | 'a3' | 'tabloid'>('us-letter')
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setIsGenerating(true)
|
||||
try {
|
||||
const response = await fetch('/api/create/calendar/generate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
month,
|
||||
year,
|
||||
format,
|
||||
paperSize,
|
||||
abacusConfig,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate calendar')
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `calendar-${year}-${String(month).padStart(2, '0')}.pdf`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
} catch (error) {
|
||||
console.error('Error generating calendar:', error)
|
||||
alert('Failed to generate calendar. Please try again.')
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="calendar-creator"
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
bg: 'gray.900',
|
||||
color: 'white',
|
||||
padding: '2rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
maxWidth: '1400px',
|
||||
margin: '0 auto',
|
||||
})}
|
||||
>
|
||||
{/* Header */}
|
||||
<header
|
||||
data-section="page-header"
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '3rem',
|
||||
})}
|
||||
>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: '2.5rem',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '0.5rem',
|
||||
color: 'yellow.400',
|
||||
})}
|
||||
>
|
||||
Create Abacus Calendar
|
||||
</h1>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '1.125rem',
|
||||
color: 'gray.300',
|
||||
})}
|
||||
>
|
||||
Generate printable calendars with abacus date numbers
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { base: '1fr', lg: '350px 1fr' },
|
||||
gap: '2rem',
|
||||
})}
|
||||
>
|
||||
{/* Configuration Panel */}
|
||||
<CalendarConfigPanel
|
||||
month={month}
|
||||
year={year}
|
||||
format={format}
|
||||
paperSize={paperSize}
|
||||
isGenerating={isGenerating}
|
||||
onMonthChange={setMonth}
|
||||
onYearChange={setYear}
|
||||
onFormatChange={setFormat}
|
||||
onPaperSizeChange={setPaperSize}
|
||||
onGenerate={handleGenerate}
|
||||
/>
|
||||
|
||||
{/* Preview */}
|
||||
<CalendarPreview month={month} year={year} format={format} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useTranslations, useMessages } from 'next-intl'
|
||||
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { HeroAbacus } from '@/components/HeroAbacus'
|
||||
import { HomeHeroProvider } from '@/contexts/HomeHeroContext'
|
||||
import { useHomeHero } from '@/contexts/HomeHeroContext'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { TutorialPlayer } from '@/components/tutorial/TutorialPlayer'
|
||||
import { getTutorialForEditor } from '@/utils/tutorialConverter'
|
||||
@@ -15,6 +14,135 @@ import { LevelSliderDisplay } from '@/components/LevelSliderDisplay'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { container, grid, hstack, stack } from '../../styled-system/patterns'
|
||||
|
||||
// Hero section placeholder - the actual abacus is rendered by MyAbacus component
|
||||
function HeroSection() {
|
||||
const { subtitle, setIsHeroVisible, isSubtitleLoaded } = useHomeHero()
|
||||
const heroRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Detect when hero scrolls out of view
|
||||
useEffect(() => {
|
||||
if (!heroRef.current) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
setIsHeroVisible(entry.intersectionRatio > 0.2)
|
||||
},
|
||||
{
|
||||
threshold: [0, 0.2, 0.5, 1],
|
||||
}
|
||||
)
|
||||
|
||||
observer.observe(heroRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [setIsHeroVisible])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={heroRef}
|
||||
className={css({
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
bg: 'gray.900',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
px: '4',
|
||||
py: '12',
|
||||
})}
|
||||
>
|
||||
{/* Background pattern */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
opacity: 0.1,
|
||||
backgroundImage:
|
||||
'radial-gradient(circle at 2px 2px, rgba(255, 255, 255, 0.15) 1px, transparent 0)',
|
||||
backgroundSize: '40px 40px',
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Title and Subtitle */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
zIndex: 10,
|
||||
})}
|
||||
>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: { base: '4xl', md: '6xl', lg: '7xl' },
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #fbbf24 100%)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
})}
|
||||
>
|
||||
Abaci One
|
||||
</h1>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: { base: 'xl', md: '2xl' },
|
||||
fontWeight: 'medium',
|
||||
color: 'purple.300',
|
||||
fontStyle: 'italic',
|
||||
marginBottom: '8',
|
||||
opacity: isSubtitleLoaded ? 1 : 0,
|
||||
transition: 'opacity 0.5s ease-in-out',
|
||||
})}
|
||||
>
|
||||
{subtitle.text}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Space for abacus - rendered by MyAbacus component in hero mode */}
|
||||
<div className={css({ flex: 1 })} />
|
||||
|
||||
{/* Scroll hint */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
fontSize: 'sm',
|
||||
color: 'gray.400',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
animation: 'bounce 2s ease-in-out infinite',
|
||||
zIndex: 10,
|
||||
})}
|
||||
>
|
||||
<span>Scroll to explore</span>
|
||||
<span>↓</span>
|
||||
</div>
|
||||
|
||||
{/* Keyframes for bounce animation */}
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes bounce {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.7;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Mini abacus that cycles through a sequence of values
|
||||
function MiniAbacus({
|
||||
values,
|
||||
@@ -119,14 +247,246 @@ export default function HomePage() {
|
||||
const selectedTutorial = skillTutorials[selectedSkillIndex]
|
||||
|
||||
return (
|
||||
<HomeHeroProvider>
|
||||
<PageWithNav>
|
||||
<div className={css({ bg: 'gray.900', minHeight: '100vh' })}>
|
||||
{/* Hero Section with Large Interactive Abacus */}
|
||||
<HeroAbacus />
|
||||
<PageWithNav>
|
||||
<div className={css({ bg: 'gray.900', minHeight: '100vh' })}>
|
||||
{/* Hero Section - abacus rendered by MyAbacus in hero mode */}
|
||||
<HeroSection />
|
||||
|
||||
{/* Learn by Doing Section - with inline tutorial demo */}
|
||||
<section className={stack({ gap: '8', mb: '16', px: '4', py: '12' })}>
|
||||
{/* Learn by Doing Section - with inline tutorial demo */}
|
||||
<section className={stack({ gap: '8', mb: '16', px: '4', py: '12' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{t('learnByDoing.title')}
|
||||
</h2>
|
||||
<p className={css({ color: 'gray.400', fontSize: 'md', maxW: '2xl', mx: 'auto' })}>
|
||||
{t('learnByDoing.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Live demo and learning objectives */}
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(0, 0, 0, 0.4)',
|
||||
rounded: 'xl',
|
||||
p: '8',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.700',
|
||||
shadow: 'lg',
|
||||
minW: { base: '100%', xl: '1400px' },
|
||||
mx: 'auto',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: { base: 'column', xl: 'row' },
|
||||
gap: '8',
|
||||
alignItems: { base: 'center', xl: 'flex-start' },
|
||||
})}
|
||||
>
|
||||
{/* Tutorial on the left */}
|
||||
<div
|
||||
className={css({
|
||||
flex: '1',
|
||||
minW: { base: '100%', xl: '500px' },
|
||||
maxW: { base: '100%', xl: '500px' },
|
||||
})}
|
||||
>
|
||||
<TutorialPlayer
|
||||
key={selectedTutorial.id}
|
||||
tutorial={selectedTutorial}
|
||||
isDebugMode={false}
|
||||
showDebugPanel={false}
|
||||
hideNavigation={true}
|
||||
hideTooltip={true}
|
||||
silentErrors={true}
|
||||
abacusColumns={1}
|
||||
theme="dark"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* What you'll learn on the right */}
|
||||
<div
|
||||
className={css({
|
||||
flex: '0 0 auto',
|
||||
w: { base: '100%', lg: '800px' },
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '6',
|
||||
})}
|
||||
>
|
||||
{t('whatYouLearn.title')}
|
||||
</h3>
|
||||
<div className={grid({ columns: { base: 1, lg: 2 }, gap: '5' })}>
|
||||
{[
|
||||
{
|
||||
title: t('skills.readNumbers.title'),
|
||||
desc: t('skills.readNumbers.desc'),
|
||||
example: t('skills.readNumbers.example'),
|
||||
badge: t('skills.readNumbers.badge'),
|
||||
values: [0, 1, 2, 3, 4, 5, 10, 50, 100, 500, 999],
|
||||
columns: 3,
|
||||
},
|
||||
{
|
||||
title: t('skills.friends.title'),
|
||||
desc: t('skills.friends.desc'),
|
||||
example: t('skills.friends.example'),
|
||||
badge: t('skills.friends.badge'),
|
||||
values: [2, 5, 3],
|
||||
columns: 1,
|
||||
},
|
||||
{
|
||||
title: t('skills.multiply.title'),
|
||||
desc: t('skills.multiply.desc'),
|
||||
example: t('skills.multiply.example'),
|
||||
badge: t('skills.multiply.badge'),
|
||||
values: [12, 24, 36, 48],
|
||||
columns: 2,
|
||||
},
|
||||
{
|
||||
title: t('skills.mental.title'),
|
||||
desc: t('skills.mental.desc'),
|
||||
example: t('skills.mental.example'),
|
||||
badge: t('skills.mental.badge'),
|
||||
values: [7, 14, 21, 28, 35],
|
||||
columns: 2,
|
||||
},
|
||||
].map((skill, i) => {
|
||||
const isSelected = i === selectedSkillIndex
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => setSelectedSkillIndex(i)}
|
||||
className={css({
|
||||
bg: isSelected
|
||||
? 'linear-gradient(135deg, rgba(250, 204, 21, 0.15), rgba(250, 204, 21, 0.08))'
|
||||
: 'linear-gradient(135deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03))',
|
||||
borderRadius: 'xl',
|
||||
p: { base: '4', lg: '5' },
|
||||
border: '1px solid',
|
||||
borderColor: isSelected
|
||||
? 'rgba(250, 204, 21, 0.4)'
|
||||
: 'rgba(255, 255, 255, 0.15)',
|
||||
boxShadow: isSelected
|
||||
? '0 6px 16px rgba(250, 204, 21, 0.2)'
|
||||
: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||
transition: 'all 0.2s',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
bg: isSelected
|
||||
? 'linear-gradient(135deg, rgba(250, 204, 21, 0.2), rgba(250, 204, 21, 0.12))'
|
||||
: 'linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05))',
|
||||
borderColor: isSelected
|
||||
? 'rgba(250, 204, 21, 0.5)'
|
||||
: 'rgba(255, 255, 255, 0.25)',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: isSelected
|
||||
? '0 8px 20px rgba(250, 204, 21, 0.3)'
|
||||
: '0 6px 16px rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div className={hstack({ gap: '3', alignItems: 'flex-start' })}>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '3xl',
|
||||
width: { base: '120px', lg: '150px' },
|
||||
minHeight: { base: '115px', lg: '140px' },
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
bg: isSelected
|
||||
? 'rgba(250, 204, 21, 0.15)'
|
||||
: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: 'lg',
|
||||
})}
|
||||
>
|
||||
<MiniAbacus values={skill.values} columns={skill.columns} />
|
||||
</div>
|
||||
<div className={stack({ gap: '2', flex: '1', minWidth: '0' })}>
|
||||
<div
|
||||
className={hstack({
|
||||
gap: '2',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
color: 'white',
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
{skill.title}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(250, 204, 21, 0.2)',
|
||||
color: 'yellow.400',
|
||||
fontSize: '2xs',
|
||||
fontWeight: 'semibold',
|
||||
px: '2',
|
||||
py: '0.5',
|
||||
borderRadius: 'md',
|
||||
})}
|
||||
>
|
||||
{skill.badge}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
color: 'gray.300',
|
||||
fontSize: 'xs',
|
||||
lineHeight: '1.5',
|
||||
})}
|
||||
>
|
||||
{skill.desc}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
color: 'yellow.400',
|
||||
fontSize: 'xs',
|
||||
fontFamily: 'mono',
|
||||
fontWeight: 'semibold',
|
||||
mt: '1',
|
||||
bg: 'rgba(250, 204, 21, 0.1)',
|
||||
px: '2',
|
||||
py: '1',
|
||||
borderRadius: 'md',
|
||||
w: 'fit-content',
|
||||
})}
|
||||
>
|
||||
{skill.example}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Main content container */}
|
||||
<div className={container({ maxW: '7xl', px: '4', py: '12' })}>
|
||||
{/* Current Offerings Section */}
|
||||
<section className={stack({ gap: '6', mb: '16' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h2
|
||||
className={css({
|
||||
@@ -136,397 +496,161 @@ export default function HomePage() {
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{t('learnByDoing.title')}
|
||||
{t('arcade.title')}
|
||||
</h2>
|
||||
<p className={css({ color: 'gray.400', fontSize: 'md' })}>{t('arcade.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className={grid({ columns: { base: 1, sm: 2, lg: 4 }, gap: '5' })}>
|
||||
{getAvailableGames().map((game) => {
|
||||
const playersText =
|
||||
game.manifest.maxPlayers === 1
|
||||
? t('arcade.soloChallenge')
|
||||
: t('arcade.playersCount', { min: 1, max: game.manifest.maxPlayers })
|
||||
return (
|
||||
<GameCard
|
||||
key={game.manifest.name}
|
||||
icon={game.manifest.icon}
|
||||
title={game.manifest.displayName}
|
||||
description={game.manifest.description}
|
||||
players={playersText}
|
||||
tags={game.manifest.chips}
|
||||
gradient={game.manifest.gradient}
|
||||
href="/games"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Progression Visualization */}
|
||||
<section className={stack({ gap: '6', mb: '16', overflow: 'hidden' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{t('journey.title')}
|
||||
</h2>
|
||||
<p style={{ color: '#e5e7eb', fontSize: '16px' }}>{t('journey.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<LevelSliderDisplay />
|
||||
</section>
|
||||
|
||||
{/* Flashcard Generator Section */}
|
||||
<section className={stack({ gap: '8', mb: '16' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{t('flashcards.title')}
|
||||
</h2>
|
||||
<p className={css({ color: 'gray.400', fontSize: 'md', maxW: '2xl', mx: 'auto' })}>
|
||||
{t('learnByDoing.subtitle')}
|
||||
{t('flashcards.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Live demo and learning objectives */}
|
||||
{/* Combined interactive display and CTA */}
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(0, 0, 0, 0.4)',
|
||||
rounded: 'xl',
|
||||
p: '8',
|
||||
p: { base: '6', md: '8' },
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.700',
|
||||
shadow: 'lg',
|
||||
minW: { base: '100%', xl: '1400px' },
|
||||
maxW: '1200px',
|
||||
mx: 'auto',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: { base: 'column', xl: 'row' },
|
||||
gap: '8',
|
||||
alignItems: { base: 'center', xl: 'flex-start' },
|
||||
})}
|
||||
>
|
||||
{/* Tutorial on the left */}
|
||||
<div
|
||||
className={css({
|
||||
flex: '1',
|
||||
minW: { base: '100%', xl: '500px' },
|
||||
maxW: { base: '100%', xl: '500px' },
|
||||
})}
|
||||
>
|
||||
<TutorialPlayer
|
||||
key={selectedTutorial.id}
|
||||
tutorial={selectedTutorial}
|
||||
isDebugMode={false}
|
||||
showDebugPanel={false}
|
||||
hideNavigation={true}
|
||||
hideTooltip={true}
|
||||
silentErrors={true}
|
||||
abacusColumns={1}
|
||||
theme="dark"
|
||||
/>
|
||||
</div>
|
||||
{/* Interactive Flashcards Display */}
|
||||
<div className={css({ mb: '8' })}>
|
||||
<InteractiveFlashcards />
|
||||
</div>
|
||||
|
||||
{/* What you'll learn on the right */}
|
||||
<div
|
||||
className={css({
|
||||
flex: '0 0 auto',
|
||||
w: { base: '100%', lg: '800px' },
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
{/* Features */}
|
||||
<div className={grid({ columns: { base: 1, md: 3 }, gap: '4', mb: '6' })}>
|
||||
{[
|
||||
{
|
||||
icon: t('flashcards.features.formats.icon'),
|
||||
title: t('flashcards.features.formats.title'),
|
||||
desc: t('flashcards.features.formats.desc'),
|
||||
},
|
||||
{
|
||||
icon: t('flashcards.features.customizable.icon'),
|
||||
title: t('flashcards.features.customizable.title'),
|
||||
desc: t('flashcards.features.customizable.desc'),
|
||||
},
|
||||
{
|
||||
icon: t('flashcards.features.paperSizes.icon'),
|
||||
title: t('flashcards.features.paperSizes.title'),
|
||||
desc: t('flashcards.features.paperSizes.desc'),
|
||||
},
|
||||
].map((feature, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '6',
|
||||
textAlign: 'center',
|
||||
p: '4',
|
||||
rounded: 'lg',
|
||||
bg: 'rgba(255, 255, 255, 0.05)',
|
||||
})}
|
||||
>
|
||||
{t('whatYouLearn.title')}
|
||||
</h3>
|
||||
<div className={grid({ columns: { base: 1, lg: 2 }, gap: '5' })}>
|
||||
{[
|
||||
{
|
||||
title: t('skills.readNumbers.title'),
|
||||
desc: t('skills.readNumbers.desc'),
|
||||
example: t('skills.readNumbers.example'),
|
||||
badge: t('skills.readNumbers.badge'),
|
||||
values: [0, 1, 2, 3, 4, 5, 10, 50, 100, 500, 999],
|
||||
columns: 3,
|
||||
},
|
||||
{
|
||||
title: t('skills.friends.title'),
|
||||
desc: t('skills.friends.desc'),
|
||||
example: t('skills.friends.example'),
|
||||
badge: t('skills.friends.badge'),
|
||||
values: [2, 5, 3],
|
||||
columns: 1,
|
||||
},
|
||||
{
|
||||
title: t('skills.multiply.title'),
|
||||
desc: t('skills.multiply.desc'),
|
||||
example: t('skills.multiply.example'),
|
||||
badge: t('skills.multiply.badge'),
|
||||
values: [12, 24, 36, 48],
|
||||
columns: 2,
|
||||
},
|
||||
{
|
||||
title: t('skills.mental.title'),
|
||||
desc: t('skills.mental.desc'),
|
||||
example: t('skills.mental.example'),
|
||||
badge: t('skills.mental.badge'),
|
||||
values: [7, 14, 21, 28, 35],
|
||||
columns: 2,
|
||||
},
|
||||
].map((skill, i) => {
|
||||
const isSelected = i === selectedSkillIndex
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => setSelectedSkillIndex(i)}
|
||||
className={css({
|
||||
bg: isSelected
|
||||
? 'linear-gradient(135deg, rgba(250, 204, 21, 0.15), rgba(250, 204, 21, 0.08))'
|
||||
: 'linear-gradient(135deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03))',
|
||||
borderRadius: 'xl',
|
||||
p: { base: '4', lg: '5' },
|
||||
border: '1px solid',
|
||||
borderColor: isSelected
|
||||
? 'rgba(250, 204, 21, 0.4)'
|
||||
: 'rgba(255, 255, 255, 0.15)',
|
||||
boxShadow: isSelected
|
||||
? '0 6px 16px rgba(250, 204, 21, 0.2)'
|
||||
: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||
transition: 'all 0.2s',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
bg: isSelected
|
||||
? 'linear-gradient(135deg, rgba(250, 204, 21, 0.2), rgba(250, 204, 21, 0.12))'
|
||||
: 'linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05))',
|
||||
borderColor: isSelected
|
||||
? 'rgba(250, 204, 21, 0.5)'
|
||||
: 'rgba(255, 255, 255, 0.25)',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: isSelected
|
||||
? '0 8px 20px rgba(250, 204, 21, 0.3)'
|
||||
: '0 6px 16px rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div className={hstack({ gap: '3', alignItems: 'flex-start' })}>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '3xl',
|
||||
width: { base: '120px', lg: '150px' },
|
||||
minHeight: { base: '115px', lg: '140px' },
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
bg: isSelected
|
||||
? 'rgba(250, 204, 21, 0.15)'
|
||||
: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: 'lg',
|
||||
})}
|
||||
>
|
||||
<MiniAbacus values={skill.values} columns={skill.columns} />
|
||||
</div>
|
||||
<div className={stack({ gap: '2', flex: '1', minWidth: '0' })}>
|
||||
<div
|
||||
className={hstack({
|
||||
gap: '2',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
color: 'white',
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
{skill.title}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(250, 204, 21, 0.2)',
|
||||
color: 'yellow.400',
|
||||
fontSize: '2xs',
|
||||
fontWeight: 'semibold',
|
||||
px: '2',
|
||||
py: '0.5',
|
||||
borderRadius: 'md',
|
||||
})}
|
||||
>
|
||||
{skill.badge}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
color: 'gray.300',
|
||||
fontSize: 'xs',
|
||||
lineHeight: '1.5',
|
||||
})}
|
||||
>
|
||||
{skill.desc}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
color: 'yellow.400',
|
||||
fontSize: 'xs',
|
||||
fontFamily: 'mono',
|
||||
fontWeight: 'semibold',
|
||||
mt: '1',
|
||||
bg: 'rgba(250, 204, 21, 0.1)',
|
||||
px: '2',
|
||||
py: '1',
|
||||
borderRadius: 'md',
|
||||
w: 'fit-content',
|
||||
})}
|
||||
>
|
||||
{skill.example}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className={css({ fontSize: '2xl', mb: '2' })}>{feature.icon}</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
color: 'white',
|
||||
mb: '1',
|
||||
})}
|
||||
>
|
||||
{feature.title}
|
||||
</div>
|
||||
<div className={css({ fontSize: 'xs', color: 'gray.400' })}>{feature.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CTA Button */}
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<Link
|
||||
href="/create"
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
bg: 'blue.600',
|
||||
color: 'white',
|
||||
px: '6',
|
||||
py: '3',
|
||||
rounded: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
bg: 'blue.500',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>{t('flashcards.cta')}</span>
|
||||
<span>→</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Main content container */}
|
||||
<div className={container({ maxW: '7xl', px: '4', py: '12' })}>
|
||||
{/* Current Offerings Section */}
|
||||
<section className={stack({ gap: '6', mb: '16' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{t('arcade.title')}
|
||||
</h2>
|
||||
<p className={css({ color: 'gray.400', fontSize: 'md' })}>{t('arcade.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className={grid({ columns: { base: 1, sm: 2, lg: 4 }, gap: '5' })}>
|
||||
{getAvailableGames().map((game) => {
|
||||
const playersText =
|
||||
game.manifest.maxPlayers === 1
|
||||
? t('arcade.soloChallenge')
|
||||
: t('arcade.playersCount', { min: 1, max: game.manifest.maxPlayers })
|
||||
return (
|
||||
<GameCard
|
||||
key={game.manifest.name}
|
||||
icon={game.manifest.icon}
|
||||
title={game.manifest.displayName}
|
||||
description={game.manifest.description}
|
||||
players={playersText}
|
||||
tags={game.manifest.chips}
|
||||
gradient={game.manifest.gradient}
|
||||
href="/games"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Progression Visualization */}
|
||||
<section className={stack({ gap: '6', mb: '16', overflow: 'hidden' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{t('journey.title')}
|
||||
</h2>
|
||||
<p style={{ color: '#e5e7eb', fontSize: '16px' }}>{t('journey.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<LevelSliderDisplay />
|
||||
</section>
|
||||
|
||||
{/* Flashcard Generator Section */}
|
||||
<section className={stack({ gap: '8', mb: '16' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{t('flashcards.title')}
|
||||
</h2>
|
||||
<p className={css({ color: 'gray.400', fontSize: 'md', maxW: '2xl', mx: 'auto' })}>
|
||||
{t('flashcards.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Combined interactive display and CTA */}
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(0, 0, 0, 0.4)',
|
||||
rounded: 'xl',
|
||||
p: { base: '6', md: '8' },
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.700',
|
||||
shadow: 'lg',
|
||||
maxW: '1200px',
|
||||
mx: 'auto',
|
||||
})}
|
||||
>
|
||||
{/* Interactive Flashcards Display */}
|
||||
<div className={css({ mb: '8' })}>
|
||||
<InteractiveFlashcards />
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className={grid({ columns: { base: 1, md: 3 }, gap: '4', mb: '6' })}>
|
||||
{[
|
||||
{
|
||||
icon: t('flashcards.features.formats.icon'),
|
||||
title: t('flashcards.features.formats.title'),
|
||||
desc: t('flashcards.features.formats.desc'),
|
||||
},
|
||||
{
|
||||
icon: t('flashcards.features.customizable.icon'),
|
||||
title: t('flashcards.features.customizable.title'),
|
||||
desc: t('flashcards.features.customizable.desc'),
|
||||
},
|
||||
{
|
||||
icon: t('flashcards.features.paperSizes.icon'),
|
||||
title: t('flashcards.features.paperSizes.title'),
|
||||
desc: t('flashcards.features.paperSizes.desc'),
|
||||
},
|
||||
].map((feature, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
p: '4',
|
||||
rounded: 'lg',
|
||||
bg: 'rgba(255, 255, 255, 0.05)',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '2xl', mb: '2' })}>{feature.icon}</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
color: 'white',
|
||||
mb: '1',
|
||||
})}
|
||||
>
|
||||
{feature.title}
|
||||
</div>
|
||||
<div className={css({ fontSize: 'xs', color: 'gray.400' })}>
|
||||
{feature.desc}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CTA Button */}
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<Link
|
||||
href="/create"
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
bg: 'blue.600',
|
||||
color: 'white',
|
||||
px: '6',
|
||||
py: '3',
|
||||
rounded: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
bg: 'blue.500',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>{t('flashcards.cta')}</span>
|
||||
<span>→</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
</HomeHeroProvider>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ import { createQueryClient } from '@/lib/queryClient'
|
||||
import type { Locale } from '@/i18n/messages'
|
||||
import { AbacusSettingsSync } from './AbacusSettingsSync'
|
||||
import { DeploymentInfo } from './DeploymentInfo'
|
||||
import { MyAbacusProvider } from '@/contexts/MyAbacusContext'
|
||||
import { MyAbacus } from './MyAbacus'
|
||||
import { HomeHeroProvider } from '@/contexts/HomeHeroContext'
|
||||
|
||||
interface ClientProvidersProps {
|
||||
children: ReactNode
|
||||
@@ -31,8 +34,13 @@ function InnerProviders({ children }: { children: ReactNode }) {
|
||||
<UserProfileProvider>
|
||||
<GameModeProvider>
|
||||
<FullscreenProvider>
|
||||
{children}
|
||||
<DeploymentInfo />
|
||||
<HomeHeroProvider>
|
||||
<MyAbacusProvider>
|
||||
{children}
|
||||
<DeploymentInfo />
|
||||
<MyAbacus />
|
||||
</MyAbacusProvider>
|
||||
</HomeHeroProvider>
|
||||
</FullscreenProvider>
|
||||
</GameModeProvider>
|
||||
</UserProfileProvider>
|
||||
|
||||
@@ -68,6 +68,7 @@ export function InteractiveFlashcards() {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-component="interactive-flashcards"
|
||||
className={css({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
@@ -78,6 +79,7 @@ export function InteractiveFlashcards() {
|
||||
bg: 'rgba(0, 0, 0, 0.3)',
|
||||
rounded: 'xl',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
zIndex: 1, // Create stacking context so child z-indexes are relative
|
||||
})}
|
||||
>
|
||||
{cards.map((card) => (
|
||||
@@ -113,7 +115,7 @@ function DraggableCard({ card, containerRef }: DraggableCardProps) {
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent) => {
|
||||
setIsDragging(true)
|
||||
setZIndex(1000) // Bring to front
|
||||
setZIndex(10) // Bring to front within container stacking context
|
||||
setDragSpeed(0)
|
||||
|
||||
// Capture the pointer
|
||||
|
||||
305
apps/web/src/components/MyAbacus.tsx
Normal file
305
apps/web/src/components/MyAbacus.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
'use client'
|
||||
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { useMyAbacus } from '@/contexts/MyAbacusContext'
|
||||
import { HomeHeroContext } from '@/contexts/HomeHeroContext'
|
||||
import { Z_INDEX } from '@/constants/zIndex'
|
||||
|
||||
export function MyAbacus() {
|
||||
const { isOpen, close, toggle } = useMyAbacus()
|
||||
const appConfig = useAbacusConfig()
|
||||
|
||||
// Sync with hero context if on home page
|
||||
const homeHeroContext = useContext(HomeHeroContext)
|
||||
const [localAbacusValue, setLocalAbacusValue] = useState(1234)
|
||||
const abacusValue = homeHeroContext?.abacusValue ?? localAbacusValue
|
||||
const setAbacusValue = homeHeroContext?.setAbacusValue ?? setLocalAbacusValue
|
||||
|
||||
// Determine display mode
|
||||
const isOnHomePage = Boolean(homeHeroContext)
|
||||
const isHeroVisible = homeHeroContext?.isHeroVisible ?? false
|
||||
const isHeroMode = isOnHomePage && isHeroVisible && !isOpen
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [isOpen, close])
|
||||
|
||||
// Prevent body scroll when open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Hero mode styles - white structural (from original HeroAbacus)
|
||||
const structuralStyles = {
|
||||
columnPosts: {
|
||||
fill: 'rgb(255, 255, 255)',
|
||||
stroke: 'rgb(200, 200, 200)',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
reckoningBar: {
|
||||
fill: 'rgb(255, 255, 255)',
|
||||
stroke: 'rgb(200, 200, 200)',
|
||||
strokeWidth: 3,
|
||||
},
|
||||
}
|
||||
|
||||
// Trophy abacus styles - golden/premium look
|
||||
const trophyStyles = {
|
||||
columnPosts: {
|
||||
fill: '#fbbf24',
|
||||
stroke: '#f59e0b',
|
||||
strokeWidth: 3,
|
||||
},
|
||||
reckoningBar: {
|
||||
fill: '#fbbf24',
|
||||
stroke: '#f59e0b',
|
||||
strokeWidth: 4,
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Blur backdrop - only visible when open */}
|
||||
{isOpen && (
|
||||
<div
|
||||
data-component="my-abacus-backdrop"
|
||||
style={{
|
||||
WebkitBackdropFilter: 'blur(12px)',
|
||||
}}
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
bg: 'rgba(0, 0, 0, 0.8)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
zIndex: Z_INDEX.MY_ABACUS_BACKDROP,
|
||||
animation: 'backdropFadeIn 0.4s ease-out',
|
||||
})}
|
||||
onClick={close}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Close button - only visible when open */}
|
||||
{isOpen && (
|
||||
<button
|
||||
data-action="close-my-abacus"
|
||||
onClick={close}
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
top: { base: '4', md: '8' },
|
||||
right: { base: '4', md: '8' },
|
||||
w: '12',
|
||||
h: '12',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bg: 'rgba(255, 255, 255, 0.1)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: '2px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: 'full',
|
||||
color: 'white',
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
zIndex: Z_INDEX.MY_ABACUS + 1,
|
||||
animation: 'fadeIn 0.3s ease-out 0.2s both',
|
||||
_hover: {
|
||||
bg: 'rgba(255, 255, 255, 0.2)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.4)',
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Single abacus element that morphs between states */}
|
||||
<div
|
||||
data-component="my-abacus"
|
||||
data-mode={isOpen ? 'open' : isHeroMode ? 'hero' : 'button'}
|
||||
onClick={isOpen || isHeroMode ? undefined : toggle}
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
zIndex: Z_INDEX.MY_ABACUS,
|
||||
cursor: isOpen || isHeroMode ? 'default' : 'pointer',
|
||||
transition: 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
// Three modes: hero (top center), button (bottom-right), open (center)
|
||||
...(isOpen
|
||||
? {
|
||||
// Open mode: center of screen
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}
|
||||
: isHeroMode
|
||||
? {
|
||||
// Hero mode: top center (in viewport flow position)
|
||||
top: '50vh',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}
|
||||
: {
|
||||
// Button mode: bottom-right corner
|
||||
bottom: { base: '4', md: '6' },
|
||||
right: { base: '4', md: '6' },
|
||||
transform: 'translate(0, 0)',
|
||||
}),
|
||||
})}
|
||||
>
|
||||
{/* Container that changes between hero, button, and open states */}
|
||||
<div
|
||||
className={css({
|
||||
transition: 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
...(isOpen || isHeroMode
|
||||
? {
|
||||
// Open/Hero state: no background, just the abacus
|
||||
bg: 'transparent',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
borderRadius: '0',
|
||||
}
|
||||
: {
|
||||
// Button state: button styling
|
||||
bg: 'rgba(0, 0, 0, 0.7)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: '3px solid rgba(251, 191, 36, 0.5)',
|
||||
boxShadow: '0 8px 32px rgba(251, 191, 36, 0.4)',
|
||||
borderRadius: 'xl',
|
||||
w: { base: '80px', md: '100px' },
|
||||
h: { base: '80px', md: '100px' },
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
animation: 'pulse 2s ease-in-out infinite',
|
||||
_hover: {
|
||||
transform: 'scale(1.1)',
|
||||
boxShadow: '0 12px 48px rgba(251, 191, 36, 0.6)',
|
||||
borderColor: 'rgba(251, 191, 36, 0.8)',
|
||||
},
|
||||
}),
|
||||
})}
|
||||
>
|
||||
{/* The abacus itself - same element, scales between hero/button/open */}
|
||||
<div
|
||||
data-element="abacus-display"
|
||||
className={css({
|
||||
transform: isOpen
|
||||
? { base: 'scale(2.5)', md: 'scale(3.5)', lg: 'scale(4.5)' }
|
||||
: isHeroMode
|
||||
? { base: 'scale(3)', md: 'scale(3.5)', lg: 'scale(4.25)' }
|
||||
: 'scale(0.35)',
|
||||
transformOrigin: 'center center',
|
||||
transition: 'transform 0.6s cubic-bezier(0.4, 0, 0.2, 1), filter 0.6s ease',
|
||||
filter:
|
||||
isOpen || isHeroMode
|
||||
? 'drop-shadow(0 10px 40px rgba(251, 191, 36, 0.3))'
|
||||
: 'drop-shadow(0 4px 12px rgba(251, 191, 36, 0.2))',
|
||||
pointerEvents: isOpen || isHeroMode ? 'auto' : 'none',
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
key={isHeroMode ? 'hero' : isOpen ? 'open' : 'closed'}
|
||||
value={abacusValue}
|
||||
columns={isHeroMode ? 4 : 5}
|
||||
beadShape={appConfig.beadShape}
|
||||
showNumbers={isOpen || isHeroMode}
|
||||
interactive={isOpen || isHeroMode}
|
||||
animated={isOpen || isHeroMode}
|
||||
customStyles={isHeroMode ? structuralStyles : trophyStyles}
|
||||
onValueChange={setAbacusValue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title and achievement info - only visible when open */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
mt: { base: '16', md: '20', lg: '24' },
|
||||
textAlign: 'center',
|
||||
animation: 'fadeIn 0.5s ease-out 0.3s both',
|
||||
maxW: '600px',
|
||||
px: '8',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl', lg: '4xl' },
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #fbbf24 100%)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
mb: '3',
|
||||
})}
|
||||
>
|
||||
My Abacus
|
||||
</h2>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: { base: 'md', md: 'lg' },
|
||||
color: 'gray.300',
|
||||
mb: '4',
|
||||
fontWeight: 'medium',
|
||||
})}
|
||||
>
|
||||
Your personal abacus grows with you
|
||||
</p>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: { base: 'sm', md: 'md' },
|
||||
color: 'gray.400',
|
||||
lineHeight: '1.6',
|
||||
})}
|
||||
>
|
||||
Complete tutorials, play games, and earn achievements to unlock higher place values
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyframes for animations */}
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes backdropFadeIn {
|
||||
from { opacity: 0; backdrop-filter: blur(0px); -webkit-backdrop-filter: blur(0px); }
|
||||
to { opacity: 1; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { box-shadow: 0 8px 32px rgba(251, 191, 36, 0.4); }
|
||||
50% { box-shadow: 0 12px 48px rgba(251, 191, 36, 0.6); }
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -26,6 +26,10 @@ export const Z_INDEX = {
|
||||
// Top-level overlays (20000+)
|
||||
TOAST: 20000,
|
||||
|
||||
// My Abacus - Personal trophy overlay (30000+)
|
||||
MY_ABACUS_BACKDROP: 30000,
|
||||
MY_ABACUS: 30001,
|
||||
|
||||
// Special navigation layers for game pages
|
||||
GAME_NAV: {
|
||||
// Hamburger menu and its nested content
|
||||
|
||||
35
apps/web/src/contexts/MyAbacusContext.tsx
Normal file
35
apps/web/src/contexts/MyAbacusContext.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { createContext, useContext, useState, useCallback } from 'react'
|
||||
|
||||
interface MyAbacusContextValue {
|
||||
isOpen: boolean
|
||||
open: () => void
|
||||
close: () => void
|
||||
toggle: () => void
|
||||
}
|
||||
|
||||
const MyAbacusContext = createContext<MyAbacusContextValue | undefined>(undefined)
|
||||
|
||||
export function MyAbacusProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const open = useCallback(() => setIsOpen(true), [])
|
||||
const close = useCallback(() => setIsOpen(false), [])
|
||||
const toggle = useCallback(() => setIsOpen((prev) => !prev), [])
|
||||
|
||||
return (
|
||||
<MyAbacusContext.Provider value={{ isOpen, open, close, toggle }}>
|
||||
{children}
|
||||
</MyAbacusContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useMyAbacus() {
|
||||
const context = useContext(MyAbacusContext)
|
||||
if (!context) {
|
||||
throw new Error('useMyAbacus must be used within MyAbacusProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
19
packages/abacus-react/.claude/settings.local.json
Normal file
19
packages/abacus-react/.claude/settings.local.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(tree:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(npm test:*)",
|
||||
"Bash(npm run test:run:*)",
|
||||
"Bash(timeout 60 npm run test:run)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git rm:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(npm run build:*)"
|
||||
]
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
"enabledMcpjsonServers": [
|
||||
"sqlite"
|
||||
]
|
||||
}
|
||||
360
packages/abacus-react/src/Abacus3D.css
Normal file
360
packages/abacus-react/src/Abacus3D.css
Normal file
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* Abacus 3D Enhancement Styles
|
||||
* Three levels of progressive enhancement:
|
||||
* - subtle: CSS perspective + shadows
|
||||
* - realistic: Lighting + material design
|
||||
* - delightful: Physics + micro-interactions
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
PROPOSAL 1: SUBTLE (CSS Perspective + Shadows)
|
||||
============================================ */
|
||||
|
||||
.abacus-3d-container {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.abacus-3d-container.enhanced-subtle {
|
||||
perspective: 1200px;
|
||||
perspective-origin: 50% 50%;
|
||||
}
|
||||
|
||||
.abacus-3d-container.enhanced-subtle .abacus-svg {
|
||||
transform-style: preserve-3d;
|
||||
transform: rotateX(2deg) rotateY(-1deg);
|
||||
transition: transform 0.3s ease-out;
|
||||
}
|
||||
|
||||
.abacus-3d-container.enhanced-subtle:hover .abacus-svg {
|
||||
transform: rotateX(0deg) rotateY(0deg);
|
||||
}
|
||||
|
||||
/* Bead depth shadows - subtle */
|
||||
.abacus-3d-container.enhanced-subtle .abacus-bead.active {
|
||||
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.25))
|
||||
drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
.abacus-3d-container.enhanced-subtle .abacus-bead.inactive {
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
/* Frame depth */
|
||||
.abacus-3d-container.enhanced-subtle rect[class*="column-post"],
|
||||
.abacus-3d-container.enhanced-subtle rect[class*="reckoning-bar"] {
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
PROPOSAL 2: REALISTIC (Lighting + Materials)
|
||||
============================================ */
|
||||
|
||||
.abacus-3d-container.enhanced-realistic {
|
||||
perspective: 1200px;
|
||||
perspective-origin: 50% 50%;
|
||||
}
|
||||
|
||||
.abacus-3d-container.enhanced-realistic .abacus-svg {
|
||||
transform-style: preserve-3d;
|
||||
transform: rotateX(3deg) rotateY(-2deg);
|
||||
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.abacus-3d-container.enhanced-realistic:hover .abacus-svg {
|
||||
transform: rotateX(0deg) rotateY(0deg);
|
||||
}
|
||||
|
||||
/* Enhanced bead shadows with ambient occlusion */
|
||||
.abacus-3d-container.enhanced-realistic .abacus-bead.active {
|
||||
filter: drop-shadow(0 6px 12px rgba(0, 0, 0, 0.3))
|
||||
drop-shadow(0 3px 6px rgba(0, 0, 0, 0.2))
|
||||
drop-shadow(0 1px 3px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
.abacus-3d-container.enhanced-realistic .abacus-bead.inactive {
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15))
|
||||
drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
/* Frame with depth and texture */
|
||||
.abacus-3d-container.enhanced-realistic rect[class*="column-post"],
|
||||
.abacus-3d-container.enhanced-realistic rect[class*="reckoning-bar"] {
|
||||
filter: drop-shadow(0 3px 6px rgba(0, 0, 0, 0.25))
|
||||
drop-shadow(0 1px 3px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
/* Material-specific enhancements */
|
||||
.abacus-3d-container.enhanced-realistic .bead-material-glossy {
|
||||
filter: drop-shadow(0 6px 12px rgba(0, 0, 0, 0.3))
|
||||
drop-shadow(0 3px 6px rgba(0, 0, 0, 0.2))
|
||||
drop-shadow(0 0 4px rgba(255, 255, 255, 0.3));
|
||||
}
|
||||
|
||||
.abacus-3d-container.enhanced-realistic .bead-material-satin {
|
||||
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.25))
|
||||
drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
.abacus-3d-container.enhanced-realistic .bead-material-matte {
|
||||
filter: drop-shadow(0 3px 6px rgba(0, 0, 0, 0.2))
|
||||
drop-shadow(0 1px 3px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
/* Wood grain texture overlay */
|
||||
.abacus-3d-container.enhanced-realistic .frame-wood {
|
||||
opacity: 0.15;
|
||||
mix-blend-mode: multiply;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Lighting effects - top-down */
|
||||
.abacus-3d-container.enhanced-realistic.lighting-top-down::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -10%;
|
||||
left: 50%;
|
||||
width: 120%;
|
||||
height: 30%;
|
||||
transform: translateX(-50%);
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(255, 255, 255, 0.15) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Lighting effects - ambient */
|
||||
.abacus-3d-container.enhanced-realistic.lighting-ambient::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -10%;
|
||||
background: radial-gradient(
|
||||
circle at 50% 50%,
|
||||
rgba(255, 255, 255, 0.08) 0%,
|
||||
transparent 60%
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Lighting effects - dramatic */
|
||||
.abacus-3d-container.enhanced-realistic.lighting-dramatic::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -20%;
|
||||
left: -10%;
|
||||
width: 60%;
|
||||
height: 60%;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(255, 255, 255, 0.25) 0%,
|
||||
transparent 50%
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
PROPOSAL 3: DELIGHTFUL (Physics + Micro-interactions)
|
||||
============================================ */
|
||||
|
||||
.abacus-3d-container.enhanced-delightful {
|
||||
perspective: 1400px;
|
||||
perspective-origin: 50% 50%;
|
||||
}
|
||||
|
||||
.abacus-3d-container.enhanced-delightful .abacus-svg {
|
||||
transform-style: preserve-3d;
|
||||
transform: rotateX(4deg) rotateY(-3deg);
|
||||
transition: transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.abacus-3d-container.enhanced-delightful:hover .abacus-svg {
|
||||
transform: rotateX(1deg) rotateY(-0.5deg);
|
||||
}
|
||||
|
||||
/* Maximum depth shadows */
|
||||
.abacus-3d-container.enhanced-delightful .abacus-bead.active {
|
||||
filter: drop-shadow(0 8px 16px rgba(0, 0, 0, 0.35))
|
||||
drop-shadow(0 4px 8px rgba(0, 0, 0, 0.25))
|
||||
drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
.abacus-3d-container.enhanced-delightful .abacus-bead.inactive {
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15))
|
||||
drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
/* Hover parallax effect */
|
||||
.abacus-3d-container.enhanced-delightful.parallax-enabled .abacus-bead {
|
||||
transition: transform 0.15s ease-out, filter 0.15s ease-out;
|
||||
}
|
||||
|
||||
.abacus-3d-container.enhanced-delightful.parallax-enabled .abacus-bead.parallax-lift {
|
||||
transform: translateZ(4px) scale(1.02);
|
||||
filter: drop-shadow(0 10px 20px rgba(0, 0, 0, 0.4))
|
||||
drop-shadow(0 5px 10px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
|
||||
/* Clack ripple effect */
|
||||
@keyframes clack-ripple {
|
||||
0% {
|
||||
r: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
100% {
|
||||
r: 25;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.clack-ripple {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.clack-ripple.animating {
|
||||
animation: clack-ripple 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Particle effects */
|
||||
@keyframes particle-rise {
|
||||
0% {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-20px) scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes particle-sparkle {
|
||||
0%, 100% {
|
||||
opacity: 0;
|
||||
transform: scale(0) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.particle {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.particle.particle-subtle {
|
||||
animation: particle-rise 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
.particle.particle-sparkle {
|
||||
animation: particle-sparkle 0.6s ease-in-out forwards;
|
||||
}
|
||||
|
||||
/* Enhanced lighting with multiple sources */
|
||||
.abacus-3d-container.enhanced-delightful::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -15%;
|
||||
left: 50%;
|
||||
width: 140%;
|
||||
height: 40%;
|
||||
transform: translateX(-50%);
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(255, 255, 255, 0.2) 0%,
|
||||
rgba(255, 255, 255, 0.05) 50%,
|
||||
transparent 80%
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.abacus-3d-container.enhanced-delightful::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -10%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 20%;
|
||||
transform: translateX(-50%);
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(0, 0, 0, 0.1) 0%,
|
||||
transparent 60%
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* Wobble physics - applied via inline styles from React Spring */
|
||||
.bead-wobble {
|
||||
/* transform-origin set dynamically */
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
/* Frame depth enhancement */
|
||||
.abacus-3d-container.enhanced-delightful rect[class*="column-post"],
|
||||
.abacus-3d-container.enhanced-delightful rect[class*="reckoning-bar"] {
|
||||
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))
|
||||
drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))
|
||||
drop-shadow(0 0 2px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
/* Wood grain texture - enhanced */
|
||||
.frame-wood-enhanced {
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(139, 90, 43, 0.03) 2px,
|
||||
rgba(139, 90, 43, 0.03) 4px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 1px,
|
||||
rgba(101, 67, 33, 0.02) 1px,
|
||||
rgba(101, 67, 33, 0.02) 2px
|
||||
);
|
||||
opacity: 0.2;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
/* Accessibility - Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.abacus-3d-container.enhanced-subtle .abacus-svg,
|
||||
.abacus-3d-container.enhanced-realistic .abacus-svg,
|
||||
.abacus-3d-container.enhanced-delightful .abacus-svg {
|
||||
transition: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.abacus-3d-container.enhanced-delightful.parallax-enabled .abacus-bead {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.clack-ripple.animating {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.particle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Performance optimization - will-change hints */
|
||||
.abacus-3d-container.enhanced-delightful .abacus-bead {
|
||||
will-change: transform, filter;
|
||||
}
|
||||
|
||||
.abacus-3d-container.enhanced-realistic .abacus-bead.active {
|
||||
will-change: filter;
|
||||
}
|
||||
291
packages/abacus-react/src/Abacus3DUtils.ts
Normal file
291
packages/abacus-react/src/Abacus3DUtils.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Utility functions for 3D abacus effects
|
||||
* Includes gradient generation, color manipulation, and material definitions
|
||||
*/
|
||||
|
||||
import type { BeadMaterial, LightingStyle } from "./AbacusReact";
|
||||
|
||||
/**
|
||||
* Darken a hex color by a given amount (0-1)
|
||||
*/
|
||||
export function darkenColor(hex: string, amount: number): string {
|
||||
// Remove # if present
|
||||
const color = hex.replace('#', '');
|
||||
|
||||
// Parse RGB
|
||||
const r = parseInt(color.substring(0, 2), 16);
|
||||
const g = parseInt(color.substring(2, 4), 16);
|
||||
const b = parseInt(color.substring(4, 6), 16);
|
||||
|
||||
// Darken
|
||||
const newR = Math.max(0, Math.floor(r * (1 - amount)));
|
||||
const newG = Math.max(0, Math.floor(g * (1 - amount)));
|
||||
const newB = Math.max(0, Math.floor(b * (1 - amount)));
|
||||
|
||||
// Convert back to hex
|
||||
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lighten a hex color by a given amount (0-1)
|
||||
*/
|
||||
export function lightenColor(hex: string, amount: number): string {
|
||||
// Remove # if present
|
||||
const color = hex.replace('#', '');
|
||||
|
||||
// Parse RGB
|
||||
const r = parseInt(color.substring(0, 2), 16);
|
||||
const g = parseInt(color.substring(2, 4), 16);
|
||||
const b = parseInt(color.substring(4, 6), 16);
|
||||
|
||||
// Lighten
|
||||
const newR = Math.min(255, Math.floor(r + (255 - r) * amount));
|
||||
const newG = Math.min(255, Math.floor(g + (255 - g) * amount));
|
||||
const newB = Math.min(255, Math.floor(b + (255 - b) * amount));
|
||||
|
||||
// Convert back to hex
|
||||
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an SVG radial gradient for a bead based on material type
|
||||
*/
|
||||
export function getBeadGradient(
|
||||
id: string,
|
||||
color: string,
|
||||
material: BeadMaterial = "satin",
|
||||
active: boolean = true
|
||||
): string {
|
||||
const baseColor = active ? color : "rgb(211, 211, 211)";
|
||||
|
||||
switch (material) {
|
||||
case "glossy":
|
||||
// High shine with strong highlight
|
||||
return `
|
||||
<radialGradient id="${id}" cx="30%" cy="30%">
|
||||
<stop offset="0%" stop-color="${lightenColor(baseColor, 0.6)}" stop-opacity="0.8" />
|
||||
<stop offset="20%" stop-color="${lightenColor(baseColor, 0.3)}" />
|
||||
<stop offset="50%" stop-color="${baseColor}" />
|
||||
<stop offset="100%" stop-color="${darkenColor(baseColor, 0.4)}" />
|
||||
</radialGradient>
|
||||
`;
|
||||
|
||||
case "matte":
|
||||
// Subtle, no shine
|
||||
return `
|
||||
<radialGradient id="${id}" cx="50%" cy="50%">
|
||||
<stop offset="0%" stop-color="${lightenColor(baseColor, 0.1)}" />
|
||||
<stop offset="80%" stop-color="${baseColor}" />
|
||||
<stop offset="100%" stop-color="${darkenColor(baseColor, 0.15)}" />
|
||||
</radialGradient>
|
||||
`;
|
||||
|
||||
case "satin":
|
||||
default:
|
||||
// Medium shine, balanced
|
||||
return `
|
||||
<radialGradient id="${id}" cx="35%" cy="35%">
|
||||
<stop offset="0%" stop-color="${lightenColor(baseColor, 0.4)}" stop-opacity="0.9" />
|
||||
<stop offset="35%" stop-color="${lightenColor(baseColor, 0.15)}" />
|
||||
<stop offset="70%" stop-color="${baseColor}" />
|
||||
<stop offset="100%" stop-color="${darkenColor(baseColor, 0.25)}" />
|
||||
</radialGradient>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate shadow definition based on lighting style
|
||||
*/
|
||||
export function getLightingFilter(lighting: LightingStyle = "top-down"): string {
|
||||
switch (lighting) {
|
||||
case "dramatic":
|
||||
return `
|
||||
drop-shadow(0 8px 16px rgba(0, 0, 0, 0.4))
|
||||
drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))
|
||||
`;
|
||||
|
||||
case "ambient":
|
||||
return `
|
||||
drop-shadow(0 4px 8px rgba(0, 0, 0, 0.2))
|
||||
drop-shadow(0 2px 4px rgba(0, 0, 0, 0.15))
|
||||
`;
|
||||
|
||||
case "top-down":
|
||||
default:
|
||||
return `
|
||||
drop-shadow(0 6px 12px rgba(0, 0, 0, 0.3))
|
||||
drop-shadow(0 3px 6px rgba(0, 0, 0, 0.2))
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Z-depth for a bead based on enhancement level and state
|
||||
*/
|
||||
export function getBeadZDepth(
|
||||
enhanced3d: boolean | "subtle" | "realistic" | "delightful",
|
||||
active: boolean
|
||||
): number {
|
||||
if (!enhanced3d || enhanced3d === true) return 0;
|
||||
|
||||
if (!active) return 0;
|
||||
|
||||
switch (enhanced3d) {
|
||||
case "subtle":
|
||||
return 6;
|
||||
case "realistic":
|
||||
return 10;
|
||||
case "delightful":
|
||||
return 12;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate wobble rotation based on velocity (for delightful mode)
|
||||
*/
|
||||
export function getWobbleRotation(velocity: number, axis: "x" | "y" = "x"): string {
|
||||
const maxRotation = 3; // degrees
|
||||
const rotation = Math.max(-maxRotation, Math.min(maxRotation, velocity * -2));
|
||||
|
||||
if (axis === "x") {
|
||||
return `rotateX(${rotation}deg)`;
|
||||
}
|
||||
return `rotateY(${rotation}deg)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate parallax offset based on mouse position
|
||||
*/
|
||||
export function calculateParallaxOffset(
|
||||
beadX: number,
|
||||
beadY: number,
|
||||
mouseX: number,
|
||||
mouseY: number,
|
||||
containerX: number,
|
||||
containerY: number,
|
||||
intensity: number = 0.5
|
||||
): { x: number; y: number; z: number } {
|
||||
// Calculate distance from bead center to mouse
|
||||
const dx = (mouseX - containerX) - beadX;
|
||||
const dy = (mouseY - containerY) - beadY;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
// Max influence radius (pixels)
|
||||
const maxRadius = 150;
|
||||
|
||||
if (distance > maxRadius) {
|
||||
return { x: 0, y: 0, z: 0 };
|
||||
}
|
||||
|
||||
// Calculate lift amount (inverse square falloff)
|
||||
const influence = Math.max(0, 1 - (distance / maxRadius));
|
||||
const lift = influence * influence * intensity;
|
||||
|
||||
return {
|
||||
x: dx * lift * 0.1,
|
||||
y: dy * lift * 0.1,
|
||||
z: lift * 8
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate wood grain texture SVG pattern
|
||||
*/
|
||||
export function getWoodGrainPattern(id: string): string {
|
||||
return `
|
||||
<pattern id="${id}" x="0" y="0" width="100" height="100" patternUnits="userSpaceOnUse">
|
||||
<rect width="100" height="100" fill="#8B5A2B" opacity="0.3"/>
|
||||
<!-- Grain lines -->
|
||||
<path d="M 0 10 Q 25 8 50 10 T 100 10" stroke="#654321" stroke-width="0.5" fill="none" opacity="0.4"/>
|
||||
<path d="M 0 30 Q 25 28 50 30 T 100 30" stroke="#654321" stroke-width="0.5" fill="none" opacity="0.3"/>
|
||||
<path d="M 0 50 Q 25 48 50 50 T 100 50" stroke="#654321" stroke-width="0.5" fill="none" opacity="0.4"/>
|
||||
<path d="M 0 70 Q 25 68 50 70 T 100 70" stroke="#654321" stroke-width="0.5" fill="none" opacity="0.3"/>
|
||||
<path d="M 0 90 Q 25 88 50 90 T 100 90" stroke="#654321" stroke-width="0.5" fill="none" opacity="0.4"/>
|
||||
<!-- Knots -->
|
||||
<ellipse cx="20" cy="25" rx="8" ry="6" fill="#654321" opacity="0.2"/>
|
||||
<ellipse cx="75" cy="65" rx="6" ry="8" fill="#654321" opacity="0.2"/>
|
||||
</pattern>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get container class names for 3D enhancement level
|
||||
*/
|
||||
export function get3DContainerClasses(
|
||||
enhanced3d: boolean | "subtle" | "realistic" | "delightful" | undefined,
|
||||
lighting?: LightingStyle,
|
||||
parallaxEnabled?: boolean
|
||||
): string {
|
||||
const classes: string[] = ["abacus-3d-container"];
|
||||
|
||||
if (!enhanced3d) return classes.join(" ");
|
||||
|
||||
// Add enhancement level
|
||||
if (enhanced3d === true || enhanced3d === "subtle") {
|
||||
classes.push("enhanced-subtle");
|
||||
} else if (enhanced3d === "realistic") {
|
||||
classes.push("enhanced-realistic");
|
||||
} else if (enhanced3d === "delightful") {
|
||||
classes.push("enhanced-delightful");
|
||||
}
|
||||
|
||||
// Add lighting class
|
||||
if (lighting && enhanced3d !== "subtle") {
|
||||
classes.push(`lighting-${lighting}`);
|
||||
}
|
||||
|
||||
// Add parallax class
|
||||
if (parallaxEnabled && enhanced3d === "delightful") {
|
||||
classes.push("parallax-enabled");
|
||||
}
|
||||
|
||||
return classes.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique gradient ID for a bead
|
||||
*/
|
||||
export function getBeadGradientId(
|
||||
columnIndex: number,
|
||||
beadType: "heaven" | "earth",
|
||||
position: number,
|
||||
material: BeadMaterial
|
||||
): string {
|
||||
return `bead-gradient-${columnIndex}-${beadType}-${position}-${material}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Physics config for different enhancement levels
|
||||
*/
|
||||
export function getPhysicsConfig(enhanced3d: boolean | "subtle" | "realistic" | "delightful") {
|
||||
const base = {
|
||||
tension: 300,
|
||||
friction: 22,
|
||||
mass: 0.5,
|
||||
clamp: false
|
||||
};
|
||||
|
||||
if (!enhanced3d || enhanced3d === "subtle") {
|
||||
return { ...base, clamp: true };
|
||||
}
|
||||
|
||||
if (enhanced3d === "realistic") {
|
||||
return {
|
||||
tension: 320,
|
||||
friction: 24,
|
||||
mass: 0.6,
|
||||
clamp: false
|
||||
};
|
||||
}
|
||||
|
||||
// delightful
|
||||
return {
|
||||
tension: 280,
|
||||
friction: 20,
|
||||
mass: 0.7,
|
||||
clamp: false, // Allow overshoot for satisfying settle
|
||||
};
|
||||
}
|
||||
642
packages/abacus-react/src/AbacusReact.3d-effects.stories.tsx
Normal file
642
packages/abacus-react/src/AbacusReact.3d-effects.stories.tsx
Normal file
@@ -0,0 +1,642 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { AbacusReact } from './AbacusReact';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import './Abacus3D.css';
|
||||
|
||||
const meta: Meta<typeof AbacusReact> = {
|
||||
title: 'Soroban/3D Effects Showcase',
|
||||
component: AbacusReact,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
# 3D Enhancement Showcase
|
||||
|
||||
Three levels of progressive 3D enhancement for the abacus to make interactions feel satisfying and real.
|
||||
|
||||
## Proposal 1: Subtle (CSS Perspective + Shadows)
|
||||
- Light perspective tilt
|
||||
- Depth shadows on active beads
|
||||
- Smooth transitions
|
||||
- **Zero performance cost**
|
||||
|
||||
## Proposal 2: Realistic (Lighting + Materials)
|
||||
- Everything from Proposal 1 +
|
||||
- Realistic lighting effects
|
||||
- Material-based bead rendering (glossy/satin/matte)
|
||||
- Ambient occlusion
|
||||
- Frame depth
|
||||
|
||||
## Proposal 3: Delightful (Physics + Micro-interactions)
|
||||
- Everything from Proposal 2 +
|
||||
- Enhanced physics with satisfying bounce
|
||||
- Clack ripple effects when beads snap
|
||||
- Hover parallax
|
||||
- Maximum satisfaction
|
||||
|
||||
**Note:** Currently these are CSS-only demos. Full integration with React Spring physics coming next!
|
||||
`
|
||||
}
|
||||
}
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// Wrapper component to apply 3D CSS classes
|
||||
const Wrapper3D: React.FC<{
|
||||
children: React.ReactNode;
|
||||
level: 'subtle' | 'realistic' | 'delightful';
|
||||
lighting?: 'top-down' | 'ambient' | 'dramatic';
|
||||
}> = ({ children, level, lighting }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
const svg = containerRef.current.querySelector('.abacus-svg');
|
||||
const beads = containerRef.current.querySelectorAll('.abacus-bead');
|
||||
|
||||
// Add classes to container
|
||||
containerRef.current.classList.add('abacus-3d-container');
|
||||
containerRef.current.classList.add(`enhanced-${level}`);
|
||||
if (lighting) {
|
||||
containerRef.current.classList.add(`lighting-${lighting}`);
|
||||
}
|
||||
|
||||
// Apply will-change for performance
|
||||
if (level === 'delightful') {
|
||||
beads.forEach(bead => {
|
||||
(bead as HTMLElement).style.willChange = 'transform, filter';
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [level, lighting]);
|
||||
|
||||
return <div ref={containerRef}>{children}</div>;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// PROPOSAL 1: SUBTLE
|
||||
// ============================================
|
||||
|
||||
export const Subtle_Static: Story = {
|
||||
name: '1. Subtle - Static Display',
|
||||
render: () => (
|
||||
<Wrapper3D level="subtle">
|
||||
<AbacusReact
|
||||
value={12345}
|
||||
columns={5}
|
||||
showNumbers
|
||||
colorScheme="place-value"
|
||||
scaleFactor={1.2}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Subtle 3D with light perspective tilt and depth shadows. Notice the slight elevation of active beads.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Subtle_Interactive: Story = {
|
||||
name: '1. Subtle - Interactive',
|
||||
render: () => (
|
||||
<Wrapper3D level="subtle">
|
||||
<AbacusReact
|
||||
value={678}
|
||||
columns={3}
|
||||
showNumbers
|
||||
interactive
|
||||
animated
|
||||
soundEnabled
|
||||
colorScheme="heaven-earth"
|
||||
scaleFactor={1.2}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Subtle 3D + interaction. Click beads to see depth shadows change. Notice how the perspective gives a sense of physicality.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Subtle_Tutorial: Story = {
|
||||
name: '1. Subtle - Tutorial Mode',
|
||||
render: () => {
|
||||
const [step, setStep] = React.useState(0);
|
||||
const highlights = [
|
||||
{ placeValue: 0, beadType: 'earth' as const, position: 2 },
|
||||
{ placeValue: 1, beadType: 'heaven' as const },
|
||||
{ placeValue: 2, beadType: 'earth' as const, position: 0 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Wrapper3D level="subtle">
|
||||
<AbacusReact
|
||||
value={123}
|
||||
columns={3}
|
||||
showNumbers
|
||||
interactive
|
||||
animated
|
||||
highlightBeads={[highlights[step]]}
|
||||
colorScheme="place-value"
|
||||
scaleFactor={1.2}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<button onClick={() => setStep((step + 1) % 3)} style={{ padding: '8px 16px' }}>
|
||||
Next Step ({step + 1}/3)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Tutorial mode with subtle 3D effects. The depth helps highlight which bead to focus on.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// PROPOSAL 2: REALISTIC
|
||||
// ============================================
|
||||
|
||||
export const Realistic_TopDown: Story = {
|
||||
name: '2. Realistic - Top-Down Lighting',
|
||||
render: () => (
|
||||
<Wrapper3D level="realistic" lighting="top-down">
|
||||
<AbacusReact
|
||||
value={24680}
|
||||
columns={5}
|
||||
showNumbers
|
||||
colorScheme="place-value"
|
||||
beadShape="circle"
|
||||
scaleFactor={1.2}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Realistic 3D with top-down lighting. Notice the enhanced shadows and sense of illumination from above.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Realistic_Ambient: Story = {
|
||||
name: '2. Realistic - Ambient Lighting',
|
||||
render: () => (
|
||||
<Wrapper3D level="realistic" lighting="ambient">
|
||||
<AbacusReact
|
||||
value={13579}
|
||||
columns={5}
|
||||
showNumbers
|
||||
colorScheme="place-value"
|
||||
beadShape="diamond"
|
||||
scaleFactor={1.2}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Realistic 3D with ambient lighting. Softer, more even illumination creates a cozy feel.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Realistic_Dramatic: Story = {
|
||||
name: '2. Realistic - Dramatic Lighting',
|
||||
render: () => (
|
||||
<Wrapper3D level="realistic" lighting="dramatic">
|
||||
<AbacusReact
|
||||
value={99999}
|
||||
columns={5}
|
||||
showNumbers
|
||||
colorScheme="heaven-earth"
|
||||
beadShape="square"
|
||||
colorPalette="colorblind"
|
||||
scaleFactor={1.2}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Realistic 3D with dramatic lighting. Strong directional light creates bold shadows and depth.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Realistic_Interactive: Story = {
|
||||
name: '2. Realistic - Interactive',
|
||||
render: () => (
|
||||
<Wrapper3D level="realistic" lighting="top-down">
|
||||
<AbacusReact
|
||||
value={555}
|
||||
columns={3}
|
||||
showNumbers
|
||||
interactive
|
||||
animated
|
||||
soundEnabled
|
||||
colorScheme="place-value"
|
||||
colorPalette="nature"
|
||||
scaleFactor={1.3}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Realistic 3D + interaction. Click beads and watch the enhanced shadows and lighting respond. Feel that satisfaction!'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Realistic_AllShapes: Story = {
|
||||
name: '2. Realistic - All Bead Shapes',
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: '40px', flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Wrapper3D level="realistic" lighting="top-down">
|
||||
<AbacusReact
|
||||
value={777}
|
||||
columns={3}
|
||||
showNumbers
|
||||
beadShape="diamond"
|
||||
colorScheme="place-value"
|
||||
/>
|
||||
</Wrapper3D>
|
||||
<p style={{ marginTop: '12px', fontSize: '14px' }}>Diamond</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Wrapper3D level="realistic" lighting="top-down">
|
||||
<AbacusReact
|
||||
value={777}
|
||||
columns={3}
|
||||
showNumbers
|
||||
beadShape="circle"
|
||||
colorScheme="place-value"
|
||||
/>
|
||||
</Wrapper3D>
|
||||
<p style={{ marginTop: '12px', fontSize: '14px' }}>Circle</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Wrapper3D level="realistic" lighting="top-down">
|
||||
<AbacusReact
|
||||
value={777}
|
||||
columns={3}
|
||||
showNumbers
|
||||
beadShape="square"
|
||||
colorScheme="place-value"
|
||||
/>
|
||||
</Wrapper3D>
|
||||
<p style={{ marginTop: '12px', fontSize: '14px' }}>Square</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Realistic 3D works beautifully with all three bead shapes.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// PROPOSAL 3: DELIGHTFUL
|
||||
// ============================================
|
||||
|
||||
export const Delightful_Static: Story = {
|
||||
name: '3. Delightful - Maximum Depth',
|
||||
render: () => (
|
||||
<Wrapper3D level="delightful">
|
||||
<AbacusReact
|
||||
value={11111}
|
||||
columns={5}
|
||||
showNumbers
|
||||
colorScheme="alternating"
|
||||
beadShape="circle"
|
||||
colorPalette="mnemonic"
|
||||
scaleFactor={1.2}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Delightful 3D with maximum depth and richness. The beads really pop off the page!'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Delightful_Interactive: Story = {
|
||||
name: '3. Delightful - Interactive (Physics Ready)',
|
||||
render: () => (
|
||||
<Wrapper3D level="delightful">
|
||||
<AbacusReact
|
||||
value={987}
|
||||
columns={3}
|
||||
showNumbers
|
||||
interactive
|
||||
animated
|
||||
soundEnabled
|
||||
colorScheme="place-value"
|
||||
scaleFactor={1.3}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Delightful 3D + interaction. This is the CSS foundation - physics effects (wobble, clack ripple) will be added in the next iteration. Already feels great!'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const Delightful_LargeScale: Story = {
|
||||
name: '3. Delightful - Large Scale',
|
||||
render: () => (
|
||||
<Wrapper3D level="delightful">
|
||||
<AbacusReact
|
||||
value={9876543210}
|
||||
columns={10}
|
||||
showNumbers
|
||||
colorScheme="place-value"
|
||||
scaleFactor={1}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Delightful 3D scales beautifully even with many columns. The depth hierarchy helps organize the visual.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// COMPARISON VIEWS
|
||||
// ============================================
|
||||
|
||||
export const CompareAllLevels: Story = {
|
||||
name: 'Compare All Three Levels',
|
||||
render: () => {
|
||||
const value = 4242;
|
||||
const columns = 4;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '60px', padding: '20px' }}>
|
||||
{/* No 3D */}
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h3 style={{ fontSize: '16px', marginBottom: '20px', color: '#666' }}>
|
||||
No Enhancement (Current)
|
||||
</h3>
|
||||
<AbacusReact
|
||||
value={value}
|
||||
columns={columns}
|
||||
showNumbers
|
||||
colorScheme="place-value"
|
||||
scaleFactor={1.2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subtle */}
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h3 style={{ fontSize: '16px', marginBottom: '20px', color: '#666' }}>
|
||||
Proposal 1: Subtle 😊
|
||||
</h3>
|
||||
<Wrapper3D level="subtle">
|
||||
<AbacusReact
|
||||
value={value}
|
||||
columns={columns}
|
||||
showNumbers
|
||||
colorScheme="place-value"
|
||||
scaleFactor={1.2}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
<p style={{ fontSize: '12px', color: '#888', marginTop: '12px' }}>
|
||||
Light tilt + depth shadows
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Realistic */}
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h3 style={{ fontSize: '16px', marginBottom: '20px', color: '#666' }}>
|
||||
Proposal 2: Realistic 😍
|
||||
</h3>
|
||||
<Wrapper3D level="realistic" lighting="top-down">
|
||||
<AbacusReact
|
||||
value={value}
|
||||
columns={columns}
|
||||
showNumbers
|
||||
colorScheme="place-value"
|
||||
scaleFactor={1.2}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
<p style={{ fontSize: '12px', color: '#888', marginTop: '12px' }}>
|
||||
Lighting + materials + ambient occlusion
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Delightful */}
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h3 style={{ fontSize: '16px', marginBottom: '20px', color: '#666' }}>
|
||||
Proposal 3: Delightful 🤩
|
||||
</h3>
|
||||
<Wrapper3D level="delightful">
|
||||
<AbacusReact
|
||||
value={value}
|
||||
columns={columns}
|
||||
showNumbers
|
||||
colorScheme="place-value"
|
||||
scaleFactor={1.2}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
<p style={{ fontSize: '12px', color: '#888', marginTop: '12px' }}>
|
||||
Maximum depth + enhanced lighting (physics effects coming next!)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Side-by-side comparison of all three enhancement levels. Which feels best to you?'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const CompareInteractive: Story = {
|
||||
name: 'Compare Interactive (Side-by-Side)',
|
||||
render: () => {
|
||||
const [value1, setValue1] = React.useState(123);
|
||||
const [value2, setValue2] = React.useState(456);
|
||||
const [value3, setValue3] = React.useState(789);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '40px', flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h4 style={{ fontSize: '14px', marginBottom: '12px' }}>Subtle</h4>
|
||||
<Wrapper3D level="subtle">
|
||||
<AbacusReact
|
||||
value={value1}
|
||||
onValueChange={(v) => setValue1(Number(v))}
|
||||
columns={3}
|
||||
showNumbers
|
||||
interactive
|
||||
animated
|
||||
colorScheme="place-value"
|
||||
/>
|
||||
</Wrapper3D>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h4 style={{ fontSize: '14px', marginBottom: '12px' }}>Realistic</h4>
|
||||
<Wrapper3D level="realistic" lighting="top-down">
|
||||
<AbacusReact
|
||||
value={value2}
|
||||
onValueChange={(v) => setValue2(Number(v))}
|
||||
columns={3}
|
||||
showNumbers
|
||||
interactive
|
||||
animated
|
||||
colorScheme="place-value"
|
||||
/>
|
||||
</Wrapper3D>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h4 style={{ fontSize: '14px', marginBottom: '12px' }}>Delightful</h4>
|
||||
<Wrapper3D level="delightful">
|
||||
<AbacusReact
|
||||
value={value3}
|
||||
onValueChange={(v) => setValue3(Number(v))}
|
||||
columns={3}
|
||||
showNumbers
|
||||
interactive
|
||||
animated
|
||||
colorScheme="place-value"
|
||||
/>
|
||||
</Wrapper3D>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Try all three side-by-side! Click beads and feel the difference in satisfaction.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// FEATURE TESTS
|
||||
// ============================================
|
||||
|
||||
export const ColorSchemes_With3D: Story = {
|
||||
name: '3D Works With All Color Schemes',
|
||||
render: () => {
|
||||
const value = 333;
|
||||
const schemes: Array<'monochrome' | 'place-value' | 'alternating' | 'heaven-earth'> = [
|
||||
'monochrome',
|
||||
'place-value',
|
||||
'alternating',
|
||||
'heaven-earth'
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '30px', flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
{schemes.map(scheme => (
|
||||
<div key={scheme} style={{ textAlign: 'center' }}>
|
||||
<Wrapper3D level="realistic" lighting="top-down">
|
||||
<AbacusReact
|
||||
value={value}
|
||||
columns={3}
|
||||
showNumbers
|
||||
colorScheme={scheme}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
<p style={{ fontSize: '12px', marginTop: '8px', textTransform: 'capitalize' }}>
|
||||
{scheme}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'The 3D effects work seamlessly with all existing color schemes.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const ColorPalettes_With3D: Story = {
|
||||
name: '3D Works With All Palettes',
|
||||
render: () => {
|
||||
const value = 555;
|
||||
const palettes: Array<'default' | 'colorblind' | 'mnemonic' | 'grayscale' | 'nature'> = [
|
||||
'default',
|
||||
'colorblind',
|
||||
'mnemonic',
|
||||
'grayscale',
|
||||
'nature'
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '30px', flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
{palettes.map(palette => (
|
||||
<div key={palette} style={{ textAlign: 'center' }}>
|
||||
<Wrapper3D level="realistic" lighting="top-down">
|
||||
<AbacusReact
|
||||
value={value}
|
||||
columns={3}
|
||||
showNumbers
|
||||
colorScheme="place-value"
|
||||
colorPalette={palette}
|
||||
/>
|
||||
</Wrapper3D>
|
||||
<p style={{ fontSize: '12px', marginTop: '8px', textTransform: 'capitalize' }}>
|
||||
{palette}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'The 3D effects enhance all color palettes beautifully.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -6,6 +6,8 @@ import { useDrag } from "@use-gesture/react";
|
||||
import NumberFlow from "@number-flow/react";
|
||||
import { useAbacusConfig, getDefaultAbacusConfig } from "./AbacusContext";
|
||||
import { playBeadSound } from "./soundManager";
|
||||
import * as Abacus3DUtils from "./Abacus3DUtils";
|
||||
import "./Abacus3D.css";
|
||||
|
||||
// Types
|
||||
export interface BeadConfig {
|
||||
@@ -238,6 +240,27 @@ export interface AbacusOverlay {
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
// 3D Enhancement Configuration
|
||||
export type BeadMaterial = "glossy" | "satin" | "matte";
|
||||
export type FrameMaterial = "wood" | "metal" | "minimal";
|
||||
export type LightingStyle = "top-down" | "ambient" | "dramatic";
|
||||
|
||||
export interface Abacus3DMaterial {
|
||||
heavenBeads?: BeadMaterial;
|
||||
earthBeads?: BeadMaterial;
|
||||
frame?: FrameMaterial;
|
||||
lighting?: LightingStyle;
|
||||
woodGrain?: boolean; // Add wood texture to frame
|
||||
}
|
||||
|
||||
export interface Abacus3DPhysics {
|
||||
wobble?: boolean; // Beads rotate slightly during movement
|
||||
clackEffect?: boolean; // Visual ripple when beads snap
|
||||
hoverParallax?: boolean; // Beads lift on hover
|
||||
particleSnap?: "off" | "subtle" | "sparkle"; // Particle effects on snap
|
||||
hapticFeedback?: boolean; // Trigger haptic feedback on mobile
|
||||
}
|
||||
|
||||
export interface AbacusConfig {
|
||||
// Basic configuration
|
||||
value?: number | bigint;
|
||||
@@ -255,6 +278,11 @@ export interface AbacusConfig {
|
||||
soundEnabled?: boolean;
|
||||
soundVolume?: number;
|
||||
|
||||
// 3D Enhancement
|
||||
enhanced3d?: boolean | "subtle" | "realistic" | "delightful";
|
||||
material3d?: Abacus3DMaterial;
|
||||
physics3d?: Abacus3DPhysics;
|
||||
|
||||
// Advanced customization
|
||||
customStyles?: AbacusCustomStyles;
|
||||
callbacks?: AbacusCallbacks;
|
||||
@@ -1540,6 +1568,10 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
|
||||
showNumbers,
|
||||
soundEnabled,
|
||||
soundVolume,
|
||||
// 3D enhancement props
|
||||
enhanced3d,
|
||||
material3d,
|
||||
physics3d,
|
||||
// Advanced customization props
|
||||
customStyles,
|
||||
callbacks,
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"dist",
|
||||
"**/*.stories.*",
|
||||
"**/*.test.*",
|
||||
"src/test/**/*"
|
||||
"src/test/**/*",
|
||||
"src/__tests__/**/*"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user