fix(ui): выровнять экспорт WireGuard под ReUI Frame
Убрать дубль копирования, вынести CodeExportSheet и семантические токены. Открывать вкладку Peer при экспорте пира. Мигрировать VXLAN/Firewall/Containers/GRE. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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 }
|
||||
Reference in New Issue
Block a user