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,
|
"when": 1762776068979,
|
||||||
"tag": "0020_supreme_saracen",
|
"tag": "0020_supreme_saracen",
|
||||||
"breakpoints": true
|
"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-form": "^0.19.0",
|
||||||
"@tanstack/react-query": "^5.90.2",
|
"@tanstack/react-query": "^5.90.2",
|
||||||
"@types/jsdom": "^21.1.7",
|
"@types/jsdom": "^21.1.7",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@use-gesture/react": "^10.3.1",
|
"@use-gesture/react": "^10.3.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^12.4.1",
|
"better-sqlite3": "^12.4.1",
|
||||||
|
|
@ -74,6 +75,7 @@
|
||||||
"next-intl": "^4.4.0",
|
"next-intl": "^4.4.0",
|
||||||
"openscad-wasm-prebuilt": "^1.2.0",
|
"openscad-wasm-prebuilt": "^1.2.0",
|
||||||
"python-bridge": "^1.1.0",
|
"python-bridge": "^1.1.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^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