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 #492 from erzabytyci/user-tiers
feat(admin-editor): improve multichoice authoring, editor behavior and page styling
This commit is contained in:
Generated
+7
@@ -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",
|
||||
@@ -8678,6 +8679,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",
|
||||
|
||||
@@ -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(/ /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]", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user