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:
@@ -5,6 +5,7 @@ import { join } from 'path'
|
|||||||
import { execSync } from 'child_process'
|
import { execSync } from 'child_process'
|
||||||
import { generateMonthlyTypst, getDaysInMonth } from '../utils/typstGenerator'
|
import { generateMonthlyTypst, getDaysInMonth } from '../utils/typstGenerator'
|
||||||
import { generateCalendarComposite } from '@/utils/calendar/generateCalendarComposite'
|
import { generateCalendarComposite } from '@/utils/calendar/generateCalendarComposite'
|
||||||
|
import { generateAbacusElement } from '@/utils/calendar/generateCalendarAbacus'
|
||||||
|
|
||||||
interface PreviewRequest {
|
interface PreviewRequest {
|
||||||
month: number
|
month: number
|
||||||
@@ -26,34 +27,137 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Invalid month or year' }, { status: 400 })
|
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
|
// Dynamic import to avoid Next.js bundler issues
|
||||||
const { renderToStaticMarkup } = await import('react-dom/server')
|
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()}`)
|
tempDir = join(tmpdir(), `calendar-preview-${Date.now()}-${Math.random()}`)
|
||||||
mkdirSync(tempDir, { recursive: true })
|
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
|
// Generate Typst document content
|
||||||
const daysInMonth = getDaysInMonth(year, month)
|
const daysInMonth = getDaysInMonth(year, month)
|
||||||
const typstContent = generateMonthlyTypst({
|
let typstContent: string
|
||||||
month,
|
|
||||||
year,
|
if (format === 'monthly') {
|
||||||
paperSize: 'us-letter',
|
// Generate and write composite SVG
|
||||||
daysInMonth,
|
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
|
// Compile with Typst: stdin for .typ content, stdout for SVG output
|
||||||
let svg: string
|
let svg: string
|
||||||
|
|||||||
@@ -95,43 +95,93 @@ export function generateDailyTypst(config: TypstDailyConfig): string {
|
|||||||
paper: "${paperConfig.typstName}",
|
paper: "${paperConfig.typstName}",
|
||||||
margin: (x: ${paperConfig.marginX}, y: ${paperConfig.marginY}),
|
margin: (x: ${paperConfig.marginX}, y: ${paperConfig.marginY}),
|
||||||
)[
|
)[
|
||||||
// Header: Year
|
#set text(font: "Georgia")
|
||||||
#align(center)[
|
|
||||||
#v(1em)
|
// Decorative borders
|
||||||
#image("year.svg", width: 30%)
|
#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 ? '' : ''}`
|
${day < daysInMonth ? '' : ''}`
|
||||||
@@ -141,7 +191,5 @@ ${day < daysInMonth ? '' : ''}`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return `#set text(font: "Arial")
|
return pages
|
||||||
${pages}
|
|
||||||
`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ async function fetchTypstPreview(
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
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()
|
const data = await response.json()
|
||||||
@@ -34,14 +35,14 @@ export function CalendarPreview({ month, year, format, previewSvg }: CalendarPre
|
|||||||
const { data: typstPreviewSvg, isLoading } = useQuery({
|
const { data: typstPreviewSvg, isLoading } = useQuery({
|
||||||
queryKey: ['calendar-typst-preview', month, year, format],
|
queryKey: ['calendar-typst-preview', month, year, format],
|
||||||
queryFn: () => fetchTypstPreview(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
|
// Use generated PDF SVG if available, otherwise use Typst live preview
|
||||||
const displaySvg = previewSvg || typstPreviewSvg
|
const displaySvg = previewSvg || typstPreviewSvg
|
||||||
|
|
||||||
// Show loading state while fetching preview
|
// Show loading state while fetching preview
|
||||||
if (isLoading || (!displaySvg && format === 'monthly')) {
|
if (isLoading || !displaySvg) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-component="calendar-preview"
|
data-component="calendar-preview"
|
||||||
@@ -63,35 +64,7 @@ export function CalendarPreview({ month, year, format, previewSvg }: CalendarPre
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
Loading preview...
|
{isLoading ? 'Loading preview...' : 'No preview available'}
|
||||||
</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'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -118,7 +91,11 @@ export function CalendarPreview({ month, year, format, previewSvg }: CalendarPre
|
|||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{previewSvg ? 'Generated PDF' : 'Live Preview'}
|
{previewSvg
|
||||||
|
? 'Generated PDF'
|
||||||
|
: format === 'daily'
|
||||||
|
? 'Live Preview (First Day)'
|
||||||
|
: 'Live Preview'}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { css } from '../../../../styled-system/css'
|
import { css } from '../../../../styled-system/css'
|
||||||
import { useAbacusConfig } from '@soroban/abacus-react'
|
import { useAbacusConfig } from '@soroban/abacus-react'
|
||||||
import { PageWithNav } from '@/components/PageWithNav'
|
import { PageWithNav } from '@/components/PageWithNav'
|
||||||
@@ -17,6 +17,19 @@ export default function CalendarCreatorPage() {
|
|||||||
const [isGenerating, setIsGenerating] = useState(false)
|
const [isGenerating, setIsGenerating] = useState(false)
|
||||||
const [previewSvg, setPreviewSvg] = useState<string | null>(null)
|
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 () => {
|
const handleGenerate = async () => {
|
||||||
setIsGenerating(true)
|
setIsGenerating(true)
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user