From 38d89592c951ec9f015a18b767e82b565d068ec5 Mon Sep 17 00:00:00 2001
From: Thomas Hallock
Date: Sun, 14 Sep 2025 11:08:41 -0500
Subject: [PATCH] feat: add comprehensive soroban learning guide with
server-generated SVGs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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
---
apps/web/package.json | 15 +-
apps/web/src/app/api/generate/route.ts | 58 +-
apps/web/src/app/create/page.tsx | 280 ++++---
apps/web/src/app/guide/page.tsx | 681 ++++++++++++++++++
apps/web/src/app/page.tsx | 18 +-
apps/web/src/components/ConfigurationForm.tsx | 97 +--
.../ConfigurationFormWithoutGenerate.tsx | 611 ++++++++++++++++
apps/web/src/components/FormatSelectField.tsx | 141 ++++
.../web/src/components/GenerationProgress.tsx | 12 +-
apps/web/src/components/ServerSorobanSVG.tsx | 217 ++++++
apps/web/src/components/SorobanSVG.tsx | 156 ++++
apps/web/src/components/StyleControls.tsx | 236 ++++++
apps/web/src/lib/asset-store.ts | 102 ++-
13 files changed, 2364 insertions(+), 260 deletions(-)
create mode 100644 apps/web/src/app/guide/page.tsx
create mode 100644 apps/web/src/components/ConfigurationFormWithoutGenerate.tsx
create mode 100644 apps/web/src/components/FormatSelectField.tsx
create mode 100644 apps/web/src/components/ServerSorobanSVG.tsx
create mode 100644 apps/web/src/components/SorobanSVG.tsx
create mode 100644 apps/web/src/components/StyleControls.tsx
diff --git a/apps/web/package.json b/apps/web/package.json
index b2190881..75943771 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -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",
diff --git a/apps/web/src/app/api/generate/route.ts b/apps/web/src/app/api/generate/route.ts
index ea634b4e..ad5859cf 100644
--- a/apps/web/src/app/api/generate/route.ts
+++ b/apps/web/src/app/api/generate/route.ts
@@ -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({
diff --git a/apps/web/src/app/create/page.tsx b/apps/web/src/app/create/page.tsx
index 70ae7f2b..952f235f 100644
--- a/apps/web/src/app/create/page.tsx
+++ b/apps/web/src/app/create/page.tsx
@@ -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('idle')
- const [generationResult, setGenerationResult] = useState(null)
const [error, setError] = useState(null)
const form = useForm({
@@ -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() {
- Gallery
+ Guide
@@ -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
{/* Configuration Interface */}
- {/* Configuration Panel */}
+ {/* Main Configuration Panel */}
-
+
- {/* Preview & Generation Panel */}
-
- {/* Live Preview */}
-
+ {/* Style Controls Panel */}
+
+
+
+
+ 🎨 Visual Style
+
+
+ See changes instantly in the preview
+
+
+
+
state.values}
+ children={(values) => }
+ />
+
+
+
+ {/* Live Preview Panel */}
+
+
state.values}
children={(values) => }
/>
-
- {/* Generation Status */}
- {generationStatus === 'generating' && (
+ {/* Generate Button within Preview */}
-
-
- )}
-
- {/* Success Result */}
- {generationStatus === 'success' && generationResult && (
-
-
-
- )}
-
- {/* Error Display */}
- {generationStatus === 'error' && error && (
-
-
-
-
❌
-
- Generation Failed
-
+ {/* Generation Status */}
+ {generationStatus === 'generating' && (
+
+
-
- {error}
-
-
-
+ )}
+
+
- )}
+
+
+
+ {/* Error Display - moved to global level */}
+ {generationStatus === 'error' && error && (
+
+
+
+
❌
+
+ Generation Failed
+
+
+
+ {error}
+
+
+
+
+ )}
)
diff --git a/apps/web/src/app/guide/page.tsx b/apps/web/src/app/guide/page.tsx
new file mode 100644
index 00000000..d3c6b5c7
--- /dev/null
+++ b/apps/web/src/app/guide/page.tsx
@@ -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 (
+
+ {/* Header */}
+
+
+
+
+ 🧮 Soroban Generator
+
+
+
+
+ Create Flashcards
+
+
+
+
+
+
+ {/* Hero Section */}
+
+
+
+ 📚 Complete Soroban Mastery Guide
+
+
+ From basic reading to advanced arithmetic - everything you need to master the Japanese abacus
+
+
+
+
+ {/* Navigation Tabs */}
+
+
+
+
+
+
+
+
+
+ {/* Main Content */}
+
+
+ )
+}
+
+function ReadingNumbersGuide() {
+ return (
+
+ {/* Section Introduction */}
+
+
+ 🔍 Learning to Read Soroban Numbers
+
+
+ Master the fundamentals of reading numbers on the soroban with step-by-step visual tutorials
+
+
+
+ {/* Step 1: Basic Structure */}
+
+
+
+
+ 1
+
+
+ Understanding the Structure
+
+
+
+
+
+ The soroban consists of two main sections divided by a horizontal bar. Understanding this structure is fundamental to reading any number.
+
+
+
+
+
+ 🌅 Heaven Beads (Top)
+
+
+ - • Located above the horizontal bar
+ - • Each bead represents 5
+ - • Only one bead per column
+ - • When pushed down = active/counted
+
+
+
+
+
+ 🌍 Earth Beads (Bottom)
+
+
+ - • Located below the horizontal bar
+ - • Each bead represents 1
+ - • Four beads per column
+ - • When pushed up = active/counted
+
+
+
+
+
+
+ 💡 Key Concept: Active beads are those touching the horizontal bar
+
+
+
+
+
+
+ {/* Step 2: Single Digits */}
+
+
+
+
+ 2
+
+
+ Reading Single Digits (1-9)
+
+
+
+
+ Let's learn to read single digits by understanding how heaven and earth beads combine to represent numbers 1 through 9.
+
+
+
+ {[
+ { 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) => (
+
+
+ {example.num}
+
+
+ {/* Aspect ratio container for soroban - roughly 1:3 ratio */}
+
+
+
+
+
+ {example.desc}
+
+
+ ))}
+
+
+
+
+ {/* Step 3: Multi-digit Numbers */}
+
+
+
+
+ 3
+
+
+ Multi-Digit Numbers
+
+
+
+
+ Reading larger numbers is simply a matter of reading each column from left to right, with each column representing a different place value.
+
+
+
+
+ 📍 Reading Direction & Place Values
+
+
+
+
Reading Order:
+
+ - • Always read from LEFT to RIGHT
+ - • Each column is one digit
+ - • Combine digits to form the complete number
+
+
+
+
Place Values:
+
+ - • Rightmost = Ones (1s)
+ - • Next left = Tens (10s)
+ - • Continue for hundreds, thousands, etc.
+
+
+
+
+
+ {/* Multi-digit Examples */}
+
+
+ 🔢 Multi-Digit Examples
+
+
+
+ {[
+ { 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) => (
+
+
+ {example.num}
+
+
+ {/* Larger container for multi-digit numbers */}
+
+
+
+
+
+ {example.desc}
+
+
+ ))}
+
+
+
+
+
+ {/* Step 4: Practice Tips */}
+
+
+
+
+ 4
+
+
+ Practice Strategy
+
+
+
+
+
+
+ 🎯 Learning Tips
+
+
+ - • Start with single digits (0-9)
+ - • Practice identifying active vs. inactive beads
+ - • Work on speed recognition
+ - • Progress to multi-digit numbers gradually
+
+
+
+
+
+ ⚡ Quick Recognition
+
+
+ - • Numbers 1-4: Only earth beads
+ - • Number 5: Only heaven bead
+ - • Numbers 6-9: Heaven + earth beads
+ - • Zero: All beads away from bar
+
+
+
+
+
+
+ 🚀 Ready to Practice?
+
+
+ Test your newfound knowledge with interactive flashcards
+
+
+ Create Practice Flashcards →
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index ccc0ef35..d3824aeb 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -19,6 +19,20 @@ export default function HomePage() {
🧮 Soroban Generator
+
+ Guide
+
- 🖼️ View Examples
+ 📚 Learn Soroban
diff --git a/apps/web/src/components/ConfigurationForm.tsx b/apps/web/src/components/ConfigurationForm.tsx
index a990e628..f2dd5ec8 100644
--- a/apps/web/src/components/ConfigurationForm.tsx
+++ b/apps/web/src/components/ConfigurationForm.tsx
@@ -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
- onGenerate: (config: FlashcardConfig) => Promise
+ form: FormApi
+ onGenerate: (formState: FlashcardFormState) => Promise
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
- Customize every aspect of your soroban flashcards
+ Content, layout, and output settings
@@ -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
- {/* Appearance Tab */}
-
-
-
-
- {(field) => (
- 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' }
- ]}
- />
- )}
-
-
-
-
-
- {(field) => (
- 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' }
- ]}
- />
- )}
-
-
-
-
-
-
- {(field) => (
-
- )}
-
-
-
-
-
- {(field) => (
-
- )}
-
-
-
-
-
{/* Layout Tab */}
diff --git a/apps/web/src/components/ConfigurationFormWithoutGenerate.tsx b/apps/web/src/components/ConfigurationFormWithoutGenerate.tsx
new file mode 100644
index 00000000..6281f2f8
--- /dev/null
+++ b/apps/web/src/components/ConfigurationFormWithoutGenerate.tsx
@@ -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
+}
+
+export function ConfigurationFormWithoutGenerate({ form }: ConfigurationFormProps) {
+ return (
+
+
+
+ Configuration
+
+
+ Content, layout, and output settings
+
+
+
+
+ {(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 (
+
+
+ {[
+ { value: 'content', label: '📝 Content', icon: '🔢' },
+ { value: 'output', label: '💾 Output', icon: '💾' }
+ ].map((tab) => (
+
+ {tab.icon}
+ {tab.label}
+
+ ))}
+
+
+ {/* Content Tab */}
+
+
+
+
+
+ {/* Output Tab */}
+
+
+
+
+ {(field) => (
+ field.handleChange(value as any)}
+ />
+ )}
+
+
+
+ {/* PDF-Specific Options */}
+
+ {(formatField) => {
+ const isPdf = formatField.state.value === 'pdf'
+
+ return isPdf ? (
+
+
+
+
+
+ 📄 PDF Layout Options
+
+
+ Configure page layout and printing options for your PDF
+
+
+
+
+
+
+ {(field) => (
+ field.handleChange(value)}
+ min={1}
+ max={12}
+ step={1}
+ formatValue={(value) => `${value} cards`}
+ />
+ )}
+
+
+
+
+
+ {(field) => (
+ 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)' }
+ ]}
+ />
+ )}
+
+
+
+
+
+
+ {(field) => (
+ field.handleChange(value as any)}
+ options={[
+ { value: 'portrait', label: '📄 Portrait', desc: 'Taller than wide' },
+ { value: 'landscape', label: '📃 Landscape', desc: 'Wider than tall' }
+ ]}
+ />
+ )}
+
+
+
+
+
+
+ {(field) => (
+
+ )}
+
+
+
+
+
+ {(field) => (
+
+ )}
+
+
+
+
+
+
+ ) : null
+ }}
+
+
+
+
+ {(field) => (
+ field.handleChange(value)}
+ min={0.5}
+ max={1.0}
+ step={0.05}
+ formatValue={(value) => `${Math.round(value * 100)}%`}
+ />
+ )}
+
+
+
+
+
+ )
+ }}
+
+
+ )
+}
+
+// Helper Components
+function FormField({
+ label,
+ description,
+ children
+}: {
+ label: string
+ description?: string
+ children: React.ReactNode
+}) {
+ return (
+
+
+ {label}
+
+ {description && (
+
+ {description}
+
+ )}
+ {children}
+
+ )
+}
+
+function SwitchField({
+ checked,
+ onCheckedChange
+}: {
+ checked: boolean
+ onCheckedChange: (checked: boolean) => void
+}) {
+ return (
+
+
+
+ )
+}
+
+function RadioGroupField({
+ value,
+ onValueChange,
+ options
+}: {
+ value: string
+ onValueChange: (value: string) => void
+ options: Array<{ value: string; label: string; desc?: string }>
+}) {
+ return (
+
+ {options.map((option) => (
+
+
+
+
+
+
+ {option.desc && (
+
+ {option.desc}
+
+ )}
+
+
+ ))}
+
+ )
+}
+
+function SelectField({
+ value,
+ onValueChange,
+ options,
+ placeholder = "Select..."
+}: {
+ value: string
+ onValueChange: (value: string) => void
+ options: Array<{ value: string; label: string }>
+ placeholder?: string
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+ )
+}
+
+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 (
+
+
+
+ {formatValue(min)}
+
+
+ {formatValue(value[0])}
+
+
+ {formatValue(max)}
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+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)'
+ }
+})
\ No newline at end of file
diff --git a/apps/web/src/components/FormatSelectField.tsx b/apps/web/src/components/FormatSelectField.tsx
new file mode 100644
index 00000000..08ce7d77
--- /dev/null
+++ b/apps/web/src/components/FormatSelectField.tsx
@@ -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 (
+
+
+
+
+
{selectedOption.icon}
+
+
+ {selectedOption.label}
+
+
+ {selectedOption.description}
+
+
+
+
+
+
+
+
+
+
+
+
+ {formatOptions.map((option) => (
+
+
+
{option.icon}
+
+
+ {option.label}
+
+
+ {option.description}
+
+
+
+
+ ))}
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/GenerationProgress.tsx b/apps/web/src/components/GenerationProgress.tsx
index 46208332..08e48239 100644
--- a/apps/web/src/components/GenerationProgress.tsx
+++ b/apps/web/src/components/GenerationProgress.tsx
@@ -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.',
diff --git a/apps/web/src/components/ServerSorobanSVG.tsx b/apps/web/src/components/ServerSorobanSVG.tsx
new file mode 100644
index 00000000..7023e3bb
--- /dev/null
+++ b/apps/web/src/components/ServerSorobanSVG.tsx
@@ -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('')
+ const [isLoading, setIsLoading] = useState(true)
+ const [error, setError] = useState(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 (
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
🧮
+
Soroban for {number}
+
+ )
+ }
+
+ // Process the SVG to ensure proper scaling
+ const processedSVG = svgContent ? processSVGForDisplay(svgContent, width, height) : ''
+
+ return (
+
+ )
+}
+
+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('`
+}
\ No newline at end of file
diff --git a/apps/web/src/components/SorobanSVG.tsx b/apps/web/src/components/SorobanSVG.tsx
new file mode 100644
index 00000000..269a4677
--- /dev/null
+++ b/apps/web/src/components/SorobanSVG.tsx
@@ -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 (
+
+ )
+}
+
+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 = `'
+ return svg
+}
+
+function createBead(x: number, y: number, size: number, fill: string, stroke: string, shape: string): string {
+ switch (shape) {
+ case 'circle':
+ return ``
+ case 'square':
+ return ``
+ case 'diamond':
+ default:
+ const points = `${x},${y - size} ${x + size},${y} ${x},${y + size} ${x - size},${y}`
+ return ``
+ }
+}
+
+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
+ }
+}
\ No newline at end of file
diff --git a/apps/web/src/components/StyleControls.tsx b/apps/web/src/components/StyleControls.tsx
new file mode 100644
index 00000000..cc2d4596
--- /dev/null
+++ b/apps/web/src/components/StyleControls.tsx
@@ -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
+}
+
+export function StyleControls({ form }: StyleControlsProps) {
+ return (
+
+
+
+ {(field) => (
+ 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' }
+ ]}
+ />
+ )}
+
+
+
+
+
+ {(field) => (
+ 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' }
+ ]}
+ />
+ )}
+
+
+
+
+
+
+ {(field) => (
+
+ )}
+
+
+
+
+
+ {(field) => (
+
+ )}
+
+
+
+
+ )
+}
+
+// Helper Components
+function FormField({
+ label,
+ description,
+ children
+}: {
+ label: string
+ description?: string
+ children: React.ReactNode
+}) {
+ return (
+
+
+ {label}
+
+ {description && (
+
+ {description}
+
+ )}
+ {children}
+
+ )
+}
+
+function SwitchField({
+ checked,
+ onCheckedChange
+}: {
+ checked: boolean
+ onCheckedChange: (checked: boolean) => void
+}) {
+ return (
+
+
+
+ )
+}
+
+function RadioGroupField({
+ value,
+ onValueChange,
+ options
+}: {
+ value: string
+ onValueChange: (value: string) => void
+ options: Array<{ value: string; label: string; desc?: string }>
+}) {
+ return (
+
+ {options.map((option) => (
+
+
+
+
+
+
+ {option.desc && (
+
+ {option.desc}
+
+ )}
+
+
+ ))}
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/lib/asset-store.ts b/apps/web/src/lib/asset-store.ts
index effb839a..e246c27c 100644
--- a/apps/web/src/lib/asset-store.ts
+++ b/apps/web/src/lib/asset-store.ts
@@ -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()
+// 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 {
+ 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 {
+ 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 {
+ 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)
\ No newline at end of file