mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-06-03 13:54:41 +00:00
5e795a654d
`#host` and `#lxc-net` are visual sidebar section headers for the Storage Share Manager page — they group their submenu items in the sidebar tree but point back at the parent Overview with an anchor, so they aren't standalone docs the reader advances to. Including them in the flat Previous/Next sequence produced two regressions: * On `/docs/storage-share/#host` the Next button targeted `#host` again, so clicking it didn't move. The earlier hash-tracking fix intended to catch this, but a `useEffect` with an empty dep array only runs on mount — and Next.js Link navigations don't fire `hashchange` when the path changes too, so a cross-page navigation that lands on `#host` (sidebar click) rendered with hash="" and re-collapsed to the section header. * On `/docs/storage-share/lxc-mount-points/` the Next button pointed at `#lxc-net` instead of advancing to `lxc-nfs-client`, since the section header sat between the two real pages in the flat list. Filter out any sidebar entry whose href contains `#` at walk time so the flat list only carries real pages. With them gone, an anchored URL collapses to its parent Overview and Next walks straight into the first subpage. The hash effect + state are no longer needed so the component drops them, keeping only the pathname-based match. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
186 lines
7.1 KiB
TypeScript
186 lines
7.1 KiB
TypeScript
"use client"
|
|
|
|
// Use the locale-aware Link + usePathname from next-intl. With the
|
|
// plain `next/link` and `next/navigation` imports the hrefs were
|
|
// emitted without a locale (404s) AND the active-page detection
|
|
// failed because `pathname` carried the `/en/` prefix while sidebar
|
|
// items don't, so findIndex always returned -1 → no Previous/Next
|
|
// buttons. See app/[locale]/docs/layout.tsx for the wider context.
|
|
import { Link, usePathname } from "@/i18n/navigation"
|
|
import { ChevronLeft, ChevronRight } from "lucide-react"
|
|
import { useTranslations } from "next-intl"
|
|
import { sidebarItems } from "@/components/DocSidebar"
|
|
|
|
interface DocNavigationProps {
|
|
className?: string
|
|
}
|
|
|
|
interface SubMenuItem {
|
|
title: string
|
|
i18nKey?: string
|
|
href: string
|
|
submenu?: SubMenuItem[]
|
|
}
|
|
|
|
interface FlatPage {
|
|
title: string
|
|
i18nKey?: string
|
|
href: string
|
|
section?: string
|
|
sectionI18nKey?: string
|
|
}
|
|
|
|
// Sidebar entries whose href contains a fragment (`#host`, `#lxc-net`,
|
|
// …) are visual section headers that group submenu items inside an
|
|
// existing physical page (currently only Storage Share Manager uses
|
|
// this pattern — `Host storage integration` and `LXC network sharing`
|
|
// are headers for groups of subpages, but their href is the parent
|
|
// Overview page with an anchor). They aren't standalone docs the
|
|
// reader can advance to, so including them in the Previous/Next walk
|
|
// produces two regressions: on the page they anchor (`#host`) Next
|
|
// circles back to the same URL, and on a regular page that happens to
|
|
// sit next to one of them in the flat list (`lxc-mount-points`) Next
|
|
// jumps to the section header instead of skipping to the next real
|
|
// subpage. Skip them at walk time so both cases collapse to "the next
|
|
// real page in reading order".
|
|
const isAnchorOnlyHref = (href: string) => href.includes("#")
|
|
|
|
function walkSubmenu(
|
|
items: SubMenuItem[],
|
|
section: string,
|
|
sectionI18nKey: string | undefined,
|
|
out: FlatPage[],
|
|
) {
|
|
items.forEach((sub) => {
|
|
if (!isAnchorOnlyHref(sub.href)) {
|
|
out.push({
|
|
title: sub.title,
|
|
i18nKey: sub.i18nKey,
|
|
href: sub.href,
|
|
section,
|
|
sectionI18nKey,
|
|
})
|
|
}
|
|
if (sub.submenu && sub.submenu.length > 0) {
|
|
walkSubmenu(sub.submenu, section, sectionI18nKey, out)
|
|
}
|
|
})
|
|
}
|
|
|
|
export function DocNavigation({ className }: DocNavigationProps) {
|
|
const pathname = usePathname()
|
|
const tNav = useTranslations("docNav")
|
|
const tSidebar = useTranslations("docSidebar")
|
|
|
|
const tItem = (i18nKey: string | undefined, fallback: string) => {
|
|
if (!i18nKey) return fallback
|
|
try {
|
|
return tSidebar(`items.${i18nKey}`)
|
|
} catch {
|
|
return fallback
|
|
}
|
|
}
|
|
|
|
const flattenSidebarItems = (): FlatPage[] => {
|
|
const flatItems: FlatPage[] = []
|
|
|
|
sidebarItems.forEach((item) => {
|
|
if (item.href && !isAnchorOnlyHref(item.href)) {
|
|
flatItems.push({ title: item.title, i18nKey: item.i18nKey, href: item.href })
|
|
}
|
|
|
|
if (item.submenu) {
|
|
walkSubmenu(item.submenu as SubMenuItem[], item.title, item.i18nKey, flatItems)
|
|
}
|
|
})
|
|
|
|
return flatItems
|
|
}
|
|
|
|
// Dedupe consecutive entries with the same href. Several sidebar
|
|
// sections (Post-Install, GPUs, Create VM, Disk Manager, …) have a
|
|
// parent whose href equals its first child's "Overview" href, so the
|
|
// flat sequence contains the same page twice in a row. Without dedup,
|
|
// Previous/Next on the parent would point to itself.
|
|
const rawPages = flattenSidebarItems()
|
|
const allPages: FlatPage[] = []
|
|
for (const p of rawPages) {
|
|
if (allPages.length > 0 && allPages[allPages.length - 1].href === p.href) continue
|
|
allPages.push(p)
|
|
}
|
|
|
|
// Normalize trailing slashes before comparing. Next.js is configured
|
|
// with `trailingSlash: true` (so GitHub Pages serves `/foo/` as
|
|
// `foo/index.html`), which means usePathname() returns
|
|
// `/docs/.../page/` while sidebarItems declares hrefs as
|
|
// `/docs/.../page` (no trailing slash). Without this normalization
|
|
// findIndex always returned -1 → prevPage was null and nextPage was
|
|
// allPages[0] (Introduction) on every page, so the bottom Previous/Next
|
|
// bar showed "Next: Introduction" everywhere regardless of the route.
|
|
const stripTrailingSlash = (s: string) => (s !== "/" ? s.replace(/\/+$/, "") : s)
|
|
const normalizedPathname = stripTrailingSlash(pathname)
|
|
|
|
// Anchored URLs (`/docs/storage-share/#host`) share their pathname
|
|
// with the Overview page, so they collapse to that page's flat-list
|
|
// index — Previous walks back into the previous section as usual and
|
|
// Next advances to the first real subpage (`host-nfs`) instead of
|
|
// looping back to the same anchor.
|
|
const currentPageIndex = allPages.findIndex(
|
|
(page) => stripTrailingSlash(page.href) === normalizedPathname,
|
|
)
|
|
|
|
const prevPage = currentPageIndex > 0 ? allPages[currentPageIndex - 1] : null
|
|
const nextPage = currentPageIndex < allPages.length - 1 ? allPages[currentPageIndex + 1] : null
|
|
|
|
if (!prevPage && !nextPage) return null
|
|
|
|
return (
|
|
<div className={`mt-16 ${className || ""}`}>
|
|
|
|
<div className="w-full h-0.5 bg-gray-300 mb-8"></div>
|
|
|
|
<div className="flex flex-col sm:flex-row justify-between gap-4">
|
|
{prevPage ? (
|
|
<Link
|
|
href={prevPage.href}
|
|
className="flex items-center p-4 border-2 border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all duration-200 group w-full sm:w-[calc(50%-0.5rem)] sm:max-w-[calc(50%-0.5rem)]"
|
|
>
|
|
<ChevronLeft className="h-5 w-5 mr-2 text-gray-500 group-hover:text-blue-500 flex-shrink-0" />
|
|
<div className="min-w-0 overflow-hidden">
|
|
<div className="text-sm text-gray-500 group-hover:text-blue-600 truncate">
|
|
{prevPage.section ? `${tItem(prevPage.sectionI18nKey, prevPage.section)}: ` : ""}
|
|
{tNav("previous")}
|
|
</div>
|
|
<div className="font-medium group-hover:text-blue-700 truncate">
|
|
{tItem(prevPage.i18nKey, prevPage.title)}
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
) : (
|
|
<div className="hidden sm:block sm:w-[calc(50%-0.5rem)]"></div>
|
|
)}
|
|
|
|
{nextPage ? (
|
|
<Link
|
|
href={nextPage.href}
|
|
className="flex items-center justify-end p-4 border-2 border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all duration-200 group sm:text-right w-full sm:w-[calc(50%-0.5rem)] sm:max-w-[calc(50%-0.5rem)] ml-auto"
|
|
>
|
|
<div className="min-w-0 overflow-hidden">
|
|
<div className="text-sm text-gray-500 group-hover:text-blue-600 truncate">
|
|
{nextPage.section ? `${tItem(nextPage.sectionI18nKey, nextPage.section)}: ` : ""}
|
|
{tNav("next")}
|
|
</div>
|
|
<div className="font-medium group-hover:text-blue-700 truncate">
|
|
{tItem(nextPage.i18nKey, nextPage.title)}
|
|
</div>
|
|
</div>
|
|
<ChevronRight className="h-5 w-5 ml-2 text-gray-500 group-hover:text-blue-500 flex-shrink-0" />
|
|
</Link>
|
|
) : (
|
|
<div className="hidden sm:block sm:w-[calc(50%-0.5rem)]"></div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|