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:
parent
a778d81bcf
commit
7b4c7c3fb6
|
|
@ -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
|
|
@ -148,6 +148,13 @@
|
|||
"when": 1762776068979,
|
||||
"tag": "0020_supreme_saracen",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "6",
|
||||
"when": 1762879693900,
|
||||
"tag": "0021_little_sentry",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue