feat: add worksheet sharing infrastructure with database persistence

Add complete sharing system for worksheet configurations:

Database Schema:
- New worksheet_shares table with short share IDs (7-char base62)
- Stores worksheetType, config JSON, views, creator IP hash, optional title
- Migration 0021 creates the table

Share ID Generation:
- Cryptographically secure base62 IDs (3.5 trillion combinations)
- Collision detection with retry logic (max 5 attempts)
- Validation function for ID format checking

API Endpoints:
- POST /api/worksheets/share - Creates share, returns URL
- GET /api/worksheets/share/[id] - Retrieves config, increments views
- Uses serializeAdditionConfig() for consistent config formatting

Share Modal:
- Auto-generates share link on open (no button needed)
- Displays QR code for mobile sharing (theme-aware colors)
- Copy to clipboard functionality with visual feedback
- Loading states during generation

Dependencies:
- Added qrcode + @types/qrcode for QR code generation

Config Serialization:
- Share uses same serializeAdditionConfig() as database auto-save
- Ensures version field and structure consistency
- Shared configs match database-saved settings exactly

🤖 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-11 11:16:42 -06:00
parent a778d81bcf
commit 7b4c7c3fb6
9 changed files with 1697 additions and 1 deletions

View File

@ -0,0 +1,12 @@
-- Custom SQL migration file, put your code below! --
-- Create worksheet_shares table for shareable worksheet configurations
CREATE TABLE `worksheet_shares` (
`id` text PRIMARY KEY NOT NULL,
`worksheet_type` text NOT NULL,
`config` text NOT NULL,
`created_at` integer NOT NULL,
`views` integer DEFAULT 0 NOT NULL,
`creator_ip` text,
`title` text
);

File diff suppressed because it is too large Load Diff

View File

@ -148,6 +148,13 @@
"when": 1762776068979,
"tag": "0020_supreme_saracen",
"breakpoints": true
},
{
"idx": 21,
"version": "6",
"when": 1762879693900,
"tag": "0021_little_sentry",
"breakpoints": true
}
]
}
}

View File

@ -55,6 +55,7 @@
"@tanstack/react-form": "^0.19.0",
"@tanstack/react-query": "^5.90.2",
"@types/jsdom": "^21.1.7",
"@types/qrcode": "^1.5.6",
"@use-gesture/react": "^10.3.1",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.4.1",
@ -74,6 +75,7 @@
"next-intl": "^4.4.0",
"openscad-wasm-prebuilt": "^1.2.0",
"python-bridge": "^1.1.0",
"qrcode": "^1.5.4",
"qrcode.react": "^4.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",

View File

@ -0,0 +1,64 @@
import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db } from '@/db'
import { worksheetShares } from '@/db/schema'
import { isValidShareId } from '@/lib/generateShareId'
/**
* GET /api/worksheets/share/[id]
*
* Retrieve a shared worksheet configuration by ID
* Increments view counter on each access
*
* Response:
* {
* id: 'abc123X',
* worksheetType: 'addition',
* config: { ...worksheet config object },
* createdAt: '2025-01-01T00:00:00.000Z',
* views: 42,
* title: 'Optional title'
* }
*/
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
// Validate ID format
if (!isValidShareId(id)) {
return NextResponse.json({ error: 'Invalid share ID format' }, { status: 400 })
}
// Fetch share record
const share = await db.query.worksheetShares.findFirst({
where: eq(worksheetShares.id, id),
})
if (!share) {
return NextResponse.json({ error: 'Share not found' }, { status: 404 })
}
// Increment view counter
await db
.update(worksheetShares)
.set({
views: sql`${worksheetShares.views} + 1`,
})
.where(eq(worksheetShares.id, id))
// Parse config JSON
const config = JSON.parse(share.config)
return NextResponse.json({
id: share.id,
worksheetType: share.worksheetType,
config,
createdAt: share.createdAt.toISOString(),
views: share.views + 1, // Return incremented count
title: share.title,
})
} catch (error) {
console.error('Error fetching worksheet share:', error)
return NextResponse.json({ error: 'Failed to fetch share' }, { status: 500 })
}
}

View File

@ -0,0 +1,113 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db } from '@/db'
import { worksheetShares } from '@/db/schema'
import { generateShareId } from '@/lib/generateShareId'
import { serializeAdditionConfig } from '@/app/create/worksheets/config-schemas'
/**
* POST /api/worksheets/share
*
* Create a shareable link for a worksheet configuration
*
* Request body:
* {
* worksheetType: 'addition' | 'subtraction' | ...,
* config: { ...worksheet config object },
* title?: string (optional description)
* }
*
* Response:
* {
* id: 'abc123X',
* url: 'https://abaci.one/worksheets/shared/abc123X'
* }
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { worksheetType, config, title } = body
// Validate required fields
if (!worksheetType || !config) {
return NextResponse.json(
{ error: 'Missing required fields: worksheetType, config' },
{ status: 400 }
)
}
// Validate worksheetType - only 'addition' is supported for now
if (worksheetType !== 'addition') {
return NextResponse.json(
{ error: `Unsupported worksheet type: ${worksheetType}` },
{ status: 400 }
)
}
// Generate unique ID (retry if collision - extremely unlikely with base62^7)
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) {
return NextResponse.json({ error: 'Failed to generate unique share ID' }, { status: 500 })
}
// 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'
// Simple hash function for IP (not cryptographic, just for basic spam prevention)
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 // Convert to 32-bit integer
}
return hash.toString(36)
}
// Serialize config (adds version, validates structure)
// This ensures the shared config uses the same format as database auto-save
const configJson = serializeAdditionConfig(config)
// Create share record
await db.insert(worksheetShares).values({
id: shareId,
worksheetType,
config: configJson,
createdAt: new Date(),
views: 0,
creatorIp: hashIp(ip),
title: title || null,
})
// Build full URL
const protocol = request.headers.get('x-forwarded-proto') || 'https'
const host = request.headers.get('host') || 'abaci.one'
const url = `${protocol}://${host}/worksheets/shared/${shareId}`
return NextResponse.json({
id: shareId,
url,
})
} catch (error) {
console.error('Error creating worksheet share:', error)
return NextResponse.json({ error: 'Failed to create share' }, { status: 500 })
}
}

View File

@ -0,0 +1,318 @@
'use client'
import { css } from '@styled/css'
import { stack } from '@styled/patterns'
import QRCode from 'qrcode'
import { useEffect, useRef, useState } from 'react'
interface ShareModalProps {
isOpen: boolean
onClose: () => void
worksheetType: string
config: unknown
isDark?: boolean
}
export function ShareModal({
isOpen,
onClose,
worksheetType,
config,
isDark = false,
}: ShareModalProps) {
const [shareUrl, setShareUrl] = useState<string>('')
const [shareId, setShareId] = useState<string>('')
const [isGenerating, setIsGenerating] = useState(false)
const [error, setError] = useState<string>('')
const [copied, setCopied] = useState(false)
const qrCanvasRef = useRef<HTMLCanvasElement>(null)
// Auto-generate share link when modal opens
useEffect(() => {
if (!isOpen) return
const generateShare = async () => {
setIsGenerating(true)
setError('')
try {
const response = await fetch('/api/worksheets/share', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
worksheetType,
config,
}),
})
if (!response.ok) {
throw new Error('Failed to create share link')
}
const data = await response.json()
setShareUrl(data.url)
setShareId(data.id)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create share link')
} finally {
setIsGenerating(false)
}
}
generateShare()
}, [isOpen, worksheetType, config])
// Generate QR code when URL is available
useEffect(() => {
if (!shareUrl || !qrCanvasRef.current) return
QRCode.toCanvas(
qrCanvasRef.current,
shareUrl,
{
width: 200,
margin: 2,
color: {
dark: isDark ? '#ffffff' : '#000000',
light: isDark ? '#1f2937' : '#ffffff',
},
},
(error) => {
if (error) console.error('QR Code generation error:', error)
}
)
}, [shareUrl, isDark])
if (!isOpen) return null
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(shareUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy:', err)
}
}
const handleClose = () => {
setShareUrl('')
setShareId('')
setError('')
setCopied(false)
onClose()
}
return (
<div
data-component="share-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={handleClose}
>
<div
className={css({
bg: isDark ? 'gray.800' : 'white',
rounded: 'xl',
shadow: 'xl',
maxW: 'lg',
w: 'full',
p: '6',
})}
onClick={(e) => e.stopPropagation()}
>
<div className={stack({ gap: '4' })}>
{/* Header */}
<div>
<h2
className={css({
fontSize: '2xl',
fontWeight: 'bold',
color: isDark ? 'gray.100' : 'gray.900',
mb: '2',
})}
>
Share Worksheet
</h2>
<p
className={css({
fontSize: 'sm',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
Create a shareable link to this exact worksheet configuration. Anyone with the link
can view and generate this worksheet.
</p>
</div>
{/* Loading or Share URL */}
{isGenerating ? (
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '3',
py: '6',
})}
>
<div
className={css({
w: '12',
h: '12',
border: '4px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
borderTopColor: 'brand.600',
rounded: 'full',
animation: 'spin 1s linear infinite',
})}
/>
<p
className={css({
fontSize: 'sm',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
Generating share link...
</p>
</div>
) : shareUrl ? (
<div className={stack({ gap: '4' })}>
{/* QR Code */}
<div
className={css({
display: 'flex',
justifyContent: 'center',
py: '2',
})}
>
<div
className={css({
bg: isDark ? 'gray.700' : 'white',
p: '4',
rounded: 'lg',
border: '2px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
})}
>
<canvas ref={qrCanvasRef} />
</div>
</div>
{/* Share URL Display */}
<div
className={css({
bg: isDark ? 'gray.700' : 'gray.100',
p: '4',
rounded: 'lg',
border: '2px solid',
borderColor: isDark ? 'gray.600' : 'gray.300',
})}
>
<div
className={css({
fontSize: 'xs',
fontWeight: 'semibold',
color: isDark ? 'gray.400' : 'gray.600',
mb: '2',
})}
>
SHARE LINK
</div>
<code
className={css({
fontSize: 'sm',
color: isDark ? 'gray.200' : 'gray.800',
wordBreak: 'break-all',
})}
>
{shareUrl}
</code>
</div>
{/* Copy Button */}
<button
onClick={handleCopy}
className={css({
w: 'full',
px: '6',
py: '3',
bg: copied ? 'green.600' : 'brand.600',
color: 'white',
fontSize: 'md',
fontWeight: 'bold',
rounded: 'lg',
transition: 'all 0.2s',
cursor: 'pointer',
_hover: {
bg: copied ? 'green.700' : 'brand.700',
},
})}
>
{copied ? '✓ Copied!' : 'Copy Link'}
</button>
{/* Share ID Info */}
<p
className={css({
fontSize: 'xs',
color: isDark ? 'gray.500' : 'gray.500',
textAlign: 'center',
})}
>
Share ID: <code>{shareId}</code>
</p>
</div>
) : null}
{/* Error Message */}
{error && (
<div
className={css({
bg: 'red.50',
border: '2px solid',
borderColor: 'red.300',
color: 'red.800',
p: '3',
rounded: 'lg',
fontSize: 'sm',
})}
>
{error}
</div>
)}
{/* Close Button */}
<button
onClick={handleClose}
className={css({
w: 'full',
px: '4',
py: '2',
bg: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.300' : 'gray.700',
fontSize: 'sm',
fontWeight: 'medium',
rounded: 'lg',
transition: 'all 0.2s',
cursor: 'pointer',
_hover: {
bg: isDark ? 'gray.600' : 'gray.300',
},
})}
>
Close
</button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,40 @@
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
/**
* Worksheet shares table - stores immutable worksheet configurations for sharing
*
* Allows users to create shareable links to specific worksheet configurations.
* Each share is identified by a short code (e.g., "abc123") for clean URLs.
*
* Use cases:
* - Teacher collaboration (sharing proven worksheets)
* - Parent resources (sending specific practice worksheets)
* - Curriculum documentation (maintaining standard configurations)
* - Bug reports (sharing exact problematic configurations)
* - Social sharing ("Check out this worksheet!")
*/
export const worksheetShares = sqliteTable('worksheet_shares', {
/** Short code identifier for URL (e.g., "abc123") - 7 characters, base62 */
id: text('id').primaryKey(),
/** Type of worksheet: 'addition', 'subtraction', 'multiplication', etc. */
worksheetType: text('worksheet_type').notNull(),
/** JSON blob containing full worksheet configuration (immutable snapshot) */
config: text('config').notNull(),
/** Timestamp of creation */
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
/** View counter - incremented each time the share is accessed */
views: integer('views').notNull().default(0),
/** Optional: Creator IP for spam prevention (hashed) */
creatorIp: text('creator_ip'),
/** Optional: Title/description for the worksheet share */
title: text('title'),
})
export type WorksheetShare = typeof worksheetShares.$inferSelect
export type NewWorksheetShare = typeof worksheetShares.$inferInsert

View File

@ -0,0 +1,46 @@
/**
* Generate a short, URL-safe ID for worksheet shares
*
* Uses base62 encoding (0-9, a-z, A-Z) for maximum readability
* 7 characters = 62^7 = ~3.5 trillion possible IDs
*
* Format: abc123X (lowercase, uppercase, numbers)
* Example: k7mP2qR
*/
const BASE62_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
const ID_LENGTH = 7
/**
* Generate a random base62 string of specified length
*/
export function generateShareId(length: number = ID_LENGTH): string {
let result = ''
// Use crypto.getRandomValues for cryptographically secure randomness
const randomBytes = new Uint8Array(length)
crypto.getRandomValues(randomBytes)
for (let i = 0; i < length; i++) {
// Map random byte to base62 character
result += BASE62_CHARS[randomBytes[i] % BASE62_CHARS.length]
}
return result
}
/**
* Check if a share ID has valid format
*/
export function isValidShareId(id: string): boolean {
if (id.length !== ID_LENGTH) return false
// Check all characters are in base62 alphabet
for (let i = 0; i < id.length; i++) {
if (!BASE62_CHARS.includes(id[i])) {
return false
}
}
return true
}