feat(ui): enhance CodeExportSheet and WgExportSheet components
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
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
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.
This commit is contained in:
@@ -3,10 +3,10 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react"
|
||||
import {
|
||||
Alert,
|
||||
AlertAction,
|
||||
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"
|
||||
@@ -19,8 +19,15 @@ import {
|
||||
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 { 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 = {
|
||||
@@ -30,6 +37,8 @@ export type CodeExportFormat = {
|
||||
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) {
|
||||
@@ -47,14 +56,14 @@ function highlightLine(line: string): string {
|
||||
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"
|
||||
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-4 text-[12px] font-mono leading-relaxed whitespace-pre-wrap break-all select-all">
|
||||
<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 || " "}
|
||||
@@ -75,6 +84,7 @@ export interface CodeExportSheetProps {
|
||||
/** 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) */
|
||||
@@ -84,6 +94,7 @@ export interface CodeExportSheetProps {
|
||||
/**
|
||||
* 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,
|
||||
@@ -94,7 +105,7 @@ function CodeExportSheet({
|
||||
initialFormatId,
|
||||
onLiveFetch,
|
||||
liveBusy,
|
||||
liveLabel = "Подтянуть с роутера (с private-key)",
|
||||
liveLabel = "С роутера",
|
||||
className,
|
||||
beforeCode,
|
||||
}: CodeExportSheetProps) {
|
||||
@@ -104,9 +115,11 @@ function CodeExportSheet({
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setTab(initialFormatId && formats.some((f) => f.id === initialFormatId)
|
||||
? initialFormatId
|
||||
: firstId)
|
||||
setTab(
|
||||
initialFormatId && formats.some((f) => f.id === initialFormatId)
|
||||
? initialFormatId
|
||||
: firstId,
|
||||
)
|
||||
setCopied(false)
|
||||
}, [open, initialFormatId, firstId, formats])
|
||||
|
||||
@@ -119,124 +132,149 @@ function CodeExportSheet({
|
||||
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), 2000)
|
||||
setTimeout(() => setCopied(false), 1800)
|
||||
})
|
||||
}
|
||||
|
||||
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",
|
||||
"flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b pr-12">
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
<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>{description}</SheetDescription>
|
||||
<SheetDescription className="font-mono text-xs">
|
||||
{description}
|
||||
</SheetDescription>
|
||||
) : null}
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 px-6 pt-3">
|
||||
<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"
|
||||
className="shrink-0 gap-0"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsList className="h-9 w-full">
|
||||
{formats.map((f) => (
|
||||
<TabsTrigger key={f.id} value={f.id}>
|
||||
<TabsTrigger key={f.id} value={f.id} className="flex-1 px-2 text-xs sm:text-sm">
|
||||
{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>
|
||||
{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" />
|
||||
) : null}
|
||||
) : (
|
||||
<RefreshCwIcon className="size-3.5" />
|
||||
)}
|
||||
{liveBusy ? "Загрузка…" : liveLabel}
|
||||
</Button>
|
||||
</AlertAction>
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{beforeCode}
|
||||
|
||||
<Frame dense className="min-h-0 flex-1 flex flex-col">
|
||||
<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="p-4">
|
||||
<Alert variant="warning">
|
||||
<InfoIcon />
|
||||
<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-[12rem] max-h-[min(60vh,28rem)]">
|
||||
<CodeBlock code={code} />
|
||||
<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 px-6 py-4 border-t flex-row gap-2 sm:justify-stretch">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -35,11 +35,11 @@ function WgExportSheet({
|
||||
const formats = useMemo((): CodeExportFormat[] => {
|
||||
if (!iface) {
|
||||
return [
|
||||
{ id: "rsc", label: "MikroTik .rsc", filename: "wg.rsc", code: "" },
|
||||
{ id: "conf", label: "Native .conf", filename: "wg.conf", code: "" },
|
||||
{ id: "rsc", label: ".rsc", filename: "wg.rsc", code: "" },
|
||||
{ id: "conf", label: ".conf", filename: "wg.conf", code: "" },
|
||||
{
|
||||
id: "peer",
|
||||
label: "Peer .conf",
|
||||
label: "Peer",
|
||||
filename: "wg-peer.conf",
|
||||
code: "",
|
||||
emptyMessage: "Интерфейс не выбран",
|
||||
@@ -99,22 +99,25 @@ function WgExportSheet({
|
||||
return [
|
||||
{
|
||||
id: "rsc",
|
||||
label: "MikroTik .rsc",
|
||||
label: ".rsc",
|
||||
filename: `${iface.name}.rsc`,
|
||||
code: liveContent?.rsc ?? localRsc,
|
||||
isLive: Boolean(liveContent?.rsc),
|
||||
},
|
||||
{
|
||||
id: "conf",
|
||||
label: "Native .conf",
|
||||
label: ".conf",
|
||||
filename: `${iface.name}.conf`,
|
||||
code: liveContent?.conf ?? localConf,
|
||||
isLive: Boolean(liveContent?.conf),
|
||||
},
|
||||
{
|
||||
id: "peer",
|
||||
label: "Peer .conf",
|
||||
label: "Peer",
|
||||
filename: `${iface.name}-peer.conf`,
|
||||
code: liveContent?.peerConf ?? peerConf,
|
||||
emptyMessage: liveContent?.peerConf ? undefined : peerEmpty,
|
||||
isLive: Boolean(liveContent?.peerConf),
|
||||
},
|
||||
]
|
||||
}, [iface, liveContent, peerId])
|
||||
@@ -128,6 +131,7 @@ function WgExportSheet({
|
||||
formats={formats}
|
||||
initialFormatId={initialTab}
|
||||
liveBusy={liveBusy}
|
||||
liveLabel="С роутера"
|
||||
onLiveFetch={
|
||||
onRequestLiveExport
|
||||
? (formatId) =>
|
||||
|
||||
Generated
+15
@@ -13875,6 +13875,21 @@
|
||||
"dependencies": {
|
||||
"zod": "^4.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
|
||||
"integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user