diff --git a/.DS_Store b/.DS_Store index 0f2cc13..6927c5d 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index cc88487..7fa82dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules +.DS_Store .cursorignore \ No newline at end of file diff --git a/gradesa/src/app/api/admin/exercises/free-form/route.js b/gradesa/src/app/api/admin/exercises/free-form/route.js index 83cc820..d76b7cd 100644 --- a/gradesa/src/app/api/admin/exercises/free-form/route.js +++ b/gradesa/src/app/api/admin/exercises/free-form/route.js @@ -1,4 +1,3 @@ -import { z } from "zod"; import { withAuth } from "@/backend/middleware/withAuth"; import { withInputValidation } from "@/backend/middleware/withInputValidation"; import { DB } from "@/backend/db"; diff --git a/gradesa/src/app/api/exercises/freeform/[exercise_id]/route.js b/gradesa/src/app/api/exercises/freeform/[exercise_id]/route.js new file mode 100644 index 0000000..499f556 --- /dev/null +++ b/gradesa/src/app/api/exercises/freeform/[exercise_id]/route.js @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { DB } from "@/backend/db"; +import { withAuth } from "@/backend/middleware/withAuth"; + +export const GET = withAuth(async (request, { params }) => { + try { + const { exercise_id } = await params; + + const { rows } = await DB.pool( + ` + SELECT + ffe.id, + ffe.question, + ffe.exercise_id + FROM + free_form_exercises ffe + WHERE + ffe.id = $1 + `, + [exercise_id] + ); + + if (rows.length === 0) { + return NextResponse.json( + { error: "Exercise not found" }, + { status: 404 } + ); + } + + return NextResponse.json(rows[0]); + } catch (error) { + console.error("Error fetching free form exercise:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +}); diff --git a/gradesa/src/app/api/exercises/freeform/answers/[exercise_id]/route.js b/gradesa/src/app/api/exercises/freeform/answers/[exercise_id]/route.js new file mode 100644 index 0000000..a855eef --- /dev/null +++ b/gradesa/src/app/api/exercises/freeform/answers/[exercise_id]/route.js @@ -0,0 +1,57 @@ +import { NextResponse } from "next/server"; +import { DB } from "@/backend/db"; +import { withAuth } from "@/backend/middleware/withAuth"; + +export const GET = withAuth(async (request, { params }) => { + try { + const { exercise_id } = params; + + const { rows: exerciseRows } = await DB.pool( + `SELECT id FROM free_form_exercises WHERE id = $1`, + [exercise_id] + ); + + if (exerciseRows.length === 0) { + return NextResponse.json( + { error: "Exercise not found" }, + { status: 404 } + ); + } + + const { rows: answerRows } = await DB.pool( + `SELECT + id, + answer, + is_correct, + feedback + FROM free_form_answers + WHERE free_form_exercise_id = $1 + ORDER BY is_correct DESC, id ASC`, + [exercise_id] + ); + + const userId = request.user.id; + const { rows: userAnswerRows } = await DB.pool( + `SELECT + answer, + is_correct, + created_at, + updated_at + FROM free_form_user_answers + WHERE free_form_exercise_id = $1 AND user_id = $2 + ORDER BY updated_at DESC`, + [exercise_id, userId] + ); + + return NextResponse.json({ + possibleAnswers: answerRows, + userAnswers: userAnswerRows, + }); + } catch (error) { + console.error("Error fetching free form exercise answers:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +}); diff --git a/gradesa/src/app/api/exercises/freeform/answers/answer-route.test.js b/gradesa/src/app/api/exercises/freeform/answers/answer-route.test.js new file mode 100644 index 0000000..42741cc --- /dev/null +++ b/gradesa/src/app/api/exercises/freeform/answers/answer-route.test.js @@ -0,0 +1,250 @@ +import { describe, it, expect } from "vitest"; +import { + POST, + normalizeText, + findLongestCommonSubsequence, + calculatePositionSimilarity, +} from "./route"; +import { useTestDatabase } from "@/backend/test/testdb"; +import { useTestRequest } from "@/backend/test/mock-request"; +import { DB } from "@/backend/db"; +import { TestFactory } from "@/backend/test/testfactory"; + +describe("freeform answers", () => { + useTestDatabase(); + + async function setupExercise() { + const exercise = await TestFactory.exercise({}); + const exerciseId = exercise.id; + const freeFormExercise = await TestFactory.freeFormExercise({ + exercise_id: exerciseId, + question: "Test Question", + }); + + // Add possible answers + await TestFactory.freeFormAnswer({ + free_form_exercise_id: freeFormExercise.id, + answer: "This is the correct answer", + is_correct: true, + feedback: "Great job!", + }); + + await TestFactory.freeFormAnswer({ + free_form_exercise_id: freeFormExercise.id, + answer: "This is another correct answer", + is_correct: true, + feedback: "Well done!", + }); + + await TestFactory.freeFormAnswer({ + free_form_exercise_id: freeFormExercise.id, + answer: "This is an incorrect answer", + is_correct: false, + feedback: "Try again", + }); + + return exercise.id; + } + + it("should evaluate exact match answers correctly", async () => { + const user = await TestFactory.user(); + const { mockPost } = useTestRequest(user); + const exerciseId = await setupExercise(); + + const response = await POST( + mockPost("/api/exercises/freeform/answers", { + freeFormExerciseId: exerciseId, + answer: "This is the correct answer", + }) + ); + + expect(response.status).toBe(200); + const result = await response.json(); + + expect(result.is_correct).toBe(true); + expect(result.similarity).toBe(1); + expect(result.perfectAnswer).toBe(true); + expect(result.feedback).toBe("Great job!"); + }); + + it("should evaluate similar but not exact answers", async () => { + const user = await TestFactory.user(); + const { mockPost } = useTestRequest(user); + const exerciseId = await setupExercise(); + + const response = await POST( + mockPost("/api/exercises/freeform/answers", { + freeFormExerciseId: exerciseId, + answer: "This is the correct answer word", + }) + ); + + expect(response.status).toBe(200); + const result = await response.json(); + + expect(result.is_correct).toBe(true); + expect(result.similarity).toBeGreaterThan(0.7); + expect(result.similarity).toBeLessThan(1); + expect(result.perfectAnswer).toBe(false); + expect(result.comparisonDetails).toBeDefined(); + }); + + it("should reject completely incorrect answers", async () => { + const user = await TestFactory.user(); + const { mockPost } = useTestRequest(user); + const exerciseId = await setupExercise(); + + const response = await POST( + mockPost("/api/exercises/freeform/answers", { + freeFormExerciseId: exerciseId, + answer: "Something totally different", + }) + ); + + expect(response.status).toBe(200); + const result = await response.json(); + + expect(result.is_correct).toBe(false); + expect(result.similarity).toBeLessThan(0.7); + }); + + it("should return 400 for missing required fields", async () => { + const user = await TestFactory.user(); + const { mockPost } = useTestRequest(user); + + const response = await POST( + mockPost("/api/exercises/freeform/answers", { + freeFormExerciseId: "some-id", + // missing answer field + }) + ); + + expect(response.status).toBe(422); + }); + + it("should return 404 for non-existent exercise", async () => { + const user = await TestFactory.user(); + const { mockPost } = useTestRequest(user); + + const response = await POST( + mockPost("/api/exercises/freeform/answers", { + freeFormExerciseId: "-1", + answer: "Some answer", + }) + ); + + expect(response.status).toBe(404); + }); + + it("should record user answers in the database", async () => { + const user = await TestFactory.user(); + const { mockPost } = useTestRequest(user); + const exerciseId = await setupExercise(); + + await POST( + mockPost("/api/exercises/freeform/answers", { + freeFormExerciseId: exerciseId, + answer: "This is the correct answer", + }) + ); + + const result = await DB.pool( + `SELECT * FROM free_form_user_answers + WHERE user_id = $1 AND free_form_exercise_id = $2`, + [user.id, exerciseId] + ); + + expect(result.rows.length).toBe(1); + expect(result.rows[0].answer).toBe("This is the correct answer"); + expect(result.rows[0].is_correct).toBe(true); + }); +}); + +describe("text processing utility functions", () => { + describe("normalizeText", () => { + it("should convert text to lowercase", () => { + expect(normalizeText("THIS IS A TEST")).toBe("this is a test"); + }); + + it("should remove punctuation", () => { + expect(normalizeText("Hello, world!")).toBe("hello world"); + expect(normalizeText("test.with.dots")).toBe("test with dots"); + expect(normalizeText("complex-punctuation;:{}=")).toBe( + "complex punctuation" + ); + }); + + it("should trim whitespace", () => { + expect(normalizeText(" leading and trailing spaces ")).toBe( + "leading and trailing spaces" + ); + }); + + it("should normalize multiple spaces", () => { + expect(normalizeText("too many spaces")).toBe("too many spaces"); + }); + }); + + describe("findLongestCommonSubsequence", () => { + it("should find the correct LCS length for identical sequences", () => { + const seq1 = ["a", "b", "c", "d"]; + const seq2 = ["a", "b", "c", "d"]; + expect(findLongestCommonSubsequence(seq1, seq2)).toBe(4); + }); + + it("should return 0 for completely different sequences", () => { + const seq1 = ["a", "b", "c"]; + const seq2 = ["x", "y", "z"]; + expect(findLongestCommonSubsequence(seq1, seq2)).toBe(0); + }); + + it("should find the correct LCS length for partially overlapping sequences", () => { + const seq1 = ["a", "b", "c", "d", "e"]; + const seq2 = ["b", "c", "x", "e"]; + expect(findLongestCommonSubsequence(seq1, seq2)).toBe(3); + }); + + it("should handle empty sequences", () => { + expect(findLongestCommonSubsequence([], ["a", "b"])).toBe(0); + expect(findLongestCommonSubsequence(["a", "b"], [])).toBe(0); + expect(findLongestCommonSubsequence([], [])).toBe(0); + }); + + it("should find LCS with sequences out of order", () => { + const seq1 = ["this", "is", "a", "test"]; + const seq2 = ["a", "test", "this", "is"]; + // The LCS should be ["this", "is"] or ["a", "test"] (both length 2) + expect(findLongestCommonSubsequence(seq1, seq2)).toBe(2); + }); + }); + + describe("calculatePositionSimilarity", () => { + it("should return 1.0 for identical word arrays", () => { + const words1 = ["this", "is", "a", "test"]; + const words2 = ["this", "is", "a", "test"]; + expect(calculatePositionSimilarity(words1, words2)).toBe(1); + }); + + it("should return lower score for same words in different positions", () => { + const words1 = ["this", "is", "a", "test"]; + const words2 = ["test", "a", "is", "this"]; + // Each word is in the wrong position, so similarity should be lower + expect(calculatePositionSimilarity(words1, words2)).toBeLessThan(1); + }); + + it("should handle partially overlapping arrays", () => { + const words1 = ["this", "is", "a", "test"]; + const words2 = ["this", "is", "not", "same"]; + // Only "this" and "is" match and are in the same position + const result = calculatePositionSimilarity(words1, words2); + expect(result).toBeGreaterThan(0); + expect(result).toBeLessThan(1); + }); + + it("should handle empty arrays", () => { + expect(calculatePositionSimilarity([], [])).toBe(1); + expect(calculatePositionSimilarity([], ["test"])).toBe(0); + expect(calculatePositionSimilarity(["test"], [])).toBe(0); + }); + }); +}); diff --git a/gradesa/src/app/api/exercises/freeform/answers/route.js b/gradesa/src/app/api/exercises/freeform/answers/route.js new file mode 100644 index 0000000..fd2327f --- /dev/null +++ b/gradesa/src/app/api/exercises/freeform/answers/route.js @@ -0,0 +1,336 @@ +import { NextResponse } from "next/server"; +import { DB } from "@/backend/db"; +import { withAuth } from "@/backend/middleware/withAuth"; +import { withInputValidation } from "@/backend/middleware/withInputValidation"; +import { z } from "zod"; + +export function normalizeText(text) { + return text + .toLowerCase() + .replace(/[.,\/#!$%\^&\*;:{}=\-_`~()]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +const positionWeight = 0.3; +const sequenceWeight = 0.3; +const jaccardWeight = 0.3; + +export function calculateSimilarity(str1, str2) { + const normalized1 = normalizeText(str1); + const normalized2 = normalizeText(str2); + + if (normalized1 === normalized2) return 1.0; + + const words1 = normalized1.split(" "); + const words2 = normalized2.split(" "); + + const uniqueWords = new Set([...words1, ...words2]); + let matchingWords = 0; + for (const word of words1) { + if (words2.includes(word)) matchingWords++; + } + const jaccardSimilarity = matchingWords / uniqueWords.size; + + const lcsLength = findLongestCommonSubsequence(words1, words2); + const maxLength = Math.max(words1.length, words2.length); + const sequenceSimilarity = maxLength > 0 ? lcsLength / maxLength : 0; + + const positionSimilarity = calculatePositionSimilarity(words1, words2); + + return ( + jaccardWeight * jaccardSimilarity + + sequenceWeight * sequenceSimilarity + + positionWeight * positionSimilarity + ); +} + +export function findLongestCommonSubsequence(sequence1, sequence2) { + const m = sequence1.length; + const n = sequence2.length; + + const dp = Array(m + 1) + .fill() + .map(() => Array(n + 1).fill(0)); + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (sequence1[i - 1] === sequence2[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1; + } else { + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + } + + return dp[m][n]; +} + +export function calculatePositionSimilarity(words1, words2) { + let positionMatchScore = 0; + const maxLength = Math.max(words1.length, words2.length); + + if (maxLength === 0) return 1.0; + + for (let i = 0; i < words1.length; i++) { + const word = words1[i]; + const posInSeq2 = words2.indexOf(word); + + if (posInSeq2 !== -1) { + const posDiff = Math.abs(i - posInSeq2); + const maxPossibleDiff = maxLength - 1; + + const wordPositionScore = + maxPossibleDiff > 0 ? 1 - posDiff / maxPossibleDiff : 1; + + positionMatchScore += wordPositionScore; + } + } + + return words1.length > 0 ? positionMatchScore / words1.length : 0; +} + +export function generateComparisonDetails(studentAnswer, correctAnswer) { + const studentWords = studentAnswer.trim().split(/\s+/); + const correctWords = correctAnswer.trim().split(/\s+/); + + const normalizedStudentWords = studentAnswer + .trim() + .toLowerCase() + .split(/\s+/); + const normalizedCorrectWords = correctAnswer + .trim() + .toLowerCase() + .split(/\s+/); + + const studentWordStatus = studentWords.map((word, index) => { + const normalizedWord = normalizedStudentWords[index]; + + const matchIndex = normalizedCorrectWords.indexOf(normalizedWord); + + if (matchIndex !== -1) { + if (matchIndex === index) { + return { + word, + status: "match", + correctWord: correctWords[matchIndex], + position: "correct", + }; + } else { + return { + word, + status: "match", + correctWord: correctWords[matchIndex], + position: "wrong", + correctPosition: matchIndex, + }; + } + } else { + let bestMatch = null; + let highestSimilarity = 0; + + for (let i = 0; i < normalizedCorrectWords.length; i++) { + const correctWord = normalizedCorrectWords[i]; + const similarity = calculateWordSimilarity(normalizedWord, correctWord); + + if (similarity > 0.7 && similarity > highestSimilarity) { + highestSimilarity = similarity; + bestMatch = correctWords[i]; + } + } + + return { + word, + status: bestMatch ? "similar" : "error", + suggestion: bestMatch || null, + }; + } + }); + + // Find words in correct answer that are missing from student answer + const missingWords = []; + correctWords.forEach((word, index) => { + const normalizedWord = normalizedCorrectWords[index]; + if (!normalizedStudentWords.includes(normalizedWord)) { + missingWords.push({ + word, + status: "missing", + position: index, + }); + } + }); + + // Calculate word order errors + const wordOrderErrors = studentWordStatus.filter( + (word) => word.status === "match" && word.position === "wrong" + ).length; + + return { + studentWords: studentWordStatus, + missingWords: missingWords, + wordOrderErrors, + hasErrors: + studentWordStatus.some( + (w) => + w.status === "error" || + w.status === "similar" || + w.position === "wrong" + ) || missingWords.length > 0, + }; +} + +// Calculate character-level similarity between words +function calculateWordSimilarity(word1, word2) { + // For very short words, be more strict + if (word1.length <= 2 || word2.length <= 2) { + return word1 === word2 ? 1 : 0; + } + + // Simple character overlap + const chars1 = word1.split(""); + const chars2 = word2.split(""); + + const uniqueChars = new Set([...chars1, ...chars2]); + + let matches = 0; + for (const char of chars1) { + if (chars2.includes(char)) matches++; + } + + return matches / uniqueChars.size; +} + +export const POST = withAuth( + withInputValidation( + z.object({ + freeFormExerciseId: z.string(), + answer: z.string(), + }), + async (request) => { + try { + const userId = request.user.id; + + const body = await request.json(); + const { freeFormExerciseId, answer } = body; + + if (!freeFormExerciseId || !answer) { + return NextResponse.json( + { error: "Missing required fields" }, + { status: 400 } + ); + } + + const { rows: possibleAnswers } = await DB.pool( + `SELECT id, answer, is_correct, feedback + FROM free_form_answers + WHERE free_form_exercise_id = $1`, + [freeFormExerciseId] + ); + + if (possibleAnswers.length === 0) { + return NextResponse.json( + { error: "No answers found for this exercise" }, + { status: 404 } + ); + } + + const SIMILARITY_THRESHOLD = 0.7; // Minimum similarity to consider a match + + const similarities = possibleAnswers.map((possibleAnswer) => ({ + ...possibleAnswer, + similarity: calculateSimilarity(answer, possibleAnswer.answer), + })); + + // Sort by similarity (highest first) + similarities.sort((a, b) => b.similarity - a.similarity); + + // Get the most similar answer + const bestMatch = similarities[0]; + + // If similarity is below threshold, consider it not matched + if (bestMatch.similarity < SIMILARITY_THRESHOLD) { + await recordUserAnswer(userId, freeFormExerciseId, answer, false); + + return NextResponse.json({ + is_correct: false, + answer: "", + feedback: + "Your answer doesn't match any of the expected answers. Please try again.", + similarity: bestMatch.similarity, + }); + } + + // Generate comparison details if the match is not perfect + const comparisonDetails = + bestMatch.similarity < 1 + ? generateComparisonDetails(answer, bestMatch.answer) + : null; + + // Answer is correct enough, but may have minor errors + const perfectAnswer = bestMatch.similarity === 1; + + // Allow partially correct answers to be considered correct but with feedback + const isAnswerCorrect = bestMatch.is_correct; + + // Record user's answer with the matched answer's correctness + await recordUserAnswer( + userId, + freeFormExerciseId, + answer, + isAnswerCorrect + ); + + // Generate feedback based on comparison + let feedbackMessage = + bestMatch.feedback || + (isAnswerCorrect + ? "Correct answer!" + : "Incorrect answer. Please try again."); + + if (comparisonDetails) { + if (comparisonDetails.wordOrderErrors > 0 && isAnswerCorrect) { + feedbackMessage = + "Your answer has the right words, but some are in the wrong order."; + } else if (comparisonDetails.hasErrors && isAnswerCorrect) { + feedbackMessage = + "Your answer is correct, but there are some minor errors."; + } + } + + return NextResponse.json({ + is_correct: isAnswerCorrect, + answer: bestMatch.answer, + similarity: bestMatch.similarity, + perfectAnswer, + comparisonDetails, + feedback: feedbackMessage, + }); + } catch (error) { + console.error("Error processing free form answer:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } + } + ) +); + +async function recordUserAnswer(userId, freeFormExerciseId, answer, isCorrect) { + try { + await DB.pool( + `INSERT INTO free_form_user_answers + (answer, free_form_exercise_id, is_correct, user_id) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, free_form_exercise_id) + DO UPDATE SET + answer = $1, + is_correct = $3, + updated_at = NOW()`, + [answer, freeFormExerciseId, isCorrect, userId] + ); + } catch (error) { + console.error("Error recording user answer:", error); + throw error; + } +} diff --git a/gradesa/src/app/api/exercises/freeform/route.js b/gradesa/src/app/api/exercises/freeform/route.js new file mode 100644 index 0000000..3b6128d --- /dev/null +++ b/gradesa/src/app/api/exercises/freeform/route.js @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { DB } from "@/backend/db"; + +export async function GET() { + try { + const { rows } = await DB.pool(` + SELECT + ffe.id, + ffe.question, + ffe.exercise_id, + e.created_at + FROM + free_form_exercises ffe + JOIN + exercises e ON ffe.exercise_id = e.id + ORDER BY + e.created_at DESC + `); + + return NextResponse.json(rows); + } catch (error) { + console.error("Error fetching free form exercises:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/gradesa/src/app/favicon.ico b/gradesa/src/app/favicon.ico index 718d6fe..91a5a76 100644 Binary files a/gradesa/src/app/favicon.ico and b/gradesa/src/app/favicon.ico differ diff --git a/gradesa/src/app/grammar/exercises/exercises.css b/gradesa/src/app/grammar/exercises/exercises.css new file mode 100644 index 0000000..cde5cca --- /dev/null +++ b/gradesa/src/app/grammar/exercises/exercises.css @@ -0,0 +1,36 @@ +/* Exercise Type Card */ +.exercise-type-card { + border: 1px solid var(--bg4); + cursor: pointer; +} + +.exercise-icon { + font-size: 2rem; + margin-right: 1rem; +} + +.exercise-title { + margin-bottom: 0.5rem; + font-weight: 600; +} + +.exercise-description { + color: var(--fg4); + font-size: 0.9rem; +} + +.exercise-link { + display: block; + padding: 0.75rem 1rem; + background-color: var(--bg2); + border-radius: 0.25rem; + border: 1px solid var(--bg4); + width: 100%; + text-align: left; + transition: all 0.2s ease; +} + +.exercise-link:hover { + background-color: var(--bg3); + transform: translateY(-2px); +} diff --git a/gradesa/src/app/grammar/exercises/freeform/[exercise_id]/feedback.js b/gradesa/src/app/grammar/exercises/freeform/[exercise_id]/feedback.js new file mode 100644 index 0000000..2c2fd71 --- /dev/null +++ b/gradesa/src/app/grammar/exercises/freeform/[exercise_id]/feedback.js @@ -0,0 +1,128 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useParams } from "next/navigation"; +import { Container, Row } from "@/components/ui/layout/container"; +import { Button } from "@/components/ui/button"; +import useQuery from "@/shared/hooks/useQuery"; + +export default function FreeformFeedback() { + const { exercise_id } = useParams(); + const [showAllFeedback, setShowAllFeedback] = useState(false); + + const { + data: feedback, + isLoading, + error, + refetch, + } = useQuery(`/exercises/freeform/answers/${exercise_id}`, undefined, { + enabled: showAllFeedback, + }); + + const toggleFeedback = () => { + setShowAllFeedback(!showAllFeedback); + if (!showAllFeedback) { + refetch(); + } + }; + + return ( + + + + + + {showAllFeedback && ( + <> + {isLoading && ( + + Loading feedback... + + )} + + {error && ( + + Error: {error} + + + )} + + {!isLoading && !error && feedback && ( + +

Feedback

+ + {feedback.userAnswers && feedback.userAnswers.length > 0 ? ( + +

Your Answer

+ {feedback.userAnswers.map((answer, index) => ( + + + Answer: {answer.answer} + {answer.is_correct ? "Correct" : "Incorrect"} + +
+ Attempted:{" "} + {new Date(answer.updated_at).toLocaleString()} +
+
+ ))} +
+ ) : null} + + {feedback.possibleAnswers && + feedback.possibleAnswers.length > 0 ? ( + +

Possible Answers

+ {feedback.possibleAnswers + .filter((answer) => answer.is_correct) + .map((answer) => ( + +
+ Answer: {answer.answer} +
+
+ Feedback:{" "} + {answer.feedback || + (answer.is_correct ? "Correct" : "Incorrect")} +
+
+ ))} +
+ ) : null} + + {(!feedback.possibleAnswers || + !feedback.possibleAnswers.length) && + (!feedback.userAnswers || !feedback.userAnswers.length) && ( + + No feedback available for this exercise. + + )} +
+ )} + + )} +
+ ); +} diff --git a/gradesa/src/app/grammar/exercises/freeform/[exercise_id]/page.js b/gradesa/src/app/grammar/exercises/freeform/[exercise_id]/page.js new file mode 100644 index 0000000..431fbc3 --- /dev/null +++ b/gradesa/src/app/grammar/exercises/freeform/[exercise_id]/page.js @@ -0,0 +1,256 @@ +"use client"; + +import { useState } from "react"; +import { useParams } from "next/navigation"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Container, Column, Row } from "@/components/ui/layout/container"; +import useQuery from "@/shared/hooks/useQuery"; +import { useRequest } from "@/shared/hooks/useRequest"; +import FreeformFeedback from "./feedback"; +import { LinkButton } from "@/components/ui/linkbutton"; + +const styles = { + errorWord: { + color: "var(--tertiary2)", + textDecoration: "line-through", + marginRight: "4px", + }, + similarWord: { + color: "var(--yellow)", + textDecoration: "underline", + marginRight: "4px", + }, + matchWord: { + marginRight: "4px", + }, + wrongPosition: { + color: "var(--blue)", + textDecoration: "underline wavy", + marginRight: "4px", + }, + missingWord: { + color: "var(--green1)", + marginRight: "4px", + }, +}; + +// Aktualisierte AnswerComparison-Komponente +const AnswerComparison = ({ comparisonDetails }) => { + if (!comparisonDetails) return null; + + return ( + +

