From 69e46f556c29cbddd10128121d4139e06e0ee432 Mon Sep 17 00:00:00 2001 From: Erza Bytyci Date: Mon, 6 Jul 2026 23:19:15 +0000 Subject: [PATCH] feat: search tool, improve: free exercise --- .../00058_add_global_search_fts_indexes.sql | 102 +++++++ gradesa/playwright/e2e/global-search.spec.js | 30 +++ gradesa/src/app/api/search/route.js | 254 ++++++++++++++++++ .../freeform/[exercise_id]/feedback.js | 6 +- .../components/ui/navbar/navbar-search.css | 114 ++++++++ .../src/components/ui/navbar/navbar-search.js | 235 ++++++++++++++++ gradesa/src/components/ui/navbar/navbar.css | 22 +- .../src/components/ui/navbar/navigation.js | 5 + 8 files changed, 765 insertions(+), 3 deletions(-) create mode 100644 gradesa/data/migrations/00058_add_global_search_fts_indexes.sql create mode 100644 gradesa/playwright/e2e/global-search.spec.js create mode 100644 gradesa/src/app/api/search/route.js create mode 100644 gradesa/src/components/ui/navbar/navbar-search.css create mode 100644 gradesa/src/components/ui/navbar/navbar-search.js diff --git a/gradesa/data/migrations/00058_add_global_search_fts_indexes.sql b/gradesa/data/migrations/00058_add_global_search_fts_indexes.sql new file mode 100644 index 0000000..35f2bf0 --- /dev/null +++ b/gradesa/data/migrations/00058_add_global_search_fts_indexes.sql @@ -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, '')) + ); diff --git a/gradesa/playwright/e2e/global-search.spec.js b/gradesa/playwright/e2e/global-search.spec.js new file mode 100644 index 0000000..d80004b --- /dev/null +++ b/gradesa/playwright/e2e/global-search.spec.js @@ -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(); + }); +}); diff --git a/gradesa/src/app/api/search/route.js b/gradesa/src/app/api/search/route.js new file mode 100644 index 0000000..5d53e72 --- /dev/null +++ b/gradesa/src/app/api/search/route.js @@ -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 } + ); + } +} diff --git a/gradesa/src/app/grammar/exercises/freeform/[exercise_id]/feedback.js b/gradesa/src/app/grammar/exercises/freeform/[exercise_id]/feedback.js index 816b343..3ee0200 100644 --- a/gradesa/src/app/grammar/exercises/freeform/[exercise_id]/feedback.js +++ b/gradesa/src/app/grammar/exercises/freeform/[exercise_id]/feedback.js @@ -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); }; diff --git a/gradesa/src/components/ui/navbar/navbar-search.css b/gradesa/src/components/ui/navbar/navbar-search.css new file mode 100644 index 0000000..5712d4c --- /dev/null +++ b/gradesa/src/components/ui/navbar/navbar-search.css @@ -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; + } +} \ No newline at end of file diff --git a/gradesa/src/components/ui/navbar/navbar-search.js b/gradesa/src/components/ui/navbar/navbar-search.js new file mode 100644 index 0000000..23a6f6d --- /dev/null +++ b/gradesa/src/components/ui/navbar/navbar-search.js @@ -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() ? ( + + {part} + + ) : ( + 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 ( +
+ + setQuery(event.target.value)} + onFocus={() => setIsOpen(true)} + onKeyDown={handleKeyDown} + autoComplete="off" + /> +
{helperText}
+ + {isOpen && trimmedQuery.length >= 2 && ( +
+ {results.length > 0 ? ( +
    + {results.map((item, index) => { + const isActive = activeIndex === index; + const label = GROUP_LABELS[item.group] || "Ergebnis"; + + return ( +
  • + +
  • + ); + })} +
+ ) : ( +
Keine Treffer gefunden
+ )} +
+ )} +
+ ); +} diff --git a/gradesa/src/components/ui/navbar/navbar.css b/gradesa/src/components/ui/navbar/navbar.css index e25371d..2900124 100644 --- a/gradesa/src/components/ui/navbar/navbar.css +++ b/gradesa/src/components/ui/navbar/navbar.css @@ -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); } -} +} \ No newline at end of file diff --git a/gradesa/src/components/ui/navbar/navigation.js b/gradesa/src/components/ui/navbar/navigation.js index d55e0b4..83b6b9a 100644 --- a/gradesa/src/components/ui/navbar/navigation.js +++ b/gradesa/src/components/ui/navbar/navigation.js @@ -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() { +
+ +
+