refactor: move 3D abacus creator feature to separate branch

Removed 3D abacus creator from main branch due to Docker build failures
with OpenSCAD AppImage extraction in GitHub Actions. The feature will be
reworked in the feature/3d-abacus-creator branch.

Removed:
- src/app/create/abacus/ - 3D abacus creator page
- src/app/api/abacus/ - API routes for 3D generation
- src/components/3d-print/ - 3D-related components
- Dockerfile: OpenSCAD and BOSL2 builder stages
- Dockerfile: OpenGL/Qt runtime dependencies
- Dockerfile: tmp/3d-jobs directory creation
- Create hub: 3D abacus card

This restores successful Docker builds and unblocks deployments.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-11-05 13:58:39 -06:00
parent 66241c21d1
commit c8aa602e1c
9 changed files with 1 additions and 1414 deletions

View File

@ -91,44 +91,7 @@ RUN ARCH=$(uname -m) && \
mv "typst-${TYPST_ARCH}/typst" /usr/local/bin/typst && \
chmod +x /usr/local/bin/typst
# BOSL2 builder stage - clone and minimize the library
FROM node:18-slim AS bosl2-builder
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /bosl2 && \
cd /bosl2 && \
git clone --depth 1 https://github.com/BelfrySCAD/BOSL2.git . && \
# Remove unnecessary files to minimize size
rm -rf .git .github tests tutorials examples images *.md CONTRIBUTING* LICENSE* && \
# Keep only .scad files and essential directories
find . -type f ! -name "*.scad" -delete && \
find . -type d -empty -delete
# OpenSCAD builder stage - download and prepare newer OpenSCAD binary
FROM node:18-slim AS openscad-builder
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
ca-certificates \
file \
libfuse2 \
&& rm -rf /var/lib/apt/lists/*
# Download latest OpenSCAD AppImage and extract it
# Using 2024.11 which has CGAL fixes for intersection operations
# APPIMAGE_EXTRACT_AND_RUN=1 allows extraction without FUSE mounting
RUN export APPIMAGE_EXTRACT_AND_RUN=1 && \
wget -q https://files.openscad.org/OpenSCAD-2024.11.18-x86_64.AppImage -O /tmp/openscad.AppImage && \
chmod +x /tmp/openscad.AppImage && \
cd /tmp && \
./openscad.AppImage --appimage-extract && \
mv squashfs-root/usr/bin/openscad /usr/local/bin/openscad && \
mv squashfs-root/usr/lib /usr/local/openscad-lib && \
chmod +x /usr/local/bin/openscad
# Production image - Using Debian base for OpenSCAD availability
# Production image
FROM node:18-slim AS runner
WORKDIR /app
@ -138,36 +101,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
python3-pip \
qpdf \
ca-certificates \
libgomp1 \
libglu1-mesa \
libglew2.2 \
libfreetype6 \
libfontconfig1 \
libharfbuzz0b \
libxml2 \
libzip4 \
libdouble-conversion3 \
libqt5core5a \
libqt5gui5 \
libqt5widgets5 \
libqt5concurrent5 \
libqt5multimedia5 \
libqt5network5 \
libqt5dbus5 \
&& rm -rf /var/lib/apt/lists/*
# Copy typst binary from typst-builder stage
COPY --from=typst-builder /usr/local/bin/typst /usr/local/bin/typst
# Copy newer OpenSCAD from openscad-builder stage
COPY --from=openscad-builder /usr/local/bin/openscad /usr/local/bin/openscad
COPY --from=openscad-builder /usr/local/openscad-lib /usr/local/openscad-lib
ENV LD_LIBRARY_PATH=/usr/local/openscad-lib:$LD_LIBRARY_PATH
# Copy minimized BOSL2 library from bosl2-builder stage
RUN mkdir -p /usr/share/openscad/libraries
COPY --from=bosl2-builder /bosl2 /usr/share/openscad/libraries/BOSL2
# Create non-root user
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
@ -212,9 +150,6 @@ WORKDIR /app/apps/web
# Create data directory for SQLite database
RUN mkdir -p data && chown nextjs:nodejs data
# Create tmp directory for 3D job outputs
RUN mkdir -p tmp/3d-jobs && chown nextjs:nodejs tmp
USER nextjs
EXPOSE 3000
ENV PORT 3000

View File

@ -1,46 +0,0 @@
import { JobManager } from '@/lib/3d-printing/jobManager'
import { NextResponse } from 'next/server'
export async function GET(request: Request, { params }: { params: Promise<{ jobId: string }> }) {
try {
const { jobId } = await params
const job = JobManager.getJob(jobId)
if (!job) {
return NextResponse.json({ error: 'Job not found' }, { status: 404 })
}
if (job.status !== 'completed') {
return NextResponse.json(
{ error: `Job is ${job.status}, not ready for download` },
{ status: 400 }
)
}
const fileBuffer = await JobManager.getJobOutput(jobId)
// Determine content type and filename
const contentTypes = {
stl: 'model/stl',
'3mf': 'application/vnd.ms-package.3dmanufacturing-3dmodel+xml',
scad: 'text/plain',
}
const contentType = contentTypes[job.params.format]
const filename = `abacus.${job.params.format}`
// Convert Buffer to Uint8Array for NextResponse
const uint8Array = new Uint8Array(fileBuffer)
return new NextResponse(uint8Array, {
headers: {
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': fileBuffer.length.toString(),
},
})
} catch (error) {
console.error('Error downloading job:', error)
return NextResponse.json({ error: 'Failed to download file' }, { status: 500 })
}
}

View File

@ -1,57 +0,0 @@
import { JobManager } from '@/lib/3d-printing/jobManager'
import type { AbacusParams } from '@/lib/3d-printing/jobManager'
import { NextResponse } from 'next/server'
export async function POST(request: Request) {
try {
const body = await request.json()
// Validate parameters
const columns = Number.parseInt(body.columns, 10)
const scaleFactor = Number.parseFloat(body.scaleFactor)
const widthMm = body.widthMm ? Number.parseFloat(body.widthMm) : undefined
const format = body.format
// Validation
if (Number.isNaN(columns) || columns < 1 || columns > 13) {
return NextResponse.json({ error: 'columns must be between 1 and 13' }, { status: 400 })
}
if (Number.isNaN(scaleFactor) || scaleFactor < 0.5 || scaleFactor > 3) {
return NextResponse.json({ error: 'scaleFactor must be between 0.5 and 3' }, { status: 400 })
}
if (widthMm !== undefined && (Number.isNaN(widthMm) || widthMm < 50 || widthMm > 500)) {
return NextResponse.json({ error: 'widthMm must be between 50 and 500' }, { status: 400 })
}
if (!['stl', '3mf', 'scad'].includes(format)) {
return NextResponse.json({ error: 'format must be stl, 3mf, or scad' }, { status: 400 })
}
const params: AbacusParams = {
columns,
scaleFactor,
widthMm,
format,
// 3MF colors (optional)
frameColor: body.frameColor,
heavenBeadColor: body.heavenBeadColor,
earthBeadColor: body.earthBeadColor,
decorationColor: body.decorationColor,
}
const jobId = await JobManager.createJob(params)
return NextResponse.json(
{
jobId,
message: 'Job created successfully',
},
{ status: 202 }
)
} catch (error) {
console.error('Error creating job:', error)
return NextResponse.json({ error: 'Failed to create job' }, { status: 500 })
}
}

View File

@ -1,109 +0,0 @@
import { JobManager } from '@/lib/3d-printing/jobManager'
import type { AbacusParams } from '@/lib/3d-printing/jobManager'
import { NextResponse } from 'next/server'
// Allow up to 90 seconds for OpenSCAD rendering
export const maxDuration = 90
// Cache for preview STLs to avoid regenerating on every request
const previewCache = new Map<string, { buffer: Buffer; timestamp: number }>()
const CACHE_TTL = 300000 // 5 minutes
function getCacheKey(params: AbacusParams): string {
return `${params.columns}-${params.scaleFactor}`
}
export async function POST(request: Request) {
try {
const body = await request.json()
// Validate parameters
const columns = Number.parseInt(body.columns, 10)
const scaleFactor = Number.parseFloat(body.scaleFactor)
// Validation
if (Number.isNaN(columns) || columns < 1 || columns > 13) {
return NextResponse.json({ error: 'columns must be between 1 and 13' }, { status: 400 })
}
if (Number.isNaN(scaleFactor) || scaleFactor < 0.5 || scaleFactor > 3) {
return NextResponse.json({ error: 'scaleFactor must be between 0.5 and 3' }, { status: 400 })
}
const params: AbacusParams = {
columns,
scaleFactor,
format: 'stl', // Always STL for preview
}
// Check cache first
const cacheKey = getCacheKey(params)
const cached = previewCache.get(cacheKey)
const now = Date.now()
if (cached && now - cached.timestamp < CACHE_TTL) {
// Return cached preview
const uint8Array = new Uint8Array(cached.buffer)
return new NextResponse(uint8Array, {
headers: {
'Content-Type': 'model/stl',
'Cache-Control': 'public, max-age=300', // Cache for 5 minutes
},
})
}
// Generate new preview
const jobId = await JobManager.createJob(params)
// Wait for job to complete (with timeout)
const startTime = Date.now()
const timeout = 90000 // 90 seconds max wait (OpenSCAD can take 40-60s)
while (Date.now() - startTime < timeout) {
const job = JobManager.getJob(jobId)
if (!job) {
return NextResponse.json({ error: 'Job not found' }, { status: 404 })
}
if (job.status === 'completed') {
const buffer = await JobManager.getJobOutput(jobId)
// Cache the result
previewCache.set(cacheKey, { buffer, timestamp: now })
// Clean up old cache entries
for (const [key, value] of previewCache.entries()) {
if (now - value.timestamp > CACHE_TTL) {
previewCache.delete(key)
}
}
// Clean up the job
await JobManager.cleanupJob(jobId)
const uint8Array = new Uint8Array(buffer)
return new NextResponse(uint8Array, {
headers: {
'Content-Type': 'model/stl',
'Cache-Control': 'public, max-age=300',
},
})
}
if (job.status === 'failed') {
return NextResponse.json(
{ error: job.error || 'Preview generation failed' },
{ status: 500 }
)
}
// Wait 500ms before checking again
await new Promise((resolve) => setTimeout(resolve, 500))
}
return NextResponse.json({ error: 'Preview generation timeout' }, { status: 408 })
} catch (error) {
console.error('Error generating preview:', error)
return NextResponse.json({ error: 'Failed to generate preview' }, { status: 500 })
}
}

View File

@ -1,25 +0,0 @@
import { JobManager } from '@/lib/3d-printing/jobManager'
import { NextResponse } from 'next/server'
export async function GET(request: Request, { params }: { params: Promise<{ jobId: string }> }) {
try {
const { jobId } = await params
const job = JobManager.getJob(jobId)
if (!job) {
return NextResponse.json({ error: 'Job not found' }, { status: 404 })
}
return NextResponse.json({
id: job.id,
status: job.status,
progress: job.progress,
error: job.error,
createdAt: job.createdAt,
completedAt: job.completedAt,
})
} catch (error) {
console.error('Error fetching job status:', error)
return NextResponse.json({ error: 'Failed to fetch job status' }, { status: 500 })
}
}

View File

@ -1,570 +0,0 @@
'use client'
import { useTranslations } from 'next-intl'
import { JobMonitor } from '@/components/3d-print/JobMonitor'
import { PageWithNav } from '@/components/PageWithNav'
import { STLPreview } from '@/components/3d-print/STLPreview'
import { useState } from 'react'
import { css } from '../../../../styled-system/css'
export default function ThreeDPrintPage() {
const t = useTranslations('create.abacus')
// New unified parameter system
const [columns, setColumns] = useState(4)
const [scaleFactor, setScaleFactor] = useState(1.5)
const [widthMm, setWidthMm] = useState<number | undefined>(undefined)
const [format, setFormat] = useState<'stl' | '3mf' | 'scad'>('stl')
// 3MF color options
const [frameColor, setFrameColor] = useState('#8b7355')
const [heavenBeadColor, setHeavenBeadColor] = useState('#e8d5c4')
const [earthBeadColor, setEarthBeadColor] = useState('#6b5444')
const [decorationColor, setDecorationColor] = useState('#d4af37')
const [jobId, setJobId] = useState<string | null>(null)
const [isGenerating, setIsGenerating] = useState(false)
const [isComplete, setIsComplete] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleGenerate = async () => {
setIsGenerating(true)
setError(null)
setIsComplete(false)
try {
const response = await fetch('/api/abacus/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
columns,
scaleFactor,
widthMm,
format,
// Include 3MF colors if format is 3mf
...(format === '3mf' && {
frameColor,
heavenBeadColor,
earthBeadColor,
decorationColor,
}),
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Failed to generate file')
}
const data = await response.json()
setJobId(data.jobId)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
setIsGenerating(false)
}
}
const handleJobComplete = () => {
setIsComplete(true)
setIsGenerating(false)
}
const handleDownload = () => {
if (!jobId) return
window.location.href = `/api/abacus/download/${jobId}`
}
return (
<PageWithNav navTitle={t('navTitle')} navEmoji="🖨">
<div className={css({ minHeight: '100vh', bg: 'gray.50' })}>
<div
data-component="3d-print-page"
className={css({
maxWidth: '1200px',
mx: 'auto',
p: 6,
})}
>
<h1
className={css({
fontSize: '3xl',
fontWeight: 'bold',
mb: 2,
})}
>
{t('pageTitle')}
</h1>
<p className={css({ mb: 6, color: 'gray.600' })}>{t('pageSubtitle')}</p>
<div
className={css({
display: 'grid',
gridTemplateColumns: { base: '1fr', md: '1fr 1fr' },
gap: 8,
})}
>
{/* Left column: Controls */}
<div data-section="controls">
<div
className={css({
bg: 'white',
p: 6,
borderRadius: '8px',
boxShadow: 'md',
})}
>
<h2
className={css({
fontSize: 'xl',
fontWeight: 'bold',
mb: 4,
})}
>
{t('customizationTitle')}
</h2>
{/* Number of Columns */}
<div data-setting="columns" className={css({ mb: 4 })}>
<label
className={css({
display: 'block',
fontWeight: 'medium',
mb: 2,
})}
>
{t('columns.label', { count: columns })}
</label>
<input
type="range"
min="1"
max="13"
step="1"
value={columns}
onChange={(e) => setColumns(Number.parseInt(e.target.value, 10))}
className={css({ width: '100%' })}
/>
<div
className={css({
fontSize: 'sm',
color: 'gray.500',
mt: 1,
})}
>
{t('columns.help')}
</div>
</div>
{/* Scale Factor */}
<div data-setting="scale-factor" className={css({ mb: 4 })}>
<label
className={css({
display: 'block',
fontWeight: 'medium',
mb: 2,
})}
>
{t('scaleFactor.label', { factor: scaleFactor.toFixed(1) })}
</label>
<input
type="range"
min="0.5"
max="3"
step="0.1"
value={scaleFactor}
onChange={(e) => setScaleFactor(Number.parseFloat(e.target.value))}
className={css({ width: '100%' })}
/>
<div
className={css({
fontSize: 'sm',
color: 'gray.500',
mt: 1,
})}
>
{t('scaleFactor.help')}
</div>
</div>
{/* Optional Width in mm */}
<div data-setting="width-mm" className={css({ mb: 4 })}>
<label
className={css({
display: 'block',
fontWeight: 'medium',
mb: 2,
})}
>
{t('widthMm.label')}
</label>
<input
type="number"
min="50"
max="500"
step="1"
value={widthMm ?? ''}
onChange={(e) => {
const value = e.target.value
setWidthMm(value ? Number.parseFloat(value) : undefined)
}}
placeholder={t('widthMm.placeholder')}
className={css({
width: '100%',
px: 3,
py: 2,
border: '1px solid',
borderColor: 'gray.300',
borderRadius: '4px',
_focus: {
outline: 'none',
borderColor: 'blue.500',
},
})}
/>
<div
className={css({
fontSize: 'sm',
color: 'gray.500',
mt: 1,
})}
>
{t('widthMm.help')}
</div>
</div>
{/* Format Selection */}
<div data-setting="format" className={css({ mb: format === '3mf' ? 4 : 6 })}>
<label
className={css({
display: 'block',
fontWeight: 'medium',
mb: 2,
})}
>
{t('format.label')}
</label>
<div className={css({ display: 'flex', gap: 2, flexWrap: 'wrap' })}>
<button
type="button"
onClick={() => setFormat('stl')}
className={css({
px: 4,
py: 2,
borderRadius: '4px',
border: '2px solid',
borderColor: format === 'stl' ? 'blue.600' : 'gray.300',
bg: format === 'stl' ? 'blue.50' : 'white',
color: format === 'stl' ? 'blue.700' : 'gray.700',
cursor: 'pointer',
fontWeight: format === 'stl' ? 'bold' : 'normal',
_hover: { bg: format === 'stl' ? 'blue.100' : 'gray.50' },
})}
>
STL
</button>
<button
type="button"
onClick={() => setFormat('3mf')}
className={css({
px: 4,
py: 2,
borderRadius: '4px',
border: '2px solid',
borderColor: format === '3mf' ? 'blue.600' : 'gray.300',
bg: format === '3mf' ? 'blue.50' : 'white',
color: format === '3mf' ? 'blue.700' : 'gray.700',
cursor: 'pointer',
fontWeight: format === '3mf' ? 'bold' : 'normal',
_hover: { bg: format === '3mf' ? 'blue.100' : 'gray.50' },
})}
>
3MF
</button>
<button
type="button"
onClick={() => setFormat('scad')}
className={css({
px: 4,
py: 2,
borderRadius: '4px',
border: '2px solid',
borderColor: format === 'scad' ? 'blue.600' : 'gray.300',
bg: format === 'scad' ? 'blue.50' : 'white',
color: format === 'scad' ? 'blue.700' : 'gray.700',
cursor: 'pointer',
fontWeight: format === 'scad' ? 'bold' : 'normal',
_hover: { bg: format === 'scad' ? 'blue.100' : 'gray.50' },
})}
>
OpenSCAD
</button>
</div>
</div>
{/* 3MF Color Options */}
{format === '3mf' && (
<div data-section="3mf-colors" className={css({ mb: 6 })}>
<h3
className={css({
fontSize: 'lg',
fontWeight: 'bold',
mb: 3,
})}
>
{t('colors.title')}
</h3>
{/* Frame Color */}
<div data-setting="frame-color" className={css({ mb: 3 })}>
<label
className={css({
display: 'block',
fontWeight: 'medium',
mb: 1,
})}
>
{t('colors.frame')}
</label>
<div className={css({ display: 'flex', gap: 2, alignItems: 'center' })}>
<input
type="color"
value={frameColor}
onChange={(e) => setFrameColor(e.target.value)}
className={css({ width: '60px', height: '40px', cursor: 'pointer' })}
/>
<input
type="text"
value={frameColor}
onChange={(e) => setFrameColor(e.target.value)}
placeholder="#8b7355"
className={css({
flex: 1,
px: 3,
py: 2,
border: '1px solid',
borderColor: 'gray.300',
borderRadius: '4px',
fontFamily: 'monospace',
})}
/>
</div>
</div>
{/* Heaven Bead Color */}
<div data-setting="heaven-bead-color" className={css({ mb: 3 })}>
<label
className={css({
display: 'block',
fontWeight: 'medium',
mb: 1,
})}
>
{t('colors.heavenBead')}
</label>
<div className={css({ display: 'flex', gap: 2, alignItems: 'center' })}>
<input
type="color"
value={heavenBeadColor}
onChange={(e) => setHeavenBeadColor(e.target.value)}
className={css({ width: '60px', height: '40px', cursor: 'pointer' })}
/>
<input
type="text"
value={heavenBeadColor}
onChange={(e) => setHeavenBeadColor(e.target.value)}
placeholder="#e8d5c4"
className={css({
flex: 1,
px: 3,
py: 2,
border: '1px solid',
borderColor: 'gray.300',
borderRadius: '4px',
fontFamily: 'monospace',
})}
/>
</div>
</div>
{/* Earth Bead Color */}
<div data-setting="earth-bead-color" className={css({ mb: 3 })}>
<label
className={css({
display: 'block',
fontWeight: 'medium',
mb: 1,
})}
>
{t('colors.earthBead')}
</label>
<div className={css({ display: 'flex', gap: 2, alignItems: 'center' })}>
<input
type="color"
value={earthBeadColor}
onChange={(e) => setEarthBeadColor(e.target.value)}
className={css({ width: '60px', height: '40px', cursor: 'pointer' })}
/>
<input
type="text"
value={earthBeadColor}
onChange={(e) => setEarthBeadColor(e.target.value)}
placeholder="#6b5444"
className={css({
flex: 1,
px: 3,
py: 2,
border: '1px solid',
borderColor: 'gray.300',
borderRadius: '4px',
fontFamily: 'monospace',
})}
/>
</div>
</div>
{/* Decoration Color */}
<div data-setting="decoration-color" className={css({ mb: 0 })}>
<label
className={css({
display: 'block',
fontWeight: 'medium',
mb: 1,
})}
>
{t('colors.decoration')}
</label>
<div className={css({ display: 'flex', gap: 2, alignItems: 'center' })}>
<input
type="color"
value={decorationColor}
onChange={(e) => setDecorationColor(e.target.value)}
className={css({ width: '60px', height: '40px', cursor: 'pointer' })}
/>
<input
type="text"
value={decorationColor}
onChange={(e) => setDecorationColor(e.target.value)}
placeholder="#d4af37"
className={css({
flex: 1,
px: 3,
py: 2,
border: '1px solid',
borderColor: 'gray.300',
borderRadius: '4px',
fontFamily: 'monospace',
})}
/>
</div>
</div>
</div>
)}
{/* Generate Button */}
<button
type="button"
onClick={handleGenerate}
disabled={isGenerating}
data-action="generate"
className={css({
width: '100%',
px: 6,
py: 3,
bg: 'blue.600',
color: 'white',
borderRadius: '4px',
fontWeight: 'bold',
cursor: isGenerating ? 'not-allowed' : 'pointer',
opacity: isGenerating ? 0.6 : 1,
_hover: { bg: isGenerating ? 'blue.600' : 'blue.700' },
})}
>
{isGenerating ? t('generate.generating') : t('generate.button')}
</button>
{/* Job Status */}
{jobId && !isComplete && (
<div className={css({ mt: 4 })}>
<JobMonitor jobId={jobId} onComplete={handleJobComplete} />
</div>
)}
{/* Download Button */}
{isComplete && (
<button
type="button"
onClick={handleDownload}
data-action="download"
className={css({
width: '100%',
mt: 4,
px: 6,
py: 3,
bg: 'green.600',
color: 'white',
borderRadius: '4px',
fontWeight: 'bold',
cursor: 'pointer',
_hover: { bg: 'green.700' },
})}
>
{t('download', { format: format.toUpperCase() })}
</button>
)}
{/* Error Message */}
{error && (
<div
data-status="error"
className={css({
mt: 4,
p: 4,
bg: 'red.100',
borderRadius: '4px',
color: 'red.700',
})}
>
{error}
</div>
)}
</div>
</div>
{/* Right column: Preview */}
<div data-section="preview">
<div
className={css({
bg: 'white',
p: 6,
borderRadius: '8px',
boxShadow: 'md',
})}
>
<h2
className={css({
fontSize: 'xl',
fontWeight: 'bold',
mb: 4,
})}
>
{t('preview.title')}
</h2>
<STLPreview columns={columns} scaleFactor={scaleFactor} />
<div
className={css({
mt: 4,
fontSize: 'sm',
color: 'gray.600',
})}
>
<p className={css({ mb: 2 })}>{t('preview.liveDescription')}</p>
<p className={css({ mb: 2 })}>{t('preview.note')}</p>
<p>{t('preview.instructions')}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</PageWithNav>
)
}

View File

@ -309,202 +309,6 @@ export default function CreateHubPage() {
</div>
</Link>
{/* 3D Abacus Creator */}
<Link href="/create/abacus">
<div
data-element="abacus-card"
className={css({
bg: 'white',
borderRadius: '3xl',
p: 8,
boxShadow: '0 20px 60px rgba(0,0,0,0.25)',
cursor: 'pointer',
transition: 'all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
position: 'relative',
overflow: 'hidden',
_hover: {
transform: 'translateY(-12px) scale(1.02)',
boxShadow: '0 30px 80px rgba(0,0,0,0.35)',
},
_before: {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '6px',
background: 'linear-gradient(90deg, #f093fb 0%, #f5576c 100%)',
},
})}
>
{/* Icon with gradient background */}
<div
className={css({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '4xl',
mb: 5,
width: '80px',
height: '80px',
borderRadius: '2xl',
background: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
boxShadow: '0 8px 24px rgba(240, 147, 251, 0.4)',
})}
>
🖨
</div>
{/* Title */}
<h2
className={css({
fontSize: '2xl',
fontWeight: 'extrabold',
mb: 3,
color: 'gray.900',
letterSpacing: 'tight',
})}
>
{t('abacus.title')}
</h2>
{/* Description */}
<p
className={css({
fontSize: 'md',
color: 'gray.600',
mb: 5,
lineHeight: '1.7',
})}
>
{t('abacus.description')}
</p>
{/* Features */}
<ul
className={css({
listStyle: 'none',
display: 'flex',
flexDirection: 'column',
gap: 3,
})}
>
<li
className={css({
display: 'flex',
alignItems: 'center',
gap: 3,
fontSize: 'sm',
color: 'gray.700',
})}
>
<span
className={css({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '20px',
height: '20px',
borderRadius: 'full',
bg: 'pink.100',
color: 'pink.600',
fontSize: 'xs',
fontWeight: 'bold',
})}
>
</span>
{t('abacus.feature1')}
</li>
<li
className={css({
display: 'flex',
alignItems: 'center',
gap: 3,
fontSize: 'sm',
color: 'gray.700',
})}
>
<span
className={css({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '20px',
height: '20px',
borderRadius: 'full',
bg: 'pink.100',
color: 'pink.600',
fontSize: 'xs',
fontWeight: 'bold',
})}
>
</span>
{t('abacus.feature2')}
</li>
<li
className={css({
display: 'flex',
alignItems: 'center',
gap: 3,
fontSize: 'sm',
color: 'gray.700',
})}
>
<span
className={css({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '20px',
height: '20px',
borderRadius: 'full',
bg: 'pink.100',
color: 'pink.600',
fontSize: 'xs',
fontWeight: 'bold',
})}
>
</span>
{t('abacus.feature3')}
</li>
</ul>
{/* CTA Button */}
<div
className={css({
mt: 7,
})}
>
<div
className={css({
display: 'inline-flex',
alignItems: 'center',
gap: 2,
px: 6,
py: 3,
borderRadius: 'xl',
background: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
color: 'white',
fontWeight: 'bold',
fontSize: 'md',
boxShadow: '0 4px 15px rgba(240, 147, 251, 0.4)',
transition: 'all 0.3s',
_hover: {
boxShadow: '0 6px 20px rgba(240, 147, 251, 0.5)',
transform: 'translateX(4px)',
},
})}
>
<span>{t('abacus.button')}</span>
<span className={css({ fontSize: 'lg' })}></span>
</div>
</div>
</div>
</Link>
{/* Worksheet Creator */}
<Link href="/create/worksheets/addition">
<div

View File

@ -1,146 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { css } from '../../../styled-system/css'
type JobStatus = 'pending' | 'processing' | 'completed' | 'failed'
interface Job {
id: string
status: JobStatus
progress?: string
error?: string
createdAt: string
completedAt?: string
}
interface JobMonitorProps {
jobId: string
onComplete: () => void
}
export function JobMonitor({ jobId, onComplete }: JobMonitorProps) {
const [job, setJob] = useState<Job | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let pollInterval: NodeJS.Timeout
const pollStatus = async () => {
try {
const response = await fetch(`/api/abacus/status/${jobId}`)
if (!response.ok) {
throw new Error('Failed to fetch job status')
}
const data = await response.json()
setJob(data)
if (data.status === 'completed') {
onComplete()
clearInterval(pollInterval)
} else if (data.status === 'failed') {
setError(data.error || 'Job failed')
clearInterval(pollInterval)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
clearInterval(pollInterval)
}
}
// Poll immediately
pollStatus()
// Then poll every 1 second
pollInterval = setInterval(pollStatus, 1000)
return () => clearInterval(pollInterval)
}, [jobId, onComplete])
if (error) {
return (
<div
data-status="error"
className={css({
p: 4,
bg: 'red.100',
borderRadius: '8px',
borderLeft: '4px solid',
borderColor: 'red.600',
})}
>
<div className={css({ fontWeight: 'bold', color: 'red.800', mb: 1 })}>Error</div>
<div className={css({ color: 'red.700' })}>{error}</div>
</div>
)
}
if (!job) {
return (
<div data-status="loading" className={css({ p: 4, textAlign: 'center' })}>
Loading...
</div>
)
}
const statusColors = {
pending: 'blue',
processing: 'yellow',
completed: 'green',
failed: 'red',
}
const statusColor = statusColors[job.status]
return (
<div
data-component="job-monitor"
className={css({
p: 4,
bg: `${statusColor}.50`,
borderRadius: '8px',
borderLeft: '4px solid',
borderColor: `${statusColor}.600`,
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: 2,
mb: 2,
})}
>
<div
data-status={job.status}
className={css({
fontWeight: 'bold',
color: `${statusColor}.800`,
textTransform: 'capitalize',
})}
>
{job.status}
</div>
{(job.status === 'pending' || job.status === 'processing') && (
<div
className={css({
width: '16px',
height: '16px',
border: '2px solid',
borderColor: `${statusColor}.600`,
borderTopColor: 'transparent',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
})}
/>
)}
</div>
{job.progress && (
<div className={css({ color: `${statusColor}.700`, fontSize: 'sm' })}>{job.progress}</div>
)}
{job.error && (
<div className={css({ color: 'red.700', fontSize: 'sm', mt: 2 })}>Error: {job.error}</div>
)}
</div>
)
}

