Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m39s
Docker images / frontend-image (push) Successful in 3m2s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 43s
Docker images / publish-release (push) Successful in 9s
- Renamed and refactored functions for better clarity, including `buildHostComposeSnippet` to `buildHostComposeOverride` and `buildHostNftSnippet` to `buildHostLinuxInstallSh`. - Introduced a new script for Linux installation of WireGuard, enhancing the setup process for Docker hosts. - Updated the `generateNativeConf` function to conditionally include the listen port in the configuration. - Adjusted the `FlowOverlaySheet` component to default to the new Linux tab and improved the user interface for selecting jump-hosts. Co-authored-by: Cursor <cursoragent@cursor.com>
285 lines
8.8 KiB
TypeScript
285 lines
8.8 KiB
TypeScript
"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, className }: { code: string; className?: string }) {
|
||
const lines = code.length ? code.split("\n") : [""]
|
||
return (
|
||
<pre className={cn("px-4 py-3.5 text-[12px] font-mono leading-[1.65] whitespace-pre-wrap break-all select-all", className)}>
|
||
{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, CodeBlock, downloadText }
|