diff --git a/apps/web/src/app/api/create/flashcards/preview/route.ts b/apps/web/src/app/api/create/flashcards/preview/route.ts new file mode 100644 index 00000000..b5015659 --- /dev/null +++ b/apps/web/src/app/api/create/flashcards/preview/route.ts @@ -0,0 +1,188 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { writeFileSync, mkdirSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { execSync } from 'child_process' +import type { FlashcardFormState } from '@/app/create/flashcards/page' +import { + generateFlashcardFront, + generateFlashcardBack, +} from '@/utils/flashcards/generateFlashcardSvgs' + +export const dynamic = 'force-dynamic' + +/** + * Parse range string to get numbers for preview (first page only) + */ +function parseRangeForPreview(range: string, step: number, cardsPerPage: number): number[] { + const numbers: number[] = [] + + if (range.includes('-')) { + const [start, end] = range.split('-').map((n) => parseInt(n, 10)) + for (let i = start; i <= end && numbers.length < cardsPerPage; i += step) { + numbers.push(i) + } + } else if (range.includes(',')) { + const parts = range.split(',').map((n) => parseInt(n.trim(), 10)) + numbers.push(...parts.slice(0, cardsPerPage)) + } else { + numbers.push(parseInt(range, 10)) + } + + return numbers.slice(0, cardsPerPage) +} + +export async function POST(request: NextRequest) { + let tempDir: string | null = null + + try { + const body: FlashcardFormState = await request.json() + const { + range = '0-99', + step = 1, + cardsPerPage = 6, + paperSize = 'us-letter', + orientation = 'portrait', + beadShape = 'diamond', + colorScheme = 'place-value', + colorPalette = 'default', + hideInactiveBeads = false, + showEmptyColumns = false, + columns = 'auto', + scaleFactor = 0.9, + coloredNumerals = false, + } = body + + // Dynamic import to avoid Next.js bundler issues + const { renderToStaticMarkup } = await import('react-dom/server') + + // Create temp directory for SVG files + tempDir = join(tmpdir(), `flashcards-preview-${Date.now()}-${Math.random()}`) + mkdirSync(tempDir, { recursive: true }) + + // Get numbers for first page only + const numbers = parseRangeForPreview(range, step, cardsPerPage) + + if (numbers.length === 0) { + return NextResponse.json({ error: 'No valid numbers in range' }, { status: 400 }) + } + + // Generate SVG files for each card (front and back) + const config = { + beadShape, + colorScheme, + colorPalette, + hideInactiveBeads, + showEmptyColumns, + columns: columns === 'auto' ? 'auto' : Number(columns), + scaleFactor, + coloredNumerals, + } + + for (let i = 0; i < numbers.length; i++) { + const num = numbers[i] + + // Generate front (abacus) + const frontElement = generateFlashcardFront(num, config) + const frontSvg = renderToStaticMarkup(frontElement) + writeFileSync(join(tempDir, `card_${i}_front.svg`), frontSvg) + + // Generate back (numeral) + const backElement = generateFlashcardBack(num, config) + const backSvg = renderToStaticMarkup(backElement) + writeFileSync(join(tempDir, `card_${i}_back.svg`), backSvg) + } + + // Calculate card dimensions based on paper size and orientation + const paperDimensions = { + 'us-letter': { width: 8.5, height: 11 }, + a4: { width: 8.27, height: 11.69 }, + a3: { width: 11.69, height: 16.54 }, + a5: { width: 5.83, height: 8.27 }, + } + + const paper = paperDimensions[paperSize] || paperDimensions['us-letter'] + const [pageWidth, pageHeight] = + orientation === 'landscape' ? [paper.height, paper.width] : [paper.width, paper.height] + + // Calculate grid layout (2 columns × 3 rows for 6 cards per page typically) + const cols = 2 + const rows = Math.ceil(cardsPerPage / cols) + const margin = 0.5 // inches + const gutter = 0.2 // inches between cards + + const availableWidth = pageWidth - 2 * margin - gutter * (cols - 1) + const availableHeight = pageHeight - 2 * margin - gutter * (rows - 1) + const cardWidth = availableWidth / cols + const cardHeight = availableHeight / rows + + // Generate Typst document with card grid + const typstContent = ` +#set page( + paper: "${paperSize}", + margin: (x: ${margin}in, y: ${margin}in), + flipped: ${orientation === 'landscape'}, +) + +// Grid layout for flashcards preview (first page only) +#grid( + columns: ${cols}, + rows: ${rows}, + column-gutter: ${gutter}in, + row-gutter: ${gutter}in, + ${numbers + .map((_, i) => { + return ` image("card_${i}_front.svg", width: ${cardWidth}in, height: ${cardHeight}in, fit: "contain"),` + }) + .join('\n')} +) + +// Add preview label +#place( + top + right, + dx: -0.5in, + dy: 0.25in, + text(10pt, fill: gray)[Preview (first ${numbers.length} cards)] +) +` + + // Compile with Typst: stdin for .typ content, stdout for SVG output + let svg: string + try { + svg = execSync('typst compile --format svg - -', { + input: typstContent, + encoding: 'utf8', + cwd: tempDir, // Run in temp dir so relative paths work + }) + } catch (error) { + console.error('Typst compilation error:', error) + return NextResponse.json( + { error: 'Failed to compile preview. Is Typst installed?' }, + { status: 500 } + ) + } + + // Clean up temp directory + rmSync(tempDir, { recursive: true, force: true }) + tempDir = null + + return NextResponse.json({ svg }) + } catch (error) { + console.error('Error generating preview:', error) + + // Clean up temp directory if it exists + if (tempDir) { + try { + rmSync(tempDir, { recursive: true, force: true }) + } catch (cleanupError) { + console.error('Failed to clean up temp directory:', cleanupError) + } + } + + const errorMessage = error instanceof Error ? error.message : String(error) + return NextResponse.json( + { error: 'Failed to generate preview', message: errorMessage }, + { status: 500 } + ) + } +} diff --git a/apps/web/src/app/api/generate/route.ts b/apps/web/src/app/api/generate/route.ts index bd5bec46..aa09d818 100644 --- a/apps/web/src/app/api/generate/route.ts +++ b/apps/web/src/app/api/generate/route.ts @@ -1,68 +1,237 @@ -import { SorobanGenerator } from '@soroban/core' import { type NextRequest, NextResponse } from 'next/server' -import path from 'path' +import { writeFileSync, mkdirSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { execSync } from 'child_process' +import type { FlashcardConfig } from '@/app/create/flashcards/page' +import { + generateFlashcardFront, + generateFlashcardBack, +} from '@/utils/flashcards/generateFlashcardSvgs' -// Global generator instance for better performance -let generator: SorobanGenerator | null = null +export const dynamic = 'force-dynamic' -async function getGenerator() { - if (!generator) { - // Point to the core package in our monorepo - const corePackagePath = path.join(process.cwd(), '../../packages/core') - generator = new SorobanGenerator(corePackagePath) +/** + * Parse range string to get all numbers + */ +function parseRange(range: string, step: number): number[] { + const numbers: number[] = [] - // Note: SorobanGenerator from @soroban/core doesn't have initialize method - // It uses one-shot mode by default + if (range.includes('-')) { + const [start, end] = range.split('-').map((n) => parseInt(n, 10)) + for (let i = start; i <= end; i += step) { + numbers.push(i) + } + } else if (range.includes(',')) { + const parts = range.split(',').map((n) => parseInt(n.trim(), 10)) + numbers.push(...parts) + } else { + numbers.push(parseInt(range, 10)) + } + + return numbers +} + +/** + * Shuffle array with seed for reproducibility + */ +function shuffleWithSeed(array: T[], seed?: number): T[] { + const shuffled = [...array] + const rng = seed !== undefined ? seededRandom(seed) : Math.random + + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)) + ;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]] + } + + return shuffled +} + +/** + * Simple seeded random number generator (Mulberry32) + */ +function seededRandom(seed: number) { + return () => { + seed = (seed + 0x6d2b79f5) | 0 + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 } - return generator } export async function POST(request: NextRequest) { + let tempDir: string | null = null + try { - const config = await request.json() + const config: FlashcardConfig = await request.json() + const { + range = '0-99', + step = 1, + cardsPerPage = 6, + paperSize = 'us-letter', + orientation = 'portrait', + margins, + gutter = '5mm', + shuffle = false, + seed, + showCutMarks = false, + showRegistration = false, + beadShape = 'diamond', + colorScheme = 'place-value', + colorPalette = 'default', + hideInactiveBeads = false, + showEmptyColumns = false, + columns = 'auto', + scaleFactor = 0.9, + coloredNumerals = false, + format = 'pdf', + } = config - // Debug: log the received config - console.log('📥 Received config:', JSON.stringify(config, null, 2)) + // Dynamic import to avoid Next.js bundler issues + const { renderToStaticMarkup } = await import('react-dom/server') - // Ensure range is set with a default - if (!config.range) { - console.log('⚠️ No range provided, using default: 0-99') - config.range = '0-99' + // Create temp directory for SVG files + tempDir = join(tmpdir(), `flashcards-${Date.now()}-${Math.random()}`) + mkdirSync(tempDir, { recursive: true }) + + // Get all numbers + let numbers = parseRange(range, step) + + // Apply shuffle if requested + if (shuffle) { + numbers = shuffleWithSeed(numbers, seed) } - // Get generator instance - const gen = await getGenerator() + if (numbers.length === 0) { + return NextResponse.json({ error: 'No valid numbers in range' }, { status: 400 }) + } - // Check dependencies before generating - const deps = await gen.checkDependencies?.() - if (deps && (!deps.python || !deps.typst)) { + // Generate SVG files for each card (front and back) + const svgConfig = { + beadShape, + colorScheme, + colorPalette, + hideInactiveBeads, + showEmptyColumns, + columns: columns === 'auto' ? 'auto' : Number(columns), + scaleFactor, + coloredNumerals, + } + + for (let i = 0; i < numbers.length; i++) { + const num = numbers[i] + + // Generate front (abacus) + const frontElement = generateFlashcardFront(num, svgConfig) + const frontSvg = renderToStaticMarkup(frontElement) + writeFileSync(join(tempDir, `card_${i}_front.svg`), frontSvg) + + // Generate back (numeral) + const backElement = generateFlashcardBack(num, svgConfig) + const backSvg = renderToStaticMarkup(backElement) + writeFileSync(join(tempDir, `card_${i}_back.svg`), backSvg) + } + + // Calculate paper dimensions and layout + const paperDimensions = { + 'us-letter': { width: 8.5, height: 11 }, + a4: { width: 8.27, height: 11.69 }, + a3: { width: 11.69, height: 16.54 }, + a5: { width: 5.83, height: 8.27 }, + } + + const paper = paperDimensions[paperSize] || paperDimensions['us-letter'] + const [pageWidth, pageHeight] = + orientation === 'landscape' ? [paper.height, paper.width] : [paper.width, paper.height] + + // Calculate grid layout (typically 2 columns × 3 rows for 6 cards) + const cols = 2 + const rows = Math.ceil(cardsPerPage / cols) + + // Use provided margins or defaults + const margin = { + top: margins?.top || '0.5in', + bottom: margins?.bottom || '0.5in', + left: margins?.left || '0.5in', + right: margins?.right || '0.5in', + } + + // Parse gutter (convert from string like "5mm" to inches for calculation) + const gutterInches = parseFloat(gutter) / 25.4 // Rough mm to inch conversion + + // Calculate available space (approximate, Typst will handle exact layout) + const marginInches = 0.5 // Simplified for now + const availableWidth = pageWidth - 2 * marginInches - gutterInches * (cols - 1) + const availableHeight = pageHeight - 2 * marginInches - gutterInches * (rows - 1) + const cardWidth = availableWidth / cols + const cardHeight = availableHeight / rows + + // Generate pages + const totalPages = Math.ceil(numbers.length / cardsPerPage) + const pages: string[] = [] + + for (let pageNum = 0; pageNum < totalPages; pageNum++) { + const startIdx = pageNum * cardsPerPage + const endIdx = Math.min(startIdx + cardsPerPage, numbers.length) + const pageCards = [] + + for (let i = startIdx; i < endIdx; i++) { + pageCards.push( + ` image("card_${i}_front.svg", width: ${cardWidth}in, height: ${cardHeight}in, fit: "contain")` + ) + } + + // Fill remaining slots with empty cells if needed + const remaining = cardsPerPage - pageCards.length + for (let i = 0; i < remaining; i++) { + pageCards.push(` []`) // Empty cell + } + + pages.push(`#grid( + columns: ${cols}, + rows: ${rows}, + column-gutter: ${gutter}, + row-gutter: ${gutter}, +${pageCards.join(',\n')} +)`) + } + + // Generate Typst document + const typstContent = ` +#set page( + paper: "${paperSize}", + margin: (x: ${margin.left}, y: ${margin.top}), + flipped: ${orientation === 'landscape'}, +) + +${pages.join('\n\n#pagebreak()\n\n')} +` + + // Compile with Typst + let pdfBuffer: Buffer + try { + pdfBuffer = execSync('typst compile --format pdf - -', { + input: typstContent, + cwd: tempDir, // Run in temp dir so relative paths work + maxBuffer: 100 * 1024 * 1024, // 100MB limit for large sets + }) + } catch (error) { + console.error('Typst compilation error:', error) return NextResponse.json( - { - error: 'Missing system dependencies', - details: { - python: deps.python ? '✅ Available' : '❌ Missing Python 3', - typst: deps.typst ? '✅ Available' : '❌ Missing Typst', - qpdf: deps.qpdf ? '✅ Available' : '⚠️ Missing qpdf (optional)', - }, - }, + { error: 'Failed to compile PDF. Is Typst installed?' }, { status: 500 } ) } - // Generate flashcards using Python via TypeScript bindings - console.log('🚀 Generating flashcards with config:', JSON.stringify(config, null, 2)) - const result = await gen.generate(config) + // Clean up temp directory + rmSync(tempDir, { recursive: true, force: true }) + tempDir = null - // SorobanGenerator.generate() returns PDF data directly as Buffer - if (!Buffer.isBuffer(result)) { - throw new Error(`Expected PDF Buffer from generator, got: ${typeof result}`) - } - const pdfBuffer = result // Create filename for download - const filename = `soroban-flashcards-${config.range || 'cards'}.pdf` + const filename = `soroban-flashcards-${range}.pdf` // Return PDF directly as download - return new NextResponse(new Uint8Array(pdfBuffer), { + return new NextResponse(pdfBuffer, { headers: { 'Content-Type': 'application/pdf', 'Content-Disposition': `attachment; filename="${filename}"`, @@ -70,70 +239,45 @@ export async function POST(request: NextRequest) { }, }) } catch (error) { - console.error('❌ Generation failed:', error) + console.error('Error generating flashcards:', error) + // Clean up temp directory if it exists + if (tempDir) { + try { + rmSync(tempDir, { recursive: true, force: true }) + } catch (cleanupError) { + console.error('Failed to clean up temp directory:', cleanupError) + } + } + + const errorMessage = error instanceof Error ? error.message : String(error) return NextResponse.json( - { - error: 'Failed to generate flashcards', - details: error instanceof Error ? error.message : 'Unknown error', - success: false, - }, + { error: 'Failed to generate flashcards', message: errorMessage }, { status: 500 } ) } } -// Helper functions to calculate metadata -function _calculateCardCount(range: string, step: number): number { - if (range.includes('-')) { - const [start, end] = range.split('-').map((n) => parseInt(n, 10) || 0) - return Math.floor((end - start + 1) / step) - } - - if (range.includes(',')) { - return range.split(',').length - } - - return 1 -} - -function _generateNumbersFromRange(range: string, step: number): number[] { - if (range.includes('-')) { - const [start, end] = range.split('-').map((n) => parseInt(n, 10) || 0) - const numbers: number[] = [] - for (let i = start; i <= end; i += step) { - numbers.push(i) - if (numbers.length >= 100) break // Limit to prevent huge arrays - } - return numbers - } - - if (range.includes(',')) { - return range.split(',').map((n) => parseInt(n.trim(), 10) || 0) - } - - return [parseInt(range, 10) || 0] -} - // Health check endpoint export async function GET() { try { - const gen = await getGenerator() - const deps = (await gen.checkDependencies?.()) || { - python: true, - typst: true, - qpdf: true, - } + // Check if Typst is available + execSync('typst --version', { encoding: 'utf8' }) return NextResponse.json({ status: 'healthy', - dependencies: deps, + generator: 'typescript-typst', + dependencies: { + typst: true, + python: false, // No longer needed! + }, }) } catch (error) { return NextResponse.json( { status: 'unhealthy', - error: error instanceof Error ? error.message : 'Unknown error', + error: 'Typst not available', + message: error instanceof Error ? error.message : 'Unknown error', }, { status: 500 } ) diff --git a/apps/web/src/app/create/flashcards/page.tsx b/apps/web/src/app/create/flashcards/page.tsx index 867f1b46..54d9d277 100644 --- a/apps/web/src/app/create/flashcards/page.tsx +++ b/apps/web/src/app/create/flashcards/page.tsx @@ -6,7 +6,7 @@ import { useTranslations } from 'next-intl' import { useState } from 'react' import { ConfigurationFormWithoutGenerate } from '@/components/ConfigurationFormWithoutGenerate' import { GenerationProgress } from '@/components/GenerationProgress' -import { LivePreview } from '@/components/LivePreview' +import { FlashcardPreview } from '@/components/FlashcardPreview' import { PageWithNav } from '@/components/PageWithNav' import { StyleControls } from '@/components/StyleControls' import { css } from '../../../../styled-system/css' @@ -281,7 +281,7 @@ export default function CreatePage() {
state} - children={(state) => } + children={(state) => } /> {/* Generate Button within Preview */} diff --git a/apps/web/src/components/FlashcardPreview.tsx b/apps/web/src/components/FlashcardPreview.tsx new file mode 100644 index 00000000..7c2abe8c --- /dev/null +++ b/apps/web/src/components/FlashcardPreview.tsx @@ -0,0 +1,142 @@ +'use client' + +import { useQuery } from '@tanstack/react-query' +import { useTranslations } from 'next-intl' +import { css } from '../../styled-system/css' +import type { FlashcardFormState } from '@/app/create/flashcards/page' + +interface FlashcardPreviewProps { + config: Partial +} + +async function fetchFlashcardPreview(config: Partial): Promise { + const response = await fetch('/api/create/flashcards/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.error || errorData.message || 'Failed to fetch preview') + } + + const data = await response.json() + return data.svg +} + +export function FlashcardPreview({ config }: FlashcardPreviewProps) { + const t = useTranslations('create.flashcards') + + // Use React Query to fetch preview (with automatic caching and updates) + const { + data: previewSvg, + isLoading, + error, + } = useQuery({ + queryKey: ['flashcard-preview', config], + queryFn: () => fetchFlashcardPreview(config), + enabled: typeof window !== 'undefined' && !!config.range, + staleTime: 30000, // Consider data fresh for 30 seconds + retry: 1, + }) + + // Show loading state + if (isLoading || !previewSvg) { + return ( +
+
+

+ {isLoading ? t('preview.loading') : t('preview.noPreview')} +

+
+ ) + } + + // Show error state + if (error) { + return ( +
+

+ {t('preview.error')} +

+

+ {error instanceof Error ? error.message : 'Unknown error'} +

+
+ ) + } + + return ( +
+
+ {t('preview.livePreview')} +
+
+
+ {t('preview.hint')} +
+
+ ) +} diff --git a/apps/web/src/utils/flashcards/generateFlashcardSvgs.tsx b/apps/web/src/utils/flashcards/generateFlashcardSvgs.tsx new file mode 100644 index 00000000..4fa8c0dd --- /dev/null +++ b/apps/web/src/utils/flashcards/generateFlashcardSvgs.tsx @@ -0,0 +1,107 @@ +/** + * Generate SVG elements for flashcards using React SSR + * This replaces Python-based SVG generation for better performance + */ + +import type React from 'react' +import { AbacusStatic } from '@soroban/abacus-react/static' + +export interface FlashcardConfig { + beadShape?: 'diamond' | 'circle' | 'square' + colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating' + colorPalette?: 'default' | 'pastel' | 'vibrant' | 'earth-tones' + hideInactiveBeads?: boolean + showEmptyColumns?: boolean + columns?: number | 'auto' + scaleFactor?: number + coloredNumerals?: boolean +} + +/** + * Generate front SVG (abacus) for a flashcard + */ +export function generateFlashcardFront( + value: number, + config: FlashcardConfig = {} +): React.ReactElement { + const { + beadShape = 'diamond', + colorScheme = 'place-value', + colorPalette = 'default', + hideInactiveBeads = false, + showEmptyColumns = false, + columns = 'auto', + scaleFactor = 0.9, + } = config + + return ( + + ) +} + +/** + * Generate back SVG (numeral) for a flashcard + */ +export function generateFlashcardBack( + value: number, + config: FlashcardConfig = {} +): React.ReactElement { + const { coloredNumerals = false, colorScheme = 'place-value' } = config + + // For back, we show just the numeral + // Use a simple SVG with text + const fontSize = 120 + const width = 300 + const height = 200 + + // Get color based on place value if colored numerals enabled + const textColor = + coloredNumerals && colorScheme === 'place-value' ? getPlaceValueColor(value) : '#000000' + + return ( + + + {value} + + + ) +} + +function getPlaceValueColor(value: number): string { + // Simple place value coloring for single digits + const colors = [ + '#ef4444', // red - ones + '#f59e0b', // amber - tens + '#10b981', // emerald - hundreds + '#3b82f6', // blue - thousands + '#8b5cf6', // purple - ten thousands + ] + + const digits = value.toString().length + return colors[(digits - 1) % colors.length] +}