mirror of
https://github.com/OHTU-German-learning-website/OHTU-German-learning-website.git
synced 2026-10-12 19:13:51 +03:00
Merge pull request #526 from erzabytyci/user-tiers
improve jumbled exercise: rename, add feedback option, functionality
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE public.jumbled_sentence_sentences
|
||||
ADD COLUMN IF NOT EXISTS correct_feedback text,
|
||||
ADD COLUMN IF NOT EXISTS alternate_feedbacks text[] NOT NULL DEFAULT ARRAY[]::text[],
|
||||
ADD COLUMN IF NOT EXISTS incorrect_feedbacks text[] NOT NULL DEFAULT ARRAY[]::text[];
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE public.jumbled_sentence_sentences
|
||||
ADD COLUMN IF NOT EXISTS incorrect_alternates text[] NOT NULL DEFAULT ARRAY[]::text[];
|
||||
@@ -423,7 +423,11 @@ CREATE TABLE public.jumbled_sentence_sentences (
|
||||
id bigint PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
||||
jumbled_exercise_id bigint NOT NULL,
|
||||
sentence text NOT NULL,
|
||||
alternates text[] DEFAULT ARRAY[]::text[]
|
||||
alternates text[] DEFAULT ARRAY[]::text[],
|
||||
correct_feedback text,
|
||||
alternate_feedbacks text[] NOT NULL DEFAULT ARRAY[]::text[],
|
||||
incorrect_feedbacks text[] NOT NULL DEFAULT ARRAY[]::text[],
|
||||
incorrect_alternates text[] NOT NULL DEFAULT ARRAY[]::text[]
|
||||
);
|
||||
|
||||
CREATE TABLE public.jumbled_sentence_exercises (
|
||||
|
||||
@@ -23,6 +23,18 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sentenceHint {
|
||||
font-size: 0.9rem;
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
color: var(--fg4);
|
||||
}
|
||||
|
||||
.sentenceTextarea {
|
||||
resize: vertical;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.sentenceCard {
|
||||
border: 1px solid #ccc;
|
||||
padding: 0.5rem;
|
||||
@@ -30,12 +42,33 @@
|
||||
width: 40rem;
|
||||
}
|
||||
|
||||
.alternateGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.alternateGroup+.alternateGroup {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.alternateRow {
|
||||
position: relative;
|
||||
width: min(100%, var(--jumbled-input-max-width));
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.alternateGroup>.alternateRow {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.alternateRowNoGapTop {
|
||||
position: relative;
|
||||
width: min(100%, var(--jumbled-input-max-width));
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.alternateInput {
|
||||
padding-right: 2rem;
|
||||
}
|
||||
@@ -56,15 +89,51 @@
|
||||
}
|
||||
|
||||
.addAlternateButton {
|
||||
min-width: 14rem;
|
||||
width: fit-content;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fitContentButton {
|
||||
width: auto;
|
||||
min-width: 18rem;
|
||||
white-space: nowrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.inlineActionsRow {
|
||||
width: min(100%, var(--jumbled-input-max-width));
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.feedbackSection {
|
||||
width: min(100%, var(--jumbled-input-max-width));
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin-bottom: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sectionTitle+.alternateRowNoGapTop {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.alternateRowNoGapTop+.alternateRowNoGapTop {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.feedbackSection>.fitContentButton {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.feedbackSection>.sectionTitle+.sectionTitle {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.removeSentenceButton {
|
||||
background-color: #f8d7da;
|
||||
border-color: #f5c2c7;
|
||||
|
||||
@@ -9,13 +9,25 @@ import { withBasePath } from "@/shared/utils/basePath";
|
||||
import useQuery from "@/shared/hooks/useQuery";
|
||||
import styles from "./jumbled-sentence.module.css";
|
||||
|
||||
function toWordPerLine(value) {
|
||||
if (typeof value !== "string") return "";
|
||||
return value.trim().split(/\s+/).filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
export default function CreateJumbledSentenceExercise() {
|
||||
const router = useRouter();
|
||||
const { exercise_id } = useParams();
|
||||
const isEditMode = Boolean(exercise_id);
|
||||
const [title, setTitle] = useState("");
|
||||
const [sentences, setSentences] = useState([
|
||||
{ sentence: "", alternates: [""] },
|
||||
{
|
||||
sentence: "",
|
||||
alternates: [""],
|
||||
alternateFeedbacks: [""],
|
||||
correctSentenceFeedback: "",
|
||||
incorrectAlternates: [""],
|
||||
incorrectFeedbacks: [""],
|
||||
},
|
||||
]);
|
||||
const [error, setError] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -38,13 +50,47 @@ export default function CreateJumbledSentenceExercise() {
|
||||
setSentences(
|
||||
(loadedExercise.sentences || []).length
|
||||
? loadedExercise.sentences.map((item) => ({
|
||||
sentence: item.sentence || "",
|
||||
sentence: toWordPerLine(item.sentence || ""),
|
||||
alternates:
|
||||
item.alternates && item.alternates.length > 0
|
||||
? item.alternates
|
||||
: [""],
|
||||
alternateFeedbacks:
|
||||
item.alternateFeedbacks &&
|
||||
item.alternateFeedbacks.length === item.alternates?.length
|
||||
? item.alternateFeedbacks
|
||||
: new Array(
|
||||
item.alternates && item.alternates.length > 0
|
||||
? item.alternates.length
|
||||
: 1
|
||||
).fill(""),
|
||||
correctSentenceFeedback: item.correctSentenceFeedback || "",
|
||||
incorrectAlternates:
|
||||
item.incorrectAlternates && item.incorrectAlternates.length > 0
|
||||
? item.incorrectAlternates
|
||||
: [""],
|
||||
incorrectFeedbacks:
|
||||
item.incorrectFeedbacks &&
|
||||
item.incorrectFeedbacks.length ===
|
||||
(item.incorrectAlternates?.length || 0)
|
||||
? item.incorrectFeedbacks
|
||||
: new Array(
|
||||
item.incorrectAlternates &&
|
||||
item.incorrectAlternates.length > 0
|
||||
? item.incorrectAlternates.length
|
||||
: 1
|
||||
).fill(""),
|
||||
}))
|
||||
: [{ sentence: "", alternates: [""] }]
|
||||
: [
|
||||
{
|
||||
sentence: "",
|
||||
alternates: [""],
|
||||
alternateFeedbacks: [""],
|
||||
correctSentenceFeedback: "",
|
||||
incorrectAlternates: [""],
|
||||
incorrectFeedbacks: [""],
|
||||
},
|
||||
]
|
||||
);
|
||||
}, [isEditMode, data]);
|
||||
|
||||
@@ -60,13 +106,62 @@ export default function CreateJumbledSentenceExercise() {
|
||||
setSentences(updated);
|
||||
};
|
||||
|
||||
const handleAlternateFeedbackChange = (idx, altIdx, value) => {
|
||||
const updated = [...sentences];
|
||||
updated[idx].alternateFeedbacks[altIdx] = value;
|
||||
setSentences(updated);
|
||||
};
|
||||
|
||||
const handleCorrectFeedbackChange = (idx, value) => {
|
||||
const updated = [...sentences];
|
||||
updated[idx].correctSentenceFeedback = value;
|
||||
setSentences(updated);
|
||||
};
|
||||
|
||||
const handleIncorrectFeedbackChange = (idx, fbIdx, value) => {
|
||||
const updated = [...sentences];
|
||||
updated[idx].incorrectFeedbacks[fbIdx] = value;
|
||||
setSentences(updated);
|
||||
};
|
||||
|
||||
const handleIncorrectAlternateChange = (idx, altIdx, value) => {
|
||||
const updated = [...sentences];
|
||||
updated[idx].incorrectAlternates[altIdx] = value;
|
||||
setSentences(updated);
|
||||
};
|
||||
|
||||
const addSentence = () => {
|
||||
setSentences([...sentences, { sentence: "", alternates: [""] }]);
|
||||
setSentences([
|
||||
...sentences,
|
||||
{
|
||||
sentence: "",
|
||||
alternates: [""],
|
||||
alternateFeedbacks: [""],
|
||||
correctSentenceFeedback: "",
|
||||
incorrectAlternates: [""],
|
||||
incorrectFeedbacks: [""],
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const addAlternate = (idx) => {
|
||||
const updated = [...sentences];
|
||||
updated[idx].alternates.push("");
|
||||
updated[idx].alternateFeedbacks.push("");
|
||||
setSentences(updated);
|
||||
};
|
||||
|
||||
const addIncorrectFeedback = (idx) => {
|
||||
const updated = [...sentences];
|
||||
updated[idx].incorrectAlternates.push("");
|
||||
updated[idx].incorrectFeedbacks.push("");
|
||||
setSentences(updated);
|
||||
};
|
||||
|
||||
const addIncorrectAlternate = (idx) => {
|
||||
const updated = [...sentences];
|
||||
updated[idx].incorrectAlternates.push("");
|
||||
updated[idx].incorrectFeedbacks.push("");
|
||||
setSentences(updated);
|
||||
};
|
||||
|
||||
@@ -79,15 +174,87 @@ export default function CreateJumbledSentenceExercise() {
|
||||
updated[idx].alternates = updated[idx].alternates.filter(
|
||||
(_, i) => i !== altIdx
|
||||
);
|
||||
updated[idx].alternateFeedbacks = updated[idx].alternateFeedbacks.filter(
|
||||
(_, i) => i !== altIdx
|
||||
);
|
||||
if (updated[idx].alternates.length === 0) {
|
||||
updated[idx].alternates = [""];
|
||||
updated[idx].alternateFeedbacks = [""];
|
||||
}
|
||||
setSentences(updated);
|
||||
};
|
||||
|
||||
const removeIncorrectAlternate = (idx, altIdx) => {
|
||||
const updated = [...sentences];
|
||||
updated[idx].incorrectAlternates = updated[idx].incorrectAlternates.filter(
|
||||
(_, i) => i !== altIdx
|
||||
);
|
||||
updated[idx].incorrectFeedbacks = updated[idx].incorrectFeedbacks.filter(
|
||||
(_, i) => i !== altIdx
|
||||
);
|
||||
if (updated[idx].incorrectAlternates.length === 0) {
|
||||
updated[idx].incorrectAlternates = [""];
|
||||
updated[idx].incorrectFeedbacks = [""];
|
||||
}
|
||||
setSentences(updated);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
const normalizedSentences = sentences.map((item) => ({
|
||||
...item,
|
||||
sentence: toWordPerLine(item.sentence),
|
||||
alternates: item.alternates
|
||||
.map((alt, index) => ({
|
||||
alt: typeof alt === "string" ? alt.trim() : "",
|
||||
feedback:
|
||||
typeof item.alternateFeedbacks?.[index] === "string"
|
||||
? item.alternateFeedbacks[index].trim()
|
||||
: "",
|
||||
}))
|
||||
.filter((entry) => entry.alt.length > 0)
|
||||
.map((entry) => entry.alt),
|
||||
alternateFeedbacks: item.alternates
|
||||
.map((alt, index) => ({
|
||||
alt: typeof alt === "string" ? alt.trim() : "",
|
||||
feedback:
|
||||
typeof item.alternateFeedbacks?.[index] === "string"
|
||||
? item.alternateFeedbacks[index].trim()
|
||||
: "",
|
||||
}))
|
||||
.filter((entry) => entry.alt.length > 0)
|
||||
.map((entry) => entry.feedback),
|
||||
correctSentenceFeedback:
|
||||
typeof item.correctSentenceFeedback === "string"
|
||||
? item.correctSentenceFeedback.trim()
|
||||
: "",
|
||||
incorrectAlternates: (item.incorrectAlternates || [])
|
||||
.map((alt, index) => ({
|
||||
alt: typeof alt === "string" ? alt.trim() : "",
|
||||
feedback:
|
||||
typeof item.incorrectFeedbacks?.[index] === "string"
|
||||
? item.incorrectFeedbacks[index].trim()
|
||||
: "",
|
||||
}))
|
||||
.filter((entry) => entry.alt.length > 0)
|
||||
.map((entry) => entry.alt),
|
||||
incorrectFeedbacks: (item.incorrectAlternates || [])
|
||||
.map((alt, index) => ({
|
||||
alt: typeof alt === "string" ? alt.trim() : "",
|
||||
feedback:
|
||||
typeof item.incorrectFeedbacks?.[index] === "string"
|
||||
? item.incorrectFeedbacks[index].trim()
|
||||
: "",
|
||||
}))
|
||||
.filter((entry) => entry.alt.length > 0)
|
||||
.map((entry) => entry.feedback),
|
||||
}));
|
||||
|
||||
const parsed = jumbledSentenceExerciseSchema.safeParse({
|
||||
title,
|
||||
sentences,
|
||||
sentences: normalizedSentences,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
setError(
|
||||
@@ -105,7 +272,7 @@ export default function CreateJumbledSentenceExercise() {
|
||||
const res = await fetch(endpoint, {
|
||||
method: isEditMode ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, sentences }),
|
||||
body: JSON.stringify({ title, sentences: normalizedSentences }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data?.error || "Fehler beim Speichern.");
|
||||
@@ -133,9 +300,7 @@ export default function CreateJumbledSentenceExercise() {
|
||||
<form onSubmit={handleSubmit} className={styles.form}>
|
||||
<Column gap="md">
|
||||
<h2>
|
||||
{isEditMode
|
||||
? "Jumbled Sentence Übung bearbeiten"
|
||||
: "Jumbled Sentence Übung erstellen"}
|
||||
{isEditMode ? "Satzmix-Übung bearbeiten" : "Satzmix-Übung erstellen"}
|
||||
</h2>
|
||||
<label className={styles.fieldLabel}>
|
||||
Titel:
|
||||
@@ -147,44 +312,71 @@ export default function CreateJumbledSentenceExercise() {
|
||||
/>
|
||||
</label>
|
||||
<p className={styles.exerciseDescription}>
|
||||
Trage den Satz in der richtigen Reihenfolge ein und optional
|
||||
alternative Reihenfolgen mit <br></br> denselben Wörtern. Für die
|
||||
Lernenden werden die Wörter anschließend gemischt angezeigt.
|
||||
Geben Sie den Satz in der richtigen Reihenfolge und optional in
|
||||
alternativen Reihenfolgen <br></br> mit denselben Elementen ein. Die
|
||||
Elemente werden den Lernenden anschließend in zufälliger <br></br>
|
||||
Reihenfolge angezeigt.
|
||||
</p>
|
||||
{sentences.map((s, idx) => (
|
||||
<div key={idx} className={styles.sentenceCard}>
|
||||
<label className={styles.fieldLabel}>
|
||||
Satz (korrekt):
|
||||
<input
|
||||
<span className={styles.sentenceHint}>
|
||||
Schreiben Sie jedes Element in eine neue Zeile.
|
||||
</span>
|
||||
<textarea
|
||||
value={s.sentence}
|
||||
onChange={(e) => handleSentenceChange(idx, e.target.value)}
|
||||
className={styles.fieldInput}
|
||||
className={`${styles.fieldInput} ${styles.sentenceTextarea}`}
|
||||
rows={6}
|
||||
placeholder={"Ich\nlerne\njeden\nTag\nDeutsch"}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.fieldLabel}>
|
||||
Feedback für korrekten Satz (optional):
|
||||
<input
|
||||
value={s.correctSentenceFeedback}
|
||||
onChange={(e) =>
|
||||
handleCorrectFeedbackChange(idx, e.target.value)
|
||||
}
|
||||
className={styles.fieldInput}
|
||||
placeholder="Sehr gut!"
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
Alternativ-Sätze:
|
||||
<div className={styles.sectionTitle}>Alternativ-Sätze:</div>
|
||||
{s.alternates.map((alt, altIdx) => (
|
||||
<div key={altIdx} className={styles.alternateRow}>
|
||||
<div key={altIdx} className={styles.alternateGroup}>
|
||||
<div className={styles.alternateRow}>
|
||||
<input
|
||||
value={alt}
|
||||
onChange={(e) =>
|
||||
handleAlternateChange(idx, altIdx, e.target.value)
|
||||
}
|
||||
className={`${styles.fieldInput} ${styles.alternateInput}`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => removeAlternate(idx, altIdx)}
|
||||
disabled={s.alternates.length === 1}
|
||||
variant="none"
|
||||
width="fit"
|
||||
className={styles.alternateDeleteButton}
|
||||
style={{ color: "#c62828" }}
|
||||
aria-label="Alternative entfernen"
|
||||
>
|
||||
X
|
||||
</Button>
|
||||
</div>
|
||||
<input
|
||||
value={alt}
|
||||
value={s.alternateFeedbacks[altIdx] || ""}
|
||||
onChange={(e) =>
|
||||
handleAlternateChange(idx, altIdx, e.target.value)
|
||||
handleAlternateFeedbackChange(idx, altIdx, e.target.value)
|
||||
}
|
||||
className={`${styles.fieldInput} ${styles.alternateInput}`}
|
||||
className={styles.fieldInput}
|
||||
placeholder="Feedback für diese Alternative (optional)"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => removeAlternate(idx, altIdx)}
|
||||
disabled={s.alternates.length === 1}
|
||||
variant="none"
|
||||
width="fit"
|
||||
className={styles.alternateDeleteButton}
|
||||
style={{ color: "#c62828" }}
|
||||
aria-label="Alternative entfernen"
|
||||
>
|
||||
X
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Row
|
||||
@@ -195,7 +387,7 @@ export default function CreateJumbledSentenceExercise() {
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => addAlternate(idx)}
|
||||
className={styles.addAlternateButton}
|
||||
className={styles.fitContentButton}
|
||||
>
|
||||
Alternative hinzufügen
|
||||
</Button>
|
||||
@@ -214,6 +406,56 @@ export default function CreateJumbledSentenceExercise() {
|
||||
</Button>
|
||||
</Row>
|
||||
</div>
|
||||
<div className={styles.feedbackSection}>
|
||||
<div className={styles.sectionTitle}>
|
||||
Alternativ-Sätze (Falsche):
|
||||
</div>
|
||||
{s.incorrectAlternates.map((alt, altIdx) => (
|
||||
<div key={altIdx} className={styles.alternateGroup}>
|
||||
<div className={styles.alternateRowNoGapTop}>
|
||||
<input
|
||||
value={alt}
|
||||
onChange={(e) =>
|
||||
handleIncorrectAlternateChange(
|
||||
idx,
|
||||
altIdx,
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
className={`${styles.fieldInput} ${styles.alternateInput}`}
|
||||
placeholder="Falsche Alternative (optional)"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => removeIncorrectAlternate(idx, altIdx)}
|
||||
disabled={s.incorrectAlternates.length === 1}
|
||||
variant="none"
|
||||
width="fit"
|
||||
className={styles.alternateDeleteButton}
|
||||
style={{ color: "#c62828" }}
|
||||
aria-label="Falsche Alternative entfernen"
|
||||
>
|
||||
X
|
||||
</Button>
|
||||
</div>
|
||||
<input
|
||||
value={s.incorrectFeedbacks[altIdx] || ""}
|
||||
onChange={(e) =>
|
||||
handleIncorrectFeedbackChange(idx, altIdx, e.target.value)
|
||||
}
|
||||
className={styles.fieldInput}
|
||||
placeholder="Feedback für diese falsche Alternative (optional)"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => addIncorrectAlternate(idx)}
|
||||
className={styles.fitContentButton}
|
||||
>
|
||||
Alternative hinzufügen (Falsche)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{error && <div className={styles.errorText}>{error}</div>}
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function CreateExercise() {
|
||||
Memory Game
|
||||
</LinkButton>
|
||||
<LinkButton href="/admin/create-exercise/jumbled-sentence">
|
||||
Jumbled Sentence
|
||||
Satzmix
|
||||
</LinkButton>
|
||||
</Column>
|
||||
);
|
||||
|
||||
@@ -15,12 +15,16 @@ export const GET = withAuth(async (request, { params }) => {
|
||||
}
|
||||
const exercise = exRes.rows[0];
|
||||
const sRes = await DB.pool(
|
||||
`SELECT sentence, alternates FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1 ORDER BY id ASC`,
|
||||
`SELECT sentence, alternates, correct_feedback, alternate_feedbacks, incorrect_feedbacks, incorrect_alternates FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1 ORDER BY id ASC`,
|
||||
[id]
|
||||
);
|
||||
exercise.sentences = sRes.rows.map((row) => ({
|
||||
sentence: row.sentence,
|
||||
alternates: row.alternates || [],
|
||||
correctSentenceFeedback: row.correct_feedback || "",
|
||||
alternateFeedbacks: row.alternate_feedbacks || [],
|
||||
incorrectFeedbacks: row.incorrect_feedbacks || [],
|
||||
incorrectAlternates: row.incorrect_alternates || [],
|
||||
}));
|
||||
return NextResponse.json({ exercise });
|
||||
} catch (err) {
|
||||
@@ -38,7 +42,7 @@ export const PUT = withAuth(
|
||||
{
|
||||
error:
|
||||
parseResult.error.issues[0]?.message ||
|
||||
"Ungültige Eingabe für Jumbled Sentence.",
|
||||
"Ungültige Eingabe für Satzmix-Übung.",
|
||||
issues: parseResult.error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
@@ -57,8 +61,16 @@ export const PUT = withAuth(
|
||||
);
|
||||
for (const s of sentences) {
|
||||
await client.query(
|
||||
`INSERT INTO jumbled_sentence_sentences (jumbled_exercise_id, sentence, alternates) VALUES ($1, $2, $3)`,
|
||||
[id, s.sentence, s.alternates?.filter(Boolean) || []]
|
||||
`INSERT INTO jumbled_sentence_sentences (jumbled_exercise_id, sentence, alternates, correct_feedback, alternate_feedbacks, incorrect_feedbacks, incorrect_alternates) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
id,
|
||||
s.sentence,
|
||||
s.alternates?.filter(Boolean) || [],
|
||||
s.correctSentenceFeedback || null,
|
||||
s.alternateFeedbacks || [],
|
||||
s.incorrectFeedbacks || [],
|
||||
s.incorrectAlternates || [],
|
||||
]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ export const POST = withAuth(
|
||||
{
|
||||
error:
|
||||
parseResult.error.issues[0]?.message ||
|
||||
"Ungültige Eingabe für Jumbled Sentence.",
|
||||
"Ungültige Eingabe für Satzmix-Übung.",
|
||||
issues: parseResult.error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
@@ -34,8 +34,16 @@ export const POST = withAuth(
|
||||
const jumbled_id = jumbledRes.rows[0].id;
|
||||
for (const s of sentences) {
|
||||
await client.query(
|
||||
`INSERT INTO jumbled_sentence_sentences (jumbled_exercise_id, sentence, alternates) VALUES ($1, $2, $3)`,
|
||||
[jumbled_id, s.sentence, s.alternates?.filter(Boolean) || []]
|
||||
`INSERT INTO jumbled_sentence_sentences (jumbled_exercise_id, sentence, alternates, correct_feedback, alternate_feedbacks, incorrect_feedbacks, incorrect_alternates) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
jumbled_id,
|
||||
s.sentence,
|
||||
s.alternates?.filter(Boolean) || [],
|
||||
s.correctSentenceFeedback || null,
|
||||
s.alternateFeedbacks || [],
|
||||
s.incorrectFeedbacks || [],
|
||||
s.incorrectAlternates || [],
|
||||
]
|
||||
);
|
||||
}
|
||||
return { id: jumbled_id };
|
||||
|
||||
@@ -18,6 +18,10 @@ describe("admin jumbled sentence exercises API", () => {
|
||||
{
|
||||
sentence: "Ich lerne jeden Tag Deutsch",
|
||||
alternates: ["Jeden Tag lerne ich Deutsch"],
|
||||
alternateFeedbacks: ["Auch richtig."],
|
||||
correctSentenceFeedback: "Perfekt!",
|
||||
incorrectAlternates: ["Deutsch ich lerne jeden Tag"],
|
||||
incorrectFeedbacks: ["Subjekt und Verb bitte prüfen."],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -48,7 +52,7 @@ describe("admin jumbled sentence exercises API", () => {
|
||||
expect(linkedExercise.rows[0].created_by).toBe(admin.id);
|
||||
|
||||
const sentences = await DB.pool(
|
||||
"SELECT sentence, alternates FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1",
|
||||
"SELECT sentence, alternates, alternate_feedbacks, correct_feedback, incorrect_alternates, incorrect_feedbacks FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1",
|
||||
[json.id]
|
||||
);
|
||||
expect(sentences.rows.length).toBe(1);
|
||||
@@ -56,6 +60,18 @@ describe("admin jumbled sentence exercises API", () => {
|
||||
expect(sentences.rows[0].alternates).toEqual(
|
||||
payload.sentences[0].alternates
|
||||
);
|
||||
expect(sentences.rows[0].alternate_feedbacks).toEqual(
|
||||
payload.sentences[0].alternateFeedbacks
|
||||
);
|
||||
expect(sentences.rows[0].correct_feedback).toBe(
|
||||
payload.sentences[0].correctSentenceFeedback
|
||||
);
|
||||
expect(sentences.rows[0].incorrect_alternates).toEqual(
|
||||
payload.sentences[0].incorrectAlternates
|
||||
);
|
||||
expect(sentences.rows[0].incorrect_feedbacks).toEqual(
|
||||
payload.sentences[0].incorrectFeedbacks
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid payload", async () => {
|
||||
|
||||
@@ -14,12 +14,16 @@ export async function GET(request, { params }) {
|
||||
}
|
||||
const exercise = exRes.rows[0];
|
||||
const sRes = await DB.pool(
|
||||
`SELECT sentence, alternates FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1 ORDER BY id ASC`,
|
||||
`SELECT sentence, alternates, correct_feedback, alternate_feedbacks, incorrect_feedbacks, incorrect_alternates FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1 ORDER BY id ASC`,
|
||||
[id]
|
||||
);
|
||||
exercise.sentences = sRes.rows.map((row) => ({
|
||||
sentence: row.sentence,
|
||||
alternates: row.alternates || [],
|
||||
correctSentenceFeedback: row.correct_feedback || "",
|
||||
alternateFeedbacks: row.alternate_feedbacks || [],
|
||||
incorrectFeedbacks: row.incorrect_feedbacks || [],
|
||||
incorrectAlternates: row.incorrect_alternates || [],
|
||||
}));
|
||||
return NextResponse.json({ exercise });
|
||||
} catch (err) {
|
||||
@@ -50,8 +54,16 @@ export async function PUT(request, { params }) {
|
||||
);
|
||||
for (const s of sentences) {
|
||||
await client.query(
|
||||
`INSERT INTO jumbled_sentence_sentences (jumbled_exercise_id, sentence, alternates) VALUES ($1, $2, $3)`,
|
||||
[id, s.sentence, s.alternates?.filter(Boolean) || []]
|
||||
`INSERT INTO jumbled_sentence_sentences (jumbled_exercise_id, sentence, alternates, correct_feedback, alternate_feedbacks, incorrect_feedbacks, incorrect_alternates) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
id,
|
||||
s.sentence,
|
||||
s.alternates?.filter(Boolean) || [],
|
||||
s.correctSentenceFeedback || null,
|
||||
s.alternateFeedbacks || [],
|
||||
s.incorrectFeedbacks || [],
|
||||
s.incorrectAlternates || [],
|
||||
]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -21,20 +21,31 @@ export default function JumbledSentenceExercisePage() {
|
||||
const [bankWords, setBankWords] = useState([]);
|
||||
const [answerWords, setAnswerWords] = useState([]);
|
||||
const [sentenceResults, setSentenceResults] = useState([]);
|
||||
const [sentenceFeedbacks, setSentenceFeedbacks] = useState([]);
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const cleanToken = (token) => token.replace(/[\p{P}]/gu, "").toLowerCase();
|
||||
const stripIgnoredPunctuation = (token) =>
|
||||
token.replace(/[\p{P}]/gu, (character) =>
|
||||
character === "'" || character === "’" ? character : ""
|
||||
);
|
||||
|
||||
const tokenizeSentence = (value) =>
|
||||
value.trim().split(/\s+/).map(cleanToken).filter(Boolean);
|
||||
const cleanTokenForComparison = (token) =>
|
||||
stripIgnoredPunctuation(token).toLowerCase();
|
||||
|
||||
const normalizeSentence = (value) => tokenizeSentence(value).join(" ");
|
||||
const tokenizeSentenceForDisplay = (value) =>
|
||||
value.trim().split(/\s+/).map(stripIgnoredPunctuation).filter(Boolean);
|
||||
|
||||
const normalizeSentence = (value) =>
|
||||
tokenizeSentenceForDisplay(value)
|
||||
.map(cleanTokenForComparison)
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const initializeExerciseState = (exerciseData) => {
|
||||
const sentenceTokenSets = (exerciseData?.sentences || []).map(
|
||||
(sentence) => {
|
||||
const tokens = tokenizeSentence(sentence.sentence).map(
|
||||
const tokens = tokenizeSentenceForDisplay(sentence.sentence).map(
|
||||
(text, index) => ({
|
||||
id: `${index}-${text}`,
|
||||
text,
|
||||
@@ -49,6 +60,7 @@ export default function JumbledSentenceExercisePage() {
|
||||
sentenceTokenSets.map((tokens) => Array(tokens.length).fill(null))
|
||||
);
|
||||
setSentenceResults(sentenceTokenSets.map(() => null));
|
||||
setSentenceFeedbacks(sentenceTokenSets.map(() => ""));
|
||||
setFeedback("");
|
||||
};
|
||||
|
||||
@@ -59,6 +71,13 @@ export default function JumbledSentenceExercisePage() {
|
||||
next[sentenceIdx] = null;
|
||||
return next;
|
||||
});
|
||||
|
||||
setSentenceFeedbacks((prev) => {
|
||||
if (!prev.length || !prev[sentenceIdx]) return prev;
|
||||
const next = [...prev];
|
||||
next[sentenceIdx] = "";
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -177,28 +196,73 @@ export default function JumbledSentenceExercisePage() {
|
||||
|
||||
const checkAnswers = () => {
|
||||
if (!exercise) return;
|
||||
const results = exercise.sentences.map((sentence, sentenceIdx) => {
|
||||
const evaluation = exercise.sentences.map((sentence, sentenceIdx) => {
|
||||
if (answerWords[sentenceIdx].some((slot) => slot === null)) {
|
||||
return false;
|
||||
return {
|
||||
isCorrect: false,
|
||||
feedbackText: "",
|
||||
};
|
||||
}
|
||||
|
||||
const acceptedAnswers = [
|
||||
sentence.sentence,
|
||||
...(sentence.alternates || []),
|
||||
].map((value) => normalizeSentence(value));
|
||||
const normalizedMainSentence = normalizeSentence(sentence.sentence);
|
||||
const normalizedAlternates = (sentence.alternates || []).map((value) =>
|
||||
normalizeSentence(value)
|
||||
);
|
||||
const normalizedAlternateFeedbacks = sentence.alternateFeedbacks || [];
|
||||
const normalizedIncorrectAlternates = (
|
||||
sentence.incorrectAlternates || []
|
||||
).map((value) => normalizeSentence(value));
|
||||
const normalizedIncorrectFeedbacks = sentence.incorrectFeedbacks || [];
|
||||
|
||||
const userAnswer = normalizeSentence(
|
||||
answerWords[sentenceIdx].map((token) => token?.text || "").join(" ")
|
||||
);
|
||||
|
||||
return acceptedAnswers.includes(userAnswer);
|
||||
if (userAnswer === normalizedMainSentence) {
|
||||
return {
|
||||
isCorrect: true,
|
||||
feedbackText: sentence.correctSentenceFeedback || "",
|
||||
};
|
||||
}
|
||||
|
||||
const alternateIndex = normalizedAlternates.findIndex(
|
||||
(candidate) => candidate === userAnswer
|
||||
);
|
||||
|
||||
if (alternateIndex !== -1) {
|
||||
return {
|
||||
isCorrect: true,
|
||||
feedbackText: normalizedAlternateFeedbacks[alternateIndex] || "",
|
||||
};
|
||||
}
|
||||
|
||||
const incorrectAlternateIndex = normalizedIncorrectAlternates.findIndex(
|
||||
(candidate) => candidate === userAnswer
|
||||
);
|
||||
|
||||
if (incorrectAlternateIndex !== -1) {
|
||||
return {
|
||||
isCorrect: false,
|
||||
feedbackText:
|
||||
normalizedIncorrectFeedbacks[incorrectAlternateIndex] || "",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isCorrect: false,
|
||||
feedbackText: "",
|
||||
};
|
||||
});
|
||||
|
||||
const results = evaluation.map((entry) => entry.isCorrect);
|
||||
const perSentenceFeedbacks = evaluation.map((entry) => entry.feedbackText);
|
||||
|
||||
setSentenceResults(results);
|
||||
setSentenceFeedbacks(perSentenceFeedbacks);
|
||||
|
||||
const allCorrect = results.every(Boolean);
|
||||
|
||||
setFeedback(allCorrect ? "Richtig!" : "Leider falsch, versuche es erneut.");
|
||||
setFeedback(allCorrect ? "Alle Sätze sind richtig." : "");
|
||||
};
|
||||
|
||||
if (loading) return <div>Lädt...</div>;
|
||||
@@ -215,7 +279,7 @@ export default function JumbledSentenceExercisePage() {
|
||||
style={{ marginBottom: "var(--u-lg)" }}
|
||||
>
|
||||
<Row align="center" gap="sm">
|
||||
<div>Ordne die Wörter richtig:</div>
|
||||
<div>Ordnen Sie die Elemente richtig an:</div>
|
||||
{sentenceResults[sentenceIdx] === true && (
|
||||
<span style={{ color: "#1f9d55", fontWeight: 700 }}>Richtig</span>
|
||||
)}
|
||||
@@ -224,6 +288,18 @@ export default function JumbledSentenceExercisePage() {
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{sentenceFeedbacks[sentenceIdx] && (
|
||||
<div
|
||||
style={{
|
||||
color:
|
||||
sentenceResults[sentenceIdx] === true ? "#1f9d55" : "#d64545",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{sentenceFeedbacks[sentenceIdx]}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Row
|
||||
gap="sm"
|
||||
style={{
|
||||
@@ -322,11 +398,7 @@ export default function JumbledSentenceExercisePage() {
|
||||
</Button>
|
||||
</Row>
|
||||
|
||||
{feedback && (
|
||||
<div style={{ color: feedback === "Richtig!" ? "green" : "red" }}>
|
||||
{feedback}
|
||||
</div>
|
||||
)}
|
||||
{feedback && <div style={{ color: "#1f9d55" }}>{feedback}</div>}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function JumbledSentenceExercisesPage() {
|
||||
return (
|
||||
<Container maxW="800px" m="0 auto" p="md">
|
||||
<Container mb="lg">
|
||||
<h1>Jumbled Sentence Übungen</h1>
|
||||
<h1>Satzmix-Übungen</h1>
|
||||
</Container>
|
||||
<Container color="var(--fg4)">
|
||||
Zurzeit sind keine Übungen verfügbar.
|
||||
@@ -71,7 +71,7 @@ export default function JumbledSentenceExercisesPage() {
|
||||
return (
|
||||
<Container maxW="800px" m="0 auto" p="md">
|
||||
<Container mb="lg">
|
||||
<h1>Jumbled Sentence Übungen</h1>
|
||||
<h1>Satzmix-Übungen</h1>
|
||||
</Container>
|
||||
|
||||
<Container
|
||||
|
||||
@@ -48,7 +48,7 @@ const exerciseTypes = [
|
||||
image: "🧠",
|
||||
},
|
||||
{
|
||||
title: "Jumbled Sentence",
|
||||
title: "Satzmix-Übungen",
|
||||
description: "Üben mit Satzordnungen.",
|
||||
link: "/grammar/exercises/jumbled-sentence",
|
||||
image: "↔️",
|
||||
|
||||
@@ -32,10 +32,36 @@ export const jumbledSentenceSchema = z
|
||||
.map((item) => (typeof item === "string" ? item.trim() : item))
|
||||
.filter((item) => typeof item === "string" && item.length > 0);
|
||||
}, z.array(z.string())),
|
||||
alternateFeedbacks: z.preprocess((value) => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((item) => (typeof item === "string" ? item.trim() : ""));
|
||||
}, z.array(z.string())),
|
||||
correctSentenceFeedback: z.preprocess(
|
||||
(value) => (typeof value === "string" ? value.trim() : ""),
|
||||
z.string()
|
||||
),
|
||||
incorrectFeedbacks: z.preprocess((value) => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((item) => (typeof item === "string" ? item.trim() : ""));
|
||||
}, z.array(z.string())),
|
||||
incorrectAlternates: z.preprocess((value) => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => (typeof item === "string" ? item.trim() : item))
|
||||
.filter((item) => typeof item === "string" && item.length > 0);
|
||||
}, z.array(z.string())),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const baseTokens = normalizeTokens(data.sentence);
|
||||
|
||||
if (data.alternateFeedbacks.length !== data.alternates.length) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["alternateFeedbacks"],
|
||||
message: "Jeder Alternativ-Satz kann optional eigenes Feedback haben.",
|
||||
});
|
||||
}
|
||||
|
||||
for (let index = 0; index < data.alternates.length; index++) {
|
||||
const alternate = data.alternates[index];
|
||||
const alternateTokens = normalizeTokens(alternate);
|
||||
@@ -49,6 +75,29 @@ export const jumbledSentenceSchema = z
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index < data.incorrectAlternates.length; index++) {
|
||||
const incorrectAlternate = data.incorrectAlternates[index];
|
||||
const incorrectTokens = normalizeTokens(incorrectAlternate);
|
||||
|
||||
if (!sameWordSet(baseTokens, incorrectTokens)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["incorrectAlternates", index],
|
||||
message:
|
||||
"Falsche Alternativ-Antwort muss genau die gleichen Wörter wie 'Satz (korrekt)' enthalten (nur Reihenfolge darf anders sein).",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.incorrectFeedbacks.length !== data.incorrectAlternates.length) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["incorrectFeedbacks"],
|
||||
message:
|
||||
"Jede falsche Alternative kann optional eigenes Feedback haben.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const jumbledSentenceExerciseSchema = z.object({
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"18e1443b7169cebccd21-4a0da125f055d790cf05",
|
||||
"cf4ffee829137744e3cd-e6406448e73a28c0ddd9",
|
||||
"d936625e1207b3e19d4b-67eb4c2dd9e8efb88e3d",
|
||||
"18e1443b7169cebccd21-2292e031b11e8f9b3824",
|
||||
"d8918302922cfb4b5ab2-53975d1f9ee0854667fa"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user