Files
soroban-abacus-flashcards/apps/web/src/components/ConfigurationFormWithoutGenerate.tsx
Thomas Hallock 38d89592c9 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>
2025-09-14 11:08:41 -05:00

611 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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)'
}
})