feat: add responsive mobile drawer with draggable settings button
Implement a mobile-friendly settings interface for the worksheet generator: **Mobile Layout (< 768px):** - Full-screen worksheet preview - Floating draggable settings button showing current config summary - Settings drawer slides in from left (90% width, max 400px) - Swipe-left gesture, backdrop click, or Escape key to close **Desktop Layout (>= 768px):** - Keeps existing resizable panel layout with grab tab - No changes to desktop UX **Draggable Settings Button:** - Drag anywhere on screen with safe 16px margins - Never overlaps nav bar or action buttons (constrained to safe zone) - Position persists in localStorage - Visual feedback: grab/grabbing cursor, elevated shadow while dragging - Smart click detection: only opens drawer on click, not after drag **Settings Summary:** - Shows human-readable config with icons (➕📄🎨🎯) - Multi-line format: operator, layout, scaffolding, difficulty - Updates live as settings change **New Components:** - useMediaQuery hook for responsive breakpoint detection - MobileDrawer with backdrop and animations - MobileSettingsButton with drag-and-drop - ResponsivePanelLayout wrapper (conditionally renders mobile or desktop) - generateSettingsSummary utility with icon system **Integration:** - AdditionWorksheetClient now uses ResponsivePanelLayout - Single codebase handles both mobile and desktop seamlessly - No breaking changes to existing desktop functionality 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
a40fa809ef
commit
fc1d7fcbd6
|
|
@ -1,14 +1,9 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:react-resizable-panels.vercel.app)"
|
||||
],
|
||||
"allow": ["WebFetch(domain:github.com)", "WebFetch(domain:react-resizable-panels.vercel.app)"],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
"enabledMcpjsonServers": [
|
||||
"sqlite"
|
||||
]
|
||||
"enabledMcpjsonServers": ["sqlite"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { css } from '@styled/css'
|
|||
import { useSearchParams } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'
|
||||
import type { WorksheetFormState } from '@/app/create/worksheets/types'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
|
|
@ -15,6 +14,7 @@ import { getDefaultDate } from '../utils/dateFormatting'
|
|||
import { ConfigSidebar } from './ConfigSidebar'
|
||||
import { GenerationErrorDisplay } from './GenerationErrorDisplay'
|
||||
import { PreviewCenter } from './PreviewCenter'
|
||||
import { ResponsivePanelLayout } from './ResponsivePanelLayout'
|
||||
import { WorksheetConfigProvider } from './WorksheetConfigContext'
|
||||
|
||||
interface AdditionWorksheetClientProps {
|
||||
|
|
@ -77,25 +77,6 @@ export function AdditionWorksheetClient({
|
|||
})
|
||||
}
|
||||
|
||||
// Resize handle - 36px wide but overlaps preview by 28px
|
||||
const resizeHandleStyles = css({
|
||||
width: '36px',
|
||||
marginRight: '-28px',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
cursor: 'col-resize',
|
||||
zIndex: 10,
|
||||
})
|
||||
|
||||
// Visual appearance: thin divider + grab tab
|
||||
const handleVisualStyles = css({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
})
|
||||
|
||||
return (
|
||||
<PageWithNav navTitle={t('navTitle')} navEmoji="📝">
|
||||
<WorksheetConfigProvider formState={formState} updateFormState={updateFormState}>
|
||||
|
|
@ -110,118 +91,19 @@ export function AdditionWorksheetClient({
|
|||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
{/* Resizable Panel Layout */}
|
||||
<PanelGroup
|
||||
direction="horizontal"
|
||||
autoSaveId="worksheet-generator-layout"
|
||||
className={css({ flex: '1', minHeight: '0' })}
|
||||
>
|
||||
{/* Left Panel: Config Sidebar */}
|
||||
<Panel defaultSize={25} minSize={20} maxSize={40} collapsible>
|
||||
<ConfigSidebar isSaving={isSaving} lastSaved={lastSaved} />
|
||||
</Panel>
|
||||
|
||||
<PanelResizeHandle className={resizeHandleStyles}>
|
||||
<div className={handleVisualStyles}>
|
||||
{/* Thin divider (8px, full height) */}
|
||||
<div
|
||||
className={css({
|
||||
width: '8px',
|
||||
height: '100%',
|
||||
bg: isDark ? 'gray.700' : 'gray.300',
|
||||
position: 'relative',
|
||||
transition: 'background-color 0.2s',
|
||||
_groupHover: {
|
||||
bg: isDark ? 'blue.600' : 'blue.300',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{/* Grip dots on divider */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '3px',
|
||||
height: '3px',
|
||||
borderRadius: 'full',
|
||||
bg: isDark ? 'gray.500' : 'gray.500',
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
width: '3px',
|
||||
height: '3px',
|
||||
borderRadius: 'full',
|
||||
bg: isDark ? 'gray.500' : 'gray.500',
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
width: '3px',
|
||||
height: '3px',
|
||||
borderRadius: 'full',
|
||||
bg: isDark ? 'gray.500' : 'gray.500',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grab tab (28px wide, 64px tall, centered vertically) */}
|
||||
<div
|
||||
className={css({
|
||||
width: '28px',
|
||||
height: '64px',
|
||||
bg: isDark ? 'gray.600' : 'gray.400',
|
||||
borderTopRightRadius: '8px',
|
||||
borderBottomRightRadius: '8px',
|
||||
position: 'relative',
|
||||
transition: 'background-color 0.2s',
|
||||
overflow: 'hidden',
|
||||
_groupHover: {
|
||||
bg: isDark ? 'blue.500' : 'blue.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{/* Knurled texture (vertical ridges) - multiple visible lines */}
|
||||
{[0, 4, 8, 12, 16, 20, 24].map((offset) => (
|
||||
<div
|
||||
key={offset}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
left: `${offset}px`,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '2px',
|
||||
bg: isDark ? 'rgba(255, 255, 255, 0.25)' : 'rgba(0, 0, 0, 0.25)',
|
||||
boxShadow: isDark
|
||||
? '1px 0 0 rgba(0, 0, 0, 0.3)'
|
||||
: '1px 0 0 rgba(255, 255, 255, 0.3)',
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PanelResizeHandle>
|
||||
|
||||
{/* Center Panel: Preview */}
|
||||
<Panel defaultSize={75} minSize={60}>
|
||||
{/* Responsive Panel Layout (desktop) or Drawer (mobile) */}
|
||||
<ResponsivePanelLayout
|
||||
config={formState}
|
||||
sidebarContent={<ConfigSidebar isSaving={isSaving} lastSaved={lastSaved} />}
|
||||
previewContent={
|
||||
<PreviewCenter
|
||||
formState={debouncedFormState}
|
||||
initialPreview={initialPreview}
|
||||
onGenerate={handleGenerate}
|
||||
status={status}
|
||||
/>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Error Display */}
|
||||
<GenerationErrorDisplay error={error} visible={status === 'error'} onRetry={reset} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
'use client'
|
||||
|
||||
import { css } from '@styled/css'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
|
||||
interface MobileDrawerProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function MobileDrawer({ isOpen, onClose, children }: MobileDrawerProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
const drawerRef = useRef<HTMLDivElement>(null)
|
||||
const [touchStart, setTouchStart] = useState<number | null>(null)
|
||||
const [touchCurrent, setTouchCurrent] = useState<number | null>(null)
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [isOpen, onClose])
|
||||
|
||||
// Prevent body scroll when drawer is open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Touch event handlers for swipe-to-close
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
setTouchStart(e.touches[0].clientX)
|
||||
setTouchCurrent(e.touches[0].clientX)
|
||||
}
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
if (touchStart === null) return
|
||||
setTouchCurrent(e.touches[0].clientX)
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (touchStart === null || touchCurrent === null) {
|
||||
setTouchStart(null)
|
||||
setTouchCurrent(null)
|
||||
return
|
||||
}
|
||||
|
||||
const distance = touchCurrent - touchStart
|
||||
const threshold = 100 // pixels
|
||||
|
||||
// Swipe left to close
|
||||
if (distance < -threshold) {
|
||||
onClose()
|
||||
}
|
||||
|
||||
setTouchStart(null)
|
||||
setTouchCurrent(null)
|
||||
}
|
||||
|
||||
// Calculate transform for swipe animation
|
||||
const getTransform = () => {
|
||||
if (touchStart === null || touchCurrent === null) {
|
||||
return isOpen ? 'translateX(0)' : 'translateX(-100%)'
|
||||
}
|
||||
|
||||
const distance = touchCurrent - touchStart
|
||||
// Only allow leftward swipes
|
||||
if (distance < 0) {
|
||||
return `translateX(${distance}px)`
|
||||
}
|
||||
return 'translateX(0)'
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
data-component="mobile-drawer-backdrop"
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
bg: 'rgba(0, 0, 0, 0.5)',
|
||||
zIndex: 40,
|
||||
opacity: isOpen ? 1 : 0,
|
||||
pointerEvents: isOpen ? 'auto' : 'none',
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
})}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div
|
||||
ref={drawerRef}
|
||||
data-component="mobile-drawer"
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
width: '90%',
|
||||
maxWidth: '400px',
|
||||
bg: isDark ? 'gray.800' : 'white',
|
||||
zIndex: 50,
|
||||
overflow: 'auto',
|
||||
boxShadow: '2xl',
|
||||
transition: touchStart === null ? 'transform 0.3s ease-in-out' : 'none',
|
||||
})}
|
||||
style={{
|
||||
transform: getTransform(),
|
||||
}}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Close button */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
right: 0,
|
||||
zIndex: 10,
|
||||
p: 4,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
bg: isDark ? 'gray.800' : 'white',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
data-action="close-mobile-drawer"
|
||||
onClick={onClose}
|
||||
className={css({
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
rounded: 'full',
|
||||
bg: isDark ? 'gray.700' : 'gray.200',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
fontSize: 'xl',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
bg: isDark ? 'gray.600' : 'gray.300',
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
_active: {
|
||||
transform: 'scale(0.95)',
|
||||
},
|
||||
})}
|
||||
aria-label="Close settings"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={css({ p: 4 })}>{children}</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,226 @@
|
|||
'use client'
|
||||
|
||||
import { css } from '@styled/css'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import type { WorksheetFormState } from '../types'
|
||||
import { generateSettingsSummary } from '../utils/settingsSummary'
|
||||
|
||||
interface MobileSettingsButtonProps {
|
||||
config: Partial<WorksheetFormState>
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const MARGIN = 16 // Safe margin from viewport edges
|
||||
const STORAGE_KEY = 'mobile-settings-button-position'
|
||||
|
||||
export function MobileSettingsButton({ config, onClick }: MobileSettingsButtonProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
const { lines } = generateSettingsSummary(config)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [position, setPosition] = useState({ x: MARGIN, y: 0 })
|
||||
const dragStartRef = useRef<{ x: number; y: number; startX: number; startY: number } | null>(null)
|
||||
|
||||
// Load saved position from localStorage
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved)
|
||||
setPosition(parsed)
|
||||
} catch (e) {
|
||||
// Ignore invalid JSON
|
||||
}
|
||||
} else {
|
||||
// Default position: below nav and action button
|
||||
const navHeight = 60 // Approximate --app-nav-height
|
||||
const actionButtonHeight = 48 // Approximate height of download/action button
|
||||
setPosition({ x: MARGIN, y: navHeight + actionButtonHeight + MARGIN })
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Save position to localStorage
|
||||
const savePosition = (pos: { x: number; y: number }) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(pos))
|
||||
}
|
||||
|
||||
// Constrain position within viewport bounds
|
||||
const constrainPosition = (x: number, y: number) => {
|
||||
if (!buttonRef.current) return { x, y }
|
||||
|
||||
const rect = buttonRef.current.getBoundingClientRect()
|
||||
|
||||
// Calculate bounds
|
||||
const navHeight = 60 // Approximate --app-nav-height
|
||||
const actionButtonHeight = 48 // Approximate height of download/action button
|
||||
const minY = navHeight + actionButtonHeight + MARGIN // Don't go above action button
|
||||
const maxX = window.innerWidth - rect.width - MARGIN
|
||||
const maxY = window.innerHeight - rect.height - MARGIN
|
||||
|
||||
return {
|
||||
x: Math.max(MARGIN, Math.min(x, maxX)),
|
||||
y: Math.max(minY, Math.min(y, maxY)),
|
||||
}
|
||||
}
|
||||
|
||||
// Handle drag start
|
||||
const handlePointerDown = (e: React.PointerEvent) => {
|
||||
// Only drag if clicking the button itself, not when opening
|
||||
if ((e.target as HTMLElement).closest('[data-summary-line]')) {
|
||||
return // Allow text selection
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
setIsDragging(true)
|
||||
dragStartRef.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
startX: position.x,
|
||||
startY: position.y,
|
||||
}
|
||||
|
||||
// Capture pointer to receive all events
|
||||
if (buttonRef.current) {
|
||||
buttonRef.current.setPointerCapture(e.pointerId)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle drag move
|
||||
const handlePointerMove = (e: React.PointerEvent) => {
|
||||
if (!isDragging || !dragStartRef.current) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const deltaX = e.clientX - dragStartRef.current.x
|
||||
const deltaY = e.clientY - dragStartRef.current.y
|
||||
|
||||
const newX = dragStartRef.current.startX + deltaX
|
||||
const newY = dragStartRef.current.startY + deltaY
|
||||
|
||||
const constrained = constrainPosition(newX, newY)
|
||||
setPosition(constrained)
|
||||
}
|
||||
|
||||
// Handle drag end
|
||||
const handlePointerUp = (e: React.PointerEvent) => {
|
||||
if (!isDragging) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
setIsDragging(false)
|
||||
dragStartRef.current = null
|
||||
|
||||
// Release pointer capture
|
||||
if (buttonRef.current) {
|
||||
buttonRef.current.releasePointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
// Save final position
|
||||
savePosition(position)
|
||||
}
|
||||
|
||||
// Handle click (only if not dragged)
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (isDragging) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
onClick()
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
data-action="open-mobile-settings"
|
||||
onClick={handleClick}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
style={{
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
touchAction: 'none', // Prevent default touch behaviors
|
||||
}}
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
zIndex: 30,
|
||||
bg: isDark ? 'gray.800' : 'white',
|
||||
color: isDark ? 'gray.100' : 'gray.900',
|
||||
rounded: 'xl',
|
||||
p: '3',
|
||||
boxShadow: isDragging
|
||||
? '0 8px 24px rgba(0, 0, 0, 0.3)'
|
||||
: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.300',
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
transition: isDragging ? 'none' : 'box-shadow 0.2s, background-color 0.2s',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
userSelect: 'none',
|
||||
_hover: {
|
||||
boxShadow: isDragging
|
||||
? '0 8px 24px rgba(0, 0, 0, 0.3)'
|
||||
: '0 6px 16px rgba(0, 0, 0, 0.2)',
|
||||
bg: isDark ? 'gray.750' : 'gray.50',
|
||||
},
|
||||
})}
|
||||
aria-label="Open worksheet settings (draggable)"
|
||||
>
|
||||
{/* Header with gear icon */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
mb: '2',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.300' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: 'lg' })}>⚙️</span>
|
||||
<span>Worksheet Settings</span>
|
||||
</div>
|
||||
|
||||
{/* Summary lines */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1',
|
||||
fontSize: 'xs',
|
||||
color: isDark ? 'gray.400' : 'gray.700',
|
||||
})}
|
||||
>
|
||||
{lines.map((line, i) => (
|
||||
<div key={i} className={css({ lineHeight: '1.4' })}>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tap indicator */}
|
||||
<div
|
||||
className={css({
|
||||
mt: '2',
|
||||
pt: '2',
|
||||
borderTop: '1px solid',
|
||||
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||
fontSize: '2xs',
|
||||
color: isDark ? 'gray.500' : 'gray.500',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Tap to customize
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
'use client'
|
||||
|
||||
import { css } from '@styled/css'
|
||||
import { useState } from 'react'
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import { useIsMobile } from '@/hooks/useMediaQuery'
|
||||
import type { WorksheetFormState } from '../types'
|
||||
import { MobileDrawer } from './MobileDrawer'
|
||||
import { MobileSettingsButton } from './MobileSettingsButton'
|
||||
|
||||
interface ResponsivePanelLayoutProps {
|
||||
config: Partial<WorksheetFormState>
|
||||
sidebarContent: React.ReactNode
|
||||
previewContent: React.ReactNode
|
||||
}
|
||||
|
||||
export function ResponsivePanelLayout({
|
||||
config,
|
||||
sidebarContent,
|
||||
previewContent,
|
||||
}: ResponsivePanelLayoutProps) {
|
||||
const isMobile = useIsMobile()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||
|
||||
// Mobile layout: Drawer + floating button
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks -- All hooks are called before this conditional return
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
{/* Full-screen preview */}
|
||||
<div
|
||||
data-component="mobile-preview-container"
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
})}
|
||||
>
|
||||
{previewContent}
|
||||
</div>
|
||||
|
||||
{/* Floating settings button */}
|
||||
<MobileSettingsButton config={config} onClick={() => setIsDrawerOpen(true)} />
|
||||
|
||||
{/* Settings drawer */}
|
||||
<MobileDrawer isOpen={isDrawerOpen} onClose={() => setIsDrawerOpen(false)}>
|
||||
{sidebarContent}
|
||||
</MobileDrawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Desktop layout: Resizable panels
|
||||
const resizeHandleStyles = css({
|
||||
width: '36px',
|
||||
marginRight: '-28px',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
cursor: 'col-resize',
|
||||
zIndex: 10,
|
||||
})
|
||||
|
||||
const handleVisualStyles = css({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
})
|
||||
|
||||
return (
|
||||
<PanelGroup
|
||||
direction="horizontal"
|
||||
autoSaveId="worksheet-generator-layout"
|
||||
className={css({ flex: '1', minHeight: '0' })}
|
||||
>
|
||||
{/* Left Panel: Config Sidebar */}
|
||||
<Panel defaultSize={25} minSize={20} maxSize={40} collapsible>
|
||||
<div
|
||||
data-component="desktop-sidebar-container"
|
||||
className={css({
|
||||
h: 'full',
|
||||
overflow: 'auto',
|
||||
})}
|
||||
>
|
||||
{sidebarContent}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<PanelResizeHandle className={resizeHandleStyles}>
|
||||
<div className={handleVisualStyles}>
|
||||
{/* Thin divider (8px, full height) */}
|
||||
<div
|
||||
className={css({
|
||||
width: '8px',
|
||||
height: '100%',
|
||||
bg: isDark ? 'gray.700' : 'gray.300',
|
||||
position: 'relative',
|
||||
transition: 'background-color 0.2s',
|
||||
_groupHover: {
|
||||
bg: isDark ? 'blue.600' : 'blue.300',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{/* Grip dots on divider */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '3px',
|
||||
height: '3px',
|
||||
borderRadius: 'full',
|
||||
bg: isDark ? 'gray.500' : 'gray.500',
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
width: '3px',
|
||||
height: '3px',
|
||||
borderRadius: 'full',
|
||||
bg: isDark ? 'gray.500' : 'gray.500',
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
width: '3px',
|
||||
height: '3px',
|
||||
borderRadius: 'full',
|
||||
bg: isDark ? 'gray.500' : 'gray.500',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grab tab (28px wide, 64px tall, centered vertically) */}
|
||||
<div
|
||||
className={css({
|
||||
width: '28px',
|
||||
height: '64px',
|
||||
bg: isDark ? 'gray.600' : 'gray.400',
|
||||
borderTopRightRadius: '8px',
|
||||
borderBottomRightRadius: '8px',
|
||||
position: 'relative',
|
||||
transition: 'background-color 0.2s',
|
||||
overflow: 'hidden',
|
||||
_groupHover: {
|
||||
bg: isDark ? 'blue.500' : 'blue.400',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{/* Knurled texture (vertical ridges) - multiple visible lines */}
|
||||
{[0, 4, 8, 12, 16, 20, 24].map((offset) => (
|
||||
<div
|
||||
key={offset}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
left: `${offset}px`,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: '2px',
|
||||
bg: isDark ? 'rgba(255, 255, 255, 0.25)' : 'rgba(0, 0, 0, 0.25)',
|
||||
boxShadow: isDark
|
||||
? '1px 0 0 rgba(0, 0, 0, 0.3)'
|
||||
: '1px 0 0 rgba(255, 255, 255, 0.3)',
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PanelResizeHandle>
|
||||
|
||||
{/* Center Panel: Preview */}
|
||||
<Panel defaultSize={75} minSize={60}>
|
||||
<div
|
||||
data-component="desktop-preview-container"
|
||||
className={css({
|
||||
h: 'full',
|
||||
overflow: 'auto',
|
||||
})}
|
||||
>
|
||||
{previewContent}
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
import type { WorksheetFormState } from '../types'
|
||||
|
||||
/**
|
||||
* Icons for different worksheet settings
|
||||
*/
|
||||
export const SETTING_ICONS = {
|
||||
operator: {
|
||||
addition: '➕',
|
||||
subtraction: '➖',
|
||||
multiplication: '✖️',
|
||||
division: '➗',
|
||||
mixed: '🔀',
|
||||
},
|
||||
difficulty: {
|
||||
smart: '🎯',
|
||||
manual: '🎚️',
|
||||
},
|
||||
scaffolding: {
|
||||
tenFrames: '🎨',
|
||||
carryBoxes: '📦',
|
||||
placeValueColors: '🌈',
|
||||
answerBoxes: '✏️',
|
||||
},
|
||||
layout: {
|
||||
pages: '📄',
|
||||
columns: '📊',
|
||||
problems: '📝',
|
||||
},
|
||||
range: '🔢',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Generate a human-readable summary of worksheet settings
|
||||
* for display on mobile settings button
|
||||
*/
|
||||
export function generateSettingsSummary(config: Partial<WorksheetFormState>): {
|
||||
lines: string[]
|
||||
icons: string[]
|
||||
} {
|
||||
const lines: string[] = []
|
||||
const icons: string[] = []
|
||||
|
||||
// Line 1: Operator and digit range
|
||||
if (config.operator) {
|
||||
const operatorIcon = SETTING_ICONS.operator[config.operator]
|
||||
const operatorName = config.operator.charAt(0).toUpperCase() + config.operator.slice(1)
|
||||
const digitRange = config.digitRange
|
||||
? config.digitRange.min === config.digitRange.max
|
||||
? `${config.digitRange.min}-digit`
|
||||
: `${config.digitRange.min}-${config.digitRange.max} digits`
|
||||
: ''
|
||||
lines.push(`${operatorIcon} ${operatorName}${digitRange ? ` • ${digitRange}` : ''}`)
|
||||
icons.push(operatorIcon)
|
||||
}
|
||||
|
||||
// Line 2: Layout (problems per page, columns)
|
||||
if (config.problemsPerPage && config.cols) {
|
||||
const layoutLine = `📄 ${config.problemsPerPage} problems • ${config.cols} columns`
|
||||
lines.push(layoutLine)
|
||||
icons.push('📄')
|
||||
}
|
||||
|
||||
// Line 3: Visual scaffolding (enabled features)
|
||||
const scaffolds: string[] = []
|
||||
if (config.displayRules) {
|
||||
if (config.displayRules.tenFrames === 'always') {
|
||||
scaffolds.push(`${SETTING_ICONS.scaffolding.tenFrames} Ten frames`)
|
||||
icons.push(SETTING_ICONS.scaffolding.tenFrames)
|
||||
}
|
||||
if (config.displayRules.carryBoxes === 'always') {
|
||||
scaffolds.push(`${SETTING_ICONS.scaffolding.carryBoxes} Carry boxes`)
|
||||
icons.push(SETTING_ICONS.scaffolding.carryBoxes)
|
||||
}
|
||||
if (config.displayRules.placeValueColors === 'always') {
|
||||
scaffolds.push(`${SETTING_ICONS.scaffolding.placeValueColors} Colors`)
|
||||
icons.push(SETTING_ICONS.scaffolding.placeValueColors)
|
||||
}
|
||||
}
|
||||
if (scaffolds.length > 0) {
|
||||
lines.push(scaffolds.join(' • '))
|
||||
}
|
||||
|
||||
// Line 4: Difficulty mode
|
||||
if (config.mode) {
|
||||
const diffIcon = SETTING_ICONS.difficulty[config.mode]
|
||||
const modeName = config.mode === 'smart' ? 'Smart difficulty' : 'Manual mode'
|
||||
const pStart =
|
||||
config.mode === 'smart' && config.pAnyStart != null
|
||||
? ` • ${Math.round(config.pAnyStart * 100)}% starts`
|
||||
: ''
|
||||
lines.push(`${diffIcon} ${modeName}${pStart}`)
|
||||
icons.push(diffIcon)
|
||||
}
|
||||
|
||||
return { lines, icons }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a compact one-line summary for small spaces
|
||||
*/
|
||||
export function generateCompactSummary(config: Partial<WorksheetFormState>): string {
|
||||
const parts: string[] = []
|
||||
|
||||
if (config.operator) {
|
||||
const icon = SETTING_ICONS.operator[config.operator]
|
||||
const name = config.operator.charAt(0).toUpperCase() + config.operator.slice(1)
|
||||
parts.push(`${icon} ${name}`)
|
||||
}
|
||||
|
||||
if (config.digitRange) {
|
||||
const range =
|
||||
config.digitRange.min === config.digitRange.max
|
||||
? `${config.digitRange.min}d`
|
||||
: `${config.digitRange.min}-${config.digitRange.max}d`
|
||||
parts.push(range)
|
||||
}
|
||||
|
||||
if (config.problemsPerPage) {
|
||||
parts.push(`${config.problemsPerPage}p`)
|
||||
}
|
||||
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Hook to detect media query matches
|
||||
* @param query - CSS media query string (e.g., '(min-width: 768px)')
|
||||
* @returns boolean indicating if the query matches
|
||||
*/
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
// Initialize with false to avoid hydration mismatch
|
||||
const [matches, setMatches] = useState(false)
|
||||
const [isClient, setIsClient] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isClient) return
|
||||
|
||||
const mediaQuery = window.matchMedia(query)
|
||||
|
||||
// Set initial value
|
||||
setMatches(mediaQuery.matches)
|
||||
|
||||
// Create event listener
|
||||
const handler = (event: MediaQueryListEvent) => {
|
||||
setMatches(event.matches)
|
||||
}
|
||||
|
||||
// Modern browsers
|
||||
mediaQuery.addEventListener('change', handler)
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handler)
|
||||
}
|
||||
}, [query, isClient])
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
/**
|
||||
* Common breakpoint hooks
|
||||
*/
|
||||
export function useIsMobile(): boolean {
|
||||
return useMediaQuery('(max-width: 767px)')
|
||||
}
|
||||
|
||||
export function useIsTablet(): boolean {
|
||||
return useMediaQuery('(min-width: 768px) and (max-width: 1023px)')
|
||||
}
|
||||
|
||||
export function useIsDesktop(): boolean {
|
||||
return useMediaQuery('(min-width: 1024px)')
|
||||
}
|
||||
Loading…
Reference in New Issue