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

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:
Denozordec
2026-09-06 00:23:51 +07:00
parent 5e0c16e808
commit b680f882cc
4 changed files with 132 additions and 75 deletions
+106 -68
View File
@@ -3,10 +3,10 @@
import { useEffect, useMemo, useState, type ReactNode } from "react" import { useEffect, useMemo, useState, type ReactNode } from "react"
import { import {
Alert, Alert,
AlertAction,
AlertDescription, AlertDescription,
AlertTitle, AlertTitle,
} from "@/components/reui/alert" } from "@/components/reui/alert"
import { Badge } from "@/components/reui/badge"
import { Frame, FramePanel } from "@/components/reui/frame" import { Frame, FramePanel } from "@/components/reui/frame"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
@@ -19,8 +19,15 @@ import {
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from "@/components/ui/sheet" } from "@/components/ui/sheet"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { CheckIcon, CopyIcon, DownloadIcon, InfoIcon, LoaderCircleIcon } from "lucide-react" import {
CheckIcon,
CopyIcon,
DownloadIcon,
LoaderCircleIcon,
RefreshCwIcon,
TriangleAlertIcon,
} from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export type CodeExportFormat = { export type CodeExportFormat = {
@@ -30,6 +37,8 @@ export type CodeExportFormat = {
code: string code: string
/** Empty / unavailable — show warning instead of code */ /** Empty / unavailable — show warning instead of code */
emptyMessage?: string emptyMessage?: string
/** Content came from live device fetch */
isLive?: boolean
} }
function downloadText(filename: string, content: string) { 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 (line.startsWith("#") || trimmed.startsWith(";")) return "text-muted-foreground"
if (trimmed.startsWith("[")) return "text-info" if (trimmed.startsWith("[")) return "text-info"
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" if (/^\s+[a-zA-Z]/.test(line) || /^[A-Za-z][\w-]*=/.test(line)) return "text-foreground"
return "text-foreground" return "text-foreground/90"
} }
function CodeBlock({ code }: { code: string }) { function CodeBlock({ code }: { code: string }) {
const lines = code.length ? code.split("\n") : [""] const lines = code.length ? code.split("\n") : [""]
return ( 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) => ( {lines.map((line, i) => (
<span key={i} className={cn("block", highlightLine(line))}> <span key={i} className={cn("block", highlightLine(line))}>
{line || " "} {line || " "}
@@ -75,6 +84,7 @@ export interface CodeExportSheetProps {
/** Optional live fetch from device */ /** Optional live fetch from device */
onLiveFetch?: (formatId: string) => void onLiveFetch?: (formatId: string) => void
liveBusy?: boolean liveBusy?: boolean
/** Short CTA — keep laconic */
liveLabel?: string liveLabel?: string
className?: string className?: string
/** Extra content under format tabs (e.g. meta strip) */ /** Extra content under format tabs (e.g. meta strip) */
@@ -84,6 +94,7 @@ export interface CodeExportSheetProps {
/** /**
* Shared code export Sheet — ReUI Frame + sticky footer. * 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 * 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({ function CodeExportSheet({
open, open,
@@ -94,7 +105,7 @@ function CodeExportSheet({
initialFormatId, initialFormatId,
onLiveFetch, onLiveFetch,
liveBusy, liveBusy,
liveLabel = "Подтянуть с роутера (с private-key)", liveLabel = "С роутера",
className, className,
beforeCode, beforeCode,
}: CodeExportSheetProps) { }: CodeExportSheetProps) {
@@ -104,9 +115,11 @@ function CodeExportSheet({
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
setTab(initialFormatId && formats.some((f) => f.id === initialFormatId) setTab(
? initialFormatId initialFormatId && formats.some((f) => f.id === initialFormatId)
: firstId) ? initialFormatId
: firstId,
)
setCopied(false) setCopied(false)
}, [open, initialFormatId, firstId, formats]) }, [open, initialFormatId, firstId, formats])
@@ -119,124 +132,149 @@ function CodeExportSheet({
const emptyMessage = active?.emptyMessage const emptyMessage = active?.emptyMessage
const filename = active?.filename ?? "export.txt" const filename = active?.filename ?? "export.txt"
const canCopy = Boolean(code) && !emptyMessage const canCopy = Boolean(code) && !emptyMessage
const isLive = Boolean(active?.isLive)
const showTabs = formats.length > 1
function handleCopy() { function handleCopy() {
if (!canCopy) return if (!canCopy) return
void navigator.clipboard.writeText(code).then(() => { void navigator.clipboard.writeText(code).then(() => {
setCopied(true) setCopied(true)
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 1800)
}) })
} }
const showTabs = formats.length > 1
return ( return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}> <Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent <SheetContent
className={cn( 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, className,
)} )}
> >
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b pr-12"> <SheetHeader className="shrink-0 gap-1 border-b px-5 pt-5 pb-4 pr-12">
<SheetTitle>{title}</SheetTitle> <SheetTitle className="text-base font-semibold tracking-tight">
{title}
</SheetTitle>
{description ? ( {description ? (
<SheetDescription>{description}</SheetDescription> <SheetDescription className="font-mono text-xs">
{description}
</SheetDescription>
) : null} ) : null}
</SheetHeader> </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 ? ( {showTabs ? (
<Tabs <Tabs
value={tab} value={tab}
onValueChange={(v) => setTab(String(v))} onValueChange={(v) => setTab(String(v))}
className="shrink-0" className="shrink-0 gap-0"
> >
<TabsList> <TabsList className="h-9 w-full">
{formats.map((f) => ( {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} {f.label}
</TabsTrigger> </TabsTrigger>
))} ))}
</TabsList> </TabsList>
{formats.map((f) => (
<TabsContent key={f.id} value={f.id} className="mt-0 hidden" />
))}
</Tabs> </Tabs>
) : null} ) : null}
{onLiveFetch ? ( {onLiveFetch || beforeCode ? (
<Alert variant="info" className="shrink-0"> <div className="flex shrink-0 flex-wrap items-center justify-between gap-2">
<InfoIcon /> <div className="flex min-w-0 flex-wrap items-center gap-2">
<AlertTitle>Live с роутера</AlertTitle> {onLiveFetch ? (
<AlertDescription> <Badge
Подтянуть актуальный конфиг с private-key с устройства. variant={isLive ? "success-light" : "secondary"}
</AlertDescription> size="sm"
<AlertAction> radius="full"
>
{isLive ? "С роутера" : "Локально"}
</Badge>
) : null}
{beforeCode}
</div>
{onLiveFetch ? (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
className="shrink-0"
disabled={liveBusy} disabled={liveBusy}
onClick={() => onLiveFetch(tab)} onClick={() => onLiveFetch(tab)}
aria-label="Подтянуть конфиг с роутера с private-key"
> >
{liveBusy ? ( {liveBusy ? (
<LoaderCircleIcon className="size-3.5 animate-spin" /> <LoaderCircleIcon className="size-3.5 animate-spin" />
) : null} ) : (
<RefreshCwIcon className="size-3.5" />
)}
{liveBusy ? "Загрузка…" : liveLabel} {liveBusy ? "Загрузка…" : liveLabel}
</Button> </Button>
</AlertAction> ) : null}
</Alert> </div>
) : null} ) : null}
{beforeCode} <Frame dense className="flex min-h-0 flex-1 flex-col">
<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"> <FramePanel className="relative flex min-h-0 flex-1 flex-col overflow-hidden p-0">
{emptyMessage ? ( {emptyMessage ? (
<div className="p-4"> <div className="flex flex-1 items-center p-4">
<Alert variant="warning"> <Alert variant="warning" className="w-full">
<InfoIcon /> <TriangleAlertIcon />
<AlertTitle>Нет данных</AlertTitle> <AlertTitle>Нет данных</AlertTitle>
<AlertDescription>{emptyMessage}</AlertDescription> <AlertDescription>{emptyMessage}</AlertDescription>
</Alert> </Alert>
</div> </div>
) : ( ) : (
<ScrollArea className="h-full min-h-[12rem] max-h-[min(60vh,28rem)]"> <ScrollArea className="h-full min-h-0 flex-1">
<CodeBlock code={code} /> <div className="min-h-[min(52vh,22rem)]">
<CodeBlock code={code} />
</div>
</ScrollArea> </ScrollArea>
)} )}
</FramePanel> </FramePanel>
</Frame> </Frame>
{onLiveFetch && !isLive ? (
<p className="text-muted-foreground shrink-0 text-[11px] leading-snug">
Локальный снимок. Подтяните с роутера, если нужен private-key с устройства.
</p>
) : null}
</div> </div>
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2 sm:justify-stretch"> <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 variant="outline" className="flex-1" />}> <SheetClose
render={
<Button type="button" variant="ghost" className="shrink-0 px-3" />
}
>
Закрыть Закрыть
</SheetClose> </SheetClose>
<Button <div className="flex items-center gap-2">
type="button" <Button
variant="outline" type="button"
className="flex-1" variant="outline"
disabled={!canCopy} size="default"
onClick={() => downloadText(filename, code)} disabled={!canCopy}
> onClick={() => downloadText(filename, code)}
<DownloadIcon className="size-4" /> >
Файл <DownloadIcon className="size-4" />
</Button> Файл
<Button </Button>
type="button" <Button
className="flex-1" type="button"
disabled={!canCopy} size="default"
onClick={handleCopy} disabled={!canCopy}
> onClick={handleCopy}
{copied ? ( className="min-w-28"
<CheckIcon className="size-4 text-success" /> >
) : ( {copied ? (
<CopyIcon className="size-4" /> <CheckIcon className="size-4 text-success" />
)} ) : (
{copied ? "Скопировано" : "Копировать"} <CopyIcon className="size-4" />
</Button> )}
{copied ? "Скопировано" : "Копировать"}
</Button>
</div>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
+10 -6
View File
@@ -35,11 +35,11 @@ function WgExportSheet({
const formats = useMemo((): CodeExportFormat[] => { const formats = useMemo((): CodeExportFormat[] => {
if (!iface) { if (!iface) {
return [ return [
{ id: "rsc", label: "MikroTik .rsc", filename: "wg.rsc", code: "" }, { id: "rsc", label: ".rsc", filename: "wg.rsc", code: "" },
{ id: "conf", label: "Native .conf", filename: "wg.conf", code: "" }, { id: "conf", label: ".conf", filename: "wg.conf", code: "" },
{ {
id: "peer", id: "peer",
label: "Peer .conf", label: "Peer",
filename: "wg-peer.conf", filename: "wg-peer.conf",
code: "", code: "",
emptyMessage: "Интерфейс не выбран", emptyMessage: "Интерфейс не выбран",
@@ -99,22 +99,25 @@ function WgExportSheet({
return [ return [
{ {
id: "rsc", id: "rsc",
label: "MikroTik .rsc", label: ".rsc",
filename: `${iface.name}.rsc`, filename: `${iface.name}.rsc`,
code: liveContent?.rsc ?? localRsc, code: liveContent?.rsc ?? localRsc,
isLive: Boolean(liveContent?.rsc),
}, },
{ {
id: "conf", id: "conf",
label: "Native .conf", label: ".conf",
filename: `${iface.name}.conf`, filename: `${iface.name}.conf`,
code: liveContent?.conf ?? localConf, code: liveContent?.conf ?? localConf,
isLive: Boolean(liveContent?.conf),
}, },
{ {
id: "peer", id: "peer",
label: "Peer .conf", label: "Peer",
filename: `${iface.name}-peer.conf`, filename: `${iface.name}-peer.conf`,
code: liveContent?.peerConf ?? peerConf, code: liveContent?.peerConf ?? peerConf,
emptyMessage: liveContent?.peerConf ? undefined : peerEmpty, emptyMessage: liveContent?.peerConf ? undefined : peerEmpty,
isLive: Boolean(liveContent?.peerConf),
}, },
] ]
}, [iface, liveContent, peerId]) }, [iface, liveContent, peerId])
@@ -128,6 +131,7 @@ function WgExportSheet({
formats={formats} formats={formats}
initialFormatId={initialTab} initialFormatId={initialTab}
liveBusy={liveBusy} liveBusy={liveBusy}
liveLabel="С роутера"
onLiveFetch={ onLiveFetch={
onRequestLiveExport onRequestLiveExport
? (formatId) => ? (formatId) =>
+15
View File
@@ -13875,6 +13875,21 @@
"dependencies": { "dependencies": {
"zod": "^4.4.1" "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"
}
} }
} }
} }
+1 -1
View File
File diff suppressed because one or more lines are too long