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 #521 from erzabytyci/user-tiers
Add New Exercise: Jumbled Sentence
This commit is contained in:
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
];
|
||||
|
||||
|
||||
@@ -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." }),
|
||||
});
|
||||
Reference in New Issue
Block a user