mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-06-01 13:04: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:
@@ -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