Merge pull request #521 from erzabytyci/user-tiers

Add New Exercise: Jumbled Sentence
This commit is contained in:
erzabytyci
2026-05-21 14:40:36 +03:00
committed by GitHub
16 changed files with 1322 additions and 1 deletions
@@ -0,0 +1,25 @@
-- Jumbled Sentence Exercises Table
CREATE TABLE public.jumbled_sentence_exercises (
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(),
exercise_id bigint UNIQUE NOT NULL,
created_by bigint NOT NULL,
title text NOT NULL
);
ALTER TABLE public.jumbled_sentence_exercises
ADD CONSTRAINT jumbled_sentence_exercises_created_by_fkey
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE public.jumbled_sentence_exercises
ADD CONSTRAINT jumbled_sentence_exercises_exercise_id_fkey
FOREIGN KEY (exercise_id) REFERENCES exercises(id) ON DELETE CASCADE;
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[],
CONSTRAINT fk_jumbled_exercise FOREIGN KEY(jumbled_exercise_id) REFERENCES jumbled_sentence_exercises(id) ON DELETE CASCADE
);
+28
View File
@@ -419,6 +419,26 @@ ALTER TABLE public.fill_gap_user_answers
ADD CONSTRAINT fill_gap_user_answers_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
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[]
);
CREATE TABLE public.jumbled_sentence_exercises (
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(),
exercise_id bigint UNIQUE NOT NULL,
created_by bigint NOT NULL,
title text NOT NULL
);
ALTER TABLE public.jumbled_sentence_sentences
ADD CONSTRAINT fk_jumbled_exercise
FOREIGN KEY (jumbled_exercise_id) REFERENCES jumbled_sentence_exercises(id) ON DELETE CASCADE;
CREATE TABLE public.form_parts (
id bigint PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY,
created_at timestamp with time zone NOT NULL DEFAULT now(),
@@ -564,6 +584,14 @@ ALTER TABLE public.html_pages
ADD CONSTRAINT html_pages_grammar_topic_id_fkey
FOREIGN KEY (grammar_topic_id) REFERENCES grammar_topics(id);
ALTER TABLE public.jumbled_sentence_exercises
ADD CONSTRAINT jumbled_sentence_exercises_created_by_fkey
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE public.jumbled_sentence_exercises
ADD CONSTRAINT jumbled_sentence_exercises_exercise_id_fkey
FOREIGN KEY (exercise_id) REFERENCES exercises(id) ON DELETE CASCADE;
CREATE TABLE public.memory_game_exercises (
id bigint PRIMARY KEY NOT NULL GENERATED BY DEFAULT AS IDENTITY,
created_at timestamp with time zone NOT NULL DEFAULT now(),
@@ -0,0 +1,5 @@
import CreateJumbledSentenceExercise from "@/app/admin/create-exercise/jumbled-sentence/page";
export default function EditJumbledSentenceExercisePage() {
return <CreateJumbledSentenceExercise />;
}
@@ -0,0 +1,94 @@
.form {
--jumbled-input-max-width: 40rem;
}
.exerciseDescription {
color: var(--fg1);
margin-top: 0;
margin-bottom: var(--u-lg);
font-style: italic;
}
.fieldLabel {
display: flex;
flex-direction: column;
gap: 0.4rem;
width: min(100%, var(--jumbled-input-max-width));
font-size: 1.0rem;
font-weight: 600;
}
.fieldInput {
width: 100%;
box-sizing: border-box;
}
.sentenceCard {
border: 1px solid #ccc;
padding: 0.5rem;
margin-bottom: 0.5rem;
width: 40rem;
}
.alternateRow {
position: relative;
width: min(100%, var(--jumbled-input-max-width));
margin-top: 0.5rem;
}
.alternateInput {
padding-right: 2rem;
}
.alternateDeleteButton {
color: #c62828;
font-weight: 700;
font-size: 1rem;
padding: 0.1rem 0.4rem;
position: absolute;
right: 0.2rem;
top: 50%;
transform: translateY(-50%);
}
.alternateDeleteButton:hover {
color: #c62828;
}
.addAlternateButton {
min-width: 14rem;
white-space: nowrap;
}
.inlineActionsRow {
width: min(100%, var(--jumbled-input-max-width));
margin-top: 0.5rem;
}
.removeSentenceButton {
background-color: #f8d7da;
border-color: #f5c2c7;
color: #842029;
}
.removeSentenceButton:hover {
background-color: #f8d7da;
border-color: #f5c2c7;
color: #842029;
}
.saveButton {
background-color: #d1e7dd;
border-color: #badbcc;
color: #0f5132;
}
.saveButton:hover {
background-color: #d1e7dd;
border-color: #badbcc;
color: #0f5132;
}
.errorText {
color: red;
}
@@ -0,0 +1,244 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Column, Row } from "@/components/ui/layout/container";
import { jumbledSentenceExerciseSchema } from "@/shared/schemas/jumbled-sentence.schemas";
import { withBasePath } from "@/shared/utils/basePath";
import useQuery from "@/shared/hooks/useQuery";
import styles from "./jumbled-sentence.module.css";
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: [""] },
]);
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const {
data,
isLoading,
error: loadError,
} = useQuery(`/admin/exercises/jumbled-sentence/${exercise_id || ""}`, null, {
enabled: isEditMode,
});
useEffect(() => {
if (!isEditMode || !data?.exercise) {
return;
}
const loadedExercise = data.exercise;
setTitle(loadedExercise.title || "");
setSentences(
(loadedExercise.sentences || []).length
? loadedExercise.sentences.map((item) => ({
sentence: item.sentence || "",
alternates:
item.alternates && item.alternates.length > 0
? item.alternates
: [""],
}))
: [{ sentence: "", alternates: [""] }]
);
}, [isEditMode, data]);
const handleSentenceChange = (idx, value) => {
const updated = [...sentences];
updated[idx].sentence = value;
setSentences(updated);
};
const handleAlternateChange = (idx, altIdx, value) => {
const updated = [...sentences];
updated[idx].alternates[altIdx] = value;
setSentences(updated);
};
const addSentence = () => {
setSentences([...sentences, { sentence: "", alternates: [""] }]);
};
const addAlternate = (idx) => {
const updated = [...sentences];
updated[idx].alternates.push("");
setSentences(updated);
};
const removeSentence = (idx) => {
setSentences(sentences.filter((_, i) => i !== idx));
};
const removeAlternate = (idx, altIdx) => {
const updated = [...sentences];
updated[idx].alternates = updated[idx].alternates.filter(
(_, i) => i !== altIdx
);
setSentences(updated);
};
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
const parsed = jumbledSentenceExerciseSchema.safeParse({
title,
sentences,
});
if (!parsed.success) {
setError(
parsed.error.issues[0]?.message ||
"Bitte alle Felder korrekt ausfüllen."
);
return;
}
setIsSubmitting(true);
try {
const endpoint = isEditMode
? withBasePath(`/api/admin/exercises/jumbled-sentence/${exercise_id}`)
: withBasePath("/api/admin/exercises/jumbled-sentence");
const res = await fetch(endpoint, {
method: isEditMode ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, sentences }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error || "Fehler beim Speichern.");
router.push(
isEditMode
? "/grammar/exercises/jumbled-sentence"
: `/grammar/exercises/jumbled-sentence/${data.id}`
);
} catch (err) {
setError(err.message);
} finally {
setIsSubmitting(false);
}
};
if (isEditMode && isLoading) {
return <div>Lädt...</div>;
}
if (isEditMode && loadError) {
return <div>Fehler: {loadError.message || "Etwas ist schiefgelaufen"}</div>;
}
return (
<form onSubmit={handleSubmit} className={styles.form}>
<Column gap="md">
<h2>
{isEditMode
? "Jumbled Sentence Übung bearbeiten"
: "Jumbled Sentence Übung erstellen"}
</h2>
<label className={styles.fieldLabel}>
Titel:
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className={styles.fieldInput}
required
/>
</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.
</p>
{sentences.map((s, idx) => (
<div key={idx} className={styles.sentenceCard}>
<label className={styles.fieldLabel}>
Satz (korrekt):
<input
value={s.sentence}
onChange={(e) => handleSentenceChange(idx, e.target.value)}
className={styles.fieldInput}
required
/>
</label>
<div>
Alternativ-Sätze:
{s.alternates.map((alt, altIdx) => (
<div key={altIdx} 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>
))}
<Row
justify="space-between"
align="center"
className={styles.inlineActionsRow}
>
<Button
type="button"
onClick={() => addAlternate(idx)}
className={styles.addAlternateButton}
>
Alternative hinzufügen
</Button>
<Button
type="button"
onClick={() => removeSentence(idx)}
disabled={sentences.length === 1}
className={styles.removeSentenceButton}
style={{
backgroundColor: "#f8d7da",
borderColor: "#f5c2c7",
color: "#842029",
}}
>
Satz entfernen
</Button>
</Row>
</div>
</div>
))}
{error && <div className={styles.errorText}>{error}</div>}
<Row
justify="space-between"
align="center"
className={styles.inlineActionsRow}
>
<Button type="button" onClick={addSentence}>
Satz hinzufügen
</Button>
<Button
type="submit"
disabled={isSubmitting}
className={styles.saveButton}
style={{
backgroundColor: "#d1e7dd",
borderColor: "#badbcc",
color: "#0f5132",
}}
>
{isSubmitting ? "Speichern..." : "Speichern"}
</Button>
</Row>
</Column>
</form>
);
}
@@ -28,6 +28,9 @@ export default function CreateExercise() {
<LinkButton href="/admin/create-exercise/memory-game">
Memory Game
</LinkButton>
<LinkButton href="/admin/create-exercise/jumbled-sentence">
Jumbled Sentence
</LinkButton>
</Column>
);
}
@@ -0,0 +1,93 @@
import { NextResponse } from "next/server";
import { jumbledSentenceExerciseSchema } from "@/shared/schemas/jumbled-sentence.schemas";
import { DB } from "@/backend/db";
import { withAuth } from "@/backend/middleware/withAuth";
export const GET = withAuth(async (request, { params }) => {
const { id } = await params;
try {
const exRes = await DB.pool(
`SELECT id, title FROM jumbled_sentence_exercises WHERE id = $1`,
[id]
);
if (!exRes.rows.length) {
return NextResponse.json({ exercise: null }, { status: 404 });
}
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`,
[id]
);
exercise.sentences = sRes.rows.map((row) => ({
sentence: row.sentence,
alternates: row.alternates || [],
}));
return NextResponse.json({ exercise });
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
});
export const PUT = withAuth(
async (request, { params }) => {
const { id } = await params;
const body = await request.json();
const parseResult = jumbledSentenceExerciseSchema.safeParse(body);
if (!parseResult.success) {
return NextResponse.json(
{
error:
parseResult.error.issues[0]?.message ||
"Ungültige Eingabe für Jumbled Sentence.",
issues: parseResult.error.issues,
},
{ status: 400 }
);
}
const { title, sentences } = parseResult.data;
try {
await DB.transaction(async (client) => {
await client.query(
`UPDATE jumbled_sentence_exercises SET title = $1, updated_at = NOW() WHERE id = $2`,
[title, id]
);
await client.query(
`DELETE FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1`,
[id]
);
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) || []]
);
}
});
return NextResponse.json({ success: true });
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
},
{ requireAdmin: true }
);
export const DELETE = withAuth(
async (request, { params }) => {
const { id } = await params;
try {
await DB.transaction(async (client) => {
await client.query(
`DELETE FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1`,
[id]
);
await client.query(
`DELETE FROM jumbled_sentence_exercises WHERE id = $1`,
[id]
);
});
return NextResponse.json({ success: true });
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
},
{ requireAdmin: true }
);
@@ -0,0 +1,63 @@
import { NextResponse } from "next/server";
import { jumbledSentenceExerciseSchema } from "@/shared/schemas/jumbled-sentence.schemas";
import { DB } from "@/backend/db";
import { withAuth } from "@/backend/middleware/withAuth";
export const POST = withAuth(
async (request) => {
const body = await request.json();
const parseResult = jumbledSentenceExerciseSchema.safeParse(body);
if (!parseResult.success) {
return NextResponse.json(
{
error:
parseResult.error.issues[0]?.message ||
"Ungültige Eingabe für Jumbled Sentence.",
issues: parseResult.error.issues,
},
{ status: 400 }
);
}
const { title, sentences } = parseResult.data;
const created_by = request.user.id;
try {
const result = await DB.transaction(async (client) => {
const exerciseRes = await client.query(
`INSERT INTO exercises (created_at, updated_at, category, created_by) VALUES (NOW(), NOW(), $1, $2) RETURNING id`,
["jumbled-sentence", created_by]
);
const exercise_id = exerciseRes.rows[0].id;
const jumbledRes = await client.query(
`INSERT INTO jumbled_sentence_exercises (created_at, updated_at, exercise_id, created_by, title) VALUES (NOW(), NOW(), $1, $2, $3) RETURNING id`,
[exercise_id, created_by, title]
);
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) || []]
);
}
return { id: jumbled_id };
});
return NextResponse.json({ success: true, id: result.id });
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
},
{ requireAdmin: true }
);
export const GET = withAuth(
async () => {
try {
const res = await DB.pool(
`SELECT id, title FROM jumbled_sentence_exercises ORDER BY id DESC`
);
return NextResponse.json({ exercises: res.rows });
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
},
{ requireAdmin: true }
);
@@ -0,0 +1,117 @@
import { describe, it, expect } from "vitest";
import { GET, POST } from "./route";
import { useTestDatabase } from "@/backend/test/testdb";
import { useTestRequest } from "@/backend/test/mock-request";
import { TestFactory } from "@/backend/test/testfactory";
import { DB } from "@/backend/db";
describe("admin jumbled sentence exercises API", () => {
useTestDatabase();
it("creates a jumbled sentence exercise with valid input", async () => {
const admin = await TestFactory.user({ is_admin: true });
const { mockPost } = useTestRequest(admin);
const payload = {
title: "Satzreihenfolge A1",
sentences: [
{
sentence: "Ich lerne jeden Tag Deutsch",
alternates: ["Jeden Tag lerne ich Deutsch"],
},
],
};
const response = await POST(
mockPost("/api/admin/exercises/jumbled-sentence", payload)
);
expect(response.status).toBe(200);
const json = await response.json();
expect(json.success).toBe(true);
expect(json.id).toBeDefined();
const createdExercise = await DB.pool(
"SELECT id, exercise_id, title, created_by FROM jumbled_sentence_exercises WHERE id = $1",
[json.id]
);
expect(createdExercise.rows.length).toBe(1);
expect(createdExercise.rows[0].title).toBe(payload.title);
expect(createdExercise.rows[0].created_by).toBe(admin.id);
const linkedExercise = await DB.pool(
"SELECT id, category, created_by FROM exercises WHERE id = $1",
[createdExercise.rows[0].exercise_id]
);
expect(linkedExercise.rows.length).toBe(1);
expect(linkedExercise.rows[0].category).toBe("jumbled-sentence");
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",
[json.id]
);
expect(sentences.rows.length).toBe(1);
expect(sentences.rows[0].sentence).toBe(payload.sentences[0].sentence);
expect(sentences.rows[0].alternates).toEqual(
payload.sentences[0].alternates
);
});
it("returns 400 for invalid payload", async () => {
const admin = await TestFactory.user({ is_admin: true });
const { mockPost } = useTestRequest(admin);
const response = await POST(
mockPost("/api/admin/exercises/jumbled-sentence", {
title: "ab",
sentences: [],
})
);
expect(response.status).toBe(400);
const json = await response.json();
expect(json.error).toBeDefined();
expect(Array.isArray(json.issues)).toBe(true);
expect(json.issues.length).toBeGreaterThan(0);
});
it("lists existing jumbled sentence exercises", async () => {
const admin = await TestFactory.user({ is_admin: true });
const baseExerciseA = await DB.pool(
"INSERT INTO exercises (created_at, updated_at, category, created_by) VALUES (NOW(), NOW(), $1, $2) RETURNING id",
["jumbled-sentence", admin.id]
);
const baseExerciseB = await DB.pool(
"INSERT INTO exercises (created_at, updated_at, category, created_by) VALUES (NOW(), NOW(), $1, $2) RETURNING id",
["jumbled-sentence", admin.id]
);
await DB.pool(
"INSERT INTO jumbled_sentence_exercises (created_at, updated_at, exercise_id, created_by, title) VALUES (NOW(), NOW(), $1, $2, $3), (NOW(), NOW(), $4, $5, $6)",
[
baseExerciseA.rows[0].id,
admin.id,
"Titel Eins",
baseExerciseB.rows[0].id,
admin.id,
"Titel Zwei",
]
);
const { mockGet } = useTestRequest(admin);
const response = await GET(
mockGet("/api/admin/exercises/jumbled-sentence")
);
expect(response.status).toBe(200);
const json = await response.json();
expect(Array.isArray(json.exercises)).toBe(true);
expect(json.exercises.length).toBeGreaterThanOrEqual(2);
const titles = json.exercises.map((item) => item.title);
expect(titles).toContain("Titel Eins");
expect(titles).toContain("Titel Zwei");
});
});
@@ -0,0 +1,81 @@
import { NextResponse } from "next/server";
import { jumbledSentenceExerciseSchema } from "@/shared/schemas/jumbled-sentence.schemas";
import { DB } from "@/backend/db";
export async function GET(request, { params }) {
const { id } = await params;
try {
const exRes = await DB.pool(
`SELECT id, title FROM jumbled_sentence_exercises WHERE id = $1`,
[id]
);
if (!exRes.rows.length) {
return NextResponse.json({ exercise: null }, { status: 404 });
}
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`,
[id]
);
exercise.sentences = sRes.rows.map((row) => ({
sentence: row.sentence,
alternates: row.alternates || [],
}));
return NextResponse.json({ exercise });
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function PUT(request, { params }) {
const { id } = await params;
const body = await request.json();
const parseResult = jumbledSentenceExerciseSchema.safeParse(body);
if (!parseResult.success) {
return NextResponse.json(
{ error: parseResult.error.errors },
{ status: 400 }
);
}
const { title, sentences } = body;
try {
await DB.transaction(async (client) => {
await client.query(
`UPDATE jumbled_sentence_exercises SET title = $1, updated_at = NOW() WHERE id = $2`,
[title, id]
);
await client.query(
`DELETE FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1`,
[id]
);
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) || []]
);
}
});
return NextResponse.json({ success: true });
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function DELETE(request, { params }) {
const { id } = await params;
try {
await DB.transaction(async (client) => {
await client.query(
`DELETE FROM jumbled_sentence_sentences WHERE jumbled_exercise_id = $1`,
[id]
);
await client.query(
`DELETE FROM jumbled_sentence_exercises WHERE id = $1`,
[id]
);
});
return NextResponse.json({ success: true });
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { DB } from "@/backend/db";
export async function GET() {
try {
const { rows } = await DB.pool(
`SELECT jse.id, jse.title, e.created_at
FROM jumbled_sentence_exercises jse
JOIN exercises e ON e.id = jse.exercise_id
ORDER BY e.created_at DESC`
);
return NextResponse.json(rows);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
@@ -0,0 +1,332 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Column, Row } from "@/components/ui/layout/container";
import { withBasePath } from "@/shared/utils/basePath";
function shuffle(array) {
const arr = [...array];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
export default function JumbledSentenceExercisePage() {
const { id } = useParams();
const [exercise, setExercise] = useState(null);
const [bankWords, setBankWords] = useState([]);
const [answerWords, setAnswerWords] = useState([]);
const [sentenceResults, setSentenceResults] = useState([]);
const [feedback, setFeedback] = useState("");
const [loading, setLoading] = useState(true);
const cleanToken = (token) => token.replace(/[\p{P}]/gu, "").toLowerCase();
const tokenizeSentence = (value) =>
value.trim().split(/\s+/).map(cleanToken).filter(Boolean);
const normalizeSentence = (value) => tokenizeSentence(value).join(" ");
const initializeExerciseState = (exerciseData) => {
const sentenceTokenSets = (exerciseData?.sentences || []).map(
(sentence) => {
const tokens = tokenizeSentence(sentence.sentence).map(
(text, index) => ({
id: `${index}-${text}`,
text,
})
);
return shuffle(tokens);
}
);
setBankWords(sentenceTokenSets);
setAnswerWords(
sentenceTokenSets.map((tokens) => Array(tokens.length).fill(null))
);
setSentenceResults(sentenceTokenSets.map(() => null));
setFeedback("");
};
const clearSentenceResult = (sentenceIdx) => {
setSentenceResults((prev) => {
if (!prev.length || prev[sentenceIdx] === null) return prev;
const next = [...prev];
next[sentenceIdx] = null;
return next;
});
};
useEffect(() => {
async function fetchExercise() {
setLoading(true);
const res = await fetch(
withBasePath(`/api/exercises/jumbled-sentence/${id}`)
);
const data = await res.json();
setExercise(data.exercise || null);
initializeExerciseState(data.exercise);
setLoading(false);
}
fetchExercise();
}, [id]);
const moveTokenToSlot = (sentenceIdx, sourceType, sourceIndex, slotIndex) => {
if (answerWords[sentenceIdx]?.[slotIndex]) {
return;
}
const nextBankWords = bankWords.map((words) => [...words]);
const nextAnswerWords = answerWords.map((slots) => [...slots]);
let token = null;
if (sourceType === "bank") {
token = nextBankWords[sentenceIdx][sourceIndex];
if (!token) return;
nextBankWords[sentenceIdx].splice(sourceIndex, 1);
} else {
token = nextAnswerWords[sentenceIdx][sourceIndex];
if (!token) return;
nextAnswerWords[sentenceIdx][sourceIndex] = null;
}
nextAnswerWords[sentenceIdx][slotIndex] = token;
setBankWords(nextBankWords);
setAnswerWords(nextAnswerWords);
clearSentenceResult(sentenceIdx);
setFeedback("");
};
const moveTokenBackToBank = (sentenceIdx, slotIndex) => {
const nextBankWords = bankWords.map((words) => [...words]);
const nextAnswerWords = answerWords.map((slots) => [...slots]);
const token = nextAnswerWords[sentenceIdx][slotIndex];
if (!token) return;
nextAnswerWords[sentenceIdx][slotIndex] = null;
nextBankWords[sentenceIdx].push(token);
setBankWords(nextBankWords);
setAnswerWords(nextAnswerWords);
clearSentenceResult(sentenceIdx);
setFeedback("");
};
const handleDragStart = (event, sentenceIdx, sourceType, sourceIndex) => {
event.dataTransfer.setData(
"application/json",
JSON.stringify({ sentenceIdx, sourceType, sourceIndex })
);
};
const handleDropOnSlot = (event, sentenceIdx, slotIndex) => {
event.preventDefault();
const payload = event.dataTransfer.getData("application/json");
if (!payload) return;
const dragged = JSON.parse(payload);
if (dragged.sentenceIdx !== sentenceIdx) return;
moveTokenToSlot(
sentenceIdx,
dragged.sourceType,
dragged.sourceIndex,
slotIndex
);
};
const handleDropOnBank = (event, sentenceIdx) => {
event.preventDefault();
const payload = event.dataTransfer.getData("application/json");
if (!payload) return;
const dragged = JSON.parse(payload);
if (
dragged.sentenceIdx !== sentenceIdx ||
dragged.sourceType !== "answer"
) {
return;
}
moveTokenBackToBank(sentenceIdx, dragged.sourceIndex);
};
const handleTokenClick = (sentenceIdx, sourceType, index) => {
if (sourceType === "answer") {
moveTokenBackToBank(sentenceIdx, index);
return;
}
const firstEmptySlot = answerWords[sentenceIdx].findIndex(
(slot) => slot === null
);
if (firstEmptySlot === -1) return;
moveTokenToSlot(sentenceIdx, "bank", index, firstEmptySlot);
};
const resetExercise = () => {
initializeExerciseState(exercise);
};
const checkAnswers = () => {
if (!exercise) return;
const results = exercise.sentences.map((sentence, sentenceIdx) => {
if (answerWords[sentenceIdx].some((slot) => slot === null)) {
return false;
}
const acceptedAnswers = [
sentence.sentence,
...(sentence.alternates || []),
].map((value) => normalizeSentence(value));
const userAnswer = normalizeSentence(
answerWords[sentenceIdx].map((token) => token?.text || "").join(" ")
);
return acceptedAnswers.includes(userAnswer);
});
setSentenceResults(results);
const allCorrect = results.every(Boolean);
setFeedback(allCorrect ? "Richtig!" : "Leider falsch, versuche es erneut.");
};
if (loading) return <div>Lädt...</div>;
if (!exercise) return <div>Übung nicht gefunden.</div>;
return (
<Column gap="lg">
<h2>{exercise.title}</h2>
{exercise.sentences.map((_, sentenceIdx) => (
<Column
key={sentenceIdx}
gap="sm"
style={{ marginBottom: "var(--u-lg)" }}
>
<Row align="center" gap="sm">
<div>Ordne die Wörter richtig:</div>
{sentenceResults[sentenceIdx] === true && (
<span style={{ color: "#1f9d55", fontWeight: 700 }}>Richtig</span>
)}
{sentenceResults[sentenceIdx] === false && (
<span style={{ color: "#d64545", fontWeight: 700 }}>Falsch</span>
)}
</Row>
<Row
gap="sm"
style={{
flexWrap: "wrap",
minHeight: 52,
padding: "var(--u-sm)",
border: "1px dashed var(--fg4)",
borderRadius: "var(--radius-md)",
color: "var(--fg1)",
}}
onDrop={(event) => handleDropOnBank(event, sentenceIdx)}
onDragOver={(event) => event.preventDefault()}
>
{bankWords[sentenceIdx]?.map((token, tokenIndex) => (
<button
key={token.id}
type="button"
draggable
onDragStart={(event) =>
handleDragStart(event, sentenceIdx, "bank", tokenIndex)
}
onClick={() =>
handleTokenClick(sentenceIdx, "bank", tokenIndex)
}
style={{
padding: "8px 12px",
border: "1px solid var(--fg4)",
borderRadius: 8,
background: "var(--bg2)",
color: "var(--fg1)",
cursor: "grab",
}}
>
{token.text}
</button>
))}
</Row>
<Row gap="sm" style={{ flexWrap: "wrap" }}>
{answerWords[sentenceIdx]?.map((token, slotIndex) => (
<div
key={`${sentenceIdx}-slot-${slotIndex}`}
onDrop={(event) =>
handleDropOnSlot(event, sentenceIdx, slotIndex)
}
onDragOver={(event) => event.preventDefault()}
style={{
minWidth: 110,
minHeight: 42,
padding: "6px",
border: "1px solid var(--fg4)",
borderRadius: 8,
background: token ? "var(--bg2)" : "var(--bg1)",
color: "var(--fg1)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{token ? (
<button
type="button"
draggable
onDragStart={(event) =>
handleDragStart(event, sentenceIdx, "answer", slotIndex)
}
onClick={() =>
handleTokenClick(sentenceIdx, "answer", slotIndex)
}
style={{
padding: "6px 10px",
border: "1px solid var(--fg4)",
borderRadius: 8,
background: "var(--bg2)",
color: "var(--fg1)",
cursor: "grab",
}}
>
{token.text}
</button>
) : (
<span style={{ color: "var(--fg4)", fontSize: "0.9rem" }}>
Leer
</span>
)}
</div>
))}
</Row>
</Column>
))}
<Row gap="sm">
<Button onClick={checkAnswers}>Antwort prüfen</Button>
<Button variant="secondary" onClick={resetExercise}>
Neu mischen
</Button>
</Row>
{feedback && (
<div style={{ color: feedback === "Richtig!" ? "green" : "red" }}>
{feedback}
</div>
)}
</Column>
);
}
@@ -0,0 +1,153 @@
"use client";
import Link from "next/link";
import { Container, Row, Column } from "@/components/ui/layout/container";
import useQuery from "@/shared/hooks/useQuery";
import { ExerciseLinkButton } from "@/components/ui/button/exercise-link-button";
import { Button } from "@/components/ui/button";
import { withBasePath } from "@/shared/utils/basePath";
export default function JumbledSentenceExercisesPage() {
const {
data: exercises,
isLoading,
error,
refetch,
} = useQuery("/exercises/jumbled-sentence");
const handleDelete = async (exerciseId) => {
const confirmed = confirm("Möchten Sie diese Übung wirklich löschen?");
if (!confirmed) return;
try {
const response = await fetch(
withBasePath(`/api/admin/exercises/jumbled-sentence/${exerciseId}`),
{ method: "DELETE" }
);
if (!response.ok) {
throw new Error("Fehler beim Löschen der Übung.");
}
refetch();
} catch (deleteError) {
console.error("Error deleting exercise:", deleteError);
alert("Fehler beim Löschen der Übung.");
}
};
if (isLoading) {
return (
<Container display="flex" justify="center" align="center" h="200px">
Lädt...
</Container>
);
}
if (error) {
return (
<Container p="md" bg="var(--tertiary1)" mb="md" br="md">
Fehler: {error.message || "Unbekannter Fehler"}
</Container>
);
}
if (!exercises || exercises.length === 0) {
return (
<Container maxW="800px" m="0 auto" p="md">
<Container mb="lg">
<h1>Jumbled Sentence Übungen</h1>
</Container>
<Container color="var(--fg4)">
Zurzeit sind keine Übungen verfügbar.
</Container>
<Container mt="lg">
<Link href="/grammar/exercises">Zurück zu allen Übungen</Link>
</Container>
</Container>
);
}
return (
<Container maxW="800px" m="0 auto" p="md">
<Container mb="lg">
<h1>Jumbled Sentence Übungen</h1>
</Container>
<Container
display="grid"
gap="md"
templateColumns={{ base: "1fr", md: "1fr 1fr", lg: "1fr 1fr 1fr" }}
mb="lg"
>
{exercises.map((exercise) => (
<Row
key={exercise.id}
p="md"
b="1px solid var(--bg3)"
br="md"
justify="space-between"
align="center"
boxShadow="0 1px 2px 0 rgba(0, 0, 0, 0.05)"
bg="var(--bg2)"
gap="md"
>
<Column flex="1">
<Container
mb="sm"
fontWeight="600"
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
<Link
href={`/grammar/exercises/jumbled-sentence/${exercise.id}`}
style={{ fontSize: "var(--font-lg)" }}
>
{exercise.title}
</Link>
</Container>
<Container fontSize="sm" color="var(--fg4)">
Erstellt: {new Date(exercise.created_at).toLocaleDateString()}
</Container>
</Column>
<Column gap="sm" align="flex-end">
<ExerciseLinkButton
href={`/grammar/exercises/jumbled-sentence/${exercise.id}`}
size="sm"
>
Link kopieren
</ExerciseLinkButton>
<Link
href={`/admin/create-exercise/jumbled-sentence/${exercise.id}/edit`}
>
<Button type="button" variant="outline" size="sm">
Bearbeiten
</Button>
</Link>
<Button
type="button"
variant="outline"
size="sm"
style={{
color: "#fff5f5",
borderColor: "#d85a5a",
backgroundColor: "#d85a5a",
}}
onClick={() => handleDelete(exercise.id)}
>
Löschen
</Button>
</Column>
</Row>
))}
</Container>
<Container mt="lg">
<Link href="/grammar/exercises">Zurück zu allen Übungen</Link>
</Container>
</Container>
);
}
@@ -47,6 +47,12 @@ const exerciseTypes = [
link: "/grammar/exercises/memory-game",
image: "🧠",
},
{
title: "Jumbled Sentence",
description: "Üben mit Satzordnungen.",
link: "/grammar/exercises/jumbled-sentence",
image: "↔️",
},
// Add other exercise types here
];
+3 -1
View File
@@ -221,7 +221,9 @@ function AdminSideBar({ showUsers }) {
? adminSidebarLinks
: adminSidebarLinks.filter((l) => l.id !== "users");
return <SidebarGroup title="Admin" sublinks={links} topLink={null} />;
const title = showUsers ? "Admin" : "Lehrer";
return <SidebarGroup title={title} sublinks={links} topLink={null} />;
}
function SidebarGroup({ title, sublinks, topLink, collapsible = false }) {
@@ -0,0 +1,59 @@
import { z } from "zod";
function trimmed(schema) {
return z.preprocess(
(value) => (typeof value === "string" ? value.trim() : value),
schema
);
}
function cleanToken(token) {
return token.replace(/[\p{P}]/gu, "").toLowerCase();
}
function normalizeTokens(sentence) {
return sentence.trim().split(/\s+/).map(cleanToken).filter(Boolean).sort();
}
function sameWordSet(a, b) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
export const jumbledSentenceSchema = z
.object({
sentence: trimmed(z.string().min(2, { message: "Satz ist erforderlich." })),
alternates: 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);
for (let index = 0; index < data.alternates.length; index++) {
const alternate = data.alternates[index];
const alternateTokens = normalizeTokens(alternate);
if (!sameWordSet(baseTokens, alternateTokens)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["alternates", index],
message:
"Alternativ-Satz muss genau die gleichen Wörter wie 'Satz (korrekt)' enthalten (nur Reihenfolge darf anders sein).",
});
}
}
});
export const jumbledSentenceExerciseSchema = z.object({
title: trimmed(z.string().min(3, { message: "Titel ist erforderlich." })),
sentences: z
.array(jumbledSentenceSchema)
.min(1, { message: "Mindestens ein Satz ist erforderlich." }),
});