feat(web): add Typst-based preview endpoint with React Suspense

- Create /api/create/calendar/preview endpoint using Typst compilation
- Refactor CalendarPreview to use useSuspenseQuery for data fetching
- Add Suspense boundary in calendar page with loading fallback
- Preview now matches PDF exactly as both use Typst rendering
- Remove post-generation SVG replacement to preserve Typst preview

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-11-04 10:42:53 -06:00
parent e5ba772fde
commit 599a758471
3 changed files with 152 additions and 172 deletions

View File

@@ -0,0 +1,97 @@
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 { generateMonthlyTypst, getDaysInMonth } from '../utils/typstGenerator'
import { generateCalendarComposite } from '@/../../scripts/generateCalendarComposite'
interface PreviewRequest {
month: number
year: number
format: 'monthly' | 'daily'
}
export const dynamic = 'force-dynamic'
export async function POST(request: NextRequest) {
let tempDir: string | null = null
try {
const body: PreviewRequest = await request.json()
const { month, year, format } = body
// Validate inputs
if (!month || month < 1 || month > 12 || !year || year < 1 || year > 9999) {
return NextResponse.json({ error: 'Invalid month or year' }, { status: 400 })
}
// Only generate preview for monthly format
if (format !== 'monthly') {
return NextResponse.json({ svg: null })
}
// Dynamic import to avoid Next.js bundler issues
const { renderToStaticMarkup } = await import('react-dom/server')
// Create temp directory for SVG file
tempDir = join(tmpdir(), `calendar-preview-${Date.now()}-${Math.random()}`)
mkdirSync(tempDir, { recursive: true })
// Generate and write composite SVG
const calendarSvg = generateCalendarComposite({
month,
year,
renderToString: renderToStaticMarkup
})
writeFileSync(join(tempDir, 'calendar.svg'), calendarSvg)
// Generate Typst document content
const daysInMonth = getDaysInMonth(year, month)
const typstContent = generateMonthlyTypst({
month,
year,
paperSize: 'us-letter',
daysInMonth,
})
// 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 }
)
}
}

View File

