feat: add comprehensive metadata, SEO, and make AbacusReact SSR-compatible

- Update metadata in layout.tsx with full SEO, Open Graph, Twitter cards, PWA config
- Create sitemap.ts for dynamic sitemap generation
- Create robots.ts for search engine guidance
- Create icon.svg favicon generated from AbacusReact component
- Create opengraph-image.tsx for dynamic Open Graph image generation
- Create public/og-image.svg as fallback Open Graph image
- Make AbacusReact component SSR-compatible by:
  - Detecting server environment and conditionally using animations
  - Using regular SVG elements instead of animated ones when on server
  - Making useSpring and useDrag hooks SSR-safe
- Add generateAbacusIcons.tsx script to generate icons from real AbacusReact component

All icons now use the actual AbacusReact component rendering, ensuring consistency
between static assets and the interactive version.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-11-03 04:06:00 -06:00
parent 489c849873
commit 0922ea10b7
8 changed files with 658 additions and 41 deletions

View File

@@ -0,0 +1,69 @@
<svg width="1200" height="630" viewBox="0 0 1200 630" xmlns="http://www.w3.org/2000/svg">
<!-- Gradient background -->
<defs>
<linearGradient id="bg-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#fef3c7;stop-opacity:1" />
<stop offset="100%" style="stop-color:#fcd34d;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background -->
<rect width="1200" height="630" fill="url(#bg-gradient)"/>
<!-- Left side - Abacus from @soroban/abacus-react -->
<g transform="translate(80, 100) scale(0.9)">
<div class="abacus-container" style="display:inline-block;text-align:center;position:relative"><svg width="135" height="216" viewBox="0 0 135 216" class="abacus-svg " style="overflow:visible;display:block"><defs><style>
/* CSS-based opacity system for hidden inactive beads */
.abacus-bead {
transition: opacity 0.2s ease-in-out;
}
/* Hidden inactive beads are invisible by default */
.hide-inactive-mode .abacus-bead.hidden-inactive {
opacity: 0;
}
/* Interactive abacus: When hovering over the abacus, hidden inactive beads become semi-transparent */
.abacus-svg.hide-inactive-mode.interactive:hover .abacus-bead.hidden-inactive {
opacity: 0.5;
}
/* Interactive abacus: When hovering over a specific hidden inactive bead, it becomes fully visible */
.hide-inactive-mode.interactive .abacus-bead.hidden-inactive:hover {
opacity: 1 !important;
}
/* Non-interactive abacus: Hidden inactive beads always stay at opacity 0 */
.abacus-svg.hide-inactive-mode:not(.interactive) .abacus-bead.hidden-inactive {
opacity: 0 !important;
}
</style></defs><rect x="19.8" y="0" width="5.4" height="216" fill="#7c2d12" stroke="#92400e" stroke-width="2" opacity="1"></rect><rect x="64.8" y="0" width="5.4" height="216" fill="#7c2d12" stroke="#92400e" stroke-width="2" opacity="1"></rect><rect x="109.8" y="0" width="5.4" height="216" fill="#7c2d12" stroke="#92400e" stroke-width="2" opacity="1"></rect><rect x="11.7" y="54" width="111.6" height="3.6" fill="#92400e" stroke="#92400e" stroke-width="3" opacity="1"></rect><g class="abacus-bead inactive " transform="translate(7.380000000000001, 18)" style="cursor:default;touch-action:none;transition:opacity 0.2s ease-in-out"><polygon points="15.12,0 30.24,10.8 15.12,21.6 0,10.8" fill="#fbbf24" stroke="#000" stroke-width="0.5"></polygon></g><g class="abacus-bead active " transform="translate(7.380000000000001, 59.400000000000006)" style="cursor:default;touch-action:none;transition:opacity 0.2s ease-in-out"><polygon points="15.12,0 30.24,10.8 15.12,21.6 0,10.8" fill="#fbbf24" stroke="#000" stroke-width="0.5"></polygon></g><g class="abacus-bead inactive " transform="translate(7.380000000000001, 95.4)" style="cursor:default;touch-action:none;transition:opacity 0.2s ease-in-out"><polygon points="15.12,0 30.24,10.8 15.12,21.6 0,10.8" fill="#fbbf24" stroke="#000" stroke-width="0.5"></polygon></g><g class="abacus-bead inactive " transform="translate(7.380000000000001, 117.89999999999999)" style="cursor:default;touch-action:none;transition:opacity 0.2s ease-in-out"><polygon points="15.12,0 30.24,10.8 15.12,21.6 0,10.8" fill="#fbbf24" stroke="#000" stroke-width="0.5"></polygon></g><g class="abacus-bead inactive " transform="translate(7.380000000000001, 140.39999999999998)" style="cursor:default;touch-action:none;transition:opacity 0.2s ease-in-out"><polygon points="15.12,0 30.24,10.8 15.12,21.6 0,10.8" fill="#fbbf24" stroke="#000" stroke-width="0.5"></polygon></g><g class="abacus-bead inactive " transform="translate(52.38, 18)" style="cursor:default;touch-action:none;transition:opacity 0.2s ease-in-out"><polygon points="15.12,0 30.24,10.8 15.12,21.6 0,10.8" fill="#fbbf24" stroke="#000" stroke-width="0.5"></polygon></g><g class="abacus-bead active " transform="translate(52.38, 59.400000000000006)" style="cursor:default;touch-action:none;transition:opacity 0.2s ease-in-out"><polygon points="15.12,0 30.24,10.8 15.12,21.6 0,10.8" fill="#fbbf24" stroke="#000" stroke-width="0.5"></polygon></g><g class="abacus-bead active " transform="translate(52.38, 81.9)" style="cursor:default;touch-action:none;transition:opacity 0.2s ease-in-out"><polygon points="15.12,0 30.24,10.8 15.12,21.6 0,10.8" fill="#fbbf24" stroke="#000" stroke-width="0.5"></polygon></g><g class="abacus-bead inactive " transform="translate(52.38, 117.90000000000002)" style="cursor:default;touch-action:none;transition:opacity 0.2s ease-in-out"><polygon points="15.12,0 30.24,10.8 15.12,21.6 0,10.8" fill="#fbbf24" stroke="#000" stroke-width="0.5"></polygon></g><g class="abacus-bead inactive " transform="translate(52.38, 140.4)" style="cursor:default;touch-action:none;transition:opacity 0.2s ease-in-out"><polygon points="15.12,0 30.24,10.8 15.12,21.6 0,10.8" fill="#fbbf24" stroke="#000" stroke-width="0.5"></polygon></g><g class="abacus-bead inactive " transform="translate(97.38, 18)" style="cursor:default;touch-action:none;transition:opacity 0.2s ease-in-out"><polygon points="15.12,0 30.24,10.8 15.12,21.6 0,10.8" fill="#fbbf24" stroke="#000" stroke-width="0.5"></polygon></g><g class="abacus-bead active " transform="translate(97.38, 59.400000000000006)" style="cursor:default;touch-action:none;transition:opacity 0.2s ease-in-out"><polygon points="15.12,0 30.24,10.8 15.12,21.6 0,10.8" fill="#fbbf24" stroke="#000" stroke-width="0.5"></polygon></g><g class="abacus-bead active " transform="translate(97.38, 81.9)" style="cursor:default;touch-action:none;transition:opacity 0.2s ease-in-out"><polygon points="15.12,0 30.24,10.8 15.12,21.6 0,10.8" fill="#fbbf24" stroke="#000" stroke-width="0.5"></polygon></g><g class="abacus-bead active " transform="translate(97.38, 104.4)" style="cursor:default;touch-action:none;transition:opacity 0.2s ease-in-out"><polygon points="15.12,0 30.24,10.8 15.12,21.6 0,10.8" fill="#fbbf24" stroke="#000" stroke-width="0.5"></polygon></g><g class="abacus-bead inactive " transform="translate(97.38, 140.4)" style="cursor:default;touch-action:none;transition:opacity 0.2s ease-in-out"><polygon points="15.12,0 30.24,10.8 15.12,21.6 0,10.8" fill="#fbbf24" stroke="#000" stroke-width="0.5"></polygon></g><rect x="0" y="0" width="45" height="216" fill="transparent" stroke="none" style="cursor:default;pointer-events:none"></rect><rect x="45" y="0" width="45" height="216" fill="transparent" stroke="none" style="cursor:default;pointer-events:none"></rect><rect x="90" y="0" width="45" height="216" fill="transparent" stroke="none" style="cursor:default;pointer-events:none"></rect></svg></div>
</g>
<!-- Right side - Text content -->
<g transform="translate(550, 180)">
<!-- Main title -->
<text x="0" y="0" font-family="Arial, sans-serif" font-size="80" font-weight="bold" fill="#7c2d12">
Abaci.One
</text>
<!-- Subtitle -->
<text x="0" y="80" font-family="Arial, sans-serif" font-size="36" font-weight="600" fill="#92400e">
Learn Soroban Through Play
</text>
<!-- Features -->
<text x="0" y="150" font-family="Arial, sans-serif" font-size="28" fill="#78350f">
• Interactive Games
</text>
<text x="0" y="190" font-family="Arial, sans-serif" font-size="28" fill="#78350f">
• Tutorials
</text>
<text x="0" y="230" font-family="Arial, sans-serif" font-size="28" fill="#78350f">
• Practice Tools
</text>
</g>
<!-- Bottom accent line -->
<rect x="0" y="610" width="1200" height="20" fill="#92400e" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@@ -0,0 +1,147 @@
#!/usr/bin/env tsx
/**
* Generate icon.svg and og-image.svg from AbacusReact component
*
* This script renders AbacusReact server-side to produce the exact same
* SVG output as the interactive client-side version (without animations).
*/
import { renderToStaticMarkup } from 'react-dom/server'
import { writeFileSync } from 'fs'
import { join } from 'path'
import { AbacusReact } from '@soroban/abacus-react'
// Generate the favicon (icon.svg) - single column showing value 5
function generateFavicon(): string {
const iconSvg = renderToStaticMarkup(
<AbacusReact
value={5}
columns={1}
scaleFactor={1.0}
animated={false}
interactive={false}
showNumbers={false}
customStyles={{
heavenBeads: { fill: '#fbbf24' },
earthBeads: { fill: '#fbbf24' },
columnPosts: {
fill: '#7c2d12',
stroke: '#92400e',
strokeWidth: 2,
},
reckoningBar: {
fill: '#92400e',
stroke: '#92400e',
strokeWidth: 3,
},
}}
/>
)
// Wrap in SVG with proper viewBox for favicon sizing
return `<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<!-- Background circle for better visibility -->
<circle cx="50" cy="50" r="48" fill="#fef3c7"/>
<!-- Abacus from @soroban/abacus-react -->
<g transform="translate(32, 8) scale(0.36)">
${iconSvg}
</g>
</svg>
`
}
// Generate the Open Graph image (og-image.svg)
function generateOGImage(): string {
const abacusSvg = renderToStaticMarkup(
<AbacusReact
value={123}
columns={3}
scaleFactor={1.8}
animated={false}
interactive={false}
showNumbers={false}
customStyles={{
heavenBeads: { fill: '#fbbf24' },
earthBeads: { fill: '#fbbf24' },
columnPosts: {
fill: '#7c2d12',
stroke: '#92400e',
strokeWidth: 2,
},
reckoningBar: {
fill: '#92400e',
stroke: '#92400e',
strokeWidth: 3,
},
}}
/>
)
return `<svg width="1200" height="630" viewBox="0 0 1200 630" xmlns="http://www.w3.org/2000/svg">
<!-- Gradient background -->
<defs>
<linearGradient id="bg-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#fef3c7;stop-opacity:1" />
<stop offset="100%" style="stop-color:#fcd34d;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background -->
<rect width="1200" height="630" fill="url(#bg-gradient)"/>
<!-- Left side - Abacus from @soroban/abacus-react -->
<g transform="translate(80, 100) scale(0.9)">
${abacusSvg}
</g>
<!-- Right side - Text content -->
<g transform="translate(550, 180)">
<!-- Main title -->
<text x="0" y="0" font-family="Arial, sans-serif" font-size="80" font-weight="bold" fill="#7c2d12">
Abaci.One
</text>
<!-- Subtitle -->
<text x="0" y="80" font-family="Arial, sans-serif" font-size="36" font-weight="600" fill="#92400e">
Learn Soroban Through Play
</text>
<!-- Features -->
<text x="0" y="150" font-family="Arial, sans-serif" font-size="28" fill="#78350f">
• Interactive Games
</text>
<text x="0" y="190" font-family="Arial, sans-serif" font-size="28" fill="#78350f">
• Tutorials
</text>
<text x="0" y="230" font-family="Arial, sans-serif" font-size="28" fill="#78350f">
• Practice Tools
</text>
</g>
<!-- Bottom accent line -->
<rect x="0" y="610" width="1200" height="20" fill="#92400e" opacity="0.3"/>
</svg>
`
}
// Main execution
const appDir = __dirname.replace('/scripts', '')
try {
console.log('Generating favicon from AbacusReact...')
const faviconSvg = generateFavicon()
writeFileSync(join(appDir, 'src', 'app', 'icon.svg'), faviconSvg)
console.log('✓ Generated src/app/icon.svg')
console.log('Generating Open Graph image from AbacusReact...')
const ogImageSvg = generateOGImage()
writeFileSync(join(appDir, 'public', 'og-image.svg'), ogImageSvg)
console.log('✓ Generated public/og-image.svg')
console.log('\n✅ All icons generated successfully!')
} catch (error) {
console.error('❌ Error generating icons:', error)
process.exit(1)
}

