feat(calendar): add i18n support and cropped abacus day numbers

This commit implements two enhancements to the calendar feature:

1. i18n Support:
   - Created translation files for all 7 languages (en, de, ja, hi, es, la, goh)
   - Updated CalendarConfigPanel to use translations for UI labels
   - Updated CalendarPreview to use translations for status messages
   - Updated calendar page to use translations for page title/subtitle
   - Added calendarMessages export and integrated into main messages.ts

2. Cropped Abacus Day Numbers:
   - Enabled cropToActiveBeads feature for monthly calendar day abacuses
   - Abacuses now fill calendar cells more efficiently
   - Extracts cropped viewBox from rendered SVG for proper scaling
   - Uses custom padding (top: 8, bottom: 2, left: 5, right: 5)
   - Dynamically scales cropped abacuses to fit cells

🤖 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-05 09:42:41 -06:00
parent b9416883b6
commit 5242f890f7
13 changed files with 514 additions and 39 deletions

View File

@ -1,5 +1,6 @@
'use client'
import { useTranslations } from 'next-intl'
import { css } from '../../../../../styled-system/css'
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
import { AbacusDisplayDropdown } from '@/components/AbacusDisplayDropdown'
@ -17,21 +18,6 @@ interface CalendarConfigPanelProps {
onGenerate: () => void
}
const MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
]
export function CalendarConfigPanel({
month,
year,
@ -44,8 +30,24 @@ export function CalendarConfigPanel({
onPaperSizeChange,
onGenerate,
}: CalendarConfigPanelProps) {
const t = useTranslations('calendar')
const abacusConfig = useAbacusConfig()
const MONTHS = [
t('months.january'),
t('months.february'),
t('months.march'),
t('months.april'),
t('months.may'),
t('months.june'),
t('months.july'),
t('months.august'),
t('months.september'),
t('months.october'),
t('months.november'),
t('months.december'),
]
return (
<div
data-component="calendar-config-panel"
@ -75,7 +77,7 @@ export function CalendarConfigPanel({
color: 'yellow.400',
})}
>
Calendar Format
{t('format.title')}
</legend>
<div
className={css({
@ -104,7 +106,7 @@ export function CalendarConfigPanel({
cursor: 'pointer',
})}
/>
<span>Monthly Calendar (one page per month)</span>
<span>{t('format.monthly')}</span>
</label>
<label
className={css({
@ -126,7 +128,7 @@ export function CalendarConfigPanel({
cursor: 'pointer',
})}
/>
<span>Daily Calendar (one page per day)</span>
<span>{t('format.daily')}</span>
</label>
</div>
</fieldset>
@ -148,7 +150,7 @@ export function CalendarConfigPanel({
color: 'yellow.400',
})}
>
Date
{t('date.title')}
</legend>
<div
className={css({
@ -216,7 +218,7 @@ export function CalendarConfigPanel({
color: 'yellow.400',
})}
>
Paper Size
{t('paperSize.title')}
</legend>
<select
data-element="paper-size-select"
@ -236,10 +238,10 @@ export function CalendarConfigPanel({
_hover: { borderColor: 'gray.500' },
})}
>
<option value="us-letter">US Letter (8.5" × 11")</option>
<option value="a4">A4 (210mm × 297mm)</option>
<option value="a3">A3 (297mm × 420mm)</option>
<option value="tabloid">Tabloid (11" × 17")</option>
<option value="us-letter">{t('paperSize.usLetter')}</option>
<option value="a4">{t('paperSize.a4')}</option>
<option value="a3">{t('paperSize.a3')}</option>
<option value="tabloid">{t('paperSize.tabloid')}</option>
</select>
</fieldset>
@ -259,7 +261,7 @@ export function CalendarConfigPanel({
color: 'gray.300',
})}
>
Calendar abacus style preview:
{t('styling.preview')}
</p>
<div
className={css({
@ -312,7 +314,7 @@ export function CalendarConfigPanel({
},
})}
>
{isGenerating ? 'Generating PDF...' : 'Generate PDF Calendar'}
{isGenerating ? t('generate.generating') : t('generate.button')}
</button>
</div>
)

View File

@ -1,6 +1,7 @@
'use client'
import { useQuery } from '@tanstack/react-query'
import { useTranslations } from 'next-intl'
import { css } from '../../../../../styled-system/css'
interface CalendarPreviewProps {
@ -31,6 +32,7 @@ async function fetchTypstPreview(
}
export function CalendarPreview({ month, year, format, previewSvg }: CalendarPreviewProps) {
const t = useTranslations('calendar')
// Use React Query to fetch Typst-generated preview (client-side only)
const { data: typstPreviewSvg, isLoading } = useQuery({
queryKey: ['calendar-typst-preview', month, year, format],
@ -64,7 +66,7 @@ export function CalendarPreview({ month, year, format, previewSvg }: CalendarPre
textAlign: 'center',
})}
>
{isLoading ? 'Loading preview...' : 'No preview available'}
{isLoading ? t('preview.loading') : t('preview.noPreview')}
</p>
</div>
)
@ -92,10 +94,10 @@ export function CalendarPreview({ month, year, format, previewSvg }: CalendarPre
})}
>
{previewSvg
? 'Generated PDF'
? t('preview.generatedPdf')
: format === 'daily'
? 'Live Preview (First Day)'
: 'Live Preview'}
? t('preview.livePreviewFirstDay')
: t('preview.livePreview')}
</p>
<div
className={css({

View File

@ -1,6 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { css } from '../../../../styled-system/css'
import { useAbacusConfig } from '@soroban/abacus-react'
import { PageWithNav } from '@/components/PageWithNav'
@ -8,6 +9,7 @@ import { CalendarConfigPanel } from './components/CalendarConfigPanel'
import { CalendarPreview } from './components/CalendarPreview'
export default function CalendarCreatorPage() {
const t = useTranslations('calendar')
const currentDate = new Date()
const abacusConfig = useAbacusConfig()
const [month, setMonth] = useState(currentDate.getMonth() + 1) // 1-12
@ -108,7 +110,7 @@ export default function CalendarCreatorPage() {
color: 'yellow.400',
})}
>
Create Abacus Calendar
{t('pageTitle')}
</h1>
<p
className={css({
@ -116,7 +118,7 @@ export default function CalendarCreatorPage() {
color: 'gray.300',
})}
>
Generate printable calendars with abacus date numbers
{t('pageSubtitle')}
</p>
</header>

View File

@ -0,0 +1,61 @@
{
"calendar": {
"pageTitle": "Abakus-Kalender erstellen",
"pageSubtitle": "Druckbare Kalender mit Abakus-Datumszahlen erstellen",
"format": {
"title": "Kalenderformat",
"monthly": "Monatskalender (eine Seite pro Monat)",
"daily": "Tageskalender (eine Seite pro Tag)"
},
"date": {
"title": "Datum",
"month": "Monat",
"year": "Jahr"
},
"paperSize": {
"title": "Papiergröße",
"usLetter": "US Letter (8,5\" × 11\")",
"a4": "A4 (210mm × 297mm)",
"a3": "A3 (297mm × 420mm)",
"tabloid": "Tabloid (11\" × 17\")"
},
"styling": {
"preview": "Kalender-Abakus-Stil Vorschau:"
},
"generate": {
"button": "PDF-Kalender erstellen",
"generating": "PDF wird erstellt..."
},
"preview": {
"loading": "Vorschau wird geladen...",
"noPreview": "Keine Vorschau verfügbar",
"generatedPdf": "Erstelltes PDF",
"livePreview": "Live-Vorschau",
"livePreviewFirstDay": "Live-Vorschau (Erster Tag)"
},
"months": {
"january": "Januar",
"february": "Februar",
"march": "März",
"april": "April",
"may": "Mai",
"june": "Juni",
"july": "Juli",
"august": "August",
"september": "September",
"october": "Oktober",
"november": "November",
"december": "Dezember"
},
"weekdays": {
"sunday": "Sonntag",
"monday": "Montag",
"tuesday": "Dienstag",
"wednesday": "Mittwoch",
"thursday": "Donnerstag",
"friday": "Freitag",
"saturday": "Samstag"
},
"notes": "Notizen:"
}
}

View File

@ -0,0 +1,61 @@
{
"calendar": {
"pageTitle": "Create Abacus Calendar",
"pageSubtitle": "Generate printable calendars with abacus date numbers",
"format": {
"title": "Calendar Format",
"monthly": "Monthly Calendar (one page per month)",
"daily": "Daily Calendar (one page per day)"
},
"date": {
"title": "Date",
"month": "Month",
"year": "Year"
},
"paperSize": {
"title": "Paper Size",
"usLetter": "US Letter (8.5\" × 11\")",
"a4": "A4 (210mm × 297mm)",
"a3": "A3 (297mm × 420mm)",
"tabloid": "Tabloid (11\" × 17\")"
},
"styling": {
"preview": "Calendar abacus style preview:"
},
"generate": {
"button": "Generate PDF Calendar",
"generating": "Generating PDF..."
},
"preview": {
"loading": "Loading preview...",
"noPreview": "No preview available",
"generatedPdf": "Generated PDF",
"livePreview": "Live Preview",
"livePreviewFirstDay": "Live Preview (First Day)"
},
"months": {
"january": "January",
"february": "February",
"march": "March",
"april": "April",
"may": "May",
"june": "June",
"july": "July",
"august": "August",
"september": "September",
"october": "October",
"november": "November",
"december": "December"
},
"weekdays": {
"sunday": "Sunday",
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday"
},
"notes": "Notes:"
}
}

View File

@ -0,0 +1,61 @@
{
"calendar": {
"pageTitle": "Crear Calendario de Ábaco",
"pageSubtitle": "Generar calendarios imprimibles con números de fecha de ábaco",
"format": {
"title": "Formato de Calendario",
"monthly": "Calendario Mensual (una página por mes)",
"daily": "Calendario Diario (una página por día)"
},
"date": {
"title": "Fecha",
"month": "Mes",
"year": "Año"
},
"paperSize": {
"title": "Tamaño de Papel",
"usLetter": "Carta US (8.5\" × 11\")",
"a4": "A4 (210mm × 297mm)",
"a3": "A3 (297mm × 420mm)",
"tabloid": "Tabloide (11\" × 17\")"
},
"styling": {
"preview": "Vista previa del estilo de ábaco del calendario:"
},
"generate": {
"button": "Generar Calendario PDF",
"generating": "Generando PDF..."
},
"preview": {
"loading": "Cargando vista previa...",
"noPreview": "No hay vista previa disponible",
"generatedPdf": "PDF Generado",
"livePreview": "Vista Previa en Vivo",
"livePreviewFirstDay": "Vista Previa en Vivo (Primer Día)"
},
"months": {
"january": "Enero",
"february": "Febrero",
"march": "Marzo",
"april": "Abril",
"may": "Mayo",
"june": "Junio",
"july": "Julio",
"august": "Agosto",
"september": "Septiembre",
"october": "Octubre",
"november": "Noviembre",
"december": "Diciembre"
},
"weekdays": {
"sunday": "Domingo",
"monday": "Lunes",
"tuesday": "Martes",
"wednesday": "Miércoles",
"thursday": "Jueves",
"friday": "Viernes",
"saturday": "Sábado"
},
"notes": "Notas:"
}
}

View File

@ -0,0 +1,61 @@
{
"calendar": {
"pageTitle": "Abacus Kalender Giscaffan",
"pageSubtitle": "Drucchāri kalendera mit abacus tagozalun giskaffen",
"format": {
"title": "Kalenderart",
"monthly": "Mānoðkalender (ein sīta per mānoð)",
"daily": "Tagkalender (ein sīta per tag)"
},
"date": {
"title": "Tag",
"month": "Mānoð",
"year": "Jār"
},
"paperSize": {
"title": "Papiergrōzi",
"usLetter": "US Brief (8.5\" × 11\")",
"a4": "A4 (210mm × 297mm)",
"a3": "A3 (297mm × 420mm)",
"tabloid": "Tabloid (11\" × 17\")"
},
"styling": {
"preview": "Kalender abacus stil forasihti:"
},
"generate": {
"button": "PDF Kalender Giskaffen",
"generating": "PDF wirdit giskaffan..."
},
"preview": {
"loading": "Forasihti ladet...",
"noPreview": "Nein forasihti ferfuogbar",
"generatedPdf": "Giskaffan PDF",
"livePreview": "Lebenti Forasihti",
"livePreviewFirstDay": "Lebenti Forasihti (Ēristo Tag)"
},
"months": {
"january": "Hartmānot",
"february": "Hornung",
"march": "Lentzinmānot",
"april": "Ōstarmānot",
"may": "Winnemānot",
"june": "Brāhmānot",
"july": "Hewimānot",
"august": "Aranmānot",
"september": "Witumanot",
"october": "Windurmānot",
"november": "Herbistmānot",
"december": "Heilagmānot"
},
"weekdays": {
"sunday": "Sunnūntag",
"monday": "Mānetag",
"tuesday": "Ziostag",
"wednesday": "Mittawehha",
"thursday": "Donarestag",
"friday": "Frīatag",
"saturday": "Sambaztag"
},
"notes": "Notiziun:"
}
}

View File

@ -0,0 +1,61 @@
{
"calendar": {
"pageTitle": "अबेकस कैलेंडर बनाएं",
"pageSubtitle": "अबेकस तिथि संख्याओं के साथ मुद्रण योग्य कैलेंडर उत्पन्न करें",
"format": {
"title": "कैलेंडर प्रारूप",
"monthly": "मासिक कैलेंडर (प्रति माह एक पृष्ठ)",
"daily": "दैनिक कैलेंडर (प्रति दिन एक पृष्ठ)"
},
"date": {
"title": "तिथि",
"month": "महीना",
"year": "वर्ष"
},
"paperSize": {
"title": "कागज का आकार",
"usLetter": "यूएस लेटर (8.5\" × 11\")",
"a4": "A4 (210mm × 297mm)",
"a3": "A3 (297mm × 420mm)",
"tabloid": "टैबलॉइड (11\" × 17\")"
},
"styling": {
"preview": "कैलेंडर अबेकस शैली पूर्वावलोकन:"
},
"generate": {
"button": "पीडीएफ कैलेंडर उत्पन्न करें",
"generating": "पीडीएफ उत्पन्न हो रहा है..."
},
"preview": {
"loading": "पूर्वावलोकन लोड हो रहा है...",
"noPreview": "कोई पूर्वावलोकन उपलब्ध नहीं",
"generatedPdf": "उत्पन्न पीडीएफ",
"livePreview": "लाइव पूर्वावलोकन",
"livePreviewFirstDay": "लाइव पूर्वावलोकन (पहला दिन)"
},
"months": {
"january": "जनवरी",
"february": "फरवरी",
"march": "मार्च",
"april": "अप्रैल",
"may": "मई",
"june": "जून",
"july": "जुलाई",
"august": "अगस्त",
"september": "सितंबर",
"october": "अक्टूबर",
"november": "नवंबर",
"december": "दिसंबर"
},
"weekdays": {
"sunday": "रविवार",
"monday": "सोमवार",
"tuesday": "मंगलवार",
"wednesday": "बुधवार",
"thursday": "गुरुवार",
"friday": "शुक्रवार",
"saturday": "शनिवार"
},
"notes": "नोट्स:"
}
}

View File

@ -0,0 +1,61 @@
{
"calendar": {
"pageTitle": "そろばんカレンダーを作成",
"pageSubtitle": "そろばんの日付番号付き印刷可能なカレンダーを生成",
"format": {
"title": "カレンダー形式",
"monthly": "月間カレンダー月ごとに1ページ",
"daily": "日めくりカレンダー日ごとに1ページ"
},
"date": {
"title": "日付",
"month": "月",
"year": "年"
},
"paperSize": {
"title": "用紙サイズ",
"usLetter": "USレター (8.5\" × 11\")",
"a4": "A4 (210mm × 297mm)",
"a3": "A3 (297mm × 420mm)",
"tabloid": "タブロイド (11\" × 17\")"
},
"styling": {
"preview": "カレンダーそろばんスタイルプレビュー:"
},
"generate": {
"button": "PDFカレンダーを生成",
"generating": "PDFを生成中..."
},
"preview": {
"loading": "プレビューを読み込み中...",
"noPreview": "プレビューがありません",
"generatedPdf": "生成されたPDF",
"livePreview": "ライブプレビュー",
"livePreviewFirstDay": "ライブプレビュー(初日)"
},
"months": {
"january": "1月",
"february": "2月",
"march": "3月",
"april": "4月",
"may": "5月",
"june": "6月",
"july": "7月",
"august": "8月",
"september": "9月",
"october": "10月",
"november": "11月",
"december": "12月"
},
"weekdays": {
"sunday": "日曜日",
"monday": "月曜日",
"tuesday": "火曜日",
"wednesday": "水曜日",
"thursday": "木曜日",
"friday": "金曜日",
"saturday": "土曜日"
},
"notes": "メモ:"
}
}

View File

@ -0,0 +1,61 @@
{
"calendar": {
"pageTitle": "Calendarium Abaci Creare",
"pageSubtitle": "Calendaria imprimibilia cum numeris diei abaci generare",
"format": {
"title": "Forma Calendarii",
"monthly": "Calendarium Mensuale (una pagina per mensem)",
"daily": "Calendarium Diurnum (una pagina per diem)"
},
"date": {
"title": "Dies",
"month": "Mensis",
"year": "Annus"
},
"paperSize": {
"title": "Magnitudo Chartae",
"usLetter": "US Epistula (8.5\" × 11\")",
"a4": "A4 (210mm × 297mm)",
"a3": "A3 (297mm × 420mm)",
"tabloid": "Tabloid (11\" × 17\")"
},
"styling": {
"preview": "Praevisio stili abaci calendarii:"
},
"generate": {
"button": "Calendarium PDF Generare",
"generating": "PDF Generatur..."
},
"preview": {
"loading": "Praevisio cargatur...",
"noPreview": "Nulla praevisio disponibilis",
"generatedPdf": "PDF Generatum",
"livePreview": "Praevisio Viva",
"livePreviewFirstDay": "Praevisio Viva (Primus Dies)"
},
"months": {
"january": "Ianuarius",
"february": "Februarius",
"march": "Martius",
"april": "Aprilis",
"may": "Maius",
"june": "Iunius",
"july": "Iulius",
"august": "Augustus",
"september": "September",
"october": "October",
"november": "November",
"december": "December"
},
"weekdays": {
"sunday": "Dies Solis",
"monday": "Dies Lunae",
"tuesday": "Dies Martis",
"wednesday": "Dies Mercurii",
"thursday": "Dies Iovis",
"friday": "Dies Veneris",
"saturday": "Dies Saturni"
},
"notes": "Notae:"
}
}

View File

@ -0,0 +1,17 @@
import de from './de.json'
import en from './en.json'
import es from './es.json'
import goh from './goh.json'
import hi from './hi.json'
import ja from './ja.json'
import la from './la.json'
export const calendarMessages = {
en: en.calendar,
de: de.calendar,
ja: ja.calendar,
hi: hi.calendar,
es: es.calendar,
la: la.calendar,
goh: goh.calendar,
} as const

View File

@ -1,4 +1,5 @@
import { rithmomachiaMessages } from '@/arcade-games/rithmomachia/messages'
import { calendarMessages } from '@/i18n/locales/calendar/messages'
import { gamesMessages } from '@/i18n/locales/games/messages'
import { guideMessages } from '@/i18n/locales/guide/messages'
import { homeMessages } from '@/i18n/locales/home/messages'
@ -40,6 +41,7 @@ export async function getMessages(locale: Locale) {
{ games: gamesMessages[locale] },
{ guide: guideMessages[locale] },
{ tutorial: tutorialMessages[locale] },
{ calendar: calendarMessages[locale] },
rithmomachiaMessages[locale]
)
}

View File

@ -117,6 +117,10 @@ export function generateCalendarComposite(options: CalendarCompositeOptions): st
showNumbers={false}
frameVisible={true}
compact={false}
hideInactiveBeads={true}
cropToActiveBeads={{
padding: { top: 8, bottom: 2, left: 5, right: 5 }
}}
/>
)
}
@ -179,22 +183,41 @@ export function generateCalendarComposite(options: CalendarCompositeOptions): st
const cellX = MARGIN + col * CELL_WIDTH
const cellY = GRID_START_Y + WEEKDAY_ROW_HEIGHT + row * DAY_CELL_HEIGHT
// Render cropped abacus SVG
const abacusSVG = renderAbacusSVG(day, 2, 1)
// Extract viewBox and dimensions from the cropped SVG
const viewBoxMatch = abacusSVG.match(/viewBox="([^"]*)"/)
const widthMatch = abacusSVG.match(/width="?([0-9.]+)"?/)
const heightMatch = abacusSVG.match(/height="?([0-9.]+)"?/)
const croppedViewBox = viewBoxMatch ? viewBoxMatch[1] : '0 0 120 230'
const croppedWidth = widthMatch ? parseFloat(widthMatch[1]) : ABACUS_NATURAL_WIDTH
const croppedHeight = heightMatch ? parseFloat(heightMatch[1]) : ABACUS_NATURAL_HEIGHT
// Calculate scale to fit cropped abacus in cell
const MAX_SCALE_X = (CELL_WIDTH - CELL_PADDING * 2) / croppedWidth
const MAX_SCALE_Y = (DAY_CELL_HEIGHT - CELL_PADDING * 2) / croppedHeight
const fitScale = Math.min(MAX_SCALE_X, MAX_SCALE_Y) * 0.95 // 95% to leave breathing room
const scaledWidth = croppedWidth * fitScale
const scaledHeight = croppedHeight * fitScale
// Center abacus in cell
const abacusCenterX = cellX + CELL_WIDTH / 2
const abacusCenterY = cellY + DAY_CELL_HEIGHT / 2
// Offset to top-left corner of abacus (accounting for scaled size)
const abacusX = abacusCenterX - SCALED_ABACUS_WIDTH / 2
const abacusY = abacusCenterY - SCALED_ABACUS_HEIGHT / 2
// Offset to top-left corner of abacus
const abacusX = abacusCenterX - scaledWidth / 2
const abacusY = abacusCenterY - scaledHeight / 2
// Render at scale=1 and let the nested SVG handle scaling via viewBox
const abacusSVG = renderAbacusSVG(day, 2, 1)
// Extract SVG content (remove outer <svg> tags)
const svgContent = abacusSVG.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')
return `
<!-- Day ${day} (row ${row}, col ${col}) -->
<svg x="${abacusX}" y="${abacusY}" width="${SCALED_ABACUS_WIDTH}" height="${SCALED_ABACUS_HEIGHT}"
viewBox="0 0 ${ABACUS_NATURAL_WIDTH} ${ABACUS_NATURAL_HEIGHT}">
<svg x="${abacusX}" y="${abacusY}" width="${scaledWidth}" height="${scaledHeight}"
viewBox="${croppedViewBox}">
${svgContent}
</svg>`
})