complete i18n migration to /[locale]/ with EN+ES content

Full rewrite of the docs site under app/[locale]/ with next-intl
in localePrefix:"always" mode. Every page now exists at both
/en/<path> and /es/<path>; the root / shows a meta-refresh + JS
redirect to /<defaultLocale>/ so GitHub Pages serves something
on the apex URL.

Highlights:
- 107 doc pages migrated to file-per-page JSON namespaces under
  messages/en/ and messages/es/. Spanish content is fully
  translated (no copy-of-English placeholders).
- New documentation for the Active Suppressions section in the
  Settings tab and the per-event Dismiss dropdown in the Health
  Monitor modal.
- New screenshots: dismiss-duration-dropdown.png and an updated
  health-suppression-settings.png.
- Pagefind integrated for client-side search; index is built on
  every CI deploy (not committed).
- RSS feeds: per-locale at /<locale>/rss.xml plus root /rss.xml
  for backward compat.
- Removed the dead app/[locale]/guides/[slug]/ route — every
  guide now has its own static page and no markdown source
  remains.
- Fixed orphan link /guides/nvidia -> /guides/nvidia-manual in
  docs/hardware/nvidia-host.
- Removed obsolete components (footer2, calendar, drawer).

Verified locally with `npm ci && npm run build`: 2804 files in
out/, 231 pages indexed by pagefind, root redirect intact, both
locale roots and the new Active Suppressions docs render OK.
This commit is contained in:
MacRimi
2026-05-31 12:41:10 +02:00
parent 875910b4d7
commit 5ca3463bf6
649 changed files with 83958 additions and 11096 deletions
-66
View File
@@ -1,66 +0,0 @@
"use client"
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }
+74
View File
@@ -0,0 +1,74 @@
import React from "react"
import { Info, Lightbulb, CheckCircle2, AlertTriangle, AlertOctagon, Wrench } from "lucide-react"
import { cn } from "@/lib/utils"
type CalloutVariant = "info" | "tip" | "success" | "warning" | "danger" | "troubleshoot"
interface CalloutProps {
variant?: CalloutVariant
title?: string
children: React.ReactNode
className?: string
}
const variantStyles: Record<
CalloutVariant,
{ container: string; icon: string; Icon: React.ComponentType<{ className?: string }> }
> = {
info: {
container: "bg-blue-50 border-blue-300 text-blue-900",
icon: "text-blue-600",
Icon: Info,
},
tip: {
container: "bg-emerald-50 border-emerald-300 text-emerald-900",
icon: "text-emerald-600",
Icon: Lightbulb,
},
success: {
container: "bg-green-50 border-green-300 text-green-900",
icon: "text-green-600",
Icon: CheckCircle2,
},
warning: {
container: "bg-amber-50 border-amber-300 text-amber-900",
icon: "text-amber-600",
Icon: AlertTriangle,
},
danger: {
container: "bg-red-50 border-red-300 text-red-900",
icon: "text-red-600",
Icon: AlertOctagon,
},
troubleshoot: {
container: "bg-slate-50 border-slate-300 text-slate-900",
icon: "text-slate-600",
Icon: Wrench,
},
}
export const Callout: React.FC<CalloutProps> = ({
variant = "info",
title,
children,
className,
}) => {
const { container, icon, Icon } = variantStyles[variant]
return (
<div
role="note"
className={cn(
"my-6 flex gap-3 rounded-lg border-l-4 p-4 [&_p]:my-1 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0",
container,
className,
)}
>
<Icon className={cn("h-5 w-5 flex-shrink-0 mt-0.5", icon)} aria-hidden="true" />
<div className="flex-1 min-w-0">
{title && <p className="font-semibold mb-1">{title}</p>}
<div className="text-sm leading-relaxed">{children}</div>
</div>
</div>
)
}
+85
View File
@@ -0,0 +1,85 @@
"use client"
import React, { useState } from "react"
import { Copy, Check } from "lucide-react"
export interface CommandEntry {
command: string
description: string
}
export interface CommandGroup {
title: string
commands: CommandEntry[]
}
interface CommandTableProps {
groups: CommandGroup[]
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
const copyToClipboard = () => {
navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<button
type="button"
onClick={copyToClipboard}
aria-label={copied ? "Copied" : "Copy to clipboard"}
className="inline-flex items-center gap-1 rounded-md border border-gray-200 bg-white px-2 py-1 text-xs text-gray-600 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-700 transition-colors"
>
{copied ? (
<>
<Check className="h-3.5 w-3.5 text-emerald-600" aria-hidden />
<span>Copied</span>
</>
) : (
<>
<Copy className="h-3.5 w-3.5" aria-hidden />
<span>Copy</span>
</>
)}
</button>
)
}
export const CommandTable: React.FC<CommandTableProps> = ({ groups }) => {
return (
<div className="space-y-8">
{groups.map((group, gi) => (
<section key={gi}>
<h2 className="text-xl font-semibold mt-6 mb-3 text-gray-900">{group.title}</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm border border-gray-200 rounded-md">
<thead className="bg-gray-50 text-gray-900">
<tr>
<th className="text-left px-3 py-2 border-b border-gray-200 w-2/5">Command</th>
<th className="text-left px-3 py-2 border-b border-gray-200">Description</th>
<th className="text-left px-3 py-2 border-b border-gray-200 w-24">Action</th>
</tr>
</thead>
<tbody className="text-gray-800">
{group.commands.map((cmd, ci) => (
<tr key={ci} className="border-b border-gray-100 last:border-b-0">
<td className="px-3 py-2 align-top font-mono text-xs whitespace-pre-wrap break-all">
{cmd.command}
</td>
<td className="px-3 py-2 align-top text-gray-700">{cmd.description}</td>
<td className="px-3 py-2 align-top">
<CopyButton text={cmd.command} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
))}
</div>
)
}
+99
View File
@@ -0,0 +1,99 @@
import React, { Fragment } from "react"
import { ArrowRight, ArrowDown, ArrowLeftRight, ArrowUpDown } from "lucide-react"
import { cn } from "@/lib/utils"
type NodeVariant = "source" | "bridge" | "target"
export interface DataFlowNode {
label: string
detail?: string
variant?: NodeVariant
}
export interface DataFlowDiagramProps {
nodes: DataFlowNode[]
arrowLabel?: string
bidirectional?: boolean
command?: string
caption?: string
className?: string
}
const variantStyles: Record<NodeVariant, string> = {
source: "border-blue-300 bg-blue-50",
bridge: "border-gray-300 bg-gray-50",
target: "border-amber-300 bg-amber-50",
}
const variantLabel: Record<NodeVariant, string> = {
source: "text-blue-800",
bridge: "text-gray-700",
target: "text-amber-800",
}
export const DataFlowDiagram: React.FC<DataFlowDiagramProps> = ({
nodes,
arrowLabel,
bidirectional = false,
command,
caption,
className,
}) => {
const HorizArrow = bidirectional ? ArrowLeftRight : ArrowRight
const VertArrow = bidirectional ? ArrowUpDown : ArrowDown
return (
<div className={cn("my-6 not-prose", className)}>
<div className="flex flex-col md:flex-row md:items-stretch gap-3">
{nodes.map((node, i) => {
const variant = node.variant ?? "bridge"
return (
<Fragment key={i}>
<div
className={cn(
"flex-1 min-w-0 rounded-lg border-2 p-4 flex flex-col",
variantStyles[variant],
)}
>
<div
className={cn(
"text-xs font-semibold uppercase tracking-wide mb-2",
variantLabel[variant],
)}
>
{node.label}
</div>
{node.detail && (
<div className="font-mono text-sm text-gray-800 whitespace-pre-line leading-relaxed">
{node.detail}
</div>
)}
</div>
{i < nodes.length - 1 && (
<div className="flex md:flex-col items-center justify-center text-gray-500 px-2">
<HorizArrow className="hidden md:block h-5 w-5" aria-hidden />
<VertArrow className="md:hidden h-5 w-5" aria-hidden />
{arrowLabel && (
<span className="ml-2 md:ml-0 md:mt-1 text-xs font-semibold tracking-wide">
{arrowLabel}
</span>
)}
</div>
)}
</Fragment>
)
})}
</div>
{command && (
<pre className="mt-4 rounded-md bg-gray-100 p-3 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">
{command}
</pre>
)}
{caption && (
<p className="mt-2 text-xs text-gray-500 text-center">{caption}</p>
)}
</div>
)
}
+47
View File
@@ -0,0 +1,47 @@
import React from "react"
import { Sparkles, Wrench, Zap } from "lucide-react"
import { cn } from "@/lib/utils"
export type Difficulty = "beginner" | "intermediate" | "advanced"
interface DifficultyBadgeProps {
level: Difficulty
className?: string
}
const levelConfig: Record<
Difficulty,
{ label: string; style: string; Icon: React.ComponentType<{ className?: string }> }
> = {
beginner: {
label: "Beginner",
style: "bg-emerald-100 text-emerald-800 border-emerald-200",
Icon: Sparkles,
},
intermediate: {
label: "Intermediate",
style: "bg-amber-100 text-amber-800 border-amber-200",
Icon: Wrench,
},
advanced: {
label: "Advanced",
style: "bg-red-100 text-red-800 border-red-200",
Icon: Zap,
},
}
export const DifficultyBadge: React.FC<DifficultyBadgeProps> = ({ level, className }) => {
const { label, style, Icon } = levelConfig[level]
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium",
style,
className,
)}
>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
{label}
</span>
)
}
+40
View File
@@ -0,0 +1,40 @@
import React from "react"
import { SectionBadge } from "./section-badge"
import { EstimatedTime } from "./estimated-time"
import { ScriptViewer } from "./script-viewer"
import { cn } from "@/lib/utils"
interface DocHeaderProps {
title: string
description?: string
section?: string
estimatedMinutes?: number
scriptPath?: string
className?: string
}
export const DocHeader: React.FC<DocHeaderProps> = ({
title,
description,
section,
estimatedMinutes,
scriptPath,
className,
}) => {
const hasBadges = section || estimatedMinutes || scriptPath
return (
<header className={cn("mb-8", className)}>
<h1 className="text-3xl font-bold text-gray-900 mb-2">{title}</h1>
{hasBadges && (
<div className="flex flex-wrap items-center gap-2 mb-3">
{section && <SectionBadge section={section} />}
{estimatedMinutes !== undefined && <EstimatedTime minutes={estimatedMinutes} />}
{scriptPath && <ScriptViewer scriptPath={scriptPath} />}
</div>
)}
{description && (
<p className="text-gray-700 leading-relaxed m-0">{description}</p>
)}
</header>
)
}
+81 -19
View File
@@ -1,40 +1,96 @@
"use client"
import Link from "next/link"
// Use the locale-aware Link + usePathname from next-intl. With the
// plain `next/link` and `next/navigation` imports the hrefs were
// emitted without a locale (404s) AND the active-page detection
// failed because `pathname` carried the `/en/` prefix while sidebar
// items don't, so findIndex always returned -1 → no Previous/Next
// buttons. See app/[locale]/docs/layout.tsx for the wider context.
import { Link, usePathname } from "@/i18n/navigation"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { usePathname } from "next/navigation"
import { useTranslations } from "next-intl"
import { sidebarItems } from "@/components/DocSidebar"
interface DocNavigationProps {
className?: string
}
interface SubMenuItem {
title: string
i18nKey?: string
href: string
submenu?: SubMenuItem[]
}
interface FlatPage {
title: string
i18nKey?: string
href: string
section?: string
sectionI18nKey?: string
}
function walkSubmenu(
items: SubMenuItem[],
section: string,
sectionI18nKey: string | undefined,
out: FlatPage[],
) {
items.forEach((sub) => {
out.push({
title: sub.title,
i18nKey: sub.i18nKey,
href: sub.href,
section,
sectionI18nKey,
})
if (sub.submenu && sub.submenu.length > 0) {
walkSubmenu(sub.submenu, section, sectionI18nKey, out)
}
})
}
export function DocNavigation({ className }: DocNavigationProps) {
const pathname = usePathname()
const tNav = useTranslations("docNav")
const tSidebar = useTranslations("docSidebar")
const flattenSidebarItems = () => {
const flatItems: Array<{ title: string; href: string; section?: string }> = []
const tItem = (i18nKey: string | undefined, fallback: string) => {
if (!i18nKey) return fallback
try {
return tSidebar(`items.${i18nKey}`)
} catch {
return fallback
}
}
const flattenSidebarItems = (): FlatPage[] => {
const flatItems: FlatPage[] = []
sidebarItems.forEach((item) => {
if (item.href) {
flatItems.push({ title: item.title, href: item.href })
flatItems.push({ title: item.title, i18nKey: item.i18nKey, href: item.href })
}
if (item.submenu) {
item.submenu.forEach((subItem) => {
flatItems.push({
title: subItem.title,
href: subItem.href,
section: item.title,
})
})
walkSubmenu(item.submenu as SubMenuItem[], item.title, item.i18nKey, flatItems)
}
})
return flatItems
}
const allPages = flattenSidebarItems()
// Dedupe consecutive entries with the same href. Several sidebar
// sections (Post-Install, GPUs, Create VM, Disk Manager, …) have a
// parent whose href equals its first child's "Overview" href, so the
// flat sequence contains the same page twice in a row. Without dedup,
// Previous/Next on the parent would point to itself.
const rawPages = flattenSidebarItems()
const allPages: FlatPage[] = []
for (const p of rawPages) {
if (allPages.length > 0 && allPages[allPages.length - 1].href === p.href) continue
allPages.push(p)
}
const currentPageIndex = allPages.findIndex((page) => page.href === pathname)
@@ -57,13 +113,16 @@ export function DocNavigation({ className }: DocNavigationProps) {
<ChevronLeft className="h-5 w-5 mr-2 text-gray-500 group-hover:text-blue-500 flex-shrink-0" />
<div className="min-w-0 overflow-hidden">
<div className="text-sm text-gray-500 group-hover:text-blue-600 truncate">
{prevPage.section ? `${prevPage.section}: ` : ""}Previous
{prevPage.section ? `${tItem(prevPage.sectionI18nKey, prevPage.section)}: ` : ""}
{tNav("previous")}
</div>
<div className="font-medium group-hover:text-blue-700 truncate">
{tItem(prevPage.i18nKey, prevPage.title)}
</div>
<div className="font-medium group-hover:text-blue-700 truncate">{prevPage.title}</div>
</div>
</Link>
) : (
<div className="hidden sm:block sm:w-[calc(50%-0.5rem)]"></div>
<div className="hidden sm:block sm:w-[calc(50%-0.5rem)]"></div>
)}
{nextPage ? (
@@ -73,14 +132,17 @@ export function DocNavigation({ className }: DocNavigationProps) {
>
<div className="min-w-0 overflow-hidden">
<div className="text-sm text-gray-500 group-hover:text-blue-600 truncate">
{nextPage.section ? `${nextPage.section}: ` : ""}Next
{nextPage.section ? `${tItem(nextPage.sectionI18nKey, nextPage.section)}: ` : ""}
{tNav("next")}
</div>
<div className="font-medium group-hover:text-blue-700 truncate">
{tItem(nextPage.i18nKey, nextPage.title)}
</div>
<div className="font-medium group-hover:text-blue-700 truncate">{nextPage.title}</div>
</div>
<ChevronRight className="h-5 w-5 ml-2 text-gray-500 group-hover:text-blue-500 flex-shrink-0" />
</Link>
) : (
<div className="hidden sm:block sm:w-[calc(50%-0.5rem)]"></div>
<div className="hidden sm:block sm:w-[calc(50%-0.5rem)]"></div>
)}
</div>
</div>
-118
View File
@@ -1,118 +0,0 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
+23
View File
@@ -0,0 +1,23 @@
import React from "react"
import { Clock } from "lucide-react"
import { cn } from "@/lib/utils"
interface EstimatedTimeProps {
minutes: number
className?: string
}
export const EstimatedTime: React.FC<EstimatedTimeProps> = ({ minutes, className }) => {
const label = minutes < 60 ? `~${minutes} min` : `~${Math.round(minutes / 60)} h`
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 text-xs font-medium text-gray-700",
className,
)}
>
<Clock className="h-3.5 w-3.5" aria-hidden="true" />
{label}
</span>
)
}
+82
View File
@@ -0,0 +1,82 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { cn } from "@/lib/utils"
interface MermaidProps {
chart: string
className?: string
}
export function Mermaid({ chart, className }: MermaidProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [error, setError] = useState<string | null>(null)
const [rendered, setRendered] = useState(false)
useEffect(() => {
let cancelled = false
async function render() {
try {
const mermaid = (await import("mermaid")).default
mermaid.initialize({
startOnLoad: false,
theme: "default",
securityLevel: "strict",
fontFamily:
'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
flowchart: {
htmlLabels: true,
curve: "basis",
useMaxWidth: true,
},
})
const id = `mmd-${Math.random().toString(36).slice(2, 10)}`
const { svg } = await mermaid.render(id, chart)
if (!cancelled && containerRef.current) {
containerRef.current.innerHTML = svg
setRendered(true)
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to render diagram")
}
}
}
render()
return () => {
cancelled = true
}
}, [chart])
if (error) {
return (
<div
className={cn(
"my-6 rounded-md border border-red-200 bg-red-50 p-4 text-sm text-red-800",
className,
)}
>
<p className="font-medium mb-1">Diagram failed to render</p>
<pre className="text-xs whitespace-pre-wrap">{error}</pre>
</div>
)
}
return (
<div
className={cn(
"my-6 overflow-x-auto rounded-md border border-gray-200 bg-white p-4",
!rendered && "min-h-[120px] flex items-center justify-center text-sm text-gray-500",
className,
)}
>
{!rendered && <span>Loading diagram</span>}
<div ref={containerRef} className="flex justify-center [&_svg]:max-w-full [&_svg]:h-auto" />
</div>
)
}
+52
View File
@@ -0,0 +1,52 @@
import React from "react"
import { CheckCircle2, ListChecks } from "lucide-react"
import { cn } from "@/lib/utils"
interface PrerequisiteItem {
label: React.ReactNode
check?: string
}
interface PrerequisitesProps {
title?: string
items: PrerequisiteItem[]
className?: string
}
export const Prerequisites: React.FC<PrerequisitesProps> = ({
title = "Before you start",
items,
className,
}) => {
return (
<div
className={cn(
"my-6 rounded-lg border border-gray-200 bg-gray-50 p-5",
className,
)}
>
<div className="flex items-center gap-2 mb-3">
<ListChecks className="h-5 w-5 text-gray-700" aria-hidden="true" />
<h4 className="font-semibold text-gray-900 m-0">{title}</h4>
</div>
<ul className="space-y-2 list-none pl-0">
{items.map((item, idx) => (
<li key={idx} className="flex items-start gap-2">
<CheckCircle2
className="h-4 w-4 text-emerald-600 flex-shrink-0 mt-1"
aria-hidden="true"
/>
<div className="flex-1 text-sm text-gray-800">
<div>{item.label}</div>
{item.check && (
<code className="mt-1 inline-block text-xs bg-white border border-gray-200 rounded px-1.5 py-0.5 text-gray-700">
{item.check}
</code>
)}
</div>
</li>
))}
</ul>
</div>
)
}
@@ -0,0 +1,35 @@
import React from "react"
import { Camera } from "lucide-react"
import { cn } from "@/lib/utils"
interface ScreenshotPlaceholderProps {
description: string
filename?: string
className?: string
}
export const ScreenshotPlaceholder: React.FC<ScreenshotPlaceholderProps> = ({
description,
filename,
className,
}) => {
return (
<div
className={cn(
"my-6 flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center",
className,
)}
role="img"
aria-label={`Screenshot placeholder: ${description}`}
>
<Camera className="h-8 w-8 text-gray-400 mb-2" aria-hidden="true" />
<p className="text-sm font-medium text-gray-700 m-0">Screenshot needed</p>
<p className="text-sm text-gray-600 mt-1 max-w-md m-0">{description}</p>
{filename && (
<code className="mt-2 text-xs bg-white border border-gray-200 rounded px-2 py-0.5 text-gray-600">
{filename}
</code>
)}
</div>
)
}
+54
View File
@@ -0,0 +1,54 @@
import { ExternalLink } from "lucide-react"
import { cn } from "@/lib/utils"
interface ScriptViewerProps {
scriptPath: string
githubRepo?: string
githubBranch?: string
className?: string
}
const DEFAULT_REPO = "MacRimi/ProxMenux"
const DEFAULT_BRANCH = "main"
// Top-level repo directories. When a `scriptPath` starts with any of
// these, we treat it as already-relative-to-repo-root and skip the
// implicit `scripts/` prefix. Bash scripts under `scripts/` (the
// majority of doc pages) keep working as before — they don't start
// with one of these prefixes.
const REPO_ROOTS = ["AppImage/", "web/", "menu", "json/", "guides/", "lang/", "images/"]
function buildScriptHref(scriptPath: string, repo: string, branch: string): string {
const isRepoAbsolute = REPO_ROOTS.some((r) => scriptPath.startsWith(r))
const path = isRepoAbsolute ? scriptPath : `scripts/${scriptPath}`
return `https://github.com/${repo}/blob/${branch}/${path}`
}
export function ScriptViewer({
scriptPath,
githubRepo = DEFAULT_REPO,
githubBranch = DEFAULT_BRANCH,
className,
}: ScriptViewerProps) {
const filename = scriptPath.split("/").pop() || scriptPath
const githubUrl = buildScriptHref(scriptPath, githubRepo, githubBranch)
const isRepoAbsolute = REPO_ROOTS.some((r) => scriptPath.startsWith(r))
const titlePath = isRepoAbsolute ? scriptPath : `scripts/${scriptPath}`
return (
<a
href={githubUrl}
target="_blank"
rel="noopener noreferrer"
className={cn(
"inline-flex items-center gap-1.5 rounded-full border border-emerald-300 bg-emerald-50 px-2.5 py-0.5 text-xs font-medium text-emerald-800 transition-colors hover:border-emerald-400 hover:bg-emerald-100",
className,
)}
aria-label={`View ${filename} on GitHub (opens in a new tab)`}
title={`${titlePath} — opens on GitHub in a new tab`}
>
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
View script
</a>
)
}
+22
View File
@@ -0,0 +1,22 @@
import React from "react"
import { FolderOpen } from "lucide-react"
import { cn } from "@/lib/utils"
interface SectionBadgeProps {
section: string
className?: string
}
export const SectionBadge: React.FC<SectionBadgeProps> = ({ section, className }) => {
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border border-blue-200 bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-800",
className,
)}
>
<FolderOpen className="h-3.5 w-3.5" aria-hidden="true" />
{section}
</span>
)
}
+32 -16
View File
@@ -6,9 +6,12 @@ interface StepProps {
}
const Step: React.FC<StepProps> = ({ title, children }) => (
<div className="mb-8">
<h3 className="text-xl font-semibold mb-2 text-gray-900">{title}</h3>
{children}
<div className="mb-10 last:mb-0">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 mb-3">
{/* placeholder — actual badge content injected by Steps wrapper below */}
<h3 className="text-xl font-semibold text-gray-900 m-0">{title}</h3>
</div>
<div className="text-gray-800">{children}</div>
</div>
)
@@ -16,20 +19,33 @@ interface StepsProps {
children: React.ReactNode
}
const Steps: React.FC<StepsProps> & { Step: typeof Step } = ({ children }) => (
<div className="space-y-4">
{React.Children.map(children, (child, index) => (
<div className="flex items-start">
<div className="flex-shrink-0 w-8 h-8 bg-blue-500 text-white rounded-full flex items-center justify-center mr-4 mt-1">
{index + 1}
</div>
<div className="flex-grow">{child}</div>
</div>
))}
</div>
)
const Steps: React.FC<StepsProps> & { Step: typeof Step } = ({ children }) => {
const items = React.Children.toArray(children).filter(React.isValidElement)
return (
<div className="my-6 space-y-0">
{items.map((child, index) => {
// We expect each child to be a <Steps.Step>; inject the Step N badge
// before its title. We rebuild the child so the rendering stays self
// contained inside Steps — callers don't need to pass the number.
const element = child as React.ReactElement<StepProps>
return (
<div key={index} className="mb-10 last:mb-0">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 mb-3">
<span className="inline-flex items-center rounded-full border border-blue-200 bg-blue-50 px-2.5 py-0.5 text-xs font-semibold text-blue-800">
Step {index + 1}
</span>
<h3 className="text-xl font-semibold text-gray-900 m-0">
{element.props.title}
</h3>
</div>
<div className="text-gray-800">{element.props.children}</div>
</div>
)
})}
</div>
)
}
Steps.Step = Step
export { Steps }
+172
View File
@@ -0,0 +1,172 @@
import React from "react"
import { cn } from "@/lib/utils"
interface SwitchModeGraphicProps {
mode: "lxc" | "vm"
title: string
description: string
className?: string
}
const palette = {
lxc: {
active: "#60a5fa", // blue-400
activeText: "text-blue-400",
},
vm: {
active: "#c084fc", // purple-400
activeText: "text-purple-400",
},
} as const
const inactive = "#4b5563" // gray-600
const inactiveText = "text-gray-500"
export const SwitchModeGraphic: React.FC<SwitchModeGraphicProps> = ({
mode,
title,
description,
className,
}) => {
const color = palette[mode].active
const lxcColor = mode === "lxc" ? color : inactive
const vmColor = mode === "vm" ? color : inactive
const lxcLabelClass = mode === "lxc" ? palette.lxc.activeText : inactiveText
const vmLabelClass = mode === "vm" ? palette.vm.activeText : inactiveText
return (
<div
className={cn(
"rounded-xl border border-gray-800 bg-gray-950 p-5 shadow-sm",
className,
)}
>
<p className="text-xs font-semibold tracking-wider text-gray-400 mb-4 uppercase m-0">
Switch Mode
</p>
<div className="flex items-center gap-5">
{/* Diagram */}
<svg
viewBox="0 0 240 150"
xmlns="http://www.w3.org/2000/svg"
className="flex-shrink-0"
style={{ width: "150px", height: "auto" }}
aria-hidden="true"
>
{/* GPU box */}
<g>
<rect
x="4"
y="55"
width="60"
height="40"
rx="4"
fill="none"
stroke={color}
strokeWidth="2.5"
/>
{/* GPU "pins" top/bottom */}
{[14, 22, 30, 38, 46, 54].map((x, i) => (
<React.Fragment key={i}>
<line x1={x} y1="50" x2={x} y2="55" stroke={color} strokeWidth="2" />
<line x1={x} y1="95" x2={x} y2="100" stroke={color} strokeWidth="2" />
</React.Fragment>
))}
<text
x="34"
y="80"
textAnchor="middle"
fill={color}
fontSize="12"
fontWeight="700"
fontFamily="ui-sans-serif, system-ui, sans-serif"
>
GPU
</text>
</g>
{/* Horizontal line GPU → dot */}
<line x1="64" y1="75" x2="114" y2="75" stroke={color} strokeWidth="2.5" />
{/* Central dot */}
<circle cx="118" cy="75" r="9" fill="none" stroke={color} strokeWidth="2.5" />
<circle cx="118" cy="75" r="4" fill={color} />
{/* Branch to LXC (top) */}
<path
d="M 127 75 L 145 75 L 170 45"
fill="none"
stroke={lxcColor}
strokeWidth="2.5"
strokeLinecap="round"
/>
{/* LXC box (stacked rectangles icon) */}
<g>
<rect
x="175"
y="30"
width="45"
height="30"
rx="3"
fill="none"
stroke={lxcColor}
strokeWidth="2.5"
/>
<line x1="181" y1="38" x2="189" y2="38" stroke={lxcColor} strokeWidth="2.5" strokeLinecap="round" />
<circle cx="214" cy="38" r="1.5" fill={lxcColor} />
<line x1="181" y1="46" x2="189" y2="46" stroke={lxcColor} strokeWidth="2.5" strokeLinecap="round" />
<circle cx="214" cy="46" r="1.5" fill={lxcColor} />
<line x1="181" y1="54" x2="189" y2="54" stroke={lxcColor} strokeWidth="2.5" strokeLinecap="round" />
<circle cx="214" cy="54" r="1.5" fill={lxcColor} />
</g>
{/* Branch to VM (bottom) */}
<path
d="M 127 75 L 145 75 L 170 105"
fill="none"
stroke={vmColor}
strokeWidth="2.5"
strokeLinecap="round"
/>
{/* VM box (monitor icon) */}
<g>
<rect
x="175"
y="90"
width="45"
height="28"
rx="3"
fill="none"
stroke={vmColor}
strokeWidth="2.5"
/>
<line
x1="175"
y1="113"
x2="220"
y2="113"
stroke={vmColor}
strokeWidth="2.5"
strokeLinecap="round"
/>
<line x1="189" y1="125" x2="206" y2="125" stroke={vmColor} strokeWidth="2.5" strokeLinecap="round" />
<line x1="197" y1="118" x2="197" y2="125" stroke={vmColor} strokeWidth="2.5" />
</g>
</svg>
{/* Labels column */}
<div className="flex flex-col gap-1 min-w-0">
<div className="flex items-center gap-3">
<span className={cn("text-sm font-semibold", lxcLabelClass)}>LXC</span>
</div>
<div className="flex items-center gap-3">
<span className={cn("text-sm font-semibold", vmLabelClass)}>VM</span>
</div>
<p className={cn("text-base font-bold mt-2 mb-0", palette[mode].activeText)}>{title}</p>
<p className="text-sm text-gray-400 m-0">{description}</p>
</div>
</div>
</div>
)
}
+38
View File
@@ -0,0 +1,38 @@
import React from "react"
import { cn } from "@/lib/utils"
interface YouTubeEmbedProps {
videoId: string
title: string
caption?: string
className?: string
}
export const YouTubeEmbed: React.FC<YouTubeEmbedProps> = ({
videoId,
title,
caption,
className,
}) => {
return (
<div className={cn("my-6 not-prose", className)}>
<div
className="relative w-full overflow-hidden rounded-lg shadow-lg bg-black"
style={{ paddingTop: "56.25%" }}
>
<iframe
className="absolute inset-0 h-full w-full"
src={`https://www.youtube-nocookie.com/embed/${videoId}`}
title={title}
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
</div>
{caption && (
<p className="mt-2 text-xs text-gray-500 text-center">{caption}</p>
)}
</div>
)
}