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 * as Tooltip from '@radix-ui/react-tooltip'
|
||||||
import { css } from '@styled/css'
|
import { css } from '@styled/css'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
import { LayoutPreview } from './config-sidebar/LayoutPreview'
|
||||||
import { validateProblemSpace } from '../utils/validateProblemSpace'
|
import { validateProblemSpace } from '../utils/validateProblemSpace'
|
||||||
import type { ProblemSpaceValidation } from '../utils/validateProblemSpace'
|
import type { ProblemSpaceValidation } from '../utils/validateProblemSpace'
|
||||||
import { getDefaultColsForProblemsPerPage } from '../utils/layoutCalculations'
|
import { getDefaultColsForProblemsPerPage } from '../utils/layoutCalculations'
|
||||||
|
|
@ -25,7 +26,7 @@ interface OrientationPanelProps {
|
||||||
digitRange?: { min: number; max: number }
|
digitRange?: { min: number; max: number }
|
||||||
pAnyStart?: number
|
pAnyStart?: number
|
||||||
operator?: 'addition' | 'subtraction' | 'mixed'
|
operator?: 'addition' | 'subtraction' | 'mixed'
|
||||||
mode?: 'smart' | 'mastery'
|
mode?: 'custom' | 'mastery'
|
||||||
// Layout options
|
// Layout options
|
||||||
problemNumbers?: 'always' | 'never'
|
problemNumbers?: 'always' | 'never'
|
||||||
cellBorders?: 'always' | 'never'
|
cellBorders?: 'always' | 'never'
|
||||||
|
|
@ -49,7 +50,7 @@ export function OrientationPanel({
|
||||||
digitRange = { min: 2, max: 2 },
|
digitRange = { min: 2, max: 2 },
|
||||||
pAnyStart = 0,
|
pAnyStart = 0,
|
||||||
operator = 'addition',
|
operator = 'addition',
|
||||||
mode = 'smart',
|
mode = 'custom',
|
||||||
problemNumbers = 'always',
|
problemNumbers = 'always',
|
||||||
cellBorders = 'always',
|
cellBorders = 'always',
|
||||||
onProblemNumbersChange,
|
onProblemNumbersChange,
|
||||||
|
|
@ -188,250 +189,36 @@ export function OrientationPanel({
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<button
|
<LayoutPreview
|
||||||
type="button"
|
orientation="portrait"
|
||||||
data-action="select-portrait"
|
cols={
|
||||||
|
orientation === 'portrait'
|
||||||
|
? cols
|
||||||
|
: getDefaultColsForProblemsPerPage(15, 'portrait')
|
||||||
|
}
|
||||||
|
rows={
|
||||||
|
orientation === 'portrait'
|
||||||
|
? Math.ceil(problemsPerPage / cols)
|
||||||
|
: Math.ceil(15 / getDefaultColsForProblemsPerPage(15, 'portrait'))
|
||||||
|
}
|
||||||
onClick={() => handleOrientationChange('portrait')}
|
onClick={() => handleOrientationChange('portrait')}
|
||||||
className={css({
|
isSelected={orientation === 'portrait'}
|
||||||
display: 'flex',
|
/>
|
||||||
alignItems: 'center',
|
<LayoutPreview
|
||||||
gap: '1.5',
|
orientation="landscape"
|
||||||
flex: '1',
|
cols={
|
||||||
px: '2',
|
orientation === 'landscape'
|
||||||
py: '1.5',
|
? cols
|
||||||
border: '2px solid',
|
: getDefaultColsForProblemsPerPage(20, 'landscape')
|
||||||
borderColor:
|
}
|
||||||
orientation === 'portrait' ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
|
rows={
|
||||||
bg:
|
orientation === 'landscape'
|
||||||
orientation === 'portrait'
|
? Math.ceil(problemsPerPage / cols)
|
||||||
? isDark
|
: Math.ceil(20 / getDefaultColsForProblemsPerPage(20, 'landscape'))
|
||||||
? '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"
|
|
||||||
/>
|
|
||||||
<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"
|
|
||||||
onClick={() => handleOrientationChange('landscape')}
|
onClick={() => handleOrientationChange('landscape')}
|
||||||
className={css({
|
isSelected={orientation === 'landscape'}
|
||||||
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"
|
|
||||||
/>
|
|
||||||
<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>
|
||||||
</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 { css } from '@styled/css'
|
||||||
import { useTheme } from '@/contexts/ThemeContext'
|
import { useTheme } from '@/contexts/ThemeContext'
|
||||||
import type { DisplayRules } from '../../displayRules'
|
import type { DisplayRules } from '../../displayRules'
|
||||||
|
import { LayoutPreview } from './LayoutPreview'
|
||||||
import { ProblemPreview } from './ProblemPreview'
|
import { ProblemPreview } from './ProblemPreview'
|
||||||
|
|
||||||
export interface Tab {
|
export interface Tab {
|
||||||
|
|
@ -36,7 +37,7 @@ export const TABS: Tab[] = [
|
||||||
{
|
{
|
||||||
id: 'layout',
|
id: 'layout',
|
||||||
label: 'Layout',
|
label: 'Layout',
|
||||||
icon: '📐',
|
icon: 'preview',
|
||||||
subtitle: ({ orientation, problemsPerPage, cols, pages }) => {
|
subtitle: ({ orientation, problemsPerPage, cols, pages }) => {
|
||||||
if (!orientation || !problemsPerPage || !cols || !pages) return null
|
if (!orientation || !problemsPerPage || !cols || !pages) return null
|
||||||
const orientationLabel = orientation === 'portrait' ? 'Portrait' : 'Landscape'
|
const orientationLabel = orientation === 'portrait' ? 'Portrait' : 'Landscape'
|
||||||
|
|
@ -182,7 +183,8 @@ export function TabNavigation({
|
||||||
{TABS.map((tab) => {
|
{TABS.map((tab) => {
|
||||||
const icon = getTabIcon(tab)
|
const icon = getTabIcon(tab)
|
||||||
const subtitle = getTabSubtitle(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
|
// All operator symbols (+, −, ±) are ASCII characters that need larger font size
|
||||||
const isOperatorSymbol = tab.id === 'operator'
|
const isOperatorSymbol = tab.id === 'operator'
|
||||||
|
|
||||||
|
|
@ -240,7 +242,13 @@ export function TabNavigation({
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{showPreviewIcon ? (
|
{showLayoutPreview ? (
|
||||||
|
<LayoutPreview
|
||||||
|
orientation={orientation!}
|
||||||
|
cols={cols!}
|
||||||
|
rows={Math.ceil(problemsPerPage! / cols!)}
|
||||||
|
/>
|
||||||
|
) : showScaffoldingPreview ? (
|
||||||
<ProblemPreview
|
<ProblemPreview
|
||||||
displayRules={displayRules!}
|
displayRules={displayRules!}
|
||||||
resolvedDisplayRules={resolvedDisplayRules}
|
resolvedDisplayRules={resolvedDisplayRules}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue