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 */} + +
+ + + {(field) => ( + field.handleChange(e.target.value)} + placeholder="0-99" + className={inputStyles} + /> + )} + + + +
+ + + {(field) => ( + field.handleChange(parseInt(e.target.value))} + className={inputStyles} + /> + )} + + + + + + {(field) => ( + + )} + + +
+
+
+ + + {/* 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 ( +
+
+ Loading... +
+ ) + } + + 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(' + + + + ${number} + + ` +} \ 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 = `` + + // Frame + svg += `` + + // Crossbar (divider between heaven and earth) + const crossbarY = height / 2 + svg += `` + + // 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 += `` + + // 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 += '' + 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