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:
@@ -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>
|
||||
)
|
||||
}
|
||||
281
apps/web/src/app/worksheets/shared/[id]/page.tsx
Normal file
281
apps/web/src/app/worksheets/shared/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user