View File

@ -1,199 +0,0 @@
'use client'
import { OrbitControls, Stage } from '@react-three/drei'
import { Canvas, useLoader } from '@react-three/fiber'
import { Suspense, useEffect, useRef, useState } from 'react'
// @ts-expect-error - STLLoader doesn't have TypeScript declarations
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'
import { css } from '../../../styled-system/css'
interface STLModelProps {
url: string
}
function STLModel({ url }: STLModelProps) {
const geometry = useLoader(STLLoader, url)
return (
<mesh geometry={geometry}>
<meshStandardMaterial color="#8b7355" metalness={0.1} roughness={0.6} />
</mesh>
)
}
interface STLPreviewProps {
columns: number
scaleFactor: number
}
export function STLPreview({ columns, scaleFactor }: STLPreviewProps) {
const [previewUrl, setPreviewUrl] = useState<string>('/3d-models/simplified.abacus.stl')
const [isGenerating, setIsGenerating] = useState(false)
const [error, setError] = useState<string | null>(null)
const workerRef = useRef<Worker | null>(null)
// Initialize worker
useEffect(() => {
const worker = new Worker(new URL('../../workers/openscad.worker.ts', import.meta.url), {
type: 'module',
})
worker.onmessage = (event: MessageEvent) => {
const { data } = event
switch (data.type) {
case 'ready':
console.log('[STLPreview] Worker ready')
break
case 'result': {
// Create blob from STL data
const blob = new Blob([data.stl], { type: 'application/octet-stream' })
const objectUrl = URL.createObjectURL(blob)
// Revoke old URL if it exists
if (previewUrl && previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(previewUrl)
}
setPreviewUrl(objectUrl)
setIsGenerating(false)
setError(null)
break
}
case 'error':
console.error('[STLPreview] Worker error:', data.error)
setError(data.error)
setIsGenerating(false)
// Fallback to showing the base STL
setPreviewUrl('/3d-models/simplified.abacus.stl')
break
default:
console.warn('[STLPreview] Unknown message type:', data)
}
}
worker.onerror = (error) => {
console.error('[STLPreview] Worker error:', error)
setError('Worker failed to load')
setIsGenerating(false)
}
workerRef.current = worker
return () => {
worker.terminate()
workerRef.current = null
// Clean up any blob URLs
if (previewUrl && previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(previewUrl)
}
}
}, [])
// Trigger rendering when parameters change
useEffect(() => {
if (!workerRef.current) return
setIsGenerating(true)
setError(null)
// Debounce: Wait 500ms after parameters change before regenerating
const timeoutId = setTimeout(() => {
workerRef.current?.postMessage({
type: 'render',
columns,
scaleFactor,
})
}, 500)
return () => {
clearTimeout(timeoutId)
}
}, [columns, scaleFactor])
return (
<div
data-component="stl-preview"
className={css({
position: 'relative',
width: '100%',
height: '500px',
bg: 'gray.900',
borderRadius: '8px',
overflow: 'hidden',
})}
>
{isGenerating && (
<div
className={css({
position: 'absolute',
top: 4,
right: 4,
left: 4,
zIndex: 10,
bg: 'blue.600',
color: 'white',
px: 3,
py: 2,
borderRadius: '4px',
fontSize: 'sm',
fontWeight: 'bold',
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: 2 })}>
<div
className={css({
width: '16px',
height: '16px',
border: '2px solid white',
borderTopColor: 'transparent',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
})}
/>
<span>Rendering preview (may take 30-60 seconds)...</span>
</div>
</div>
)}
{error && (
<div
className={css({
position: 'absolute',
top: 4,
right: 4,
left: 4,
zIndex: 10,
bg: 'red.600',
color: 'white',
px: 3,
py: 2,
borderRadius: '4px',
fontSize: 'sm',
fontWeight: 'bold',
})}
>
<div>Preview Error:</div>
<div className={css({ fontSize: 'xs', mt: 1, opacity: 0.9 })}>{error}</div>
</div>
)}
<Canvas camera={{ position: [0, 0, 100], fov: 50 }}>
<Suspense
fallback={
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="orange" />
</mesh>
}
>
<Stage environment="city" intensity={0.6}>
<STLModel url={previewUrl} key={previewUrl} />
</Stage>
<OrbitControls makeDefault />
</Suspense>
</Canvas>
</div>
)
}