mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-06-01 04:54:42 +00:00
complete i18n migration to /[locale]/ with EN+ES content
Full rewrite of the docs site under app/[locale]/ with next-intl in localePrefix:"always" mode. Every page now exists at both /en/<path> and /es/<path>; the root / shows a meta-refresh + JS redirect to /<defaultLocale>/ so GitHub Pages serves something on the apex URL. Highlights: - 107 doc pages migrated to file-per-page JSON namespaces under messages/en/ and messages/es/. Spanish content is fully translated (no copy-of-English placeholders). - New documentation for the Active Suppressions section in the Settings tab and the per-event Dismiss dropdown in the Health Monitor modal. - New screenshots: dismiss-duration-dropdown.png and an updated health-suppression-settings.png. - Pagefind integrated for client-side search; index is built on every CI deploy (not committed). - RSS feeds: per-locale at /<locale>/rss.xml plus root /rss.xml for backward compat. - Removed the dead app/[locale]/guides/[slug]/ route — every guide now has its own static page and no markdown source remains. - Fixed orphan link /guides/nvidia -> /guides/nvidia-manual in docs/hardware/nvidia-host. - Removed obsolete components (footer2, calendar, drawer). Verified locally with `npm ci && npm run build`: 2804 files in out/, 231 pages indexed by pagefind, root redirect intact, both locale roots and the new Active Suppressions docs render OK.
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user