feat: add comprehensive soroban learning guide with server-generated SVGs
- Create /guide page with step-by-step tutorial for reading soroban numbers - Add ServerSorobanSVG component using same API as LivePreview for consistency - Implement proper viewBox correction for Typst-generated sorobans - Design responsive layout with appropriate aspect ratios for soroban display - Add navigation links between guide and flashcard creation - Include examples for single digits (0,1,3,5,7) and multi-digit numbers (23,58,147) - Provide educational content covering heaven/earth beads, place values, and practice tips Technical improvements: - Handle complex SVG transforms (matrix(4 0 0 4 -37.5 -180)) that push content outside viewBox - Calculate precise content bounds to eliminate whitespace and show full soroban - Use aspect-ratio containers and flexbox for graceful responsive display 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -11,13 +11,7 @@
|
||||
"clean": "rm -rf .next"
|
||||
},
|
||||
"dependencies": {
|
||||
"@soroban/core": "workspace:*",
|
||||
"@soroban/client": "workspace:*",
|
||||
"next": "^14.0.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"@pandacss/dev": "^0.20.0",
|
||||
"@tanstack/react-form": "^0.19.0",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
@@ -30,7 +24,14 @@
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"lucide-react": "^0.294.0"
|
||||
"@soroban/client": "workspace:*",
|
||||
"@soroban/core": "workspace:*",
|
||||
"@tanstack/react-form": "^0.19.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"next": "^14.0.0",
|
||||
"python-bridge": "^1.1.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { SorobanGenerator } from '@soroban/core'
|
||||
import { assetStore } from '@/lib/asset-store'
|
||||
import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
|
||||
// Global generator instance for better performance
|
||||
let generator: SorobanGenerator | null = null
|
||||
@@ -50,40 +48,31 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Generate flashcards using Python via TypeScript bindings
|
||||
console.log('🚀 Generating flashcards with config:', JSON.stringify(config, null, 2))
|
||||
const pdfBuffer = await gen.generate(config)
|
||||
|
||||
// Create unique ID for this generated asset
|
||||
const assetId = crypto.randomUUID()
|
||||
|
||||
// For now, only PDF format is supported by the core generator
|
||||
const format = 'pdf'
|
||||
const mimeType = 'application/pdf'
|
||||
const filename = `soroban-flashcards-${config.range || 'cards'}.pdf`
|
||||
|
||||
// Store the generated asset temporarily
|
||||
assetStore.set(assetId, {
|
||||
data: pdfBuffer,
|
||||
filename,
|
||||
mimeType,
|
||||
createdAt: new Date()
|
||||
const result = await gen.generate(config)
|
||||
console.log('📦 Generation result:', {
|
||||
pdfLength: result.pdf?.length || 0,
|
||||
count: result.count,
|
||||
numbersLength: result.numbers?.length || 0
|
||||
})
|
||||
|
||||
// Calculate metadata from config
|
||||
const cardCount = calculateCardCount(config.range || '0', config.step || 1)
|
||||
const numbers = generateNumbersFromRange(config.range || '0', config.step || 1)
|
||||
if (!result.pdf) {
|
||||
throw new Error('No PDF data received from generator')
|
||||
}
|
||||
|
||||
// Return metadata and download URL
|
||||
return NextResponse.json({
|
||||
id: assetId,
|
||||
downloadUrl: `/api/download/${assetId}`,
|
||||
metadata: {
|
||||
cardCount,
|
||||
numbers: numbers.slice(0, 20), // Show first 20 numbers for preview
|
||||
format,
|
||||
filename,
|
||||
fileSize: pdfBuffer.length
|
||||
},
|
||||
success: true
|
||||
// Convert base64 PDF string to Buffer
|
||||
const pdfBuffer = Buffer.from(result.pdf, 'base64')
|
||||
console.log('📄 PDF buffer size:', pdfBuffer.length, 'bytes')
|
||||
|
||||
// Create filename for download
|
||||
const filename = `soroban-flashcards-${config.range || 'cards'}.pdf`
|
||||
|
||||
// Return PDF directly as download
|
||||
return new NextResponse(pdfBuffer, {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Content-Length': pdfBuffer.length.toString()
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
@@ -137,8 +126,7 @@ export async function GET() {
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'healthy',
|
||||
dependencies: deps,
|
||||
assetsInMemory: assetStore.size
|
||||
dependencies: deps
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -6,9 +6,10 @@ import { css } from '../../../styled-system/css'
|
||||
import { container, stack, hstack, grid } from '../../../styled-system/patterns'
|
||||
import Link from 'next/link'
|
||||
import { ConfigurationForm } from '@/components/ConfigurationForm'
|
||||
import { ConfigurationFormWithoutGenerate } from '@/components/ConfigurationFormWithoutGenerate'
|
||||
import { LivePreview } from '@/components/LivePreview'
|
||||
import { GenerationProgress } from '@/components/GenerationProgress'
|
||||
import { DownloadCard } from '@/components/DownloadCard'
|
||||
import { StyleControls } from '@/components/StyleControls'
|
||||
|
||||
// Complete, validated configuration ready for generation
|
||||
export interface FlashcardConfig {
|
||||
@@ -100,23 +101,10 @@ function validateAndCompleteConfig(formState: FlashcardFormState): FlashcardConf
|
||||
}
|
||||
}
|
||||
|
||||
type GenerationStatus = 'idle' | 'generating' | 'success' | 'error'
|
||||
|
||||
interface GenerationResult {
|
||||
id: string
|
||||
downloadUrl: string
|
||||
metadata: {
|
||||
cardCount: number
|
||||
numbers: number[]
|
||||
format: string
|
||||
filename: string
|
||||
fileSize: number
|
||||
}
|
||||
}
|
||||
type GenerationStatus = 'idle' | 'generating' | 'error'
|
||||
|
||||
export default function CreatePage() {
|
||||
const [generationStatus, setGenerationStatus] = useState<GenerationStatus>('idle')
|
||||
const [generationResult, setGenerationResult] = useState<GenerationResult | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const form = useForm<FlashcardFormState>({
|
||||
@@ -159,14 +147,28 @@ export default function CreatePage() {
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Generation failed')
|
||||
// Handle error response (should be JSON)
|
||||
const errorResult = await response.json()
|
||||
throw new Error(errorResult.error || 'Generation failed')
|
||||
}
|
||||
|
||||
setGenerationResult(result)
|
||||
setGenerationStatus('success')
|
||||
// Success - response is binary PDF data, trigger download
|
||||
const blob = await response.blob()
|
||||
const filename = `soroban-flashcards-${config.range || 'cards'}.pdf`
|
||||
|
||||
// Create download link and trigger download
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.style.display = 'none'
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
|
||||
setGenerationStatus('idle') // Reset to idle after successful download
|
||||
} catch (err) {
|
||||
console.error('Generation error:', err)
|
||||
setError(err instanceof Error ? err.message : 'Unknown error occurred')
|
||||
@@ -176,7 +178,6 @@ export default function CreatePage() {
|
||||
|
||||
const handleNewGeneration = () => {
|
||||
setGenerationStatus('idle')
|
||||
setGenerationResult(null)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
@@ -200,7 +201,7 @@ export default function CreatePage() {
|
||||
|
||||
<div className={hstack({ gap: '3' })}>
|
||||
<Link
|
||||
href="/gallery"
|
||||
href="/guide"
|
||||
className={css({
|
||||
px: '4',
|
||||
py: '2',
|
||||
@@ -211,7 +212,7 @@ export default function CreatePage() {
|
||||
_hover: { bg: 'brand.50' }
|
||||
})}
|
||||
>
|
||||
Gallery
|
||||
Guide
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -233,120 +234,181 @@ export default function CreatePage() {
|
||||
fontSize: 'lg',
|
||||
color: 'gray.600'
|
||||
})}>
|
||||
Configure your perfect soroban flashcards and download instantly
|
||||
Configure content and style, preview instantly, then generate your flashcards
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration Interface */}
|
||||
<div className={grid({
|
||||
columns: { base: 1, lg: 2 },
|
||||
columns: { base: 1, lg: 3 },
|
||||
gap: '8',
|
||||
alignItems: 'start'
|
||||
})}>
|
||||
{/* Configuration Panel */}
|
||||
{/* Main Configuration Panel */}
|
||||
<div className={css({
|
||||
bg: 'white',
|
||||
rounded: '2xl',
|
||||
shadow: 'card',
|
||||
p: '8'
|
||||
})}>
|
||||
<ConfigurationForm
|
||||
form={form}
|
||||
onGenerate={handleGenerate}
|
||||
isGenerating={generationStatus === 'generating'}
|
||||
/>
|
||||
<ConfigurationFormWithoutGenerate form={form} />
|
||||
</div>
|
||||
|
||||
{/* Preview & Generation Panel */}
|
||||
<div className={stack({ gap: '6' })}>
|
||||
{/* Live Preview */}
|
||||
<div className={css({
|
||||
bg: 'white',
|
||||
rounded: '2xl',
|
||||
shadow: 'card',
|
||||
p: '8'
|
||||
})}>
|
||||
{/* Style Controls Panel */}
|
||||
<div className={css({
|
||||
bg: 'white',
|
||||
rounded: '2xl',
|
||||
shadow: 'card',
|
||||
p: '6'
|
||||
})}>
|
||||
<div className={stack({ gap: '4' })}>
|
||||
<div className={stack({ gap: '1' })}>
|
||||
<h3 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900'
|
||||
})}>
|
||||
🎨 Visual Style
|
||||
</h3>
|
||||
<p className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600'
|
||||
})}>
|
||||
See changes instantly in the preview
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form.Subscribe
|
||||
selector={(state) => state.values}
|
||||
children={(values) => <StyleControls form={form} />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Preview Panel */}
|
||||
<div className={css({
|
||||
bg: 'white',
|
||||
rounded: '2xl',
|
||||
shadow: 'card',
|
||||
p: '6'
|
||||
})}>
|
||||
<div className={stack({ gap: '6' })}>
|
||||
<form.Subscribe
|
||||
selector={(state) => state.values}
|
||||
children={(values) => <LivePreview config={values} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Generation Status */}
|
||||
{generationStatus === 'generating' && (
|
||||
{/* Generate Button within Preview */}
|
||||
<div className={css({
|
||||
bg: 'white',
|
||||
rounded: '2xl',
|
||||
shadow: 'card',
|
||||
p: '8'
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
pt: '6'
|
||||
})}>
|
||||
<GenerationProgress config={form.state.values} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Result */}
|
||||
{generationStatus === 'success' && generationResult && (
|
||||
<div className={css({
|
||||
bg: 'white',
|
||||
rounded: '2xl',
|
||||
shadow: 'card',
|
||||
p: '8'
|
||||
})}>
|
||||
<DownloadCard
|
||||
result={generationResult}
|
||||
onNewGeneration={handleNewGeneration}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{generationStatus === 'error' && error && (
|
||||
<div className={css({
|
||||
bg: 'red.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'red.200',
|
||||
rounded: '2xl',
|
||||
p: '8'
|
||||
})}>
|
||||
<div className={stack({ gap: '4' })}>
|
||||
<div className={hstack({ gap: '3', alignItems: 'center' })}>
|
||||
<div className={css({ fontSize: '2xl' })}>❌</div>
|
||||
<h3 className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'semibold',
|
||||
color: 'red.800'
|
||||
})}>
|
||||
Generation Failed
|
||||
</h3>
|
||||
{/* Generation Status */}
|
||||
{generationStatus === 'generating' && (
|
||||
<div className={css({ mb: '4' })}>
|
||||
<GenerationProgress config={form.state.values} />
|
||||
</div>
|
||||
<p className={css({
|
||||
color: 'red.700',
|
||||
lineHeight: 'relaxed'
|
||||
})}>
|
||||
{error}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleNewGeneration}
|
||||
className={css({
|
||||
alignSelf: 'start',
|
||||
px: '4',
|
||||
py: '2',
|
||||
bg: 'red.600',
|
||||
color: 'white',
|
||||
fontWeight: 'medium',
|
||||
rounded: 'lg',
|
||||
transition: 'all',
|
||||
_hover: { bg: 'red.700' }
|
||||
})}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => handleGenerate(form.state.values)}
|
||||
disabled={generationStatus === 'generating'}
|
||||
className={css({
|
||||
w: 'full',
|
||||
px: '6',
|
||||
py: '4',
|
||||
bg: 'brand.600',
|
||||
color: 'white',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
rounded: 'xl',
|
||||
shadow: 'card',
|
||||
transition: 'all',
|
||||
cursor: generationStatus === 'generating' ? 'not-allowed' : 'pointer',
|
||||
opacity: generationStatus === 'generating' ? '0.7' : '1',
|
||||
_hover: generationStatus === 'generating' ? {} : {
|
||||
bg: 'brand.700',
|
||||
transform: 'translateY(-1px)',
|
||||
shadow: 'modal'
|
||||
}
|
||||
})}
|
||||
>
|
||||
<span className={hstack({ gap: '3', justify: 'center' })}>
|
||||
{generationStatus === 'generating' ? (
|
||||
<>
|
||||
<div className={css({
|
||||
w: '5',
|
||||
h: '5',
|
||||
border: '2px solid',
|
||||
borderColor: 'white',
|
||||
borderTopColor: 'transparent',
|
||||
rounded: 'full',
|
||||
animation: 'spin 1s linear infinite'
|
||||
})} />
|
||||
Generating Your Flashcards...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={css({ fontSize: 'xl' })}>✨</div>
|
||||
Generate Flashcards
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Error Display - moved to global level */}
|
||||
{generationStatus === 'error' && error && (
|
||||
<div className={css({
|
||||
bg: 'red.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'red.200',
|
||||
rounded: '2xl',
|
||||
p: '8',
|
||||
mt: '8'
|
||||
})}>
|
||||
<div className={stack({ gap: '4' })}>
|
||||
<div className={hstack({ gap: '3', alignItems: 'center' })}>
|
||||
<div className={css({ fontSize: '2xl' })}>❌</div>
|
||||
<h3 className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'semibold',
|
||||
color: 'red.800'
|
||||
})}>
|
||||
Generation Failed
|
||||
</h3>
|
||||
</div>
|
||||
<p className={css({
|
||||
color: 'red.700',
|
||||
lineHeight: 'relaxed'
|
||||
})}>
|
||||
{error}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleNewGeneration}
|
||||
className={css({
|
||||
alignSelf: 'start',
|
||||
px: '4',
|
||||
py: '2',
|
||||
bg: 'red.600',
|
||||
color: 'white',
|
||||
fontWeight: 'medium',
|
||||
rounded: 'lg',
|
||||
transition: 'all',
|
||||
_hover: { bg: 'red.700' }
|
||||
})}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
681
apps/web/src/app/guide/page.tsx
Normal file
681
apps/web/src/app/guide/page.tsx
Normal file
@@ -0,0 +1,681 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { container, stack, hstack, grid } from '../../../styled-system/patterns'
|
||||
import { ServerSorobanSVG } from '@/components/ServerSorobanSVG'
|
||||
|
||||
export default function GuidePage() {
|
||||
return (
|
||||
<div className={css({ minHeight: '100vh', bg: 'gray.50' })}>
|
||||
{/* Header */}
|
||||
<header className={css({ bg: 'white', shadow: 'card', position: 'sticky', top: 0, zIndex: 10 })}>
|
||||
<div className={container({ maxW: '7xl', px: '4', py: '4' })}>
|
||||
<div className={hstack({ justify: 'space-between', alignItems: 'center' })}>
|
||||
<Link
|
||||
href="/"
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'brand.800',
|
||||
textDecoration: 'none'
|
||||
})}
|
||||
>
|
||||
🧮 Soroban Generator
|
||||
</Link>
|
||||
|
||||
<div className={hstack({ gap: '3' })}>
|
||||
<Link
|
||||
href="/create"
|
||||
className={css({
|
||||
px: '4',
|
||||
py: '2',
|
||||
color: 'brand.600',
|
||||
fontWeight: 'medium',
|
||||
rounded: 'lg',
|
||||
transition: 'all',
|
||||
_hover: { bg: 'brand.50' }
|
||||
})}
|
||||
>
|
||||
Create Flashcards
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<div className={css({
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
textAlign: 'center',
|
||||
py: '20'
|
||||
})}>
|
||||
<div className={container({ maxW: '4xl', px: '4' })}>
|
||||
<h1 className={css({
|
||||
fontSize: '4xl',
|
||||
fontWeight: 'bold',
|
||||
mb: '4',
|
||||
textShadow: '0 4px 20px rgba(0,0,0,0.3)'
|
||||
})}>
|
||||
📚 Complete Soroban Mastery Guide
|
||||
</h1>
|
||||
<p className={css({
|
||||
fontSize: 'xl',
|
||||
opacity: '0.95',
|
||||
maxW: '2xl',
|
||||
mx: 'auto',
|
||||
lineHeight: 'relaxed'
|
||||
})}>
|
||||
From basic reading to advanced arithmetic - everything you need to master the Japanese abacus
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className={css({
|
||||
bg: 'white',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'gray.200'
|
||||
})}>
|
||||
<div className={container({ maxW: '7xl', px: '4' })}>
|
||||
<div className={hstack({ gap: '0' })}>
|
||||
<button className={css({
|
||||
px: '6',
|
||||
py: '4',
|
||||
fontWeight: 'medium',
|
||||
borderBottom: '2px solid',
|
||||
borderColor: 'brand.600',
|
||||
color: 'brand.600',
|
||||
bg: 'brand.50'
|
||||
})}>
|
||||
📖 Reading Numbers
|
||||
</button>
|
||||
<button className={css({
|
||||
px: '6',
|
||||
py: '4',
|
||||
fontWeight: 'medium',
|
||||
borderBottom: '2px solid',
|
||||
borderColor: 'transparent',
|
||||
color: 'gray.600',
|
||||
_hover: { bg: 'gray.50' }
|
||||
})}>
|
||||
🧮 Arithmetic Operations
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className={container({ maxW: '6xl', px: '4', py: '12' })}>
|
||||
<div className={css({
|
||||
bg: 'white',
|
||||
rounded: '2xl',
|
||||
shadow: 'card',
|
||||
p: '10'
|
||||
})}>
|
||||
<ReadingNumbersGuide />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReadingNumbersGuide() {
|
||||
return (
|
||||
<div className={stack({ gap: '12' })}>
|
||||
{/* Section Introduction */}
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h2 className={css({
|
||||
fontSize: '3xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '4'
|
||||
})}>
|
||||
🔍 Learning to Read Soroban Numbers
|
||||
</h2>
|
||||
<p className={css({
|
||||
fontSize: 'lg',
|
||||
color: 'gray.600',
|
||||
maxW: '3xl',
|
||||
mx: 'auto',
|
||||
lineHeight: 'relaxed'
|
||||
})}>
|
||||
Master the fundamentals of reading numbers on the soroban with step-by-step visual tutorials
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step 1: Basic Structure */}
|
||||
<div className={css({
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
rounded: 'xl',
|
||||
p: '8'
|
||||
})}>
|
||||
<div className={stack({ gap: '6' })}>
|
||||
<div className={hstack({ gap: '4', alignItems: 'center' })}>
|
||||
<div className={css({
|
||||
w: '12',
|
||||
h: '12',
|
||||
bg: 'brand.600',
|
||||
color: 'white',
|
||||
rounded: 'full',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 'lg'
|
||||
})}>
|
||||
1
|
||||
</div>
|
||||
<h3 className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900'
|
||||
})}>
|
||||
Understanding the Structure
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className={stack({ gap: '6' })}>
|
||||
<p className={css({
|
||||
fontSize: 'lg',
|
||||
color: 'gray.700',
|
||||
lineHeight: 'relaxed'
|
||||
})}>
|
||||
The soroban consists of two main sections divided by a horizontal bar. Understanding this structure is fundamental to reading any number.
|
||||
</p>
|
||||
|
||||
<div className={grid({ columns: { base: 1, md: 2 }, gap: '8' })}>
|
||||
<div className={css({
|
||||
bg: 'blue.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'blue.200',
|
||||
rounded: 'xl',
|
||||
p: '6'
|
||||
})}>
|
||||
<h4 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: 'blue.800',
|
||||
mb: '3'
|
||||
})}>
|
||||
🌅 Heaven Beads (Top)
|
||||
</h4>
|
||||
<ul className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'blue.700',
|
||||
lineHeight: 'relaxed',
|
||||
pl: '4'
|
||||
})}>
|
||||
<li className={css({ mb: '2' })}>• Located above the horizontal bar</li>
|
||||
<li className={css({ mb: '2' })}>• Each bead represents 5</li>
|
||||
<li className={css({ mb: '2' })}>• Only one bead per column</li>
|
||||
<li>• When pushed down = active/counted</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={css({
|
||||
bg: 'green.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'green.200',
|
||||
rounded: 'xl',
|
||||
p: '6'
|
||||
})}>
|
||||
<h4 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: 'green.800',
|
||||
mb: '3'
|
||||
})}>
|
||||
🌍 Earth Beads (Bottom)
|
||||
</h4>
|
||||
<ul className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'green.700',
|
||||
lineHeight: 'relaxed',
|
||||
pl: '4'
|
||||
})}>
|
||||
<li className={css({ mb: '2' })}>• Located below the horizontal bar</li>
|
||||
<li className={css({ mb: '2' })}>• Each bead represents 1</li>
|
||||
<li className={css({ mb: '2' })}>• Four beads per column</li>
|
||||
<li>• When pushed up = active/counted</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={css({
|
||||
bg: 'yellow.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'yellow.300',
|
||||
rounded: 'xl',
|
||||
p: '4',
|
||||
textAlign: 'center'
|
||||
})}>
|
||||
<p className={css({
|
||||
fontSize: 'md',
|
||||
color: 'yellow.800',
|
||||
fontWeight: 'medium'
|
||||
})}>
|
||||
💡 Key Concept: Active beads are those touching the horizontal bar
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 2: Single Digits */}
|
||||
<div className={css({
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
rounded: 'xl',
|
||||
p: '8'
|
||||
})}>
|
||||
<div className={stack({ gap: '6' })}>
|
||||
<div className={hstack({ gap: '4', alignItems: 'center' })}>
|
||||
<div className={css({
|
||||
w: '12',
|
||||
h: '12',
|
||||
bg: 'brand.600',
|
||||
color: 'white',
|
||||
rounded: 'full',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 'lg'
|
||||
})}>
|
||||
2
|
||||
</div>
|
||||
<h3 className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900'
|
||||
})}>
|
||||
Reading Single Digits (1-9)
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className={css({
|
||||
fontSize: 'lg',
|
||||
color: 'gray.700',
|
||||
lineHeight: 'relaxed'
|
||||
})}>
|
||||
Let's learn to read single digits by understanding how heaven and earth beads combine to represent numbers 1 through 9.
|
||||
</p>
|
||||
|
||||
<div className={grid({ columns: { base: 1, lg: 5 }, gap: '6' })}>
|
||||
{[
|
||||
{ num: 0, desc: 'No beads active - all away from bar' },
|
||||
{ num: 1, desc: 'One earth bead pushed up' },
|
||||
{ num: 3, desc: 'Three earth beads pushed up' },
|
||||
{ num: 5, desc: 'Heaven bead pushed down' },
|
||||
{ num: 7, desc: 'Heaven bead + two earth beads' }
|
||||
].map((example) => (
|
||||
<div key={example.num} className={css({
|
||||
bg: 'gray.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
rounded: 'lg',
|
||||
p: '4',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center'
|
||||
})}>
|
||||
<div className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'brand.600',
|
||||
mb: '3'
|
||||
})}>
|
||||
{example.num}
|
||||
</div>
|
||||
|
||||
{/* Aspect ratio container for soroban - roughly 1:3 ratio */}
|
||||
<div className={css({
|
||||
width: '100%',
|
||||
aspectRatio: '1/2.8',
|
||||
maxW: '120px',
|
||||
bg: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
rounded: 'md',
|
||||
mb: '3',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
})}>
|
||||
<ServerSorobanSVG
|
||||
number={example.num}
|
||||
width={120}
|
||||
height={336}
|
||||
colorScheme="place-value"
|
||||
className={css({
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
'& svg': {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain'
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className={css({
|
||||
fontSize: '2xs',
|
||||
color: 'gray.600',
|
||||
lineHeight: 'tight',
|
||||
textAlign: 'center',
|
||||
mt: 'auto'
|
||||
})}>
|
||||
{example.desc}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 3: Multi-digit Numbers */}
|
||||
<div className={css({
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
rounded: 'xl',
|
||||
p: '8'
|
||||
})}>
|
||||
<div className={stack({ gap: '6' })}>
|
||||
<div className={hstack({ gap: '4', alignItems: 'center' })}>
|
||||
<div className={css({
|
||||
w: '12',
|
||||
h: '12',
|
||||
bg: 'brand.600',
|
||||
color: 'white',
|
||||
rounded: 'full',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 'lg'
|
||||
})}>
|
||||
3
|
||||
</div>
|
||||
<h3 className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900'
|
||||
})}>
|
||||
Multi-Digit Numbers
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className={css({
|
||||
fontSize: 'lg',
|
||||
color: 'gray.700',
|
||||
lineHeight: 'relaxed'
|
||||
})}>
|
||||
Reading larger numbers is simply a matter of reading each column from left to right, with each column representing a different place value.
|
||||
</p>
|
||||
|
||||
<div className={css({
|
||||
bg: 'purple.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'purple.200',
|
||||
rounded: 'xl',
|
||||
p: '6'
|
||||
})}>
|
||||
<h4 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: 'purple.800',
|
||||
mb: '4',
|
||||
textAlign: 'center'
|
||||
})}>
|
||||
📍 Reading Direction & Place Values
|
||||
</h4>
|
||||
<div className={grid({ columns: { base: 1, md: 2 }, gap: '6' })}>
|
||||
<div>
|
||||
<h5 className={css({ fontWeight: 'semibold', mb: '2', color: 'purple.800' })}>Reading Order:</h5>
|
||||
<ul className={css({ fontSize: 'sm', color: 'purple.700', pl: '4' })}>
|
||||
<li className={css({ mb: '1' })}>• Always read from LEFT to RIGHT</li>
|
||||
<li className={css({ mb: '1' })}>• Each column is one digit</li>
|
||||
<li>• Combine digits to form the complete number</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className={css({ fontWeight: 'semibold', mb: '2', color: 'purple.800' })}>Place Values:</h5>
|
||||
<ul className={css({ fontSize: 'sm', color: 'purple.700', pl: '4' })}>
|
||||
<li className={css({ mb: '1' })}>• Rightmost = Ones (1s)</li>
|
||||
<li className={css({ mb: '1' })}>• Next left = Tens (10s)</li>
|
||||
<li>• Continue for hundreds, thousands, etc.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Multi-digit Examples */}
|
||||
<div className={css({
|
||||
bg: 'blue.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'blue.200',
|
||||
rounded: 'xl',
|
||||
p: '6'
|
||||
})}>
|
||||
<h4 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: 'blue.800',
|
||||
mb: '4',
|
||||
textAlign: 'center'
|
||||
})}>
|
||||
🔢 Multi-Digit Examples
|
||||
</h4>
|
||||
|
||||
<div className={grid({ columns: { base: 1, md: 3 }, gap: '8' })}>
|
||||
{[
|
||||
{ num: 23, desc: 'Two-digit: 2 in tens place + 3 in ones place' },
|
||||
{ num: 58, desc: 'Heaven bead in tens (5) + heaven + earth beads in ones (8)' },
|
||||
{ num: 147, desc: 'Three-digit: 1 hundred + 4 tens + 7 ones' }
|
||||
].map((example) => (
|
||||
<div key={example.num} className={css({
|
||||
bg: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'blue.300',
|
||||
rounded: 'lg',
|
||||
p: '4',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center'
|
||||
})}>
|
||||
<div className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'blue.600',
|
||||
mb: '3'
|
||||
})}>
|
||||
{example.num}
|
||||
</div>
|
||||
|
||||
{/* Larger container for multi-digit numbers */}
|
||||
<div className={css({
|
||||
width: '100%',
|
||||
aspectRatio: '3/4',
|
||||
maxW: '180px',
|
||||
bg: 'gray.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'blue.200',
|
||||
rounded: 'md',
|
||||
mb: '3',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
})}>
|
||||
<ServerSorobanSVG
|
||||
number={example.num}
|
||||
width={180}
|
||||
height={240}
|
||||
colorScheme="place-value"
|
||||
className={css({
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
'& svg': {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain'
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className={css({
|
||||
fontSize: 'xs',
|
||||
color: 'blue.700',
|
||||
lineHeight: 'relaxed',
|
||||
textAlign: 'center'
|
||||
})}>
|
||||
{example.desc}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 4: Practice Tips */}
|
||||
<div className={css({
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
rounded: 'xl',
|
||||
p: '8'
|
||||
})}>
|
||||
<div className={stack({ gap: '6' })}>
|
||||
<div className={hstack({ gap: '4', alignItems: 'center' })}>
|
||||
<div className={css({
|
||||
w: '12',
|
||||
h: '12',
|
||||
bg: 'brand.600',
|
||||
color: 'white',
|
||||
rounded: 'full',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 'lg'
|
||||
})}>
|
||||
4
|
||||
</div>
|
||||
<h3 className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900'
|
||||
})}>
|
||||
Practice Strategy
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className={grid({ columns: { base: 1, md: 2 }, gap: '6' })}>
|
||||
<div className={css({
|
||||
bg: 'green.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'green.200',
|
||||
rounded: 'xl',
|
||||
p: '6'
|
||||
})}>
|
||||
<h4 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: 'green.800',
|
||||
mb: '4'
|
||||
})}>
|
||||
🎯 Learning Tips
|
||||
</h4>
|
||||
<ul className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'green.700',
|
||||
lineHeight: 'relaxed',
|
||||
pl: '4'
|
||||
})}>
|
||||
<li className={css({ mb: '2' })}>• Start with single digits (0-9)</li>
|
||||
<li className={css({ mb: '2' })}>• Practice identifying active vs. inactive beads</li>
|
||||
<li className={css({ mb: '2' })}>• Work on speed recognition</li>
|
||||
<li>• Progress to multi-digit numbers gradually</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={css({
|
||||
bg: 'orange.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'orange.200',
|
||||
rounded: 'xl',
|
||||
p: '6'
|
||||
})}>
|
||||
<h4 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: 'orange.800',
|
||||
mb: '4'
|
||||
})}>
|
||||
⚡ Quick Recognition
|
||||
</h4>
|
||||
<ul className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'orange.700',
|
||||
lineHeight: 'relaxed',
|
||||
pl: '4'
|
||||
})}>
|
||||
<li className={css({ mb: '2' })}>• Numbers 1-4: Only earth beads</li>
|
||||
<li className={css({ mb: '2' })}>• Number 5: Only heaven bead</li>
|
||||
<li className={css({ mb: '2' })}>• Numbers 6-9: Heaven + earth beads</li>
|
||||
<li>• Zero: All beads away from bar</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={css({
|
||||
bg: 'blue.600',
|
||||
color: 'white',
|
||||
rounded: 'xl',
|
||||
p: '6',
|
||||
textAlign: 'center'
|
||||
})}>
|
||||
<h4 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
mb: '3'
|
||||
})}>
|
||||
🚀 Ready to Practice?
|
||||
</h4>
|
||||
<p className={css({
|
||||
mb: '4',
|
||||
opacity: '0.9'
|
||||
})}>
|
||||
Test your newfound knowledge with interactive flashcards
|
||||
</p>
|
||||
<Link
|
||||
href="/create"
|
||||
className={css({
|
||||
display: 'inline-block',
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: 'white',
|
||||
color: 'blue.600',
|
||||
fontWeight: 'semibold',
|
||||
rounded: 'lg',
|
||||
textDecoration: 'none',
|
||||
transition: 'all',
|
||||
_hover: { transform: 'translateY(-1px)', shadow: 'lg' }
|
||||
})}
|
||||
>
|
||||
Create Practice Flashcards →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,20 @@ export default function HomePage() {
|
||||
🧮 Soroban Generator
|
||||
</h1>
|
||||
<div className={hstack({ gap: '4' })}>
|
||||
<Link
|
||||
href="/guide"
|
||||
className={css({
|
||||
px: '4',
|
||||
py: '2',
|
||||
color: 'brand.600',
|
||||
rounded: 'lg',
|
||||
fontWeight: 'medium',
|
||||
transition: 'all',
|
||||
_hover: { bg: 'brand.50' }
|
||||
})}
|
||||
>
|
||||
Guide
|
||||
</Link>
|
||||
<Link
|
||||
href="/create"
|
||||
className={css({
|
||||
@@ -90,7 +104,7 @@ export default function HomePage() {
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/gallery"
|
||||
href="/guide"
|
||||
className={css({
|
||||
px: '8',
|
||||
py: '4',
|
||||
@@ -109,7 +123,7 @@ export default function HomePage() {
|
||||
}
|
||||
})}
|
||||
>
|
||||
🖼️ View Examples
|
||||
📚 Learn Soroban
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,29 +10,18 @@ import * as Slider from '@radix-ui/react-slider'
|
||||
import { ChevronDown, Download, Sparkles } from 'lucide-react'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { stack, hstack, grid } from '../../styled-system/patterns'
|
||||
import { FlashcardConfig } from '@/app/create/page'
|
||||
import { FlashcardFormState } from '@/app/create/page'
|
||||
|
||||
interface ConfigurationFormProps {
|
||||
form: FormApi<FlashcardConfig>
|
||||
onGenerate: (config: FlashcardConfig) => Promise<void>
|
||||
form: FormApi<FlashcardFormState>
|
||||
onGenerate: (formState: FlashcardFormState) => Promise<void>
|
||||
isGenerating: boolean
|
||||
}
|
||||
|
||||
export function ConfigurationForm({ form, onGenerate, isGenerating }: ConfigurationFormProps) {
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
// Debug: log form values
|
||||
console.log('📝 Form values:', JSON.stringify(form.state.values, null, 2))
|
||||
|
||||
// Ensure required fields are set
|
||||
const config = {
|
||||
...form.state.values,
|
||||
range: form.state.values.range || '0-99' // Fallback
|
||||
}
|
||||
|
||||
console.log('📤 Sending config:', JSON.stringify(config, null, 2))
|
||||
onGenerate(config)
|
||||
onGenerate(form.state.values)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -43,12 +32,12 @@ export function ConfigurationForm({ form, onGenerate, isGenerating }: Configurat
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900'
|
||||
})}>
|
||||
Configure Your Flashcards
|
||||
Configuration
|
||||
</h2>
|
||||
<p className={css({
|
||||
color: 'gray.600'
|
||||
})}>
|
||||
Customize every aspect of your soroban flashcards
|
||||
Content, layout, and output settings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -62,7 +51,6 @@ export function ConfigurationForm({ form, onGenerate, isGenerating }: Configurat
|
||||
})}>
|
||||
{[
|
||||
{ value: 'content', label: '📝 Content', icon: '🔢' },
|
||||
{ value: 'appearance', label: '🎨 Style', icon: '🎨' },
|
||||
{ value: 'layout', label: '📐 Layout', icon: '📐' },
|
||||
{ value: 'output', label: '💾 Output', icon: '💾' }
|
||||
].map((tab) => (
|
||||
@@ -146,79 +134,6 @@ export function ConfigurationForm({ form, onGenerate, isGenerating }: Configurat
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
{/* Appearance Tab */}
|
||||
<Tabs.Content value="appearance" className={css({ mt: '6' })}>
|
||||
<div className={stack({ gap: '6' })}>
|
||||
<FormField
|
||||
label="Color Scheme"
|
||||
description="Choose how colors are applied to beads and numerals"
|
||||
>
|
||||
<form.Field name="colorScheme">
|
||||
{(field) => (
|
||||
<RadioGroupField
|
||||
value={field.state.value || 'place-value'}
|
||||
onValueChange={(value) => field.handleChange(value as any)}
|
||||
options={[
|
||||
{ value: 'monochrome', label: 'Monochrome', desc: 'Classic black and white' },
|
||||
{ value: 'place-value', label: 'Place Value', desc: 'Colors by digit position' },
|
||||
{ value: 'heaven-earth', label: 'Heaven-Earth', desc: 'Different colors for 5s and 1s' },
|
||||
{ value: 'alternating', label: 'Alternating', desc: 'Alternating column colors' }
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Bead Shape"
|
||||
description="Choose the visual style of the beads"
|
||||
>
|
||||
<form.Field name="beadShape">
|
||||
{(field) => (
|
||||
<RadioGroupField
|
||||
value={field.state.value || 'diamond'}
|
||||
onValueChange={(value) => field.handleChange(value as any)}
|
||||
options={[
|
||||
{ value: 'diamond', label: '💎 Diamond', desc: 'Realistic 3D appearance' },
|
||||
{ value: 'circle', label: '⭕ Circle', desc: 'Traditional round beads' },
|
||||
{ value: 'square', label: '⬜ Square', desc: 'Modern geometric style' }
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
</FormField>
|
||||
|
||||
<div className={grid({ columns: 2, gap: '4' })}>
|
||||
<FormField
|
||||
label="Colored Numerals"
|
||||
description="Match numeral colors to bead colors"
|
||||
>
|
||||
<form.Field name="coloredNumerals">
|
||||
{(field) => (
|
||||
<SwitchField
|
||||
checked={field.state.value || false}
|
||||
onCheckedChange={field.handleChange}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Hide Inactive Beads"
|
||||
description="Show only active beads for clarity"
|
||||
>
|
||||
<form.Field name="hideInactiveBeads">
|
||||
{(field) => (
|
||||
<SwitchField
|
||||
checked={field.state.value || false}
|
||||
onCheckedChange={field.handleChange}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
{/* Layout Tab */}
|
||||
<Tabs.Content value="layout" className={css({ mt: '6' })}>
|
||||
|
||||
611
apps/web/src/components/ConfigurationFormWithoutGenerate.tsx
Normal file
611
apps/web/src/components/ConfigurationFormWithoutGenerate.tsx
Normal file
@@ -0,0 +1,611 @@
|
||||
'use client'
|
||||
|
||||
import { FormApi } from '@tanstack/react-form'
|
||||
import * as Tabs from '@radix-ui/react-tabs'
|
||||
import * as Label from '@radix-ui/react-label'
|
||||
import * as Select from '@radix-ui/react-select'
|
||||
import * as RadioGroup from '@radix-ui/react-radio-group'
|
||||
import * as Switch from '@radix-ui/react-switch'
|
||||
import * as Slider from '@radix-ui/react-slider'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { stack, hstack, grid } from '../../styled-system/patterns'
|
||||
import { FlashcardFormState } from '@/app/create/page'
|
||||
import { FormatSelectField } from './FormatSelectField'
|
||||
|
||||
interface ConfigurationFormProps {
|
||||
form: FormApi<FlashcardFormState>
|
||||
}
|
||||
|
||||
export function ConfigurationFormWithoutGenerate({ form }: ConfigurationFormProps) {
|
||||
return (
|
||||
<div className={stack({ gap: '6' })}>
|
||||
<div className={stack({ gap: '2' })}>
|
||||
<h2 className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900'
|
||||
})}>
|
||||
Configuration
|
||||
</h2>
|
||||
<p className={css({
|
||||
color: 'gray.600'
|
||||
})}>
|
||||
Content, layout, and output settings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form.Field name="format">
|
||||
{(formatField) => {
|
||||
const isPdf = formatField.state.value === 'pdf'
|
||||
// Auto-switch away from layout tab if format changed to non-PDF
|
||||
const defaultTab = !isPdf ? 'content' : 'content'
|
||||
|
||||
return (
|
||||
<Tabs.Root defaultValue={defaultTab} className={css({ w: 'full' })}>
|
||||
<Tabs.List className={css({
|
||||
display: 'flex',
|
||||
gap: '1',
|
||||
bg: 'gray.100',
|
||||
p: '1',
|
||||
rounded: 'xl'
|
||||
})}>
|
||||
{[
|
||||
{ value: 'content', label: '📝 Content', icon: '🔢' },
|
||||
{ value: 'output', label: '💾 Output', icon: '💾' }
|
||||
].map((tab) => (
|
||||
<Tabs.Trigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className={css({
|
||||
flex: 1,
|
||||
px: '3',
|
||||
py: '2',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
rounded: 'lg',
|
||||
transition: 'all',
|
||||
color: 'gray.600',
|
||||
_hover: { color: 'gray.900' },
|
||||
'&[data-state=active]': {
|
||||
bg: 'white',
|
||||
color: 'brand.600',
|
||||
shadow: 'card'
|
||||
}
|
||||
})}
|
||||
>
|
||||
<span className={css({ mr: '2' })}>{tab.icon}</span>
|
||||
{tab.label}
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
|
||||
{/* Content Tab */}
|
||||
<Tabs.Content value="content" className={css({ mt: '6' })}>
|
||||
<div className={stack({ gap: '6' })}>
|
||||
<FormField
|
||||
label="Number Range"
|
||||
description="Define which numbers to include (e.g., '0-99' or '1,2,5,10')"
|
||||
>
|
||||
<form.Field name="range">
|
||||
{(field) => (
|
||||
<input
|
||||
value={field.state.value || ''}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
placeholder="0-99"
|
||||
className={inputStyles}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
</FormField>
|
||||
|
||||
<div className={grid({ columns: 2, gap: '4' })}>
|
||||
<FormField
|
||||
label="Step Size"
|
||||
description="For ranges, increment by this amount"
|
||||
>
|
||||
<form.Field name="step">
|
||||
{(field) => (
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={field.state.value || 1}
|
||||
onChange={(e) => field.handleChange(parseInt(e.target.value))}
|
||||
className={inputStyles}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Shuffle Cards"
|
||||
description="Randomize the order"
|
||||
>
|
||||
<form.Field name="shuffle">
|
||||
{(field) => (
|
||||
<SwitchField
|
||||
checked={field.state.value || false}
|
||||
onCheckedChange={field.handleChange}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
|
||||
{/* Output Tab */}
|
||||
<Tabs.Content value="output" className={css({ mt: '6' })}>
|
||||
<div className={stack({ gap: '6' })}>
|
||||
<FormField
|
||||
label="Output Format"
|
||||
description="Choose your preferred file format"
|
||||
>
|
||||
<form.Field name="format">
|
||||
{(field) => (
|
||||
<FormatSelectField
|
||||
value={field.state.value || 'pdf'}
|
||||
onValueChange={(value) => field.handleChange(value as any)}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
</FormField>
|
||||
|
||||
{/* PDF-Specific Options */}
|
||||
<form.Field name="format">
|
||||
{(formatField) => {
|
||||
const isPdf = formatField.state.value === 'pdf'
|
||||
|
||||
return isPdf ? (
|
||||
<div className={stack({ gap: '6' })}>
|
||||
<div className={css({
|
||||
p: '4',
|
||||
bg: 'blue.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'blue.200',
|
||||
rounded: 'xl'
|
||||
})}>
|
||||
<div className={stack({ gap: '4' })}>
|
||||
<div className={stack({ gap: '2' })}>
|
||||
<h3 className={css({
|
||||
fontSize: 'md',
|
||||
fontWeight: 'semibold',
|
||||
color: 'blue.800'
|
||||
})}>
|
||||
📄 PDF Layout Options
|
||||
</h3>
|
||||
<p className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'blue.700'
|
||||
})}>
|
||||
Configure page layout and printing options for your PDF
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={grid({ columns: 2, gap: '4' })}>
|
||||
<FormField
|
||||
label="Cards Per Page"
|
||||
description="Number of flashcards on each page"
|
||||
>
|
||||
<form.Field name="cardsPerPage">
|
||||
{(field) => (
|
||||
<SliderField
|
||||
value={[field.state.value || 6]}
|
||||
onValueChange={([value]) => field.handleChange(value)}
|
||||
min={1}
|
||||
max={12}
|
||||
step={1}
|
||||
formatValue={(value) => `${value} cards`}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Paper Size"
|
||||
description="Output paper dimensions"
|
||||
>
|
||||
<form.Field name="paperSize">
|
||||
{(field) => (
|
||||
<SelectField
|
||||
value={field.state.value || 'us-letter'}
|
||||
onValueChange={(value) => field.handleChange(value as any)}
|
||||
options={[
|
||||
{ value: 'us-letter', label: 'US Letter (8.5×11")' },
|
||||
{ value: 'a4', label: 'A4 (210×297mm)' },
|
||||
{ value: 'a3', label: 'A3 (297×420mm)' },
|
||||
{ value: 'a5', label: 'A5 (148×210mm)' }
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label="Orientation"
|
||||
description="Page layout direction"
|
||||
>
|
||||
<form.Field name="orientation">
|
||||
{(field) => (
|
||||
<RadioGroupField
|
||||
value={field.state.value || 'portrait'}
|
||||
onValueChange={(value) => field.handleChange(value as any)}
|
||||
options={[
|
||||
{ value: 'portrait', label: '📄 Portrait', desc: 'Taller than wide' },
|
||||
{ value: 'landscape', label: '📃 Landscape', desc: 'Wider than tall' }
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
</FormField>
|
||||
|
||||
<div className={grid({ columns: 2, gap: '4' })}>
|
||||
<FormField
|
||||
label="Show Cut Marks"
|
||||
description="Add guides for cutting cards"
|
||||
>
|
||||
<form.Field name="showCutMarks">
|
||||
{(field) => (
|
||||
<SwitchField
|
||||
checked={field.state.value || false}
|
||||
onCheckedChange={field.handleChange}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Registration Marks"
|
||||
description="Alignment guides for duplex printing"
|
||||
>
|
||||
<form.Field name="showRegistration">
|
||||
{(field) => (
|
||||
<SwitchField
|
||||
checked={field.state.value || false}
|
||||
onCheckedChange={field.handleChange}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}}
|
||||
</form.Field>
|
||||
|
||||
<FormField
|
||||
label="Scale Factor"
|
||||
description="Adjust the overall size of flashcards"
|
||||
>
|
||||
<form.Field name="scaleFactor">
|
||||
{(field) => (
|
||||
<SliderField
|
||||
value={[field.state.value || 0.9]}
|
||||
onValueChange={([value]) => field.handleChange(value)}
|
||||
min={0.5}
|
||||
max={1.0}
|
||||
step={0.05}
|
||||
formatValue={(value) => `${Math.round(value * 100)}%`}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
</FormField>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
)
|
||||
}}
|
||||
</form.Field>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper Components
|
||||
function FormField({
|
||||
label,
|
||||
description,
|
||||
children
|
||||
}: {
|
||||
label: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className={stack({ gap: '2' })}>
|
||||
<Label.Root className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
color: 'gray.900'
|
||||
})}>
|
||||
{label}
|
||||
</Label.Root>
|
||||
{description && (
|
||||
<p className={css({
|
||||
fontSize: 'xs',
|
||||
color: 'gray.600'
|
||||
})}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SwitchField({
|
||||
checked,
|
||||
onCheckedChange
|
||||
}: {
|
||||
checked: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<Switch.Root
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
className={css({
|
||||
w: '11',
|
||||
h: '6',
|
||||
bg: checked ? 'brand.600' : 'gray.300',
|
||||
rounded: 'full',
|
||||
position: 'relative',
|
||||
transition: 'all',
|
||||
cursor: 'pointer',
|
||||
_hover: { bg: checked ? 'brand.700' : 'gray.400' }
|
||||
})}
|
||||
>
|
||||
<Switch.Thumb
|
||||
className={css({
|
||||
display: 'block',
|
||||
w: '5',
|
||||
h: '5',
|
||||
bg: 'white',
|
||||
rounded: 'full',
|
||||
shadow: 'card',
|
||||
transition: 'transform 0.2s',
|
||||
transform: checked ? 'translateX(20px)' : 'translateX(0px)',
|
||||
willChange: 'transform'
|
||||
})}
|
||||
/>
|
||||
</Switch.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupField({
|
||||
value,
|
||||
onValueChange,
|
||||
options
|
||||
}: {
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
options: Array<{ value: string; label: string; desc?: string }>
|
||||
}) {
|
||||
return (
|
||||
<RadioGroup.Root
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
className={stack({ gap: '3' })}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<div key={option.value} className={hstack({ gap: '3', alignItems: 'start' })}>
|
||||
<RadioGroup.Item
|
||||
value={option.value}
|
||||
className={css({
|
||||
w: '5',
|
||||
h: '5',
|
||||
rounded: 'full',
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.300',
|
||||
bg: 'white',
|
||||
cursor: 'pointer',
|
||||
transition: 'all',
|
||||
_hover: { borderColor: 'brand.400' },
|
||||
'&[data-state=checked]': { borderColor: 'brand.600' }
|
||||
})}
|
||||
>
|
||||
<RadioGroup.Indicator
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
position: 'relative',
|
||||
_after: {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
w: '2',
|
||||
h: '2',
|
||||
rounded: 'full',
|
||||
bg: 'brand.600'
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</RadioGroup.Item>
|
||||
<div className={stack({ gap: '1', flex: 1 })}>
|
||||
<label className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
color: 'gray.900',
|
||||
cursor: 'pointer'
|
||||
})}>
|
||||
{option.label}
|
||||
</label>
|
||||
{option.desc && (
|
||||
<p className={css({
|
||||
fontSize: 'xs',
|
||||
color: 'gray.600'
|
||||
})}>
|
||||
{option.desc}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectField({
|
||||
value,
|
||||
onValueChange,
|
||||
options,
|
||||
placeholder = "Select..."
|
||||
}: {
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
options: Array<{ value: string; label: string }>
|
||||
placeholder?: string
|
||||
}) {
|
||||
return (
|
||||
<Select.Root value={value} onValueChange={onValueChange}>
|
||||
<Select.Trigger className={inputStyles}>
|
||||
<Select.Value placeholder={placeholder} />
|
||||
<Select.Icon>
|
||||
<ChevronDown size={16} />
|
||||
</Select.Icon>
|
||||
</Select.Trigger>
|
||||
|
||||
<Select.Portal>
|
||||
<Select.Content
|
||||
className={css({
|
||||
bg: 'white',
|
||||
rounded: 'xl',
|
||||
shadow: 'modal',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
p: '2',
|
||||
zIndex: 50
|
||||
})}
|
||||
>
|
||||
<Select.Viewport>
|
||||
{options.map((option) => (
|
||||
<Select.Item
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={css({
|
||||
px: '3',
|
||||
py: '2',
|
||||
fontSize: 'sm',
|
||||
rounded: 'lg',
|
||||
cursor: 'pointer',
|
||||
transition: 'all',
|
||||
_hover: { bg: 'brand.50' },
|
||||
'&[data-state=checked]': { bg: 'brand.100', color: 'brand.800' }
|
||||
})}
|
||||
>
|
||||
<Select.ItemText>{option.label}</Select.ItemText>
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Viewport>
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function SliderField({
|
||||
value,
|
||||
onValueChange,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
formatValue
|
||||
}: {
|
||||
value: number[]
|
||||
onValueChange: (value: number[]) => void
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
formatValue: (value: number) => string
|
||||
}) {
|
||||
return (
|
||||
<div className={stack({ gap: '3' })}>
|
||||
<div className={hstack({ justify: 'space-between' })}>
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>
|
||||
{formatValue(min)}
|
||||
</span>
|
||||
<span className={css({ fontSize: 'sm', fontWeight: 'medium', color: 'brand.600' })}>
|
||||
{formatValue(value[0])}
|
||||
</span>
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>
|
||||
{formatValue(max)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Slider.Root
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
className={css({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
w: 'full',
|
||||
h: '5'
|
||||
})}
|
||||
>
|
||||
<Slider.Track
|
||||
className={css({
|
||||
bg: 'gray.200',
|
||||
position: 'relative',
|
||||
flexGrow: 1,
|
||||
rounded: 'full',
|
||||
h: '2'
|
||||
})}
|
||||
>
|
||||
<Slider.Range
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
bg: 'brand.600',
|
||||
rounded: 'full',
|
||||
h: 'full'
|
||||
})}
|
||||
/>
|
||||
</Slider.Track>
|
||||
<Slider.Thumb
|
||||
className={css({
|
||||
display: 'block',
|
||||
w: '5',
|
||||
h: '5',
|
||||
bg: 'white',
|
||||
shadow: 'card',
|
||||
rounded: 'full',
|
||||
border: '2px solid',
|
||||
borderColor: 'brand.600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all',
|
||||
_hover: { transform: 'scale(1.1)' },
|
||||
_focus: { outline: 'none', boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.1)' }
|
||||
})}
|
||||
/>
|
||||
</Slider.Root>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const inputStyles = css({
|
||||
w: 'full',
|
||||
px: '4',
|
||||
py: '3',
|
||||
bg: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
rounded: 'lg',
|
||||
fontSize: 'sm',
|
||||
transition: 'all',
|
||||
_hover: { borderColor: 'gray.400' },
|
||||
_focus: {
|
||||
outline: 'none',
|
||||
borderColor: 'brand.500',
|
||||
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.1)'
|
||||
}
|
||||
})
|
||||
141
apps/web/src/components/FormatSelectField.tsx
Normal file
141
apps/web/src/components/FormatSelectField.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
'use client'
|
||||
|
||||
import * as Select from '@radix-ui/react-select'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { hstack, stack } from '../../styled-system/patterns'
|
||||
|
||||
interface FormatOption {
|
||||
value: string
|
||||
label: string
|
||||
icon: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface FormatSelectFieldProps {
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
}
|
||||
|
||||
const formatOptions: FormatOption[] = [
|
||||
{ value: 'pdf', label: 'PDF', icon: '📄', description: 'Print-ready vector document with layout options' },
|
||||
{ value: 'html', label: 'HTML', icon: '🌐', description: 'Interactive web flashcards' },
|
||||
{ value: 'svg', label: 'SVG', icon: '🖼️', description: 'Scalable vector images' },
|
||||
{ value: 'png', label: 'PNG', icon: '📷', description: 'High-resolution images' }
|
||||
]
|
||||
|
||||
export function FormatSelectField({ value, onValueChange }: FormatSelectFieldProps) {
|
||||
const selectedOption = formatOptions.find(option => option.value === value) || formatOptions[0]
|
||||
|
||||
return (
|
||||
<Select.Root value={value} onValueChange={onValueChange}>
|
||||
<Select.Trigger
|
||||
asChild={false}
|
||||
className={css({
|
||||
w: 'full',
|
||||
px: '4',
|
||||
py: '3',
|
||||
bg: 'white',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.300',
|
||||
rounded: 'lg',
|
||||
fontSize: 'sm',
|
||||
transition: 'all',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
_hover: { borderColor: 'gray.400' },
|
||||
_focus: {
|
||||
outline: 'none',
|
||||
borderColor: 'brand.500',
|
||||
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.1)'
|
||||
},
|
||||
'&[data-state=open]': {
|
||||
borderColor: 'brand.500',
|
||||
boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.1)'
|
||||
},
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
minH: '12'
|
||||
})}
|
||||
>
|
||||
<Select.Value asChild>
|
||||
<div className={hstack({ gap: '3', alignItems: 'center' })}>
|
||||
<span className={css({ fontSize: 'lg' })}>{selectedOption.icon}</span>
|
||||
<div className={stack({ gap: '0', alignItems: 'start' })}>
|
||||
<span className={css({ fontWeight: 'medium', color: 'gray.900' })}>
|
||||
{selectedOption.label}
|
||||
</span>
|
||||
<span className={css({ fontSize: 'xs', color: 'gray.500', lineHeight: 'tight' })}>
|
||||
{selectedOption.description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Select.Value>
|
||||
<Select.Icon>
|
||||
<ChevronDown size={16} className={css({ color: 'gray.400' })} />
|
||||
</Select.Icon>
|
||||
</Select.Trigger>
|
||||
|
||||
<Select.Portal>
|
||||
<Select.Content
|
||||
className={css({
|
||||
bg: 'white',
|
||||
rounded: 'xl',
|
||||
shadow: 'modal',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
p: '2',
|
||||
zIndex: 999,
|
||||
minW: '320px',
|
||||
maxH: '300px',
|
||||
overflow: 'hidden'
|
||||
})}
|
||||
position="popper"
|
||||
sideOffset={4}
|
||||
>
|
||||
<Select.Viewport>
|
||||
{formatOptions.map((option) => (
|
||||
<Select.Item
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={css({
|
||||
px: '3',
|
||||
py: '3',
|
||||
rounded: 'lg',
|
||||
cursor: 'pointer',
|
||||
transition: 'all',
|
||||
outline: 'none',
|
||||
_hover: { bg: 'brand.50' },
|
||||
'&[data-state=checked]': {
|
||||
bg: 'brand.100',
|
||||
color: 'brand.800'
|
||||
}
|
||||
})}
|
||||
>
|
||||
<div className={hstack({ gap: '3', alignItems: 'center' })}>
|
||||
<span className={css({ fontSize: 'xl' })}>{option.icon}</span>
|
||||
<div className={stack({ gap: '1', alignItems: 'start', flex: 1 })}>
|
||||
<Select.ItemText className={css({
|
||||
fontWeight: 'medium',
|
||||
fontSize: 'sm'
|
||||
})}>
|
||||
{option.label}
|
||||
</Select.ItemText>
|
||||
<div className={css({
|
||||
fontSize: 'xs',
|
||||
color: 'gray.600',
|
||||
lineHeight: 'relaxed'
|
||||
})}>
|
||||
{option.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Viewport>
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
)
|
||||
}
|
||||
@@ -4,11 +4,11 @@ import { useState, useEffect } from 'react'
|
||||
import * as Progress from '@radix-ui/react-progress'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { stack, hstack } from '../../styled-system/patterns'
|
||||
import { FlashcardConfig } from '@/app/create/page'
|
||||
import { FlashcardConfig, FlashcardFormState } from '@/app/create/page'
|
||||
import { Sparkles, Zap, CheckCircle } from 'lucide-react'
|
||||
|
||||
interface GenerationProgressProps {
|
||||
config: FlashcardConfig
|
||||
config: FlashcardFormState
|
||||
}
|
||||
|
||||
interface ProgressStep {
|
||||
@@ -291,8 +291,8 @@ export function GenerationProgress({ config }: GenerationProgressProps) {
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
function getEstimatedCardCount(config: FlashcardConfig): number {
|
||||
const range = config.range
|
||||
function getEstimatedCardCount(config: FlashcardFormState): number {
|
||||
const range = config.range || '0-99' // Safe default for form state
|
||||
|
||||
if (range.includes('-')) {
|
||||
const [start, end] = range.split('-').map(n => parseInt(n) || 0)
|
||||
@@ -306,7 +306,7 @@ function getEstimatedCardCount(config: FlashcardConfig): number {
|
||||
return 1
|
||||
}
|
||||
|
||||
function getEstimatedTime(config: FlashcardConfig): number {
|
||||
function getEstimatedTime(config: FlashcardFormState): number {
|
||||
const cardCount = getEstimatedCardCount(config)
|
||||
const baseTime = 3 // Base generation time
|
||||
const cardTime = Math.max(cardCount * 0.1, 1)
|
||||
@@ -320,7 +320,7 @@ function getEstimatedTime(config: FlashcardConfig): number {
|
||||
return Math.round((baseTime + cardTime) * formatMultiplier)
|
||||
}
|
||||
|
||||
function getFunFact(config: FlashcardConfig): string {
|
||||
function getFunFact(config: FlashcardFormState): string {
|
||||
const facts = [
|
||||
'The soroban is a Japanese counting tool that dates back over 400 years!',
|
||||
'Master soroban users can calculate faster than electronic calculators.',
|
||||
|
||||
217
apps/web/src/components/ServerSorobanSVG.tsx
Normal file
217
apps/web/src/components/ServerSorobanSVG.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { css } from '../../styled-system/css'
|
||||
|
||||
interface ServerSorobanSVGProps {
|
||||
number: number
|
||||
colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating'
|
||||
hideInactiveBeads?: boolean
|
||||
beadShape?: 'diamond' | 'circle' | 'square'
|
||||
width?: number
|
||||
height?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ServerSorobanSVG({
|
||||
number,
|
||||
colorScheme = 'place-value',
|
||||
hideInactiveBeads = false,
|
||||
beadShape = 'diamond',
|
||||
width = 240,
|
||||
height = 320,
|
||||
className = ''
|
||||
}: ServerSorobanSVGProps) {
|
||||
const [svgContent, setSvgContent] = useState<string>('')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const generateSVG = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const config = {
|
||||
range: number.toString(),
|
||||
colorScheme,
|
||||
hideInactiveBeads,
|
||||
beadShape,
|
||||
format: 'svg'
|
||||
}
|
||||
|
||||
const response = await fetch('/api/preview', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// Find the SVG for our specific number
|
||||
const sample = data.samples?.find((s: any) => s.number === number)
|
||||
if (sample?.front) {
|
||||
setSvgContent(sample.front)
|
||||
} else {
|
||||
throw new Error('No SVG found for number')
|
||||
}
|
||||
} else {
|
||||
throw new Error('SVG generation failed')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to generate SVG for ${number}:`, err)
|
||||
setError('Unable to generate SVG')
|
||||
// Use fallback placeholder
|
||||
setSvgContent(generateFallbackSVG(number, width, height))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
generateSVG()
|
||||
}, [number, colorScheme, hideInactiveBeads, beadShape])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`${className} ${css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'gray.400',
|
||||
fontSize: 'sm'
|
||||
})}`}>
|
||||
<div className={css({
|
||||
w: '4',
|
||||
h: '4',
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.300',
|
||||
borderTopColor: 'transparent',
|
||||
rounded: 'full',
|
||||
animation: 'spin 1s linear infinite',
|
||||
mr: '2'
|
||||
})} />
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`${className} ${css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'gray.400',
|
||||
fontSize: 'sm',
|
||||
gap: '1'
|
||||
})}`}>
|
||||
<div className={css({ fontSize: '2xl' })}>🧮</div>
|
||||
<div>Soroban for {number}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Process the SVG to ensure proper scaling
|
||||
const processedSVG = svgContent ? processSVGForDisplay(svgContent, width, height) : ''
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
dangerouslySetInnerHTML={{ __html: processedSVG }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function processSVGForDisplay(svgContent: string, targetWidth: number, targetHeight: number): string {
|
||||
// Parse the SVG to fix dimensions and viewBox if needed
|
||||
try {
|
||||
// Extract current width, height, and viewBox
|
||||
const widthMatch = svgContent.match(/width="([^"]*)"/)
|
||||
const heightMatch = svgContent.match(/height="([^"]*)"/)
|
||||
const viewBoxMatch = svgContent.match(/viewBox="([^"]*)"/)
|
||||
|
||||
let processedSVG = svgContent
|
||||
|
||||
// If the SVG doesn't have proper dimensions, set them
|
||||
if (widthMatch && heightMatch) {
|
||||
const currentWidth = parseFloat(widthMatch[1])
|
||||
const currentHeight = parseFloat(heightMatch[1])
|
||||
|
||||
// Replace with target dimensions while preserving aspect ratio
|
||||
processedSVG = processedSVG
|
||||
.replace(/width="[^"]*"/, `width="${targetWidth}"`)
|
||||
.replace(/height="[^"]*"/, `height="${targetHeight}"`)
|
||||
|
||||
// Special handling for soroban SVGs generated by Typst
|
||||
// These have complex transforms that push content outside the original viewBox
|
||||
if (viewBoxMatch && processedSVG.includes('matrix(4 0 0 4 -37.5 -180)')) {
|
||||
// This is a generated soroban SVG with known transform issues
|
||||
// Let's analyze the actual content positioning more carefully
|
||||
|
||||
// The transforms are:
|
||||
// 1. translate(6 6) - base offset
|
||||
// 2. translate(41.5 14) - content positioning
|
||||
// 3. matrix(4 0 0 4 -37.5 -180) - scale 4x, translate -37.5, -180
|
||||
|
||||
// After matrix transform, the content coordinate (0,17) becomes:
|
||||
// x: (0 * 4) + (-37.5) = -37.5
|
||||
// y: (17 * 4) + (-180) = -112
|
||||
// Plus the initial translates: x: -37.5 + 41.5 + 6 = 10, y: -112 + 14 + 6 = -92
|
||||
|
||||
// So the actual top of content is around y = -92, and extends down ~290 units
|
||||
const actualContentTop = -92
|
||||
const contentHeight = 290
|
||||
|
||||
// Create a tight viewBox around the actual content
|
||||
const padding = 10 // Reduced padding
|
||||
const newX = -padding
|
||||
const newY = actualContentTop - padding // Start just above actual content
|
||||
const newWidth = currentWidth + (padding * 2)
|
||||
const newHeight = contentHeight + (padding * 2)
|
||||
|
||||
const newViewBox = `${newX} ${newY} ${newWidth} ${newHeight}`
|
||||
processedSVG = processedSVG.replace(/viewBox="[^"]*"/, `viewBox="${newViewBox}"`)
|
||||
} else if (viewBoxMatch) {
|
||||
// Standard viewBox adjustment for other SVGs
|
||||
const viewBoxValues = viewBoxMatch[1].split(' ').map(v => parseFloat(v))
|
||||
if (viewBoxValues.length === 4) {
|
||||
const [x, y, width, height] = viewBoxValues
|
||||
const paddingX = width * 0.05
|
||||
const paddingY = height * 0.08
|
||||
const newViewBox = `${x - paddingX} ${y - paddingY} ${width + (paddingX * 2)} ${height + (paddingY * 2)}`
|
||||
processedSVG = processedSVG.replace(/viewBox="[^"]*"/, `viewBox="${newViewBox}"`)
|
||||
}
|
||||
} else {
|
||||
// If no viewBox exists, create one with padding
|
||||
const paddingX = currentWidth * 0.05
|
||||
const paddingY = currentHeight * 0.08
|
||||
const newViewBox = `${-paddingX} ${-paddingY} ${currentWidth + (paddingX * 2)} ${currentHeight + (paddingY * 2)}`
|
||||
processedSVG = processedSVG.replace('<svg', `<svg viewBox="${newViewBox}"`)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the SVG has responsive attributes for proper scaling
|
||||
if (!processedSVG.includes('preserveAspectRatio')) {
|
||||
processedSVG = processedSVG.replace('<svg', '<svg preserveAspectRatio="xMidYMid meet"')
|
||||
}
|
||||
|
||||
return processedSVG
|
||||
} catch (error) {
|
||||
console.warn('Failed to process SVG:', error)
|
||||
return svgContent // Return original if processing fails
|
||||
}
|
||||
}
|
||||
|
||||
function generateFallbackSVG(number: number, width: number, height: number): string {
|
||||
// Simple fallback SVG showing the number
|
||||
return `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
|
||||
<rect x="10" y="10" width="${width-20}" height="${height-20}" fill="none" stroke="#8B4513" stroke-width="3"/>
|
||||
<line x1="15" y1="${height/2}" x2="${width-15}" y2="${height/2}" stroke="#8B4513" stroke-width="3"/>
|
||||
<text x="${width/2}" y="${height/2 + 40}" text-anchor="middle" font-size="24" fill="#666">
|
||||
${number}
|
||||
</text>
|
||||
</svg>`
|
||||
}
|
||||
156
apps/web/src/components/SorobanSVG.tsx
Normal file
156
apps/web/src/components/SorobanSVG.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import { css } from '../../styled-system/css'
|
||||
|
||||
interface SorobanSVGProps {
|
||||
number: number
|
||||
colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating'
|
||||
width?: number
|
||||
height?: number
|
||||
hideInactiveBeads?: boolean
|
||||
beadShape?: 'diamond' | 'circle' | 'square'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SorobanSVG({
|
||||
number,
|
||||
colorScheme = 'place-value',
|
||||
width = 240,
|
||||
height = 320,
|
||||
hideInactiveBeads = false,
|
||||
beadShape = 'diamond',
|
||||
className = ''
|
||||
}: SorobanSVGProps) {
|
||||
const svg = generateSorobanSVG({
|
||||
number,
|
||||
colorScheme,
|
||||
width,
|
||||
height,
|
||||
hideInactiveBeads,
|
||||
beadShape
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function generateSorobanSVG({
|
||||
number,
|
||||
colorScheme,
|
||||
width,
|
||||
height,
|
||||
hideInactiveBeads,
|
||||
beadShape
|
||||
}: {
|
||||
number: number
|
||||
colorScheme: string
|
||||
width: number
|
||||
height: number
|
||||
hideInactiveBeads: boolean
|
||||
beadShape: string
|
||||
}): string {
|
||||
const rodWidth = 4
|
||||
const beadSize = beadShape === 'circle' ? 10 : 8
|
||||
const heavenBeadHeight = 40
|
||||
const earthBeadHeight = 40
|
||||
|
||||
// Determine number of rods needed
|
||||
const numString = Math.abs(number).toString()
|
||||
const rods = Math.max(numString.length, 2) // At least 2 rods for display
|
||||
|
||||
// Adjust width based on number of rods
|
||||
const actualWidth = Math.max(width, 80 + rods * 50)
|
||||
|
||||
let svg = `<svg width="${actualWidth}" height="${height}" viewBox="0 0 ${actualWidth} ${height}" xmlns="http://www.w3.org/2000/svg">`
|
||||
|
||||
// Frame
|
||||
svg += `<rect x="10" y="10" width="${actualWidth-20}" height="${height-20}" fill="none" stroke="#8B4513" stroke-width="3"/>`
|
||||
|
||||
// Crossbar (divider between heaven and earth)
|
||||
const crossbarY = height / 2
|
||||
svg += `<line x1="15" y1="${crossbarY}" x2="${actualWidth-15}" y2="${crossbarY}" stroke="#8B4513" stroke-width="3"/>`
|
||||
|
||||
// Generate digits with proper padding
|
||||
const digits = numString.padStart(rods, '0').split('').map(d => parseInt(d))
|
||||
|
||||
for (let i = 0; i < rods; i++) {
|
||||
const rodX = 40 + i * 50
|
||||
const digit = digits[i]
|
||||
const placeValue = Math.pow(10, rods - 1 - i) // 100s, 10s, 1s place etc.
|
||||
|
||||
// Rod
|
||||
svg += `<line x1="${rodX}" y1="20" x2="${rodX}" y2="${height-20}" stroke="#654321" stroke-width="${rodWidth}"/>`
|
||||
|
||||
// Calculate bead positions for this digit
|
||||
const heavenValue = digit >= 5 ? 1 : 0
|
||||
const earthValue = digit % 5
|
||||
|
||||
// Get colors based on scheme and place value
|
||||
const colors = getBeadColors(colorScheme, placeValue)
|
||||
|
||||
// Heaven bead (worth 5)
|
||||
const heavenActive = heavenValue > 0
|
||||
const heavenY = heavenActive ? crossbarY - 15 : 30
|
||||
const heavenColor = heavenActive ? colors.heaven : (hideInactiveBeads ? 'transparent' : '#E5E5E5')
|
||||
const heavenStroke = heavenActive || !hideInactiveBeads ? '#333' : 'transparent'
|
||||
|
||||
if (heavenColor !== 'transparent') {
|
||||
svg += createBead(rodX, heavenY, beadSize, heavenColor, heavenStroke, beadShape)
|
||||
}
|
||||
|
||||
// Earth beads (worth 1 each)
|
||||
for (let j = 0; j < 4; j++) {
|
||||
const isActive = j < earthValue
|
||||
const earthY = crossbarY + 20 + j * 25
|
||||
const earthColor = isActive ? colors.earth : (hideInactiveBeads ? 'transparent' : '#E5E5E5')
|
||||
const earthStroke = isActive || !hideInactiveBeads ? '#333' : 'transparent'
|
||||
|
||||
if (earthColor !== 'transparent') {
|
||||
svg += createBead(rodX, earthY, beadSize, earthColor, earthStroke, beadShape)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svg += '</svg>'
|
||||
return svg
|
||||
}
|
||||
|
||||
function createBead(x: number, y: number, size: number, fill: string, stroke: string, shape: string): string {
|
||||
switch (shape) {
|
||||
case 'circle':
|
||||
return `<circle cx="${x}" cy="${y}" r="${size}" fill="${fill}" stroke="${stroke}" stroke-width="1"/>`
|
||||
case 'square':
|
||||
return `<rect x="${x - size}" y="${y - size}" width="${size * 2}" height="${size * 2}" fill="${fill}" stroke="${stroke}" stroke-width="1"/>`
|
||||
case 'diamond':
|
||||
default:
|
||||
const points = `${x},${y - size} ${x + size},${y} ${x},${y + size} ${x - size},${y}`
|
||||
return `<polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="1"/>`
|
||||
}
|
||||
}
|
||||
|
||||
function getBeadColors(colorScheme: string, placeValue: number): { heaven: string; earth: string } {
|
||||
switch (colorScheme) {
|
||||
case 'monochrome':
|
||||
return { heaven: '#666', earth: '#666' }
|
||||
|
||||
case 'heaven-earth':
|
||||
return { heaven: '#FF6B6B', earth: '#4ECDC4' }
|
||||
|
||||
case 'alternating':
|
||||
return placeValue % 2 === 0
|
||||
? { heaven: '#FF6B6B', earth: '#4ECDC4' }
|
||||
: { heaven: '#4ECDC4', earth: '#FF6B6B' }
|
||||
|
||||
case 'place-value':
|
||||
default:
|
||||
// Colors based on place value (ones, tens, hundreds, etc.)
|
||||
if (placeValue >= 1000) return { heaven: '#9B59B6', earth: '#8E44AD' } // Purple for thousands
|
||||
if (placeValue >= 100) return { heaven: '#E74C3C', earth: '#C0392B' } // Red for hundreds
|
||||
if (placeValue >= 10) return { heaven: '#3498DB', earth: '#2980B9' } // Blue for tens
|
||||
return { heaven: '#2ECC71', earth: '#27AE60' } // Green for ones
|
||||
}
|
||||
}
|
||||
236
apps/web/src/components/StyleControls.tsx
Normal file
236
apps/web/src/components/StyleControls.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
'use client'
|
||||
|
||||
import { FormApi } from '@tanstack/react-form'
|
||||
import * as Label from '@radix-ui/react-label'
|
||||
import * as RadioGroup from '@radix-ui/react-radio-group'
|
||||
import * as Switch from '@radix-ui/react-switch'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { stack, hstack, grid } from '../../styled-system/patterns'
|
||||
import { FlashcardFormState } from '@/app/create/page'
|
||||
|
||||
interface StyleControlsProps {
|
||||
form: FormApi<FlashcardFormState>
|
||||
}
|
||||
|
||||
export function StyleControls({ form }: StyleControlsProps) {
|
||||
return (
|
||||
<div className={stack({ gap: '4' })}>
|
||||
<FormField
|
||||
label="Color Scheme"
|
||||
description="Choose how colors are applied to beads"
|
||||
>
|
||||
<form.Field name="colorScheme">
|
||||
{(field) => (
|
||||
<RadioGroupField
|
||||
value={field.state.value || 'place-value'}
|
||||
onValueChange={(value) => field.handleChange(value as any)}
|
||||
options={[
|
||||
{ value: 'monochrome', label: 'Monochrome', desc: 'Classic black and white' },
|
||||
{ value: 'place-value', label: 'Place Value', desc: 'Colors by digit position' },
|
||||
{ value: 'heaven-earth', label: 'Heaven-Earth', desc: 'Different colors for 5s and 1s' },
|
||||
{ value: 'alternating', label: 'Alternating', desc: 'Alternating column colors' }
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Bead Shape"
|
||||
description="Choose the visual style of the beads"
|
||||
>
|
||||
<form.Field name="beadShape">
|
||||
{(field) => (
|
||||
<RadioGroupField
|
||||
value={field.state.value || 'diamond'}
|
||||
onValueChange={(value) => field.handleChange(value as any)}
|
||||
options={[
|
||||
{ value: 'diamond', label: '💎 Diamond', desc: 'Realistic 3D appearance' },
|
||||
{ value: 'circle', label: '⭕ Circle', desc: 'Traditional round beads' },
|
||||
{ value: 'square', label: '⬜ Square', desc: 'Modern geometric style' }
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
</FormField>
|
||||
|
||||
<div className={grid({ columns: 1, gap: '4' })}>
|
||||
<FormField
|
||||
label="Colored Numerals"
|
||||
description="Match numeral colors to bead colors"
|
||||
>
|
||||
<form.Field name="coloredNumerals">
|
||||
{(field) => (
|
||||
<SwitchField
|
||||
checked={field.state.value || false}
|
||||
onCheckedChange={field.handleChange}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Hide Inactive Beads"
|
||||
description="Show only active beads for clarity"
|
||||
>
|
||||
<form.Field name="hideInactiveBeads">
|
||||
{(field) => (
|
||||
<SwitchField
|
||||
checked={field.state.value || false}
|
||||
onCheckedChange={field.handleChange}
|
||||
/>
|
||||
)}
|
||||
</form.Field>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper Components
|
||||
function FormField({
|
||||
label,
|
||||
description,
|
||||
children
|
||||
}: {
|
||||
label: string
|
||||
description?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className={stack({ gap: '2' })}>
|
||||
<Label.Root className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
color: 'gray.900'
|
||||
})}>
|
||||
{label}
|
||||
</Label.Root>
|
||||
{description && (
|
||||
<p className={css({
|
||||
fontSize: 'xs',
|
||||
color: 'gray.600'
|
||||
})}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SwitchField({
|
||||
checked,
|
||||
onCheckedChange
|
||||
}: {
|
||||
checked: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<Switch.Root
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
className={css({
|
||||
w: '11',
|
||||
h: '6',
|
||||
bg: checked ? 'brand.600' : 'gray.300',
|
||||
rounded: 'full',
|
||||
position: 'relative',
|
||||
transition: 'all',
|
||||
cursor: 'pointer',
|
||||
_hover: { bg: checked ? 'brand.700' : 'gray.400' }
|
||||
})}
|
||||
>
|
||||
<Switch.Thumb
|
||||
className={css({
|
||||
display: 'block',
|
||||
w: '5',
|
||||
h: '5',
|
||||
bg: 'white',
|
||||
rounded: 'full',
|
||||
shadow: 'card',
|
||||
transition: 'transform 0.2s',
|
||||
transform: checked ? 'translateX(20px)' : 'translateX(0px)',
|
||||
willChange: 'transform'
|
||||
})}
|
||||
/>
|
||||
</Switch.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupField({
|
||||
value,
|
||||
onValueChange,
|
||||
options
|
||||
}: {
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
options: Array<{ value: string; label: string; desc?: string }>
|
||||
}) {
|
||||
return (
|
||||
<RadioGroup.Root
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
className={stack({ gap: '3' })}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<div key={option.value} className={hstack({ gap: '3', alignItems: 'start' })}>
|
||||
<RadioGroup.Item
|
||||
value={option.value}
|
||||
className={css({
|
||||
w: '5',
|
||||
h: '5',
|
||||
rounded: 'full',
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.300',
|
||||
bg: 'white',
|
||||
cursor: 'pointer',
|
||||
transition: 'all',
|
||||
_hover: { borderColor: 'brand.400' },
|
||||
'&[data-state=checked]': { borderColor: 'brand.600' },
|
||||
mt: '0.5'
|
||||
})}
|
||||
>
|
||||
<RadioGroup.Indicator
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
w: 'full',
|
||||
h: 'full',
|
||||
position: 'relative',
|
||||
_after: {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
w: '2',
|
||||
h: '2',
|
||||
rounded: 'full',
|
||||
bg: 'brand.600'
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</RadioGroup.Item>
|
||||
<div className={stack({ gap: '0.5', flex: 1 })}>
|
||||
<label className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
color: 'gray.900',
|
||||
cursor: 'pointer'
|
||||
})}>
|
||||
{option.label}
|
||||
</label>
|
||||
{option.desc && (
|
||||
<p className={css({
|
||||
fontSize: 'xs',
|
||||
color: 'gray.600',
|
||||
lineHeight: 'tight'
|
||||
})}>
|
||||
{option.desc}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup.Root>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
// Shared asset store for generated files
|
||||
// In production, this should be replaced with Redis or a database
|
||||
// File-based asset store for generated files
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const writeFile = promisify(fs.writeFile)
|
||||
const readFile = promisify(fs.readFile)
|
||||
const unlink = promisify(fs.unlink)
|
||||
const readdir = promisify(fs.readdir)
|
||||
const stat = promisify(fs.stat)
|
||||
|
||||
export interface StoredAsset {
|
||||
data: Buffer
|
||||
@@ -8,15 +16,89 @@ export interface StoredAsset {
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export const assetStore = new Map<string, StoredAsset>()
|
||||
// Use temp directory for storing generated assets
|
||||
const ASSETS_DIR = path.join(process.cwd(), '.tmp/assets')
|
||||
|
||||
// Ensure assets directory exists
|
||||
if (!fs.existsSync(ASSETS_DIR)) {
|
||||
fs.mkdirSync(ASSETS_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
export const assetStore = {
|
||||
async set(id: string, asset: StoredAsset): Promise<void> {
|
||||
const assetPath = path.join(ASSETS_DIR, `${id}.bin`)
|
||||
const metaPath = path.join(ASSETS_DIR, `${id}.meta.json`)
|
||||
|
||||
// Store binary data
|
||||
await writeFile(assetPath, asset.data)
|
||||
|
||||
// Store metadata
|
||||
const metadata = {
|
||||
filename: asset.filename,
|
||||
mimeType: asset.mimeType,
|
||||
createdAt: asset.createdAt.toISOString()
|
||||
}
|
||||
await writeFile(metaPath, JSON.stringify(metadata))
|
||||
console.log('💾 Asset stored to file:', assetPath)
|
||||
},
|
||||
|
||||
async get(id: string): Promise<StoredAsset | undefined> {
|
||||
const assetPath = path.join(ASSETS_DIR, `${id}.bin`)
|
||||
const metaPath = path.join(ASSETS_DIR, `${id}.meta.json`)
|
||||
|
||||
try {
|
||||
const data = await readFile(assetPath)
|
||||
const metaData = JSON.parse(await readFile(metaPath, 'utf-8'))
|
||||
|
||||
return {
|
||||
data,
|
||||
filename: metaData.filename,
|
||||
mimeType: metaData.mimeType,
|
||||
createdAt: new Date(metaData.createdAt)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('❌ Asset not found in file system:', assetPath)
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
|
||||
async keys(): Promise<string[]> {
|
||||
try {
|
||||
const files = await readdir(ASSETS_DIR)
|
||||
return files.filter(f => f.endsWith('.bin')).map(f => f.replace('.bin', ''))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
get size(): number {
|
||||
try {
|
||||
return fs.readdirSync(ASSETS_DIR).filter(f => f.endsWith('.bin')).length
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up old assets every hour
|
||||
setInterval(() => {
|
||||
const cutoff = new Date(Date.now() - 60 * 60 * 1000) // 1 hour ago
|
||||
const entries = Array.from(assetStore.entries())
|
||||
entries.forEach(([id, asset]) => {
|
||||
if (asset.createdAt < cutoff) {
|
||||
assetStore.delete(id)
|
||||
setInterval(async () => {
|
||||
const cutoff = Date.now() - 60 * 60 * 1000 // 1 hour ago
|
||||
try {
|
||||
const files = await readdir(ASSETS_DIR)
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.bin')) continue
|
||||
|
||||
const filePath = path.join(ASSETS_DIR, file)
|
||||
const stats = await stat(filePath)
|
||||
|
||||
if (stats.mtime.getTime() < cutoff) {
|
||||
const id = file.replace('.bin', '')
|
||||
await unlink(filePath).catch(() => {})
|
||||
await unlink(path.join(ASSETS_DIR, `${id}.meta.json`)).catch(() => {})
|
||||
console.log('🗑️ Cleaned up old asset:', id)
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('❌ Error cleaning up assets:', error)
|
||||
}
|
||||
}, 60 * 60 * 1000)
|
||||
Reference in New Issue
Block a user