mirror of
https://github.com/OHTU-German-learning-website/OHTU-German-learning-website.git
synced 2026-10-12 19:13:51 +03:00
Free form answers (#270)
* init * complete exercise * add tests * style exercises page * cleanup usequery * icon * translate to german * fix test
This commit is contained in:
@@ -1,2 +1,3 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
.DS_Store
|
||||||
.cursorignore
|
.cursorignore
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { z } from "zod";
|
|
||||||
import { withAuth } from "@/backend/middleware/withAuth";
|
import { withAuth } from "@/backend/middleware/withAuth";
|
||||||
import { withInputValidation } from "@/backend/middleware/withInputValidation";
|
import { withInputValidation } from "@/backend/middleware/withInputValidation";
|
||||||
import { DB } from "@/backend/db";
|
import { DB } from "@/backend/db";
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 108 KiB |
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Container mt="xl">
|
||||||
|
<Container textAlign="center" mb="md">
|
||||||
|
<Button variant="outline" onClick={toggleFeedback}>
|
||||||
|
{showAllFeedback ? "Hide Feedback" : "Show correct answers"}
|
||||||
|
</Button>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
{showAllFeedback && (
|
||||||
|
<>
|
||||||
|
{isLoading && (
|
||||||
|
<Container p="md" bg="var(--bg2)" br="md" mb="md">
|
||||||
|
Loading feedback...
|
||||||
|
</Container>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Container p="md" bg="var(--tertiary1)" br="md" mb="md">
|
||||||
|
Error: {error}
|
||||||
|
<Button onClick={refetch} variant="secondary" size="sm" mt="sm">
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</Container>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !error && feedback && (
|
||||||
|
<Container p="md" bg="var(--bg2)" br="md" mb="md">
|
||||||
|
<h2>Feedback</h2>
|
||||||
|
|
||||||
|
{feedback.userAnswers && feedback.userAnswers.length > 0 ? (
|
||||||
|
<Container mt="md">
|
||||||
|
<h3>Your Answer</h3>
|
||||||
|
{feedback.userAnswers.map((answer, index) => (
|
||||||
|
<Container
|
||||||
|
key={index}
|
||||||
|
p="sm"
|
||||||
|
mb="sm"
|
||||||
|
bg={
|
||||||
|
answer.is_correct ? "var(--green1)" : "var(--tertiary1)"
|
||||||
|
}
|
||||||
|
br="md"
|
||||||
|
>
|
||||||
|
<Row justify="space-between" w="100%">
|
||||||
|
<strong>Answer: {answer.answer}</strong>
|
||||||
|
{answer.is_correct ? "Correct" : "Incorrect"}
|
||||||
|
</Row>
|
||||||
|
<div>
|
||||||
|
<strong>Attempted:</strong>{" "}
|
||||||
|
{new Date(answer.updated_at).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
))}
|
||||||
|
</Container>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{feedback.possibleAnswers &&
|
||||||
|
feedback.possibleAnswers.length > 0 ? (
|
||||||
|
<Container mt="md">
|
||||||
|
<h3>Possible Answers</h3>
|
||||||
|
{feedback.possibleAnswers
|
||||||
|
.filter((answer) => answer.is_correct)
|
||||||
|
.map((answer) => (
|
||||||
|
<Container
|
||||||
|
key={answer.id}
|
||||||
|
p="sm"
|
||||||
|
mb="sm"
|
||||||
|
bg={
|
||||||
|
answer.is_correct
|
||||||
|
? "var(--green1)"
|
||||||
|
: "var(--tertiary1)"
|
||||||
|
}
|
||||||
|
br="md"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong>Answer:</strong> {answer.answer}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Feedback:</strong>{" "}
|
||||||
|
{answer.feedback ||
|
||||||
|
(answer.is_correct ? "Correct" : "Incorrect")}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
))}
|
||||||
|
</Container>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{(!feedback.possibleAnswers ||
|
||||||
|
!feedback.possibleAnswers.length) &&
|
||||||
|
(!feedback.userAnswers || !feedback.userAnswers.length) && (
|
||||||
|
<Container>
|
||||||
|
No feedback available for this exercise.
|
||||||
|
</Container>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Container mt="md" mb="md">
|
||||||
|
<h3>Deine Antwort:</h3>
|
||||||
|
<Container p="sm" bg="var(--bg2)" br="md">
|
||||||
|
{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 (
|
||||||
|
<span key={index} style={wordStyle} title={title}>
|
||||||
|
{word.word}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
{comparisonDetails.missingWords.length > 0 && (
|
||||||
|
<Container mt="sm">
|
||||||
|
<h4>Fehlende Wörter:</h4>
|
||||||
|
<Container p="sm" bg="var(--bg2)" br="md">
|
||||||
|
{comparisonDetails.missingWords.map((word, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
style={styles.missingWord}
|
||||||
|
title={`Dieses Wort sollte an Position ${word.position + 1} stehen`}
|
||||||
|
>
|
||||||
|
{word.word}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</Container>
|
||||||
|
</Container>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Container mt="sm">
|
||||||
|
<h4>Richtige Antwort:</h4>
|
||||||
|
<Container p="sm" bg="var(--bg2)" br="md">
|
||||||
|
{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) => (
|
||||||
|
<span key={index} style={{ marginRight: "4px" }}>
|
||||||
|
{word.word}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</Container>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Container mt="sm" fontSize="sm">
|
||||||
|
<div>
|
||||||
|
<span style={styles.errorWord}>Rot/durchgestrichen</span>: Falsche
|
||||||
|
Wörter
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={styles.similarWord}>Gelb/unterstrichen</span>: Fast
|
||||||
|
richtige Wörter
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={styles.wrongPosition}>Blau/wellig unterstrichen</span>:
|
||||||
|
Richtiges Wort an falscher Position
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={styles.missingWord}>Grün</span>: Fehlende Wörter
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Container display="flex" justify="center" align="center" h="200px">
|
||||||
|
Lädt...
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Container p="md" bg="var(--tertiary1)" mb="md" br="md">
|
||||||
|
Fehler: {error}
|
||||||
|
<Container mt="md">
|
||||||
|
<Link href="/grammar/exercises">Zurück zu den Übungen</Link>
|
||||||
|
</Container>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!exercise) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxW="800px" m="0 auto" p="md">
|
||||||
|
<Container mb="lg">
|
||||||
|
<h1>Freie Übung</h1>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Container mb="xl" p="md" bg="var(--bg2)" br="md">
|
||||||
|
<Container mb="sm">
|
||||||
|
<h2>Frage:</h2>
|
||||||
|
</Container>
|
||||||
|
<Container>
|
||||||
|
<p>{exercise.question}</p>
|
||||||
|
</Container>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Column gap="lg">
|
||||||
|
<Container>
|
||||||
|
<label>
|
||||||
|
Deine Antwort:
|
||||||
|
<textarea
|
||||||
|
id="answer"
|
||||||
|
rows="4"
|
||||||
|
className="w-full p-2 mt-1 border rounded"
|
||||||
|
value={answer}
|
||||||
|
onChange={(e) => setAnswer(e.target.value)}
|
||||||
|
placeholder="Gib deine Antwort hier ein..."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</Container>
|
||||||
|
<Button type="submit" variant="secondary">
|
||||||
|
Antwort abschicken
|
||||||
|
</Button>
|
||||||
|
</Column>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{feedback && (
|
||||||
|
<Row
|
||||||
|
p="md"
|
||||||
|
br="md"
|
||||||
|
mb="md"
|
||||||
|
mt="lg"
|
||||||
|
justify="space-between"
|
||||||
|
bg={feedback.is_correct ? "var(--green1)" : "var(--tertiary1)"}
|
||||||
|
>
|
||||||
|
<Column>
|
||||||
|
<span>Feedback des Lehrers:</span>
|
||||||
|
{feedback.feedback}
|
||||||
|
</Column>
|
||||||
|
<Container mb="sm" fontWeight="600">
|
||||||
|
{feedback.is_correct ? "Richtig!" : "Falsch"}
|
||||||
|
</Container>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{feedback && feedback.comparisonDetails && (
|
||||||
|
<AnswerComparison comparisonDetails={feedback.comparisonDetails} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FreeformFeedback />
|
||||||
|
|
||||||
|
<Row gap="md" mt="xl" justify="space-between">
|
||||||
|
<LinkButton href="/grammar/exercises">Zurück zu den Übungen</LinkButton>
|
||||||
|
{feedback?.is_correct && (
|
||||||
|
<LinkButton variant="secondary" href="/grammar/exercises/freeform">
|
||||||
|
Nächste Übung
|
||||||
|
</LinkButton>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Container display="flex" justify="center" align="center" h="200px">
|
||||||
|
Lädt...
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Container p="md" bg="var(--tertiary1)" mb="md" br="md">
|
||||||
|
Fehler: {error.message}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!exercises || exercises.length === 0) {
|
||||||
|
return (
|
||||||
|
<Container maxW="800px" m="0 auto" p="md">
|
||||||
|
<Container mb="lg">
|
||||||
|
<h1>Freie Übungen</h1>
|
||||||
|
</Container>
|
||||||
|
<Container color="var(--fg4)">
|
||||||
|
Zurzeit sind keine Übungen verfügbar.
|
||||||
|
</Container>
|
||||||
|
<Container mt="lg">
|
||||||
|
<Link href="/grammar/exercises">Zurück zu allen Übungen</Link>
|
||||||
|
</Container>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxW="800px" m="0 auto" p="md">
|
||||||
|
<Container mb="lg">
|
||||||
|
<h1>Freie Übungen</h1>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Container
|
||||||
|
display="grid"
|
||||||
|
gap="md"
|
||||||
|
templateColumns={{
|
||||||
|
base: "1fr",
|
||||||
|
md: "1fr 1fr",
|
||||||
|
lg: "1fr 1fr 1fr",
|
||||||
|
}}
|
||||||
|
mb="lg"
|
||||||
|
>
|
||||||
|
{exercises.map((exercise) => (
|
||||||
|
<Link
|
||||||
|
key={exercise.id}
|
||||||
|
href={`/grammar/exercises/freeform/${exercise.id}`}
|
||||||
|
>
|
||||||
|
<Container
|
||||||
|
p="md"
|
||||||
|
b={`1px solid var(--bg6)`}
|
||||||
|
br="md"
|
||||||
|
boxShadow="0 1px 2px 0 rgba(0, 0, 0, 0.05)"
|
||||||
|
_hover={{
|
||||||
|
boxShadow:
|
||||||
|
"0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
|
||||||
|
}}
|
||||||
|
transition="box-shadow 0.3s"
|
||||||
|
bg="var(--bg2)"
|
||||||
|
>
|
||||||
|
<Container
|
||||||
|
mb="sm"
|
||||||
|
fontWeight="600"
|
||||||
|
overflow="hidden"
|
||||||
|
textOverflow="ellipsis"
|
||||||
|
whiteSpace="nowrap"
|
||||||
|
>
|
||||||
|
{exercise.question.length > 30
|
||||||
|
? `${exercise.question.substring(0, 30)}...`
|
||||||
|
: exercise.question}
|
||||||
|
</Container>
|
||||||
|
<Container fontSize="sm" color="var(--fg5)">
|
||||||
|
Erstellt: {new Date(exercise.created_at).toLocaleDateString()}
|
||||||
|
</Container>
|
||||||
|
</Container>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Container mt="lg">
|
||||||
|
<Link href="/grammar/exercises">Zurück zu allen Übungen</Link>
|
||||||
|
</Container>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,18 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import LessonsLayout from "./layout";
|
import LessonsLayout from "./layout";
|
||||||
import Link from "next/link";
|
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 = [
|
const initialGrammarTopics = [
|
||||||
{
|
{
|
||||||
@@ -15,22 +27,50 @@ export default function ExercisePage({}) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<LessonsLayout>
|
<LessonsLayout>
|
||||||
<div className="lessons-container">
|
<Column>
|
||||||
|
<h1>Übungstypen</h1>
|
||||||
|
|
||||||
|
<Column gap="md">
|
||||||
|
{exerciseTypes.map((type, index) => (
|
||||||
|
<Link href={type.link} key={index}>
|
||||||
|
<Row
|
||||||
|
className="exercise-type-card"
|
||||||
|
align="center"
|
||||||
|
p="lg"
|
||||||
|
mb="md"
|
||||||
|
br="md"
|
||||||
|
bg="var(--bg2)"
|
||||||
|
transition="all 0.3s ease"
|
||||||
|
>
|
||||||
|
<Row pl="xl" className="exercise-icon">
|
||||||
|
{type.image}
|
||||||
|
</Row>
|
||||||
|
<Column align="start" justify="center">
|
||||||
|
<h4 className="exercise-title">{type.title}</h4>
|
||||||
|
<span className="exercise-description">
|
||||||
|
{type.description}
|
||||||
|
</span>
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</Column>
|
||||||
|
|
||||||
<h1>Grammatik 1</h1>
|
<h1>Grammatik 1</h1>
|
||||||
<div className="flex-parent-element">
|
<Column gap="md">
|
||||||
<ul className="exercise-list">
|
{initialGrammarTopics[0].exercises.map((exercise, exIndex) => (
|
||||||
{initialGrammarTopics[0].exercises.map((exercise, exIndex) => (
|
<Row key={`exercise-${exIndex}`}>
|
||||||
<li key={`exercise-${exIndex}`}>
|
<Link href={`/lessons/exercises`}>
|
||||||
<Link href={`/lessons/exercises`}>
|
<button className="exercise-link">{exercise}</button>
|
||||||
<button className="exercise-link">{exercise}</button>
|
</Link>
|
||||||
</Link>
|
</Row>
|
||||||
</li>
|
))}
|
||||||
))}
|
</Column>
|
||||||
</ul>
|
</Column>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Link href="/lessons">
|
<Link href="/lessons">
|
||||||
<button className="back-button">Zurück zu den Übungen</button>
|
<Button width="fit" size="lg">
|
||||||
|
Zurück zu den Übungen
|
||||||
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</LessonsLayout>
|
</LessonsLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,8 +16,13 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Create Next App",
|
title: "Gradesa",
|
||||||
description: "Generated by create next app",
|
description: "Gradesa — deutsch lernen",
|
||||||
|
icons: {
|
||||||
|
icon: "/favicon.ico",
|
||||||
|
shortcut: "/favicon.ico",
|
||||||
|
apple: "/favicon.ico",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }) {
|
export default function RootLayout({ children }) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ export const withInputValidation = (schema, callback) => {
|
|||||||
mode: req.mode,
|
mode: req.mode,
|
||||||
redirect: req.redirect,
|
redirect: req.redirect,
|
||||||
});
|
});
|
||||||
|
newRequest.user = req.user;
|
||||||
|
|
||||||
req = newRequest;
|
req = newRequest;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -9,13 +9,28 @@ import { DB } from "../db";
|
|||||||
* @param {Object|Function} base - The base values or a function returning base values for the model.
|
* @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.
|
* @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) {
|
return async function (mod) {
|
||||||
if (!isTest) {
|
if (!isTest) {
|
||||||
throw new Error("This function is only available in test mode");
|
throw new Error("This function is only available in test mode");
|
||||||
}
|
}
|
||||||
const baseValues = typeof base === "function" ? base() : base;
|
const baseValues = typeof base === "function" ? base() : base;
|
||||||
const model = { ...baseValues, ...mod };
|
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(
|
const insertedRow = await DB.pool(
|
||||||
`INSERT INTO ${tableName} (${Object.keys(model).join(", ")}) VALUES (${Object.values(
|
`INSERT INTO ${tableName} (${Object.keys(model).join(", ")}) VALUES (${Object.values(
|
||||||
model
|
model
|
||||||
@@ -42,6 +57,40 @@ const user = modelFactory("users", () => ({
|
|||||||
salt: faker.string.alphanumeric(16),
|
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 = {
|
export const TestFactory = {
|
||||||
user,
|
user,
|
||||||
|
exercise,
|
||||||
|
freeFormExercise,
|
||||||
|
freeFormAnswer,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export const Container = memo(({ children, className, ...props }) => {
|
|||||||
propValue.forEach((value, i) => {
|
propValue.forEach((value, i) => {
|
||||||
if (i === 0) return;
|
if (i === 0) return;
|
||||||
// aggregate media queries
|
// aggregate media queries
|
||||||
const query = `@media(min-width: ${breakpointsValues[i - 1]})`;
|
const query = `@media(minWidth: ${breakpointsValues[i - 1]})`;
|
||||||
mediaQueries[query] = {
|
mediaQueries[query] = {
|
||||||
...mediaQueries[query],
|
...mediaQueries[query],
|
||||||
[cssProp]: getValue(cssProp, value),
|
[cssProp]: getValue(cssProp, value),
|
||||||
@@ -139,7 +139,7 @@ export const Container = memo(({ children, className, ...props }) => {
|
|||||||
} else if (key in breakpoints) {
|
} else if (key in breakpoints) {
|
||||||
// aggregate media queries
|
// aggregate media queries
|
||||||
const bpValue = breakpoints[key];
|
const bpValue = breakpoints[key];
|
||||||
const query = `@media(min-width: ${bpValue})`;
|
const query = `@media(minWidth: ${bpValue})`;
|
||||||
mediaQueries[query] = {
|
mediaQueries[query] = {
|
||||||
...mediaQueries[query],
|
...mediaQueries[query],
|
||||||
[cssProp]: getValue(cssProp, value),
|
[cssProp]: getValue(cssProp, value),
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export function useIsAdmin() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { auth, actAs } = useUser();
|
const { auth, actAs } = useUser();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!auth.user?.id) return;
|
if (!auth.user?.id) return;
|
||||||
if (!auth.user?.is_admin || !auth.isLoggedIn || actAs.value !== "admin") {
|
if (!auth.user?.is_admin || !auth.isLoggedIn || actAs.value !== "admin") {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { stringify } from "qs";
|
import { stringify } from "qs";
|
||||||
import { useState, useEffect, useRef, useMemo } from "react";
|
import { useState, useEffect, useRef, useMemo, memo } from "react";
|
||||||
import { useIsMounted } from "./useIsMounted";
|
import { useIsMounted } from "./useIsMounted";
|
||||||
import { useUser } from "@/context/user.context";
|
import { useUser } from "@/context/user.context";
|
||||||
|
|
||||||
@@ -42,14 +42,14 @@ const useQuery = (url, params, config) => {
|
|||||||
const memoizedConfig = useMemo(() => {
|
const memoizedConfig = useMemo(() => {
|
||||||
if (!config) return defaultConfig;
|
if (!config) return defaultConfig;
|
||||||
return { ...defaultConfig, ...config };
|
return { ...defaultConfig, ...config };
|
||||||
}, [config]);
|
}, [JSON.stringify(config)]);
|
||||||
|
|
||||||
const currentAttemptRef = useRef(0);
|
const currentAttemptRef = useRef(0);
|
||||||
const currentBackoffRef = useRef(memoizedConfig.refetchBackoff);
|
const currentBackoffRef = useRef(memoizedConfig.refetchBackoff);
|
||||||
|
|
||||||
const memoizedQueryString = useMemo(() => {
|
const memoizedQueryString = useMemo(() => {
|
||||||
return params ? objectToQueryString(params) : null;
|
return params ? objectToQueryString(params) : null;
|
||||||
}, [params]);
|
}, [JSON.stringify(params ?? {})]);
|
||||||
|
|
||||||
const makeGetRequest = async (url, queryString, abortCtrl) => {
|
const makeGetRequest = async (url, queryString, abortCtrl) => {
|
||||||
if (!isMounted) {
|
if (!isMounted) {
|
||||||
|
|||||||
Reference in New Issue
Block a user