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:
69
apps/web/public/og-image.svg
Normal file
69
apps/web/public/og-image.svg
Normal 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 |
147
apps/web/scripts/generateAbacusIcons.tsx
Normal file
147
apps/web/scripts/generateAbacusIcons.tsx
Normal 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
34
apps/web/src/app/icon.svg
Normal 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 |
@@ -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 = {
|
||||
|
||||
231
apps/web/src/app/opengraph-image.tsx
Normal file
231
apps/web/src/app/opengraph-image.tsx
Normal 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,
|
||||
}
|
||||
)
|
||||
}
|
||||
12
apps/web/src/app/robots.ts
Normal file
12
apps/web/src/app/robots.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
37
apps/web/src/app/sitemap.ts
Normal file
37
apps/web/src/app/sitemap.ts
Normal 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]
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user