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
|
ADD CONSTRAINT click_answers_user_id_fkey
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
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 (
|
CREATE TABLE public.click_to_exercises (
|
||||||
id bigint PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
id bigint PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
||||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||||
|
|||||||
@@ -54,6 +54,19 @@ CREATE TABLE public.click_exercises (
|
|||||||
source_html text
|
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
|
ALTER TABLE public.click_exercises
|
||||||
ADD CONSTRAINT click_exercises_all_words_target_words_key
|
ADD CONSTRAINT click_exercises_all_words_target_words_key
|
||||||
UNIQUE (all_words, target_words);
|
UNIQUE (all_words, target_words);
|
||||||
|
|||||||
@@ -47,6 +47,11 @@
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.correct-word-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
@@ -78,4 +83,40 @@
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
word-wrap: break-word;
|
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";
|
"use client";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { Container } from "@/components/ui/layout/container";
|
import { Container } from "@/components/ui/layout/container";
|
||||||
import { Column } 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 { withBasePath } from "@/shared/utils/basePath";
|
||||||
import AdminLastModified from "@/components/ui/admin-last-modified";
|
import AdminLastModified from "@/components/ui/admin-last-modified";
|
||||||
|
|
||||||
|
const WORD_REGEX = /\p{L}+(?:['’-]\p{L}+)*/gu;
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
return String(value || "")
|
return String(value || "")
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
@@ -90,6 +92,25 @@ function splitIntoContiguousChunks(slotKeys) {
|
|||||||
return chunks;
|
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() {
|
export default function CreateExercise() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { click_id } = useParams();
|
const { click_id } = useParams();
|
||||||
@@ -103,6 +124,9 @@ export default function CreateExercise() {
|
|||||||
const [allWordsHtml, setAllWordsHtml] = useState("");
|
const [allWordsHtml, setAllWordsHtml] = useState("");
|
||||||
const [allWordsText, setAllWordsText] = useState("");
|
const [allWordsText, setAllWordsText] = useState("");
|
||||||
const [selectedWords, setSelectedWords] = useState([]);
|
const [selectedWords, setSelectedWords] = useState([]);
|
||||||
|
const [showFalseWordSelection, setShowFalseWordSelection] = useState(false);
|
||||||
|
const [falseWordSelectionKeys, setFalseWordSelectionKeys] = useState([]);
|
||||||
|
const [falseWordFeedbackByKey, setFalseWordFeedbackByKey] = useState({});
|
||||||
const [previewMode, setPreviewMode] = useState(false);
|
const [previewMode, setPreviewMode] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -141,6 +165,24 @@ export default function CreateExercise() {
|
|||||||
setSelectedWords(
|
setSelectedWords(
|
||||||
Array.isArray(exerciseData.target_words) ? exerciseData.target_words : []
|
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]);
|
}, [exerciseData, isEditMode]);
|
||||||
|
|
||||||
const handleEditorContentChange = (html) => {
|
const handleEditorContentChange = (html) => {
|
||||||
@@ -152,6 +194,48 @@ export default function CreateExercise() {
|
|||||||
const allWords =
|
const allWords =
|
||||||
allWordsText.replace(/\r\n/g, "\n").match(/[^\S\n]+|\n|[^\s]+/g) ?? [];
|
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) => {
|
const handlePreview = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setPreviewMode(true);
|
setPreviewMode(true);
|
||||||
@@ -169,8 +253,16 @@ export default function CreateExercise() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniqueSlotKeys = [...new Set(entries.map((entry) => entry.slotKey))];
|
const existingGroupedEntries = entries.filter((entry) => entry.groupId);
|
||||||
const chunks = splitIntoContiguousChunks(uniqueSlotKeys);
|
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);
|
const hasElementChunk = chunks.some((chunk) => chunk.length > 1);
|
||||||
|
|
||||||
if (!hasElementChunk) {
|
if (!hasElementChunk) {
|
||||||
@@ -180,16 +272,64 @@ export default function CreateExercise() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextTargets = chunks.flatMap((chunk, chunkIndex) => {
|
const groupedExistingRaw = existingGroupedEntries.map((entry) => entry.raw);
|
||||||
|
const nextTargetsFromUngrouped = chunks.flatMap((chunk, chunkIndex) => {
|
||||||
if (chunk.length <= 1) {
|
if (chunk.length <= 1) {
|
||||||
return chunk;
|
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}`);
|
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 () => {
|
const handleSaveExercise = async () => {
|
||||||
@@ -197,6 +337,30 @@ export default function CreateExercise() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setTitleError(false);
|
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) {
|
if (isEditMode) {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
withBasePath(`/api/admin/exercises/click/${click_id}`),
|
withBasePath(`/api/admin/exercises/click/${click_id}`),
|
||||||
@@ -209,6 +373,7 @@ export default function CreateExercise() {
|
|||||||
targetWords: selectedWords,
|
targetWords: selectedWords,
|
||||||
allWords,
|
allWords,
|
||||||
sourceHtml: allWordsHtml,
|
sourceHtml: allWordsHtml,
|
||||||
|
falseWordFeedbacks: falseWordFeedbacksPayload,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -232,6 +397,7 @@ export default function CreateExercise() {
|
|||||||
targetWords: selectedWords,
|
targetWords: selectedWords,
|
||||||
allWords,
|
allWords,
|
||||||
sourceHtml: allWordsHtml,
|
sourceHtml: allWordsHtml,
|
||||||
|
falseWordFeedbacks: falseWordFeedbacksPayload,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -385,7 +551,7 @@ export default function CreateExercise() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
<Container>
|
<Container className="correct-word-actions">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -395,6 +561,66 @@ export default function CreateExercise() {
|
|||||||
Als Element übernehmen
|
Als Element übernehmen
|
||||||
</Button>
|
</Button>
|
||||||
</Container>
|
</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>
|
<Container>
|
||||||
<Button size="sm" variant="secondary" onClick={handleEditAgain}>
|
<Button size="sm" variant="secondary" onClick={handleEditAgain}>
|
||||||
Übung bearbeiten
|
Übung bearbeiten
|
||||||
|
|||||||
@@ -72,6 +72,14 @@ export const GET = withAuth(
|
|||||||
[click_id]
|
[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) {
|
if (result.rows.length === 0) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Übung nicht gefunden." },
|
{ 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) {
|
} catch (error) {
|
||||||
console.error("Error fetching click exercise:", error);
|
console.error("Error fetching click exercise:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -101,6 +112,15 @@ export const PUT = withAuth(
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { title, targetCategory, targetWords, allWords, sourceHtml } = body;
|
const { title, targetCategory, targetWords, allWords, sourceHtml } = body;
|
||||||
const sanitizedSourceHtml = sanitizeHtml(sourceHtml || "");
|
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({
|
const validationError = validatePayload({
|
||||||
title,
|
title,
|
||||||
@@ -144,33 +164,54 @@ export const PUT = withAuth(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await DB.pool(
|
await DB.transaction(async (tx) => {
|
||||||
`UPDATE click_exercises
|
await tx.query(
|
||||||
SET title = $1,
|
`UPDATE click_exercises
|
||||||
category = $2,
|
SET title = $1,
|
||||||
target_words = $3,
|
category = $2,
|
||||||
all_words = $4,
|
target_words = $3,
|
||||||
source_html = $5,
|
all_words = $4,
|
||||||
updated_at = NOW()
|
source_html = $5,
|
||||||
WHERE id = $6`,
|
updated_at = NOW()
|
||||||
[
|
WHERE id = $6`,
|
||||||
title,
|
[
|
||||||
targetCategory,
|
title,
|
||||||
targetWords,
|
targetCategory,
|
||||||
allWords,
|
targetWords,
|
||||||
sanitizedSourceHtml,
|
allWords,
|
||||||
click_id,
|
sanitizedSourceHtml,
|
||||||
]
|
click_id,
|
||||||
);
|
]
|
||||||
|
);
|
||||||
|
|
||||||
await DB.pool(
|
await tx.query(
|
||||||
`UPDATE exercises e
|
`DELETE FROM click_false_word_feedbacks
|
||||||
SET updated_by = $1
|
WHERE click_exercise_id = $1`,
|
||||||
FROM click_to_exercises cte
|
[click_id]
|
||||||
WHERE cte.exercise_id = e.id
|
);
|
||||||
AND cte.click_id = $2`,
|
|
||||||
[request.user?.id ?? null, 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 });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -9,11 +9,59 @@ function sanitizeHtml(html) {
|
|||||||
return purify.sanitize(String(html || ""), { ADD_ATTR: ["target"] });
|
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(
|
export const POST = withAuth(
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const json = await request.json();
|
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 sanitizedSourceHtml = sanitizeHtml(sourceHtml || "");
|
||||||
|
const normalizedFalseWordFeedbacks =
|
||||||
|
normalizeFalseWordFeedbacks(falseWordFeedbacks);
|
||||||
|
|
||||||
if (!title || !targetCategory || !targetWords || !allWords) {
|
if (!title || !targetCategory || !targetWords || !allWords) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
@@ -45,6 +93,17 @@ export const POST = withAuth(
|
|||||||
{ status: 422 }
|
{ status: 422 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const falseWordValidationError = validateFalseWordFeedbacks(
|
||||||
|
normalizedFalseWordFeedbacks
|
||||||
|
);
|
||||||
|
if (falseWordValidationError) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: falseWordValidationError.error },
|
||||||
|
{ status: falseWordValidationError.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const existingExercise = await DB.pool(
|
const existingExercise = await DB.pool(
|
||||||
`SELECT ce.id
|
`SELECT ce.id
|
||||||
FROM click_exercises ce
|
FROM click_exercises ce
|
||||||
@@ -59,23 +118,38 @@ export const POST = withAuth(
|
|||||||
{ status: 409 }
|
{ status: 409 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const id = await DB.pool(
|
const id = await DB.transaction(async (tx) => {
|
||||||
`INSERT INTO click_exercises (title, category, target_words, all_words, source_html)
|
const insertResult = await tx.query(
|
||||||
VALUES ($1, $2, $3, $4, $5) returning id`,
|
`INSERT INTO click_exercises (title, category, target_words, all_words, source_html)
|
||||||
[title, targetCategory, targetWords, allWords, sanitizedSourceHtml]
|
VALUES ($1, $2, $3, $4, $5) returning id`,
|
||||||
);
|
[title, targetCategory, targetWords, allWords, sanitizedSourceHtml]
|
||||||
|
);
|
||||||
|
|
||||||
await DB.pool(
|
const clickExerciseId = insertResult.rows[0].id;
|
||||||
`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]
|
|
||||||
);
|
|
||||||
|
|
||||||
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,
|
requireAdmin: true,
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ describe("create click_exercises API", () => {
|
|||||||
targetCategory: "Verben",
|
targetCategory: "Verben",
|
||||||
targetWords: ["laufen", "springen", "schwimmen"],
|
targetWords: ["laufen", "springen", "schwimmen"],
|
||||||
allWords: ["Die", "Kinder", "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]
|
[json.id]
|
||||||
);
|
);
|
||||||
expect(exercise.rows.length).toBe(1);
|
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 () => {
|
it("should return 400 if required fields are missing", async () => {
|
||||||
const admin = await TestFactory.user({ is_admin: true });
|
const admin = await TestFactory.user({ is_admin: true });
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ export const GET = withAuth(async (request, { params }) => {
|
|||||||
[click_id]
|
[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) {
|
if (exercise.rows.length === 0) {
|
||||||
return Response.json({ message: "Keine Übung gefunden." }, { status: 404 });
|
return Response.json({ message: "Keine Übung gefunden." }, { status: 404 });
|
||||||
}
|
}
|
||||||
@@ -33,6 +41,7 @@ export const GET = withAuth(async (request, { params }) => {
|
|||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
exercise: exercise.rows[0],
|
exercise: exercise.rows[0],
|
||||||
|
falseWordFeedbacks: falseWordFeedbacks.rows,
|
||||||
userAnswers: answer.rows[0],
|
userAnswers: answer.rows[0],
|
||||||
},
|
},
|
||||||
{ status: 200 }
|
{ status: 200 }
|
||||||
@@ -43,6 +52,7 @@ export const GET = withAuth(async (request, { params }) => {
|
|||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
exercise: exercise.rows[0],
|
exercise: exercise.rows[0],
|
||||||
|
falseWordFeedbacks: falseWordFeedbacks.rows,
|
||||||
userAnswers: null,
|
userAnswers: null,
|
||||||
},
|
},
|
||||||
{ status: 200 }
|
{ status: 200 }
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ describe("click_exercises API", () => {
|
|||||||
all_words: ["Die", "Kinder", "laufen", "springen", "schwimmen"],
|
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
|
// Insert a user answer for the exercise
|
||||||
await DB.pool(
|
await DB.pool(
|
||||||
`INSERT INTO click_answers (user_id, click_exercise_id, answer, target_words)
|
`INSERT INTO click_answers (user_id, click_exercise_id, answer, target_words)
|
||||||
@@ -54,6 +61,13 @@ describe("click_exercises API", () => {
|
|||||||
"springen",
|
"springen",
|
||||||
"schwimmen",
|
"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).toBeDefined();
|
||||||
expect(json.userAnswers.answer).toEqual(["laufen", "springen"]);
|
expect(json.userAnswers.answer).toEqual(["laufen", "springen"]);
|
||||||
});
|
});
|
||||||
@@ -94,6 +108,7 @@ describe("click_exercises API", () => {
|
|||||||
"und",
|
"und",
|
||||||
"langsam",
|
"langsam",
|
||||||
]);
|
]);
|
||||||
|
expect(json.falseWordFeedbacks).toEqual([]);
|
||||||
expect(json.userAnswers).toBeNull(); // No user answers exist
|
expect(json.userAnswers).toBeNull(); // No user answers exist
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,30 @@ export const GET = withAuth(async () => {
|
|||||||
e.created_at DESC
|
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) {
|
} catch (error) {
|
||||||
console.error("Error fetching click exercises:", error);
|
console.error("Error fetching click exercises:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { describe, it, expect } from "vitest";
|
|||||||
import { GET } from "./route";
|
import { GET } from "./route";
|
||||||
import { useTestDatabase } from "@/backend/test/testdb";
|
import { useTestDatabase } from "@/backend/test/testdb";
|
||||||
import { useTestRequest } from "@/backend/test/mock-request";
|
import { useTestRequest } from "@/backend/test/mock-request";
|
||||||
import { DB } from "@/backend/db";
|
|
||||||
import { TestFactory } from "@/backend/test/testfactory";
|
import { TestFactory } from "@/backend/test/testfactory";
|
||||||
|
|
||||||
describe("click_exercises API", () => {
|
describe("click_exercises API", () => {
|
||||||
@@ -28,6 +27,13 @@ describe("click_exercises API", () => {
|
|||||||
all_words: ["Der", "Hund", "ist", "schnell", "und", "langsam"],
|
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"));
|
const response = await GET(mockGet("/api/exercises/click"));
|
||||||
|
|
||||||
expect(response).toBeDefined();
|
expect(response).toBeDefined();
|
||||||
@@ -48,6 +54,13 @@ describe("click_exercises API", () => {
|
|||||||
"und",
|
"und",
|
||||||
"langsam",
|
"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].title).toBe("Verben identifizieren");
|
||||||
expect(json[1].category).toBe("Verben");
|
expect(json[1].category).toBe("Verben");
|
||||||
@@ -59,6 +72,7 @@ describe("click_exercises API", () => {
|
|||||||
"springen",
|
"springen",
|
||||||
"schwimmen",
|
"schwimmen",
|
||||||
]);
|
]);
|
||||||
|
expect(json[1].false_word_feedbacks).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return an empty array if no click exercises exist", async () => {
|
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";
|
"use client";
|
||||||
import React, { useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import WordSelectionExercise from "@/components/ui/click/click.js";
|
import WordSelectionExercise from "@/components/ui/click/click.js";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import AdminVisibleLastModified from "@/components/ui/admin-visible-last-modified";
|
import AdminVisibleLastModified from "@/components/ui/admin-visible-last-modified";
|
||||||
import useQuery from "@/shared/hooks/useQuery";
|
import useQuery from "@/shared/hooks/useQuery";
|
||||||
import { useRequest } from "@/shared/hooks/useRequest";
|
import { useRequest } from "@/shared/hooks/useRequest";
|
||||||
|
import "./page.css";
|
||||||
|
|
||||||
export default function StudentExercisePage() {
|
export default function StudentExercisePage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -21,6 +22,14 @@ export default function StudentExercisePage() {
|
|||||||
|
|
||||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
const [feedback, setFeedback] = useState("");
|
const [feedback, setFeedback] = useState("");
|
||||||
|
const [falseWordFeedbacks, setFalseWordFeedbacks] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSubmitted) {
|
||||||
|
setFeedback("");
|
||||||
|
setFalseWordFeedbacks([]);
|
||||||
|
}
|
||||||
|
}, [isSubmitted]);
|
||||||
|
|
||||||
const exercise = data?.exercise || null;
|
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);
|
setFeedback(feedbackMessage);
|
||||||
|
setFalseWordFeedbacks(selectedFalseWordFeedbacks);
|
||||||
setIsSubmitted(true);
|
setIsSubmitted(true);
|
||||||
|
|
||||||
// Save answers to the database
|
// Save answers to the database
|
||||||
@@ -65,6 +80,7 @@ export default function StudentExercisePage() {
|
|||||||
targetWords={exercise.target_words}
|
targetWords={exercise.target_words}
|
||||||
allWords={exercise.all_words}
|
allWords={exercise.all_words}
|
||||||
sourceHtml={exercise.source_html}
|
sourceHtml={exercise.source_html}
|
||||||
|
falseWordFeedbacks={data.falseWordFeedbacks || []}
|
||||||
previousAnswers={data.userAnswers?.answer || []}
|
previousAnswers={data.userAnswers?.answer || []}
|
||||||
isPreviewMode={false}
|
isPreviewMode={false}
|
||||||
onSubmit={handleSubmit} // Pass the submit handler as a prop
|
onSubmit={handleSubmit} // Pass the submit handler as a prop
|
||||||
@@ -73,6 +89,29 @@ export default function StudentExercisePage() {
|
|||||||
feedback={feedback} // Pass feedback as a prop
|
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 />
|
<br />
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -186,6 +186,21 @@ const clickExercise = modelFactory(
|
|||||||
async (_base) => {}
|
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(
|
const fillGapExercise = modelFactory(
|
||||||
"fill_gap_exercises",
|
"fill_gap_exercises",
|
||||||
{
|
{
|
||||||
@@ -257,6 +272,7 @@ export const TestFactory = {
|
|||||||
multichoiceContent,
|
multichoiceContent,
|
||||||
multichoiceOption,
|
multichoiceOption,
|
||||||
clickExercise,
|
clickExercise,
|
||||||
|
clickFalseWordFeedback,
|
||||||
fillGapExercise,
|
fillGapExercise,
|
||||||
fillGapGap,
|
fillGapGap,
|
||||||
fillGapAnswer,
|
fillGapAnswer,
|
||||||
|
|||||||
@@ -295,11 +295,15 @@ const WordSelectionExercise = ({
|
|||||||
targetWords,
|
targetWords,
|
||||||
allWords,
|
allWords,
|
||||||
sourceHtml,
|
sourceHtml,
|
||||||
|
falseWordFeedbacks = [],
|
||||||
previousAnswers,
|
previousAnswers,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
previewSelectionKeys,
|
previewSelectionKeys,
|
||||||
onPreviewSelectionChange,
|
onPreviewSelectionChange,
|
||||||
previewGroupAdjacentSelection = true,
|
previewGroupAdjacentSelection = true,
|
||||||
|
previewAutoSelectTargets = true,
|
||||||
|
previewHighlightTargets = true,
|
||||||
|
instructionText,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isPreviewMode = false,
|
isPreviewMode = false,
|
||||||
isSubmitted,
|
isSubmitted,
|
||||||
@@ -435,6 +439,28 @@ const WordSelectionExercise = ({
|
|||||||
[targetSlotKeys]
|
[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 =
|
const isControlledPreview =
|
||||||
isPreviewMode &&
|
isPreviewMode &&
|
||||||
Array.isArray(previewSelectionKeys) &&
|
Array.isArray(previewSelectionKeys) &&
|
||||||
@@ -445,18 +471,40 @@ const WordSelectionExercise = ({
|
|||||||
: selectedSlotKeys;
|
: selectedSlotKeys;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isControlledPreview && isPreviewMode && targetSlotKeys.length > 0) {
|
if (
|
||||||
|
!isControlledPreview &&
|
||||||
|
isPreviewMode &&
|
||||||
|
previewAutoSelectTargets &&
|
||||||
|
targetSlotKeys.length > 0
|
||||||
|
) {
|
||||||
setSelectedSlotKeys(orderSlotKeys(targetSlotKeys, wordSlots));
|
setSelectedSlotKeys(orderSlotKeys(targetSlotKeys, wordSlots));
|
||||||
}
|
}
|
||||||
}, [targetSlotKeys, isPreviewMode, wordSlots, isControlledPreview]);
|
}, [
|
||||||
|
targetSlotKeys,
|
||||||
|
isPreviewMode,
|
||||||
|
wordSlots,
|
||||||
|
isControlledPreview,
|
||||||
|
previewAutoSelectTargets,
|
||||||
|
]);
|
||||||
|
|
||||||
const displayedSelectedSet = useMemo(() => {
|
const displayedSelectedSet = useMemo(() => {
|
||||||
if (!isPreviewMode) {
|
if (!isPreviewMode) {
|
||||||
return new Set(selectedSlotKeys);
|
return new Set(selectedSlotKeys);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Set([...targetSlotKeys, ...previewSelection]);
|
const selected = [...previewSelection];
|
||||||
}, [isPreviewMode, selectedSlotKeys, targetSlotKeys, previewSelection]);
|
if (previewHighlightTargets) {
|
||||||
|
selected.push(...targetSlotKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Set(selected);
|
||||||
|
}, [
|
||||||
|
isPreviewMode,
|
||||||
|
selectedSlotKeys,
|
||||||
|
targetSlotKeys,
|
||||||
|
previewSelection,
|
||||||
|
previewHighlightTargets,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleWordClick = (slotKey) => {
|
const handleWordClick = (slotKey) => {
|
||||||
if (isSubmitted && !isPreviewMode) return;
|
if (isSubmitted && !isPreviewMode) return;
|
||||||
@@ -631,7 +679,17 @@ const WordSelectionExercise = ({
|
|||||||
: selectedSlotKeys.map((key) => slotToWord[key]).filter(Boolean);
|
: 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 = () => {
|
const resetExercise = () => {
|
||||||
@@ -740,7 +798,10 @@ const WordSelectionExercise = ({
|
|||||||
<Column gap="md">
|
<Column gap="md">
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
<div className="click-instruction">
|
<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>
|
</div>
|
||||||
{htmlSlotsData.slots.length > 0 ? (
|
{htmlSlotsData.slots.length > 0 ? (
|
||||||
<Container className="word-container click-source-html">
|
<Container className="word-container click-source-html">
|
||||||
|
|||||||
Reference in New Issue
Block a user