feat: create shared EditorComponents library for tutorial UI consistency

Add comprehensive shared component library containing:
- EditorLayout: Consistent header/close/delete layout for all editors
- FormGroup: Responsive grid layout for form elements
- TextInput/NumberInput: Standardized form inputs with validation styling
- CompactStepItem: Ultra-compact step display component for tight layouts
- BetweenStepAdd: Elegant hover-based add functionality between steps
- Button: Reusable button component with consistent styling
- GridLayout: Flexible grid system for responsive layouts

This establishes a unified design system across concept and practice step editors while dramatically reducing code duplication and improving maintainability.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-09-21 11:45:50 -05:00
parent c470bd5575
commit 4991a91c7d
2 changed files with 1101 additions and 0 deletions

View File

@@ -0,0 +1,640 @@
'use client'
import { ReactNode, useState } from 'react'
import { css } from '../../../../styled-system/css'
import { hstack, vstack } from '../../../../styled-system/patterns'
// Shared input styles
export const inputStyles = {
w: 'full',
px: 2,
py: 1,
border: '1px solid',
borderColor: 'gray.300',
rounded: 'sm',
fontSize: 'xs'
} as const
export const labelStyles = {
fontSize: 'xs',
color: 'gray.600',
mb: 1,
display: 'block'
} as const
// Shared Editor Layout Component - wraps entire editor with consistent styling
interface EditorLayoutProps {
title: string
onClose: () => void
onDelete?: () => void
deleteLabel?: string
children: ReactNode
className?: string
}
export function EditorLayout({ title, onClose, onDelete, deleteLabel = 'Delete', children, className }: EditorLayoutProps) {
return (
<div className={css({
p: 3,
bg: 'purple.50',
border: '1px solid',
borderColor: 'purple.200',
rounded: 'lg',
height: '100%',
overflowY: 'auto'
}, className)}>
<div className={vstack({ gap: 3, alignItems: 'stretch' })}>
{/* Header */}
<EditorHeader
title={title}
onClose={onClose}
onDelete={onDelete}
deleteLabel={deleteLabel}
/>
{/* Content */}
{children}
</div>
</div>
)
}
// Shared Editor Header Component
interface EditorHeaderProps {
title: string
onClose: () => void
onDelete?: () => void
deleteLabel?: string
}
export function EditorHeader({ title, onClose, onDelete, deleteLabel = 'Delete' }: EditorHeaderProps) {
return (
<div className={hstack({ justifyContent: 'space-between', alignItems: 'center' })}>
<h3 className={css({
fontSize: 'md',
fontWeight: 'semibold',
color: 'purple.800'
})}>
{title}
</h3>
<div className={hstack({ gap: 1 })}>
{onDelete && (
<button
onClick={onDelete}
className={css({
px: 2,
py: 1,
bg: 'red.500',
color: 'white',
rounded: 'sm',
fontSize: 'xs',
cursor: 'pointer',
_hover: { bg: 'red.600' }
})}
>
{deleteLabel}
</button>
)}
<button
onClick={onClose}
className={css({
p: 1,
borderRadius: 'sm',
cursor: 'pointer',
_hover: { bg: 'gray.100' }
})}
>
</button>
</div>
</div>
)
}
// Shared Field Component
interface FieldProps {
label: string
children: ReactNode
}
export function Field({ label, children }: FieldProps) {
return (
<div>
<label className={css(labelStyles)}>
{label}
</label>
{children}
</div>
)
}
// Shared Text Input Component
interface TextInputProps {
label: string
value: string
onChange: (value: string) => void
placeholder?: string
multiline?: boolean
rows?: number
}
export function TextInput({ label, value, onChange, placeholder, multiline = false, rows = 2 }: TextInputProps) {
return (
<Field label={label}>
{multiline ? (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
rows={rows}
className={css({
...inputStyles,
resize: 'none'
})}
placeholder={placeholder}
/>
) : (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
className={css(inputStyles)}
placeholder={placeholder}
/>
)}
</Field>
)
}
// Shared Number Input Component
interface NumberInputProps {
label: string
value: number | string
onChange: (value: number) => void
min?: number
max?: number
placeholder?: string
}
export function NumberInput({ label, value, onChange, min, max, placeholder }: NumberInputProps) {
return (
<Field label={label}>
<input
type="number"
min={min}
max={max}
value={value}
onChange={(e) => onChange(parseInt(e.target.value) || 0)}
className={css(inputStyles)}
placeholder={placeholder}
/>
</Field>
)
}
// Shared Grid Layout Component
interface GridLayoutProps {
columns: 1 | 2 | 3 | 4
gap?: number
children: ReactNode
}
export function GridLayout({ columns, gap = 2, children }: GridLayoutProps) {
const gridCols = {
1: '1fr',
2: '1fr 1fr',
3: '1fr 1fr 1fr',
4: '1fr 1fr 1fr 1fr'
}
return (
<div className={css({
display: 'grid',
gridTemplateColumns: gridCols[columns],
gap: gap
})}>
{children}
</div>
)
}
// Shared Section Component
interface SectionProps {
title?: string
children: ReactNode
collapsible?: boolean
defaultOpen?: boolean
background?: 'white' | 'gray' | 'none'
}
export function Section({ title, children, background = 'white' }: SectionProps) {
const bgStyles = {
white: { p: 2, bg: 'white', border: '1px solid', borderColor: 'gray.200', rounded: 'md' },
gray: { p: 2, bg: 'gray.50', border: '1px solid', borderColor: 'gray.200', rounded: 'sm' },
none: {}
}
return (
<div className={css(bgStyles[background])}>
{title && (
<h4 className={css({
fontSize: 'sm',
fontWeight: 'medium',
mb: 2,
color: 'gray.800'
})}>
{title}
</h4>
)}
{children}
</div>
)
}
// Shared Form Group Component - handles common form layouts
interface FormGroupProps {
children: ReactNode
columns?: 1 | 2 | 3
gap?: number
}
export function FormGroup({ children, columns = 1, gap = 2 }: FormGroupProps) {
if (columns === 1) {
return (
<div className={vstack({ gap: gap, alignItems: 'stretch' })}>
{children}
</div>
)
}
return (
<GridLayout columns={columns} gap={gap}>
{children}
</GridLayout>
)
}
// Shared Compact Step Item Component
interface CompactStepItemProps {
type: 'concept' | 'practice'
index: number
title: string
subtitle?: string
description?: string
isSelected?: boolean
hasErrors?: boolean
hasWarnings?: boolean
errorCount?: number
warningCount?: number
onClick?: () => void
onPreview?: () => void
onDelete?: () => void
children?: ReactNode
// Hover-add functionality
onAddStepBefore?: () => void
onAddPracticeStepBefore?: () => void
onAddStepAfter?: () => void
onAddPracticeStepAfter?: () => void
}
export function CompactStepItem({
type,
index,
title,
subtitle,
description,
isSelected = false,
hasErrors = false,
hasWarnings = false,
errorCount = 0,
warningCount = 0,
onClick,
onPreview,
onDelete,
children,
onAddStepBefore,
onAddPracticeStepBefore,
onAddStepAfter,
onAddPracticeStepAfter
}: CompactStepItemProps) {
const getBorderColor = () => {
if (isSelected) return 'blue.500'
if (hasErrors) return 'red.300'
return 'gray.200'
}
const getBgColor = () => {
if (isSelected) return 'blue.50'
if (hasWarnings) return 'yellow.50'
if (hasErrors) return 'red.50'
return 'white'
}
const getHoverBg = () => {
return isSelected ? 'blue.100' : 'gray.50'
}
const typeIcon = type === 'concept' ? '📝' : '🎯'
const typeLabel = type === 'concept' ? 'Step' : 'Practice'
const hasAddActions = onAddStepBefore || onAddPracticeStepBefore || onAddStepAfter || onAddPracticeStepAfter
return (
<div className={css({ position: 'relative' })}>
{/* Main step item */}
<div
onClick={onClick}
className={css({
px: 1.5,
py: 1,
border: '1px solid',
borderColor: getBorderColor(),
borderRadius: 'sm',
bg: getBgColor(),
cursor: onClick ? 'pointer' : 'default',
_hover: onClick ? { bg: getHoverBg() } : {}
})}
>
<div className={hstack({ justifyContent: 'space-between', alignItems: 'center', gap: 2 })}>
<div className={css({ flex: 1, minWidth: 0 })}>
{/* Inline header: badges, title, and subtitle on same line when possible */}
<div className={hstack({ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' })}>
<span className={css({
fontSize: 'xs',
fontWeight: 'bold',
color: type === 'concept' ? 'blue.800' : 'purple.800',
bg: type === 'concept' ? 'blue.100' : 'purple.100',
px: 0.5,
py: 0.5,
borderRadius: 'xs',
flexShrink: 0
})}>
{typeIcon} {typeLabel} {index + 1}
</span>
{/* Title inline */}
<div className={css({
fontWeight: 'medium',
fontSize: 'xs',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
minWidth: 0
})}>
{title}
</div>
{/* Error/warning badges - compact */}
{hasErrors && (
<span className={css({
fontSize: 'xs',
color: 'red.600',
bg: 'red.100',
px: 1,
py: 0.5,
borderRadius: 'xs',
flexShrink: 0
})}>
{errorCount}
</span>
)}
{hasWarnings && (
<span className={css({
fontSize: 'xs',
color: 'yellow.600',
bg: 'yellow.100',
px: 1,
py: 0.5,
borderRadius: 'xs',
flexShrink: 0
})}>
{warningCount}
</span>
)}
</div>
{/* Subtitle on separate line if provided */}
{subtitle && (
<div className={css({
fontSize: 'xs',
color: 'gray.600',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
mt: 0.5
})}>
{subtitle}
</div>
)}
{/* Custom children content */}
{children}
</div>
{/* Action buttons */}
<div className={hstack({ gap: 1, flexShrink: 0 })}>
{onPreview && (
<button
onClick={(e) => {
e.stopPropagation()
onPreview()
}}
className={css({
p: 1,
bg: 'blue.100',
color: 'blue.700',
border: '1px solid',
borderColor: 'blue.300',
borderRadius: 'sm',
fontSize: 'xs',
cursor: 'pointer',
_hover: { bg: 'blue.200' }
})}
title="Preview"
>
👁
</button>
)}
{onDelete && (
<button
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
className={css({
p: 1,
bg: 'red.100',
color: 'red.700',
border: '1px solid',
borderColor: 'red.300',
borderRadius: 'sm',
fontSize: 'xs',
cursor: 'pointer',
_hover: { bg: 'red.200' }
})}
title="Delete"
>
🗑
</button>
)}
</div>
</div>
</div>
</div>
)
}
// Between-step hover area with dropdown
interface BetweenStepAddProps {
onAddStep: () => void
onAddPracticeStep: () => void
}
export function BetweenStepAdd({ onAddStep, onAddPracticeStep }: BetweenStepAddProps) {
const [isOpen, setIsOpen] = useState(false)
return (
<div className={css({
position: 'relative',
height: '16px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
'&:hover .add-button': {
opacity: 0.5
}
})}>
<button
onClick={() => setIsOpen(!isOpen)}
className={`${css({
px: 2,
py: 0.5,
bg: 'gray.100',
color: 'gray.600',
border: '1px dashed',
borderColor: 'gray.300',
borderRadius: 'sm',
fontSize: 'xs',
cursor: 'pointer',
opacity: 0,
transition: 'opacity 0.2s ease',
zIndex: 30,
position: 'relative',
_hover: {
opacity: '1 !important',
bg: 'gray.200',
borderColor: 'gray.400'
}
})} add-button`}
>
+ New
</button>
{isOpen && (
<div className={css({
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
mt: 1,
bg: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: 'md',
shadow: 'md',
zIndex: 20,
minW: '150px'
})}>
<button
onClick={() => {
onAddStep()
setIsOpen(false)
}}
className={css({
w: 'full',
px: 3,
py: 2,
textAlign: 'left',
fontSize: 'sm',
cursor: 'pointer',
_hover: { bg: 'blue.50', color: 'blue.700' },
borderBottom: '1px solid',
borderColor: 'gray.100'
})}
>
📝 Concept Step
</button>
<button
onClick={() => {
onAddPracticeStep()
setIsOpen(false)
}}
className={css({
w: 'full',
px: 3,
py: 2,
textAlign: 'left',
fontSize: 'sm',
cursor: 'pointer',
_hover: { bg: 'purple.50', color: 'purple.700' }
})}
>
🎯 Problem Page
</button>
</div>
)}
</div>
)
}
// Shared Button Component
interface ButtonProps {
children: ReactNode
onClick: () => void
variant?: 'primary' | 'secondary' | 'outline'
size?: 'xs' | 'sm' | 'md'
disabled?: boolean
title?: string
}
export function Button({
children,
onClick,
variant = 'secondary',
size = 'sm',
disabled = false,
title
}: ButtonProps) {
const variantStyles = {
primary: { bg: 'blue.500', color: 'white', _hover: { bg: 'blue.600' } },
secondary: { bg: 'blue.100', color: 'blue.800', border: '1px solid', borderColor: 'blue.300', _hover: { bg: 'blue.200' } },
outline: { bg: 'gray.100', color: 'gray.700', border: '1px solid', borderColor: 'gray.300', _hover: { bg: 'gray.200' } }
}
const sizeStyles = {
xs: { px: 1, py: 1, fontSize: 'xs' },
sm: { px: 2, py: 1, fontSize: 'xs' },
md: { px: 3, py: 2, fontSize: 'sm' }
}
return (
<button
onClick={onClick}
disabled={disabled}
title={title}
className={css({
...variantStyles[variant],
...sizeStyles[size],
rounded: 'sm',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
textAlign: 'center'
})}
>
{children}
</button>
)
}

View File

@@ -0,0 +1,461 @@
@layer utilities {
.p_3 {
padding: var(--spacing-3)
}
.bg_purple\.50 {
background: var(--colors-purple-50)
}
.border_purple\.200 {
border-color: var(--colors-purple-200)
}
.rounded_lg {
border-radius: var(--radii-lg)
}
.h_100\% {
height: 100%
}
.overflow-y_auto {
overflow-y: auto
}
.bottom_-8px {
bottom: -8px
}
.left_0 {
left: var(--spacing-0)
}
.right_0 {
right: var(--spacing-0)
}
.z_10 {
z-index: 10
}
.text_purple\.700 {
color: var(--colors-purple-700)
}
.border_none {
border: var(--borders-none)
}
.fs_md {
font-size: var(--font-sizes-md)
}
.font_semibold {
font-weight: var(--font-weights-semibold)
}
.bg_red\.500 {
background: var(--colors-red-500)
}
.text_white {
color: var(--colors-white)
}
.mb_1 {
margin-bottom: var(--spacing-1)
}
.d_block {
display: block
}
.resize_none {
resize: none
}
.d_grid {
display: grid
}
.p_2 {
padding: var(--spacing-2)
}
.mb_2 {
margin-bottom: var(--spacing-2)
}
.text_gray\.800 {
color: var(--colors-gray-800)
}
.cursor_default {
cursor: default
}
.px_1\.5 {
padding-inline: var(--spacing-1\.5)
}
.text_purple\.800 {
color: var(--colors-purple-800)
}
.bg_purple\.100 {
background: var(--colors-purple-100)
}
.font_bold {
font-weight: var(--font-weights-bold)
}
.px_0\.5 {
padding-inline: var(--spacing-0\.5)
}
.font_medium {
font-weight: var(--font-weights-medium)
}
.flex_1 {
flex: 1 1 0%
}
.min-w_0 {
min-width: var(--sizes-0)
}
.text_red\.600 {
color: var(--colors-red-600)
}
.text_yellow\.600 {
color: var(--colors-yellow-600)
}
.bg_yellow\.100 {
background: var(--colors-yellow-100)
}
.px_1 {
padding-inline: var(--spacing-1)
}
.rounded_xs {
border-radius: var(--radii-xs)
}
.overflow_hidden {
overflow: hidden
}
.text_ellipsis {
text-overflow: ellipsis
}
.white-space_nowrap {
white-space: nowrap
}
.mt_0\.5 {
margin-top: var(--spacing-0\.5)
}
.text_blue\.700 {
color: var(--colors-blue-700)
}
.p_1 {
padding: var(--spacing-1)
}
.bg_red\.100 {
background: var(--colors-red-100)
}
.text_red\.700 {
color: var(--colors-red-700)
}
.border_red\.300 {
border-color: var(--colors-red-300)
}
.h_16px {
height: 16px
}
.justify_center {
justify-content: center
}
.py_0\.5 {
padding-block: var(--spacing-0\.5)
}
.bg_gray\.100 {
background: var(--colors-gray-100)
}
.text_gray\.600 {
color: var(--colors-gray-600)
}
.border_1px_dashed {
border: 1px dashed
}
.border_gray\.300 {
border-color: var(--colors-gray-300)
}
.opacity_0 {
opacity: 0
}
.transition_opacity_0\.2s_ease {
transition: opacity 0.2s ease
}
.z_30 {
z-index: 30
}
.pos_relative {
position: relative
}
.pos_absolute {
position: absolute
}
.top_100\% {
top: 100%
}
.left_50\% {
left: 50%
}
.transform_translateX\(-50\%\) {
transform: translateX(-50%)
}
.mt_1 {
margin-top: var(--spacing-1)
}
.bg_white {
background: var(--colors-white)
}
.border_gray\.200 {
border-color: var(--colors-gray-200)
}
.rounded_md {
border-radius: var(--radii-md)
}
.shadow_md {
box-shadow: var(--shadows-md)
}
.z_20 {
z-index: 20
}
.min-w_150px {
min-width: 150px
}
.border-b_1px_solid {
border-bottom: 1px solid
}
.border_gray\.100 {
border-color: var(--colors-gray-100)
}
.w_full {
width: var(--sizes-full)
}
.px_3 {
padding-inline: var(--spacing-3)
}
.py_2 {
padding-block: var(--spacing-2)
}
.text_left {
text-align: left
}
.fs_sm {
font-size: var(--font-sizes-sm)
}
.cursor_not-allowed {
cursor: not-allowed
}
.cursor_pointer {
cursor: pointer
}
.opacity_0\.5 {
opacity: 0.5
}
.opacity_1 {
opacity: 1
}
.bg_blue\.100 {
background: var(--colors-blue-100)
}
.text_blue\.800 {
color: var(--colors-blue-800)
}
.border_1px_solid {
border: 1px solid
}
.border_blue\.300 {
border-color: var(--colors-blue-300)
}
.px_2 {
padding-inline: var(--spacing-2)
}
.py_1 {
padding-block: var(--spacing-1)
}
.fs_xs {
font-size: var(--font-sizes-xs)
}
.rounded_sm {
border-radius: var(--radii-sm)
}
.text_center {
text-align: center
}
.columns_1 {
columns: 1
}
.gap_3 {
gap: var(--spacing-3)
}
.items_stretch {
align-items: stretch
}
.flex_column {
flex-direction: column
}
.gap_10px {
gap: 10px
}
.justify_space-between {
justify-content: space-between
}
.gap_2 {
gap: var(--spacing-2)
}
.gap_1\.5 {
gap: var(--spacing-1\.5)
}
.flex-wrap_wrap {
flex-wrap: wrap
}
.d_flex {
display: flex
}
.items_center {
align-items: center
}
.gap_1 {
gap: var(--spacing-1)
}
.flex_row {
flex-direction: row
}
.shrink_0 {
flex-shrink: 0
}
.hover\:opacity_1\!:is(:hover, [data-hover]) {
opacity: 1 !important
}
.hover\:bg_gray\.200:is(:hover, [data-hover]) {
background: var(--colors-gray-200)
}
.hover\:border_gray\.400:is(:hover, [data-hover]) {
border-color: var(--colors-gray-400)
}
.hover\:bg_blue\.50:is(:hover, [data-hover]) {
background: var(--colors-blue-50)
}
.hover\:text_blue\.700:is(:hover, [data-hover]) {
color: var(--colors-blue-700)
}
.hover\:bg_purple\.50:is(:hover, [data-hover]) {
background: var(--colors-purple-50)
}
.hover\:text_purple\.700:is(:hover, [data-hover]) {
color: var(--colors-purple-700)
}
.hover\:bg_red\.600:is(:hover, [data-hover]) {
background: var(--colors-red-600)
}
.hover\:bg_gray\.100:is(:hover, [data-hover]) {
background: var(--colors-gray-100)
}
.hover\:bg_red\.200:is(:hover, [data-hover]) {
background: var(--colors-red-200)
}
.hover\:bg_purple\.200:is(:hover, [data-hover]) {
background: var(--colors-purple-200)
}
.hover\:bg_blue\.200:is(:hover, [data-hover]) {
background: var(--colors-blue-200)
}
.\[\&\:hover_\.add-button\]\:opacity_0\.5:hover .add-button {
opacity: 0.5
}
}