feat(admin-editor): improve multichoice authoring, editor behavior and page styling

This commit is contained in:
Erza Bytyci
2026-04-30 12:24:15 +00:00
parent aedaa6ff8f
commit df5dc8b70c
18 changed files with 324 additions and 82 deletions
+7 -1
View File
@@ -23,6 +23,7 @@
"pg": "^8.13.1",
"qs": "^6.14.0",
"quill": "^2.0.3",
"quill-resize-module": "^2.1.3",
"quill-table-better": "^1.2.3",
"react": "^19.0.0",
"react-dnd": "^16.0.1",
@@ -6266,7 +6267,6 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
@@ -8678,6 +8678,12 @@
"node": ">= 12.0.0"
}
},
"node_modules/quill-resize-module": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/quill-resize-module/-/quill-resize-module-2.1.3.tgz",
"integrity": "sha512-Hrs/pwKqmnEkY8Z5zbp4LxdLSHmQBXGAR5yzEFDeSfWTZIMpi6yII/dqIeXfwQBKAxWAVSxEeWqY7DMZ2GLTaQ==",
"license": "MIT"
},
"node_modules/quill-table-better": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/quill-table-better/-/quill-table-better-1.2.3.tgz",
+2 -1
View File
@@ -33,6 +33,7 @@
"pg": "^8.13.1",
"qs": "^6.14.0",
"quill": "^2.0.3",
"quill-resize-module": "^2.1.3",
"quill-table-better": "^1.2.3",
"react": "^19.0.0",
"react-dnd": "^16.0.1",
@@ -61,4 +62,4 @@
"ts-node": "^10.9.2",
"vitest": "^3.0.4"
}
}
}
@@ -70,6 +70,26 @@
color: var(--fg1);
}
.builder-hint {
margin: 0 0 12px;
color: var(--fg4);
font-size: 0.95rem;
}
.clear-selection-button {
border: none;
background: transparent;
color: var(--blue, #3b82f6);
cursor: pointer;
padding: 0;
margin-bottom: 12px;
font-size: 0.95rem;
}
.clear-selection-button:hover {
text-decoration: underline;
}
.sentence-flow {
display: flex;
flex-wrap: wrap;
@@ -87,6 +107,11 @@
position: relative;
display: flex;
align-items: center;
border-radius: 8px;
}
.inline-block-wrapper.active {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.18);
}
.linebreak-block {
@@ -101,6 +126,19 @@
user-select: none;
}
.linebreak-button {
border: 1px dashed var(--bg3);
background: transparent;
border-radius: 999px;
padding: 4px 10px;
cursor: pointer;
}
.linebreak-button.active {
border-color: var(--blue, #3b82f6);
background: rgba(59, 130, 246, 0.1);
}
/* The text input in the sentence flow (The "..." lines) */
.text-block-input {
border: none;
@@ -120,6 +158,10 @@
border-bottom-color: var(--blue, #3b82f6);
}
.inline-block-wrapper.active .text-block-input {
border-bottom-color: var(--blue, #3b82f6);
}
/* The Dropdown Placeholder (Clickable Chips) */
.dropdown-placeholder {
background: var(--bg1);
@@ -91,44 +91,48 @@ export default function CreateMultichoicePage() {
// --- Actions ---
const insertSection = (newSection, { selectInserted = false } = {}) => {
const insertIndex =
activeSectionIndex === null ? sections.length : activeSectionIndex + 1;
const newSections = [...sections];
newSections.splice(insertIndex, 0, newSection);
setSections(newSections);
if (selectInserted) {
setActiveSectionIndex(insertIndex);
}
};
const addTextSection = () => {
setSections([
...sections,
{ id: crypto.randomUUID(), type: "text", value: "" },
]);
insertSection({ id: crypto.randomUUID(), type: "text", value: "" });
};
const addMultiChoiceSection = () => {
const newIndex = sections.length;
setSections([
...sections,
insertSection(
{
id: crypto.randomUUID(),
type: "multichoice",
options: ["Option 1", "Option 2"],
correct: 0, // Index of the correct answer
},
]);
// Automatically select the new dropdown for editing
setActiveSectionIndex(newIndex);
{ selectInserted: true }
);
};
const addGapSection = () => {
const newIndex = sections.length;
setSections([
...sections,
insertSection(
{
id: crypto.randomUUID(),
type: "gap",
value: "___",
correct: "",
},
]);
setActiveSectionIndex(newIndex);
{ selectInserted: true }
);
};
const addLineBreakSection = () => {
setSections([...sections, { id: crypto.randomUUID(), type: "linebreak" }]);
insertSection({ id: crypto.randomUUID(), type: "linebreak" });
};
const handleTextChange = (index, newValue) => {
@@ -140,7 +144,13 @@ export default function CreateMultichoicePage() {
const removeSection = (index) => {
const newSections = sections.filter((_, i) => i !== index);
setSections(newSections);
if (activeSectionIndex === index) setActiveSectionIndex(null);
if (activeSectionIndex === index) {
setActiveSectionIndex(null);
return;
}
if (activeSectionIndex !== null && activeSectionIndex > index) {
setActiveSectionIndex(activeSectionIndex - 1);
}
};
// --- Backend Submission ---
@@ -235,15 +245,35 @@ export default function CreateMultichoicePage() {
{/* --- The Sentence Builder (Inline Flow) --- */}
<div className="sentence-builder-container">
<h3>Satz bauen:</h3>
<p className="builder-hint">
Neue Elemente werden nach dem markierten Abschnitt eingefügt. Ohne
Auswahl werden sie am Ende angehängt.
</p>
{activeSectionIndex !== null && (
<button
type="button"
className="clear-selection-button"
onClick={() => setActiveSectionIndex(null)}
>
Auswahl aufheben und am Ende einfügen
</button>
)}
<div className="sentence-flow">
{sections.map((section, index) => {
if (section.type === "text") {
return (
<div key={section.id} className="inline-block-wrapper">
<div
key={section.id}
className={`inline-block-wrapper ${
activeSectionIndex === index ? "active" : ""
}`}
>
<input
type="text"
value={section.value}
onChange={(e) => handleTextChange(index, e.target.value)}
onFocus={() => setActiveSectionIndex(index)}
onClick={() => setActiveSectionIndex(index)}
className="text-block-input"
placeholder="..."
/>
@@ -260,9 +290,19 @@ export default function CreateMultichoicePage() {
return (
<div
key={section.id}
className="inline-block-wrapper linebreak-block"
className={`inline-block-wrapper linebreak-block ${
activeSectionIndex === index ? "active" : ""
}`}
>
<span className="linebreak-indicator">↵</span>
<button
type="button"
onClick={() => setActiveSectionIndex(index)}
className={`linebreak-button ${
activeSectionIndex === index ? "active" : ""
}`}
>
<span className="linebreak-indicator">↵</span>
</button>
<button
onClick={() => removeSection(index)}
className="delete-x"
@@ -276,7 +316,10 @@ export default function CreateMultichoicePage() {
const isActive = activeSectionIndex === index;
return (
<div key={section.id} className="inline-block-wrapper">
<div
key={section.id}
className={`inline-block-wrapper ${isActive ? "active" : ""}`}
>
<button
onClick={() => setActiveSectionIndex(index)}
className={`dropdown-placeholder ${isActive ? "active" : ""}`}
@@ -295,7 +338,10 @@ export default function CreateMultichoicePage() {
// Multichoice Block
const isActive = activeSectionIndex === index;
return (
<div key={section.id} className="inline-block-wrapper">
<div
key={section.id}
className={`inline-block-wrapper ${isActive ? "active" : ""}`}
>
<button
onClick={() => setActiveSectionIndex(index)}
className={`dropdown-placeholder ${isActive ? "active" : ""}`}
@@ -13,12 +13,12 @@ export default function CreateExercise() {
Freie Übung
</LinkButton>
<LinkButton href="/admin/create-exercise/click">Klick Übung</LinkButton>
<LinkButton href="/admin/create-exercise/dragdrop">
Sortieren/Gruppieren
</LinkButton>
<LinkButton href="/admin/create-exercise/multichoice">
Multiple choice
</LinkButton>
<LinkButton href="/admin/create-exercise/dragdrop">
Sortieren/Gruppieren
</LinkButton>
<LinkButton href="/admin/create-exercise/fillinthegap">
Lückentext
</LinkButton>
@@ -38,7 +38,7 @@ export default function EditorSection({
const submitEditorContent = async () => {
// A naive approach with string replacement is used here.
const requestBody = {
content: editorContent.replace(/&nbsp;/g, " "),
content: editorContent,
title: titleInput,
};
@@ -28,6 +28,8 @@
}
.control {
box-sizing: border-box;
display: block;
width: 100%;
min-height: 2.65rem;
padding: 0.55rem 0.75rem;
@@ -37,11 +39,17 @@
color: var(--fg1);
caret-color: var(--fg1);
font-size: 0.98rem;
line-height: 1.25;
text-align: left;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
select.control {
padding-right: 2.1rem;
}
.control::placeholder {
color: var(--fg4);
}
@@ -66,4 +74,4 @@
grid-template-columns: 1fr;
row-gap: 0.45rem;
}
}
}
@@ -189,7 +189,9 @@ export const POST = withAuth(
function sanitize(data) {
const window = new JSDOM("").window;
const purify = DOMPurify(window);
const cleaned = purify.sanitize(data, { ADD_ATTR: ["target"] });
const cleaned = purify.sanitize(data, {
ADD_ATTR: ["target", "width", "height", "data-size"],
});
return cleaned;
}
@@ -97,6 +97,34 @@ describe("PUT /api/admin/pages/[type]/[slug]", () => {
expect(response.status).toBe(404);
});
it("should preserve resize attributes and remove unsafe ones in content", async () => {
const admin = await TestFactory.user({ is_admin: true });
const { mockPut, mockParams } = useTestRequest(admin);
const updateData = {
content:
'<p><img src="/example.png" width="320" height="180" data-size="320x180" onerror="alert(1)"></p>',
};
const response = await PUT(
mockPut("/api/admin/pages/resources/test-page", updateData),
mockParams({ type: "resources", slug: "test-page" })
);
expect(response.status).toBe(200);
const result = await DB.pool(
"SELECT content FROM html_pages WHERE page_group = $1 AND slug = $2",
["resources", "test-page"]
);
expect(result.rows.length).toBe(1);
expect(result.rows[0].content).toContain('width="320"');
expect(result.rows[0].content).toContain('height="180"');
expect(result.rows[0].content).toContain('data-size="320x180"');
expect(result.rows[0].content).not.toContain("onerror");
});
});
describe("DELETE /api/admin/pages/[type]/[slug]", () => {
+66 -5
View File
@@ -536,25 +536,72 @@ label {
font-size: 2.5em;
}
.rendered-html .ql-indent-1 {
.rendered-html :not(li).ql-indent-1 {
padding-left: 3em;
}
.rendered-html .ql-indent-2 {
.rendered-html :not(li).ql-indent-2 {
padding-left: 6em;
}
.rendered-html .ql-indent-3 {
.rendered-html :not(li).ql-indent-3 {
padding-left: 9em;
}
.rendered-html .ql-indent-4 {
.rendered-html :not(li).ql-indent-4 {
padding-left: 12em;
}
.rendered-html :not(li).ql-indent-5 {
padding-left: 15em;
}
.rendered-html :not(li).ql-indent-6 {
padding-left: 18em;
}
.rendered-html :not(li).ql-indent-7 {
padding-left: 21em;
}
.rendered-html :not(li).ql-indent-8 {
padding-left: 24em;
}
.rendered-html li.ql-indent-1 {
margin-left: 3em;
}
.rendered-html li.ql-indent-2 {
margin-left: 6em;
}
.rendered-html li.ql-indent-3 {
margin-left: 9em;
}
.rendered-html li.ql-indent-4 {
margin-left: 12em;
}
.rendered-html li.ql-indent-5 {
margin-left: 15em;
}
.rendered-html li.ql-indent-6 {
margin-left: 18em;
}
.rendered-html li.ql-indent-7 {
margin-left: 21em;
}
.rendered-html li.ql-indent-8 {
margin-left: 24em;
}
.rendered-html img {
max-width: 100%;
height: auto;
}
.rendered-html {
@@ -608,4 +655,18 @@ label {
.ql-table-dropdown-list li,
.ql-table-dropdown-properties-list li {
color: #000;
}
/* Image resize UX: use a single bottom-right handle, similar to table resizing. */
.ql-resize-handle.tl,
.ql-resize-handle.tr,
.ql-resize-handle.bl {
display: none !important;
}
.ql-resize-handle.br {
width: 14px;
height: 14px;
right: -7px;
bottom: -7px;
}
+5 -2
View File
@@ -93,6 +93,9 @@
color: var(--red);
}
h2 {
.themes-title h2,
.lessons-content h2,
.lessons-container h2,
.flex-child-element h2 {
color: var(--fg2);
}
}
@@ -10,9 +10,10 @@
color: hsla(0, 100%, 50%, 1);
}
h1,
h2,
h3 {
.chapter-page-title,
.chapter-rendered-content h1,
.chapter-rendered-content h2,
.chapter-rendered-content h3 {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
margin-top: 1.5rem;
margin-bottom: 1rem;
@@ -20,18 +21,19 @@ h3 {
line-height: 1.3;
}
h1 {
.chapter-page-title,
.chapter-rendered-content h1 {
font-size: 2rem;
padding-bottom: 0.5rem;
color: var(--fg1);
}
h2 {
.chapter-rendered-content h2 {
font-size: 1.75rem;
color: var(--fg1);
}
h3 {
.chapter-rendered-content h3 {
font-size: 1.35rem;
color: var(--fg1);
}
+1 -1
View File
@@ -32,7 +32,7 @@ export default async function Chapters({ params }) {
return (
<Column className={layout.viewContent}>
<AdminButtons type={type} slug={slug} />
<h1>{pageData.title}</h1>
<h1 className="chapter-page-title">{pageData.title}</h1>
<Container className="chapter-rendered-content">
<RenderHTML data={transformedContent} />
+4 -4
View File
@@ -4,7 +4,7 @@
flex-direction: column;
flex-grow: 1;
align-items: center;
padding-top: 100px;
padding-top: 10px;
}
.talkback-box {
@@ -21,7 +21,7 @@
max-width: max-content;
background-color: var(--bg3);
border-radius: var(--radius-2xl);
padding: var(--u-3xl);
padding: var(--u-xl);
display: flex;
flex-direction: column;
gap: var(--u-lg);
@@ -48,7 +48,7 @@
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: 0.2rem;
}
.form-label {
@@ -80,4 +80,4 @@
padding: var(--u-xl);
border-radius: var(--radius-xl);
}
}
}
@@ -2,10 +2,6 @@
width: fit-content;
}
h2 {
color: var(--tertiary1);
}
.word-container {
line-height: 2.2;
white-space: pre-wrap;
@@ -1,6 +1,7 @@
import React, { useEffect, useRef } from "react";
import "quill/dist/quill.snow.css";
import "quill-table-better/dist/quill-table-better.css";
import "quill-resize-module/dist/resize.css";
function setHtmlContent(quill, html) {
const content = String(html || "");
@@ -26,12 +27,25 @@ const ClientEditor = (props) => {
const load = async () => {
const Quill = (await import("quill")).default;
const TableBetter = (await import("quill-table-better")).default;
let QuillResize = null;
try {
const QuillResizeModule = await import("quill-resize-module");
QuillResize = QuillResizeModule?.default || QuillResizeModule || null;
} catch (error) {
// Keep editor functional even if the resize plugin is unavailable.
console.warn("Resize plugin could not be loaded", error);
}
Quill.register(
{
"modules/table-better": TableBetter,
},
true
);
if (QuillResize) {
Quill.register("modules/resize", QuillResize, true);
}
if (containerRef.current && containerRef.current.children.length === 0) {
const toolbarOptions = [
[{ header: [false, 1, 2, 3] }],
@@ -43,26 +57,45 @@ const ClientEditor = (props) => {
[{ script: "sub" }, { script: "super" }],
[{ indent: "-1" }, { indent: "+1" }],
];
const modules = {
toolbar: toolbarOptions,
table: false,
"table-better": {
language: "en_US",
menus: [
"column",
"row",
"merge",
"table",
"cell",
"wrap",
"copy",
"delete",
],
toolbarTable: true,
},
};
if (QuillResize) {
modules.resize = {
modules: ["Resize", "DisplaySize", "Toolbar"],
parchment: {
image: {
attribute: ["width", "height"],
},
},
onChangeSize: (_blot, _activeEle, _size) => {
// Capture resized image HTML and send to parent immediately.
const html = quill.root.innerHTML;
lastUserHtmlRef.current = html;
updateEditorContentRef.current?.(html);
},
};
}
quill = new Quill(containerRef.current, {
theme: "snow",
modules: {
toolbar: toolbarOptions,
table: false,
"table-better": {
language: "en_US",
menus: [
"column",
"row",
"merge",
"table",
"cell",
"wrap",
"copy",
"delete",
],
toolbarTable: true,
},
},
modules,
keyboard: {
bindings: TableBetter.keyboardBindings,
},
@@ -71,7 +104,7 @@ const ClientEditor = (props) => {
quill.root.style.fontSize = "1.125rem";
quill.root.style.lineHeight = "1.7";
quill.on(Quill.events.TEXT_CHANGE, (_delta, _oldDelta, source) => {
if (source !== Quill.sources.USER) {
if (source === Quill.sources.SILENT) {
return;
}
const html = quill.root.innerHTML;
+18 -5
View File
@@ -1,19 +1,32 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import styles from "@/components/ui/button/button.module.css";
export const LinkButton = ({
href,
children,
variant = "outline",
size = "md",
width = "fit",
className,
}) => {
const classes = [
styles.baseButton,
styles[variant],
styles[size],
styles[width],
className,
]
.filter(Boolean)
.join(" ");
return (
<Link href={href}>
<Button variant={variant} size={size} width="fit" className={className}>
{children}
</Button>
<Link
href={href}
className={classes}
style={{ display: "inline-block", textDecoration: "none" }}
>
{children}
</Link>
);
};
@@ -91,12 +91,13 @@ export default function MultichoicePage({ exerciseId }) {
}
);
const hasIncorrectAnswers = newCheckedAnswers.some(
(isCorrect, index) =>
shuffledExerciseData.content[index].content_type === "multichoice" ||
(shuffledExerciseData.content[index].content_type === "gap" &&
!isCorrect)
);
const hasIncorrectAnswers = newCheckedAnswers.some((isCorrect, index) => {
const contentType = shuffledExerciseData.content[index].content_type;
const isAnswerField =
contentType === "multichoice" || contentType === "gap";
return isAnswerField && !isCorrect;
});
// Set error state if there are missing or incorrect answers
setHasErrors(hasMissingAnswers || hasIncorrectAnswers);