feat: search tool, improve: free exercise

This commit is contained in:
Erza Bytyci
2026-07-06 23:19:15 +00:00
parent 14da419ecc
commit 69e46f556c
8 changed files with 765 additions and 3 deletions
@@ -0,0 +1,102 @@
CREATE INDEX IF NOT EXISTS html_pages_search_fts_idx
ON html_pages
USING GIN (
to_tsvector(
'simple',
COALESCE(title, '') || ' ' || COALESCE(description, '') || ' ' || COALESCE(content, '')
)
);
CREATE INDEX IF NOT EXISTS free_form_exercises_search_fts_idx
ON free_form_exercises
USING GIN (
to_tsvector('simple', COALESCE(title, ''))
);
CREATE INDEX IF NOT EXISTS free_form_questions_search_fts_idx
ON free_form_questions
USING GIN (
to_tsvector('simple', COALESCE(question, ''))
);
CREATE INDEX IF NOT EXISTS multichoice_exercises_search_fts_idx
ON multichoice_exercises
USING GIN (
to_tsvector(
'simple',
COALESCE(title, '') || ' ' || COALESCE(exercise_description, '')
)
);
CREATE INDEX IF NOT EXISTS multichoice_content_search_fts_idx
ON multichoice_content
USING GIN (
to_tsvector('simple', COALESCE(content_value, '') || ' ' || COALESCE(correct_answer, ''))
);
CREATE INDEX IF NOT EXISTS click_exercises_search_fts_idx
ON click_exercises
USING GIN (
to_tsvector(
'simple',
COALESCE(title, '') || ' ' || COALESCE(category, '') || ' ' ||
COALESCE(target_words::text, '') || ' ' || COALESCE(all_words::text, '')
)
);
CREATE INDEX IF NOT EXISTS fill_gap_exercises_search_fts_idx
ON fill_gap_exercises
USING GIN (
to_tsvector(
'simple',
COALESCE(title, '') || ' ' || COALESCE(source_text, '') || ' ' || COALESCE(source_html, '')
)
);
CREATE INDEX IF NOT EXISTS fill_gap_gaps_search_fts_idx
ON fill_gap_gaps
USING GIN (
to_tsvector('simple', COALESCE(token_text, ''))
);
CREATE INDEX IF NOT EXISTS dnd_exercises_search_fts_idx
ON dnd_exercises
USING GIN (
to_tsvector('simple', COALESCE(title, '') || ' ' || COALESCE(description, ''))
);
CREATE INDEX IF NOT EXISTS dnd_match_exercises_search_fts_idx
ON dnd_match_exercises
USING GIN (
to_tsvector('simple', COALESCE(title, '') || ' ' || COALESCE(description, ''))
);
CREATE INDEX IF NOT EXISTS dnd_match_pairs_search_fts_idx
ON dnd_match_pairs
USING GIN (
to_tsvector('simple', COALESCE(left_item, '') || ' ' || COALESCE(right_item, ''))
);
CREATE INDEX IF NOT EXISTS memory_game_exercises_search_fts_idx
ON memory_game_exercises
USING GIN (
to_tsvector('simple', COALESCE(title, '') || ' ' || COALESCE(description, ''))
);
CREATE INDEX IF NOT EXISTS memory_game_pairs_search_fts_idx
ON memory_game_pairs
USING GIN (
to_tsvector('simple', COALESCE(left_item, '') || ' ' || COALESCE(right_item, ''))
);
CREATE INDEX IF NOT EXISTS jumbled_sentence_exercises_search_fts_idx
ON jumbled_sentence_exercises
USING GIN (
to_tsvector('simple', COALESCE(title, ''))
);
CREATE INDEX IF NOT EXISTS jumbled_sentence_sentences_search_fts_idx
ON jumbled_sentence_sentences
USING GIN (
to_tsvector('simple', COALESCE(sentence, ''))
);
@@ -0,0 +1,30 @@
const { test, expect } = require("@playwright/test");
test.describe("Global header search", () => {
test("should search and navigate to a page result", async ({ page }) => {
test.setTimeout(60000);
await page.goto("");
await expect(page.getByLabel("Suche")).toBeVisible();
const searchInput = page.getByLabel("Suche");
await searchInput.fill("Glossar");
await expect(page.locator(".navbar-search-dropdown")).toBeVisible();
const resultButton = page
.locator(".navbar-search-item", {
has: page.locator(".navbar-search-item-title", { hasText: "Glossar" }),
})
.first();
await expect(resultButton).toBeVisible();
await expect(
resultButton.locator(".navbar-search-highlight").first()
).toBeVisible();
await resultButton.click();
await expect(page).toHaveURL(/\/glossary$/);
await expect(page.locator("main")).toBeVisible();
});
});
+254
View File
@@ -0,0 +1,254 @@
import { NextResponse } from "next/server";
import { DB } from "@/backend/db";
const MAX_RESULTS = 12;
function toPlainText(value) {
return (value || "")
.replace(/<[^>]*>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function buildSnippet(searchableText, query) {
const text = toPlainText(searchableText);
if (!text) return "";
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
const index = lowerText.indexOf(lowerQuery);
if (index < 0) {
return text.length > 140 ? `${text.slice(0, 140)}...` : text;
}
const start = Math.max(0, index - 55);
const end = Math.min(text.length, index + query.length + 85);
const prefix = start > 0 ? "..." : "";
const suffix = end < text.length ? "..." : "";
return `${prefix}${text.slice(start, end)}${suffix}`;
}
export async function GET(request) {
try {
const url = new URL(request.url);
const query = (url.searchParams.get("q") || "").trim();
if (query.length < 2) {
return NextResponse.json({ results: [] });
}
const likeQuery = `%${query}%`;
const { rows } = await DB.pool(
`
WITH candidates AS (
SELECT
'page' AS item_kind,
'static' AS item_group,
p.title AS title,
p.url AS url,
p.searchable_text AS searchable_text
FROM (
VALUES
('Startseite', '/', 'Startseite home gradesa deutsch lernen'),
('Übungstypen', '/grammar/exercises', 'Übungen exercise types Übersicht'),
('Freie-Übungen', '/grammar/exercises/freeform', 'Freie-Übungen offene Fragen freeform'),
('Klick-Übungen', '/grammar/exercises/click', 'Klick-Übungen target words'),
('Multiple-Choice-Übungen', '/grammar/exercises/multichoice', 'Multiple Choice Übungen'),
('Sortieren/Gruppieren-Übungen', '/grammar/exercises/dragdrop', 'drag drop Übungen sortieren gruppieren'),
('Lückentext-Übungen', '/grammar/exercises/fillinthegap', 'fill in the gap Lückentext Übungen'),
('Zuordnungs-Übungen', '/grammar/exercises/dnd-match', 'Zuordnung dnd match Übungen'),
('Memory Game', '/grammar/exercises/memory-game', 'Memory Game Übungen'),
('Satzmix-Übungen', '/grammar/exercises/jumbled-sentence', 'Satzmix jumbled sentence Übungen'),
('Themen der Grammatik', '/grammar/themes', 'Grammatik Themen Übersicht'),
('Glossar', '/glossary', 'Glossar Wörterbuch Worterklärungen'),
('Neuigkeiten', '/news', 'News Neuigkeiten Artikel'),
('Brauchen Sie Hilfe?', '/contact', 'Kontakt Hilfe support'),
('Effektiv online lernen', '/pages/resources', 'resources lernen chapters'),
('Kommunikationssituationen', '/pages/communications', 'communications Kommunikation Seiten')
) AS p(title, url, searchable_text)
UNION ALL
SELECT
'page' AS item_kind,
hp.page_group AS item_group,
hp.title AS title,
CONCAT('/pages/', hp.page_group, '/', hp.slug) AS url,
COALESCE(hp.description, '') || ' ' || COALESCE(hp.content, '') AS searchable_text
FROM html_pages hp
WHERE hp.page_group IN ('resources', 'communications', 'grammar')
UNION ALL
SELECT
'exercise' AS item_kind,
'freeform' AS item_group,
ffe.title AS title,
CONCAT('/grammar/exercises/freeform/', ffe.id) AS url,
COALESCE(ffe.title, '') || ' ' || COALESCE(questions.questions_text, '') AS searchable_text
FROM free_form_exercises ffe
LEFT JOIN (
SELECT free_form_exercise_id, STRING_AGG(question, ' ') AS questions_text
FROM free_form_questions
GROUP BY free_form_exercise_id
) questions ON questions.free_form_exercise_id = ffe.id
UNION ALL
SELECT
'exercise' AS item_kind,
'multichoice' AS item_group,
me.title AS title,
CONCAT('/grammar/exercises/multichoice/', me.id) AS url,
COALESCE(me.title, '') || ' ' || COALESCE(me.exercise_description, '') || ' ' ||
COALESCE(content.content_text, '') || ' ' || COALESCE(options.options_text, '') AS searchable_text
FROM multichoice_exercises me
LEFT JOIN (
SELECT multichoice_exercise_id, STRING_AGG(content_value, ' ') AS content_text
FROM multichoice_content
GROUP BY multichoice_exercise_id
) content ON content.multichoice_exercise_id = me.id
LEFT JOIN (
SELECT mc.multichoice_exercise_id, STRING_AGG(mo.option_value, ' ') AS options_text
FROM multichoice_content mc
JOIN multichoice_options mo ON mo.multichoice_content_id = mc.id
GROUP BY mc.multichoice_exercise_id
) options ON options.multichoice_exercise_id = me.id
UNION ALL
SELECT
'exercise' AS item_kind,
'click' AS item_group,
ce.title AS title,
CONCAT('/grammar/exercises/click/', ce.id) AS url,
COALESCE(ce.title, '') || ' ' || COALESCE(ce.category, '') || ' ' ||
COALESCE(ce.target_words::text, '') || ' ' || COALESCE(ce.all_words::text, '') AS searchable_text
FROM click_exercises ce
UNION ALL
SELECT
'exercise' AS item_kind,
'fillinthegap' AS item_group,
fge.title AS title,
CONCAT('/grammar/exercises/fillinthegap/', fge.id) AS url,
COALESCE(fge.title, '') || ' ' || COALESCE(fge.source_text, '') || ' ' ||
COALESCE(gaps.gap_text, '') AS searchable_text
FROM fill_gap_exercises fge
LEFT JOIN (
SELECT fill_gap_exercise_id, STRING_AGG(token_text, ' ') AS gap_text
FROM fill_gap_gaps
GROUP BY fill_gap_exercise_id
) gaps ON gaps.fill_gap_exercise_id = fge.id
UNION ALL
SELECT
'exercise' AS item_kind,
'dragdrop' AS item_group,
dnd.title AS title,
CONCAT('/grammar/exercises/dragdrop/', dnd.id) AS url,
COALESCE(dnd.title, '') || ' ' || COALESCE(dnd.description, '') || ' ' ||
COALESCE(words.word_text, '') || ' ' || COALESCE(categories.category_text, '') AS searchable_text
FROM dnd_exercises dnd
LEFT JOIN (
SELECT wcm.exercise_id, STRING_AGG(dw.word, ' ') AS word_text
FROM word_category_mappings wcm
JOIN draggable_words dw ON dw.id = wcm.word_id
GROUP BY wcm.exercise_id
) words ON words.exercise_id = dnd.id
LEFT JOIN (
SELECT wcm.exercise_id, STRING_AGG(dc.category, ' ') AS category_text
FROM word_category_mappings wcm
JOIN dnd_categories dc ON dc.id = wcm.category_id
GROUP BY wcm.exercise_id
) categories ON categories.exercise_id = dnd.id
UNION ALL
SELECT
'exercise' AS item_kind,
'dnd-match' AS item_group,
dme.title AS title,
CONCAT('/grammar/exercises/dnd-match/', dme.id) AS url,
COALESCE(dme.title, '') || ' ' || COALESCE(dme.description, '') || ' ' ||
COALESCE(pairs.pair_text, '') AS searchable_text
FROM dnd_match_exercises dme
LEFT JOIN (
SELECT dnd_match_exercise_id, STRING_AGG(left_item || ' ' || right_item, ' ') AS pair_text
FROM dnd_match_pairs
GROUP BY dnd_match_exercise_id
) pairs ON pairs.dnd_match_exercise_id = dme.id
UNION ALL
SELECT
'exercise' AS item_kind,
'memory-game' AS item_group,
me.title AS title,
CONCAT('/grammar/exercises/memory-game/', me.id) AS url,
COALESCE(me.title, '') || ' ' || COALESCE(me.description, '') || ' ' ||
COALESCE(pairs.pair_text, '') AS searchable_text
FROM memory_game_exercises me
LEFT JOIN (
SELECT memory_game_exercise_id, STRING_AGG(left_item || ' ' || right_item, ' ') AS pair_text
FROM memory_game_pairs
GROUP BY memory_game_exercise_id
) pairs ON pairs.memory_game_exercise_id = me.id
UNION ALL
SELECT
'exercise' AS item_kind,
'jumbled-sentence' AS item_group,
jse.title AS title,
CONCAT('/grammar/exercises/jumbled-sentence/', jse.id) AS url,
COALESCE(jse.title, '') || ' ' || COALESCE(sentences.sentence_text, '') AS searchable_text
FROM jumbled_sentence_exercises jse
LEFT JOIN (
SELECT jumbled_exercise_id, STRING_AGG(sentence, ' ') AS sentence_text
FROM jumbled_sentence_sentences
GROUP BY jumbled_exercise_id
) sentences ON sentences.jumbled_exercise_id = jse.id
)
SELECT
item_kind,
item_group,
title,
url,
searchable_text,
CASE
WHEN title ILIKE $1 || '%' THEN 0
WHEN title ILIKE $2 THEN 1
ELSE 2
END AS rank_bucket
FROM candidates
WHERE title ILIKE $2 OR searchable_text ILIKE $2
ORDER BY rank_bucket ASC, title ASC
LIMIT $3
`,
[query, likeQuery, MAX_RESULTS]
);
return NextResponse.json({
results: rows.map((row) => ({
kind: row.item_kind,
group: row.item_group,
title: row.title,
url: row.url,
snippet: buildSnippet(row.searchable_text, query),
})),
});
} catch (error) {
console.error("Error searching content:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { Container } from "@/components/ui/layout/container";
import { Button } from "@/components/ui/button";
@@ -10,6 +10,10 @@ export default function FreeformFeedback({
}) {
const [showAllFeedback, setShowAllFeedback] = useState(false);
useEffect(() => {
setShowAllFeedback(false);
}, [currentQuestionIndex]);
const toggleFeedback = () => {
setShowAllFeedback(!showAllFeedback);
};
@@ -0,0 +1,114 @@
.navbar-search {
position: relative;
width: min(580px, 100%);
}
.navbar-search-label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
clip-path: inset(50%);
}
.navbar-search-input {
width: 100%;
border-radius: var(--radius-xl);
border: 1px solid var(--bg3);
background: var(--bg2);
color: var(--fg1);
padding: 10px 14px;
font-size: var(--font-sm);
}
.navbar-search-input:focus {
outline: 2px solid var(--blue);
outline-offset: 1px;
}
.navbar-search-helper {
margin-top: 4px;
color: var(--fg4);
font-size: var(--font-xs);
}
.navbar-search-dropdown {
position: absolute;
left: 0;
right: 0;
margin-top: 6px;
border: 1px solid var(--bg3);
border-radius: var(--radius-xl);
background: var(--bg1);
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.24);
z-index: 1200;
max-height: min(420px, 60vh);
overflow-y: auto;
}
.navbar-search-list {
list-style: none;
margin: 0;
padding: 6px;
}
.navbar-search-item {
display: flex;
flex-direction: column;
text-align: left;
width: 100%;
border: 0;
background: transparent;
padding: 10px;
border-radius: var(--radius-md);
cursor: pointer;
color: inherit;
gap: 3px;
}
.navbar-search-item:hover,
.navbar-search-item.active {
background: var(--bg2);
}
.navbar-search-item-label {
color: var(--fg4);
font-size: var(--font-xs);
}
.navbar-search-item-title {
color: var(--fg1);
font-size: var(--font-md);
font-weight: 600;
}
.navbar-search-item-snippet {
color: var(--fg3);
font-size: var(--font-xs);
line-height: 1.35;
}
.navbar-search-highlight {
background: color-mix(in srgb, var(--blue) 70%, transparent);
color: inherit;
padding: 0 2px;
border-radius: 4px;
}
.navbar-search-empty {
color: var(--fg4);
font-size: var(--font-sm);
padding: 14px;
}
@media (max-width: 900px) {
.navbar-search {
width: 100%;
}
.navbar-search-helper {
display: none;
}
}
@@ -0,0 +1,235 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { withBasePath } from "@/shared/utils/basePath";
import "./navbar-search.css";
const GROUP_LABELS = {
resources: "Seite: Lernen",
communications: "Seite: Kommunikation",
grammar: "Seite: Grammatik",
freeform: "Übung: Freie-Übung",
multichoice: "Übung: Multiple Choice",
click: "Übung: Klick",
fillinthegap: "Übung: Lückentext",
dragdrop: "Übung: Sortieren/Gruppieren",
"dnd-match": "Übung: Zuordnung",
"memory-game": "Übung: Memory Game",
"jumbled-sentence": "Übung: Satzmix",
static: "Seite",
};
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function highlightText(text, query) {
if (!text) return null;
const trimmedQuery = query.trim();
if (trimmedQuery.length < 2) {
return text;
}
const parts = text.split(new RegExp(`(${escapeRegExp(trimmedQuery)})`, "i"));
return parts.map((part, index) =>
part.toLowerCase() === trimmedQuery.toLowerCase() ? (
<mark key={`${part}-${index}`} className="navbar-search-highlight">
{part}
</mark>
) : (
part
)
);
}
export default function NavbarSearch() {
const router = useRouter();
const rootRef = useRef(null);
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const trimmedQuery = query.trim();
useEffect(() => {
const handleClickOutside = (event) => {
if (!rootRef.current?.contains(event.target)) {
setIsOpen(false);
setActiveIndex(-1);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
useEffect(() => {
if (trimmedQuery.length < 2) {
setResults([]);
setIsLoading(false);
setActiveIndex(-1);
return;
}
const controller = new AbortController();
const timeoutId = setTimeout(async () => {
setIsLoading(true);
try {
const response = await fetch(
withBasePath(`/api/search?q=${encodeURIComponent(trimmedQuery)}`),
{
method: "GET",
signal: controller.signal,
}
);
if (!response.ok) {
throw new Error(`Search failed with status ${response.status}`);
}
const data = await response.json();
setResults(Array.isArray(data.results) ? data.results : []);
setIsOpen(true);
setActiveIndex(-1);
} catch (error) {
if (error.name !== "AbortError") {
console.error("Search request failed:", error);
setResults([]);
}
} finally {
setIsLoading(false);
}
}, 250);
return () => {
clearTimeout(timeoutId);
controller.abort();
};
}, [trimmedQuery]);
const totalItems = results.length;
const helperText = useMemo(() => {
if (trimmedQuery.length < 2) {
return "Mindestens 2 Zeichen eingeben";
}
if (isLoading) {
return "Suche läuft...";
}
if (!results.length) {
return "Keine Treffer";
}
return `${results.length} Treffer`;
}, [isLoading, results.length, trimmedQuery.length]);
const navigateTo = (url) => {
setIsOpen(false);
setActiveIndex(-1);
router.push(url);
};
const handleKeyDown = (event) => {
if (!isOpen && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
setIsOpen(true);
return;
}
if (event.key === "Escape") {
setIsOpen(false);
setActiveIndex(-1);
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
if (!totalItems) return;
setActiveIndex((prev) => (prev + 1) % totalItems);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
if (!totalItems) return;
setActiveIndex((prev) => (prev <= 0 ? totalItems - 1 : prev - 1));
return;
}
if (event.key === "Enter") {
if (!totalItems) return;
event.preventDefault();
const target = results[activeIndex] || results[0];
if (target?.url) {
navigateTo(target.url);
}
}
};
return (
<div className="navbar-search" ref={rootRef}>
<label htmlFor="global-search" className="navbar-search-label">
Suche
</label>
<input
id="global-search"
type="search"
className="navbar-search-input"
placeholder="Seiten und Übungen durchsuchen"
value={query}
onChange={(event) => setQuery(event.target.value)}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
autoComplete="off"
/>
<div className="navbar-search-helper">{helperText}</div>
{isOpen && trimmedQuery.length >= 2 && (
<div className="navbar-search-dropdown">
{results.length > 0 ? (
<ul className="navbar-search-list">
{results.map((item, index) => {
const isActive = activeIndex === index;
const label = GROUP_LABELS[item.group] || "Ergebnis";
return (
<li key={`${item.url}-${index}`}>
<button
type="button"
className={`navbar-search-item ${isActive ? "active" : ""}`}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => navigateTo(item.url)}
>
<span className="navbar-search-item-label">{label}</span>
<span className="navbar-search-item-title">
{highlightText(item.title, trimmedQuery)}
</span>
{item.snippet && (
<span className="navbar-search-item-snippet">
{highlightText(item.snippet, trimmedQuery)}
</span>
)}
</button>
</li>
);
})}
</ul>
) : (
<div className="navbar-search-empty">Keine Treffer gefunden</div>
)}
</div>
)}
</div>
);
}
+20 -2
View File
@@ -2,8 +2,8 @@
background: var(--bg1);
padding: var(--u-md) var(--u-2xl);
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--u-lg);
position: sticky;
top: 0;
z-index: 1000;
@@ -21,6 +21,13 @@
display: flex;
align-items: center;
gap: var(--u-md);
margin-left: auto;
}
.nav-center {
flex: 1;
display: flex;
justify-content: center;
}
.nav-links {
@@ -78,6 +85,17 @@
gap: var(--u-xl);
}
@media (max-width: 1100px) {
.navbar {
flex-wrap: wrap;
}
.nav-center {
order: 3;
flex: 1 0 100%;
}
}
.nav-auth button {
padding: var(--u-md) var(--u-xl);
border-radius: var(--radius-2xl);
@@ -105,4 +123,4 @@
box-shadow: none;
border-bottom: 1px solid var(--fg4);
}
}
}
@@ -6,6 +6,7 @@ import { useUser } from "@/context/user.context";
import { Column } from "../layout/container";
import { useRouter } from "next/navigation";
import { withBasePath } from "@/shared/utils/basePath";
import NavbarSearch from "./navbar-search";
function Navbar() {
const { auth, logout } = useUser();
@@ -29,6 +30,10 @@ function Navbar() {
</div>
</div>
<div className="nav-center">
<NavbarSearch />
</div>
<div className="nav-right">
<div className="nav-links"></div>
<div className="nav-auth">