Deine Antwort:

+ + {comparisonDetails.studentWords.map((word, index) => { + let wordStyle; + let title = ""; + + if (word.status === "error") { + wordStyle = styles.errorWord; + } else if (word.status === "similar") { + wordStyle = styles.similarWord; + title = `Meintest du: ${word.suggestion}`; + } else if (word.status === "match" && word.position === "wrong") { + wordStyle = styles.wrongPosition; + title = `Dieses Wort sollte an Position ${word.correctPosition + 1} stehen`; + } else { + wordStyle = styles.matchWord; + } + + return ( + + {word.word} + + ); + })} + + + {comparisonDetails.missingWords.length > 0 && ( + +

Fehlende Wörter:

+ + {comparisonDetails.missingWords.map((word, index) => ( + + {word.word} + + ))} + +
+ )} + + +

Richtige Antwort:

+ + {comparisonDetails.missingWords + .concat( + comparisonDetails.studentWords + .filter((word) => word.status === "match") + .map((word) => ({ + word: word.correctWord, + position: + word.correctPosition || + comparisonDetails.studentWords.indexOf(word), + })) + ) + .sort((a, b) => a.position - b.position) + .map((word, index) => ( + + {word.word} + + ))} + +
+ + +
+ Rot/durchgestrichen: Falsche + Wörter +
+
+ Gelb/unterstrichen: Fast + richtige Wörter +
+
+ Blau/wellig unterstrichen: + Richtiges Wort an falscher Position +
+
+ Grün: Fehlende Wörter +
+
+
+ ); +}; + +export default function FreeFormExercisePage() { + const { exercise_id } = useParams(); + const [answer, setAnswer] = useState(""); + const [feedback, setFeedback] = useState(null); + const [isCorrect, setIsCorrect] = useState(null); + const [error, setError] = useState(null); + const makeRequest = useRequest(); + + const { data: exercise, isLoading } = useQuery( + `/exercises/freeform/${exercise_id}` + ); + + const handleSubmit = async (e) => { + e.preventDefault(); + + try { + const response = await makeRequest("/exercises/freeform/answers", { + freeFormExerciseId: exercise_id, + answer, + }); + const data = response.data; + if (data) { + setIsCorrect(data.is_correct); + setFeedback(data); + } + } catch (error) { + setError(error.message); + } + }; + + if (isLoading) { + return ( + + Lädt... + + ); + } + + if (error) { + return ( + + Fehler: {error} + + Zurück zu den Übungen + + + ); + } + + if (!exercise) { + return null; + } + + return ( + + +

Freie Übung

+
+ + + +

Frage:

+
+ +

{exercise.question}

+
+
+ +
+ + +