mirror of
https://github.com/OHTU-German-learning-website/OHTU-German-learning-website.git
synced 2026-10-12 19:13:51 +03:00
Add false-word feedback support to click exercises
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE public.click_false_word_feedbacks (
|
||||
id bigint PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
updated_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
click_exercise_id bigint NOT NULL REFERENCES click_exercises(id) ON DELETE CASCADE,
|
||||
slot_key text NOT NULL,
|
||||
word_text text NOT NULL,
|
||||
feedback text NOT NULL,
|
||||
CONSTRAINT click_false_word_feedbacks_unique UNIQUE (click_exercise_id, slot_key)
|
||||
);
|
||||
|
||||
CREATE TRIGGER updated_at
|
||||
BEFORE UPDATE ON public.click_false_word_feedbacks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION updated_at();
|
||||
@@ -125,6 +125,24 @@ ALTER TABLE public.click_answers
|
||||
ADD CONSTRAINT click_answers_user_id_fkey
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
CREATE TABLE public.click_false_word_feedbacks (
|
||||
id bigint PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
updated_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
click_exercise_id bigint NOT NULL,
|
||||
slot_key text NOT NULL,
|
||||
word_text text NOT NULL,
|
||||
feedback text NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE public.click_false_word_feedbacks
|
||||
ADD CONSTRAINT click_false_word_feedbacks_unique
|
||||
UNIQUE (click_exercise_id, slot_key);
|
||||
|
||||
ALTER TABLE public.click_false_word_feedbacks
|
||||
ADD CONSTRAINT click_false_word_feedbacks_click_exercise_id_fkey
|
||||
FOREIGN KEY (click_exercise_id) REFERENCES click_exercises(id) ON DELETE CASCADE;
|
||||
|
||||
CREATE TABLE public.click_to_exercises (
|
||||
id bigint PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
|
||||
@@ -54,6 +54,19 @@ CREATE TABLE public.click_exercises (
|
||||
source_html text
|
||||
);
|
||||
|
||||
CREATE TABLE public.click_false_word_feedbacks (
|
||||
id bigint PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
updated_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
click_exercise_id bigint NOT NULL REFERENCES click_exercises(id) ON DELETE CASCADE,
|
||||
slot_key text NOT NULL,
|
||||
word_text text NOT NULL,
|
||||
feedback text NOT NULL,
|
||||
CONSTRAINT click_false_word_feedbacks_unique UNIQUE (click_exercise_id, slot_key)
|
||||
);
|
||||
|
||||
CREATE TRIGGER updated_at BEFORE UPDATE ON public.click_false_word_feedbacks FOR EACH ROW EXECUTE FUNCTION updated_at();
|
||||
|
||||
ALTER TABLE public.click_exercises
|
||||
ADD CONSTRAINT click_exercises_all_words_target_words_key
|
||||
UNIQUE (all_words, target_words);
|
||||
|
||||
@@ -47,6 +47,11 @@
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.correct-word-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 0.8em;
|
||||
margin-top: 1em;
|
||||
@@ -78,4 +83,40 @@
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.false-word-feedback-list {
|
||||
display: grid;
|
||||
gap: var(--u-sm);
|
||||
margin-top: var(--u-sm);
|
||||
}
|
||||
|
||||
.false-word-feedback-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 180px) minmax(320px, 1fr);
|
||||
gap: var(--u-sm);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.false-word-feedback-word {
|
||||
display: inline-block;
|
||||
padding-top: 0.45rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.false-word-feedback-input {
|
||||
width: 100%;
|
||||
min-height: 2.8rem;
|
||||
resize: vertical;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.false-word-feedback-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.false-word-feedback-word {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Container } from "@/components/ui/layout/container";
|
||||
import { Column } from "@/components/ui/layout/container";
|
||||
@@ -12,6 +12,8 @@ import { htmlToPlainText } from "@/shared/utils/normalizeEditorText";
|
||||
import { withBasePath } from "@/shared/utils/basePath";
|
||||
import AdminLastModified from "@/components/ui/admin-last-modified";
|
||||
|
||||
const WORD_REGEX = /\p{L}+(?:['’-]\p{L}+)*/gu;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
@@ -90,6 +92,25 @@ function splitIntoContiguousChunks(slotKeys) {
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function buildWordSlotLookup(tokens) {
|
||||
const lookup = new Map();
|
||||
let wordIndex = 0;
|
||||
|
||||
(Array.isArray(tokens) ? tokens : []).forEach((token) => {
|
||||
if (token === "\n" || /^[^\S\n]+$/u.test(token)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = [...String(token).matchAll(WORD_REGEX)];
|
||||
matches.forEach((match) => {
|
||||
lookup.set(`w-${wordIndex}`, match[0]);
|
||||
wordIndex += 1;
|
||||
});
|
||||
});
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
export default function CreateExercise() {
|
||||
const router = useRouter();
|
||||
const { click_id } = useParams();
|
||||
@@ -103,6 +124,9 @@ export default function CreateExercise() {
|
||||
const [allWordsHtml, setAllWordsHtml] = useState("");
|
||||
const [allWordsText, setAllWordsText] = useState("");
|
||||
const [selectedWords, setSelectedWords] = useState([]);
|
||||
const [showFalseWordSelection, setShowFalseWordSelection] = useState(false);
|
||||
const [falseWordSelectionKeys, setFalseWordSelectionKeys] = useState([]);
|
||||
const [falseWordFeedbackByKey, setFalseWordFeedbackByKey] = useState({});
|
||||
const [previewMode, setPreviewMode] = useState(false);
|
||||
|
||||
const {
|
||||
@@ -141,6 +165,24 @@ export default function CreateExercise() {
|
||||
setSelectedWords(
|
||||
Array.isArray(exerciseData.target_words) ? exerciseData.target_words : []
|
||||
);
|
||||
|
||||
const loadedFalseWordFeedbacks = Array.isArray(
|
||||
exerciseData.false_word_feedbacks
|
||||
)
|
||||
? exerciseData.false_word_feedbacks
|
||||
: [];
|
||||
|
||||
setShowFalseWordSelection(loadedFalseWordFeedbacks.length > 0);
|
||||
setFalseWordSelectionKeys(
|
||||
loadedFalseWordFeedbacks.map((entry) => entry.slot_key).filter(Boolean)
|
||||
);
|
||||
setFalseWordFeedbackByKey(
|
||||
Object.fromEntries(
|
||||
loadedFalseWordFeedbacks
|
||||
.filter((entry) => entry.slot_key)
|
||||
.map((entry) => [entry.slot_key, entry.feedback || ""])
|
||||
)
|
||||
);
|
||||
}, [exerciseData, isEditMode]);
|
||||
|
||||
const handleEditorContentChange = (html) => {
|
||||
@@ -152,6 +194,48 @@ export default function CreateExercise() {
|
||||
const allWords =
|
||||
allWordsText.replace(/\r\n/g, "\n").match(/[^\S\n]+|\n|[^\s]+/g) ?? [];
|
||||
|
||||
const falseWordSlotLookup = useMemo(
|
||||
() => buildWordSlotLookup(allWords),
|
||||
[allWords]
|
||||
);
|
||||
|
||||
const falseWordFeedbackEntries = useMemo(
|
||||
() =>
|
||||
falseWordSelectionKeys.map((slotKey) => ({
|
||||
slotKey,
|
||||
wordText: falseWordSlotLookup.get(slotKey) || slotKey,
|
||||
feedback: falseWordFeedbackByKey[slotKey] || "",
|
||||
})),
|
||||
[falseWordSelectionKeys, falseWordFeedbackByKey, falseWordSlotLookup]
|
||||
);
|
||||
|
||||
const disallowedFalseWordSlotKeys = useMemo(() => {
|
||||
const entries = (Array.isArray(selectedWords) ? selectedWords : [])
|
||||
.map(parseTargetEntry)
|
||||
.filter(Boolean);
|
||||
|
||||
const disallowed = new Set();
|
||||
const legacyWordTargets = new Set();
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (/^w-\d+$/u.test(entry.slotKey)) {
|
||||
disallowed.add(entry.slotKey);
|
||||
} else if (entry.slotKey) {
|
||||
legacyWordTargets.add(entry.slotKey);
|
||||
}
|
||||
});
|
||||
|
||||
if (legacyWordTargets.size > 0) {
|
||||
falseWordSlotLookup.forEach((word, slotKey) => {
|
||||
if (legacyWordTargets.has(word)) {
|
||||
disallowed.add(slotKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return disallowed;
|
||||
}, [selectedWords, falseWordSlotLookup]);
|
||||
|
||||
const handlePreview = (e) => {
|
||||
e.preventDefault();
|
||||
setPreviewMode(true);
|
||||
@@ -169,8 +253,16 @@ export default function CreateExercise() {
|
||||
return;
|
||||
}
|
||||
|
||||
const uniqueSlotKeys = [...new Set(entries.map((entry) => entry.slotKey))];
|
||||
const chunks = splitIntoContiguousChunks(uniqueSlotKeys);
|
||||
const existingGroupedEntries = entries.filter((entry) => entry.groupId);
|
||||
const existingGroupedSlotKeys = new Set(
|
||||
existingGroupedEntries.map((entry) => entry.slotKey)
|
||||
);
|
||||
const ungroupedSlotKeys = entries
|
||||
.filter((entry) => !existingGroupedSlotKeys.has(entry.slotKey))
|
||||
.map((entry) => entry.slotKey);
|
||||
|
||||
const uniqueUngroupedSlotKeys = [...new Set(ungroupedSlotKeys)];
|
||||
const chunks = splitIntoContiguousChunks(uniqueUngroupedSlotKeys);
|
||||
const hasElementChunk = chunks.some((chunk) => chunk.length > 1);
|
||||
|
||||
if (!hasElementChunk) {
|
||||
@@ -180,16 +272,64 @@ export default function CreateExercise() {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTargets = chunks.flatMap((chunk, chunkIndex) => {
|
||||
const groupedExistingRaw = existingGroupedEntries.map((entry) => entry.raw);
|
||||
const nextTargetsFromUngrouped = chunks.flatMap((chunk, chunkIndex) => {
|
||||
if (chunk.length <= 1) {
|
||||
return chunk;
|
||||
}
|
||||
|
||||
const groupId = `group-${Date.now()}-${chunkIndex}`;
|
||||
const groupId =
|
||||
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
||||
? `group-${crypto.randomUUID()}`
|
||||
: `group-${Date.now()}-${Math.random().toString(36).slice(2)}-${chunkIndex}`;
|
||||
return chunk.map((slotKey) => `${groupId}::${slotKey}`);
|
||||
});
|
||||
|
||||
setSelectedWords(nextTargets);
|
||||
setSelectedWords([...groupedExistingRaw, ...nextTargetsFromUngrouped]);
|
||||
};
|
||||
|
||||
const handleFalseWordSelectionChange = (updatedSelectedKeys) => {
|
||||
const blockedKeys = updatedSelectedKeys.filter((slotKey) =>
|
||||
disallowedFalseWordSlotKeys.has(slotKey)
|
||||
);
|
||||
|
||||
if (blockedKeys.length > 0) {
|
||||
const warningMessage =
|
||||
"Du hast dieses Wort bereits als korrektes Wort ausgewählt.";
|
||||
if (typeof window !== "undefined") {
|
||||
window.alert(warningMessage);
|
||||
}
|
||||
}
|
||||
|
||||
const allowedKeys = updatedSelectedKeys.filter(
|
||||
(slotKey) => !disallowedFalseWordSlotKeys.has(slotKey)
|
||||
);
|
||||
|
||||
setError(null);
|
||||
setFalseWordSelectionKeys(allowedKeys);
|
||||
};
|
||||
|
||||
const handleFalseWordFeedbackChange = (slotKey, value) => {
|
||||
setFalseWordFeedbackByKey((current) => ({
|
||||
...current,
|
||||
[slotKey]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const validateFalseWordFeedbacks = () => {
|
||||
if (falseWordSelectionKeys.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const missingFeedback = falseWordSelectionKeys.find(
|
||||
(slotKey) => !(falseWordFeedbackByKey[slotKey] || "").trim()
|
||||
);
|
||||
|
||||
if (missingFeedback) {
|
||||
return "Bitte gib für jedes markierte falsche Wort ein Feedback ein.";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSaveExercise = async () => {
|
||||
@@ -197,6 +337,30 @@ export default function CreateExercise() {
|
||||
setError(null);
|
||||
setTitleError(false);
|
||||
|
||||
const validationError = validateFalseWordFeedbacks();
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
const conflictingFalseWords = falseWordSelectionKeys.filter((slotKey) =>
|
||||
disallowedFalseWordSlotKeys.has(slotKey)
|
||||
);
|
||||
if (conflictingFalseWords.length > 0) {
|
||||
setError(
|
||||
"Einige falsche Wörter sind bereits als korrekte Wörter ausgewählt. Bitte entferne diese zuerst."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const falseWordFeedbacksPayload = falseWordFeedbackEntries.map(
|
||||
(entry) => ({
|
||||
slotKey: entry.slotKey,
|
||||
wordText: entry.wordText,
|
||||
feedback: entry.feedback,
|
||||
})
|
||||
);
|
||||
|
||||
if (isEditMode) {
|
||||
const response = await fetch(
|
||||
withBasePath(`/api/admin/exercises/click/${click_id}`),
|
||||
@@ -209,6 +373,7 @@ export default function CreateExercise() {
|
||||
targetWords: selectedWords,
|
||||
allWords,
|
||||
sourceHtml: allWordsHtml,
|
||||
falseWordFeedbacks: falseWordFeedbacksPayload,
|
||||
}),
|
||||
}
|
||||
);
|
||||
@@ -232,6 +397,7 @@ export default function CreateExercise() {
|
||||
targetWords: selectedWords,
|
||||
allWords,
|
||||
sourceHtml: allWordsHtml,
|
||||
falseWordFeedbacks: falseWordFeedbacksPayload,
|
||||
}),
|
||||
}
|
||||
);
|
||||
@@ -385,7 +551,7 @@ export default function CreateExercise() {
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
<Container>
|
||||
<Container className="correct-word-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
@@ -395,6 +561,66 @@ export default function CreateExercise() {
|
||||
Als Element übernehmen
|
||||
</Button>
|
||||
</Container>
|
||||
<Container className="click-block">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setShowFalseWordSelection((current) => !current)}
|
||||
>
|
||||
{showFalseWordSelection
|
||||
? "Falsche Wörter ausblenden"
|
||||
: "Falsche Wörter auswählen"}
|
||||
</Button>
|
||||
</Container>
|
||||
{showFalseWordSelection && (
|
||||
<Container className="click-block">
|
||||
<WordSelectionExercise
|
||||
title={title}
|
||||
targetCategory={targetCategory}
|
||||
targetWords={[]}
|
||||
allWords={allWords}
|
||||
sourceHtml={allWordsHtml}
|
||||
isPreviewMode={true}
|
||||
previewGroupAdjacentSelection={false}
|
||||
previewAutoSelectTargets={false}
|
||||
previewHighlightTargets={false}
|
||||
previewSelectionKeys={falseWordSelectionKeys}
|
||||
onPreviewSelectionChange={handleFalseWordSelectionChange}
|
||||
instructionText="Wähle die falschen Wörter aus, für die du Feedback speichern möchtest."
|
||||
/>
|
||||
<div className="false-word-feedback-list">
|
||||
{falseWordFeedbackEntries.length === 0 ? (
|
||||
<p>
|
||||
Markiere ein falsches Wort, um dafür ein Feedback zu
|
||||
hinterlegen.
|
||||
</p>
|
||||
) : (
|
||||
falseWordFeedbackEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.slotKey}
|
||||
className="false-word-feedback-row"
|
||||
>
|
||||
<strong className="false-word-feedback-word">
|
||||
{entry.wordText}
|
||||
</strong>
|
||||
<textarea
|
||||
className="false-word-feedback-input"
|
||||
rows={2}
|
||||
value={entry.feedback}
|
||||
onChange={(e) =>
|
||||
handleFalseWordFeedbackChange(
|
||||
entry.slotKey,
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
placeholder="Feedback für dieses falsche Wort"
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
)}
|
||||
<Container>
|
||||
<Button size="sm" variant="secondary" onClick={handleEditAgain}>
|
||||
Übung bearbeiten
|
||||
|
||||
@@ -72,6 +72,14 @@ export const GET = withAuth(
|
||||
[click_id]
|
||||
);
|
||||
|
||||
const feedbackResult = await DB.pool(
|
||||
`SELECT slot_key, word_text, feedback
|
||||
FROM click_false_word_feedbacks
|
||||
WHERE click_exercise_id = $1
|
||||
ORDER BY id ASC`,
|
||||
[click_id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Übung nicht gefunden." },
|
||||
@@ -79,7 +87,10 @@ export const GET = withAuth(
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(result.rows[0]);
|
||||
return NextResponse.json({
|
||||
...result.rows[0],
|
||||
false_word_feedbacks: feedbackResult.rows,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching click exercise:", error);
|
||||
return NextResponse.json(
|
||||
@@ -101,6 +112,15 @@ export const PUT = withAuth(
|
||||
const body = await request.json();
|
||||
const { title, targetCategory, targetWords, allWords, sourceHtml } = body;
|
||||
const sanitizedSourceHtml = sanitizeHtml(sourceHtml || "");
|
||||
const falseWordFeedbacks = Array.isArray(body.falseWordFeedbacks)
|
||||
? body.falseWordFeedbacks
|
||||
: [];
|
||||
|
||||
const normalizedFalseWordFeedbacks = falseWordFeedbacks.map((entry) => ({
|
||||
slotKey: String(entry?.slotKey || entry?.slot_key || "").trim(),
|
||||
wordText: String(entry?.wordText || entry?.word_text || "").trim(),
|
||||
feedback: String(entry?.feedback || "").trim(),
|
||||
}));
|
||||
|
||||
const validationError = validatePayload({
|
||||
title,
|
||||
@@ -144,33 +164,54 @@ export const PUT = withAuth(
|
||||
);
|
||||
}
|
||||
|
||||
await DB.pool(
|
||||
`UPDATE click_exercises
|
||||
SET title = $1,
|
||||
category = $2,
|
||||
target_words = $3,
|
||||
all_words = $4,
|
||||
source_html = $5,
|
||||
updated_at = NOW()
|
||||
WHERE id = $6`,
|
||||
[
|
||||
title,
|
||||
targetCategory,
|
||||
targetWords,
|
||||
allWords,
|
||||
sanitizedSourceHtml,
|
||||
click_id,
|
||||
]
|
||||
);
|
||||
await DB.transaction(async (tx) => {
|
||||
await tx.query(
|
||||
`UPDATE click_exercises
|
||||
SET title = $1,
|
||||
category = $2,
|
||||
target_words = $3,
|
||||
all_words = $4,
|
||||
source_html = $5,
|
||||
updated_at = NOW()
|
||||
WHERE id = $6`,
|
||||
[
|
||||
title,
|
||||
targetCategory,
|
||||
targetWords,
|
||||
allWords,
|
||||
sanitizedSourceHtml,
|
||||
click_id,
|
||||
]
|
||||
);
|
||||
|
||||
await DB.pool(
|
||||
`UPDATE exercises e
|
||||
SET updated_by = $1
|
||||
FROM click_to_exercises cte
|
||||
WHERE cte.exercise_id = e.id
|
||||
AND cte.click_id = $2`,
|
||||
[request.user?.id ?? null, click_id]
|
||||
);
|
||||
await tx.query(
|
||||
`DELETE FROM click_false_word_feedbacks
|
||||
WHERE click_exercise_id = $1`,
|
||||
[click_id]
|
||||
);
|
||||
|
||||
for (const entry of normalizedFalseWordFeedbacks) {
|
||||
if (!entry.slotKey || !entry.wordText || !entry.feedback) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await tx.query(
|
||||
`INSERT INTO click_false_word_feedbacks
|
||||
(click_exercise_id, slot_key, word_text, feedback)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[click_id, entry.slotKey, entry.wordText, entry.feedback]
|
||||
);
|
||||
}
|
||||
|
||||
await tx.query(
|
||||
`UPDATE exercises e
|
||||
SET updated_by = $1
|
||||
FROM click_to_exercises cte
|
||||
WHERE cte.exercise_id = e.id
|
||||
AND cte.click_id = $2`,
|
||||
[request.user?.id ?? null, click_id]
|
||||
);
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -9,11 +9,59 @@ function sanitizeHtml(html) {
|
||||
return purify.sanitize(String(html || ""), { ADD_ATTR: ["target"] });
|
||||
}
|
||||
|
||||
function normalizeFalseWordFeedbacks(falseWordFeedbacks) {
|
||||
if (!Array.isArray(falseWordFeedbacks)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return falseWordFeedbacks.map((entry) => ({
|
||||
slotKey: String(entry?.slotKey || entry?.slot_key || "").trim(),
|
||||
wordText: String(entry?.wordText || entry?.word_text || "").trim(),
|
||||
feedback: String(entry?.feedback || "").trim(),
|
||||
}));
|
||||
}
|
||||
|
||||
function validateFalseWordFeedbacks(falseWordFeedbacks) {
|
||||
if (falseWordFeedbacks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (falseWordFeedbacks.length > 1000) {
|
||||
return {
|
||||
error:
|
||||
"Es dürfen maximal 1000 falsche Wörter mit Feedback gespeichert werden.",
|
||||
status: 422,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
falseWordFeedbacks.some(
|
||||
(entry) => !entry.slotKey || !entry.wordText || !entry.feedback
|
||||
)
|
||||
) {
|
||||
return {
|
||||
error: "Für jedes markierte falsche Wort ist ein Feedback erforderlich.",
|
||||
status: 422,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const POST = withAuth(
|
||||
async (request) => {
|
||||
const json = await request.json();
|
||||
const { title, targetCategory, targetWords, allWords, sourceHtml } = json;
|
||||
const {
|
||||
title,
|
||||
targetCategory,
|
||||
targetWords,
|
||||
allWords,
|
||||
sourceHtml,
|
||||
falseWordFeedbacks,
|
||||
} = json;
|
||||
const sanitizedSourceHtml = sanitizeHtml(sourceHtml || "");
|
||||
const normalizedFalseWordFeedbacks =
|
||||
normalizeFalseWordFeedbacks(falseWordFeedbacks);
|
||||
|
||||
if (!title || !targetCategory || !targetWords || !allWords) {
|
||||
return Response.json(
|
||||
@@ -45,6 +93,17 @@ export const POST = withAuth(
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
const falseWordValidationError = validateFalseWordFeedbacks(
|
||||
normalizedFalseWordFeedbacks
|
||||
);
|
||||
if (falseWordValidationError) {
|
||||
return Response.json(
|
||||
{ error: falseWordValidationError.error },
|
||||
{ status: falseWordValidationError.status }
|
||||
);
|
||||
}
|
||||
|
||||
const existingExercise = await DB.pool(
|
||||
`SELECT ce.id
|
||||
FROM click_exercises ce
|
||||
@@ -59,23 +118,38 @@ export const POST = withAuth(
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
const id = await DB.pool(
|
||||
`INSERT INTO click_exercises (title, category, target_words, all_words, source_html)
|
||||
VALUES ($1, $2, $3, $4, $5) returning id`,
|
||||
[title, targetCategory, targetWords, allWords, sanitizedSourceHtml]
|
||||
);
|
||||
const id = await DB.transaction(async (tx) => {
|
||||
const insertResult = await tx.query(
|
||||
`INSERT INTO click_exercises (title, category, target_words, all_words, source_html)
|
||||
VALUES ($1, $2, $3, $4, $5) returning id`,
|
||||
[title, targetCategory, targetWords, allWords, sanitizedSourceHtml]
|
||||
);
|
||||
|
||||
await DB.pool(
|
||||
`UPDATE exercises e
|
||||
SET created_by = $1,
|
||||
updated_by = $1
|
||||
FROM click_to_exercises cte
|
||||
WHERE cte.exercise_id = e.id
|
||||
AND cte.click_id = $2`,
|
||||
[request.user?.id ?? null, id.rows[0].id]
|
||||
);
|
||||
const clickExerciseId = insertResult.rows[0].id;
|
||||
|
||||
return Response.json({ id: id.rows[0].id }, { status: 201 });
|
||||
for (const entry of normalizedFalseWordFeedbacks) {
|
||||
await tx.query(
|
||||
`INSERT INTO click_false_word_feedbacks
|
||||
(click_exercise_id, slot_key, word_text, feedback)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[clickExerciseId, entry.slotKey, entry.wordText, entry.feedback]
|
||||
);
|
||||
}
|
||||
|
||||
await tx.query(
|
||||
`UPDATE exercises e
|
||||
SET created_by = $1,
|
||||
updated_by = $1
|
||||
FROM click_to_exercises cte
|
||||
WHERE cte.exercise_id = e.id
|
||||
AND cte.click_id = $2`,
|
||||
[request.user?.id ?? null, clickExerciseId]
|
||||
);
|
||||
|
||||
return clickExerciseId;
|
||||
});
|
||||
|
||||
return Response.json({ id }, { status: 201 });
|
||||
},
|
||||
{
|
||||
requireAdmin: true,
|
||||
|
||||
@@ -18,6 +18,13 @@ describe("create click_exercises API", () => {
|
||||
targetCategory: "Verben",
|
||||
targetWords: ["laufen", "springen", "schwimmen"],
|
||||
allWords: ["Die", "Kinder", "laufen", "springen", "schwimmen"],
|
||||
falseWordFeedbacks: [
|
||||
{
|
||||
slotKey: "w-0",
|
||||
wordText: "Die",
|
||||
feedback: "Das ist hier kein Verb.",
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
@@ -32,6 +39,20 @@ describe("create click_exercises API", () => {
|
||||
[json.id]
|
||||
);
|
||||
expect(exercise.rows.length).toBe(1);
|
||||
|
||||
const falseWordFeedbacks = await DB.pool(
|
||||
`SELECT slot_key, word_text, feedback
|
||||
FROM click_false_word_feedbacks
|
||||
WHERE click_exercise_id = $1`,
|
||||
[json.id]
|
||||
);
|
||||
expect(falseWordFeedbacks.rows).toEqual([
|
||||
{
|
||||
slot_key: "w-0",
|
||||
word_text: "Die",
|
||||
feedback: "Das ist hier kein Verb.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("should return 400 if required fields are missing", async () => {
|
||||
const admin = await TestFactory.user({ is_admin: true });
|
||||
|
||||
@@ -18,6 +18,14 @@ export const GET = withAuth(async (request, { params }) => {
|
||||
[click_id]
|
||||
);
|
||||
|
||||
const falseWordFeedbacks = await DB.pool(
|
||||
`SELECT slot_key, word_text, feedback
|
||||
FROM click_false_word_feedbacks
|
||||
WHERE click_exercise_id = $1
|
||||
ORDER BY id ASC`,
|
||||
[click_id]
|
||||
);
|
||||
|
||||
if (exercise.rows.length === 0) {
|
||||
return Response.json({ message: "Keine Übung gefunden." }, { status: 404 });
|
||||
}
|
||||
@@ -33,6 +41,7 @@ export const GET = withAuth(async (request, { params }) => {
|
||||
return Response.json(
|
||||
{
|
||||
exercise: exercise.rows[0],
|
||||
falseWordFeedbacks: falseWordFeedbacks.rows,
|
||||
userAnswers: answer.rows[0],
|
||||
},
|
||||
{ status: 200 }
|
||||
@@ -43,6 +52,7 @@ export const GET = withAuth(async (request, { params }) => {
|
||||
return Response.json(
|
||||
{
|
||||
exercise: exercise.rows[0],
|
||||
falseWordFeedbacks: falseWordFeedbacks.rows,
|
||||
userAnswers: null,
|
||||
},
|
||||
{ status: 200 }
|
||||
|
||||
@@ -19,6 +19,13 @@ describe("click_exercises API", () => {
|
||||
all_words: ["Die", "Kinder", "laufen", "springen", "schwimmen"],
|
||||
});
|
||||
|
||||
await TestFactory.clickFalseWordFeedback({
|
||||
click_exercise_id: exercise.id,
|
||||
slot_key: "w-0",
|
||||
word_text: "Die",
|
||||
feedback: "Das ist hier kein Verb.",
|
||||
});
|
||||
|
||||
// Insert a user answer for the exercise
|
||||
await DB.pool(
|
||||
`INSERT INTO click_answers (user_id, click_exercise_id, answer, target_words)
|
||||
@@ -54,6 +61,13 @@ describe("click_exercises API", () => {
|
||||
"springen",
|
||||
"schwimmen",
|
||||
]);
|
||||
expect(json.falseWordFeedbacks).toEqual([
|
||||
{
|
||||
slot_key: "w-0",
|
||||
word_text: "Die",
|
||||
feedback: "Das ist hier kein Verb.",
|
||||
},
|
||||
]);
|
||||
expect(json.userAnswers).toBeDefined();
|
||||
expect(json.userAnswers.answer).toEqual(["laufen", "springen"]);
|
||||
});
|
||||
@@ -94,6 +108,7 @@ describe("click_exercises API", () => {
|
||||
"und",
|
||||
"langsam",
|
||||
]);
|
||||
expect(json.falseWordFeedbacks).toEqual([]);
|
||||
expect(json.userAnswers).toBeNull(); // No user answers exist
|
||||
});
|
||||
|
||||
|
||||
@@ -22,7 +22,30 @@ export const GET = withAuth(async () => {
|
||||
e.created_at DESC
|
||||
`);
|
||||
|
||||
return NextResponse.json(rows);
|
||||
const feedbackRows = await DB.pool(
|
||||
`SELECT click_exercise_id, slot_key, word_text, feedback
|
||||
FROM click_false_word_feedbacks
|
||||
ORDER BY id ASC`
|
||||
);
|
||||
|
||||
const feedbackByExerciseId = new Map();
|
||||
feedbackRows.rows.forEach((row) => {
|
||||
if (!feedbackByExerciseId.has(row.click_exercise_id)) {
|
||||
feedbackByExerciseId.set(row.click_exercise_id, []);
|
||||
}
|
||||
feedbackByExerciseId.get(row.click_exercise_id).push({
|
||||
slot_key: row.slot_key,
|
||||
word_text: row.word_text,
|
||||
feedback: row.feedback,
|
||||
});
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
rows.map((row) => ({
|
||||
...row,
|
||||
false_word_feedbacks: feedbackByExerciseId.get(row.click_id) || [],
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error fetching click exercises:", error);
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, it, expect } from "vitest";
|
||||
import { GET } 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("click_exercises API", () => {
|
||||
@@ -28,6 +27,13 @@ describe("click_exercises API", () => {
|
||||
all_words: ["Der", "Hund", "ist", "schnell", "und", "langsam"],
|
||||
});
|
||||
|
||||
await TestFactory.clickFalseWordFeedback({
|
||||
click_exercise_id: click_exercise2.id,
|
||||
slot_key: "w-0",
|
||||
word_text: "Der",
|
||||
feedback: "Das ist kein Adjektiv.",
|
||||
});
|
||||
|
||||
const response = await GET(mockGet("/api/exercises/click"));
|
||||
|
||||
expect(response).toBeDefined();
|
||||
@@ -48,6 +54,13 @@ describe("click_exercises API", () => {
|
||||
"und",
|
||||
"langsam",
|
||||
]);
|
||||
expect(json[0].false_word_feedbacks).toEqual([
|
||||
{
|
||||
slot_key: "w-0",
|
||||
word_text: "Der",
|
||||
feedback: "Das ist kein Adjektiv.",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(json[1].title).toBe("Verben identifizieren");
|
||||
expect(json[1].category).toBe("Verben");
|
||||
@@ -59,6 +72,7 @@ describe("click_exercises API", () => {
|
||||
"springen",
|
||||
"schwimmen",
|
||||
]);
|
||||
expect(json[1].false_word_feedbacks).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return an empty array if no click exercises exist", async () => {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
.false-word-feedback-panel {
|
||||
margin-top: var(--u-md);
|
||||
padding: var(--u-sm);
|
||||
border: 1px solid var(--bg3);
|
||||
border-radius: var(--u-sm);
|
||||
background: var(--bg2);
|
||||
}
|
||||
|
||||
.false-word-feedback-heading {
|
||||
margin: 0 0 var(--u-sm) 0;
|
||||
}
|
||||
|
||||
.false-word-feedback-grid {
|
||||
display: grid;
|
||||
gap: var(--u-sm);
|
||||
}
|
||||
|
||||
.false-word-feedback-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 180px) minmax(320px, 1fr);
|
||||
gap: var(--u-sm);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.false-word-feedback-item-word {
|
||||
font-weight: 700;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.false-word-feedback-item-text {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.false-word-feedback-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import WordSelectionExercise from "@/components/ui/click/click.js";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import AdminVisibleLastModified from "@/components/ui/admin-visible-last-modified";
|
||||
import useQuery from "@/shared/hooks/useQuery";
|
||||
import { useRequest } from "@/shared/hooks/useRequest";
|
||||
import "./page.css";
|
||||
|
||||
export default function StudentExercisePage() {
|
||||
const params = useParams();
|
||||
@@ -21,6 +22,14 @@ export default function StudentExercisePage() {
|
||||
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [falseWordFeedbacks, setFalseWordFeedbacks] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSubmitted) {
|
||||
setFeedback("");
|
||||
setFalseWordFeedbacks([]);
|
||||
}
|
||||
}, [isSubmitted]);
|
||||
|
||||
const exercise = data?.exercise || null;
|
||||
|
||||
@@ -34,8 +43,14 @@ export default function StudentExercisePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (selectedWords, score, feedbackMessage) => {
|
||||
const handleSubmit = async (
|
||||
selectedWords,
|
||||
score,
|
||||
feedbackMessage,
|
||||
selectedFalseWordFeedbacks = []
|
||||
) => {
|
||||
setFeedback(feedbackMessage);
|
||||
setFalseWordFeedbacks(selectedFalseWordFeedbacks);
|
||||
setIsSubmitted(true);
|
||||
|
||||
// Save answers to the database
|
||||
@@ -65,6 +80,7 @@ export default function StudentExercisePage() {
|
||||
targetWords={exercise.target_words}
|
||||
allWords={exercise.all_words}
|
||||
sourceHtml={exercise.source_html}
|
||||
falseWordFeedbacks={data.falseWordFeedbacks || []}
|
||||
previousAnswers={data.userAnswers?.answer || []}
|
||||
isPreviewMode={false}
|
||||
onSubmit={handleSubmit} // Pass the submit handler as a prop
|
||||
@@ -73,6 +89,29 @@ export default function StudentExercisePage() {
|
||||
feedback={feedback} // Pass feedback as a prop
|
||||
/>
|
||||
|
||||
{falseWordFeedbacks.length > 0 && (
|
||||
<div className="false-word-feedback-panel">
|
||||
<h3 className="false-word-feedback-heading">
|
||||
Feedback zu falschen Wörtern
|
||||
</h3>
|
||||
<div className="false-word-feedback-grid">
|
||||
{falseWordFeedbacks.map((entry) => (
|
||||
<div
|
||||
key={`${entry.slotKey}-${entry.feedback}`}
|
||||
className="false-word-feedback-item"
|
||||
>
|
||||
<div className="false-word-feedback-item-word">
|
||||
{entry.wordText || "Wort"}
|
||||
</div>
|
||||
<div className="false-word-feedback-item-text">
|
||||
{entry.feedback}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<br />
|
||||
<div>
|
||||
<Button
|
||||
|
||||
@@ -186,6 +186,21 @@ const clickExercise = modelFactory(
|
||||
async (_base) => {}
|
||||
);
|
||||
|
||||
const clickFalseWordFeedback = modelFactory(
|
||||
"click_false_word_feedbacks",
|
||||
{
|
||||
slot_key: "w-0",
|
||||
word_text: faker.lorem.word(),
|
||||
feedback: faker.lorem.sentence(),
|
||||
},
|
||||
async (base) => {
|
||||
if (!base.click_exercise_id) {
|
||||
const exercise = await clickExercise();
|
||||
base.click_exercise_id = exercise.id;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const fillGapExercise = modelFactory(
|
||||
"fill_gap_exercises",
|
||||
{
|
||||
@@ -257,6 +272,7 @@ export const TestFactory = {
|
||||
multichoiceContent,
|
||||
multichoiceOption,
|
||||
clickExercise,
|
||||
clickFalseWordFeedback,
|
||||
fillGapExercise,
|
||||
fillGapGap,
|
||||
fillGapAnswer,
|
||||
|
||||
@@ -295,11 +295,15 @@ const WordSelectionExercise = ({
|
||||
targetWords,
|
||||
allWords,
|
||||
sourceHtml,
|
||||
falseWordFeedbacks = [],
|
||||
previousAnswers,
|
||||
onSelectionChange,
|
||||
previewSelectionKeys,
|
||||
onPreviewSelectionChange,
|
||||
previewGroupAdjacentSelection = true,
|
||||
previewAutoSelectTargets = true,
|
||||
previewHighlightTargets = true,
|
||||
instructionText,
|
||||
onSubmit,
|
||||
isPreviewMode = false,
|
||||
isSubmitted,
|
||||
@@ -435,6 +439,28 @@ const WordSelectionExercise = ({
|
||||
[targetSlotKeys]
|
||||
);
|
||||
|
||||
const falseWordFeedbackMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
|
||||
(Array.isArray(falseWordFeedbacks) ? falseWordFeedbacks : []).forEach(
|
||||
(entry) => {
|
||||
const slotKey = entry?.slot_key || entry?.slotKey;
|
||||
const feedbackText = entry?.feedback;
|
||||
if (!slotKey || !feedbackText) {
|
||||
return;
|
||||
}
|
||||
|
||||
map.set(slotKey, {
|
||||
slotKey,
|
||||
wordText: entry?.word_text || entry?.wordText || "",
|
||||
feedback: feedbackText,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return map;
|
||||
}, [falseWordFeedbacks]);
|
||||
|
||||
const isControlledPreview =
|
||||
isPreviewMode &&
|
||||
Array.isArray(previewSelectionKeys) &&
|
||||
@@ -445,18 +471,40 @@ const WordSelectionExercise = ({
|
||||
: selectedSlotKeys;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isControlledPreview && isPreviewMode && targetSlotKeys.length > 0) {
|
||||
if (
|
||||
!isControlledPreview &&
|
||||
isPreviewMode &&
|
||||
previewAutoSelectTargets &&
|
||||
targetSlotKeys.length > 0
|
||||
) {
|
||||
setSelectedSlotKeys(orderSlotKeys(targetSlotKeys, wordSlots));
|
||||
}
|
||||
}, [targetSlotKeys, isPreviewMode, wordSlots, isControlledPreview]);
|
||||
}, [
|
||||
targetSlotKeys,
|
||||
isPreviewMode,
|
||||
wordSlots,
|
||||
isControlledPreview,
|
||||
previewAutoSelectTargets,
|
||||
]);
|
||||
|
||||
const displayedSelectedSet = useMemo(() => {
|
||||
if (!isPreviewMode) {
|
||||
return new Set(selectedSlotKeys);
|
||||
}
|
||||
|
||||
return new Set([...targetSlotKeys, ...previewSelection]);
|
||||
}, [isPreviewMode, selectedSlotKeys, targetSlotKeys, previewSelection]);
|
||||
const selected = [...previewSelection];
|
||||
if (previewHighlightTargets) {
|
||||
selected.push(...targetSlotKeys);
|
||||
}
|
||||
|
||||
return new Set(selected);
|
||||
}, [
|
||||
isPreviewMode,
|
||||
selectedSlotKeys,
|
||||
targetSlotKeys,
|
||||
previewSelection,
|
||||
previewHighlightTargets,
|
||||
]);
|
||||
|
||||
const handleWordClick = (slotKey) => {
|
||||
if (isSubmitted && !isPreviewMode) return;
|
||||
@@ -631,7 +679,17 @@ const WordSelectionExercise = ({
|
||||
: selectedSlotKeys.map((key) => slotToWord[key]).filter(Boolean);
|
||||
}
|
||||
|
||||
onSubmit(selectedPayload, score, feedbackMessage);
|
||||
const selectedFalseWordFeedbacks = selectedSlotKeys
|
||||
.filter((slotKey) => !targetSlotKeySet.has(slotKey))
|
||||
.map((slotKey) => falseWordFeedbackMap.get(slotKey))
|
||||
.filter(Boolean);
|
||||
|
||||
onSubmit(
|
||||
selectedPayload,
|
||||
score,
|
||||
feedbackMessage,
|
||||
selectedFalseWordFeedbacks
|
||||
);
|
||||
};
|
||||
|
||||
const resetExercise = () => {
|
||||
@@ -740,7 +798,10 @@ const WordSelectionExercise = ({
|
||||
<Column gap="md">
|
||||
<h1>{title}</h1>
|
||||
<div className="click-instruction">
|
||||
<i>{`Wähle alle ${targetCategory} aus dem untenstehenden Text aus.`}</i>
|
||||
<i>
|
||||
{instructionText ||
|
||||
`Wähle alle ${targetCategory} aus dem untenstehenden Text aus.`}
|
||||
</i>
|
||||
</div>
|
||||
{htmlSlotsData.slots.length > 0 ? (
|
||||
<Container className="word-container click-source-html">
|
||||
|
||||
Reference in New Issue
Block a user