diff --git a/apps/web/src/app/api/create/worksheets/addition/preview/route.ts b/apps/web/src/app/api/create/worksheets/addition/preview/route.ts index 12d8e335..2363dd1e 100644 --- a/apps/web/src/app/api/create/worksheets/addition/preview/route.ts +++ b/apps/web/src/app/api/create/worksheets/addition/preview/route.ts @@ -1,10 +1,7 @@ // API route for generating addition worksheet previews (SVG) import { type NextRequest, NextResponse } from 'next/server' -import { execSync } from 'child_process' -import { validateWorksheetConfig } from '@/app/create/worksheets/addition/validation' -import { generateProblems } from '@/app/create/worksheets/addition/problemGenerator' -import { generateTypstSource } from '@/app/create/worksheets/addition/typstGenerator' +import { generateWorksheetPreview } from '@/app/create/worksheets/addition/generatePreview' import type { WorksheetFormState } from '@/app/create/worksheets/addition/types' export const dynamic = 'force-dynamic' @@ -13,63 +10,21 @@ export async function POST(request: NextRequest) { try { const body: WorksheetFormState = await request.json() - // Validate configuration - const validation = validateWorksheetConfig(body) - if (!validation.isValid || !validation.config) { + // Generate preview using shared logic + const result = generateWorksheetPreview(body) + + if (!result.success) { return NextResponse.json( - { error: 'Invalid configuration', errors: validation.errors }, + { + error: result.error, + details: result.details, + }, { status: 400 } ) } - const config = validation.config - - // Generate all problems for full preview - const problems = generateProblems( - config.total, - config.pAnyStart, - config.pAllStart, - config.interpolate, - config.seed - ) - - // Generate Typst sources (one per page) - const typstSources = generateTypstSource(config, problems) - - // Compile each page source to SVG (using stdout for single-page output) - const pages: string[] = [] - for (let i = 0; i < typstSources.length; i++) { - const typstSource = typstSources[i] - - // Compile to SVG via stdin/stdout - try { - const svgOutput = execSync('typst compile --format svg - -', { - input: typstSource, - encoding: 'utf8', - maxBuffer: 10 * 1024 * 1024, // 10MB limit - }) - pages.push(svgOutput) - } catch (error) { - console.error(`Typst compilation error (page ${i + 1}):`, error) - - // Extract the actual Typst error message - const stderr = - error instanceof Error && 'stderr' in error - ? String((error as any).stderr) - : 'Unknown compilation error' - - return NextResponse.json( - { - error: `Failed to compile preview (page ${i + 1})`, - details: stderr, - }, - { status: 500 } - ) - } - } - // Return pages as JSON - return NextResponse.json({ pages }) + return NextResponse.json({ pages: result.pages }) } catch (error) { console.error('Error generating preview:', error) diff --git a/apps/web/src/app/create/worksheets/addition/components/WorksheetPreview.tsx b/apps/web/src/app/create/worksheets/addition/components/WorksheetPreview.tsx index 8b3ddbc5..8dfe0aa3 100644 --- a/apps/web/src/app/create/worksheets/addition/components/WorksheetPreview.tsx +++ b/apps/web/src/app/create/worksheets/addition/components/WorksheetPreview.tsx @@ -1,6 +1,6 @@ 'use client' -import { Suspense, useState, useEffect } from 'react' +import { Suspense, useState, useEffect, useRef } from 'react' import { useSuspenseQuery } from '@tanstack/react-query' import { useTranslations } from 'next-intl' import { css } from '../../../../../../styled-system/css' @@ -9,6 +9,7 @@ import type { WorksheetFormState } from '../types' interface WorksheetPreviewProps { formState: WorksheetFormState + initialData?: string[] } function getDefaultDate(): string { @@ -21,6 +22,12 @@ function getDefaultDate(): string { } async function fetchWorksheetPreview(formState: WorksheetFormState): Promise { + const fetchId = Math.random().toString(36).slice(2, 9) + console.log(`[WorksheetPreview] fetchWorksheetPreview called (ID: ${fetchId})`, { + seed: formState.seed, + problemsPerPage: formState.problemsPerPage, + }) + // Set current date for preview const configWithDate = { ...formState, @@ -31,6 +38,7 @@ async function fetchWorksheetPreview(formState: WorksheetFormState): Promise fetchWorksheetPreview(formState), + queryFn: () => { + console.log('[WorksheetPreview] Fetching preview from API...') + return fetchWorksheetPreview(formState) + }, + initialData: queryInitialData, // Only use on first render }) + console.log('[WorksheetPreview] Preview fetched, pages:', pages.length) + const totalPages = pages.length // Reset to first page when preview updates @@ -283,6 +316,7 @@ function PreviewContent({ formState }: WorksheetPreviewProps) { } function PreviewFallback() { + console.log('[WorksheetPreview] Showing fallback (Suspense boundary)') return (
}> - + ) } diff --git a/apps/web/src/app/create/worksheets/addition/generatePreview.ts b/apps/web/src/app/create/worksheets/addition/generatePreview.ts new file mode 100644 index 00000000..64e2700d --- /dev/null +++ b/apps/web/src/app/create/worksheets/addition/generatePreview.ts @@ -0,0 +1,91 @@ +// Shared logic for generating worksheet previews (used by both API route and SSR) + +import { execSync } from 'child_process' +import { validateWorksheetConfig } from './validation' +import { generateProblems } from './problemGenerator' +import { generateTypstSource } from './typstGenerator' +import type { WorksheetFormState } from './types' + +export interface PreviewResult { + success: boolean + pages?: string[] + error?: string + details?: string +} + +/** + * Generate worksheet preview SVG pages + * Can be called from API routes or Server Components + */ +export function generateWorksheetPreview(config: WorksheetFormState): PreviewResult { + try { + // Validate configuration + const validation = validateWorksheetConfig(config) + if (!validation.isValid || !validation.config) { + return { + success: false, + error: 'Invalid configuration', + details: validation.errors?.join(', '), + } + } + + const validatedConfig = validation.config + + // Generate all problems for full preview + const problems = generateProblems( + validatedConfig.total, + validatedConfig.pAnyStart, + validatedConfig.pAllStart, + validatedConfig.interpolate, + validatedConfig.seed + ) + + // Generate Typst sources (one per page) + const typstSources = generateTypstSource(validatedConfig, problems) + + // Compile each page source to SVG (using stdout for single-page output) + const pages: string[] = [] + for (let i = 0; i < typstSources.length; i++) { + const typstSource = typstSources[i] + + // Compile to SVG via stdin/stdout + try { + const svgOutput = execSync('typst compile --format svg - -', { + input: typstSource, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, // 10MB limit + }) + pages.push(svgOutput) + } catch (error) { + console.error(`Typst compilation error (page ${i + 1}):`, error) + + // Extract the actual Typst error message + const stderr = + error instanceof Error && 'stderr' in error + ? String((error as any).stderr) + : 'Unknown compilation error' + + return { + success: false, + error: `Failed to compile preview (page ${i + 1})`, + details: stderr, + } + } + } + + return { + success: true, + pages, + } + } catch (error) { + console.error('Error generating preview:', error) + + const errorMessage = error instanceof Error ? error.message : String(error) + + return { + success: false, + error: 'Failed to generate preview', + details: errorMessage, + } + } +} diff --git a/apps/web/src/app/create/worksheets/addition/page.tsx b/apps/web/src/app/create/worksheets/addition/page.tsx index 54e27d3d..4bd60484 100644 --- a/apps/web/src/app/create/worksheets/addition/page.tsx +++ b/apps/web/src/app/create/worksheets/addition/page.tsx @@ -1,16 +1,13 @@ -'use client' - -import { useTranslations } from 'next-intl' -import { useState, useEffect } from 'react' -import { PageWithNav } from '@/components/PageWithNav' -import { css } from '../../../../../styled-system/css' -import { container, grid, hstack, stack } from '../../../../../styled-system/patterns' -import { ConfigPanel } from './components/ConfigPanel' -import { WorksheetPreview } from './components/WorksheetPreview' +import { eq, and } from 'drizzle-orm' +import { db, schema } from '@/db' +import { getViewerId } from '@/lib/viewer' +import { + parseAdditionConfig, + defaultAdditionConfig, +} from '@/app/create/worksheets/config-schemas' +import { AdditionWorksheetClient } from './components/AdditionWorksheetClient' import type { WorksheetFormState } from './types' -import { validateWorksheetConfig } from './validation' - -type GenerationStatus = 'idle' | 'generating' | 'error' +import { generateWorksheetPreview } from './generatePreview' /** * Get current date formatted as "Month Day, Year" @@ -24,468 +21,76 @@ function getDefaultDate(): string { }) } -export default function AdditionWorksheetPage() { - const t = useTranslations('create.worksheets.addition') - const [generationStatus, setGenerationStatus] = useState('idle') - const [error, setError] = useState(null) - const [settingsLoaded, setSettingsLoaded] = useState(false) - const [lastSaved, setLastSaved] = useState(null) - const [isSaving, setIsSaving] = useState(false) +/** + * Load worksheet settings from database (server-side) + */ +async function loadWorksheetSettings(): Promise< + Omit +> { + try { + const viewerId = await getViewerId() - // Immediate form state (for controls - updates instantly) - // PRIMARY state: problemsPerPage, cols, pages (what user controls) - // DERIVED state: rows, total (calculated from primary) - const [formState, setFormState] = useState({ - // Primary state - problemsPerPage: 20, - cols: 5, - pages: 1, - orientation: 'landscape', - // Derived state - rows: 4, // (20 / 5) * 1 = 4 - total: 20, // 20 * 1 = 20 - // Other settings - name: '', - date: '', // Will be set at generation time - pAnyStart: 0.75, - pAllStart: 0.25, - interpolate: true, - showCarryBoxes: true, - showAnswerBoxes: true, - showPlaceValueColors: true, - showProblemNumbers: true, - showCellBorder: true, - showTenFrames: false, - showTenFramesForAll: false, - fontSize: 16, - seed: Date.now() % 2147483647, - }) + // Look up user's saved settings + const [row] = await db + .select() + .from(schema.worksheetSettings) + .where( + and( + eq(schema.worksheetSettings.userId, viewerId), + eq(schema.worksheetSettings.worksheetType, 'addition') + ) + ) + .limit(1) - // Debounced form state (for preview - updates after delay) - const [debouncedFormState, setDebouncedFormState] = useState(formState) - - // Debounce preview updates (500ms delay) - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedFormState(formState) - }, 500) - - return () => clearTimeout(timer) - }, [formState]) - - // Load saved settings on mount - useEffect(() => { - async function loadSettings() { - try { - const response = await fetch('/api/worksheets/settings?type=addition') - if (!response.ok) throw new Error('Failed to load settings') - - const data = await response.json() - if (data.exists && data.config) { - // Load saved config, but preserve derived state - const rows = Math.ceil((data.config.problemsPerPage * data.config.pages) / data.config.cols) - const total = data.config.problemsPerPage * data.config.pages - - setFormState({ - ...data.config, - rows, - total, - date: '', // Always start with empty date - seed: Date.now() % 2147483647, // Generate new seed - }) - } - } catch (error) { - console.error('Failed to load worksheet settings:', error) - // Continue with defaults - } finally { - setSettingsLoaded(true) + if (!row) { + // No saved settings, return defaults with a stable seed + return { + ...defaultAdditionConfig, + seed: Date.now() % 2147483647, } } - loadSettings() - }, []) - - // Auto-save settings when they change (debounced, only after initial load) - useEffect(() => { - if (!settingsLoaded) { - console.log('[Worksheet Settings] Skipping save - settings not loaded yet') - return // Don't save until we've loaded initial settings + // Parse and validate config (auto-migrates to latest version) + const config = parseAdditionConfig(row.config) + return { + ...config, + seed: Date.now() % 2147483647, } - - console.log('[Worksheet Settings] Settings changed, will save in 1s...') - - const timer = setTimeout(async () => { - console.log('[Worksheet Settings] Attempting to save settings...') - setIsSaving(true) - try { - // Extract only the fields we want to persist (exclude date, seed, derived state) - const { - problemsPerPage, - cols, - pages, - orientation, - name, - pAnyStart, - pAllStart, - interpolate, - showCarryBoxes, - showAnswerBoxes, - showPlaceValueColors, - showProblemNumbers, - showCellBorder, - showTenFrames, - showTenFramesForAll, - fontSize, - } = formState - - const response = await fetch('/api/worksheets/settings', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - type: 'addition', - config: { - problemsPerPage, - cols, - pages, - orientation, - name, - pAnyStart, - pAllStart, - interpolate, - showCarryBoxes, - showAnswerBoxes, - showPlaceValueColors, - showProblemNumbers, - showCellBorder, - showTenFrames, - showTenFramesForAll, - fontSize, - }, - }), - }) - - if (response.ok) { - const data = await response.json() - console.log('[Worksheet Settings] Save response:', data) - // Only set lastSaved if settings were actually saved (not guest user) - if (data.success) { - console.log('[Worksheet Settings] ✓ Settings saved successfully') - setLastSaved(new Date()) - } else { - console.log('[Worksheet Settings] Save skipped (guest user or no user account)') - } - // Guest users (success: false) - silently skip saving, no error shown - } else { - console.error('[Worksheet Settings] Save failed with status:', response.status) - } - } catch (error) { - // Silently fail - settings persistence is not critical - console.error('[Worksheet Settings] Settings save error:', error) - } finally { - setIsSaving(false) - } - }, 1000) // 1 second debounce for auto-save - - return () => clearTimeout(timer) - }, [formState, settingsLoaded]) - - const handleFormChange = (updates: Partial) => { - setFormState((prev) => { - const newState = { ...prev, ...updates } - - // Generate new seed when problem settings change - const affectsProblems = - updates.problemsPerPage !== undefined || - updates.cols !== undefined || - updates.pages !== undefined || - updates.orientation !== undefined || - updates.pAnyStart !== undefined || - updates.pAllStart !== undefined || - updates.interpolate !== undefined - - if (affectsProblems) { - newState.seed = Date.now() % 2147483647 - } - - return newState - }) - } - - const handleGenerate = async () => { - setGenerationStatus('generating') - setError(null) - - try { - // Set current date at generation time - const configWithDate = { - ...formState, - date: getDefaultDate(), - } - - // Validate configuration - const validation = validateWorksheetConfig(configWithDate) - if (!validation.isValid || !validation.config) { - throw new Error(validation.errors?.join(', ') || 'Invalid configuration') - } - - const response = await fetch('/api/create/worksheets/addition', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(configWithDate), - }) - - if (!response.ok) { - const errorResult = await response.json() - const errorMsg = errorResult.details - ? `${errorResult.error}\n\n${errorResult.details}` - : errorResult.error || 'Generation failed' - throw new Error(errorMsg) - } - - // Success - response is binary PDF data, trigger download - const blob = await response.blob() - const filename = `addition-worksheet-${formState.name || 'student'}-${Date.now()}.pdf` - - // Create download link and trigger download - const url = window.URL.createObjectURL(blob) - const a = document.createElement('a') - a.style.display = 'none' - a.href = url - a.download = filename - document.body.appendChild(a) - a.click() - window.URL.revokeObjectURL(url) - document.body.removeChild(a) - - setGenerationStatus('idle') - } catch (err) { - console.error('Generation error:', err) - setError(err instanceof Error ? err.message : 'Unknown error occurred') - setGenerationStatus('error') + } catch (error) { + console.error('Failed to load worksheet settings:', error) + // Return defaults on error with a stable seed + return { + ...defaultAdditionConfig, + seed: Date.now() % 2147483647, } } +} - const handleNewGeneration = () => { - setGenerationStatus('idle') - setError(null) +export default async function AdditionWorksheetPage() { + const initialSettings = await loadWorksheetSettings() + + // Calculate derived state needed for preview + const rows = Math.ceil((initialSettings.problemsPerPage * initialSettings.pages) / initialSettings.cols) + const total = initialSettings.problemsPerPage * initialSettings.pages + + // Create full config for preview generation + const fullConfig: WorksheetFormState = { + ...initialSettings, + rows, + total, + date: getDefaultDate(), } + // Pre-generate worksheet preview on the server + console.log('[SSR] Generating worksheet preview on server...') + const previewResult = generateWorksheetPreview(fullConfig) + console.log('[SSR] Preview generation complete:', previewResult.success ? 'success' : 'failed') + + // Pass both settings and initial preview data to client return ( - -
- {/* Main Content */} -
-
-
-

- {t('pageTitle')} -

-

- {t('pageSubtitle')} -

-
-
- - {/* Configuration Interface */} -
- {/* Configuration Panel */} -
-
- -
- - {/* Settings saved indicator */} - {settingsLoaded && ( -
- {isSaving ? ( - Saving settings... - ) : lastSaved ? ( - - ✓ Settings saved at {lastSaved.toLocaleTimeString()} - - ) : null} -
- )} -
- - {/* Preview & Generate Panel */} -
- {/* Preview */} -
- -
- - {/* Generate Button */} -
- -
-
-
- - {/* Error Display */} - {generationStatus === 'error' && error && ( -
-
-
-
-

- {t('error.title')} -

-
-
-                  {error}
-                
- -
-
- )} -
-
-
+ ) }