mirror of
https://github.com/OHTU-German-learning-website/OHTU-German-learning-website.git
synced 2026-10-12 19:13:51 +03:00
feat: communication- add descriptions/alphabetical ordering, grammar-list cleanup/renderer fix, lernen- sort by number/improve chapter navigation buttons, ensure images scale correctly, update tests
This commit is contained in:
@@ -10,20 +10,20 @@ ALTER TABLE html_pages ADD COLUMN IF NOT EXISTS grammar_topic_id INT REFERENCES
|
||||
|
||||
-- Insert the 8 existing topic categories (matching hardcoded topics.js)
|
||||
INSERT INTO grammar_topics (name, sort_order) VALUES
|
||||
('Das Adjektiv', 1),
|
||||
('Das Adverb', 2),
|
||||
('Das Artikelwort', 3),
|
||||
('Das Pronomen', 4),
|
||||
('Das Substantiv', 5),
|
||||
('Das Verb', 6),
|
||||
('Die Präposition', 7),
|
||||
('Die Syntax', 8);
|
||||
('Adjektiv', 1),
|
||||
('Adverb', 2),
|
||||
('Artikelwort', 3),
|
||||
('Pronomen', 4),
|
||||
('Substantiv', 5),
|
||||
('Verb', 6),
|
||||
('Präposition', 7),
|
||||
('Syntax', 8);
|
||||
|
||||
-- Assign grammar_topic_id for existing grammar pages
|
||||
|
||||
-- Das Adjektiv
|
||||
-- Adjektiv
|
||||
UPDATE html_pages
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Das Adjektiv')
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Adjektiv')
|
||||
WHERE page_group = 'grammar' AND slug IN (
|
||||
'pradikative-adverbiale-adjektive',
|
||||
'attributive-adjektive',
|
||||
@@ -33,9 +33,9 @@ WHERE page_group = 'grammar' AND slug IN (
|
||||
'wortbildung-der-adjektive'
|
||||
);
|
||||
|
||||
-- Das Adverb
|
||||
-- Adverb
|
||||
UPDATE html_pages
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Das Adverb')
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Adverb')
|
||||
WHERE page_group = 'grammar' AND slug IN (
|
||||
'hin-und-her',
|
||||
'pronominaladverbien',
|
||||
@@ -43,18 +43,18 @@ WHERE page_group = 'grammar' AND slug IN (
|
||||
'unterscheidung-zu-anderen-wortarten'
|
||||
);
|
||||
|
||||
-- Das Artikelwort
|
||||
-- Artikelwort
|
||||
UPDATE html_pages
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Das Artikelwort')
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Artikelwort')
|
||||
WHERE page_group = 'grammar' AND slug IN (
|
||||
'artikelwörter',
|
||||
'gebrauch-von-bestimmtem-unbestimmtem-und-nullartikel',
|
||||
'gebrauch-anderer-artikelwörter'
|
||||
);
|
||||
|
||||
-- Das Pronomen
|
||||
-- Pronomen
|
||||
UPDATE html_pages
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Das Pronomen')
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Pronomen')
|
||||
WHERE page_group = 'grammar' AND slug IN (
|
||||
'personalpronomen',
|
||||
'demonstrativpronomen',
|
||||
@@ -63,9 +63,9 @@ WHERE page_group = 'grammar' AND slug IN (
|
||||
'pronomen-es'
|
||||
);
|
||||
|
||||
-- Das Substantiv
|
||||
-- Substantiv
|
||||
UPDATE html_pages
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Das Substantiv')
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Substantiv')
|
||||
WHERE page_group = 'grammar' AND slug IN (
|
||||
'genuszuordnung',
|
||||
'pluralbildung-der-substantive',
|
||||
@@ -74,9 +74,9 @@ WHERE page_group = 'grammar' AND slug IN (
|
||||
'homonyme'
|
||||
);
|
||||
|
||||
-- Das Verb
|
||||
-- Verb
|
||||
UPDATE html_pages
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Das Verb')
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Verb')
|
||||
WHERE page_group = 'grammar' AND slug IN (
|
||||
'tempora',
|
||||
'perfekt',
|
||||
@@ -110,9 +110,9 @@ WHERE page_group = 'grammar' AND slug IN (
|
||||
'funktionsverbgefüge'
|
||||
);
|
||||
|
||||
-- Die Präposition
|
||||
-- Präposition
|
||||
UPDATE html_pages
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Die Präposition')
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Präposition')
|
||||
WHERE page_group = 'grammar' AND slug IN (
|
||||
'nominativ',
|
||||
'akkusativ',
|
||||
@@ -122,11 +122,11 @@ WHERE page_group = 'grammar' AND slug IN (
|
||||
'lokalprapositionen'
|
||||
);
|
||||
|
||||
-- Die Syntax
|
||||
-- Note: 'konditionalsatze' appears in both Das Verb and Die Syntax in topics.js;
|
||||
-- it is assigned here to Die Syntax since Konditionalsätze is a syntax phenomenon.
|
||||
-- Syntax
|
||||
-- Note: 'konditionalsatze' appears in both Verb and Syntax in topics.js;
|
||||
-- it is assigned here to Syntax since Konditionalsätze is a syntax phenomenon.
|
||||
UPDATE html_pages
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Die Syntax')
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Syntax')
|
||||
WHERE page_group = 'grammar' AND slug IN (
|
||||
'konjunktionalsatze',
|
||||
'konditionalsatze',
|
||||
|
||||
@@ -120,19 +120,19 @@ WHERE hp.page_group = 'grammar'
|
||||
|
||||
-- Backfill topic mapping for known legacy slugs when topic is still NULL.
|
||||
UPDATE html_pages
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Das Adjektiv' ORDER BY id LIMIT 1)
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Adjektiv' ORDER BY id LIMIT 1)
|
||||
WHERE page_group = 'grammar'
|
||||
AND grammar_topic_id IS NULL
|
||||
AND slug IN ('adjektivdeklination', 'adjektivkomparation');
|
||||
|
||||
UPDATE html_pages
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Das Artikelwort' ORDER BY id LIMIT 1)
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Artikelwort' ORDER BY id LIMIT 1)
|
||||
WHERE page_group = 'grammar'
|
||||
AND grammar_topic_id IS NULL
|
||||
AND slug IN ('artikelwörter');
|
||||
|
||||
UPDATE html_pages
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Das Verb' ORDER BY id LIMIT 1)
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Verb' ORDER BY id LIMIT 1)
|
||||
WHERE page_group = 'grammar'
|
||||
AND grammar_topic_id IS NULL
|
||||
AND slug IN (
|
||||
@@ -154,25 +154,25 @@ WHERE page_group = 'grammar'
|
||||
);
|
||||
|
||||
UPDATE html_pages
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Das Substantiv' ORDER BY id LIMIT 1)
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Substantiv' ORDER BY id LIMIT 1)
|
||||
WHERE page_group = 'grammar'
|
||||
AND grammar_topic_id IS NULL
|
||||
AND slug IN ('genuszuordnung', 'pluralbildung-der-substantive');
|
||||
|
||||
UPDATE html_pages
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Das Pronomen' ORDER BY id LIMIT 1)
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Pronomen' ORDER BY id LIMIT 1)
|
||||
WHERE page_group = 'grammar'
|
||||
AND grammar_topic_id IS NULL
|
||||
AND slug IN ('pronomen-es');
|
||||
|
||||
UPDATE html_pages
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Die Präposition' ORDER BY id LIMIT 1)
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Präposition' ORDER BY id LIMIT 1)
|
||||
WHERE page_group = 'grammar'
|
||||
AND grammar_topic_id IS NULL
|
||||
AND slug IN ('akkusativ', 'dativ', 'genitiv', 'nominativ', 'prapositionen', 'lokalprapositionen');
|
||||
|
||||
UPDATE html_pages
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Die Syntax' ORDER BY id LIMIT 1)
|
||||
SET grammar_topic_id = (SELECT id FROM grammar_topics WHERE name = 'Syntax' ORDER BY id LIMIT 1)
|
||||
WHERE page_group = 'grammar'
|
||||
AND grammar_topic_id IS NULL
|
||||
AND slug IN (
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE html_pages
|
||||
ADD COLUMN IF NOT EXISTS description TEXT;
|
||||
@@ -0,0 +1,98 @@
|
||||
-- Backfill proper titles, slugs and descriptions for the 16 communication pages.
|
||||
-- Rows are matched by their original numeric slug (set during migration 00026).
|
||||
|
||||
UPDATE html_pages
|
||||
SET title = 'Über die Vergangenheit sprechen',
|
||||
slug = 'ueber-die-vergangenheit-sprechen',
|
||||
description = 'Wie spricht man über Ereignisse, die bereits vergangen sind, z. B. die berufliche Vergangenheit, die Kindheit?'
|
||||
WHERE page_group = 'communications' AND slug = '1';
|
||||
|
||||
UPDATE html_pages
|
||||
SET title = 'Anleitungen formulieren',
|
||||
slug = 'anleitungen-formulieren',
|
||||
description = 'Wie repariert man etwas? Wie macht man das in der korrekten Reihenfolge?'
|
||||
WHERE page_group = 'communications' AND slug = '2';
|
||||
|
||||
UPDATE html_pages
|
||||
SET title = 'Offizielle Mitteilungen (Nachrichten usw.) formulieren',
|
||||
slug = 'offizielle-mitteilungen-formulieren',
|
||||
description = 'Wie formuliert man neutral und sachlich? Offizielle Texte für berufliche Situationen.'
|
||||
WHERE page_group = 'communications' AND slug = '3';
|
||||
|
||||
UPDATE html_pages
|
||||
SET title = 'Höflich mit anderen Menschen umgehen',
|
||||
slug = 'hoeflich-mit-anderen-menschen-umgehen',
|
||||
description = 'Wie stellt man sich selbst und andere Personen vor? Wie empfängt und betreut man Gäste? Wie führt man Small Talk?'
|
||||
WHERE page_group = 'communications' AND slug = '4';
|
||||
|
||||
UPDATE html_pages
|
||||
SET title = 'Menschen beschreiben / Ich und meine Familie',
|
||||
slug = 'menschen-beschreiben',
|
||||
description = 'Mit wem arbeite ich? Wer gehört zu meiner Familie? Wer sind meine Bekannten?'
|
||||
WHERE page_group = 'communications' AND slug = '5';
|
||||
|
||||
UPDATE html_pages
|
||||
SET title = 'Über Hobbys und Freizeit sprechen',
|
||||
slug = 'ueber-hobbys-und-freizeit-sprechen',
|
||||
description = 'Was machen wir nach der Arbeit? Wie verbringen wir unsere Freizeit?'
|
||||
WHERE page_group = 'communications' AND slug = '6';
|
||||
|
||||
UPDATE html_pages
|
||||
SET title = 'Über Beruf und Arbeitsplatz sprechen',
|
||||
slug = 'ueber-beruf-und-arbeitsplatz-sprechen',
|
||||
description = 'Alles über meine Arbeit! Was mache ich den ganzen Tag?'
|
||||
WHERE page_group = 'communications' AND slug = '7';
|
||||
|
||||
UPDATE html_pages
|
||||
SET title = 'Firmen und Produkte beschreiben',
|
||||
slug = 'firmen-und-produkte-beschreiben',
|
||||
description = 'Alles über meine Firma! Was stellen wir her? Welche Dienstleistungen bieten wir an?'
|
||||
WHERE page_group = 'communications' AND slug = '8';
|
||||
|
||||
UPDATE html_pages
|
||||
SET title = 'Werbung machen',
|
||||
slug = 'werbung-machen',
|
||||
description = 'Wie präsentiere ich meine Angebote? Wie motiviere ich potentielle Kunden zum Kauf?'
|
||||
WHERE page_group = 'communications' AND slug = '9';
|
||||
|
||||
UPDATE html_pages
|
||||
SET title = 'Einkaufen privat und geschäftlich',
|
||||
slug = 'einkaufen-privat-und-geschaeftlich',
|
||||
description = 'Einkaufen in einem Geschäft und online.'
|
||||
WHERE page_group = 'communications' AND slug = '10';
|
||||
|
||||
UPDATE html_pages
|
||||
SET title = 'Räume beschreiben',
|
||||
slug = 'raeume-beschreiben',
|
||||
description = 'Wie sieht es in meiner Umgebung aus? Das ist meine Wohnung, das ist mein Büro, das ist unser Lager.'
|
||||
WHERE page_group = 'communications' AND slug = '11';
|
||||
|
||||
UPDATE html_pages
|
||||
SET title = 'Über historische Ereignisse sprechen',
|
||||
slug = 'ueber-historische-ereignisse-sprechen',
|
||||
description = 'Was ist in früheren Zeiten passiert? Historische Fakten über die Geschichte der Familie oder der Firma.'
|
||||
WHERE page_group = 'communications' AND slug = '12';
|
||||
|
||||
UPDATE html_pages
|
||||
SET title = 'Pläne machen',
|
||||
slug = 'plaene-machen',
|
||||
description = 'Was machen wir am Wochenende? Wie geht es mit unserem Unternehmen weiter, was sind die Perspektiven für die nächsten fünf Jahre?'
|
||||
WHERE page_group = 'communications' AND slug = '13';
|
||||
|
||||
UPDATE html_pages
|
||||
SET title = 'An Versammlungen und Geschäftstreffen teilnehmen und sie leiten',
|
||||
slug = 'versammlungen-und-geschaeftstreffen',
|
||||
description = 'Wie verläuft ein Business-Meeting? Was sagt man als Versammlungsleiter oder als Teilnehmer?'
|
||||
WHERE page_group = 'communications' AND slug = '14';
|
||||
|
||||
UPDATE html_pages
|
||||
SET title = 'Jemandem gratulieren',
|
||||
slug = 'jemandem-gratulieren',
|
||||
description = 'Ein Geburtstag? Ein Firmenjubiläum? Sagen und schreiben Sie das Richtige!'
|
||||
WHERE page_group = 'communications' AND slug = '15';
|
||||
|
||||
UPDATE html_pages
|
||||
SET title = 'Sich beschweren / etw. reklamieren',
|
||||
slug = 'sich-beschweren-etwas-reklamieren',
|
||||
description = 'Ist eine Ware nicht in Ordnung? Hat die Spedition zu spät geliefert? Sagen Sie es im richtigen Ton.'
|
||||
WHERE page_group = 'communications' AND slug = '16';
|
||||
@@ -380,7 +380,8 @@ CREATE TABLE public.html_pages (
|
||||
page_order integer,
|
||||
slug text,
|
||||
page_group text,
|
||||
grammar_topic_id integer
|
||||
grammar_topic_id integer,
|
||||
description text
|
||||
);
|
||||
|
||||
ALTER TABLE public.html_pages
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
async function gotoWithRetry(page, url, options, retries = 1) {
|
||||
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
||||
try {
|
||||
await page.goto(url, options);
|
||||
return;
|
||||
} catch (error) {
|
||||
const message = String(error?.message || "");
|
||||
const isRetryable =
|
||||
message.includes("NS_ERROR_NET_RESET") ||
|
||||
message.includes("ERR_CONNECTION_RESET");
|
||||
|
||||
if (!isRetryable || attempt === retries) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getFirstAlphabeticalGrammarHref(page) {
|
||||
await gotoWithRetry(page, "/grammar/alphabetical", {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 60000,
|
||||
});
|
||||
|
||||
const firstGrammarLink = page
|
||||
.locator('a[href*="/grammar/themes/"][href*="view=alphabetical"]')
|
||||
.first();
|
||||
|
||||
await expect(firstGrammarLink).toBeVisible();
|
||||
|
||||
const href = await firstGrammarLink.getAttribute("href");
|
||||
expect(href).toBeTruthy();
|
||||
|
||||
return href;
|
||||
}
|
||||
|
||||
test.describe("Grammar Pages", () => {
|
||||
test("should load the grammar overview page", async ({ page }) => {
|
||||
await page.goto("/grammar");
|
||||
@@ -24,12 +60,16 @@ test.describe("Grammar Pages", () => {
|
||||
test("should render alphabetical-view grammar page directly", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/grammar/themes/artikelw%C3%B6rter?view=alphabetical");
|
||||
test.setTimeout(60000);
|
||||
|
||||
const href = await getFirstAlphabeticalGrammarHref(page);
|
||||
await gotoWithRetry(page, href, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 60000,
|
||||
});
|
||||
|
||||
await expect(page).toHaveURL(/view=alphabetical/);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /Artikelwörter/i })
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("heading").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("should show placeholder for a grammar page without content", async ({
|
||||
@@ -62,7 +102,13 @@ test.describe("Grammar Pages", () => {
|
||||
test("should keep alphabetical view when using next navigation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/grammar/themes/artikelw%C3%B6rter?view=alphabetical");
|
||||
test.setTimeout(60000);
|
||||
|
||||
const href = await getFirstAlphabeticalGrammarHref(page);
|
||||
await gotoWithRetry(page, href, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 60000,
|
||||
});
|
||||
|
||||
const weiterButton = page.getByRole("link", { name: "Weiter" });
|
||||
if (await weiterButton.isVisible()) {
|
||||
@@ -70,4 +116,12 @@ test.describe("Grammar Pages", () => {
|
||||
await expect(page).toHaveURL(/view=alphabetical/);
|
||||
}
|
||||
});
|
||||
|
||||
test("should not show Ohne Thema in topics overview", async ({ page }) => {
|
||||
await page.goto("/grammar/themes");
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /Ohne Thema/i })
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 357 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
@@ -9,6 +9,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
export default function EditorSection({
|
||||
initialContent,
|
||||
initialDescription,
|
||||
type,
|
||||
slug,
|
||||
title,
|
||||
@@ -16,8 +17,10 @@ export default function EditorSection({
|
||||
pageExists,
|
||||
}) {
|
||||
const [editorContent, setEditorContent] = useState(initialContent);
|
||||
const [descriptionInput, setDescriptionInput] = useState(initialDescription);
|
||||
const [editorMessage, setEditorMessage] = useState({ error: false, msg: "" });
|
||||
const [titleInput, setTitleInput] = useState(title);
|
||||
const supportsDescription = type === "communications";
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const view = searchParams.get("view");
|
||||
@@ -34,10 +37,16 @@ export default function EditorSection({
|
||||
|
||||
const submitEditorContent = async () => {
|
||||
// A naive approach with string replacement is used here.
|
||||
const jsonData = JSON.stringify({
|
||||
const requestBody = {
|
||||
content: editorContent.replace(/ /g, " "),
|
||||
title: titleInput,
|
||||
});
|
||||
};
|
||||
|
||||
if (supportsDescription) {
|
||||
requestBody.description = descriptionInput;
|
||||
}
|
||||
|
||||
const jsonData = JSON.stringify(requestBody);
|
||||
|
||||
if (!pageExists && type === "grammar") {
|
||||
const createRes = await fetch(`/api/admin/pages/${type}/${slug}`, {
|
||||
@@ -109,6 +118,17 @@ export default function EditorSection({
|
||||
required
|
||||
/>
|
||||
</Row>
|
||||
{supportsDescription && (
|
||||
<Column gap="0.5rem">
|
||||
<label htmlFor="description">Beschreibung</label>
|
||||
<textarea
|
||||
id="description"
|
||||
rows={4}
|
||||
value={descriptionInput}
|
||||
onChange={(e) => setDescriptionInput(e.target.value)}
|
||||
/>
|
||||
</Column>
|
||||
)}
|
||||
<Row justify="space-between" pb="xl">
|
||||
<Editor
|
||||
defaultContent={editorContent}
|
||||
|
||||
@@ -60,6 +60,7 @@ export default async function Edit({ params, searchParams }) {
|
||||
return (
|
||||
<EditorSection
|
||||
initialContent={pageContent.content}
|
||||
initialDescription={pageContent.description ?? ""}
|
||||
type={type}
|
||||
slug={decodedSlug}
|
||||
title={pageContent.title}
|
||||
@@ -84,6 +85,7 @@ export default async function Edit({ params, searchParams }) {
|
||||
return (
|
||||
<EditorSection
|
||||
initialContent=""
|
||||
initialDescription=""
|
||||
type={type}
|
||||
slug={decodedSlug}
|
||||
title={chapter?.title || decodedSlug}
|
||||
|
||||
@@ -21,18 +21,30 @@ export default function NewPage() {
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setGrammarTopics(data);
|
||||
setSelectedTopicId("none");
|
||||
setSelectedTopicId("");
|
||||
setNewTopicName("");
|
||||
})
|
||||
.catch(() => setGrammarTopics([]));
|
||||
.catch(() => {
|
||||
setGrammarTopics([]);
|
||||
setSelectedTopicId("");
|
||||
});
|
||||
}, [type]);
|
||||
|
||||
const submit = async () => {
|
||||
const body = { title, type };
|
||||
if (type === "grammar") {
|
||||
if (!selectedTopicId) {
|
||||
setCreationMessage("Bitte Thema auswählen");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedTopicId === "new") {
|
||||
if (!newTopicName.trim()) {
|
||||
setCreationMessage("Bitte neuen Themennamen eingeben");
|
||||
return;
|
||||
}
|
||||
body.newTopicName = newTopicName;
|
||||
} else if (selectedTopicId !== "none") {
|
||||
} else {
|
||||
body.topicId = Number(selectedTopicId);
|
||||
}
|
||||
}
|
||||
@@ -98,12 +110,15 @@ export default function NewPage() {
|
||||
className={styles.control}
|
||||
id="topic"
|
||||
value={selectedTopicId}
|
||||
required
|
||||
onChange={(e) => {
|
||||
setSelectedTopicId(e.target.value);
|
||||
setNewTopicName("");
|
||||
}}
|
||||
>
|
||||
<option value="none">Ohne Thema</option>
|
||||
<option value="" disabled>
|
||||
Thema auswählen...
|
||||
</option>
|
||||
{grammarTopics.map((t) => (
|
||||
<option key={t.id} value={t.id.toString()}>
|
||||
{t.name}
|
||||
|
||||
@@ -8,6 +8,13 @@ import { DB } from "@/backend/db";
|
||||
// Allowed page_group values stored in the consolidated `html_pages` table.
|
||||
const VALID_PAGE_GROUPS = new Set(["resources", "communications", "grammar"]);
|
||||
|
||||
function normalizePageDescription(description) {
|
||||
if (typeof description !== "string") return null;
|
||||
|
||||
const trimmedDescription = description.trim();
|
||||
return trimmedDescription ? trimmedDescription : null;
|
||||
}
|
||||
|
||||
/*
|
||||
Route behavior (summary):
|
||||
- The `type` route param maps to the `page_group` value stored in the
|
||||
@@ -25,15 +32,23 @@ const VALID_PAGE_GROUPS = new Set(["resources", "communications", "grammar"]);
|
||||
|
||||
export const DELETE = withAuth(
|
||||
async (req, { params }) => {
|
||||
const { type, slug } = await params;
|
||||
try {
|
||||
const { type, slug } = await params;
|
||||
|
||||
const query = `DELETE FROM html_pages WHERE slug = $1 AND page_group = $2 RETURNING *`;
|
||||
const result = await DB.pool(query, [slug, type]);
|
||||
const query = `UPDATE html_pages SET content = '' WHERE slug = $1 AND page_group = $2 RETURNING *`;
|
||||
const result = await DB.pool(query, [slug, type]);
|
||||
|
||||
if (result.rowCount === 0)
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
if (result.rowCount === 0)
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
return NextResponse.json({ message: "Deleted" }, { status: 200 });
|
||||
return NextResponse.json({ message: "Cleared" }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("DELETE page failed", error);
|
||||
return NextResponse.json(
|
||||
{ error: error?.message || "Delete failed" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
requireAdmin: true,
|
||||
@@ -55,6 +70,9 @@ export const PUT = withAuth(
|
||||
if (data.title) newData.title = data.title;
|
||||
if (data.page_order) newData.page_order = data.page_order;
|
||||
if (data.slug) newData.slug = data.slug;
|
||||
if (Object.prototype.hasOwnProperty.call(data, "description")) {
|
||||
newData.description = normalizePageDescription(data.description);
|
||||
}
|
||||
// Allow also empty content string.
|
||||
if (Object.prototype.hasOwnProperty.call(data, "content"))
|
||||
newData.content = sanitize(data.content);
|
||||
@@ -73,13 +91,14 @@ export const PUT = withAuth(
|
||||
|
||||
if (!updated && type === "grammar") {
|
||||
const insertQuery = `
|
||||
INSERT INTO html_pages (title, content, slug, page_group)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
INSERT INTO html_pages (title, description, content, slug, page_group)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
|
||||
await DB.pool(insertQuery, [
|
||||
newData.title,
|
||||
newData.description ?? null,
|
||||
newData.content ?? "",
|
||||
newData.slug,
|
||||
type,
|
||||
@@ -120,9 +139,15 @@ export const POST = withAuth(
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
const title = (await req.json()).title;
|
||||
const query = `INSERT INTO html_pages (title, content, slug, page_group) VALUES ($1, $2, $3, $4)`;
|
||||
const result = await DB.pool(query, [title, "", slug, type]);
|
||||
const { title, description } = await req.json();
|
||||
const query = `INSERT INTO html_pages (title, description, content, slug, page_group) VALUES ($1, $2, $3, $4, $5)`;
|
||||
const result = await DB.pool(query, [
|
||||
title,
|
||||
normalizePageDescription(description),
|
||||
"",
|
||||
slug,
|
||||
type,
|
||||
]);
|
||||
|
||||
if (result.rowCount === 0)
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
@@ -32,6 +32,7 @@ describe("PUT /api/admin/pages/[type]/[slug]", () => {
|
||||
const { mockPut, mockParams } = useTestRequest(admin);
|
||||
|
||||
const updateData = {
|
||||
description: "Updated resource description",
|
||||
slug: "new-slug",
|
||||
};
|
||||
|
||||
@@ -42,12 +43,13 @@ describe("PUT /api/admin/pages/[type]/[slug]", () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const result = await DB.pool(
|
||||
"SELECT slug FROM html_pages WHERE page_group = $1 AND slug = $2",
|
||||
"SELECT slug, description FROM html_pages WHERE page_group = $1 AND slug = $2",
|
||||
["resources", "new-slug"]
|
||||
);
|
||||
|
||||
expect(result.rows.length).toBe(1);
|
||||
expect(result.rows[0].slug).toBe("new-slug");
|
||||
expect(result.rows[0].description).toBe("Updated resource description");
|
||||
|
||||
const oldResult = await DB.pool(
|
||||
"SELECT * FROM html_pages WHERE page_group = $1 AND slug = $2",
|
||||
@@ -130,7 +132,14 @@ describe("DELETE /api/admin/pages/[type]/[slug]", () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const json = await response.json();
|
||||
expect(json.message).toBe("Deleted");
|
||||
expect(json.message).toBe("Cleared");
|
||||
|
||||
const result = await DB.pool(
|
||||
"SELECT content FROM html_pages WHERE page_group = $1 AND slug = $2",
|
||||
["resources", "test-page"]
|
||||
);
|
||||
expect(result.rowCount).toBe(1);
|
||||
expect(result.rows[0].content).toBe("");
|
||||
});
|
||||
|
||||
it("should return 404 if page does not exist", async () => {
|
||||
|
||||
@@ -4,6 +4,13 @@ import { NextResponse } from "next/server";
|
||||
|
||||
const VALID_PAGE_GROUPS = new Set(["resources", "communications", "grammar"]);
|
||||
|
||||
function normalizePageDescription(description) {
|
||||
if (typeof description !== "string") return null;
|
||||
|
||||
const trimmedDescription = description.trim();
|
||||
return trimmedDescription ? trimmedDescription : null;
|
||||
}
|
||||
|
||||
function generateBaseSlug(title) {
|
||||
return title
|
||||
.toLowerCase()
|
||||
@@ -86,7 +93,8 @@ async function resolveGrammarTopicId(topicId, newTopicName) {
|
||||
|
||||
export const POST = withAuth(
|
||||
async (req) => {
|
||||
const { title, type, topicId, newTopicName } = await req.json();
|
||||
const { title, description, type, topicId, newTopicName } =
|
||||
await req.json();
|
||||
|
||||
if (!title || !type || !VALID_PAGE_GROUPS.has(type)) {
|
||||
return new NextResponse("Invalid input", { status: 400 });
|
||||
@@ -99,6 +107,7 @@ export const POST = withAuth(
|
||||
|
||||
const slug = await generateUniqueSlug(type, trimmedTitle);
|
||||
const pageOrder = await getNextPageOrder(type);
|
||||
const pageDescription = normalizePageDescription(description);
|
||||
|
||||
let grammarTopicId = null;
|
||||
if (type === "grammar") {
|
||||
@@ -106,8 +115,8 @@ export const POST = withAuth(
|
||||
}
|
||||
|
||||
await DB.pool(
|
||||
"INSERT INTO html_pages (title, content, slug, page_group, page_order, grammar_topic_id) VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
[trimmedTitle, "", slug, type, pageOrder, grammarTopicId]
|
||||
"INSERT INTO html_pages (title, description, content, slug, page_group, page_order, grammar_topic_id) VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
||||
[trimmedTitle, pageDescription, "", slug, type, pageOrder, grammarTopicId]
|
||||
);
|
||||
|
||||
return NextResponse.json({ slug }, { status: 200 });
|
||||
|
||||
@@ -11,7 +11,7 @@ describe("POST /api/admin/pages", () => {
|
||||
beforeEach(async () => {
|
||||
await DB.pool(
|
||||
`INSERT INTO grammar_topics (name, sort_order) VALUES ($1, $2), ($3, $4)`,
|
||||
["Das Verb", 1, "Die Syntax", 2]
|
||||
["Verb", 1, "Syntax", 2]
|
||||
);
|
||||
|
||||
await DB.pool(
|
||||
@@ -34,6 +34,7 @@ describe("POST /api/admin/pages", () => {
|
||||
|
||||
const response = await POST(
|
||||
mockPost("/api/admin/pages", {
|
||||
description: "Resource summary",
|
||||
title: "New Resource",
|
||||
type: "resources",
|
||||
})
|
||||
@@ -42,13 +43,14 @@ describe("POST /api/admin/pages", () => {
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const inserted = await DB.pool(
|
||||
`SELECT slug, title, page_order, grammar_topic_id
|
||||
`SELECT slug, title, description, page_order, grammar_topic_id
|
||||
FROM html_pages
|
||||
WHERE page_group = 'resources' AND slug = 'new-resource'`
|
||||
);
|
||||
|
||||
expect(inserted.rowCount).toBe(1);
|
||||
expect(inserted.rows[0].title).toBe("New Resource");
|
||||
expect(inserted.rows[0].description).toBe("Resource summary");
|
||||
expect(inserted.rows[0].page_order).toBe(expectedNextOrder);
|
||||
expect(inserted.rows[0].grammar_topic_id).toBeNull();
|
||||
});
|
||||
@@ -82,7 +84,7 @@ describe("POST /api/admin/pages", () => {
|
||||
const { mockPost } = useTestRequest(admin);
|
||||
|
||||
const topic = await DB.pool(
|
||||
"SELECT id FROM grammar_topics WHERE name = 'Die Syntax'"
|
||||
"SELECT id FROM grammar_topics WHERE name = 'Syntax'"
|
||||
);
|
||||
|
||||
const response = await POST(
|
||||
|
||||
@@ -5,10 +5,6 @@ export async function GET(req, { params }) {
|
||||
const { type } = await params;
|
||||
const pages = await getPageList(type);
|
||||
|
||||
if (pages.length === 0) {
|
||||
return new NextResponse("not found", { status: 404 });
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ pages }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
@@ -514,6 +514,11 @@ label {
|
||||
font-size: 2.5em;
|
||||
}
|
||||
|
||||
.rendered-html img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.ql-table-menus,
|
||||
.ql-table-dropdown,
|
||||
.ql-table-tooltip {
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
export const chapters = [
|
||||
{
|
||||
id: "1",
|
||||
title: "Adjektivdeklination",
|
||||
link: "/grammar/themes/adjektivdeklination", //this is supposed to link to the grammar themes page about adjektivdeklination
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Adjektivkomparation",
|
||||
link: "/grammar/themes/adjektivkomparation",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "Akkusativ",
|
||||
link: "/grammar/themes/akkusativ",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
title: "Artikelverwendung",
|
||||
link: "/grammar/themes/artikelverwendung",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
title: "Artikelwörter",
|
||||
link: "/grammar/themes/artikelwörter",
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
title: "Dativ",
|
||||
link: "/grammar/themes/dativ",
|
||||
},
|
||||
{
|
||||
id: "7",
|
||||
title: "Dativ-Verben",
|
||||
link: "/grammar/themes/dativ-verben",
|
||||
},
|
||||
{
|
||||
id: "8",
|
||||
title: "Funktionsverbgefüge",
|
||||
link: "/grammar/themes/funktionsverbgefüge",
|
||||
},
|
||||
{
|
||||
id: "9",
|
||||
title: "Futur",
|
||||
link: "/grammar/themes/futur",
|
||||
},
|
||||
{
|
||||
id: "10",
|
||||
title: "Genitiv",
|
||||
link: "/grammar/themes/genitiv",
|
||||
},
|
||||
{
|
||||
id: "11",
|
||||
title: "Genuszuordnung",
|
||||
link: "/grammar/themes/genuszuordnung",
|
||||
},
|
||||
{
|
||||
id: "12",
|
||||
title: "Hilfsverben (Konjugation Präsens und Präteritum)",
|
||||
link: "/grammar/themes/hilfsverben",
|
||||
},
|
||||
{
|
||||
id: "13",
|
||||
title: "Indirekte Fragesätze",
|
||||
link: "/grammar/themes/indirekte-fragesatze",
|
||||
},
|
||||
{
|
||||
id: "14",
|
||||
title: "Infinitivkonstruktionen",
|
||||
link: "/grammar/themes/infinitivkonstruktionen",
|
||||
},
|
||||
{
|
||||
id: "15",
|
||||
title: "Kategorien des Verbs",
|
||||
link: "/grammar/themes/kategorien-des-verbs",
|
||||
},
|
||||
{
|
||||
id: "16",
|
||||
title: "Kausalsätze",
|
||||
link: "/grammar/themes/kausalsatze",
|
||||
},
|
||||
{
|
||||
id: "17",
|
||||
title: "Konditionalsätze (real/irreal mit Konjunktiv II und würde)",
|
||||
link: "/grammar/themes/konditionalsatze",
|
||||
},
|
||||
{
|
||||
id: "18",
|
||||
title: "Konjunktiv I Bildung",
|
||||
link: "/grammar/themes/konjunktiv_i",
|
||||
},
|
||||
{
|
||||
id: "19",
|
||||
title: "Konjunktiv II Bildung",
|
||||
link: "/grammar/themes/konjunktiv_ii",
|
||||
},
|
||||
{
|
||||
id: "20",
|
||||
title: "Konjunktionalsätze",
|
||||
link: "/grammar/themes/konjunktionalsatze",
|
||||
},
|
||||
{
|
||||
id: "21",
|
||||
title: "Konsekutivsätze",
|
||||
link: "/grammar/themes/konsekutivsatze",
|
||||
},
|
||||
{
|
||||
id: "22",
|
||||
title: "Konzessivsätze",
|
||||
link: "/grammar/themes/konzessivsatze",
|
||||
},
|
||||
{
|
||||
id: "23",
|
||||
title: "Lokalpräpositionen",
|
||||
link: "/grammar/themes/lokalprapositionen",
|
||||
},
|
||||
{
|
||||
id: "24",
|
||||
title: "Lokalsätze",
|
||||
link: "/grammar/themes/lokalsatze",
|
||||
},
|
||||
{
|
||||
id: "25",
|
||||
title: "Modalverben und subjektive Modalität",
|
||||
link: "/grammar/themes/modalverben-und-subjektive-modalitat",
|
||||
},
|
||||
{
|
||||
id: "26",
|
||||
title: "Nebensätze (mit uneingeleiteten)",
|
||||
link: "/grammar/themes/nebensatze",
|
||||
},
|
||||
{
|
||||
id: "27",
|
||||
title: "Nominativ (mit Kopulaverben)",
|
||||
link: "/grammar/themes/nominativ",
|
||||
},
|
||||
{
|
||||
id: "28",
|
||||
title: "Partizip II",
|
||||
link: "/grammar/themes/partizip_ii",
|
||||
},
|
||||
{
|
||||
id: "29",
|
||||
title: "Passiv",
|
||||
link: "/grammar/themes/passiv",
|
||||
},
|
||||
{
|
||||
id: "30",
|
||||
title: "Perfekt",
|
||||
link: "/grammar/themes/perfekt",
|
||||
},
|
||||
{
|
||||
id: "31",
|
||||
title: "Pluralbildung der Substantive",
|
||||
link: "/grammar/themes/pluralbildung-der-substantive",
|
||||
},
|
||||
{
|
||||
id: "32",
|
||||
title: "Plusquamperfekt",
|
||||
link: "/grammar/themes/plusquamperfekt",
|
||||
},
|
||||
{
|
||||
id: "33",
|
||||
title: "Präpositionen (Rektion)",
|
||||
link: "/grammar/themes/prapositionen",
|
||||
},
|
||||
{
|
||||
id: "34",
|
||||
title: "Präsens",
|
||||
link: "/grammar/themes/prasens",
|
||||
},
|
||||
{
|
||||
id: "35",
|
||||
title: "Präteritum",
|
||||
link: "/grammar/themes/prateritum",
|
||||
},
|
||||
{
|
||||
id: "36",
|
||||
title: "Pronomen es",
|
||||
link: "/grammar/themes/pronomen-es",
|
||||
},
|
||||
{
|
||||
id: "37",
|
||||
title: "Pronomina",
|
||||
link: "/grammar/themes/pronomina",
|
||||
},
|
||||
{
|
||||
id: "38",
|
||||
title: "Reflexive Verben",
|
||||
link: "/grammar/themes/reflexive-verben",
|
||||
},
|
||||
{
|
||||
id: "39",
|
||||
title: "Rektion der Verben",
|
||||
link: "/grammar/themes/rektion-der-verben",
|
||||
},
|
||||
{
|
||||
id: "40",
|
||||
title: "Relativsätze",
|
||||
link: "/grammar/themes/relativsatze",
|
||||
},
|
||||
{
|
||||
id: "41",
|
||||
title: "Satzklammer",
|
||||
link: "/grammar/themes/satzklammer",
|
||||
},
|
||||
{
|
||||
id: "42",
|
||||
title: "Temporalsätze (mit Chronologie in Prozessbeschreibungen)",
|
||||
link: "/grammar/themes/temporalsatze",
|
||||
},
|
||||
{
|
||||
id: "43",
|
||||
title: "Vergleiche",
|
||||
link: "/grammar/themes/vergleiche",
|
||||
},
|
||||
{
|
||||
id: "44",
|
||||
title: "Zusammengesetzte Verben (trennbar/untrennbar)",
|
||||
link: "/grammar/themes/zusammengesetzte-verben",
|
||||
},
|
||||
];
|
||||
@@ -1,13 +1,13 @@
|
||||
export const grammarTopics = [
|
||||
{
|
||||
title: "Das Adjektiv",
|
||||
title: "Adjektiv",
|
||||
subtopics: [
|
||||
{
|
||||
name: "prädikative und adverbiale Adjektive",
|
||||
name: "Prädikative und adverbiale Adjektive",
|
||||
link: "/grammar/themes/pradikative-adverbiale-adjektive",
|
||||
},
|
||||
{
|
||||
name: "attributive Adjektive",
|
||||
name: "Attributive Adjektive",
|
||||
link: "/grammar/themes/attributive-adjektive",
|
||||
},
|
||||
{
|
||||
@@ -19,20 +19,20 @@ export const grammarTopics = [
|
||||
link: "/grammar/themes/adjektivkomparation",
|
||||
},
|
||||
{
|
||||
name: "die Rektion der Adjektive",
|
||||
name: "Rektion der Adjektive",
|
||||
link: "/grammar/themes/rektion-der-adjektive",
|
||||
},
|
||||
{
|
||||
name: "die Wortbildung der Adjektive",
|
||||
name: "Wortbildung der Adjektive",
|
||||
link: "/grammar/themes/wortbildung-der-adjektive",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Das Adverb",
|
||||
title: "Adverb",
|
||||
subtopics: [
|
||||
{
|
||||
name: "hin und her",
|
||||
name: "Hin und her",
|
||||
link: "/grammar/themes/hin-und-her",
|
||||
},
|
||||
{
|
||||
@@ -44,55 +44,55 @@ export const grammarTopics = [
|
||||
link: "/grammar/themes/konjunktionaladverbien",
|
||||
},
|
||||
{
|
||||
name: "die Unterscheidung zu anderen Wortarten (Konjunktion, prädikatives Attribut, Modalwort, Partikel)",
|
||||
name: "Unterscheidung zu anderen Wortarten (Konjunktion, prädikatives Attribut, Modalwort, Partikel)",
|
||||
link: "/grammar/themes/unterscheidung-zu-anderen-wortarten",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Das Artikelwort",
|
||||
title: "Artikelwort",
|
||||
subtopics: [
|
||||
{
|
||||
name: "Formen von Artikelwörtern",
|
||||
link: "/grammar/themes/artikelwörter",
|
||||
},
|
||||
{
|
||||
name: "der Gebrauch von bestimmtem, unbestimmtem und Nullartikel",
|
||||
name: "Gebrauch von bestimmtem, unbestimmtem und Nullartikel",
|
||||
link: "/grammar/themes/gebrauch-von-bestimmtem-unbestimmtem-und-nullartikel",
|
||||
},
|
||||
{
|
||||
name: "der Gebrauch anderer Artikelwörter",
|
||||
name: "Gebrauch anderer Artikelwörter",
|
||||
link: "/grammar/themes/gebrauch-anderer-artikelwörter",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Das Pronomen",
|
||||
title: "Pronomen",
|
||||
subtopics: [
|
||||
{
|
||||
name: "das Personalpronomen",
|
||||
name: "Personalpronomen",
|
||||
link: "/grammar/themes/personalpronomen",
|
||||
},
|
||||
{
|
||||
name: "das Demonstrativpronomen",
|
||||
name: "Demonstrativpronomen",
|
||||
link: "/grammar/themes/demonstrativpronomen",
|
||||
},
|
||||
{
|
||||
name: "das Possessivpronomen",
|
||||
name: "Possessivpronomen",
|
||||
link: "/grammar/themes/possessivpronomen",
|
||||
},
|
||||
{
|
||||
name: "das Relativpronomen",
|
||||
name: "Relativpronomen",
|
||||
link: "/grammar/themes/relativpronomen",
|
||||
},
|
||||
{
|
||||
name: "das Pronomen es",
|
||||
name: "Pronomen es",
|
||||
link: "/grammar/themes/pronomen-es",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Das Substantiv",
|
||||
title: "Substantiv",
|
||||
subtopics: [
|
||||
{
|
||||
name: "Genuszuordnung",
|
||||
@@ -103,11 +103,11 @@ export const grammarTopics = [
|
||||
link: "/grammar/themes/pluralbildung-der-substantive",
|
||||
},
|
||||
{
|
||||
name: "die Kasusformen und ihre Bildung: Nominativ, Akkusativ, Dativ, Genitiv",
|
||||
name: "Kasusformen und ihre Bildung: Nominativ, Akkusativ, Dativ, Genitiv",
|
||||
link: "/grammar/themes/kasusformen-und-ihre-bildung",
|
||||
},
|
||||
{
|
||||
name: "besondere Namen (geographische, Nationalitäten usw.)",
|
||||
name: "Besondere Namen (geographische, Nationalitäten usw.)",
|
||||
link: "/grammar/themes/besondere-namen",
|
||||
},
|
||||
{
|
||||
@@ -117,30 +117,30 @@ export const grammarTopics = [
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Das Verb",
|
||||
title: "Verb",
|
||||
subtopics: [
|
||||
{ name: "die Tempora", link: "/grammar/themes/tempora" },
|
||||
{ name: "das Perfekt", link: "/grammar/themes/perfekt" },
|
||||
{ name: "Tempora", link: "/grammar/themes/tempora" },
|
||||
{ name: "Perfekt", link: "/grammar/themes/perfekt" },
|
||||
{ name: "Partizip II", link: "/grammar/themes/partizip_ii" },
|
||||
{ name: "das Präsens", link: "/grammar/themes/prasens" },
|
||||
{ name: "das Präteritum", link: "/grammar/themes/prateritum" },
|
||||
{ name: "das Plusquamperfekt", link: "/grammar/themes/plusquamperfekt" },
|
||||
{ name: "das Futur I und II", link: "/grammar/themes/futur" },
|
||||
{ name: "der Numerus", link: "/grammar/themes/numerus" },
|
||||
{ name: "die Konjugation", link: "/grammar/themes/konjugation" },
|
||||
{ name: "schwache Verben", link: "/grammar/themes/schwache-verben" },
|
||||
{ name: "Präsens", link: "/grammar/themes/prasens" },
|
||||
{ name: "Präteritum", link: "/grammar/themes/prateritum" },
|
||||
{ name: "Plusquamperfekt", link: "/grammar/themes/plusquamperfekt" },
|
||||
{ name: "Futur I und II", link: "/grammar/themes/futur" },
|
||||
{ name: "Numerus", link: "/grammar/themes/numerus" },
|
||||
{ name: "Konjugation", link: "/grammar/themes/konjugation" },
|
||||
{ name: "Schwache Verben", link: "/grammar/themes/schwache-verben" },
|
||||
{
|
||||
name: "starke und unregelmäßige Verben",
|
||||
name: "Starke und unregelmäßige Verben",
|
||||
link: "/grammar/themes/starke-und-unregelmasige-verben",
|
||||
},
|
||||
{ name: "Hilfsverben", link: "/grammar/themes/hilfsverben" },
|
||||
{
|
||||
name: "die Kategorien des Verbs",
|
||||
name: "Kategorien des Verbs",
|
||||
link: "/grammar/themes/kategorien-des-verbs",
|
||||
},
|
||||
{ name: "die Formenbildung", link: "/grammar/themes/formenbildung" },
|
||||
{ name: "Formenbildung", link: "/grammar/themes/formenbildung" },
|
||||
{
|
||||
name: "trennbare Verben",
|
||||
name: "Trennbare Verben",
|
||||
link: "/grammar/themes/zusammengesetzte-verben",
|
||||
},
|
||||
{
|
||||
@@ -148,53 +148,53 @@ export const grammarTopics = [
|
||||
link: "/grammar/themes/modalverben-und-subjektive-modalitat",
|
||||
},
|
||||
{
|
||||
name: "objektive Modalität",
|
||||
name: "Objektive Modalität",
|
||||
link: "/grammar/themes/objektive-modalitat",
|
||||
},
|
||||
{ name: "reflexive Verben", link: "/grammar/themes/reflexive-verben" },
|
||||
{ name: "der Modus", link: "/grammar/themes/modus" },
|
||||
{ name: "Reflexive Verben", link: "/grammar/themes/reflexive-verben" },
|
||||
{ name: "Modus", link: "/grammar/themes/modus" },
|
||||
{
|
||||
name: "der Konjunktiv I (=Konjunktiv Präsens)",
|
||||
name: "Konjunktiv I (=Konjunktiv Präsens)",
|
||||
link: "/grammar/themes/konjunktiv_i",
|
||||
},
|
||||
{
|
||||
name: "der Konjunktiv II (=Konjunktiv Präteritum)",
|
||||
name: "Konjunktiv II (=Konjunktiv Präteritum)",
|
||||
link: "/grammar/themes/konjunktiv_ii",
|
||||
},
|
||||
{ name: "die würde-Form", link: "/grammar/themes/konditionalsatze" },
|
||||
{ name: "der Imperativ", link: "/grammar/themes/imperativ" },
|
||||
{ name: "das Genus Verbi", link: "/grammar/themes/genus-verbi" },
|
||||
{ name: "das Passiv", link: "/grammar/themes/passiv" },
|
||||
{ name: "Würde-Form", link: "/grammar/themes/konditionalsatze" },
|
||||
{ name: "Imperativ", link: "/grammar/themes/imperativ" },
|
||||
{ name: "Genus Verbi", link: "/grammar/themes/genus-verbi" },
|
||||
{ name: "Passiv", link: "/grammar/themes/passiv" },
|
||||
{
|
||||
name: "Passiv-Ersatzformen (Paraphrasen)",
|
||||
link: "/grammar/themes/passiv-ersatzformen",
|
||||
},
|
||||
{
|
||||
name: "die Rektion der Verben",
|
||||
name: "Rektion der Verben",
|
||||
link: "/grammar/themes/rektion-der-verben",
|
||||
},
|
||||
{
|
||||
name: "mit reinem Kasus: Verben mit dem Dativ, Verben mit dem Akkusativ, Verben mit dem Genitiv, Verben mit dem Nominativ",
|
||||
name: "Mit reinem Kasus: Verben mit dem Dativ, Verben mit dem Akkusativ, Verben mit dem Genitiv, Verben mit dem Nominativ",
|
||||
link: "/grammar/themes/mit-reinem-kasus",
|
||||
},
|
||||
{
|
||||
name: "mit Präpositionalkasus",
|
||||
name: "Mit Präpositionalkasus",
|
||||
link: "/grammar/themes/mit-prapositionalkasus",
|
||||
},
|
||||
{ name: "Infinitivformen", link: "/grammar/themes/infinitivformen" },
|
||||
{
|
||||
name: "das Funktionsverbgefüge",
|
||||
name: "Funktionsverbgefüge",
|
||||
link: "/grammar/themes/funktionsverbgefüge",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Die Präposition",
|
||||
title: "Präposition",
|
||||
subtopics: [
|
||||
{ name: "mit Nominativ", link: "/grammar/themes/nominativ" },
|
||||
{ name: "mit Akkusativ", link: "/grammar/themes/akkusativ" },
|
||||
{ name: "mit Dativ", link: "/grammar/themes/dativ" },
|
||||
{ name: "mit Genitiv", link: "/grammar/themes/genitiv" },
|
||||
{ name: "Mit Nominativ", link: "/grammar/themes/nominativ" },
|
||||
{ name: "Mit Akkusativ", link: "/grammar/themes/akkusativ" },
|
||||
{ name: "Mit Dativ", link: "/grammar/themes/dativ" },
|
||||
{ name: "Mit Genitiv", link: "/grammar/themes/genitiv" },
|
||||
{ name: "Wechselpräpositionen", link: "/grammar/themes/prapositionen" },
|
||||
{
|
||||
name: "Lokalpräpositionen",
|
||||
@@ -203,7 +203,7 @@ export const grammarTopics = [
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Die Syntax",
|
||||
title: "Syntax",
|
||||
subtopics: [
|
||||
{
|
||||
name: "Konjunktionalsätze",
|
||||
@@ -222,12 +222,12 @@ export const grammarTopics = [
|
||||
link: "/grammar/themes/temporalsatze",
|
||||
},
|
||||
{
|
||||
name: "indirekte Fragesätze",
|
||||
name: "Indirekte Fragesätze",
|
||||
link: "/grammar/themes/indirekte-fragesatze",
|
||||
},
|
||||
{ name: "Relativsätze", link: "/grammar/themes/relativsatze" },
|
||||
{ name: "die Satzklammer", link: "/grammar/themes/satzklammer" },
|
||||
{ name: "uneingeleitete Nebensätze", link: "/grammar/themes/nebensatze" },
|
||||
{ name: "Satzklammer", link: "/grammar/themes/satzklammer" },
|
||||
{ name: "Uneingeleitete Nebensätze", link: "/grammar/themes/nebensatze" },
|
||||
{
|
||||
name: "Infinitivkonstruktionen",
|
||||
link: "/grammar/themes/infinitivkonstruktionen",
|
||||
@@ -236,7 +236,7 @@ export const grammarTopics = [
|
||||
name: "Konjunktionen und Konjunktionaladverbien",
|
||||
link: "/grammar/themes/konjunktionalsatze",
|
||||
},
|
||||
{ name: "die Satzperiode", link: "/grammar/themes/satzperiode" },
|
||||
{ name: "Satzperiode", link: "/grammar/themes/satzperiode" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -22,10 +22,14 @@ export default function AdminButtons({ type, slug, pageExists = true }) {
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert("Fehler beim Löschen.");
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const message = await res.text();
|
||||
alert(
|
||||
`Fehler beim Löschen (HTTP ${res.status}): ${message || "Unbekannter Fehler"}`
|
||||
);
|
||||
};
|
||||
|
||||
if (checkUseIsAdmin()) {
|
||||
|
||||
@@ -89,4 +89,25 @@ table th {
|
||||
|
||||
table th {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chapter-nav-row {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chapter-nav-left,
|
||||
.chapter-nav-right {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chapter-nav-left {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.chapter-nav-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
@@ -32,18 +32,15 @@ export default async function Chapters({ params }) {
|
||||
<AdminButtons type={type} slug={slug} />
|
||||
<h1>{pageData.title}</h1>
|
||||
<RenderHTML data={transformedContent} />
|
||||
<Row pb="xl" justify="space-between">
|
||||
{previousLink && (
|
||||
<Container>
|
||||
<LinkButton href={previousLink}>Zurück</LinkButton>
|
||||
</Container>
|
||||
)}
|
||||
<Container>
|
||||
{showLearningFormLink ? (
|
||||
<Row pb="xl" className="chapter-nav-row" justify="space-between">
|
||||
<Container className="chapter-nav-left">
|
||||
{previousLink && <LinkButton href={previousLink}>Zurück</LinkButton>}
|
||||
</Container>
|
||||
<Container className="chapter-nav-right">
|
||||
{showLearningFormLink && (
|
||||
<LinkButton href="/learning">Go to form</LinkButton>
|
||||
) : (
|
||||
nextLink && <LinkButton href={nextLink}>Weiter</LinkButton>
|
||||
)}
|
||||
{nextLink && <LinkButton href={nextLink}>Weiter</LinkButton>}
|
||||
</Container>
|
||||
</Row>
|
||||
</Column>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
.cards {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--bg3);
|
||||
color: var(--fg2);
|
||||
padding: 1.75rem;
|
||||
width: min(100%, 38rem);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
color: var(--fg2);
|
||||
display: inline-block;
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.75rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cardTitle:hover,
|
||||
.cardTitle:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cardDescription {
|
||||
color: var(--fg2);
|
||||
margin: 0;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.linkList {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getPageList } from "@/backend/html-services";
|
||||
import { Column } from "@/components/ui/layout/container";
|
||||
import Link from "next/link";
|
||||
import styles from "./overview.module.css";
|
||||
|
||||
export default async function Chapter({ params }) {
|
||||
const { type } = await params;
|
||||
@@ -12,11 +13,31 @@ export default async function Chapter({ params }) {
|
||||
return (
|
||||
<Column gap="md">
|
||||
<h1>{header}</h1>
|
||||
{chapters.map((chapter) => (
|
||||
<Link key={chapter.slug} href={`/pages/${type}/${chapter.slug}`}>
|
||||
{chapter.title}
|
||||
</Link>
|
||||
))}
|
||||
{type === "communications" ? (
|
||||
<div className={styles.cards}>
|
||||
{chapters.map((chapter) => (
|
||||
<article className={styles.card} key={chapter.slug}>
|
||||
<Link
|
||||
className={styles.cardTitle}
|
||||
href={`/pages/${type}/${chapter.slug}`}
|
||||
>
|
||||
{chapter.title}
|
||||
</Link>
|
||||
{chapter.description && (
|
||||
<p className={styles.cardDescription}>{chapter.description}</p>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.linkList}>
|
||||
{chapters.map((chapter) => (
|
||||
<Link key={chapter.slug} href={`/pages/${type}/${chapter.slug}`}>
|
||||
{chapter.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { DB } from "@/backend/db";
|
||||
* Services for reading and writing HTML page content stored in the database.
|
||||
*
|
||||
* Pages are grouped by `page_group` (e.g., type/category) and identified by a `slug`.
|
||||
* The schema expects columns: `title`, `content`, `page_order`, `slug`, `page_group`.
|
||||
* The schema expects columns: `title`, `description`, `content`, `page_order`, `slug`, `page_group`.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -12,13 +12,13 @@ import { DB } from "@/backend/db";
|
||||
*
|
||||
* @param {string} type - The `page_group` to filter by.
|
||||
* @param {string} slug - The unique slug within the group.
|
||||
* @returns {Promise<{title:string, content:string, page_order:number, slug:string, page_group:string}>}
|
||||
* @returns {Promise<{title:string, description:string|null, content:string, page_order:number, slug:string, page_group:string}>}
|
||||
* Resolves with the page row when found.
|
||||
* @throws {Error} If the page does not exist.
|
||||
*/
|
||||
export async function getPageData(type, slug) {
|
||||
const result = await DB.pool(
|
||||
"SELECT title, content, page_order, slug, page_group FROM html_pages WHERE page_group = $1 AND slug = $2",
|
||||
"SELECT title, description, content, page_order, slug, page_group FROM html_pages WHERE page_group = $1 AND slug = $2",
|
||||
[type, slug]
|
||||
);
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function getPageData(type, slug) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an HTML page's core fields (`title`, `slug`, `content`).
|
||||
* Updates an HTML page's core fields (`title`, `description`, `slug`, `content`).
|
||||
*
|
||||
* Matching is done by the provided `type` (maps to `page_group`) and the
|
||||
* current `slug` value. If the row is found, its fields are updated and the
|
||||
@@ -43,13 +43,20 @@ export async function getPageData(type, slug) {
|
||||
*
|
||||
* @param {string} type - The `page_group` of the page to update.
|
||||
* @param {string} slug - The current slug of the page to update.
|
||||
* @param {{title:string, slug:string, content:string}} newData - New values to set.
|
||||
* @param {{title:string, description:string|null, slug:string, content:string}} newData - New values to set.
|
||||
* @returns {Promise<boolean>} Resolves `true` when exactly one row was updated; otherwise `false`.
|
||||
*/
|
||||
export async function setPageData(type, slug, newData) {
|
||||
const result = await DB.pool(
|
||||
"UPDATE html_pages SET title = $1, slug = $2, content = $3 WHERE page_group = $4 AND slug = $5",
|
||||
[newData.title, newData.slug, newData.content, type, slug]
|
||||
"UPDATE html_pages SET title = $1, description = $2, slug = $3, content = $4 WHERE page_group = $5 AND slug = $6",
|
||||
[
|
||||
newData.title,
|
||||
newData.description ?? null,
|
||||
newData.slug,
|
||||
newData.content,
|
||||
type,
|
||||
slug,
|
||||
]
|
||||
);
|
||||
return result.rowCount >= 1;
|
||||
}
|
||||
@@ -58,22 +65,43 @@ export async function setPageData(type, slug, newData) {
|
||||
* Lists pages (title and slug) within a group.
|
||||
*
|
||||
* Ordering rules:
|
||||
* - `grammar`: alphabetical by title
|
||||
* - `grammar` and `communications`: alphabetical by title
|
||||
* - others: page_order ascending, then title as a tiebreaker
|
||||
*
|
||||
* @param {string} type - The `page_group` to list.
|
||||
* @returns {Promise<Array<{title:string, slug:string}>>} Resolves with page summaries.
|
||||
* @returns {Promise<Array<{title:string, description:string|null, slug:string}>>} Resolves with page summaries.
|
||||
*/
|
||||
export async function getPageList(type) {
|
||||
const orderByClause =
|
||||
type === "grammar"
|
||||
type === "grammar" || type === "communications"
|
||||
? "ORDER BY LOWER(title) ASC"
|
||||
: "ORDER BY page_order ASC NULLS LAST, LOWER(title) ASC";
|
||||
|
||||
const result = await DB.pool(
|
||||
`SELECT title, slug FROM html_pages WHERE page_group = $1 ${orderByClause}`,
|
||||
`SELECT title, description, slug FROM html_pages WHERE page_group = $1 ${orderByClause}`,
|
||||
[type]
|
||||
);
|
||||
|
||||
if (type === "resources") {
|
||||
const extractFirstNumber = (value) => {
|
||||
const match = value.match(/\d+/);
|
||||
return match ? Number.parseInt(match[0], 10) : Number.POSITIVE_INFINITY;
|
||||
};
|
||||
|
||||
result.rows.sort((a, b) => {
|
||||
const numberA = extractFirstNumber(a.title);
|
||||
const numberB = extractFirstNumber(b.title);
|
||||
|
||||
if (numberA !== numberB) {
|
||||
return numberA - numberB;
|
||||
}
|
||||
|
||||
return a.title.localeCompare(b.title, undefined, {
|
||||
sensitivity: "base",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
@@ -120,24 +148,11 @@ export async function getGrammarTopicsWithPages() {
|
||||
}
|
||||
}
|
||||
|
||||
const uncategorized = await DB.pool(
|
||||
`SELECT title, slug
|
||||
FROM html_pages
|
||||
WHERE page_group = 'grammar'
|
||||
AND grammar_topic_id IS NULL
|
||||
ORDER BY LOWER(title) ASC`
|
||||
);
|
||||
|
||||
if (uncategorized.rowCount > 0) {
|
||||
topics.push({
|
||||
id: -1,
|
||||
name: "Ohne Thema",
|
||||
subtopics: uncategorized.rows,
|
||||
});
|
||||
}
|
||||
|
||||
const legacyTopics = await getLegacyGrammarTopics();
|
||||
return mergeGrammarTopics(topics, legacyTopics);
|
||||
|
||||
return mergeGrammarTopics(topics, legacyTopics).filter(
|
||||
(topic) => !isOhneThemaTopic(topic.name)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,32 +161,36 @@ export async function getGrammarTopicsWithPages() {
|
||||
* @returns {Promise<Array<{title:string,slug:string}>>}
|
||||
*/
|
||||
export async function getAlphabeticalGrammarPages() {
|
||||
const [dbPages, legacyChapters] = await Promise.all([
|
||||
getPageList("grammar"),
|
||||
getLegacyAlphabeticalChapters(),
|
||||
]);
|
||||
const topics = await getGrammarTopicsWithPages();
|
||||
|
||||
const mergedBySlug = new Map();
|
||||
|
||||
for (const chapter of legacyChapters) {
|
||||
mergedBySlug.set(chapter.slug, {
|
||||
title: chapter.title,
|
||||
slug: chapter.slug,
|
||||
});
|
||||
}
|
||||
|
||||
for (const page of dbPages) {
|
||||
mergedBySlug.set(page.slug, {
|
||||
title: page.title,
|
||||
slug: page.slug,
|
||||
});
|
||||
}
|
||||
const mergedBySlug = collectUniqueSubtopicsBySlug(topics);
|
||||
|
||||
return [...mergedBySlug.values()].sort((a, b) =>
|
||||
a.title.localeCompare(b.title, undefined, { sensitivity: "base" })
|
||||
);
|
||||
}
|
||||
|
||||
function isOhneThemaTopic(name) {
|
||||
return typeof name === "string" && name.trim().toLowerCase() === "ohne thema";
|
||||
}
|
||||
|
||||
function collectUniqueSubtopicsBySlug(topics) {
|
||||
const mergedBySlug = new Map();
|
||||
|
||||
for (const topic of topics) {
|
||||
for (const subtopic of topic.subtopics || []) {
|
||||
if (!subtopic?.slug) continue;
|
||||
|
||||
mergedBySlug.set(subtopic.slug, {
|
||||
title: subtopic.title,
|
||||
slug: subtopic.slug,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return mergedBySlug;
|
||||
}
|
||||
|
||||
async function getLegacyGrammarTopics() {
|
||||
try {
|
||||
const module = await import("@/app/grammar/themes/topics");
|
||||
@@ -190,22 +209,6 @@ async function getLegacyGrammarTopics() {
|
||||
}
|
||||
}
|
||||
|
||||
async function getLegacyAlphabeticalChapters() {
|
||||
try {
|
||||
const module = await import("@/app/grammar/alphabetical/chapters");
|
||||
const chapters = module?.chapters || [];
|
||||
|
||||
return chapters
|
||||
.map((chapter) => ({
|
||||
title: chapter.title,
|
||||
slug: decodeURIComponent((chapter.link || "").split("/").pop() || ""),
|
||||
}))
|
||||
.filter((chapter) => chapter.slug);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function mergeGrammarTopics(dbTopics, legacyTopics) {
|
||||
const merged = [];
|
||||
const byName = new Map();
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { getPageData, setPageData, getPageList } from "./html-services";
|
||||
import {
|
||||
getPageData,
|
||||
setPageData,
|
||||
getPageList,
|
||||
getGrammarTopicsWithPages,
|
||||
getAlphabeticalGrammarPages,
|
||||
} from "./html-services";
|
||||
import { useTestDatabase } from "@/backend/test/testdb";
|
||||
import { DB } from "@/backend/db";
|
||||
|
||||
@@ -8,12 +14,12 @@ describe("html-services", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await DB.pool(
|
||||
`INSERT INTO html_pages (title, content, page_order, slug, page_group)
|
||||
`INSERT INTO html_pages (title, description, content, page_order, slug, page_group)
|
||||
VALUES
|
||||
('Welcome', '<p>Welcome to our site</p>', 1, 'welcome', 'home'),
|
||||
('About Us', '<p>Learn about us</p>', 2, 'about', 'home'),
|
||||
('Contact', '<p>Contact us here</p>', 1, 'contact', 'contact'),
|
||||
('FAQ', '<p>Frequently asked questions</p>', 1, 'faq', 'support')`
|
||||
('Welcome', 'Welcome description', '<p>Welcome to our site</p>', 1, 'welcome', 'home'),
|
||||
('About Us', 'About description', '<p>Learn about us</p>', 2, 'about', 'home'),
|
||||
('Contact', 'Contact description', '<p>Contact us here</p>', 1, 'contact', 'contact'),
|
||||
('FAQ', 'FAQ description', '<p>Frequently asked questions</p>', 1, 'faq', 'support')`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -25,6 +31,7 @@ describe("html-services", () => {
|
||||
expect(page.title).toBe("Welcome");
|
||||
expect(page.slug).toBe("welcome");
|
||||
expect(page.page_group).toBe("home");
|
||||
expect(page.description).toBe("Welcome description");
|
||||
expect(page.content).toBe("<p>Welcome to our site</p>");
|
||||
expect(page.page_order).toBe(1);
|
||||
});
|
||||
@@ -61,6 +68,7 @@ describe("html-services", () => {
|
||||
const page = await getPageData("home", "welcome");
|
||||
|
||||
expect(page).toHaveProperty("title");
|
||||
expect(page).toHaveProperty("description");
|
||||
expect(page).toHaveProperty("content");
|
||||
expect(page).toHaveProperty("page_order");
|
||||
expect(page).toHaveProperty("slug");
|
||||
@@ -69,9 +77,10 @@ describe("html-services", () => {
|
||||
});
|
||||
|
||||
describe("setPageData", () => {
|
||||
it("should update page title, slug, and content", async () => {
|
||||
it("should update page title, description, slug, and content", async () => {
|
||||
const success = await setPageData("home", "welcome", {
|
||||
title: "Welcome Updated",
|
||||
description: "Updated description",
|
||||
slug: "welcome-new",
|
||||
content: "<p>Updated welcome content</p>",
|
||||
});
|
||||
@@ -80,12 +89,14 @@ describe("html-services", () => {
|
||||
|
||||
const updatedPage = await getPageData("home", "welcome-new");
|
||||
expect(updatedPage.title).toBe("Welcome Updated");
|
||||
expect(updatedPage.description).toBe("Updated description");
|
||||
expect(updatedPage.content).toBe("<p>Updated welcome content</p>");
|
||||
});
|
||||
|
||||
it("should return false when page does not exist", async () => {
|
||||
const success = await setPageData("home", "nonexistent", {
|
||||
title: "New Title",
|
||||
description: "New description",
|
||||
slug: "new-slug",
|
||||
content: "<p>New content</p>",
|
||||
});
|
||||
@@ -96,6 +107,7 @@ describe("html-services", () => {
|
||||
it("should return false when type does not match", async () => {
|
||||
const success = await setPageData("nonexistent", "welcome", {
|
||||
title: "New Title",
|
||||
description: "New description",
|
||||
slug: "new-slug",
|
||||
content: "<p>New content</p>",
|
||||
});
|
||||
@@ -108,6 +120,7 @@ describe("html-services", () => {
|
||||
|
||||
await setPageData("home", "welcome", {
|
||||
title: "New Title",
|
||||
description: "New description",
|
||||
slug: "welcome",
|
||||
content: "<p>New content</p>",
|
||||
});
|
||||
@@ -115,6 +128,7 @@ describe("html-services", () => {
|
||||
const updatedPage = await getPageData("home", "welcome");
|
||||
|
||||
expect(updatedPage.title).toBe("New Title");
|
||||
expect(updatedPage.description).toBe("New description");
|
||||
expect(updatedPage.content).toBe("<p>New content</p>");
|
||||
expect(updatedPage.page_order).toBe(originalPage.page_order);
|
||||
expect(updatedPage.page_group).toBe(originalPage.page_group);
|
||||
@@ -123,6 +137,7 @@ describe("html-services", () => {
|
||||
it("should allow changing the slug", async () => {
|
||||
await setPageData("home", "welcome", {
|
||||
title: "Welcome",
|
||||
description: "Welcome description",
|
||||
slug: "welcome-updated",
|
||||
content: "<p>Welcome to our site</p>",
|
||||
});
|
||||
@@ -140,6 +155,7 @@ describe("html-services", () => {
|
||||
|
||||
await setPageData("home", "welcome", {
|
||||
title: "Welcome",
|
||||
description: "Welcome description",
|
||||
slug: "welcome",
|
||||
content: htmlContent,
|
||||
});
|
||||
@@ -151,6 +167,7 @@ describe("html-services", () => {
|
||||
it("should handle empty content", async () => {
|
||||
const success = await setPageData("home", "welcome", {
|
||||
title: "Welcome",
|
||||
description: null,
|
||||
slug: "welcome",
|
||||
content: "",
|
||||
});
|
||||
@@ -183,6 +200,7 @@ describe("html-services", () => {
|
||||
|
||||
pages.forEach((page) => {
|
||||
expect(page).toHaveProperty("title");
|
||||
expect(page).toHaveProperty("description");
|
||||
expect(page).toHaveProperty("slug");
|
||||
expect(page).not.toHaveProperty("content");
|
||||
expect(page).not.toHaveProperty("page_group");
|
||||
@@ -211,8 +229,102 @@ describe("html-services", () => {
|
||||
|
||||
pages.forEach((page) => {
|
||||
const keys = Object.keys(page).sort();
|
||||
expect(keys).toEqual(["slug", "title"]);
|
||||
expect(keys).toEqual(["description", "slug", "title"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should sort communications alphabetically by title", async () => {
|
||||
await DB.pool(
|
||||
`INSERT INTO html_pages (title, description, content, page_order, slug, page_group)
|
||||
VALUES
|
||||
('Zugfahrt', 'Z description', '', 3, 'zugfahrt', 'communications'),
|
||||
('Arztbesuch', 'A description', '', 1, 'arztbesuch', 'communications'),
|
||||
('Bahnhof', 'B description', '', 2, 'bahnhof', 'communications')`
|
||||
);
|
||||
|
||||
const pages = await getPageList("communications");
|
||||
|
||||
const relevantSlugs = pages
|
||||
.map((page) => page.slug)
|
||||
.filter((slug) => ["arztbesuch", "bahnhof", "zugfahrt"].includes(slug));
|
||||
|
||||
expect(relevantSlugs).toEqual(["arztbesuch", "bahnhof", "zugfahrt"]);
|
||||
});
|
||||
|
||||
it("should sort resources by the number in title", async () => {
|
||||
await DB.pool(
|
||||
`INSERT INTO html_pages (title, content, page_order, slug, page_group)
|
||||
VALUES
|
||||
('Lektion 10', '', 3, 'lektion-10', 'resources'),
|
||||
('Lektion 2', '', 1, 'lektion-2', 'resources'),
|
||||
('Lektion 1', '', 2, 'lektion-1', 'resources')`
|
||||
);
|
||||
|
||||
const pages = await getPageList("resources");
|
||||
|
||||
const relevantSlugs = pages
|
||||
.map((page) => page.slug)
|
||||
.filter((slug) =>
|
||||
["lektion-1", "lektion-2", "lektion-10"].includes(slug)
|
||||
);
|
||||
|
||||
expect(relevantSlugs).toEqual(["lektion-1", "lektion-2", "lektion-10"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("grammar topic/alphabetical consistency", () => {
|
||||
it("should exclude Ohne Thema and keep identical slug sets across views", async () => {
|
||||
const insertedTopics = await DB.pool(
|
||||
`INSERT INTO grammar_topics (name, sort_order)
|
||||
VALUES ('Verb', 1), ('Ohne Thema', 2), ('Nomen', 3)
|
||||
RETURNING id, name`
|
||||
);
|
||||
|
||||
const topicIdByName = new Map(
|
||||
insertedTopics.rows.map((row) => [row.name, row.id])
|
||||
);
|
||||
|
||||
await DB.pool(
|
||||
`INSERT INTO html_pages (
|
||||
title,
|
||||
content,
|
||||
page_order,
|
||||
slug,
|
||||
page_group,
|
||||
grammar_topic_id
|
||||
)
|
||||
VALUES
|
||||
('Zeta Thema', '', 1, 'zeta-thema', 'grammar', $1),
|
||||
('Alpha Thema', '', 2, 'alpha-thema', 'grammar', $2),
|
||||
('Should Be Hidden', '', 3, 'hidden-ohne-thema', 'grammar', $3)`,
|
||||
[
|
||||
topicIdByName.get("Verb"),
|
||||
topicIdByName.get("Nomen"),
|
||||
topicIdByName.get("Ohne Thema"),
|
||||
]
|
||||
);
|
||||
|
||||
const [topics, alphabetical] = await Promise.all([
|
||||
getGrammarTopicsWithPages(),
|
||||
getAlphabeticalGrammarPages(),
|
||||
]);
|
||||
|
||||
const topicSlugs = topics.flatMap((topic) =>
|
||||
topic.subtopics.map((subtopic) => subtopic.slug)
|
||||
);
|
||||
const alphabeticalSlugs = alphabetical.map((page) => page.slug);
|
||||
|
||||
expect(topics.some((topic) => topic.name === "Ohne Thema")).toBe(false);
|
||||
expect(topicSlugs).not.toContain("hidden-ohne-thema");
|
||||
expect(alphabeticalSlugs).not.toContain("hidden-ohne-thema");
|
||||
|
||||
expect(new Set(alphabeticalSlugs)).toEqual(new Set(topicSlugs));
|
||||
|
||||
const alphabeticalTitles = alphabetical.map((page) => page.title);
|
||||
const sortedAlphabeticalTitles = [...alphabeticalTitles].sort((a, b) =>
|
||||
a.localeCompare(b, undefined, { sensitivity: "base" })
|
||||
);
|
||||
expect(alphabeticalTitles).toEqual(sortedAlphabeticalTitles);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
"use client";
|
||||
import { Fragment } from "react";
|
||||
import parse, { domToReact } from "html-react-parser";
|
||||
import {
|
||||
GlossaryParagraph,
|
||||
GlossaryListItem,
|
||||
} from "@/components/ui/glossary/GlossaryText";
|
||||
|
||||
// ?NOTE The <container> tag still throws an error, but for now I've chosen not to go down that rabbit hole
|
||||
// ?It seems slightly too complicated for this last sprint.
|
||||
|
||||
/**
|
||||
* Normalize HTML attributes for use in React elements.
|
||||
*
|
||||
@@ -77,6 +75,9 @@ export default function RenderHTML({ data }) {
|
||||
.join(" ");
|
||||
return <div {...safeAttribs}>{children}</div>; // This replacement should maybe use <Column> if it exists
|
||||
},
|
||||
// Legacy DB content can contain <Container> tags from old React markup.
|
||||
// Render only children to preserve previous look without invalid tag warnings.
|
||||
container: (_attributes, children) => <Fragment>{children}</Fragment>,
|
||||
anchor: (attributes, children) => {
|
||||
const safeAttribs = { ...attributes };
|
||||
const href = safeAttribs.href || safeAttribs["data-href"];
|
||||
|
||||
@@ -263,6 +263,14 @@ describe("RenderHTML Component", () => {
|
||||
expect(columnDiv).toBeTruthy();
|
||||
expect(columnDiv.textContent).toContain("Hello Column");
|
||||
});
|
||||
|
||||
it("should unwrap custom container tags without rendering container elements", () => {
|
||||
const html = "<container><p>Wrapped content</p></container>";
|
||||
const { container } = render(<RenderHTML data={html} />);
|
||||
|
||||
expect(container.querySelector("container")).toBeNull();
|
||||
expect(screen.getByText("Wrapped content")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance scenarios", () => {
|
||||
|
||||
@@ -89,6 +89,10 @@ function StudentSideBar() {
|
||||
};
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently handle errors (e.g., no resources pages exist yet)
|
||||
setChapters([]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user