Files
ProxMenux/web/components/search-dialog.tsx
T
MacRimi 5ca3463bf6 complete i18n migration to /[locale]/ with EN+ES content
Full rewrite of the docs site under app/[locale]/ with next-intl
in localePrefix:"always" mode. Every page now exists at both
/en/<path> and /es/<path>; the root / shows a meta-refresh + JS
redirect to /<defaultLocale>/ so GitHub Pages serves something
on the apex URL.

Highlights:
- 107 doc pages migrated to file-per-page JSON namespaces under
  messages/en/ and messages/es/. Spanish content is fully
  translated (no copy-of-English placeholders).
- New documentation for the Active Suppressions section in the
  Settings tab and the per-event Dismiss dropdown in the Health
  Monitor modal.
- New screenshots: dismiss-duration-dropdown.png and an updated
  health-suppression-settings.png.
- Pagefind integrated for client-side search; index is built on
  every CI deploy (not committed).
- RSS feeds: per-locale at /<locale>/rss.xml plus root /rss.xml
  for backward compat.
- Removed the dead app/[locale]/guides/[slug]/ route — every
  guide now has its own static page and no markdown source
  remains.
- Fixed orphan link /guides/nvidia -> /guides/nvidia-manual in
  docs/hardware/nvidia-host.
- Removed obsolete components (footer2, calendar, drawer).

Verified locally with `npm ci && npm run build`: 2804 files in
out/, 231 pages indexed by pagefind, root redirect intact, both
locale roots and the new Active Suppressions docs render OK.
2026-05-31 12:41:10 +02:00

234 lines
9.6 KiB
TypeScript

