mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-06-14 20:36:59 +00:00
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.
This commit is contained in:
233
web/components/search-dialog.tsx
Normal file
233
web/components/search-dialog.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
"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,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user