feat: enable automatic live preview updates and improve abacus sizing

- Fix live preview to update automatically when config changes
- Use form.Subscribe to properly track form state changes
- Increase abacus scale factor from 2.0 to 4.0 for better visibility
- Optimize card dimensions (120pt x 160pt) for preview display
- Improve SVG CSS handling with proper scaling properties
- Add debug logging for preview update troubleshooting

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-09-14 10:01:14 -05:00
parent 83da1eb086
commit f680987ed6
3 changed files with 136 additions and 25 deletions

View File

@@ -10,6 +10,7 @@ import { LivePreview } from '@/components/LivePreview'
import { GenerationProgress } from '@/components/GenerationProgress'
import { DownloadCard } from '@/components/DownloadCard'
// Complete, validated configuration ready for generation
export interface FlashcardConfig {
range: string
step?: number
@@ -39,6 +40,66 @@ export interface FlashcardConfig {
format?: 'pdf' | 'html' | 'png' | 'svg'
}
// Partial form state during editing (may have undefined values)
export interface FlashcardFormState {
range?: string
step?: number
cardsPerPage?: number
paperSize?: 'us-letter' | 'a4' | 'a3' | 'a5'
orientation?: 'portrait' | 'landscape'
margins?: {
top?: string
bottom?: string
left?: string
right?: string
}
gutter?: string
shuffle?: boolean
seed?: number
showCutMarks?: boolean
showRegistration?: boolean
fontFamily?: string
fontSize?: string
columns?: string | number
showEmptyColumns?: boolean
hideInactiveBeads?: boolean
beadShape?: 'diamond' | 'circle' | 'square'
colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating'
coloredNumerals?: boolean
scaleFactor?: number
format?: 'pdf' | 'html' | 'png' | 'svg'
}
// Validation function to convert form state to complete config
function validateAndCompleteConfig(formState: FlashcardFormState): FlashcardConfig {
return {
// Required fields with defaults
range: formState.range || '0-99',
// Optional fields with defaults
step: formState.step ?? 1,
cardsPerPage: formState.cardsPerPage ?? 6,
paperSize: formState.paperSize ?? 'us-letter',
orientation: formState.orientation ?? 'portrait',
gutter: formState.gutter ?? '5mm',
shuffle: formState.shuffle ?? false,
seed: formState.seed,
showCutMarks: formState.showCutMarks ?? false,
showRegistration: formState.showRegistration ?? false,
fontFamily: formState.fontFamily ?? 'DejaVu Sans',
fontSize: formState.fontSize ?? '48pt',
columns: formState.columns ?? 'auto',
showEmptyColumns: formState.showEmptyColumns ?? false,
hideInactiveBeads: formState.hideInactiveBeads ?? false,
beadShape: formState.beadShape ?? 'diamond',
colorScheme: formState.colorScheme ?? 'place-value',
coloredNumerals: formState.coloredNumerals ?? false,
scaleFactor: formState.scaleFactor ?? 0.9,
format: formState.format ?? 'pdf',
margins: formState.margins
}
}
type GenerationStatus = 'idle' | 'generating' | 'success' | 'error'
interface GenerationResult {
@@ -58,7 +119,7 @@ export default function CreatePage() {
const [generationResult, setGenerationResult] = useState<GenerationResult | null>(null)
const [error, setError] = useState<string | null>(null)
const form = useForm<FlashcardConfig>({
const form = useForm<FlashcardFormState>({
defaultValues: {
range: '0-99',
step: 1,
@@ -82,11 +143,14 @@ export default function CreatePage() {
}
})
const handleGenerate = async (config: FlashcardConfig) => {
const handleGenerate = async (formState: FlashcardFormState) => {
setGenerationStatus('generating')
setError(null)
try {
// Validate and complete the configuration
const config = validateAndCompleteConfig(formState)
const response = await fetch('/api/generate', {
method: 'POST',
headers: {
@@ -203,7 +267,10 @@ export default function CreatePage() {
shadow: 'card',
p: '8'
})}>
<LivePreview config={form.state.values} />
<form.Subscribe
selector={(state) => state.values}
children={(values) => <LivePreview config={values} />}
/>
</div>
{/* Generation Status */}

View File

@@ -3,11 +3,11 @@
import { useState, useEffect, useMemo } from 'react'
import { css } from '../../styled-system/css'
import { stack, hstack, grid } from '../../styled-system/patterns'
import { FlashcardConfig } from '@/app/create/page'
import { FlashcardConfig, FlashcardFormState } from '@/app/create/page'
import { Eye, RefreshCw } from 'lucide-react'
interface LivePreviewProps {
config: FlashcardConfig
config: FlashcardFormState
}
interface PreviewData {
@@ -24,10 +24,16 @@ export function LivePreview({ config }: LivePreviewProps) {
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Debug: Log config changes
console.log('🔧 LivePreview config changed:', config)
// Debounced preview generation
const debouncedConfig = useDebounce(config, 1000)
const debouncedConfig = useDebounce(config, 500)
console.log('🕐 Debounced config:', debouncedConfig)
useEffect(() => {
console.log('🚀 useEffect triggered with debouncedConfig:', debouncedConfig)
const generatePreview = async () => {
setIsLoading(true)
setError(null)
@@ -50,6 +56,9 @@ export function LivePreview({ config }: LivePreviewProps) {
if (response.ok) {
const data = await response.json()
console.log('🔍 Preview data received:', data)
console.log('🔍 First sample SVG length:', data.samples?.[0]?.front?.length || 'No SVG')
console.log('🔍 First sample SVG preview:', data.samples?.[0]?.front?.substring(0, 100) || 'No SVG')
setPreviewData(data)
} else {
throw new Error('Preview generation failed')
@@ -117,9 +126,9 @@ export function LivePreview({ config }: LivePreviewProps) {
columns: { base: 1, md: 2, lg: 3 },
gap: '4'
})}>
{previewData.samples.map((card, i) => (
{previewData.samples.map((card) => (
<FlashcardPreview
key={i}
key={card.number}
number={card.number}
frontSvg={card.front}
backContent={card.back}
@@ -145,7 +154,7 @@ export function LivePreview({ config }: LivePreviewProps) {
Configuration Summary
</h4>
<div className={grid({ columns: 2, gap: '3' })}>
<ConfigSummaryItem label="Range" value={config.range} />
<ConfigSummaryItem label="Range" value={config.range || '0-99'} />
<ConfigSummaryItem label="Cards per page" value={config.cardsPerPage?.toString() || '6'} />
<ConfigSummaryItem label="Color scheme" value={config.colorScheme || 'place-value'} />
<ConfigSummaryItem label="Bead shape" value={config.beadShape || 'diamond'} />
@@ -166,6 +175,18 @@ function FlashcardPreview({
}) {
const [showBack, setShowBack] = useState(false)
// Reset to front when new SVG data comes in
useEffect(() => {
if (frontSvg && frontSvg.trim()) {
setShowBack(false)
}
}, [frontSvg])
// Debug logging (simple)
if (!frontSvg || !frontSvg.trim()) {
console.warn(`⚠️ No SVG for number ${number}`)
}
return (
<div
className={css({
@@ -229,11 +250,32 @@ function FlashcardPreview({
alignItems: 'center',
justifyContent: 'center'
})}>
{frontSvg ? (
<div
dangerouslySetInnerHTML={{ __html: frontSvg }}
className={css({ maxW: 'full', maxH: 'full' })}
/>
{frontSvg && frontSvg.trim() ? (
<div className={css({
maxW: 'full',
maxH: 'full',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden'
})}>
<div
dangerouslySetInnerHTML={{ __html: frontSvg }}
className={css({
maxW: 'full',
maxH: 'full',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'& svg': {
maxWidth: '100%',
maxHeight: '100%',
width: 'auto',
height: 'auto'
}
})}
/>
</div>
) : (
<SorobanPlaceholder number={number} />
)}
@@ -331,23 +373,25 @@ function useDebounce<T>(value: T, delay: number): T {
return debouncedValue
}
function getPreviewRange(range: string): string {
function getPreviewRange(range: string | undefined): string {
// For preview, limit to a few sample numbers
if (range.includes('-')) {
const [start] = range.split('-')
const safeRange = range || '0-99' // Default fallback for undefined range
if (safeRange.includes('-')) {
const [start] = safeRange.split('-')
const startNum = parseInt(start) || 0
return `${startNum}-${startNum + 2}`
}
if (range.includes(',')) {
const numbers = range.split(',').slice(0, 3)
if (safeRange.includes(',')) {
const numbers = safeRange.split(',').slice(0, 3)
return numbers.join(',')
}
return range
return safeRange
}
function getMockPreviewData(config: FlashcardConfig): PreviewData {
function getMockPreviewData(config: FlashcardFormState): PreviewData {
// Mock data for development/fallback
return {
count: 3,

View File

@@ -198,7 +198,7 @@ def generate_single_card_json(config_json):
if number is None:
return json.dumps({'error': 'Missing number parameter'})
# Build Typst config
# Build Typst config optimized for preview display
typst_config = {
'bead_shape': config.get('beadShape', 'diamond'),
'color_scheme': config.get('colorScheme', 'monochrome'),
@@ -208,11 +208,11 @@ def generate_single_card_json(config_json):
'show_empty_columns': config.get('showEmptyColumns', False),
'columns': config.get('columns', 'auto'),
'transparent': config.get('transparent', False),
'card_width': config.get('cardWidth', '3.5in'),
'card_height': config.get('cardHeight', '2.5in'),
'card_width': '120pt', # Smaller card for larger abacus
'card_height': '160pt', # Smaller card for larger abacus
'font_size': config.get('fontSize', '48pt'),
'font_family': config.get('fontFamily', 'DejaVu Sans'),
'scale_factor': config.get('scaleFactor', 1.0),
'scale_factor': config.get('scaleFactor', 4.0), # Much larger scale for preview visibility
}
# Generate in core package directory