"use client"
import { useEffect, useRef, useState } from "react"
import { createPortal } from "react-dom"
import { useRouter } from "next/navigation"
import { Search, X } from "lucide-react"
import { cn } from "@/lib/utils"
declare global {
interface Window {
PagefindUI?: new (options: Record<string, unknown>) => unknown
}
}
export function SearchDialog() {
const [isOpen, setIsOpen] = useState(false)
const [isReady, setIsReady] = useState(false)
const [loadError, setLoadError] = useState(false)
// Track when the component has hydrated so we know it's safe to use document.body
// for the portal target — avoids React hydration mismatch warnings.
const [mounted, setMounted] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const router = useRouter()
useEffect(() => {
setMounted(true)
}, [])
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault()
setIsOpen((v) => !v)
}
if (e.key === "Escape" && isOpen) {
setIsOpen(false)
}
}
window.addEventListener("keydown", handler)
return () => window.removeEventListener("keydown", handler)
}, [isOpen])
useEffect(() => {
if (!isOpen) {
// Reset readiness on close so the next open shows the loading state
// until Pagefind UI mounts again into the new container ref.
setIsReady(false)
return
}
// Inject Pagefind UI CSS once across the page lifetime.
const cssId = "pagefind-ui-css"
if (!document.getElementById(cssId)) {
const link = document.createElement("link")
link.id = cssId
link.rel = "stylesheet"
link.href = "/pagefind/pagefind-ui.css"
document.head.appendChild(link)
}
const init = () => {
if (!containerRef.current) return
if (typeof window.PagefindUI !== "function") {
setLoadError(true)
return
}
// Wipe any prior UI instance — the container is a fresh DOM node every open,
// but this also handles the unlikely case of a partial mount.
containerRef.current.innerHTML = ""
new window.PagefindUI({
element: containerRef.current,
showSubResults: true,
showImages: false,
resetStyles: false,
autofocus: true,
// Append ?pagefind-search=<term> to result URLs so the destination page can highlight
// the matched terms via /pagefind/pagefind-highlight.js (loaded in the root layout).
highlightParam: "pagefind-search",
// Note: we intentionally do NOT use Pagefind's `processResult` to rewrite URLs.
// Pagefind UI versions differ in which field they bind to the rendered <a href>
// (some use `meta.url`, some `url`, some keep raw_url internally), and mutating
// the result object can also break sub-result rendering. Instead, we intercept
// the click event below and clean the URL at click time — see onClickCapture
// on the result container.
translations: {
placeholder: "Search documentation…",
clear_search: "Clear",
load_more: "Load more results",
search_label: "Search this site",
filters_label: "Filters",
zero_results: "No results for [SEARCH_TERM]",
many_results: "[COUNT] results for [SEARCH_TERM]",
one_result: "[COUNT] result for [SEARCH_TERM]",
alt_search: "No results for [SEARCH_TERM]. Showing results for [DIFFERENT_TERM] instead",
search_suggestion: "No results for [SEARCH_TERM]. Try one of the following searches:",
searching: "Searching for [SEARCH_TERM]…",
},
})
setIsReady(true)
}
// If Pagefind UI is already loaded (we've opened the dialog before in this session),
// re-init directly into the new container ref.
if (typeof window.PagefindUI === "function") {
init()
return
}
// First time: load the script and init on load.
const scriptId = "pagefind-ui-js"
let script = document.getElementById(scriptId) as HTMLScriptElement | null
if (!script) {
script = document.createElement("script")
script.id = scriptId
script.src = "/pagefind/pagefind-ui.js"
script.defer = true
script.onerror = () => setLoadError(true)
document.head.appendChild(script)
}
script.addEventListener("load", init, { once: true })
}, [isOpen])
return (
<>
{/* Trigger button — icon only on mobile/tablet, full button with text + kbd on lg+ */}
<button
type="button"
onClick={() => setIsOpen(true)}
className={cn(
"flex items-center rounded-md text-zinc-400 transition-colors hover:text-zinc-100",
// Mobile/tablet: just the icon, no border/bg
"p-2 lg:p-0",
// Desktop (lg+): full button with grey background for contrast against the dark navbar
"lg:gap-2 lg:rounded-md lg:border lg:border-zinc-700 lg:bg-zinc-800 lg:px-3 lg:py-1.5 lg:text-sm lg:hover:bg-zinc-700 lg:hover:border-zinc-600",
)}
aria-label="Search documentation"
>
<Search className="h-5 w-5 lg:h-4 lg:w-4" />
<span className="hidden lg:inline">Search</span>
<kbd className="hidden lg:inline-flex items-center gap-0.5 rounded border border-zinc-600 bg-zinc-900 px-1.5 py-0.5 text-[10px] font-mono text-zinc-300">
<span className="text-xs"></span>K
</kbd>
</button>
{/*
Render the modal in a portal to document.body so it escapes the Navbar's
fixed/z-50 stacking context. Otherwise z-[1000] is bounded by the parent
context and the mobile "Documentation" bar (also z-50, later in the DOM)
paints on top, hiding the close button.
*/}
{mounted && isOpen && createPortal(
<div
className="fixed inset-0 z-[1000] flex items-start justify-center bg-black/60 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) setIsOpen(false)
}}
role="dialog"
aria-modal="true"
aria-label="Search"
>
<div
className={cn(
"relative mt-4 sm:mt-16 w-full max-w-2xl rounded-lg border border-gray-200 bg-white shadow-2xl mx-4",
"max-h-[90vh] sm:max-h-[80vh] overflow-hidden flex flex-col",
)}
>
{/* Header bar — close button. Esc hint is desktop-only (no keyboard on mobile). */}
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-200 bg-gray-50">
<span className="hidden sm:inline text-xs text-gray-500">
Press <kbd className="rounded border border-gray-300 bg-white px-1 py-0.5 text-[10px] font-mono">Esc</kbd> to close
</span>
{/* Spacer so the X stays right-aligned even when the Esc hint is hidden */}
<span className="sm:hidden" aria-hidden />
<button
type="button"
onClick={() => setIsOpen(false)}
className="rounded p-1 text-gray-500 hover:bg-gray-200 hover:text-gray-900 transition-colors"
aria-label="Close search"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="p-4 overflow-y-auto flex-1">
{loadError ? (
<div className="p-6 text-center text-sm text-gray-600">
<p className="font-medium text-gray-900 mb-2">Search index not available</p>
<p>
Search is generated during the production build. Run{" "}
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-xs">npm run build</code> to
generate the index locally, or wait for the next deploy.
</p>
</div>
) : (
<>
{!isReady && (
<div className="p-6 text-center text-sm text-gray-500">Loading search</div>
)}
{/*
Click interception: Pagefind indexes the static export (.html files), so
result links carry hrefs like "/docs/foo.html?pagefind-search=term". In dev
mode and on hosts that don't serve .html, those URLs 404. We intercept the
click here, strip .html / /index.html, and route via Next.js for SPA nav.
Capture phase runs before any Pagefind handlers; modifier-key clicks fall
through to the browser so cmd/ctrl-click still opens in a new tab.
*/}
<div
ref={containerRef}
className="pagefind-root"
onClickCapture={(e) => {
const target = e.target as HTMLElement
const anchor = target.closest("a") as HTMLAnchorElement | null
if (!anchor) return
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return
const raw = anchor.getAttribute("href")
if (!raw || /^(https?:)?\/\//i.test(raw) || raw.startsWith("mailto:")) return
const cleaned = raw
.replace(/\/index\.html(?=[?#]|$)/g, "/")
.replace(/\.html(?=[?#]|$)/g, "")
e.preventDefault()
setIsOpen(false)
router.push(cleaned)
}}
/>
</>
)}
</div>
</div>
</div>,
document.body,
)}
</>
)
}