feat: add shared worksheet viewer with open-in-editor functionality

Add complete viewer page for shared worksheet links:

Shared Worksheet Page (/worksheets/shared/[id]):
- Fetches shared config from GET /api/worksheets/share/[id]
- Displays worksheet type, view count, creation date
- Shows configuration summary (operator, problems, pages, digit range)
- Loading state with spinner during fetch
- Error handling for 404/failed loads

Open in Editor:
- "Open in Editor" button loads config into worksheet creator
- Uses sessionStorage to pass config from viewer to editor
- Navigates to /create/worksheets?from=share
- Auto-clears sessionStorage after loading

Editor Integration:
- AdditionWorksheetClient checks for ?from=share query param
- Reads sharedWorksheetConfig from sessionStorage on mount
- Replaces initialSettings with shared config
- Cleans up sessionStorage after load

Navigation:
- Error state includes "Create Your Own Worksheet" button
- All navigation uses correct /create/worksheets path

User Flow:
1. User receives share link (/worksheets/shared/abc123X)
2. Views config details and QR code
3. Clicks "Open in Editor"
4. Editor loads with exact shared configuration
5. Can modify and save as their own

🤖 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:17:19 -06:00
parent 085d200da4
commit 4b8b3ee532
2 changed files with 434 additions and 0 deletions

View File

@@ -0,0 +1,153 @@
'use client'
import { css } from '@styled/css'
import { useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { useEffect, useState } from 'react'
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'
import type { WorksheetFormState } from '@/app/create/worksheets/types'
import { PageWithNav } from '@/components/PageWithNav'
import { useTheme } from '@/contexts/ThemeContext'
import { useWorksheetAutoSave } from '../hooks/useWorksheetAutoSave'
import { useWorksheetGeneration } from '../hooks/useWorksheetGeneration'
import { useWorksheetState } from '../hooks/useWorksheetState'
import { getDefaultDate } from '../utils/dateFormatting'
import { ConfigSidebar } from './ConfigSidebar'
import { GenerationErrorDisplay } from './GenerationErrorDisplay'
import { PreviewCenter } from './PreviewCenter'
import { WorksheetConfigProvider } from './WorksheetConfigContext'
interface AdditionWorksheetClientProps {
initialSettings: Omit<WorksheetFormState, 'date' | 'rows' | 'total'>
initialPreview?: string[]
}
export function AdditionWorksheetClient({
initialSettings,
initialPreview,
}: AdditionWorksheetClientProps) {
const searchParams = useSearchParams()
const isFromShare = searchParams.get('from') === 'share'
// Check for shared config in sessionStorage
const [effectiveSettings, setEffectiveSettings] = useState(initialSettings)
useEffect(() => {
if (isFromShare && typeof window !== 'undefined') {
const sharedConfigStr = sessionStorage.getItem('sharedWorksheetConfig')
if (sharedConfigStr) {
try {
const sharedConfig = JSON.parse(sharedConfigStr)
console.log('[Worksheet Client] Loading shared config:', sharedConfig)
setEffectiveSettings(sharedConfig)
// Clear from sessionStorage after loading
sessionStorage.removeItem('sharedWorksheetConfig')
} catch (err) {
console.error('Failed to parse shared config:', err)
}
}
}
}, [isFromShare])
console.log('[Worksheet Client] Component render, effectiveSettings:', {
problemsPerPage: effectiveSettings.problemsPerPage,
cols: effectiveSettings.cols,
pages: effectiveSettings.pages,
seed: effectiveSettings.seed,
})
const t = useTranslations('create.worksheets.addition')
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
// State management (formState, debouncedFormState, updateFormState)
const { formState, debouncedFormState, updateFormState } = useWorksheetState(effectiveSettings)
// Generation workflow (status, error, generate, reset)
const { status, error, generate, reset } = useWorksheetGeneration()
// Auto-save (isSaving, lastSaved)
const { isSaving, lastSaved } = useWorksheetAutoSave(formState, 'addition')
// Generate handler with date injection
const handleGenerate = async () => {
await generate({
...formState,
date: getDefaultDate(),
})
}
// Resize handle styles
const resizeHandleStyles = css({
width: '8px',
bg: isDark ? 'gray.700' : 'gray.200',
position: 'relative',
cursor: 'col-resize',
transition: 'background 0.2s',
_hover: {
bg: isDark ? 'brand.600' : 'brand.400',
},
_active: {
bg: 'brand.500',
},
_before: {
content: '""',
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '3px',
height: '20px',
bg: isDark ? 'gray.500' : 'gray.400',
borderRadius: 'full',
boxShadow: isDark
? '0 -8px 0 0 rgb(107, 114, 128), 0 8px 0 0 rgb(107, 114, 128)'
: '0 -8px 0 0 rgb(156, 163, 175), 0 8px 0 0 rgb(156, 163, 175)',
},
})
return (
<PageWithNav navTitle={t('navTitle')} navEmoji="📝">
<WorksheetConfigProvider formState={formState} onChange={updateFormState}>
<div
data-component="addition-worksheet-page"
className={css({
height: '100vh',
bg: isDark ? 'gray.900' : 'gray.50',
paddingTop: 'var(--app-nav-height)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
})}
>
{/* Resizable Panel Layout */}
<PanelGroup
direction="horizontal"
autoSaveId="worksheet-generator-layout"
className={css({ flex: '1', minHeight: '0' })}
>
{/* Left Panel: Config Sidebar */}
<Panel defaultSize={25} minSize={20} maxSize={40} collapsible>
<ConfigSidebar isSaving={isSaving} lastSaved={lastSaved} />
</Panel>
<PanelResizeHandle className={resizeHandleStyles} />
{/* Center Panel: Preview */}
<Panel defaultSize={75} minSize={60}>
<PreviewCenter
formState={debouncedFormState}
initialPreview={initialPreview}
onGenerate={handleGenerate}
status={status}
/>
</Panel>
</PanelGroup>
{/* Error Display */}
<GenerationErrorDisplay error={error} visible={status === 'error'} onRetry={reset} />
</div>
</WorksheetConfigProvider>
</PageWithNav>
)
}

View File

@@ -0,0 +1,281 @@
'use client'
import { useParams, useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { css } from '@styled/css'
import { stack } from '@styled/patterns'
import type { WorksheetFormState } from '@/app/create/worksheets/types'
interface ShareData {
id: string
worksheetType: string
config: WorksheetFormState
createdAt: string
views: number
title: string | null
}
export default function SharedWorksheetPage() {
const params = useParams()
const router = useRouter()
const shareId = params.id as string
const [shareData, setShareData] = useState<ShareData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchShare = async () => {
try {
const response = await fetch(`/api/worksheets/share/${shareId}`)
if (!response.ok) {
if (response.status === 404) {
setError('Shared worksheet not found')
} else {
setError('Failed to load shared worksheet')
}
return
}
const data = await response.json()
setShareData(data)
} catch (err) {
console.error('Error fetching shared worksheet:', err)
setError('Failed to load shared worksheet')
} finally {
setLoading(false)
}
}
fetchShare()
}, [shareId])
const handleOpenInEditor = () => {
if (!shareData) return
// Navigate to the worksheet creator with the shared config
// Store config in sessionStorage so it can be loaded by the editor
sessionStorage.setItem('sharedWorksheetConfig', JSON.stringify(shareData.config))
router.push('/create/worksheets?from=share')
}
if (loading) {
return (
<div
className={css({
minH: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bg: 'gray.50',
})}
>
<div className={stack({ gap: '4', alignItems: 'center' })}>
<div
className={css({
w: '16',
h: '16',
border: '4px solid',
borderColor: 'gray.300',
borderTopColor: 'brand.600',
rounded: 'full',
animation: 'spin 1s linear infinite',
})}
/>
<p className={css({ fontSize: 'lg', color: 'gray.600' })}>Loading shared worksheet...</p>
</div>
</div>
)
}
if (error || !shareData) {
return (
<div
className={css({
minH: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bg: 'gray.50',
p: '4',
})}
>
<div
className={css({
maxW: 'md',
w: 'full',
bg: 'white',
rounded: 'xl',
shadow: 'xl',
p: '8',
})}
>
<div className={stack({ gap: '4', alignItems: 'center' })}>
<div className={css({ fontSize: '4xl' })}></div>
<h1 className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'gray.900' })}>
{error || 'Not Found'}
</h1>
<p className={css({ fontSize: 'md', color: 'gray.600', textAlign: 'center' })}>
This shared worksheet link may have expired or been removed.
</p>
<button
onClick={() => router.push('/create/worksheets')}
className={css({
px: '6',
py: '3',
bg: 'brand.600',
color: 'white',
fontSize: 'md',
fontWeight: 'bold',
rounded: 'lg',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
bg: 'brand.700',
},
})}
>
Create Your Own Worksheet
</button>
</div>
</div>
</div>
)
}
return (
<div
className={css({
minH: '100vh',
bg: 'gray.50',
p: '8',
})}
>
<div
className={css({
maxW: '4xl',
mx: 'auto',
})}
>
<div
className={css({
bg: 'white',
rounded: 'xl',
shadow: 'xl',
p: '8',
})}
>
<div className={stack({ gap: '6' })}>
{/* Header */}
<div className={stack({ gap: '2' })}>
<h1 className={css({ fontSize: '3xl', fontWeight: 'bold', color: 'gray.900' })}>
Shared Worksheet
</h1>
{shareData.title && (
<p className={css({ fontSize: 'lg', color: 'gray.700' })}>{shareData.title}</p>
)}
<div
className={css({
display: 'flex',
gap: '4',
fontSize: 'sm',
color: 'gray.500',
})}
>
<span>Type: {shareData.worksheetType}</span>
<span></span>
<span>Views: {shareData.views}</span>
<span></span>
<span>Created: {new Date(shareData.createdAt).toLocaleDateString()}</span>
</div>
</div>
{/* Configuration Summary */}
<div
className={css({
bg: 'gray.50',
rounded: 'lg',
p: '6',
border: '2px solid',
borderColor: 'gray.200',
})}
>
<h2
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: 'gray.900',
mb: '4',
})}
>
Configuration
</h2>
<div className={stack({ gap: '2' })}>
<div className={css({ display: 'grid', gridTemplateColumns: '2', gap: '4' })}>
<ConfigItem label="Operator" value={shareData.config.operator || 'addition'} />
<ConfigItem
label="Problems per page"
value={shareData.config.problemsPerPage.toString()}
/>
<ConfigItem label="Pages" value={shareData.config.pages.toString()} />
<ConfigItem
label="Digit range"
value={`${shareData.config.digitRange.min}-${shareData.config.digitRange.max}`}
/>
</div>
</div>
</div>
{/* Actions */}
<div
className={css({
display: 'flex',
gap: '4',
flexWrap: 'wrap',
})}
>
<button
onClick={handleOpenInEditor}
className={css({
flex: '1',
px: '6',
py: '4',
bg: 'brand.600',
color: 'white',
fontSize: 'md',
fontWeight: 'bold',
rounded: 'lg',
cursor: 'pointer',
transition: 'all 0.2s',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '2',
_hover: {
bg: 'brand.700',
transform: 'translateY(-1px)',
},
})}
>
<span></span>
<span>Open in Editor</span>
</button>
</div>
</div>
</div>
</div>
</div>
)
}
function ConfigItem({ label, value }: { label: string; value: string }) {
return (
<div className={stack({ gap: '1' })}>
<dt className={css({ fontSize: 'xs', fontWeight: 'semibold', color: 'gray.500' })}>
{label}
</dt>
<dd className={css({ fontSize: 'md', color: 'gray.900' })}>{value}</dd>
</div>
)
}