feat(worksheets): add QR codes with share codes for easy worksheet sharing
- Add optional QR code embedding in worksheet PDFs (toggle in Layout settings) - Display 7-character share code under QR code for manual entry - Create share record when generating PDF with QR code enabled - Add "Load Share Code" modal for entering share codes without smartphone - Update preview to show QR code placement when enabled - Fix async/await for generateTypstSource across all callers The QR code appears in the header next to the date, with the share code printed below it. Users without smartphones can type the code into the "Load Share Code" option in the worksheet generator menu. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
ed25b323e8
commit
a0e73d971b
|
|
@ -11,7 +11,7 @@ export async function POST(request: NextRequest) {
|
|||
const body: WorksheetFormState = await request.json()
|
||||
|
||||
// Generate preview using shared logic
|
||||
const result = generateWorksheetPreview(body)
|
||||
const result = await generateWorksheetPreview(body)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export async function POST(request: NextRequest, { params }: { params: { pageNum
|
|||
}
|
||||
|
||||
// Generate only the requested page
|
||||
const result = generateSinglePage(body, pageNumber)
|
||||
const result = await generateSinglePage(body, pageNumber)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ export async function POST(request: NextRequest) {
|
|||
}
|
||||
|
||||
// Generate preview using shared logic with pagination
|
||||
const result = generateWorksheetPreview(body, startPage, endPage)
|
||||
const result = await generateWorksheetPreview(body, startPage, endPage)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { execSync } from 'child_process'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { validateWorksheetConfig } from '@/app/create/worksheets/validation'
|
||||
import {
|
||||
generateProblems,
|
||||
|
|
@ -9,7 +10,11 @@ import {
|
|||
generateMixedProblems,
|
||||
} from '@/app/create/worksheets/problemGenerator'
|
||||
import { generateTypstSource } from '@/app/create/worksheets/typstGenerator'
|
||||
import { serializeAdditionConfig } from '@/app/create/worksheets/config-schemas'
|
||||
import type { WorksheetFormState, WorksheetProblem } from '@/app/create/worksheets/types'
|
||||
import { db } from '@/db'
|
||||
import { worksheetShares } from '@/db/schema'
|
||||
import { generateShareId } from '@/lib/generateShareId'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
|
|
@ -58,8 +63,74 @@ export async function POST(request: NextRequest) {
|
|||
)
|
||||
}
|
||||
|
||||
// If QR code is enabled, create a share record first
|
||||
let shareUrl: string | undefined
|
||||
if (config.includeQRCode) {
|
||||
try {
|
||||
// Generate unique share ID
|
||||
let shareId = generateShareId()
|
||||
let attempts = 0
|
||||
const MAX_ATTEMPTS = 5
|
||||
let isUnique = false
|
||||
|
||||
while (!isUnique && attempts < MAX_ATTEMPTS) {
|
||||
shareId = generateShareId()
|
||||
const existing = await db.query.worksheetShares.findFirst({
|
||||
where: eq(worksheetShares.id, shareId),
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
isUnique = true
|
||||
} else {
|
||||
attempts++
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUnique) {
|
||||
console.error('Failed to generate unique share ID for QR code')
|
||||
// Continue without QR code rather than failing the entire request
|
||||
} else {
|
||||
// Get creator IP (hashed for privacy)
|
||||
const forwardedFor = request.headers.get('x-forwarded-for')
|
||||
const ip = forwardedFor?.split(',')[0] || request.headers.get('x-real-ip') || 'unknown'
|
||||
|
||||
const hashIp = (str: string) => {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i)
|
||||
hash = (hash << 5) - hash + char
|
||||
hash = hash & hash
|
||||
}
|
||||
return hash.toString(36)
|
||||
}
|
||||
|
||||
// Serialize config for sharing
|
||||
const configJson = serializeAdditionConfig(body)
|
||||
|
||||
// Create share record
|
||||
await db.insert(worksheetShares).values({
|
||||
id: shareId,
|
||||
worksheetType: 'addition',
|
||||
config: configJson,
|
||||
createdAt: new Date(),
|
||||
views: 0,
|
||||
creatorIp: hashIp(ip),
|
||||
title: config.name || null,
|
||||
})
|
||||
|
||||
// Build full URL
|
||||
const protocol = request.headers.get('x-forwarded-proto') || 'https'
|
||||
const host = request.headers.get('host') || 'abaci.one'
|
||||
shareUrl = `${protocol}://${host}/worksheets/shared/${shareId}`
|
||||
}
|
||||
} catch (shareError) {
|
||||
console.error('Error creating share for QR code:', shareError)
|
||||
// Continue without QR code rather than failing the entire request
|
||||
}
|
||||
}
|
||||
|
||||
// Generate Typst sources (one per page)
|
||||
const typstSources = generateTypstSource(config, problems)
|
||||
const typstSources = await generateTypstSource(config, problems, shareUrl)
|
||||
|
||||
// Join pages with pagebreak for PDF
|
||||
const typstSource = typstSources.join('\n\n#pagebreak()\n\n')
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export async function POST(request: Request) {
|
|||
}
|
||||
|
||||
// Generate preview
|
||||
const result = generateWorksheetPreview(fullConfig)
|
||||
const result = await generateWorksheetPreview(fullConfig)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,299 @@
|
|||
'use client'
|
||||
|
||||
import { css } from '@styled/css'
|
||||
import { stack } from '@styled/patterns'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { isValidShareId } from '@/lib/generateShareId'
|
||||
|
||||
interface LoadShareCodeModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for entering a share code to load a shared worksheet
|
||||
*
|
||||
* Users can type in a 7-character share code (printed under QR codes on worksheets)
|
||||
* to load the shared worksheet configuration.
|
||||
*/
|
||||
export function LoadShareCodeModal({ isOpen, onClose, isDark }: LoadShareCodeModalProps) {
|
||||
const router = useRouter()
|
||||
const [shareCode, setShareCode] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// Remove any non-alphanumeric characters and convert to string
|
||||
const value = e.target.value.replace(/[^a-zA-Z0-9]/g, '').slice(0, 7)
|
||||
setShareCode(value)
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
// Validate share code format
|
||||
if (!isValidShareId(shareCode)) {
|
||||
setError('Invalid share code. Share codes are 7 characters (letters and numbers).')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Check if the share exists
|
||||
const response = await fetch(`/api/worksheets/share/${shareCode}`)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
setError('Share code not found. Please check the code and try again.')
|
||||
} else {
|
||||
setError('Failed to load shared worksheet. Please try again.')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Navigate to the shared worksheet page
|
||||
router.push(`/worksheets/shared/${shareCode}`)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
console.error('Error loading share code:', err)
|
||||
setError('Failed to load shared worksheet. Please check your connection.')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[shareCode, router, onClose]
|
||||
)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="load-share-code-modal"
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
bg: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 50,
|
||||
p: '4',
|
||||
})}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
bg: isDark ? 'gray.800' : 'white',
|
||||
rounded: 'xl',
|
||||
shadow: 'xl',
|
||||
maxW: 'md',
|
||||
w: 'full',
|
||||
p: '6',
|
||||
})}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className={stack({ gap: '4' })}>
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: isDark ? 'gray.100' : 'gray.900',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
Load Shared Worksheet
|
||||
</h2>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
Enter the 7-character share code printed under the QR code on a worksheet to load
|
||||
it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Share Code Input */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="share-code-input"
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
Share Code
|
||||
</label>
|
||||
<input
|
||||
id="share-code-input"
|
||||
type="text"
|
||||
value={shareCode}
|
||||
onChange={handleInputChange}
|
||||
placeholder="e.g., k7mP2qR"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
className={css({
|
||||
w: 'full',
|
||||
px: '4',
|
||||
py: '3',
|
||||
fontSize: 'xl',
|
||||
fontFamily: 'mono',
|
||||
fontWeight: 'bold',
|
||||
letterSpacing: '0.1em',
|
||||
textAlign: 'center',
|
||||
textTransform: 'none',
|
||||
bg: isDark ? 'gray.900' : 'gray.50',
|
||||
border: '2px solid',
|
||||
borderColor: error
|
||||
? 'red.500'
|
||||
: shareCode.length === 7
|
||||
? 'green.500'
|
||||
: isDark
|
||||
? 'gray.600'
|
||||
: 'gray.300',
|
||||
rounded: 'lg',
|
||||
color: isDark ? 'gray.100' : 'gray.900',
|
||||
outline: 'none',
|
||||
transition: 'all 0.2s',
|
||||
_focus: {
|
||||
borderColor: isDark ? 'brand.400' : 'brand.500',
|
||||
boxShadow: `0 0 0 3px ${isDark ? 'rgba(99, 102, 241, 0.3)' : 'rgba(99, 102, 241, 0.2)'}`,
|
||||
},
|
||||
_placeholder: {
|
||||
color: isDark ? 'gray.500' : 'gray.400',
|
||||
letterSpacing: '0.1em',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
mt: '2',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: isDark ? 'gray.500' : 'gray.500',
|
||||
})}
|
||||
>
|
||||
{shareCode.length}/7 characters
|
||||
</span>
|
||||
{shareCode.length === 7 && !error && (
|
||||
<span
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'green.500',
|
||||
})}
|
||||
>
|
||||
✓ Valid format
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div
|
||||
className={css({
|
||||
p: '3',
|
||||
bg: isDark ? 'red.900/30' : 'red.50',
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'red.700' : 'red.200',
|
||||
rounded: 'lg',
|
||||
color: isDark ? 'red.300' : 'red.600',
|
||||
fontSize: 'sm',
|
||||
})}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Help Text */}
|
||||
<div
|
||||
className={css({
|
||||
p: '4',
|
||||
bg: isDark ? 'gray.700/50' : 'blue.50',
|
||||
rounded: 'lg',
|
||||
fontSize: 'sm',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
})}
|
||||
>
|
||||
<strong>Where to find the share code:</strong>
|
||||
<ul className={css({ mt: '2', ml: '4', listStyle: 'disc' })}>
|
||||
<li>Look at the top-right corner of a printed worksheet</li>
|
||||
<li>The share code is printed below the QR code</li>
|
||||
<li>It's 7 characters: letters and numbers (e.g., k7mP2qR)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '3',
|
||||
flexDirection: 'row-reverse',
|
||||
pt: '2',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={shareCode.length !== 7 || isLoading}
|
||||
className={css({
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: shareCode.length === 7 ? 'brand.600' : isDark ? 'gray.600' : 'gray.300',
|
||||
color: shareCode.length === 7 ? 'white' : isDark ? 'gray.400' : 'gray.500',
|
||||
fontSize: 'md',
|
||||
fontWeight: 'bold',
|
||||
rounded: 'lg',
|
||||
cursor: shareCode.length === 7 ? 'pointer' : 'not-allowed',
|
||||
transition: 'all 0.2s',
|
||||
opacity: isLoading ? 0.7 : 1,
|
||||
_hover: shareCode.length === 7 ? { bg: 'brand.700' } : {},
|
||||
})}
|
||||
>
|
||||
{isLoading ? 'Loading...' : 'Load Worksheet'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={css({
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: isDark ? 'gray.700' : 'gray.200',
|
||||
color: isDark ? 'gray.300' : 'gray.700',
|
||||
fontSize: 'md',
|
||||
fontWeight: 'medium',
|
||||
rounded: 'lg',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
bg: isDark ? 'gray.600' : 'gray.300',
|
||||
},
|
||||
})}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -30,8 +30,12 @@ interface OrientationPanelProps {
|
|||
// Layout options
|
||||
problemNumbers?: 'always' | 'never'
|
||||
cellBorders?: 'always' | 'never'
|
||||
includeAnswerKey?: boolean
|
||||
includeQRCode?: boolean
|
||||
onProblemNumbersChange?: (value: 'always' | 'never') => void
|
||||
onCellBordersChange?: (value: 'always' | 'never') => void
|
||||
onIncludeAnswerKeyChange?: (value: boolean) => void
|
||||
onIncludeQRCodeChange?: (value: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -53,8 +57,12 @@ export function OrientationPanel({
|
|||
mode = 'custom',
|
||||
problemNumbers = 'always',
|
||||
cellBorders = 'always',
|
||||
includeAnswerKey = false,
|
||||
includeQRCode = false,
|
||||
onProblemNumbersChange,
|
||||
onCellBordersChange,
|
||||
onIncludeAnswerKeyChange,
|
||||
onIncludeQRCodeChange,
|
||||
}: OrientationPanelProps) {
|
||||
// Calculate best problems per page for an orientation to minimize total change
|
||||
const getBestProblemsPerPage = (targetOrientation: 'portrait' | 'landscape') => {
|
||||
|
|
@ -1272,6 +1280,126 @@ export function OrientationPanel({
|
|||
/>
|
||||
</button>
|
||||
</label>
|
||||
|
||||
{/* Answer Key Toggle */}
|
||||
<label
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
color: isDark ? 'gray.200' : 'gray.800',
|
||||
})}
|
||||
>
|
||||
Answer Key
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
Include answer key at end of PDF
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-action="toggle-answer-key"
|
||||
onClick={() => {
|
||||
onIncludeAnswerKeyChange?.(!includeAnswerKey)
|
||||
}}
|
||||
className={css({
|
||||
position: 'relative',
|
||||
w: '12',
|
||||
h: '6',
|
||||
bg: includeAnswerKey ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
|
||||
rounded: 'full',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '1',
|
||||
left: includeAnswerKey ? '7' : '1',
|
||||
w: '4',
|
||||
h: '4',
|
||||
bg: 'white',
|
||||
rounded: 'full',
|
||||
transition: 'left 0.2s',
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
|
||||
{/* QR Code Toggle */}
|
||||
<label
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
color: isDark ? 'gray.200' : 'gray.800',
|
||||
})}
|
||||
>
|
||||
QR Code
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
color: isDark ? 'gray.400' : 'gray.600',
|
||||
})}
|
||||
>
|
||||
Link to shareable worksheet
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-action="toggle-qr-code"
|
||||
onClick={() => {
|
||||
onIncludeQRCodeChange?.(!includeQRCode)
|
||||
}}
|
||||
className={css({
|
||||
position: 'relative',
|
||||
w: '12',
|
||||
h: '6',
|
||||
bg: includeQRCode ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
|
||||
rounded: 'full',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '1',
|
||||
left: includeQRCode ? '7' : '1',
|
||||
w: '4',
|
||||
h: '4',
|
||||
bg: 'white',
|
||||
rounded: 'full',
|
||||
transition: 'left 0.2s',
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { UploadWorksheetModal } from '@/components/worksheets/UploadWorksheetMod
|
|||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import { extractConfigFields } from '../utils/extractConfigFields'
|
||||
import { FloatingPageIndicator } from './FloatingPageIndicator'
|
||||
import { LoadShareCodeModal } from './LoadShareCodeModal'
|
||||
import { ShareModal } from './ShareModal'
|
||||
import { useWorksheetConfig } from './WorksheetConfigContext'
|
||||
import { WorksheetPreview } from './WorksheetPreview'
|
||||
|
|
@ -211,6 +212,7 @@ export function PreviewCenter({
|
|||
const scrollTimeoutRef = useRef<NodeJS.Timeout>()
|
||||
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false)
|
||||
const [isShareModalOpen, setIsShareModalOpen] = useState(false)
|
||||
const [isLoadShareModalOpen, setIsLoadShareModalOpen] = useState(false)
|
||||
const [isGeneratingShare, setIsGeneratingShare] = useState(false)
|
||||
const [justCopied, setJustCopied] = useState(false)
|
||||
// Dice rotation state for react-spring animation
|
||||
|
|
@ -698,6 +700,34 @@ export function PreviewCenter({
|
|||
<span className={css({ fontSize: 'lg' })}>⬆️</span>
|
||||
<span>Upload</span>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
data-action="load-share-code"
|
||||
onClick={() => setIsLoadShareModalOpen(true)}
|
||||
className={css({
|
||||
px: '4',
|
||||
py: '2.5',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
color: 'gray.700',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
outline: 'none',
|
||||
_hover: {
|
||||
bg: 'amber.50',
|
||||
color: 'amber.700',
|
||||
},
|
||||
_focus: {
|
||||
bg: 'amber.50',
|
||||
color: 'amber.700',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: 'lg' })}>🔗</span>
|
||||
<span>Load Share Code</span>
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu.Content>
|
||||
|
|
@ -721,6 +751,12 @@ export function PreviewCenter({
|
|||
onClose={() => setIsUploadModalOpen(false)}
|
||||
onUploadComplete={handleUploadComplete}
|
||||
/>
|
||||
|
||||
<LoadShareCodeModal
|
||||
isOpen={isLoadShareModalOpen}
|
||||
onClose={() => setIsLoadShareModalOpen(false)}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -163,6 +163,8 @@ function PreviewContent({
|
|||
formState.showTenFrames,
|
||||
formState.showTenFramesForAll,
|
||||
formState.seed, // Include seed to bust cache when problem set regenerates
|
||||
formState.includeQRCode, // Include QR code setting to regenerate preview when toggled
|
||||
formState.includeAnswerKey, // Include answer key setting to regenerate preview when toggled
|
||||
// Note: fontSize, date, rows, total intentionally excluded
|
||||
// (rows and total are derived from primary state)
|
||||
],
|
||||
|
|
|
|||
|
|
@ -160,6 +160,22 @@ export function LayoutTab() {
|
|||
|
||||
onChange(updates)
|
||||
}}
|
||||
includeAnswerKey={formState.includeAnswerKey ?? false}
|
||||
onIncludeAnswerKeyChange={(value) => {
|
||||
console.log('[LayoutTab] Changing includeAnswerKey:', {
|
||||
from: formState.includeAnswerKey,
|
||||
to: value,
|
||||
})
|
||||
onChange({ includeAnswerKey: value })
|
||||
}}
|
||||
includeQRCode={formState.includeQRCode ?? false}
|
||||
onIncludeQRCodeChange={(value) => {
|
||||
console.log('[LayoutTab] Changing includeQRCode:', {
|
||||
from: formState.includeQRCode,
|
||||
to: value,
|
||||
})
|
||||
onChange({ includeQRCode: value })
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -350,6 +350,12 @@ const additionConfigV4BaseSchema = z.object({
|
|||
pAllStart: z.number().min(0).max(1),
|
||||
interpolate: z.boolean(),
|
||||
|
||||
// V4: Include answer key pages at end of PDF
|
||||
includeAnswerKey: z.boolean().optional().default(false),
|
||||
|
||||
// V4: Include QR code linking to shared worksheet on each page
|
||||
includeQRCode: z.boolean().optional().default(false),
|
||||
|
||||
// Problem reproducibility (CRITICAL for sharing worksheets)
|
||||
seed: z.number().int().min(0).optional(),
|
||||
prngAlgorithm: z.string().optional(),
|
||||
|
|
|
|||
|
|
@ -39,11 +39,11 @@ export interface SinglePageResult {
|
|||
* @param startPage - Optional start page (0-indexed, inclusive). Default: 0
|
||||
* @param endPage - Optional end page (0-indexed, inclusive). Default: last page
|
||||
*/
|
||||
export function generateWorksheetPreview(
|
||||
export async function generateWorksheetPreview(
|
||||
config: WorksheetFormState,
|
||||
startPage?: number,
|
||||
endPage?: number
|
||||
): PreviewResult {
|
||||
): Promise<PreviewResult> {
|
||||
const totalProblems = (config.problemsPerPage ?? 20) * (config.pages ?? 1)
|
||||
console.log(`[PREVIEW] Starting generation: ${totalProblems} problems, pages ${config.pages}`)
|
||||
|
||||
|
|
@ -159,9 +159,13 @@ export function generateWorksheetPreview(
|
|||
console.log(`[PREVIEW] Step 2: ✓ Generated ${problems.length} problems`)
|
||||
|
||||
// Generate Typst sources (one per page)
|
||||
// Use placeholder URL for QR code in preview (actual URL will be generated when PDF is created)
|
||||
const previewShareUrl = validatedConfig.includeQRCode
|
||||
? 'https://abaci.one/worksheets/shared/preview'
|
||||
: undefined
|
||||
console.log(`[PREVIEW] Step 3: Generating Typst source for ${validatedConfig.pages} pages...`)
|
||||
const startTypst = Date.now()
|
||||
const typstSources = generateTypstSource(validatedConfig, problems)
|
||||
const typstSources = await generateTypstSource(validatedConfig, problems, previewShareUrl)
|
||||
const typstTime = Date.now() - startTypst
|
||||
const totalPages = typstSources.length
|
||||
console.log(`[PREVIEW] Step 3: ✓ Generated ${totalPages} Typst sources in ${typstTime}ms`)
|
||||
|
|
@ -246,10 +250,10 @@ export function generateWorksheetPreview(
|
|||
* Generate a single worksheet page SVG
|
||||
* Much faster than generating all pages when you only need one
|
||||
*/
|
||||
export function generateSinglePage(
|
||||
export async function generateSinglePage(
|
||||
config: WorksheetFormState,
|
||||
pageNumber: number
|
||||
): SinglePageResult {
|
||||
): Promise<SinglePageResult> {
|
||||
try {
|
||||
// First, validate and get total page count
|
||||
const validation = validateWorksheetConfig(config)
|
||||
|
|
@ -346,7 +350,11 @@ export function generateSinglePage(
|
|||
}
|
||||
|
||||
// Generate Typst source for ALL pages (lightweight operation)
|
||||
const typstSources = generateTypstSource(problems, validatedConfig)
|
||||
// Use placeholder URL for QR code in preview
|
||||
const previewShareUrl = validatedConfig.includeQRCode
|
||||
? 'https://abaci.one/worksheets/shared/preview'
|
||||
: undefined
|
||||
const typstSources = await generateTypstSource(validatedConfig, problems, previewShareUrl)
|
||||
|
||||
// Only compile the requested page
|
||||
const typstSource = typstSources[pageNumber]
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ export default async function AdditionWorksheetPage() {
|
|||
const INITIAL_PAGES = 3
|
||||
const pagesToGenerate = Math.min(INITIAL_PAGES, pages)
|
||||
console.log(`[SSR] Generating initial ${pagesToGenerate} pages on server (total: ${pages})...`)
|
||||
const previewResult = generateWorksheetPreview(fullConfig, 0, pagesToGenerate - 1)
|
||||
const previewResult = await generateWorksheetPreview(fullConfig, 0, pagesToGenerate - 1)
|
||||
console.log('[SSR] Preview generation complete:', previewResult.success ? 'success' : 'failed')
|
||||
|
||||
// Pass settings and preview to client, wrapped in error boundary
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* QR Code generation utilities for worksheet PDF embedding
|
||||
*
|
||||
* Generates QR codes as SVG strings that can be embedded in Typst documents
|
||||
* using image.decode() with raw SVG strings.
|
||||
*/
|
||||
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
/**
|
||||
* Generate a QR code as an SVG string
|
||||
*
|
||||
* @param url - The URL to encode in the QR code
|
||||
* @param size - The size of the QR code in pixels (default 100)
|
||||
* @returns SVG string representing the QR code
|
||||
*/
|
||||
export async function generateQRCodeSVG(url: string, size = 100): Promise<string> {
|
||||
const svg = await QRCode.toString(url, {
|
||||
type: 'svg',
|
||||
width: size,
|
||||
margin: 1,
|
||||
errorCorrectionLevel: 'M', // Medium error correction - good balance
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff',
|
||||
},
|
||||
})
|
||||
|
||||
return svg
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a QR code as a base64-encoded SVG data URI
|
||||
* This format can be embedded directly in Typst using #image()
|
||||
*
|
||||
* @param url - The URL to encode in the QR code
|
||||
* @param size - The size of the QR code in pixels (default 100)
|
||||
* @returns Base64-encoded data URI string (e.g., "data:image/svg+xml;base64,...")
|
||||
*/
|
||||
export async function generateQRCodeBase64(url: string, size = 100): Promise<string> {
|
||||
const svg = await generateQRCodeSVG(url, size)
|
||||
const base64 = Buffer.from(svg).toString('base64')
|
||||
return `data:image/svg+xml;base64,${base64}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the Typst code snippet for embedding a QR code
|
||||
* Places the QR code at the bottom-left corner of the page
|
||||
*
|
||||
* @param shareUrl - The share URL to encode
|
||||
* @param sizeInches - Size of the QR code in inches (default 0.5)
|
||||
* @returns Typst code string for placing the QR code
|
||||
*/
|
||||
export async function generateQRCodeTypst(shareUrl: string, sizeInches = 0.5): Promise<string> {
|
||||
const base64DataUri = await generateQRCodeBase64(shareUrl, 200) // Higher res for print
|
||||
|
||||
// Position at bottom-left corner with small margin
|
||||
return `#place(bottom + left, dx: 0.25in, dy: -0.25in)[
|
||||
#image("${base64DataUri}", width: ${sizeInches}in, height: ${sizeInches}in)
|
||||
]`
|
||||
}
|
||||
|
|
@ -29,6 +29,12 @@ export type WorksheetConfig = AdditionConfigV4 & {
|
|||
seed: number
|
||||
prngAlgorithm: string
|
||||
|
||||
// Answer key generation
|
||||
includeAnswerKey: boolean
|
||||
|
||||
// QR code linking to shared worksheet
|
||||
includeQRCode: boolean
|
||||
|
||||
// Layout
|
||||
page: {
|
||||
wIn: number
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
// Typst document generator for addition worksheets
|
||||
|
||||
import type { WorksheetProblem, WorksheetConfig } from '@/app/create/worksheets/types'
|
||||
import type { WorksheetConfig, WorksheetProblem } from '@/app/create/worksheets/types'
|
||||
import { resolveDisplayForProblem } from './displayRules'
|
||||
import { analyzeProblem, analyzeSubtractionProblem } from './problemAnalysis'
|
||||
import { generateQRCodeSVG } from './qrCodeGenerator'
|
||||
import {
|
||||
generateTypstHelpers,
|
||||
generatePlaceValueColors,
|
||||
generateProblemStackFunction,
|
||||
generateSubtractionProblemStackFunction,
|
||||
generatePlaceValueColors,
|
||||
generateTypstHelpers,
|
||||
} from './typstHelpers'
|
||||
import { analyzeProblem, analyzeSubtractionProblem } from './problemAnalysis'
|
||||
import { resolveDisplayForProblem } from './displayRules'
|
||||
|
||||
/**
|
||||
* Chunk array into pages of specified size
|
||||
|
|
@ -46,12 +47,16 @@ function calculateMaxDigits(problems: WorksheetProblem[]): number {
|
|||
|
||||
/**
|
||||
* Generate Typst source code for a single page
|
||||
* @param qrCodeSvg - Optional raw SVG string for QR code to embed
|
||||
* @param shareCode - Optional share code to display under QR code (e.g., "k7mP2qR")
|
||||
*/
|
||||
function generatePageTypst(
|
||||
config: WorksheetConfig,
|
||||
pageProblems: WorksheetProblem[],
|
||||
problemOffset: number,
|
||||
rowsPerPage: number
|
||||
rowsPerPage: number,
|
||||
qrCodeSvg?: string,
|
||||
shareCode?: string
|
||||
): string {
|
||||
// Calculate maximum digits for proper column layout
|
||||
const maxDigits = calculateMaxDigits(pageProblems)
|
||||
|
|
@ -235,12 +240,18 @@ ${generateSubtractionProblemStackFunction(cellSize, maxDigits)}
|
|||
${problemsTypst}
|
||||
)
|
||||
|
||||
// Compact header - name on left, date on right
|
||||
// Compact header - name on left, date and QR code with share code on right
|
||||
#grid(
|
||||
columns: (1fr, 1fr),
|
||||
align: (left, right),
|
||||
columns: (1fr, auto, auto),
|
||||
column-gutter: 0.15in,
|
||||
align: (left, right, right),
|
||||
text(size: 0.75em, weight: "bold")[${config.name}],
|
||||
text(size: 0.65em)[${config.date}]
|
||||
text(size: 0.65em)[${config.date}],
|
||||
${
|
||||
qrCodeSvg
|
||||
? `stack(dir: ttb, spacing: 2pt, align(center)[#image.decode("${qrCodeSvg.replace(/"/g, '\\"').replace(/\n/g, '')}", width: 0.35in, height: 0.35in)], align(center)[#text(size: 6pt, font: "Courier New")[${shareCode || 'PREVIEW'}]])`
|
||||
: `[]`
|
||||
}
|
||||
)
|
||||
#v(${headerHeight}in - 0.25in)
|
||||
|
||||
|
|
@ -266,21 +277,197 @@ ${problemsTypst}
|
|||
}
|
||||
|
||||
/**
|
||||
* Generate Typst source code for the worksheet (returns array of page sources)
|
||||
* Calculate the answer for a problem
|
||||
*/
|
||||
export function generateTypstSource(
|
||||
function calculateAnswer(problem: WorksheetProblem): number {
|
||||
if (problem.operator === 'add') {
|
||||
return problem.a + problem.b
|
||||
} else {
|
||||
return problem.minuend - problem.subtrahend
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a problem as a string for the answer key
|
||||
* Example: "45 + 27 = 72" or "89 − 34 = 55"
|
||||
*/
|
||||
function formatProblemWithAnswer(
|
||||
problem: WorksheetProblem,
|
||||
index: number,
|
||||
showNumber: boolean
|
||||
): string {
|
||||
const answer = calculateAnswer(problem)
|
||||
if (problem.operator === 'add') {
|
||||
const prefix = showNumber ? `*${index + 1}.* ` : ''
|
||||
return `${prefix}${problem.a} + ${problem.b} = *${answer}*`
|
||||
} else {
|
||||
const prefix = showNumber ? `*${index + 1}.* ` : ''
|
||||
return `${prefix}${problem.minuend} − ${problem.subtrahend} = *${answer}*`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Typst source code for answer key page(s)
|
||||
* Displays problems with answers grouped by worksheet page
|
||||
* @param qrCodeSvg - Optional raw SVG string for QR code to embed
|
||||
* @param shareCode - Optional share code to display under QR code
|
||||
*/
|
||||
function generateAnswerKeyTypst(
|
||||
config: WorksheetConfig,
|
||||
problems: WorksheetProblem[]
|
||||
problems: WorksheetProblem[],
|
||||
showProblemNumbers: boolean,
|
||||
qrCodeSvg?: string,
|
||||
shareCode?: string
|
||||
): string[] {
|
||||
const { problemsPerPage } = config
|
||||
const worksheetPageCount = Math.ceil(problems.length / problemsPerPage)
|
||||
|
||||
// Group problems by worksheet page
|
||||
const worksheetPages: WorksheetProblem[][] = []
|
||||
for (let i = 0; i < problems.length; i += problemsPerPage) {
|
||||
worksheetPages.push(problems.slice(i, i + problemsPerPage))
|
||||
}
|
||||
|
||||
// Generate answer sections for each worksheet page
|
||||
// Each section is wrapped in a non-breakable block to keep page answers together
|
||||
const generatePageSection = (
|
||||
pageProblems: WorksheetProblem[],
|
||||
worksheetPageNum: number,
|
||||
globalOffset: number
|
||||
): string => {
|
||||
const answers = pageProblems
|
||||
.map((problem, i) => {
|
||||
const globalIndex = globalOffset + i
|
||||
return formatProblemWithAnswer(problem, globalIndex, showProblemNumbers)
|
||||
})
|
||||
.join(' \\\n')
|
||||
|
||||
// Only show page header if there are multiple worksheet pages
|
||||
// Wrap in block(breakable: false) to prevent splitting across columns/pages
|
||||
if (worksheetPageCount > 1) {
|
||||
return `#block(breakable: false)[
|
||||
#text(size: 10pt, weight: "bold")[Page ${worksheetPageNum}] \\
|
||||
${answers}
|
||||
]`
|
||||
}
|
||||
return answers
|
||||
}
|
||||
|
||||
// Generate all page sections
|
||||
const allSections = worksheetPages.map((pageProblems, idx) =>
|
||||
generatePageSection(pageProblems, idx + 1, idx * problemsPerPage)
|
||||
)
|
||||
|
||||
// Combine sections with spacing between page groups
|
||||
const combinedAnswers =
|
||||
worksheetPageCount > 1 ? allSections.join('\n\n#v(0.5em)\n\n') : allSections[0]
|
||||
|
||||
// For now, generate a single answer key page
|
||||
// TODO: If content exceeds page height, could split into multiple pages
|
||||
const pageTypst = String.raw`
|
||||
// answer-key-page.typ (auto-generated)
|
||||
|
||||
#set page(
|
||||
width: ${config.page.wIn}in,
|
||||
height: ${config.page.hIn}in,
|
||||
margin: 0.5in,
|
||||
fill: white
|
||||
)
|
||||
#set text(size: 11pt, font: "New Computer Modern Math")
|
||||
|
||||
// Header - matches worksheet header format
|
||||
#grid(
|
||||
columns: (1fr, 1fr),
|
||||
align: (left, right),
|
||||
text(size: 0.75em, weight: "bold")[${config.name}],
|
||||
text(size: 0.65em)[${config.date}]
|
||||
)
|
||||
#v(0.15in)
|
||||
#align(center)[
|
||||
#text(size: 14pt, weight: "bold")[Answer Key]
|
||||
]
|
||||
#v(0.25in)
|
||||
|
||||
// Answers in 3 columns, grouped by worksheet page
|
||||
#columns(3, gutter: 1.5em)[
|
||||
#set par(leading: 0.8em)
|
||||
${combinedAnswers}
|
||||
]
|
||||
|
||||
${
|
||||
qrCodeSvg
|
||||
? `// QR code linking to shared worksheet with share code below
|
||||
#place(bottom + left, dx: 0.1in, dy: -0.1in)[
|
||||
#stack(dir: ttb, spacing: 2pt, align(center)[#image.decode("${qrCodeSvg.replace(/"/g, '\\"').replace(/\n/g, '')}", width: 0.5in, height: 0.5in)], align(center)[#text(size: 7pt, font: "Courier New")[${shareCode || 'PREVIEW'}]])
|
||||
]`
|
||||
: ''
|
||||
}
|
||||
`
|
||||
|
||||
return [pageTypst]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract share code from a share URL
|
||||
* @param shareUrl - Full URL like "https://abaci.one/worksheets/shared/k7mP2qR"
|
||||
* @returns The share code (e.g., "k7mP2qR") or undefined
|
||||
*/
|
||||
function extractShareCode(shareUrl?: string): string | undefined {
|
||||
if (!shareUrl) return undefined
|
||||
// URL format: https://abaci.one/worksheets/shared/{shareCode}
|
||||
const match = shareUrl.match(/\/worksheets\/shared\/([a-zA-Z0-9]+)$/)
|
||||
return match ? match[1] : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Typst source code for the worksheet (returns array of page sources)
|
||||
* @param shareUrl - Optional share URL for QR code embedding (required if config.includeQRCode is true)
|
||||
*/
|
||||
export async function generateTypstSource(
|
||||
config: WorksheetConfig,
|
||||
problems: WorksheetProblem[],
|
||||
shareUrl?: string
|
||||
): Promise<string[]> {
|
||||
// Use the problemsPerPage directly from config (primary state)
|
||||
const problemsPerPage = config.problemsPerPage
|
||||
const rowsPerPage = problemsPerPage / config.cols
|
||||
|
||||
// Generate QR code if enabled and shareUrl is provided
|
||||
let qrCodeSvg: string | undefined
|
||||
let shareCode: string | undefined
|
||||
if (config.includeQRCode && shareUrl) {
|
||||
qrCodeSvg = await generateQRCodeSVG(shareUrl, 200) // Higher res for print quality
|
||||
shareCode = extractShareCode(shareUrl)
|
||||
}
|
||||
|
||||
// Chunk problems into discrete pages
|
||||
const pages = chunkProblems(problems, problemsPerPage)
|
||||
|
||||
// Generate separate Typst source for each page
|
||||
return pages.map((pageProblems, pageIndex) =>
|
||||
generatePageTypst(config, pageProblems, pageIndex * problemsPerPage, rowsPerPage)
|
||||
// Generate separate Typst source for each worksheet page
|
||||
const worksheetPages = pages.map((pageProblems, pageIndex) =>
|
||||
generatePageTypst(
|
||||
config,
|
||||
pageProblems,
|
||||
pageIndex * problemsPerPage,
|
||||
rowsPerPage,
|
||||
qrCodeSvg,
|
||||
shareCode
|
||||
)
|
||||
)
|
||||
|
||||
// If answer key is requested, append answer key page(s)
|
||||
if (config.includeAnswerKey) {
|
||||
// Check if problem numbers are shown (from displayRules)
|
||||
const showProblemNumbers = config.displayRules?.problemNumbers !== 'never'
|
||||
const answerKeyPages = generateAnswerKeyTypst(
|
||||
config,
|
||||
problems,
|
||||
showProblemNumbers,
|
||||
qrCodeSvg,
|
||||
shareCode
|
||||
)
|
||||
return [...worksheetPages, ...answerKeyPages]
|
||||
}
|
||||
|
||||
return worksheetPages
|
||||
}
|
||||
|
|
|
|||
|
|
@ -233,6 +233,12 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
|
|||
fontSize,
|
||||
seed,
|
||||
prngAlgorithm: formState.prngAlgorithm ?? 'mulberry32',
|
||||
|
||||
// V4: Include answer key pages at end of PDF
|
||||
includeAnswerKey: formState.includeAnswerKey ?? false,
|
||||
|
||||
// V4: Include QR code linking to shared worksheet on each page
|
||||
includeQRCode: formState.includeQRCode ?? false,
|
||||
}
|
||||
|
||||
// Build mode-specific config
|
||||
|
|
|
|||
Loading…
Reference in New Issue