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:
MacRimi
2026-05-31 12:41:10 +02:00
parent 875910b4d7
commit 5ca3463bf6
649 changed files with 83958 additions and 11096 deletions
+39
View File
@@ -0,0 +1,39 @@
"use client"
import { useState } from "react"
import { Copy, Check } from "lucide-react"
/**
* Copy-to-clipboard button used by the (server-rendered) CopyableCode
* wrapper. Kept as a tiny client component so the parent can stay on
* the server side and run Shiki's syntax-highlighter at build time
* (no highlighter JS in the client bundle, just the pre-coloured
* HTML).
*/
export function CopyButton({ text }: { text: string }) {
const [isCopied, setIsCopied] = useState(false)
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text)
setIsCopied(true)
setTimeout(() => setIsCopied(false), 2000)
} catch {
// clipboard may be unavailable on insecure origins; swallow.
}
}
return (
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-md transition-colors"
aria-label="Copy code"
>
{isCopied ? (
<Check className="h-4 w-4 text-green-400" />
) : (
<Copy className="h-4 w-4 text-gray-300" />
)}
</button>
)
}
+42 -33
View File
@@ -1,9 +1,6 @@
"use client"
import type React from "react"
import { useState } from "react"
import { Copy, Check } from "lucide-react"
import { codeToHtml } from "shiki"
import { cn } from "@/lib/utils"
import { CopyButton } from "./CopyButton"
interface CopyableCodeProps {
code: string
@@ -11,39 +8,51 @@ interface CopyableCodeProps {
className?: string
}
const CopyableCode: React.FC<CopyableCodeProps> = ({ code, language, className }) => {
const [isCopied, setIsCopied] = useState(false)
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(decodeURIComponent(code))
setIsCopied(true)
setTimeout(() => setIsCopied(false), 2000)
} catch (err) {
console.error("Failed to copy text: ", err)
}
/**
* Server-rendered code block with Shiki syntax highlighting.
*
* Shiki runs at build time (Next.js static export pre-renders every
* page) so the resulting HTML carries pre-coloured `<span>` elements
* and the client doesn't have to load any highlighter JS. The copy
* button is the only interactive bit and lives in CopyButton, a tiny
* client component.
*
* Default theme is `github-dark` — matches the Hermes/Docusaurus look
* the user asked us to emulate. Default language is bash because most
* snippets in the docs are shell commands.
*
* Defensive fallback: if Shiki can't tokenize the requested language
* (unknown alias, unsupported grammar) we fall back to a plain
* dark-background <pre> so the page never crashes.
*/
const CopyableCode = async ({ code, language = "bash", className }: CopyableCodeProps) => {
let html: string
try {
html = await codeToHtml(code, {
lang: language,
theme: "github-dark",
})
} catch {
// Unknown lang or grammar error → render as plain text on a dark
// background to preserve the visual style without colour.
const escaped = code
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
html = `<pre class="shiki" style="background-color:#24292e;color:#e1e4e8"><code>${escaped}</code></pre>`
}
return (
<div className={cn("relative w-full", className)}>
<pre
<div className={cn("relative w-full my-4", className)}>
<div
className={cn(
"bg-gray-100 p-2 rounded-md",
"text-base",
"w-full overflow-x-auto",
"flex items-center",
language ? `language-${language}` : "",
"rounded-md overflow-hidden",
"[&_pre]:p-4 [&_pre]:overflow-x-auto [&_pre]:text-sm [&_pre]:leading-relaxed",
"[&_code]:font-mono",
)}
>
<code className="whitespace-pre flex-1 min-w-0">{decodeURIComponent(code)}</code>
</pre>
<button
onClick={copyToClipboard}
className="absolute top-2 right-2 p-1 bg-white rounded-md shadow-sm hover:bg-gray-100 transition-colors"
aria-label="Copy code"
>
{isCopied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4 text-gray-500" />}
</button>
dangerouslySetInnerHTML={{ __html: html }}
/>
<CopyButton text={code} />
</div>
)
}
+62
View File
@@ -0,0 +1,62 @@
"use client"
import { usePathname } from "@/i18n/navigation"
import { Link } from "@/i18n/navigation"
import { Home, ChevronRight } from "lucide-react"
/**
* Breadcrumb shown above the docs content.
*
* Reads the current pathname (after the locale prefix has been
* stripped by next-intl) and turns each segment into a clickable
* crumb. Segments are humanized — `access-auth` → "Access Auth".
* Intermediate links go to their parent docs section; the last
* segment is the current page and renders as plain text.
*
* Skips itself entirely on the docs root (`/docs`) where the
* breadcrumb would just be "Docs" with nothing meaningful before it.
*/
export function DocBreadcrumb() {
const pathname = usePathname()
const segments = pathname.split("/").filter(Boolean)
// Need at least `docs/<section>` to show something useful.
if (segments.length < 2 || segments[0] !== "docs") return null
const crumbs = segments.map((seg, i) => {
const href = "/" + segments.slice(0, i + 1).join("/")
const label = humanize(seg)
const isLast = i === segments.length - 1
return { href, label, isLast }
})
return (
<nav aria-label="Breadcrumb" className="mb-6 text-sm">
<ol className="flex items-center flex-wrap gap-1 text-gray-500">
<li className="flex items-center">
<Link href="/" className="flex items-center hover:text-gray-900 transition-colors" aria-label="Home">
<Home className="h-4 w-4" />
</Link>
</li>
{crumbs.map((c) => (
<li key={c.href} className="flex items-center gap-1">
<ChevronRight className="h-3.5 w-3.5 text-gray-400" />
{c.isLast ? (
<span className="text-gray-900 font-medium">{c.label}</span>
) : (
<Link href={c.href} className="hover:text-gray-900 transition-colors">
{c.label}
</Link>
)}
</li>
))}
</ol>
</nav>
)
}
function humanize(slug: string): string {
return slug
.replace(/-/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase())
}
+334 -92
View File
@@ -1,113 +1,295 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
// Use the locale-aware Link from next-intl so every sidebar href gets
// the active /en/ or /es/ prefix automatically. With `next/link` the
// hrefs were emitted without a locale (e.g. /docs/create-vm) and 404'd
// because the routing is configured with `localePrefix: "always"`.
import { Link, usePathname } from "@/i18n/navigation"
import { useState, useEffect } from "react"
import { ChevronDown, ChevronRight, Menu, X } from "lucide-react"
import { useTranslations } from "next-intl"
interface SubMenuItem {
title: string
i18nKey?: string // key under docSidebar.items.* in messages — falls back to `title` if absent
href: string
submenu?: SubMenuItem[]
}
interface MenuItem {
title: string
i18nKey?: string // key under docSidebar.items.* in messages — falls back to `title` if absent
href?: string
submenu?: SubMenuItem[]
}
function collectHrefs(items: SubMenuItem[]): string[] {
const out: string[] = []
for (const it of items) {
out.push(it.href)
if (it.submenu) out.push(...collectHrefs(it.submenu))
}
return out
}
export const sidebarItems: MenuItem[] = [
{ title: "Introduction", href: "/docs/introduction" },
{ title: "Installation", href: "/docs/installation" },
{ title: "Introduction", i18nKey: "introduction", href: "/docs/introduction" },
{ title: "Installation", i18nKey: "installation", href: "/docs/installation" },
{
title: "Post-Install Script",
title: "ProxMenux Monitor",
i18nKey: "proxmenuxMonitor",
submenu: [
{ title: "Overview", href: "/docs/post-install" },
{ title: "Basic Settings", href: "/docs/post-install/basic-settings" },
{ title: "System", href: "/docs/post-install/system" },
{ title: "Virtualization", href: "/docs/post-install/virtualization" },
{ title: "Network", href: "/docs/post-install/network" },
{ title: "Storage", href: "/docs/post-install/storage" },
{ title: "Security", href: "/docs/post-install/security" },
{ title: "Customization", href: "/docs/post-install/customization" },
{ title: "Monitoring", href: "/docs/post-install/monitoring" },
{ title: "Performance", href: "/docs/post-install/performance" },
{ title: "Optional", href: "/docs/post-install/optional" },
{ title: "Overview", i18nKey: "monitorOverview", href: "/docs/monitor" },
{ title: "Architecture", i18nKey: "architecture", href: "/docs/monitor/architecture" },
{ title: "Access & Authentication", i18nKey: "accessAuth", href: "/docs/monitor/access-auth" },
{
title: "Dashboard",
i18nKey: "dashboard",
href: "/docs/monitor/dashboard",
submenu: [
{ title: "System Overview tab", i18nKey: "dashboardSystemOverview", href: "/docs/monitor/dashboard/system-overview" },
{ title: "Storage tab", i18nKey: "dashboardStorage", href: "/docs/monitor/dashboard/storage" },
{ title: "Network tab", i18nKey: "dashboardNetwork", href: "/docs/monitor/dashboard/network" },
{ title: "VMs & LXCs tab", i18nKey: "dashboardVmsLxcs", href: "/docs/monitor/dashboard/vms-lxcs" },
{ title: "Hardware tab", i18nKey: "dashboardHardware", href: "/docs/monitor/dashboard/hardware" },
{ title: "System Logs tab", i18nKey: "dashboardSystemLogs", href: "/docs/monitor/dashboard/system-logs" },
{ title: "Terminal tab", i18nKey: "dashboardTerminal", href: "/docs/monitor/dashboard/terminal" },
{ title: "Security tab", i18nKey: "dashboardSecurity", href: "/docs/monitor/dashboard/security" },
{ title: "Settings tab", i18nKey: "dashboardSettings", href: "/docs/monitor/dashboard/settings" },
],
},
{ title: "Health Monitor", i18nKey: "healthMonitor", href: "/docs/monitor/health-monitor" },
{ title: "Notifications", i18nKey: "notifications", href: "/docs/monitor/notifications" },
{ title: "AI Assistant", i18nKey: "aiAssistant", href: "/docs/monitor/ai-assistant" },
{ title: "API Reference", i18nKey: "apiReference", href: "/docs/monitor/api" },
{ title: "Integrations", i18nKey: "integrations", href: "/docs/monitor/integrations" },
],
},
{
title: "Help and Info",
title: "ProxMenux Scripts",
i18nKey: "proxmenuxScripts",
submenu: [
{ title: "Overview", href: "/docs/help-info" },
{ title: "Useful System Commands", href: "/docs/help-info/system-commands" },
{ title: "VM and CT Management", href: "/docs/help-info/vm-ct-commands" },
{ title: "Storage and Disks", href: "/docs/help-info/storage-commands" },
{ title: "Network Commands", href: "/docs/help-info/network-commands" },
{ title: "Updates and Packages", href: "/docs/help-info/update-commands" },
{ title: "GPU Passthrough", href: "/docs/help-info/gpu-commands" },
{ title: "ZFS Management", href: "/docs/help-info/zfs-commands" },
{ title: "Backup and Restore", href: "/docs/help-info/backup-commands" },
{ title: "System CLI Tools", href: "/docs/help-info/tools-commands" },
{
title: "Post-Install Script",
i18nKey: "postInstallScript",
href: "/docs/post-install",
submenu: [
{ title: "Overview", i18nKey: "postInstallOverview", href: "/docs/post-install" },
{ title: "Automated", i18nKey: "postInstallAutomated", href: "/docs/post-install/automated" },
{
title: "Customizable",
i18nKey: "postInstallCustomizable",
href: "/docs/post-install/customizable",
submenu: [
{ title: "Basic Settings", i18nKey: "postInstallBasicSettings", href: "/docs/post-install/basic-settings" },
{ title: "System", i18nKey: "postInstallSystem", href: "/docs/post-install/system" },
{ title: "Virtualization", i18nKey: "postInstallVirtualization", href: "/docs/post-install/virtualization" },
{ title: "Network", i18nKey: "postInstallNetwork", href: "/docs/post-install/network" },
{ title: "Storage", i18nKey: "postInstallStorage", href: "/docs/post-install/storage" },
{ title: "Security", i18nKey: "postInstallSecurity", href: "/docs/post-install/security" },
{ title: "Customization", i18nKey: "postInstallCustomization", href: "/docs/post-install/customization" },
{ title: "Monitoring", i18nKey: "postInstallMonitoring", href: "/docs/post-install/monitoring" },
{ title: "Performance", i18nKey: "postInstallPerformance", href: "/docs/post-install/performance" },
{ title: "Optional", i18nKey: "postInstallOptional", href: "/docs/post-install/optional" },
],
},
{ title: "Apply Available Updates", i18nKey: "postInstallUpdates", href: "/docs/post-install/updates" },
{ title: "Uninstall Optimizations", i18nKey: "postInstallUninstall", href: "/docs/post-install/uninstall" },
],
},
{
title: "GPUs and Coral-TPU",
i18nKey: "gpusCoralTpu",
href: "/docs/hardware/nvidia-host",
submenu: [
{ title: "Install NVIDIA Drivers (Host)", i18nKey: "nvidiaHost", href: "/docs/hardware/nvidia-host" },
{ title: "Install Coral TPU (Host)", i18nKey: "coralHost", href: "/docs/hardware/install-coral-tpu-host" },
{ title: "Add GPU to LXC", i18nKey: "addGpuLxc", href: "/docs/hardware/igpu-acceleration-lxc" },
{ title: "Add Coral TPU to LXC", i18nKey: "addCoralLxc", href: "/docs/hardware/coral-tpu-lxc" },
{ title: "Add GPU to VM (Passthrough)", i18nKey: "addGpuVm", href: "/docs/hardware/gpu-vm-passthrough" },
{ title: "Switch GPU Mode (VM ↔ LXC)", i18nKey: "switchGpuMode", href: "/docs/hardware/switch-gpu-mode" },
],
},
{
title: "Create VM",
i18nKey: "createVm",
href: "/docs/create-vm",
submenu: [
{ title: "Overview", i18nKey: "createVmOverview", href: "/docs/create-vm" },
{ title: "System NAS", i18nKey: "createVmSystemNas", href: "/docs/create-vm/system-nas" },
{ title: "Synology VM", i18nKey: "createVmSynology", href: "/docs/create-vm/system-nas/synology" },
{ title: "Others System NAS", i18nKey: "createVmNasOthers", href: "/docs/create-vm/system-nas/system-nas-others" },
{ title: "System Windows", i18nKey: "createVmSystemWindows", href: "/docs/create-vm/system-windows" },
{ title: "System Linux", i18nKey: "createVmSystemLinux", href: "/docs/create-vm/system-linux" },
],
},
{
title: "Disk Manager",
i18nKey: "diskManager",
href: "/docs/disk-manager",
submenu: [
{ title: "Overview", i18nKey: "diskManagerOverview", href: "/docs/disk-manager" },
{ title: "Import Disk to VM", i18nKey: "diskImportVm", href: "/docs/disk-manager/import-disk-vm" },
{ title: "Import Disk Image to VM", i18nKey: "diskImportImageVm", href: "/docs/disk-manager/import-disk-image-vm" },
{ title: "Add Controller or NVMe to VM", i18nKey: "diskAddController", href: "/docs/disk-manager/add-controller-nvme-vm" },
{ title: "Import Disk to LXC", i18nKey: "diskImportLxc", href: "/docs/disk-manager/import-disk-lxc" },
{ title: "Format / Wipe Physical Disk", i18nKey: "diskFormat", href: "/docs/disk-manager/format-disk" },
{ title: "SMART Disk Health & Test", i18nKey: "diskSmart", href: "/docs/disk-manager/smart-disk-test" },
],
},
{
title: "Storage & Share Manager",
i18nKey: "storageShareManager",
href: "/docs/storage-share",
submenu: [
{ title: "Overview", i18nKey: "storageShareOverview", href: "/docs/storage-share" },
{
title: "Host storage integration",
i18nKey: "hostStorage",
href: "/docs/storage-share#host",
submenu: [
{ title: "Add NFS share as Proxmox storage", i18nKey: "hostNfs", href: "/docs/storage-share/host-nfs" },
{ title: "Add Samba share as Proxmox storage", i18nKey: "hostSamba", href: "/docs/storage-share/host-samba" },
{ title: "Add iSCSI target as Proxmox storage", i18nKey: "hostIscsi", href: "/docs/storage-share/host-iscsi" },
{ title: "Add local disk as Proxmox storage", i18nKey: "hostLocalDisk", href: "/docs/storage-share/host-local-disk" },
{ title: "Add shared directory on Host", i18nKey: "hostLocalShared", href: "/docs/storage-share/host-local-shared" },
],
},
{ title: "LXC Mount Points (Host ↔ CT)", i18nKey: "lxcMountPoints", href: "/docs/storage-share/lxc-mount-points" },
{
title: "LXC network sharing",
i18nKey: "lxcNetworkSharing",
href: "/docs/storage-share#lxc-net",
submenu: [
{ title: "NFS client in LXC", i18nKey: "lxcNfsClient", href: "/docs/storage-share/lxc-nfs-client" },
{ title: "Samba client in LXC", i18nKey: "lxcSambaClient", href: "/docs/storage-share/lxc-samba-client" },
{ title: "NFS server in LXC", i18nKey: "lxcNfsServer", href: "/docs/storage-share/lxc-nfs-server" },
{ title: "Samba server in LXC", i18nKey: "lxcSambaServer", href: "/docs/storage-share/lxc-samba-server" },
],
},
],
},
{
title: "Network",
i18nKey: "network",
href: "/docs/network",
submenu: [
{ title: "Overview", i18nKey: "networkOverview", href: "/docs/network" },
{ title: "Diagnostics", i18nKey: "networkDiagnostics", href: "/docs/network/diagnostics" },
{ title: "Live monitoring tools", i18nKey: "networkMonitoring", href: "/docs/network/monitoring" },
{ title: "Bridge analysis & repair", i18nKey: "networkBridge", href: "/docs/network/bridge-analysis" },
{ title: "Config analysis & cleanup", i18nKey: "networkConfig", href: "/docs/network/config-analysis" },
{ title: "Persistent interface names", i18nKey: "networkPersistent", href: "/docs/network/persistent-names" },
{ title: "Interfaces backup & restart", i18nKey: "networkBackup", href: "/docs/network/backup-restore" },
],
},
{
title: "Security",
i18nKey: "security",
href: "/docs/security",
submenu: [
{ title: "Overview", i18nKey: "securityOverview", href: "/docs/security" },
{ title: "Fail2Ban", i18nKey: "securityFail2ban", href: "/docs/security/fail2ban" },
{ title: "Lynis", i18nKey: "securityLynis", href: "/docs/security/lynis" },
],
},
{
title: "Utilities",
i18nKey: "utilities",
href: "/docs/utils",
submenu: [
{ title: "Overview", i18nKey: "utilsOverview", href: "/docs/utils" },
{ title: "UUP Dump ISO Creator", i18nKey: "utilsUupDump", href: "/docs/utils/UUp-Dump-ISO-Creator" },
{ title: "System Utilities Installer", i18nKey: "utilsSystemUtils", href: "/docs/utils/system-utils" },
{ title: "Proxmox System Update", i18nKey: "utilsSystemUpdate", href: "/docs/utils/system-update" },
{ title: "Upgrade PVE 8 to PVE 9", i18nKey: "utilsUpgradePve", href: "/docs/utils/upgrade-pve8-pve9" },
{ title: "Export VM to OVA / OVF", i18nKey: "utilsExportVm", href: "/docs/utils/export-vm" },
{ title: "Import VM from OVA / OVF", i18nKey: "utilsImportVm", href: "/docs/utils/import-vm" },
],
},
{
title: "Settings ProxMenux",
i18nKey: "settingsProxmenux",
href: "/docs/settings",
submenu: [
{ title: "Overview", i18nKey: "settingsOverview", href: "/docs/settings" },
{ title: "ProxMenux Monitor", i18nKey: "settingsMonitor", href: "/docs/settings/proxmenux-monitor" },
{ title: "Change Release Channel", i18nKey: "settingsBeta", href: "/docs/settings/beta-program" },
// "Change Language" is intentionally hidden until the translation
// install flow is reactivated. The page file at
// /docs/settings/change-language/page.tsx is preserved so we can
// restore this entry in a single line edit once the feature ships.
// { title: "Change Language", i18nKey: "settingsLanguage", href: "/docs/settings/change-language" },
{ title: "Show Version Information", i18nKey: "settingsVersion", href: "/docs/settings/show-version-information" },
{ title: "Uninstall ProxMenux", i18nKey: "settingsUninstall", href: "/docs/settings/uninstall-proxmenux" },
],
},
],
},
{
title: "GPUs and Coral",
title: "Commands Reference",
i18nKey: "commandsReference",
submenu: [
{ title: "HW iGPU acceleration to an LXC", href: "/docs/hardware/igpu-acceleration-lxc" },
{ title: "Coral TPU to an LXC", href: "/docs/hardware/coral-tpu-lxc" },
{ title: "Install Coral TPU on the Host", href: "/docs/hardware/install-coral-tpu-host" },
],
},
{
title: "Create VM",
submenu: [
{ title: "Overview", href: "/docs/create-vm" },
{ title: "System NAS", href: "/docs/create-vm/system-nas" },
{ title: "Synology VM", href: "/docs/create-vm/synology" },
{ title: "Others System NAS", href: "/docs/create-vm/system-nas/system-nas-others" },
{ title: "System Windows", href: "/docs/create-vm/system-windows" },
{ title: "UUP Dump ISO Creator", href: "/docs/utils/UUp-Dump-ISO-Creator" },
{ title: "System Linux", href: "/docs/create-vm/system-linux" },
],
},
{
title: "Storage",
submenu: [
{ title: "Disk Passthrough to a VM", href: "/docs/storage/disk-passthrough-vm" },
{ title: "Disk Passthrough to a CT", href: "/docs/storage/disk-passthrough-ct" },
{ title: "Import Disk Image to a VM", href: "/docs/storage/import-disk-image-vm" },
],
},
{
title: "Network",
submenu: [
{ title: "Verify Network", href: "/docs/network/verify-network" },
{ title: "Show IP Information", href: "/docs/network/show-ip-information" },
],
},
{
title: "Settings ProxMenux",
submenu: [
{ title: "Change Language", href: "/docs/settings/change-language" },
{ title: "Show Version Information", href: "/docs/settings/show-version-information" },
{ title: "Uninstall ProxMenux", href: "/docs/settings/uninstall-proxmenux" },
{ title: "Overview", i18nKey: "commandsOverview", href: "/docs/help-info" },
{ title: "Useful System Commands", i18nKey: "commandsSystem", href: "/docs/help-info/system-commands" },
{ title: "VM and CT Management", i18nKey: "commandsVmCt", href: "/docs/help-info/vm-ct-commands" },
{ title: "Storage and Disks", i18nKey: "commandsStorage", href: "/docs/help-info/storage-commands" },
{ title: "Network Commands", i18nKey: "commandsNetwork", href: "/docs/help-info/network-commands" },
{ title: "Updates and Packages", i18nKey: "commandsUpdates", href: "/docs/help-info/update-commands" },
{ title: "GPU Passthrough", i18nKey: "commandsGpu", href: "/docs/help-info/gpu-commands" },
{ title: "ZFS Management", i18nKey: "commandsZfs", href: "/docs/help-info/zfs-commands" },
{ title: "Backup and Restore", i18nKey: "commandsBackup", href: "/docs/help-info/backup-commands" },
{ title: "System CLI Tools", i18nKey: "commandsTools", href: "/docs/help-info/tools-commands" },
],
},
{ title: "Glossary", i18nKey: "glossary", href: "/docs/glossary" },
{
title: "About",
i18nKey: "about",
submenu: [
{ title: "Code of Conduct", href: "/docs/about/code-of-conduct" },
{ title: "FAQ", href: "/docs/about/faq" },
{ title: "Contributors", href: "/docs/about/contributors" },
{ title: "Overview", i18nKey: "aboutOverview", href: "/docs/about" },
{ title: "FAQ", i18nKey: "aboutFaq", href: "/docs/about/faq" },
{ title: "Contributors", i18nKey: "aboutContributors", href: "/docs/about/contributors" },
{ title: "Contributing", i18nKey: "aboutContributing", href: "/docs/about/contributing" },
{ title: "Code of Conduct", i18nKey: "aboutCodeOfConduct", href: "/docs/about/code-of-conduct" },
],
},
{ title: "External Repositories", href: "/docs/external-repositories" },
{ title: "External Repositories", i18nKey: "externalRepositories", href: "/docs/external-repositories" },
]
export default function DocSidebar() {
const pathname = usePathname()
const [openSections, setOpenSections] = useState<{ [key: string]: boolean }>({})
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const t = useTranslations("docSidebar")
// Resolve the visible label for a sidebar item. Prefer the translated
// entry under `docSidebar.items.<i18nKey>` and fall back to the literal
// `title` for items that haven't been keyed yet (so adding a new entry
// without remembering to add a translation still renders the English
// string instead of throwing).
const tItem = (item: { title: string; i18nKey?: string }) => {
if (!item.i18nKey) return item.title
try {
return t(`items.${item.i18nKey}`)
} catch {
return item.title
}
}
const toggleSection = (title: string) => {
setOpenSections((prev) => ({ ...prev, [title]: !prev[title] }))
@@ -128,35 +310,89 @@ export default function DocSidebar() {
return () => window.removeEventListener("resize", handleResize)
}, [])
const renderSubItem = (subItem: SubMenuItem, depth: number) => {
const hasChildren = !!subItem.submenu && subItem.submenu.length > 0
if (hasChildren) {
const descendantHrefs = collectHrefs(subItem.submenu!)
const containsActivePage =
subItem.href === pathname || descendantHrefs.includes(pathname)
const sectionKey = `${subItem.href}__${subItem.title}`
const isOpen = (openSections[sectionKey] ?? containsActivePage) || false
return (
<li key={subItem.href}>
<div className="flex items-stretch">
<Link
href={subItem.href}
onClick={() => setIsMobileMenuOpen(false)}
className={`flex-1 block p-2 rounded-l ${
pathname === subItem.href
? "bg-blue-500 text-white"
: containsActivePage
? "bg-blue-50 text-blue-900 font-medium"
: "text-gray-700 hover:bg-gray-200 hover:text-gray-900"
}`}
>
{tItem(subItem)}
</Link>
<button
type="button"
aria-label={isOpen ? "Collapse" : "Expand"}
onClick={() => toggleSection(sectionKey)}
className={`px-2 flex items-center rounded-r ${
containsActivePage && pathname !== subItem.href
? "bg-blue-50 text-blue-900 hover:bg-blue-100"
: "text-gray-500 hover:bg-gray-200"
}`}
>
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
</div>
{isOpen && (
<ul className="ml-4 mt-2 space-y-2">
{subItem.submenu!.map((nested) => renderSubItem(nested, depth + 1))}
</ul>
)}
</li>
)
}
return (
<li key={subItem.href}>
<Link
href={subItem.href}
className={`block p-2 rounded ${
pathname === subItem.href
? "bg-blue-500 text-white"
: "text-gray-700 hover:bg-gray-200 hover:text-gray-900"
}`}
onClick={() => setIsMobileMenuOpen(false)}
>
{tItem(subItem)}
</Link>
</li>
)
}
const renderMenuItem = (item: MenuItem) => {
if (item.submenu) {
const isOpen = openSections[item.title] || false
const containsActivePage = collectHrefs(item.submenu).includes(pathname)
const isOpen = (openSections[item.title] ?? containsActivePage) || false
return (
<li key={item.title} className="mb-2">
<button
onClick={() => toggleSection(item.title)}
className="flex items-center justify-between w-full text-left p-2 rounded hover:bg-gray-200"
className={`flex items-center justify-between w-full text-left p-2 rounded transition-colors ${
containsActivePage
? "bg-blue-100 text-blue-900 font-semibold hover:bg-blue-200"
: "hover:bg-gray-200"
}`}
>
<span>{item.title}</span>
<span>{tItem(item)}</span>
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
{isOpen && (
<ul className="ml-4 mt-2 space-y-2">
{item.submenu.map((subItem) => (
<li key={subItem.href}>
<Link
href={subItem.href}
className={`block p-2 rounded ${
pathname === subItem.href
? "bg-blue-500 text-white"
: "text-gray-700 hover:bg-gray-200 hover:text-gray-900"
}`}
onClick={() => setIsMobileMenuOpen(false)}
>
{subItem.title}
</Link>
</li>
))}
{item.submenu.map((subItem) => renderSubItem(subItem, 1))}
</ul>
)}
</li>
@@ -171,7 +407,7 @@ export default function DocSidebar() {
}`}
onClick={() => setIsMobileMenuOpen(false)}
>
{item.title}
{tItem(item)}
</Link>
</li>
)
@@ -186,16 +422,22 @@ export default function DocSidebar() {
onClick={toggleMobileMenu}
aria-label="Toggle menu"
>
<span className="font-semibold">Documentation</span>
<span className="font-semibold">{t("documentation")}</span>
{isMobileMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
</button>
</div>
{/* On desktop (lg+) the sidebar is FIXED to the left so it stays
in place while the main content scrolls — matches the
Docusaurus / Hermes docs UX. The layout adds `lg:pl-72` to
the <main> so the content isn't hidden beneath the sidebar.
On mobile the sidebar is still a slide-down drawer triggered
by the hamburger button above. */}
<nav
className={`fixed lg:static top-[104px] left-0 w-full lg:w-72 h-[calc(100vh-104px)] lg:h-[calc(100vh-64px)] bg-gray-100 p-4 lg:p-6 pt-16 lg:pt-6 transform ${
className={`fixed top-[104px] lg:top-16 left-0 w-full lg:w-72 h-[calc(100vh-104px)] lg:h-[calc(100vh-64px)] bg-gray-100 border-r border-gray-200 p-4 lg:p-6 pt-16 lg:pt-6 transform ${
isMobileMenuOpen ? "translate-y-0" : "-translate-y-full"
} lg:translate-y-0 transition-transform duration-300 ease-in-out overflow-y-auto z-30`}
>
<h2 className="text-lg font-semibold mb-4 text-gray-900 lg:mt-0 sr-only lg:not-sr-only">Documentation</h2>
<h2 className="text-lg font-semibold mb-4 text-gray-900 lg:mt-0 sr-only lg:not-sr-only">{t("documentation")}</h2>
<ul className="space-y-2">{sidebarItems.map(renderMenuItem)}</ul>
</nav>
</>
+131
View File
@@ -0,0 +1,131 @@
"use client"
import { useEffect, useState } from "react"
import { usePathname } from "next/navigation"
import { useTranslations } from "next-intl"
type Heading = { id: string; text: string; level: 2 | 3 }
/**
* Right-rail table of contents for docs pages.
*
* On mount it walks every <h2> and <h3> inside `<main>` (the docs
* container), assigns each one an `id` derived from its text if it
* doesn't already have one, and renders a sticky list of anchors.
*
* Why client-side and not server-rendered? Most docs pages emit
* headings as plain JSX without ids — extracting them at build time
* would require either touching all ~107 pages or a custom MDX
* pipeline. A 15-line useEffect avoids both.
*
* Scroll-spy: an IntersectionObserver highlights the entry whose
* heading is currently in the upper half of the viewport, so the user
* can see where they are as they scroll.
*
* Only renders on xl+ screens (the layout reserves the right gutter
* there); on smaller viewports the ToC stays hidden so the article
* keeps full width.
*/
export function DocTableOfContents() {
const pathname = usePathname()
const t = useTranslations("tocPanel")
const [headings, setHeadings] = useState<Heading[]>([])
const [activeId, setActiveId] = useState<string>("")
// Re-scan whenever the route changes (so navigating between docs
// pages refreshes the ToC).
useEffect(() => {
const main = document.querySelector("main")
if (!main) {
setHeadings([])
return
}
const nodes = Array.from(main.querySelectorAll("h2, h3")) as HTMLHeadingElement[]
const used = new Set<string>()
const collected: Heading[] = nodes
.map((node) => {
const text = (node.textContent || "").trim()
if (!text) return null
// Always dedupe: if a heading carries an explicit id that already
// appeared (e.g. a card <h3>VM</h3> auto-slugged to "vm" before a
// <h2 id="vm"> later in the page), append -2, -3, ... so React
// keys stay unique. The scroll-anchor still works for the first
// occurrence; subsequent ones get their own anchor.
let base = node.id || slugify(text)
let id = base
let n = 1
while (used.has(id)) {
n += 1
id = `${base}-${n}`
}
if (!node.id || node.id !== id) {
node.id = id
}
used.add(id)
return { id, text, level: node.tagName === "H3" ? 3 : 2 } as Heading
})
.filter((h): h is Heading => h !== null)
setHeadings(collected)
}, [pathname])
// Scroll-spy: highlight the heading currently in the upper half of
// the viewport. Using rootMargin pushes the trigger zone toward the
// top so the active entry changes as you scroll, not only when the
// heading is dead-centre.
useEffect(() => {
if (headings.length === 0) return
const observer = new IntersectionObserver(
(entries) => {
const visible = entries.filter((e) => e.isIntersecting)
if (visible.length > 0) {
// First visible entry from the top of the viewport.
visible.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
setActiveId(visible[0].target.id)
}
},
{ rootMargin: "-80px 0px -70% 0px", threshold: 0 },
)
headings.forEach((h) => {
const el = document.getElementById(h.id)
if (el) observer.observe(el)
})
return () => observer.disconnect()
}, [headings])
if (headings.length === 0) return null
return (
<nav
aria-label={t("onThisPage")}
className="text-sm sticky top-20 max-h-[calc(100vh-6rem)] overflow-y-auto pl-4"
>
<p className="font-semibold text-gray-900 mb-3 uppercase tracking-wide text-xs">{t("onThisPage")}</p>
<ul className="space-y-1.5 border-l border-gray-200">
{headings.map((h) => (
<li key={h.id} className={h.level === 3 ? "ml-3" : ""}>
<a
href={`#${h.id}`}
className={`-ml-px block border-l-2 pl-3 py-0.5 transition-colors ${
activeId === h.id
? "border-blue-500 text-blue-600 font-medium"
: "border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300"
}`}
>
{h.text}
</a>
</li>
))}
</ul>
</nav>
)
}
function slugify(text: string): string {
return text
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")
.slice(0, 80)
}
+24 -23
View File
@@ -3,18 +3,19 @@
import Link from "next/link"
import { MessageCircle } from "lucide-react"
import Image from "next/image"
import { useTranslations } from "next-intl"
export default function Footer() {
const t = useTranslations("footer")
return (
<footer className="bg-gray-900 text-white py-12">
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex flex-col md:flex-row justify-between">
{/* Support Section - Left Side */}
<div className="flex flex-col items-start mb-8 md:mb-0">
<h4 className="text-lg font-semibold mb-4">Sponsor</h4>
<p className="text-gray-400 mb-4 max-w-md">
If you would like to support the project.
</p>
<h4 className="text-lg font-semibold mb-4">{t("sponsorHeading")}</h4>
<p className="text-gray-400 mb-4 max-w-md">{t("sponsorBody")}</p>
<a
href="https://ko-fi.com/G2G313ECAN"
target="_blank"
@@ -23,21 +24,21 @@ export default function Footer() {
>
<Image
src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/kofi.png"
alt="Support me on Ko-fi"
alt={t("sponsorAlt")}
width={140}
height={40}
className="w-[140px]"
style={{ height: "auto" }}
loading="lazy"
unoptimized
/>
</a>
</div>
{/* Connect Section - Right Side */}
<div className="flex flex-col items-start md:items-end">
<h4 className="text-lg font-semibold mb-4">Connect</h4>
<p className="text-gray-400 mb-4 max-w-md md:text-right">
Join the community discussions on GitHub to get help, share ideas, and contribute to the project. Every idea is welcome!
</p>
<h4 className="text-lg font-semibold mb-4">{t("connectHeading")}</h4>
<p className="text-gray-400 mb-4 max-w-md md:text-right">{t("connectBody")}</p>
<Link
href="https://github.com/MacRimi/ProxMenux/discussions"
className="flex items-center text-blue-400 hover:text-blue-300 transition-colors duration-200"
@@ -45,27 +46,27 @@ export default function Footer() {
rel="noopener noreferrer"
>
<MessageCircle className="mr-2 h-5 w-5" />
Join the Discussion
{t("joinDiscussion")}
</Link>
</div>
</div>
{/* Copyright - Center */}
<div className="mt-8 pt-8 border-t border-gray-800 text-center text-gray-400">
<p>
ProxMenux, an open-source and collaborative project by{' '}
<a
href="https://macrimi.pro"
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline"
>
MacRimi
</a>.
</p>
</div>
<p>
{t("copyrightPrefix")}{" "}
<a
href="https://macrimi.pro"
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline"
>
MacRimi
</a>
{t("copyrightSuffix")}
</p>
</div>
</div>
</footer>
)
}
-67
View File
@@ -1,67 +0,0 @@
"use client"
import Link from "next/link"
import { MessageCircle } from "lucide-react"
import Image from "next/image"
export default function Footer() {
return (
<footer className="bg-gray-900 text-white py-12">
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex flex-col md:flex-row justify-between">
{/* Support Section - Left Side */}
<div className="flex items-start mb-8 md:mb-0">
<a
href="https://ko-fi.com/G2G313ECAN"
target="_blank"
rel="noopener noreferrer"
className="hover:opacity-90 transition-opacity"
>
<Image
src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/kofi.png"
alt="Support me on Ko-fi"
width={140}
height={40}
className="w-[140px]"
loading="lazy"
/>
</a>
</div>
{/* Connect Section - Right Side */}
<div className="flex flex-col items-start md:items-end">
<h4 className="text-lg font-semibold mb-4">Connect</h4>
<p className="text-gray-400 mb-4 max-w-md md:text-right">
Join the community discussions on GitHub to get help, share ideas, and contribute to the project. Every idea is welcome!
</p>
<Link
href="https://github.com/MacRimi/ProxMenux/discussions"
className="flex items-center text-blue-400 hover:text-blue-300 transition-colors duration-200"
target="_blank"
rel="noopener noreferrer"
>
<MessageCircle className="mr-2 h-5 w-5" />
Join the Discussion
</Link>
</div>
</div>
{/* Copyright - Center */}
<div className="mt-8 pt-8 border-t border-gray-800 text-center text-gray-400">
<p>
ProxMenux, an open-source and collaborative project by{' '}
<a
href="https://macrimi.pro"
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline"
>
MacRimi
</a>.
</p>
</div>
</div>
</footer>
)
}
+84 -24
View File
@@ -1,32 +1,92 @@
"use client"
import { Button } from "@/components/ui/button"
import { ArrowRight } from "lucide-react"
import Link from "next/link"
import { ArrowRight, BookOpen } from "lucide-react"
import { Link } from "@/i18n/navigation"
import { useTranslations } from "next-intl"
import Image from "next/image"
export default function Hero() {
const t = useTranslations("hero")
return (
<section className="py-20 px-4 sm:px-6 lg:px-8 text-center">
<h1 className="text-4xl sm:text-5xl md:text-6xl font-extrabold mb-6">
ProxMenux{" "}
<span className="bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-purple-500">
An Interactive Menu for Proxmox VE Management
</span>
</h1>
<p className="text-base sm:text-lg md:text-xl mb-8 max-w-4xl mx-auto text-gray-300">
ProxMenux is a management tool for Proxmox VE that simplifies system administration
through an interactive menu, allowing you to execute commands and scripts with ease.
</p>
<div className="flex justify-center">
<Button size="lg" className="bg-blue-500 hover:bg-blue-600" asChild>
<Link href="/docs/installation">
Install Now
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</section>
<div className="bg-gradient-to-b from-gray-900 to-gray-800 text-white">
{/* Mobile version (visible only on small screens) */}
<section className="md:hidden py-20 px-4 sm:px-6 text-center">
<h1 className="text-4xl sm:text-5xl font-extrabold mb-6">
{t("title")}{" "}
<span className="block mt-2 bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-purple-500 font-bold">
{t("tagline")}
</span>
</h1>
<p className="text-base sm:text-lg mb-8 max-w-4xl mx-auto text-gray-300">
{t("description")}
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center items-center">
<Button size="lg" className="bg-blue-500 hover:bg-blue-600" asChild>
<Link href="/docs/installation">
{t("installButton")}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
<Button
size="lg"
variant="outline"
className="border-gray-400 text-gray-100 hover:bg-gray-700 hover:text-white bg-transparent"
asChild
>
<Link href="/docs/introduction">
<BookOpen className="mr-2 h-4 w-4" />
{t("whatIsButton")}
</Link>
</Button>
</div>
</section>
{/* Desktop version (visible only on medium and large screens) */}
<section className="hidden md:flex py-20 px-4 sm:px-6 lg:px-8 flex-col justify-center">
<div className="flex items-center justify-center mb-8">
<div className="flex items-center">
<div className="w-40 h-40 lg:w-48 lg:h-48 xl:w-56 xl:h-56 relative">
<Image
src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/logo.png"
alt={t("logoAlt")}
fill
className="object-contain"
sizes="(max-width: 1024px) 10rem, (max-width: 1280px) 12rem, 14rem"
/>
</div>
<div className="w-0.5 h-40 lg:h-48 xl:h-56 bg-white mx-6 self-center"></div>
<div className="text-left max-w-md lg:max-w-lg xl:max-w-xl">
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white leading-tight">{t("title")}</h1>
<p className="text-xl md:text-2xl lg:text-3xl xl:text-4xl mt-2 bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-purple-500 font-bold leading-tight">
{t("tagline")}
</p>
</div>
</div>
</div>
<p className="text-base md:text-lg lg:text-xl mb-8 max-w-2xl lg:max-w-3xl xl:max-w-4xl mx-auto text-gray-300 text-center leading-relaxed">
{t("description")}
</p>
<div className="flex gap-3 justify-center items-center">
<Button size="lg" className="bg-blue-500 hover:bg-blue-600" asChild>
<Link href="/docs/installation">
{t("installButton")}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
<Button
size="lg"
variant="outline"
className="border-gray-400 text-gray-100 hover:bg-gray-700 hover:text-white bg-transparent"
asChild
>
<Link href="/docs/introduction">
<BookOpen className="mr-2 h-4 w-4" />
{t("whatIsButton")}
</Link>
</Button>
</div>
</section>
</div>
)
}
+34 -10
View File
@@ -1,8 +1,8 @@
"use client"
import { Button } from "@/components/ui/button"
import { ArrowRight } from "lucide-react"
import Link from "next/link"
import { ArrowRight, BookOpen } from "lucide-react"
import { Link } from "@/i18n/navigation"
import Image from "next/image"
export default function Hero() {
@@ -13,20 +13,32 @@ export default function Hero() {
<h1 className="text-4xl sm:text-5xl font-extrabold mb-6">
ProxMenux{" "}
<span className="block mt-2 bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-purple-500 font-bold">
An Interactive Menu for Proxmox VE Management
An Interactive Menu and Web Dashboard for Proxmox VE
</span>
</h1>
<p className="text-base sm:text-lg mb-8 max-w-4xl mx-auto text-gray-300">
ProxMenux is a management tool for Proxmox VE that simplifies system administration through an interactive
menu, allowing you to execute commands and scripts with ease.
ProxMenux is an interactive menu and a web dashboard for Proxmox VE run commands,
scripts and guided wizards from a terminal menu, or watch host health, metrics and notifications
from a browser.
</p>
<div className="flex justify-center">
<div className="flex flex-col sm:flex-row gap-3 justify-center items-center">
<Button size="lg" className="bg-blue-500 hover:bg-blue-600" asChild>
<Link href="/docs/installation">
Install Now
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
<Button
size="lg"
variant="outline"
className="border-gray-400 text-gray-100 hover:bg-gray-700 hover:text-white bg-transparent"
asChild
>
<Link href="/docs/introduction">
<BookOpen className="mr-2 h-4 w-4" />
What is ProxMenux?
</Link>
</Button>
</div>
</section>
@@ -47,22 +59,34 @@ export default function Hero() {
<div className="text-left max-w-md lg:max-w-lg xl:max-w-xl">
<h1 className="text-4xl md:text-5xl lg:text-6xl font-extrabold text-white leading-tight">ProxMenux</h1>
<p className="text-xl md:text-2xl lg:text-3xl xl:text-4xl mt-2 bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-purple-500 font-bold leading-tight">
An Interactive Menu for Proxmox VE Management
An Interactive Menu and Web Dashboard for Proxmox VE
</p>
</div>
</div>
</div>
<p className="text-base md:text-lg lg:text-xl mb-8 max-w-2xl lg:max-w-3xl xl:max-w-4xl mx-auto text-gray-300 text-center leading-relaxed">
ProxMenux is a management tool for Proxmox VE that simplifies system administration through an interactive
menu, allowing you to execute commands and scripts with ease.
ProxMenux is an interactive menu and a web dashboard for Proxmox VE run commands,
scripts and guided wizards from a terminal menu, or watch host health, metrics and notifications
from a browser.
</p>
<div className="flex justify-center">
<div className="flex gap-3 justify-center items-center">
<Button size="lg" className="bg-blue-500 hover:bg-blue-600" asChild>
<Link href="/docs/installation">
Install Now
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
<Button
size="lg"
variant="outline"
className="border-gray-400 text-gray-100 hover:bg-gray-700 hover:text-white bg-transparent"
asChild
>
<Link href="/docs/introduction">
<BookOpen className="mr-2 h-4 w-4" />
What is ProxMenux?
</Link>
</Button>
</div>
</section>
</div>
+53
View File
@@ -0,0 +1,53 @@
"use client"
import { useLocale, useTranslations } from "next-intl"
import { useRouter, usePathname } from "@/i18n/navigation"
import { Languages } from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "./ui/dropdown-menu"
import { routing } from "@/i18n/routing"
/**
* Language switcher dropdown for the navbar.
*
* Reads the active locale from next-intl and replaces it in the URL on
* selection. The locale-aware `usePathname` and `useRouter` from
* @/i18n/navigation strip the current `[locale]` prefix when reading
* and re-add the chosen one when navigating, so the user stays on the
* same logical page after switching language.
*/
export function LanguageSwitcher() {
const t = useTranslations("language")
const locale = useLocale()
const router = useRouter()
const pathname = usePathname()
const labels: Record<string, string> = {
en: t("en"),
es: t("es"),
}
return (
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-secondary transition-colors text-sm font-medium" aria-label={t("switcher")}>
<Languages className="h-4 w-4" />
<span className="uppercase">{locale}</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{routing.locales.map((loc) => (
<DropdownMenuItem
key={loc}
onSelect={() => router.replace(pathname, { locale: loc })}
className={loc === locale ? "font-semibold" : ""}
>
{labels[loc] || loc}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
+19
View File
@@ -0,0 +1,19 @@
"use client"
import { useEffect } from "react"
/**
* Updates `<html lang>` to the active locale after hydration. The
* static root layout hard-codes lang="en" because next-intl's dynamic
* locale lives inside [locale]/ where we can't own the <html> tag
* anymore. Without this sync, screen readers and crawlers that respect
* the `lang` attribute would see "en" on every page.
*/
export function LocaleHtmlSync({ locale }: { locale: string }) {
useEffect(() => {
if (typeof document !== "undefined") {
document.documentElement.lang = locale
}
}, [locale])
return null
}
+111 -52
View File
@@ -1,18 +1,35 @@
"use client"
import Link from "next/link"
import NextLink from "next/link"
import { Link } from "@/i18n/navigation"
import Image from "next/image"
import { Book, GitBranch, FileText, Github, Menu, Rss } from "lucide-react"
import { useState } from "react"
import { useLocale, useTranslations } from "next-intl"
import { SearchDialog } from "./search-dialog"
import { LanguageSwitcher } from "./language-switcher"
export default function Navbar() {
const [isMenuOpen, setIsMenuOpen] = useState(false)
const t = useTranslations("nav")
const locale = useLocale()
// English keeps the canonical root /rss.xml; other locales use the
// per-locale feed at /{locale}/rss.xml (mirrors components/rss-link.tsx).
const rssUrl =
locale === "en"
? "https://proxmenux.com/rss.xml"
: `https://proxmenux.com/${locale}/rss.xml`
// Internal hrefs use the locale-aware Link from @/i18n/navigation,
// so the active /[locale]/ segment is added automatically. External
// URLs (GitHub) stay as `next/link` via NextLink to avoid the
// locale prefix. Labels read from messages/<locale>/common.json
// under the `nav.*` namespace.
const navItems = [
{ href: "/docs/introduction", icon: <Book className="h-4 w-4" />, label: "Documentation" },
{ href: "/changelog", icon: <FileText className="h-4 w-4" />, label: "Changelog" },
{ href: "/guides", icon: <GitBranch className="h-4 w-4" />, label: "Guides" },
{ href: "https://github.com/MacRimi/ProxMenux", icon: <Github className="h-4 w-4" />, label: "GitHub" },
{ href: "/docs/introduction", icon: <Book className="h-4 w-4" />, label: t("documentation"), external: false },
{ href: "/changelog", icon: <FileText className="h-4 w-4" />, label: t("changelog"), external: false },
{ href: "/guides", icon: <GitBranch className="h-4 w-4" />, label: t("guides"), external: false },
{ href: "https://github.com/MacRimi/ProxMenux", icon: <Github className="h-4 w-4" />, label: t("github"), external: true },
]
return (
@@ -26,71 +43,113 @@ export default function Navbar() {
width={32}
height={32}
className="w-8 h-8"
unoptimized
/>
<span className="text-xl font-bold">ProxMenux</span>
</Link>
{/* Desktop menu */}
<nav className="hidden md:flex items-center space-x-6 text-sm font-medium">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="flex items-center space-x-2 transition-colors hover:text-primary"
{...(item.label === "GitHub" ? { target: "_blank", rel: "noopener noreferrer" } : {})}
{/* Right side — search (responsive) + desktop nav + mobile menu button */}
<div className="flex items-center gap-3 lg:gap-6">
{/* Search — always visible: icon only on mobile/tablet, full button on lg+ */}
<SearchDialog />
{/* Desktop menu — only on lg+ to avoid overlap with the logo on tablet portrait */}
<nav className="hidden lg:flex items-center space-x-6 text-sm font-medium">
{navItems.map((item) =>
item.external ? (
<NextLink
key={item.href}
href={item.href}
className="flex items-center space-x-2 transition-colors hover:text-primary"
target="_blank"
rel="noopener noreferrer"
>
{item.icon}
<span>{item.label}</span>
</NextLink>
) : (
<Link
key={item.href}
href={item.href}
className="flex items-center space-x-2 transition-colors hover:text-primary"
>
{item.icon}
<span>{item.label}</span>
</Link>
),
)}
{/* RSS Feed Link */}
<NextLink
href={rssUrl}
className="flex items-center space-x-2 transition-colors hover:text-primary text-orange-600 hover:text-orange-700"
target="_blank"
rel="noopener noreferrer"
title={t("rssTitle")}
>
{item.icon}
<span>{item.label}</span>
</Link>
))}
<Rss className="h-4 w-4" />
<span>{t("rss")}</span>
</NextLink>
{/* RSS Feed Link */}
<Link
href="https://proxmenux.com/rss.xml"
className="flex items-center space-x-2 transition-colors hover:text-primary text-orange-600 hover:text-orange-700"
target="_blank"
rel="noopener noreferrer"
title="RSS Feed"
<LanguageSwitcher />
</nav>
{/* Mobile + tablet menu button — visible until lg breakpoint */}
<button
className="lg:hidden p-2"
onClick={() => setIsMenuOpen(!isMenuOpen)}
aria-label={t("menuOpen")}
>
<Rss className="h-4 w-4" />
<span>RSS</span>
</Link>
</nav>
{/* Mobile menu button */}
<button className="md:hidden p-2" onClick={() => setIsMenuOpen(!isMenuOpen)}>
<Menu className="h-6 w-6" />
</button>
<Menu className="h-6 w-6" />
</button>
</div>
</div>
{/* Mobile menu */}
{/* Mobile + tablet menu */}
{isMenuOpen && (
<nav className="md:hidden py-4">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="flex items-center space-x-2 py-2 transition-colors hover:text-primary"
onClick={() => setIsMenuOpen(false)}
{...(item.label === "GitHub" ? { target: "_blank", rel: "noopener noreferrer" } : {})}
>
{item.icon}
<span>{item.label}</span>
</Link>
))}
<nav className="lg:hidden py-4">
{navItems.map((item) =>
item.external ? (
<NextLink
key={item.href}
href={item.href}
className="flex items-center space-x-2 py-2 transition-colors hover:text-primary"
onClick={() => setIsMenuOpen(false)}
target="_blank"
rel="noopener noreferrer"
>
{item.icon}
<span>{item.label}</span>
</NextLink>
) : (
<Link
key={item.href}
href={item.href}
className="flex items-center space-x-2 py-2 transition-colors hover:text-primary"
onClick={() => setIsMenuOpen(false)}
>
{item.icon}
<span>{item.label}</span>
</Link>
),
)}
{/* RSS Feed Link - Mobile */}
<Link
href="https://proxmenux.com/rss.xml"
<NextLink
href={rssUrl}
className="flex items-center space-x-2 py-2 transition-colors hover:text-primary text-orange-600 hover:text-orange-700"
onClick={() => setIsMenuOpen(false)}
target="_blank"
rel="noopener noreferrer"
title="RSS Feed"
title={t("rssTitle")}
>
<Rss className="h-4 w-4" />
<span>RSS</span>
</Link>
<span>{t("rss")}</span>
</NextLink>
<div className="py-2">
<LanguageSwitcher />
</div>
</nav>
)}
</div>
+54
View File
@@ -0,0 +1,54 @@
"use client"
import { useEffect } from "react"
import { usePathname, useSearchParams } from "next/navigation"
declare global {
interface Window {
PagefindHighlight?: new (options: { highlightParam?: string; addStyles?: boolean }) => unknown
}
}
/*
Pagefind term highlighter.
pagefind-highlight.js only attaches the `PagefindHighlight` class to window — it does
NOT auto-run. We instantiate it here on every route change so that:
1. Initial page load: runs after the script loads.
2. SPA navigation from search results (router.push from search-dialog.tsx): re-runs
so highlights apply on the new page even though the page wasn't fully reloaded.
We pass `highlightParam: "pagefind-search"` to match what the search dialog appends.
*/
export function PagefindHighlighter() {
const pathname = usePathname()
const searchParams = useSearchParams()
useEffect(() => {
if (!searchParams?.get("pagefind-search")) return
const run = () => {
if (typeof window.PagefindHighlight !== "function") return false
try {
new window.PagefindHighlight({ highlightParam: "pagefind-search" })
} catch {
// Highlighter constructor throws if mark.js can't find any text nodes — harmless.
}
return true
}
if (run()) return
// Script may not have loaded yet on first paint; poll briefly.
const id = window.setInterval(() => {
if (run()) window.clearInterval(id)
}, 100)
const timeout = window.setTimeout(() => window.clearInterval(id), 5000)
return () => {
window.clearInterval(id)
window.clearTimeout(timeout)
}
}, [pathname, searchParams])
return null
}
+38 -34
View File
@@ -1,51 +1,55 @@
"use client"
import { Book, GitBranch, FileText, Github } from "lucide-react"
import Link from "next/link"
const resources = [
{
icon: <Book className="h-6 w-6" />,
title: "Documentation",
description: "System description and user guides",
link: "/docs/introduction",
},
{
icon: <FileText className="h-6 w-6" />,
title: "Changelog",
description: "Information on the latest updates",
link: "/changelog",
},
{
icon: <GitBranch className="h-6 w-6" />,
title: "Guides",
description: "Step-by-step tutorials and guides for common tasks",
link: "/guides",
},
{
icon: <Github className="h-6 w-6" />,
title: "GitHub Repository",
description: "Explore the source code.",
link: "https://github.com/MacRimi/ProxMenux",
},
]
import { Link } from "@/i18n/navigation"
import NextLink from "next/link"
import { useTranslations } from "next-intl"
export default function Resources() {
const t = useTranslations("resources")
// External link to GitHub stays on next/link to avoid the locale
// prefix; the three internal links use the locale-aware Link so they
// route under /[locale]/ automatically.
const resources = [
{ key: "documentation", icon: <Book className="h-6 w-6" />, link: "/docs/introduction", external: false },
{ key: "changelog", icon: <FileText className="h-6 w-6" />, link: "/changelog", external: false },
{ key: "guides", icon: <GitBranch className="h-6 w-6" />, link: "/guides", external: false },
{ key: "github", icon: <Github className="h-6 w-6" />, link: "https://github.com/MacRimi/ProxMenux", external: true },
] as const
return (
<section className="py-20 bg-gray-900">
<div className="container mx-auto px-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{resources.map((resource, index) => (
<Link key={index} href={resource.link} className="block h-full">
{resources.map((resource) => {
const title = t(`${resource.key}.title`)
const description = t(`${resource.key}.description`)
const inner = (
<div className="bg-gray-800 p-6 rounded-lg shadow-lg hover:bg-gray-700 transition-colors duration-200 h-full flex flex-col justify-between">
<div className="flex items-center mb-4">
{resource.icon}
<h3 className="text-xl font-semibold ml-2">{resource.title}</h3>
<h3 className="text-xl font-semibold ml-2">{title}</h3>
</div>
<p className="text-gray-400 min-h-[48px]">{resource.description}</p>
<p className="text-gray-400 min-h-[48px]">{description}</p>
</div>
</Link>
))}
)
return resource.external ? (
<NextLink
key={resource.key}
href={resource.link}
target="_blank"
rel="noopener noreferrer"
className="block h-full"
>
{inner}
</NextLink>
) : (
<Link key={resource.key} href={resource.link} className="block h-full">
{inner}
</Link>
)
})}
</div>
</div>
</section>
+17 -8
View File
@@ -3,10 +3,20 @@
import { Rss, Copy, Check } from "lucide-react"
import Link from "next/link"
import { useState } from "react"
import { useLocale, useTranslations } from "next-intl"
export default function RSSLink() {
const [copied, setCopied] = useState(false)
const rssUrl = "https://proxmenux.com/rss.xml"
const locale = useLocale()
const t = useTranslations("rssLink")
// English keeps the existing root /rss.xml endpoint for backwards
// compatibility with existing subscribers; other locales use the
// per-locale feed served from app/[locale]/rss.xml/route.ts.
const rssUrl =
locale === "en"
? "https://proxmenux.com/rss.xml"
: `https://proxmenux.com/${locale}/rss.xml`
const copyToClipboard = async () => {
try {
@@ -22,11 +32,10 @@ export default function RSSLink() {
<div className="mb-8 p-4 bg-orange-50 border border-orange-200 rounded-lg">
<div className="space-y-3">
<div>
<h3 className="text-lg font-semibold text-orange-900 mb-1">Stay Updated!</h3>
<p className="text-orange-700 text-sm">Subscribe to our RSS feed to get notified of new changes.</p>
<h3 className="text-lg font-semibold text-orange-900 mb-1">{t("heading")}</h3>
<p className="text-orange-700 text-sm">{t("body")}</p>
</div>
{/* RSS URL and buttons - Responsive layout */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<code className="bg-orange-100 text-orange-800 px-2 py-1 rounded text-xs flex-1 min-w-0 truncate">
@@ -35,10 +44,10 @@ export default function RSSLink() {
<button
onClick={copyToClipboard}
className="flex items-center gap-1 px-2 py-1 bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors text-xs whitespace-nowrap"
title="Copy RSS URL"
title={t("copyTitle")}
>
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
<span className="hidden sm:inline">{copied ? "Copied!" : "Copy"}</span>
<span className="hidden sm:inline">{copied ? t("copied") : t("copy")}</span>
</button>
</div>
@@ -47,10 +56,10 @@ export default function RSSLink() {
className="inline-flex items-center justify-center space-x-2 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors w-full sm:w-auto"
target="_blank"
rel="noopener noreferrer"
title="Open RSS Feed"
title={t("openTitle")}
>
<Rss className="h-4 w-4" />
<span>Open RSS Feed</span>
<span>{t("openFeed")}</span>
</Link>
</div>
</div>
+233
View 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,
)}
</>
)
}
+7 -4
View File
@@ -2,8 +2,10 @@
import { Button } from "@/components/ui/button"
import { Star } from "lucide-react"
import { useTranslations } from "next-intl"
export default function SupportProject() {
const t = useTranslations("supportProject")
const handleClick = () => {
window.open("https://github.com/MacRimi/ProxMenux", "_blank")
}
@@ -11,15 +13,16 @@ export default function SupportProject() {
return (
<section className="py-16 bg-gray-900">
<div className="container mx-auto px-4 text-center">
<h2 className="text-3xl font-bold mb-6">Support the Project!</h2>
<h2 className="text-3xl font-bold mb-6">{t("heading")}</h2>
<p className="text-xl mb-8">
If you find <span className="font-bold">ProxMenux</span> useful, consider giving it a on GitHub to help
others discover it!
{t.rich("body", {
strong: (chunks) => <span className="font-bold">{chunks}</span>,
})}
</p>
<div className="flex justify-center items-center">
<Button className="bg-yellow-400 text-gray-900 hover:bg-yellow-500" onClick={handleClick}>
<Star className="mr-2" />
Star on GitHub
{t("button")}
</Button>
</div>
</div>
-66
View File
@@ -1,66 +0,0 @@
"use client"
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }
+74
View File
@@ -0,0 +1,74 @@
import React from "react"
import { Info, Lightbulb, CheckCircle2, AlertTriangle, AlertOctagon, Wrench } from "lucide-react"
import { cn } from "@/lib/utils"
type CalloutVariant = "info" | "tip" | "success" | "warning" | "danger" | "troubleshoot"
interface CalloutProps {
variant?: CalloutVariant
title?: string
children: React.ReactNode
className?: string
}
const variantStyles: Record<
CalloutVariant,
{ container: string; icon: string; Icon: React.ComponentType<{ className?: string }> }
> = {
info: {
container: "bg-blue-50 border-blue-300 text-blue-900",
icon: "text-blue-600",
Icon: Info,
},
tip: {
container: "bg-emerald-50 border-emerald-300 text-emerald-900",
icon: "text-emerald-600",
Icon: Lightbulb,
},
success: {
container: "bg-green-50 border-green-300 text-green-900",
icon: "text-green-600",
Icon: CheckCircle2,
},
warning: {
container: "bg-amber-50 border-amber-300 text-amber-900",
icon: "text-amber-600",
Icon: AlertTriangle,
},
danger: {
container: "bg-red-50 border-red-300 text-red-900",
icon: "text-red-600",
Icon: AlertOctagon,
},
troubleshoot: {
container: "bg-slate-50 border-slate-300 text-slate-900",
icon: "text-slate-600",
Icon: Wrench,
},
}
export const Callout: React.FC<CalloutProps> = ({
variant = "info",
title,
children,
className,
}) => {
const { container, icon, Icon } = variantStyles[variant]
return (
<div
role="note"
className={cn(
"my-6 flex gap-3 rounded-lg border-l-4 p-4 [&_p]:my-1 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0",
container,
className,
)}
>
<Icon className={cn("h-5 w-5 flex-shrink-0 mt-0.5", icon)} aria-hidden="true" />
<div className="flex-1 min-w-0">
{title && <p className="font-semibold mb-1">{title}</p>}
<div className="text-sm leading-relaxed">{children}</div>
</div>
</div>
)
}
+85
View File
@@ -0,0 +1,85 @@
"use client"
import React, { useState } from "react"
import { Copy, Check } from "lucide-react"
export interface CommandEntry {
command: string
description: string
}
export interface CommandGroup {
title: string
commands: CommandEntry[]
}
interface CommandTableProps {
groups: CommandGroup[]
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
const copyToClipboard = () => {
navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<button
type="button"
onClick={copyToClipboard}
aria-label={copied ? "Copied" : "Copy to clipboard"}
className="inline-flex items-center gap-1 rounded-md border border-gray-200 bg-white px-2 py-1 text-xs text-gray-600 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-700 transition-colors"
>
{copied ? (
<>
<Check className="h-3.5 w-3.5 text-emerald-600" aria-hidden />
<span>Copied</span>
</>
) : (
<>
<Copy className="h-3.5 w-3.5" aria-hidden />
<span>Copy</span>
</>
)}
</button>
)
}
export const CommandTable: React.FC<CommandTableProps> = ({ groups }) => {
return (
<div className="space-y-8">
{groups.map((group, gi) => (
<section key={gi}>
<h2 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{group.title}</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200 w-2/5">Command</th>
<th className="text-left px-3 py-2 border-b border-gray-200">Description</th>
<th className="text-left px-3 py-2 border-b border-gray-200 w-24">Action</th>
</tr>
</thead>
<tbody className="text-gray-800">
{group.commands.map((cmd, ci) => (
<tr key={ci} className="border-b border-gray-100 last:border-b-0">
<td className="px-3 py-2 align-top font-mono text-xs whitespace-pre-wrap break-all">
{cmd.command}
</td>
<td className="px-3 py-2 align-top text-gray-700">{cmd.description}</td>
<td className="px-3 py-2 align-top">
<CopyButton text={cmd.command} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
))}
</div>
)
}
+99
View File
@@ -0,0 +1,99 @@
import React, { Fragment } from "react"
import { ArrowRight, ArrowDown, ArrowLeftRight, ArrowUpDown } from "lucide-react"
import { cn } from "@/lib/utils"
type NodeVariant = "source" | "bridge" | "target"
export interface DataFlowNode {
label: string
detail?: string
variant?: NodeVariant
}
export interface DataFlowDiagramProps {
nodes: DataFlowNode[]
arrowLabel?: string
bidirectional?: boolean
command?: string
caption?: string
className?: string
}
const variantStyles: Record<NodeVariant, string> = {
source: "border-blue-300 bg-blue-50",
bridge: "border-gray-300 bg-gray-50",
target: "border-amber-300 bg-amber-50",
}
const variantLabel: Record<NodeVariant, string> = {
source: "text-blue-800",
bridge: "text-gray-700",
target: "text-amber-800",
}
export const DataFlowDiagram: React.FC<DataFlowDiagramProps> = ({
nodes,
arrowLabel,
bidirectional = false,
command,
caption,
className,
}) => {
const HorizArrow = bidirectional ? ArrowLeftRight : ArrowRight
const VertArrow = bidirectional ? ArrowUpDown : ArrowDown
return (
<div className={cn("my-6 not-prose", className)}>
<div className="flex flex-col md:flex-row md:items-stretch gap-3">
{nodes.map((node, i) => {
const variant = node.variant ?? "bridge"
return (
<Fragment key={i}>
<div
className={cn(
"flex-1 min-w-0 rounded-lg border-2 p-4 flex flex-col",
variantStyles[variant],
)}
>
<div
className={cn(
"text-xs font-semibold uppercase tracking-wide mb-2",
variantLabel[variant],
)}
>
{node.label}
</div>
{node.detail && (
<div className="font-mono text-sm text-gray-800 whitespace-pre-line leading-relaxed">
{node.detail}
</div>
)}
</div>
{i < nodes.length - 1 && (
<div className="flex md:flex-col items-center justify-center text-gray-500 px-2">
<HorizArrow className="hidden md:block h-5 w-5" aria-hidden />
<VertArrow className="md:hidden h-5 w-5" aria-hidden />
{arrowLabel && (
<span className="ml-2 md:ml-0 md:mt-1 text-xs font-semibold tracking-wide">
{arrowLabel}
</span>
)}
</div>
)}
</Fragment>
)
})}
</div>
{command && (
<pre className="mt-4 rounded-md bg-gray-100 p-3 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">
{command}
</pre>
)}
{caption && (
<p className="mt-2 text-xs text-gray-500 text-center">{caption}</p>
)}
</div>
)
}
+47
View File
@@ -0,0 +1,47 @@
import React from "react"
import { Sparkles, Wrench, Zap } from "lucide-react"
import { cn } from "@/lib/utils"
export type Difficulty = "beginner" | "intermediate" | "advanced"
interface DifficultyBadgeProps {
level: Difficulty
className?: string
}
const levelConfig: Record<
Difficulty,
{ label: string; style: string; Icon: React.ComponentType<{ className?: string }> }
> = {
beginner: {
label: "Beginner",
style: "bg-emerald-100 text-emerald-800 border-emerald-200",
Icon: Sparkles,
},
intermediate: {
label: "Intermediate",
style: "bg-amber-100 text-amber-800 border-amber-200",
Icon: Wrench,
},
advanced: {
label: "Advanced",
style: "bg-red-100 text-red-800 border-red-200",
Icon: Zap,
},
}
export const DifficultyBadge: React.FC<DifficultyBadgeProps> = ({ level, className }) => {
const { label, style, Icon } = levelConfig[level]
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium",
style,
className,
)}
>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
{label}
</span>
)
}
+40
View File
@@ -0,0 +1,40 @@
import React from "react"
import { SectionBadge } from "./section-badge"
import { EstimatedTime } from "./estimated-time"
import { ScriptViewer } from "./script-viewer"
import { cn } from "@/lib/utils"
interface DocHeaderProps {
title: string
description?: string
section?: string
estimatedMinutes?: number
scriptPath?: string
className?: string
}
export const DocHeader: React.FC<DocHeaderProps> = ({
title,
description,
section,
estimatedMinutes,
scriptPath,
className,
}) => {
const hasBadges = section || estimatedMinutes || scriptPath
return (
<header className={cn("mb-8", className)}>
<h1 className="text-3xl font-bold text-gray-900 mb-2">{title}</h1>
{hasBadges && (
<div className="flex flex-wrap items-center gap-2 mb-3">
{section && <SectionBadge section={section} />}
{estimatedMinutes !== undefined && <EstimatedTime minutes={estimatedMinutes} />}
{scriptPath && <ScriptViewer scriptPath={scriptPath} />}
</div>
)}
{description && (
<p className="text-gray-700 leading-relaxed m-0">{description}</p>
)}
</header>
)
}
+81 -19
View File
@@ -1,40 +1,96 @@
"use client"
import Link from "next/link"
// 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 { usePathname } from "next/navigation"
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
}
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)
}
})
}
export function DocNavigation({ className }: DocNavigationProps) {
const pathname = usePathname()
const tNav = useTranslations("docNav")
const tSidebar = useTranslations("docSidebar")
const flattenSidebarItems = () => {
const flatItems: Array<{ title: string; href: string; section?: string }> = []
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) {
flatItems.push({ title: item.title, href: item.href })
flatItems.push({ title: item.title, i18nKey: item.i18nKey, href: item.href })
}
if (item.submenu) {
item.submenu.forEach((subItem) => {
flatItems.push({
title: subItem.title,
href: subItem.href,
section: item.title,
})
})
walkSubmenu(item.submenu as SubMenuItem[], item.title, item.i18nKey, flatItems)
}
})
return flatItems
}
const allPages = flattenSidebarItems()
// 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)
}
const currentPageIndex = allPages.findIndex((page) => page.href === pathname)
@@ -57,13 +113,16 @@ export function DocNavigation({ className }: DocNavigationProps) {
<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 ? `${prevPage.section}: ` : ""}Previous
{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 className="font-medium group-hover:text-blue-700 truncate">{prevPage.title}</div>
</div>
</Link>
) : (
<div className="hidden sm:block sm:w-[calc(50%-0.5rem)]"></div>
<div className="hidden sm:block sm:w-[calc(50%-0.5rem)]"></div>
)}
{nextPage ? (
@@ -73,14 +132,17 @@ export function DocNavigation({ className }: DocNavigationProps) {
>
<div className="min-w-0 overflow-hidden">
<div className="text-sm text-gray-500 group-hover:text-blue-600 truncate">
{nextPage.section ? `${nextPage.section}: ` : ""}Next
{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 className="font-medium group-hover:text-blue-700 truncate">{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 className="hidden sm:block sm:w-[calc(50%-0.5rem)]"></div>
)}
</div>
</div>
-118
View File
@@ -1,118 +0,0 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
+23
View File
@@ -0,0 +1,23 @@
import React from "react"
import { Clock } from "lucide-react"
import { cn } from "@/lib/utils"
interface EstimatedTimeProps {
minutes: number
className?: string
}
export const EstimatedTime: React.FC<EstimatedTimeProps> = ({ minutes, className }) => {
const label = minutes < 60 ? `~${minutes} min` : `~${Math.round(minutes / 60)} h`
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 text-xs font-medium text-gray-700",
className,
)}
>
<Clock className="h-3.5 w-3.5" aria-hidden="true" />
{label}
</span>
)
}
+82
View File
@@ -0,0 +1,82 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { cn } from "@/lib/utils"
interface MermaidProps {
chart: string
className?: string
}
export function Mermaid({ chart, className }: MermaidProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [error, setError] = useState<string | null>(null)
const [rendered, setRendered] = useState(false)
useEffect(() => {
let cancelled = false
async function render() {
try {
const mermaid = (await import("mermaid")).default
mermaid.initialize({
startOnLoad: false,
theme: "default",
securityLevel: "strict",
fontFamily:
'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
flowchart: {
htmlLabels: true,
curve: "basis",
useMaxWidth: true,
},
})
const id = `mmd-${Math.random().toString(36).slice(2, 10)}`
const { svg } = await mermaid.render(id, chart)
if (!cancelled && containerRef.current) {
containerRef.current.innerHTML = svg
setRendered(true)
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to render diagram")
}
}
}
render()
return () => {
cancelled = true
}
}, [chart])
if (error) {
return (
<div
className={cn(
"my-6 rounded-md border border-red-200 bg-red-50 p-4 text-sm text-red-800",
className,
)}
>
<p className="font-medium mb-1">Diagram failed to render</p>
<pre className="text-xs whitespace-pre-wrap">{error}</pre>
</div>
)
}
return (
<div
className={cn(
"my-6 overflow-x-auto rounded-md border border-gray-200 bg-white p-4",
!rendered && "min-h-[120px] flex items-center justify-center text-sm text-gray-500",
className,
)}
>
{!rendered && <span>Loading diagram</span>}
<div ref={containerRef} className="flex justify-center [&_svg]:max-w-full [&_svg]:h-auto" />
</div>
)
}
+52
View File
@@ -0,0 +1,52 @@
import React from "react"
import { CheckCircle2, ListChecks } from "lucide-react"
import { cn } from "@/lib/utils"
interface PrerequisiteItem {
label: React.ReactNode
check?: string
}
interface PrerequisitesProps {
title?: string
items: PrerequisiteItem[]
className?: string
}
export const Prerequisites: React.FC<PrerequisitesProps> = ({
title = "Before you start",
items,
className,
}) => {
return (
<div
className={cn(
"my-6 rounded-lg border border-gray-200 bg-gray-50 p-5",
className,
)}
>
<div className="flex items-center gap-2 mb-3">
<ListChecks className="h-5 w-5 text-gray-700" aria-hidden="true" />
<h4 className="font-semibold text-gray-900 m-0">{title}</h4>
</div>
<ul className="space-y-2 list-none pl-0">
{items.map((item, idx) => (
<li key={idx} className="flex items-start gap-2">
<CheckCircle2
className="h-4 w-4 text-emerald-600 flex-shrink-0 mt-1"
aria-hidden="true"
/>
<div className="flex-1 text-sm text-gray-800">
<div>{item.label}</div>
{item.check && (
<code className="mt-1 inline-block text-xs bg-white border border-gray-200 rounded px-1.5 py-0.5 text-gray-700">
{item.check}
</code>
)}
</div>
</li>
))}
</ul>
</div>
)
}
@@ -0,0 +1,35 @@
import React from "react"
import { Camera } from "lucide-react"
import { cn } from "@/lib/utils"
interface ScreenshotPlaceholderProps {
description: string
filename?: string
className?: string
}
export const ScreenshotPlaceholder: React.FC<ScreenshotPlaceholderProps> = ({
description,
filename,
className,
}) => {
return (
<div
className={cn(
"my-6 flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center",
className,
)}
role="img"
aria-label={`Screenshot placeholder: ${description}`}
>
<Camera className="h-8 w-8 text-gray-400 mb-2" aria-hidden="true" />
<p className="text-sm font-medium text-gray-700 m-0">Screenshot needed</p>
<p className="text-sm text-gray-600 mt-1 max-w-md m-0">{description}</p>
{filename && (
<code className="mt-2 text-xs bg-white border border-gray-200 rounded px-2 py-0.5 text-gray-600">
{filename}
</code>
)}
</div>
)
}
+54
View File
@@ -0,0 +1,54 @@
import { ExternalLink } from "lucide-react"
import { cn } from "@/lib/utils"
interface ScriptViewerProps {
scriptPath: string
githubRepo?: string
githubBranch?: string
className?: string
}
const DEFAULT_REPO = "MacRimi/ProxMenux"
const DEFAULT_BRANCH = "main"
// Top-level repo directories. When a `scriptPath` starts with any of
// these, we treat it as already-relative-to-repo-root and skip the
// implicit `scripts/` prefix. Bash scripts under `scripts/` (the
// majority of doc pages) keep working as before — they don't start
// with one of these prefixes.
const REPO_ROOTS = ["AppImage/", "web/", "menu", "json/", "guides/", "lang/", "images/"]
function buildScriptHref(scriptPath: string, repo: string, branch: string): string {
const isRepoAbsolute = REPO_ROOTS.some((r) => scriptPath.startsWith(r))
const path = isRepoAbsolute ? scriptPath : `scripts/${scriptPath}`
return `https://github.com/${repo}/blob/${branch}/${path}`
}
export function ScriptViewer({
scriptPath,
githubRepo = DEFAULT_REPO,
githubBranch = DEFAULT_BRANCH,
className,
}: ScriptViewerProps) {
const filename = scriptPath.split("/").pop() || scriptPath
const githubUrl = buildScriptHref(scriptPath, githubRepo, githubBranch)
const isRepoAbsolute = REPO_ROOTS.some((r) => scriptPath.startsWith(r))
const titlePath = isRepoAbsolute ? scriptPath : `scripts/${scriptPath}`
return (
<a
href={githubUrl}
target="_blank"
rel="noopener noreferrer"
className={cn(
"inline-flex items-center gap-1.5 rounded-full border border-emerald-300 bg-emerald-50 px-2.5 py-0.5 text-xs font-medium text-emerald-800 transition-colors hover:border-emerald-400 hover:bg-emerald-100",
className,
)}
aria-label={`View ${filename} on GitHub (opens in a new tab)`}
title={`${titlePath} — opens on GitHub in a new tab`}
>
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
View script
</a>
)
}
+22
View File
@@ -0,0 +1,22 @@
import React from "react"
import { FolderOpen } from "lucide-react"
import { cn } from "@/lib/utils"
interface SectionBadgeProps {
section: string
className?: string
}
export const SectionBadge: React.FC<SectionBadgeProps> = ({ section, className }) => {
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border border-blue-200 bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-800",
className,
)}
>
<FolderOpen className="h-3.5 w-3.5" aria-hidden="true" />
{section}
</span>
)
}
+32 -16
View File
@@ -6,9 +6,12 @@ interface StepProps {
}
const Step: React.FC<StepProps> = ({ title, children }) => (
<div className="mb-8">
<h3 className="text-xl font-semibold mb-2 text-gray-900">{title}</h3>
{children}
<div className="mb-10 last:mb-0">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 mb-3">
{/* placeholder — actual badge content injected by Steps wrapper below */}
<h3 className="text-xl font-semibold text-gray-900 m-0">{title}</h3>
</div>
<div className="text-gray-800">{children}</div>
</div>
)
@@ -16,20 +19,33 @@ interface StepsProps {
children: React.ReactNode
}
const Steps: React.FC<StepsProps> & { Step: typeof Step } = ({ children }) => (
<div className="space-y-4">
{React.Children.map(children, (child, index) => (
<div className="flex items-start">
<div className="flex-shrink-0 w-8 h-8 bg-blue-500 text-white rounded-full flex items-center justify-center mr-4 mt-1">
{index + 1}
</div>
<div className="flex-grow">{child}</div>
</div>
))}
</div>
)
const Steps: React.FC<StepsProps> & { Step: typeof Step } = ({ children }) => {
const items = React.Children.toArray(children).filter(React.isValidElement)
return (
<div className="my-6 space-y-0">
{items.map((child, index) => {
// We expect each child to be a <Steps.Step>; inject the Step N badge
// before its title. We rebuild the child so the rendering stays self
// contained inside Steps — callers don't need to pass the number.
const element = child as React.ReactElement<StepProps>
return (
<div key={index} className="mb-10 last:mb-0">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 mb-3">
<span className="inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-2.5 py-0.5 text-xs font-semibold text-blue-800">
Step {index + 1}
</span>
<h3 className="text-xl font-semibold text-gray-900 m-0">
{element.props.title}
</h3>
</div>
<div className="text-gray-800">{element.props.children}</div>
</div>
)
})}
</div>
)
}
Steps.Step = Step
export { Steps }
+172
View File
@@ -0,0 +1,172 @@
import React from "react"
import { cn } from "@/lib/utils"
interface SwitchModeGraphicProps {
mode: "lxc" | "vm"
title: string
description: string
className?: string
}
const palette = {
lxc: {
active: "#60a5fa", // blue-400
activeText: "text-blue-400",
},
vm: {
active: "#c084fc", // purple-400
activeText: "text-purple-400",
},
} as const
const inactive = "#4b5563" // gray-600
const inactiveText = "text-gray-500"
export const SwitchModeGraphic: React.FC<SwitchModeGraphicProps> = ({
mode,
title,
description,
className,
}) => {
const color = palette[mode].active
const lxcColor = mode === "lxc" ? color : inactive
const vmColor = mode === "vm" ? color : inactive
const lxcLabelClass = mode === "lxc" ? palette.lxc.activeText : inactiveText
const vmLabelClass = mode === "vm" ? palette.vm.activeText : inactiveText
return (
<div
className={cn(
"rounded-xl border border-gray-800 bg-gray-950 p-5 shadow-sm",
className,
)}
>
<p className="text-xs font-semibold tracking-wider text-gray-400 mb-4 uppercase m-0">
Switch Mode
</p>
<div className="flex items-center gap-5">
{/* Diagram */}
<svg
viewBox="0 0 240 150"
xmlns="http://www.w3.org/2000/svg"
className="flex-shrink-0"
style={{ width: "150px", height: "auto" }}
aria-hidden="true"
>
{/* GPU box */}
<g>
<rect
x="4"
y="55"
width="60"
height="40"
rx="4"
fill="none"
stroke={color}
strokeWidth="2.5"
/>
{/* GPU "pins" top/bottom */}
{[14, 22, 30, 38, 46, 54].map((x, i) => (
<React.Fragment key={i}>
<line x1={x} y1="50" x2={x} y2="55" stroke={color} strokeWidth="2" />
<line x1={x} y1="95" x2={x} y2="100" stroke={color} strokeWidth="2" />
</React.Fragment>
))}
<text
x="34"
y="80"
textAnchor="middle"
fill={color}
fontSize="12"
fontWeight="700"
fontFamily="ui-sans-serif, system-ui, sans-serif"
>
GPU
</text>
</g>
{/* Horizontal line GPU → dot */}
<line x1="64" y1="75" x2="114" y2="75" stroke={color} strokeWidth="2.5" />
{/* Central dot */}
<circle cx="118" cy="75" r="9" fill="none" stroke={color} strokeWidth="2.5" />
<circle cx="118" cy="75" r="4" fill={color} />
{/* Branch to LXC (top) */}
<path
d="M 127 75 L 145 75 L 170 45"
fill="none"
stroke={lxcColor}
strokeWidth="2.5"
strokeLinecap="round"
/>
{/* LXC box (stacked rectangles icon) */}
<g>
<rect
x="175"
y="30"
width="45"
height="30"
rx="3"
fill="none"
stroke={lxcColor}
strokeWidth="2.5"
/>
<line x1="181" y1="38" x2="189" y2="38" stroke={lxcColor} strokeWidth="2.5" strokeLinecap="round" />
<circle cx="214" cy="38" r="1.5" fill={lxcColor} />
<line x1="181" y1="46" x2="189" y2="46" stroke={lxcColor} strokeWidth="2.5" strokeLinecap="round" />
<circle cx="214" cy="46" r="1.5" fill={lxcColor} />
<line x1="181" y1="54" x2="189" y2="54" stroke={lxcColor} strokeWidth="2.5" strokeLinecap="round" />
<circle cx="214" cy="54" r="1.5" fill={lxcColor} />
</g>
{/* Branch to VM (bottom) */}
<path
d="M 127 75 L 145 75 L 170 105"
fill="none"
stroke={vmColor}
strokeWidth="2.5"
strokeLinecap="round"
/>
{/* VM box (monitor icon) */}
<g>
<rect
x="175"
y="90"
width="45"
height="28"
rx="3"
fill="none"
stroke={vmColor}
strokeWidth="2.5"
/>
<line
x1="175"
y1="113"
x2="220"
y2="113"
stroke={vmColor}
strokeWidth="2.5"
strokeLinecap="round"
/>
<line x1="189" y1="125" x2="206" y2="125" stroke={vmColor} strokeWidth="2.5" strokeLinecap="round" />
<line x1="197" y1="118" x2="197" y2="125" stroke={vmColor} strokeWidth="2.5" />
</g>
</svg>
{/* Labels column */}
<div className="flex flex-col gap-1 min-w-0">
<div className="flex items-center gap-3">
<span className={cn("text-sm font-semibold", lxcLabelClass)}>LXC</span>
</div>
<div className="flex items-center gap-3">
<span className={cn("text-sm font-semibold", vmLabelClass)}>VM</span>
</div>
<p className={cn("text-base font-bold mt-2 mb-0", palette[mode].activeText)}>{title}</p>
<p className="text-sm text-gray-400 m-0">{description}</p>
</div>
</div>
</div>
)
}
+38
View File
@@ -0,0 +1,38 @@
import React from "react"
import { cn } from "@/lib/utils"
interface YouTubeEmbedProps {
videoId: string
title: string
caption?: string
className?: string
}
export const YouTubeEmbed: React.FC<YouTubeEmbedProps> = ({
videoId,
title,
caption,
className,
}) => {
return (
<div className={cn("my-6 not-prose", className)}>
<div
className="relative w-full overflow-hidden rounded-lg shadow-lg bg-black"
style={{ paddingTop: "56.25%" }}
>
<iframe
className="absolute inset-0 h-full w-full"
src={`https://www.youtube-nocookie.com/embed/${videoId}`}
title={title}
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
</div>
{caption && (
<p className="mt-2 text-xs text-gray-500 text-center">{caption}</p>
)}
</div>
)
}