feat(calendar): add beautiful daily calendar with locale-based paper size detection
Implement fully-featured daily calendar generation with enhanced visual design: Daily Calendar Features: - Beautiful single-page-per-day design with decorative borders - Blue double-border frame for visual polish - Light blue header section with uppercase month name (Georgia serif) - Large prominent day-of-week text (42pt) - 2.5x larger day abacus as main focal point - Styled notes section with warm yellow background - Full support for all paper sizes (US Letter, A4, A3, Tabloid) Preview System: - Add live preview support for daily calendars - Generate composite SVG for preview (avoid Typst multi-page export issues) - Show "Live Preview (First Day)" label for daily format - Use same composite SVG approach as monthly calendars Locale Detection: - Auto-detect paper size from browser locale on page load - US Letter for US, CA, MX, GT, PA, DO, PR, PH - A4 for all other countries - Hardcoded mapping (stable, no external deps needed) Code Quality: - Remove all debug console.log statements - Fix unused imports - Format and lint all files Technical Details: - Daily preview creates single composite SVG with embedded abacuses - Uses Typst's responsive layout (percentages) for multi-size support - Matches monthly calendar's single-image-export pattern - Full PDF generation uses Typst #page() for multi-page output 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
651b14f630
commit
bdca3154f8
|
|
@ -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 <svg> tags)
|
||||
const yearSvgContent = yearSvg.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')
|
||||
const daySvgContent = daySvg.replace(/<svg[^>]*>/, '').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 = `<svg xmlns="http://www.w3.org/2000/svg" width="${compositeWidth}" height="${compositeHeight}" viewBox="0 0 ${compositeWidth} ${compositeHeight}">
|
||||
<!-- Background -->
|
||||
<rect width="${compositeWidth}" height="${compositeHeight}" fill="white"/>
|
||||
|
||||
<!-- Decorative border -->
|
||||
<rect x="40" y="40" width="${compositeWidth - 80}" height="${compositeHeight - 80}" fill="none" stroke="#2563eb" stroke-width="3" rx="8"/>
|
||||
<rect x="50" y="50" width="${compositeWidth - 100}" height="${compositeHeight - 100}" fill="none" stroke="#2563eb" stroke-width="1" rx="4"/>
|
||||
|
||||
<!-- Header section with background -->
|
||||
<rect x="70" y="70" width="${compositeWidth - 140}" height="120" fill="#eff6ff" stroke="#2563eb" stroke-width="2" rx="6"/>
|
||||
|
||||
<!-- Month name -->
|
||||
<text x="${compositeWidth / 2}" y="125" text-anchor="middle" font-family="Georgia, serif" font-size="48" font-weight="bold" fill="#1e40af" letter-spacing="2">
|
||||
${monthName.toUpperCase()}
|
||||
</text>
|
||||
|
||||
<!-- Year abacus (smaller, in header) -->
|
||||
<svg x="${compositeWidth / 2 - yearAbacusWidth * 0.4}" y="140" width="${yearAbacusWidth * 0.8}" height="${yearAbacusHeight * 0.8}" viewBox="0 0 ${yearAbacusWidth} ${yearAbacusHeight}">
|
||||
${yearSvgContent}
|
||||
</svg>
|
||||
|
||||
<!-- Day of week (large and prominent) -->
|
||||
<text x="${compositeWidth / 2}" y="260" text-anchor="middle" font-family="Georgia, serif" font-size="42" font-weight="bold" fill="#1e3a8a">
|
||||
${dayOfWeek}
|
||||
</text>
|
||||
|
||||
<!-- Day abacus (much larger, main focus) -->
|
||||
<svg x="${compositeWidth / 2 - dayAbacusWidth * 1.25}" y="300" width="${dayAbacusWidth * 2.5}" height="${dayAbacusHeight * 2.5}" viewBox="0 0 ${dayAbacusWidth} ${dayAbacusHeight}">
|
||||
${daySvgContent}
|
||||
</svg>
|
||||
|
||||
<!-- Full date (below day abacus) -->
|
||||
<text x="${compositeWidth / 2}" y="890" text-anchor="middle" font-family="Georgia, serif" font-size="24" font-weight="500" fill="#475569">
|
||||
${monthName} 1, ${year}
|
||||
</text>
|
||||
|
||||
<!-- Notes section with decorative box -->
|
||||
<rect x="70" y="930" width="${compositeWidth - 140}" height="120" fill="#fefce8" stroke="#ca8a04" stroke-width="2" rx="4"/>
|
||||
<text x="90" y="960" font-family="Georgia, serif" font-size="18" font-weight="bold" fill="#854d0e">
|
||||
Notes:
|
||||
</text>
|
||||
<line x1="90" y1="980" x2="${compositeWidth - 90}" y2="980" stroke="#ca8a04" stroke-width="1"/>
|
||||
<line x1="90" y1="1005" x2="${compositeWidth - 90}" y2="1005" stroke="#ca8a04" stroke-width="1"/>
|
||||
<line x1="90" y1="1030" x2="${compositeWidth - 90}" y2="1030" stroke="#ca8a04" stroke-width="1"/>
|
||||
</svg>`
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
data-component="calendar-preview"
|
||||
|
|
@ -63,35 +64,7 @@ export function CalendarPreview({ month, year, format, previewSvg }: CalendarPre
|
|||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Loading preview...
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!displaySvg) {
|
||||
return (
|
||||
<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',
|
||||
})}
|
||||
>
|
||||
{format === 'daily' ? 'Daily format - preview after generation' : 'No preview available'}
|
||||
{isLoading ? 'Loading preview...' : 'No preview available'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -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'}
|
||||
</p>
|
||||
<div
|
||||
className={css({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
|
|
@ -17,6 +17,19 @@ export default function CalendarCreatorPage() {
|
|||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [previewSvg, setPreviewSvg] = useState<string | null>(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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue