feat(worksheets): implement auto-save and load for worksheet settings
API Routes: - GET /api/worksheets/settings?type=addition - Load user's saved config - POST /api/worksheets/settings - Save current config (upsert) - Uses parseAdditionConfig() for validation and migration - Returns defaults if no saved settings exist UI Integration: - Load saved settings on mount (preserves derived state) - Auto-save settings 1 second after changes (debounced) - Only save after initial load completes (prevents overwriting on mount) - Exclude transient fields (date, seed, rows, total) from persistence - Show "Saving settings..." and "✓ Settings saved at HH:MM:SS" indicator User Experience: - Settings persist across sessions automatically - New seed generated on each page load (fresh problems) - Date field always starts empty (filled at generation time) - Derived state (rows, total) recalculated from primary state Technical Details: - Type-safe with Zod validation - Automatic schema migration (v1 → v2 when we add fields) - Graceful fallback to defaults on errors - 1s debounce prevents excessive saves during rapid changes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
42e1a71292
commit
186fa81b08
|
|
@ -0,0 +1,155 @@
|
|||
import { eq, and } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import {
|
||||
parseAdditionConfig,
|
||||
serializeAdditionConfig,
|
||||
defaultAdditionConfig,
|
||||
type AdditionConfigV1,
|
||||
} from '@/app/create/worksheets/config-schemas'
|
||||
|
||||
/**
|
||||
* GET /api/worksheets/settings?type=addition
|
||||
* Load user's saved worksheet settings
|
||||
*
|
||||
* Query params:
|
||||
* - type: 'addition' | 'subtraction' | etc.
|
||||
*
|
||||
* Returns:
|
||||
* - config: Parsed and validated config (latest version)
|
||||
* - exists: boolean (true if user has saved settings)
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const viewerId = await getViewerId()
|
||||
const { searchParams } = new URL(req.url)
|
||||
const worksheetType = searchParams.get('type')
|
||||
|
||||
if (!worksheetType) {
|
||||
return NextResponse.json({ error: 'Missing type parameter' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Only 'addition' is supported for now
|
||||
if (worksheetType !== 'addition') {
|
||||
return NextResponse.json({ error: `Unsupported worksheet type: ${worksheetType}` }, { status: 400 })
|
||||
}
|
||||
|
||||
// Look up user's saved settings
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(schema.worksheetSettings)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.worksheetSettings.userId, viewerId),
|
||||
eq(schema.worksheetSettings.worksheetType, worksheetType)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!row) {
|
||||
// No saved settings, return defaults
|
||||
return NextResponse.json({
|
||||
config: defaultAdditionConfig,
|
||||
exists: false,
|
||||
})
|
||||
}
|
||||
|
||||
// Parse and validate config (auto-migrates to latest version)
|
||||
const config = parseAdditionConfig(row.config)
|
||||
|
||||
return NextResponse.json({
|
||||
config,
|
||||
exists: true,
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load worksheet settings:', error)
|
||||
return NextResponse.json({ error: 'Failed to load worksheet settings' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/worksheets/settings
|
||||
* Save user's worksheet settings
|
||||
*
|
||||
* Body:
|
||||
* - type: 'addition' | 'subtraction' | etc.
|
||||
* - config: Config object (version will be added automatically)
|
||||
*
|
||||
* Returns:
|
||||
* - success: boolean
|
||||
* - id: string (worksheet_settings row id)
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
const { type: worksheetType, config } = body
|
||||
|
||||
if (!worksheetType) {
|
||||
return NextResponse.json({ error: 'Missing type field' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return NextResponse.json({ error: 'Missing config field' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Only 'addition' is supported for now
|
||||
if (worksheetType !== 'addition') {
|
||||
return NextResponse.json({ error: `Unsupported worksheet type: ${worksheetType}` }, { status: 400 })
|
||||
}
|
||||
|
||||
// Serialize config (adds version automatically)
|
||||
const configJson = serializeAdditionConfig(config)
|
||||
|
||||
// Check if user already has settings for this type
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(schema.worksheetSettings)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.worksheetSettings.userId, viewerId),
|
||||
eq(schema.worksheetSettings.worksheetType, worksheetType)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const now = new Date()
|
||||
|
||||
if (existing) {
|
||||
// Update existing row
|
||||
await db
|
||||
.update(schema.worksheetSettings)
|
||||
.set({
|
||||
config: configJson,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(schema.worksheetSettings.id, existing.id))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
id: existing.id,
|
||||
})
|
||||
} else {
|
||||
// Insert new row
|
||||
const id = crypto.randomUUID()
|
||||
await db.insert(schema.worksheetSettings).values({
|
||||
id,
|
||||
userId: viewerId,
|
||||
worksheetType,
|
||||
config: configJson,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
id,
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to save worksheet settings:', error)
|
||||
return NextResponse.json({ error: 'Failed to save worksheet settings' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,9 @@ export default function AdditionWorksheetPage() {
|
|||
const t = useTranslations('create.worksheets.addition')
|
||||
const [generationStatus, setGenerationStatus] = useState<GenerationStatus>('idle')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [settingsLoaded, setSettingsLoaded] = useState(false)
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
// Immediate form state (for controls - updates instantly)
|
||||
// PRIMARY state: problemsPerPage, cols, pages (what user controls)
|
||||
|
|
@ -70,6 +73,104 @@ export default function AdditionWorksheetPage() {
|
|||
return () => clearTimeout(timer)
|
||||
}, [formState])
|
||||
|
||||
// Load saved settings on mount
|
||||
useEffect(() => {
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const response = await fetch('/api/worksheets/settings?type=addition')
|
||||
if (!response.ok) throw new Error('Failed to load settings')
|
||||
|
||||
const data = await response.json()
|
||||
if (data.exists && data.config) {
|
||||
// Load saved config, but preserve derived state
|
||||
const rows = Math.ceil((data.config.problemsPerPage * data.config.pages) / data.config.cols)
|
||||
const total = data.config.problemsPerPage * data.config.pages
|
||||
|
||||
setFormState({
|
||||
...data.config,
|
||||
rows,
|
||||
total,
|
||||
date: '', // Always start with empty date
|
||||
seed: Date.now() % 2147483647, // Generate new seed
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load worksheet settings:', error)
|
||||
// Continue with defaults
|
||||
} finally {
|
||||
setSettingsLoaded(true)
|
||||
}
|
||||
}
|
||||
|
||||
loadSettings()
|
||||
}, [])
|
||||
|
||||
// Auto-save settings when they change (debounced, only after initial load)
|
||||
useEffect(() => {
|
||||
if (!settingsLoaded) return // Don't save until we've loaded initial settings
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
// Extract only the fields we want to persist (exclude date, seed, derived state)
|
||||
const {
|
||||
problemsPerPage,
|
||||
cols,
|
||||
pages,
|
||||
orientation,
|
||||
name,
|
||||
pAnyStart,
|
||||
pAllStart,
|
||||
interpolate,
|
||||
showCarryBoxes,
|
||||
showAnswerBoxes,
|
||||
showPlaceValueColors,
|
||||
showProblemNumbers,
|
||||
showCellBorder,
|
||||
showTenFrames,
|
||||
showTenFramesForAll,
|
||||
fontSize,
|
||||
} = formState
|
||||
|
||||
const response = await fetch('/api/worksheets/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'addition',
|
||||
config: {
|
||||
problemsPerPage,
|
||||
cols,
|
||||
pages,
|
||||
orientation,
|
||||
name,
|
||||
pAnyStart,
|
||||
pAllStart,
|
||||
interpolate,
|
||||
showCarryBoxes,
|
||||
showAnswerBoxes,
|
||||
showPlaceValueColors,
|
||||
showProblemNumbers,
|
||||
showCellBorder,
|
||||
showTenFrames,
|
||||
showTenFramesForAll,
|
||||
fontSize,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
setLastSaved(new Date())
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save worksheet settings:', error)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}, 1000) // 1 second debounce for auto-save
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [formState, settingsLoaded])
|
||||
|
||||
const handleFormChange = (updates: Partial<WorksheetFormState>) => {
|
||||
setFormState((prev) => {
|
||||
const newState = { ...prev, ...updates }
|
||||
|
|
@ -192,16 +293,39 @@ export default function AdditionWorksheetPage() {
|
|||
})}
|
||||
>
|
||||
{/* Configuration Panel */}
|
||||
<div
|
||||
data-section="config-panel"
|
||||
className={css({
|
||||
bg: 'white',
|
||||
rounded: '2xl',
|
||||
shadow: 'card',
|
||||
p: '8',
|
||||
})}
|
||||
>
|
||||
<ConfigPanel formState={formState} onChange={handleFormChange} />
|
||||
<div className={stack({ gap: '3' })}>
|
||||
<div
|
||||
data-section="config-panel"
|
||||
className={css({
|
||||
bg: 'white',
|
||||
rounded: '2xl',
|
||||
shadow: 'card',
|
||||
p: '8',
|
||||
})}
|
||||
>
|
||||
<ConfigPanel formState={formState} onChange={handleFormChange} />
|
||||
</div>
|
||||
|
||||
{/* Settings saved indicator */}
|
||||
{settingsLoaded && (
|
||||
<div
|
||||
data-element="settings-status"
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
textAlign: 'center',
|
||||
py: '2',
|
||||
})}
|
||||
>
|
||||
{isSaving ? (
|
||||
<span className={css({ color: 'gray.500' })}>Saving settings...</span>
|
||||
) : lastSaved ? (
|
||||
<span className={css({ color: 'green.600' })}>
|
||||
✓ Settings saved at {lastSaved.toLocaleTimeString()}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview & Generate Panel */}
|
||||
|
|
|
|||
Loading…
Reference in New Issue