feat: add dynamic layout preview component for orientation selection
Create LayoutPreview component: - Visual preview showing page orientation and problem grid - SVG-based rendering with proper aspect ratios - Full theme support (light/dark mode) - Can act as a button with onClick and isSelected props - Shows actual layout (cols × rows) or default for unselected orientation Update tab navigation: - Use LayoutPreview for layout button icon - Show actual worksheet layout in button preview - Pass pages count to layout button subtitle Update orientation selection: - Replace text labels and SVG icons with LayoutPreview - Buttons ARE the preview (no nested styling) - Show current layout if selected, defaults if not - Portrait: 3×5 (15 problems default), Landscape: 4×5 (20 problems default) - Clean, visual interface without text labels Benefits: - Instantly see what each orientation looks like - Preview matches actual worksheet layout - Consistent visual language across UI - More intuitive than text/emoji 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d470be2c3a
commit
8df62d6a45
|
|
@ -4,6 +4,7 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
|||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { css } from '@styled/css'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { LayoutPreview } from './config-sidebar/LayoutPreview'
|
||||
import { validateProblemSpace } from '../utils/validateProblemSpace'
|
||||
import type { ProblemSpaceValidation } from '../utils/validateProblemSpace'
|
||||
import { getDefaultColsForProblemsPerPage } from '../utils/layoutCalculations'
|
||||
|
|
@ -25,7 +26,7 @@ interface OrientationPanelProps {
|
|||
digitRange?: { min: number; max: number }
|
||||
pAnyStart?: number
|
||||
operator?: 'addition' | 'subtraction' | 'mixed'
|
||||
mode?: 'smart' | 'mastery'
|
||||
mode?: 'custom' | 'mastery'
|
||||
// Layout options
|
||||
problemNumbers?: 'always' | 'never'
|
||||
cellBorders?: 'always' | 'never'
|
||||
|
|
@ -49,7 +50,7 @@ export function OrientationPanel({
|
|||
digitRange = { min: 2, max: 2 },
|
||||
pAnyStart = 0,
|
||||
operator = 'addition',
|
||||
mode = 'smart',
|
||||
mode = 'custom',
|
||||
problemNumbers = 'always',
|
||||
cellBorders = 'always',
|
||||
onProblemNumbersChange,
|
||||
|
|
@ -188,250 +189,36 @@ export function OrientationPanel({
|
|||
},
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-action="select-portrait"
|
||||
<LayoutPreview
|
||||
orientation="portrait"
|
||||
cols={
|
||||
orientation === 'portrait'
|
||||
? cols
|
||||
: getDefaultColsForProblemsPerPage(15, 'portrait')
|
||||
}
|
||||
rows={
|
||||
orientation === 'portrait'
|
||||
? Math.ceil(problemsPerPage / cols)
|
||||
: Math.ceil(15 / getDefaultColsForProblemsPerPage(15, 'portrait'))
|
||||
}
|
||||
onClick={() => handleOrientationChange('portrait')}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1.5',
|
||||
flex: '1',
|
||||
px: '2',
|
||||
py: '1.5',
|
||||
border: '2px solid',
|
||||
borderColor:
|
||||
orientation === 'portrait' ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
|
||||
bg:
|
||||
orientation === 'portrait'
|
||||
? isDark
|
||||
? 'brand.900'
|
||||
: 'brand.50'
|
||||
: isDark
|
||||
? 'gray.700'
|
||||
: 'white',
|
||||
rounded: 'lg',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
justifyContent: 'center',
|
||||
minWidth: 0,
|
||||
_hover: {
|
||||
borderColor: 'brand.400',
|
||||
},
|
||||
'@media (max-width: 400px)': {
|
||||
px: '1.5',
|
||||
py: '1',
|
||||
gap: '1',
|
||||
},
|
||||
'@media (max-width: 200px)': {
|
||||
px: '1',
|
||||
py: '0.5',
|
||||
gap: '0.5',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{/* Portrait page icon */}
|
||||
<svg
|
||||
width="16"
|
||||
height="20"
|
||||
viewBox="0 0 16 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={css({
|
||||
flexShrink: 0,
|
||||
'@media (max-width: 300px)': {
|
||||
width: '12px',
|
||||
height: '16px',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<rect
|
||||
x="1"
|
||||
y="1"
|
||||
width="14"
|
||||
height="18"
|
||||
rx="1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
isSelected={orientation === 'portrait'}
|
||||
/>
|
||||
<line
|
||||
x1="3"
|
||||
y1="4"
|
||||
x2="13"
|
||||
y2="4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="3"
|
||||
y1="7"
|
||||
x2="13"
|
||||
y2="7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="3"
|
||||
y1="10"
|
||||
x2="10"
|
||||
y2="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
fontWeight: 'semibold',
|
||||
color:
|
||||
orientation === 'portrait'
|
||||
? isDark
|
||||
? 'brand.200'
|
||||
: 'brand.700'
|
||||
: isDark
|
||||
? 'gray.300'
|
||||
: 'gray.600',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
'@media (max-width: 200px)': {
|
||||
fontSize: '2xs',
|
||||
},
|
||||
'@media (max-width: 150px)': {
|
||||
display: 'none',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Portrait
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-action="select-landscape"
|
||||
<LayoutPreview
|
||||
orientation="landscape"
|
||||
cols={
|
||||
orientation === 'landscape'
|
||||
? cols
|
||||
: getDefaultColsForProblemsPerPage(20, 'landscape')
|
||||
}
|
||||
rows={
|
||||
orientation === 'landscape'
|
||||
? Math.ceil(problemsPerPage / cols)
|
||||
: Math.ceil(20 / getDefaultColsForProblemsPerPage(20, 'landscape'))
|
||||
}
|
||||
onClick={() => handleOrientationChange('landscape')}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1.5',
|
||||
flex: '1',
|
||||
px: '2',
|
||||
py: '1.5',
|
||||
border: '2px solid',
|
||||
borderColor:
|
||||
orientation === 'landscape' ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
|
||||
bg:
|
||||
orientation === 'landscape'
|
||||
? isDark
|
||||
? 'brand.900'
|
||||
: 'brand.50'
|
||||
: isDark
|
||||
? 'gray.700'
|
||||
: 'white',
|
||||
rounded: 'lg',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
justifyContent: 'center',
|
||||
minWidth: 0,
|
||||
_hover: {
|
||||
borderColor: 'brand.400',
|
||||
},
|
||||
'@media (max-width: 400px)': {
|
||||
px: '1.5',
|
||||
py: '1',
|
||||
gap: '1',
|
||||
},
|
||||
'@media (max-width: 200px)': {
|
||||
px: '1',
|
||||
py: '0.5',
|
||||
gap: '0.5',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{/* Landscape page icon */}
|
||||
<svg
|
||||
width="20"
|
||||
height="16"
|
||||
viewBox="0 0 20 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={css({
|
||||
flexShrink: 0,
|
||||
'@media (max-width: 300px)': {
|
||||
width: '16px',
|
||||
height: '12px',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<rect
|
||||
x="1"
|
||||
y="1"
|
||||
width="18"
|
||||
height="14"
|
||||
rx="1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
isSelected={orientation === 'landscape'}
|
||||
/>
|
||||
<line
|
||||
x1="3"
|
||||
y1="4"
|
||||
x2="17"
|
||||
y2="4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="3"
|
||||
y1="7"
|
||||
x2="17"
|
||||
y2="7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="3"
|
||||
y1="10"
|
||||
x2="13"
|
||||
y2="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
fontWeight: 'semibold',
|
||||
color:
|
||||
orientation === 'landscape'
|
||||
? isDark
|
||||
? 'brand.200'
|
||||
: 'brand.700'
|
||||
: isDark
|
||||
? 'gray.300'
|
||||
: 'gray.600',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
'@media (max-width: 200px)': {
|
||||
fontSize: '2xs',
|
||||
},
|
||||
'@media (max-width: 150px)': {
|
||||
display: 'none',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Landscape
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
'use client'
|
||||
|
||||
import { css } from '@styled/css'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
|
||||
interface LayoutPreviewProps {
|
||||
orientation: 'portrait' | 'landscape'
|
||||
cols: number
|
||||
rows: number
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
isSelected?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual preview of worksheet layout showing page orientation and problem grid
|
||||
* Can act as a button when onClick is provided
|
||||
*/
|
||||
export function LayoutPreview({
|
||||
orientation,
|
||||
cols,
|
||||
rows,
|
||||
className,
|
||||
onClick,
|
||||
isSelected = false,
|
||||
}: LayoutPreviewProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
// Page dimensions (aspect ratios)
|
||||
const pageAspect = orientation === 'portrait' ? 8.5 / 11 : 11 / 8.5
|
||||
|
||||
// Scale to fit in button (max dimensions)
|
||||
const maxSize = 48
|
||||
let pageWidth: number
|
||||
let pageHeight: number
|
||||
|
||||
if (orientation === 'portrait') {
|
||||
pageHeight = maxSize
|
||||
pageWidth = pageHeight * pageAspect
|
||||
} else {
|
||||
pageWidth = maxSize
|
||||
pageHeight = pageWidth / pageAspect
|
||||
}
|
||||
|
||||
// Problem cell dimensions with padding
|
||||
const padding = 3
|
||||
const cellWidth = (pageWidth - padding * 2) / cols
|
||||
const cellHeight = (pageHeight - padding * 2) / rows
|
||||
const cellPadding = 1
|
||||
|
||||
const svgContent = (
|
||||
<svg
|
||||
width={pageWidth}
|
||||
height={pageHeight}
|
||||
viewBox={`0 0 ${pageWidth} ${pageHeight}`}
|
||||
className={css({
|
||||
rounded: 'sm',
|
||||
})}
|
||||
style={{
|
||||
backgroundColor: isDark ? '#1f2937' : 'white',
|
||||
}}
|
||||
>
|
||||
{/* Problem grid */}
|
||||
{Array.from({ length: rows }).map((_, rowIdx) =>
|
||||
Array.from({ length: cols }).map((_, colIdx) => (
|
||||
<rect
|
||||
key={`${rowIdx}-${colIdx}`}
|
||||
x={padding + colIdx * cellWidth + cellPadding}
|
||||
y={padding + rowIdx * cellHeight + cellPadding}
|
||||
width={cellWidth - cellPadding * 2}
|
||||
height={cellHeight - cellPadding * 2}
|
||||
fill={isDark ? '#6b7280' : '#d1d5db'}
|
||||
rx={0.5}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
|
||||
// If used as a button, wrap in button element with styling
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '2px solid',
|
||||
borderColor: isSelected ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
|
||||
bg: isSelected ? (isDark ? 'brand.900' : 'brand.50') : isDark ? 'gray.700' : 'white',
|
||||
rounded: 'lg',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
p: '2',
|
||||
_hover: {
|
||||
borderColor: 'brand.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{svgContent}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Otherwise just return the SVG with border
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||
rounded: 'sm',
|
||||
display: 'inline-block',
|
||||
})}
|
||||
>
|
||||
{svgContent}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { css } from '@styled/css'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import type { DisplayRules } from '../../displayRules'
|
||||
import { LayoutPreview } from './LayoutPreview'
|
||||
import { ProblemPreview } from './ProblemPreview'
|
||||
|
||||
export interface Tab {
|
||||
|
|
@ -36,7 +37,7 @@ export const TABS: Tab[] = [
|
|||
{
|
||||
id: 'layout',
|
||||
label: 'Layout',
|
||||
icon: '📐',
|
||||
icon: 'preview',
|
||||
subtitle: ({ orientation, problemsPerPage, cols, pages }) => {
|
||||
if (!orientation || !problemsPerPage || !cols || !pages) return null
|
||||
const orientationLabel = orientation === 'portrait' ? 'Portrait' : 'Landscape'
|
||||
|
|
@ -182,7 +183,8 @@ export function TabNavigation({
|
|||
{TABS.map((tab) => {
|
||||
const icon = getTabIcon(tab)
|
||||
const subtitle = getTabSubtitle(tab)
|
||||
const showPreviewIcon = tab.icon === 'preview' && displayRules
|
||||
const showLayoutPreview = tab.id === 'layout' && orientation && problemsPerPage && cols
|
||||
const showScaffoldingPreview = tab.id === 'scaffolding' && displayRules
|
||||
// All operator symbols (+, −, ±) are ASCII characters that need larger font size
|
||||
const isOperatorSymbol = tab.id === 'operator'
|
||||
|
||||
|
|
@ -240,7 +242,13 @@ export function TabNavigation({
|
|||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
{showPreviewIcon ? (
|
||||
{showLayoutPreview ? (
|
||||
<LayoutPreview
|
||||
orientation={orientation!}
|
||||
cols={cols!}
|
||||
rows={Math.ceil(problemsPerPage! / cols!)}
|
||||
/>
|
||||
) : showScaffoldingPreview ? (
|
||||
<ProblemPreview
|
||||
displayRules={displayRules!}
|
||||
resolvedDisplayRules={resolvedDisplayRules}
|
||||
|
|
|
|||
Loading…
Reference in New Issue