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:
Erza
2026-04-16 20:11:38 +00:00
parent 773136e208
commit d4e5b81d8b
29 changed files with 662 additions and 439 deletions
@@ -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';
+2 -1
View File
@@ -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
+59 -5
View File
@@ -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(/&nbsp;/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}
+19 -4
View File
@@ -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 () => {
+12 -3
View File
@@ -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" },
});
+5
View File
@@ -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",
},
];
+57 -57
View File
@@ -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;
}
+7 -10
View File
@@ -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;
}
+26 -5
View File
@@ -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>
);
}
+66 -63
View File
@@ -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();
+120 -8
View File
@@ -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([]);
});
}, []);