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:
Axel Adlercreutz
2025-04-15 20:05:25 +03:00
committed by GitHub
parent 8d106964f4
commit cf62f618d4
21 changed files with 1367 additions and 23 deletions
Vendored
BIN
View File
Binary file not shown.
+1
View File
@@ -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>
);
}
+49 -9
View File
@@ -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) => (
<li key={`exercise-${exIndex}`}> <Row 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>
</li> </Row>
))} ))}
</ul> </Column>
</div> </Column>
</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>
); );
+7 -2
View File
@@ -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 }) {
+18
View File
@@ -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) {
+50 -1
View File
@@ -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),
+1
View File
@@ -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") {
+3 -3
View File
@@ -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) {