Merge pull request #464 from OHTU-German-learning-website/449-implement-front-for-creating-multichoice-exercises

449 implement front for creating multichoice exercises
This commit is contained in:
saarapasonen
2025-12-08 19:52:38 +02:00
committed by GitHub
5 changed files with 601 additions and 29 deletions
@@ -0,0 +1,242 @@
/* --- Global Container --- */
.admin-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 800px;
margin: 0 auto;
padding: 20px;
color: #e2e8f0; /* Default text color for the page (Light Grey) */
}
.error-message {
color: #ff8080; /* Lighter red for visibility on dark */
background: #451a1a; /* Dark red background */
padding: 10px;
border-radius: 4px;
border: 1px solid #ff8080;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Label styling to ensure visibility */
.form-group label {
font-weight: 600;
color: #ffffff;
}
.form-input {
padding: 10px;
background-color: #1e293b; /* Dark Slate Blue */
color: #ffffff; /* White Text */
border: 1px solid #475569; /* Grey Border */
border-radius: 6px;
font-size: 1rem;
}
.form-input::placeholder {
color: #94a3b8; /* Light grey placeholder text */
}
.divider {
border: 0;
border-top: 1px solid #334155;
margin: 15px 0;
}
/* --- Sentence Builder Area (Top Box) --- */
.sentence-builder-container {
background: #1e293b; /* Dark background */
padding: 20px;
border-radius: 8px;
border: 1px solid #334155;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
}
.sentence-flow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 20px;
min-height: 50px;
padding: 10px;
background: #0f172a; /* Slightly darker inner box for the sentence area */
border-radius: 6px;
border: 1px solid #334155;
}
.inline-block-wrapper {
position: relative;
display: flex;
align-items: center;
}
/* The text input in the sentence flow (The "..." lines) */
.text-block-input {
border: none;
border-bottom: 2px solid #64748b;
background: transparent;
padding: 5px;
font-size: 1.1rem;
color: #ffffff; /* FORCE WHITE TEXT HERE */
width: auto;
min-width: 60px;
max-width: 150px;
transition: border-color 0.2s;
}
.text-block-input:focus {
outline: none;
border-bottom-color: var(--blue, #3b82f6);
}
/* The Dropdown Placeholder (Clickable Chips) */
.dropdown-placeholder {
background: #334155; /* Dark grey background */
border: 2px solid var(--blue, #3b82f6);
color: #ffffff; /* White text */
padding: 5px 12px;
border-radius: 20px;
cursor: pointer;
font-weight: bold;
display: flex;
align-items: center;
gap: 5px;
transition: all 0.2s;
}
.dropdown-placeholder:hover {
background: #475569;
}
.dropdown-placeholder.active {
background: var(--blue, #3b82f6); /* Bright blue when selected */
color: white;
box-shadow: 0 0 10px rgba(59, 130, 246, 0.5);
}
.delete-x {
background: none;
border: none;
color: #94a3b8;
cursor: pointer;
font-size: 1.2rem;
margin-left: -5px;
margin-top: -15px;
z-index: 2;
}
.delete-x:hover {
color: #ef4444; /* Red on hover */
}
.action-row {
display: flex;
gap: 10px;
}
/* --- Editor Panel (Bottom Box - "Edit Options") --- */
.dropdown-editor-panel {
border: 1px solid var(--blue, #3b82f6);
padding: 20px;
border-radius: 8px;
background: #1e293b; /* Match top container */
margin-top: 10px;
}
/* Headings inside the editor panel */
.dropdown-editor-panel h3,
.dropdown-editor-panel h4 {
margin-top: 0;
color: #ffffff;
}
.option-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
/* Inputs for the answers (Option 1, Option 2...) */
.option-input {
flex: 1;
padding: 10px;
background: #0f172a; /* Very dark background */
color: #ffffff; /* White text */
border: 1px solid #475569;
border-radius: 6px;
}
.delete-btn {
border: none;
background: none;
cursor: pointer;
color: #94a3b8;
transition: color 0.2s;
}
.delete-btn:hover {
color: #ef4444;
}
.hint {
font-size: 0.9rem;
color: #94a3b8; /* Light grey for hints */
margin-bottom: 15px;
}
/* --- Main Buttons --- */
.main-actions {
display: flex;
gap: 10px;
padding-bottom: 50px;
margin-top: 20px;
}
/* --- Preview Modal --- */
.preview-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.8); /* Darker overlay */
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
}
.preview-content {
background: #1e293b; /* Dark modal background */
padding: 30px;
border-radius: 12px;
width: 90%;
max-width: 600px;
border: 1px solid #334155;
color: #fff;
}
.preview-sentence {
font-size: 1.2rem;
line-height: 2;
margin: 20px 0;
}
.preview-select {
padding: 5px;
font-size: 1rem;
border: 1px solid #475569;
border-radius: 4px;
margin: 0 5px;
background: #0f172a; /* Dark dropdown in preview */
color: white;
}
.preview-close {
display: flex;
justify-content: flex-end;
}
@@ -0,0 +1,303 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import "./multichoice.css";
export default function CreateMultichoicePage() {
const router = useRouter();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [sections, setSections] = useState([
{ id: "1", type: "text", value: "Ich" },
{ id: "2", type: "text", value: "bin" },
{
id: "3",
type: "multichoice",
options: ["ein", "einer", "eine"],
correct: 0,
},
{ id: "4", type: "text", value: "Berliner" },
]);
// Which dropdown is currently being edited? (Index of the section)
const [activeDropdownIndex, setActiveDropdownIndex] = useState(null);
const [showPreview, setShowPreview] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
// --- Actions ---
const addTextSection = () => {
setSections([
...sections,
{ id: crypto.randomUUID(), type: "text", value: "" },
]);
};
const addMultiChoiceSection = () => {
const newIndex = sections.length;
setSections([
...sections,
{
id: crypto.randomUUID(),
type: "multichoice",
options: ["Option 1", "Option 2"],
correct: 0, // Index of the correct answer
},
]);
// Automatically select the new dropdown for editing
setActiveDropdownIndex(newIndex);
};
const handleTextChange = (index, newValue) => {
const newSections = [...sections];
newSections[index].value = newValue;
setSections(newSections);
};
const removeSection = (index) => {
const newSections = sections.filter((_, i) => i !== index);
setSections(newSections);
if (activeDropdownIndex === index) setActiveDropdownIndex(null);
};
// --- Backend Submission ---
const handleSubmit = async () => {
try {
setIsSubmitting(true);
setError("");
const res = await fetch("/api/admin/exercises/multichoice", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title,
description,
content: sections, // Sends the array exactly as backend expects
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Fehler beim Speichern");
}
router.push("/admin/create-exercise");
} catch (err) {
console.error(err);
setError(err.message);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="admin-container">
<h1>Multiple Choice Übung erstellen</h1>
{error && <div className="error-message">{error}</div>}
<div className="form-group">
<label>Titel</label>
<input
className="form-input"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Titel der Übung"
/>
</div>
<div className="form-group">
<label>Beschreibung</label>
<input
className="form-input"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Anleitung für den Schüler"
/>
</div>
<hr className="divider" />
{/* --- The Sentence Builder (Inline Flow) --- */}
<div className="sentence-builder-container">
<h3>Satz bauen:</h3>
<div className="sentence-flow">
{sections.map((section, index) => {
if (section.type === "text") {
return (
<div key={section.id} className="inline-block-wrapper">
<input
type="text"
value={section.value}
onChange={(e) => handleTextChange(index, e.target.value)}
className="text-block-input"
placeholder="..."
/>
<button
onClick={() => removeSection(index)}
className="delete-x"
>
×
</button>
</div>
);
} else {
// Multichoice Block
const isActive = activeDropdownIndex === index;
return (
<div key={section.id} className="inline-block-wrapper">
<button
onClick={() => setActiveDropdownIndex(index)}
className={`dropdown-placeholder ${isActive ? "active" : ""}`}
>
{/* Show the Correct Answer or "Dropdown" if empty */}
{section.options[section.correct] || "Dropdown"}
<span className="icon">▼</span>
</button>
<button
onClick={() => removeSection(index)}
className="delete-x"
>
×
</button>
</div>
);
}
})}
</div>
{/* --- Buttons to add new parts --- */}
<div className="action-row">
<Button size="sm" onClick={addTextSection}>
+ Text
</Button>
<Button size="sm" onClick={addMultiChoiceSection}>
+ Dropdown
</Button>
</div>
</div>
{/* --- Dropdown Editor (Only shows if a dropdown is clicked) --- */}
{activeDropdownIndex !== null && sections[activeDropdownIndex] && (
<div className="dropdown-editor-panel">
<h3>Optionen bearbeiten</h3>
<p className="hint">
Wähle den Radio-Button für die richtige Antwort.
</p>
{sections[activeDropdownIndex].options.map((opt, optIndex) => (
<div key={optIndex} className="option-row">
<input
type="radio"
name="correct-answer"
checked={sections[activeDropdownIndex].correct === optIndex}
onChange={() => {
const newSections = [...sections];
newSections[activeDropdownIndex].correct = optIndex;
setSections(newSections);
}}
/>
<input
type="text"
value={opt}
onChange={(e) => {
const newSections = [...sections];
newSections[activeDropdownIndex].options[optIndex] =
e.target.value;
setSections(newSections);
}}
className="form-input option-input"
/>
<button
className="delete-btn"
onClick={() => {
const newSections = [...sections];
newSections[activeDropdownIndex].options = newSections[
activeDropdownIndex
].options.filter((_, i) => i !== optIndex);
// Reset correct index if needed
if (newSections[activeDropdownIndex].correct >= optIndex) {
newSections[activeDropdownIndex].correct = 0;
}
setSections(newSections);
}}
>
🗑
</button>
</div>
))}
<Button
size="sm"
variant="secondary"
onClick={() => {
const newSections = [...sections];
newSections[activeDropdownIndex].options.push(
`Option ${newSections[activeDropdownIndex].options.length + 1}`
);
setSections(newSections);
}}
>
+ Weitere Option
</Button>
</div>
)}
<hr className="divider" />
{/* --- Main Action Buttons --- */}
<div className="main-actions">
<Button onClick={handleSubmit} disabled={isSubmitting || !title}>
{isSubmitting ? "Speichern..." : "Erstellen"}
</Button>
<Button variant="secondary" onClick={() => setShowPreview(true)}>
Vorschau
</Button>
<Button
variant="tertiary"
onClick={() => router.push("/admin/create-exercise")}
>
Abbrechen
</Button>
</div>
{/* --- Preview Modal --- */}
{showPreview && (
<div className="preview-overlay">
<div className="preview-content">
<h2>Vorschau: {title}</h2>
<p className="preview-desc">{description}</p>
<div className="preview-sentence">
{sections.map((s, i) =>
s.type === "text" ? (
<span key={i} className="preview-text">
{s.value}&nbsp;
</span>
) : (
<select key={i} className="preview-select">
<option value="">???</option>
{s.options.map((opt, k) => (
<option key={k} value={opt}>
{opt}
</option>
))}
</select>
)
)}
</div>
<div className="preview-close">
<Button onClick={() => setShowPreview(false)}>Schließen</Button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -14,6 +14,9 @@ export default function CreateExercise() {
<LinkButton href="/admin/create-exercise/dragdrop">
Drag und drop
</LinkButton>
<LinkButton href="/admin/create-exercise/multichoice">
Multiple choice
</LinkButton>
</Column>
);
}
@@ -8,19 +8,14 @@ export const POST = withAuth(
const { title, description, content } = json;
// Validation
if (
!title ||
!content ||
!Array.isArray(content) ||
content.length === 0
) {
if (!title || !Array.isArray(content) || content.length === 0) {
return Response.json(
{ error: "Titel und Inhalt erforderlich." },
{ status: 400 }
);
}
// Prevent duplicates
// Check for duplicates
const exists = await DB.pool(
"SELECT id FROM multichoice_exercises WHERE title = $1",
[title]
@@ -32,56 +27,83 @@ export const POST = withAuth(
);
}
// Insert into exercises table
const created_by = 1;
// 1. Insert into parent 'exercises' table
const exResult = await DB.pool(
`INSERT INTO exercises (category) VALUES ('multichoice') RETURNING id`
`INSERT INTO exercises (created_by, category)
VALUES ($1, 'multichoice')
RETURNING id`,
[created_by]
);
const exercise_id = exResult.rows[0].id;
// Insert into multichoice_exercises metadata table
const mcRes = await DB.pool(
`INSERT INTO multichoice_exercises (exercise_id, title, exercise_description)
VALUES ($1, $2, $3)
RETURNING id`,
[exercise_id, title, description ?? null]
`INSERT INTO multichoice_exercises (exercise_id, title)
VALUES ($1, $2)
RETURNING id`,
[exercise_id, title]
);
const multichoice_id = mcRes.rows[0].id;
// Insert content rows
let contentOrder = 1;
// Insert content blocks
let order = 1;
for (const item of content) {
if (!item.type || !item.value) {
return Response.json(
{ error: "Jedes Content-Item benötigt Typ und Wert." },
{ status: 422 }
);
}
const contentRes = await DB.pool(
`INSERT INTO multichoice_content
(multichoice_exercise_id, content_type, content_value, content_order, correct_answer)
VALUES ($1, $2, $3, $4, $5)
RETURNING id`,
VALUES ($1, $2, $3, $4, $5)
RETURNING id`,
[
multichoice_id,
item.type,
item.value ?? "",
contentOrder,
item.value,
order,
item.type === "multichoice" ? item.correct : null,
]
);
const content_id = contentRes.rows[0].id;
// Insert options if it is a selection field
if (item.type === "multichoice" && item.options?.length > 0) {
for (const option of item.options) {
// Insert options
if (item.type === "multichoice") {
if (!Array.isArray(item.options) || item.options.length < 2) {
return Response.json(
{
error:
"Multiple-Choice Felder benötigen mindestens zwei Optionen.",
},
{ status: 422 }
);
}
for (const opt of item.options) {
await DB.pool(
`INSERT INTO multichoice_options (multichoice_content_id, option_value)
VALUES ($1, $2)`,
[content_id, option]
`INSERT INTO multichoice_options
(multichoice_content_id, option_value)
VALUES ($1, $2)`,
[content_id, opt]
);
}
}
contentOrder++;
order++;
}
// Return the ID as the slug fallback
return Response.json(
{ success: true, exerciseId: exercise_id },
{
success: true,
exerciseId: exercise_id,
slug: exercise_id.toString(),
},
{ status: 201 }
);
} catch (err) {
@@ -4,6 +4,7 @@ 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("POST /api/admin/exercises/multichoice", () => {
useTestDatabase();
@@ -21,7 +22,8 @@ describe("POST /api/admin/exercises/multichoice", () => {
},
{
type: "multichoice",
value: null,
// --- FIX: Changed null to a string so validation passes ---
value: "gap",
correct: "France",
options: ["France", "Germany", "Spain", "Italy"],
},