diff --git a/apps/web/.claude/settings.local.json b/apps/web/.claude/settings.local.json index 16d83197..d77b2cc0 100644 --- a/apps/web/.claude/settings.local.json +++ b/apps/web/.claude/settings.local.json @@ -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"] } diff --git a/apps/web/src/app/create/worksheets/components/AdditionWorksheetClient.tsx b/apps/web/src/app/create/worksheets/components/AdditionWorksheetClient.tsx index 221acf5b..a9f2481c 100644 --- a/apps/web/src/app/create/worksheets/components/AdditionWorksheetClient.tsx +++ b/apps/web/src/app/create/worksheets/components/AdditionWorksheetClient.tsx @@ -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 ( @@ -110,118 +91,19 @@ export function AdditionWorksheetClient({ flexDirection: 'column', })} > - {/* Resizable Panel Layout */} - - {/* Left Panel: Config Sidebar */} - - - - - -
- {/* Thin divider (8px, full height) */} -
- {/* Grip dots on divider */} -
-
-
-
-
-
- - {/* Grab tab (28px wide, 64px tall, centered vertically) */} -
- {/* Knurled texture (vertical ridges) - multiple visible lines */} - {[0, 4, 8, 12, 16, 20, 24].map((offset) => ( -
- ))} -
-
- - - {/* Center Panel: Preview */} - + {/* Responsive Panel Layout (desktop) or Drawer (mobile) */} + } + previewContent={ - - + } + /> {/* Error Display */} diff --git a/apps/web/src/app/create/worksheets/components/MobileDrawer.tsx b/apps/web/src/app/create/worksheets/components/MobileDrawer.tsx new file mode 100644 index 00000000..1e923bc4 --- /dev/null +++ b/apps/web/src/app/create/worksheets/components/MobileDrawer.tsx @@ -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(null) + const [touchStart, setTouchStart] = useState(null) + const [touchCurrent, setTouchCurrent] = useState(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 */} +
+ + {/* Drawer */} +
+ {/* Close button */} +
+ +
+ + {/* Content */} +
{children}
+
+ + ) +} diff --git a/apps/web/src/app/create/worksheets/components/MobileSettingsButton.tsx b/apps/web/src/app/create/worksheets/components/MobileSettingsButton.tsx new file mode 100644 index 00000000..2d7ba495 --- /dev/null +++ b/apps/web/src/app/create/worksheets/components/MobileSettingsButton.tsx @@ -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 + 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(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 ( + + ) +} diff --git a/apps/web/src/app/create/worksheets/components/ResponsivePanelLayout.tsx b/apps/web/src/app/create/worksheets/components/ResponsivePanelLayout.tsx new file mode 100644 index 00000000..d89b52cf --- /dev/null +++ b/apps/web/src/app/create/worksheets/components/ResponsivePanelLayout.tsx @@ -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 + 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 */} +
+ {previewContent} +
+ + {/* Floating settings button */} + setIsDrawerOpen(true)} /> + + {/* Settings drawer */} + setIsDrawerOpen(false)}> + {sidebarContent} + + + ) + } + + // 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 ( + + {/* Left Panel: Config Sidebar */} + +
+ {sidebarContent} +
+
+ + +
+ {/* Thin divider (8px, full height) */} +
+ {/* Grip dots on divider */} +
+
+
+
+
+
+ + {/* Grab tab (28px wide, 64px tall, centered vertically) */} +
+ {/* Knurled texture (vertical ridges) - multiple visible lines */} + {[0, 4, 8, 12, 16, 20, 24].map((offset) => ( +
+ ))} +
+
+ + + {/* Center Panel: Preview */} + +
+ {previewContent} +
+
+ + ) +} diff --git a/apps/web/src/app/create/worksheets/utils/settingsSummary.ts b/apps/web/src/app/create/worksheets/utils/settingsSummary.ts new file mode 100644 index 00000000..b2940fc9 --- /dev/null +++ b/apps/web/src/app/create/worksheets/utils/settingsSummary.ts @@ -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): { + 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): 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(' • ') +} diff --git a/apps/web/src/hooks/useMediaQuery.ts b/apps/web/src/hooks/useMediaQuery.ts new file mode 100644 index 00000000..b1353323 --- /dev/null +++ b/apps/web/src/hooks/useMediaQuery.ts @@ -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)') +}