Init 2
This commit is contained in:
+25
-11
@@ -115,7 +115,13 @@ const navGroups: NavGroup[] = [
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const { resolvedTheme, setTheme } = useTheme()
|
||||
const isDark = resolvedTheme === "dark"
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
React.useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
setMounted(true)
|
||||
})
|
||||
}, [])
|
||||
const isDark = (resolvedTheme ?? "dark") === "dark"
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" {...props}>
|
||||
@@ -146,16 +152,24 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
<span className="text-[13px] font-medium text-sidebar-foreground truncate">а.коротаев</span>
|
||||
<span className="text-[11px] text-sidebar-foreground/50 truncate">admin@routerlists.io</span>
|
||||
</div>
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
title={isDark ? "Светлая тема" : "Тёмная тема"}
|
||||
className="shrink-0 size-6 flex items-center justify-center rounded-md text-sidebar-foreground/50 hover:text-sidebar-foreground hover:bg-sidebar-accent transition-colors group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
{isDark
|
||||
? <SunIcon className="size-3.5" />
|
||||
: <MoonIcon className="size-3.5" />}
|
||||
</button>
|
||||
{/* Theme toggle — только после mount, иначе SSR и клиент расходятся (next-themes). */}
|
||||
{mounted ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
title={isDark ? "Светлая тема" : "Тёмная тема"}
|
||||
className="shrink-0 size-6 flex items-center justify-center rounded-md text-sidebar-foreground/50 hover:text-sidebar-foreground hover:bg-sidebar-accent transition-colors group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
{isDark
|
||||
? <SunIcon className="size-3.5" />
|
||||
: <MoonIcon className="size-3.5" />}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="shrink-0 size-6 group-data-[collapsible=icon]:hidden"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from "react"
|
||||
import { useEffect, useState, useRef, useMemo } from "react"
|
||||
import { useRouter, usePathname } from "next/navigation"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
@@ -113,7 +113,9 @@ export function CommandPalette() {
|
||||
}, [open])
|
||||
|
||||
// ── Close on route change ─────────────────────────────────────────────────
|
||||
useEffect(() => { setOpen(false) }, [pathname])
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => setOpen(false))
|
||||
}, [pathname])
|
||||
|
||||
// ── Arrow key + Enter navigation ──────────────────────────────────────────
|
||||
function onInputKey(e: React.KeyboardEvent) {
|
||||
@@ -142,7 +144,9 @@ export function CommandPalette() {
|
||||
}, [activeIdx])
|
||||
|
||||
// ── Reset active idx when query changes ───────────────────────────────────
|
||||
useEffect(() => { setActiveIdx(0) }, [query])
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => setActiveIdx(0))
|
||||
}, [query])
|
||||
|
||||
// ── Grouped results for display ───────────────────────────────────────────
|
||||
const grouped = useMemo(() => {
|
||||
|
||||
@@ -1,52 +1,80 @@
|
||||
"use client"
|
||||
|
||||
interface LatencyChartProps {
|
||||
series: Record<string, number[]>
|
||||
}
|
||||
const PALETTE = [
|
||||
"var(--chart-line-1)",
|
||||
"var(--chart-line-2)",
|
||||
"var(--chart-line-3)",
|
||||
"var(--chart-line-4)",
|
||||
"var(--chart-1)",
|
||||
"var(--chart-2)",
|
||||
"var(--chart-3)",
|
||||
"var(--chart-4)",
|
||||
]
|
||||
|
||||
// Maps to CSS variables defined in globals.css
|
||||
const COLORS: Record<string, string> = {
|
||||
msk: "var(--chart-line-1)",
|
||||
spb: "var(--chart-line-2)",
|
||||
fra: "var(--chart-line-3)",
|
||||
ams: "var(--chart-line-4)",
|
||||
}
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
/** Легенда для демо-данных (ключи `msk`, `spb`, …). */
|
||||
const LEGACY_LABELS: Record<string, string> = {
|
||||
msk: "MSK",
|
||||
spb: "SPB",
|
||||
fra: "FRA",
|
||||
ams: "AMS",
|
||||
}
|
||||
|
||||
export function LatencyChart({ series }: LatencyChartProps) {
|
||||
const W = 800, H = 220
|
||||
interface LatencyChartProps {
|
||||
series: Record<string, number[]>
|
||||
/** Подписи линий (ключ → короткое имя). Если нет — берётся legacy или сам ключ. */
|
||||
labels?: Record<string, string>
|
||||
}
|
||||
|
||||
export function LatencyChart({ series, labels: labelsProp }: LatencyChartProps) {
|
||||
const W = 800
|
||||
const H = 220
|
||||
const pad = { l: 44, r: 12, t: 12, b: 28 }
|
||||
const iw = W - pad.l - pad.r
|
||||
const ih = H - pad.t - pad.b
|
||||
|
||||
const allVals = Object.values(series).flat()
|
||||
const maxVal = Math.ceil(Math.max(...allVals) / 20) * 20 + 10
|
||||
const entries = Object.entries(series).filter(([, arr]) => arr.length > 0)
|
||||
const pointCount = entries.length ? Math.max(...entries.map(([, a]) => a.length), 2) : 60
|
||||
|
||||
const xAt = (i: number, n: number) => pad.l + (i / (n - 1)) * iw
|
||||
const allVals = entries.flatMap(([, arr]) => arr)
|
||||
const maxVal = Math.ceil(Math.max(1, ...allVals) / 20) * 20 + 10
|
||||
|
||||
const xAt = (i: number, n: number) => pad.l + (i / Math.max(n - 1, 1)) * iw
|
||||
const yAt = (v: number) => pad.t + (1 - v / maxVal) * ih
|
||||
|
||||
const gridLines = [0, 0.25, 0.5, 0.75, 1]
|
||||
|
||||
const tickIndices = [
|
||||
0,
|
||||
Math.floor((pointCount - 1) * 0.25),
|
||||
Math.floor((pointCount - 1) * 0.5),
|
||||
Math.floor((pointCount - 1) * 0.75),
|
||||
pointCount - 1,
|
||||
]
|
||||
|
||||
const labelForIndex = (i: number) => {
|
||||
const spanMin = 60
|
||||
const m = Math.round(spanMin * (1 - i / Math.max(pointCount - 1, 1)))
|
||||
return `-${m}м`
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: 220, display: "block" }}>
|
||||
{gridLines.map((p, i) => (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1={pad.l} x2={W - pad.r}
|
||||
y1={pad.t + ih * p} y2={pad.t + ih * p}
|
||||
x1={pad.l}
|
||||
x2={W - pad.r}
|
||||
y1={pad.t + ih * p}
|
||||
y2={pad.t + ih * p}
|
||||
style={{ stroke: "var(--border)" }}
|
||||
strokeDasharray={p === 1 ? "0" : "2 4"}
|
||||
/>
|
||||
<text
|
||||
x={pad.l - 8} y={pad.t + ih * p + 4}
|
||||
textAnchor="end" fontSize="10"
|
||||
x={pad.l - 8}
|
||||
y={pad.t + ih * p + 4}
|
||||
textAnchor="end"
|
||||
fontSize="10"
|
||||
style={{ fill: "var(--muted-foreground)" }}
|
||||
fontFamily="var(--font-mono, monospace)"
|
||||
>
|
||||
@@ -54,34 +82,44 @@ export function LatencyChart({ series }: LatencyChartProps) {
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{[0, 15, 30, 45, 59].map((i) => (
|
||||
{tickIndices.map((i) => (
|
||||
<text
|
||||
key={i}
|
||||
x={xAt(i, 60)} y={H - 6}
|
||||
textAnchor="middle" fontSize="10"
|
||||
x={xAt(i, pointCount)}
|
||||
y={H - 6}
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
style={{ fill: "var(--muted-foreground)" }}
|
||||
fontFamily="var(--font-mono, monospace)"
|
||||
>
|
||||
-{60 - i}м
|
||||
{labelForIndex(i)}
|
||||
</text>
|
||||
))}
|
||||
{Object.entries(series).map(([key, arr]) => {
|
||||
const pts = arr.map((v, i) => `${xAt(i, arr.length).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ")
|
||||
const color = COLORS[key] ?? "var(--chart-line-1)"
|
||||
{entries.map(([key, arr], idx) => {
|
||||
const n = arr.length
|
||||
const pts = arr.map((v, i) => `${xAt(i, n).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ")
|
||||
const color = PALETTE[idx % PALETTE.length]
|
||||
return (
|
||||
<polyline key={key} points={pts} fill="none"
|
||||
<polyline
|
||||
key={key}
|
||||
points={pts}
|
||||
fill="none"
|
||||
style={{ stroke: color }}
|
||||
strokeWidth="1.6" strokeLinejoin="round" />
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
<div className="flex gap-4 pt-2 pl-11">
|
||||
{Object.keys(series).map((k) => {
|
||||
const color = COLORS[k] ?? "var(--chart-line-1)"
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-2 pt-2 pl-11">
|
||||
{entries.map(([key], idx) => {
|
||||
const color = PALETTE[idx % PALETTE.length]
|
||||
const leg =
|
||||
labelsProp?.[key] ?? LEGACY_LABELS[key] ?? key.replace(/^src-/, "")
|
||||
return (
|
||||
<div key={k} className="flex items-center gap-1.5 text-xs">
|
||||
<div key={key} className="flex items-center gap-1.5 text-xs">
|
||||
<span className="w-3 h-0.5 rounded" style={{ background: color }} />
|
||||
<span className="font-mono text-foreground">{LABELS[k]}</span>
|
||||
<span className="font-mono text-foreground">{leg}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -50,6 +50,8 @@ export function Flag({ code, size = 20, className }: FlagProps) {
|
||||
const cdnSrc = nearestCdnSize(size)
|
||||
const cdnSrc2x = nearestCdnSize(size * 2)
|
||||
return (
|
||||
// Внешний CDN (динамический URL) — next/image без remotePatterns не подходит
|
||||
// eslint-disable-next-line @next/next/no-img-element -- flagcdn.com, размеры задаём явно
|
||||
<img
|
||||
src={`https://flagcdn.com/w${cdnSrc}/${lower}.png`}
|
||||
srcSet={`https://flagcdn.com/w${cdnSrc2x}/${lower}.png 2x`}
|
||||
|
||||
@@ -13,9 +13,9 @@ export function StatusBadge({ status }: { status: ServerStatus }) {
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium",
|
||||
status === "online" && "bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400",
|
||||
status === "offline" && "bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400",
|
||||
status === "degraded" && "bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-400",
|
||||
status === "online" && "bg-[var(--status-online-bg)] text-[var(--status-online-fg)]",
|
||||
status === "offline" && "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)]",
|
||||
status === "degraded" && "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)]",
|
||||
)}
|
||||
>
|
||||
<StatusDot status={status} />
|
||||
|
||||
@@ -6,9 +6,9 @@ export function StatusDot({ status, pulse }: { status: ServerStatus; pulse?: boo
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block size-2 rounded-full shrink-0",
|
||||
status === "online" && "bg-emerald-500",
|
||||
status === "offline" && "bg-red-500",
|
||||
status === "degraded" && "bg-amber-400",
|
||||
status === "online" && "bg-[var(--status-online)]",
|
||||
status === "offline" && "bg-[var(--status-offline)]",
|
||||
status === "degraded" && "bg-[var(--status-degraded)]",
|
||||
pulse && status === "online" && "animate-pulse",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,90 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
|
||||
// ── types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ThemeValue = "dark" | "light" | "system"
|
||||
type ResolvedTheme = "dark" | "light"
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: ThemeValue
|
||||
resolvedTheme: ResolvedTheme
|
||||
setTheme: (t: ThemeValue) => void
|
||||
}
|
||||
|
||||
// ── context ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue>({
|
||||
theme: "dark",
|
||||
resolvedTheme: "dark",
|
||||
setTheme: () => {},
|
||||
})
|
||||
|
||||
// ── provider ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ThemeProviderProps {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: ThemeValue
|
||||
/** kept for API compat with next-themes — unused, class is always used */
|
||||
attribute?: string
|
||||
disableTransitionOnChange?: boolean
|
||||
}
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
import type { ComponentProps } from "react"
|
||||
|
||||
/** Обёртка над next-themes: тот же storageKey `rl-theme`, класс на `<html>`. */
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "dark",
|
||||
disableTransitionOnChange = false,
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setThemeState] = useState<ThemeValue>(() => {
|
||||
if (typeof window === "undefined") return defaultTheme
|
||||
return (localStorage.getItem("rl-theme") as ThemeValue | null) ?? defaultTheme
|
||||
})
|
||||
|
||||
const [systemDark, setSystemDark] = useState<boolean>(() => {
|
||||
if (typeof window === "undefined") return true
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
})
|
||||
|
||||
// Track system preference
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
const handler = (e: MediaQueryListEvent) => setSystemDark(e.matches)
|
||||
mq.addEventListener("change", handler)
|
||||
return () => mq.removeEventListener("change", handler)
|
||||
}, [])
|
||||
|
||||
const resolvedTheme: ResolvedTheme =
|
||||
theme === "system" ? (systemDark ? "dark" : "light") : theme
|
||||
|
||||
// Apply class to <html>
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
|
||||
if (disableTransitionOnChange) {
|
||||
root.style.setProperty("transition", "none")
|
||||
void root.offsetHeight // force reflow
|
||||
setTimeout(() => root.style.removeProperty("transition"), 0)
|
||||
}
|
||||
|
||||
root.classList.toggle("dark", resolvedTheme === "dark")
|
||||
root.classList.toggle("light", resolvedTheme === "light")
|
||||
}, [resolvedTheme, disableTransitionOnChange])
|
||||
|
||||
const setTheme = (t: ThemeValue) => {
|
||||
setThemeState(t)
|
||||
try { localStorage.setItem("rl-theme", t) } catch {}
|
||||
}
|
||||
|
||||
...props
|
||||
}: ComponentProps<typeof NextThemesProvider>) {
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
enableSystem
|
||||
storageKey="rl-theme"
|
||||
disableTransitionOnChange
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// ── hook (drop-in replacement for next-themes useTheme) ───────────────────────
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
return useContext(ThemeContext)
|
||||
}
|
||||
export { useTheme } from "next-themes"
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: PopoverPrimitive.Popup.Props &
|
||||
Pick<
|
||||
PopoverPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
data-slot="popover-content"
|
||||
className={cn(
|
||||
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Positioner>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Title
|
||||
data-slot="popover-title"
|
||||
className={cn("font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: PopoverPrimitive.Description.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Description
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
}
|
||||
Reference in New Issue
Block a user