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:
97
apps/web/src/app/api/create/calendar/preview/route.ts
Normal file
97
apps/web/src/app/api/create/calendar/preview/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user