@@ -1,6 +1,6 @@
'use client'
import { AbacusStatic } from '@soroban/abacus-react/static'
import { useSuspenseQuery } from '@tanstack/react-query'
import { css } from '../../../../../styled-system/css'
interface CalendarPreviewProps {
@@ -10,63 +10,32 @@ interface CalendarPreviewProps {
previewSvg: string | null
}
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
]
async function fetchTypstPreview(month: number, year: number, format: string): Promise<string | null> {
const response = await fetch('/api/create/calendar/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ month, year, format }),
})
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
if (!response.ok) {
throw new Error('Failed to fetch preview')
}
function getDaysInMonth(year: number, month: number): number {
return new Date(year, month, 0).getDate()
}
function getFirstDayOfWeek(year: number, month: number): number {
return new Date(year, month - 1, 1).getDay()
const data = await response.json()
return data.svg
}
export function CalendarPreview({ month, year, format, previewSvg }: CalendarPreviewProps) {
// If we have the generated PDF SVG, show that instead
if (previewSvg) {
return (
<div
data-component="calendar-preview"
className={css({
bg: 'gray.800',
borderRadius: '12px',
padding: '2rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
})}
>
<p
className={css({
fontSize: '1.125rem',
color: 'yellow.400',
marginBottom: '1rem',
textAlign: 'center',
fontWeight: 'bold',
})}
>
Generated PDF Preview
</p>
<div
className={css({
bg: 'white',
borderRadius: '8px',
padding: '1rem',
maxWidth: '100%',
overflow: 'auto',
})}
dangerouslySetInnerHTML={{ __html: previewSvg }}
/>
</div>
)
}
// Use React Query with Suspense to fetch Typst-generated preview
const { data: typstPreviewSvg } = useSuspenseQuery({
queryKey: ['calendar-typst-preview', month, year, format],
queryFn: () => fetchTypstPreview(month, year, format),
})
// For daily format, no live preview
if (format === 'daily') {
// Use generated PDF SVG if available, otherwise use Typst live preview
const displaySvg = previewSvg || typstPreviewSvg
if (!displaySvg) {
return (
<div
data-component="calendar-preview"
@@ -88,27 +57,12 @@ export function CalendarPreview({ month, year, format, previewSvg }: CalendarPre
textAlign: 'center',
})}
>
Daily format - preview after generation
{format === 'daily' ? 'Daily format - preview after generation' : 'No preview available'}
</p>
</div>
)
}
// Live preview: render calendar with React components
const daysInMonth = getDaysInMonth(year, month)
const firstDayOfWeek = getFirstDayOfWeek(year, month)
const monthName = MONTH_NAMES[month - 1]
const yearColumns = Math.max(1, Math.ceil(Math.log10(year + 1)))
// Generate calendar cells
const calendarCells: (number | null)[] = []
for (let i = 0; i < firstDayOfWeek; i++) {
calendarCells.push(null)
}
for (let day = 1; day <= daysInMonth; day++) {
calendarCells.push(day)
}
return (
<div
data-component="calendar-preview"
@@ -130,7 +84,7 @@ export function CalendarPreview({ month, year, format, previewSvg }: CalendarPre
fontWeight: 'bold',
})}
>
Live Preview
{previewSvg ? 'Generated PDF' : 'Live Preview'}
</p>
<div
className={css({
@@ -140,102 +94,8 @@ export function CalendarPreview({ month, year, format, previewSvg }: CalendarPre
maxWidth: '100%',
overflow: 'auto',
})}
>
{/* Calendar Header */}
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginBottom: '1rem',
})}
>
<h2
className={css({
fontSize: '2rem',
fontWeight: 'bold',
marginBottom: '0.5rem',
color: '#1a1a1a',
})}
>
{monthName}
</h2>
<div className={css({ transform: 'scale(0.6)' })}>
<AbacusStatic
value={year}
columns={yearColumns}
scaleFactor={1}
showNumbers={false}
frameVisible={true}
compact={false}
/>
</div>
</div>
{/* Weekday Headers */}
<div
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: '2px',
marginBottom: '2px',
})}
>
{WEEKDAYS.map((day) => (
<div
key={day}
className={css({
textAlign: 'center',
fontWeight: 'bold',
fontSize: '0.875rem',
padding: '0.5rem',
color: '#555',
borderBottom: '2px solid #333',
})}
>
{day}
</div>
))}
</div>
{/* Calendar Grid */}
<div
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: '2px',
bg: '#333',
border: '2px solid #333',
})}
>
{calendarCells.map((day, index) => (
<div
key={index}
className={css({
bg: 'white',
minHeight: '120px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0.25rem',
})}
>
{day !== null && (
<div className={css({ transform: 'scale(0.4)' })}>
<AbacusStatic
value={day}
columns={2}
scaleFactor={1}
showNumbers={false}
frameVisible={true}
compact={false}
/>
</div>
)}
</div>
))}
</div>
</div>
dangerouslySetInnerHTML={{ __html: displaySvg }}
/>
</div>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, Suspense } from 'react'
import { css } from '../../../../styled-system/css'
import { useAbacusConfig } from '@soroban/abacus-react'
import { PageWithNav } from '@/components/PageWithNav'
@@ -41,11 +41,6 @@ export default function CalendarCreatorPage() {
const data = await response.json()
// Store SVG preview for display
if (data.svg) {
setPreviewSvg(data.svg)
}
// Convert base64 PDF to blob and trigger download
const pdfBytes = Uint8Array.from(atob(data.pdf), c => c.charCodeAt(0))
const blob = new Blob([pdfBytes], { type: 'application/pdf' })
@@ -133,7 +128,35 @@ export default function CalendarCreatorPage() {
/>
{/* Preview */}
<CalendarPreview month={month} year={year} format={format} previewSvg={previewSvg} />
<Suspense
fallback={
<div
data-component="calendar-preview"
className={css({
bg: 'gray.800',
borderRadius: '12px',
padding: '2rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '600px',
})}
>
<p
className={css({
fontSize: '1.25rem',
color: 'gray.400',
textAlign: 'center',
})}
>
Loading preview...
</p>
</div>
}
>
<CalendarPreview month={month} year={year} format={format} previewSvg={previewSvg} />
</Suspense>
</div>
</div>
</div>