mirror of
https://github.com/OHTU-German-learning-website/OHTU-German-learning-website.git
synced 2026-10-12 19:13:51 +03:00
feat: search tool, improve: free exercise
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user