34
apps/web/src/app/icon.svg Normal file
View File

@@ -0,0 +1,34 @@
<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<!-- Background circle for better visibility -->
<circle cx="50" cy="50" r="48" fill="#fef3c7"/>
<!-- Abacus from @soroban/abacus-react -->
<g transform="translate(32, 8) scale(0.36)">
<div class="abacus-container" style="display:inline-block;text-align:center;position:relative"><svg width="25" height="120" viewBox="0 0 25 120" class="abacus-svg " style="overflow:visible;display:block"><defs><style>
/* CSS-based opacity system for hidden inactive beads */
.abacus-bead {
transition: opacity 0.2s ease-in-out;
}
/* Hidden inactive beads are invisible by default */
.hide-inactive-mode .abacus-bead.hidden-inactive {
opacity: 0;
}
/* Interactive abacus: When hovering over the abacus, hidden inactive beads become semi-transparent */
.abacus-svg.hide-inactive-mode.interactive:hover .abacus-bead.hidden-inactive {
opacity: 0.5;
}
/* Interactive abacus: When hovering over a specific hidden inactive bead, it becomes fully visible */
.hide-inactive-mode.interactive .abacus-bead.hidden-inactive:hover {
opacity: 1 !important;
}
/* Non-interactive abacus: Hidden inactive beads always stay at opacity 0 */
.abacus-svg.hide-inactive-mode:not(.interactive) .abacus-bead.hidden-inactive {
opacity: 0 !important;
}
</style></defs><rect x="11" y="0" width="3" height="120" fill="#7c2d12" stroke="#92400e" stroke-width="2" opacity="1"></rect><rect x="6.5" y="30" width="12" height="2" fill="#92400e" stroke="#92400e" stroke-width="3" opacity="1"></rect><g class="abacus-bead active " transform="translate(4.100000000000001, 17)" style="cursor:default;touch-action:none;transition:opacity 0.2s ease-in-out"><polygon points="8.399999999999999,0 16.799999999999997,6 8.399999999999999,12 0,6" fill="#fbbf24" stroke="#000" stroke-width="0.5"></polygon></g><g class="abacus-bead inactive " transform="translate(4.100000000000001, 40)" style="cursor:default;touch-action:none;transition:opacity 0.2s ease-in-out"><polygon points="8.399999999999999,0 16.799999999999997,6 8.399999999999999,12 0,6" fill="#fbbf24" stroke="#000" stroke-width="0.5"></polygon></g><g class="abacus-bead inactive " transform="translate(4.100000000000001, 52.5)" style="cursor:default;touch-action:none;transition:opacity 0.2s ease-in-out"><polygon points="8.399999999999999,0 16.799999999999997,6 8.399999999999999,12 0,6" fill="#fbbf24" stroke="#000" stroke-width="0.5"></polygon></g><g class="abacus-bead inactive " transform="translate(4.100000000000001, 65)" style="cursor:default;touch-action:none;transition:opacity 0.2s ease-in-out"><polygon points="8.399999999999999,0 16.799999999999997,6 8.399999999999999,12 0,6" fill="#fbbf24" stroke="#000" stroke-width="0.5"></polygon></g><g class="abacus-bead inactive " transform="translate(4.100000000000001, 77.5)" style="cursor:default;touch-action:none;transition:opacity 0.2s ease-in-out"><polygon points="8.399999999999999,0 16.799999999999997,6 8.399999999999999,12 0,6" fill="#fbbf24" stroke="#000" stroke-width="0.5"></polygon></g><rect x="0" y="0" width="25" height="120" fill="transparent" stroke="none" style="cursor:default;pointer-events:none"></rect></svg></div>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -5,9 +5,71 @@ import { getRequestLocale } from '@/i18n/request'
import { getMessages } from '@/i18n/messages'
export const metadata: Metadata = {
title: 'Soroban Flashcard Generator',
metadataBase: new URL('https://abaci.one'),
title: {
default: 'Abaci.One - Interactive Soroban Learning',
template: '%s | Abaci.One',
},
description:
'Create beautiful, educational soroban flashcards with authentic Japanese abacus representations',
'Master the Japanese abacus (soroban) with interactive tutorials, arcade-style math games, and beautiful flashcards. Learn arithmetic through play with Rithmomachia, Complement Race, and more.',
keywords: [
'soroban',
'abacus',
'Japanese abacus',
'mental arithmetic',
'math games',
'abacus tutorial',
'soroban learning',
'arithmetic practice',
'educational games',
'Rithmomachia',
'number bonds',
'complement training',
],
authors: [{ name: 'Abaci.One' }],
creator: 'Abaci.One',
publisher: 'Abaci.One',
// Open Graph
openGraph: {
type: 'website',
locale: 'en_US',
alternateLocale: ['de_DE', 'ja_JP', 'hi_IN', 'es_ES', 'la'],
url: 'https://abaci.one',
title: 'Abaci.One - Interactive Soroban Learning',
description: 'Master the Japanese abacus through interactive games, tutorials, and practice',
siteName: 'Abaci.One',
},
// Twitter
twitter: {
card: 'summary_large_image',
title: 'Abaci.One - Interactive Soroban Learning',
description: 'Master the Japanese abacus through games and practice',
},
// Icons
icons: {
icon: [
{ url: '/favicon.ico', sizes: 'any' },
{ url: '/icon.svg', type: 'image/svg+xml' },
],
apple: '/apple-touch-icon.png',
},
// Manifest
manifest: '/manifest.json',
// App-specific
applicationName: 'Abaci.One',
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: 'Abaci.One',
},
// Category
category: 'education',
}
export const viewport: Viewport = {

View File

@@ -0,0 +1,231 @@
import { ImageResponse } from 'next/og'
// Route segment config
export const runtime = 'edge'
// Image metadata
export const alt = 'Abaci.One - Interactive Soroban Learning Platform'
export const size = {
width: 1200,
height: 630,
}
export const contentType = 'image/png'
// Image generation
// Note: Using simplified abacus HTML/CSS representation instead of StaticAbacus
// because ImageResponse has limited JSX support (no custom components)
export default async function Image() {
return new ImageResponse(
<div
style={{
background: 'linear-gradient(135deg, #fef3c7 0%, #fcd34d 100%)',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '80px',
}}
>
{/* Left side - Simplified abacus visualization (HTML/CSS)
Can't use StaticAbacus here because ImageResponse only supports
basic HTML elements, not custom React components */}
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '40%',
}}
>
{/* Simple abacus representation with 3 columns */}
<div
style={{
display: 'flex',
gap: '30px',
}}
>
{/* Column 1 */}
<div
style={{
width: '80px',
height: '400px',
background: '#7c2d12',
borderRadius: '8px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'space-around',
padding: '20px 0',
position: 'relative',
}}
>
{/* Reckoning bar */}
<div
style={{
position: 'absolute',
top: '50%',
left: '-10px',
right: '-10px',
height: '12px',
background: '#92400e',
borderRadius: '4px',
}}
/>
{/* Beads - simplified representation */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}>
{[...Array(2)].map((_, i) => (
<div
key={i}
style={{
width: '36px',
height: '36px',
background: '#fbbf24',
borderRadius: '50%',
border: '3px solid #92400e',
}}
/>
))}
</div>
</div>
{/* Column 2 */}
<div
style={{
width: '80px',
height: '400px',
background: '#7c2d12',
borderRadius: '8px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'space-around',
padding: '20px 0',
position: 'relative',
}}
>
<div
style={{
position: 'absolute',
top: '50%',
left: '-10px',
right: '-10px',
height: '12px',
background: '#92400e',
borderRadius: '4px',
}}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}>
{[...Array(2)].map((_, i) => (
<div
key={i}
style={{
width: '36px',
height: '36px',
background: '#fbbf24',
borderRadius: '50%',
border: '3px solid #92400e',
}}
/>
))}
</div>
</div>
{/* Column 3 */}
<div
style={{
width: '80px',
height: '400px',
background: '#7c2d12',
borderRadius: '8px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'space-around',
padding: '20px 0',
position: 'relative',
}}
>
<div
style={{
position: 'absolute',
top: '50%',
left: '-10px',
right: '-10px',
height: '12px',
background: '#92400e',
borderRadius: '4px',
}}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}>
{[...Array(2)].map((_, i) => (
<div
key={i}
style={{
width: '36px',
height: '36px',
background: '#fbbf24',
borderRadius: '50%',
border: '3px solid #92400e',
}}
/>
))}
</div>
</div>
</div>
</div>
{/* Right side - Text content */}
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '55%',
gap: '30px',
}}
>
<h1
style={{
fontSize: '80px',
fontWeight: 'bold',
color: '#7c2d12',
margin: 0,
lineHeight: 1,
}}
>
Abaci.One
</h1>
<p
style={{
fontSize: '40px',
fontWeight: 600,
color: '#92400e',
margin: 0,
lineHeight: 1.2,
}}
>
Learn Soroban Through Play
</p>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '15px',
fontSize: '32px',
color: '#78350f',
}}
>
<div style={{ display: 'flex', alignItems: 'center' }}> Interactive Games</div>
<div style={{ display: 'flex', alignItems: 'center' }}> Tutorials</div>
<div style={{ display: 'flex', alignItems: 'center' }}> Practice Tools</div>
</div>
</div>
</div>,
{
...size,
}
)
}

