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": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": ["WebFetch(domain:github.com)", "WebFetch(domain:react-resizable-panels.vercel.app)"],
|
||||||
"WebFetch(domain:github.com)",
|
|
||||||
"WebFetch(domain:react-resizable-panels.vercel.app)"
|
|
||||||
],
|
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
},
|
},
|
||||||
"enableAllProjectMcpServers": true,
|
"enableAllProjectMcpServers": true,
|
||||||
"enabledMcpjsonServers": [
|
"enabledMcpjsonServers": ["sqlite"]
|
||||||
"sqlite"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { css } from '@styled/css'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'
|
|
||||||
import type { WorksheetFormState } from '@/app/create/worksheets/types'
|
import type { WorksheetFormState } from '@/app/create/worksheets/types'
|
||||||
import { PageWithNav } from '@/components/PageWithNav'
|
import { PageWithNav } from '@/components/PageWithNav'
|
||||||
import { useTheme } from '@/contexts/ThemeContext'
|
import { useTheme } from '@/contexts/ThemeContext'
|
||||||
|
|
@ -15,6 +14,7 @@ import { getDefaultDate } from '../utils/dateFormatting'
|
||||||
import { ConfigSidebar } from './ConfigSidebar'
|
import { ConfigSidebar } from './ConfigSidebar'
|
||||||
import { GenerationErrorDisplay } from './GenerationErrorDisplay'
|
import { GenerationErrorDisplay } from './GenerationErrorDisplay'
|
||||||
import { PreviewCenter } from './PreviewCenter'
|
import { PreviewCenter } from './PreviewCenter'
|
||||||
|
import { ResponsivePanelLayout } from './ResponsivePanelLayout'
|
||||||
import { WorksheetConfigProvider } from './WorksheetConfigContext'
|
import { WorksheetConfigProvider } from './WorksheetConfigContext'
|
||||||
|
|
||||||
interface AdditionWorksheetClientProps {
|
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 (
|
return (
|
||||||
<PageWithNav navTitle={t('navTitle')} navEmoji="📝">
|
<PageWithNav navTitle={t('navTitle')} navEmoji="📝">
|
||||||
<WorksheetConfigProvider formState={formState} updateFormState={updateFormState}>
|
<WorksheetConfigProvider formState={formState} updateFormState={updateFormState}>
|
||||||
|
|
@ -110,118 +91,19 @@ export function AdditionWorksheetClient({
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{/* Resizable Panel Layout */}
|
{/* Responsive Panel Layout (desktop) or Drawer (mobile) */}
|
||||||
<PanelGroup
|
<ResponsivePanelLayout
|
||||||
direction="horizontal"
|
config={formState}
|
||||||
autoSaveId="worksheet-generator-layout"
|
sidebarContent={<ConfigSidebar isSaving={isSaving} lastSaved={lastSaved} />}
|
||||||
className={css({ flex: '1', minHeight: '0' })}
|
previewContent={
|
||||||
>
|
|
||||||
{/* 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}>
|
|
||||||
<PreviewCenter
|
<PreviewCenter
|
||||||
formState={debouncedFormState}
|
formState={debouncedFormState}
|
||||||
initialPreview={initialPreview}
|
initialPreview={initialPreview}
|
||||||
onGenerate={handleGenerate}
|
onGenerate={handleGenerate}
|
||||||
status={status}
|
status={status}
|
||||||
/>
|
/>
|
||||||
</Panel>
|
}
|
||||||
</PanelGroup>
|
/>
|
||||||
|
|
||||||
{/* Error Display */}
|
{/* Error Display */}
|
||||||
<GenerationErrorDisplay error={error} visible={status === 'error'} onRetry={reset} />
|
<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