feat: redesign shared worksheet viewer with read-only studio and proper error handling
Transform shared worksheet experience from simple info page to full read-only studio: **Shared Worksheet Viewer (/worksheets/shared/[id])** - Show full worksheet studio UI in read-only mode - Display live preview with actual worksheet rendering - Blue banner indicates "Shared Worksheet (Read-Only)" - Config sidebar shows all settings but disabled for interaction - Clear "Edit This Worksheet" button with overwrite warning modal **Edit Modal** - Warns that editing will overwrite current worksheet settings - Provides tips (download current worksheet first, or use different browser) - Two-step confirmation prevents accidental data loss - Saves shared config to user's session before navigating to editor **Read-Only Mode Infrastructure** - WorksheetConfigContext: Added isReadOnly prop - ConfigSidebar: Shows "👁️ Read-Only" badge, disables inputs with pointer-events - PreviewCenter: Hides Download/Share/Upload buttons when read-only - StudentNameInput: Added readOnly prop with disabled styling **Mastery Mode Field Persistence** - extractConfigFields: Now includes currentStepId, currentAdditionSkillId, currentSubtractionSkillId - Fixes issue where mastery mode worksheets couldn't be shared properly - Future shares will include all required mastery mode fields **Error Handling** - Created /api/worksheets/preview route for server-side preview generation - generatePreview: Returns detailed error messages instead of fallbacks - Shared viewer: Shows prominent error card with diagnostic details - Error format: "Missing skill IDs - addition: none, subtraction: none. This config may have been shared before mastery mode fields were added." **Architecture Changes** - Shared page now calls API for preview (avoids importing Node.js child_process in client) - Clear separation between client components and server-side generation - Proper error propagation from preview generation to UI 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
726f514d30
commit
23dccc0ef3
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { generateWorksheetPreview } from '@/app/create/worksheets/generatePreview'
|
||||||
|
import type { WorksheetFormState } from '@/app/create/worksheets/types'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/worksheets/preview
|
||||||
|
* Generate a preview of a worksheet configuration
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { config } = body as { config: WorksheetFormState }
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Missing config' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add date and seed if missing
|
||||||
|
const fullConfig: WorksheetFormState = {
|
||||||
|
...config,
|
||||||
|
date:
|
||||||
|
config.date ||
|
||||||
|
new Date().toLocaleDateString('en-US', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
}),
|
||||||
|
seed: config.seed || Date.now() % 2147483647,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate preview
|
||||||
|
const result = generateWorksheetPreview(fullConfig)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: result.error,
|
||||||
|
details: result.details,
|
||||||
|
},
|
||||||
|
{ status: 422 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
pages: result.pages,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Preview generation error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Internal server error',
|
||||||
|
details: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -108,7 +108,7 @@ export function AdditionWorksheetClient({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageWithNav navTitle={t('navTitle')} navEmoji="📝">
|
<PageWithNav navTitle={t('navTitle')} navEmoji="📝">
|
||||||
<WorksheetConfigProvider formState={formState} onChange={updateFormState}>
|
<WorksheetConfigProvider formState={formState} updateFormState={updateFormState}>
|
||||||
<div
|
<div
|
||||||
data-component="addition-worksheet-page"
|
data-component="addition-worksheet-page"
|
||||||
className={css({
|
className={css({
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ export function ConfigPanel({ formState, onChange, isDark = false }: ConfigPanel
|
||||||
const currentMethod = formState.mode === 'mastery' ? 'mastery' : 'smart'
|
const currentMethod = formState.mode === 'mastery' ? 'mastery' : 'smart'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorksheetConfigProvider formState={formState} onChange={onChange}>
|
<WorksheetConfigProvider formState={formState} updateFormState={onChange}>
|
||||||
<div data-component="config-panel" className={stack({ gap: '3' })}>
|
<div data-component="config-panel" className={stack({ gap: '3' })}>
|
||||||
{/* Student Name */}
|
{/* Student Name */}
|
||||||
<StudentNameInput
|
<StudentNameInput
|
||||||
|
|
|
||||||
|
|
@ -12,15 +12,21 @@ import { TabNavigation } from './config-sidebar/TabNavigation'
|
||||||
import { useWorksheetConfig } from './WorksheetConfigContext'
|
import { useWorksheetConfig } from './WorksheetConfigContext'
|
||||||
|
|
||||||
interface ConfigSidebarProps {
|
interface ConfigSidebarProps {
|
||||||
isSaving: boolean
|
isSaving?: boolean
|
||||||
lastSaved: Date | null
|
lastSaved?: Date | null
|
||||||
|
isReadOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConfigSidebar({ isSaving, lastSaved }: ConfigSidebarProps) {
|
export function ConfigSidebar({
|
||||||
|
isSaving = false,
|
||||||
|
lastSaved = null,
|
||||||
|
isReadOnly = false,
|
||||||
|
}: ConfigSidebarProps) {
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
const isDark = resolvedTheme === 'dark'
|
const isDark = resolvedTheme === 'dark'
|
||||||
const [activeTab, setActiveTab] = useState('operator')
|
const [activeTab, setActiveTab] = useState('operator')
|
||||||
const { formState, onChange } = useWorksheetConfig()
|
const { formState, onChange, isReadOnly: contextReadOnly } = useWorksheetConfig()
|
||||||
|
const effectiveReadOnly = isReadOnly || contextReadOnly
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -58,7 +64,11 @@ export function ConfigSidebar({ isSaving, lastSaved }: ConfigSidebarProps) {
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
fontSize: 'xs',
|
fontSize: 'xs',
|
||||||
color: isSaving
|
color: effectiveReadOnly
|
||||||
|
? isDark
|
||||||
|
? 'blue.400'
|
||||||
|
: 'blue.600'
|
||||||
|
: isSaving
|
||||||
? isDark
|
? isDark
|
||||||
? 'gray.400'
|
? 'gray.400'
|
||||||
: 'gray.500'
|
: 'gray.500'
|
||||||
|
|
@ -67,7 +77,7 @@ export function ConfigSidebar({ isSaving, lastSaved }: ConfigSidebarProps) {
|
||||||
: 'green.600',
|
: 'green.600',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{isSaving ? 'Saving...' : lastSaved ? '✓ Saved' : ''}
|
{effectiveReadOnly ? '👁️ Read-Only' : isSaving ? 'Saving...' : lastSaved ? '✓ Saved' : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -77,6 +87,7 @@ export function ConfigSidebar({ isSaving, lastSaved }: ConfigSidebarProps) {
|
||||||
value={formState.name}
|
value={formState.name}
|
||||||
onChange={(name) => onChange({ name })}
|
onChange={(name) => onChange({ name })}
|
||||||
isDark={isDark}
|
isDark={isDark}
|
||||||
|
readOnly={effectiveReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -99,6 +110,8 @@ export function ConfigSidebar({ isSaving, lastSaved }: ConfigSidebarProps) {
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
opacity: effectiveReadOnly ? '0.7' : '1',
|
||||||
|
pointerEvents: effectiveReadOnly ? 'none' : 'auto',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{activeTab === 'operator' && <ContentTab />}
|
{activeTab === 'operator' && <ContentTab />}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ interface PreviewCenterProps {
|
||||||
initialPreview?: string[]
|
initialPreview?: string[]
|
||||||
onGenerate: () => Promise<void>
|
onGenerate: () => Promise<void>
|
||||||
status: 'idle' | 'generating' | 'success' | 'error'
|
status: 'idle' | 'generating' | 'success' | 'error'
|
||||||
|
isReadOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PreviewCenter({
|
export function PreviewCenter({
|
||||||
|
|
@ -23,6 +24,7 @@ export function PreviewCenter({
|
||||||
initialPreview,
|
initialPreview,
|
||||||
onGenerate,
|
onGenerate,
|
||||||
status,
|
status,
|
||||||
|
isReadOnly = false,
|
||||||
}: PreviewCenterProps) {
|
}: PreviewCenterProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
|
|
@ -116,7 +118,8 @@ export function PreviewCenter({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{/* Floating Action Button - Top Right */}
|
{/* Floating Action Button - Top Right (hidden in read-only mode) */}
|
||||||
|
{!isReadOnly && (
|
||||||
<div
|
<div
|
||||||
data-component="floating-action-button"
|
data-component="floating-action-button"
|
||||||
className={css({
|
className={css({
|
||||||
|
|
@ -288,7 +291,8 @@ export function PreviewCenter({
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
opacity: isGeneratingShare ? '0.6' : '1',
|
opacity: isGeneratingShare ? '0.6' : '1',
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
_hover: isGeneratingShare || justCopied
|
_hover:
|
||||||
|
isGeneratingShare || justCopied
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
bg: 'green.50',
|
bg: 'green.50',
|
||||||
|
|
@ -333,8 +337,11 @@ export function PreviewCenter({
|
||||||
</DropdownMenu.Portal>
|
</DropdownMenu.Portal>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Share Modal */}
|
{/* Share Modal (hidden in read-only mode) */}
|
||||||
|
{!isReadOnly && (
|
||||||
|
<>
|
||||||
<ShareModal
|
<ShareModal
|
||||||
isOpen={isShareModalOpen}
|
isOpen={isShareModalOpen}
|
||||||
onClose={() => setIsShareModalOpen(false)}
|
onClose={() => setIsShareModalOpen(false)}
|
||||||
|
|
@ -349,6 +356,8 @@ export function PreviewCenter({
|
||||||
onClose={() => setIsUploadModalOpen(false)}
|
onClose={() => setIsUploadModalOpen(false)}
|
||||||
onUploadComplete={handleUploadComplete}
|
onUploadComplete={handleUploadComplete}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export interface WorksheetConfigContextValue {
|
||||||
formState: WorksheetFormState
|
formState: WorksheetFormState
|
||||||
onChange: (updates: Partial<WorksheetFormState>) => void
|
onChange: (updates: Partial<WorksheetFormState>) => void
|
||||||
operator: 'addition' | 'subtraction' | 'mixed'
|
operator: 'addition' | 'subtraction' | 'mixed'
|
||||||
|
isReadOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WorksheetConfigContext = createContext<WorksheetConfigContextValue | null>(null)
|
export const WorksheetConfigContext = createContext<WorksheetConfigContextValue | null>(null)
|
||||||
|
|
@ -29,8 +30,9 @@ export function useWorksheetConfig() {
|
||||||
|
|
||||||
export interface WorksheetConfigProviderProps {
|
export interface WorksheetConfigProviderProps {
|
||||||
formState: WorksheetFormState
|
formState: WorksheetFormState
|
||||||
onChange: (updates: Partial<WorksheetFormState>) => void
|
updateFormState: (updates: Partial<WorksheetFormState>) => void
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
|
isReadOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -39,16 +41,18 @@ export interface WorksheetConfigProviderProps {
|
||||||
*/
|
*/
|
||||||
export function WorksheetConfigProvider({
|
export function WorksheetConfigProvider({
|
||||||
formState,
|
formState,
|
||||||
onChange,
|
updateFormState,
|
||||||
children,
|
children,
|
||||||
|
isReadOnly = false,
|
||||||
}: WorksheetConfigProviderProps) {
|
}: WorksheetConfigProviderProps) {
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
formState,
|
formState,
|
||||||
onChange,
|
onChange: updateFormState,
|
||||||
operator: formState.operator || 'addition',
|
operator: formState.operator || 'addition',
|
||||||
|
isReadOnly,
|
||||||
}),
|
}),
|
||||||
[formState, onChange]
|
[formState, updateFormState, isReadOnly]
|
||||||
)
|
)
|
||||||
|
|
||||||
return <WorksheetConfigContext.Provider value={value}>{children}</WorksheetConfigContext.Provider>
|
return <WorksheetConfigContext.Provider value={value}>{children}</WorksheetConfigContext.Provider>
|
||||||
|
|
|
||||||
|
|
@ -4,26 +4,30 @@ export interface StudentNameInputProps {
|
||||||
value: string | undefined
|
value: string | undefined
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
isDark?: boolean
|
isDark?: boolean
|
||||||
|
readOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StudentNameInput({ value, onChange, isDark = false }: StudentNameInputProps) {
|
export function StudentNameInput({ value, onChange, isDark = false, readOnly = false }: StudentNameInputProps) {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={value || ''}
|
value={value || ''}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder="Student Name"
|
placeholder="Student Name"
|
||||||
|
readOnly={readOnly}
|
||||||
className={css({
|
className={css({
|
||||||
w: 'full',
|
w: 'full',
|
||||||
px: '3',
|
px: '3',
|
||||||
py: '2',
|
py: '2',
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: isDark ? 'gray.600' : 'gray.300',
|
borderColor: isDark ? 'gray.600' : 'gray.300',
|
||||||
bg: isDark ? 'gray.700' : 'white',
|
bg: readOnly ? (isDark ? 'gray.800' : 'gray.100') : isDark ? 'gray.700' : 'white',
|
||||||
color: isDark ? 'gray.100' : 'gray.900',
|
color: isDark ? 'gray.100' : 'gray.900',
|
||||||
rounded: 'lg',
|
rounded: 'lg',
|
||||||
fontSize: 'sm',
|
fontSize: 'sm',
|
||||||
_focus: {
|
opacity: readOnly ? '0.7' : '1',
|
||||||
|
cursor: readOnly ? 'not-allowed' : 'text',
|
||||||
|
_focus: readOnly ? {} : {
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
borderColor: 'brand.500',
|
borderColor: 'brand.500',
|
||||||
ring: '2px',
|
ring: '2px',
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
// Shared logic for generating worksheet previews (used by both API route and SSR)
|
// Shared logic for generating worksheet previews (used by both API route and SSR)
|
||||||
|
|
||||||
import { execSync } from 'child_process'
|
import { execSync } from 'child_process'
|
||||||
import { validateWorksheetConfig } from './validation'
|
import type { WorksheetFormState } from '@/app/create/worksheets/types'
|
||||||
import {
|
import {
|
||||||
|
generateMasteryMixedProblems,
|
||||||
|
generateMixedProblems,
|
||||||
generateProblems,
|
generateProblems,
|
||||||
generateSubtractionProblems,
|
generateSubtractionProblems,
|
||||||
generateMixedProblems,
|
|
||||||
generateMasteryMixedProblems,
|
|
||||||
} from './problemGenerator'
|
} from './problemGenerator'
|
||||||
import { generateTypstSource } from './typstGenerator'
|
|
||||||
import type { WorksheetFormState } from '@/app/create/worksheets/types'
|
|
||||||
import { getSkillById } from './skills'
|
import { getSkillById } from './skills'
|
||||||
|
import { generateTypstSource } from './typstGenerator'
|
||||||
|
import { validateWorksheetConfig } from './validation'
|
||||||
|
|
||||||
export interface PreviewResult {
|
export interface PreviewResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
|
|
@ -53,6 +53,7 @@ export function generateWorksheetPreview(config: WorksheetFormState): PreviewRes
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Mixed mastery mode requires both addition and subtraction skill IDs',
|
error: 'Mixed mastery mode requires both addition and subtraction skill IDs',
|
||||||
|
details: `Missing skill IDs - addition: ${addSkillId || 'none'}, subtraction: ${subSkillId || 'none'}. This config may have been shared before mastery mode fields were added to the share system.`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,6 +64,7 @@ export function generateWorksheetPreview(config: WorksheetFormState): PreviewRes
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Invalid skill IDs',
|
error: 'Invalid skill IDs',
|
||||||
|
details: `Addition skill ID: ${addSkillId} (${addSkill ? 'valid' : 'invalid'}), Subtraction skill ID: ${subSkillId} (${subSkill ? 'valid' : 'invalid'})`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { WorksheetFormState } from '../types'
|
|
||||||
import type { AdditionConfigV4 } from '../config-schemas'
|
import type { AdditionConfigV4 } from '../config-schemas'
|
||||||
|
import type { WorksheetFormState } from '../types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract only the persisted config fields from formState
|
* Extract only the persisted config fields from formState
|
||||||
|
|
@ -41,5 +41,9 @@ export function extractConfigFields(
|
||||||
difficultyProfile: formState.difficultyProfile,
|
difficultyProfile: formState.difficultyProfile,
|
||||||
displayRules: formState.displayRules,
|
displayRules: formState.displayRules,
|
||||||
manualPreset: formState.manualPreset,
|
manualPreset: formState.manualPreset,
|
||||||
|
// Mastery mode fields (optional)
|
||||||
|
currentStepId: formState.currentStepId,
|
||||||
|
currentAdditionSkillId: formState.currentAdditionSkillId,
|
||||||
|
currentSubtractionSkillId: formState.currentSubtractionSkillId,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,16 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { css } from '@styled/css'
|
import { css } from '@styled/css'
|
||||||
import { stack } from '@styled/patterns'
|
import { stack } from '@styled/patterns'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'
|
||||||
|
import { ConfigSidebar } from '@/app/create/worksheets/components/ConfigSidebar'
|
||||||
|
import { PreviewCenter } from '@/app/create/worksheets/components/PreviewCenter'
|
||||||
|
import { WorksheetConfigProvider } from '@/app/create/worksheets/components/WorksheetConfigContext'
|
||||||
import type { WorksheetFormState } from '@/app/create/worksheets/types'
|
import type { WorksheetFormState } from '@/app/create/worksheets/types'
|
||||||
|
import { PageWithNav } from '@/components/PageWithNav'
|
||||||
|
import { useTheme } from '@/contexts/ThemeContext'
|
||||||
|
|
||||||
interface ShareData {
|
interface ShareData {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -19,11 +25,17 @@ export default function SharedWorksheetPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const shareId = params.id as string
|
const shareId = params.id as string
|
||||||
|
const { resolvedTheme } = useTheme()
|
||||||
|
const isDark = resolvedTheme === 'dark'
|
||||||
|
|
||||||
const [shareData, setShareData] = useState<ShareData | null>(null)
|
const [shareData, setShareData] = useState<ShareData | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [previewError, setPreviewError] = useState<{ error: string; details?: string } | null>(null)
|
||||||
|
const [preview, setPreview] = useState<string[] | undefined>(undefined)
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false)
|
||||||
|
|
||||||
|
// Fetch shared worksheet data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchShare = async () => {
|
const fetchShare = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -40,6 +52,39 @@ export default function SharedWorksheetPage() {
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setShareData(data)
|
setShareData(data)
|
||||||
|
|
||||||
|
// Fetch preview from API
|
||||||
|
try {
|
||||||
|
const previewResponse = await fetch('/api/worksheets/preview', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ config: data.config }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (previewResponse.ok) {
|
||||||
|
const previewData = await previewResponse.json()
|
||||||
|
if (previewData.success) {
|
||||||
|
setPreview(previewData.pages)
|
||||||
|
} else {
|
||||||
|
// Preview generation failed - store error details
|
||||||
|
setPreviewError({
|
||||||
|
error: previewData.error || 'Failed to generate preview',
|
||||||
|
details: previewData.details,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setPreviewError({
|
||||||
|
error: 'Preview generation failed',
|
||||||
|
details: `HTTP ${previewResponse.status}: ${previewResponse.statusText}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to generate preview:', err)
|
||||||
|
setPreviewError({
|
||||||
|
error: 'Failed to generate preview',
|
||||||
|
details: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching shared worksheet:', err)
|
console.error('Error fetching shared worksheet:', err)
|
||||||
setError('Failed to load shared worksheet')
|
setError('Failed to load shared worksheet')
|
||||||
|
|
@ -51,14 +96,53 @@ export default function SharedWorksheetPage() {
|
||||||
fetchShare()
|
fetchShare()
|
||||||
}, [shareId])
|
}, [shareId])
|
||||||
|
|
||||||
const handleOpenInEditor = () => {
|
const handleOpenInEditor = async () => {
|
||||||
if (!shareData) return
|
if (!shareData) return
|
||||||
|
|
||||||
// Navigate to the worksheet creator with the shared config
|
// Save shared config to user's session (overwrites current settings)
|
||||||
// Store config in sessionStorage so it can be loaded by the editor
|
try {
|
||||||
sessionStorage.setItem('sharedWorksheetConfig', JSON.stringify(shareData.config))
|
await fetch('/api/worksheets/settings', {
|
||||||
router.push('/create/worksheets?from=share')
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: 'addition',
|
||||||
|
config: shareData.config,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Navigate to editor
|
||||||
|
router.push('/create/worksheets')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save config:', err)
|
||||||
|
// TODO: Show error toast
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.600' : 'gray.300',
|
||||||
|
rounded: 'full',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -145,102 +229,282 @@ export default function SharedWorksheetPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<PageWithNav>
|
||||||
className={css({
|
<WorksheetConfigProvider
|
||||||
minH: '100vh',
|
formState={shareData.config}
|
||||||
bg: 'gray.50',
|
updateFormState={() => {}} // No-op for read-only
|
||||||
p: '8',
|
isReadOnly={true}
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
data-component="shared-worksheet-studio"
|
||||||
className={css({
|
className={css({
|
||||||
maxW: '4xl',
|
position: 'fixed',
|
||||||
mx: 'auto',
|
inset: 0,
|
||||||
})}
|
top: '16', // Account for nav bar
|
||||||
>
|
|
||||||
<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',
|
display: 'flex',
|
||||||
gap: '4',
|
flexDirection: 'column',
|
||||||
fontSize: 'sm',
|
bg: isDark ? 'gray.900' : 'gray.50',
|
||||||
color: 'gray.500',
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<span>Type: {shareData.worksheetType}</span>
|
{/* Read-only banner */}
|
||||||
<span>•</span>
|
<div
|
||||||
<span>Views: {shareData.views}</span>
|
data-component="shared-mode-banner"
|
||||||
<span>•</span>
|
className={css({
|
||||||
<span>Created: {new Date(shareData.createdAt).toLocaleDateString()}</span>
|
bg: 'blue.600',
|
||||||
|
color: 'white',
|
||||||
|
px: '6',
|
||||||
|
py: '3',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '4',
|
||||||
|
shadow: 'md',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className={css({ display: 'flex', alignItems: 'center', gap: '3' })}>
|
||||||
|
<span className={css({ fontSize: 'xl' })}>🔗</span>
|
||||||
|
<div>
|
||||||
|
<div className={css({ fontWeight: 'bold', fontSize: 'md' })}>
|
||||||
|
Shared Worksheet (Read-Only)
|
||||||
|
</div>
|
||||||
|
<div className={css({ fontSize: 'sm', opacity: '0.9' })}>
|
||||||
|
{shareData.title || `Shared by someone • ${shareData.views} views`}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Configuration Summary */}
|
<button
|
||||||
<div
|
data-action="open-edit-modal"
|
||||||
|
onClick={() => setShowEditModal(true)}
|
||||||
className={css({
|
className={css({
|
||||||
bg: 'gray.50',
|
px: '4',
|
||||||
|
py: '2',
|
||||||
|
bg: 'white',
|
||||||
|
color: 'blue.600',
|
||||||
|
fontSize: 'sm',
|
||||||
|
fontWeight: 'bold',
|
||||||
rounded: 'lg',
|
rounded: 'lg',
|
||||||
p: '6',
|
cursor: 'pointer',
|
||||||
border: '2px solid',
|
transition: 'all 0.2s',
|
||||||
borderColor: 'gray.200',
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '2',
|
||||||
|
_hover: {
|
||||||
|
bg: 'blue.50',
|
||||||
|
transform: 'translateY(-1px)',
|
||||||
|
shadow: 'md',
|
||||||
|
},
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<h2
|
<span>✏️</span>
|
||||||
|
<span>Edit This Worksheet</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Worksheet studio layout */}
|
||||||
|
<PanelGroup direction="horizontal" className={css({ flex: '1', overflow: 'hidden' })}>
|
||||||
|
{/* Left sidebar - Config controls (read-only) */}
|
||||||
|
<Panel
|
||||||
|
defaultSize={25}
|
||||||
|
minSize={20}
|
||||||
|
maxSize={35}
|
||||||
|
className={css({
|
||||||
|
overflow: 'auto',
|
||||||
|
p: '4',
|
||||||
|
bg: isDark ? 'gray.800' : 'white',
|
||||||
|
borderRight: '1px solid',
|
||||||
|
borderColor: isDark ? 'gray.700' : 'gray.200',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ConfigSidebar isReadOnly={true} />
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<PanelResizeHandle className={resizeHandleStyles} />
|
||||||
|
|
||||||
|
{/* Center - Preview */}
|
||||||
|
<Panel defaultSize={75} minSize={50} className={css({ overflow: 'hidden' })}>
|
||||||
|
{previewError ? (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
h: 'full',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
p: '8',
|
||||||
|
bg: isDark ? 'gray.900' : 'gray.50',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
maxW: '2xl',
|
||||||
|
bg: isDark ? 'gray.800' : 'white',
|
||||||
|
border: '2px solid',
|
||||||
|
borderColor: 'red.500',
|
||||||
|
rounded: 'xl',
|
||||||
|
p: '6',
|
||||||
|
shadow: 'xl',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className={stack({ gap: '4' })}>
|
||||||
|
<div className={css({ display: 'flex', alignItems: 'center', gap: '3' })}>
|
||||||
|
<span className={css({ fontSize: '3xl' })}>⚠️</span>
|
||||||
|
<h3
|
||||||
className={css({
|
className={css({
|
||||||
fontSize: 'xl',
|
fontSize: 'xl',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
color: 'gray.900',
|
color: 'red.600',
|
||||||
mb: '4',
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
Configuration
|
Preview Generation Failed
|
||||||
</h2>
|
</h3>
|
||||||
<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
|
||||||
|
className={css({
|
||||||
|
p: '4',
|
||||||
|
bg: isDark ? 'gray.900' : 'red.50',
|
||||||
|
rounded: 'lg',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: isDark ? 'gray.700' : 'red.200',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
fontSize: 'md',
|
||||||
|
fontWeight: 'semibold',
|
||||||
|
color: isDark ? 'red.400' : 'red.700',
|
||||||
|
mb: '2',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{previewError.error}
|
||||||
|
</div>
|
||||||
|
{previewError.details && (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
fontSize: 'sm',
|
||||||
|
color: isDark ? 'gray.400' : 'gray.700',
|
||||||
|
fontFamily: 'mono',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{previewError.details}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
fontSize: 'sm',
|
||||||
|
color: isDark ? 'gray.400' : 'gray.600',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<strong>What this means:</strong> This shared worksheet configuration cannot
|
||||||
|
be previewed because it's missing required data. This may happen if the
|
||||||
|
worksheet was shared before certain features were added to the system.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PreviewCenter
|
||||||
|
formState={shareData.config}
|
||||||
|
initialPreview={preview}
|
||||||
|
onGenerate={async () => {}} // No-op for read-only
|
||||||
|
status="idle"
|
||||||
|
isReadOnly={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</PanelGroup>
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
{showEditModal && (
|
||||||
|
<div
|
||||||
|
data-component="edit-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={() => setShowEditModal(false)}
|
||||||
|
>
|
||||||
|
<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' })}>
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
className={css({
|
||||||
|
fontSize: '2xl',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: isDark ? 'gray.100' : 'gray.900',
|
||||||
|
mb: '2',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Edit This Worksheet?
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className={css({
|
||||||
|
fontSize: 'md',
|
||||||
|
color: isDark ? 'gray.400' : 'gray.600',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Opening this worksheet in the editor will{' '}
|
||||||
|
<strong className={css({ color: isDark ? 'yellow.400' : 'orange.600' })}>
|
||||||
|
overwrite your current worksheet settings
|
||||||
|
</strong>
|
||||||
|
. Your current configuration will be replaced with this shared worksheet's
|
||||||
|
settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
bg: isDark ? 'gray.700' : 'gray.50',
|
||||||
|
p: '4',
|
||||||
|
rounded: 'lg',
|
||||||
|
border: '2px solid',
|
||||||
|
borderColor: isDark ? 'gray.600' : 'gray.200',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css({ fontSize: 'sm', color: isDark ? 'gray.300' : 'gray.700' })}
|
||||||
|
>
|
||||||
|
<strong>💡 Tip:</strong> If you want to keep your current settings, you can:
|
||||||
|
<ul className={css({ mt: '2', ml: '4', listStyle: 'disc' })}>
|
||||||
|
<li>Download your current worksheet first</li>
|
||||||
|
<li>Open this shared worksheet in a different browser/window</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: '4',
|
gap: '3',
|
||||||
flexWrap: 'wrap',
|
flexDirection: 'row-reverse',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
data-action="confirm-edit"
|
||||||
onClick={handleOpenInEditor}
|
onClick={handleOpenInEditor}
|
||||||
className={css({
|
className={css({
|
||||||
flex: '1',
|
|
||||||
px: '6',
|
px: '6',
|
||||||
py: '4',
|
py: '3',
|
||||||
bg: 'brand.600',
|
bg: 'brand.600',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
fontSize: 'md',
|
fontSize: 'md',
|
||||||
|
|
@ -248,34 +512,40 @@ export default function SharedWorksheetPage() {
|
||||||
rounded: 'lg',
|
rounded: 'lg',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: '2',
|
|
||||||
_hover: {
|
_hover: {
|
||||||
bg: 'brand.700',
|
bg: 'brand.700',
|
||||||
transform: 'translateY(-1px)',
|
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<span>✏️</span>
|
Replace My Settings & Edit
|
||||||
<span>Open in Editor</span>
|
</button>
|
||||||
|
<button
|
||||||
|
data-action="cancel-edit"
|
||||||
|
onClick={() => setShowEditModal(false)}
|
||||||
|
className={css({
|
||||||
|
px: '6',
|
||||||
|
py: '3',
|
||||||
|
bg: isDark ? 'gray.700' : 'gray.200',
|
||||||
|
color: isDark ? 'gray.300' : 'gray.700',
|
||||||
|
fontSize: 'md',
|
||||||
|
fontWeight: 'medium',
|
||||||
|
rounded: 'lg',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
_hover: {
|
||||||
|
bg: isDark ? 'gray.600' : 'gray.300',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
</WorksheetConfigProvider>
|
||||||
}
|
</PageWithNav>
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue