2025-04-17 17:45:23 +02:00
|
|
|
"use client"
|
|
|
|
|
|
2026-05-31 12:41:10 +02:00
|
|
|
// 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"
|
2025-04-17 17:45:23 +02:00
|
|
|
import { ChevronLeft, ChevronRight } from "lucide-react"
|
2026-05-31 12:41:10 +02:00
|
|
|
import { useTranslations } from "next-intl"
|
2025-04-17 17:45:23 +02:00
|
|
|
import { sidebarItems } from "@/components/DocSidebar"
|
|
|
|
|
|
|
|
|
|
interface DocNavigationProps {
|
|
|
|
|
className?: string
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 12:41:10 +02:00
|
|
|
interface SubMenuItem {
|
|
|
|
|
title: string
|
|
|
|
|
i18nKey?: string
|
|
|
|
|
href: string
|
|
|
|
|
submenu?: SubMenuItem[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface FlatPage {
|
|
|
|
|
title: string
|
|
|
|
|
i18nKey?: string
|
|
|
|
|
href: string
|
|
|
|
|
section?: string
|
|
|
|
|
sectionI18nKey?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function walkSubmenu(
|
|
|
|
|
items: SubMenuItem[],
|
|
|
|
|
section: string,
|
|
|
|
|
sectionI18nKey: string | undefined,
|
|
|
|
|
out: FlatPage[],
|
|
|
|
|
) {
|
|
|
|
|
items.forEach((sub) => {
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-17 17:45:23 +02:00
|
|
|
export function DocNavigation({ className }: DocNavigationProps) {
|
|
|
|
|
const pathname = usePathname()
|
2026-05-31 12:41:10 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-04-17 17:45:23 +02:00
|
|
|
|
2026-05-31 12:41:10 +02:00
|
|
|
const flattenSidebarItems = (): FlatPage[] => {
|
|
|
|
|
const flatItems: FlatPage[] = []
|
2025-04-17 17:45:23 +02:00
|
|
|
|
|
|
|
|
sidebarItems.forEach((item) => {
|
|
|
|
|
if (item.href) {
|
2026-05-31 12:41:10 +02:00
|
|
|
flatItems.push({ title: item.title, i18nKey: item.i18nKey, href: item.href })
|
2025-04-17 17:45:23 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (item.submenu) {
|
2026-05-31 12:41:10 +02:00
|
|
|
walkSubmenu(item.submenu as SubMenuItem[], item.title, item.i18nKey, flatItems)
|
2025-04-17 17:45:23 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return flatItems
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 12:41:10 +02:00
|
|
|
// 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)
|
|
|
|
|
}
|
2025-04-17 17:45:23 +02:00
|
|
|
|
2026-05-31 14:44:52 +02:00
|
|
|
// 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)
|
|
|
|
|
const currentPageIndex = allPages.findIndex(
|
|
|
|
|
(page) => stripTrailingSlash(page.href) === normalizedPathname,
|
|
|
|
|
)
|
2025-04-17 17:45:23 +02:00
|
|
|
|
|
|
|
|
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">
|
2026-05-31 12:41:10 +02:00
|
|
|
{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)}
|
2025-04-17 17:45:23 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Link>
|
|
|
|
|
) : (
|
2026-05-31 12:41:10 +02:00
|
|
|
<div className="hidden sm:block sm:w-[calc(50%-0.5rem)]"></div>
|
2025-04-17 17:45:23 +02:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{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">
|
2026-05-31 12:41:10 +02:00
|
|
|
{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)}
|
2025-04-17 17:45:23 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<ChevronRight className="h-5 w-5 ml-2 text-gray-500 group-hover:text-blue-500 flex-shrink-0" />
|
|
|
|
|
</Link>
|
|
|
|
|
) : (
|
2026-05-31 12:41:10 +02:00
|
|
|
<div className="hidden sm:block sm:w-[calc(50%-0.5rem)]"></div>
|
2025-04-17 17:45:23 +02:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|