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 ? "Hide Feedback" : "Show correct answers"} + + + + {showAllFeedback && ( + <> + {isLoading && ( + + Loading feedback... + + )} + + {error && ( + + Error: {error} + + Try Again + + + )} + + {!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} + + + + + + + + Deine Antwort: + setAnswer(e.target.value)} + placeholder="Gib deine Antwort hier ein..." + required + /> + + + + Antwort abschicken + + + + + {feedback && ( + + + Feedback des Lehrers: + {feedback.feedback} + + + {feedback.is_correct ? "Richtig!" : "Falsch"} + + + )} + + {feedback && feedback.comparisonDetails && ( + + )} + + + + + Zurück zu den Übungen + {feedback?.is_correct && ( + + Nächste Übung + + )} + + + ); +} diff --git a/gradesa/src/app/grammar/exercises/freeform/page.js b/gradesa/src/app/grammar/exercises/freeform/page.js new file mode 100644 index 0000000..0243074 --- /dev/null +++ b/gradesa/src/app/grammar/exercises/freeform/page.js @@ -0,0 +1,101 @@ +"use client"; + +import Link from "next/link"; +import { Container, Row, Column } from "@/components/ui/layout/container"; +import { useRequest } from "@/shared/hooks/useRequest"; +import useQuery from "@/shared/hooks/useQuery"; +export default function FreeFormExercisesPage() { + const makeRequest = useRequest(); + + const { data: exercises, isLoading, error } = useQuery("/exercises/freeform"); + + if (isLoading) { + return ( + + Lädt... + + ); + } + + if (error) { + return ( + + Fehler: {error.message} + + ); + } + + if (!exercises || exercises.length === 0) { + return ( + + + Freie Übungen + + + Zurzeit sind keine Übungen verfügbar. + + + Zurück zu allen Übungen + + + ); + } + + return ( + + + Freie Übungen + + + + {exercises.map((exercise) => ( + + + + {exercise.question.length > 30 + ? `${exercise.question.substring(0, 30)}...` + : exercise.question} + + + Erstellt: {new Date(exercise.created_at).toLocaleDateString()} + + + + ))} + + + + Zurück zu allen Übungen + + + ); +} diff --git a/gradesa/src/app/grammar/exercises/page.js b/gradesa/src/app/grammar/exercises/page.js index ff2f1a0..4ffefde 100644 --- a/gradesa/src/app/grammar/exercises/page.js +++ b/gradesa/src/app/grammar/exercises/page.js @@ -2,6 +2,18 @@ import { useState } from "react"; import LessonsLayout from "./layout"; import Link from "next/link"; +import { Column, Row } from "@/components/ui/layout/container"; +import "./exercises.css"; +import { Button } from "@/components/ui/button"; +const exerciseTypes = [ + { + title: "Freie Übungen", + description: "Üben mit offenen Fragen", + link: "/grammar/exercises/freeform", + image: "📝", + }, + // Add other exercise types here +]; const initialGrammarTopics = [ { @@ -15,22 +27,50 @@ export default function ExercisePage({}) { return ( - + + Übungstypen + + + {exerciseTypes.map((type, index) => ( + + + + {type.image} + + + {type.title} + + {type.description} + + + + + ))} + + Grammatik 1 - - - {initialGrammarTopics[0].exercises.map((exercise, exIndex) => ( - - - {exercise} - - - ))} - - - + + {initialGrammarTopics[0].exercises.map((exercise, exIndex) => ( + + + {exercise} + + + ))} + + - Zurück zu den Übungen + + Zurück zu den Übungen + ); diff --git a/gradesa/src/app/layout.js b/gradesa/src/app/layout.js index 0a63c1d..1b88220 100644 --- a/gradesa/src/app/layout.js +++ b/gradesa/src/app/layout.js @@ -16,8 +16,13 @@ const geistMono = Geist_Mono({ }); export const metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Gradesa", + description: "Gradesa — deutsch lernen", + icons: { + icon: "/favicon.ico", + shortcut: "/favicon.ico", + apple: "/favicon.ico", + }, }; export default function RootLayout({ children }) { diff --git a/gradesa/src/app/manifest.json b/gradesa/src/app/manifest.json new file mode 100644 index 0000000..6dd594a --- /dev/null +++ b/gradesa/src/app/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "Gradesa", + "short_name": "Gradesa", + "description": "Gradesa — deutsch lernen", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#ffffff", + "icons": [ + { + "src": "/favicon.ico", + "sizes": "256x256", + "type": "image/x-icon" + } + ], + "lang": "de", + "dir": "ltr" +} \ No newline at end of file diff --git a/gradesa/src/backend/middleware/withInputValidation.js b/gradesa/src/backend/middleware/withInputValidation.js index d28840b..e1bd29c 100644 --- a/gradesa/src/backend/middleware/withInputValidation.js +++ b/gradesa/src/backend/middleware/withInputValidation.js @@ -21,6 +21,7 @@ export const withInputValidation = (schema, callback) => { mode: req.mode, redirect: req.redirect, }); + newRequest.user = req.user; req = newRequest; } catch (error) { diff --git a/gradesa/src/backend/test/testfactory.js b/gradesa/src/backend/test/testfactory.js index 34d7f70..66ca076 100644 --- a/gradesa/src/backend/test/testfactory.js +++ b/gradesa/src/backend/test/testfactory.js @@ -9,13 +9,28 @@ import { DB } from "../db"; * @param {Object|Function} base - The base values or a function returning base values for the model. * @returns {function} - A function that creates a model for the given table. */ -function modelFactory(tableName, base) { +function modelFactory(tableName, base, insertLinked) { return async function (mod) { if (!isTest) { throw new Error("This function is only available in test mode"); } const baseValues = typeof base === "function" ? base() : base; const model = { ...baseValues, ...mod }; + + if (insertLinked !== undefined) { + await insertLinked(model); + } + + if (Object.keys(model).length === 0) { + const insertedRow = await DB.pool( + `INSERT INTO ${tableName} DEFAULT VALUES RETURNING *` + ); + if (insertedRow.rows.length === 0) { + throw new Error("No row was inserted"); + } + return insertedRow.rows[0]; + } + const insertedRow = await DB.pool( `INSERT INTO ${tableName} (${Object.keys(model).join(", ")}) VALUES (${Object.values( model @@ -42,6 +57,40 @@ const user = modelFactory("users", () => ({ salt: faker.string.alphanumeric(16), })); +const exercise = modelFactory("exercises", () => ({})); + +const freeFormExercise = modelFactory( + "free_form_exercises", + { + question: faker.lorem.sentence(), + }, + async (base) => { + if (!base.exercise_id) { + const exercise = await exercise(); + base.exercise_id = exercise.id; + } + } +); + +const freeFormAnswer = modelFactory( + "free_form_answers", + { + free_form_exercise_id: freeFormExercise.id, + answer: faker.lorem.sentence(), + is_correct: faker.datatype.boolean(), + feedback: faker.lorem.sentence(), + }, + async (base) => { + if (!base.free_form_exercise_id) { + const freeFormExercise = await freeFormExercise(); + base.free_form_exercise_id = freeFormExercise.id; + } + } +); + export const TestFactory = { user, + exercise, + freeFormExercise, + freeFormAnswer, }; diff --git a/gradesa/src/components/ui/layout/container.jsx b/gradesa/src/components/ui/layout/container.jsx index bc044a5..e8c451d 100644 --- a/gradesa/src/components/ui/layout/container.jsx +++ b/gradesa/src/components/ui/layout/container.jsx @@ -120,7 +120,7 @@ export const Container = memo(({ children, className, ...props }) => { propValue.forEach((value, i) => { if (i === 0) return; // aggregate media queries - const query = `@media(min-width: ${breakpointsValues[i - 1]})`; + const query = `@media(minWidth: ${breakpointsValues[i - 1]})`; mediaQueries[query] = { ...mediaQueries[query], [cssProp]: getValue(cssProp, value), @@ -139,7 +139,7 @@ export const Container = memo(({ children, className, ...props }) => { } else if (key in breakpoints) { // aggregate media queries const bpValue = breakpoints[key]; - const query = `@media(min-width: ${bpValue})`; + const query = `@media(minWidth: ${bpValue})`; mediaQueries[query] = { ...mediaQueries[query], [cssProp]: getValue(cssProp, value), diff --git a/gradesa/src/context/user.context.js b/gradesa/src/context/user.context.js index 2bb3494..77478e5 100644 --- a/gradesa/src/context/user.context.js +++ b/gradesa/src/context/user.context.js @@ -115,6 +115,7 @@ export function useIsAdmin() { const router = useRouter(); const pathname = usePathname(); const { auth, actAs } = useUser(); + useEffect(() => { if (!auth.user?.id) return; if (!auth.user?.is_admin || !auth.isLoggedIn || actAs.value !== "admin") { diff --git a/gradesa/src/shared/hooks/useQuery.js b/gradesa/src/shared/hooks/useQuery.js index 929b272..ab9649a 100644 --- a/gradesa/src/shared/hooks/useQuery.js +++ b/gradesa/src/shared/hooks/useQuery.js @@ -1,6 +1,6 @@ import axios from "axios"; import { stringify } from "qs"; -import { useState, useEffect, useRef, useMemo } from "react"; +import { useState, useEffect, useRef, useMemo, memo } from "react"; import { useIsMounted } from "./useIsMounted"; import { useUser } from "@/context/user.context"; @@ -42,14 +42,14 @@ const useQuery = (url, params, config) => { const memoizedConfig = useMemo(() => { if (!config) return defaultConfig; return { ...defaultConfig, ...config }; - }, [config]); + }, [JSON.stringify(config)]); const currentAttemptRef = useRef(0); const currentBackoffRef = useRef(memoizedConfig.refetchBackoff); const memoizedQueryString = useMemo(() => { return params ? objectToQueryString(params) : null; - }, [params]); + }, [JSON.stringify(params ?? {})]); const makeGetRequest = async (url, queryString, abortCtrl) => { if (!isMounted) {
{exercise.question}