View File

@@ -0,0 +1,12 @@
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/api/', '/test/', '/_next/'],
},
sitemap: 'https://abaci.one/sitemap.xml',
}
}

View File

@@ -0,0 +1,37 @@
import type { MetadataRoute } from 'next'
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = 'https://abaci.one'
// Main pages
const routes = ['', '/arcade', '/create', '/guide', '/about'].map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: route === '' ? 1 : 0.8,
}))
// Arcade games
const games = [
'/arcade/rithmomachia',
'/arcade/complement-race',
'/arcade/matching',
'/arcade/memory-quiz',
'/arcade/card-sorting',
].map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.6,
}))
// Guide pages
const guides = ['/arcade/rithmomachia/guide'].map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.5,
}))
return [...routes, ...games, ...guides]
}

View File

@@ -1248,12 +1248,21 @@ const Bead: React.FC<BeadProps> = ({
colorPalette = "default",
totalColumns = 1,
}) => {
const [{ x: springX, y: springY }, api] = useSpring(() => ({ x, y }));
// Detect server-side rendering
const isServer = typeof window === 'undefined';
// Use springs only if not on server and animations are enabled
// Even on server, we must call hooks unconditionally, so we provide static values
const [{ x: springX, y: springY }, api] = useSpring(() => ({
x,
y,
config: enableAnimation && !isServer ? config.default : { duration: 0 }
}));
// Arrow pulse animation for urgency indication
const [{ arrowPulse }, arrowApi] = useSpring(() => ({
arrowPulse: 1,
config: { tension: 200, friction: 10 },
config: enableAnimation && !isServer ? { tension: 200, friction: 10 } : { duration: 0 },
}));
const gestureStateRef = useRef({
@@ -1281,8 +1290,8 @@ const Bead: React.FC<BeadProps> = ({
[bead.type],
);
// Directional gesture handler
const bind = useDrag(
// Directional gesture handler - only on client with gestures enabled
const bind = (enableGestures && !isServer) ? useDrag(
({ event, movement: [, deltaY], first, active }) => {
if (first) {
event?.preventDefault();
@@ -1322,7 +1331,7 @@ const Bead: React.FC<BeadProps> = ({
enabled: enableGestures,
preventDefault: true,
},
);
) : () => ({});
React.useEffect(() => {
if (enableAnimation) {
@@ -1395,7 +1404,9 @@ const Bead: React.FC<BeadProps> = ({
}
};
const AnimatedG = animated.g;
// Use animated.g only if animations are enabled, otherwise use regular g
const GElement = enableAnimation ? animated.g : 'g';
const DirectionIndicatorG = (enableAnimation && showDirectionIndicator && direction) ? animated.g : 'g';
// Calculate correct offset based on shape (matching Typst positioning)
const getXOffset = () => {
@@ -1406,8 +1417,34 @@ const Bead: React.FC<BeadProps> = ({
return size / 2; // Y offset is always size/2 for all shapes
};
// Calculate static transform for direction indicator
const getDirectionIndicatorTransform = () => {
const centerX = shape === "diamond" ? size * 0.7 : size / 2;
const centerY = size / 2;
const pulse = enableAnimation ? undefined : 1;
return `translate(${centerX}, ${centerY}) scale(${pulse})`;
};
// Build style object based on animation mode
const beadStyle: any = enableAnimation
? {
transform: to(
[springX, springY],
(sx, sy) =>
`translate(${sx - getXOffset()}px, ${sy - getYOffset()}px)`,
),
cursor: enableGestures ? "grab" : onClick ? "pointer" : "default",
touchAction: "none" as const,
transition: "opacity 0.2s ease-in-out",
}
: {
cursor: enableGestures ? "grab" : onClick ? "pointer" : "default",
touchAction: "none" as const,
transition: "opacity 0.2s ease-in-out",
};
return (
<AnimatedG
<GElement
ref={onRef}
{...(enableGestures ? bind() : {})}
className={`abacus-bead ${bead.active ? "active" : "inactive"} ${hideInactiveBeads && !bead.active ? "hidden-inactive" : ""}`}
@@ -1423,24 +1460,7 @@ const Bead: React.FC<BeadProps> = ({
? undefined
: `translate(${x - getXOffset()}, ${y - getYOffset()})`
}
style={
enableAnimation
? {
transform: to(
[springX, springY],
(sx, sy) =>
`translate(${sx - getXOffset()}px, ${sy - getYOffset()}px)`,
),
cursor: enableGestures ? "grab" : onClick ? "pointer" : "default",
touchAction: "none",
transition: "opacity 0.2s ease-in-out",
}
: {
cursor: enableGestures ? "grab" : onClick ? "pointer" : "default",
touchAction: "none",
transition: "opacity 0.2s ease-in-out",
}
}
style={beadStyle}
onClick={(e) => {
// Prevent click if a gesture just triggered to avoid double-toggling
if (enableGestures && gestureStateRef.current.hasGestureTriggered) {
@@ -1451,17 +1471,21 @@ const Bead: React.FC<BeadProps> = ({
}} // Enable click with gesture conflict prevention
>
{renderShape()}
{showDirectionIndicator && direction && (
<animated.g
className="direction-indicator"
style={{ pointerEvents: "none" }}
transform={to([arrowPulse], (pulse) => {
// Match the exact center coordinates of each shape
const centerX = shape === "diamond" ? size * 0.7 : size / 2;
const centerY = size / 2;
return `translate(${centerX}, ${centerY}) scale(${pulse})`;
})}
>
{showDirectionIndicator && direction && (() => {
const indicatorTransform: any = enableAnimation
? to([arrowPulse], (pulse) => {
const centerX = shape === "diamond" ? size * 0.7 : size / 2;
const centerY = size / 2;
return `translate(${centerX}, ${centerY}) scale(${pulse})`;
})
: getDirectionIndicatorTransform();
return (
<DirectionIndicatorG
className="direction-indicator"
style={{ pointerEvents: "none" as const }}
transform={indicatorTransform}
>
{(() => {
const arrowColors = getArrowColors(
bead,
@@ -1493,9 +1517,10 @@ const Bead: React.FC<BeadProps> = ({
/>
);
})()}
</animated.g>
)}
</AnimatedG>
</DirectionIndicatorG>
);
})()}
</GElement>
);
};