Refactor learning boxes UI, rename headers/buttons, add footer credits, improve KPL 5 result sorting, support line breaks in MC exercises, and update sidebar

This commit is contained in:
Erza
2026-04-13 13:07:32 +00:00
parent 097e852d47
commit 45d6b4cb5e
18 changed files with 444 additions and 64 deletions
+22 -8
View File
@@ -7,23 +7,37 @@ test.describe("Learning Page", () => {
await expect(page.getByRole("main").first()).toBeVisible();
});
test("should show language dropdown button", async ({ page }) => {
test("should show language toggle options", async ({ page }) => {
await page.goto("/learning", { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("button", { name: /English|Deutsch/i })
page.getByRole("group", { name: "Choose language" })
).toBeVisible({ timeout: 15000 });
await expect(page.getByRole("button", { name: "English" })).toBeVisible();
await expect(page.getByRole("button", { name: "Deutsch" })).toBeVisible();
});
test("should have language selection functionality", async ({ page }) => {
test("should switch language selection", async ({ page }) => {
await page.goto("/learning");
const languageButton = page.getByRole("button", {
name: /English|Deutsch/i,
});
await expect(languageButton).toBeVisible();
const englishButton = page.getByRole("button", { name: "English" });
const deutschButton = page.getByRole("button", { name: "Deutsch" });
await languageButton.click();
await expect(englishButton).toBeVisible();
await expect(deutschButton).toBeVisible();
const englishPressed =
(await englishButton.getAttribute("aria-pressed")) === "true";
if (englishPressed) {
await deutschButton.click();
await expect(deutschButton).toHaveAttribute("aria-pressed", "true");
await expect(englishButton).toHaveAttribute("aria-pressed", "false");
} else {
await englishButton.click();
await expect(englishButton).toHaveAttribute("aria-pressed", "true");
await expect(deutschButton).toHaveAttribute("aria-pressed", "false");
}
await expect(page).toHaveURL(/.*learning/);
});
+4 -4
View File
@@ -8,14 +8,14 @@ test.describe("Navigation", () => {
});
test("should navigate to learning resources page", async ({ page }) => {
await page.goto("/resources");
await expect(page).toHaveURL(/.*resources/);
await page.goto("/pages/resources");
await expect(page).toHaveURL(/.*pages\/resources/);
await expect(page.locator("main")).toBeVisible();
});
test("should navigate to a specific chapter", async ({ page }) => {
await page.goto("/resources/1");
await expect(page).toHaveURL(/.*resources\/1/);
await page.goto("/pages/resources/1");
await expect(page).toHaveURL(/.*pages\/resources\/1/);
await expect(page.locator("main")).toBeVisible();
});
});
+7 -4
View File
@@ -1,5 +1,5 @@
const { test, expect } = require("@playwright/test");
const { takeScreenshot, elementExists } = require("./utils/helpers");
const { test } = require("@playwright/test");
const { elementExists } = require("./utils/helpers");
test.describe("Page Structure Analysis", () => {
test("analyze homepage structure", async ({ page }) => {
@@ -40,8 +40,11 @@ test.describe("Page Structure Analysis", () => {
const hasH1 = await elementExists(page, "h1");
console.log("Has h1:", hasH1);
const hasTabs = await elementExists(page, '[role="tablist"]');
console.log("Has tablist:", hasTabs);
const hasLanguageToggle = await elementExists(
page,
'[role="group"][aria-label="Choose language"]'
);
console.log("Has language toggle group:", hasLanguageToggle);
const hasButtons = await elementExists(page, "button");
console.log("Has buttons:", hasButtons);
@@ -36,7 +36,7 @@ test.describe("Resources Detail Pages", () => {
await page.goto("/pages/resources/1", { waitUntil: "domcontentloaded" });
const resourcesLink = page.getByRole("link", {
name: "Richtig Online Lernen",
name: "Effektiv online lernen",
});
if (await resourcesLink.isVisible()) {
@@ -11,7 +11,7 @@
.error-message {
color: var(--fg1);
background: var(--tertiary1);
background: var(--tertiary2);
padding: 10px;
border-radius: 4px;
border: 1px solid var(--tertiary2);
@@ -38,6 +38,18 @@
font-size: 1rem;
}
.form-input.input-error {
border-color: var(--tertiary2, #ef4444);
outline: none;
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2);
}
.field-error-hint {
font-size: 0.85rem;
color: var(--tertiary2, #ef4444);
margin-top: 2px;
}
.form-input::placeholder {
color: var(--fg4);
}
@@ -77,6 +89,18 @@
align-items: center;
}
.linebreak-block {
width: 100%;
flex-basis: 100%;
}
.linebreak-indicator {
font-size: 1.1rem;
color: var(--fg4);
padding: 0 6px;
user-select: none;
}
/* The text input in the sentence flow (The "..." lines) */
.text-block-input {
border: none;
@@ -26,6 +26,7 @@ export default function CreateMultichoicePage() {
const [showPreview, setShowPreview] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
const [titleError, setTitleError] = useState(false);
// --- Actions ---
@@ -65,6 +66,10 @@ export default function CreateMultichoicePage() {
setActiveSectionIndex(newIndex);
};
const addLineBreakSection = () => {
setSections([...sections, { id: crypto.randomUUID(), type: "linebreak" }]);
};
const handleTextChange = (index, newValue) => {
const newSections = [...sections];
newSections[index].value = newValue;
@@ -83,6 +88,7 @@ export default function CreateMultichoicePage() {
try {
setIsSubmitting(true);
setError("");
setTitleError(false);
// --- FIX 1: Convert Index to Text for correct answer ---
const contentToSave = sections.map((section) => {
@@ -107,6 +113,7 @@ export default function CreateMultichoicePage() {
const data = await res.json();
if (!res.ok) {
if (res.status === 409) setTitleError(true);
throw new Error(data.error || "Fehler beim Speichern");
}
@@ -128,11 +135,19 @@ export default function CreateMultichoicePage() {
<div className="form-group">
<label>Titel</label>
<input
className="form-input"
className={`form-input${titleError ? " input-error" : ""}`}
value={title}
onChange={(e) => setTitle(e.target.value)}
onChange={(e) => {
setTitle(e.target.value);
setTitleError(false);
}}
placeholder="Titel der Übung"
/>
{titleError && (
<span className="field-error-hint">
Dieser Titel existiert bereits. Bitte wähle einen anderen.
</span>
)}
</div>
<hr className="divider" />
@@ -160,6 +175,22 @@ export default function CreateMultichoicePage() {
</div>
);
}
if (section.type === "linebreak") {
return (
<div
key={section.id}
className="inline-block-wrapper linebreak-block"
>
<span className="linebreak-indicator">↵</span>
<button
onClick={() => removeSection(index)}
className="delete-x"
>
×
</button>
</div>
);
}
if (section.type === "gap") {
const isActive = activeSectionIndex === index;
@@ -209,10 +240,13 @@ export default function CreateMultichoicePage() {
+ Text
</Button>
<Button size="sm" onClick={addMultiChoiceSection}>
+ Dropdown
+ Runterfallen
</Button>
<Button size="sm" onClick={addGapSection}>
+ Gap
+ Lücke
</Button>
<Button size="sm" onClick={addLineBreakSection}>
+ Zeilenumbruch
</Button>
</div>
</div>
@@ -358,6 +392,8 @@ export default function CreateMultichoicePage() {
placeholder="..."
disabled
/>
) : s.type === "linebreak" ? (
<br key={i} />
) : null
)}
</div>
+2 -1
View File
@@ -388,6 +388,7 @@ h2 {
ul {
list-style-type: circle;
padding-left: 1rem;
}
p {
@@ -489,7 +490,7 @@ label {
.rendered-html ul,
.rendered-html ol {
padding-left: 1.5rem;
padding-left: 3rem;
margin-bottom: 1em;
}
@@ -30,7 +30,9 @@ export default function FreeformFeedback() {
<Container mt="xl">
<Container mb="md">
<Button variant="outline" onClick={toggleFeedback}>
{showAllFeedback ? "Hide Feedback" : "Show correct answers"}
{showAllFeedback
? "Feedback ausblenden"
: "Richtige Antworten anzeigen"}
</Button>
</Container>
@@ -38,15 +40,15 @@ export default function FreeformFeedback() {
<>
{isLoading && (
<Container p="md" bg="var(--bg2)" br="md" mb="md">
Loading feedback...
Feedback wird geladen...
</Container>
)}
{error && (
<Container p="md" bg="var(--tertiary1)" br="md" mb="md">
Error: {error}
Fehler: {error}
<Button onClick={refetch} variant="secondary" size="sm" mt="sm">
Try Again
Erneut versuchen
</Button>
</Container>
)}
@@ -57,7 +59,7 @@ export default function FreeformFeedback() {
{feedback.userAnswers && feedback.userAnswers.length > 0 ? (
<Container mt="md">
<h3>Your Answer</h3>
<h3>Ihre Antwort</h3>
{feedback.userAnswers.map((answer, index) => (
<Container
key={index}
@@ -69,11 +71,11 @@ export default function FreeformFeedback() {
br="md"
>
<Row justify="space-between" w="100%">
<strong>Answer: {answer.answer}</strong>
{answer.is_correct ? "Correct" : "Incorrect"}
<strong>Antwort: {answer.answer}</strong>
{answer.is_correct ? "Richtig" : "Falsch"}
</Row>
<div>
<strong>Attempted:</strong>{" "}
<strong>Versucht:</strong>{" "}
{new Date(answer.updated_at).toLocaleString()}
</div>
</Container>
@@ -84,7 +86,7 @@ export default function FreeformFeedback() {
{feedback.possibleAnswers &&
feedback.possibleAnswers.length > 0 ? (
<Container mt="md">
<h3>Possible Answers</h3>
<h3>Mögliche Antworten</h3>
{feedback.possibleAnswers
.filter((answer) => answer.is_correct)
.map((answer, index) => (
@@ -100,12 +102,12 @@ export default function FreeformFeedback() {
br="md"
>
<div>
<strong>Answer {index + 1}:</strong> {answer.answer}
<strong>Antwort {index + 1}:</strong> {answer.answer}
</div>
<div>
<strong>Feedback:</strong>{" "}
{answer.feedback ||
(answer.is_correct ? "Correct" : "Incorrect")}
(answer.is_correct ? "Richtig" : "Falsch")}
</div>
</Container>
))}
@@ -116,7 +118,7 @@ export default function FreeformFeedback() {
!feedback.possibleAnswers.length) &&
(!feedback.userAnswers || !feedback.userAnswers.length) && (
<Container>
No feedback available for this exercise.
Für diese Übung ist kein Feedback verfügbar.
</Container>
)}
</Container>
@@ -49,6 +49,30 @@
width: 100%;
}
.languageToggleWrap {
display: flex;
flex-direction: column;
gap: var(--u-sm);
}
.languageLabel {
font-size: var(--font-sm);
color: var(--fg3);
}
.languageToggle {
display: inline-flex;
gap: var(--u-sm);
padding: var(--u-xs);
border: 1px solid var(--fg4);
border-radius: var(--radius-md);
background: var(--bg2);
}
.languageButton {
min-width: 7rem;
}
.learningAdvice {
display: flex;
flex-direction: column;
@@ -80,3 +104,20 @@
flex-direction: column;
gap: 1rem;
}
@media (max-width: 768px) {
.learningAnswersHeader {
flex-direction: column;
align-items: stretch;
gap: var(--u-md);
}
.languageToggle {
width: 100%;
}
.languageButton {
flex: 1;
min-width: 0;
}
}
+37 -7
View File
@@ -2,16 +2,15 @@
import useQuery from "@/shared/hooks/useQuery";
import styles from "./learning-answers.module.css";
import { FORM_LANGUAGE_OPTIONS } from "../page";
import { useState } from "react";
import {
getLanguageField,
getLanguageTitle,
} from "@/components/ui/learning-form";
import { Dropdown } from "@/components/ui/dropdown";
import { Button } from "@/components/ui/button";
import layout from "@/shared/styles/layout.module.css";
import { useRouter } from "next/navigation";
import useLocalStorage from "@/shared/utils/useLocalStorage";
export default function LearningAnswersPage() {
const { data: answers } = useQuery("/forms/learning_type/answer");
const { data: form } = useQuery("/forms/learning_type");
@@ -20,8 +19,16 @@ export default function LearningAnswersPage() {
"language",
FORM_LANGUAGE_OPTIONS[0]
);
const partsByHighestScore = [...(form?.parts || [])].sort((partA, partB) => {
const scoreA = answers?.[partA.id] ?? 0;
const scoreB = answers?.[partB.id] ?? 0;
return scoreB - scoreA;
});
const renderAverages = () => {
return form?.parts.map((part, index) => {
return partsByHighestScore.map((part) => {
const average = answers?.[part.id];
return (
<LearningAverage
@@ -35,7 +42,7 @@ export default function LearningAnswersPage() {
};
const renderAdvices = () => {
return form?.parts
return [...(form?.parts || [])]
.sort(
(a, b) =>
(b.advice_threshold < answers?.[b.id]) -
@@ -56,9 +63,32 @@ export default function LearningAnswersPage() {
return (
<div className={layout.view}>
<div className={styles.learningAnswersHeader}>
<Dropdown options={FORM_LANGUAGE_OPTIONS} onSelect={setLanguage}>
<Button>{language.label}</Button>
</Dropdown>
<div className={styles.languageToggleWrap}>
<span className={styles.languageLabel}>Language / Sprache</span>
<div
className={styles.languageToggle}
role="group"
aria-label="Choose language"
>
{FORM_LANGUAGE_OPTIONS.map((option) => {
const isActive = language.value === option.value;
return (
<Button
key={option.value}
type="button"
size="sm"
variant={isActive ? "primary" : "outline"}
className={styles.languageButton}
onClick={() => setLanguage(option)}
aria-pressed={isActive}
>
{option.label}
</Button>
);
})}
</div>
</div>
<Button onClick={() => router.push("/learning")}>
{language.value === "de" ? "Test neu machen" : "Retake Test"}
</Button>
@@ -0,0 +1,34 @@
.languageToggleWrap {
display: flex;
flex-direction: column;
gap: var(--u-sm);
}
.languageLabel {
font-size: var(--font-sm);
color: var(--fg3);
}
.languageToggle {
display: inline-flex;
gap: var(--u-sm);
padding: var(--u-xs);
border: 1px solid var(--fg4);
border-radius: var(--radius-md);
background: var(--bg2);
}
.languageButton {
min-width: 7rem;
}
@media (max-width: 768px) {
.languageToggle {
width: 100%;
}
.languageButton {
flex: 1;
min-width: 0;
}
}
+28 -5
View File
@@ -1,9 +1,9 @@
"use client";
import { useState, useEffect } from "react";
import { Dropdown } from "@/components/ui/dropdown";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import useQuery from "@/shared/hooks/useQuery";
import layout from "@/shared/styles/layout.module.css";
import styles from "./learning.module.css";
import { useRequest } from "@/shared/hooks/useRequest";
import useLocalStorage from "@/shared/utils/useLocalStorage";
import { LearningForm } from "@/components/ui/learning-form";
@@ -50,9 +50,32 @@ export default function Learning() {
return (
<div className={layout.view}>
<Row justify="space-between">
<Dropdown options={FORM_LANGUAGE_OPTIONS} onSelect={setLanguage}>
<Button>{language.label}</Button>
</Dropdown>
<div className={styles.languageToggleWrap}>
<span className={styles.languageLabel}>Language / Sprache</span>
<div
className={styles.languageToggle}
role="group"
aria-label="Choose language"
>
{FORM_LANGUAGE_OPTIONS.map((option) => {
const isActive = language.value === option.value;
return (
<Button
key={option.value}
type="button"
size="sm"
variant={isActive ? "primary" : "outline"}
className={styles.languageButton}
onClick={() => setLanguage(option)}
aria-pressed={isActive}
>
{option.label}
</Button>
);
})}
</div>
</div>
{error && <span className="error">{error}</span>}
</Row>
{data && (
+56 -13
View File
@@ -1,6 +1,7 @@
"use client";
import { LinkButton } from "@/components/ui/linkbutton";
import styles from "./page.module.css";
export default function Home() {
return (
@@ -16,27 +17,69 @@ export default function Home() {
<div className="features">
<div className="feature-card">
<h3>Lernplan</h3>
<h3>Lerntechniken</h3>
<p>Wie lernst du online am besten?</p>
<LinkButton href="/learning">
Entdecke deine Lernstrategien
</LinkButton>
<p>Finde den passenden Übungstyp für dich!</p>
<div className="feature-card">
<LinkButton href="/pages/resources/1" size="md">
Effektiv online lernen
</LinkButton>
<LinkButton href="/learning" size="md">
Entdecke deine Lernstrategien
</LinkButton>
</div>
</div>
<div className="feature-card">
<h3>Grammatik</h3>
<p>Selbständig Grammatik lernen mit kommunikativen Situationen!</p>
<LinkButton href="/pages/communications" size="md">
Kommunikationssituationen
</LinkButton>
<LinkButton href="/grammar/themes" size="md">
Themen der Grammatik
</LinkButton>
<LinkButton href="/grammar/exercises" size="md">
Übungen
</LinkButton>
<div className="feature-card">
<LinkButton href="/pages/communications" size="md">
Kommunikationssituationen
</LinkButton>
<LinkButton href="/grammar/themes" size="md">
Themen der Grammatik
</LinkButton>
<LinkButton href="/grammar/exercises" size="md">
Alle Übungen anzeigen
</LinkButton>
</div>
</div>
</div>
<div className="container">
<section className={styles.creditsSection}>
<div className={styles.creditsList}>
<p className={styles.creditsItem}>
Diese Webseiten entstanden in einem Autorenkollektiv der
Universitäten Helsinki, Oulu und Turku unter der Leitung von{" "}
<a href="mailto:michi@dlc.fi" className={styles.creditsLink}>
Dr. Michael Möbius
</a>
.
</p>
<br></br>
<p className={styles.creditsItem}>
Die Entwicklung wurde gefördert durch die{" "}
<a
href="https://foundationhollo.fi/"
target="_blank"
rel="noreferrer"
className={styles.creditsLink}
>
Stiftung Erkki-J-Hollo
</a>
.
</p>
<p className={styles.creditsItem}>
Programmierung durch IT-Studierende der Universität Helsinki
</p>
<p className={styles.creditsItem}>
Technischer Support und Hosting: Universität Helsinki
</p>
</div>
</section>
</div>
</div>
</>
);
+50
View File
@@ -53,3 +53,53 @@
.card {
padding: 2em;
}
.creditsSection {
width: min(100%, 1080px);
margin: 0 auto;
padding: 1.75rem 2rem;
border-radius: 24px;
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.08);
}
.creditsTitle {
margin-bottom: 1rem;
color: var(--fg2);
font-size: clamp(1.25rem, 2vw, 1.7rem);
font-weight: 700;
}
.creditsList {
display: grid;
gap: 0.9rem;
}
.creditsItem {
color: var(--fg2);
font-size: 1.05rem;
line-height: 1.7;
text-wrap: pretty;
}
.creditsLink {
color: var(--blue);
font-weight: 600;
text-decoration: underline;
text-underline-offset: 0.15em;
transition: color 0.2s ease;
}
.creditsLink:hover {
color: var(--fg1);
}
@media (max-width: 768px) {
.creditsSection {
padding: 1.25rem;
border-radius: 18px;
}
.creditsItem {
font-size: 1rem;
}
}
@@ -23,7 +23,8 @@
.baseButton.sm {
padding: 0.25rem 0.5rem;
width: 8rem;
/* width: 8rem; , it aligns better with auto */
width: auto;
/* height: 1.5rem; , it aligns better with auto */
height: auto;
border-radius: var(--radius-sm);
@@ -66,6 +66,10 @@ export default function RenderText({
);
}
if (item.content_type === "linebreak") {
return <br key={index} />;
}
return null; // Fallback for unsupported types
};
return <p className="task-sentence">{exerciseData.map(renderItem)}</p>;
+52 -2
View File
@@ -95,13 +95,17 @@ function StudentSideBar() {
return (
<>
<SidebarGroup
title="Richtig Online Lernen"
title="Effektiv online lernen"
sublinks={chapters}
topLink="/pages/resources"
collapsible
/>
<Link className={styles.sidebarLink} href="/pages/communications">
Kommunikations-situationen
</Link>
<Link className={styles.sidebarLink} href="/grammar/themes">
Themen der Grammatik
</Link>
<Link className={styles.sidebarLink} href="/talkback">
Rückmeldekanal-Feedback geben
</Link>
@@ -211,8 +215,54 @@ function AdminSideBar({ showUsers }) {
return <SidebarGroup title="Admin" sublinks={links} topLink="/admin" />;
}
function SidebarGroup({ title, sublinks, topLink }) {
function SidebarGroup({ title, sublinks, topLink, collapsible = false }) {
const pathname = usePathname();
const isUnderSection =
pathname === topLink || pathname.startsWith(topLink + "/");
const [open, setOpen] = useState(isUnderSection);
if (collapsible) {
return (
<div className={styles.sidebarGroup}>
<button
type="button"
className={[
styles.sidebarLink,
styles.sidebarGroupToggle,
isUnderSection ? styles.active : "",
].join(" ")}
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
>
{title}
<span
className={[styles.chevron, open ? styles.chevronOpen : ""].join(
" "
)}
aria-hidden="true"
>
▾
</span>
</button>
{open && (
<div className={styles.sublinkContainer}>
{sublinks.map((sublink) => (
<Link
className={[
styles.sublink,
pathname === sublink.link ? styles.active : "",
].join(" ")}
key={sublink.link}
href={sublink.link}
>
{sublink.title}
</Link>
))}
</div>
)}
</div>
);
}
return (
<div className={styles.sidebarGroup}>
@@ -95,6 +95,30 @@
gap: var(--u-md);
}
.sidebarGroupToggle {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
background: none;
border: none;
cursor: pointer;
width: 100%;
text-align: left;
font-family: inherit;
}
.chevron {
display: inline-block;
font-size: var(--font-md);
transition: transform 0.2s ease;
flex-shrink: 0;
}
.chevronOpen {
transform: rotate(180deg);
}
.navItem {
width: 100%;
padding: 12px 12px;
@@ -117,4 +141,4 @@
.sidebar {
background-color: var(--bg1);
}
}
}