diff --git a/app/components/CodeEditor/CodeEditor.tsx b/app/components/CodeEditor/CodeEditor.tsx index 32870c3..95d5b7b 100644 --- a/app/components/CodeEditor/CodeEditor.tsx +++ b/app/components/CodeEditor/CodeEditor.tsx @@ -5,94 +5,22 @@ import ctx from "classnames"; import { GeistMono } from "geist/font/mono"; import Editor, { Monaco } from "@monaco-editor/react"; import { Flex, useColorMode } from "@chakra-ui/react"; -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState, useRef, useCallback } from "react"; import MyBtn from "../MyBtn"; import { tryFormattingCode, validateCode } from "@/lib/client-functions"; import FiChevronRight from "@/app/styles/icons/HiChevronRightGreen"; import { useRouter } from "next/navigation"; -import { useUserSolutionStore, useEditorStore } from "@/lib/stores"; +import { useEditorStore } from "@/lib/stores"; import { sendGAEvent } from "@next/third-parties/google"; import { CodeFile, OutputResult } from "@/lib/types"; import { OutputReducerAction } from "@/lib/reducers"; import CertificateButton from "../CertificateButton/CertificateButton"; - -// Custom hook for editor theme setup -const useEditorTheme = (monaco: Monaco, colorMode: "dark" | "light") => { - useEffect(() => { - if (monaco) { - monaco.editor.defineTheme("my-theme", { - base: "vs-dark", - inherit: true, - rules: [], - colors: { - "editor.background": "#1f1f1f", - }, - }); - monaco.editor.setTheme(colorMode === "light" ? "light" : "my-theme"); - } - }, [monaco, colorMode]); -}; - -// Custom hook for keyboard shortcuts -const useValidationShortcut = ( - handleValidate: () => void, - codeString: string, -) => { - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Enter" && event.shiftKey) { - sendGAEvent("event", "buttonClicked", { - value: "Validate (through shortcut)", - }); - event.preventDefault(); - handleValidate(); - } - }; - - document.addEventListener("keydown", handleKeyDown); - return () => { - document.removeEventListener("keydown", handleKeyDown); - }; - }, [handleValidate, codeString]); -}; - -// Custom hook for code persistence -const useCodePersistence = ( - chapterIndex: number, - stepIndex: number, - codeString: string, - setCodeString: (value: string) => void, - codeFile: CodeFile, -) => { - const userSolutionStore = useUserSolutionStore(); - - // Load saved code - useEffect(() => { - const savedCode = userSolutionStore.getSavedUserSolutionByLesson( - chapterIndex, - stepIndex, - ); - if (savedCode && savedCode !== codeString) { - setCodeString(savedCode); - } - }, [chapterIndex, stepIndex]); - - // Save code changes - useEffect(() => { - userSolutionStore.saveUserSolutionForLesson( - chapterIndex, - stepIndex, - codeString, - ); - }, [codeString, chapterIndex, stepIndex]); - - // Initialize code if no saved solutions - useEffect(() => { - if (Object.keys(userSolutionStore.userSolutionsByLesson).length === 0) { - setCodeString(JSON.stringify(codeFile.code, null, 2)); - } - }, [userSolutionStore]); -}; +import { + useEditorTheme, + useValidationShortcut, + useCodePersistence, + useValidationRestore, +} from "@/app/utils/hooks"; // EditorControls component for the buttons section const EditorControls = ({ @@ -179,7 +107,7 @@ export default function CodeEditor({ // Apply custom hooks useEditorTheme(monaco, colorMode); - const handleValidate = () => { + const handleValidate = useCallback(() => { setIsValidating(true); setTimeout(() => { tryFormattingCode(editorRef, setCodeString); @@ -192,7 +120,7 @@ export default function CodeEditor({ ); setIsValidating(false); }, 500); - }; + }, [codeString, codeFile, dispatchOutput, stepIndex, chapterIndex, setCodeString]); useValidationShortcut(handleValidate, codeString); useCodePersistence( @@ -203,21 +131,46 @@ export default function CodeEditor({ codeFile, ); + // Restore previous validation on lesson revisit + const { isRestored } = useValidationRestore( + chapterIndex, + stepIndex, + dispatchOutput, + setCodeString, + ); + + // Reset code to initial state const resetCode = () => { setCodeString(JSON.stringify(codeFile.code, null, 2)); dispatchOutput({ type: "RESET" }); }; - const handleEditorMount = (editor: any, monaco: Monaco) => { - setMonaco(monaco); + const handleEditorMount = (editor: any, monacoInstance: Monaco) => { + setMonaco(monacoInstance); editorRef.current = editor; editorStore.setEditor(editor); - editorStore.setMonaco(monaco); + editorStore.setMonaco(monacoInstance); }; return ( <> + {/* Show success banner when previous validation is restored */} + {isRestored && ( +
+ ✅ Previous submission restored +
+ )} +
{ + useEffect(() => { + if (monaco) { + monaco.editor.defineTheme("my-theme", { + base: "vs-dark", + inherit: true, + rules: [], + colors: { + "editor.background": "#1f1f1f", + }, + }); + monaco.editor.setTheme(colorMode === "light" ? "light" : "my-theme"); + } + }, [monaco, colorMode]); +}; + +/** + * Hook to handle keyboard shortcuts for validation + * Triggers validation when Shift+Enter is pressed + */ +export const useValidationShortcut = ( + handleValidate: () => void, + codeString: string, +) => { + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter" && event.shiftKey) { + sendGAEvent("event", "buttonClicked", { + value: "Validate (through shortcut)", + }); + event.preventDefault(); + handleValidate(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [handleValidate, codeString]); +}; + +/** + * Hook to persist user code in localStorage across sessions + * Loads saved code on mount and saves changes automatically + */ +export const useCodePersistence = ( + chapterIndex: number, + stepIndex: number, + codeString: string, + setCodeString: (value: string) => void, + codeFile: CodeFile, +) => { + const userSolutionStore = useUserSolutionStore(); + + // Load saved code on mount or lesson change + useEffect(() => { + const savedCode = userSolutionStore.getSavedUserSolutionByLesson( + chapterIndex, + stepIndex, + ); + if (savedCode && savedCode !== codeString) { + setCodeString(savedCode); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chapterIndex, stepIndex]); + + // Save code changes to localStorage + useEffect(() => { + userSolutionStore.saveUserSolutionForLesson( + chapterIndex, + stepIndex, + codeString, + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [codeString, chapterIndex, stepIndex]); + + // Initialize with default code if no saved solutions exist + useEffect(() => { + if (Object.keys(userSolutionStore.userSolutionsByLesson).length === 0) { + setCodeString(JSON.stringify(codeFile.code, null, 2)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userSolutionStore]); +}; + +/** + * Hook to restore previous validation results when revisiting a lesson + * Automatically loads and displays saved validation state from localStorage + * Returns isRestored flag to show restoration status to user + */ +export const useValidationRestore = ( + chapterIndex: number, + stepIndex: number, + dispatchOutput: React.Dispatch, + setCodeString: (value: string) => void, +) => { + const [isRestored, setIsRestored] = useState(false); + + useEffect(() => { + // Check if previous validation exists before restoring + if (!isRestored && hasValidationResult(chapterIndex, stepIndex)) { + try { + const { restored } = restorePreviousValidation( + chapterIndex, + stepIndex, + dispatchOutput, + setCodeString, + ); + if (restored) { + setIsRestored(true); + console.log( + "✅ Previous validation restored for lesson:", + chapterIndex, + stepIndex, + ); + } + } catch (error) { + console.error("Failed to restore validation:", error); + } + } + }, [chapterIndex, stepIndex, isRestored, dispatchOutput, setCodeString]); + + return { isRestored }; +}; diff --git a/lib/client-functions.ts b/lib/client-functions.ts index b018af4..26143ee 100644 --- a/lib/client-functions.ts +++ b/lib/client-functions.ts @@ -8,6 +8,11 @@ import { CodeFile, TestCaseResult } from "./types"; import { hyperjumpCheckAnnotations, hyperjumpValidate } from "./validators"; import { sendGAEvent } from "@next/third-parties/google"; import { contentManager } from "./contentManager"; +import { + saveValidationResult, + getValidationResult, + hasValidationResult, +} from "./progressSaving"; export async function validateCode( codeString: string, @@ -53,17 +58,30 @@ export async function validateCode( }); } } + if (codeFile.expectedAnnotations) { await hyperjumpCheckAnnotations(schemaCode, codeFile.expectedAnnotations); } + // Sort results: failed tests first, passed tests last + const sortedResults = testCaseResults.sort((a, b) => { + if (a.passed === b.passed) { + return 0; + } + return a.passed ? 1 : -1; + }); + + // Persist validation results to localStorage for restoration on revisit + saveValidationResult( + chapterIndex, + stepIndex, + codeString, + sortedResults, + totalTestCases, + validationStatus, + ); + if (validationStatus === "valid") { - const sortedResults = testCaseResults.sort((a, b) => { - if (a.passed === b.passed) { - return 0; // If both are the same, their order doesn't change - } - return a.passed ? 1 : -1; // If a.passed is true, put a after b; if false, put a before b - }); dispatchOutput({ type: "valid", payload: { testCaseResults: sortedResults, totalTestCases }, @@ -72,13 +90,7 @@ export async function validateCode( sendGAEvent("event", "validation", { validation_result: "passed", }); - } else { - const sortedResults = testCaseResults.sort((a, b) => { - if (a.passed === b.passed) { - return 0; // If both are the same, their order doesn't change - } - return a.passed ? 1 : -1; // If a.passed is true, put a after b; if false, put a before b - }); + } else { dispatchOutput({ type: "invalid", payload: { testCaseResults: sortedResults, totalTestCases }, @@ -88,6 +100,9 @@ export async function validateCode( }); } } catch (e) { + // Persist error state for restoration on revisit + saveValidationResult(chapterIndex, stepIndex, codeString, [], 0, "invalid"); + if ((e as Error).message === "Invalid Schema") { dispatchOutput({ type: "invalidSchema", @@ -193,3 +208,49 @@ export async function tryFormattingCode( return; } } + +/** + * Restore previous validation results when revisiting a lesson + * Automatically restores both code and validation state from localStorage + */ +export function restorePreviousValidation( + chapterIndex: number, + stepIndex: number, + dispatchOutput: React.Dispatch, + setCodeString?: (code: string) => void +): { restored: boolean; code?: string } { + if (typeof window === "undefined") return { restored: false }; + + const validationResult = getValidationResult(chapterIndex, stepIndex); + + if (validationResult) { + // Restore user's submitted code + if (setCodeString) { + setCodeString(validationResult.code); + } + + // Restore validation output state + if (validationResult.validationStatus === "valid") { + dispatchOutput({ + type: "valid", + payload: { + testCaseResults: validationResult.testCaseResults, + totalTestCases: validationResult.totalTestCases + }, + }); + } else if (validationResult.validationStatus === "invalid") { + dispatchOutput({ + type: "invalid", + payload: { + testCaseResults: validationResult.testCaseResults, + totalTestCases: validationResult.totalTestCases + }, + }); + } + + return { restored: true, code: validationResult.code }; + } + + return { restored: false }; +} +export { hasValidationResult } from "./progressSaving"; \ No newline at end of file diff --git a/lib/progressSaving.ts b/lib/progressSaving.ts index 6f139a7..7e08394 100644 --- a/lib/progressSaving.ts +++ b/lib/progressSaving.ts @@ -1,3 +1,89 @@ +// Stores validation results for a specific lesson in localStorage +interface ValidationResult { + code: string; + testCaseResults: any[]; + totalTestCases: number; + validationStatus: "valid" | "invalid" | "neutral"; + timestamp: number; + chapterIndex: number; + stepIndex: number; +} + +/** + * Save validation results to localStorage for a specific lesson + * This allows restoring validation state when user revisits the lesson + */ +export function saveValidationResult( + chapterIndex: number, + stepIndex: number, + code: string, + testCaseResults: any[], + totalTestCases: number, + validationStatus: "valid" | "invalid" | "neutral" +): boolean { + if (typeof window === "undefined") return false; + + const key = `validation-${chapterIndex}-${stepIndex}`; + const validationData: ValidationResult = { + code, + testCaseResults, + totalTestCases, + validationStatus, + timestamp: Date.now(), + chapterIndex, + stepIndex + }; + + try { + localStorage.setItem(key, JSON.stringify(validationData)); + return true; + } catch (error) { + console.warn('Failed to save validation result:', error); + return false; + } +} + +/** + * Retrieve saved validation results for a specific lesson + */ +export function getValidationResult(chapterIndex: number, stepIndex: number): ValidationResult | null { + if (typeof window === "undefined") return null; + + const key = `validation-${chapterIndex}-${stepIndex}`; + const stored = localStorage.getItem(key); + + if (stored) { + try { + return JSON.parse(stored); + } catch (error) { + console.warn('Failed to parse validation result:', error); + return null; + } + } + return null; +} + +/** + * Check if a validation result exists for a specific lesson + */ +export function hasValidationResult(chapterIndex: number, stepIndex: number): boolean { + if (typeof window === "undefined") return false; + + const key = `validation-${chapterIndex}-${stepIndex}`; + return localStorage.getItem(key) !== null; +} + +/** + * Clear saved validation result for a specific lesson + */ +export function clearValidationResult(chapterIndex: number, stepIndex: number): boolean { + if (typeof window === "undefined") return false; + + const key = `validation-${chapterIndex}-${stepIndex}`; + localStorage.removeItem(key); + return true; +} + export function setCheckpoint(path: string) { if (typeof window === "undefined") return false; const checkpoint = path;