Files
MikrotikManager/components/reui-kit/code-export-sheet.tsx
T
Denozordec b680f882cc
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-image (push) Successful in 1m40s
Docker images / frontend-image (push) Successful in 2m47s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 38s
Docker images / publish-release (push) Successful in 8s
feat(ui): enhance CodeExportSheet and WgExportSheet components
Updated CodeExportSheet to improve UI elements, including the addition of a Badge component for live status indication and refined layout adjustments. Modified WgExportSheet to simplify format labels and incorporate live content fetching capabilities. Adjusted styles for better visual consistency and user experience.
2026-09-06 00:23:51 +07:00

285 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useEffect, useMemo, useState, type ReactNode } from "react"
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/reui/alert"
import { Badge } from "@/components/reui/badge"
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, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
CheckIcon,
CopyIcon,
DownloadIcon,
LoaderCircleIcon,
RefreshCwIcon,
TriangleAlertIcon,
} 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
/** Content came from live device fetch */
isLive?: boolean
}
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-zA-Z]/.test(line) || /^[A-Za-z][\w-]*=/.test(line)) return "text-foreground"
return "text-foreground/90"
}
function CodeBlock({ code }: { code: string }) {
const lines = code.length ? code.split("\n") : [""]
return (
<pre className="px-4 py-3.5 text-[12px] font-mono leading-[1.65] 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
/** Short CTA — keep laconic */
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
* Alert: https://reui.io/docs/components/base/alert
*/
function CodeExportSheet({
open,
onClose,
title,
description,
formats,
initialFormatId,
onLiveFetch,
liveBusy,
liveLabel = "С роутера",
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
const isLive = Boolean(active?.isLive)
const showTabs = formats.length > 1
function handleCopy() {
if (!canCopy) return
void navigator.clipboard.writeText(code).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1800)
})
}
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent
className={cn(
"flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-xl",
className,
)}
>
<SheetHeader className="shrink-0 gap-1 border-b px-5 pt-5 pb-4 pr-12">
<SheetTitle className="text-base font-semibold tracking-tight">
{title}
</SheetTitle>
{description ? (
<SheetDescription className="font-mono text-xs">
{description}
</SheetDescription>
) : null}
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-3 px-5 py-4">
{showTabs ? (
<Tabs
value={tab}
onValueChange={(v) => setTab(String(v))}
className="shrink-0 gap-0"
>
<TabsList className="h-9 w-full">
{formats.map((f) => (
<TabsTrigger key={f.id} value={f.id} className="flex-1 px-2 text-xs sm:text-sm">
{f.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
{onLiveFetch || beforeCode ? (
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{onLiveFetch ? (
<Badge
variant={isLive ? "success-light" : "secondary"}
size="sm"
radius="full"
>
{isLive ? "С роутера" : "Локально"}
</Badge>
) : null}
{beforeCode}
</div>
{onLiveFetch ? (
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
disabled={liveBusy}
onClick={() => onLiveFetch(tab)}
aria-label="Подтянуть конфиг с роутера с private-key"
>
{liveBusy ? (
<LoaderCircleIcon className="size-3.5 animate-spin" />
) : (
<RefreshCwIcon className="size-3.5" />
)}
{liveBusy ? "Загрузка…" : liveLabel}
</Button>
) : null}
</div>
) : null}
<Frame dense className="flex min-h-0 flex-1 flex-col">
<FramePanel className="relative flex min-h-0 flex-1 flex-col overflow-hidden p-0">
{emptyMessage ? (
<div className="flex flex-1 items-center p-4">
<Alert variant="warning" className="w-full">
<TriangleAlertIcon />
<AlertTitle>Нет данных</AlertTitle>
<AlertDescription>{emptyMessage}</AlertDescription>
</Alert>
</div>
) : (
<ScrollArea className="h-full min-h-0 flex-1">
<div className="min-h-[min(52vh,22rem)]">
<CodeBlock code={code} />
</div>
</ScrollArea>
)}
</FramePanel>
</Frame>
{onLiveFetch && !isLive ? (
<p className="text-muted-foreground shrink-0 text-[11px] leading-snug">
Локальный снимок. Подтяните с роутера, если нужен private-key с устройства.
</p>
) : null}
</div>
<SheetFooter className="shrink-0 flex-row items-center justify-between gap-3 border-t px-5 py-3.5 sm:flex-row">
<SheetClose
render={
<Button type="button" variant="ghost" className="shrink-0 px-3" />
}
>
Закрыть
</SheetClose>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="default"
disabled={!canCopy}
onClick={() => downloadText(filename, code)}
>
<DownloadIcon className="size-4" />
Файл
</Button>
<Button
type="button"
size="default"
disabled={!canCopy}
onClick={handleCopy}
className="min-w-28"
>
{copied ? (
<CheckIcon className="size-4 text-success" />
) : (
<CopyIcon className="size-4" />
)}
{copied ? "Скопировано" : "Копировать"}
</Button>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { CodeExportSheet, downloadText }