diff --git a/apps/web/src/app/api/create/calendar/preview/route.ts b/apps/web/src/app/api/create/calendar/preview/route.ts index 33a1740f..997cb7fd 100644 --- a/apps/web/src/app/api/create/calendar/preview/route.ts +++ b/apps/web/src/app/api/create/calendar/preview/route.ts @@ -5,6 +5,7 @@ import { join } from 'path' import { execSync } from 'child_process' import { generateMonthlyTypst, getDaysInMonth } from '../utils/typstGenerator' import { generateCalendarComposite } from '@/utils/calendar/generateCalendarComposite' +import { generateAbacusElement } from '@/utils/calendar/generateCalendarAbacus' interface PreviewRequest { month: number @@ -26,34 +27,137 @@ export async function POST(request: NextRequest) { 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 + // Create temp directory for SVG file(s) 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, - }) + let typstContent: string + + if (format === 'monthly') { + // Generate and write composite SVG + const calendarSvg = generateCalendarComposite({ + month, + year, + renderToString: renderToStaticMarkup, + }) + writeFileSync(join(tempDir, 'calendar.svg'), calendarSvg) + + typstContent = generateMonthlyTypst({ + month, + year, + paperSize: 'us-letter', + daysInMonth, + }) + } else { + // Daily format: Create a SINGLE composite SVG (like monthly) to avoid multi-image export issue + + // Generate individual abacus SVGs + const daySvg = renderToStaticMarkup(generateAbacusElement(1, 2)) + if (!daySvg || daySvg.trim().length === 0) { + throw new Error('Generated empty SVG for day 1') + } + + const yearColumns = Math.max(1, Math.ceil(Math.log10(year + 1))) + const yearSvg = renderToStaticMarkup(generateAbacusElement(year, yearColumns)) + if (!yearSvg || yearSvg.trim().length === 0) { + throw new Error(`Generated empty SVG for year ${year}`) + } + + // Create composite SVG with both year and day abacus + const monthName = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ][month - 1] + const dayOfWeek = new Date(year, month - 1, 1).toLocaleDateString('en-US', { + weekday: 'long', + }) + + // Extract SVG content (remove outer tags) + const yearSvgContent = yearSvg.replace(/]*>/, '').replace(/<\/svg>$/, '') + const daySvgContent = daySvg.replace(/]*>/, '').replace(/<\/svg>$/, '') + + // Create composite SVG (850x1100 = US Letter aspect ratio) + const compositeWidth = 850 + const compositeHeight = 1100 + const yearAbacusWidth = 120 // Natural width at scale 1 + const yearAbacusHeight = 230 + const dayAbacusWidth = 120 + const dayAbacusHeight = 230 + + const compositeSvg = ` + + + + + + + + + + + + + ${monthName.toUpperCase()} + + + + + ${yearSvgContent} + + + + + ${dayOfWeek} + + + + + ${daySvgContent} + + + + + ${monthName} 1, ${year} + + + + + + Notes: + + + + +` + + writeFileSync(join(tempDir, 'daily-preview.svg'), compositeSvg) + + // Use single composite image (like monthly) + typstContent = `#set page( + paper: "us-letter", + margin: (x: 0.5in, y: 0.5in), +) + +#align(center + horizon)[ + #image("daily-preview.svg", width: 100%, fit: "contain") +] +` + } // Compile with Typst: stdin for .typ content, stdout for SVG output let svg: string diff --git a/apps/web/src/app/api/create/calendar/utils/typstGenerator.ts b/apps/web/src/app/api/create/calendar/utils/typstGenerator.ts index f50e789b..98ee086c 100644 --- a/apps/web/src/app/api/create/calendar/utils/typstGenerator.ts +++ b/apps/web/src/app/api/create/calendar/utils/typstGenerator.ts @@ -95,43 +95,93 @@ export function generateDailyTypst(config: TypstDailyConfig): string { paper: "${paperConfig.typstName}", margin: (x: ${paperConfig.marginX}, y: ${paperConfig.marginY}), )[ - // Header: Year - #align(center)[ - #v(1em) - #image("year.svg", width: 30%) + #set text(font: "Georgia") + + // Decorative borders + #rect( + width: 100%, + height: 100%, + stroke: (paint: rgb("#2563eb"), thickness: 3pt), + radius: 8pt, + inset: 0pt, + )[ + #rect( + width: 100%, + height: 100%, + stroke: (paint: rgb("#2563eb"), thickness: 1pt), + radius: 4pt, + inset: 10pt, + )[ + #v(10pt) + + // Header section with background + #rect( + width: 100%, + height: 90pt, + fill: rgb("#eff6ff"), + stroke: (paint: rgb("#2563eb"), thickness: 2pt), + radius: 6pt, + )[ + #align(center)[ + #v(15pt) + #text(size: 32pt, weight: "bold", fill: rgb("#1e40af"), tracking: 2pt)[ + ${monthName.toUpperCase()} + ] + #v(5pt) + #image("year.svg", width: 15%) + ] + ] + + #v(15pt) + + // Day of week (large and prominent) + #align(center)[ + #text(size: 28pt, weight: "bold", fill: rgb("#1e3a8a"))[ + ${dayOfWeek} + ] + ] + + #v(10pt) + + // Day abacus (main focus, large) + #align(center)[ + #image("day-${day}.svg", width: 45%) + ] + + #v(10pt) + + // Full date + #align(center)[ + #text(size: 18pt, weight: 500, fill: rgb("#475569"))[ + ${monthName} ${day}, ${year} + ] + ] + + #v(1fr) + + // Notes section with decorative box + #rect( + width: 100%, + height: 90pt, + fill: rgb("#fefce8"), + stroke: (paint: rgb("#ca8a04"), thickness: 2pt), + radius: 4pt, + )[ + #v(8pt) + #text(size: 14pt, weight: "bold", fill: rgb("#854d0e"))[ + #h(10pt) Notes: + ] + #v(8pt) + #line(length: 95%, stroke: (paint: rgb("#ca8a04"), thickness: 1pt)) + #v(8pt) + #line(length: 95%, stroke: (paint: rgb("#ca8a04"), thickness: 1pt)) + #v(8pt) + #line(length: 95%, stroke: (paint: rgb("#ca8a04"), thickness: 1pt)) + ] + + #v(10pt) + ] ] - - #v(2em) - - // Main: Day number as large abacus - #align(center + horizon)[ - #image("day-${day}.svg", width: 50%) - ] - - #v(2em) - - // Footer: Day of week and date - #align(center)[ - #text(size: 18pt, weight: "bold")[${dayOfWeek}] - - #v(0.5em) - - #text(size: 14pt)[${monthName} ${day}, ${year}] - ] - - // Notes section - #v(3em) - #line(length: 100%, stroke: 0.5pt) - #v(0.5em) - #text(size: 10pt, fill: gray)[Notes:] - #v(0.5em) - #line(length: 100%, stroke: 0.5pt) - #v(1em) - #line(length: 100%, stroke: 0.5pt) - #v(1em) - #line(length: 100%, stroke: 0.5pt) - #v(1em) - #line(length: 100%, stroke: 0.5pt) ] ${day < daysInMonth ? '' : ''}` @@ -141,7 +191,5 @@ ${day < daysInMonth ? '' : ''}` } } - return `#set text(font: "Arial") -${pages} -` + return pages } diff --git a/apps/web/src/app/create/calendar/components/CalendarPreview.tsx b/apps/web/src/app/create/calendar/components/CalendarPreview.tsx index f58a2f90..658ff911 100644 --- a/apps/web/src/app/create/calendar/components/CalendarPreview.tsx +++ b/apps/web/src/app/create/calendar/components/CalendarPreview.tsx @@ -22,7 +22,8 @@ async function fetchTypstPreview( }) if (!response.ok) { - throw new Error('Failed to fetch preview') + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.error || errorData.message || 'Failed to fetch preview') } const data = await response.json() @@ -34,14 +35,14 @@ export function CalendarPreview({ month, year, format, previewSvg }: CalendarPre const { data: typstPreviewSvg, isLoading } = useQuery({ queryKey: ['calendar-typst-preview', month, year, format], queryFn: () => fetchTypstPreview(month, year, format), - enabled: typeof window !== 'undefined' && format === 'monthly', // Only run on client and for monthly format + enabled: typeof window !== 'undefined', // Run on client for both formats }) // Use generated PDF SVG if available, otherwise use Typst live preview const displaySvg = previewSvg || typstPreviewSvg // Show loading state while fetching preview - if (isLoading || (!displaySvg && format === 'monthly')) { + if (isLoading || !displaySvg) { return (
- Loading preview... -

-
- ) - } - - if (!displaySvg) { - return ( -
-

- {format === 'daily' ? 'Daily format - preview after generation' : 'No preview available'} + {isLoading ? 'Loading preview...' : 'No preview available'}

) @@ -118,7 +91,11 @@ export function CalendarPreview({ month, year, format, previewSvg }: CalendarPre fontWeight: 'bold', })} > - {previewSvg ? 'Generated PDF' : 'Live Preview'} + {previewSvg + ? 'Generated PDF' + : format === 'daily' + ? 'Live Preview (First Day)' + : 'Live Preview'}

(null) + // Detect default paper size based on user's locale (client-side only) + useEffect(() => { + // Get user's locale + const locale = navigator.language || navigator.languages?.[0] || 'en-US' + const country = locale.split('-')[1]?.toUpperCase() + + // Countries that use US Letter (8.5" × 11") + const letterCountries = ['US', 'CA', 'MX', 'GT', 'PA', 'DO', 'PR', 'PH'] + + const detectedSize = letterCountries.includes(country || '') ? 'us-letter' : 'a4' + setPaperSize(detectedSize) + }, []) + const handleGenerate = async () => { setIsGenerating(true) try {