fix: correct hero abacus scroll direction to flow with page content
The hero abacus was scrolling in the opposite direction of page content due to incorrect math. Fixed by subtracting scroll position instead of adding it.
Change: top: calc(50vh - ${scrollY}px) instead of calc(50vh + ${scrollY}px)
Now the abacus properly scrolls up with the page content when scrolling down.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
6620418a70
commit
423274657c
|
|
@ -155,7 +155,12 @@
|
|||
"Bash(git clone:*)",
|
||||
"Bash(git ls-remote:*)",
|
||||
"Bash(openscad:*)",
|
||||
"Bash(npx eslint:*)"
|
||||
"Bash(npx eslint:*)",
|
||||
"Bash(env)",
|
||||
"Bash(security find-generic-password -s 'Anthropic API Key' -w)",
|
||||
"Bash(printenv:*)",
|
||||
"Bash(typst:*)",
|
||||
"Bash(npx tsx:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Generate all abacus SVGs needed for a calendar
|
||||
* Usage: npx tsx scripts/generateCalendarSVGs.tsx <maxDay> <year> <customStylesJson>
|
||||
* Example: npx tsx scripts/generateCalendarSVGs.tsx 31 2025 '{}'
|
||||
*
|
||||
* This script runs as a subprocess to avoid Next.js restrictions on react-dom/server in API routes.
|
||||
* Pattern copied from generateAbacusIcons.tsx which works correctly.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type { AbacusReact as AbacusReactType } from '@soroban/abacus-react'
|
||||
|
||||
// Use dynamic import to ensure correct module resolution
|
||||
const AbacusReactModule = require('@soroban/abacus-react')
|
||||
const AbacusReact = AbacusReactModule.AbacusReact || AbacusReactModule.default
|
||||
|
||||
// Get arguments
|
||||
const maxDay = parseInt(process.argv[2], 10)
|
||||
const year = parseInt(process.argv[3], 10)
|
||||
const customStylesJson = process.argv[4] || '{}'
|
||||
|
||||
if (!maxDay || maxDay < 1 || maxDay > 31) {
|
||||
console.error('Invalid maxDay argument')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!year || year < 1 || year > 9999) {
|
||||
console.error('Invalid year argument')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let customStyles: any
|
||||
try {
|
||||
customStyles = JSON.parse(customStylesJson)
|
||||
} catch (error) {
|
||||
console.error('Invalid JSON for customStyles')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
interface CalendarSVGs {
|
||||
days: Record<string, string>
|
||||
year: string
|
||||
}
|
||||
|
||||
const result: CalendarSVGs = {
|
||||
days: {},
|
||||
year: '',
|
||||
}
|
||||
|
||||
// Generate day SVGs
|
||||
for (let day = 1; day <= maxDay; day++) {
|
||||
const svg = renderToStaticMarkup(
|
||||
<AbacusReact
|
||||
value={day}
|
||||
columns={2}
|
||||
customStyles={customStyles}
|
||||
scaleFactor={1}
|
||||
animated={false}
|
||||
interactive={false}
|
||||
/>
|
||||
)
|
||||
result.days[`day-${day}`] = svg
|
||||
}
|
||||
|
||||
// Generate year SVG
|
||||
const yearColumns = Math.max(1, Math.ceil(Math.log10(year + 1)))
|
||||
const yearSvg = renderToStaticMarkup(
|
||||
<AbacusReact
|
||||
value={year}
|
||||
columns={yearColumns}
|
||||
customStyles={customStyles}
|
||||
scaleFactor={1}
|
||||
animated={false}
|
||||
interactive={false}
|
||||
/>
|
||||
)
|
||||
result.year = yearSvg
|
||||
|
||||
// Output as JSON to stdout
|
||||
process.stdout.write(JSON.stringify(result))
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Generate a single abacus SVG
|
||||
* Usage: npx tsx scripts/generateSingleAbacusSVG.tsx <value> <columns> <customStylesJson>
|
||||
* Example: npx tsx scripts/generateSingleAbacusSVG.tsx 15 2 '{}'
|
||||
*
|
||||
* Pattern copied from generateDayIcon.tsx
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
|
||||
// Get arguments
|
||||
const value = parseInt(process.argv[2], 10)
|
||||
const columns = parseInt(process.argv[3], 10)
|
||||
const customStylesJson = process.argv[4] || '{}'
|
||||
|
||||
if (isNaN(value) || value < 0) {
|
||||
console.error('Invalid value argument')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (isNaN(columns) || columns < 1) {
|
||||
console.error('Invalid columns argument')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let customStyles: any
|
||||
try {
|
||||
customStyles = JSON.parse(customStylesJson)
|
||||
} catch (error) {
|
||||
console.error('Invalid JSON for customStyles')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Render abacus
|
||||
const abacusMarkup = renderToStaticMarkup(
|
||||
<AbacusReact
|
||||
value={value}
|
||||
columns={columns}
|
||||
customStyles={customStyles}
|
||||
scaleFactor={1}
|
||||
animated={false}
|
||||
interactive={false}
|
||||
/>
|
||||
)
|
||||
|
||||
// Output SVG to stdout
|
||||
process.stdout.write(abacusMarkup)
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { writeFileSync, readFileSync, 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'
|
||||
|
||||
interface CalendarRequest {
|
||||
month: number
|
||||
year: number
|
||||
format: 'monthly' | 'daily'
|
||||
paperSize: 'us-letter' | 'a4' | 'a3' | 'tabloid'
|
||||
abacusConfig?: AbacusConfig
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let tempDir: string | null = null
|
||||
|
||||
try {
|
||||
const body: CalendarRequest = await request.json()
|
||||
const { month, year, format, paperSize, abacusConfig } = body
|
||||
|
||||
// Validate inputs
|
||||
if (!month || month < 1 || month > 12 || !year || year < 1 || year > 9999) {
|
||||
return NextResponse.json({ error: 'Invalid month or year' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Create temp directory
|
||||
tempDir = join(tmpdir(), `calendar-${Date.now()}-${Math.random()}`)
|
||||
mkdirSync(tempDir, { recursive: true })
|
||||
|
||||
// Generate SVGs using script (avoids Next.js react-dom/server restriction)
|
||||
const daysInMonth = getDaysInMonth(year, month)
|
||||
const maxDay = format === 'daily' ? daysInMonth : 31 // For monthly, pre-generate all
|
||||
const customStyles = abacusConfig?.customStyles || {}
|
||||
|
||||
// Call script to generate all SVGs
|
||||
const scriptPath = join(process.cwd(), 'scripts', 'generateCalendarSVGs.tsx')
|
||||
const customStylesJson = JSON.stringify(customStyles)
|
||||
const svgsJson = execSync(`npx tsx "${scriptPath}" ${maxDay} ${year} '${customStylesJson}'`, {
|
||||
encoding: 'utf-8',
|
||||
cwd: process.cwd(),
|
||||
})
|
||||
|
||||
interface CalendarSVGs {
|
||||
days: Record<string, string>
|
||||
year: string
|
||||
}
|
||||
|
||||
const svgs: CalendarSVGs = JSON.parse(svgsJson)
|
||||
|
||||
// Write day SVGs to temp directory
|
||||
for (const [key, svg] of Object.entries(svgs.days)) {
|
||||
writeFileSync(join(tempDir, `${key}.svg`), svg)
|
||||
}
|
||||
|
||||
// Write year SVG
|
||||
writeFileSync(join(tempDir, 'year.svg'), svgs.year)
|
||||
|
||||
// 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')
|
||||
try {
|
||||
execSync(`typst compile "${typstPath}" "${pdfPath}"`, {
|
||||
stdio: 'pipe',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Typst compilation error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to compile PDF. Is Typst installed?' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// 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"`,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error generating calendar:', 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)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Failed to generate calendar' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
interface TypstConfig {
|
||||
month: number
|
||||
year: number
|
||||
paperSize: 'us-letter' | 'a4' | 'a3' | 'tabloid'
|
||||
tempDir: string
|
||||
daysInMonth: number
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
]
|
||||
|
||||
export 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() // 0 = Sunday
|
||||
}
|
||||
|
||||
function getDayOfWeek(year: number, month: number, day: number): string {
|
||||
const date = new Date(year, month - 1, day)
|
||||
return date.toLocaleDateString('en-US', { weekday: 'long' })
|
||||
}
|
||||
|
||||
type PaperSize = 'us-letter' | 'a4' | 'a3' | 'tabloid'
|
||||
|
||||
interface PaperConfig {
|
||||
typstName: string
|
||||
marginX: string
|
||||
marginY: string
|
||||
}
|
||||
|
||||
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' },
|
||||
}
|
||||
return configs[size as PaperSize] || configs['us-letter']
|
||||
}
|
||||
|
||||
export function generateMonthlyTypst(config: TypstConfig): string {
|
||||
const { month, year, paperSize, tempDir, daysInMonth } = 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`
|
||||
}
|
||||
|
||||
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%)
|
||||
]
|
||||
|
||||
#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
|
||||
const paperConfig = getPaperConfig(paperSize)
|
||||
const monthName = MONTH_NAMES[month - 1]
|
||||
|
||||
let pages = ''
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dayOfWeek = getDayOfWeek(year, month, day)
|
||||
|
||||
pages += `
|
||||
#page(
|
||||
paper: "${paperConfig.typstName}",
|
||||
margin: (x: ${paperConfig.marginX}, y: ${paperConfig.marginY}),
|
||||
)[
|
||||
// Header: Year
|
||||
#align(center)[
|
||||
#v(1em)
|
||||
#image("${tempDir}/year.svg", width: 30%)
|
||||
]
|
||||
|
||||
#v(2em)
|
||||
|
||||
// Main: Day number as large abacus
|
||||
#align(center + horizon)[
|
||||
#image("${tempDir}/day-${day}.svg", width: 50%)
|
||||
]
|
||||
|
||||
#v(2em)
|
||||
|
||||
// Footer: Day of week and date
|
||||
#align(center)[
|
||||
#text(size: 18pt, weight: "bold")[${dayOfWeek}]
|
||||
|
||||
#v(0.5em)
|
||||
|
||||
#text(size: 14pt)[${monthName} ${day}, ${year}]
|
||||
]
|
||||
|
||||
// Notes section
|
||||
#v(3em)
|
||||
#line(length: 100%, stroke: 0.5pt)
|
||||
#v(0.5em)
|
||||
#text(size: 10pt, fill: gray)[Notes:]
|
||||
#v(0.5em)
|
||||
#line(length: 100%, stroke: 0.5pt)
|
||||
#v(1em)
|
||||
#line(length: 100%, stroke: 0.5pt)
|
||||
#v(1em)
|
||||
#line(length: 100%, stroke: 0.5pt)
|
||||
#v(1em)
|
||||
#line(length: 100%, stroke: 0.5pt)
|
||||
]
|
||||
|
||||
${day < daysInMonth ? '' : ''}`
|
||||
|
||||
if (day < daysInMonth) {
|
||||
pages += '\n'
|
||||
}
|
||||
}
|
||||
|
||||
return `#set text(font: "Arial")
|
||||
${pages}
|
||||
`
|
||||
}
|
||||
|
|
@ -22,6 +22,21 @@ export function MyAbacus() {
|
|||
const isHeroVisible = homeHeroContext?.isHeroVisible ?? false
|
||||
const isHeroMode = isOnHomePage && isHeroVisible && !isOpen
|
||||
|
||||
// Track scroll position for hero mode
|
||||
const [scrollY, setScrollY] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHeroMode) return
|
||||
|
||||
const handleScroll = () => {
|
||||
setScrollY(window.scrollY)
|
||||
}
|
||||
|
||||
handleScroll() // Initial position
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [isHeroMode])
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
|
@ -138,12 +153,20 @@ export function MyAbacus() {
|
|||
data-component="my-abacus"
|
||||
data-mode={isOpen ? 'open' : isHeroMode ? 'hero' : 'button'}
|
||||
onClick={isOpen || isHeroMode ? undefined : toggle}
|
||||
style={
|
||||
isHeroMode
|
||||
? {
|
||||
// Hero mode: position accounts for scroll to flow with page (subtract scroll to move up with content)
|
||||
top: `calc(50vh - ${scrollY}px)`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
zIndex: Z_INDEX.MY_ABACUS,
|
||||
cursor: isOpen || isHeroMode ? 'default' : 'pointer',
|
||||
transition: 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
// Three modes: hero (top center), button (bottom-right), open (center)
|
||||
transition: isHeroMode ? 'none' : 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
// Three modes: hero (inline with content), button (bottom-right), open (center)
|
||||
...(isOpen
|
||||
? {
|
||||
// Open mode: center of screen
|
||||
|
|
@ -153,8 +176,7 @@ export function MyAbacus() {
|
|||
}
|
||||
: isHeroMode
|
||||
? {
|
||||
// Hero mode: top center (in viewport flow position)
|
||||
top: '50vh',
|
||||
// Hero mode: centered horizontally, top handled by inline style
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue