Compare commits
38 Commits
abacus-rea
...
abacus-rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20ab40b2df | ||
|
|
06f68cc74c | ||
|
|
599a758471 | ||
|
|
e5ba772fde | ||
|
|
293390ae35 | ||
|
|
f880cbe4bf | ||
|
|
14a5de0dfa | ||
|
|
867c7ee172 | ||
|
|
3a20b46185 | ||
|
|
4f93c7d996 | ||
|
|
5956217979 | ||
|
|
00a8bc3e5e | ||
|
|
42016acec1 | ||
|
|
9f1715f085 | ||
|
|
33eb90e316 | ||
|
|
f9cbee8fcd | ||
|
|
8aaec90e11 | ||
|
|
448f93c1e2 | ||
|
|
8ce8038bae | ||
|
|
c93409fc8c | ||
|
|
b277a89415 | ||
|
|
203f110b65 | ||
|
|
98cd019d4a | ||
|
|
858a1b4976 | ||
|
|
08c6a419e2 | ||
|
|
a9664bdcb4 | ||
|
|
329e623212 | ||
|
|
8439727b15 | ||
|
|
7ce1287525 | ||
|
|
903dea2584 | ||
|
|
72a4c2b80c | ||
|
|
ed69f6b917 | ||
|
|
9d322301ef | ||
|
|
0641eb719e | ||
|
|
3588d5acde | ||
|
|
74f2d97434 | ||
|
|
4f9dc4666d | ||
|
|
3b8e864cfa |
1776
CHANGELOG.md
1776
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -146,6 +146,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/apps/web/dist ./apps/web/dist
|
||||
# Copy database migrations
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/drizzle ./apps/web/drizzle
|
||||
|
||||
# Copy scripts directory (needed for calendar generation)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/scripts ./apps/web/scripts
|
||||
|
||||
# Copy PRODUCTION node_modules only (no dev dependencies)
|
||||
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=deps --chown=nextjs:nodejs /app/apps/web/node_modules ./apps/web/node_modules
|
||||
@@ -156,6 +159,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/packages/core ./packages/core
|
||||
# Copy templates package (needed for Typst templates)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages/templates ./packages/templates
|
||||
|
||||
# Copy abacus-react package (needed for calendar generation scripts)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages/abacus-react ./packages/abacus-react
|
||||
|
||||
# Install Python dependencies for flashcard generation
|
||||
RUN pip3 install --no-cache-dir --break-system-packages -r packages/core/requirements.txt
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json && concurrently \"node server.js\" \"npx @pandacss/dev --watch\"",
|
||||
"build": "node scripts/generate-build-info.js && tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json && next build",
|
||||
"build": "node scripts/generate-build-info.js && npx @pandacss/dev && tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json && next build",
|
||||
"start": "NODE_ENV=production node server.js",
|
||||
"lint": "npx @biomejs/biome lint . && npx eslint .",
|
||||
"lint:fix": "npx @biomejs/biome lint . --write && npx eslint . --fix",
|
||||
|
||||
@@ -5,31 +5,36 @@
|
||||
* Usage: npx tsx scripts/generateCalendarAbacus.tsx <value> <columns>
|
||||
* Example: npx tsx scripts/generateCalendarAbacus.tsx 15 2
|
||||
*
|
||||
* Pattern copied directly from working generateDayIcon.tsx
|
||||
* Uses AbacusStatic for server-side rendering (no client hooks)
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import { AbacusStatic } from '@soroban/abacus-react/static'
|
||||
|
||||
const value = parseInt(process.argv[2], 10)
|
||||
const columns = parseInt(process.argv[3], 10)
|
||||
|
||||
if (isNaN(value) || isNaN(columns)) {
|
||||
console.error('Usage: npx tsx scripts/generateCalendarAbacus.tsx <value> <columns>')
|
||||
process.exit(1)
|
||||
export function generateAbacusElement(value: number, columns: number) {
|
||||
return (
|
||||
<AbacusStatic
|
||||
value={value}
|
||||
columns={columns}
|
||||
scaleFactor={1}
|
||||
showNumbers={false}
|
||||
frameVisible={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Use exact same pattern as generateDayIcon - inline customStyles
|
||||
const abacusMarkup = renderToStaticMarkup(
|
||||
<AbacusReact
|
||||
value={value}
|
||||
columns={columns}
|
||||
scaleFactor={1}
|
||||
animated={false}
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
/>
|
||||
)
|
||||
// CLI interface (if run directly)
|
||||
if (require.main === module) {
|
||||
// Only import react-dom/server for CLI usage
|
||||
const { renderToStaticMarkup } = require('react-dom/server')
|
||||
|
||||
process.stdout.write(abacusMarkup)
|
||||
const value = parseInt(process.argv[2], 10)
|
||||
const columns = parseInt(process.argv[3], 10)
|
||||
|
||||
if (isNaN(value) || isNaN(columns)) {
|
||||
console.error('Usage: npx tsx scripts/generateCalendarAbacus.tsx <value> <columns>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.stdout.write(renderToStaticMarkup(generateAbacusElement(value, columns)))
|
||||
}
|
||||
|
||||
208
apps/web/scripts/generateCalendarComposite.tsx
Normal file
208
apps/web/scripts/generateCalendarComposite.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Generate a complete monthly calendar as a single SVG
|
||||
* This prevents multi-page overflow - one image scales to fit
|
||||
*
|
||||
* Usage: npx tsx scripts/generateCalendarComposite.tsx <month> <year>
|
||||
* Example: npx tsx scripts/generateCalendarComposite.tsx 12 2025
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { AbacusStatic, calculateAbacusDimensions } from '@soroban/abacus-react/static'
|
||||
|
||||
interface CalendarCompositeOptions {
|
||||
month: number
|
||||
year: number
|
||||
renderToString: (element: React.ReactElement) => string
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
]
|
||||
|
||||
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
export function generateCalendarComposite(options: CalendarCompositeOptions): string {
|
||||
const { month, year, renderToString } = options
|
||||
const daysInMonth = getDaysInMonth(year, month)
|
||||
const firstDayOfWeek = getFirstDayOfWeek(year, month)
|
||||
const monthName = MONTH_NAMES[month - 1]
|
||||
|
||||
// Layout constants for US Letter aspect ratio (8.5 x 11)
|
||||
const WIDTH = 850
|
||||
const HEIGHT = 1100
|
||||
const MARGIN = 50
|
||||
const CONTENT_WIDTH = WIDTH - MARGIN * 2
|
||||
const CONTENT_HEIGHT = HEIGHT - MARGIN * 2
|
||||
|
||||
// Abacus natural size is 120x230 at scale=1
|
||||
const ABACUS_NATURAL_WIDTH = 120
|
||||
const ABACUS_NATURAL_HEIGHT = 230
|
||||
|
||||
// Calculate how many columns needed for year
|
||||
const yearColumns = Math.max(1, Math.ceil(Math.log10(year + 1)))
|
||||
|
||||
// Year abacus dimensions (calculate first to determine header height)
|
||||
// Use the shared dimension calculator so we stay in sync with AbacusStatic
|
||||
const { width: yearAbacusActualWidth, height: yearAbacusActualHeight } = calculateAbacusDimensions({
|
||||
columns: yearColumns,
|
||||
showNumbers: false,
|
||||
columnLabels: [],
|
||||
})
|
||||
|
||||
const yearAbacusDisplayWidth = WIDTH * 0.15 // Display size on page
|
||||
const yearAbacusDisplayHeight = (yearAbacusActualHeight / yearAbacusActualWidth) * yearAbacusDisplayWidth
|
||||
|
||||
// Header - sized to fit month name + year abacus
|
||||
const MONTH_NAME_HEIGHT = 40
|
||||
const HEADER_HEIGHT = MONTH_NAME_HEIGHT + yearAbacusDisplayHeight + 20 // 20px spacing
|
||||
const TITLE_Y = MARGIN + 35
|
||||
const yearAbacusX = (WIDTH - yearAbacusDisplayWidth) / 2
|
||||
const yearAbacusY = TITLE_Y + 10
|
||||
|
||||
// Calendar grid
|
||||
const GRID_START_Y = MARGIN + HEADER_HEIGHT
|
||||
const GRID_HEIGHT = CONTENT_HEIGHT - HEADER_HEIGHT
|
||||
const WEEKDAY_ROW_HEIGHT = 25
|
||||
const DAY_GRID_HEIGHT = GRID_HEIGHT - WEEKDAY_ROW_HEIGHT
|
||||
|
||||
// 7 columns, up to 6 rows (35 cells max = 5 empty + 30 days worst case)
|
||||
const CELL_WIDTH = CONTENT_WIDTH / 7
|
||||
const DAY_CELL_HEIGHT = DAY_GRID_HEIGHT / 6
|
||||
|
||||
// Day abacus sizing - fit in cell with padding
|
||||
const CELL_PADDING = 5
|
||||
|
||||
// Calculate max scale to fit in cell
|
||||
const MAX_SCALE_X = (CELL_WIDTH - CELL_PADDING * 2) / ABACUS_NATURAL_WIDTH
|
||||
const MAX_SCALE_Y = (DAY_CELL_HEIGHT - CELL_PADDING * 2) / ABACUS_NATURAL_HEIGHT
|
||||
const ABACUS_SCALE = Math.min(MAX_SCALE_X, MAX_SCALE_Y) * 0.9 // 90% to leave breathing room
|
||||
|
||||
const SCALED_ABACUS_WIDTH = ABACUS_NATURAL_WIDTH * ABACUS_SCALE
|
||||
const SCALED_ABACUS_HEIGHT = ABACUS_NATURAL_HEIGHT * ABACUS_SCALE
|
||||
|
||||
// Generate calendar grid
|
||||
const calendarCells: (number | null)[] = []
|
||||
for (let i = 0; i < firstDayOfWeek; i++) {
|
||||
calendarCells.push(null)
|
||||
}
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
calendarCells.push(day)
|
||||
}
|
||||
|
||||
// Render individual abacus SVGs as complete SVG elements
|
||||
function renderAbacusSVG(value: number, columns: number, scale: number): string {
|
||||
return renderToString(
|
||||
<AbacusStatic
|
||||
value={value}
|
||||
columns={columns}
|
||||
scaleFactor={scale}
|
||||
showNumbers={false}
|
||||
frameVisible={true}
|
||||
compact={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Main composite SVG
|
||||
const compositeSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}">
|
||||
<!-- Background -->
|
||||
<rect width="${WIDTH}" height="${HEIGHT}" fill="white"/>
|
||||
|
||||
<!-- Title: Month Name -->
|
||||
<text x="${WIDTH / 2}" y="${TITLE_Y}" text-anchor="middle" font-family="Arial" font-size="32" font-weight="bold" fill="#1a1a1a">
|
||||
${monthName}
|
||||
</text>
|
||||
|
||||
<!-- Year Abacus (centered below month name) -->
|
||||
${(() => {
|
||||
const yearAbacusSVG = renderAbacusSVG(year, yearColumns, 1)
|
||||
const yearAbacusContent = yearAbacusSVG.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')
|
||||
return `<svg x="${yearAbacusX}" y="${yearAbacusY}" width="${yearAbacusDisplayWidth}" height="${yearAbacusDisplayHeight}"
|
||||
viewBox="0 0 ${yearAbacusActualWidth} ${yearAbacusActualHeight}">
|
||||
${yearAbacusContent}
|
||||
</svg>`
|
||||
})()}
|
||||
|
||||
<!-- Weekday Headers -->
|
||||
${WEEKDAYS.map((day, i) => `
|
||||
<text x="${MARGIN + i * CELL_WIDTH + CELL_WIDTH / 2}" y="${GRID_START_Y + 18}"
|
||||
text-anchor="middle" font-family="Arial" font-size="14" font-weight="bold" fill="#555">
|
||||
${day}
|
||||
</text>`).join('')}
|
||||
|
||||
<!-- Separator line under weekdays -->
|
||||
<line x1="${MARGIN}" y1="${GRID_START_Y + WEEKDAY_ROW_HEIGHT}"
|
||||
x2="${WIDTH - MARGIN}" y2="${GRID_START_Y + WEEKDAY_ROW_HEIGHT}"
|
||||
stroke="#333" stroke-width="2"/>
|
||||
|
||||
<!-- Calendar Grid Cells -->
|
||||
${calendarCells.map((day, index) => {
|
||||
const row = Math.floor(index / 7)
|
||||
const col = index % 7
|
||||
const cellX = MARGIN + col * CELL_WIDTH
|
||||
const cellY = GRID_START_Y + WEEKDAY_ROW_HEIGHT + row * DAY_CELL_HEIGHT
|
||||
|
||||
return `
|
||||
<rect x="${cellX}" y="${cellY}" width="${CELL_WIDTH}" height="${DAY_CELL_HEIGHT}"
|
||||
fill="none" stroke="#333" stroke-width="2"/>`
|
||||
}).join('')}
|
||||
|
||||
<!-- Calendar Day Abaci -->
|
||||
${calendarCells.map((day, index) => {
|
||||
if (day === null) return ''
|
||||
|
||||
const row = Math.floor(index / 7)
|
||||
const col = index % 7
|
||||
const cellX = MARGIN + col * CELL_WIDTH
|
||||
const cellY = GRID_START_Y + WEEKDAY_ROW_HEIGHT + row * DAY_CELL_HEIGHT
|
||||
|
||||
// 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
|
||||
|
||||
// Render at scale=1 and let the nested SVG handle scaling via viewBox
|
||||
const abacusSVG = renderAbacusSVG(day, 2, 1)
|
||||
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}">
|
||||
${svgContent}
|
||||
</svg>`
|
||||
}).join('')}
|
||||
</svg>`
|
||||
|
||||
return compositeSVG
|
||||
}
|
||||
|
||||
// CLI interface (if run directly)
|
||||
if (require.main === module) {
|
||||
// Only import react-dom/server for CLI usage
|
||||
const { renderToStaticMarkup } = require('react-dom/server')
|
||||
|
||||
const month = parseInt(process.argv[2], 10)
|
||||
const year = parseInt(process.argv[3], 10)
|
||||
|
||||
if (isNaN(month) || isNaN(year) || month < 1 || month > 12) {
|
||||
console.error('Usage: npx tsx scripts/generateCalendarComposite.tsx <month> <year>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.stdout.write(generateCalendarComposite({ month, year, renderToString: renderToStaticMarkup }))
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { writeFileSync, readFileSync, mkdirSync, rmSync } from 'fs'
|
||||
import { writeFileSync, mkdirSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { execSync } from 'child_process'
|
||||
import { generateMonthlyTypst, generateDailyTypst, getDaysInMonth } from '../utils/typstGenerator'
|
||||
import type { AbacusConfig } from '@soroban/abacus-react'
|
||||
import { generateCalendarComposite } from '@/../../scripts/generateCalendarComposite'
|
||||
import { generateAbacusElement } from '@/../../scripts/generateCalendarAbacus'
|
||||
|
||||
interface CalendarRequest {
|
||||
month: number
|
||||
@@ -18,6 +20,9 @@ export async function POST(request: NextRequest) {
|
||||
let tempDir: string | null = null
|
||||
|
||||
try {
|
||||
// Dynamic import to avoid Next.js bundler issues with react-dom/server
|
||||
const { renderToStaticMarkup } = await import('react-dom/server')
|
||||
|
||||
const body: CalendarRequest = await request.json()
|
||||
const { month, year, format, paperSize, abacusConfig } = body
|
||||
|
||||
@@ -26,58 +31,67 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Invalid month or year' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Create temp directory
|
||||
// Create temp directory for SVG files
|
||||
tempDir = join(tmpdir(), `calendar-${Date.now()}-${Math.random()}`)
|
||||
mkdirSync(tempDir, { recursive: true })
|
||||
|
||||
// Generate SVGs using script (avoids Next.js react-dom/server restriction)
|
||||
// Generate and write SVG files
|
||||
const daysInMonth = getDaysInMonth(year, month)
|
||||
const maxDay = format === 'daily' ? daysInMonth : 31 // For monthly, pre-generate all
|
||||
const scriptPath = join(process.cwd(), 'scripts', 'generateCalendarAbacus.tsx')
|
||||
let typstContent: string
|
||||
|
||||
// Generate day SVGs (1 to maxDay)
|
||||
for (let day = 1; day <= maxDay; day++) {
|
||||
const svg = execSync(`npx tsx "${scriptPath}" ${day} 2`, {
|
||||
encoding: 'utf-8',
|
||||
cwd: process.cwd(),
|
||||
if (format === 'monthly') {
|
||||
// Generate single composite SVG for monthly calendar
|
||||
const calendarSvg = generateCalendarComposite({
|
||||
month,
|
||||
year,
|
||||
renderToString: renderToStaticMarkup
|
||||
})
|
||||
if (!calendarSvg || calendarSvg.trim().length === 0) {
|
||||
throw new Error('Generated empty composite calendar SVG')
|
||||
}
|
||||
writeFileSync(join(tempDir, 'calendar.svg'), calendarSvg)
|
||||
|
||||
// Generate Typst document
|
||||
typstContent = generateMonthlyTypst({
|
||||
month,
|
||||
year,
|
||||
paperSize,
|
||||
daysInMonth,
|
||||
})
|
||||
} else {
|
||||
// Daily format: generate individual SVGs for each day
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const svg = renderToStaticMarkup(generateAbacusElement(day, 2))
|
||||
if (!svg || svg.trim().length === 0) {
|
||||
throw new Error(`Generated empty SVG for day ${day}`)
|
||||
}
|
||||
writeFileSync(join(tempDir, `day-${day}.svg`), svg)
|
||||
}
|
||||
|
||||
// Generate year SVG
|
||||
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}`)
|
||||
}
|
||||
writeFileSync(join(tempDir, 'year.svg'), yearSvg)
|
||||
|
||||
// Generate Typst document
|
||||
typstContent = generateDailyTypst({
|
||||
month,
|
||||
year,
|
||||
paperSize,
|
||||
daysInMonth,
|
||||
})
|
||||
writeFileSync(join(tempDir, `day-${day}.svg`), svg)
|
||||
}
|
||||
|
||||
// Generate year SVG
|
||||
const yearColumns = Math.max(1, Math.ceil(Math.log10(year + 1)))
|
||||
const yearSvg = execSync(`npx tsx "${scriptPath}" ${year} ${yearColumns}`, {
|
||||
encoding: 'utf-8',
|
||||
cwd: process.cwd(),
|
||||
})
|
||||
writeFileSync(join(tempDir, 'year.svg'), yearSvg)
|
||||
|
||||
// Generate Typst document
|
||||
const typstContent =
|
||||
format === 'monthly'
|
||||
? generateMonthlyTypst({
|
||||
month,
|
||||
year,
|
||||
paperSize,
|
||||
tempDir,
|
||||
daysInMonth,
|
||||
})
|
||||
: generateDailyTypst({
|
||||
month,
|
||||
year,
|
||||
paperSize,
|
||||
tempDir,
|
||||
daysInMonth,
|
||||
})
|
||||
|
||||
const typstPath = join(tempDir, 'calendar.typ')
|
||||
writeFileSync(typstPath, typstContent)
|
||||
|
||||
// Compile with Typst
|
||||
const pdfPath = join(tempDir, 'calendar.pdf')
|
||||
// Compile with Typst: stdin for .typ content, stdout for PDF output
|
||||
let pdfBuffer: Buffer
|
||||
try {
|
||||
execSync(`typst compile "${typstPath}" "${pdfPath}"`, {
|
||||
stdio: 'pipe',
|
||||
pdfBuffer = execSync('typst compile - -', {
|
||||
input: typstContent,
|
||||
cwd: tempDir, // Run in temp dir so relative paths work
|
||||
maxBuffer: 50 * 1024 * 1024, // 50MB limit for large calendars
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Typst compilation error:', error)
|
||||
@@ -87,18 +101,14 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Read and return PDF
|
||||
const pdfBuffer = readFileSync(pdfPath)
|
||||
|
||||
// Clean up temp directory
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
tempDir = null
|
||||
|
||||
return new NextResponse(pdfBuffer, {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="calendar-${year}-${String(month).padStart(2, '0')}.pdf"`,
|
||||
},
|
||||
// Return JSON with PDF
|
||||
return NextResponse.json({
|
||||
pdf: pdfBuffer.toString('base64'),
|
||||
filename: `calendar-${year}-${String(month).padStart(2, '0')}.pdf`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error generating calendar:', error)
|
||||
@@ -112,6 +122,17 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Failed to generate calendar' }, { status: 500 })
|
||||
// Surface the actual error for debugging
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const errorStack = error instanceof Error ? error.stack : undefined
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to generate calendar',
|
||||
message: errorMessage,
|
||||
...(process.env.NODE_ENV === 'development' && { stack: errorStack })
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,8 +1,14 @@
|
||||
interface TypstConfig {
|
||||
interface TypstMonthlyConfig {
|
||||
month: number
|
||||
year: number
|
||||
paperSize: 'us-letter' | 'a4' | 'a3' | 'tabloid'
|
||||
daysInMonth: number
|
||||
}
|
||||
|
||||
interface TypstDailyConfig {
|
||||
month: number
|
||||
year: number
|
||||
paperSize: 'us-letter' | 'a4' | 'a3' | 'tabloid'
|
||||
tempDir: string
|
||||
daysInMonth: number
|
||||
}
|
||||
|
||||
@@ -44,73 +50,38 @@ interface PaperConfig {
|
||||
|
||||
function getPaperConfig(size: string): PaperConfig {
|
||||
const configs: Record<PaperSize, PaperConfig> = {
|
||||
'us-letter': { typstName: 'us-letter', marginX: '0.75in', marginY: '1in' },
|
||||
a4: { typstName: 'a4', marginX: '2cm', marginY: '2.5cm' },
|
||||
a3: { typstName: 'a3', marginX: '2cm', marginY: '2.5cm' },
|
||||
tabloid: { typstName: 'us-tabloid', marginX: '1in', marginY: '1in' },
|
||||
// Tight margins to maximize space for calendar grid
|
||||
'us-letter': { typstName: 'us-letter', marginX: '0.5in', marginY: '0.5in' },
|
||||
// A4 is slightly taller/narrower than US Letter - adjust margins proportionally
|
||||
a4: { typstName: 'a4', marginX: '1.3cm', marginY: '1.3cm' },
|
||||
// A3 is 2x area of A4 - can use same margins but will scale content larger
|
||||
a3: { typstName: 'a3', marginX: '1.5cm', marginY: '1.5cm' },
|
||||
// Tabloid (11" × 17") is larger - can use more margin
|
||||
tabloid: { typstName: 'us-tabloid', marginX: '0.75in', marginY: '0.75in' },
|
||||
}
|
||||
return configs[size as PaperSize] || configs['us-letter']
|
||||
}
|
||||
|
||||
export function generateMonthlyTypst(config: TypstConfig): string {
|
||||
const { month, year, paperSize, tempDir, daysInMonth } = config
|
||||
export function generateMonthlyTypst(config: TypstMonthlyConfig): string {
|
||||
const { paperSize } = config
|
||||
const paperConfig = getPaperConfig(paperSize)
|
||||
const firstDayOfWeek = getFirstDayOfWeek(year, month)
|
||||
const monthName = MONTH_NAMES[month - 1]
|
||||
|
||||
// Generate calendar cells with proper empty cells before the first day
|
||||
let cells = ''
|
||||
|
||||
// Empty cells before first day
|
||||
for (let i = 0; i < firstDayOfWeek; i++) {
|
||||
cells += ' [],\n'
|
||||
}
|
||||
|
||||
// Day cells
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
cells += ` [#image("${tempDir}/day-${day}.svg", width: 90%)],\n`
|
||||
}
|
||||
|
||||
// Single-page design: use one composite SVG that scales to fit
|
||||
// This prevents overflow - Typst will scale the image to fit available space
|
||||
return `#set page(
|
||||
paper: "${paperConfig.typstName}",
|
||||
margin: (x: ${paperConfig.marginX}, y: ${paperConfig.marginY}),
|
||||
)
|
||||
|
||||
#set text(font: "Arial", size: 12pt)
|
||||
|
||||
// Title
|
||||
#align(center)[
|
||||
#text(size: 24pt, weight: "bold")[${monthName} ${year}]
|
||||
|
||||
#v(0.5em)
|
||||
|
||||
// Year as abacus
|
||||
#image("${tempDir}/year.svg", width: 35%)
|
||||
// Composite calendar SVG - scales to fit page (prevents multi-page overflow)
|
||||
#align(center + horizon)[
|
||||
#image("calendar.svg", width: 100%, fit: "contain")
|
||||
]
|
||||
|
||||
#v(1.5em)
|
||||
|
||||
// Calendar grid
|
||||
#grid(
|
||||
columns: (1fr, 1fr, 1fr, 1fr, 1fr, 1fr, 1fr),
|
||||
gutter: 4pt,
|
||||
|
||||
// Weekday headers
|
||||
[#align(center)[*Sun*]],
|
||||
[#align(center)[*Mon*]],
|
||||
[#align(center)[*Tue*]],
|
||||
[#align(center)[*Wed*]],
|
||||
[#align(center)[*Thu*]],
|
||||
[#align(center)[*Fri*]],
|
||||
[#align(center)[*Sat*]],
|
||||
|
||||
// Calendar days
|
||||
${cells})
|
||||
`
|
||||
}
|
||||
|
||||
export function generateDailyTypst(config: TypstConfig): string {
|
||||
const { month, year, paperSize, tempDir, daysInMonth } = config
|
||||
export function generateDailyTypst(config: TypstDailyConfig): string {
|
||||
const { month, year, paperSize, daysInMonth } = config
|
||||
const paperConfig = getPaperConfig(paperSize)
|
||||
const monthName = MONTH_NAMES[month - 1]
|
||||
|
||||
@@ -127,14 +98,14 @@ export function generateDailyTypst(config: TypstConfig): string {
|
||||
// Header: Year
|
||||
#align(center)[
|
||||
#v(1em)
|
||||
#image("${tempDir}/year.svg", width: 30%)
|
||||
#image("year.svg", width: 30%)
|
||||
]
|
||||
|
||||
#v(2em)
|
||||
|
||||
// Main: Day number as large abacus
|
||||
#align(center + horizon)[
|
||||
#image("${tempDir}/day-${day}.svg", width: 50%)
|
||||
#image("day-${day}.svg", width: 50%)
|
||||
]
|
||||
|
||||
#v(2em)
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import { rithmomachiaGame } from '@/arcade-games/rithmomachia'
|
||||
|
||||
// Force dynamic rendering to avoid build-time initialization errors
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const { Provider, GameComponent } = rithmomachiaGame
|
||||
|
||||
export default function RithmomachiaPage() {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
import Link from 'next/link'
|
||||
import { AbacusDisplayDropdown } from '@/components/AbacusDisplayDropdown'
|
||||
|
||||
interface CalendarConfigPanelProps {
|
||||
month: number
|
||||
@@ -243,7 +243,7 @@ export function CalendarConfigPanel({
|
||||
</select>
|
||||
</fieldset>
|
||||
|
||||
{/* Abacus Styling Info */}
|
||||
{/* Abacus Styling */}
|
||||
<div
|
||||
data-section="styling-info"
|
||||
className={css({
|
||||
@@ -259,7 +259,7 @@ export function CalendarConfigPanel({
|
||||
color: 'gray.300',
|
||||
})}
|
||||
>
|
||||
Using your saved abacus style:
|
||||
Calendar abacus style preview:
|
||||
</p>
|
||||
<div
|
||||
className={css({
|
||||
@@ -273,22 +273,17 @@ export function CalendarConfigPanel({
|
||||
columns={2}
|
||||
customStyles={abacusConfig.customStyles}
|
||||
scaleFactor={0.5}
|
||||
showNumbers={false}
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
href="/create"
|
||||
data-action="edit-style"
|
||||
<div
|
||||
className={css({
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
fontSize: '0.875rem',
|
||||
color: 'yellow.400',
|
||||
textDecoration: 'underline',
|
||||
_hover: { color: 'yellow.300' },
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
Edit your abacus style →
|
||||
</Link>
|
||||
<AbacusDisplayDropdown />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generate Button */}
|
||||
|
||||
@@ -1,45 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
|
||||
interface CalendarPreviewProps {
|
||||
month: number
|
||||
year: number
|
||||
format: 'monthly' | 'daily'
|
||||
previewSvg: string | null
|
||||
}
|
||||
|
||||
const MONTHS = [
|
||||
'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()
|
||||
const data = await response.json()
|
||||
return data.svg
|
||||
}
|
||||
|
||||
function getFirstDayOfWeek(year: number, month: number): number {
|
||||
return new Date(year, month - 1, 1).getDay()
|
||||
}
|
||||
export function CalendarPreview({ month, year, format, previewSvg }: CalendarPreviewProps) {
|
||||
// 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),
|
||||
})
|
||||
|
||||
export function CalendarPreview({ month, year, format }: CalendarPreviewProps) {
|
||||
const abacusConfig = useAbacusConfig()
|
||||
const daysInMonth = getDaysInMonth(year, month)
|
||||
const firstDayOfWeek = getFirstDayOfWeek(year, month)
|
||||
// Use generated PDF SVG if available, otherwise use Typst live preview
|
||||
const displaySvg = previewSvg || typstPreviewSvg
|
||||
|
||||
if (format === 'daily') {
|
||||
if (!displaySvg) {
|
||||
return (
|
||||
<div
|
||||
data-component="calendar-preview"
|
||||
@@ -57,110 +53,16 @@ export function CalendarPreview({ month, year, format }: CalendarPreviewProps) {
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
color: 'gray.300',
|
||||
marginBottom: '1.5rem',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Daily format preview
|
||||
</p>
|
||||
<div
|
||||
className={css({
|
||||
bg: 'white',
|
||||
padding: '3rem 2rem',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
maxWidth: '400px',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
{/* Year at top */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
value={year}
|
||||
columns={4}
|
||||
customStyles={abacusConfig.customStyles}
|
||||
scaleFactor={0.4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Large day number */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
value={1}
|
||||
columns={2}
|
||||
customStyles={abacusConfig.customStyles}
|
||||
scaleFactor={0.8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date text */}
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
color: 'gray.800',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: '600',
|
||||
marginBottom: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{new Date(year, month - 1, 1).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '1rem',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
{MONTHS[month - 1]} 1, {year}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: 'gray.400',
|
||||
marginTop: '1rem',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Example of first day (1 page per day for all {daysInMonth} days)
|
||||
{format === 'daily' ? 'Daily format - preview after generation' : 'No preview available'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Monthly format
|
||||
const calendarDays: (number | null)[] = []
|
||||
|
||||
// Add empty cells for days before the first day of month
|
||||
for (let i = 0; i < firstDayOfWeek; i++) {
|
||||
calendarDays.push(null)
|
||||
}
|
||||
|
||||
// Add actual days
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
calendarDays.push(day)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="calendar-preview"
|
||||
@@ -168,99 +70,32 @@ export function CalendarPreview({ month, year, format }: CalendarPreviewProps) {
|
||||
bg: 'gray.800',
|
||||
borderRadius: '12px',
|
||||
padding: '2rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '2rem',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '2rem',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '1rem',
|
||||
color: 'yellow.400',
|
||||
})}
|
||||
>
|
||||
{MONTHS[month - 1]} {year}
|
||||
</h2>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
value={year}
|
||||
columns={4}
|
||||
customStyles={abacusConfig.customStyles}
|
||||
scaleFactor={0.6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{/* Weekday headers */}
|
||||
{WEEKDAYS.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
fontWeight: '600',
|
||||
padding: '0.5rem',
|
||||
color: 'yellow.400',
|
||||
fontSize: '0.875rem',
|
||||
})}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Calendar days */}
|
||||
{calendarDays.map((day, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={css({
|
||||
aspectRatio: '1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bg: day ? 'gray.700' : 'transparent',
|
||||
borderRadius: '6px',
|
||||
padding: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{day && (
|
||||
<AbacusReact
|
||||
value={day}
|
||||
columns={2}
|
||||
customStyles={abacusConfig.customStyles}
|
||||
scaleFactor={0.35}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '0.875rem',
|
||||
color: 'gray.400',
|
||||
marginTop: '1.5rem',
|
||||
fontSize: '1.125rem',
|
||||
color: 'yellow.400',
|
||||
marginBottom: '1rem',
|
||||
textAlign: 'center',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
Preview of monthly calendar layout (actual PDF will be optimized for printing)
|
||||
{previewSvg ? 'Generated PDF' : 'Live Preview'}
|
||||
</p>
|
||||
<div
|
||||
className={css({
|
||||
bg: 'white',
|
||||
borderRadius: '8px',
|
||||
padding: '1rem',
|
||||
maxWidth: '100%',
|
||||
overflow: 'auto',
|
||||
})}
|
||||
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'
|
||||
@@ -15,6 +15,7 @@ export default function CalendarCreatorPage() {
|
||||
const [format, setFormat] = useState<'monthly' | 'daily'>('monthly')
|
||||
const [paperSize, setPaperSize] = useState<'us-letter' | 'a4' | 'a3' | 'tabloid'>('us-letter')
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [previewSvg, setPreviewSvg] = useState<string | null>(null)
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setIsGenerating(true)
|
||||
@@ -34,21 +35,26 @@ export default function CalendarCreatorPage() {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate calendar')
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.message || 'Failed to generate calendar')
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const data = await response.json()
|
||||
|
||||
// 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' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `calendar-${year}-${String(month).padStart(2, '0')}.pdf`
|
||||
a.download = data.filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
} catch (error) {
|
||||
console.error('Error generating calendar:', error)
|
||||
alert('Failed to generate calendar. Please try again.')
|
||||
alert(`Failed to generate calendar: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
@@ -122,7 +128,35 @@ export default function CalendarCreatorPage() {
|
||||
/>
|
||||
|
||||
{/* Preview */}
|
||||
<CalendarPreview month={month} year={year} format={format} />
|
||||
<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>
|
||||
|
||||
66
apps/web/src/app/test-static-abacus/page.tsx
Normal file
66
apps/web/src/app/test-static-abacus/page.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Test page for AbacusStatic - Server Component
|
||||
* This demonstrates that AbacusStatic works without "use client"
|
||||
*
|
||||
* Note: Uses /static import path to avoid client-side code
|
||||
*/
|
||||
|
||||
import { AbacusStatic } from '@soroban/abacus-react/static'
|
||||
|
||||
export default function TestStaticAbacusPage() {
|
||||
const numbers = [1, 2, 3, 4, 5, 10, 25, 50, 100, 123, 456, 789]
|
||||
|
||||
return (
|
||||
<div style={{ padding: '40px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<h1 style={{ marginBottom: '10px' }}>AbacusStatic Test (Server Component)</h1>
|
||||
<p style={{ color: '#64748b', marginBottom: '30px' }}>
|
||||
This page is a React Server Component - no "use client" directive!
|
||||
All abacus displays below are rendered on the server with zero client-side JavaScript.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||||
gap: '20px',
|
||||
}}
|
||||
>
|
||||
{numbers.map((num) => (
|
||||
<div
|
||||
key={num}
|
||||
style={{
|
||||
padding: '20px',
|
||||
background: 'white',
|
||||
border: '2px solid #e2e8f0',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
}}
|
||||
>
|
||||
<AbacusStatic
|
||||
value={num}
|
||||
columns="auto"
|
||||
hideInactiveBeads
|
||||
compact
|
||||
scaleFactor={0.9}
|
||||
/>
|
||||
<span style={{ fontSize: '20px', fontWeight: 'bold', color: '#475569' }}>
|
||||
{num}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '40px', padding: '20px', background: '#f0fdf4', borderRadius: '8px' }}>
|
||||
<h2 style={{ marginTop: 0, color: '#166534' }}>✅ Success!</h2>
|
||||
<p style={{ color: '#15803d' }}>
|
||||
If you can see the abacus displays above, then AbacusStatic is working correctly
|
||||
in React Server Components. Check the page source - you'll see pure HTML/SVG with
|
||||
no client-side hydration markers!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,60 @@
|
||||
# [2.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.7.1...abacus-react-v2.8.0) (2025-11-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **docker:** add scripts, abacus-react, and tsx for production calendar generation ([33eb90e](https://github.com/antialias/soroban-abacus-flashcards/commit/33eb90e316f84650ae619f8c6c02c9e77c663d1b))
|
||||
* **web:** generate styled-system artifacts during build ([293390a](https://github.com/antialias/soroban-abacus-flashcards/commit/293390ae350a6c6aa467410f68c735512104d9dd))
|
||||
* **web:** move react-dom/server import to API route to satisfy Next.js ([00a8bc3](https://github.com/antialias/soroban-abacus-flashcards/commit/00a8bc3e5e8f044df280c4356d3605a852f82e84))
|
||||
* **web:** prevent abacus overlap in composite calendar ([448f93c](https://github.com/antialias/soroban-abacus-flashcards/commit/448f93c1e2a7f86bc48e678d4599ca968c6d81d2)), closes [#f0f0f0](https://github.com/antialias/soroban-abacus-flashcards/issues/f0f0f0)
|
||||
* **web:** use dynamic import for react-dom/server in API route ([4f93c7d](https://github.com/antialias/soroban-abacus-flashcards/commit/4f93c7d996732de4bc19e7acf2d4ce803cba88b6))
|
||||
* **web:** use nested SVG elements to prevent coordinate space conflicts ([f9cbee8](https://github.com/antialias/soroban-abacus-flashcards/commit/f9cbee8fcdf80641f3b82a65fad6b8a3575525fc))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **abacus-react:** add shared dimension calculator for consistent sizing ([e5ba772](https://github.com/antialias/soroban-abacus-flashcards/commit/e5ba772fde9839c22daec92007f052ca125c7695))
|
||||
* **web:** add Typst-based preview endpoint with React Suspense ([599a758](https://github.com/antialias/soroban-abacus-flashcards/commit/599a758471c43ab0fc87301c5e7eeceed608062e))
|
||||
* **web:** add year abacus to calendar header and make grid bolder ([867c7ee](https://github.com/antialias/soroban-abacus-flashcards/commit/867c7ee17251b8df13665bee9c0391961975e681)), closes [#333](https://github.com/antialias/soroban-abacus-flashcards/issues/333)
|
||||
* **web:** optimize monthly calendar for single-page layout ([b277a89](https://github.com/antialias/soroban-abacus-flashcards/commit/b277a89415d1823455376c3e0f641b52f3394e7c))
|
||||
* **web:** redesign monthly calendar as single composite SVG ([8ce8038](https://github.com/antialias/soroban-abacus-flashcards/commit/8ce8038baeea0b8b0fffe3215746958731bd9d6a))
|
||||
|
||||
## [2.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.7.0...abacus-react-v2.7.1) (2025-11-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add xmlns to AbacusStatic for Typst SVG parsing ([98cd019](https://github.com/antialias/soroban-abacus-flashcards/commit/98cd019d4af91d7ca4e7a88f700194273476afb7))
|
||||
* **web:** use AbacusStatic for calendar SVG generation ([08c6a41](https://github.com/antialias/soroban-abacus-flashcards/commit/08c6a419e25d220560eba13d6db437145e6e61b8))
|
||||
|
||||
# [2.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.6.0...abacus-react-v2.7.0) (2025-11-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **web:** add dynamic export to rithmomachia page ([329e623](https://github.com/antialias/soroban-abacus-flashcards/commit/329e62321245ef62726c986c917f19a909a5b65e))
|
||||
* **web:** fix Typst PDF generation path resolution ([7ce1287](https://github.com/antialias/soroban-abacus-flashcards/commit/7ce12875254a31d8acdb35ef5de7d36d215ccd92))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **abacus-react:** add separate /static export path for React Server Components ([ed69f6b](https://github.com/antialias/soroban-abacus-flashcards/commit/ed69f6b917c543bbcaa4621a0e63745bee70f5bf))
|
||||
* **web:** add test page for AbacusStatic RSC compatibility ([903dea2](https://github.com/antialias/soroban-abacus-flashcards/commit/903dea25844f1d2b3730fbcbd8478e7af1887663))
|
||||
* **web:** improve calendar abacus preview styling ([8439727](https://github.com/antialias/soroban-abacus-flashcards/commit/8439727b152accf61f0c28158b92788510ca086e))
|
||||
|
||||
# [2.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.5.0...abacus-react-v2.6.0) (2025-11-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **abacus-react:** correct column highlighting offset in AbacusStatic ([0641eb7](https://github.com/antialias/soroban-abacus-flashcards/commit/0641eb719ef56c67de965296006df666f83e5b08))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **abacus-react:** add AbacusStatic for React Server Components ([3b8e864](https://github.com/antialias/soroban-abacus-flashcards/commit/3b8e864cfa3af50b1912ce7ff55003d7f6b9c229))
|
||||
* **web:** add test page for AbacusStatic Server Component ([3588d5a](https://github.com/antialias/soroban-abacus-flashcards/commit/3588d5acde25588ce4db3ee32adb04ace0e394d4))
|
||||
|
||||
# [2.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.4.0...abacus-react-v2.5.0) (2025-11-03)
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ A comprehensive React component for rendering interactive Soroban (Japanese abac
|
||||
- 🎓 **Tutorial system** - Built-in overlay and guidance capabilities
|
||||
- 🧩 **Framework-free SVG** - Complete control over rendering
|
||||
- ✨ **3D Enhancement** - Three levels of progressive 3D effects for immersive visuals
|
||||
- 🚀 **Server Component support** - AbacusStatic works in React Server Components (Next.js App Router)
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -121,6 +122,64 @@ import { AbacusReact, ABACUS_THEMES } from '@soroban/abacus-react';
|
||||
- `solid` - Black frame (best for high contrast/educational contexts)
|
||||
- `traditional` - Brown wooden appearance (best for traditional soroban aesthetic)
|
||||
|
||||
### Static Display (Server Components)
|
||||
|
||||
For static, non-interactive displays that work in React Server Components:
|
||||
|
||||
```tsx
|
||||
// IMPORTANT: Use /static import path for RSC compatibility!
|
||||
import { AbacusStatic } from '@soroban/abacus-react/static';
|
||||
|
||||
// ✅ Works in React Server Components - no "use client" needed!
|
||||
// ✅ No JavaScript sent to client
|
||||
// ✅ Perfect for SSG, SSR, and static previews
|
||||
|
||||
<AbacusStatic
|
||||
value={123}
|
||||
columns="auto"
|
||||
hideInactiveBeads
|
||||
compact
|
||||
/>
|
||||
```
|
||||
|
||||
**Import paths:**
|
||||
- `@soroban/abacus-react` - Full package (client components with hooks/animations)
|
||||
- `@soroban/abacus-react/static` - Server-compatible components only (no client code)
|
||||
|
||||
**When to use `AbacusStatic` vs `AbacusReact`:**
|
||||
|
||||
| Feature | AbacusStatic | AbacusReact |
|
||||
|---------|--------------|-------------|
|
||||
| React Server Components | ✅ Yes | ❌ No (requires "use client") |
|
||||
| Client-side JavaScript | ❌ None | ✅ Yes |
|
||||
| User interaction | ❌ No | ✅ Click/drag beads |
|
||||
| Animations | ❌ No | ✅ Smooth transitions |
|
||||
| Sound effects | ❌ No | ✅ Optional sounds |
|
||||
| 3D effects | ❌ No | ✅ Yes |
|
||||
| Bundle size | 📦 Minimal | 📦 Full-featured |
|
||||
| Use cases | Preview cards, thumbnails, static pages | Interactive tutorials, games, tools |
|
||||
|
||||
```tsx
|
||||
// Example: Server Component with static abacus cards
|
||||
// app/flashcards/page.tsx
|
||||
import { AbacusStatic } from '@soroban/abacus-react/static'
|
||||
|
||||
export default function FlashcardsPage() {
|
||||
const numbers = [1, 5, 10, 25, 50, 100]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{numbers.map(num => (
|
||||
<div key={num} className="card">
|
||||
<AbacusStatic value={num} columns="auto" compact />
|
||||
<p>{num}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Compact/Inline Display
|
||||
|
||||
Create mini abacus displays for inline use:
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.es.js",
|
||||
"require": "./dist/index.cjs.js"
|
||||
},
|
||||
"./static": {
|
||||
"types": "./dist/static.d.ts",
|
||||
"import": "./dist/static.es.js",
|
||||
"require": "./dist/static.cjs.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
|
||||
264
packages/abacus-react/src/AbacusStatic.stories.tsx
Normal file
264
packages/abacus-react/src/AbacusStatic.stories.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import { AbacusStatic } from './AbacusStatic'
|
||||
import { ABACUS_THEMES } from './AbacusThemes'
|
||||
|
||||
/**
|
||||
* AbacusStatic - Server Component compatible static abacus
|
||||
*
|
||||
* ## Key Features:
|
||||
* - ✅ Works in React Server Components (no "use client")
|
||||
* - ✅ Shares core utilities with AbacusReact (numberToAbacusState, color logic)
|
||||
* - ✅ No animations, hooks, or client-side JavaScript
|
||||
* - ✅ Lightweight rendering for static displays
|
||||
*
|
||||
* ## Shared Code (No Duplication!):
|
||||
* - Uses `numberToAbacusState()` from AbacusUtils
|
||||
* - Uses same color scheme logic as AbacusReact
|
||||
* - Uses same bead positioning concepts
|
||||
* - Accepts same `customStyles` prop structure
|
||||
*
|
||||
* ## When to Use:
|
||||
* - React Server Components (Next.js App Router)
|
||||
* - Static site generation
|
||||
* - Non-interactive previews
|
||||
* - Server-side rendering without hydration
|
||||
*/
|
||||
const meta = {
|
||||
title: 'AbacusStatic/Server Component Ready',
|
||||
component: AbacusStatic,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof AbacusStatic>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
value: 123,
|
||||
columns: 'auto',
|
||||
},
|
||||
}
|
||||
|
||||
export const DifferentValues: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: '30px', flexWrap: 'wrap' }}>
|
||||
{[1, 5, 10, 25, 50, 100, 456, 789].map((value) => (
|
||||
<div key={value} style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={value} columns="auto" />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const ColorSchemes: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: '40px', flexWrap: 'wrap' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={456} colorScheme="place-value" />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Place Value</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={456} colorScheme="monochrome" />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Monochrome</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={456} colorScheme="heaven-earth" />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Heaven-Earth</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={456} colorScheme="alternating" />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Alternating</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const BeadShapes: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: '40px' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={42} beadShape="circle" />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Circle</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={42} beadShape="diamond" />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Diamond</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={42} beadShape="square" />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Square</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const CompactMode: Story = {
|
||||
render: () => (
|
||||
<div style={{ fontSize: '24px', display: 'flex', alignItems: 'center', gap: '15px' }}>
|
||||
<span>The equation:</span>
|
||||
<AbacusStatic value={5} columns={1} compact hideInactiveBeads scaleFactor={0.7} />
|
||||
<span>+</span>
|
||||
<AbacusStatic value={3} columns={1} compact hideInactiveBeads scaleFactor={0.7} />
|
||||
<span>=</span>
|
||||
<AbacusStatic value={8} columns={1} compact hideInactiveBeads scaleFactor={0.7} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const HideInactiveBeads: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: '40px' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={25} hideInactiveBeads={false} />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Show All</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={25} hideInactiveBeads />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Hide Inactive</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const WithThemes: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: '40px', flexWrap: 'wrap' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={123} customStyles={ABACUS_THEMES.light} />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Light</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', background: '#1e293b', borderRadius: '8px' }}>
|
||||
<AbacusStatic value={123} customStyles={ABACUS_THEMES.dark} />
|
||||
<p style={{ marginTop: '10px', color: '#cbd5e1' }}>Dark</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={123} customStyles={ABACUS_THEMES.trophy} />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Trophy</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const ColumnHighlightingAndLabels: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '40px' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic
|
||||
value={456}
|
||||
highlightColumns={[1]}
|
||||
columnLabels={['ones', 'tens', 'hundreds']}
|
||||
/>
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Highlighting tens place</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic
|
||||
value={789}
|
||||
highlightColumns={[0, 2]}
|
||||
columnLabels={['ones', 'tens', 'hundreds']}
|
||||
/>
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>Multiple highlights</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Scaling: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: '40px', alignItems: 'flex-end' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={9} scaleFactor={0.5} />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>0.5x</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={9} scaleFactor={1} />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>1x</p>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<AbacusStatic value={9} scaleFactor={1.5} />
|
||||
<p style={{ marginTop: '10px', color: '#64748b' }}>1.5x</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const ServerComponentExample: Story = {
|
||||
render: () => (
|
||||
<div style={{ maxWidth: '700px', padding: '20px', background: '#f8fafc', borderRadius: '8px' }}>
|
||||
<h3 style={{ marginTop: 0 }}>React Server Component Usage</h3>
|
||||
<pre
|
||||
style={{
|
||||
background: '#1e293b',
|
||||
color: '#e2e8f0',
|
||||
padding: '15px',
|
||||
borderRadius: '6px',
|
||||
overflow: 'auto',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
{`// app/flashcards/page.tsx (Server Component)
|
||||
import { AbacusStatic } from '@soroban/abacus-react'
|
||||
|
||||
export default function FlashcardsPage() {
|
||||
const numbers = [1, 5, 10, 25, 50, 100]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{numbers.map(num => (
|
||||
<div key={num} className="card">
|
||||
<AbacusStatic
|
||||
value={num}
|
||||
columns="auto"
|
||||
hideInactiveBeads
|
||||
compact
|
||||
/>
|
||||
<p>{num}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ✅ No "use client" needed!
|
||||
// ✅ Rendered on server
|
||||
// ✅ Zero client JavaScript`}
|
||||
</pre>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const PreviewCards: Story = {
|
||||
render: () => (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
|
||||
gap: '20px',
|
||||
maxWidth: '900px',
|
||||
}}
|
||||
>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 25, 50].map((value) => (
|
||||
<div
|
||||
key={value}
|
||||
style={{
|
||||
padding: '15px',
|
||||
background: 'white',
|
||||
border: '2px solid #e2e8f0',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
}}
|
||||
>
|
||||
<AbacusStatic value={value} columns="auto" scaleFactor={0.8} hideInactiveBeads />
|
||||
<span style={{ fontSize: '18px', fontWeight: 'bold', color: '#475569' }}>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}
|
||||
371
packages/abacus-react/src/AbacusStatic.tsx
Normal file
371
packages/abacus-react/src/AbacusStatic.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* AbacusStatic - Server Component compatible static abacus
|
||||
*
|
||||
* Shares core logic with AbacusReact but uses static rendering without hooks/animations.
|
||||
* Reuses: numberToAbacusState, getBeadColor logic, positioning calculations
|
||||
* Different: No hooks, no animations, no interactions, simplified rendering
|
||||
*/
|
||||
|
||||
import { numberToAbacusState, calculateAbacusDimensions } from './AbacusUtils'
|
||||
import { AbacusStaticBead } from './AbacusStaticBead'
|
||||
import type {
|
||||
AbacusCustomStyles,
|
||||
BeadConfig,
|
||||
ValidPlaceValues
|
||||
} from './AbacusReact'
|
||||
|
||||
export interface AbacusStaticConfig {
|
||||
value: number | bigint
|
||||
columns?: number | 'auto'
|
||||
beadShape?: 'circle' | 'diamond' | 'square'
|
||||
colorScheme?: 'monochrome' | 'place-value' | 'alternating' | 'heaven-earth'
|
||||
colorPalette?: 'default' | 'pastel' | 'vibrant' | 'earth-tones'
|
||||
showNumbers?: boolean | 'always' | 'never'
|
||||
hideInactiveBeads?: boolean
|
||||
scaleFactor?: number
|
||||
frameVisible?: boolean
|
||||
compact?: boolean
|
||||
customStyles?: AbacusCustomStyles
|
||||
highlightColumns?: number[]
|
||||
columnLabels?: string[]
|
||||
}
|
||||
|
||||
// Shared color logic from AbacusReact (simplified for static use)
|
||||
function getBeadColor(
|
||||
bead: BeadConfig,
|
||||
totalColumns: number,
|
||||
colorScheme: string,
|
||||
colorPalette: string
|
||||
): string {
|
||||
const placeValue = bead.placeValue
|
||||
|
||||
// Place-value coloring
|
||||
if (colorScheme === 'place-value') {
|
||||
const colors: Record<string, string[]> = {
|
||||
default: [
|
||||
'#ef4444', // red - ones
|
||||
'#f59e0b', // amber - tens
|
||||
'#10b981', // emerald - hundreds
|
||||
'#3b82f6', // blue - thousands
|
||||
'#8b5cf6', // purple - ten thousands
|
||||
'#ec4899', // pink - hundred thousands
|
||||
'#14b8a6', // teal - millions
|
||||
'#f97316', // orange - ten millions
|
||||
'#6366f1', // indigo - hundred millions
|
||||
'#84cc16', // lime - billions
|
||||
],
|
||||
pastel: [
|
||||
'#fca5a5', '#fcd34d', '#6ee7b7', '#93c5fd', '#c4b5fd',
|
||||
'#f9a8d4', '#5eead4', '#fdba74', '#a5b4fc', '#bef264',
|
||||
],
|
||||
vibrant: [
|
||||
'#dc2626', '#d97706', '#059669', '#2563eb', '#7c3aed',
|
||||
'#db2777', '#0d9488', '#ea580c', '#4f46e5', '#65a30d',
|
||||
],
|
||||
'earth-tones': [
|
||||
'#92400e', '#78350f', '#365314', '#1e3a8a', '#4c1d95',
|
||||
'#831843', '#134e4a', '#7c2d12', '#312e81', '#3f6212',
|
||||
],
|
||||
}
|
||||
|
||||
const palette = colors[colorPalette] || colors.default
|
||||
return palette[placeValue % palette.length]
|
||||
}
|
||||
|
||||
// Heaven-earth coloring
|
||||
if (colorScheme === 'heaven-earth') {
|
||||
return bead.type === 'heaven' ? '#3b82f6' : '#10b981'
|
||||
}
|
||||
|
||||
// Alternating coloring
|
||||
if (colorScheme === 'alternating') {
|
||||
const columnIndex = totalColumns - 1 - placeValue
|
||||
return columnIndex % 2 === 0 ? '#3b82f6' : '#10b981'
|
||||
}
|
||||
|
||||
// Monochrome (default)
|
||||
return '#3b82f6'
|
||||
}
|
||||
|
||||
// Calculate bead positions (simplified from AbacusReact)
|
||||
function calculateBeadPosition(
|
||||
bead: BeadConfig,
|
||||
dimensions: { beadSize: number; rodSpacing: number; heavenY: number; earthY: number; barY: number; totalColumns: number }
|
||||
): { x: number; y: number } {
|
||||
const { beadSize, rodSpacing, heavenY, earthY, barY, totalColumns } = dimensions
|
||||
|
||||
// X position based on place value (rightmost = ones place)
|
||||
const columnIndex = totalColumns - 1 - bead.placeValue
|
||||
const x = columnIndex * rodSpacing + rodSpacing / 2
|
||||
|
||||
// Y position based on bead type and active state
|
||||
if (bead.type === 'heaven') {
|
||||
// Heaven bead: if active, near bar; if inactive, at top
|
||||
const y = bead.active ? barY - beadSize - 5 : heavenY
|
||||
return { x, y }
|
||||
} else {
|
||||
// Earth bead: if active, stack up from bar; if inactive, at bottom
|
||||
const earthSpacing = beadSize + 4
|
||||
if (bead.active) {
|
||||
// Active earth beads stack upward from the bar
|
||||
const y = barY + beadSize / 2 + 10 + bead.position * earthSpacing
|
||||
return { x, y }
|
||||
} else {
|
||||
// Inactive earth beads rest at the bottom
|
||||
const y = earthY + (bead.position - 2) * earthSpacing
|
||||
return { x, y }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AbacusStatic - Pure static abacus component (Server Component compatible)
|
||||
*/
|
||||
export function AbacusStatic({
|
||||
value,
|
||||
columns = 'auto',
|
||||
beadShape = 'circle',
|
||||
colorScheme = 'place-value',
|
||||
colorPalette = 'default',
|
||||
showNumbers = true,
|
||||
hideInactiveBeads = false,
|
||||
scaleFactor = 1,
|
||||
frameVisible = true,
|
||||
compact = false,
|
||||
customStyles,
|
||||
highlightColumns = [],
|
||||
columnLabels = [],
|
||||
}: AbacusStaticConfig) {
|
||||
// Calculate columns
|
||||
const valueStr = value.toString().replace('-', '')
|
||||
const minColumns = Math.max(1, valueStr.length)
|
||||
const effectiveColumns = columns === 'auto' ? minColumns : Math.max(columns, minColumns)
|
||||
|
||||
// Use shared utility to convert value to bead states
|
||||
const state = numberToAbacusState(value, effectiveColumns)
|
||||
|
||||
// Generate bead configs (matching AbacusReact's structure)
|
||||
const beadConfigs: BeadConfig[][] = []
|
||||
for (let colIndex = 0; colIndex < effectiveColumns; colIndex++) {
|
||||
const placeValue = (effectiveColumns - 1 - colIndex) as ValidPlaceValues
|
||||
const columnState = state[placeValue] || { heavenActive: false, earthActive: 0 }
|
||||
|
||||
const beads: BeadConfig[] = []
|
||||
|
||||
// Heaven bead
|
||||
beads.push({
|
||||
type: 'heaven',
|
||||
value: 5,
|
||||
active: columnState.heavenActive,
|
||||
position: 0,
|
||||
placeValue,
|
||||
})
|
||||
|
||||
// Earth beads
|
||||
for (let i = 0; i < 4; i++) {
|
||||
beads.push({
|
||||
type: 'earth',
|
||||
value: 1,
|
||||
active: i < columnState.earthActive,
|
||||
position: i,
|
||||
placeValue,
|
||||
})
|
||||
}
|
||||
|
||||
beadConfigs.push(beads)
|
||||
}
|
||||
|
||||
// Calculate dimensions using shared utility
|
||||
const { width, height } = calculateAbacusDimensions({
|
||||
columns: effectiveColumns,
|
||||
showNumbers: !!showNumbers,
|
||||
columnLabels,
|
||||
})
|
||||
|
||||
// Layout constants (must match calculateAbacusDimensions)
|
||||
const beadSize = 20
|
||||
const rodSpacing = 40
|
||||
const heavenHeight = 60
|
||||
const earthHeight = 120
|
||||
const barHeight = 10
|
||||
const padding = 20
|
||||
const numberHeightCalc = showNumbers ? 30 : 0
|
||||
const labelHeight = columnLabels.length > 0 ? 30 : 0
|
||||
|
||||
const dimensions = {
|
||||
width,
|
||||
height,
|
||||
beadSize,
|
||||
rodSpacing,
|
||||
heavenY: padding + labelHeight + heavenHeight / 3,
|
||||
earthY: padding + labelHeight + heavenHeight + barHeight + earthHeight * 0.7,
|
||||
barY: padding + labelHeight + heavenHeight,
|
||||
padding,
|
||||
totalColumns: effectiveColumns,
|
||||
}
|
||||
|
||||
// Compact mode hides frame
|
||||
const effectiveFrameVisible = compact ? false : frameVisible
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={width * scaleFactor}
|
||||
height={height * scaleFactor}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className={`abacus-svg ${hideInactiveBeads ? 'hide-inactive-mode' : ''}`}
|
||||
style={{ overflow: 'visible', display: 'block' }}
|
||||
>
|
||||
<defs>
|
||||
<style>{`
|
||||
.abacus-bead {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
.hide-inactive-mode .abacus-bead.hidden-inactive {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
`}</style>
|
||||
</defs>
|
||||
|
||||
{/* Column highlights */}
|
||||
{highlightColumns.map((colIndex) => {
|
||||
if (colIndex < 0 || colIndex >= effectiveColumns) return null
|
||||
|
||||
const x = colIndex * rodSpacing + rodSpacing / 2 + padding
|
||||
const highlightWidth = rodSpacing * 0.9
|
||||
const highlightHeight = height - padding * 2 - numberHeightCalc - labelHeight
|
||||
|
||||
return (
|
||||
<rect
|
||||
key={`column-highlight-${colIndex}`}
|
||||
x={x - highlightWidth / 2}
|
||||
y={padding + labelHeight}
|
||||
width={highlightWidth}
|
||||
height={highlightHeight}
|
||||
fill="rgba(59, 130, 246, 0.15)"
|
||||
stroke="rgba(59, 130, 246, 0.4)"
|
||||
strokeWidth={2}
|
||||
rx={6}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Column labels */}
|
||||
{columnLabels.map((label, colIndex) => {
|
||||
if (!label || colIndex >= effectiveColumns) return null
|
||||
|
||||
const x = colIndex * rodSpacing + rodSpacing / 2 + padding
|
||||
|
||||
return (
|
||||
<text
|
||||
key={`column-label-${colIndex}`}
|
||||
x={x}
|
||||
y={padding + 15}
|
||||
textAnchor="middle"
|
||||
fontSize="14"
|
||||
fontWeight="600"
|
||||
fill="rgba(0, 0, 0, 0.7)"
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Rods (column posts) */}
|
||||
{effectiveFrameVisible && beadConfigs.map((_, colIndex) => {
|
||||
const x = colIndex * rodSpacing + rodSpacing / 2 + padding
|
||||
|
||||
return (
|
||||
<rect
|
||||
key={`rod-${colIndex}`}
|
||||
x={x - 3}
|
||||
y={padding + labelHeight}
|
||||
width={6}
|
||||
height={heavenHeight + earthHeight + barHeight}
|
||||
fill={customStyles?.columnPosts?.fill || 'rgb(0, 0, 0, 0.1)'}
|
||||
stroke={customStyles?.columnPosts?.stroke || 'rgba(0, 0, 0, 0.2)'}
|
||||
strokeWidth={customStyles?.columnPosts?.strokeWidth || 1}
|
||||
opacity={customStyles?.columnPosts?.opacity ?? 1}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Reckoning bar */}
|
||||
{effectiveFrameVisible && (
|
||||
<rect
|
||||
x={padding}
|
||||
y={dimensions.barY}
|
||||
width={effectiveColumns * rodSpacing}
|
||||
height={barHeight}
|
||||
fill={customStyles?.reckoningBar?.fill || 'rgb(0, 0, 0, 0.15)'}
|
||||
stroke={customStyles?.reckoningBar?.stroke || 'rgba(0, 0, 0, 0.3)'}
|
||||
strokeWidth={customStyles?.reckoningBar?.strokeWidth || 2}
|
||||
opacity={customStyles?.reckoningBar?.opacity ?? 1}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Beads */}
|
||||
{beadConfigs.map((columnBeads, colIndex) => {
|
||||
const placeValue = effectiveColumns - 1 - colIndex
|
||||
|
||||
return (
|
||||
<g key={`column-${colIndex}`}>
|
||||
{columnBeads.map((bead, beadIndex) => {
|
||||
const position = calculateBeadPosition(bead, dimensions)
|
||||
|
||||
// Adjust X for padding
|
||||
position.x += padding
|
||||
|
||||
const color = getBeadColor(bead, effectiveColumns, colorScheme, colorPalette)
|
||||
|
||||
return (
|
||||
<AbacusStaticBead
|
||||
key={`bead-${colIndex}-${beadIndex}`}
|
||||
bead={bead}
|
||||
x={position.x}
|
||||
y={position.y}
|
||||
size={beadSize}
|
||||
shape={beadShape}
|
||||
color={color}
|
||||
hideInactiveBeads={hideInactiveBeads}
|
||||
customStyle={
|
||||
bead.type === 'heaven'
|
||||
? customStyles?.heavenBeads
|
||||
: customStyles?.earthBeads
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Column numbers */}
|
||||
{showNumbers && beadConfigs.map((_, colIndex) => {
|
||||
const placeValue = effectiveColumns - 1 - colIndex
|
||||
const columnState = state[placeValue] || { heavenActive: false, earthActive: 0 }
|
||||
const digit = (columnState.heavenActive ? 5 : 0) + columnState.earthActive
|
||||
const x = colIndex * rodSpacing + rodSpacing / 2 + padding
|
||||
|
||||
return (
|
||||
<text
|
||||
key={`number-${colIndex}`}
|
||||
x={x}
|
||||
y={height - padding + 5}
|
||||
textAnchor="middle"
|
||||
fontSize={customStyles?.numerals?.fontSize || 16}
|
||||
fontWeight={customStyles?.numerals?.fontWeight || '600'}
|
||||
fill={customStyles?.numerals?.color || 'rgba(0, 0, 0, 0.8)'}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
>
|
||||
{digit}
|
||||
</text>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default AbacusStatic
|
||||
100
packages/abacus-react/src/AbacusStaticBead.tsx
Normal file
100
packages/abacus-react/src/AbacusStaticBead.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* StaticBead - Pure SVG bead with no animations or interactions
|
||||
* Used by AbacusStatic for server-side rendering
|
||||
*/
|
||||
|
||||
import type { BeadConfig, BeadStyle } from './AbacusReact'
|
||||
|
||||
export interface StaticBeadProps {
|
||||
bead: BeadConfig
|
||||
x: number
|
||||
y: number
|
||||
size: number
|
||||
shape: 'diamond' | 'square' | 'circle'
|
||||
color: string
|
||||
customStyle?: BeadStyle
|
||||
hideInactiveBeads?: boolean
|
||||
}
|
||||
|
||||
export function AbacusStaticBead({
|
||||
bead,
|
||||
x,
|
||||
y,
|
||||
size,
|
||||
shape,
|
||||
color,
|
||||
customStyle,
|
||||
hideInactiveBeads = false,
|
||||
}: StaticBeadProps) {
|
||||
// Don't render inactive beads if hideInactiveBeads is true
|
||||
if (!bead.active && hideInactiveBeads) {
|
||||
return null
|
||||
}
|
||||
|
||||
const halfSize = size / 2
|
||||
const opacity = bead.active ? (customStyle?.opacity ?? 1) : 0.3
|
||||
const fill = customStyle?.fill || color
|
||||
const stroke = customStyle?.stroke || '#000'
|
||||
const strokeWidth = customStyle?.strokeWidth || 0.5
|
||||
|
||||
// Calculate offset based on shape (matching AbacusReact positioning)
|
||||
const getXOffset = () => {
|
||||
return shape === 'diamond' ? size * 0.7 : halfSize
|
||||
}
|
||||
|
||||
const getYOffset = () => {
|
||||
return halfSize
|
||||
}
|
||||
|
||||
const transform = `translate(${x - getXOffset()}, ${y - getYOffset()})`
|
||||
|
||||
const renderShape = () => {
|
||||
switch (shape) {
|
||||
case 'diamond':
|
||||
return (
|
||||
<polygon
|
||||
points={`${size * 0.7},0 ${size * 1.4},${halfSize} ${size * 0.7},${size} 0,${halfSize}`}
|
||||
fill={fill}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
opacity={opacity}
|
||||
/>
|
||||
)
|
||||
case 'square':
|
||||
return (
|
||||
<rect
|
||||
width={size}
|
||||
height={size}
|
||||
fill={fill}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
rx="1"
|
||||
opacity={opacity}
|
||||
/>
|
||||
)
|
||||
case 'circle':
|
||||
default:
|
||||
return (
|
||||
<circle
|
||||
cx={halfSize}
|
||||
cy={halfSize}
|
||||
r={halfSize}
|
||||
fill={fill}
|
||||
stroke={stroke}
|
||||
strokeWidth={strokeWidth}
|
||||
opacity={opacity}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<g
|
||||
className={`abacus-bead ${bead.active ? 'active' : 'inactive'} ${hideInactiveBeads && !bead.active ? 'hidden-inactive' : ''}`}
|
||||
transform={transform}
|
||||
style={{ transition: 'opacity 0.2s ease-in-out' }}
|
||||
>
|
||||
{renderShape()}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
@@ -356,3 +356,37 @@ function getPlaceName(place: number): string {
|
||||
return `place ${place} column`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the natural dimensions of an abacus SVG
|
||||
* This uses the same logic as AbacusStatic to ensure consistency
|
||||
*
|
||||
* @param columns - Number of columns in the abacus
|
||||
* @param showNumbers - Whether numbers are shown below columns
|
||||
* @param columnLabels - Array of column labels (if any)
|
||||
* @returns Object with width and height in pixels (at scale=1)
|
||||
*/
|
||||
export function calculateAbacusDimensions({
|
||||
columns,
|
||||
showNumbers = true,
|
||||
columnLabels = [],
|
||||
}: {
|
||||
columns: number
|
||||
showNumbers?: boolean
|
||||
columnLabels?: string[]
|
||||
}): { width: number; height: number } {
|
||||
// Constants matching AbacusStatic
|
||||
const beadSize = 20
|
||||
const rodSpacing = 40
|
||||
const heavenHeight = 60
|
||||
const earthHeight = 120
|
||||
const barHeight = 10
|
||||
const padding = 20
|
||||
const numberHeightCalc = showNumbers ? 30 : 0
|
||||
const labelHeight = columnLabels.length > 0 ? 30 : 0
|
||||
|
||||
const width = columns * rodSpacing + padding * 2
|
||||
const height = heavenHeight + earthHeight + barHeight + padding * 2 + numberHeightCalc + labelHeight
|
||||
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ export type {
|
||||
export { StandaloneBead } from "./StandaloneBead";
|
||||
export type { StandaloneBeadProps } from "./StandaloneBead";
|
||||
|
||||
export { AbacusStatic } from "./AbacusStatic";
|
||||
export type { AbacusStaticConfig } from "./AbacusStatic";
|
||||
|
||||
export { ABACUS_THEMES } from "./AbacusThemes";
|
||||
export type { AbacusThemeName } from "./AbacusThemes";
|
||||
|
||||
@@ -44,6 +47,7 @@ export {
|
||||
calculateBeadDiffFromValues,
|
||||
validateAbacusValue,
|
||||
areStatesEqual,
|
||||
calculateAbacusDimensions,
|
||||
} from "./AbacusUtils";
|
||||
export type {
|
||||
BeadState,
|
||||
|
||||
18
packages/abacus-react/src/static.ts
Normal file
18
packages/abacus-react/src/static.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Server Component compatible exports
|
||||
* This entry point only exports components that work without "use client"
|
||||
*/
|
||||
|
||||
export { AbacusStatic } from './AbacusStatic'
|
||||
export type { AbacusStaticConfig } from './AbacusStatic'
|
||||
export { AbacusStaticBead } from './AbacusStaticBead'
|
||||
export type { StaticBeadProps } from './AbacusStaticBead'
|
||||
|
||||
// Re-export shared utilities that are safe for server components
|
||||
export { numberToAbacusState, calculateAbacusDimensions } from './AbacusUtils'
|
||||
export type {
|
||||
AbacusCustomStyles,
|
||||
BeadConfig,
|
||||
PlaceState,
|
||||
ValidPlaceValues,
|
||||
} from './AbacusReact'
|
||||
@@ -8,10 +8,12 @@ export default defineConfig(async () => {
|
||||
plugins: [react()],
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, "src/index.ts"),
|
||||
name: "AbacusReact",
|
||||
entry: {
|
||||
index: resolve(__dirname, "src/index.ts"),
|
||||
static: resolve(__dirname, "src/static.ts"),
|
||||
},
|
||||
formats: ["es", "cjs"],
|
||||
fileName: (format) => `index.${format}.js`,
|
||||
fileName: (format, entryName) => `${entryName}.${format === "es" ? "es" : "cjs"}.js`,
|
||||
},
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
|
||||
Reference in New Issue
Block a user