fix(ui): выровнять экспорт WireGuard под ReUI Frame

Убрать дубль копирования, вынести CodeExportSheet и семантические токены.
Открывать вкладку Peer при экспорте пира. Мигрировать VXLAN/Firewall/Containers/GRE.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-09-06 00:12:50 +07:00
co-authored by Cursor
parent 15ad53af1f
commit 66509b26bd
11 changed files with 825 additions and 460 deletions
+246
View File
@@ -0,0 +1,246 @@
"use client"
import { useEffect, useMemo, useState, type ReactNode } from "react"
import {
Alert,
AlertAction,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { Frame, FramePanel } from "@/components/reui/frame"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { CheckIcon, CopyIcon, DownloadIcon, InfoIcon, LoaderCircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
export type CodeExportFormat = {
id: string
label: string
filename: string
code: string
/** Empty / unavailable — show warning instead of code */
emptyMessage?: string
}
function downloadText(filename: string, content: string) {
const blob = new Blob([content], { type: "text/plain;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
function highlightLine(line: string): string {
const trimmed = line.trimStart()
if (line.startsWith("#") || trimmed.startsWith(";")) return "text-muted-foreground"
if (trimmed.startsWith("[")) return "text-info"
if (trimmed.startsWith("/")) return "text-info"
if (/^\s+[a-z]/.test(line) || /^[A-Za-z][\w-]*=/.test(line)) return "text-primary"
return "text-foreground"
}
function CodeBlock({ code }: { code: string }) {
const lines = code.length ? code.split("\n") : [""]
return (
<pre className="px-4 py-4 text-[12px] font-mono leading-relaxed whitespace-pre-wrap break-all select-all">
{lines.map((line, i) => (
<span key={i} className={cn("block", highlightLine(line))}>
{line || " "}
</span>
))}
</pre>
)
}
export interface CodeExportSheetProps {
open: boolean
onClose: () => void
title: string
description?: ReactNode
formats: CodeExportFormat[]
/** Initial format id when sheet opens */
initialFormatId?: string
/** Optional live fetch from device */
onLiveFetch?: (formatId: string) => void
liveBusy?: boolean
liveLabel?: string
className?: string
/** Extra content under format tabs (e.g. meta strip) */
beforeCode?: ReactNode
}
/**
* Shared code export Sheet — ReUI Frame + sticky footer.
* Preview DNA: https://reui.io/preview/base/sheet-8 · Frame https://reui.io/docs/components/base/frame
*/
function CodeExportSheet({
open,
onClose,
title,
description,
formats,
initialFormatId,
onLiveFetch,
liveBusy,
liveLabel = "Подтянуть с роутера (с private-key)",
className,
beforeCode,
}: CodeExportSheetProps) {
const firstId = formats[0]?.id ?? "default"
const [tab, setTab] = useState(initialFormatId ?? firstId)
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!open) return
setTab(initialFormatId && formats.some((f) => f.id === initialFormatId)
? initialFormatId
: firstId)
setCopied(false)
}, [open, initialFormatId, firstId, formats])
const active = useMemo(
() => formats.find((f) => f.id === tab) ?? formats[0],
[formats, tab],
)
const code = active?.code ?? ""
const emptyMessage = active?.emptyMessage
const filename = active?.filename ?? "export.txt"
const canCopy = Boolean(code) && !emptyMessage
function handleCopy() {
if (!canCopy) return
void navigator.clipboard.writeText(code).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
const showTabs = formats.length > 1
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent
className={cn(
"flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl",
className,
)}
>
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b pr-12">
<SheetTitle>{title}</SheetTitle>
{description ? (
<SheetDescription>{description}</SheetDescription>
) : null}
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-3 px-6 pt-3">
{showTabs ? (
<Tabs
value={tab}
onValueChange={(v) => setTab(String(v))}
className="shrink-0"
>
<TabsList>
{formats.map((f) => (
<TabsTrigger key={f.id} value={f.id}>
{f.label}
</TabsTrigger>
))}
</TabsList>
{formats.map((f) => (
<TabsContent key={f.id} value={f.id} className="mt-0 hidden" />
))}
</Tabs>
) : null}
{onLiveFetch ? (
<Alert variant="info" className="shrink-0">
<InfoIcon />
<AlertTitle>Live с роутера</AlertTitle>
<AlertDescription>
Подтянуть актуальный конфиг с private-key с устройства.
</AlertDescription>
<AlertAction>
<Button
type="button"
variant="outline"
size="sm"
disabled={liveBusy}
onClick={() => onLiveFetch(tab)}
>
{liveBusy ? (
<LoaderCircleIcon className="size-3.5 animate-spin" />
) : null}
{liveBusy ? "Загрузка…" : liveLabel}
</Button>
</AlertAction>
</Alert>
) : null}
{beforeCode}
<Frame dense className="min-h-0 flex-1 flex flex-col">
<FramePanel className="relative flex min-h-0 flex-1 flex-col overflow-hidden p-0">
{emptyMessage ? (
<div className="p-4">
<Alert variant="warning">
<InfoIcon />
<AlertTitle>Нет данных</AlertTitle>
<AlertDescription>{emptyMessage}</AlertDescription>
</Alert>
</div>
) : (
<ScrollArea className="h-full min-h-[12rem] max-h-[min(60vh,28rem)]">
<CodeBlock code={code} />
</ScrollArea>
)}
</FramePanel>
</Frame>
</div>
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2 sm:justify-stretch">
<SheetClose render={<Button variant="outline" className="flex-1" />}>
Закрыть
</SheetClose>
<Button
type="button"
variant="outline"
className="flex-1"
disabled={!canCopy}
onClick={() => downloadText(filename, code)}
>
<DownloadIcon className="size-4" />
Файл
</Button>
<Button
type="button"
className="flex-1"
disabled={!canCopy}
onClick={handleCopy}
>
{copied ? (
<CheckIcon className="size-4 text-success" />
) : (
<CopyIcon className="size-4" />
)}
{copied ? "Скопировано" : "Копировать"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { CodeExportSheet, downloadText }